From ce474797c488987d38b918912097e27cc5daf618 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Wed, 24 Jun 2026 23:38:07 +0200 Subject: [PATCH] =?UTF-8?q?fix(#131):=20co=5Fparent=20en=20r=C3=A9ponse=20?= =?UTF-8?q?parents=20+=20masquage=20secrets=20user?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contrat API fiche parent : co_parent peuplé (déjà chargé), sans password ni tokens sur user/co_parent. Doc tmp front→back. Co-authored-by: Cursor --- .../utils/sanitize-user-for-api.spec.ts | 32 +++++ .../src/common/utils/sanitize-user-for-api.ts | 23 ++++ .../src/routes/parents/parents.controller.ts | 39 +++--- .../src/routes/parents/parents.mapper.spec.ts | 38 ++++++ backend/src/routes/parents/parents.mapper.ts | 18 +++ .../131-fiche-parent-co-parent-header.md | 127 ++++++++++++++++++ 6 files changed, 262 insertions(+), 15 deletions(-) create mode 100644 backend/src/common/utils/sanitize-user-for-api.spec.ts create mode 100644 backend/src/common/utils/sanitize-user-for-api.ts create mode 100644 backend/src/routes/parents/parents.mapper.spec.ts create mode 100644 backend/src/routes/parents/parents.mapper.ts create mode 100644 docs/archive/temporaires/131-fiche-parent-co-parent-header.md diff --git a/backend/src/common/utils/sanitize-user-for-api.spec.ts b/backend/src/common/utils/sanitize-user-for-api.spec.ts new file mode 100644 index 0000000..c4ad2b2 --- /dev/null +++ b/backend/src/common/utils/sanitize-user-for-api.spec.ts @@ -0,0 +1,32 @@ +import { sanitizeUserForApi } from './sanitize-user-for-api'; +import { RoleType, StatutUtilisateurType, Users } from '../../entities/users.entity'; + +describe('sanitizeUserForApi', () => { + const base: Users = { + id: 'u1', + email: 'a@b.fr', + prenom: 'Paul', + nom: 'Parent', + role: RoleType.PARENT, + statut: StatutUtilisateurType.ACTIF, + password: 'hash', + token_creation_mdp: 'tok', + token_creation_mdp_expire_le: new Date(), + password_reset_token: 'rst', + password_reset_expires: new Date(), + } as Users; + + it('retire password et tokens', () => { + const out = sanitizeUserForApi(base)!; + expect(out.prenom).toBe('Paul'); + expect(out.nom).toBe('Parent'); + expect(out.password).toBeUndefined(); + expect(out.token_creation_mdp).toBeUndefined(); + expect(out.password_reset_token).toBeUndefined(); + }); + + it('retourne undefined si user absent', () => { + expect(sanitizeUserForApi(null)).toBeUndefined(); + expect(sanitizeUserForApi(undefined)).toBeUndefined(); + }); +}); diff --git a/backend/src/common/utils/sanitize-user-for-api.ts b/backend/src/common/utils/sanitize-user-for-api.ts new file mode 100644 index 0000000..dcd7e89 --- /dev/null +++ b/backend/src/common/utils/sanitize-user-for-api.ts @@ -0,0 +1,23 @@ +import { Users } from 'src/entities/users.entity'; + +/** Champs sensibles exclus des réponses API (ticket #131 — user / co_parent). */ +const SENSITIVE_USER_KEYS: (keyof Users)[] = [ + 'password', + 'token_creation_mdp', + 'token_creation_mdp_expire_le', + 'password_reset_token', + 'password_reset_expires', +]; + +/** + * Retourne une copie utilisateur sans secrets (hash MDP, tokens). + * Utilisé pour `user` et `co_parent` dans les réponses Parents. + */ +export function sanitizeUserForApi(user?: Users | null): Users | undefined { + if (!user) return undefined; + const safe = { ...user } as Users; + for (const key of SENSITIVE_USER_KEYS) { + delete safe[key]; + } + return safe; +} diff --git a/backend/src/routes/parents/parents.controller.ts b/backend/src/routes/parents/parents.controller.ts index 6f44bee..350eed7 100644 --- a/backend/src/routes/parents/parents.controller.ts +++ b/backend/src/routes/parents/parents.controller.ts @@ -23,6 +23,7 @@ import { RolesGuard } from 'src/common/guards/roles.guard'; import { User } from 'src/common/decorators/user.decorator'; import { PendingFamilyDto } from './dto/pending-family.dto'; import { DossierFamilleCompletDto } from './dto/dossier-famille-complet.dto'; +import { mapParentForApi, mapParentsForApi } from './parents.mapper'; @ApiTags('Parents') @Controller('parents') @@ -82,20 +83,23 @@ export class ParentsController { } @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) - @Get() + @ApiOperation({ summary: 'Liste des parents (user, co_parent, parentChildren) — ticket #131' }) @ApiResponse({ status: 200, type: [Parents], description: 'Liste des parents' }) @ApiResponse({ status: 403, description: 'Accès refusé !' }) - getAll(): Promise { - return this.parentsService.findAll(); + async getAll(): Promise { + const parents = await this.parentsService.findAll(); + return mapParentsForApi(parents); } @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @Get(':id') + @ApiOperation({ summary: 'Détail parent par user_id (inclut co_parent si id_co_parent renseigné) — ticket #131' }) @ApiResponse({ status: 200, type: Parents, description: 'Détails du parent par ID utilisateur' }) @ApiResponse({ status: 404, description: 'Parent non trouvé' }) @ApiResponse({ status: 403, description: 'Accès refusé !' }) - getOne(@Param('id') user_id: string): Promise { - return this.parentsService.findOne(user_id); + async getOne(@Param('id') user_id: string): Promise { + const parent = await this.parentsService.findOne(user_id); + return mapParentForApi(parent); } @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE) @@ -103,8 +107,9 @@ export class ParentsController { @ApiBody({ type: CreateParentDto }) @ApiResponse({ status: 201, type: Parents, description: 'Parent créé avec succès' }) @ApiResponse({ status: 403, description: 'Accès refusé !' }) - create(@Body() dto: CreateParentDto): Promise { - return this.parentsService.create(dto); + async create(@Body() dto: CreateParentDto): Promise { + const parent = await this.parentsService.create(dto); + return mapParentForApi(parent); } @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @@ -113,11 +118,12 @@ export class ParentsController { @ApiParam({ name: 'id', description: "UUID utilisateur du parent" }) @ApiBody({ type: UpdateParentFicheAdminDto }) @ApiResponse({ status: 200, type: Parents, description: 'Fiche parent mise à jour' }) - updateFicheAdmin( + async updateFicheAdmin( @Param('id') id: string, @Body() dto: UpdateParentFicheAdminDto, ): Promise { - return this.parentsService.updateFicheAdmin(id, dto); + const parent = await this.parentsService.updateFicheAdmin(id, dto); + return mapParentForApi(parent); } @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @@ -126,11 +132,12 @@ export class ParentsController { @ApiParam({ name: 'id', description: "UUID utilisateur du parent" }) @ApiParam({ name: 'enfantId', description: "UUID de l'enfant" }) @ApiResponse({ status: 200, type: Parents, description: 'Parent avec enfants mis à jour' }) - attachEnfant( + async attachEnfant( @Param('id') id: string, @Param('enfantId') enfantId: string, ): Promise { - return this.parentsService.attachEnfant(id, enfantId); + const parent = await this.parentsService.attachEnfant(id, enfantId); + return mapParentForApi(parent); } @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @@ -139,11 +146,12 @@ export class ParentsController { @ApiParam({ name: 'id', description: "UUID utilisateur du parent" }) @ApiParam({ name: 'enfantId', description: "UUID de l'enfant" }) @ApiResponse({ status: 200, type: Parents, description: 'Parent avec enfants mis à jour' }) - detachEnfant( + async detachEnfant( @Param('id') id: string, @Param('enfantId') enfantId: string, ): Promise { - return this.parentsService.detachEnfant(id, enfantId); + const parent = await this.parentsService.detachEnfant(id, enfantId); + return mapParentForApi(parent); } @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE) @@ -152,7 +160,8 @@ export class ParentsController { @ApiResponse({ status: 200, type: Parents, description: 'Parent mis à jour avec succès' }) @ApiResponse({ status: 404, description: 'Parent introuvable' }) @ApiResponse({ status: 403, description: 'Accès refusé !' }) - update(@Param('id') id: string, @Body() dto: UpdateParentsDto): Promise { - return this.parentsService.update(id, dto); + async update(@Param('id') id: string, @Body() dto: UpdateParentsDto): Promise { + const parent = await this.parentsService.update(id, dto); + return mapParentForApi(parent); } } diff --git a/backend/src/routes/parents/parents.mapper.spec.ts b/backend/src/routes/parents/parents.mapper.spec.ts new file mode 100644 index 0000000..e34ecc0 --- /dev/null +++ b/backend/src/routes/parents/parents.mapper.spec.ts @@ -0,0 +1,38 @@ +import { mapParentForApi } from './parents.mapper'; +import { Parents } from '../../entities/parents.entity'; +import { RoleType, StatutUtilisateurType, Users } from '../../entities/users.entity'; + +describe('mapParentForApi', () => { + it('expose co_parent avec prenom/nom sans secrets', () => { + const coParent = { + id: 'cp1', + email: 'co@b.fr', + prenom: 'Clara', + nom: 'Co', + role: RoleType.PARENT, + statut: StatutUtilisateurType.ACTIF, + password: 'secret', + } as Users; + + const parent = { + user_id: 'u1', + numero_dossier: '2026-000042', + user: { + id: 'u1', + email: 'p@b.fr', + prenom: 'Paul', + nom: 'Parent', + role: RoleType.PARENT, + password: 'secret', + } as Users, + co_parent: coParent, + parentChildren: [], + } as Parents; + + const out = mapParentForApi(parent); + expect(out.co_parent?.prenom).toBe('Clara'); + expect(out.co_parent?.nom).toBe('Co'); + expect(out.co_parent?.password).toBeUndefined(); + expect(out.user.password).toBeUndefined(); + }); +}); diff --git a/backend/src/routes/parents/parents.mapper.ts b/backend/src/routes/parents/parents.mapper.ts new file mode 100644 index 0000000..5c9e517 --- /dev/null +++ b/backend/src/routes/parents/parents.mapper.ts @@ -0,0 +1,18 @@ +import { Parents } from 'src/entities/parents.entity'; +import { sanitizeUserForApi } from '../../common/utils/sanitize-user-for-api'; + +/** + * Sérialisation API fiche parent — ticket #131. + * Garantit `user`, `co_parent` (si présent) et relations sans champs sensibles. + */ +export function mapParentForApi(parent: Parents): Parents { + return { + ...parent, + user: sanitizeUserForApi(parent.user)!, + co_parent: sanitizeUserForApi(parent.co_parent), + }; +} + +export function mapParentsForApi(parents: Parents[]): Parents[] { + return parents.map(mapParentForApi); +} diff --git a/docs/archive/temporaires/131-fiche-parent-co-parent-header.md b/docs/archive/temporaires/131-fiche-parent-co-parent-header.md new file mode 100644 index 0000000..6f8a3c8 --- /dev/null +++ b/docs/archive/temporaires/131-fiche-parent-co-parent-header.md @@ -0,0 +1,127 @@ +# #131 — En-tête fiche parent : co-parent (note front → back) + +**Ticket :** #131 (fiche parent dashboard, doc `28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1) +**Date :** 2026-06-01 +**Statut front :** livré (en-tête dynamique) +**Modif backend demandée :** **aucune fonctionnelle** — ce document fixe le contrat attendu ; le back valide `co_parent` et masque les champs sensibles. + +--- + +## 1. Comportement UI (front) + +Dans la modale **fiche parent** (`AdminParentEditModal`) : + +| Zone | Contenu | +|------|---------| +| **Titre** | `prenom` + `nom` du parent affiché (plus le libellé fixe « Fiche parent ») | +| **Sous-titre** | `Co-parent : {prenom} {nom}` — affiché **uniquement** si un co-parent est connu | + +Le titre se met à jour en direct pendant l’édition des champs nom/prénom. +Le sous-titre provient du co-parent **chargé depuis l’API** (pas saisi à la main dans la modale). + +--- + +## 2. Endpoints consommés + +| Méthode | Route | Usage front | +|---------|-------|-------------| +| `GET` | `/api/v1/parents` | Liste parents (onglet Parents) | +| `GET` | `/api/v1/parents/:userId` | Rechargement fiche après rattachement/détachement enfant | +| `PATCH` | `/api/v1/parents/:userId/fiche` | Sauvegarde identité + statut (inchangé) | + +Rôles : `super_admin`, `gestionnaire`, `administrateur` (selon route). + +--- + +## 3. Contrat JSON attendu pour `co_parent` + +Le front parse `ParentModel.fromJson` avec la clé **`co_parent`** (snake_case), objet utilisateur imbriqué. + +### Champs minimum utilisés pour le sous-titre + +| Clé JSON | Usage | +|----------|--------| +| `co_parent` | Objet ou absent/`null` | +| `co_parent.id` | Identifiant (futur lien cliquable éventuel) | +| `co_parent.prenom` | Affichage | +| `co_parent.nom` | Affichage | + +Affichage front : `'{prenom} {nom}'.trim()` → libellé `Co-parent : …`. + +### Exemple de fragment de réponse (`GET /parents/:id`) + +```json +{ + "user_id": "33333333-3333-3333-3333-333333333333", + "numero_dossier": "2026-000042", + "user": { + "id": "33333333-3333-3333-3333-333333333333", + "email": "parent1@example.com", + "prenom": "Paul", + "nom": "PARENT", + "statut": "actif", + "telephone": "0601020304" + }, + "co_parent": { + "id": "44444444-4444-4444-4444-444444444444", + "email": "coparent1@example.com", + "prenom": "Clara", + "nom": "COPARENT", + "role": "parent", + "statut": "actif" + }, + "parentChildren": [] +} +``` + +> **Note :** le front lit `user` (pas `utilisateur`). La doc `11_API.md` § Parents mentionne encore `utilisateur` / `id_co_parent` seul — le contrat **effectif** côté Nest/TypeORM est l’entité `Parents` sérialisée (`user`, `co_parent`, `parentChildren`, …). + +--- + +## 4. État backend + +### Relations (déjà en place) + +- `findAll()` et `findOne(user_id)` chargent **`co_parent`** ; +- FK : `parents.id_co_parent` → `utilisateurs.id` ; +- inscription couple : les deux sens renseignés en principe (`auth.service.ts`). + +### Livraison back (#131) + +- `mapParentForApi` / `sanitizeUserForApi` : réponses `GET/PATCH/POST/DELETE` parents **sans** `password`, `token_creation_mdp`, `password_reset_*` sur `user` et `co_parent`. + +**Checklist validation :** + +- [x] `GET /parents/:id` renvoie `co_parent` peuplé quand `id_co_parent` est non null +- [x] `GET /parents` (liste) inclut `co_parent` +- [x] `prenom` / `nom` du co-parent présents +- [x] Pas de fuite `password` / tokens sur `user` ni `co_parent` + +--- + +## 5. Points d’attention (hors périmètre immédiat) + +| Sujet | Détail | +|-------|--------| +| **Lien inverse** | Si B est co-parent de A (`A.id_co_parent = B`) mais `B.id_co_parent` est `null`, le sous-titre **ne s’affichera pas** sur la fiche de B. Pas de résolution inverse côté front. | +| **Familles > 2 adultes** | Sous-titre = co-parent direct (`id_co_parent`) uniquement. | +| **Trou AM ↔ enfants en garde** | Pas de lien AM–enfant aujourd’hui (à documenter / traiter plus tard). | + +--- + +## 6. Fichiers back concernés + +| Fichier | Rôle | +|---------|------| +| `backend/src/routes/parents/parents.service.ts` | `findOne`, `findAll` + relations | +| `backend/src/routes/parents/parents.controller.ts` | `mapParentForApi` sur les réponses | +| `backend/src/routes/parents/parents.mapper.ts` | Sérialisation API | +| `backend/src/common/utils/sanitize-user-for-api.ts` | Masquage secrets | +| `backend/src/entities/parents.entity.ts` | relation `co_parent` | + +--- + +## 7. Références + +- `docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1 +- Ticket Gitea **#131**