From 5319aef78b8589cd53cc45459d73695cabd2b05f Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Wed, 9 Sep 2026 17:57:52 +0200 Subject: [PATCH] =?UTF-8?q?feat(#159):=20suppressions=20m=C3=A9tier=20doss?= =?UTF-8?q?iers/parents/enfants/AM/staff.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SuppressionService + DELETE /dossiers/:numero, cascades DELETE /users et DELETE /enfants?deleteDossier, flag sans_enfant, specs front #160. Co-authored-by: Cursor --- backend/src/app.module.ts | 2 + .../dossiers/dossiers.controller.spec.ts | 24 +- .../routes/dossiers/dossiers.controller.ts | 38 +- .../src/routes/dossiers/dossiers.module.ts | 2 + .../routes/dossiers/dossiers.service.spec.ts | 6 + .../src/routes/dossiers/dossiers.service.ts | 15 +- .../dossiers/dto/dossier-list-item.dto.ts | 6 + .../src/routes/enfants/enfants.controller.ts | 36 +- backend/src/routes/enfants/enfants.module.ts | 11 +- .../suppressions/suppression.service.spec.ts | 175 ++++++++ .../suppressions/suppression.service.ts | 420 ++++++++++++++++++ .../suppressions/suppressions.module.ts | 25 ++ backend/src/routes/user/user.controller.ts | 22 +- backend/src/routes/user/user.module.ts | 2 + backend/src/routes/user/user.service.ts | 1 + docs/tmp/154-matrice-suppression.md | 35 ++ docs/tmp/154-mini-spec-front-suppression.md | 169 +++++++ 17 files changed, 970 insertions(+), 19 deletions(-) create mode 100644 backend/src/routes/suppressions/suppression.service.spec.ts create mode 100644 backend/src/routes/suppressions/suppression.service.ts create mode 100644 backend/src/routes/suppressions/suppressions.module.ts create mode 100644 docs/tmp/154-matrice-suppression.md create mode 100644 docs/tmp/154-mini-spec-front-suppression.md diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index ce8dbc2..715f83c 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -18,6 +18,7 @@ import { AppConfigModule } from './modules/config/config.module'; import { DocumentsLegauxModule } from './modules/documents-legaux'; import { RelaisModule } from './routes/relais/relais.module'; import { DossiersModule } from './routes/dossiers/dossiers.module'; +import { SuppressionsModule } from './routes/suppressions/suppressions.module'; @Module({ imports: [ @@ -57,6 +58,7 @@ import { DossiersModule } from './routes/dossiers/dossiers.module'; DocumentsLegauxModule, RelaisModule, DossiersModule, + SuppressionsModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/routes/dossiers/dossiers.controller.spec.ts b/backend/src/routes/dossiers/dossiers.controller.spec.ts index 165e1e3..8cab3f5 100644 --- a/backend/src/routes/dossiers/dossiers.controller.spec.ts +++ b/backend/src/routes/dossiers/dossiers.controller.spec.ts @@ -1,6 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { DossiersController } from './dossiers.controller'; import { DossiersService } from './dossiers.service'; +import { SuppressionService } from '../suppressions/suppression.service'; import { AuthGuard } from 'src/common/guards/auth.guard'; import { RolesGuard } from 'src/common/guards/roles.guard'; import { StatutUtilisateurType } from 'src/entities/users.entity'; @@ -11,11 +12,17 @@ describe('DossiersController', () => { listDossiers: jest.fn(), getDossierByNumero: jest.fn(), }; + const suppressionServiceMock = { + deleteDossier: jest.fn(), + }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [DossiersController], - providers: [{ provide: DossiersService, useValue: dossiersServiceMock }], + providers: [ + { provide: DossiersService, useValue: dossiersServiceMock }, + { provide: SuppressionService, useValue: suppressionServiceMock }, + ], }) .overrideGuard(AuthGuard) .useValue({ canActivate: () => true }) @@ -60,4 +67,19 @@ describe('DossiersController', () => { expect(dossiersServiceMock.getDossierByNumero).toHaveBeenCalledWith('2026-000001'); expect(res.type).toBe('family'); }); + + it('remove delegates to suppressionService.deleteDossier', async () => { + const user = { id: 'u1', role: 'gestionnaire' } as never; + suppressionServiceMock.deleteDossier.mockResolvedValue({ + type: 'famille', + deleted_user_ids: [], + deleted_enfant_ids: [], + message: 'ok', + }); + await controller.remove('2026-000001', user); + expect(suppressionServiceMock.deleteDossier).toHaveBeenCalledWith( + '2026-000001', + user, + ); + }); }); diff --git a/backend/src/routes/dossiers/dossiers.controller.ts b/backend/src/routes/dossiers/dossiers.controller.ts index 4849f9b..3009a0e 100644 --- a/backend/src/routes/dossiers/dossiers.controller.ts +++ b/backend/src/routes/dossiers/dossiers.controller.ts @@ -1,4 +1,11 @@ -import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'; +import { + Controller, + Delete, + Get, + Param, + Query, + UseGuards, +} from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, @@ -8,10 +15,12 @@ import { ApiTags, } from '@nestjs/swagger'; import { Roles } from 'src/common/decorators/roles.decorator'; -import { RoleType } from 'src/entities/users.entity'; +import { RoleType, Users } from 'src/entities/users.entity'; import { AuthGuard } from 'src/common/guards/auth.guard'; import { RolesGuard } from 'src/common/guards/roles.guard'; +import { User } from 'src/common/decorators/user.decorator'; import { DossiersService } from './dossiers.service'; +import { SuppressionService } from '../suppressions/suppression.service'; import { DossierUnifieDto } from './dto/dossier-unifie.dto'; import { DossierListItemDto } from './dto/dossier-list-item.dto'; @@ -20,7 +29,10 @@ import { DossierListItemDto } from './dto/dossier-list-item.dto'; @Controller('dossiers') @UseGuards(AuthGuard, RolesGuard) export class DossiersController { - constructor(private readonly dossiersService: DossiersService) {} + constructor( + private readonly dossiersService: DossiersService, + private readonly suppressionService: SuppressionService, + ) {} @Get() @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) @@ -28,7 +40,8 @@ export class DossiersController { summary: 'Liste unifiée des dossiers (familles + AM) — ticket #153', description: '1 entrée = 1 numero_dossier. Types `famille` | `assistante_maternelle`. ' + - 'Filtre optionnel `q` (n°, nom, email). Tri : à valider d’abord, puis n° décroissant.', + 'Filtre optionnel `q` (n°, nom, email). Tri : à valider d’abord, puis n° décroissant. ' + + '`sans_enfant` (#159) pour dossiers famille sans enfant.', }) @ApiQuery({ name: 'q', @@ -51,4 +64,21 @@ export class DossiersController { getDossier(@Param('numeroDossier') numeroDossier: string): Promise { return this.dossiersService.getDossierByNumero(numeroDossier); } + + @Delete(':numeroDossier') + @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) + @ApiOperation({ + summary: 'Supprimer un dossier (famille ou AM) — #159', + description: + 'Famille : parents + enfants. AM : compte AM + dossier AM (enfants conservés, placements clos).', + }) + @ApiParam({ name: 'numeroDossier', description: 'Numéro de dossier' }) + @ApiResponse({ status: 200, description: 'Résultat de suppression' }) + @ApiResponse({ status: 404, description: 'Dossier introuvable' }) + remove( + @Param('numeroDossier') numeroDossier: string, + @User() currentUser: Users, + ) { + return this.suppressionService.deleteDossier(numeroDossier, currentUser); + } } diff --git a/backend/src/routes/dossiers/dossiers.module.ts b/backend/src/routes/dossiers/dossiers.module.ts index e6ba196..d130bb2 100644 --- a/backend/src/routes/dossiers/dossiers.module.ts +++ b/backend/src/routes/dossiers/dossiers.module.ts @@ -5,6 +5,7 @@ import { JwtModule } from '@nestjs/jwt'; import { Parents } from 'src/entities/parents.entity'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; import { ParentsModule } from '../parents/parents.module'; +import { SuppressionsModule } from '../suppressions/suppressions.module'; import { DossiersController } from './dossiers.controller'; import { DossiersService } from './dossiers.service'; @@ -12,6 +13,7 @@ import { DossiersService } from './dossiers.service'; imports: [ TypeOrmModule.forFeature([Parents, AssistanteMaternelle]), ParentsModule, + SuppressionsModule, JwtModule.registerAsync({ imports: [ConfigModule], useFactory: (config: ConfigService) => ({ diff --git a/backend/src/routes/dossiers/dossiers.service.spec.ts b/backend/src/routes/dossiers/dossiers.service.spec.ts index ecdf772..5519c26 100644 --- a/backend/src/routes/dossiers/dossiers.service.spec.ts +++ b/backend/src/routes/dossiers/dossiers.service.spec.ts @@ -4,6 +4,7 @@ import { DossiersService } from './dossiers.service'; import { Parents } from 'src/entities/parents.entity'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; import { ParentsService } from '../parents/parents.service'; +import { SuppressionService } from '../suppressions/suppression.service'; import { StatutUtilisateurType } from 'src/entities/users.entity'; describe('DossiersService.listDossiers', () => { @@ -32,6 +33,9 @@ describe('DossiersService.listDossiers', () => { const parentsService = { getDossierFamilleByNumero: jest.fn(), }; + const suppressionService = { + countEnfantsForNumero: jest.fn().mockResolvedValue(1), + }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -40,6 +44,7 @@ describe('DossiersService.listDossiers', () => { { provide: getRepositoryToken(Parents), useValue: parentsRepo }, { provide: getRepositoryToken(AssistanteMaternelle), useValue: amRepo }, { provide: ParentsService, useValue: parentsService }, + { provide: SuppressionService, useValue: suppressionService }, ], }).compile(); @@ -47,6 +52,7 @@ describe('DossiersService.listDossiers', () => { jest.clearAllMocks(); parentsRepo.createQueryBuilder.mockReturnValue(parentsQb); amRepo.createQueryBuilder.mockReturnValue(amQb); + suppressionService.countEnfantsForNumero.mockResolvedValue(1); }); it('aggregates famille (pivot+co-parent) and AM, sorts a_valider first', async () => { diff --git a/backend/src/routes/dossiers/dossiers.service.ts b/backend/src/routes/dossiers/dossiers.service.ts index cfc4711..75ce244 100644 --- a/backend/src/routes/dossiers/dossiers.service.ts +++ b/backend/src/routes/dossiers/dossiers.service.ts @@ -5,12 +5,13 @@ import { Parents } from 'src/entities/parents.entity'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; import { StatutUtilisateurType, Users } from 'src/entities/users.entity'; import { ParentsService } from '../parents/parents.service'; +import { SuppressionService } from '../suppressions/suppression.service'; import { DossierUnifieDto } from './dto/dossier-unifie.dto'; import { DossierAmCompletDto, DossierAmUserDto } from './dto/dossier-am-complet.dto'; import { DossierListItemDto } from './dto/dossier-list-item.dto'; /** - * Dossiers unifiés — détail (#119) + liste (#153). + * Dossiers unifiés — détail (#119) + liste (#153) + sans_enfant (#159). */ @Injectable() export class DossiersService { @@ -20,6 +21,7 @@ export class DossiersService { @InjectRepository(AssistanteMaternelle) private readonly amRepository: Repository, private readonly parentsService: ParentsService, + private readonly suppressionService: SuppressionService, ) {} /** @@ -43,6 +45,17 @@ export class DossiersService { return b.numero_dossier.localeCompare(a.numero_dossier, 'fr'); }); + for (const item of filtered) { + if (item.type === 'famille') { + const n = await this.suppressionService.countEnfantsForNumero( + item.numero_dossier, + ); + item.sans_enfant = n === 0; + } else { + item.sans_enfant = false; + } + } + return filtered; } diff --git a/backend/src/routes/dossiers/dto/dossier-list-item.dto.ts b/backend/src/routes/dossiers/dto/dossier-list-item.dto.ts index c510f7e..fb38384 100644 --- a/backend/src/routes/dossiers/dto/dossier-list-item.dto.ts +++ b/backend/src/routes/dossiers/dto/dossier-list-item.dto.ts @@ -49,4 +49,10 @@ export class DossierListItemDto { description: 'Date de référence (MIN cree_le des users du dossier)', }) date_reference: string | null; + + @ApiPropertyOptional({ + description: + 'True si dossier famille sans enfant lié (#159). Omis ou false pour AM.', + }) + sans_enfant?: boolean; } diff --git a/backend/src/routes/enfants/enfants.controller.ts b/backend/src/routes/enfants/enfants.controller.ts index 8b44228..b6b1e8e 100644 --- a/backend/src/routes/enfants/enfants.controller.ts +++ b/backend/src/routes/enfants/enfants.controller.ts @@ -13,6 +13,7 @@ import { ParseUUIDPipe, Patch, Post, + Query, UploadedFile, UseGuards, UseInterceptors, @@ -23,6 +24,7 @@ import { ApiBody, ApiConsumes, ApiOperation, + ApiQuery, ApiTags, } from '@nestjs/swagger'; import { diskStorage } from 'multer'; @@ -36,6 +38,7 @@ import { User } from 'src/common/decorators/user.decorator'; import { AuthGuard } from 'src/common/guards/auth.guard'; import { Roles } from 'src/common/decorators/roles.decorator'; import { RolesGuard } from 'src/common/guards/roles.guard'; +import { SuppressionService } from '../suppressions/suppression.service'; const photoMulterOptions = { storage: diskStorage({ @@ -83,7 +86,10 @@ class OptionalEnfantPhotoInterceptor implements NestInterceptor { @UseGuards(AuthGuard, RolesGuard) @Controller('enfants') export class EnfantsController { - constructor(private readonly enfantsService: EnfantsService) { } + constructor( + private readonly enfantsService: EnfantsService, + private readonly suppressionService: SuppressionService, + ) { } @Roles( RoleType.PARENT, @@ -157,9 +163,31 @@ export class EnfantsController { return this.enfantsService.update(id, dto, currentUser, photo); } - @Roles(RoleType.SUPER_ADMIN) + @Roles( + RoleType.SUPER_ADMIN, + RoleType.ADMINISTRATEUR, + RoleType.GESTIONNAIRE, + ) @Delete(':id') - remove(@Param('id', new ParseUUIDPipe()) id: string) { - return this.enfantsService.remove(id); + @ApiOperation({ + summary: 'Supprimer un enfant (#159)', + description: + 'Query `deleteDossier=true` si dernier enfant et suppression du dossier famille souhaitée.', + }) + @ApiQuery({ + name: 'deleteDossier', + required: false, + description: 'Si true et dernier enfant : cascade dossier famille', + }) + remove( + @Param('id', new ParseUUIDPipe()) id: string, + @Query('deleteDossier') deleteDossier: string | undefined, + @User() currentUser: Users, + ) { + const flag = + deleteDossier === 'true' || + deleteDossier === '1' || + deleteDossier === 'yes'; + return this.suppressionService.deleteEnfant(id, flag, currentUser); } } diff --git a/backend/src/routes/enfants/enfants.module.ts b/backend/src/routes/enfants/enfants.module.ts index 2b9385b..4565cb7 100644 --- a/backend/src/routes/enfants/enfants.module.ts +++ b/backend/src/routes/enfants/enfants.module.ts @@ -6,13 +6,16 @@ import { Children } from 'src/entities/children.entity'; import { Parents } from 'src/entities/parents.entity'; import { ParentsChildren } from 'src/entities/parents_children.entity'; import { AuthModule } from '../auth/auth.module'; +import { SuppressionsModule } from '../suppressions/suppressions.module'; @Module({ - imports: [TypeOrmModule.forFeature([Children, Parents, ParentsChildren]), - AuthModule - + imports: [ + TypeOrmModule.forFeature([Children, Parents, ParentsChildren]), + AuthModule, + SuppressionsModule, ], controllers: [EnfantsController], - providers: [EnfantsService] + providers: [EnfantsService], + exports: [EnfantsService], }) export class EnfantsModule { } diff --git a/backend/src/routes/suppressions/suppression.service.spec.ts b/backend/src/routes/suppressions/suppression.service.spec.ts new file mode 100644 index 0000000..0e38008 --- /dev/null +++ b/backend/src/routes/suppressions/suppression.service.spec.ts @@ -0,0 +1,175 @@ +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { SuppressionService } from './suppression.service'; +import { RoleType } from 'src/entities/users.entity'; + +describe('SuppressionService (#159)', () => { + const dataSource = { + transaction: jest.fn(async (cb: (m: unknown) => Promise) => + cb({ + delete: jest.fn(), + query: jest.fn(), + }), + ), + }; + + const usersRepository = { + findOne: jest.fn(), + delete: jest.fn(), + count: jest.fn(), + }; + const parentsRepository = { + findOne: jest.fn(), + find: jest.fn(), + query: jest.fn(), + }; + const amRepository = { + findOne: jest.fn(), + }; + const childrenRepository = { + findOne: jest.fn(), + delete: jest.fn(), + save: jest.fn(), + }; + const parentsChildrenRepository = { + find: jest.fn(), + }; + const amChildrenRepository = { + find: jest.fn(), + save: jest.fn(), + count: jest.fn(), + }; + + let service: SuppressionService; + + const staff = { + id: 'staff-1', + role: RoleType.GESTIONNAIRE, + } as never; + + const admin = { + id: 'admin-1', + role: RoleType.ADMINISTRATEUR, + } as never; + + const superAdmin = { + id: 'sa-1', + role: RoleType.SUPER_ADMIN, + } as never; + + beforeEach(() => { + jest.clearAllMocks(); + service = new SuppressionService( + dataSource as never, + usersRepository as never, + parentsRepository as never, + amRepository as never, + childrenRepository as never, + parentsChildrenRepository as never, + amChildrenRepository as never, + ); + }); + + it('refuse self-delete', async () => { + usersRepository.findOne.mockResolvedValue({ + id: 'admin-1', + role: RoleType.ADMINISTRATEUR, + }); + await expect(service.deleteUser('admin-1', admin)).rejects.toBeInstanceOf( + ForbiddenException, + ); + }); + + it('refuse gestionnaire deleting another gestionnaire', async () => { + usersRepository.findOne.mockResolvedValue({ + id: 'g2', + role: RoleType.GESTIONNAIRE, + }); + await expect(service.deleteUser('g2', staff)).rejects.toBeInstanceOf( + ForbiddenException, + ); + }); + + it('dernier admin : refus si pas super_admin', async () => { + usersRepository.findOne.mockResolvedValue({ + id: 'admin-2', + role: RoleType.ADMINISTRATEUR, + }); + usersRepository.count.mockResolvedValue(1); + await expect(service.deleteUser('admin-2', admin)).rejects.toBeInstanceOf( + ForbiddenException, + ); + }); + + it('dernier admin : OK pour super_admin', async () => { + usersRepository.findOne.mockResolvedValue({ + id: 'admin-2', + role: RoleType.ADMINISTRATEUR, + }); + usersRepository.count.mockResolvedValue(1); + usersRepository.delete.mockResolvedValue({ affected: 1 }); + const res = await service.deleteUser('admin-2', superAdmin); + expect(res.deleted_user_ids).toEqual(['admin-2']); + }); + + it('delete AM : clos placements, pas d’enfants deleted', async () => { + usersRepository.findOne.mockResolvedValue({ + id: 'am-1', + role: RoleType.ASSISTANTE_MATERNELLE, + }); + amRepository.findOne.mockResolvedValue({ + user_id: 'am-1', + numero_dossier: '2026-000015', + }); + amChildrenRepository.find.mockResolvedValue([ + { + amId: 'am-1', + enfantId: 'e1', + child: { id: 'e1', status: 'garde' }, + }, + ]); + amChildrenRepository.count.mockResolvedValue(0); + amChildrenRepository.save.mockImplementation(async (x) => x); + childrenRepository.save.mockResolvedValue({}); + usersRepository.delete.mockResolvedValue({ affected: 1 }); + + const res = await service.deleteUser('am-1', staff); + expect(res.deleted_enfant_ids).toEqual([]); + expect(res.deleted_user_ids).toEqual(['am-1']); + expect(res.type).toBe('assistante_maternelle'); + }); + + it('delete dossier famille introuvable', async () => { + parentsRepository.findOne.mockResolvedValue(null); + amRepository.findOne.mockResolvedValue(null); + await expect( + service.deleteDossier('2026-999999', staff), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('co-parent : delete user seul', async () => { + usersRepository.findOne.mockResolvedValue({ + id: 'p2', + role: RoleType.PARENT, + }); + parentsRepository.findOne.mockResolvedValue({ + user_id: 'p2', + numero_dossier: '2026-000010', + co_parent: { id: 'p1' }, + }); + parentsRepository.query.mockResolvedValue([ + { id: 'p1' }, + { id: 'p2' }, + ]); + dataSource.transaction.mockImplementation(async (cb) => + cb({ + delete: jest.fn(), + query: jest.fn(), + }), + ); + + const res = await service.deleteUser('p2', staff); + expect(res.deleted_enfant_ids).toEqual([]); + expect(res.deleted_user_ids).toEqual(['p2']); + expect(res.message).toMatch(/co-parent/i); + }); +}); diff --git a/backend/src/routes/suppressions/suppression.service.ts b/backend/src/routes/suppressions/suppression.service.ts new file mode 100644 index 0000000..f401bfc --- /dev/null +++ b/backend/src/routes/suppressions/suppression.service.ts @@ -0,0 +1,420 @@ +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))]; + } +} diff --git a/backend/src/routes/suppressions/suppressions.module.ts b/backend/src/routes/suppressions/suppressions.module.ts new file mode 100644 index 0000000..4b210eb --- /dev/null +++ b/backend/src/routes/suppressions/suppressions.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Users } from 'src/entities/users.entity'; +import { Parents } from 'src/entities/parents.entity'; +import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; +import { Children } from 'src/entities/children.entity'; +import { ParentsChildren } from 'src/entities/parents_children.entity'; +import { AmChildren } from 'src/entities/am_children.entity'; +import { SuppressionService } from './suppression.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Users, + Parents, + AssistanteMaternelle, + Children, + ParentsChildren, + AmChildren, + ]), + ], + providers: [SuppressionService], + exports: [SuppressionService], +}) +export class SuppressionsModule {} diff --git a/backend/src/routes/user/user.controller.ts b/backend/src/routes/user/user.controller.ts index 90b8535..c419ac2 100644 --- a/backend/src/routes/user/user.controller.ts +++ b/backend/src/routes/user/user.controller.ts @@ -10,13 +10,17 @@ import { CreateUserDto } from './dto/create_user.dto'; import { CreateAdminDto } from './dto/create_admin.dto'; import { UpdateUserDto } from './dto/update_user.dto'; import { AffecterNumeroDossierDto } from './dto/affecter-numero-dossier.dto'; +import { SuppressionService } from '../suppressions/suppression.service'; @ApiTags('Utilisateurs') @ApiBearerAuth('access-token') @UseGuards(AuthGuard, RolesGuard) @Controller('users') export class UserController { - constructor(private readonly userService: UserService) { } + constructor( + private readonly userService: UserService, + private readonly suppressionService: SuppressionService, + ) { } // Création d'un administrateur (réservée aux super admins) @Post('admin') @@ -146,12 +150,20 @@ export class UserController { return this.userService.suspendUser(id, currentUser, comment); } - // Supprimer un utilisateur (super_admin uniquement) + // Supprimer un utilisateur — cascades métier #159 @Delete(':id') - @Roles(RoleType.SUPER_ADMIN) - @ApiOperation({ summary: 'Supprimer un utilisateur' }) + @Roles( + RoleType.SUPER_ADMIN, + RoleType.ADMINISTRATEUR, + RoleType.GESTIONNAIRE, + ) + @ApiOperation({ + summary: 'Supprimer un utilisateur (cascades métier #159)', + description: + 'Parent / AM / staff selon matrice. Gestionnaire ne peut pas supprimer un autre gestionnaire. Self interdit.', + }) @ApiParam({ name: 'id', description: "UUID de l'utilisateur" }) remove(@Param('id') id: string, @User() currentUser: Users) { - return this.userService.remove(id, currentUser); + return this.suppressionService.deleteUser(id, currentUser); } } diff --git a/backend/src/routes/user/user.module.ts b/backend/src/routes/user/user.module.ts index 62598f8..839763f 100644 --- a/backend/src/routes/user/user.module.ts +++ b/backend/src/routes/user/user.module.ts @@ -12,6 +12,7 @@ import { Parents } from 'src/entities/parents.entity'; import { GestionnairesModule } from './gestionnaires/gestionnaires.module'; import { MailModule } from 'src/modules/mail/mail.module'; import { AppConfigModule } from 'src/modules/config/config.module'; +import { SuppressionsModule } from '../suppressions/suppressions.module'; @Module({ imports: [TypeOrmModule.forFeature( @@ -26,6 +27,7 @@ import { AppConfigModule } from 'src/modules/config/config.module'; GestionnairesModule, MailModule, AppConfigModule, + SuppressionsModule, ], controllers: [UserController], providers: [UserService], diff --git a/backend/src/routes/user/user.service.ts b/backend/src/routes/user/user.service.ts index be700a3..50a0c52 100644 --- a/backend/src/routes/user/user.service.ts +++ b/backend/src/routes/user/user.service.ts @@ -520,6 +520,7 @@ export class UserService { } async remove(id: string, currentUser: Users): Promise { + // Délégué historiquement ; préférer SuppressionService via controller (#159). if (currentUser.role !== RoleType.SUPER_ADMIN) { throw new ForbiddenException('Accès réservé aux super admins'); } diff --git a/docs/tmp/154-matrice-suppression.md b/docs/tmp/154-matrice-suppression.md new file mode 100644 index 0000000..fc1da2f --- /dev/null +++ b/docs/tmp/154-matrice-suppression.md @@ -0,0 +1,35 @@ +# Matrice suppression — #154 / back **#159** / front **#160** + +**Statut** : cadrage PO validé (sept. 2026) +**Milestone** : 0.1.0 +**Email** : pas d’email de suppression (cas rare) + +## Droits + +| Cible | Qui peut supprimer | +|-------|-------------------| +| Dossier / parent / enfant / AM | `GESTIONNAIRE`, `ADMINISTRATEUR`, `SUPER_ADMIN` | +| Gestionnaire (user) | `ADMINISTRATEUR`, `SUPER_ADMIN` | +| Administrateur (user) | Autre admin OK ; **self interdit** ; **dernier admin** = `SUPER_ADMIN` only ; `SUPER_ADMIN` non supprimable | + +## Matrice métier + +| Point d’entrée | Action | Effet | +|----------------|--------|--------| +| Dossiers | Delete dossier **famille** | Tous **parents** + tous **enfants** ; clore placements AM des enfants | +| Dossiers / AM | Delete dossier **AM** ou compte AM | **Compte AM + dossier AM** ; enfants **conservés** ; placements **clos** | +| Parents | Co-parent (autre parent reste) | Compte parent seul ; dossier + enfants restent | +| Parents | Dernier parent | Parent + **enfants** rattachés | +| Enfants | Pas dernier | Enfant seul (retiré du dossier) | +| Enfants | Dernier + `deleteDossier=true` | Cascade dossier famille (parents + enfants) | +| Enfants | Dernier + `deleteDossier=false` | Enfant seul ; dossier peut apparaître **`sans_enfant`** | +| Pending / validé | — | **Mêmes règles** (pas de différenciation) | + +## Warning + +- `sans_enfant` sur liste `GET /dossiers` (dossier famille sans enfant lié). +- Miroir de `sans_responsable` (#157) côté enfants. + +## Hors scope + +Soft-delete RGPD, audit (#128), famille N (#139), restriction admin-only métier (plus tard). diff --git a/docs/tmp/154-mini-spec-front-suppression.md b/docs/tmp/154-mini-spec-front-suppression.md new file mode 100644 index 0000000..271b26d --- /dev/null +++ b/docs/tmp/154-mini-spec-front-suppression.md @@ -0,0 +1,169 @@ +# Mini-spec front — Suppressions dashboard + +**Ticket front** : **#160** — https://git.ptits-pas.fr/jmartin/petitspas/issues/160 +**Ticket back** : **#159** — https://git.ptits-pas.fr/jmartin/petitspas/issues/159 +**Epic** : #154 (complète #133) +**Branche back** : `feature/159-suppressions-backend` +**Doc matrice** : [154-matrice-suppression.md](./154-matrice-suppression.md) + +Travail **en parallèle** : ce contrat est la source de vérité UI ↔ API. + +--- + +## UX commune + +Sur chaque ligne / carte des listes : + +- Icône **poubelle** en bout de ligne +- Clic → **dialog de confirmation** (texte d’impact) → DELETE → **refresh** liste +- Pending = **mêmes** règles que validés +- **Pas** d’email + +| Liste | Poubelle visible si | +|-------|---------------------| +| Dossiers, Parents, Enfants, AM | gestionnaire **ou** admin | +| Gestionnaires | **admin** only | +| Administrateurs | admin+ ; **pas** sur sa propre ligne ; dernier admin : UI warning + réservé super_admin | + +--- + +## Contrat API + +Base : auth Bearer. Erreurs : `400` / `403` / `404` / `409` avec `message` FR. + +### 1. `DELETE /dossiers/:numeroDossier` + +- **Famille** → supprime tous parents + enfants du n° ; clos placements AM des enfants. +- **AM** → compte AM + dossier AM ; enfants gardés ; placements clos. + +**Réponse 200** (exemple) : + +```json +{ + "type": "famille", + "numero_dossier": "2026-000043", + "deleted_user_ids": ["…"], + "deleted_enfant_ids": ["…"], + "message": "Dossier famille supprimé." +} +``` + +ou + +```json +{ + "type": "assistante_maternelle", + "numero_dossier": "2026-000015", + "deleted_user_ids": ["…"], + "deleted_enfant_ids": [], + "message": "Dossier assistante maternelle supprimé." +} +``` + +**Dialog UI** : lister libellé + n° + « X parent(s), Y enfant(s) » (ou « compte AM, enfants conservés »). + +--- + +### 2. `DELETE /users/:id` + +Comportement selon le **rôle** de la cible : + +| Cible | Effet | +|-------|--------| +| Parent **co-parent** | Delete ce user seul | +| Parent **dernier** du dossier | Delete user + enfants du foyer | +| AM | Delete user AM + dossier AM ; enfants conservés ; placements clos | +| Gestionnaire | Admin only ; self → 403 | +| Administrateur | Self → 403 ; dernier admin → super_admin only sinon 403 ; super_admin → 403 | + +**Réponse 200** : + +```json +{ + "deleted_user_ids": ["…"], + "deleted_enfant_ids": ["…"], + "message": "…" +} +``` + +**Dialogs** : + +- Co-parent : « Ce parent sera retiré / supprimé du dossier {n°}. Les enfants restent avec le co-parent. » +- Dernier parent : « Dernier parent du dossier {n°}. Les enfants rattachés seront aussi supprimés. » +- AM : « Le compte et le dossier AM seront supprimés. Les enfants accueillis ne seront pas supprimés. » + +Optionnel (si exposé) : `GET /users/:id/suppression-impact` — sinon calculer depuis données déjà en liste / détail dossier. + +--- + +### 3. `DELETE /enfants/:id?deleteDossier=true|false` + +- Pas dernier enfant → delete enfant (`deleteDossier` ignoré ou false). +- Dernier enfant + `deleteDossier=false` → delete enfant ; dossier famille peut passer `sans_enfant`. +- Dernier enfant + `deleteDossier=true` → cascade dossier famille (parents + enfants). + +**Réponse 200** : + +```json +{ + "deleted_enfant_ids": ["…"], + "deleted_user_ids": ["…"], + "dossier_supprime": false, + "message": "…" +} +``` + +**Dialog** : + +- Standard : « L’enfant sera supprimé du dossier de {famille} ({n°}). » +- Dernier : proposer **deux actions** : + 1. Supprimer l’enfant seulement (`deleteDossier=false`) + 2. Supprimer aussi le dossier / parents (`deleteDossier=true`) + +Pour savoir si dernier : compter enfants du `numero_dossier` (détail dossier ou champ impact API). + +--- + +### 4. `GET /dossiers` — flag `sans_enfant` + +Chaque item famille peut exposer : + +```json +"sans_enfant": true +``` + +- `true` si dossier **famille** sans enfant lié. +- AM : `false` ou omis. + +**UI** : badge / warning vigilance (comme `sans_responsable` / alertes AM). + +--- + +## UserService (Flutter) — signatures cibles + +```dart +Future deleteDossier(String numeroDossier); +Future> deleteUser(String userId); +Future> deleteEnfant(String enfantId, {bool deleteDossier = false}); +``` + +(Adapter le parsing au JSON réel une fois le back mergé ; en parallèle, stubber sur ce contrat.) + +--- + +## Fichiers front probables + +- Cartes listes : `admin_user_card.dart`, `admin_enfant_user_card.dart`, cartes dossiers +- Listes : `dossiers_management_widget.dart`, `parent_managmant_widget.dart`, `enfant_management_widget.dart`, `assistante_maternelle_management_widget.dart`, `gestionnaire_management_widget.dart`, `admin_management_widget.dart` +- `user_service.dart` + +--- + +## Critères front (#160) + +- [ ] Poubelle selon droits +- [ ] Confirmations avec impact +- [ ] Refresh après succès +- [ ] Dernier enfant : choix dossier oui/non +- [ ] Warning `sans_enfant` +- [ ] Self-admin / dernier admin gérés côté UI (masquer ou message 403)