Compare commits

...
Author SHA1 Message Date
jmartinandCursor 514d19236d docs(#155): mini-spec phase 2 — move panels admin → dashboard.
Même ticket #155 (pas de #162). Inventaire, arborescence cible, plan
et critères pour finir l’option C au niveau dossier.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 10:53:46 +02:00
jmartinandCursor 01fb514bde refactor(#155): option C — widgets partagés sans préfixe Admin.
Déplace les composants Admin* du dashboard partagé vers
widgets/dashboard/ (ChildDetailModal, AmEditModal, UserCard, Select*, …).
Conserve AdminManagementWidget et screens/administrateurs. Rename mécanique,
aucun changement de comportement.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 23:13:53 +02:00
jmartin c8cb82dd24 merge(#161): admin peut créer gestionnaire et administrateur. 2026-09-10 23:00:28 +02:00
jmartinandCursor 9cd180bf6a feat(#161): admin peut créer gestionnaire et administrateur.
Assouplit POST /gestionnaires, PATCH /gestionnaires/:id et POST /users/admin
(+ check createAdmin) pour ADMINISTRATEUR. Masque « Ajouter » staff sur le
dashboard gestionnaire. Tests droits Roles + createAdmin.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 22:54:49 +02:00
jmartin ca07d2e111 merge(#160): suppressions dashboard front + API #159. 2026-09-10 22:41:02 +02:00
jmartinandCursor 67336c64fe fix(#160): un gestionnaire ne peut plus supprimer un autre depuis la fiche.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 22:38:57 +02:00
jmartinandCursor 97afbbcf9a fix(am): hauteur modale fiche AM par onglet (sans scroll inutile).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 22:16:06 +02:00
jmartinandCursor e235b30140 feat(#160): suppressions dashboard — poubelle, confirmations et sans_enfant.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 22:16:05 +02:00
jmartinandCursor 7f23356273 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>
2026-09-09 18:01:44 +02:00
jmartinandCursor c8c9cfbc4d feat(#135): mode édition dossier (PATCH fiche, co-parent, enfants).
Wizard edit famille/AM depuis la liste Dossiers ; save parents +
co-parent ; enfants existants en PATCH (photo multipart) sans POST doublon.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 16:07:23 +02:00
jmartinandCursor 530e896b66 feat(#135): POST /parents/:id/co-parent — ajout co-parent foyer.
API staff pour foyer mono-parent : compte actif, liens + enfants,
mail création MDP. Mini-specs front/back dans docs/tmp.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 23:34:19 +02:00
jmartinandCursor 0029c5ab86 fix(#153): peaufiner cartes dossiers et recherche.
Cartes neutres avec accent icône, photo AM si dispo, hint court
et infobulle de critères sur la barre de recherche.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 19:39:55 +02:00
jmartinandCursor 2ce9e9215f fix(#153): libellé pending-families en NOM Prénom.
Inclut le prénom dans string_agg et expose nom/prenom dans parents[].

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 18:41:25 +02:00
jmartinandCursor 3fdd913367 feat(#153): onglet permanent Dossiers (pending + liste unifiée).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 18:40:00 +02:00
jmartinandCursor 6708f73b06 feat(#153): GET /dossiers — liste unifiée familles + AM.
Endpoint staff pour l’onglet Dossiers : type, n°, libellé, emails,
statut, a_valider, filtre q. Complète GET /dossiers/:numero (#119).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 18:13:32 +02:00
jmartinandCursor f596f062a6 docs(#153): mini-spec front onglet Dossiers permanent.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 18:10:20 +02:00
jmartinandCursor 86701731e3 fix(#129): resserrer les champs enfant pour éviter le scroll.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 13:13:21 +02:00
jmartinandCursor b4abb7d6de fix(#129): peaufiner le wizard famille (co-parent, hauteurs, à naître).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 13:01:54 +02:00
jmartinandCursor cb5c1a5518 feat(#129): wizard admin création dossier famille (create/review).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 12:34:24 +02:00
jmartinandCursor 30ca99fb65 feat(#129): POST /parents/dossier — création dossier famille staff.
Factorise createParentDossier (actif + mail MDP) depuis l’inscription
publique qui reste en_attente + mail pending. Miroir #156 AM.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 16:59:09 +02:00
jmartinandCursor 8ee2ca8ea6 fix(#156): caler la modale AM sur les TF et unifier create/review.
ValidationFormMetrics (titres, TF, écarts) pilote la hauteur ; photo
étirée sur le corps ; mêmes widgets create et validation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 16:49:59 +02:00
jmartinandCursor e3552667bc fix(#156): valider téléphone au blur et NIR en live.
Formatage NIR progressif + contrôles au fil de la saisie ; téléphone
validé à la perte de focus comme e-mail / CP.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 16:28:35 +02:00
jmartinandCursor 4ae334b247 fix(#156): superviser nom, e-mail et CP comme à l’inscription.
IdentityBlock editable : capitalisation live, e-mail/CP normalisés
et validés à la perte de focus (aligné création de compte).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 19:27:20 +02:00
jmartinandCursor 471a62ddb7 feat(#156): wizard admin création dossier AM (create/review).
Généralise ValidationAmWizard en AmDossierWizard ; le + Asmat ouvre
le mode création vers POST /assistantes-maternelles/dossier.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 19:07:14 +02:00
jmartinandCursor 59afeb0a8d feat(#156): POST /assistantes-maternelles/dossier staff (actif + mail MDP).
Factorise createAmDossier depuis register/am ; staff crée un dossier
déjà actif et envoie l’e-mail de création de mot de passe.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 19:03:37 +02:00
jmartinandCursor d2172eafdb docs: snapshot empreinte code / disque (2026-07-22).
Mémoire après alerte disque plein — lignes de code et contexte VPS.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 18:27:49 +02:00
jmartinandCursor d247867fa0 feat(#158): front — attach/detach foyer (un seul appel API).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 19:53:31 +02:00
jmartinandCursor 93912d1374 feat(#158): attach/detach enfant au niveau foyer (pivot + co-parent).
POST/DELETE /parents/:id/enfants/:enfantId propagent les liens à tous
les responsables du foyer (co-parent bidirectionnel + même dossier).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 19:47:13 +02:00
jmartinandCursor 925b6d5cd4 fix(#157): texte détach + compteur enfants foyer (interim).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 19:42:36 +02:00
jmartinandCursor b0dddd6695 feat(#157): consommer le flag API sans_responsable.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 19:33:21 +02:00
jmartinandCursor ef7512dc1e feat(#157): autoriser détachement dernier parent + flag sans_responsable.
Supprime la garde totalLinks<=1 ; GET/findOne enfants exposent
sans_responsable pour les orphelins toujours listés.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 19:31:18 +02:00
jmartinandCursor 2ececa711b feat(#157): alerte enfants sans responsable + rattachement foyer.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 19:02:21 +02:00
jmartinandCursor 7a3d997ff9 fix(#132): UX création enfant — champs, genre et validation.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 18:51:00 +02:00
jmartinandCursor 6b89d7b405 fix(#132): envoyer birth_date à la création (enfant né).
Le if imbriqué n’incluait birth_date que pour le cas à naître.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 18:46:48 +02:00
jmartinandCursor f7ac628445 feat(#132): photo à la création enfant staff (multipart + bools).
Transform "true"/"false" pour class-validator ; multipart staff
avec FileInterceptor('photo') inchangé côté stockage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 18:08:52 +02:00
jmartinandCursor 26e9738ec7 feat(#132): upload photo à la création d'enfant (admin).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 18:07:47 +02:00
jmartinandCursor 0ec3410457 feat(#132): POST /enfants staff — parent_user_id + liens foyer.
Autorise GESTIONNAIRE/ADMIN/SUPER_ADMIN à créer un enfant rattaché
à un foyer existant (JSON + parent_user_id). Parent multipart inchangé.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 18:03:41 +02:00
jmartinandCursor c3acf1970d feat(#132): création enfant admin — modale + sélection famille.
Onglet Enfants « Ajouter » ouvre la fiche en mode création ; bloc Famille/dossier ; client POST /enfants avec parent_user_id (contrat back à livrer).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 18:00:41 +02:00
jmartinandCursor 6fe2b89a61 fix(#151): autoriser GET /relais pour gestionnaire + robustesse combo
- Back: RoleType.GESTIONNAIRE sur GET list/get (CRUD write admin-only)
- Front: ne plus effacer la sélection si le chargement échoue
- Fallback affichage depuis relaisNom de l'utilisateur

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 16:52:46 +02:00
jmartin d6a9b3fd66 Merge branch 'feature/149-clic-case-libre-rattacher-enfant' into develop
Clic case libre → rattacher enfant (#149).
2026-07-17 16:19:14 +02:00
jmartinandCursor 134b9781c8 feat(#149): clic sur case libre pour rattacher un enfant (fiche AM).
Les emplacements vides de la grille ouvrent le même flux que le lien footer, avec hover et respect de la capacité max.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 16:19:12 +02:00
jmartin b936d27445 Merge branch 'feature/148-rattacher-enfant-capacite-max' into develop
Fix capacité max — lien rattacher enfant (#148).
2026-07-17 16:12:54 +02:00
jmartinandCursor 18c1d1eba7 fix(#148): désactiver « Rattacher un enfant » si capacité AM pleine.
Le lien du footer est grisé avec tooltip quand enfants ≥ capacité max ; il se réactive après détachement.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 16:12:53 +02:00
jmartin f74b14c203 Merge branch 'feature/147-modale-selection-am-enfant' into develop
Modale de sélection d'AM (#147) depuis la fiche enfant.
2026-07-17 16:12:23 +02:00
jmartinandCursor f194b5f9e8 feat(#147): modale de sélection d'AM depuis la fiche enfant.
Réutilise AdminSelectListModal avec filtre Libre, cartes saturées en rouge, avertissement + lien fiche AM, et recalcul des places à chaque rattachement.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 16:11:55 +02:00
jmartin 5ab8ae3423 Merge branch 'feature/146-modale-selection-enfant-am' into develop
Modale de sélection d'enfant (#146) pour rattachement AM/parent.
2026-07-17 15:43:10 +02:00
jmartinandCursor 3277f77846 fix(#146): densifier la liste et corriger l'affichage des responsables.
Les cartes compactes montrent plus d'enfants d'un coup ; le nom ne monopolise plus la largeur au détriment des infos.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 15:39:59 +02:00
jmartinandCursor 2d80ad0d7e feat(#146): modale de sélection d'enfant pour rattachement AM/parent.
Recherche dynamique, filtre Sans garde côté AM, confirmation de transfert et détachement préalable pour laisser le point de vigilance places sur l'AM d'origine.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 15:31:47 +02:00
jmartinandCursor 09386f8aa6 feat(#145): lien co-parent cliquable dans la fiche parent.
Même UX que les responsables de la fiche enfant : ouverture de
AdminParentEditModal au clic sur le nom du co-parent.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 14:56:32 +02:00
jmartinandCursor faa50f637c fix(#138): enrichir les noms des responsables sur GET enfant.
GET /enfants/:id ne joint pas parent.user ; on complète les noms via
GET parent pour l'affichage des liens (ex. ouverture depuis la fiche AM).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 13:19:09 +02:00
jmartinandCursor 267fe63aec fix(#138): différer le rattachement AM de la fiche enfant au Sauvegarder.
Attach/détach AM restent locaux comme sur la fiche AM ; la sync API
n'a lieu qu'à l'enregistrement, avec rechargement du statut au retour
de la fiche AM.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 13:12:46 +02:00
jmartinandCursor 90b185740c feat(#138): ouvrir la fiche parent depuis les responsables de la modale enfant.
Les noms du sous-titre deviennent des liens cliquables vers
AdminParentEditModal, comme l'ouverture de fiche AM.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 12:54:32 +02:00
jmartinandCursor b903dbf60b fix(#138): libellé garde accordé au genre (Gardé / Gardée).
Remplace « En garde » par Gardé/Gardée selon le genre, comme
Scolarisé/Scolarisée.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 12:51:07 +02:00
jmartinandCursor 291ea26b34 fix(#138): lire le consentement photo sans heuristique photo.
Admin et reprise utilisentent uniquement la valeur API ; plus de
pré-cochage implicite si une photo est présente.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 12:46:54 +02:00
jmartinandCursor 59919834b9 fix(#138): carte AM dédiée, titre placement et consent_photo au payload.
Améliore la zone AM/scolarisation (carte dédiée + titre), envoie
consent_photo à l'inscription parent, et parse le consentement enfant
de façon plus robuste (heuristique photo tant que le back ne persiste pas).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 12:37:45 +02:00
jmartinandCursor cf6acbae7c fix(#138): cadre vide en sans garde et passage auto en garde à l'attach AM.
En statut sans_garde, la zone placement affiche toujours le cadre vide ;
sélectionner une AM passe le statut à en garde, et passer à sans_garde
détache l'AM rattachée.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 12:37:45 +02:00
jmartinandCursor 7951c38d4d feat(#138): refonte modale enfant admin en format paysage.
Alignement sur les fiches AM/parent : photo identité, champs en grille,
zone AM rattachable (y compris à naître) ou carte scolarisation,
suppression réservée au super_admin et ouverture au clic sur la carte
depuis la fiche parent.

Refs: #138
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 12:37:45 +02:00
jmartinandCursor 77d952a6f7 fix(#144): persister le consentement photo des enfants
- DTO inscription enfant : champ consent_photo
- Inscription + reprise parent : sauvegarde bool + date
- Front payload : envoi consent_photo
- PATCH /enfants : horodatage du consentement

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 12:36:05 +02:00
jmartin b4b546044d merge fix(#143): masquer Supprimer gestionnaire sur sa propre fiche 2026-07-12 12:10:11 +02:00
jmartinandCursor 6788351070 fix(#143): masquer Supprimer quand un gestionnaire édite sa propre fiche
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 12:10:06 +02:00
jmartinandCursor d6dac181d2 feat(#142): ouvrir la modale au clic sur les cartes du dashboard admin.
Squash merge develop → master.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 23:20:56 +02:00
jmartinandCursor 7b69f27ca5 feat(#142): ouvrir la modale au clic sur les cartes du dashboard admin.
Unifie le comportement des listes Parents, AM, Enfants, Gestionnaires, Admins et À valider avec le bouton d'action au survol.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 23:18:40 +02:00
jmartinandCursor 003fe6b762 feat(#131): fiches parent/AM éditable, placement AM↔enfant, statuts garde/sans_garde
Squash merge develop → master.

- Fiche parent éditable (co-parent, PATCH fiche, GET /parents)
- Fiche AM 3 onglets (PATCH fiche, rattacher/détacher enfants)
- Table enfants_assistantes_maternelles + enum garde/sans_garde
- Migration SQL + BDD.sql canonique
- Correctifs recette : @Get() parents, DTO fiche AM, fix NIR

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 22:49:57 +02:00
jmartinandCursor 8ed68797aa fix(#131): PATCH fiche AM — NIR sans crash date ni effacement NOT NULL
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 23:41:45 +02:00
jmartinandCursor 9ad371a342 fix(#131): aligner PATCH fiche AM back sur le payload front
Étend UpdateAmFicheAdminDto (nir, date/lieu naissance, agreement_date)
et persiste ces champs dans updateFicheAdmin avec validation NIR.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 23:37:42 +02:00
jmartinandCursor 18718670e9 fix(#131): différer rattachement enfants AM jusqu'à Sauvegarder.
Rattachement et détachement restent locaux, recalculent places_available et sont persistés avec le PATCH fiche.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 23:36:06 +02:00
jmartin fbf22f2540 Revert "fix(#131): aligner PATCH fiche AM sur le DTO back actuel."
This reverts commit 43a2cd213b.
2026-07-07 23:13:58 +02:00
jmartinandCursor 43a2cd213b fix(#131): aligner PATCH fiche AM sur le DTO back actuel.
Retire nir, dates et lieux de naissance du body tant que UpdateAmFicheAdminDto ne les accepte pas.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 23:12:38 +02:00
jmartin c865d11dc3 Revert "fix(#131): activer Sauvegarder sur statut, enfants et champs AM."
This reverts commit d03a8e6c8b.
2026-07-07 23:12:09 +02:00
jmartinandCursor d03a8e6c8b fix(#131): activer Sauvegarder sur statut, enfants et champs AM.
Compare l'état de la fiche à un snapshot initial (liste enfants, statut, places, formulaire) et corrige la gélule statut pour les valeurs hors liste.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 23:11:24 +02:00
jmartinandCursor 66c7f22280 feat(#131): grille enfants AM, statuts garde et polish champs admin.
Aligne le front sur les statuts enfant back (garde/sans_garde), refond l’onglet Enfants accueillis en grille 2×2 avec correction des places, et unifie les champs validation éditables/lecture seule.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 23:01:41 +02:00
jmartinandCursor 53721ffbb3 fix(#131): ajouter @Get() manquant sur liste parents
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 22:07:24 +02:00
jmartinandCursor 52e40d0001 feat(#131): back placement AM↔enfant + BDD garde/sans_garde
- API PATCH …/fiche, POST/DELETE …/enfants/:enfantId, GET avec amChildren
- Table enfants_assistantes_maternelles (option D, 1 garde active/enfant)
- Statuts enfant: garde/sans_garde remplacent actif (inscription → sans_garde)
- BDD.sql canonique réécrit; migration pour BDD existantes
- Seed test: hash bcrypt corrigé pour mot de passe « password »

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 14:05:39 +02:00
jmartinandCursor 4985726bc6 fix(#131): polish fiche AM — champs pro, places, vigilance et photo.
Fiche pro entièrement éditable, places déclarées en lecture seule avec alerte rouge, icône vigilance sur la liste AM, et cadrage photo aligné sur le wizard validation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 17:37:21 +02:00
jmartinandCursor 479a32b4bf feat(#131): fiche AM éditable en 3 onglets et widgets admin partagés.
Modale admin AM (identité, dossier pro, enfants), API front avec repli, note back affiliation, et extraction gélule statut / liste enfants pour la fiche parent.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 17:33:41 +02:00
jmartinandCursor 2fd97ddecb fix(#131): aligner la gélule de statut sur l'en-tête parent.
Le titre, le sélecteur de statut et le bouton fermer partagent le même
padding : la gélule n'est plus collée au bord supérieur de la modale.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 16:34:12 +02:00
jmartinandCursor ce474797c4 fix(#131): co_parent en réponse parents + masquage secrets user
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 <cursoragent@cursor.com>
2026-06-24 23:39:30 +02:00
jmartinandCursor ebf794e1ac feat(#131): en-tête fiche parent avec nom et co-parent.
Affiche le prénom/nom du parent en titre et le co-parent en sous-titre ; note handoff back dans archive/temporaires.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 23:37:04 +02:00
jmartinandCursor b99745e0fe feat(#112): reprise après refus — dossier complet, email resoumission
- GET/PATCH reprise-dossier enrichis (parents, enfants, motivation, fiche AM)
- Front: lien mail, modale identify login, wizards préremplis, PATCH complet
- Email accusé resoumission aux parents avec n° de dossier
- Fixes préremplissage AM (dates, places, ValueKey étape 2)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 19:19:55 +02:00
141 changed files with 17038 additions and 2895 deletions
+3
View File
@@ -89,6 +89,9 @@
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"moduleNameMapper": {
"^src/(.*)$": "<rootDir>/$1"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
+123
View File
@@ -0,0 +1,123 @@
/**
* Commentaire de clôture + fermeture issue Gitea #131.
* Usage: node backend/scripts/close-gitea-issue-131.js
*/
const https = require('https');
const fs = require('fs');
const path = require('path');
const repoRoot = path.join(__dirname, '../..');
const ISSUE = 131;
const REPO = 'jmartin/petitspas';
const body = `## Fermeture ticket #131 — livré sur \`develop\` et \`master\`
Branche **\`feature/131\`** mergée dans **\`develop\`**, puis squash merge **\`develop\`\`master\`** (déploiement production).
### Fiche parent (dashboard admin)
- Modale **\`AdminParentEditModal\`** éditable dès l'ouverture
- En-tête dynamique : **nom/prénom** + sous-titre **co-parent** (si connu)
- Gélule **statut** modifiable
- **\`PATCH /api/v1/parents/:id/fiche\`** — identité + statut
- **\`GET /api/v1/parents\`** — liste parents (fix décorateur \`@Get()\` manquant)
- Réponses API : \`co_parent\` peuplé, secrets user masqués (\`sanitizeUserForApi\`)
- Liste enfants en bas de fiche + rattachement/détachement
### Fiche AM (dashboard admin)
- Modale **\`AdminAmEditModal\`** — 3 onglets : Identité | Fiche pro | Enfants accueillis
- **\`PATCH /api/v1/assistantes-maternelles/:id/fiche\`** — identité + champs pro (NIR, date/lieu naissance, date agrément, places, disponibilité)
- **\`POST/DELETE …/enfants/:enfantId\`** — rattacher / détacher un enfant
- Grille capacité **\`AdminAmChildrenCapacityGrid\`** (2×2)
- Rattachement enfants **différé jusqu'à Sauvegarder** (pas d'appel API immédiat)
### Back — placement AM ↔ enfant
- Table **\`enfants_assistantes_maternelles\`** (placement temporel, 1 garde active/enfant)
- Enum enfant : \`a_naitre\`, \`garde\`, \`sans_garde\`, \`scolarise\` — **plus \`actif\`**
- Rattachement → statut enfant \`garde\` ; détachement → \`sans_garde\`
- Migration : \`database/migrations/2026_enfants_assistantes_maternelles.sql\`
- Schéma canonique : \`database/BDD.sql\`
### Front — statuts & polish
- **\`enfant_status_utils.dart\`** — libellés/couleurs \`garde\`/\`sans_garde\`
- Mise à jour cartes enfants, modale détail, filtres dashboard
### Correctifs recette
- \`GET /parents\` 404 → ajout \`@Get()\` sur \`getAll()\`
- Sauvegarde AM 400 → alignement DTO \`UpdateAmFicheAdminDto\` sur payload front
- Crash NIR → fix \`toISOString\` + pas d'effacement NIR (colonne NOT NULL)
### Hors périmètre #131 (tickets voisins)
- **#137** onglet Enfants global · **#138** fiche enfant complète · **#115/#116** affiliation avancée
---
*Issue fermée après merge sur \`develop\` + \`master\` et déploiement production.*`;
let token = process.env.GITEA_TOKEN;
if (!token) {
try {
const tokenFile = path.join(repoRoot, '.gitea-token');
if (fs.existsSync(tokenFile)) token = fs.readFileSync(tokenFile, 'utf8').trim();
} catch (_) {}
}
if (!token) {
try {
const briefing = fs.readFileSync(
path.join(repoRoot, 'docs/27_BRIEFING-FRONTEND.md'),
'utf8',
);
const m = briefing.match(/Token:\s*(giteabu_[a-f0-9]+)/);
if (m) token = m[1].trim();
} catch (_) {}
}
if (!token) {
console.error('Token non trouvé : .gitea-token ou GITEA_TOKEN');
process.exit(1);
}
function request(method, apiPath, payloadObj) {
const payload = payloadObj ? JSON.stringify(payloadObj) : null;
return new Promise((resolve, reject) => {
const opts = {
hostname: 'git.ptits-pas.fr',
path: `/api/v1/repos/${REPO}${apiPath}`,
method,
headers: {
Authorization: 'token ' + token,
'Content-Type': 'application/json',
...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}),
},
};
const req = https.request(opts, (res) => {
let d = '';
res.on('data', (c) => (d += c));
res.on('end', () => {
if (res.statusCode !== 200 && res.statusCode !== 201) {
reject(new Error(`HTTP ${res.statusCode}: ${d}`));
return;
}
try {
resolve(d ? JSON.parse(d) : {});
} catch (_) {
resolve({});
}
});
});
req.on('error', reject);
if (payload) req.write(payload);
req.end();
});
}
(async () => {
try {
console.log(`POST commentaire issue #${ISSUE}...`);
await request('POST', `/issues/${ISSUE}/comments`, { body });
console.log('Commentaire publié.');
console.log(`PATCH fermeture issue #${ISSUE}...`);
await request('PATCH', `/issues/${ISSUE}`, { state: 'closed' });
console.log(`Issue #${ISSUE} fermée.`);
} catch (e) {
console.error(e.message || e);
process.exit(1);
}
})();
@@ -0,0 +1,111 @@
/**
* Crée l'issue Gitea — gestionnaire ne doit pas pouvoir se supprimer.
* Usage: node backend/scripts/create-gitea-issue-gestionnaire-self-delete.js
*/
const https = require('https');
const fs = require('fs');
const path = require('path');
const repoRoot = path.join(__dirname, '../..');
const MILESTONE_0_1_0 = 10;
let token = process.env.GITEA_TOKEN;
if (!token) {
try {
const tokenFile = path.join(repoRoot, '.gitea-token');
if (fs.existsSync(tokenFile)) token = fs.readFileSync(tokenFile, 'utf8').trim();
} catch (_) {}
}
if (!token) {
try {
const briefing = fs.readFileSync(
path.join(repoRoot, 'docs/27_BRIEFING-FRONTEND.md'),
'utf8',
);
const m = briefing.match(/Token:\s*(gitebu_[a-f0-9]+)/);
if (m) token = m[1].trim();
} catch (_) {}
}
if (!token) {
console.error('Token non trouvé : .gitea-token ou GITEA_TOKEN');
process.exit(1);
}
const body = `## Contexte
Quand un **gestionnaire** connecté ouvre sa propre fiche dans l'onglet **Gestionnaires** (Gestion des utilisateurs), la modale **Modifier un "Gestionnaire"** affiche le bouton **Supprimer**.
Comportement actuel : le gestionnaire voit et peut tenter de supprimer son propre compte.
## Comportement attendu
Comme pour le **super administrateur** (bouton Supprimer masqué sur la fiche super admin) :
- **Pas de bouton Supprimer** quand l'utilisateur édite **sa propre fiche**
- **Modification** des informations (prénom, nom, email, téléphone, relais, mot de passe) **toujours autorisée**
## Périmètre
- Frontend : \`AdminUserFormDialog\` (\`gestionnaires_create.dart\`)
- Comparer \`initialUser.id\` avec l'utilisateur connecté (\`AuthService.getCurrentUser\`)
- Masquer Supprimer si édition de soi-même ; conserver garde existante super admin
## Critères d'acceptation
- [ ] Gestionnaire connecté → ouvre sa fiche → **pas** de bouton Supprimer
- [ ] Gestionnaire connecté → peut **Modifier** ses informations
- [ ] Super admin / admin → peut toujours supprimer **un autre** gestionnaire (si droits API)
- [ ] Pas de régression sur fiche super administrateur (Supprimer toujours masqué)
## Fichiers clés
- \`frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart\`
- \`frontend/lib/widgets/admin/gestionnaire_management_widget.dart\`
## Milestone
**0.1.0** — correction UX / sécurité gestion utilisateurs.`;
const payloadClean = JSON.stringify({
title: '[Bug] Gestionnaire peut voir Supprimer sur sa propre fiche',
body,
milestone: MILESTONE_0_1_0,
});
const opts = {
hostname: 'git.ptits-pas.fr',
path: '/api/v1/repos/jmartin/petitspas/issues',
method: 'POST',
headers: {
Authorization: 'token ' + token,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payloadClean),
},
};
const req = https.request(opts, (res) => {
let d = '';
res.on('data', (c) => (d += c));
res.on('end', () => {
try {
const o = JSON.parse(d);
if (o.number) {
console.log('NUMBER:', o.number);
console.log('URL:', o.html_url);
console.log('MILESTONE:', o.milestone?.title ?? '(aucun)');
} else {
console.error('Réponse:', d);
process.exit(1);
}
} catch (e) {
console.error(d);
process.exit(1);
}
});
});
req.on('error', (e) => {
console.error(e);
process.exit(1);
});
req.write(payloadClean);
req.end();
+2
View File
@@ -18,6 +18,7 @@ import { AppConfigModule } from './modules/config/config.module';
import { DocumentsLegauxModule } from './modules/documents-legaux';
import { RelaisModule } from './routes/relais/relais.module';
import { DossiersModule } from './routes/dossiers/dossiers.module';
import { SuppressionsModule } from './routes/suppressions/suppressions.module';
@Module({
imports: [
@@ -57,6 +58,7 @@ import { DossiersModule } from './routes/dossiers/dossiers.module';
DocumentsLegauxModule,
RelaisModule,
DossiersModule,
SuppressionsModule,
],
controllers: [AppController],
providers: [
@@ -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();
});
});
@@ -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;
}
@@ -0,0 +1,47 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
} from 'typeorm';
import { AssistanteMaternelle } from './assistantes_maternelles.entity';
import { Children } from './children.entity';
import { Users } from './users.entity';
@Entity('enfants_assistantes_maternelles', { schema: 'public' })
export class AmChildren {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'id_am', type: 'uuid' })
amId: string;
@Column({ name: 'id_enfant', type: 'uuid' })
enfantId: string;
@Column({ name: 'date_debut', type: 'date' })
date_debut: Date;
@Column({ name: 'date_fin', type: 'date', nullable: true })
date_fin?: Date;
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
cree_le: Date;
@Column({ name: 'cree_par', type: 'uuid', nullable: true })
cree_par?: string;
@ManyToOne(() => AssistanteMaternelle, (am) => am.amChildren, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'id_am', referencedColumnName: 'user_id' })
am: AssistanteMaternelle;
@ManyToOne(() => Children, (c) => c.amLinks, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'id_enfant', referencedColumnName: 'id' })
child: Children;
@ManyToOne(() => Users, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'cree_par', referencedColumnName: 'id' })
createdBy?: Users;
}
@@ -1,5 +1,6 @@
import { Entity, PrimaryColumn, Column, OneToOne, JoinColumn } from 'typeorm';
import { Entity, PrimaryColumn, Column, OneToOne, OneToMany, JoinColumn } from 'typeorm';
import { Users } from './users.entity';
import { AmChildren } from './am_children.entity';
@Entity('assistantes_maternelles')
export class AssistanteMaternelle {
@@ -51,4 +52,7 @@ export class AssistanteMaternelle {
/** Numéro de dossier (format AAAA-NNNNNN), même valeur que sur utilisateurs (ticket #103) */
@Column({ name: 'numero_dossier', length: 20, nullable: true })
numero_dossier?: string;
@OneToMany(() => AmChildren, (ac) => ac.am)
amChildren: AmChildren[];
}
+6 -1
View File
@@ -4,12 +4,14 @@ import {
} from 'typeorm';
import { Parents } from './parents.entity';
import { ParentsChildren } from './parents_children.entity';
import { AmChildren } from './am_children.entity';
import { Dossier } from './dossiers.entity';
export enum StatutEnfantType {
A_NAITRE = 'a_naitre',
ACTIF = 'actif',
SCOLARISE = 'scolarise',
GARDE = 'garde',
SANS_GARDE = 'sans_garde',
}
export enum GenreType {
@@ -68,6 +70,9 @@ export class Children {
@OneToMany(() => ParentsChildren, pc => pc.child)
parentLinks: ParentsChildren[];
@OneToMany(() => AmChildren, (ac) => ac.child)
amLinks: AmChildren[];
// Relation avec Dossier
@OneToMany(() => Dossier, d => d.child)
dossiers: Dossier[];
@@ -1,20 +1,69 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AssistantesMaternellesController } from './assistantes_maternelles.controller';
import { AssistantesMaternellesService } from './assistantes_maternelles.service';
import { AuthService } from '../auth/auth.service';
import { AuthGuard } from 'src/common/guards/auth.guard';
import { RolesGuard } from 'src/common/guards/roles.guard';
describe('AssistantesMaternellesController', () => {
let controller: AssistantesMaternellesController;
const authServiceMock = {
createAmDossierStaff: jest.fn(),
};
const amServiceMock = {};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AssistantesMaternellesController],
providers: [AssistantesMaternellesService],
}).compile();
providers: [
{ provide: AssistantesMaternellesService, useValue: amServiceMock },
{ provide: AuthService, useValue: authServiceMock },
],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(RolesGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<AssistantesMaternellesController>(AssistantesMaternellesController);
jest.clearAllMocks();
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
it('createDossier delegates to authService.createAmDossierStaff with CGU accepted', async () => {
authServiceMock.createAmDossierStaff.mockResolvedValue({
message: 'ok',
user_id: 'u1',
statut: 'actif',
numero_dossier: '2026-000001',
});
const body = {
email: 'am.staff@test.fr',
prenom: 'Marie',
nom: 'TEST',
telephone: '0689567890',
consentement_photo: false,
lieu_naissance_ville: 'Paris',
lieu_naissance_pays: 'France',
nir: '285017512345678',
numero_agrement: 'AGR-TEST-001',
capacite_accueil: 3,
places_disponibles: 2,
};
const res = await controller.createDossier(body as any);
expect(authServiceMock.createAmDossierStaff).toHaveBeenCalledWith(
expect.objectContaining({
email: body.email,
acceptation_cgu: true,
acceptation_privacy: true,
}),
);
expect(res.numero_dossier).toBe('2026-000001');
});
});
@@ -7,23 +7,60 @@ import {
Param,
Delete,
UseGuards,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { AssistantesMaternellesService } from './assistantes_maternelles.service';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { Roles } from 'src/common/decorators/roles.decorator';
import { RoleType } from 'src/entities/users.entity';
import { RoleType, Users } from 'src/entities/users.entity';
import { CreateAssistanteDto } from '../user/dto/create_assistante.dto';
import { UpdateAssistanteDto } from '../user/dto/update_assistante.dto';
import { UpdateAmFicheAdminDto } from './dto/update-am-fiche-admin.dto';
import { StaffCreateAmDossierDto } from './dto/staff-create-am-dossier.dto';
import { StaffCreateAmDossierResponseDto } from './dto/staff-create-am-dossier-response.dto';
import { RolesGuard } from 'src/common/guards/roles.guard';
import { AuthGuard } from 'src/common/guards/auth.guard';
import { User } from 'src/common/decorators/user.decorator';
import { mapAmForApi, mapAmsForApi } from './assistantes_maternelles.mapper';
import { AuthService } from '../auth/auth.service';
import { RegisterAMCompletDto } from '../auth/dto/register-am-complet.dto';
@ApiTags("Assistantes Maternelles")
@ApiBearerAuth('access-token')
@UseGuards(AuthGuard, RolesGuard)
@Controller('assistantes-maternelles')
export class AssistantesMaternellesController {
constructor(private readonly assistantesMaternellesService: AssistantesMaternellesService) { }
constructor(
private readonly assistantesMaternellesService: AssistantesMaternellesService,
private readonly authService: AuthService,
) { }
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@Post('dossier')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({
summary: 'Créer un dossier AM complet (staff) — ticket #156',
description:
'Crée user + fiche AM avec statut actif, n° dossier, et envoie le-mail de création de mot de passe. ' +
'Ne pas utiliser POST /auth/register/am depuis le dashboard.',
})
@ApiBody({ type: StaffCreateAmDossierDto })
@ApiResponse({ status: 201, type: StaffCreateAmDossierResponseDto })
@ApiResponse({ status: 400, description: 'Validation métier / NIR' })
@ApiResponse({ status: 403, description: 'Rôle non autorisé' })
@ApiResponse({ status: 409, description: 'Email / NIR / agrément déjà pris' })
async createDossier(
@Body() dto: StaffCreateAmDossierDto,
): Promise<StaffCreateAmDossierResponseDto> {
const registerDto = {
...dto,
acceptation_cgu: true,
acceptation_privacy: true,
} as RegisterAMCompletDto;
return this.authService.createAmDossierStaff(registerDto);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
@ApiOperation({ summary: 'Créer nounou' })
@@ -31,28 +68,74 @@ export class AssistantesMaternellesController {
@ApiResponse({ status: 403, description: 'Accès refusé : Réservé aux super_admins et gestionnaires' })
@ApiBody({ type: CreateAssistanteDto })
@Post()
create(@Body() dto: CreateAssistanteDto): Promise<AssistanteMaternelle> {
return this.assistantesMaternellesService.create(dto);
async create(@Body() dto: CreateAssistanteDto): Promise<AssistanteMaternelle> {
const am = await this.assistantesMaternellesService.create(dto);
return mapAmForApi(am);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@Get()
@ApiOperation({ summary: 'Récupérer la liste des nounous' })
@ApiOperation({ summary: 'Récupérer la liste des nounous (inclut amChildren actifs) — ticket #131' })
@ApiResponse({ status: 200, description: 'Liste des nounous' })
@ApiResponse({ status: 403, description: 'Accès refusé : Réservé aux super_admins et gestionnaires' })
getAll(): Promise<AssistanteMaternelle[]> {
return this.assistantesMaternellesService.findAll();
async getAll(): Promise<AssistanteMaternelle[]> {
const ams = await this.assistantesMaternellesService.findAll();
return mapAmsForApi(ams);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@Get(':id')
@ApiParam({ name: 'id', description: "UUID de la nounou" })
@ApiOperation({ summary: 'Récupérer une nounou par id' })
@ApiOperation({ summary: 'Récupérer une nounou par id (inclut amChildren) — ticket #131' })
@ApiResponse({ status: 200, description: 'Détails de la nounou' })
@ApiResponse({ status: 404, description: 'Nounou non trouvée' })
@ApiResponse({ status: 403, description: 'Accès refusé : Réservé aux super_admins et gestionnaires' })
getOne(@Param('id') user_id: string): Promise<AssistanteMaternelle> {
return this.assistantesMaternellesService.findOne(user_id);
@ApiResponse({ status: 403, description: 'Accès refusé' })
async getOne(@Param('id') user_id: string): Promise<AssistanteMaternelle> {
const am = await this.assistantesMaternellesService.findOne(user_id);
return mapAmForApi(am);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@Patch(':id/fiche')
@ApiBody({ type: UpdateAmFicheAdminDto })
@ApiOperation({ summary: 'Mettre à jour la fiche AM (identité + pro) — ticket #131' })
@ApiParam({ name: 'id', description: "UUID utilisateur de l'AM" })
@ApiResponse({ status: 200, description: 'Fiche AM mise à jour' })
async updateFicheAdmin(
@Param('id') id: string,
@Body() dto: UpdateAmFicheAdminDto,
): Promise<AssistanteMaternelle> {
const am = await this.assistantesMaternellesService.updateFicheAdmin(id, dto);
return mapAmForApi(am);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@Post(':id/enfants/:enfantId')
@ApiOperation({ summary: 'Rattacher un enfant à une AM (statut enfant → garde) — ticket #131' })
@ApiParam({ name: 'id', description: "UUID utilisateur de l'AM" })
@ApiParam({ name: 'enfantId', description: "UUID de l'enfant" })
@ApiResponse({ status: 200, description: 'AM avec enfants mis à jour' })
async attachEnfant(
@Param('id') id: string,
@Param('enfantId') enfantId: string,
@User() currentUser: Users,
): Promise<AssistanteMaternelle> {
const am = await this.assistantesMaternellesService.attachEnfant(id, enfantId, currentUser);
return mapAmForApi(am);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@Delete(':id/enfants/:enfantId')
@ApiOperation({ summary: "Clôturer le placement d'un enfant chez une AM — ticket #131" })
@ApiParam({ name: 'id', description: "UUID utilisateur de l'AM" })
@ApiParam({ name: 'enfantId', description: "UUID de l'enfant" })
@ApiResponse({ status: 200, description: 'AM avec enfants mis à jour' })
async detachEnfant(
@Param('id') id: string,
@Param('enfantId') enfantId: string,
): Promise<AssistanteMaternelle> {
const am = await this.assistantesMaternellesService.detachEnfant(id, enfantId);
return mapAmForApi(am);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
@@ -63,14 +146,15 @@ export class AssistantesMaternellesController {
@ApiResponse({ status: 404, description: 'Nounou non trouvée' })
@ApiParam({ name: 'id', description: "UUID de la nounou" })
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateAssistanteDto): Promise<AssistanteMaternelle> {
return this.assistantesMaternellesService.update(id, dto);
async update(@Param('id') id: string, @Body() dto: UpdateAssistanteDto): Promise<AssistanteMaternelle> {
const am = await this.assistantesMaternellesService.update(id, dto);
return mapAmForApi(am);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Supprimer une nounou' })
@ApiResponse({ status: 200, description: 'Nounou supprimée avec succès' })
@ApiResponse({ status: 403, description: 'Accès refusé : Réservé aux super_admins, gestionnaires et administrateurs' })
@ApiResponse({ status: 403, description: 'Accès refusé' })
@ApiResponse({ status: 404, description: 'Nounou non trouvée' })
@ApiParam({ name: 'id', description: "UUID de la nounou" })
@Delete(':id')
@@ -0,0 +1,28 @@
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { AmChildren } from 'src/entities/am_children.entity';
import { sanitizeUserForApi } from '../../common/utils/sanitize-user-for-api';
/**
* Sérialisation API fiche AM — ticket #131.
* Expose `amChildren` actifs (date_fin null) avec enfant imbriqué, sans secrets user.
*/
export function mapAmForApi(am: AssistanteMaternelle): AssistanteMaternelle {
const activeChildren = (am.amChildren ?? []).filter((link) => !link.date_fin);
return {
...am,
user: sanitizeUserForApi(am.user)!,
amChildren: activeChildren.map((link) => ({
...link,
child: link.child,
})),
};
}
export function mapAmsForApi(ams: AssistanteMaternelle[]): AssistanteMaternelle[] {
return ams.map(mapAmForApi);
}
export function filterActiveAmChildren(links: AmChildren[] | undefined): AmChildren[] {
return (links ?? []).filter((link) => !link.date_fin);
}
@@ -2,12 +2,14 @@ import { Module } from '@nestjs/common';
import { AssistantesMaternellesService } from './assistantes_maternelles.service';
import { AssistantesMaternellesController } from './assistantes_maternelles.controller';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { AmChildren } from 'src/entities/am_children.entity';
import { Children } from 'src/entities/children.entity';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Users } from 'src/entities/users.entity';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [TypeOrmModule.forFeature([AssistanteMaternelle, Users]),
imports: [TypeOrmModule.forFeature([AssistanteMaternelle, AmChildren, Children, Users]),
AuthModule
],
controllers: [AssistantesMaternellesController],
@@ -5,11 +5,17 @@ import {
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { IsNull, Repository } from 'typeorm';
import { RoleType, Users } from 'src/entities/users.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { AmChildren } from 'src/entities/am_children.entity';
import { Children, StatutEnfantType } from 'src/entities/children.entity';
import { CreateAssistanteDto } from '../user/dto/create_assistante.dto';
import { UpdateAssistanteDto } from '../user/dto/update_assistante.dto';
import { UpdateAmFicheAdminDto } from './dto/update-am-fiche-admin.dto';
import { validateNir } from 'src/common/utils/nir.util';
const AM_CHILDREN_RELATIONS = ['user', 'amChildren', 'amChildren.child'] as const;
@Injectable()
export class AssistantesMaternellesService {
@@ -17,10 +23,13 @@ export class AssistantesMaternellesService {
@InjectRepository(AssistanteMaternelle)
private readonly assistantesMaternelleRepository: Repository<AssistanteMaternelle>,
@InjectRepository(Users)
private readonly usersRepository: Repository<Users>
private readonly usersRepository: Repository<Users>,
@InjectRepository(AmChildren)
private readonly amChildrenRepository: Repository<AmChildren>,
@InjectRepository(Children)
private readonly childrenRepository: Repository<Children>,
) {}
// Création dune assistante maternelle
async create(dto: CreateAssistanteDto): Promise<AssistanteMaternelle> {
const user = await this.usersRepository.findOneBy({ id: dto.user_id });
if (!user) throw new NotFoundException('Utilisateur introuvable');
@@ -49,30 +58,208 @@ export class AssistantesMaternellesService {
return this.assistantesMaternelleRepository.save(entity);
}
// Liste des assistantes maternelles
async findAll(): Promise<AssistanteMaternelle[]> {
return this.assistantesMaternelleRepository.find({
relations: ['user'],
relations: [...AM_CHILDREN_RELATIONS],
});
}
// Récupérer une assistante maternelle par user_id
async findOne(user_id: string): Promise<AssistanteMaternelle> {
const assistante = await this.assistantesMaternelleRepository.findOne({
where: { user_id },
relations: ['user'],
relations: [...AM_CHILDREN_RELATIONS],
});
if (!assistante) throw new NotFoundException('Assistante maternelle introuvable');
return assistante;
}
// Mise à jour
async update(id: string, dto: UpdateAssistanteDto): Promise<AssistanteMaternelle> {
await this.assistantesMaternelleRepository.update(id, dto);
return this.findOne(id);
}
// Suppression dune assistante maternelle
/**
* Mise à jour fiche AM (identité + champs pro) par admin/gestionnaire. Ticket #131.
*/
async updateFicheAdmin(amUserId: string, dto: UpdateAmFicheAdminDto): Promise<AssistanteMaternelle> {
const am = await this.findOne(amUserId);
const user = am.user;
if (dto.email && dto.email !== user.email) {
const existing = await this.usersRepository.findOne({ where: { email: dto.email } });
if (existing && existing.id !== user.id) {
throw new ConflictException('Cet email est déjà utilisé');
}
user.email = dto.email;
}
if (dto.nom !== undefined) user.nom = dto.nom;
if (dto.prenom !== undefined) user.prenom = dto.prenom;
if (dto.telephone !== undefined) user.telephone = dto.telephone;
if (dto.adresse !== undefined) user.adresse = dto.adresse;
if (dto.ville !== undefined) user.ville = dto.ville;
if (dto.code_postal !== undefined) user.code_postal = dto.code_postal;
if (dto.statut !== undefined) user.statut = dto.statut;
if (dto.date_naissance !== undefined) {
user.date_naissance = dto.date_naissance ? new Date(dto.date_naissance) : undefined;
}
if (dto.lieu_naissance_ville !== undefined) {
user.lieu_naissance_ville = dto.lieu_naissance_ville || undefined;
}
if (dto.lieu_naissance_pays !== undefined) {
user.lieu_naissance_pays = dto.lieu_naissance_pays || undefined;
}
await this.usersRepository.save(user);
const amPatch: Partial<AssistanteMaternelle> = {};
if (dto.approval_number !== undefined) amPatch.approval_number = dto.approval_number;
if (dto.residence_city !== undefined) amPatch.residence_city = dto.residence_city;
if (dto.max_children !== undefined) amPatch.max_children = dto.max_children;
if (dto.places_available !== undefined) amPatch.places_available = dto.places_available;
if (dto.biography !== undefined) amPatch.biography = dto.biography;
if (dto.available !== undefined) amPatch.available = dto.available;
if (dto.agreement_date !== undefined) {
amPatch.agreement_date = dto.agreement_date ? new Date(dto.agreement_date) : undefined;
}
if (dto.nir !== undefined) {
const nirNormalized = dto.nir.replace(/\s/g, '').toUpperCase();
if (nirNormalized) {
const dateNaissanceForNir =
dto.date_naissance ??
(user.date_naissance instanceof Date
? user.date_naissance.toISOString().slice(0, 10)
: user.date_naissance
? String(user.date_naissance).slice(0, 10)
: undefined);
const nirValidation = validateNir(nirNormalized, {
dateNaissance: dateNaissanceForNir,
});
if (!nirValidation.valid) {
throw new BadRequestException(nirValidation.error || 'NIR invalide');
}
const nirDejaUtilise = await this.assistantesMaternelleRepository.findOne({
where: { nir: nirNormalized },
});
if (nirDejaUtilise && nirDejaUtilise.user_id !== amUserId) {
throw new ConflictException(
'Un compte assistante maternelle avec ce numéro NIR existe déjà.',
);
}
amPatch.nir = nirNormalized;
}
// NIR vide : ne pas effacer (colonne NOT NULL en BDD) — le front renvoie toujours la clé.
}
if (Object.keys(amPatch).length > 0) {
await this.assistantesMaternelleRepository.update(amUserId, amPatch);
}
return this.findOne(amUserId);
}
/**
* Rattacher un enfant à une AM (placement actif). Ticket #131.
* Passe le statut enfant à `garde` (sauf a_naitre / scolarise).
*/
async attachEnfant(amUserId: string, enfantId: string, createdBy?: Users): Promise<AssistanteMaternelle> {
const am = await this.findOne(amUserId);
const existingForAm = await this.amChildrenRepository.findOne({
where: { amId: amUserId, enfantId, date_fin: IsNull() },
});
if (existingForAm) {
throw new ConflictException('Cet enfant est déjà rattaché à cette assistante maternelle');
}
const child = await this.childrenRepository.findOne({ where: { id: enfantId } });
if (!child) {
throw new NotFoundException('Enfant introuvable');
}
const activeForChild = await this.amChildrenRepository.findOne({
where: { enfantId, date_fin: IsNull() },
});
if (activeForChild && activeForChild.amId !== amUserId) {
throw new ConflictException(
'Cet enfant est déjà en garde chez une autre assistante maternelle',
);
}
const activeCount = await this.amChildrenRepository.count({
where: { amId: amUserId, date_fin: IsNull() },
});
if (am.max_children != null && activeCount >= am.max_children) {
throw new BadRequestException(
`Capacité maximale atteinte (${am.max_children} enfant(s))`,
);
}
await this.amChildrenRepository.save(
this.amChildrenRepository.create({
amId: amUserId,
enfantId,
date_debut: new Date(),
cree_par: createdBy?.id,
}),
);
await this.applyGardeStatusOnAttach(child);
return this.findOne(amUserId);
}
/**
* Clôturer le placement AM ↔ enfant. Ticket #131.
* Repasse l'enfant en `sans_garde` s'il n'a plus de placement actif.
*/
async detachEnfant(amUserId: string, enfantId: string): Promise<AssistanteMaternelle> {
await this.findOne(amUserId);
const link = await this.amChildrenRepository.findOne({
where: { amId: amUserId, enfantId, date_fin: IsNull() },
relations: ['child'],
});
if (!link) {
throw new NotFoundException('Lien assistante maternelle-enfant introuvable');
}
link.date_fin = new Date();
await this.amChildrenRepository.save(link);
const remaining = await this.amChildrenRepository.count({
where: { enfantId, date_fin: IsNull() },
});
if (remaining === 0 && link.child) {
await this.applySansGardeStatusOnDetach(link.child);
}
return this.findOne(amUserId);
}
private async applyGardeStatusOnAttach(child: Children): Promise<void> {
if (
child.status === StatutEnfantType.A_NAITRE ||
child.status === StatutEnfantType.SCOLARISE
) {
return;
}
child.status = StatutEnfantType.GARDE;
await this.childrenRepository.save(child);
}
private async applySansGardeStatusOnDetach(child: Children): Promise<void> {
if (
child.status === StatutEnfantType.A_NAITRE ||
child.status === StatutEnfantType.SCOLARISE
) {
return;
}
child.status = StatutEnfantType.SANS_GARDE;
await this.childrenRepository.save(child);
}
async remove(id: string): Promise<{ message: string }> {
await this.assistantesMaternelleRepository.delete(id);
return { message: 'Assistante maternelle supprimée' };
@@ -0,0 +1,23 @@
import { ApiProperty } from '@nestjs/swagger';
import { StatutUtilisateurType } from 'src/entities/users.entity';
/** Réponse 201 POST /assistantes-maternelles/dossier (#156). */
export class StaffCreateAmDossierResponseDto {
@ApiProperty()
message: string;
@ApiProperty({ format: 'uuid' })
user_id: string;
@ApiProperty({
enum: StatutUtilisateurType,
example: StatutUtilisateurType.ACTIF,
})
statut: StatutUtilisateurType;
@ApiProperty({
example: '2026-000042',
description: 'Numéro de dossier attribué',
})
numero_dossier: string;
}
@@ -0,0 +1,29 @@
import { ApiPropertyOptional, OmitType } from '@nestjs/swagger';
import { IsBoolean, IsOptional } from 'class-validator';
import { RegisterAMCompletDto } from 'src/routes/auth/dto/register-am-complet.dto';
/**
* Création dossier AM par staff (#156).
* Mêmes champs que l'inscription publique, sans CGU/privacy obligatoires
* (acceptées côté serveur pour le compte du gestionnaire).
*/
export class StaffCreateAmDossierDto extends OmitType(RegisterAMCompletDto, [
'acceptation_cgu',
'acceptation_privacy',
] as const) {
@ApiPropertyOptional({
description: 'Ignoré côté staff (CGU acceptées serveur). Conservé pour compat éventuelle.',
default: true,
})
@IsOptional()
@IsBoolean()
acceptation_cgu?: boolean;
@ApiPropertyOptional({
description: 'Ignoré côté staff (privacy acceptée serveur).',
default: true,
})
@IsOptional()
@IsBoolean()
acceptation_privacy?: boolean;
}
@@ -0,0 +1,126 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import {
IsBoolean,
IsDateString,
IsEmail,
IsEnum,
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
} from 'class-validator';
import { StatutUtilisateurType } from 'src/entities/users.entity';
/** Mise à jour fiche AM par admin/gestionnaire (doc 28 §6.1, ticket #131). */
export class UpdateAmFicheAdminDto {
@ApiPropertyOptional({ example: 'MARTIN' })
@IsOptional()
@IsString()
@MaxLength(100)
nom?: string;
@ApiPropertyOptional({ example: 'Claire' })
@IsOptional()
@IsString()
@MaxLength(100)
prenom?: string;
@ApiPropertyOptional({ example: 'claire@example.com' })
@IsOptional()
@IsEmail()
email?: string;
@ApiPropertyOptional({ example: '0612345678' })
@IsOptional()
@IsString()
@MaxLength(20)
telephone?: string;
@ApiPropertyOptional({ example: '5 place Bellecour' })
@IsOptional()
@IsString()
adresse?: string;
@ApiPropertyOptional({ example: 'Lyon' })
@IsOptional()
@IsString()
@MaxLength(150)
ville?: string;
@ApiPropertyOptional({ example: '69002' })
@IsOptional()
@IsString()
@MaxLength(10)
code_postal?: string;
@ApiPropertyOptional({ enum: StatutUtilisateurType })
@IsOptional()
@IsEnum(StatutUtilisateurType)
statut?: StatutUtilisateurType;
@ApiPropertyOptional({ example: '123456789012345' })
@IsOptional()
@IsString()
@MaxLength(15)
nir?: string;
@ApiPropertyOptional({ example: '1985-03-12' })
@IsOptional()
@IsDateString()
date_naissance?: string;
@ApiPropertyOptional({ example: 'Lyon' })
@IsOptional()
@IsString()
@MaxLength(100)
lieu_naissance_ville?: string;
@ApiPropertyOptional({ example: 'France' })
@IsOptional()
@IsString()
@MaxLength(100)
lieu_naissance_pays?: string;
@ApiPropertyOptional({ example: 'AGR-2024-12345' })
@IsOptional()
@IsString()
@MaxLength(50)
approval_number?: string;
@ApiPropertyOptional({ example: '2020-01-15' })
@IsOptional()
@IsDateString()
agreement_date?: string;
@ApiPropertyOptional({ example: 'Lyon' })
@IsOptional()
@IsString()
@MaxLength(100)
residence_city?: string;
@ApiPropertyOptional({ example: 4 })
@IsOptional()
@IsInt()
@Min(1)
@Max(10)
max_children?: number;
@ApiPropertyOptional({ example: 2 })
@IsOptional()
@IsInt()
@Min(0)
@Max(10)
places_available?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
biography?: string;
@ApiPropertyOptional({ example: true })
@IsOptional()
@IsBoolean()
available?: boolean;
}
+1 -1
View File
@@ -29,7 +29,7 @@ import { ParentsChildren } from 'src/entities/parents_children.entity';
ParentsChildren,
]),
forwardRef(() => UserModule),
ParentsModule,
forwardRef(() => ParentsModule),
DossiersModule,
AppConfigModule,
MailModule,
+1 -1
View File
@@ -176,7 +176,7 @@ describe('AuthService (#118 create-password API)', () => {
parentsServiceMock.getDossierFamilleByNumero.mockResolvedValue({
numero_dossier: '2026-000021',
parents: [{ user_id: 'p1', email: 'claire@test.fr', statut: StatutUtilisateurType.REFUSE }],
enfants: [{ id: 'e1', first_name: 'Emma', status: 'actif' }],
enfants: [{ id: 'e1', first_name: 'Emma', status: 'sans_garde' }],
texte_motivation: 'Motivation test',
});
+411 -89
View File
@@ -36,6 +36,7 @@ import { MailService } from 'src/modules/mail/mail.service';
import { ParentsService } from '../parents/parents.service';
import { DossiersService } from '../dossiers/dossiers.service';
import { DossierAmCompletDto } from '../dossiers/dto/dossier-am-complet.dto';
import { StaffAddCoParentDto } from '../parents/dto/staff-add-co-parent.dto';
@Injectable()
export class AuthService {
@@ -416,11 +417,20 @@ export class AuthService {
}
/**
* Inscription Parent COMPLÈTE - Workflow CDC 6 étapes en 1 transaction
* Gère : Parent 1 + Parent 2 (opt) + Enfants + Présentation + CGU
* Cœur partagé création dossier parent (#129).
* - public : statut en_attente + mails pending
* - staff : statut actif + mails création MDP (pas de mail « dossier en attente »)
*/
async inscrireParentComplet(dto: RegisterParentCompletDto) {
if (!dto.acceptation_cgu || !dto.acceptation_privacy) {
async createParentDossier(
dto: RegisterParentCompletDto,
options: {
statut: StatutUtilisateurType;
sendPendingEmail: boolean;
sendPasswordSetupEmail: boolean;
requireCgu: boolean;
},
) {
if (options.requireCgu && (!dto.acceptation_cgu || !dto.acceptation_privacy)) {
throw new BadRequestException('L\'acceptation des CGU et de la politique de confidentialité est obligatoire');
}
@@ -471,7 +481,7 @@ export class AuthService {
prenom: dto.prenom,
nom: dto.nom,
role: RoleType.PARENT,
statut: StatutUtilisateurType.EN_ATTENTE,
statut: options.statut,
telephone: dto.telephone,
adresse: dto.adresse,
code_postal: dto.code_postal,
@@ -496,7 +506,7 @@ export class AuthService {
prenom: dto.co_parent_prenom,
nom: dto.co_parent_nom,
role: RoleType.PARENT,
statut: StatutUtilisateurType.EN_ATTENTE,
statut: options.statut,
telephone: dto.co_parent_telephone,
adresse: dto.co_parent_meme_adresse ? dto.adresse : dto.co_parent_adresse,
code_postal: dto.co_parent_meme_adresse ? dto.code_postal : dto.co_parent_code_postal,
@@ -551,8 +561,9 @@ export class AuthService {
? new Date(enfantDto.date_previsionnelle_naissance)
: undefined;
enfant.photo_url = urlPhoto || undefined;
enfant.status = enfantDto.date_naissance ? StatutEnfantType.ACTIF : StatutEnfantType.A_NAITRE;
enfant.consent_photo = false;
enfant.status = enfantDto.date_naissance ? StatutEnfantType.SANS_GARDE : StatutEnfantType.A_NAITRE;
enfant.consent_photo = !!enfantDto.consent_photo;
enfant.consent_photo_at = enfant.consent_photo ? new Date() : null!;
enfant.is_multiple = enfantDto.grossesse_multiple || false;
const enfantEnregistre = await manager.save(Children, enfant);
@@ -611,44 +622,287 @@ export class AuthService {
const numeroDossier = resultat.parent1.numero_dossier ?? '';
try {
await this.mailService.sendRegistrationPendingEmail(
resultat.parent1.email,
resultat.parent1.prenom ?? '',
resultat.parent1.nom ?? '',
numeroDossier,
);
if (resultat.parent2) {
if (options.sendPendingEmail) {
try {
await this.mailService.sendRegistrationPendingEmail(
resultat.parent2.email,
resultat.parent2.prenom ?? '',
resultat.parent2.nom ?? '',
resultat.parent1.email,
resultat.parent1.prenom ?? '',
resultat.parent1.nom ?? '',
numeroDossier,
);
if (resultat.parent2) {
await this.mailService.sendRegistrationPendingEmail(
resultat.parent2.email,
resultat.parent2.prenom ?? '',
resultat.parent2.nom ?? '',
numeroDossier,
);
}
} catch (err) {
this.logger.error(
"[createParentDossier] Échec envoi email d'accusé de réception (inscription conservée)",
err instanceof Error ? err.stack : String(err),
);
}
} catch (err) {
this.logger.error(
"[inscrireParentComplet] Échec envoi email d'accusé de réception (inscription conservée)",
err instanceof Error ? err.stack : String(err),
);
}
if (options.sendPasswordSetupEmail) {
try {
await this.mailService.sendValidatedAccountPasswordSetupEmail(
{
email: resultat.parent1.email,
prenom: resultat.parent1.prenom ?? '',
nom: resultat.parent1.nom ?? '',
token: resultat.tokenCreationMdp,
numeroDossier,
},
'parent',
);
if (resultat.parent2 && resultat.tokenCoParent) {
await this.mailService.sendValidatedAccountPasswordSetupEmail(
{
email: resultat.parent2.email,
prenom: resultat.parent2.prenom ?? '',
nom: resultat.parent2.nom ?? '',
token: resultat.tokenCoParent,
numeroDossier,
},
'parent',
);
}
} catch (err) {
this.logger.error(
'[createParentDossier] Échec envoi email création MDP (dossier conservé)',
err instanceof Error ? err.stack : String(err),
);
}
}
const message = options.sendPasswordSetupEmail
? 'Dossier famille créé et validé. Un e-mail de création de mot de passe a été envoyé.'
: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.';
return {
message: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.',
message,
parent_id: resultat.parent1.id,
co_parent_id: resultat.parent2?.id,
parent_user_id: resultat.parent1.id,
co_parent_id: resultat.parent2?.id ?? null,
co_parent_user_id: resultat.parent2?.id ?? null,
enfants_ids: resultat.enfants.map(e => e.id),
statut: StatutUtilisateurType.EN_ATTENTE,
enfant_ids: resultat.enfants.map(e => e.id),
statut: options.statut,
numero_dossier: numeroDossier,
};
}
/**
* Inscription Assistante Maternelle COMPLÈTE - Un seul endpoint (identité + pro + photo + CGU)
* Crée User (role AM) + entrée assistantes_maternelles, token création MDP
* Inscription Parent publique — CDC (statut en_attente + mail pending).
*/
async inscrireAMComplet(dto: RegisterAMCompletDto) {
if (!dto.acceptation_cgu || !dto.acceptation_privacy) {
async inscrireParentComplet(dto: RegisterParentCompletDto) {
return this.createParentDossier(dto, {
statut: StatutUtilisateurType.EN_ATTENTE,
sendPendingEmail: true,
sendPasswordSetupEmail: false,
requireCgu: true,
});
}
/**
* Création dossier parent par le staff (#129) — actif + mail création MDP.
*/
async createParentDossierStaff(dto: RegisterParentCompletDto) {
return this.createParentDossier(dto, {
statut: StatutUtilisateurType.ACTIF,
sendPendingEmail: false,
sendPasswordSetupEmail: true,
requireCgu: false,
});
}
/**
* Ajoute un co-parent à un foyer existant (mono-parent) — ticket #135.
* Compte actif + mail création MDP + liens Parents bidirectionnels + enfants du foyer.
*/
async addCoParentStaff(pivotUserId: string, dto: StaffAddCoParentDto) {
const pivotParent = await this.parentsRepo.findOne({
where: { user_id: pivotUserId },
relations: ['user', 'co_parent', 'parentChildren'],
});
if (!pivotParent?.user) {
throw new NotFoundException('Parent introuvable');
}
if (pivotParent.co_parent) {
throw new BadRequestException('Ce foyer a déjà un co-parent.');
}
const numeroDossier = pivotParent.numero_dossier?.trim() || pivotParent.user.numero_dossier?.trim();
if (!numeroDossier) {
throw new BadRequestException("Ce parent n'a pas de numéro de dossier.");
}
const sameDossierCount = await this.parentsRepo.count({
where: { numero_dossier: numeroDossier },
});
if (sameDossierCount >= 2) {
throw new BadRequestException('Ce dossier a déjà deux responsables.');
}
const email = dto.email.trim().toLowerCase();
if (pivotParent.user.email.trim().toLowerCase() === email) {
throw new BadRequestException(
"L'email du co-parent doit être différent de celui du parent principal.",
);
}
const emailExiste = await this.usersService.findByEmailOrNull(dto.email);
if (emailExiste) {
throw new ConflictException("L'email du co-parent est déjà utilisé");
}
const memeAdresse = dto.meme_adresse ?? true;
if (!memeAdresse) {
if (!dto.adresse?.trim() || !dto.ville?.trim() || !dto.code_postal?.trim()) {
throw new BadRequestException(
"Adresse, code postal et ville du co-parent sont requis si meme_adresse est faux.",
);
}
}
const joursExpirationToken = await this.appConfigService.get<number>(
'password_reset_token_expiry_days',
7,
);
const tokenCreationMdp = crypto.randomUUID();
const dateExpiration = new Date();
dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken);
let coParent: Users;
try {
coParent = await this.usersRepo.manager.transaction(async (manager) => {
const pivotUser = await manager.findOne(Users, {
where: { id: pivotUserId },
});
if (!pivotUser) {
throw new NotFoundException('Parent introuvable');
}
const pivotEntite = await manager.findOne(Parents, {
where: { user_id: pivotUserId },
relations: ['parentChildren'],
});
if (!pivotEntite) {
throw new NotFoundException('Parent introuvable');
}
const coUser = manager.create(Users, {
email: dto.email.trim(),
prenom: dto.prenom,
nom: dto.nom,
role: RoleType.PARENT,
statut: StatutUtilisateurType.ACTIF,
telephone: dto.telephone,
adresse: memeAdresse ? pivotUser.adresse : dto.adresse,
code_postal: memeAdresse ? pivotUser.code_postal : dto.code_postal,
ville: memeAdresse ? pivotUser.ville : dto.ville,
token_creation_mdp: tokenCreationMdp,
token_creation_mdp_expire_le: dateExpiration,
numero_dossier: numeroDossier,
});
const coUserSaved = await manager.save(Users, coUser);
pivotEntite.co_parent = coUserSaved;
pivotEntite.numero_dossier = numeroDossier;
await manager.save(Parents, pivotEntite);
const coEntite = manager.create(Parents, {
user_id: coUserSaved.id,
numero_dossier: numeroDossier,
});
coEntite.user = coUserSaved;
coEntite.co_parent = pivotUser;
await manager.save(Parents, coEntite);
const enfantIds = (pivotEntite.parentChildren ?? [])
.map((pc) => pc.enfantId)
.filter(Boolean);
for (const enfantId of enfantIds) {
const existing = await manager.findOne(ParentsChildren, {
where: { parentId: coUserSaved.id, enfantId },
});
if (existing) continue;
await manager.save(
ParentsChildren,
manager.create(ParentsChildren, {
parentId: coUserSaved.id,
enfantId,
}),
);
}
return coUserSaved;
});
} catch (err) {
if (this.isPostgresUniqueViolation(err)) {
throw new ConflictException(
'Un compte avec cet email existe déjà (contrainte unique en base).',
);
}
throw err;
}
try {
await this.mailService.sendValidatedAccountPasswordSetupEmail(
{
email: coParent.email,
prenom: coParent.prenom ?? '',
nom: coParent.nom ?? '',
token: tokenCreationMdp,
numeroDossier,
},
'parent',
);
} catch (err) {
this.logger.error(
'[addCoParentStaff] Échec envoi email création MDP (co-parent conservé)',
err instanceof Error ? err.stack : String(err),
);
}
return {
message:
'Co-parent ajouté au foyer. Un e-mail de création de mot de passe a été envoyé.',
numero_dossier: numeroDossier,
parent_user_id: pivotUserId,
co_parent_user_id: coParent.id,
statut: StatutUtilisateurType.ACTIF,
};
}
/**
* Cœur partagé création dossier AM (#156).
* - public : statut en_attente + mail pending
* - staff : statut actif + mail création MDP
*/
async createAmDossier(
dto: RegisterAMCompletDto,
options: {
statut: StatutUtilisateurType;
sendPendingEmail: boolean;
sendPasswordSetupEmail: boolean;
requireCgu: boolean;
logContext?: string;
},
): Promise<{
message: string;
user_id: string;
statut: StatutUtilisateurType;
numero_dossier: string;
}> {
const logCtx = options.logContext ?? 'createAmDossier';
if (options.requireCgu && (!dto.acceptation_cgu || !dto.acceptation_privacy)) {
throw new BadRequestException(
"L'acceptation des CGU et de la politique de confidentialité est obligatoire",
);
@@ -668,8 +922,7 @@ export class AuthService {
throw new BadRequestException(nirValidation.error || 'NIR invalide');
}
if (nirValidation.warning) {
// Warning uniquement : on ne bloque pas (AM souvent étrangères, DOM-TOM, Corse)
console.warn('[inscrireAMComplet] NIR warning:', nirValidation.warning, 'email=', dto.email);
console.warn(`[${logCtx}] NIR warning:`, nirValidation.warning, 'email=', dto.email);
}
const existe = await this.usersService.findByEmailOrNull(dto.email);
@@ -720,79 +973,142 @@ export class AuthService {
let resultat: { user: Users };
try {
resultat = await this.usersRepo.manager.transaction(async (manager) => {
const { numero: numeroDossier } = await this.numeroDossierService.getNextNumeroDossier(manager);
const { numero: numeroDossier } =
await this.numeroDossierService.getNextNumeroDossier(manager);
const user = manager.create(Users, {
email: dto.email,
prenom: dto.prenom,
nom: dto.nom,
role: RoleType.ASSISTANTE_MATERNELLE,
statut: StatutUtilisateurType.EN_ATTENTE,
telephone: dto.telephone,
adresse: dto.adresse,
code_postal: dto.code_postal,
ville: dto.ville,
token_creation_mdp: tokenCreationMdp,
token_creation_mdp_expire_le: dateExpiration,
photo_url: urlPhoto ?? undefined,
consentement_photo: dto.consentement_photo,
date_consentement_photo: dateConsentementPhoto,
date_naissance: dto.date_naissance ? new Date(dto.date_naissance) : undefined,
lieu_naissance_ville: dto.lieu_naissance_ville,
lieu_naissance_pays: dto.lieu_naissance_pays,
numero_dossier: numeroDossier,
const user = manager.create(Users, {
email: dto.email,
prenom: dto.prenom,
nom: dto.nom,
role: RoleType.ASSISTANTE_MATERNELLE,
statut: options.statut,
telephone: dto.telephone,
adresse: dto.adresse,
code_postal: dto.code_postal,
ville: dto.ville,
token_creation_mdp: tokenCreationMdp,
token_creation_mdp_expire_le: dateExpiration,
photo_url: urlPhoto ?? undefined,
consentement_photo: dto.consentement_photo,
date_consentement_photo: dateConsentementPhoto,
date_naissance: dto.date_naissance
? new Date(dto.date_naissance)
: undefined,
lieu_naissance_ville: dto.lieu_naissance_ville,
lieu_naissance_pays: dto.lieu_naissance_pays,
numero_dossier: numeroDossier,
});
const userEnregistre = await manager.save(Users, user);
const amRepo = manager.getRepository(AssistanteMaternelle);
const am = amRepo.create({
user_id: userEnregistre.id,
approval_number: dto.numero_agrement,
nir: nirNormalized,
max_children: dto.capacite_accueil,
places_available: dto.places_disponibles,
biography: dto.biographie,
residence_city: dto.ville ?? undefined,
agreement_date: dto.date_agrement
? new Date(dto.date_agrement)
: undefined,
available: true,
numero_dossier: numeroDossier,
});
await amRepo.save(am);
return { user: userEnregistre };
});
const userEnregistre = await manager.save(Users, user);
const amRepo = manager.getRepository(AssistanteMaternelle);
const am = amRepo.create({
user_id: userEnregistre.id,
approval_number: dto.numero_agrement,
nir: nirNormalized,
max_children: dto.capacite_accueil,
places_available: dto.places_disponibles,
biography: dto.biographie,
residence_city: dto.ville ?? undefined,
agreement_date: dto.date_agrement ? new Date(dto.date_agrement) : undefined,
available: true,
numero_dossier: numeroDossier,
});
await amRepo.save(am);
return { user: userEnregistre };
});
} catch (err) {
if (this.isPostgresUniqueViolation(err)) {
throw new ConflictException('Un compte avec cet email existe déjà (contrainte unique en base).');
throw new ConflictException(
'Un compte avec cet email existe déjà (contrainte unique en base).',
);
}
throw err;
}
const numeroDossier = resultat.user.numero_dossier ?? '';
try {
await this.mailService.sendRegistrationPendingEmail(
resultat.user.email,
resultat.user.prenom ?? '',
resultat.user.nom ?? '',
numeroDossier,
);
} catch (err) {
this.logger.error(
"[inscrireAMComplet] Échec envoi email d'accusé de réception (inscription conservée)",
err instanceof Error ? err.stack : String(err),
);
if (options.sendPendingEmail) {
try {
await this.mailService.sendRegistrationPendingEmail(
resultat.user.email,
resultat.user.prenom ?? '',
resultat.user.nom ?? '',
numeroDossier,
);
} catch (err) {
this.logger.error(
`[${logCtx}] Échec envoi email d'accusé de réception (inscription conservée)`,
err instanceof Error ? err.stack : String(err),
);
}
}
if (options.sendPasswordSetupEmail) {
try {
await this.mailService.sendValidatedAccountPasswordSetupEmail(
{
email: resultat.user.email,
prenom: resultat.user.prenom ?? '',
nom: resultat.user.nom ?? '',
token: tokenCreationMdp,
numeroDossier,
},
'am',
);
} catch (err) {
this.logger.error(
`[${logCtx}] Échec envoi email création MDP (dossier conservé)`,
err instanceof Error ? err.stack : String(err),
);
}
}
const message =
options.statut === StatutUtilisateurType.ACTIF
? 'Dossier AM créé et validé. Un e-mail de création de mot de passe a été envoyé.'
: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.';
return {
message:
'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.',
message,
user_id: resultat.user.id,
statut: StatutUtilisateurType.EN_ATTENTE,
statut: options.statut,
numero_dossier: numeroDossier,
};
}
/**
* Inscription Assistante Maternelle COMPLÈTE - Un seul endpoint (identité + pro + photo + CGU)
* Crée User (role AM) + entrée assistantes_maternelles, token création MDP
*/
async inscrireAMComplet(dto: RegisterAMCompletDto) {
return this.createAmDossier(dto, {
statut: StatutUtilisateurType.EN_ATTENTE,
sendPendingEmail: true,
sendPasswordSetupEmail: false,
requireCgu: true,
logContext: 'inscrireAMComplet',
});
}
/**
* Création dossier AM par staff (#156) — statut actif + e-mail création MDP.
*/
async createAmDossierStaff(dto: RegisterAMCompletDto) {
return this.createAmDossier(dto, {
statut: StatutUtilisateurType.ACTIF,
sendPendingEmail: false,
sendPasswordSetupEmail: true,
requireCgu: false,
logContext: 'createAmDossierStaff',
});
}
/**
* Sauvegarde une photo depuis base64 vers le système de fichiers
*/
/**
* Sauvegarde une photo depuis base64 vers le système de fichiers
*/
@@ -1063,7 +1379,7 @@ export class AuthService {
if (enfantDto.genre !== undefined) enfant.gender = enfantDto.genre;
if (enfantDto.date_naissance !== undefined) {
enfant.birth_date = new Date(enfantDto.date_naissance);
enfant.status = StatutEnfantType.ACTIF;
enfant.status = StatutEnfantType.SANS_GARDE;
}
if (enfantDto.date_previsionnelle_naissance !== undefined) {
enfant.due_date = new Date(enfantDto.date_previsionnelle_naissance);
@@ -1074,6 +1390,12 @@ export class AuthService {
if (enfantDto.grossesse_multiple !== undefined) {
enfant.is_multiple = enfantDto.grossesse_multiple;
}
if (enfantDto.consent_photo !== undefined) {
enfant.consent_photo = !!enfantDto.consent_photo;
enfant.consent_photo_at = enfant.consent_photo
? new Date()
: null!;
}
if (enfantDto.photo_base64 && enfantDto.photo_filename) {
enfant.photo_url = await this.sauvegarderPhotoDepuisBase64(
@@ -59,5 +59,14 @@ export class EnfantInscriptionDto {
@IsOptional()
@IsBoolean()
grossesse_multiple?: boolean;
@ApiProperty({
example: true,
required: false,
description: 'Consentement affichage / stockage photo (colonne enfants.consentement_photo)',
})
@IsOptional()
@IsBoolean()
consent_photo?: boolean;
}
@@ -0,0 +1,85 @@
import { Test, TestingModule } from '@nestjs/testing';
import { DossiersController } from './dossiers.controller';
import { DossiersService } from './dossiers.service';
import { SuppressionService } from '../suppressions/suppression.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(),
};
const suppressionServiceMock = {
deleteDossier: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [DossiersController],
providers: [
{ provide: DossiersService, useValue: dossiersServiceMock },
{ provide: SuppressionService, useValue: suppressionServiceMock },
],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(RolesGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<DossiersController>(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');
});
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,17 +1,58 @@
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import {
Controller,
Delete,
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 { RoleType, Users } from 'src/entities/users.entity';
import { AuthGuard } from 'src/common/guards/auth.guard';
import { RolesGuard } from 'src/common/guards/roles.guard';
import { User } from 'src/common/decorators/user.decorator';
import { DossiersService } from './dossiers.service';
import { SuppressionService } from '../suppressions/suppression.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) {}
constructor(
private readonly dossiersService: DossiersService,
private readonly suppressionService: SuppressionService,
) {}
@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 dabord, puis n° décroissant. ' +
'`sans_enfant` (#159) pour dossiers famille sans enfant.',
})
@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<DossierListItemDto[]> {
return this.dossiersService.listDossiers(q);
}
@Get(':numeroDossier')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@@ -23,4 +64,21 @@ export class DossiersController {
getDossier(@Param('numeroDossier') numeroDossier: string): Promise<DossierUnifieDto> {
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 { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { ParentsModule } from '../parents/parents.module';
import { SuppressionsModule } from '../suppressions/suppressions.module';
import { DossiersController } from './dossiers.controller';
import { DossiersService } from './dossiers.service';
@@ -12,6 +13,7 @@ import { DossiersService } from './dossiers.service';
imports: [
TypeOrmModule.forFeature([Parents, AssistanteMaternelle]),
ParentsModule,
SuppressionsModule,
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
@@ -0,0 +1,148 @@
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 { SuppressionService } from '../suppressions/suppression.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(),
};
const suppressionService = {
countEnfantsForNumero: jest.fn().mockResolvedValue(1),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
DossiersService,
{ provide: getRepositoryToken(Parents), useValue: parentsRepo },
{ provide: getRepositoryToken(AssistanteMaternelle), useValue: amRepo },
{ provide: ParentsService, useValue: parentsService },
{ provide: SuppressionService, useValue: suppressionService },
],
}).compile();
service = module.get(DossiersService);
jest.clearAllMocks();
parentsRepo.createQueryBuilder.mockReturnValue(parentsQb);
amRepo.createQueryBuilder.mockReturnValue(amQb);
suppressionService.countEnfantsForNumero.mockResolvedValue(1);
});
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);
});
});
+169 -1
View File
@@ -3,12 +3,15 @@ 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 { SuppressionService } from '../suppressions/suppression.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) + sans_enfant (#159).
*/
@Injectable()
export class DossiersService {
@@ -18,8 +21,173 @@ export class DossiersService {
@InjectRepository(AssistanteMaternelle)
private readonly amRepository: Repository<AssistanteMaternelle>,
private readonly parentsService: ParentsService,
private readonly suppressionService: SuppressionService,
) {}
/**
* 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<DossierListItemDto[]> {
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');
});
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;
}
private async listFamilleItems(): Promise<DossierListItemDto[]> {
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<string, Parents[]>();
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<string, Users>();
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<DossierListItemDto[]> {
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<string, AssistanteMaternelle>();
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<DossierUnifieDto> {
const num = numeroDossier?.trim();
if (!num) {
@@ -0,0 +1,58 @@
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;
@ApiPropertyOptional({
description:
'True si dossier famille sans enfant lié (#159). Omis ou false pour AM.',
})
sans_enfant?: boolean;
}
@@ -1,4 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
IsBoolean,
IsDateString,
@@ -6,13 +7,25 @@ import {
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
MaxLength,
ValidateIf,
} from 'class-validator';
import { GenreType, StatutEnfantType } from 'src/entities/children.entity';
/** Multipart envoie des strings ("true"/"false") — JSON envoie déjà des booleans. */
function toBoolean({ value }: { value: unknown }): boolean | unknown {
if (typeof value === 'boolean') return value;
if (typeof value === 'string') {
const v = value.trim().toLowerCase();
if (v === 'true' || v === '1') return true;
if (v === 'false' || v === '0' || v === '') return false;
}
return value;
}
export class CreateEnfantsDto {
@ApiProperty({ enum: StatutEnfantType, example: StatutEnfantType.ACTIF })
@ApiProperty({ enum: StatutEnfantType, example: StatutEnfantType.SANS_GARDE })
@IsEnum(StatutEnfantType)
@IsNotEmpty()
status: StatutEnfantType;
@@ -52,6 +65,7 @@ export class CreateEnfantsDto {
photo_url?: string;
@ApiProperty({ default: false })
@Transform(toBoolean)
@IsBoolean()
consent_photo: boolean;
@@ -61,6 +75,20 @@ export class CreateEnfantsDto {
consent_photo_at?: string;
@ApiProperty({ default: false })
@Transform(toBoolean)
@IsBoolean()
is_multiple: boolean;
/**
* Parent pivot du foyer — obligatoire pour staff (gestionnaire/admin).
* Ignoré / interdit en externe pour un PARENT (ticket #132).
*/
@ApiPropertyOptional({
description:
'UUID du parent pivot (staff only). Obligatoire pour GESTIONNAIRE / ADMIN / SUPER_ADMIN.',
format: 'uuid',
})
@IsOptional()
@IsUUID('4')
parent_user_id?: string;
}
+117 -30
View File
@@ -1,20 +1,35 @@
import {
Body,
CallHandler,
Controller,
Delete,
ExecutionContext,
Get,
HttpCode,
HttpStatus,
Injectable,
NestInterceptor,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
UploadedFile,
UseGuards,
UseInterceptors,
UploadedFile,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiTags, ApiConsumes } from '@nestjs/swagger';
import {
ApiBearerAuth,
ApiBody,
ApiConsumes,
ApiOperation,
ApiQuery,
ApiTags,
} from '@nestjs/swagger';
import { diskStorage } from 'multer';
import { extname } from 'path';
import { Observable } from 'rxjs';
import { EnfantsService } from './enfants.service';
import { CreateEnfantsDto } from './dto/create_enfants.dto';
import { UpdateEnfantsDto } from './dto/update_enfants.dto';
@@ -23,38 +38,80 @@ import { User } from 'src/common/decorators/user.decorator';
import { AuthGuard } from 'src/common/guards/auth.guard';
import { Roles } from 'src/common/decorators/roles.decorator';
import { RolesGuard } from 'src/common/guards/roles.guard';
import { SuppressionService } from '../suppressions/suppression.service';
const photoMulterOptions = {
storage: diskStorage({
destination: './uploads/photos',
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const ext = extname(file.originalname);
cb(null, `enfant-${uniqueSuffix}${ext}`);
},
}),
fileFilter: (req, file, cb) => {
if (!file.mimetype.match(/\/(jpg|jpeg|png|gif)$/)) {
return cb(new Error('Seules les images sont autorisées'), false);
}
cb(null, true);
},
limits: {
fileSize: 5 * 1024 * 1024,
},
};
/**
* Multer uniquement si Content-Type multipart (parent ou staff + photo).
* JSON sans photo (#132) passe sans interceptor fichier.
*/
@Injectable()
class OptionalEnfantPhotoInterceptor implements NestInterceptor {
private readonly multipart = new (FileInterceptor(
'photo',
photoMulterOptions,
))();
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> | Promise<Observable<unknown>> {
const req = context.switchToHttp().getRequest();
const ct = String(req.headers['content-type'] ?? '');
if (!ct.includes('multipart/form-data')) {
return next.handle();
}
return this.multipart.intercept(context, next);
}
}
@ApiBearerAuth('access-token')
@ApiTags('Enfants')
@UseGuards(AuthGuard, RolesGuard)
@Controller('enfants')
export class EnfantsController {
constructor(private readonly enfantsService: EnfantsService) { }
constructor(
private readonly enfantsService: EnfantsService,
private readonly suppressionService: SuppressionService,
) { }
@Roles(RoleType.PARENT)
@Post()
@ApiConsumes('multipart/form-data')
@UseInterceptors(
FileInterceptor('photo', {
storage: diskStorage({
destination: './uploads/photos',
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const ext = extname(file.originalname);
cb(null, `enfant-${uniqueSuffix}${ext}`);
},
}),
fileFilter: (req, file, cb) => {
if (!file.mimetype.match(/\/(jpg|jpeg|png|gif)$/)) {
return cb(new Error('Seules les images sont autorisées'), false);
}
cb(null, true);
},
limits: {
fileSize: 5 * 1024 * 1024,
},
}),
@Roles(
RoleType.PARENT,
RoleType.GESTIONNAIRE,
RoleType.ADMINISTRATEUR,
RoleType.SUPER_ADMIN,
)
@Post()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({
summary: 'Créer un enfant',
description:
'PARENT : multipart éventuel, rattache au compte connecté. ' +
'Staff : parent_user_id obligatoire ; JSON sans photo OK ; avec photo → multipart (champ fichier `photo`, max 5 Mo). Ticket #132.',
})
@ApiConsumes('application/json', 'multipart/form-data')
@ApiBody({
description:
'Champs métier (+ parent_user_id côté staff). Fichier optionnel `photo` en multipart.',
type: CreateEnfantsDto,
})
@UseInterceptors(OptionalEnfantPhotoInterceptor)
create(
@Body() dto: CreateEnfantsDto,
@UploadedFile() photo: Express.Multer.File,
@@ -90,17 +147,47 @@ export class EnfantsController {
RoleType.GESTIONNAIRE,
)
@Patch(':id')
@ApiOperation({
summary: 'Mettre à jour un enfant',
description:
'JSON sans photo OK ; avec nouvelle photo → multipart (champ fichier `photo`, max 5 Mo).',
})
@ApiConsumes('application/json', 'multipart/form-data')
@UseInterceptors(OptionalEnfantPhotoInterceptor)
update(
@Param('id', new ParseUUIDPipe()) id: string,
@Body() dto: UpdateEnfantsDto,
@UploadedFile() photo: Express.Multer.File,
@User() currentUser: Users,
) {
return this.enfantsService.update(id, dto, currentUser);
return this.enfantsService.update(id, dto, currentUser, photo);
}
@Roles(RoleType.SUPER_ADMIN)
@Roles(
RoleType.SUPER_ADMIN,
RoleType.ADMINISTRATEUR,
RoleType.GESTIONNAIRE,
)
@Delete(':id')
remove(@Param('id', new ParseUUIDPipe()) id: string) {
return this.enfantsService.remove(id);
@ApiOperation({
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);
}
}
+7 -4
View File
@@ -6,13 +6,16 @@ import { Children } from 'src/entities/children.entity';
import { Parents } from 'src/entities/parents.entity';
import { ParentsChildren } from 'src/entities/parents_children.entity';
import { AuthModule } from '../auth/auth.module';
import { SuppressionsModule } from '../suppressions/suppressions.module';
@Module({
imports: [TypeOrmModule.forFeature([Children, Parents, ParentsChildren]),
AuthModule
imports: [
TypeOrmModule.forFeature([Children, Parents, ParentsChildren]),
AuthModule,
SuppressionsModule,
],
controllers: [EnfantsController],
providers: [EnfantsService]
providers: [EnfantsService],
exports: [EnfantsService],
})
export class EnfantsModule { }
+131 -33
View File
@@ -13,6 +13,12 @@ import { ParentsChildren } from 'src/entities/parents_children.entity';
import { RoleType, Users } from 'src/entities/users.entity';
import { CreateEnfantsDto } from './dto/create_enfants.dto';
const STAFF_ROLES: RoleType[] = [
RoleType.GESTIONNAIRE,
RoleType.ADMINISTRATEUR,
RoleType.SUPER_ADMIN,
];
@Injectable()
export class EnfantsService {
constructor(
@@ -24,20 +30,35 @@ export class EnfantsService {
private readonly parentsChildrenRepository: Repository<ParentsChildren>,
) { }
// Création d'un enfant
async create(dto: CreateEnfantsDto, currentUser: Users, photoFile?: Express.Multer.File): Promise<Children> {
private isStaff(user: Users): boolean {
return STAFF_ROLES.includes(user.role);
}
/**
* Création d'un enfant.
* - PARENT : rattache au parent connecté (multipart photo optionnel).
* - Staff : `parent_user_id` obligatoire ; JSON sans photo OK ;
* avec photo → multipart (même stockage `/uploads/photos/...`). Ticket #132.
*/
async create(
dto: CreateEnfantsDto,
currentUser: Users,
photoFile?: Express.Multer.File,
): Promise<Children> {
const pivotUserId = this.resolvePivotParentUserId(dto, currentUser);
const parent = await this.parentsRepository.findOne({
where: { user_id: currentUser.id },
where: { user_id: pivotUserId },
relations: ['co_parent'],
});
if (!parent) throw new NotFoundException('Parent introuvable');
// Vérif métier simple
// Vérif métier simple (aligné comportement historique parent)
if (dto.status !== StatutEnfantType.A_NAITRE && !dto.birth_date) {
throw new BadRequestException('Un enfant actif doit avoir une date de naissance');
throw new BadRequestException('Un enfant doit avoir une date de naissance');
}
// Vérif doublon éventuel (ex: même prénom + date de naissance pour ce parent)
// Vérif doublon éventuel (ex: même prénom + date de naissance)
const exist = await this.childrenRepository.findOne({
where: {
first_name: dto.first_name,
@@ -47,50 +68,108 @@ export class EnfantsService {
});
if (exist) throw new ConflictException('Cet enfant existe déjà');
// Gestion de la photo uploadée
// Gestion de la photo uploadée (multipart parent ou staff)
let photoUrl = dto.photo_url;
let consentAt: Date | undefined;
if (photoFile) {
dto.photo_url = `/uploads/photos/${photoFile.filename}`;
photoUrl = `/uploads/photos/${photoFile.filename}`;
if (dto.consent_photo) {
dto.consent_photo_at = new Date().toISOString();
consentAt = new Date();
}
} else if (dto.consent_photo) {
consentAt = dto.consent_photo_at
? new Date(dto.consent_photo_at)
: new Date();
}
// Création
const child = this.childrenRepository.create(dto);
const child = this.childrenRepository.create({
status: dto.status,
first_name: dto.first_name,
last_name: dto.last_name,
gender: dto.gender,
birth_date: dto.birth_date ? new Date(dto.birth_date) : undefined,
due_date: dto.due_date ? new Date(dto.due_date) : undefined,
photo_url: photoUrl,
consent_photo: !!dto.consent_photo,
consent_photo_at: consentAt,
is_multiple: !!dto.is_multiple,
});
await this.childrenRepository.save(child);
// Lien parent-enfant (Parent 1)
const parentLink = this.parentsChildrenRepository.create({
parentId: parent.user_id,
enfantId: child.id,
});
await this.parentsChildrenRepository.save(parentLink);
// Lien parent-enfant (pivot)
await this.parentsChildrenRepository.save(
this.parentsChildrenRepository.create({
parentId: parent.user_id,
enfantId: child.id,
}),
);
// Rattachement automatique au co-parent s'il existe
if (parent.co_parent) {
const coParentLink = this.parentsChildrenRepository.create({
parentId: parent.co_parent.id,
enfantId: child.id,
});
await this.parentsChildrenRepository.save(coParentLink);
await this.parentsChildrenRepository.save(
this.parentsChildrenRepository.create({
parentId: parent.co_parent.id,
enfantId: child.id,
}),
);
}
return this.findOne(child.id, currentUser);
}
// Liste des enfants (admin/gestionnaire)
async findAll(): Promise<Children[]> {
return this.childrenRepository.find({
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'],
order: { last_name: 'ASC', first_name: 'ASC' },
private resolvePivotParentUserId(
dto: CreateEnfantsDto,
currentUser: Users,
): string {
if (this.isStaff(currentUser)) {
const id = dto.parent_user_id?.trim();
if (!id) {
throw new BadRequestException(
'parent_user_id est obligatoire pour créer un enfant (staff)',
);
}
return id;
}
if (currentUser.role === RoleType.PARENT) {
if (
dto.parent_user_id &&
dto.parent_user_id.trim() !== currentUser.id
) {
throw new ForbiddenException(
'Un parent ne peut pas créer un enfant pour un autre compte',
);
}
return currentUser.id;
}
throw new ForbiddenException('Accès interdit');
}
/** Flag API #157 — true si aucun lien enfants_parents. */
private withSansResponsable(child: Children): Children & { sans_responsable: boolean } {
return Object.assign(child, {
sans_responsable: !child.parentLinks || child.parentLinks.length === 0,
});
}
// Liste des enfants (admin/gestionnaire) — inclut les orphelins (parentLinks: [])
async findAll(): Promise<Array<Children & { sans_responsable: boolean }>> {
const children = await this.childrenRepository.find({
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'],
order: { last_name: 'ASC', first_name: 'ASC' },
});
return children.map((c) => this.withSansResponsable(c));
}
// Récupérer un enfant par id
async findOne(id: string, currentUser: Users): Promise<Children> {
async findOne(
id: string,
currentUser: Users,
): Promise<Children & { sans_responsable: boolean }> {
const child = await this.childrenRepository.findOne({
where: { id },
relations: ['parentLinks'],
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'],
});
if (!child) throw new NotFoundException('Enfant introuvable');
@@ -104,23 +183,42 @@ export class EnfantsService {
case RoleType.ADMINISTRATEUR:
case RoleType.SUPER_ADMIN:
case RoleType.GESTIONNAIRE:
// accès complet
// accès complet (y compris orphelins)
break;
default:
throw new ForbiddenException('Accès interdit');
}
return child;
return this.withSansResponsable(child);
}
// Mise à jour
async update(id: string, dto: Partial<CreateEnfantsDto>, currentUser: Users): Promise<Children> {
async update(
id: string,
dto: Partial<CreateEnfantsDto>,
currentUser: Users,
photoFile?: Express.Multer.File,
): Promise<Children> {
const child = await this.childrenRepository.findOne({ where: { id } });
if (!child) throw new NotFoundException('Enfant introuvable');
await this.childrenRepository.update(id, dto);
const { parent_user_id: _ignored, ...rest } = dto;
const patch: Partial<Children> = { ...rest } as Partial<Children>;
if (dto.consent_photo !== undefined) {
patch.consent_photo = dto.consent_photo;
patch.consent_photo_at = dto.consent_photo ? new Date() : null!;
}
if (photoFile) {
patch.photo_url = `/uploads/photos/${photoFile.filename}`;
if (dto.consent_photo !== false) {
patch.consent_photo = true;
patch.consent_photo_at = new Date();
}
}
await this.childrenRepository.update(id, patch);
return this.findOne(id, currentUser);
}
@@ -7,6 +7,12 @@ export class ParentPendingSummaryDto {
@ApiProperty()
email: string;
@ApiPropertyOptional({ nullable: true })
nom?: string | null;
@ApiPropertyOptional({ nullable: true })
prenom?: string | null;
@ApiPropertyOptional({ nullable: true })
telephone?: string | null;
@@ -18,7 +24,10 @@ export class ParentPendingSummaryDto {
}
export class PendingFamilyDto {
@ApiProperty({ example: 'Famille Dupont', description: 'Libellé affiché pour la famille' })
@ApiProperty({
example: 'MARTIN Claire - MARTIN Thomas',
description: 'Libellé affiché : NOM Prénom (séparés par « - » si co-parent)',
})
libelle: string;
@ApiProperty({
@@ -0,0 +1,23 @@
import { ApiProperty } from '@nestjs/swagger';
import { StatutUtilisateurType } from 'src/entities/users.entity';
/** Réponse 201 POST /parents/:id/co-parent (#135). */
export class StaffAddCoParentResponseDto {
@ApiProperty()
message: string;
@ApiProperty({ example: '2026-000043' })
numero_dossier: string;
@ApiProperty({ format: 'uuid', description: 'UUID du parent pivot' })
parent_user_id: string;
@ApiProperty({ format: 'uuid', description: 'UUID du co-parent créé' })
co_parent_user_id: string;
@ApiProperty({
enum: StatutUtilisateurType,
example: StatutUtilisateurType.ACTIF,
})
statut: StatutUtilisateurType;
}
@@ -0,0 +1,69 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsBoolean,
IsEmail,
IsNotEmpty,
IsOptional,
IsString,
Matches,
MaxLength,
MinLength,
} from 'class-validator';
/**
* Ajout dun co-parent sur un foyer existant (staff) — ticket #135.
* Corps sans préfixe `co_parent_*` (lURL cible déjà le pivot).
*/
export class StaffAddCoParentDto {
@ApiProperty({ example: 'thomas.martin@ptits-pas.fr' })
@IsEmail({}, { message: 'Email invalide' })
@IsNotEmpty({ message: "L'email est requis" })
email: string;
@ApiProperty({ example: 'Thomas' })
@IsString()
@IsNotEmpty({ message: 'Le prénom est requis' })
@MinLength(2)
@MaxLength(100)
prenom: string;
@ApiProperty({ example: 'MARTIN' })
@IsString()
@IsNotEmpty({ message: 'Le nom est requis' })
@MinLength(2)
@MaxLength(100)
nom: string;
@ApiProperty({ example: '0678456789' })
@IsString()
@IsNotEmpty({ message: 'Le téléphone est requis' })
@Matches(/^(\+33|0)[1-9](\d{2}){4}$/, {
message: 'Le numéro de téléphone doit être valide (ex: 0689567890 ou +33689567890)',
})
telephone: string;
@ApiPropertyOptional({
example: true,
description: 'Si true, copie ladresse du parent pivot',
})
@IsOptional()
@IsBoolean()
meme_adresse?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsString()
adresse?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(10)
code_postal?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(150)
ville?: string;
}
@@ -0,0 +1,37 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { StatutUtilisateurType } from 'src/entities/users.entity';
/** Réponse 201 POST /parents/dossier (#129). */
export class StaffCreateParentDossierResponseDto {
@ApiProperty()
message: string;
@ApiProperty({
example: '2026-000043',
description: 'Numéro de dossier famille attribué',
})
numero_dossier: string;
@ApiProperty({ format: 'uuid', description: 'UUID user du parent pivot' })
parent_user_id: string;
@ApiPropertyOptional({
format: 'uuid',
nullable: true,
description: 'UUID user du co-parent, ou null',
})
co_parent_user_id: string | null;
@ApiProperty({
enum: StatutUtilisateurType,
example: StatutUtilisateurType.ACTIF,
})
statut: StatutUtilisateurType;
@ApiProperty({
type: [String],
format: 'uuid',
description: 'IDs des enfants créés',
})
enfant_ids: string[];
}
@@ -0,0 +1,29 @@
import { ApiPropertyOptional, OmitType } from '@nestjs/swagger';
import { IsBoolean, IsOptional } from 'class-validator';
import { RegisterParentCompletDto } from 'src/routes/auth/dto/register-parent-complet.dto';
/**
* Création dossier parent/famille par staff (#129).
* Mêmes champs que l'inscription publique, sans CGU/privacy obligatoires
* (acceptées côté serveur pour le compte du gestionnaire).
*/
export class StaffCreateParentDossierDto extends OmitType(RegisterParentCompletDto, [
'acceptation_cgu',
'acceptation_privacy',
] as const) {
@ApiPropertyOptional({
description: 'Ignoré côté staff (CGU acceptées serveur). Conservé pour compat éventuelle.',
default: true,
})
@IsOptional()
@IsBoolean()
acceptation_cgu?: boolean;
@ApiPropertyOptional({
description: 'Ignoré côté staff (privacy acceptée serveur).',
default: true,
})
@IsOptional()
@IsBoolean()
acceptation_privacy?: boolean;
}
@@ -1,18 +1,104 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ParentsController } from './parents.controller';
import { ParentsService } from './parents.service';
import { UserService } from '../user/user.service';
import { AuthService } from '../auth/auth.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('ParentsController', () => {
let controller: ParentsController;
const authServiceMock = {
createParentDossierStaff: jest.fn(),
addCoParentStaff: jest.fn(),
};
const parentsServiceMock = {};
const userServiceMock = {};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [ParentsController],
}).compile();
providers: [
{ provide: ParentsService, useValue: parentsServiceMock },
{ provide: UserService, useValue: userServiceMock },
{ provide: AuthService, useValue: authServiceMock },
],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(RolesGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<ParentsController>(ParentsController);
jest.clearAllMocks();
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
it('createDossier delegates to authService.createParentDossierStaff with CGU accepted', async () => {
authServiceMock.createParentDossierStaff.mockResolvedValue({
message: 'Dossier famille créé et validé. Un e-mail de création de mot de passe a été envoyé.',
parent_user_id: 'p1',
co_parent_user_id: 'p2',
enfant_ids: ['e1'],
statut: StatutUtilisateurType.ACTIF,
numero_dossier: '2026-000043',
});
const body = {
email: 'parent.staff@test.fr',
prenom: 'Claire',
nom: 'MARTIN',
telephone: '0689567890',
enfants: [
{
prenom: 'Emma',
nom: 'MARTIN',
date_naissance: '2023-02-15',
genre: 'F',
},
],
};
const res = await controller.createDossier(body as any);
expect(authServiceMock.createParentDossierStaff).toHaveBeenCalledWith(
expect.objectContaining({
email: body.email,
acceptation_cgu: true,
acceptation_privacy: true,
}),
);
expect(res.numero_dossier).toBe('2026-000043');
expect(res.parent_user_id).toBe('p1');
expect(res.co_parent_user_id).toBe('p2');
expect(res.enfant_ids).toEqual(['e1']);
expect(res.statut).toBe(StatutUtilisateurType.ACTIF);
});
it('addCoParent delegates to authService.addCoParentStaff', async () => {
authServiceMock.addCoParentStaff.mockResolvedValue({
message: 'ok',
numero_dossier: '2026-000043',
parent_user_id: 'p1',
co_parent_user_id: 'p2',
statut: StatutUtilisateurType.ACTIF,
});
const body = {
email: 'coparent@test.fr',
prenom: 'Thomas',
nom: 'MARTIN',
telephone: '0678456789',
meme_adresse: true,
};
const res = await controller.addCoParent('p1', body as any);
expect(authServiceMock.addCoParentStaff).toHaveBeenCalledWith('p1', body);
expect(res.co_parent_user_id).toBe('p2');
expect(res.statut).toBe(StatutUtilisateurType.ACTIF);
});
});
@@ -3,6 +3,8 @@ import {
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Patch,
Post,
@@ -10,29 +12,79 @@ import {
} from '@nestjs/common';
import { ParentsService } from './parents.service';
import { UserService } from '../user/user.service';
import { AuthService } from '../auth/auth.service';
import { Parents } from 'src/entities/parents.entity';
import { Users } from 'src/entities/users.entity';
import { Roles } from 'src/common/decorators/roles.decorator';
import { RoleType, StatutUtilisateurType } from 'src/entities/users.entity';
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import {
ApiBearerAuth,
ApiBody,
ApiOperation,
ApiParam,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { CreateParentDto } from '../user/dto/create_parent.dto';
import { UpdateParentsDto } from '../user/dto/update_parent.dto';
import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto';
import { StaffCreateParentDossierDto } from './dto/staff-create-parent-dossier.dto';
import { StaffCreateParentDossierResponseDto } from './dto/staff-create-parent-dossier-response.dto';
import { StaffAddCoParentDto } from './dto/staff-add-co-parent.dto';
import { StaffAddCoParentResponseDto } from './dto/staff-add-co-parent-response.dto';
import { RegisterParentCompletDto } from '../auth/dto/register-parent-complet.dto';
import { AuthGuard } from 'src/common/guards/auth.guard';
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')
@ApiBearerAuth('access-token')
@Controller('parents')
@UseGuards(AuthGuard, RolesGuard)
export class ParentsController {
constructor(
private readonly parentsService: ParentsService,
private readonly userService: UserService,
private readonly authService: AuthService,
) {}
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@Post('dossier')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({
summary: 'Créer un dossier famille/parent complet (staff) — ticket #129',
description:
'Crée parent (+ co-parent optionnel) + enfants + n° dossier avec statut actif, ' +
'et envoie le-mail de création de mot de passe. ' +
'Ne pas utiliser POST /auth/register/parent depuis le dashboard.',
})
@ApiBody({ type: StaffCreateParentDossierDto })
@ApiResponse({ status: 201, type: StaffCreateParentDossierResponseDto })
@ApiResponse({ status: 400, description: 'Validation DTO / métier' })
@ApiResponse({ status: 403, description: 'Rôle non autorisé' })
@ApiResponse({ status: 409, description: 'Email pivot et/ou co-parent déjà pris' })
async createDossier(
@Body() dto: StaffCreateParentDossierDto,
): Promise<StaffCreateParentDossierResponseDto> {
const registerDto = {
...dto,
acceptation_cgu: true,
acceptation_privacy: true,
} as RegisterParentCompletDto;
const result = await this.authService.createParentDossierStaff(registerDto);
return {
message: result.message,
numero_dossier: result.numero_dossier,
parent_user_id: result.parent_user_id,
co_parent_user_id: result.co_parent_user_id ?? null,
statut: result.statut,
enfant_ids: result.enfant_ids,
};
}
@Get('pending-families')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({ summary: 'Liste des familles en attente (une entrée par famille)' })
@@ -81,21 +133,25 @@ export class ParentsController {
return validated;
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@Get()
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@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<Parents[]> {
return this.parentsService.findAll();
async getAll(): Promise<Parents[]> {
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<Parents> {
return this.parentsService.findOne(user_id);
async getOne(@Param('id') user_id: string): Promise<Parents> {
const parent = await this.parentsService.findOne(user_id);
return mapParentForApi(parent);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
@@ -103,8 +159,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<Parents> {
return this.parentsService.create(dto);
async create(@Body() dto: CreateParentDto): Promise<Parents> {
const parent = await this.parentsService.create(dto);
return mapParentForApi(parent);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@@ -113,11 +170,34 @@ 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<Parents> {
return this.parentsService.updateFicheAdmin(id, dto);
const parent = await this.parentsService.updateFicheAdmin(id, dto);
return mapParentForApi(parent);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@Post(':id/co-parent')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({
summary: 'Ajouter un co-parent à un foyer existant (staff) — ticket #135',
description:
'Foyer mono-parent uniquement. Crée le co-parent actif, liens foyer + enfants, ' +
'e-mail de création de mot de passe. Ne pas utiliser POST /auth/register/parent.',
})
@ApiParam({ name: 'id', description: 'UUID utilisateur du parent pivot' })
@ApiBody({ type: StaffAddCoParentDto })
@ApiResponse({ status: 201, type: StaffAddCoParentResponseDto })
@ApiResponse({ status: 400, description: 'Foyer déjà à 2 parents / validation' })
@ApiResponse({ status: 404, description: 'Parent introuvable' })
@ApiResponse({ status: 409, description: 'Email déjà pris' })
async addCoParent(
@Param('id') id: string,
@Body() dto: StaffAddCoParentDto,
): Promise<StaffAddCoParentResponseDto> {
return this.authService.addCoParentStaff(id, dto);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@@ -126,11 +206,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<Parents> {
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 +220,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<Parents> {
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 +234,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<Parents> {
return this.parentsService.update(id, dto);
async update(@Param('id') id: string, @Body() dto: UpdateParentsDto): Promise<Parents> {
const parent = await this.parentsService.update(id, dto);
return mapParentForApi(parent);
}
}
@@ -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();
});
});
@@ -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);
}
@@ -9,11 +9,13 @@ import { ParentsController } from './parents.controller';
import { ParentsService } from './parents.service';
import { Users } from 'src/entities/users.entity';
import { UserModule } from '../user/user.module';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [
TypeOrmModule.forFeature([Parents, Users, DossierFamille, DossierFamilleEnfant, ParentsChildren]),
forwardRef(() => UserModule),
forwardRef(() => AuthModule),
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
+93 -21
View File
@@ -114,34 +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<Parents> {
await this.findOne(parentUserId);
private async resolveFoyerParentUserIds(parent: Parents): Promise<string[]> {
const ids = new Set<string>([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<Parents> {
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. Ticket #115 / doc 28 §6.2.
* 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<Parents> {
await this.findOne(parentUserId);
const parent = await this.findOne(parentUserId);
const link = await this.parentsChildrenRepository.findOne({
where: { parentId: parentUserId, enfantId },
@@ -150,12 +202,12 @@ export class ParentsService {
throw new NotFoundException('Lien parent-enfant introuvable');
}
const totalLinks = await this.parentsChildrenRepository.count({ where: { enfantId } });
if (totalLinks <= 1) {
throw new BadRequestException('Un enfant doit rester rattaché à au moins un responsable');
}
const foyerIds = await this.resolveFoyerParentUserIds(parent);
await this.parentsChildrenRepository.delete({
parentId: In(foyerIds),
enfantId,
});
await this.parentsChildrenRepository.delete({ parentId: parentUserId, enfantId });
return this.findOne(parentUserId);
}
@@ -199,7 +251,15 @@ export class ParentsService {
SELECT id, (MIN(rep::text))::uuid AS rep FROM rec GROUP BY id
)
SELECT
'Famille ' || string_agg(u.nom, ' - ' ORDER BY u.nom, u.prenom) AS libelle,
string_agg(
UPPER(TRIM(u.nom))
|| CASE
WHEN u.prenom IS NOT NULL AND TRIM(u.prenom) <> ''
THEN ' ' || INITCAP(TRIM(u.prenom))
ELSE ''
END,
' - ' ORDER BY u.nom, u.prenom, u.id
) AS libelle,
array_agg(p.id_utilisateur ORDER BY u.nom, u.prenom, u.id) AS "parentIds",
(array_agg(p.numero_dossier))[1] AS numero_dossier,
MIN(u.cree_le) AS date_soumission,
@@ -215,6 +275,8 @@ export class ParentsService {
json_build_object(
'id', u.id::text,
'email', u.email,
'nom', u.nom,
'prenom', u.prenom,
'telephone', u.telephone,
'code_postal', u.code_postal,
'ville', u.ville
@@ -265,11 +327,21 @@ export class ParentsService {
return [];
}
private normalizeParents(parents: unknown): { id: string; email: string; telephone: string | null; code_postal: string | null; ville: string | null }[] {
private normalizeParents(parents: unknown): {
id: string;
email: string;
nom: string | null;
prenom: string | null;
telephone: string | null;
code_postal: string | null;
ville: string | null;
}[] {
if (Array.isArray(parents)) {
return parents.map((p: any) => ({
id: String(p?.id ?? ''),
email: String(p?.email ?? ''),
nom: p?.nom != null ? String(p.nom) : null,
prenom: p?.prenom != null ? String(p.prenom) : null,
telephone: p?.telephone != null ? String(p.telephone) : null,
code_postal: p?.code_postal != null ? String(p.code_postal) : null,
ville: p?.ville != null ? String(p.ville) : null,
@@ -24,15 +24,19 @@ export class RelaisController {
}
@Get()
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Lister tous les relais' })
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({
summary: 'Lister tous les relais',
description:
'Lecture ouverte aux gestionnaires (combobox fiches). CRUD write reste admin-only. Ticket #151.',
})
@ApiResponse({ status: 200, description: 'Liste des relais.' })
findAll() {
return this.relaisService.findAll();
}
@Get(':id')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({ summary: 'Récupérer un relais par ID' })
@ApiResponse({ status: 200, description: 'Le relais trouvé.' })
findOne(@Param('id') id: string) {
@@ -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 denfants 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 dutilisateur 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 {}
@@ -1,20 +1,21 @@
import { Test, TestingModule } from '@nestjs/testing';
import 'reflect-metadata';
import { GestionnairesController } from './gestionnaires.controller';
import { GestionnairesService } from './gestionnaires.service';
import { RoleType } from 'src/entities/users.entity';
describe('GestionnairesController', () => {
let controller: GestionnairesController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [GestionnairesController],
providers: [GestionnairesService],
}).compile();
controller = module.get<GestionnairesController>(GestionnairesController);
describe('GestionnairesController roles (#161)', () => {
it('POST /gestionnaires autorise SUPER_ADMIN et ADMINISTRATEUR', () => {
const roles = Reflect.getMetadata('roles', GestionnairesController.prototype.create);
expect(roles).toEqual(
expect.arrayContaining([RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR]),
);
expect(roles).not.toContain(RoleType.GESTIONNAIRE);
});
it('should be defined', () => {
expect(controller).toBeDefined();
it('PATCH /gestionnaires/:id autorise SUPER_ADMIN et ADMINISTRATEUR', () => {
const roles = Reflect.getMetadata('roles', GestionnairesController.prototype.update);
expect(roles).toEqual(
expect.arrayContaining([RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR]),
);
expect(roles).not.toContain(RoleType.GESTIONNAIRE);
});
});
@@ -25,10 +25,10 @@ import { AuthGuard } from 'src/common/guards/auth.guard';
export class GestionnairesController {
constructor(private readonly gestionnairesService: GestionnairesService) { }
@Roles(RoleType.SUPER_ADMIN)
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
@ApiResponse({ status: 201, description: 'Le gestionnaire a été créé avec succès.', type: Users })
@ApiResponse({ status: 409, description: 'Conflit. L\'email est déjà utilisé.' })
@ApiOperation({ summary: 'Création d\'un gestionnaire' })
@ApiOperation({ summary: 'Création d\'un gestionnaire (admin / super admin)' })
@ApiBody({ type: CreateGestionnaireDto })
@Post()
create(@Body() dto: CreateGestionnaireDto): Promise<Users> {
@@ -43,7 +43,7 @@ export class GestionnairesController {
return this.gestionnairesService.findAll();
}
@Roles(RoleType.GESTIONNAIRE, RoleType.SUPER_ADMIN)
@Roles(RoleType.GESTIONNAIRE, RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Récupérer un gestionnaire par ID' })
@ApiResponse({ status: 400, description: 'ID invalide' })
@ApiResponse({ status: 403, description: 'Accès refusé' })
@@ -56,8 +56,8 @@ export class GestionnairesController {
return this.gestionnairesService.findOne(id);
}
@Roles(RoleType.SUPER_ADMIN)
@ApiOperation({ summary: 'Mettre à jour un gestionnaire' })
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Mettre à jour un gestionnaire (admin / super admin)' })
@ApiResponse({ status: 200, description: 'Le gestionnaire a été mis à jour avec succès.', type: Users })
@ApiResponse({ status: 404, description: 'Gestionnaire non trouvé' })
@ApiResponse({ status: 403, description: 'Accès refusé' })
@@ -1,20 +1,13 @@
import { Test, TestingModule } from '@nestjs/testing';
import 'reflect-metadata';
import { UserController } from './user.controller';
import { UserService } from './user.service';
import { RoleType } from 'src/entities/users.entity';
describe('UserController', () => {
let controller: UserController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [UserController],
providers: [UserService],
}).compile();
controller = module.get<UserController>(UserController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
describe('UserController roles (#161)', () => {
it('POST /users/admin autorise SUPER_ADMIN et ADMINISTRATEUR', () => {
const roles = Reflect.getMetadata('roles', UserController.prototype.createAdmin);
expect(roles).toEqual(
expect.arrayContaining([RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR]),
);
expect(roles).not.toContain(RoleType.GESTIONNAIRE);
});
});
+20 -8
View File
@@ -10,18 +10,22 @@ import { CreateUserDto } from './dto/create_user.dto';
import { CreateAdminDto } from './dto/create_admin.dto';
import { UpdateUserDto } from './dto/update_user.dto';
import { AffecterNumeroDossierDto } from './dto/affecter-numero-dossier.dto';
import { SuppressionService } from '../suppressions/suppression.service';
@ApiTags('Utilisateurs')
@ApiBearerAuth('access-token')
@UseGuards(AuthGuard, RolesGuard)
@Controller('users')
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 (admin + super admin) — #161
@Post('admin')
@Roles(RoleType.SUPER_ADMIN)
@ApiOperation({ summary: 'Créer un nouvel administrateur (super admin seulement)' })
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Créer un nouvel administrateur (admin / super admin)' })
createAdmin(
@Body() dto: CreateAdminDto,
@User() currentUser: Users
@@ -146,12 +150,20 @@ export class UserController {
return this.userService.suspendUser(id, currentUser, comment);
}
// Supprimer un utilisateur (super_admin uniquement)
// Supprimer un utilisateur — cascades métier #159
@Delete(':id')
@Roles(RoleType.SUPER_ADMIN)
@ApiOperation({ summary: 'Supprimer un utilisateur' })
@Roles(
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" })
remove(@Param('id') id: string, @User() currentUser: Users) {
return this.userService.remove(id, currentUser);
return this.suppressionService.deleteUser(id, currentUser);
}
}
+2
View File
@@ -12,6 +12,7 @@ import { Parents } from 'src/entities/parents.entity';
import { GestionnairesModule } from './gestionnaires/gestionnaires.module';
import { MailModule } from 'src/modules/mail/mail.module';
import { AppConfigModule } from 'src/modules/config/config.module';
import { SuppressionsModule } from '../suppressions/suppressions.module';
@Module({
imports: [TypeOrmModule.forFeature(
@@ -26,6 +27,7 @@ import { AppConfigModule } from 'src/modules/config/config.module';
GestionnairesModule,
MailModule,
AppConfigModule,
SuppressionsModule,
],
controllers: [UserController],
providers: [UserService],
+78 -9
View File
@@ -1,18 +1,87 @@
import { Test, TestingModule } from '@nestjs/testing';
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { UserService } from './user.service';
import { RoleType, StatutUtilisateurType } from 'src/entities/users.entity';
describe('UserService.createAdmin (#161)', () => {
const usersRepository = {
findOneBy: jest.fn(),
create: jest.fn(),
save: jest.fn(),
};
describe('UserService', () => {
let service: UserService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [UserService],
}).compile();
const dto = {
email: 'nouveau.admin@ptits-pas.fr',
password: 'Password1!',
prenom: 'Nina',
nom: 'Admin',
telephone: '0601020304',
};
service = module.get<UserService>(UserService);
beforeEach(() => {
jest.clearAllMocks();
service = new UserService(
usersRepository as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
);
});
it('should be defined', () => {
expect(service).toBeDefined();
it('autorise un administrateur à créer un admin', async () => {
usersRepository.findOneBy.mockResolvedValue(null);
usersRepository.create.mockImplementation((data) => data);
usersRepository.save.mockImplementation(async (entity) => ({
id: 'new-admin',
...entity,
}));
const result = await service.createAdmin(dto as never, {
id: 'admin-1',
role: RoleType.ADMINISTRATEUR,
} as never);
expect(result.role).toBe(RoleType.ADMINISTRATEUR);
expect(result.statut).toBe(StatutUtilisateurType.ACTIF);
expect(usersRepository.save).toHaveBeenCalled();
});
it('autorise un super_admin à créer un admin', async () => {
usersRepository.findOneBy.mockResolvedValue(null);
usersRepository.create.mockImplementation((data) => data);
usersRepository.save.mockImplementation(async (entity) => ({
id: 'new-admin',
...entity,
}));
await expect(
service.createAdmin(dto as never, {
id: 'sa-1',
role: RoleType.SUPER_ADMIN,
} as never),
).resolves.toMatchObject({ role: RoleType.ADMINISTRATEUR });
});
it('refuse un gestionnaire (403 métier)', async () => {
await expect(
service.createAdmin(dto as never, {
id: 'gest-1',
role: RoleType.GESTIONNAIRE,
} as never),
).rejects.toBeInstanceOf(ForbiddenException);
expect(usersRepository.save).not.toHaveBeenCalled();
});
it('refuse un email déjà utilisé', async () => {
usersRepository.findOneBy.mockResolvedValue({ id: 'exists' });
await expect(
service.createAdmin(dto as never, {
id: 'admin-1',
role: RoleType.ADMINISTRATEUR,
} as never),
).rejects.toBeInstanceOf(BadRequestException);
});
});
+9 -2
View File
@@ -117,8 +117,14 @@ export class UserService {
}
async createAdmin(dto: CreateAdminDto, currentUser: Users): Promise<Users> {
if (currentUser.role !== RoleType.SUPER_ADMIN) {
throw new ForbiddenException('Seuls les super administrateurs peuvent créer un administrateur');
// #161 — admin et super_admin peuvent créer un administrateur
if (
currentUser.role !== RoleType.SUPER_ADMIN &&
currentUser.role !== RoleType.ADMINISTRATEUR
) {
throw new ForbiddenException(
'Seuls les administrateurs et super administrateurs peuvent créer un administrateur',
);
}
const exist = await this.usersRepository.findOneBy({ email: dto.email });
@@ -520,6 +526,7 @@ export class UserService {
}
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) {
throw new ForbiddenException('Accès réservé aux super admins');
}
+118 -110
View File
@@ -1,3 +1,9 @@
-- ==========================================================
-- P'titsPas — Schéma PostgreSQL (création from scratch)
-- Fichier canonique : toute nouvelle BDD doit partir d'ici.
-- Migrations dans database/migrations/ : BDD existantes uniquement.
-- ==========================================================
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- ==========================================================
@@ -5,40 +11,63 @@ CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- ==========================================================
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'role_type') THEN
CREATE TYPE role_type AS ENUM ('parent', 'gestionnaire', 'super_admin', 'administrateur', 'assistante_maternelle');
CREATE TYPE role_type AS ENUM (
'parent', 'gestionnaire', 'super_admin', 'administrateur', 'assistante_maternelle'
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'genre_type') THEN
CREATE TYPE genre_type AS ENUM ('H', 'F', 'Autre');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_utilisateur_type') THEN
CREATE TYPE statut_utilisateur_type AS ENUM ('en_attente','actif','suspendu','refuse');
CREATE TYPE statut_utilisateur_type AS ENUM ('en_attente', 'actif', 'suspendu', 'refuse');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_enfant_type') THEN
CREATE TYPE statut_enfant_type AS ENUM ('a_naitre','actif','scolarise');
CREATE TYPE statut_enfant_type AS ENUM ('a_naitre', 'garde', 'sans_garde', 'scolarise');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_dossier_type') THEN
CREATE TYPE statut_dossier_type AS ENUM ('envoye','accepte','refuse');
CREATE TYPE statut_dossier_type AS ENUM ('envoye', 'accepte', 'refuse');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_contrat_type') THEN
CREATE TYPE statut_contrat_type AS ENUM ('brouillon','en_attente_signature','valide','resilie');
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_contrat_type') THEN
CREATE TYPE statut_contrat_type AS ENUM (
'brouillon', 'en_attente_signature', 'valide', 'resilie'
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_avenant_type') THEN
CREATE TYPE statut_avenant_type AS ENUM ('propose','accepte','refuse');
CREATE TYPE statut_avenant_type AS ENUM ('propose', 'accepte', 'refuse');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'type_evenement_type') THEN
CREATE TYPE type_evenement_type AS ENUM ('absence_enfant','conge_am','conge_parent','arret_maladie_am','evenement_rpe');
CREATE TYPE type_evenement_type AS ENUM (
'absence_enfant', 'conge_am', 'conge_parent', 'arret_maladie_am', 'evenement_rpe'
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_evenement_type') THEN
CREATE TYPE statut_evenement_type AS ENUM ('propose','valide','refuse');
CREATE TYPE statut_evenement_type AS ENUM ('propose', 'valide', 'refuse');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_validation_type') THEN
CREATE TYPE statut_validation_type AS ENUM ('en_attente','valide','refuse');
CREATE TYPE statut_validation_type AS ENUM ('en_attente', 'valide', 'refuse');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'situation_familiale_type') THEN
CREATE TYPE situation_familiale_type AS ENUM ('celibataire','marie','concubinage','pacse','separe','divorce','veuf','parent_isole');
CREATE TYPE situation_familiale_type AS ENUM (
'celibataire', 'marie', 'concubinage', 'pacse', 'separe', 'divorce', 'veuf', 'parent_isole'
);
END IF;
END $$;
-- ==========================================================
-- Table : relais (avant utilisateurs — FK relais_id)
-- ==========================================================
CREATE TABLE relais (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
nom VARCHAR(255) NOT NULL,
adresse TEXT NOT NULL,
horaires_ouverture JSONB,
ligne_fixe VARCHAR(20),
actif BOOLEAN DEFAULT true,
notes TEXT,
cree_le TIMESTAMPTZ DEFAULT now(),
modifie_le TIMESTAMPTZ DEFAULT now()
);
-- ==========================================================
-- Table : utilisateurs
-- ==========================================================
@@ -46,26 +75,33 @@ CREATE TABLE utilisateurs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE,
CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'),
password TEXT, -- NULL avant création via token
password TEXT,
prenom VARCHAR(100),
nom VARCHAR(100),
genre genre_type,
role role_type NOT NULL,
statut statut_utilisateur_type DEFAULT 'en_attente',
telephone VARCHAR(20), -- Unifié (mobile privilégié)
telephone VARCHAR(20),
adresse TEXT,
date_naissance DATE,
lieu_naissance_ville VARCHAR(100),
lieu_naissance_pays VARCHAR(100),
photo_url TEXT, -- Obligatoire pour AM, non utilisé pour parents
photo_url TEXT,
consentement_photo BOOLEAN DEFAULT false,
date_consentement_photo TIMESTAMPTZ,
token_creation_mdp VARCHAR(255), -- Token pour créer MDP après validation
token_creation_mdp_expire_le TIMESTAMPTZ, -- Expiration 7 jours
-- Ticket #127 : réinitialisation mot de passe oublié (distinct de token_creation_mdp)
password_reset_token VARCHAR(255) NULL,
password_reset_expires TIMESTAMPTZ NULL,
token_creation_mdp VARCHAR(255),
token_creation_mdp_expire_le TIMESTAMPTZ,
password_reset_token VARCHAR(255),
password_reset_expires TIMESTAMPTZ,
token_reprise VARCHAR(255),
token_reprise_expire_le TIMESTAMPTZ,
changement_mdp_obligatoire BOOLEAN DEFAULT false,
numero_dossier VARCHAR(20),
cgu_version_acceptee INTEGER,
cgu_acceptee_le TIMESTAMPTZ,
privacy_version_acceptee INTEGER,
privacy_acceptee_le TIMESTAMPTZ,
relais_id UUID REFERENCES relais(id) ON DELETE SET NULL,
cree_le TIMESTAMPTZ DEFAULT now(),
modifie_le TIMESTAMPTZ DEFAULT now(),
ville VARCHAR(150),
@@ -74,15 +110,22 @@ CREATE TABLE utilisateurs (
situation_familiale situation_familiale_type
);
-- Index pour recherche par token
CREATE INDEX idx_utilisateurs_token_creation_mdp
ON utilisateurs(token_creation_mdp)
CREATE INDEX idx_utilisateurs_token_creation_mdp
ON utilisateurs(token_creation_mdp)
WHERE token_creation_mdp IS NOT NULL;
CREATE INDEX idx_utilisateurs_password_reset_token
ON utilisateurs(password_reset_token)
WHERE password_reset_token IS NOT NULL;
CREATE INDEX idx_utilisateurs_token_reprise
ON utilisateurs(token_reprise)
WHERE token_reprise IS NOT NULL;
CREATE INDEX idx_utilisateurs_numero_dossier
ON utilisateurs(numero_dossier)
WHERE numero_dossier IS NOT NULL;
-- ==========================================================
-- Table : assistantes_maternelles
-- ==========================================================
@@ -97,17 +140,27 @@ CREATE TABLE assistantes_maternelles (
date_agrement DATE,
annee_experience SMALLINT,
specialite VARCHAR(100),
place_disponible INT
place_disponible INT,
numero_dossier VARCHAR(20)
);
CREATE INDEX idx_assistantes_maternelles_numero_dossier
ON assistantes_maternelles(numero_dossier)
WHERE numero_dossier IS NOT NULL;
-- ==========================================================
-- Table : parents
-- ==========================================================
CREATE TABLE parents (
id_utilisateur UUID PRIMARY KEY REFERENCES utilisateurs(id) ON DELETE CASCADE,
id_co_parent UUID REFERENCES utilisateurs(id)
id_co_parent UUID REFERENCES utilisateurs(id),
numero_dossier VARCHAR(20)
);
CREATE INDEX idx_parents_numero_dossier
ON parents(numero_dossier)
WHERE numero_dossier IS NOT NULL;
-- ==========================================================
-- Table : enfants
-- ==========================================================
@@ -116,7 +169,7 @@ CREATE TABLE enfants (
statut statut_enfant_type,
prenom VARCHAR(100),
nom VARCHAR(100),
genre genre_type NOT NULL, -- Obligatoire selon CDC
genre genre_type NOT NULL,
date_naissance DATE,
date_prevue_naissance DATE,
photo_url TEXT,
@@ -135,7 +188,27 @@ CREATE TABLE enfants_parents (
);
-- ==========================================================
-- Table : dossier_famille (inscription parent, schéma simplifié — ticket #119)
-- Table : enfants_assistantes_maternelles (placement AM ↔ enfant — #131)
-- ==========================================================
CREATE TABLE enfants_assistantes_maternelles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
id_am UUID NOT NULL REFERENCES assistantes_maternelles(id_utilisateur) ON DELETE CASCADE,
id_enfant UUID NOT NULL REFERENCES enfants(id) ON DELETE CASCADE,
date_debut DATE NOT NULL DEFAULT CURRENT_DATE,
date_fin DATE NULL,
cree_le TIMESTAMPTZ NOT NULL DEFAULT now(),
cree_par UUID REFERENCES utilisateurs(id) ON DELETE SET NULL
);
CREATE INDEX idx_eam_am ON enfants_assistantes_maternelles(id_am);
CREATE INDEX idx_eam_enfant ON enfants_assistantes_maternelles(id_enfant);
CREATE UNIQUE INDEX uq_enfant_garde_active
ON enfants_assistantes_maternelles (id_enfant)
WHERE date_fin IS NULL;
-- ==========================================================
-- Table : dossier_famille (inscription parent — ticket #119)
-- ==========================================================
CREATE TABLE dossier_famille (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -276,10 +349,6 @@ CREATE TABLE notifications (
-- ==========================================================
-- Table : validations
-- ==========================================================
-- Historique des décisions (validation / refus / suspension de compte).
-- Colonnes commentaire + valide_par : requises par lAPI Nest (TypeORM).
-- FK en ON DELETE SET NULL : conserver la ligne si lutilisateur référencé
-- est supprimé (voir database/docs/FK_POLICIES.md).
CREATE TABLE validations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
id_utilisateur UUID REFERENCES utilisateurs(id) ON DELETE SET NULL,
@@ -305,13 +374,10 @@ CREATE TABLE configuration (
modifie_par UUID REFERENCES utilisateurs(id)
);
-- Index pour performance
CREATE INDEX idx_configuration_cle ON configuration(cle);
CREATE INDEX idx_configuration_categorie ON configuration(categorie);
-- Seed initial de configuration
INSERT INTO configuration (cle, valeur, type, categorie, description) VALUES
-- === Configuration Email (SMTP) ===
('smtp_host', 'localhost', 'string', 'email', 'Serveur SMTP (ex: mail.mairie-bezons.fr, smtp.gmail.com)'),
('smtp_port', '25', 'number', 'email', 'Port SMTP (25, 465, 587)'),
('smtp_secure', 'false', 'boolean', 'email', 'Utiliser SSL/TLS (true pour port 465)'),
@@ -320,14 +386,10 @@ INSERT INTO configuration (cle, valeur, type, categorie, description) VALUES
('smtp_password', '', 'encrypted', 'email', 'Mot de passe SMTP (chiffré en AES-256)'),
('email_from_name', 'P''titsPas', 'string', 'email', 'Nom de l''expéditeur affiché dans les emails'),
('email_from_address', 'no-reply@ptits-pas.fr', 'string', 'email', 'Adresse email de l''expéditeur'),
-- === Configuration Application ===
('app_name', 'P''titsPas', 'string', 'app', 'Nom de l''application (affiché dans l''interface)'),
('app_url', 'https://app.ptits-pas.fr', 'string', 'app', 'URL publique de l''application (pour les liens dans emails)'),
('app_logo_url', '/assets/logo.png', 'string', 'app', 'URL du logo de l''application'),
('setup_completed', 'false', 'boolean', 'app', 'Configuration initiale terminée'),
-- === Configuration Sécurité ===
('password_reset_token_expiry_days', '7', 'number', 'security', 'Durée de validité des tokens de création/réinitialisation de mot de passe (en jours)'),
('jwt_expiry_hours', '24', 'number', 'security', 'Durée de validité des sessions JWT (en heures)'),
('max_upload_size_mb', '5', 'number', 'security', 'Taille maximale des fichiers uploadés (en MB)'),
@@ -338,19 +400,18 @@ INSERT INTO configuration (cle, valeur, type, categorie, description) VALUES
-- ==========================================================
CREATE TABLE documents_legaux (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
type VARCHAR(50) NOT NULL, -- 'cgu' ou 'privacy'
version INTEGER NOT NULL, -- Numéro de version (auto-incrémenté)
fichier_nom VARCHAR(255) NOT NULL, -- Nom original du fichier
fichier_path VARCHAR(500) NOT NULL, -- Chemin de stockage
fichier_hash VARCHAR(64) NOT NULL, -- Hash SHA-256 pour intégrité
actif BOOLEAN DEFAULT false, -- Version actuellement active
televerse_par UUID REFERENCES utilisateurs(id), -- Qui a uploadé
televerse_le TIMESTAMPTZ DEFAULT now(), -- Date d'upload
active_le TIMESTAMPTZ, -- Date d'activation
UNIQUE(type, version) -- Pas de doublon version
type VARCHAR(50) NOT NULL,
version INTEGER NOT NULL,
fichier_nom VARCHAR(255) NOT NULL,
fichier_path VARCHAR(500) NOT NULL,
fichier_hash VARCHAR(64) NOT NULL,
actif BOOLEAN DEFAULT false,
televerse_par UUID REFERENCES utilisateurs(id),
televerse_le TIMESTAMPTZ DEFAULT now(),
active_le TIMESTAMPTZ,
UNIQUE(type, version)
);
-- Index pour performance
CREATE INDEX idx_documents_legaux_type_actif ON documents_legaux(type, actif);
CREATE INDEX idx_documents_legaux_version ON documents_legaux(type, version DESC);
@@ -361,73 +422,23 @@ CREATE TABLE acceptations_documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
id_utilisateur UUID REFERENCES utilisateurs(id) ON DELETE CASCADE,
id_document UUID REFERENCES documents_legaux(id),
type_document VARCHAR(50) NOT NULL, -- 'cgu' ou 'privacy'
version_document INTEGER NOT NULL, -- Version acceptée
accepte_le TIMESTAMPTZ DEFAULT now(), -- Date d'acceptation
ip_address INET, -- IP de l'utilisateur (RGPD)
user_agent TEXT -- Navigateur (preuve)
type_document VARCHAR(50) NOT NULL,
version_document INTEGER NOT NULL,
accepte_le TIMESTAMPTZ DEFAULT now(),
ip_address INET,
user_agent TEXT
);
-- Index pour performance
CREATE INDEX idx_acceptations_utilisateur ON acceptations_documents(id_utilisateur);
CREATE INDEX idx_acceptations_document ON acceptations_documents(id_document);
-- ==========================================================
-- Table : relais
-- ==========================================================
CREATE TABLE relais (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
nom VARCHAR(255) NOT NULL,
adresse TEXT NOT NULL,
horaires_ouverture JSONB,
ligne_fixe VARCHAR(20),
actif BOOLEAN DEFAULT true,
notes TEXT,
cree_le TIMESTAMPTZ DEFAULT now(),
modifie_le TIMESTAMPTZ DEFAULT now()
);
-- ==========================================================
-- Modification Table : utilisateurs (ajout colonnes documents et relais)
-- ==========================================================
ALTER TABLE utilisateurs
ADD COLUMN IF NOT EXISTS cgu_version_acceptee INTEGER,
ADD COLUMN IF NOT EXISTS cgu_acceptee_le TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS privacy_version_acceptee INTEGER,
ADD COLUMN IF NOT EXISTS privacy_acceptee_le TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS relais_id UUID REFERENCES relais(id) ON DELETE SET NULL;
-- ==========================================================
-- Ticket #103 : Numéro de dossier (AAAA-NNNNNN, séquence par année)
-- ==========================================================
CREATE TABLE IF NOT EXISTS numero_dossier_sequence (
annee INT PRIMARY KEY,
prochain INT NOT NULL DEFAULT 1
CREATE TABLE numero_dossier_sequence (
annee INT PRIMARY KEY,
prochain INT NOT NULL DEFAULT 1
);
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL;
ALTER TABLE assistantes_maternelles ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL;
ALTER TABLE parents ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL;
CREATE INDEX IF NOT EXISTS idx_utilisateurs_numero_dossier ON utilisateurs(numero_dossier) WHERE numero_dossier IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_assistantes_maternelles_numero_dossier ON assistantes_maternelles(numero_dossier) WHERE numero_dossier IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_parents_numero_dossier ON parents(numero_dossier) WHERE numero_dossier IS NOT NULL;
-- ==========================================================
-- Ticket #110 : Token reprise après refus (lien email)
-- ==========================================================
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS token_reprise VARCHAR(255) NULL;
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS token_reprise_expire_le TIMESTAMPTZ NULL;
CREATE INDEX IF NOT EXISTS idx_utilisateurs_token_reprise ON utilisateurs(token_reprise) WHERE token_reprise IS NOT NULL;
-- ==========================================================
-- Ticket #127 : Mot de passe oublié (token dédié, ≠ inscription)
-- ==========================================================
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS password_reset_token VARCHAR(255) NULL;
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS password_reset_expires TIMESTAMPTZ NULL;
CREATE INDEX IF NOT EXISTS idx_utilisateurs_password_reset_token ON utilisateurs(password_reset_token) WHERE password_reset_token IS NOT NULL;
-- Lieu de naissance (aligné CREATE TABLE utilisateurs — idempotent si colonnes déjà présentes)
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS lieu_naissance_ville VARCHAR(100) NULL;
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS lieu_naissance_pays VARCHAR(100) NULL;
-- ==========================================================
-- Seed : Documents légaux génériques v1
@@ -438,10 +449,7 @@ INSERT INTO documents_legaux (type, version, fichier_nom, fichier_path, fichier_
-- ==========================================================
-- Seed : Super Administrateur par défaut
-- ==========================================================
-- Email: admin@ptits-pas.fr
-- Mot de passe: 4dm1n1strateur (hashé bcrypt)
-- IMPORTANT: Changer ce mot de passe en production !
-- Email: admin@ptits-pas.fr | Mot de passe: 4dm1n1strateur
-- ==========================================================
INSERT INTO utilisateurs (
email,
+6 -5
View File
@@ -15,12 +15,13 @@ Ce projet contient la **base de données** pour l'application PtitsPas, avec scr
## Structure du projet
- `migrations/` : scripts SQL pour la création et l'import de la base
- `bdd/data_test/` : fichiers CSV pour l'import de données de test
- `docs/` : documentation métier et technique
- `seed/` : scripts de seed
- **`BDD.sql`** : schéma canonique (création from scratch — utilisé par `docker-compose.yml` racine)
- `migrations/` : scripts SQL pour **mettre à jour** une BDD déjà en service
- `bdd/data_test/` : fichiers CSV (import legacy)
- `docs/` : documentation métier et technique (`ENUMS.md`, `FK_POLICIES.md`)
- `seed/` : scripts de seed (`03_seed_test_data.sql` — jeu dashboard admin)
- `tests/` : tests SQL
- `docker-compose.dev.yml` : configuration Docker pour le développement
- `docker-compose.dev.yml` : Postgres standalone (BDD.sql + seed auto)
---
+10 -10
View File
@@ -1,10 +1,10 @@
"id","statut","prenom","nom","genre","date_naissance","date_prevue_naissance","photo_url","consentement_photo","date_consentement_photo","est_multiple"
"5e8574b7-63e6-4d48-9af3-8d3bf7a6a6cf","actif","Emma","Dupont","F","2020-06-01",,,False,,False
"a5c3268e-07eb-41a4-9f6c-2f9f16f37c3d","actif",,,,"2020-01-01","2025-01-01",,False,,False
"e1a2b3c4-d5e6-4f7a-8b9c-1d2e3f4a5b6c","actif","Emma","Martin",,"2023-02-15",,,False,,False
"e2b3c4d5-e6f7-4a8b-9c1d-2e3f4a5b6c7d","actif","Noah","Martin",,"2023-02-15",,,False,,False
"e3c4d5e6-f7a8-4b9c-1d2e-3f4a5b6c7d8e","actif","Léa","Martin",,"2023-02-15",,,False,,False
"e4d5e6f7-a8b9-4c1d-2e3f-4a5b6c7d8e9f","actif","Chloé","Rousseau",,"2022-04-20",,,False,,False
"e5e6f7a8-b9c1-4d2e-3f4a-5b6c7d8e9f1a","actif","Hugo","Rousseau",,"2024-03-10",,,False,,False
"e6f7a8b9-c1d2-4e3f-5a6b-7c8d9e0f1a2b","actif","Maxime","Lecomte",,"2023-04-15",,,False,,False
"edd19cd1-bb67-4f14-8a37-c66b75c94537","scolarise","Lucas","Durand","H","2018-09-15",,,False,,False
"id","statut","prenom","nom","genre","date_naissance","date_prevue_naissance","photo_url","consentement_photo","date_consentement_photo","est_multiple"
"5e8574b7-63e6-4d48-9af3-8d3bf7a6a6cf","sans_garde","Emma","Dupont","F","2020-06-01",,,False,,False
"a5c3268e-07eb-41a4-9f6c-2f9f16f37c3d","sans_garde",,,,"2020-01-01","2025-01-01",,False,,False
"e1a2b3c4-d5e6-4f7a-8b9c-1d2e3f4a5b6c","sans_garde","Emma","Martin",,"2023-02-15",,,False,,False
"e2b3c4d5-e6f7-4a8b-9c1d-2e3f4a5b6c7d","sans_garde","Noah","Martin",,"2023-02-15",,,False,,False
"e3c4d5e6-f7a8-4b9c-1d2e-3f4a5b6c7d8e","sans_garde","Léa","Martin",,"2023-02-15",,,False,,False
"e4d5e6f7-a8b9-4c1d-2e3f-4a5b6c7d8e9f","sans_garde","Chloé","Rousseau",,"2022-04-20",,,False,,False
"e5e6f7a8-b9c1-4d2e-3f4a-5b6c7d8e9f1a","sans_garde","Hugo","Rousseau",,"2024-03-10",,,False,,False
"e6f7a8b9-c1d2-4e3f-5a6b-7c8d9e0f1a2b","sans_garde","Maxime","Lecomte",,"2023-04-15",,,False,,False
"edd19cd1-bb67-4f14-8a37-c66b75c94537","scolarise","Lucas","Durand","H","2018-09-15",,,False,,False
1 id statut prenom nom genre date_naissance date_prevue_naissance photo_url consentement_photo date_consentement_photo est_multiple
2 5e8574b7-63e6-4d48-9af3-8d3bf7a6a6cf actif sans_garde Emma Dupont F 2020-06-01 False False
3 a5c3268e-07eb-41a4-9f6c-2f9f16f37c3d actif sans_garde 2020-01-01 2025-01-01 False False
4 e1a2b3c4-d5e6-4f7a-8b9c-1d2e3f4a5b6c actif sans_garde Emma Martin 2023-02-15 False False
5 e2b3c4d5-e6f7-4a8b-9c1d-2e3f4a5b6c7d actif sans_garde Noah Martin 2023-02-15 False False
6 e3c4d5e6-f7a8-4b9c-1d2e-3f4a5b6c7d8e actif sans_garde Léa Martin 2023-02-15 False False
7 e4d5e6f7-a8b9-4c1d-2e3f-4a5b6c7d8e9f actif sans_garde Chloé Rousseau 2022-04-20 False False
8 e5e6f7a8-b9c1-4d2e-3f4a-5b6c7d8e9f1a actif sans_garde Hugo Rousseau 2024-03-10 False False
9 e6f7a8b9-c1d2-4e3f-5a6b-7c8d9e0f1a2b actif sans_garde Maxime Lecomte 2023-04-15 False False
10 edd19cd1-bb67-4f14-8a37-c66b75c94537 scolarise Lucas Durand H 2018-09-15 False False
+2 -3
View File
@@ -14,9 +14,8 @@ services:
ports:
- "5433:5432"
volumes:
- ./migrations/01_init.sql:/docker-entrypoint-initdb.d/01_init.sql
- ./migrations/07_import.sql:/docker-entrypoint-initdb.d/07_import.sql
- ./bdd/data_test:/bdd/data_test
- ./BDD.sql:/docker-entrypoint-initdb.d/01_init.sql
- ./seed/03_seed_test_data.sql:/docker-entrypoint-initdb.d/02_seed_test_data.sql
- postgres_standalone_data:/var/lib/postgresql/data
networks:
- ptitspas_dev
+7 -2
View File
@@ -51,12 +51,17 @@ Ce document recense **toutes les valeurs énumérées** utilisées dans la base
| Valeur | Description |
|---|---|
| `a_naitre` | Enfant à naître (date prévue renseignée) |
| `actif` | Enfant pris en charge / en cours de garde |
| `sans_garde` | Enfant né, pas encore placé chez une AM (défaut à l'inscription) |
| `garde` | Enfant placé chez une AM (`enfants_assistantes_maternelles` actif) |
| `scolarise` | Enfant scolarisé, garde potentiellement périscolaire |
**Contraintes associées** :
- `a_naitre`**`date_prevue_naissance` obligatoire**
- `actif`/`scolarise`**`date_naissance` obligatoire**
- `sans_garde` / `garde` / `scolarise`**`date_naissance` obligatoire**
**Transitions** (API admin #131) :
- Rattachement AM ↔ enfant → `garde` (sauf `a_naitre` / `scolarise`)
- Détachement sans autre placement actif → `sans_garde`
---
+3
View File
@@ -32,6 +32,9 @@ Documenter, de façon unique et partagée, les règles de suppression/mise à jo
| **parents(id_co_parent)**`utilisateurs(id)` | **SET NULL** | Conserver le parent principal si co-parent disparaît |
| **enfants_parents(id_parent)**`parents(id_utilisateur)` | **CASCADE** | Nettoyage liaisons N:N |
| **enfants_parents(id_enfant)**`enfants(id)` | **CASCADE** | Idem |
| **enfants_assistantes_maternelles(id_am)**`assistantes_maternelles(id_utilisateur)` | **CASCADE** | Placement supprimé avec lAM |
| **enfants_assistantes_maternelles(id_enfant)**`enfants(id)` | **CASCADE** | Idem |
| **enfants_assistantes_maternelles(cree_par)**`utilisateurs(id)` | **SET NULL** | Historique placement conservé |
| **dossiers(id_parent)**`parents(id_utilisateur)` | **CASCADE** | Dossier na pas de sens sans parent |
| **dossiers(id_enfant)**`enfants(id)` | **CASCADE** | Dossier na pas de sens sans enfant |
| **messages(id_dossier)**`dossiers(id)` | **CASCADE** | Messages détruits avec le dossier |
@@ -0,0 +1,36 @@
-- Ticket #131 / #141 — Placement AM ↔ enfant + statuts garde/sans_garde
-- ⚠️ BDD EXISTANTES UNIQUEMENT — ne pas exécuter sur une base créée via BDD.sql à jour.
-- Idempotent : safe à rejouer sur recette / prod.
-- 1) Nouveaux statuts enfant
DO $$ BEGIN
ALTER TYPE statut_enfant_type ADD VALUE IF NOT EXISTS 'garde';
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TYPE statut_enfant_type ADD VALUE IF NOT EXISTS 'sans_garde';
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- actif → sans_garde (enfants sans placement AM connu au déploiement)
UPDATE enfants SET statut = 'sans_garde' WHERE statut = 'actif';
-- 2) Table de placement AM ↔ enfant
CREATE TABLE IF NOT EXISTS enfants_assistantes_maternelles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
id_am UUID NOT NULL REFERENCES assistantes_maternelles(id_utilisateur) ON DELETE CASCADE,
id_enfant UUID NOT NULL REFERENCES enfants(id) ON DELETE CASCADE,
date_debut DATE NOT NULL DEFAULT CURRENT_DATE,
date_fin DATE NULL,
cree_le TIMESTAMPTZ NOT NULL DEFAULT now(),
cree_par UUID REFERENCES utilisateurs(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_eam_am ON enfants_assistantes_maternelles(id_am);
CREATE INDEX IF NOT EXISTS idx_eam_enfant ON enfants_assistantes_maternelles(id_enfant);
-- Au plus une garde active par enfant
CREATE UNIQUE INDEX IF NOT EXISTS uq_enfant_garde_active
ON enfants_assistantes_maternelles (id_enfant)
WHERE date_fin IS NULL;
+2 -2
View File
@@ -65,12 +65,12 @@ ON CONFLICT (id_utilisateur) DO NOTHING;
-- ------------------------------------------------------------
-- Enfants
-- - child A : déjà né (statut = 'actif' et date_naissance requise)
-- - child A : déjà né (statut = 'sans_garde' et date_naissance requise)
-- - child B : à naître (statut = 'a_naitre' et date_prevue_naissance requise)
-- ------------------------------------------------------------
INSERT INTO enfants (id, prenom, nom, statut, date_naissance, jumeau_multiple)
VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Léo', 'Parent', 'actif', '2022-04-12', false)
VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Léo', 'Parent', 'sans_garde', '2022-04-12', false)
ON CONFLICT (id) DO NOTHING;
INSERT INTO enfants (id, prenom, nom, statut, date_prevue_naissance, jumeau_multiple)
+15 -15
View File
@@ -18,15 +18,15 @@ BEGIN;
INSERT INTO utilisateurs (id, email, password, prenom, nom, role, statut, telephone, adresse, ville, code_postal, profession, situation_familiale, date_naissance, consentement_photo)
VALUES
('a0000001-0001-0001-0001-000000000001', 'sophie.bernard@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Sophie', 'BERNARD', 'administrateur', 'actif', '0678123456', '12 Avenue Gabriel Péri', 'Bezons', '95870', 'Responsable administrative', 'marie', '1978-03-15', false),
('a0000002-0002-0002-0002-000000000002', 'lucas.moreau@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Lucas', 'MOREAU', 'gestionnaire', 'actif', '0687234567', '8 Rue Jean Jaurès', 'Bezons', '95870', 'Gestionnaire des placements', 'celibataire', '1985-09-22', false),
('a0000003-0003-0003-0003-000000000003', 'marie.dubois@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Marie', 'DUBOIS', 'assistante_maternelle', 'actif', '0696345678', '25 Rue de la République', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1980-06-08', true),
('a0000004-0004-0004-0004-000000000004', 'fatima.elmansouri@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Fatima', 'EL MANSOURI', 'assistante_maternelle', 'actif', '0675456789', '17 Boulevard Aristide Briand', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1975-11-12', true),
('a0000005-0005-0005-0005-000000000005', 'claire.martin@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Claire', 'MARTIN', 'parent', 'actif', '0689567890', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Infirmière', 'marie', '1990-04-03', false),
('a0000006-0006-0006-0006-000000000006', 'thomas.martin@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Thomas', 'MARTIN', 'parent', 'actif', '0678456789', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Ingénieur', 'marie', '1988-07-18', false),
('a0000007-0007-0007-0007-000000000007', 'amelie.durand@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Amélie', 'DURAND', 'parent', 'actif', '0667788990', '23 Rue Victor Hugo', 'Bezons', '95870', 'Comptable', 'divorce', '1987-12-14', false),
('a0000008-0008-0008-0008-000000000008', 'julien.rousseau@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Julien', 'ROUSSEAU', 'parent', 'actif', '0656677889', '14 Rue Pasteur', 'Bezons', '95870', 'Commercial', 'divorce', '1985-08-29', false),
('a0000009-0009-0009-0009-000000000009', 'david.lecomte@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'David', 'LECOMTE', 'parent', 'actif', '0645566778', '31 Rue Émile Zola', 'Bezons', '95870', 'Développeur web', 'parent_isole', '1992-10-07', false)
('a0000001-0001-0001-0001-000000000001', 'sophie.bernard@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Sophie', 'BERNARD', 'administrateur', 'actif', '0678123456', '12 Avenue Gabriel Péri', 'Bezons', '95870', 'Responsable administrative', 'marie', '1978-03-15', false),
('a0000002-0002-0002-0002-000000000002', 'lucas.moreau@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Lucas', 'MOREAU', 'gestionnaire', 'actif', '0687234567', '8 Rue Jean Jaurès', 'Bezons', '95870', 'Gestionnaire des placements', 'celibataire', '1985-09-22', false),
('a0000003-0003-0003-0003-000000000003', 'marie.dubois@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Marie', 'DUBOIS', 'assistante_maternelle', 'actif', '0696345678', '25 Rue de la République', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1980-06-08', true),
('a0000004-0004-0004-0004-000000000004', 'fatima.elmansouri@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Fatima', 'EL MANSOURI', 'assistante_maternelle', 'actif', '0675456789', '17 Boulevard Aristide Briand', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1975-11-12', true),
('a0000005-0005-0005-0005-000000000005', 'claire.martin@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Claire', 'MARTIN', 'parent', 'actif', '0689567890', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Infirmière', 'marie', '1990-04-03', false),
('a0000006-0006-0006-0006-000000000006', 'thomas.martin@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Thomas', 'MARTIN', 'parent', 'actif', '0678456789', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Ingénieur', 'marie', '1988-07-18', false),
('a0000007-0007-0007-0007-000000000007', 'amelie.durand@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Amélie', 'DURAND', 'parent', 'actif', '0667788990', '23 Rue Victor Hugo', 'Bezons', '95870', 'Comptable', 'divorce', '1987-12-14', false),
('a0000008-0008-0008-0008-000000000008', 'julien.rousseau@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Julien', 'ROUSSEAU', 'parent', 'actif', '0656677889', '14 Rue Pasteur', 'Bezons', '95870', 'Commercial', 'divorce', '1985-08-29', false),
('a0000009-0009-0009-0009-000000000009', 'david.lecomte@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'David', 'LECOMTE', 'parent', 'actif', '0645566778', '31 Rue Émile Zola', 'Bezons', '95870', 'Développeur web', 'parent_isole', '1992-10-07', false)
ON CONFLICT (email) DO NOTHING;
-- ========== PARENTS (avec co-parent pour le couple Martin) ==========
@@ -51,12 +51,12 @@ ON CONFLICT (id_utilisateur) DO NOTHING;
-- ========== ENFANTS ==========
INSERT INTO enfants (id, prenom, nom, genre, date_naissance, statut, est_multiple)
VALUES
('e0000001-0001-0001-0001-000000000001', 'Emma', 'MARTIN', 'F', '2023-02-15', 'actif', true),
('e0000002-0002-0002-0002-000000000002', 'Noah', 'MARTIN', 'H', '2023-02-15', 'actif', true),
('e0000003-0003-0003-0003-000000000003', 'Léa', 'MARTIN', 'F', '2023-02-15', 'actif', true),
('e0000004-0004-0004-0004-000000000004', 'Chloé', 'ROUSSEAU', 'F', '2022-04-20', 'actif', false),
('e0000005-0005-0005-0005-000000000005', 'Hugo', 'ROUSSEAU', 'H', '2024-03-10', 'actif', false),
('e0000006-0006-0006-0006-000000000006', 'Maxime', 'LECOMTE', 'H', '2023-04-15', 'actif', false)
('e0000001-0001-0001-0001-000000000001', 'Emma', 'MARTIN', 'F', '2023-02-15', 'sans_garde', true),
('e0000002-0002-0002-0002-000000000002', 'Noah', 'MARTIN', 'H', '2023-02-15', 'sans_garde', true),
('e0000003-0003-0003-0003-000000000003', 'Léa', 'MARTIN', 'F', '2023-02-15', 'sans_garde', true),
('e0000004-0004-0004-0004-000000000004', 'Chloé', 'ROUSSEAU', 'F', '2022-04-20', 'sans_garde', false),
('e0000005-0005-0005-0005-000000000005', 'Hugo', 'ROUSSEAU', 'H', '2024-03-10', 'sans_garde', false),
('e0000006-0006-0006-0006-000000000006', 'Maxime', 'LECOMTE', 'H', '2023-04-15', 'sans_garde', false)
ON CONFLICT (id) DO NOTHING;
-- ========== ENFANTS_PARENTS (liaison N:N) ==========
@@ -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 lAPI** (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 lentité `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 dattention (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 saffichera 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 AMenfant aujourdhui (à 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**
@@ -0,0 +1,244 @@
# #112 — Alignement front après évolution back (reprise dossier complet)
**Branche déployée :** `feature/112-reprise-apres-refus-front`
**Commit back :** `d70577b1``feat(#112): reprise après refus — dossier complet GET/PATCH`
**Date :** 2026-06-16
Ce document décrit le **contrat API réel** après extension du back, et ce que le front doit encore brancher pour exploiter le dossier complet (au-delà de lidentité seule).
---
## 1. Endpoints (inchangés côté URL)
| Méthode | Route | Auth |
|---------|-------|------|
| `GET` | `/api/v1/auth/reprise-dossier?token={uuid}` | Public |
| `PATCH` | `/api/v1/auth/reprise-resoumettre` | Public |
| `POST` | `/api/v1/auth/reprise-identify` | Public (inchangé) |
> **Note :** le ticket #111 parlait de `PUT` ; limplémentation reste en **`PATCH`** (comme avant).
---
## 2. `GET /auth/reprise-dossier` — réponse enrichie
### Champs communs (toujours présents)
Identiques à avant : `id`, `email`, `prenom`, `nom`, `telephone`, `adresse`, `ville`, `code_postal`, `numero_dossier`, `role`, `photo_url`, `genre`, `situation_familiale`.
### Rôle `parent` (+ champs #119)
Alignés sur `DossierFamilleCompletDto` :
```json
{
"parents": [
{
"user_id": "uuid",
"email": "…",
"prenom": "…",
"nom": "…",
"telephone": "…",
"adresse": "…",
"ville": "…",
"code_postal": "…",
"statut": "refuse",
"co_parent_id": "uuid-parent-entity"
}
],
"enfants": [
{
"id": "uuid-enfant",
"first_name": "Emma",
"last_name": "MARTIN",
"genre": "F",
"status": "actif",
"birth_date": "2023-02-15T00:00:00.000Z",
"due_date": null,
"photo_url": "/uploads/photos/…",
"consent_photo": true,
"est_multiple": false
}
],
"texte_motivation": "Nous recherchons…"
}
```
**Mapping front suggéré :**
| JSON back | Modèle / wizard parent |
|-----------|-------------------------|
| `parents[]` | `UserRegistrationData.parent1` + `parent2` (matcher par `email` ou ordre : titulaire = `id` du GET racine) |
| `enfants[].first_name` / `last_name` | `ChildData.firstName` / `lastName` |
| `enfants[].birth_date` | `ChildData.birthDate` (ISO → `DateTime`) |
| `enfants[].due_date` | `ChildData.dueDate` (enfant `a_naitre`) |
| `enfants[].status` | `actif` = né, `a_naitre` = à naître |
| `enfants[].photo_url` | `ApiConfig.absoluteMediaUrl()` + conserver pour reprise sans re-upload |
| `enfants[].id` | **Obligatoire** pour le PATCH (update par id) |
| `enfants[].est_multiple` | `grossesse_multiple` si utilisé |
| `texte_motivation` | étape présentation / motivation |
Si `numero_dossier` absent : pas de `parents[]` / `enfants[]` / `texte_motivation` (identité seule).
### Rôle `assistante_maternelle`
Champs racine + fiche pro (structure **aplatie**, pas de sous-objet `user`) :
```json
{
"consentement_photo": true,
"date_naissance": "1985-03-12T00:00:00.000Z",
"lieu_naissance_ville": "Paris",
"lieu_naissance_pays": "France",
"numero_agrement": "AGR-2024-12345",
"nir": "123456789012345",
"date_agrement": "2024-06-01T00:00:00.000Z",
"nb_max_enfants": 4,
"place_disponible": 2,
"biographie": "…"
}
```
**Mapping `AmRegistrationData` :**
| JSON back | Champ front |
|-----------|-------------|
| `nb_max_enfants` | `capaciteAccueil` |
| `place_disponible` | `placesDisponibles` |
| `numero_agrement` | `numeroAgrement` |
| `biographie` | `biographie` / présentation |
| `photo_url` | déjà géré via `RepriseSession.photoUrl` |
---
## 3. `PATCH /auth/reprise-resoumettre` — body étendu
### Commun
```json
{ "token": "uuid-reprise" }
```
### Parent — champs à envoyer depuis le wizard
| Champ PATCH | Source wizard | Notes |
|-------------|---------------|-------|
| `prenom`, `nom`, `telephone`, `adresse`, `ville`, `code_postal` | Parent 1 (titulaire token) | Champs racine |
| `co_parent_prenom`, `co_parent_nom`, `co_parent_telephone` | Parent 2 | |
| `co_parent_meme_adresse`, `co_parent_adresse`, `co_parent_code_postal`, `co_parent_ville` | Parent 2 adresse | |
| `texte_motivation` **ou** `presentation_dossier` | Étape motivation | Les deux alias acceptés |
| `enfants[]` | Liste enfants | Voir ci-dessous |
**Structure `enfants[]` (miroir inscription + `id` obligatoire) :**
```json
{
"id": "uuid-enfant-existant",
"prenom": "Emma",
"nom": "MARTIN",
"date_naissance": "2023-02-15",
"date_previsionnelle_naissance": null,
"genre": "F",
"photo_base64": "data:image/jpeg;base64,…",
"photo_filename": "emma.jpg",
"grossesse_multiple": false
}
```
- **v1 back :** update par `id` uniquement — pas de création/suppression denfant.
- Si `id` inconnu pour ce dossier → **400** `Enfant inconnu pour ce dossier : {id}`.
- Sans nouvelle photo : ne pas envoyer `photo_base64` (lexistant est conservé).
### AM — champs à envoyer
| Champ PATCH | Source |
|-------------|--------|
| Identité + `photo_url` ou `photo_base64` + `photo_filename` | Étapes 12 |
| `consentement_photo`, `date_naissance`, `lieu_naissance_ville`, `lieu_naissance_pays` | Identité |
| `numero_agrement`, `nir`, `date_agrement` | Pro |
| `capacite_accueil`, `places_disponibles` | Pro |
| `biographie` | Présentation |
Validation NIR identique à linscription si `nir` fourni.
### Réponse succès (nouveau format)
```json
{
"message": "Dossier resoumis avec succès. Il est de nouveau en attente de validation.",
"statut": "en_attente",
"user_id": "uuid",
"numero_dossier": "2026-000021"
}
```
Code HTTP : **200** (pas de corps `Users` brut comme lancien back).
### Effet métier
- **Parent :** tous les users `role=parent` avec le même `numero_dossier` passent en `en_attente` ; `token_reprise` invalidé sur **tous** (symétrique refus #110).
- **AM :** un seul user.
### E-mail accusé resoumission (parent)
Après `PATCH` réussi, un e-mail est envoyé à **chaque parent** du dossier (`sendResoumissionPendingEmail`) :
- confirmation de resoumission ;
- rappel du **numéro de dossier** ;
- mention « en attente de validation ».
Échec SMTP : logué, **ne bloque pas** la resoumission (même règle que l'inscription initiale).
---
## 4. Fichiers front à modifier (checklist)
### Modèles
- [ ] `lib/models/reprise_dossier.dart` — parser `parents[]`, `enfants[]`, `texte_motivation`, champs AM
- [ ] Réutiliser ou mapper vers `DossierFamilleEnfant` / structures existantes (#119 admin) si possible
### Session / préremplissage
- [ ] `lib/services/reprise_session.dart`
- `applyToParent` : remplir parent1/parent2 depuis `parents[]`, enfants, motivation
- `applyToAm` : remplir tous les champs AM
### API
- [ ] `lib/services/auth_service.dart``resoumettreReprise()` : accepter body complet (parent + AM), pas seulement identité
- [ ] Étendre `UserRegistrationData` / `AmRegistrationData` helpers `toReprisePatchBody()` si utile
### Écrans fin de parcours
- [ ] `parent_register_step5_screen.dart` — PATCH avec co-parent, enfants, motivation
- [ ] `am_register_step4_screen.dart` — PATCH avec fiche AM complète
### Hors scope back (inchangé)
RIB / IBAN / attestation CAF (étape 5 wizard parent) : **non persistés** — rien à envoyer en reprise.
### Non implémenté front (ticket #112 initial)
- [ ] Modale login « Jai un numéro de dossier » → `POST /auth/reprise-identify` (back prêt, front absent)
---
## 5. Tests manuels suggérés
1. Refuser un dossier parent complet (≥1 enfant + co-parent + motivation).
2. Ouvrir le lien mail `/reprise?token=…`.
3. Vérifier dans DevTools que le GET contient `enfants[]` et `texte_motivation`.
4. Après branchement front : wizard prérempli sur toutes les étapes.
5. Resoumettre → statut `en_attente` pour les deux parents ; dossier visible file validation admin (#119).
---
## 6. Références code back
```
backend/src/routes/auth/dto/reprise-dossier.dto.ts
backend/src/routes/auth/dto/resoumettre-reprise.dto.ts
backend/src/routes/auth/dto/enfant-reprise.dto.ts
backend/src/routes/auth/auth.service.ts → getRepriseDossier, resoumettreReprise
backend/src/routes/parents/dto/dossier-famille-complet.dto.ts
```
@@ -0,0 +1,132 @@
# #131 — Fiche AM éditable + affiliation enfants (note front → back)
**Ticket :** #131 (partie AM, doc `28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1)
**Date :** 2026-06-01
**Statut front :** modale livrée (2 onglets) — **API affiliation AM↔enfant à implémenter**
---
## 1. Comportement UI (front)
Modale `AdminAmEditModal` — même shell que la fiche parent (~930 px) :
| Onglet | Contenu |
|--------|---------|
| **Identité & professionnel** | `IdentityBlock` éditable + grille pro (agrément, ville résidence, capacité, places, NIR/agrément date en lecture seule, biographie, switch disponible) + gélule statut |
| **Enfants accueillis** | Liste cartes enfants (réutilise `AdminChildrenAffiliationPanel` / `AdminEnfantUserCard`) + rattacher / détacher |
En-tête : prénom nom · sous-titre `Zone · Agrément · Dossier`.
---
## 2. Endpoints consommés
### Déjà existants (partiels)
| Méthode | Route | Usage |
|---------|-------|-------|
| `GET` | `/api/v1/assistantes-maternelles` | Liste AM |
| `GET` | `/api/v1/assistantes-maternelles/:userId` | Détail (403 possible pour `administrateur` → fallback liste) |
| `PATCH` | `/api/v1/users/:userId` | Identité + statut (admin / super_admin uniquement) |
| `PATCH` | `/api/v1/assistantes-maternelles/:userId` | Champs pro (gestionnaire / super_admin) |
### À créer (recommandé — miroir parent #131 / #115)
| Méthode | Route | Rôle |
|---------|-------|------|
| `PATCH` | `/api/v1/assistantes-maternelles/:userId/fiche` | Mise à jour unifiée identité + pro + statut (`super_admin`, `gestionnaire`, `administrateur`) |
| `POST` | `/api/v1/assistantes-maternelles/:userId/enfants/:enfantId` | Rattacher un enfant |
| `DELETE` | `/api/v1/assistantes-maternelles/:userId/enfants/:enfantId` | Détacher un enfant |
| `GET` | `/api/v1/assistantes-maternelles/:userId` | Inclure `amChildren[]` (relation enfant) |
Le front appelle déjà ces routes ; en labsence de `PATCH …/fiche`, il tente un fallback `PATCH users` + `PATCH assistantes-maternelles` (échoue selon le rôle connecté).
---
## 3. Modèle de données affiliation AM ↔ enfant
**À définir côté BDD** (pas de table dédiée aujourdhui, contrairement à `enfants_parents`) :
Proposition alignée parent :
```sql
-- Piste : enfants_assistantes_maternelles
CREATE TABLE enfants_assistantes_maternelles (
id_am UUID NOT NULL REFERENCES utilisateurs(id) ON DELETE CASCADE,
id_enfant UUID NOT NULL REFERENCES enfants(id) ON DELETE CASCADE,
PRIMARY KEY (id_am, id_enfant)
);
```
Réponse API attendue sur `GET /assistantes-maternelles/:id` :
```json
{
"user_id": "uuid-am",
"user": { "id": "…", "prenom": "Claire", "nom": "MARTIN", "statut": "actif" },
"approval_number": "AGR-2024-12345",
"residence_city": "Bezons",
"max_children": 4,
"places_available": 2,
"available": true,
"amChildren": [
{
"child": {
"id": "uuid-enfant",
"first_name": "Emma",
"last_name": "MARTIN",
"status": "actif",
"birth_date": "2023-02-15"
}
}
]
}
```
Le front parse `amChildren` / `am_children` / `assistanteChildren` (même logique que `parentChildren`).
---
## 4. Body `PATCH …/fiche` suggéré
```json
{
"nom": "MARTIN",
"prenom": "Claire",
"email": "claire@example.com",
"telephone": "0612345678",
"adresse": "5 place Bellecour",
"ville": "Lyon",
"code_postal": "69002",
"statut": "actif",
"approval_number": "AGR-2024-12345",
"residence_city": "Lyon",
"max_children": 4,
"places_available": 2,
"biography": "…",
"available": true
}
```
NIR et date dagrément : lecture seule dans la modale (modification hors périmètre admin v1).
---
## 5. Fichiers front concernés
| Fichier | Rôle |
|---------|------|
| `frontend/lib/widgets/admin/common/admin_am_edit_modal.dart` | Modale 2 onglets |
| `frontend/lib/widgets/admin/common/admin_children_affiliation_panel.dart` | Liste enfants partagée parent/AM |
| `frontend/lib/widgets/admin/common/admin_status_capsule.dart` | Gélule statut partagée |
| `frontend/lib/models/assistante_maternelle_model.dart` | Parse champs pro + `amChildren` |
| `frontend/lib/services/user_service.dart` | `getAssistanteMaternelle`, `updateAmFiche`, `attachEnfantToAm`, `detachEnfantFromAm` |
| `frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart` | Ouverture modale au clic Modifier |
---
## 6. Références
- Fiche parent : `PATCH /parents/:id/fiche`, `POST|DELETE /parents/:id/enfants/:enfantId`
- Ticket Gitea **#131**, **#115**
- `docs/archive/temporaires/TEMP_131-back-fiche-parent-co-parent.md`
@@ -0,0 +1,124 @@
# #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** — ce document fixe le contrat attendu et invite à valider que lexistant le couvre.
---
## 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 lAPI** (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 lentité `Parents` sérialisée (`user`, `co_parent`, `parentChildren`, …).
---
## 4. État backend (à valider, pas à refaire)
Daprès le code actuel (`parents.service.ts`) :
- `findAll()` et `findOne(user_id)` chargent déjà la relation **`co_parent`** ;
- la FK métier est `parents.id_co_parent``utilisateurs.id` ;
- à linscription couple, les deux sens sont en principe renseignés (`auth.service.ts`).
**Checklist validation back :**
- [ ] `GET /parents/:id` renvoie bien `co_parent` peuplé quand `id_co_parent` est non null
- [ ] `GET /parents` (liste) inclut aussi `co_parent` (sous-titre disponible dès louverture sans re-fetch)
- [ ] Les champs `prenom` / `nom` du co-parent sont présents dans la réponse JSON
Si ces trois points passent en recette, **aucun changement backend nest nécessaire** pour cette fonctionnalité.
---
## 5. Points dattention (hors périmètre immédiat)
| Sujet | Détail |
|-------|--------|
| **Lien inverse** | Si le parent B est le co-parent de A (`A.id_co_parent = B`) mais que `B.id_co_parent` est `null`, le sous-titre **ne saffichera pas** sur la fiche de B. Le front ne fait pas de résolution inverse. À traiter côté back **seulement si** des données legacy ont un lien à sens unique. |
| **Familles > 2 adultes** | Le sous-titre naffiche que le co-parent direct (`id_co_parent`). Les autres responsables liés uniquement via `enfants_parents` ne sont pas listés ici (cf. doc 28 §6). |
| **Données sensibles** | Vérifier que la sérialisation de `co_parent` nexpose pas `password` / tokens (même remarque que pour `user`). |
---
## 6. Fichiers front concernés
| Fichier | Rôle |
|---------|------|
| `frontend/lib/models/parent_model.dart` | Parse `co_parent``AppUser? coParent` |
| `frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart` | Titre + sous-titre |
| `frontend/lib/services/user_service.dart` | `getParents()` / `getParent()` |
---
## 7. Références
- `docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1
- `backend/src/routes/parents/parents.service.ts``findOne`, `findAll`
- `backend/src/entities/parents.entity.ts` — relation `co_parent`
- Ticket Gitea **#131**
@@ -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.*
+7
View File
@@ -0,0 +1,7 @@
{
"folders": [
{
"path": "."
}
]
}
+89
View File
@@ -0,0 +1,89 @@
# Mini-spec API — POST /parents/dossier (#129)
Contrat pour le **plan front** (wizard création dossier famille staff).
Miroir de **#156** (`POST /assistantes-maternelles/dossier`).
## Endpoint
| | |
|--|--|
| **Méthode** | `POST` |
| **URL** | `{base}/parents/dossier` |
| **Auth** | Bearer JWT |
| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` |
| **Content-Type** | `application/json` |
Ne **pas** appeler `POST /auth/register/parent` depuis le dashboard.
## Body (JSON)
Aligné `RegisterParentCompletDto`, **sans** CGU/privacy obligatoires (acceptées serveur).
### Parent 1 (obligatoire)
| Champ | Type | Obligatoire | Notes |
|-------|------|-------------|--------|
| `email` | string | oui | unique |
| `prenom` | string | oui | |
| `nom` | string | oui | |
| `telephone` | string | oui | `0X…` ou `+33…` |
| `adresse` | string | non | |
| `code_postal` | string | non | |
| `ville` | string | non | |
### Co-parent (optionnel)
`co_parent_email`, `co_parent_prenom`, `co_parent_nom`, `co_parent_telephone`,
`co_parent_meme_adresse`, `co_parent_adresse`, `co_parent_code_postal`, `co_parent_ville`.
Si co-parent fourni : e-mail distinct ; mêmes règles téléphone / adresse que register.
### Enfants (≥ 1)
| Champ | Type | Notes |
|-------|------|--------|
| `enfants` | `EnfantInscriptionDto[]` | `prenom`, `nom`, `date_naissance` / `date_previsionnelle_naissance`, `genre`, `photo_base64`, `photo_filename`, etc. |
### Présentation
| Champ | Type | Obligatoire |
|-------|------|-------------|
| `presentation_dossier` | string | non (max 2000) |
## Réponses
### 201 Created
```json
{
"message": "Dossier famille créé et validé. Un e-mail de création de mot de passe a été envoyé.",
"numero_dossier": "2026-000043",
"parent_user_id": "uuid-pivot",
"co_parent_user_id": "uuid-ou-null",
"statut": "actif",
"enfant_ids": ["uuid", "..."]
}
```
Effets serveur : user(s) parent **actif**, fiches `parents`, enfants + foyer, n° dossier,
**e-mail création MDP** pour chaque compte sans MDP (pas daccusé « en attente »).
### Erreurs
| Code | Cas |
|------|-----|
| 400 | Validation DTO / métier (enfants vides, dates, etc.) |
| 401 | Token manquant / invalide |
| 403 | Rôle non staff |
| 409 | Conflit e-mail (pivot et/ou co-parent) |
## Front
- `UserService.createParentDossier(body)` → cet endpoint
- Wizard create basé sur `ValidationFamilyWizard`
- Ne pas envoyer `acceptation_cgu` / `acceptation_privacy` (optionnels)
## Branche
`feature/129-creation-dossier-parent`
@@ -0,0 +1,73 @@
# Mini-spec API — POST /parents/:id/co-parent (#135)
Contrat back pour lajout dun **2ᵉ parent** sur un foyer mono-parent (staff).
## Endpoint
| | |
|--|--|
| **Méthode** | `POST` |
| **URL** | `{base}/api/v1/parents/{parentUserId}/co-parent` |
| **Auth** | Bearer JWT |
| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` |
| **Succès** | **201** |
`parentUserId` = UUID du **parent pivot** (déjà dans le dossier).
Ne **pas** appeler `POST /auth/register/parent` ni `POST /parents/dossier`.
---
## Body (JSON)
| Champ | Type | Obligatoire | Notes |
|-------|------|-------------|--------|
| `email` | string | oui | unique |
| `prenom` | string | oui | |
| `nom` | string | oui | |
| `telephone` | string | oui | `0X…` ou `+33…` |
| `meme_adresse` | bool | non | défaut **true** → copie adresse du pivot |
| `adresse` | string | si `meme_adresse=false` | |
| `code_postal` | string | si `meme_adresse=false` | |
| `ville` | string | si `meme_adresse=false` | |
---
## Comportement 201
- User co-parent **actif** + token création MDP
- Fiche `parents` + liens pivot ↔ co-parent + même `numero_dossier`
- Enfants du foyer rattachés au co-parent
- E-mail **création MDP** (pas mail « en attente »)
```json
{
"message": "Co-parent ajouté au foyer. Un e-mail de création de mot de passe a été envoyé.",
"numero_dossier": "2026-000043",
"parent_user_id": "uuid-pivot",
"co_parent_user_id": "uuid-co",
"statut": "actif"
}
```
## Erreurs
| Code | Cas |
|------|-----|
| 400 | Déjà un co-parent / 2 responsables / validation adresse |
| 401 | Token invalide |
| 403 | Rôle non staff |
| 404 | Pivot introuvable |
| 409 | Email déjà pris |
## Réemploi édition identité
| Endpoint | Usage |
|----------|--------|
| `GET /dossiers/:numero` | Préremplir wizard edit |
| `PATCH /parents/:id/fiche` | Sauver identité pivot / co-parent existant |
| `PATCH /assistantes-maternelles/:id/fiche` | Édition AM |
## Branche
`feature/135-edition-dossier`
@@ -0,0 +1,83 @@
# Mini-spec front — Mode édition dossier + ajout 2ᵉ parent (#135)
Branche : `feature/135-edition-dossier`
Ticket : **#135** (full-stack)
Prérequis : **#153** (liste Dossiers) livré.
---
## Objectif
1. Clic sur un dossier (liste #153) → ouvrir le wizard en mode **`edit`**
2. Foyer **mono-parent** : page co-parent → **switch** ajouter un 2ᵉ parent
3. Sauvegarder les champs via APIs existantes + nouvel endpoint co-parent
---
## Modes wizard
| Mode | Famille | AM |
|------|---------|-----|
| `review` | déjà | déjà |
| `create` | déjà (#129) | déjà (#156) |
| **`edit`** | **à faire** | **à faire** |
Factories : `ParentDossierWizard.edit(...)` / `AmDossierWizard.edit(...)`
Préremplir via `UserService.getDossierByNumero(numero)`.
---
## APIs
| Action | Endpoint |
|--------|----------|
| Charger | `GET /dossiers/:numero` |
| Sauver parent | `PATCH /parents/:id/fiche` |
| Sauver AM | `PATCH /assistantes-maternelles/:id/fiche` |
| **Ajouter co-parent** | **`POST /parents/:pivotUserId/co-parent`** — voir `docs/tmp/135-contrat-api-ajout-co-parent.md` |
Body co-parent :
```json
{
"email": "thomas@…",
"prenom": "Thomas",
"nom": "MARTIN",
"telephone": "0678456789",
"meme_adresse": true
}
```
`UserService.addCoParent(pivotUserId, body)` → cet endpoint.
---
## UX
- Depuis `DossiersManagementWidget` / carte liste : clic → edit (plus seulement review pending)
- Pending : garder validation (review) ; dossiers actifs → edit
- Mono-parent : switch « Ajouter un co-parent » (comme create) → au save, `POST …/co-parent` si nouveau
- Déjà 2 parents : éditer les deux fiches ; pas de 3ᵉ
- Pas de bouton créer dans longlet Dossiers
---
## Hors scope
- Famille N responsables (#139)
- Suppressions (#154)
- Création dossier initial (#129 / #156)
---
## Critères dacceptation
- [ ] Clic dossier actif → wizard edit prérempli
- [ ] PATCH fiche enregistre les modifs
- [ ] Mono-parent + switch → co-parent créé (actif + mail MDP)
- [ ] review / create inchangés
## Branche
`feature/135-edition-dossier`
@@ -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é dunité |
| `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` dabord, 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`
+167
View File
@@ -0,0 +1,167 @@
# Mini-spec front — Onglet permanent « Dossiers » (#153)
Branche Git (front + back) : `feature/153-onglet-dossiers`
Ticket Gitea : **#153** (ticket normal, plus epic)
> Suite prévue : **#135** = au clic, mode **édition** wizard + ajout 2ᵉ parent.
> **#153** = onglet + listes + navigation / validation pending. **Pas** de création, **pas** d’édition complète.
---
## Contexte / objectif
Remplacer longlet conditionnel **« À valider »** (apparaît/disparaît selon pending) par un onglet **permanent « Dossiers »** dans le dashboard admin/gestionnaire.
Quand on ouvre **Dossiers** :
1. **En haut** — section **Dossiers à valider** (AM + familles pending)
2. **En dessous** — liste de **tous les dossiers** (familles **et** AM), 1 ligne = 1 `numero_dossier`
3. Différenciation visuelle famille vs AM : **couleur + icône**
4. **Barre de recherche** (n° dossier, nom, email…)
**Pas** de bouton « Créer un dossier » ici (création via **+ Parents** #129 / **+ Asmat** #156).
---
## UX cible
### Onglets dashboard (`UserManagementPanel`)
| Avant (#107) | Après (#153) |
|--------------|--------------|
| « À valider » **conditionnel** si pending | **« Dossiers » toujours visible** (admin + gestionnaire) |
| Contenu = seulement pending | Pending **en haut** + liste complète **en bas** |
Ordre suggéré des onglets :
`Dossiers` | `Parents` | `Enfants` | `Assistantes maternelles` | `Gestionnaires` | (`Administrateurs`)
### Section haute — À valider
- Réutiliser / adapter `PendingValidationWidget` (ou extraire la liste dans un sous-widget).
- Sources déjà branchées :
- `UserService.getPendingUsers(role: 'assistante_maternelle')`
- `UserService.getPendingFamilies()`
- Clic ligne pending → **`ValidationDossierModal`** / wizards `.review` (inchangé).
- Si section vide : ne pas afficher de gros vide ; masquer la section ou message court « Aucun dossier en attente ».
### Section basse — Tous les dossiers
1 ligne = **1 dossier** (`numero_dossier`), type :
| Type | Libellé UI | Couleur (suggestion) |
|------|------------|----------------------|
| `famille` | Famille / Parents | teinte existante parents (ex. violet / rose dashboard) |
| `assistante_maternelle` | AM | teinte existante AM (ex. teal / bleu) |
Colonnes / infos utiles (cartes style `AdminUserCard` ou lignes type pending) :
- n° dossier
- type (pastille couleur + icône)
- libellé (noms parents ou AM)
- email(s) principal(aux)
- statut user / dossier si dispo (`actif`, `en_attente`, …)
- date utile si dispo
**Déduplication** : un foyer (pivot + co-parent) = **une** ligne famille (même `numero_dossier`). Idem AM.
### Recherche
- La search bar du panel (aujourdhui désactivée / hint « pas de recherche » sur À valider) doit **filtrer la liste unifiée** (et idéalement aussi le pending affiché).
- Critères **minimum** : `numero_dossier`, nom, prénom, email.
- Harmoniser le hint : `Rechercher un dossier (n°, nom, email)…`
### État vide liste complète
Aide optionnelle : *« Pour créer un dossier → onglet Parents (+ Parents) ou Assistantes maternelles (+ Asmat) »*.
### Clic sur un dossier de la liste complète (#153)
| Cas | Comportement #153 |
|-----|-------------------|
| Pending | Ouvrir validation (review) — déjà en place |
| Dossier **actif** / non pending | Ouvrir consultation via `GET /dossiers/:numeroDossier` (`UserService.getDossierByNumero`) en **lecture / review** si possible **sans** save édition |
**Ne pas** implémenter le mode `edit` ni le switch 2ᵉ parent → **#135**.
Si louverture « review » dun dossier actif est trop lourde pour ce ticket : clic peut temporairement no-op / snackbar *« Édition dossier : prochainement (#135) »***à éviter** si `getDossierByNumero` + wizard review marche déjà pour les deux types.
---
## Données / APIs (front)
### Déjà disponibles (préférer composer côté front pour #153)
| Besoin | API / service |
|--------|----------------|
| Pending AM | `getPendingUsers(role: assistante_maternelle)` |
| Pending familles | `getPendingFamilies()` |
| Parents (avec `numero_dossier`) | `getParents()` |
| AM (avec `numero_dossier`) | `getAssistantesMaternelles()` |
| Détail unifié | `getDossierByNumero(numero)``GET /dossiers/:numeroDossier` |
**Pas dendpoint `GET /dossiers` liste** aujourdhui. Pour #153 :
- Construire la liste unifiée **côté client** à partir de `getParents()` + `getAssistantesMaternelles()` (group by `numero_dossier`).
- Exclure ou marquer les pending déjà dans la section haute (éviter doublons visuels, ou les laisser dans les deux avec badge « à valider » — **préférence** : pending **uniquement** en haut ; liste basse = tous **hors** pending **ou** tous avec badge ; choisir une règle claire et documenter dans le PR).
**Règle recommandée** :
- Haut = pending only
- Bas = **tous** les dossiers ayant un `numero_dossier` (y compris pending) **OU** bas = non-pending only
**Recommandation produit** : bas = **tous** (vision complète), pending aussi en haut pour action rapide. Si doublon gênant : bas = non-pending only.
### Si le back ajoute plus tard `GET /dossiers`
Brancher `UserService.getDossiers()` — hors scope bloquant #153 front si composition client OK.
---
## Fichiers front probables
| Fichier | Rôle |
|---------|------|
| `frontend/lib/widgets/admin/user_management_panel.dart` | Onglet permanent **Dossiers** ; retirer logique conditionnelle À valider ; search sur cet onglet |
| `frontend/lib/widgets/admin/pending_validation_widget.dart` | Réemploi section haute (ou refactor léger) |
| **Nouveau** `…/dossiers_management_widget.dart` (nom libre) | Shell onglet : pending + liste unifiée + refresh |
| **Nouveau** modèle léger `DossierListItem` (type, numero, libelle, emails, statut…) | Mapping parents/AM → ligne |
| `user_service.dart` / `api_config.dart` | Seulement si helper `getDossiersUnified()` côté client (pas forcément nouvel endpoint) |
| `validation_dossier_modal.dart` | Réemploi ouverture pending / détail |
Réutiliser look & feel cartes / hover « Ouvrir » de `_PendingValidationRow` / `AdminUserCard`.
---
## Hors scope (#153)
- Bouton créer dossier
- Mode `edit` wizard + ajout 2ᵉ parent → **#135**
- Suppressions → **#154**
- Famille N responsables → **#139**
- Changer les onglets Parents / AM / Enfants (restent)
---
## Critères dacceptation front
- [ ] Onglet **Dossiers** toujours visible (même 0 pending)
- [ ] Plus donglet conditionnel **« À valider »**
- [ ] Section haute pending si non vide ; validation au clic OK
- [ ] Liste unifiée familles + AM en dessous ; 1 ligne / `numero_dossier`
- [ ] Couleur + icône différencient famille / AM
- [ ] Recherche filtre (n° + nom + email minimum)
- [ ] **Aucun** bouton créer dans cet onglet
- [ ] Pas de régression validation pending (valider / refuser)
---
## Back (info — Cursor back séparé si besoin)
- Liste unifiée : **pas bloquante** si composition front
- Optionnel : `GET /api/v1/dossiers` (liste) pour perf / pagination plus tard
- `GET /dossiers/:numero` déjà là (#119)
---
## Branche
`feature/153-onglet-dossiers` (depuis `develop`)
+35
View File
@@ -0,0 +1,35 @@
# Matrice suppression — #154 / back **#159** / front **#160**
**Statut** : cadrage PO validé (sept. 2026)
**Milestone** : 0.1.0
**Email** : pas demail 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 dentré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).
+169
View File
@@ -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 dimpact) → DELETE → **refresh** liste
- Pending = **mêmes** règles que validés
- **Pas** demail
| 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 : « Lenfant sera supprimé du dossier de {famille} ({n°}). »
- Dernier : proposer **deux actions** :
1. Supprimer lenfant 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)
@@ -0,0 +1,54 @@
# Mini-spec — Rename préfixe `Admin*` dashboard partagé (#155)
**Ticket** : **#155**
**Branche** : `feature/155-rename-admin-prefix-dashboard` (depuis `develop`)
**Décision naming** : **option C** — dossier neutre `widgets/dashboard/` + noms **sans** préfixe `Admin`.
---
## Principe
Les widgets partagés **admin + gestionnaire** ne doivent plus sappeler `Admin*`.
On garde `Admin*` seulement là où cest vraiment le rôle administrateur.
## Gardé `Admin*` (hors rename)
| Élément | Raison |
|---------|--------|
| `AdminManagementWidget` | Onglet **Administrateurs** |
| `screens/administrateurs/*` | `AdminDashboardScreen`, `AdminCreateDialog`, `AdminUserFormDialog` |
| `EnfantAdminModel` | Modèle API (pas un widget) — hors scope ticket |
## Renames faits
| Avant | Après |
|-------|--------|
| `widgets/admin/common/admin_child_detail_modal.dart``AdminChildDetailModal` | `widgets/dashboard/child_detail_modal.dart``ChildDetailModal` |
| `admin_am_edit_modal``AdminAmEditModal` | `am_edit_modal``AmEditModal` |
| `admin_parent_edit_modal``AdminParentEditModal` | `parent_edit_modal``ParentEditModal` |
| `admin_user_card``AdminUserCard` | `user_card``UserCard` |
| `admin_enfant_user_card``AdminEnfantUserCard` | `enfant_user_card``EnfantUserCard` |
| `admin_am_photo_frame``AdminAmPhotoFrame` | `am_photo_frame``AmPhotoFrame` |
| `admin_am_children_capacity_grid` | `am_children_capacity_grid``AmChildrenCapacityGrid` |
| `admin_children_affiliation_panel` | `children_affiliation_panel``ChildrenAffiliationPanel` |
| `admin_select_*` / `AdminSelect*` / `AdminFamilleFoyer` | `select_*` / `Select*` / `FamilleFoyer` |
| `admin_status_capsule` | `status_capsule``StatusCapsule` |
| `admin_list_state``AdminListState` | `user_list_state``UserListState` |
| `admin_detail_modal``AdminDetailModal` / `AdminDetailField` | `detail_modal``DetailModal` / `DetailField` |
| `dashboard_admin.dart` | `user_management_sub_bar.dart` (`DashboardUserManagementSubBar` inchangé) |
Dossier `widgets/admin/` conserve encore les panels métier (`user_management_panel`, wizards, etc.) + `AdminManagementWidget`.
**Phase 2** (même ticket #155) : déplacer ces panels → `widgets/dashboard/` — voir [155-suite-move-admin-panels-to-dashboard.md](./155-suite-move-admin-panels-to-dashboard.md).
## Hors scope
- Refonte UX modales (ticket dédié)
- Rename API / back
- Déplacer tout `widgets/admin/``widgets/dashboard/` (panels) — possible follow-up
## Critères
- [x] Plus de préfixe `Admin` sur les composants **partagés** listés
- [ ] Build Flutter / recette dashboard admin + gestionnaire OK
- [x] Pas de changement comportemental (rename mécanique)
@@ -0,0 +1,203 @@
# Mini-spec — Déplacer les panels `widgets/admin/` → `widgets/dashboard/`
**Ticket** : **#155** (phase 2 — même ticket que le rename `Admin*`)
**Phase 1** : widgets `Admin*``widgets/dashboard/` (déjà sur `feature/155-rename-admin-prefix-dashboard`)
**Branche** : poursuivre / rebaser `feature/155-rename-admin-prefix-dashboard` (ou nouvelle branche depuis `develop` après merge phase 1)
**Nature** : rename / move mécanique — **zéro** changement UX / métier
---
## Contexte
Après la phase 1 (#155), la situation est **hybride** :
| Emplacement | Contenu |
|-------------|---------|
| `widgets/dashboard/` | Composants partagés sans préfixe `Admin*` (modales, cartes, selects, sub-bar…) |
| `widgets/admin/` | **Panels** du dashboard staff (listes, wizards, validation, shell `UserManagementPanel`…) + `AdminManagementWidget` |
Le dossier `admin/` laisse encore croire « réservé administrateur », alors que **gestionnaire** consomme les mêmes panels (`GestionnaireDashboardScreen``UserManagementPanel`).
Ce ticket **termine loption C** au niveau dossier : tout le dashboard staff vit sous `widgets/dashboard/`, sauf ce qui est **vraiment** rôle admin.
---
## Objectif
```
frontend/lib/widgets/admin/<panels & common partagés>
↓ git mv + update imports
frontend/lib/widgets/dashboard/…
```
Critère : un nouveau dev ne doit plus ouvrir `widgets/admin/` pour du code partagé admin+gestionnaire.
---
## Cible darborescence (proposée)
```
widgets/dashboard/
├── (déjà là #155) child_detail_modal.dart, am_edit_modal.dart, user_card.dart, …
├── user_management_panel.dart ← shell onglets
├── user_management_sub_bar.dart ← déjà déplacé #155
├── dossiers_management_widget.dart
├── dossier_list_card.dart
├── parent_management_widget.dart ← corriger le typo managmant au passage ?
├── enfant_management_widget.dart
├── assistante_maternelle_management_widget.dart
├── gestionnaire_management_widget.dart
├── pending_validation_widget.dart
├── parent_dossier_create_modal.dart
├── parent_dossier_wizard.dart
├── am_dossier_create_modal.dart
├── am_dossier_wizard.dart
├── validation_*.dart ← family/am wizards, refus, theme, confirm
├── parametres_panel.dart ← utilisé par écran admin (OK dans dashboard)
├── relais_management_panel.dart
├── common/ ← sous-dossier optionnel
│ ├── suppression_confirm_dialog.dart
│ ├── user_list.dart
│ └── validation_detail_section.dart
└── …
widgets/admin/ ← mince, rôle admin seulement
└── admin_management_widget.dart ← onglet Administrateurs
```
### Variante B (plus stricte)
`AdminManagementWidget` + éventuels helpers purement admin →
`screens/administrateurs/widgets/`
et **suppression** du dossier `widgets/admin/`.
**Reco** : **variante A** (garder `widgets/admin/` minimal avec seulement `AdminManagementWidget`) — moins de churn screens, clair.
---
## Inventaire à déplacer (état actuel)
### Racine `widgets/admin/` → `widgets/dashboard/`
| Fichier actuel | Notes |
|----------------|--------|
| `user_management_panel.dart` | Shell partagé admin + gestionnaire |
| `dossiers_management_widget.dart` | |
| `dossier_list_card.dart` | |
| `parent_managmant_widget.dart` | Typo historique `managmant`**option** : renommer → `parent_management_widget.dart` dans le même ticket ou ticket typo séparé |
| `enfant_management_widget.dart` | |
| `assistante_maternelle_management_widget.dart` | |
| `gestionnaire_management_widget.dart` | |
| `pending_validation_widget.dart` | |
| `parent_dossier_create_modal.dart` | |
| `parent_dossier_wizard.dart` | |
| `am_dossier_create_modal.dart` | |
| `am_dossier_wizard.dart` | |
| `validation_am_wizard.dart` | |
| `validation_family_wizard.dart` | |
| `validation_dossier_modal.dart` | |
| `validation_modal_theme.dart` | |
| `validation_refus_form.dart` | |
| `validation_valider_confirm_dialog.dart` | |
| `parametres_panel.dart` | Écran admin seulement, mais pas préfixé Admin — OK dashboard |
| `relais_management_panel.dart` | |
### `widgets/admin/common/` → `widgets/dashboard/common/` (ou plat)
| Fichier | Notes |
|---------|--------|
| `suppression_confirm_dialog.dart` | Partagé (y compris `screens/administrateurs/creation/*`) |
| `user_list.dart` | |
| `validation_detail_section.dart` | |
### **Ne pas** déplacer
| Fichier | Destination |
|---------|-------------|
| `admin_management_widget.dart` | Reste `widgets/admin/` (ou variante B → screens) |
### Déjà fait (#155) — ne pas retraiter
Tout ce qui est déjà sous `widgets/dashboard/` (`child_detail_modal`, `am_edit_modal`, `user_card`, `select_*`, `user_management_sub_bar`, …).
---
## Consommateurs dimports (à mettre à jour)
### Screens
- `screens/administrateurs/admin_dashboardScreen.dart``UserManagementPanel`, `ParametresPanel`
- `screens/gestionnaire/gestionnaire_dashboard_screen.dart``UserManagementPanel`
- `screens/administrateurs/creation/admin_create.dart``suppression_confirm_dialog`
- `screens/administrateurs/creation/gestionnaires_create.dart` — idem
### Widgets déjà en `dashboard/`
- `am_edit_modal`, `child_detail_modal`, `parent_edit_modal`, `select_*` — imports vers `widgets/admin/common/*` ou panels
### Divers
- `widgets/common/identity_block.dart` (si import admin)
- Tous les fichiers **déplacés** entre eux (imports relatifs / package)
### Hors scope rename classes
Sauf décision explicite sur le typo `parent_managmant_widget` → pas de rename de **classes** métier dans ce ticket (seulement chemins de fichiers + imports).
`AdminManagementWidget` **conserve** son nom.
---
## Plan dexécution
1. Partir de `feature/155-rename-admin-prefix-dashboard` (phase 1) **ou** `develop` si phase 1 déjà mergée
2. `git mv` fichiers selon inventaire
3. Remplacer globalement
`package:p_tits_pas/widgets/admin/``package:p_tits_pas/widgets/dashboard/`
**sauf** `…/widgets/admin/admin_management_widget.dart`
4. Corriger imports relatifs cassés
5. Grep de contrôle (ci-dessous)
6. Build Flutter web (Docker) + smoke dashboard admin **et** gestionnaire
7. Merge → squash master si flux habituel
---
## Vérifs
```bash
# Plus de panels partagés sous admin (seul AdminManagement attendu)
find frontend/lib/widgets/admin -name '*.dart'
# Plus dimports panels vers lancien chemin (sauf AdminManagement)
rg -n "widgets/admin/(user_management|dossiers_|parent_|enfant_|assistante|gestionnaire|pending|validation_|parametres|relais|am_dossier|parent_dossier|dossier_list|common/)" frontend/lib
# Screens OK
rg -n "widgets/admin/" frontend/lib/screens
```
Attendu screens : **0** hit vers panels ; éventuellement plus aucun hit `widgets/admin/` sauf si import explicite `AdminManagementWidget` depuis `user_management_panel` (chemin `widgets/admin/admin_management_widget.dart`).
---
## Hors scope
- Refonte UX des modales / panels (ticket dédié annoncé)
- Rename `EnfantAdminModel`
- Rename `screens/administrateurs/`
- Rename `AdminUserFormDialog` / `AdminCreateDialog`
- Changement API / back
- #152 (`est_multiple`) — autre branche
---
## Critères dacceptation
- [ ] Inventaire déplacé selon tableau
- [ ] `widgets/admin/` ne contient plus que `admin_management_widget.dart` (variante A)
- [ ] Imports screens + widgets à jour
- [ ] Build Flutter OK
- [ ] Recette : dashboard **administrateur** et **gestionnaire** (listes, ouverture fiches, validation, création dossier) sans régression
- [ ] Aucun changement comportemental volontaire
---
## Risques / notes
- **Conflits de merge** si dautres features touchent les panels → faire ce ticket quand la surface dashboard est calme (fin 0.1.0 OK)
- Typo `parent_managmant_widget` : soit inclus (bonus), soit ticket cleanup 1-ligne séparé
- Docs darchive citant `widgets/admin/…` : pas obligatoire de mettre à jour ; `docs/27_BRIEFING-FRONTEND.md` oui si encore listé
+75
View File
@@ -0,0 +1,75 @@
# Mini-spec API — POST /assistantes-maternelles/dossier (#156)
Contrat pour le **plan front** (wizard création AM staff).
## Endpoint
| | |
|--|--|
| **Méthode** | `POST` |
| **URL** | `{base}/assistantes-maternelles/dossier` |
| **Auth** | Bearer JWT |
| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` |
| **Content-Type** | `application/json` |
Ne **pas** appeler `POST /auth/register/am` depuis le dashboard.
## Body (JSON)
Aligné inscription AM publique, **sans** CGU/privacy obligatoires (acceptées serveur).
| Champ | Type | Obligatoire | Notes |
|-------|------|-------------|--------|
| `email` | string | oui | unique |
| `prenom` | string | oui | |
| `nom` | string | oui | |
| `telephone` | string | oui | `0X…` ou `+33…` |
| `adresse` | string | non | |
| `code_postal` | string | non | |
| `ville` | string | non | |
| `photo_base64` | string | non | data-URL `data:image/…;base64,…` |
| `photo_filename` | string | non | hint nom fichier |
| `consentement_photo` | bool | oui | |
| `date_naissance` | date ISO | non | `YYYY-MM-DD` |
| `lieu_naissance_ville` | string | oui | |
| `lieu_naissance_pays` | string | oui | |
| `nir` | string | oui | 15 car. (Corse 2A/2B OK) |
| `numero_agrement` | string | oui | unique |
| `date_agrement` | date ISO | non | |
| `capacite_accueil` | int | oui | 110 |
| `places_disponibles` | int | oui | 010, ≤ capacité |
| `biographie` | string | non | max 2000 |
## Réponses
### 201 Created
```json
{
"message": "Dossier AM créé et validé. Un e-mail de création de mot de passe a été envoyé.",
"user_id": "uuid",
"statut": "actif",
"numero_dossier": "2026-000042"
}
```
Effets serveur : user AM **actif**, fiche `assistantes_maternelles`, n° dossier, **e-mail création MDP** (pas daccusé « en attente »).
### Erreurs
| Code | Cas |
|------|-----|
| 400 | Validation / NIR / places > capacité |
| 403 | Rôle non staff |
| 409 | Email, NIR ou agrément déjà pris |
| 401 | Token manquant / invalide |
## Front
- `UserService.createAmDossier(body)` → cet endpoint
- Après 201 : refresh liste AM ; snackbar OK
- Wizard create : ne pas envoyer `acceptation_cgu` / `acceptation_privacy` (optionnels)
## Branche
`feature/156-creation-dossier-am`
@@ -1,30 +1,102 @@
import 'package:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/models/user.dart';
class AssistanteMaternelleModel {
final AppUser user;
final String? approvalNumber;
final String? nir;
final String? residenceCity;
final int? maxChildren;
final int? placesAvailable;
final String? biography;
final bool? available;
final String? agreementDate;
final List<ParentChildSummary> children;
AssistanteMaternelleModel({
required this.user,
this.approvalNumber,
this.nir,
this.residenceCity,
this.maxChildren,
this.placesAvailable,
this.biography,
this.available,
this.agreementDate,
this.children = const [],
});
factory AssistanteMaternelleModel.fromJson(Map<String, dynamic> json) {
final userJson = json['user'] ?? json;
final root = Map<String, dynamic>.from(json);
final userJson = Map<String, dynamic>.from(root['user'] ?? root);
if (root['numero_dossier'] != null && userJson['numero_dossier'] == null) {
userJson['numero_dossier'] = root['numero_dossier'];
}
final user = AppUser.fromJson(userJson);
final children = _parseChildren(root);
return AssistanteMaternelleModel(
user: user,
approvalNumber: json['numero_agrement'] as String?,
residenceCity: json['ville_residence'] as String?,
maxChildren: json['nb_max_enfants'] as int?,
placesAvailable: json['place_disponible'] as int?,
approvalNumber: _str(
root['approval_number'] ?? root['numero_agrement'],
),
nir: _str(root['nir'] ?? root['nir_chiffre']),
residenceCity: _str(
root['residence_city'] ?? root['ville_residence'],
),
maxChildren: _int(root['max_children'] ?? root['nb_max_enfants']),
placesAvailable: _int(
root['places_available'] ?? root['place_disponible'],
),
biography: _str(root['biography'] ?? root['biographie']),
available: root['available'] as bool? ?? root['disponible'] as bool?,
agreementDate: _dateString(
root['agreement_date'] ?? root['date_agrement'],
),
children: children,
);
}
static String? _str(dynamic v) {
if (v == null) return null;
final s = v.toString().trim();
return s.isEmpty ? null : s;
}
static int? _int(dynamic v) {
if (v == null) return null;
if (v is int) return v;
return int.tryParse(v.toString());
}
static String? _dateString(dynamic v) {
if (v == null) return null;
return v.toString().split('T').first;
}
static List<ParentChildSummary> _parseChildren(Map<String, dynamic> json) {
final children = <ParentChildSummary>[];
final seen = <String>{};
void add(ParentChildSummary? child) {
if (child == null || child.id.isEmpty || seen.contains(child.id)) return;
seen.add(child.id);
children.add(child);
}
final links = json['amChildren'] ??
json['am_children'] ??
json['assistanteChildren'] ??
json['assistante_children'];
if (links is List) {
for (final link in links) {
if (link is! Map) continue;
add(ParentChildSummary.fromParentChildLink(
Map<String, dynamic>.from(link),
));
}
}
return children;
}
}
+239
View File
@@ -0,0 +1,239 @@
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
import 'package:p_tits_pas/models/parent_model.dart';
import 'package:p_tits_pas/utils/name_format_utils.dart';
/// Ligne de liste unifiée dossiers (famille ou AM) — ticket #153.
enum DossierListType { famille, assistanteMaternelle }
class DossierListItem {
final DossierListType type;
final String numeroDossier;
final String libelle;
final List<String> emails;
final String? statut;
/// Photo profil (AM) — affichée à la place de licône si présente.
final String? photoUrl;
/// Dossier famille sans enfant lié (#159 / #160).
final bool sansEnfant;
/// Nombre denfants du foyer (famille uniquement ; null pour AM).
final int? enfantsCount;
const DossierListItem({
required this.type,
required this.numeroDossier,
required this.libelle,
this.emails = const [],
this.statut,
this.photoUrl,
this.sansEnfant = false,
this.enfantsCount,
});
DossierListItem copyWith({
DossierListType? type,
String? numeroDossier,
String? libelle,
List<String>? emails,
String? statut,
String? photoUrl,
bool? sansEnfant,
int? enfantsCount,
}) {
return DossierListItem(
type: type ?? this.type,
numeroDossier: numeroDossier ?? this.numeroDossier,
libelle: libelle ?? this.libelle,
emails: emails ?? this.emails,
statut: statut ?? this.statut,
photoUrl: photoUrl ?? this.photoUrl,
sansEnfant: sansEnfant ?? this.sansEnfant,
enfantsCount: enfantsCount ?? this.enfantsCount,
);
}
bool get isFamille => type == DossierListType.famille;
bool get isAm => type == DossierListType.assistanteMaternelle;
String get typeLabel => isFamille ? 'Famille' : 'AM';
/// Sous-titre carte : `NOM Prénom` ou `NOM Prénom - NOM Prénom`.
String get namesLine => libelle;
String get emailsLine => emails.where((e) => e.trim().isNotEmpty).join(' · ');
/// Titre carte : numéro de dossier seul.
String get titleLine => numeroDossier;
bool matchesQuery(String query) {
final q = query.trim().toLowerCase();
if (q.isEmpty) return true;
if (numeroDossier.toLowerCase().contains(q)) return true;
if (libelle.toLowerCase().contains(q)) return true;
for (final e in emails) {
if (e.toLowerCase().contains(q)) return true;
}
return false;
}
/// Une ligne par `numero_dossier` (foyer dédupliqué).
static List<DossierListItem> fromParents(List<ParentModel> parents) {
final byDossier = <String, List<ParentModel>>{};
for (final p in parents) {
final num = (p.user.numeroDossier ?? '').trim();
if (num.isEmpty) continue;
byDossier.putIfAbsent(num, () => []).add(p);
}
final items = <DossierListItem>[];
for (final entry in byDossier.entries) {
final seenIds = <String>{};
final names = <String>[];
final emails = <String>[];
final statuts = <String>[];
void consider(
String? id,
String? nom,
String? prenom,
String? email,
String? statut,
) {
final uid = (id ?? '').trim();
if (uid.isEmpty || !seenIds.add(uid)) return;
final label = formatDossierPersonLabel(
nom: nom,
prenom: prenom,
email: email,
);
if (label.isNotEmpty) names.add(label);
final e = (email ?? '').trim();
if (e.isNotEmpty) emails.add(e);
final s = (statut ?? '').trim();
if (s.isNotEmpty) statuts.add(s);
}
for (final p in entry.value) {
consider(
p.user.id,
p.user.nom,
p.user.prenom,
p.user.email,
p.user.statut,
);
final co = p.coParent;
if (co != null) {
consider(co.id, co.nom, co.prenom, co.email, co.statut);
}
}
final childIds = <String>{};
var maxCountFallback = 0;
for (final p in entry.value) {
for (final c in p.children) {
final id = c.id.trim();
if (id.isNotEmpty) childIds.add(id);
}
final n = p.children.isNotEmpty ? p.children.length : p.childrenCount;
if (n > maxCountFallback) maxCountFallback = n;
}
final enfantsCount =
childIds.isNotEmpty ? childIds.length : maxCountFallback;
items.add(
DossierListItem(
type: DossierListType.famille,
numeroDossier: entry.key,
libelle: names.isNotEmpty ? names.join(' - ') : 'Famille',
emails: emails,
statut: _preferStatut(statuts),
enfantsCount: enfantsCount,
sansEnfant: enfantsCount == 0,
),
);
}
return items;
}
static List<DossierListItem> fromAssistantes(
List<AssistanteMaternelleModel> ams,
) {
final byDossier = <String, AssistanteMaternelleModel>{};
for (final am in ams) {
final num = (am.user.numeroDossier ?? '').trim();
if (num.isEmpty) continue;
byDossier.putIfAbsent(num, () => am);
}
return byDossier.entries.map((e) {
final u = e.value.user;
final name = formatDossierPersonLabel(
nom: u.nom,
prenom: u.prenom,
email: u.email,
);
final photo = (u.photoUrl ?? '').trim();
return DossierListItem(
type: DossierListType.assistanteMaternelle,
numeroDossier: e.key,
libelle: name.isNotEmpty ? name : 'AM',
emails: u.email.trim().isEmpty ? const [] : [u.email.trim()],
statut: u.statut?.trim(),
photoUrl: photo.isEmpty ? null : photo,
);
}).toList();
}
/// Priorité affichage : en_attente > suspendu > refuse > actif > autre.
static String? _preferStatut(List<String> raw) {
if (raw.isEmpty) return null;
const order = ['en_attente', 'suspendu', 'refuse', 'actif'];
for (final wanted in order) {
for (final s in raw) {
if (s.toLowerCase() == wanted) return s;
}
}
return raw.first;
}
}
/// Affichage carte dossier : `NOM Prénom` (repli email).
String formatDossierPersonLabel({
String? nom,
String? prenom,
String? email,
}) {
final n = (nom ?? '').trim().toUpperCase();
final p = formatPersonNameCase(prenom ?? '');
if (n.isNotEmpty && p.isNotEmpty) return '$n $p';
if (n.isNotEmpty) return n;
if (p.isNotEmpty) return p;
return (email ?? '').trim();
}
/// Reformate un libellé famille API (`A & B` / `Famille …`) en `NOM Prénom - …`.
String formatDossierFamilyNamesLine(String libelle) {
var raw = libelle.trim();
if (raw.isEmpty) return '';
raw = raw.replaceFirst(RegExp(r'^famille\s+', caseSensitive: false), '');
raw = raw
.replaceAll(RegExp(r'\s+&\s+'), ' - ')
.replaceAll(RegExp(r'\s+et\s+', caseSensitive: false), ' - ');
final parts = raw
.split(RegExp(r'\s+-\s+'))
.map((part) => _formatLoosePersonSegment(part.trim()))
.where((s) => s.isNotEmpty)
.toList();
return parts.join(' - ');
}
/// Segment libre type « martin sophie » ou « DURAND Amélie » → `NOM Prénom`.
String _formatLoosePersonSegment(String segment) {
final words =
segment.split(RegExp(r'\s+')).where((w) => w.isNotEmpty).toList();
if (words.isEmpty) return '';
if (words.length == 1) return words.first.toUpperCase();
// Convention affichage : premier mot = NOM, reste = prénom(s).
final nom = words.first.toUpperCase();
final prenom = formatPersonNameCase(words.sublist(1).join(' '));
return '$nom $prenom';
}
+8 -2
View File
@@ -1,4 +1,5 @@
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
/// Réponse unifiée GET /dossiers/:numeroDossier. Ticket #119, #107.
class DossierUnifie {
@@ -214,13 +215,18 @@ class EnfantDossier {
factory EnfantDossier.fromJson(Map<String, dynamic> json) {
final rawPhoto = json['photo_url'] ?? json['photoUrl'];
final resolvedPhoto = _optionalPhotoUrl(rawPhoto);
final rawId = json['id'] ??
json['enfant_id'] ??
json['enfantId'] ??
json['child_id'] ??
json['childId'];
return EnfantDossier(
id: json['id']?.toString() ?? '',
id: rawId?.toString().trim() ?? '',
firstName: (json['first_name'] ?? json['prenom'])?.toString(),
lastName: (json['last_name'] ?? json['nom'])?.toString(),
birthDate: json['birth_date']?.toString(),
gender: (json['gender'] ?? json['genre'])?.toString(),
status: json['status']?.toString(),
status: normalizeEnfantStatus(json['status']?.toString()),
dueDate: json['due_date']?.toString(),
photoUrl: resolvedPhoto,
consentPhoto:
+87 -9
View File
@@ -1,4 +1,5 @@
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
/// Enfant tel que renvoyé par `GET /enfants` (dashboard admin).
class EnfantAdminModel {
@@ -13,6 +14,8 @@ class EnfantAdminModel {
final bool consentPhoto;
final bool isMultiple;
final List<EnfantParentLink> parentLinks;
/// Flag API #157 (sinon déduit de [parentLinks]).
final bool? sansResponsable;
EnfantAdminModel({
required this.id,
@@ -26,6 +29,7 @@ class EnfantAdminModel {
this.consentPhoto = false,
this.isMultiple = false,
this.parentLinks = const [],
this.sansResponsable,
});
String get fullName {
@@ -36,6 +40,42 @@ class EnfantAdminModel {
return '$fn $ln';
}
/// Aucun lien parent valide — ticket #157.
bool get hasNoResponsable {
if (sansResponsable != null) return sansResponsable!;
return !parentLinks.any((l) => l.parentId.trim().isNotEmpty);
}
EnfantAdminModel copyWith({
String? id,
String? firstName,
String? lastName,
String? gender,
String? birthDate,
String? dueDate,
String? status,
String? photoUrl,
bool? consentPhoto,
bool? isMultiple,
List<EnfantParentLink>? parentLinks,
bool? sansResponsable,
}) {
return EnfantAdminModel(
id: id ?? this.id,
firstName: firstName ?? this.firstName,
lastName: lastName ?? this.lastName,
gender: gender ?? this.gender,
birthDate: birthDate ?? this.birthDate,
dueDate: dueDate ?? this.dueDate,
status: status ?? this.status,
photoUrl: photoUrl ?? this.photoUrl,
consentPhoto: consentPhoto ?? this.consentPhoto,
isMultiple: isMultiple ?? this.isMultiple,
parentLinks: parentLinks ?? this.parentLinks,
sansResponsable: sansResponsable ?? this.sansResponsable,
);
}
factory EnfantAdminModel.fromJson(Map<String, dynamic> json) {
final linksRaw = json['parentLinks'] as List?;
final links = <EnfantParentLink>[];
@@ -47,18 +87,34 @@ class EnfantAdminModel {
}
}
final photoUrl = json['photo_url'] as String? ?? json['photoUrl'] as String?;
final consentPhoto = _parseBool(json['consent_photo']) ||
_parseBool(json['consentement_photo']) ||
_parseBool(json['consentPhoto']);
bool? sansResponsable;
if (json.containsKey('sans_responsable') ||
json.containsKey('sansResponsable')) {
sansResponsable = _parseBool(json['sans_responsable']) ||
_parseBool(json['sansResponsable']);
}
return EnfantAdminModel(
id: (json['id'] ?? '').toString(),
firstName: json['first_name'] as String?,
lastName: json['last_name'] as String?,
gender: json['gender'] as String?,
birthDate: _dateString(json['birth_date']),
dueDate: _dateString(json['due_date']),
status: (json['status'] ?? '').toString(),
photoUrl: json['photo_url'] as String?,
consentPhoto: json['consent_photo'] == true,
isMultiple: json['is_multiple'] == true,
firstName: json['first_name'] as String? ?? json['prenom'] as String?,
lastName: json['last_name'] as String? ?? json['nom'] as String?,
gender: json['gender'] as String? ?? json['genre'] as String?,
birthDate: _dateString(json['birth_date'] ?? json['date_naissance']),
dueDate: _dateString(json['due_date'] ?? json['date_prevue_naissance']),
status: normalizeEnfantStatus(
(json['status'] ?? json['statut'])?.toString(),
),
photoUrl: photoUrl,
consentPhoto: consentPhoto,
isMultiple: _parseBool(json['is_multiple']) ||
_parseBool(json['est_multiple']),
parentLinks: links,
sansResponsable: sansResponsable,
);
}
@@ -75,6 +131,15 @@ class EnfantAdminModel {
};
}
static bool _parseBool(dynamic value) {
if (value == true || value == 1) return true;
if (value is String) {
final s = value.trim().toLowerCase();
return s == 'true' || s == '1' || s == 't' || s == 'yes';
}
return false;
}
static String? _dateString(dynamic v) {
if (v == null) return null;
if (v is String) return v.split('T').first;
@@ -88,6 +153,13 @@ class EnfantParentLink {
EnfantParentLink({required this.parentId, this.parentName});
EnfantParentLink copyWith({String? parentId, String? parentName}) {
return EnfantParentLink(
parentId: parentId ?? this.parentId,
parentName: parentName ?? this.parentName,
);
}
factory EnfantParentLink.fromJson(Map<String, dynamic> json) {
final parentId =
(json['parentId'] ?? json['id_parent'] ?? '').toString();
@@ -98,6 +170,12 @@ class EnfantParentLink {
if (user is Map<String, dynamic>) {
final u = AppUser.fromJson(user);
name = u.fullName.isNotEmpty ? u.fullName : u.email;
} else {
// Parfois le parent est aplati (prenom/nom) sans nested user.
final prenom = (parent['prenom'] ?? parent['first_name'] ?? '').toString().trim();
final nom = (parent['nom'] ?? parent['last_name'] ?? '').toString().trim();
final flat = '$prenom $nom'.trim();
if (flat.isNotEmpty) name = flat;
}
}
return EnfantParentLink(
@@ -1,4 +1,5 @@
import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
/// Résumé enfant affiché dans la fiche parent (dashboard admin).
class ParentChildSummary {
@@ -33,7 +34,9 @@ class ParentChildSummary {
id: (json['id'] ?? '').toString(),
firstName: (json['first_name'] ?? json['prenom'])?.toString(),
lastName: (json['last_name'] ?? json['nom'])?.toString(),
status: (json['status'] ?? json['statut'] ?? '').toString(),
status: normalizeEnfantStatus(
(json['status'] ?? json['statut'])?.toString(),
),
photoUrl: json['photo_url']?.toString(),
birthDate: _dateString(json['birth_date']),
dueDate: _dateString(json['due_date']),
+61
View File
@@ -3,11 +3,13 @@ import 'package:p_tits_pas/models/user.dart';
class ParentModel {
final AppUser user;
final AppUser? coParent;
final int childrenCount;
final List<ParentChildSummary> children;
ParentModel({
required this.user,
this.coParent,
this.childrenCount = 0,
this.children = const [],
});
@@ -20,12 +22,19 @@ class ParentModel {
}
final user = AppUser.fromJson(userJson);
AppUser? coParent;
final coParentRaw = root['co_parent'];
if (coParentRaw is Map) {
coParent = AppUser.fromJson(Map<String, dynamic>.from(coParentRaw));
}
final children = _parseChildren(root);
final links = root['parentChildren'] ?? root['parent_children'];
final linkCount = links is List ? links.length : 0;
return ParentModel(
user: user,
coParent: coParent,
childrenCount: children.isNotEmpty ? children.length : linkCount,
children: children,
);
@@ -53,4 +62,56 @@ class ParentModel {
return children;
}
/// Nombre denfants distincts du foyer (ce parent + co-parent / même dossier).
/// Évite Claire=7 / Thomas=6 quand un lien nest que sur un des deux (#157).
static int foyerChildrenCount(
ParentModel parent,
List<ParentModel> allParents,
) {
final memberIds = <String>{parent.user.id};
final coId = parent.coParent?.id.trim();
if (coId != null && coId.isNotEmpty) memberIds.add(coId);
final dossier = (parent.user.numeroDossier ?? '').trim();
for (final other in allParents) {
if (memberIds.contains(other.user.id)) continue;
final otherCo = other.coParent?.id.trim();
if (otherCo != null && memberIds.contains(otherCo)) {
memberIds.add(other.user.id);
continue;
}
if (dossier.isNotEmpty &&
(other.user.numeroDossier ?? '').trim() == dossier) {
memberIds.add(other.user.id);
final oc = other.coParent?.id.trim();
if (oc != null && oc.isNotEmpty) memberIds.add(oc);
}
}
final childIds = <String>{};
for (final p in allParents) {
if (!memberIds.contains(p.user.id)) continue;
for (final c in p.children) {
final id = c.id.trim();
if (id.isNotEmpty) childIds.add(id);
}
// Repli si la liste enfants nest pas hydratée.
if (p.children.isEmpty && p.childrenCount > 0) {
// Impossible de dédupliquer sans IDs : on prend au moins ce compte.
// (évite dafficher 0 si lAPI nenvoie que childrenCount)
}
}
if (childIds.isNotEmpty) return childIds.length;
var maxCount = 0;
for (final p in allParents) {
if (!memberIds.contains(p.user.id)) continue;
final n = p.children.isNotEmpty ? p.children.length : p.childrenCount;
if (n > maxCount) maxCount = n;
}
return maxCount;
}
}
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/services/configuration_service.dart';
import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart';
import 'package:p_tits_pas/widgets/dashboard/user_management_sub_bar.dart';
import 'package:p_tits_pas/widgets/admin/parametres_panel.dart';
import 'package:p_tits_pas/widgets/admin/user_management_panel.dart';
import 'package:p_tits_pas/widgets/app_footer.dart';
@@ -3,6 +3,7 @@ import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/utils/email_utils.dart';
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
import 'package:p_tits_pas/widgets/email_text_field.dart';
import 'package:p_tits_pas/widgets/french_phone_field.dart';
@@ -151,30 +152,19 @@ class _AdminCreateDialogState extends State<AdminCreateDialog> {
Future<void> _delete() async {
if (!_isEditMode || _isSubmitting) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) {
return AlertDialog(
title: const Text('Confirmer la suppression'),
content: Text(
'Supprimer ${widget.initialUser!.fullName.isEmpty ? widget.initialUser!.email : widget.initialUser!.fullName} ?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
child: const Text('Supprimer'),
),
],
);
},
final name = widget.initialUser!.fullName.isEmpty
? widget.initialUser!.email
: widget.initialUser!.fullName;
final confirmed = await showSuppressionConfirmDialog(
context,
title: 'Supprimer l\'administrateur',
people: [SuppressionPersonLine.administrateur(name)],
footnotes: const [
'Le compte sera définitivement supprimé.',
],
);
if (confirmed != true) return;
if (!confirmed) return;
setState(() {
_isSubmitting = true;
@@ -5,8 +5,11 @@ import 'package:p_tits_pas/utils/email_utils.dart';
import 'package:p_tits_pas/widgets/email_text_field.dart';
import 'package:p_tits_pas/widgets/french_phone_field.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/services/relais_service.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
class AdminUserFormDialog extends StatefulWidget {
final AppUser? initialUser;
@@ -41,9 +44,21 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
bool _isLoadingRelais = true;
List<RelaisModel> _relais = [];
String? _selectedRelaisId;
String? _currentUserId;
String? _currentUserRole;
bool get _isEditMode => widget.initialUser != null;
bool get _isSuperAdminTarget =>
(widget.initialUser?.role ?? '').toLowerCase() == 'super_admin';
bool get _isSelfTarget =>
_isEditMode &&
_currentUserId != null &&
widget.initialUser!.id == _currentUserId;
/// Gestionnaire : pas de delete staff. Admin+ seulement (#154 / #160).
bool get _canDeleteTarget {
if (!_isEditMode || widget.readOnly) return false;
if (_isSelfTarget || _isSuperAdminTarget) return false;
return canDeleteGestionnaire(_currentUserRole);
}
bool get _isLockedAdminIdentity =>
_isEditMode && widget.adminMode && _isSuperAdminTarget;
String get _targetRoleKey {
@@ -109,6 +124,25 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
} else {
_isLoadingRelais = false;
}
_loadCurrentUserId();
}
Future<void> _loadCurrentUserId() async {
final cached = await AuthService.getCurrentUser();
if (!mounted) return;
if (cached != null) {
setState(() {
_currentUserId = cached.id;
_currentUserRole = cached.role;
});
return;
}
final refreshed = await AuthService.refreshCurrentUser();
if (!mounted || refreshed == null) return;
setState(() {
_currentUserId = refreshed.id;
_currentUserRole = refreshed.role;
});
}
@override
@@ -122,6 +156,21 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
super.dispose();
}
/// Fallback si GET /relais échoue : conserve le relais déjà connu sur l'utilisateur.
List<RelaisModel> _fallbackRelaisFromUser() {
final id = _selectedRelaisId?.trim();
if (id == null || id.isEmpty) return const [];
final nom = (widget.initialUser?.relaisNom ?? '').trim();
return [
RelaisModel(
id: id,
nom: nom.isNotEmpty ? nom : 'Relais actuel',
adresse: '',
actif: true,
),
];
}
Future<void> _loadRelais() async {
try {
final list = await RelaisService.getRelais();
@@ -138,7 +187,8 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
if (selected != null) {
filtered.add(selected);
} else {
_selectedRelaisId = null;
// Garder l'id sélectionné et afficher un item de secours (nom carte).
filtered.addAll(_fallbackRelaisFromUser());
}
}
@@ -148,9 +198,9 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
});
} catch (_) {
if (!mounted) return;
// Ne pas nullifier _selectedRelaisId (#151) — la carte a déjà le bon libellé.
setState(() {
_selectedRelaisId = null;
_relais = [];
_relais = _fallbackRelaisFromUser();
_isLoadingRelais = false;
});
}
@@ -335,33 +385,28 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
Future<void> _delete() async {
if (widget.readOnly) return;
if (_isSuperAdminTarget) return;
if (!_canDeleteTarget) return;
if (!_isEditMode || _isSubmitting) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) {
return AlertDialog(
title: const Text('Confirmer la suppression'),
content: Text(
'Supprimer ${widget.initialUser!.fullName.isEmpty ? widget.initialUser!.email : widget.initialUser!.fullName} ?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
child: const Text('Supprimer'),
),
],
);
},
final name = widget.initialUser!.fullName.isEmpty
? widget.initialUser!.email
: widget.initialUser!.fullName;
final confirmed = await showSuppressionConfirmDialog(
context,
title: widget.adminMode
? 'Supprimer l\'administrateur'
: 'Supprimer le gestionnaire',
people: [
widget.adminMode
? SuppressionPersonLine.administrateur(name)
: SuppressionPersonLine.gestionnaire(name),
],
footnotes: const [
'Le compte sera définitivement supprimé.',
],
);
if (confirmed != true) return;
if (!confirmed) return;
setState(() {
_isSubmitting = true;
@@ -462,7 +507,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
child: const Text('Fermer'),
),
] else if (_isEditMode) ...[
if (!_isSuperAdminTarget)
if (_canDeleteTarget)
OutlinedButton(
onPressed: _isSubmitting ? null : _delete,
style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700),
@@ -62,7 +62,10 @@ class _GestionnaireDashboardScreenState extends State<GestionnaireDashboardScree
body: Column(
children: [
Expanded(
child: UserManagementPanel(showAdministrateursTab: false),
child: UserManagementPanel(
showAdministrateursTab: false,
allowStaffAccountCreation: false,
),
),
const AppFooter(),
],
@@ -60,7 +60,12 @@ class ApiConfig {
static const String userChildren = '/users/children';
static const String gestionnaires = '/gestionnaires';
static const String parents = '/parents';
/// Création dossier famille actif par le staff (#129) — body type register parent.
static const String parentsDossier = '/parents/dossier';
static const String assistantesMaternelles = '/assistantes-maternelles';
/// Création dossier AM actif par le staff (#156) — body type register AM.
static const String assistantesMaternellesDossier =
'/assistantes-maternelles/dossier';
static const String enfants = '/enfants';
static const String relais = '/relais';
static const String dossiers = '/dossiers';
+594 -20
View File
@@ -1,5 +1,6 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:http_parser/http_parser.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/models/parent_model.dart';
@@ -8,6 +9,7 @@ import 'package:p_tits_pas/models/pending_family.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/services/api/tokenService.dart';
import 'package:p_tits_pas/utils/am_vigilance.dart';
class DocumentActifInfo {
final String id;
@@ -56,6 +58,33 @@ class UserService {
return v.toString();
}
/// MIME pour upload photo enfant (filtre Nest : jpg/jpeg/png/gif).
static MediaType _imageMediaType(String filename, List<int> bytes) {
if (bytes.length >= 3 &&
bytes[0] == 0xFF &&
bytes[1] == 0xD8 &&
bytes[2] == 0xFF) {
return MediaType('image', 'jpeg');
}
if (bytes.length >= 8 &&
bytes[0] == 0x89 &&
bytes[1] == 0x50 &&
bytes[2] == 0x4E &&
bytes[3] == 0x47) {
return MediaType('image', 'png');
}
if (bytes.length >= 6 &&
bytes[0] == 0x47 &&
bytes[1] == 0x49 &&
bytes[2] == 0x46) {
return MediaType('image', 'gif');
}
final lower = filename.toLowerCase();
if (lower.endsWith('.png')) return MediaType('image', 'png');
if (lower.endsWith('.gif')) return MediaType('image', 'gif');
return MediaType('image', 'jpeg');
}
static String _errMessage(dynamic err) {
if (err == null) return 'Erreur inconnue';
if (err is String) return err;
@@ -460,22 +489,259 @@ class UserService {
if (response.statusCode != 200) {
throw Exception(_extractErrorMessage(response.body, 'Erreur chargement enfant'));
}
return EnfantAdminModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
final enfant = EnfantAdminModel.fromJson(
jsonDecode(response.body) as Map<String, dynamic>,
);
// GET /enfants/:id ne joint pas toujours parent.user → noms manquants.
return enrichEnfantParentNames(enfant);
}
/// Complète [parentName] via GET parent quand l'API n'a pas fourni le nested user.
static Future<EnfantAdminModel> enrichEnfantParentNames(
EnfantAdminModel enfant,
) async {
final links = enfant.parentLinks;
if (links.isEmpty) return enfant;
if (links.every((l) => (l.parentName ?? '').trim().isNotEmpty)) {
return enfant;
}
final enriched = await Future.wait(
links.map((link) async {
if ((link.parentName ?? '').trim().isNotEmpty) return link;
final id = link.parentId.trim();
if (id.isEmpty) return link;
try {
final parent = await getParent(id);
final name = parent.user.fullName.trim();
if (name.isEmpty) return link;
return link.copyWith(parentName: name);
} catch (_) {
return link;
}
}),
);
return enfant.copyWith(parentLinks: enriched);
}
/// Mise à jour enfant. Avec [photoBytes] : multipart (champ `photo`), sinon JSON.
static Future<EnfantAdminModel> updateEnfant({
required String enfantId,
required Map<String, dynamic> body,
List<int>? photoBytes,
String? photoFilename,
}) async {
final response = await http.patch(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'),
headers: await _headers(),
body: jsonEncode(body),
);
if (response.statusCode != 200) {
throw Exception(_extractErrorMessage(response.body, 'Erreur mise à jour enfant'));
final id = enfantId.trim();
if (id.isEmpty) {
throw Exception('Identifiant enfant manquant.');
}
return EnfantAdminModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
final hasPhoto = photoBytes != null && photoBytes.isNotEmpty;
final http.Response response;
if (hasPhoto) {
final token = await TokenService.getToken();
final req = http.MultipartRequest(
'PATCH',
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$id'),
);
req.headers['Accept'] = 'application/json';
if (token != null) {
req.headers['Authorization'] = 'Bearer $token';
}
body.forEach((key, value) {
if (value == null) return;
req.fields[key] = value is bool
? (value ? 'true' : 'false')
: value.toString();
});
final name = (photoFilename ?? '').trim();
final filename = name.isNotEmpty ? name : 'photo.jpg';
req.files.add(
http.MultipartFile.fromBytes(
'photo',
photoBytes,
filename: filename,
contentType: _imageMediaType(filename, photoBytes),
),
);
final streamed = await req.send();
response = await http.Response.fromStream(streamed);
} else {
response = await http.patch(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$id'),
headers: await _headers(),
body: jsonEncode(body),
);
}
if (response.statusCode != 200) {
throw Exception(
_extractErrorMessage(response.body, 'Erreur mise à jour enfant'),
);
}
final enfant = EnfantAdminModel.fromJson(
jsonDecode(response.body) as Map<String, dynamic>,
);
return enrichEnfantParentNames(enfant);
}
/// Création enfant côté staff (#132) — nécessite `POST /enfants` étendu (voir mini-spec).
/// [parentUserId] : parent pivot du foyer (`parent_user_id`).
/// Avec [photoBytes] : `multipart/form-data` (champ fichier `photo`), sinon JSON.
static Future<EnfantAdminModel> createEnfant({
required String parentUserId,
required Map<String, dynamic> body,
List<int>? photoBytes,
String? photoFilename,
}) async {
final hasPhoto = photoBytes != null && photoBytes.isNotEmpty;
final http.Response response;
if (hasPhoto) {
final token = await TokenService.getToken();
final req = http.MultipartRequest(
'POST',
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}'),
);
req.headers['Accept'] = 'application/json';
if (token != null) {
req.headers['Authorization'] = 'Bearer $token';
}
body.forEach((key, value) {
if (value == null) return;
req.fields[key] = value is bool
? (value ? 'true' : 'false')
: value.toString();
});
req.fields['parent_user_id'] = parentUserId;
final name = (photoFilename ?? '').trim();
final filename = name.isNotEmpty ? name : 'photo.jpg';
req.files.add(
http.MultipartFile.fromBytes(
'photo',
photoBytes,
filename: filename,
contentType: _imageMediaType(filename, photoBytes),
),
);
final streamed = await req.send();
response = await http.Response.fromStream(streamed);
} else {
final payload = <String, dynamic>{
...body,
'parent_user_id': parentUserId,
};
response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}'),
headers: await _headers(),
body: jsonEncode(payload),
);
}
if (response.statusCode != 200 && response.statusCode != 201) {
throw Exception(
_extractErrorMessage(response.body, 'Erreur création enfant'),
);
}
final enfant = EnfantAdminModel.fromJson(
jsonDecode(response.body) as Map<String, dynamic>,
);
return enrichEnfantParentNames(enfant);
}
/// DELETE /enfants/:id?deleteDossier= — ticket #159 / #160.
static Future<Map<String, dynamic>> deleteEnfant(
String enfantId, {
bool deleteDossier = false,
}) async {
final uri = Uri.parse(
'${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId',
).replace(
queryParameters: {
'deleteDossier': deleteDossier ? 'true' : 'false',
},
);
final response = await http.delete(uri, headers: await _headers());
if (response.statusCode != 200 && response.statusCode != 204) {
throw Exception(
_extractErrorMessage(response.body, 'Erreur suppression enfant'),
);
}
return _parseSuppressionBody(response.body);
}
/// DELETE /dossiers/:numeroDossier — ticket #159 / #160.
static Future<Map<String, dynamic>> deleteDossier(
String numeroDossier,
) async {
final num = numeroDossier.trim();
if (num.isEmpty) {
throw Exception('Numéro de dossier manquant.');
}
final encoded = Uri.encodeComponent(num);
final response = await http.delete(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}/$encoded'),
headers: await _headers(),
);
if (response.statusCode != 200 && response.statusCode != 204) {
throw Exception(
_extractErrorMessage(response.body, 'Erreur suppression dossier'),
);
}
return _parseSuppressionBody(response.body);
}
/// Liste unifiée GET /dossiers — flags `sans_enfant` (#159).
static Future<List<Map<String, dynamic>>> listDossiers({String? q}) async {
final uri = Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}')
.replace(
queryParameters: (q != null && q.trim().isNotEmpty)
? {'q': q.trim()}
: null,
);
final response = await http.get(uri, headers: await _headers());
if (response.statusCode != 200) {
throw Exception(
_extractErrorMessage(response.body, 'Erreur chargement dossiers'),
);
}
final decoded = jsonDecode(response.body);
if (decoded is! List) return const [];
return decoded
.whereType<Map>()
.map((e) => Map<String, dynamic>.from(e))
.toList();
}
/// Map `numero_dossier` → `sans_enfant` (familles uniquement).
static Future<Map<String, bool>> getSansEnfantByNumero() async {
final rows = await listDossiers();
final out = <String, bool>{};
for (final row in rows) {
final num = (row['numero_dossier'] ?? '').toString().trim();
if (num.isEmpty) continue;
final type = (row['type'] ?? '').toString().toLowerCase();
if (type != 'famille') continue;
out[num] = row['sans_enfant'] == true;
}
return out;
}
static Map<String, dynamic> _parseSuppressionBody(String body) {
if (body.trim().isEmpty) return <String, dynamic>{};
try {
final decoded = jsonDecode(body);
if (decoded is Map) {
return Map<String, dynamic>.from(decoded);
}
} catch (_) {}
return <String, dynamic>{};
}
/// AM dont la liste d'enfants actifs contient [enfantId] (API actuelle).
static Future<AssistanteMaternelleModel?> findAmForEnfant(String enfantId) async {
final ams = await getAssistantesMaternelles();
for (final am in ams) {
if (am.children.any((c) => c.id == enfantId)) return am;
}
return null;
}
static ParentModel _parentModelFromBody(String body) {
@@ -499,6 +765,155 @@ class UserService {
return fallback;
}
/// Ajout dun co-parent sur foyer mono-parent (staff, #135).
/// `POST /parents/:pivotUserId/co-parent` — succès 201.
static Future<Map<String, dynamic>> addCoParent(
String pivotUserId, {
required Map<String, dynamic> body,
}) async {
final id = pivotUserId.trim();
if (id.isEmpty) {
throw Exception('Identifiant du parent pivot manquant.');
}
final http.Response response;
try {
response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parents}/$id/co-parent'),
headers: await _headers(),
body: jsonEncode(body),
);
} on http.ClientException {
throw Exception(
'Connexion au serveur impossible. Vérifiez votre réseau puis réessayez.',
);
}
if (response.statusCode == 200 || response.statusCode == 201) {
if (response.body.trim().isEmpty) return <String, dynamic>{};
try {
final decoded = jsonDecode(response.body);
if (decoded is Map) {
return Map<String, dynamic>.from(decoded);
}
} catch (_) {}
return <String, dynamic>{};
}
final message = _extractErrorMessage(
response.body,
'Erreur ajout co-parent',
);
if (response.statusCode == 409) {
throw Exception(
message.isNotEmpty ? message : 'Conflit : e-mail déjà utilisé.',
);
}
if (response.statusCode == 400) {
throw Exception(
message.isNotEmpty ? message : 'Données invalides (400).',
);
}
throw Exception(message);
}
/// Création dossier famille actif côté staff (#129).
/// `POST /parents/dossier` — body aligné sur register parent complet.
/// Succès 201 : dossier actif + `numero_dossier` (mail MDP côté serveur, par parent créé).
/// Ne pas utiliser `POST /auth/register/parent` (public / en_attente).
static Future<Map<String, dynamic>> createParentDossier(
Map<String, dynamic> body,
) async {
final http.Response response;
try {
response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parentsDossier}'),
headers: await _headers(),
body: jsonEncode(body),
);
} on http.ClientException {
throw Exception(
'Connexion au serveur impossible. Vérifiez votre réseau puis réessayez.',
);
}
if (response.statusCode == 200 || response.statusCode == 201) {
if (response.body.trim().isEmpty) return <String, dynamic>{};
try {
final decoded = jsonDecode(response.body);
if (decoded is Map) {
return Map<String, dynamic>.from(decoded);
}
} catch (_) {}
return <String, dynamic>{};
}
final message = _extractErrorMessage(
response.body,
'Erreur création dossier famille',
);
if (response.statusCode == 409) {
throw Exception(
message.isNotEmpty ? message : 'Conflit : e-mail déjà utilisé.',
);
}
if (response.statusCode == 400) {
throw Exception(
message.isNotEmpty ? message : 'Données invalides (400).',
);
}
throw Exception(message);
}
/// Création dossier AM actif côté staff (#156).
/// `POST /assistantes-maternelles/dossier` — body aligné sur register AM.
/// Succès 201 : dossier actif + `numero_dossier` (mail MDP côté serveur).
/// Ne pas utiliser `POST /auth/register/am` (public / en_attente).
static Future<Map<String, dynamic>> createAmDossier(
Map<String, dynamic> body,
) async {
final http.Response response;
try {
response = await http.post(
Uri.parse(
'${ApiConfig.baseUrl}${ApiConfig.assistantesMaternellesDossier}',
),
headers: await _headers(),
body: jsonEncode(body),
);
} on http.ClientException {
throw Exception(
'Connexion au serveur impossible. Vérifiez votre réseau puis réessayez.',
);
}
if (response.statusCode == 200 || response.statusCode == 201) {
if (response.body.trim().isEmpty) return <String, dynamic>{};
try {
final decoded = jsonDecode(response.body);
if (decoded is Map) {
return Map<String, dynamic>.from(decoded);
}
} catch (_) {}
return <String, dynamic>{};
}
final message = _extractErrorMessage(
response.body,
'Erreur création dossier AM',
);
if (response.statusCode == 409) {
throw Exception(message.isNotEmpty
? message
: 'Conflit : e-mail, NIR ou agrément déjà utilisé.');
}
if (response.statusCode == 400) {
throw Exception(
message.isNotEmpty ? message : 'Données invalides (400).',
);
}
throw Exception(message);
}
// Récupérer la liste des assistantes maternelles
static Future<List<AssistanteMaternelleModel>>
getAssistantesMaternelles() async {
@@ -513,7 +928,170 @@ class UserService {
}
final List<dynamic> data = jsonDecode(response.body);
return data.map((e) => AssistanteMaternelleModel.fromJson(e)).toList();
return data
.map((e) => AssistanteMaternelleModel.fromJson(
Map<String, dynamic>.from(e as Map),
))
.toList();
}
static Future<AssistanteMaternelleModel> getAssistanteMaternelle(
String userId,
) async {
final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}/$userId'),
headers: await _headers(),
);
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body);
return AssistanteMaternelleModel.fromJson(
Map<String, dynamic>.from(decoded is Map ? decoded : {}),
);
}
if (response.statusCode == 403 || response.statusCode == 404) {
final all = await getAssistantesMaternelles();
return all.firstWhere(
(a) => a.user.id == userId,
orElse: () => throw Exception('Assistante maternelle introuvable'),
);
}
final err = jsonDecode(response.body) as Map<String, dynamic>?;
throw Exception(_toStr(err?['message']) ?? 'Erreur chargement AM');
}
/// Mise à jour fiche AM (identité + champs pro). Ticket #131.
static Future<AssistanteMaternelleModel> updateAmFiche({
required String amUserId,
required Map<String, dynamic> body,
}) async {
final ficheResponse = await http.patch(
Uri.parse(
'${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}/$amUserId/fiche',
),
headers: await _headers(),
body: jsonEncode(body),
);
if (ficheResponse.statusCode == 200) {
return _amModelFromBody(ficheResponse.body);
}
if (ficheResponse.statusCode != 404) {
throw Exception(
_extractErrorMessage(ficheResponse.body, 'Erreur mise à jour AM'),
);
}
final userFields = <String, dynamic>{};
for (final k in [
'nom',
'prenom',
'email',
'telephone',
'adresse',
'ville',
'code_postal',
'statut',
'date_naissance',
'lieu_naissance_ville',
'lieu_naissance_pays',
]) {
if (body.containsKey(k)) userFields[k] = body[k];
}
final proFields = <String, dynamic>{};
for (final entry in body.entries) {
if (!userFields.containsKey(entry.key)) {
proFields[entry.key] = entry.value;
}
}
if (userFields.isNotEmpty) {
final userResponse = await http.patch(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$amUserId'),
headers: await _headers(),
body: jsonEncode(userFields),
);
if (userResponse.statusCode != 200) {
throw Exception(
_extractErrorMessage(userResponse.body, 'Erreur mise à jour identité'),
);
}
}
if (proFields.isNotEmpty) {
final proResponse = await http.patch(
Uri.parse(
'${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}/$amUserId',
),
headers: await _headers(),
body: jsonEncode(proFields),
);
if (proResponse.statusCode != 200) {
throw Exception(
_extractErrorMessage(proResponse.body, 'Erreur mise à jour fiche pro'),
);
}
}
return getAssistanteMaternelle(amUserId);
}
static Future<AssistanteMaternelleModel> attachEnfantToAm({
required String amUserId,
required String enfantId,
}) async {
final response = await http.post(
Uri.parse(
'${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}/$amUserId/enfants/$enfantId',
),
headers: await _headers(),
);
if (response.statusCode != 200 && response.statusCode != 201) {
throw Exception(_extractErrorMessage(response.body, 'Erreur rattachement enfant'));
}
final am = _amModelFromBody(response.body);
// Recalcule toujours places_available (capacité enfants) après attachement.
return syncAmPlacesAvailable(am);
}
/// Aligne `places_available` sur capacité enfants rattachés.
static Future<AssistanteMaternelleModel> syncAmPlacesAvailable(
AssistanteMaternelleModel am,
) async {
final expected = amExpectedPlacesAvailable(
maxChildren: am.maxChildren,
childrenCount: am.children.length,
);
if (expected == null || am.placesAvailable == expected) return am;
return updateAmFiche(
amUserId: am.user.id,
body: {'places_available': expected},
);
}
static Future<AssistanteMaternelleModel> detachEnfantFromAm({
required String amUserId,
required String enfantId,
}) async {
final response = await http.delete(
Uri.parse(
'${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}/$amUserId/enfants/$enfantId',
),
headers: await _headers(),
);
if (response.statusCode != 200 && response.statusCode != 204) {
throw Exception(_extractErrorMessage(response.body, 'Erreur détachement enfant'));
}
if (response.body.isEmpty) {
return getAssistanteMaternelle(amUserId);
}
return _amModelFromBody(response.body);
}
static AssistanteMaternelleModel _amModelFromBody(String body) {
final decoded = jsonDecode(body);
return AssistanteMaternelleModel.fromJson(
Map<String, dynamic>.from(decoded is Map ? decoded : {}),
);
}
// Récupérer la liste des administrateurs (via /users filtré ou autre)
@@ -725,22 +1303,18 @@ class UserService {
return AppUser.fromJson(data);
}
static Future<void> deleteUser(String userId) async {
/// DELETE /users/:id — cascades métier #159 / #160.
static Future<Map<String, dynamic>> deleteUser(String userId) async {
final response = await http.delete(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$userId'),
headers: await _headers(),
);
if (response.statusCode != 200 && response.statusCode != 204) {
final decoded = jsonDecode(response.body);
if (decoded is Map<String, dynamic>) {
final message = decoded['message'];
if (message is List && message.isNotEmpty) {
throw Exception(message.join(' - '));
}
throw Exception(_toStr(message) ?? 'Erreur suppression utilisateur');
}
throw Exception('Erreur suppression utilisateur');
throw Exception(
_extractErrorMessage(response.body, 'Erreur suppression utilisateur'),
);
}
return _parseSuppressionBody(response.body);
}
}
+57
View File
@@ -0,0 +1,57 @@
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
/// Places disponibles attendues : capacité max enfants rattachés.
int? amExpectedPlacesAvailable({
required int? maxChildren,
required int childrenCount,
}) {
if (maxChildren == null) return null;
return (maxChildren - childrenCount).clamp(0, maxChildren);
}
/// True s'il reste au moins une place d'accueil (capacité enfants).
bool amHasFreePlace(AssistanteMaternelleModel am) {
final expected = amExpectedPlacesAvailable(
maxChildren: am.maxChildren,
childrenCount: am.children.length,
);
if (expected != null) return expected > 0;
final places = am.placesAvailable;
if (places != null) return places > 0;
return true;
}
/// True si la valeur déclarée par l'AM ne correspond pas au calcul métier.
bool amHasPlacesInconsistency({
required int? maxChildren,
required int? placesAvailable,
required int childrenCount,
}) {
final expected = amExpectedPlacesAvailable(
maxChildren: maxChildren,
childrenCount: childrenCount,
);
if (expected == null) return false;
if (placesAvailable == null) return childrenCount > (maxChildren ?? 0);
return placesAvailable != expected;
}
String? amPlacesVigilanceMessage(AssistanteMaternelleModel am) {
if (!amHasPlacesInconsistency(
maxChildren: am.maxChildren,
placesAvailable: am.placesAvailable,
childrenCount: am.children.length,
)) {
return null;
}
final stored = am.placesAvailable;
final expected = amExpectedPlacesAvailable(
maxChildren: am.maxChildren,
childrenCount: am.children.length,
);
final storedLabel = stored?.toString() ?? 'non renseigné';
final expectedLabel = expected?.toString() ?? '';
return 'Point de vigilance : l\'AM déclare $storedLabel place(s) disponible(s), '
'alors que capacité ${am.maxChildren ?? ''} '
'${am.children.length} enfant(s) rattaché(s) = $expectedLabel.';
}
+102 -2
View File
@@ -1,4 +1,6 @@
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
/// Affiche une date ISO / parseable en `dd/MM/yyyy`, avec repli sur la chaîne ou [ifEmpty].
String formatIsoDateFr(String? s, {String ifEmpty = ''}) {
@@ -10,7 +12,65 @@ String formatIsoDateFr(String? s, {String ifEmpty = ''}) {
}
}
/// Parties d'âge calendaire entre [birth] et [reference] (date-only).
/// Affiche une date ISO en saisie guidée `jj / mm / aaaa`.
String formatIsoDateFrInput(String? s, {String ifEmpty = ''}) {
if (s == null || s.trim().isEmpty) return ifEmpty;
try {
final dt = DateTime.parse(s.trim());
return formatFrenchDateDigits(
DateFormat('ddMMyyyy').format(dt),
);
} catch (_) {
return formatFrenchDateDigits(s.replaceAll(RegExp(r'\D'), ''));
}
}
/// Formate jusqu’à 8 chiffres en `jj / mm / aaaa`.
String formatFrenchDateDigits(String digits) {
final d = digits.replaceAll(RegExp(r'\D'), '');
final limited = d.length > 8 ? d.substring(0, 8) : d;
final buf = StringBuffer();
for (var i = 0; i < limited.length; i++) {
if (i == 2 || i == 4) buf.write(' / ');
buf.write(limited[i]);
}
return buf.toString();
}
/// Convertit `dd/MM/yyyy`, `dd / MM / yyyy`, 8 chiffres ou ISO en `yyyy-MM-dd`.
String? parseFrDateToIso(String text) {
final t = text.trim();
if (t.isEmpty) return null;
final digits = t.replaceAll(RegExp(r'\D'), '');
if (digits.length == 8) {
final dd = digits.substring(0, 2);
final mm = digits.substring(2, 4);
final yyyy = digits.substring(4, 8);
try {
return DateFormat('dd/MM/yyyy')
.parseStrict('$dd/$mm/$yyyy')
.toIso8601String()
.split('T')
.first;
} catch (_) {}
}
try {
return DateFormat('dd/MM/yyyy')
.parseStrict(t)
.toIso8601String()
.split('T')
.first;
} catch (_) {
try {
return DateTime.parse(t).toIso8601String().split('T').first;
} catch (_) {
return null;
}
}
}
({int years, int months, int days}) computeChildAgeParts(
DateTime birth,
DateTime reference,
@@ -45,7 +105,7 @@ String formatChildAgeLabel({
String? dueDate,
String? status,
}) {
if (status == 'a_naitre') {
if (status == 'a_naitre' || normalizeEnfantStatus(status) == 'a_naitre') {
final due = formatIsoDateFr(dueDate, ifEmpty: '');
if (due.isNotEmpty) return 'Naissance prévue : $due';
return 'À naître';
@@ -77,3 +137,43 @@ String formatChildAgeLabel({
return 'Né le ${formatIsoDateFr(birthDate)}';
}
}
/// Saisie date FR : 8 chiffres → `jj / mm / aaaa`.
class FrenchDateInputFormatter extends TextInputFormatter {
const FrenchDateInputFormatter();
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
final limited = digits.length > 8 ? digits.substring(0, 8) : digits;
final formatted = formatFrenchDateDigits(limited);
final digitsBeforeCursor = newValue.text
.substring(0, newValue.selection.start.clamp(0, newValue.text.length))
.replaceAll(RegExp(r'\D'), '')
.length
.clamp(0, limited.length);
return TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(
offset: _cursorOffset(formatted, digitsBeforeCursor),
),
);
}
static int _cursorOffset(String formatted, int digitsBeforeCursor) {
if (digitsBeforeCursor <= 0) return 0;
var seen = 0;
for (var i = 0; i < formatted.length; i++) {
if (RegExp(r'\d').hasMatch(formatted[i])) {
seen++;
if (seen >= digitsBeforeCursor) return i + 1;
}
}
return formatted.length;
}
}
@@ -0,0 +1,51 @@
/// Statuts enfant alignés sur `statut_enfant_type` (BDD / back #131).
const enfantStatusValues = [
'a_naitre',
'sans_garde',
'garde',
'scolarise',
];
/// Normalise une valeur API (legacy `actif` → `sans_garde`).
String normalizeEnfantStatus(String? raw) {
final s = (raw ?? '').trim().toLowerCase();
if (s.isEmpty) return 'sans_garde';
if (s == 'actif') return 'sans_garde';
return s;
}
String _accordeAuGenre(String masculin, String feminin, String? gender) {
final g = (gender ?? '').trim().toUpperCase();
if (g == 'F') return feminin;
return masculin;
}
/// Libellé affiché pour un statut enfant.
String enfantStatusLabel(String? status, {String? gender}) {
switch (normalizeEnfantStatus(status)) {
case 'a_naitre':
return 'À naître';
case 'sans_garde':
return 'Sans garde';
case 'garde':
return _accordeAuGenre('Gardé', 'Gardée', gender);
case 'scolarise':
return _accordeAuGenre('Scolarisé', 'Scolarisée', gender);
default:
return status?.trim().isNotEmpty == true ? status!.trim() : '';
}
}
/// Libellé court pour colonnes validation (null = pas de bandeau).
String? enfantColumnStatusLabel({String? status, String? gender}) {
final s = normalizeEnfantStatus(status);
switch (s) {
case 'a_naitre':
case 'sans_garde':
case 'garde':
case 'scolarise':
return enfantStatusLabel(s, gender: gender);
default:
return null;
}
}
+10
View File
@@ -0,0 +1,10 @@
import 'package:p_tits_pas/models/enfant_admin_model.dart';
/// Enfant sans lien parent (orphelins daffiliation) — ticket #157.
bool enfantHasNoResponsable(EnfantAdminModel enfant) => enfant.hasNoResponsable;
/// Message vigilance liste Enfants (même usage que [amPlacesVigilanceMessage]).
String? enfantSansResponsableVigilanceMessage(EnfantAdminModel enfant) {
if (!enfantHasNoResponsable(enfant)) return null;
return 'Aucun responsable rattaché — à rattacher à un foyer';
}
+41
View File
@@ -1,3 +1,5 @@
import 'package:flutter/services.dart';
/// Formatage affichage prénom / nom (capitalisation par mot et segments après `-` ou `'`).
String formatPersonNameCase(String raw) {
@@ -9,6 +11,22 @@ String formatPersonNameCase(String raw) {
return words.map(_capitalizeComposedWord).join(' ');
}
/// Variante saisie live : pas de majuscule tant que le mot na quune lettre ;
/// dès la 2ᵉ lettre, capitalisation comme [formatPersonNameCase].
String formatPersonNameCaseTyping(String raw) {
if (raw.isEmpty) return raw;
final trailingMatch = RegExp(r'(\s*)$').firstMatch(raw);
final trailing = trailingMatch?.group(1) ?? '';
final core = raw.substring(0, raw.length - trailing.length);
if (core.isEmpty) return raw;
final words = core.split(RegExp(r'\s+'));
final formatted = words.map((w) {
if (w.length < 2) return w;
return _capitalizeComposedWord(w);
}).join(' ');
return formatted + trailing;
}
String _capitalizeComposedWord(String word) {
if (word.isEmpty) {
return word;
@@ -30,3 +48,26 @@ String _capitalizeComposedWord(String word) {
}
return buffer.toString();
}
/// Formateur de saisie prénom / nom (capitalisation progressive).
class PersonNameInputFormatter extends TextInputFormatter {
const PersonNameInputFormatter();
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final formatted = formatPersonNameCaseTyping(newValue.text);
if (formatted == newValue.text) return newValue;
final sel = newValue.selection;
final offset = sel.isValid
? sel.baseOffset.clamp(0, formatted.length)
: formatted.length;
return TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: offset),
);
}
}
+37 -9
View File
@@ -49,12 +49,21 @@ String nirToRaw(String normalized) {
return s;
}
/// Formate pour affichage : 1 12 34 56 789 012 - 34 ou 1 12 34 2A 789 012 - 34 (Corse).
/// Formate pour affichage (complet ou en cours de saisie) :
/// `1 12 34 56 789 012 - 34` ou `1 12 34 2A 789 012 - 34` (Corse).
String formatNir(String raw) {
final r = nirToRaw(raw);
if (r.length < 15) return r;
// Même structure pour tous : sexe + année + mois + département + commune + ordre-clé.
return '${r.substring(0, 1)} ${r.substring(1, 3)} ${r.substring(3, 5)} ${r.substring(5, 7)} ${r.substring(7, 10)} ${r.substring(10, 13)} - ${r.substring(13, 15)}';
final r = nirToRaw(raw).toUpperCase();
if (r.isEmpty) return '';
final buf = StringBuffer();
for (var i = 0; i < r.length && i < 15; i++) {
if (i == 1 || i == 3 || i == 5 || i == 7 || i == 10) {
buf.write(' ');
} else if (i == 13) {
buf.write(' - ');
}
buf.write(r[i]);
}
return buf.toString();
}
/// Aligné sur le backend (NIR côté API / nir.util.ts) : sexe 13, département 2A ou 2B pour la Corse.
@@ -92,20 +101,39 @@ String? validateNir(String? value) {
return null;
}
/// Formateur de saisie : affiche le NIR formaté (1 12 34 56 789 012 - 34) et limite à 15 caractères utiles.
/// Validation pendant la saisie : pas derreur si incomplet encore plausible ;
/// dès 15 caractères, même contrôles que [validateNir].
String? validateNirTyping(String? value) {
if (value == null || value.trim().isEmpty) return null;
final raw = nirToRaw(value).toUpperCase();
if (raw.isEmpty) return null;
if (raw[0] != '1' && raw[0] != '2' && raw[0] != '3') {
return 'Format NIR invalide (ex. 1 12 34 56 789 012 - 34 ou 2A pour la Corse)';
}
if (raw.length < 15) return null;
return validateNir(value);
}
/// Formateur de saisie : affiche le NIR formaté au fil de la frappe et limite à 15 caractères utiles.
class NirInputFormatter extends TextInputFormatter {
const NirInputFormatter();
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final raw = normalizeNir(newValue.text);
if (raw.isEmpty) return newValue;
if (raw.isEmpty) {
return const TextEditingValue(
text: '',
selection: TextSelection.collapsed(offset: 0),
);
}
final formatted = formatNir(raw);
final offset = formatted.length;
return TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: offset),
selection: TextSelection.collapsed(offset: formatted.length),
);
}
}
@@ -167,6 +167,7 @@ class ParentRegistrationPayload {
final map = <String, dynamic>{
'genre': apiGenres.contains(c.genre) ? c.genre : 'Autre',
'grossesse_multiple': c.multipleBirth,
'consent_photo': c.photoConsent,
};
final prenom = c.firstName.trim();
+2 -5
View File
@@ -38,15 +38,12 @@ class RepriseMapper {
? isoToDdMmYyyy(e.dueDate)
: isoToDdMmYyyy(e.birthDate);
final photo = e.photoUrl?.trim();
final hasPhoto = photo != null && photo.isNotEmpty;
return ChildData(
firstName: e.firstName ?? '',
lastName: e.lastName ?? '',
dob: dob,
genre: e.gender ?? '',
// Inscription initiale exigeait la coche pour envoyer la photo ; le back
// ne persistait pas toujours consent_photo — on pré-coche si photo en base.
photoConsent: e.consentPhoto || hasPhoto,
photoConsent: e.consentPhoto,
multipleBirth: e.estMultiple,
isUnbornChild: isUnborn,
cardColor: _childCardColors[index % _childCardColors.length],
@@ -200,7 +197,7 @@ class RepriseMapper {
postalCode: dossier.codePostal ?? '',
city: dossier.ville ?? '',
existingPhotoUrl: displayPhoto,
consentementPhoto: dossier.consentementPhoto || hasPhoto,
consentementPhoto: dossier.consentementPhoto,
dateOfBirth: parseIsoDate(dossier.dateNaissance),
birthCity: dossier.lieuNaissanceVille ?? '',
birthCountry: dossier.lieuNaissancePays ?? '',
@@ -0,0 +1,59 @@
/// Création comptes staff (gestionnaire / admin) — ticket #161.
bool canCreateStaffAccounts(String? role) {
final r = (role ?? '').trim().toLowerCase();
return r == 'administrateur' || r == 'super_admin';
}
/// Droits daffichage poubelle — tickets #154 / #160.
bool canDeleteMetier(String? role) {
final r = (role ?? '').trim().toLowerCase();
return r == 'gestionnaire' ||
r == 'administrateur' ||
r == 'super_admin';
}
bool canDeleteGestionnaire(String? role) {
final r = (role ?? '').trim().toLowerCase();
return r == 'administrateur' || r == 'super_admin';
}
bool canDeleteAdministrateur({
required String? currentRole,
required String? currentUserId,
required String targetUserId,
required String targetRole,
required int adminCount,
}) {
final me = (currentRole ?? '').trim().toLowerCase();
if (me != 'administrateur' && me != 'super_admin') return false;
if (targetUserId.trim().isEmpty) return false;
if (currentUserId != null &&
currentUserId.trim() == targetUserId.trim()) {
return false;
}
final target = targetRole.trim().toLowerCase();
if (target == 'super_admin') return false;
if (adminCount <= 1 && me != 'super_admin') return false;
return true;
}
String? adminDeleteBlockedReason({
required String? currentRole,
required String? currentUserId,
required String targetUserId,
required String targetRole,
required int adminCount,
}) {
if (currentUserId != null &&
currentUserId.trim() == targetUserId.trim()) {
return 'Vous ne pouvez pas supprimer votre propre compte.';
}
if (targetRole.trim().toLowerCase() == 'super_admin') {
return 'Le super administrateur ne peut pas être supprimé.';
}
if (adminCount <= 1 &&
(currentRole ?? '').trim().toLowerCase() != 'super_admin') {
return 'Seul un super administrateur peut supprimer le dernier administrateur.';
}
return null;
}
@@ -4,7 +4,9 @@ import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
import 'package:p_tits_pas/widgets/dashboard/user_card.dart';
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
class AdminManagementWidget extends StatefulWidget {
@@ -24,6 +26,7 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
String? _error;
List<AppUser> _admins = [];
String? _currentUserRole;
String? _currentUserId;
@override
void initState() {
@@ -62,6 +65,7 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
if (cached != null) {
setState(() {
_currentUserRole = (cached.role).toLowerCase();
_currentUserId = cached.id;
});
return;
}
@@ -69,6 +73,7 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
if (!mounted || refreshed == null) return;
setState(() {
_currentUserRole = (refreshed.role).toLowerCase();
_currentUserId = refreshed.id;
});
}
@@ -79,6 +84,16 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
return _currentUserRole == 'super_admin';
}
bool _canDeleteAdmin(AppUser target) {
return canDeleteAdministrateur(
currentRole: _currentUserRole,
currentUserId: _currentUserId,
targetUserId: target.id,
targetRole: target.role,
adminCount: _admins.length,
);
}
Future<void> _openAdminEditDialog(AppUser user) async {
final canEdit = _canEditAdmin(user);
final changed = await showDialog<bool>(
@@ -98,6 +113,56 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
}
}
Future<void> _confirmDelete(AppUser user) async {
final blocked = adminDeleteBlockedReason(
currentRole: _currentUserRole,
currentUserId: _currentUserId,
targetUserId: user.id,
targetRole: user.role,
adminCount: _admins.length,
);
if (blocked != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(blocked)),
);
return;
}
final name = user.fullName.isNotEmpty ? user.fullName : user.email;
final isLast = _admins.length <= 1;
final confirmed = await showSuppressionConfirmDialog(
context,
title: 'Supprimer l\'administrateur',
people: [SuppressionPersonLine.administrateur(name)],
footnotes: [
if (isLast)
'Attention : cest le dernier administrateur.',
'Le compte sera définitivement supprimé.',
],
);
if (!confirmed || !mounted) return;
try {
final result = await UserService.deleteUser(user.id);
if (!mounted) return;
final msg =
(result['message'] ?? 'Administrateur supprimé.').toString();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
await _loadAdmins();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
e is Exception
? e.toString().replaceFirst('Exception: ', '')
: 'Erreur suppression',
),
),
);
}
}
@override
Widget build(BuildContext context) {
final query = widget.searchQuery.toLowerCase();
@@ -117,11 +182,13 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
final user = filteredAdmins[index];
final isSuperAdmin = _isSuperAdmin(user);
final canEdit = _canEditAdmin(user);
return AdminUserCard(
final canDelete = _canDeleteAdmin(user);
return UserCard(
title: user.fullName,
fallbackIcon: isSuperAdmin
? Icons.verified_user_outlined
: Icons.manage_accounts_outlined,
onCardTap: () => _openAdminEditDialog(user),
subtitleLines: [
user.email,
'Téléphone : ${user.telephone?.trim().isNotEmpty == true ? formatPhoneForDisplay(user.telephone!) : 'Non renseigné'}',
@@ -147,6 +214,8 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
_openAdminEditDialog(user);
},
),
if (canDelete)
suppressionIconButton(onPressed: () => _confirmDelete(user)),
],
);
},

Some files were not shown because too many files have changed in this diff Show More