feat(#159): suppressions métier dossiers/parents/enfants/AM/staff.
SuppressionService + DELETE /dossiers/:numero, cascades DELETE /users et DELETE /enfants?deleteDossier, flag sans_enfant, specs front #160. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -18,6 +18,7 @@ import { AppConfigModule } from './modules/config/config.module';
|
|||||||
import { DocumentsLegauxModule } from './modules/documents-legaux';
|
import { DocumentsLegauxModule } from './modules/documents-legaux';
|
||||||
import { RelaisModule } from './routes/relais/relais.module';
|
import { RelaisModule } from './routes/relais/relais.module';
|
||||||
import { DossiersModule } from './routes/dossiers/dossiers.module';
|
import { DossiersModule } from './routes/dossiers/dossiers.module';
|
||||||
|
import { SuppressionsModule } from './routes/suppressions/suppressions.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -57,6 +58,7 @@ import { DossiersModule } from './routes/dossiers/dossiers.module';
|
|||||||
DocumentsLegauxModule,
|
DocumentsLegauxModule,
|
||||||
RelaisModule,
|
RelaisModule,
|
||||||
DossiersModule,
|
DossiersModule,
|
||||||
|
SuppressionsModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { DossiersController } from './dossiers.controller';
|
import { DossiersController } from './dossiers.controller';
|
||||||
import { DossiersService } from './dossiers.service';
|
import { DossiersService } from './dossiers.service';
|
||||||
|
import { SuppressionService } from '../suppressions/suppression.service';
|
||||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||||
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||||
@@ -11,11 +12,17 @@ describe('DossiersController', () => {
|
|||||||
listDossiers: jest.fn(),
|
listDossiers: jest.fn(),
|
||||||
getDossierByNumero: jest.fn(),
|
getDossierByNumero: jest.fn(),
|
||||||
};
|
};
|
||||||
|
const suppressionServiceMock = {
|
||||||
|
deleteDossier: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
controllers: [DossiersController],
|
controllers: [DossiersController],
|
||||||
providers: [{ provide: DossiersService, useValue: dossiersServiceMock }],
|
providers: [
|
||||||
|
{ provide: DossiersService, useValue: dossiersServiceMock },
|
||||||
|
{ provide: SuppressionService, useValue: suppressionServiceMock },
|
||||||
|
],
|
||||||
})
|
})
|
||||||
.overrideGuard(AuthGuard)
|
.overrideGuard(AuthGuard)
|
||||||
.useValue({ canActivate: () => true })
|
.useValue({ canActivate: () => true })
|
||||||
@@ -60,4 +67,19 @@ describe('DossiersController', () => {
|
|||||||
expect(dossiersServiceMock.getDossierByNumero).toHaveBeenCalledWith('2026-000001');
|
expect(dossiersServiceMock.getDossierByNumero).toHaveBeenCalledWith('2026-000001');
|
||||||
expect(res.type).toBe('family');
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
|
import {
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
@@ -8,10 +15,12 @@ import {
|
|||||||
ApiTags,
|
ApiTags,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import { Roles } from 'src/common/decorators/roles.decorator';
|
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 { AuthGuard } from 'src/common/guards/auth.guard';
|
||||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||||
|
import { User } from 'src/common/decorators/user.decorator';
|
||||||
import { DossiersService } from './dossiers.service';
|
import { DossiersService } from './dossiers.service';
|
||||||
|
import { SuppressionService } from '../suppressions/suppression.service';
|
||||||
import { DossierUnifieDto } from './dto/dossier-unifie.dto';
|
import { DossierUnifieDto } from './dto/dossier-unifie.dto';
|
||||||
import { DossierListItemDto } from './dto/dossier-list-item.dto';
|
import { DossierListItemDto } from './dto/dossier-list-item.dto';
|
||||||
|
|
||||||
@@ -20,7 +29,10 @@ import { DossierListItemDto } from './dto/dossier-list-item.dto';
|
|||||||
@Controller('dossiers')
|
@Controller('dossiers')
|
||||||
@UseGuards(AuthGuard, RolesGuard)
|
@UseGuards(AuthGuard, RolesGuard)
|
||||||
export class DossiersController {
|
export class DossiersController {
|
||||||
constructor(private readonly dossiersService: DossiersService) {}
|
constructor(
|
||||||
|
private readonly dossiersService: DossiersService,
|
||||||
|
private readonly suppressionService: SuppressionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
@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',
|
summary: 'Liste unifiée des dossiers (familles + AM) — ticket #153',
|
||||||
description:
|
description:
|
||||||
'1 entrée = 1 numero_dossier. Types `famille` | `assistante_maternelle`. ' +
|
'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({
|
@ApiQuery({
|
||||||
name: 'q',
|
name: 'q',
|
||||||
@@ -51,4 +64,21 @@ export class DossiersController {
|
|||||||
getDossier(@Param('numeroDossier') numeroDossier: string): Promise<DossierUnifieDto> {
|
getDossier(@Param('numeroDossier') numeroDossier: string): Promise<DossierUnifieDto> {
|
||||||
return this.dossiersService.getDossierByNumero(numeroDossier);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { JwtModule } from '@nestjs/jwt';
|
|||||||
import { Parents } from 'src/entities/parents.entity';
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||||
import { ParentsModule } from '../parents/parents.module';
|
import { ParentsModule } from '../parents/parents.module';
|
||||||
|
import { SuppressionsModule } from '../suppressions/suppressions.module';
|
||||||
import { DossiersController } from './dossiers.controller';
|
import { DossiersController } from './dossiers.controller';
|
||||||
import { DossiersService } from './dossiers.service';
|
import { DossiersService } from './dossiers.service';
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ import { DossiersService } from './dossiers.service';
|
|||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Parents, AssistanteMaternelle]),
|
TypeOrmModule.forFeature([Parents, AssistanteMaternelle]),
|
||||||
ParentsModule,
|
ParentsModule,
|
||||||
|
SuppressionsModule,
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
useFactory: (config: ConfigService) => ({
|
useFactory: (config: ConfigService) => ({
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { DossiersService } from './dossiers.service';
|
|||||||
import { Parents } from 'src/entities/parents.entity';
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||||
import { ParentsService } from '../parents/parents.service';
|
import { ParentsService } from '../parents/parents.service';
|
||||||
|
import { SuppressionService } from '../suppressions/suppression.service';
|
||||||
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||||
|
|
||||||
describe('DossiersService.listDossiers', () => {
|
describe('DossiersService.listDossiers', () => {
|
||||||
@@ -32,6 +33,9 @@ describe('DossiersService.listDossiers', () => {
|
|||||||
const parentsService = {
|
const parentsService = {
|
||||||
getDossierFamilleByNumero: jest.fn(),
|
getDossierFamilleByNumero: jest.fn(),
|
||||||
};
|
};
|
||||||
|
const suppressionService = {
|
||||||
|
countEnfantsForNumero: jest.fn().mockResolvedValue(1),
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
@@ -40,6 +44,7 @@ describe('DossiersService.listDossiers', () => {
|
|||||||
{ provide: getRepositoryToken(Parents), useValue: parentsRepo },
|
{ provide: getRepositoryToken(Parents), useValue: parentsRepo },
|
||||||
{ provide: getRepositoryToken(AssistanteMaternelle), useValue: amRepo },
|
{ provide: getRepositoryToken(AssistanteMaternelle), useValue: amRepo },
|
||||||
{ provide: ParentsService, useValue: parentsService },
|
{ provide: ParentsService, useValue: parentsService },
|
||||||
|
{ provide: SuppressionService, useValue: suppressionService },
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
@@ -47,6 +52,7 @@ describe('DossiersService.listDossiers', () => {
|
|||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
parentsRepo.createQueryBuilder.mockReturnValue(parentsQb);
|
parentsRepo.createQueryBuilder.mockReturnValue(parentsQb);
|
||||||
amRepo.createQueryBuilder.mockReturnValue(amQb);
|
amRepo.createQueryBuilder.mockReturnValue(amQb);
|
||||||
|
suppressionService.countEnfantsForNumero.mockResolvedValue(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('aggregates famille (pivot+co-parent) and AM, sorts a_valider first', async () => {
|
it('aggregates famille (pivot+co-parent) and AM, sorts a_valider first', async () => {
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ import { Parents } from 'src/entities/parents.entity';
|
|||||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||||
import { StatutUtilisateurType, Users } from 'src/entities/users.entity';
|
import { StatutUtilisateurType, Users } from 'src/entities/users.entity';
|
||||||
import { ParentsService } from '../parents/parents.service';
|
import { ParentsService } from '../parents/parents.service';
|
||||||
|
import { SuppressionService } from '../suppressions/suppression.service';
|
||||||
import { DossierUnifieDto } from './dto/dossier-unifie.dto';
|
import { DossierUnifieDto } from './dto/dossier-unifie.dto';
|
||||||
import { DossierAmCompletDto, DossierAmUserDto } from './dto/dossier-am-complet.dto';
|
import { DossierAmCompletDto, DossierAmUserDto } from './dto/dossier-am-complet.dto';
|
||||||
import { DossierListItemDto } from './dto/dossier-list-item.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()
|
@Injectable()
|
||||||
export class DossiersService {
|
export class DossiersService {
|
||||||
@@ -20,6 +21,7 @@ export class DossiersService {
|
|||||||
@InjectRepository(AssistanteMaternelle)
|
@InjectRepository(AssistanteMaternelle)
|
||||||
private readonly amRepository: Repository<AssistanteMaternelle>,
|
private readonly amRepository: Repository<AssistanteMaternelle>,
|
||||||
private readonly parentsService: ParentsService,
|
private readonly parentsService: ParentsService,
|
||||||
|
private readonly suppressionService: SuppressionService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,6 +45,17 @@ export class DossiersService {
|
|||||||
return b.numero_dossier.localeCompare(a.numero_dossier, 'fr');
|
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;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,4 +49,10 @@ export class DossierListItemDto {
|
|||||||
description: 'Date de référence (MIN cree_le des users du dossier)',
|
description: 'Date de référence (MIN cree_le des users du dossier)',
|
||||||
})
|
})
|
||||||
date_reference: string | null;
|
date_reference: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'True si dossier famille sans enfant lié (#159). Omis ou false pour AM.',
|
||||||
|
})
|
||||||
|
sans_enfant?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
Query,
|
||||||
UploadedFile,
|
UploadedFile,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
ApiBody,
|
ApiBody,
|
||||||
ApiConsumes,
|
ApiConsumes,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
|
ApiQuery,
|
||||||
ApiTags,
|
ApiTags,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import { diskStorage } from 'multer';
|
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 { AuthGuard } from 'src/common/guards/auth.guard';
|
||||||
import { Roles } from 'src/common/decorators/roles.decorator';
|
import { Roles } from 'src/common/decorators/roles.decorator';
|
||||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||||
|
import { SuppressionService } from '../suppressions/suppression.service';
|
||||||
|
|
||||||
const photoMulterOptions = {
|
const photoMulterOptions = {
|
||||||
storage: diskStorage({
|
storage: diskStorage({
|
||||||
@@ -83,7 +86,10 @@ class OptionalEnfantPhotoInterceptor implements NestInterceptor {
|
|||||||
@UseGuards(AuthGuard, RolesGuard)
|
@UseGuards(AuthGuard, RolesGuard)
|
||||||
@Controller('enfants')
|
@Controller('enfants')
|
||||||
export class EnfantsController {
|
export class EnfantsController {
|
||||||
constructor(private readonly enfantsService: EnfantsService) { }
|
constructor(
|
||||||
|
private readonly enfantsService: EnfantsService,
|
||||||
|
private readonly suppressionService: SuppressionService,
|
||||||
|
) { }
|
||||||
|
|
||||||
@Roles(
|
@Roles(
|
||||||
RoleType.PARENT,
|
RoleType.PARENT,
|
||||||
@@ -157,9 +163,31 @@ export class EnfantsController {
|
|||||||
return this.enfantsService.update(id, dto, currentUser, photo);
|
return this.enfantsService.update(id, dto, currentUser, photo);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleType.SUPER_ADMIN)
|
@Roles(
|
||||||
|
RoleType.SUPER_ADMIN,
|
||||||
|
RoleType.ADMINISTRATEUR,
|
||||||
|
RoleType.GESTIONNAIRE,
|
||||||
|
)
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
remove(@Param('id', new ParseUUIDPipe()) id: string) {
|
@ApiOperation({
|
||||||
return this.enfantsService.remove(id);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,16 @@ import { Children } from 'src/entities/children.entity';
|
|||||||
import { Parents } from 'src/entities/parents.entity';
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { SuppressionsModule } from '../suppressions/suppressions.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Children, Parents, ParentsChildren]),
|
imports: [
|
||||||
AuthModule
|
TypeOrmModule.forFeature([Children, Parents, ParentsChildren]),
|
||||||
|
AuthModule,
|
||||||
|
SuppressionsModule,
|
||||||
],
|
],
|
||||||
controllers: [EnfantsController],
|
controllers: [EnfantsController],
|
||||||
providers: [EnfantsService]
|
providers: [EnfantsService],
|
||||||
|
exports: [EnfantsService],
|
||||||
})
|
})
|
||||||
export class EnfantsModule { }
|
export class EnfantsModule { }
|
||||||
|
|||||||
@@ -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<unknown>) =>
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<Users>,
|
||||||
|
@InjectRepository(Parents)
|
||||||
|
private readonly parentsRepository: Repository<Parents>,
|
||||||
|
@InjectRepository(AssistanteMaternelle)
|
||||||
|
private readonly amRepository: Repository<AssistanteMaternelle>,
|
||||||
|
@InjectRepository(Children)
|
||||||
|
private readonly childrenRepository: Repository<Children>,
|
||||||
|
@InjectRepository(ParentsChildren)
|
||||||
|
private readonly parentsChildrenRepository: Repository<ParentsChildren>,
|
||||||
|
@InjectRepository(AmChildren)
|
||||||
|
private readonly amChildrenRepository: Repository<AmChildren>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
assertStaffMetier(currentUser: Users): void {
|
||||||
|
if (!STAFF_METIER.includes(currentUser.role)) {
|
||||||
|
throw new ForbiddenException('Accès refusé');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteDossier(
|
||||||
|
numeroDossier: string,
|
||||||
|
currentUser: Users,
|
||||||
|
): Promise<SuppressionResult> {
|
||||||
|
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<SuppressionResult> {
|
||||||
|
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<SuppressionResult> {
|
||||||
|
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<number> {
|
||||||
|
const ids = await this.listEnfantIdsForNumero(numeroDossier);
|
||||||
|
return ids.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertCanDeleteAdministrateur(
|
||||||
|
target: Users,
|
||||||
|
currentUser: Users,
|
||||||
|
): Promise<void> {
|
||||||
|
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<SuppressionResult> {
|
||||||
|
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<SuppressionResult> {
|
||||||
|
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<SuppressionResult> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<string[]> {
|
||||||
|
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<string[]> {
|
||||||
|
const parentIds = await this.listParentUserIdsForNumero(numero);
|
||||||
|
if (!parentIds.length) return [];
|
||||||
|
return this.listEnfantIdsForParents(parentIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listEnfantIdsForParent(parentId: string): Promise<string[]> {
|
||||||
|
return this.listEnfantIdsForParents([parentId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listEnfantIdsForParents(parentIds: string[]): Promise<string[]> {
|
||||||
|
const links = await this.parentsChildrenRepository.find({
|
||||||
|
where: { parentId: In(parentIds) },
|
||||||
|
});
|
||||||
|
return [...new Set(links.map((l) => l.enfantId))];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -10,13 +10,17 @@ import { CreateUserDto } from './dto/create_user.dto';
|
|||||||
import { CreateAdminDto } from './dto/create_admin.dto';
|
import { CreateAdminDto } from './dto/create_admin.dto';
|
||||||
import { UpdateUserDto } from './dto/update_user.dto';
|
import { UpdateUserDto } from './dto/update_user.dto';
|
||||||
import { AffecterNumeroDossierDto } from './dto/affecter-numero-dossier.dto';
|
import { AffecterNumeroDossierDto } from './dto/affecter-numero-dossier.dto';
|
||||||
|
import { SuppressionService } from '../suppressions/suppression.service';
|
||||||
|
|
||||||
@ApiTags('Utilisateurs')
|
@ApiTags('Utilisateurs')
|
||||||
@ApiBearerAuth('access-token')
|
@ApiBearerAuth('access-token')
|
||||||
@UseGuards(AuthGuard, RolesGuard)
|
@UseGuards(AuthGuard, RolesGuard)
|
||||||
@Controller('users')
|
@Controller('users')
|
||||||
export class UserController {
|
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)
|
// Création d'un administrateur (réservée aux super admins)
|
||||||
@Post('admin')
|
@Post('admin')
|
||||||
@@ -146,12 +150,20 @@ export class UserController {
|
|||||||
return this.userService.suspendUser(id, currentUser, comment);
|
return this.userService.suspendUser(id, currentUser, comment);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Supprimer un utilisateur (super_admin uniquement)
|
// Supprimer un utilisateur — cascades métier #159
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@Roles(RoleType.SUPER_ADMIN)
|
@Roles(
|
||||||
@ApiOperation({ summary: 'Supprimer un utilisateur' })
|
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" })
|
@ApiParam({ name: 'id', description: "UUID de l'utilisateur" })
|
||||||
remove(@Param('id') id: string, @User() currentUser: Users) {
|
remove(@Param('id') id: string, @User() currentUser: Users) {
|
||||||
return this.userService.remove(id, currentUser);
|
return this.suppressionService.deleteUser(id, currentUser);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Parents } from 'src/entities/parents.entity';
|
|||||||
import { GestionnairesModule } from './gestionnaires/gestionnaires.module';
|
import { GestionnairesModule } from './gestionnaires/gestionnaires.module';
|
||||||
import { MailModule } from 'src/modules/mail/mail.module';
|
import { MailModule } from 'src/modules/mail/mail.module';
|
||||||
import { AppConfigModule } from 'src/modules/config/config.module';
|
import { AppConfigModule } from 'src/modules/config/config.module';
|
||||||
|
import { SuppressionsModule } from '../suppressions/suppressions.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature(
|
imports: [TypeOrmModule.forFeature(
|
||||||
@@ -26,6 +27,7 @@ import { AppConfigModule } from 'src/modules/config/config.module';
|
|||||||
GestionnairesModule,
|
GestionnairesModule,
|
||||||
MailModule,
|
MailModule,
|
||||||
AppConfigModule,
|
AppConfigModule,
|
||||||
|
SuppressionsModule,
|
||||||
],
|
],
|
||||||
controllers: [UserController],
|
controllers: [UserController],
|
||||||
providers: [UserService],
|
providers: [UserService],
|
||||||
|
|||||||
@@ -520,6 +520,7 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: string, currentUser: Users): Promise<void> {
|
async remove(id: string, currentUser: Users): Promise<void> {
|
||||||
|
// Délégué historiquement ; préférer SuppressionService via controller (#159).
|
||||||
if (currentUser.role !== RoleType.SUPER_ADMIN) {
|
if (currentUser.role !== RoleType.SUPER_ADMIN) {
|
||||||
throw new ForbiddenException('Accès réservé aux super admins');
|
throw new ForbiddenException('Accès réservé aux super admins');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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<void> deleteDossier(String numeroDossier);
|
||||||
|
Future<Map<String, dynamic>> deleteUser(String userId);
|
||||||
|
Future<Map<String, dynamic>> 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)
|
||||||
Reference in New Issue
Block a user