feat(#158): affiliation enfant au foyer — attach/detach pivot + co-parent.

Squash depuis develop : un POST/DELETE propage les liens à tout le foyer ;
front un seul appel API ; doc empreinte.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
MARTIN Julien 2026-07-22 18:31:15 +02:00
parent 99a6c17c23
commit 846afed86c
4 changed files with 186 additions and 27 deletions

View File

@ -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 (AB et BA) + même numero_dossier. Ticket #158.
*/ */
async attachEnfant(parentUserId: string, enfantId: string): Promise<Parents> { private async resolveFoyerParentUserIds(parent: Parents): Promise<string[]> {
await this.findOne(parentUserId); const ids = new Set<string>([parent.user_id]);
const existing = await this.parentsChildrenRepository.findOne({ if (parent.co_parent?.id) {
where: { parentId: parentUserId, enfantId }, ids.add(parent.co_parent.id);
});
if (existing) {
throw new ConflictException('Cet enfant est déjà rattaché à ce parent');
} }
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<Parents> {
const parent = await this.findOne(parentUserId);
const child = await this.parentsRepository.manager.findOne(Children, {
where: { id: enfantId },
});
if (!child) { if (!child) {
throw new NotFoundException('Enfant introuvable'); throw new NotFoundException('Enfant introuvable');
} }
await this.parentsChildrenRepository.save( const foyerIds = await this.resolveFoyerParentUserIds(parent);
this.parentsChildrenRepository.create({ parentId: parentUserId, enfantId }), 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); return this.findOne(parentUserId);
} }
/** /**
* Détacher un enfant d'un parent sans supprimer l'enfant. * Détacher un enfant du foyer du parent (tous les responsables). Ticket #158.
* Autorise le détachement du dernier responsable (#157) l'enfant reste listé * Si plus aucun lien ensuite enfant orphelin (#157).
* via GET /enfants avec parentLinks vides (alerte front).
*/ */
async detachEnfant(parentUserId: string, enfantId: string): Promise<Parents> { async detachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
await this.findOne(parentUserId); const parent = await this.findOne(parentUserId);
const link = await this.parentsChildrenRepository.findOne({ const link = await this.parentsChildrenRepository.findOne({
where: { parentId: parentUserId, enfantId }, where: { parentId: parentUserId, enfantId },
@ -152,7 +202,12 @@ export class ParentsService {
throw new NotFoundException('Lien parent-enfant introuvable'); 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); return this.findOne(parentUserId);
} }

View File

@ -0,0 +1,104 @@
# Analyse dempreinte — 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 lapp 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.*

View File

@ -1277,18 +1277,16 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
return; 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; final enfantId = widget.enfant?.id;
if (enfantId == null || enfantId.isEmpty) return; if (enfantId == null || enfantId.isEmpty) return;
setState(() => _saving = true); setState(() => _saving = true);
try { try {
for (final parentId in selected.parentUserIds) { await UserService.attachEnfantToParent(
await UserService.attachEnfantToParent( parentUserId: selected.pivotParentUserId,
parentUserId: parentId, enfantId: enfantId,
enfantId: enfantId, );
);
}
final refreshed = await UserService.getEnfant(enfantId); final refreshed = await UserService.getEnfant(enfantId);
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {

View File

@ -283,7 +283,7 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: const Text('Détacher l\'enfant'), title: const Text('Détacher l\'enfant'),
content: Text( content: Text(
'Retirer ${child.fullName} de la fiche de ce parent ?', 'Retirer ${child.fullName} du foyer (tous les responsables) ?',
), ),
actions: [ actions: [
TextButton( TextButton(
@ -308,8 +308,9 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
if (!mounted) return; if (!mounted) return;
await _reloadChildren(); await _reloadChildren();
if (!mounted) return; if (!mounted) return;
widget.onSaved?.call();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Enfant détaché')), const SnackBar(content: Text('Enfant détaché du foyer')),
); );
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
@ -336,8 +337,9 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
if (!mounted) return; if (!mounted) return;
await _reloadChildren(); await _reloadChildren();
if (!mounted) return; if (!mounted) return;
widget.onSaved?.call();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Enfant rattaché')), const SnackBar(content: Text('Enfant rattaché au foyer')),
); );
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;