diff --git a/backend/src/routes/dossiers/dossiers.controller.spec.ts b/backend/src/routes/dossiers/dossiers.controller.spec.ts new file mode 100644 index 0000000..165e1e3 --- /dev/null +++ b/backend/src/routes/dossiers/dossiers.controller.spec.ts @@ -0,0 +1,63 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { DossiersController } from './dossiers.controller'; +import { DossiersService } from './dossiers.service'; +import { AuthGuard } from 'src/common/guards/auth.guard'; +import { RolesGuard } from 'src/common/guards/roles.guard'; +import { StatutUtilisateurType } from 'src/entities/users.entity'; + +describe('DossiersController', () => { + let controller: DossiersController; + const dossiersServiceMock = { + listDossiers: jest.fn(), + getDossierByNumero: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [DossiersController], + providers: [{ provide: DossiersService, useValue: dossiersServiceMock }], + }) + .overrideGuard(AuthGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(RolesGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = module.get(DossiersController); + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + it('list delegates to dossiersService.listDossiers with q', async () => { + dossiersServiceMock.listDossiers.mockResolvedValue([ + { + type: 'famille', + numero_dossier: '2026-000043', + libelle: 'Claire MARTIN', + emails: ['claire@test.fr'], + user_ids: ['u1'], + statut: StatutUtilisateurType.ACTIF, + a_valider: false, + date_reference: null, + }, + ]); + + const res = await controller.list('martin'); + expect(dossiersServiceMock.listDossiers).toHaveBeenCalledWith('martin'); + expect(res).toHaveLength(1); + expect(res[0].numero_dossier).toBe('2026-000043'); + }); + + it('getDossier delegates to getDossierByNumero', async () => { + dossiersServiceMock.getDossierByNumero.mockResolvedValue({ + type: 'family', + dossier: { numero_dossier: '2026-000001' }, + }); + const res = await controller.getDossier('2026-000001'); + expect(dossiersServiceMock.getDossierByNumero).toHaveBeenCalledWith('2026-000001'); + expect(res.type).toBe('family'); + }); +}); diff --git a/backend/src/routes/dossiers/dossiers.controller.ts b/backend/src/routes/dossiers/dossiers.controller.ts index 2f3f677..4849f9b 100644 --- a/backend/src/routes/dossiers/dossiers.controller.ts +++ b/backend/src/routes/dossiers/dossiers.controller.ts @@ -1,18 +1,46 @@ -import { Controller, Get, Param, UseGuards } from '@nestjs/common'; -import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiParam, + ApiQuery, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; import { Roles } from 'src/common/decorators/roles.decorator'; import { RoleType } from 'src/entities/users.entity'; import { AuthGuard } from 'src/common/guards/auth.guard'; import { RolesGuard } from 'src/common/guards/roles.guard'; import { DossiersService } from './dossiers.service'; import { DossierUnifieDto } from './dto/dossier-unifie.dto'; +import { DossierListItemDto } from './dto/dossier-list-item.dto'; @ApiTags('Dossiers') +@ApiBearerAuth('access-token') @Controller('dossiers') @UseGuards(AuthGuard, RolesGuard) export class DossiersController { constructor(private readonly dossiersService: DossiersService) {} + @Get() + @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) + @ApiOperation({ + 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.', + }) + @ApiQuery({ + name: 'q', + required: false, + description: 'Recherche libre : n° dossier, libellé, email…', + }) + @ApiResponse({ status: 200, type: [DossierListItemDto] }) + @ApiResponse({ status: 403, description: 'Accès refusé' }) + list(@Query('q') q?: string): Promise { + return this.dossiersService.listDossiers(q); + } + @Get(':numeroDossier') @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) @ApiOperation({ summary: 'Dossier complet par numéro (AM ou famille) – Ticket #119' }) diff --git a/backend/src/routes/dossiers/dossiers.service.spec.ts b/backend/src/routes/dossiers/dossiers.service.spec.ts new file mode 100644 index 0000000..ecdf772 --- /dev/null +++ b/backend/src/routes/dossiers/dossiers.service.spec.ts @@ -0,0 +1,142 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +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 { StatutUtilisateurType } from 'src/entities/users.entity'; + +describe('DossiersService.listDossiers', () => { + let service: DossiersService; + const parentsQb = { + innerJoinAndSelect: jest.fn().mockReturnThis(), + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn(), + }; + const amQb = { + innerJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn(), + }; + const parentsRepo = { + createQueryBuilder: jest.fn(() => parentsQb), + findOne: jest.fn(), + }; + const amRepo = { + createQueryBuilder: jest.fn(() => amQb), + findOne: jest.fn(), + }; + const parentsService = { + getDossierFamilleByNumero: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DossiersService, + { provide: getRepositoryToken(Parents), useValue: parentsRepo }, + { provide: getRepositoryToken(AssistanteMaternelle), useValue: amRepo }, + { provide: ParentsService, useValue: parentsService }, + ], + }).compile(); + + service = module.get(DossiersService); + jest.clearAllMocks(); + parentsRepo.createQueryBuilder.mockReturnValue(parentsQb); + amRepo.createQueryBuilder.mockReturnValue(amQb); + }); + + it('aggregates famille (pivot+co-parent) and AM, sorts a_valider first', async () => { + parentsQb.getMany.mockResolvedValue([ + { + user_id: 'p1', + numero_dossier: '2026-000010', + user: { + id: 'p1', + email: 'claire@test.fr', + prenom: 'Claire', + nom: 'Martin', + statut: StatutUtilisateurType.ACTIF, + cree_le: new Date('2026-01-01'), + }, + co_parent: { + id: 'p2', + email: 'thomas@test.fr', + prenom: 'Thomas', + nom: 'Martin', + statut: StatutUtilisateurType.ACTIF, + cree_le: new Date('2026-01-02'), + }, + }, + { + user_id: 'p3', + numero_dossier: '2026-000020', + user: { + id: 'p3', + email: 'pending@test.fr', + prenom: 'Paul', + nom: 'Pending', + statut: StatutUtilisateurType.EN_ATTENTE, + cree_le: new Date('2026-02-01'), + }, + co_parent: undefined, + }, + ]); + amQb.getMany.mockResolvedValue([ + { + user_id: 'am1', + numero_dossier: '2026-000015', + user: { + id: 'am1', + email: 'am@test.fr', + prenom: 'Marie', + nom: 'Dupont', + statut: StatutUtilisateurType.ACTIF, + cree_le: new Date('2026-01-15'), + }, + }, + ]); + + const list = await service.listDossiers(); + expect(list).toHaveLength(3); + expect(list[0].a_valider).toBe(true); + expect(list[0].type).toBe('famille'); + expect(list[0].numero_dossier).toBe('2026-000020'); + + const famille = list.find((i) => i.numero_dossier === '2026-000010')!; + expect(famille.type).toBe('famille'); + expect(famille.user_ids).toEqual(expect.arrayContaining(['p1', 'p2'])); + expect(famille.emails).toHaveLength(2); + expect(famille.libelle).toContain('MARTIN'); + + const am = list.find((i) => i.type === 'assistante_maternelle')!; + expect(am.numero_dossier).toBe('2026-000015'); + expect(am.libelle).toContain('Marie'); + }); + + it('filters with q', async () => { + parentsQb.getMany.mockResolvedValue([]); + amQb.getMany.mockResolvedValue([ + { + user_id: 'am1', + numero_dossier: '2026-000015', + user: { + id: 'am1', + email: 'am@test.fr', + prenom: 'Marie', + nom: 'Dupont', + statut: StatutUtilisateurType.ACTIF, + cree_le: new Date('2026-01-15'), + }, + }, + ]); + + const hit = await service.listDossiers('dupont'); + expect(hit).toHaveLength(1); + const miss = await service.listDossiers('zzz'); + expect(miss).toHaveLength(0); + }); +}); diff --git a/backend/src/routes/dossiers/dossiers.service.ts b/backend/src/routes/dossiers/dossiers.service.ts index f9715e6..cfc4711 100644 --- a/backend/src/routes/dossiers/dossiers.service.ts +++ b/backend/src/routes/dossiers/dossiers.service.ts @@ -3,12 +3,14 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; 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 { DossierUnifieDto } from './dto/dossier-unifie.dto'; import { DossierAmCompletDto, DossierAmUserDto } from './dto/dossier-am-complet.dto'; +import { DossierListItemDto } from './dto/dossier-list-item.dto'; /** - * Endpoint unifié GET /dossiers/:numeroDossier – AM ou famille. Ticket #119. + * Dossiers unifiés — détail (#119) + liste (#153). */ @Injectable() export class DossiersService { @@ -20,6 +22,159 @@ export class DossiersService { private readonly parentsService: ParentsService, ) {} + /** + * Liste unifiée tous dossiers (familles + AM) ayant un numero_dossier. + * Ticket #153 — optionnel `q` filtre n° / nom / prénom / email (côté serveur). + */ + async listDossiers(q?: string): Promise { + const items: DossierListItemDto[] = [ + ...(await this.listFamilleItems()), + ...(await this.listAmItems()), + ]; + + const needle = (q ?? '').trim().toLowerCase(); + const filtered = needle + ? items.filter((item) => this.matchesQuery(item, needle)) + : items; + + filtered.sort((a, b) => { + // À valider d'abord, puis n° dossier décroissant + if (a.a_valider !== b.a_valider) return a.a_valider ? -1 : 1; + return b.numero_dossier.localeCompare(a.numero_dossier, 'fr'); + }); + + return filtered; + } + + private async listFamilleItems(): Promise { + const parents = await this.parentsRepository + .createQueryBuilder('p') + .innerJoinAndSelect('p.user', 'u') + .leftJoinAndSelect('p.co_parent', 'cp') + .where('p.numero_dossier IS NOT NULL') + .andWhere("TRIM(p.numero_dossier) <> ''") + .getMany(); + + const byNum = new Map(); + for (const p of parents) { + const num = (p.numero_dossier ?? '').trim(); + if (!num) continue; + const group = byNum.get(num) ?? []; + group.push(p); + byNum.set(num, group); + } + + const items: DossierListItemDto[] = []; + for (const [numero_dossier, group] of byNum) { + const usersMap = new Map(); + for (const p of group) { + if (p.user) usersMap.set(p.user.id, p.user); + if (p.co_parent) usersMap.set(p.co_parent.id, p.co_parent); + } + const users = [...usersMap.values()].sort((a, b) => { + const an = `${a.nom ?? ''} ${a.prenom ?? ''}`.toLowerCase(); + const bn = `${b.nom ?? ''} ${b.prenom ?? ''}`.toLowerCase(); + return an.localeCompare(bn, 'fr') || a.id.localeCompare(b.id); + }); + if (users.length === 0) continue; + + const names = users.map((u) => this.formatPersonName(u)).filter(Boolean); + const libelle = + names.length === 0 + ? `Dossier ${numero_dossier}` + : names.length === 1 + ? names[0] + : names.join(' & '); + + const emails = users.map((u) => u.email).filter(Boolean); + const user_ids = users.map((u) => u.id); + const a_valider = users.some((u) => u.statut === StatutUtilisateurType.EN_ATTENTE); + const statut = a_valider + ? StatutUtilisateurType.EN_ATTENTE + : (users[0].statut ?? StatutUtilisateurType.ACTIF); + const date_reference = this.minCreeLeIso(users); + + items.push({ + type: 'famille', + numero_dossier, + libelle, + emails, + user_ids, + statut, + a_valider, + date_reference, + }); + } + return items; + } + + private async listAmItems(): Promise { + const ams = await this.amRepository + .createQueryBuilder('am') + .innerJoinAndSelect('am.user', 'u') + .where('am.numero_dossier IS NOT NULL') + .andWhere("TRIM(am.numero_dossier) <> ''") + .getMany(); + + const byNum = new Map(); + for (const am of ams) { + const num = (am.numero_dossier ?? '').trim(); + if (!num || !am.user) continue; + // Un n° = une AM ; garder le premier + if (!byNum.has(num)) byNum.set(num, am); + } + + const items: DossierListItemDto[] = []; + for (const [numero_dossier, am] of byNum) { + const u = am.user!; + const libelle = this.formatPersonName(u) || `AM ${numero_dossier}`; + const a_valider = u.statut === StatutUtilisateurType.EN_ATTENTE; + items.push({ + type: 'assistante_maternelle', + numero_dossier, + libelle, + emails: u.email ? [u.email] : [], + user_ids: [u.id], + statut: u.statut ?? StatutUtilisateurType.ACTIF, + a_valider, + date_reference: this.minCreeLeIso([u]), + }); + } + return items; + } + + private matchesQuery(item: DossierListItemDto, needle: string): boolean { + const hay = [ + item.numero_dossier, + item.libelle, + ...item.emails, + item.statut, + item.type, + ] + .join(' ') + .toLowerCase(); + return hay.includes(needle); + } + + private formatPersonName(u: Users): string { + const prenom = (u.prenom ?? '').trim(); + const nom = (u.nom ?? '').trim(); + const nomFmt = nom ? nom.toUpperCase() : ''; + return [prenom, nomFmt].filter(Boolean).join(' '); + } + + private minCreeLeIso(users: Users[]): string | null { + let min: Date | null = null; + for (const u of users) { + const d = u.cree_le; + if (!d) continue; + const date = d instanceof Date ? d : new Date(d); + if (Number.isNaN(date.getTime())) continue; + if (!min || date < min) min = date; + } + return min ? min.toISOString() : null; + } + async getDossierByNumero(numeroDossier: string): Promise { const num = numeroDossier?.trim(); if (!num) { diff --git a/backend/src/routes/dossiers/dto/dossier-list-item.dto.ts b/backend/src/routes/dossiers/dto/dossier-list-item.dto.ts new file mode 100644 index 0000000..c510f7e --- /dev/null +++ b/backend/src/routes/dossiers/dto/dossier-list-item.dto.ts @@ -0,0 +1,52 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { StatutUtilisateurType } from 'src/entities/users.entity'; + +/** Ligne de liste GET /dossiers (#153). */ +export class DossierListItemDto { + @ApiProperty({ + enum: ['famille', 'assistante_maternelle'], + description: 'Type de dossier', + }) + type: 'famille' | 'assistante_maternelle'; + + @ApiProperty({ example: '2026-000043' }) + numero_dossier: string; + + @ApiProperty({ + example: 'Claire MARTIN & Thomas MARTIN', + description: 'Libellé affiché (noms)', + }) + libelle: string; + + @ApiProperty({ + type: [String], + example: ['claire@example.com', 'thomas@example.com'], + }) + emails: string[]; + + @ApiProperty({ + type: [String], + format: 'uuid', + description: 'IDs utilisateur liés au dossier (parents du foyer ou AM)', + }) + user_ids: string[]; + + @ApiProperty({ + enum: StatutUtilisateurType, + description: + 'Statut agrégé : en_attente si au moins un user en_attente, sinon statut du premier', + }) + statut: StatutUtilisateurType; + + @ApiProperty({ + description: 'True si le dossier est en attente de validation (section haute UI)', + }) + a_valider: boolean; + + @ApiPropertyOptional({ + nullable: true, + example: '2026-01-12T10:00:00.000Z', + description: 'Date de référence (MIN cree_le des users du dossier)', + }) + date_reference: string | null; +} diff --git a/docs/tmp/153-contrat-api-liste-dossiers.md b/docs/tmp/153-contrat-api-liste-dossiers.md new file mode 100644 index 0000000..b427a7f --- /dev/null +++ b/docs/tmp/153-contrat-api-liste-dossiers.md @@ -0,0 +1,77 @@ +# Mini-spec API — GET /dossiers (#153) + +Contrat pour le **plan front** (onglet permanent Dossiers). + +## Endpoint + +| | | +|--|--| +| **Méthode** | `GET` | +| **URL** | `{base}/api/v1/dossiers` | +| **Auth** | Bearer JWT | +| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` | +| **Query** | `q` (optionnel) — recherche n° / libellé / email | + +Complète `GET /dossiers/:numeroDossier` (#119) déjà existant. + +--- + +## Réponse 200 + +Tableau de lignes (1 entrée = 1 `numero_dossier`) : + +```json +[ + { + "type": "famille", + "numero_dossier": "2026-000043", + "libelle": "Claire MARTIN & Thomas MARTIN", + "emails": ["claire@test.fr", "thomas@test.fr"], + "user_ids": ["uuid-pivot", "uuid-co"], + "statut": "actif", + "a_valider": false, + "date_reference": "2026-01-12T10:00:00.000Z" + }, + { + "type": "assistante_maternelle", + "numero_dossier": "2026-000042", + "libelle": "Marie DUPONT", + "emails": ["marie@test.fr"], + "user_ids": ["uuid-am"], + "statut": "en_attente", + "a_valider": true, + "date_reference": "2026-02-01T08:00:00.000Z" + } +] +``` + +### Champs + +| Champ | Notes | +|-------|--------| +| `type` | `famille` \| `assistante_maternelle` | +| `numero_dossier` | Clé d’unité | +| `libelle` | Noms formatés (foyer : `A & B`) | +| `emails` / `user_ids` | Membres du foyer ou AM | +| `statut` | Agrégé : `en_attente` si au moins un user pending | +| `a_valider` | `true` si pending → section haute UI | +| `date_reference` | `MIN(cree_le)` des users | + +**Tri** : `a_valider` d’abord, puis `numero_dossier` décroissant. + +**Famille** : dédupliquée par `numero_dossier` (pivot + co-parent = 1 ligne). + +--- + +## Front + +- `UserService.getDossiers({ q? })` → cet endpoint +- Section haute : filtrer `a_valider == true` **ou** continuer pending APIs existantes +- Section basse : liste complète (ou hors pending selon règle UX) +- Clic → `GET /dossiers/:numero` (détail) / validation review + +Composition client `getParents`+`getAM` **plus nécessaire** si cet endpoint est déployé. + +## Branche + +`feature/153-onglet-dossiers`