From 846afed86c061166e210b94fd0249adf93b2086a Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Wed, 22 Jul 2026 18:31:15 +0200 Subject: [PATCH] =?UTF-8?q?feat(#158):=20affiliation=20enfant=20au=20foyer?= =?UTF-8?q?=20=E2=80=94=20attach/detach=20pivot=20+=20co-parent.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squash depuis develop : un POST/DELETE propage les liens à tout le foyer ; front un seul appel API ; doc empreinte. Co-authored-by: Cursor --- backend/src/routes/parents/parents.service.ts | 89 ++++++++++++--- .../métriques/2026-07-22_analyse-empreinte.md | 104 ++++++++++++++++++ .../common/admin_child_detail_modal.dart | 12 +- .../admin/common/admin_parent_edit_modal.dart | 8 +- 4 files changed, 186 insertions(+), 27 deletions(-) create mode 100644 docs/métriques/2026-07-22_analyse-empreinte.md diff --git a/backend/src/routes/parents/parents.service.ts b/backend/src/routes/parents/parents.service.ts index ac00f0a..15c0581 100644 --- a/backend/src/routes/parents/parents.service.ts +++ b/backend/src/routes/parents/parents.service.ts @@ -114,36 +114,86 @@ export class ParentsService { } /** - * Rattacher un enfant existant à un parent (enfants_parents). Ticket #115 / doc 28 §6.2. + * Membres du foyer (user ids) pour affiliation enfant. + * Pivot + co-parent (A→B et B→A) + même numero_dossier. Ticket #158. */ - async attachEnfant(parentUserId: string, enfantId: string): Promise { - await this.findOne(parentUserId); + private async resolveFoyerParentUserIds(parent: Parents): Promise { + const ids = new Set([parent.user_id]); - const existing = await this.parentsChildrenRepository.findOne({ - where: { parentId: parentUserId, enfantId }, - }); - if (existing) { - throw new ConflictException('Cet enfant est déjà rattaché à ce parent'); + if (parent.co_parent?.id) { + ids.add(parent.co_parent.id); } - const child = await this.parentsRepository.manager.findOne(Children, { where: { id: enfantId } }); + // Sens inverse : parents qui déclarent ce user comme co-parent + const reverseLinks = await this.parentsRepository.find({ + where: { co_parent: { id: parent.user_id } }, + relations: ['co_parent'], + }); + for (const p of reverseLinks) { + ids.add(p.user_id); + if (p.co_parent?.id) ids.add(p.co_parent.id); + } + + const dossier = parent.numero_dossier?.trim(); + if (dossier) { + const sameDossier = await this.parentsRepository.find({ + where: { numero_dossier: dossier }, + relations: ['co_parent'], + }); + for (const p of sameDossier) { + ids.add(p.user_id); + if (p.co_parent?.id) ids.add(p.co_parent.id); + } + } + + return [...ids]; + } + + /** + * Rattacher un enfant au foyer du parent (tous les responsables). Ticket #158. + * Un seul POST suffit : liens créés pour pivot + co-parent / même dossier. + */ + async attachEnfant(parentUserId: string, enfantId: string): Promise { + const parent = await this.findOne(parentUserId); + + const child = await this.parentsRepository.manager.findOne(Children, { + where: { id: enfantId }, + }); if (!child) { throw new NotFoundException('Enfant introuvable'); } - await this.parentsChildrenRepository.save( - this.parentsChildrenRepository.create({ parentId: parentUserId, enfantId }), - ); + const foyerIds = await this.resolveFoyerParentUserIds(parent); + let created = 0; + + for (const memberId of foyerIds) { + const existing = await this.parentsChildrenRepository.findOne({ + where: { parentId: memberId, enfantId }, + }); + if (existing) continue; + + await this.parentsChildrenRepository.save( + this.parentsChildrenRepository.create({ + parentId: memberId, + enfantId, + }), + ); + created += 1; + } + + if (created === 0) { + throw new ConflictException('Cet enfant est déjà rattaché à ce foyer'); + } + return this.findOne(parentUserId); } /** - * Détacher un enfant d'un parent sans supprimer l'enfant. - * Autorise le détachement du dernier responsable (#157) — l'enfant reste listé - * via GET /enfants avec parentLinks vides (alerte front). + * Détacher un enfant du foyer du parent (tous les responsables). Ticket #158. + * Si plus aucun lien ensuite → enfant orphelin (#157). */ async detachEnfant(parentUserId: string, enfantId: string): Promise { - await this.findOne(parentUserId); + const parent = await this.findOne(parentUserId); const link = await this.parentsChildrenRepository.findOne({ where: { parentId: parentUserId, enfantId }, @@ -152,7 +202,12 @@ export class ParentsService { throw new NotFoundException('Lien parent-enfant introuvable'); } - await this.parentsChildrenRepository.delete({ parentId: parentUserId, enfantId }); + const foyerIds = await this.resolveFoyerParentUserIds(parent); + await this.parentsChildrenRepository.delete({ + parentId: In(foyerIds), + enfantId, + }); + return this.findOne(parentUserId); } diff --git a/docs/métriques/2026-07-22_analyse-empreinte.md b/docs/métriques/2026-07-22_analyse-empreinte.md new file mode 100644 index 0000000..025e208 --- /dev/null +++ b/docs/métriques/2026-07-22_analyse-empreinte.md @@ -0,0 +1,104 @@ +# Analyse d’empreinte — P'titsPas + +**Date :** 2026-07-22 +**Contexte :** snapshot pour mémoire (après alerte disque plein + purge cache Gitea) +**Périmètre :** application `jmartin/petitspas` déployée sur le VPS (`/home/deploy/dev/ptitspas-app`) + +--- + +## 1. Lignes de code + +Comptage brut (`wc -l`), hors `node_modules` / builds / `.dart_tool`. + +| Zone | Lignes | Fichiers | +|------|--------|----------| +| **Frontend** (Dart `frontend/lib/`) | ~29 800 | 141 | +| **Backend** (TypeScript `backend/src/`) | ~10 000 | 141 | +| **BDD** (SQL sous `database/`) | ~1 250 | ~15 | +| **Total** | **~41 000** | | + +### Frontend (détail) + +| Dossier | Lignes | +|---------|--------| +| `widgets/` | ~18 400 | +| `screens/` | ~5 000 | +| `services/` | ~2 300 | +| `models/` | ~2 200 | +| `utils/` | ~1 400 | +| reste | ~500 | + +### Backend (détail) + +| Dossier | Lignes | +|---------|--------| +| `routes/` | ~6 500 | +| `modules/` | ~1 700 | +| `entities/` | ~1 000 | +| `common/` + `config/` | ~400 | + +### BDD (détail) + +| Élément | Lignes | +|---------|--------| +| `BDD.sql` (schéma) | ~470 | +| seeds | ~300 | +| migrations / patches | ~280 | +| tests SQL | ~205 | + +--- + +## 2. Empreinte disque — runtime (Docker) + +| Composant | Taille | Notes | +|-----------|--------|--------| +| Image `ptitspas-app-backend` | ~300 Mo | NestJS | +| Image `ptitspas-app-frontend` | ~122 Mo | Flutter web + Nginx | +| Image `postgres:17` | ~454 Mo | | +| Image `dpage/pgadmin4` | ~534 Mo | optionnel | +| Volume `postgres_data` | ~49 Mo | données BDD live | +| Volume `backend_uploads` | ~20 Mo | photos | +| Volume `backend_documents_legaux` | ~0 | | +| **Stack complète (avec pgAdmin)** | **~1,5 Go** | | +| **Stack prod sans pgAdmin** | **~945 Mo** | | + +--- + +## 3. Empreinte disque — code source + +| Élément | Taille | +|---------|--------| +| Clone `/home/deploy/dev/ptitspas-app` | ~701 Mo | +| dont `backend/node_modules` | ~354 Mo | +| dont `frontend` (+ `.dart_tool`) | ~114 Mo | +| **Code utile** (hors deps / `.git` / builds) | **~51 Mo** | +| Repo Gitea `petitspas.git` | ~109 Mo | +| Dump `database/BDD.sql` | ~19 Ko | + +--- + +## 4. Pour (re)déployer + +Minimum requis : + +1. Images custom backend + frontend (~422 Mo), ou code + build Docker +2. Image `postgres:17` (~454 Mo) +3. Volumes persistants (~70 Mo au snapshot) +4. Optionnel : pgAdmin (~534 Mo) + +**Ordre de grandeur :** ~1 Go pour faire tourner l’app en prod (sans pgAdmin). + +--- + +## 5. Notes infra du jour (lié) + +- Disque VPS passé de **100 %** à ~**44 %** après : + - purge cache Docker / journals + - purge officielle Gitea `delete_repo_archives` (~24 Go de zip/bundle `petitspas`) +- Crons Gitea activés : + - `archive_cleanup` @midnight (`OLDER_THAN = 24h`) + - `delete_repo_archives` @weekly + +--- + +*Fichier généré pour historique projet — ne pas considérer comme métrique CI automatisée.* diff --git a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart index e160b25..eae7d4d 100644 --- a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart @@ -1277,18 +1277,16 @@ class _AdminChildDetailModalState extends State { return; } - // Édition orphelin (#157) : rattache immédiatement au foyer. + // Édition orphelin (#157/#158) : un seul attach — le back propage au foyer. final enfantId = widget.enfant?.id; if (enfantId == null || enfantId.isEmpty) return; setState(() => _saving = true); try { - for (final parentId in selected.parentUserIds) { - await UserService.attachEnfantToParent( - parentUserId: parentId, - enfantId: enfantId, - ); - } + await UserService.attachEnfantToParent( + parentUserId: selected.pivotParentUserId, + enfantId: enfantId, + ); final refreshed = await UserService.getEnfant(enfantId); if (!mounted) return; setState(() { diff --git a/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart index 6365159..101b6a5 100644 --- a/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart @@ -283,7 +283,7 @@ class _AdminParentEditModalState extends State { builder: (ctx) => AlertDialog( title: const Text('Détacher l\'enfant'), content: Text( - 'Retirer ${child.fullName} de la fiche de ce parent ?', + 'Retirer ${child.fullName} du foyer (tous les responsables) ?', ), actions: [ TextButton( @@ -308,8 +308,9 @@ class _AdminParentEditModalState extends State { if (!mounted) return; await _reloadChildren(); if (!mounted) return; + widget.onSaved?.call(); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Enfant détaché')), + const SnackBar(content: Text('Enfant détaché du foyer')), ); } catch (e) { if (!mounted) return; @@ -336,8 +337,9 @@ class _AdminParentEditModalState extends State { if (!mounted) return; await _reloadChildren(); if (!mounted) return; + widget.onSaved?.call(); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Enfant rattaché')), + const SnackBar(content: Text('Enfant rattaché au foyer')), ); } catch (e) { if (!mounted) return;