Compare commits

...
108 Commits
Author SHA1 Message Date
jmartinandCursor a7c0864279 V0.1.0 - CDC V1.4 - SRS gestion utilisateurs
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 10:22:33 +02:00
jmartinandCursor 89f65356d1 docs(#117): CDC V1.4 (users à jour, reste conservé) + SRS utilisateurs.
- CDC complet conservé ; sections gestion utilisateurs / dossiers alignées v0.1.0
- Archive V1.3 + EVOLUTIONS_CDC ; nouvelle 12_SRS-GESTION-UTILISATEURS
- INDEX / versions / bilan mis à jour

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 10:22:32 +02:00
jmartin aae2beeac8 docs: rationalisation post-0.1.0 + bilan (depuis develop). 2026-09-14 17:45:52 +02:00
jmartinandCursor 71b1897678 docs: rationalisation post-0.1.0 (bilan, purge tmp, archives).
- Bilan 29 + index versions 05 ; suivi tickets pointeur Gitea
- Purge docs/tmp et archive/temporaires livrés
- Archive SuperNounou, liste tickets figée, backlog Phase 2, notes 14/92
- Suppression stubs PROCEDURE/22 ; INDEX et roadmap alignés

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 17:45:43 +02:00
jmartinandCursor f745079f0a feat(#164): uniformisation modale staff gestionnaire / admin (squash develop).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 17:34:25 +02:00
jmartin 3218daa12e merge(#164): uniformisation modale staff gestionnaire / admin. 2026-09-14 17:29:31 +02:00
jmartinandCursor 1d6261b312 feat(#164): uniformise la modale staff (gestionnaire / admin).
Shell 930 + Validation*Field, dirty-state, StaffUserFormModal sous
widgets/dashboard/ ; retire AdminUserFormDialog.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 17:29:13 +02:00
jmartin 5b83102a59 feat(#152/#155): cleanup est_multiple + dashboard sans préfixe Admin (squash develop).
Suppression complète grossesse multiple / est_multiple (BDD, API, front).
Rename option C : widgets partagés et panels staff sous widgets/dashboard/,
AdminManagementWidget seul restant dans widgets/admin/.
2026-09-14 12:52:22 +02:00
jmartin 9557cf9947 merge(#155): rename Admin* + panels sous widgets/dashboard/. 2026-09-14 12:46:46 +02:00
jmartinandCursor 939e7777ac docs(#155): mini-spec phase 2 — move panels admin → dashboard.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 11:05:47 +02:00
jmartinandCursor b474842e19 refactor(#155): phase 2 — panels staff sous widgets/dashboard/.
Déplace les panels/wizards/validation/common partagés hors de
widgets/admin/ ; ne conserve que AdminManagementWidget (variante A).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 11:05:23 +02:00
jmartinandCursor a312d9c0fa 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-14 10:57:58 +02:00
jmartin b5b32062b3 merge(#152): suppression grossesse multiple / est_multiple. 2026-09-14 10:57:28 +02:00
jmartinandCursor 946d8edcd2 feat(#152): suppression complète grossesse multiple / est_multiple.
Retire le champ partout : BDD (migration + seed), entity/DTO/services Nest,
modèles et payloads Flutter, scripts de test et docs. Back et front alignés
(forbidNonWhitelisted). Aucun fantôme de compat.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 23:08:36 +02:00
jmartin eb5e4aa915 feat(#161): admin peut créer gestionnaire et administrateur (squash develop).
Ouvre POST/PATCH /gestionnaires et POST /users/admin aux administrateurs
(en plus du super_admin). Garde-fou front : pas de création staff sur le
dashboard gestionnaire.
2026-09-10 23:00:35 +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 d6d8b299dd feat(#159/#160): suppressions métier dashboard — API + UI (squash develop).
Matrice PO #154 : supprimer dossiers famille/AM, parents, enfants et comptes
staff avec règles métier (placements clôturés, sans_enfant, cascade foyer,
droits gestionnaire/admin). Poubelle en liste, dialogues de confirmation,
garde-fou gestionnaire→gestionnaire. Inclut polish hauteur modale fiche AM.
2026-09-10 22:43:33 +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 ea0e97d930 feat(#135): mode édition dossier + ajout co-parent (squash develop).
Wizard edit famille/AM, POST co-parent, PATCH enfants avec photo.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 17:32:06 +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 84e46162fd feat(#153): onglet permanent Dossiers — liste unifiée + pending.
Remplace l’onglet conditionnel « À valider » par un onglet Dossiers
(pending en haut, familles + AM en dessous), avec GET /dossiers,
recherche, et libellés pending NOM Prénom.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 19:41:44 +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 14580c34e0 feat(#129): création dossier famille staff — API + wizard dashboard.
Permet au dashboard de créer un dossier parent complet (pivot, co-parent
optionnel, enfants, n° dossier) déjà actif, avec e-mail de création de
mot de passe — miroir du parcours AM (#156), sans passer par l’inscription
publique en attente.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 13:16:02 +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 ae610733cc feat(#156): création dossier AM staff — API + wizard dashboard.
Squash depuis develop : POST /assistantes-maternelles/dossier (actif +
mail MDP), AmDossierWizard create/review, validations inscription.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 16:53:18 +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 846afed86c feat(#158): affiliation enfant au foyer — attach/detach pivot + co-parent.
Squash depuis develop : un POST/DELETE propage les liens à tout le foyer ;
front un seul appel API ; doc empreinte.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 18:31:15 +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 99a6c17c23 feat(#157): enfants sans responsable — alerte liste et détach dernier parent.
Squash depuis develop : détachement dernier parent autorisé, flag
sans_responsable, vigilance liste Enfants, rattachement foyer depuis
la fiche, polish détach et compteur foyer interim (#158 à suivre).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 19:43:58 +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 04f49cb62f feat(#132): création enfant staff (onglet Enfants) — front + back.
Squash depuis develop : POST /enfants staff + parent_user_id, photo
multipart, modale création + sélection famille, fixes UX/birth_date.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 19:00:12 +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 3c7f4f6e16 fix(#151): autoriser GET /relais pour gestionnaire + robustesse combo
Permet au gestionnaire de peupler la liste Relais (création/édition).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 16:54:17 +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
jmartinandCursor dcd407a3da feat(#146–#149): sélection enfant/AM, capacité max et case libre.
Modales de rattachement partagées, filtre Libre/Sans garde, recalcul des places à l'attach, désactivation si capacité pleine, clic sur case libre.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 16:25:42 +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 fde63f8e72 feat(#145): lien co-parent cliquable dans la fiche parent.
Squash merge develop → master.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 14:56:45 +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 1f8f1b9507 feat(#138,#143,#144): fiche enfant admin, consentement photo et fix gestionnaire.
Squash merge develop → master.
- #138 : modale enfant paysage, zone AM/scolarisation, liens responsables
- #144 : persistance consent_photo à l'inscription enfant
- #143 : masquer Supprimer sur la propre fiche gestionnaire

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 13:21:43 +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
jmartin d55f240f56 Merge branch 'feature/140-dashboard-admin-ch6-famille' into develop
Livraison epic #140 — dashboard admin ch.6 famille (doc 28 §6.1–§6.2).

Inclus :
- Backend : PATCH fiche parent, rattachement/détachement enfant (#115)
- Frontend : fiche parent éditable (#131), onglet Enfants (#137), fiche/liste enfant (#138), affiliation UI (#116), UserService (#130)
- IdentityBlock partagé, parsing parentChildren, avatars web /uploads

Reste ouvert sur tickets dédiés : fiche enfant UI (#138), modale rattacher (#116), fiche AM (#131), création enfant (#129).
2026-06-24 23:22:39 +02:00
jmartinandCursor 966f4a9c9c feat(#140): liste enfants unifiée — cartes, âge précis et cadre scrollable
Partage AdminEnfantUserCard entre fiche parent et onglet Enfants, enrichit ParentChildSummary (photo, dates) et compacte la modale parent.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 23:06:38 +02:00
jmartinandCursor 8494341b56 feat(#140): fiches admin famille — IdentityBlock, parsing enfants et avatars web
Extrait IdentityBlock réutilisable, aligne les modales parent/enfant sur le style validation, corrige le parsing parentChildren et les URLs /uploads en Flutter web.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-21 17:42:00 +02:00
jmartinandCursor 1dddc67933 feat(#140): dashboard admin ch.6 — fiche parent, enfants, affiliation
Back: PATCH /parents/:id/fiche, attach/detach enfant, GET /enfants enrichi.
Front: modale parent éditable, onglet Enfants, fiche enfant, UserService.

Couvre doc 28 §6.1–6.2 ; tickets liés #115 #116 #130 #131 #137 #138.
Hors scope: fiche AM (#131), création admin (#129).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 00:35:00 +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
188 changed files with 21924 additions and 4134 deletions
+3
View File
@@ -89,6 +89,9 @@
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"moduleNameMapper": {
"^src/(.*)$": "<rootDir>/$1"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
-1
View File
@@ -33,7 +33,6 @@ model Child {
dateOfBirth DateTime
photoUrl String?
photoConsent Boolean @default(false)
isMultiple Boolean @default(false)
isUnborn Boolean @default(false)
parentId String
parent Parent @relation(fields: [parentId], references: [id])
+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();
@@ -0,0 +1,153 @@
/**
* Met à jour l'issue Gitea #140 — epic dashboard admin ch.6 famille.
* Usage: node backend/scripts/update-gitea-issue-140-ch6-famille.js
* Token : .gitea-token (racine), GITEA_TOKEN, ou docs/27_BRIEFING-FRONTEND.md
*/
const https = require('https');
const fs = require('fs');
const path = require('path');
const repoRoot = path.join(__dirname, '../..');
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 (voir docs/26_GITEA-API.md)');
process.exit(1);
}
const body = `## Rôle de ce ticket
**#140 est un ticket epic / livraison** : il regroupe la mise en œuvre du **chapitre 6** du doc [28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md](../docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md) (§6.1 et §6.2) sur la branche \`feature/140-dashboard-admin-ch6-famille\`.
Il **ne remplace pas** les tickets détaillés ci-dessous : il sert de **fil de livraison** (PR, recette, fermeture coordonnée). Chaque sous-ticket garde son périmètre propre ; #140 est clos quand l'ensemble est **fonctionnel et homogène en UI**.
---
## Tickets couverts (périmètres embarqués)
| Ticket | Sujet | Rôle dans #140 |
|--------|--------|----------------|
| **#115** | Rattachement parent — **backend** | API \`POST/DELETE …/enfants/:enfantId\` |
| **#116** | Rattachement parent — **frontend** | UI rattacher / détacher depuis la fiche parent |
| **#130** | \`UserService\` — APIs admin | Appels \`getParents\`, \`getParent\`, \`getEnfants\`, \`updateParentFiche\`, etc. |
| **#131** | Fiche parent éditable | Modale parent (dashboard) — *hors fiche AM* |
| **#137** | Onglet **Enfants** | Liste globale admin + accès fiche enfant |
| **#138** | Fiche enfant + liste dans fiche parent | \`AdminChildDetailModal\` + liste enfants en bas de fiche parent |
> **Note :** fermer #140 peut entraîner la fermeture **partielle ou totale** de ces tickets selon ce qui est réellement livré et recetté dans la PR.
---
## Hors scope #140
- **§6.3** — Création dossier admin sans numéro → **#129**
- Fiche **AM** éditable (dashboard) → reste **#131** (partie AM)
- Parcours gestionnaire « famille complexe » → **#139**
- Qualification responsable légal (combobox sur lien) → ticket à créer
---
## Backend (attendu / livré)
- [x] \`PATCH /parents/:id/fiche\` — édition fiche parent (admin/gestionnaire)
- [x] \`POST /parents/:id/enfants/:enfantId\` — rattacher un enfant existant
- [x] \`DELETE /parents/:id/enfants/:enfantId\` — détacher (garde-fou : ≥1 responsable / enfant)
- [x] \`GET /enfants\` enrichi ; \`PATCH /enfants/:id\` pour gestionnaire
---
## Frontend — fait
- [x] Modale **fiche parent** éditable (shell aligné validation, statut gélule, téléphone formaté, \`IdentityBlock\`)
- [x] Onglet **Enfants** — liste globale (\`EnfantManagementWidget\`)
- [x] Liste enfants dans fiche parent — cartes (photo, nom, âge ans/mois, statut), cadre blanc, scroll 2,5 lignes
- [x] Rattacher / détacher un enfant **existant** (API branchée)
- [x] Parsing robuste \`parentChildren\` + URLs médias \`/uploads\` en Flutter web
- [x] \`UserService\` — APIs parents / enfants / affiliation
---
## Frontend — reste à faire (bloquant clôture)
### Fiche enfant — #138 (UI)
- [ ] Reprendre \`AdminChildDetailModal\` : même **look & feel** que fiche parent / modales validation (largeur ~930 px, grille champs, photo enfant)
- [ ] Aligner dates, genre, statut sur les wizards validation famille
### Modale **rattacher** un enfant — #116 (UI)
- [ ] Remplacer le \`SimpleDialog\` actuel par une modale cohérente : liste type \`AdminEnfantUserCard\` (photo, nom, âge), recherche éventuelle
### Création d'un **nouvel** enfant depuis la fiche parent — doc §6.2
- [ ] **Non implémenté** aujourd'hui (seul le rattachement d'un enfant déjà en base existe)
- [ ] À trancher : inclus dans #140 si le back expose un \`POST\` admin depuis le contexte parent, sinon ticket dédié / extension #129
---
## Recette avant merge
1. Parent avec 0 / 1 / 3+ enfants — liste, scroll, compteur
2. Rattacher puis détacher (message si dernier responsable)
3. Clic enfant → fiche enfant (après refonte UI)
4. Onglet Enfants — même rendu cartes
5. Avatars photos (URLs absolues web)
---
## Branche
\`feature/140-dashboard-admin-ch6-famille\`
## Références
- Doc produit : \`docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md\` §6.1, §6.2`;
const payload = JSON.stringify({ body });
const req = https.request(
{
hostname: 'git.ptits-pas.fr',
path: '/api/v1/repos/jmartin/petitspas/issues/140',
method: 'PATCH',
headers: {
Authorization: `token ${token}`,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
},
},
(res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
const json = JSON.parse(data);
console.log('Issue #140 mise à jour :', json.html_url);
console.log('updated_at:', json.updated_at);
} else {
console.error('Erreur', res.statusCode, data);
process.exit(1);
}
});
},
);
req.on('error', (err) => {
console.error(err);
process.exit(1);
});
req.write(payload);
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 -4
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 {
@@ -61,13 +63,13 @@ export class Children {
@Column({ type: 'timestamptz', nullable: true, name: 'date_consentement_photo' })
consent_photo_at?: Date;
@Column({ default: false, name: 'est_multiple', type: 'boolean' })
is_multiple: boolean;
// Lien via table de jointure enfants_parents
@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',
});
+410 -92
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,9 +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.is_multiple = enfantDto.grossesse_multiple || 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!;
const enfantEnregistre = await manager.save(Children, enfant);
enfantsEnregistres.push(enfantEnregistre);
@@ -611,44 +621,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 +921,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 +972,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 +1378,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);
@@ -1071,8 +1386,11 @@ export class AuthService {
enfant.status = StatutEnfantType.A_NAITRE;
}
}
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) {
@@ -55,9 +55,13 @@ export class EnfantInscriptionDto {
@IsString()
photo_filename?: string;
@ApiProperty({ example: false, required: false, description: 'Grossesse multiple (jumeaux, triplés, etc.)' })
@ApiProperty({
example: true,
required: false,
description: 'Consentement affichage / stockage photo (colonne enfants.consentement_photo)',
})
@IsOptional()
@IsBoolean()
grossesse_multiple?: boolean;
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;
@@ -60,7 +74,16 @@ export class CreateEnfantsDto {
@IsDateString()
consent_photo_at?: string;
@ApiProperty({ default: false })
@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;
}
@@ -29,9 +29,6 @@ export class EnfantResponseDto {
@ApiProperty({ example: false })
consent_photo: boolean;
@ApiProperty({ example: false })
is_multiple: boolean;
@ApiProperty({ example: 'UUID-parent' })
parent_id: string;
}
+123 -31
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,
@@ -83,19 +140,54 @@ export class EnfantsController {
return this.enfantsService.findOne(id, currentUser);
}
@Roles(RoleType.ADMINISTRATEUR, RoleType.SUPER_ADMIN, RoleType.PARENT)
@Roles(
RoleType.PARENT,
RoleType.ADMINISTRATEUR,
RoleType.SUPER_ADMIN,
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 { }
+130 -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,107 @@ 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,
});
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
async findAll(): Promise<Children[]> {
return this.childrenRepository.find({
relations: ['parentLinks'],
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 +182,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);
}
@@ -54,9 +54,6 @@ export class DossierFamilleEnfantDto {
description: 'Consentement affichage photo (colonne consentement_photo)',
})
consent_photo?: boolean;
@ApiProperty({ required: false, description: 'Grossesse multiple (est_multiple)' })
est_multiple?: boolean;
}
/** Réponse GET /parents/dossier-famille/:numeroDossier dossier famille complet. Ticket #119 */
@@ -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;
}
@@ -0,0 +1,57 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import {
IsEmail,
IsEnum,
IsOptional,
IsString,
MaxLength,
} from 'class-validator';
import { StatutUtilisateurType } from 'src/entities/users.entity';
/** Mise à jour fiche parent par admin/gestionnaire (doc 28 §6.1, ticket #131). */
export class UpdateParentFicheAdminDto {
@ApiPropertyOptional({ example: 'Dupont' })
@IsOptional()
@IsString()
@MaxLength(100)
nom?: string;
@ApiPropertyOptional({ example: 'Marie' })
@IsOptional()
@IsString()
@MaxLength(100)
prenom?: string;
@ApiPropertyOptional({ example: 'marie.dupont@example.com' })
@IsOptional()
@IsEmail()
email?: string;
@ApiPropertyOptional({ example: '+33612345678' })
@IsOptional()
@IsString()
@MaxLength(20)
telephone?: string;
@ApiPropertyOptional({ example: '10 rue de la Paix' })
@IsOptional()
@IsString()
adresse?: string;
@ApiPropertyOptional({ example: 'Paris' })
@IsOptional()
@IsString()
@MaxLength(150)
ville?: string;
@ApiPropertyOptional({ example: '75001' })
@IsOptional()
@IsString()
@MaxLength(10)
code_postal?: string;
@ApiPropertyOptional({ enum: StatutUtilisateurType })
@IsOptional()
@IsEnum(StatutUtilisateurType)
statut?: StatutUtilisateurType;
}
@@ -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);
});
});
+135 -11
View File
@@ -1,7 +1,10 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Patch,
Post,
@@ -9,28 +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)' })
@@ -79,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)
@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)
@@ -101,8 +159,73 @@ 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)
@Patch(':id/fiche')
@ApiOperation({ summary: 'Mettre à jour la fiche parent (admin/gestionnaire) — ticket #131' })
@ApiParam({ name: 'id', description: "UUID utilisateur du parent" })
@ApiBody({ type: UpdateParentFicheAdminDto })
@ApiResponse({ status: 200, type: Parents, description: 'Fiche parent mise à jour' })
async updateFicheAdmin(
@Param('id') id: string,
@Body() dto: UpdateParentFicheAdminDto,
): Promise<Parents> {
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)
@Post(':id/enfants/:enfantId')
@ApiOperation({ summary: 'Rattacher un enfant à un parent — ticket #115' })
@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' })
async attachEnfant(
@Param('id') id: string,
@Param('enfantId') enfantId: string,
): Promise<Parents> {
const parent = await this.parentsService.attachEnfant(id, enfantId);
return mapParentForApi(parent);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@Delete(':id/enfants/:enfantId')
@ApiOperation({ summary: "Détacher un enfant d'un parent — ticket #115" })
@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' })
async detachEnfant(
@Param('id') id: string,
@Param('enfantId') enfantId: string,
): Promise<Parents> {
const parent = await this.parentsService.detachEnfant(id, enfantId);
return mapParentForApi(parent);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
@@ -111,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);
}
+4 -1
View File
@@ -4,15 +4,18 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { Parents } from 'src/entities/parents.entity';
import { DossierFamille, DossierFamilleEnfant } from 'src/entities/dossier_famille.entity';
import { ParentsChildren } from 'src/entities/parents_children.entity';
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]),
TypeOrmModule.forFeature([Parents, Users, DossierFamille, DossierFamilleEnfant, ParentsChildren]),
forwardRef(() => UserModule),
forwardRef(() => AuthModule),
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
+153 -5
View File
@@ -17,7 +17,9 @@ import {
DossierFamilleParentDto,
DossierFamilleEnfantDto,
} from './dto/dossier-famille-complet.dto';
import { ParentsChildren } from 'src/entities/parents_children.entity';
import { Children } from 'src/entities/children.entity';
import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto';
@Injectable()
export class ParentsService {
@@ -28,6 +30,8 @@ export class ParentsService {
private readonly usersRepository: Repository<Users>,
@InjectRepository(DossierFamille)
private readonly dossierFamilleRepository: Repository<DossierFamille>,
@InjectRepository(ParentsChildren)
private readonly parentsChildrenRepository: Repository<ParentsChildren>,
) {}
// Création dun parent
@@ -62,7 +66,7 @@ export class ParentsService {
// Liste des parents
async findAll(): Promise<Parents[]> {
return this.parentsRepository.find({
relations: ['user', 'co_parent', 'parentChildren', 'dossiers'],
relations: ['user', 'co_parent', 'parentChildren', 'parentChildren.child', 'dossiers'],
});
}
@@ -70,7 +74,7 @@ export class ParentsService {
async findOne(user_id: string): Promise<Parents> {
const parent = await this.parentsRepository.findOne({
where: { user_id },
relations: ['user', 'co_parent', 'parentChildren', 'dossiers'],
relations: ['user', 'co_parent', 'parentChildren', 'parentChildren.child', 'dossiers'],
});
if (!parent) throw new NotFoundException('Parent introuvable');
return parent;
@@ -82,6 +86,131 @@ export class ParentsService {
return this.findOne(id);
}
/**
* Mise à jour fiche parent (champs user + statut) par admin/gestionnaire. Ticket #131 / doc 28 §6.1.
*/
async updateFicheAdmin(parentUserId: string, dto: UpdateParentFicheAdminDto): Promise<Parents> {
const parent = await this.findOne(parentUserId);
const user = parent.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;
await this.usersRepository.save(user);
return this.findOne(parentUserId);
}
/**
* Membres du foyer (user ids) pour affiliation enfant.
* Pivot + co-parent (A→B et B→A) + même numero_dossier. Ticket #158.
*/
private async resolveFoyerParentUserIds(parent: Parents): Promise<string[]> {
const ids = new Set<string>([parent.user_id]);
if (parent.co_parent?.id) {
ids.add(parent.co_parent.id);
}
// 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');
}
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 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> {
const parent = await this.findOne(parentUserId);
const link = await this.parentsChildrenRepository.findOne({
where: { parentId: parentUserId, enfantId },
});
if (!link) {
throw new NotFoundException('Lien parent-enfant introuvable');
}
const foyerIds = await this.resolveFoyerParentUserIds(parent);
await this.parentsChildrenRepository.delete({
parentId: In(foyerIds),
enfantId,
});
return this.findOne(parentUserId);
}
/**
* Liste des familles en attente (une entrée par famille).
* Famille = lien co_parent ou partage d'enfants (même logique que backfill #103).
@@ -122,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,
@@ -138,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
@@ -188,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,
@@ -221,7 +370,6 @@ export class ParentsService {
status: child.status,
photo_url: child.photo_url ?? undefined,
consent_photo: child.consent_photo,
est_multiple: child.is_multiple,
};
}
@@ -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');
}
+119 -112
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,13 +169,12 @@ 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,
consentement_photo BOOLEAN DEFAULT false,
date_consentement_photo TIMESTAMPTZ,
est_multiple BOOLEAN DEFAULT false
date_consentement_photo TIMESTAMPTZ
);
-- ==========================================================
@@ -135,7 +187,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 +348,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 +373,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 +385,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 +399,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 +421,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 +448,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"
"5e8574b7-63e6-4d48-9af3-8d3bf7a6a6cf","sans_garde","Emma","Dupont","F","2020-06-01",,,False,
"a5c3268e-07eb-41a4-9f6c-2f9f16f37c3d","sans_garde",,,,"2020-01-01","2025-01-01",,False,
"e1a2b3c4-d5e6-4f7a-8b9c-1d2e3f4a5b6c","sans_garde","Emma","Martin",,"2023-02-15",,,False,
"e2b3c4d5-e6f7-4a8b-9c1d-2e3f4a5b6c7d","sans_garde","Noah","Martin",,"2023-02-15",,,False,
"e3c4d5e6-f7a8-4b9c-1d2e-3f4a5b6c7d8e","sans_garde","Léa","Martin",,"2023-02-15",,,False,
"e4d5e6f7-a8b9-4c1d-2e3f-4a5b6c7d8e9f","sans_garde","Chloé","Rousseau",,"2022-04-20",,,False,
"e5e6f7a8-b9c1-4d2e-3f4a-5b6c7d8e9f1a","sans_garde","Hugo","Rousseau",,"2024-03-10",,,False,
"e6f7a8b9-c1d2-4e3f-5a6b-7c8d9e0f1a2b","sans_garde","Maxime","Lecomte",,"2023-04-15",,,False,
"edd19cd1-bb67-4f14-8a37-c66b75c94537","scolarise","Lucas","Durand","H","2018-09-15",,,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,2 @@
-- #152 — Suppression grossesse multiple / est_multiple
ALTER TABLE enfants DROP COLUMN IF EXISTS est_multiple;
@@ -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;
+5 -5
View File
@@ -65,16 +65,16 @@ 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)
INSERT INTO enfants (id, prenom, nom, statut, date_naissance)
VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Léo', 'Parent', 'sans_garde', '2022-04-12')
ON CONFLICT (id) DO NOTHING;
INSERT INTO enfants (id, prenom, nom, statut, date_prevue_naissance, jumeau_multiple)
VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Mila', 'Parent', 'a_naitre', '2026-02-15', false)
INSERT INTO enfants (id, prenom, nom, statut, date_prevue_naissance)
VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Mila', 'Parent', 'a_naitre', '2026-02-15')
ON CONFLICT (id) DO NOTHING;
-- ------------------------------------------------------------
+16 -16
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) ==========
@@ -49,14 +49,14 @@ VALUES
ON CONFLICT (id_utilisateur) DO NOTHING;
-- ========== ENFANTS ==========
INSERT INTO enfants (id, prenom, nom, genre, date_naissance, statut, est_multiple)
INSERT INTO enfants (id, prenom, nom, genre, date_naissance, statut)
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'),
('e0000002-0002-0002-0002-000000000002', 'Noah', 'MARTIN', 'H', '2023-02-15', 'sans_garde'),
('e0000003-0003-0003-0003-000000000003', 'Léa', 'MARTIN', 'F', '2023-02-15', 'sans_garde'),
('e0000004-0004-0004-0004-000000000004', 'Chloé', 'ROUSSEAU', 'F', '2022-04-20', 'sans_garde'),
('e0000005-0005-0005-0005-000000000005', 'Hugo', 'ROUSSEAU', 'H', '2024-03-10', 'sans_garde'),
('e0000006-0006-0006-0006-000000000006', 'Maxime', 'LECOMTE', 'H', '2023-04-15', 'sans_garde')
ON CONFLICT (id) DO NOTHING;
-- ========== ENFANTS_PARENTS (liaison N:N) ==========
+53 -69
View File
@@ -1,93 +1,77 @@
# 📚 Index de la Documentation - PtitsPas App
# Index de la documentation P'titsPas
Bienvenue dans la documentation complète de l'application PtitsPas.
Index de navigation du dépôt. Dernière révision : **septembre 2026** (CDC V1.4 + SRS users #117).
Ce fichier sert d'index pour naviguer dans toute la documentation du projet.
## Produit & versions
## 📖 Table des matières
| Doc | Contenu |
|-----|---------|
| [01 — Cahier des charges V1.4](./01_CAHIER-DES-CHARGES.md) | CDC **complet** (cible) ; gestion utilisateurs alignée `v0.1.0` |
| [12 — SRS gestion utilisateurs](./12_SRS-GESTION-UTILISATEURS.md) | Spécification **technique** du domaine users / dossiers |
| [05 — Versions & milestones](./05_VERSIONS-ET-MILESTONES.md) | Semver Gitea + bilans |
| [29 — Bilan version 0.1.0](./29_BILAN-VERSION-0.1.0.md) | Tickets livrés 0.1.0 |
| [04 — Roadmap générale](./04_ROADMAP-GENERALE.md) | Vision phases long terme |
| [28 — Évolution famille / responsables](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md) | Limites modèle foyer / contournements |
### 📋 Cahier des Charges
- [**01 - Cahier des Charges**](./01_CAHIER-DES-CHARGES.md) - Cahier des charges complet du projet P'titsPas (V1.3 - 24/11/2025)
## Architecture & infra
### Architecture & Infrastructure
- [**02 - Architecture**](./02_ARCHITECTURE.md) - Vue d'ensemble de l'architecture mono-repo et multi-conteneurs
- [**03 - Déploiement**](./03_DEPLOYMENT.md) - Guide complet de déploiement et configuration CI/CD
| Doc | Contenu |
|-----|---------|
| [02 — Architecture](./02_ARCHITECTURE.md) | Mono-repo, conteneurs |
| [03 — Déploiement](./03_DEPLOYMENT.md) | Deploy / CI-CD |
| [10 — Database](./10_DATABASE.md) | Schéma BDD |
| [11 — API](./11_API.md) | Endpoints REST |
| [21 — Configuration système](./21_CONFIGURATION-SYSTEME.md) | Config on-premise |
| [99 — Règles de codage](./99_REGLES-CODAGE.md) | Conventions |
### Planification
- [**04 - Roadmap Générale**](./04_ROADMAP-GENERALE.md) - Roadmap complète du projet (Phases 1 à 5+)
## Workflows & métier
### Développement
- [**10 - Database Schema**](./10_DATABASE.md) - Schéma de la base de données et modèles
- [**11 - API Documentation**](./11_API.md) - Documentation complète des endpoints REST
- [**14 - Note backend config setup**](./14_NOTE-BACKEND-CONFIG-SETUP.md) - Setup configuration
- [**92 - Note backend gestionnaires**](./92_NOTE-BACKEND-GESTIONNAIRES.md) - Gestionnaires
- [**99 - Règles de codage**](./99_REGLES-CODAGE.md) - Conventions de code
| Doc | Contenu |
|-----|---------|
| [20 — Workflow création de compte](./20_WORKFLOW-CREATION-COMPTE.md) | Inscription / validation (détail historique) |
| [juridique/](./juridique/README.md) | CGU / CGC / privacy + [22 technique](./juridique/22_DOCUMENTS-LEGAUX.md) |
| [CHARTE_GRAPHIQUE.md](./CHARTE_GRAPHIQUE.md) | Charte UI |
### Workflows Fonctionnels
- [**20 - Workflow Création de Compte**](./20_WORKFLOW-CREATION-COMPTE.md) - Workflow complet de création et validation des comptes utilisateurs
- [**21 - Configuration Système**](./21_CONFIGURATION-SYSTEME.md) - Configuration on-premise dynamique
- [**22 - Documents Légaux**](./juridique/22_DOCUMENTS-LEGAUX.md) - Gestion CGU/Privacy avec versioning
## Projet & outillage
### Juridique (sources & technique)
- [**Dossier juridique**](./juridique/README.md) - Index : CGU/CGC en Markdown,
export PDF, lien vers la doc technique n°22
| Doc | Contenu |
|-----|---------|
| [23 — Suivi tickets](./23_SUIVI-TICKETS.md) | Pointeur Gitea |
| [24 — Décisions projet](./24_DECISIONS-PROJET.md) | ADR / décisions |
| [26 — API Gitea](./26_GITEA-API.md) | Issues, PR, milestones |
| [27 — Briefing frontend](./27_BRIEFING-FRONTEND.md) | Accès Git, priorités |
### Projet & suivi (Gitea / tickets)
- [**23 - Liste des Tickets**](./23_LISTE-TICKETS.md) - 61 tickets Phase 1 détaillés
- [**24 - Décisions Projet**](./24_DECISIONS-PROJET.md) - Décisions architecturales et fonctionnelles
- [**25 - Backlog Phase 2**](./25_PHASE-2-BACKLOG.md) - Fonctionnalités techniques reportées
- [**26 - API Gitea**](./26_GITEA-API.md) - Procédure d'utilisation de l'API Gitea (issues, PR, branches, labels)
- [**27 - Briefing frontend**](./27_BRIEFING-FRONTEND.md) - Accès Git, priorités, scripts Gitea (token)
## Audit
### Archive & convention de nommage
- [**Dossier archive**](./archive/README.md) - Fichiers **sans** `NN_` déplacés
(temporaires, obsolètes) ; règles de rangement et suppression
- Pointeur : [PROCEDURE-API-GITEA.md](./PROCEDURE-API-GITEA.md) → voir **26**
| Doc | Contenu |
|-----|---------|
| [90 — Audit YNOV](./90_AUDIT.md) | Analyse code étudiant |
### Exceptions de nommage (racine `docs/`)
Fichiers **sans préfixe numérique** encore à la racine par **héritage** ou
références outils (`.cursorrules`, etc.) — **à renommer** en `NN_` quand
possible :
- `CHARTE_GRAPHIQUE.md`
- `EVOLUTIONS_CDC.md`
- `SuperNounou_Cahier_Des_Charges_Complet_V1.1.md`
- `SuperNounou_SSS-001.md`
## Archive
### Administration (À créer)
- [**30 - Guide d'administration**](./30_ADMIN.md) - Gestion des utilisateurs, accès PgAdmin, logs
- [**31 - Troubleshooting**](./31_TROUBLESHOOTING.md) - Résolution des problèmes courants
| Emplacement | Usage |
|-------------|--------|
| [archive/](./archive/README.md) | Obsolete / temporaires |
| [archive/obsolete/](./archive/obsolete/) | CDC V1.3, EVOLUTIONS_CDC, SuperNounou, listes figées |
### Frontend (À créer)
- [**40 - Frontend Flutter**](./40_FRONTEND.md) - Structure de l'application mobile/web
## Données de test
### Audit & Analyse
- [**90 - Audit du projet YNOV**](./90_AUDIT.md) - Analyse complète du code étudiant et fonctionnalités
| Doc | Contenu |
|-----|---------|
| [test-data/](./test-data/README.md) | Jeux utilisateurs test |
## 🚀 Quick Start
## Quick start
```bash
# Cloner le projet
git clone ssh://gitea-jmartin/jmartin/app.git ptitspas-app
# Lancer l'environnement de développement
git clone … ptitspas-app
cd ptitspas-app
docker compose up -d
# Accéder aux services
Frontend: https://app.ptits-pas.fr
API: https://app.ptits-pas.fr/api
PgAdmin: https://app.ptits-pas.fr/pgadmin
# Front https://app.ptits-pas.fr — API /api — PgAdmin /pgadmin
```
## 🔗 Liens utiles
## Liens
- **Gitea** : https://git.ptits-pas.fr
- **Production** : https://app.ptits-pas.fr
- **Mail** : https://mail.ptits-pas.fr
## 📝 Maintenance
Cette documentation est maintenue par Julien Martin (julien.martin@ptits-pas.fr).
Dernière mise à jour : Novembre 2025
- Gitea : https://git.ptits-pas.fr/jmartin/petitspas
- Prod : https://app.ptits-pas.fr
Mainteneur : Julien Martin (julien.martin@ptits-pas.fr).
+155 -70
View File
@@ -1,14 +1,19 @@
---
title: "P'titsPas - Cahier des Charges Fonctionnel"
author: "Julien MARTIN"
date: "Novembre 2025"
version: "v1.3"
date: "Septembre 2026"
version: "v1.4"
---
# P'titsPas Cahier des Charges Fonctionnel
> **Objet :** Définir le périmètre fonctionnel, les rôles utilisateurs, les processus métiers et les exigences techniques de la plateforme P'titsPas, destinée à accompagner les collectivités locales dans la gestion de la garde denfants.
> **V1.4 (sept. 2026)** — Mise à jour de la **gestion des utilisateurs / dossiers / validation** pour coller au livré produit **`v0.1.0`**. Le reste du CDC (contrats, messagerie, agenda, paie, recherche AM, etc.) est **conservé** comme cible fonctionnelle.
> Détail technique du domaine utilisateurs : [12_SRS-GESTION-UTILISATEURS.md](./12_SRS-GESTION-UTILISATEURS.md).
> Bilan tickets `v0.1.0` : [29_BILAN-VERSION-0.1.0.md](./29_BILAN-VERSION-0.1.0.md).
> Archive V1.3 : [archive/obsolete/01_CAHIER-DES-CHARGES-v1.3.md](./archive/obsolete/01_CAHIER-DES-CHARGES-v1.3.md).
---
## Historique des versions
@@ -19,6 +24,7 @@ version: "v1.3"
| 1.1 | 24/04/2025 | Julien MARTIN | Ajouts : gestion multi-enfants, fin de contrat, tableau de bord étendu |
| 1.2 | 26/05/2025 | Julien MARTIN | Remplacement de "SuperNounou" par "P'titsPas" |
| 1.3 | 24/11/2025 | Julien MARTIN | Correction : retrait photo de profil parent (section 3.1.1) |
| **1.4** | **15/09/2026** | Julien MARTIN | **Gestion utilisateurs** alignée `v0.1.0` (dossiers, validation/refus/reprise, fiches staff, suppressions, retrait naissance multiple & SMS) ; reste du CDC inchangé en cible |
---
@@ -42,6 +48,8 @@ version: "v1.3"
### 3.4 Création dun administrateur
### 3.5 Fiche enfant
### 3.6 Authentification et sécurité
### 3.7 Numéro de dossier, validation, refus et reprise
### 3.8 Suppressions (vue métier)
## 4. Tableaux de bord
### 4.1 Vue densemble
@@ -60,15 +68,15 @@ version: "v1.3"
#### 4.3.5 Heures supplémentaires
#### 4.3.6 Messagerie
### 4.4 Tableau de bord des gestionnaires
#### 4.4.1 Comptes à valider
#### 4.4.2 Liste des utilisateurs
#### 4.4.1 Dossiers à valider
#### 4.4.2 Gestion des utilisateurs (partagée)
#### 4.4.3 Contrats
#### 4.4.4 Messagerie
#### 4.4.5 Événements RPE
#### 4.4.6 Alertes
### 4.5 Tableau de bord des administrateurs
#### 4.5.1 Menu Profil
#### 4.5.2 Gestion des utilisateurs
#### 4.5.2 Gestion des utilisateurs (partagée admin + gestionnaire)
#### 4.5.3 Gestion des enfants
#### 4.5.4 Paramètres de la plateforme
#### 4.5.5 Statistiques et supervision
@@ -193,18 +201,23 @@ Les assistantes maternelles peuvent :
Les gestionnaires (responsables de relais petite enfance) disposent dun tableau de bord de supervision. Ils peuvent :
- Valider ou rejeter les demandes de création de compte
- Suivre les mises en relation et les contrats
- Organiser des événements ou des rendez-vous
- Gérer les conflits ou les fins de contrat
- Valider ou refuser les **dossiers** (inscriptions parent / AM) et accompagner les reprises
- rer les usagers (fiches parent / AM / enfant, rattachements, dossiers) — socle partagé avec ladmin
- Suivre les mises en relation et les contrats (cible CDC)
- Organiser des événements ou des rendez-vous (cible CDC)
- Gérer les conflits ou les fins de contrat (cible CDC)
- Lancer des sondages ou modérer un blog RPE (si activé)
- Voir les historiques et statistiques liés à leur périmètre
Ils **ne créent pas** les comptes gestionnaire / administrateur (réservé à ladmin).
### 2.1.4 Administrateurs
Les administrateurs sont les représentants techniques et institutionnels de la collectivité (DSI ou agents désignés). Ils peuvent :
- Créer ou supprimer des comptes (gestionnaires, parents, assistantes maternelles)
- Tout ce que peut faire un gestionnaire sur les usagers / dossiers
- Créer ou supprimer des comptes **staff** (gestionnaires, administrateurs) selon garde-fous
- Créer ou supprimer des comptes usagers (parents, AM, enfants) selon droits
- Personnaliser linterface (logo, couleurs, nom de la ville)
- Activer ou désactiver des modules complémentaires
- Consulter les statistiques dusage
@@ -245,12 +258,15 @@ Le parcours de création dun compte parent seffectue en plusieurs étapes
**Note** : Le parent 2 ne définit **pas** de mot de passe lors de l'inscription. Il recevra un email avec un lien pour créer son mot de passe après validation du gestionnaire. Cette approche est particulièrement adaptée aux situations de parents séparés ou divorcés où la communication peut être difficile.
### 3.1.3 Informations sur l'enfant
- Un ou **plusieurs** enfants peuvent être ajoutés
- Prénom (facultatif si enfant à naître)
- Nom (hérité des parents)
- Genre (H / F) - obligatoire
- Genre (H / F / Autre) obligatoire
- Date de naissance ou **date prévisionnelle de naissance** (si l'enfant n'est pas encore né, un switch modifie le label)
- Photo obligatoire si l'enfant est né
- Rattachement automatique aux deux parents
- Photo (selon règles dinscription) et **consentement photo**
- Rattachement automatique au parent 1 et au parent 2 sil est renseigné
**Note V1.4** : il nexiste **pas** de champ « naissance multiple / jumeaux » (retiré du produit).
### 3.1.4 Présentation du dossier
- Zone de texte libre permettant aux parents de décrire leur situation
@@ -265,10 +281,12 @@ Le parcours de création dun compte parent seffectue en plusieurs étapes
### 3.1.6 Récapitulatif et validation
- Résumé des données saisies
- Vérification, puis envoi de la demande
- Attribution dun **numéro de dossier** (format type AAAA-NNNNNN)
- Les comptes parent sont soumis à validation par un gestionnaire avant activation
- Une fois validé, chaque parent (Parent 1 et Parent 2 si renseigné) reçoit un e-mail ou un SMS contenant un lien pour créer son mot de passe
- Le lien est valable pendant 7 jours
- Une fois validé, chaque parent (Parent 1 et Parent 2 si renseigné) reçoit un **e-mail** contenant un lien pour créer son mot de passe
- Le lien est valable pendant une durée limitée (jeton à usage unique)
- Une fois le mot de passe créé, le parent peut se connecter à son espace
- En cas de **refus**, le dossier nest pas supprimé : lusager peut **reprendre** sa demande (lien e-mail ou numéro de dossier) — voir §3.7
## 3.2 Création de compte assistante maternelle
@@ -298,7 +316,7 @@ Ce parcours est divisé en deux panneaux.
- Champ libre : message à destination du gestionnaire
- Permet de justifier une demande ou dajouter des précisions
### 3.1.4 Acceptation des CGU
### 3.2.4 Acceptation des CGU
- Les utilisateurs doivent cocher la case
« Jai lu et jaccepte les Conditions Générales dUtilisation et la Politique de confidentialité ».
- Un lien direct ouvre la version PDF des CGU.
@@ -307,23 +325,26 @@ Ce parcours est divisé en deux panneaux.
### 3.2.5 Récapitulatif et validation
- Résumé des données saisies
- Vérification, puis envoi de la demande
- Attribution dun **numéro de dossier**
- Validation par un gestionnaire requise avant activation
- Une fois validé, l'assistante maternelle reçoit un e-mail ou un SMS contenant un lien pour créer son mot de passe
- Le lien est valable pendant 7 jours
- Une fois validé, l'assistante maternelle reçoit un **e-mail** contenant un lien pour créer son mot de passe
- Le lien est valable pendant une durée limitée (jeton à usage unique)
- Une fois le mot de passe créé, l'assistante maternelle peut se connecter à son espace
- En cas de **refus** : reprise possible — voir §3.7
## 3.3 Création dun gestionnaire
- Réalisée par un administrateur
- Champs obligatoires : nom, prénom, adresse e-mail, mot de passe
- Affectation à un ou plusieurs relais petite enfance
- Le mot de passe doit être modifié lors de la première connexion
- Réalisée par un **administrateur** (ou super administrateur) — un gestionnaire ne crée pas de comptes staff
- Champs : nom, prénom, e-mail, téléphone, mot de passe, **relais principal** (optionnel)
- Le mot de passe peut être modifié à la première connexion si la politique lexige
- Formulaire unifié de création / édition (même présentation que pour les administrateurs)
## 3.4 Création dun administrateur
- Seuls les administrateurs existants peuvent créer de nouveaux comptes administrateurs
- Les droits sont équivalents (possibilité de restreindre par périmètre dans une version multi-mairies)
- Obligation de changer le mot de passe à la première connexion
- Seuls les **administrateurs** (et super administrateur) peuvent créer de nouveaux comptes administrateurs
- Champs : nom, prénom, e-mail, téléphone, mot de passe (pas de relais)
- Les droits sont équivalents entre administrateurs (le **super administrateur** est un compte dinstallation non supprimable)
- Obligation de changer le mot de passe à la première connexion si exigé
## 3.5 Fiche enfant
@@ -331,22 +352,68 @@ Chaque enfant est représenté par une fiche :
- Prénom (facultatif si enfant à naître)
- Nom
- Genre (H / F) - obligatoire
- Genre (H / F / Autre) obligatoire
- Date de naissance ou date prévisionnelle
- Photo (obligatoire si l'enfant est né ET si l'option *Photo obligatoire* est activée)
- Consentement photo enregistré : valeur booléenne + horodatage liés à l'accord donné par le parent
- Statut : à naître / actif / scolarisé
- Indication possible : jumeaux, triplés, etc.
- Possibilité de rattacher un enfant à plusieurs parents (garde alternée)
- Photo et consentement photo selon configuration (consentement tracé)
- Statut : à naître / actif / scolarisé (évolutions de libellés possibles ultérieurement)
- Responsables (parents) rattachés — liens vers les fiches parent
- Assistante maternelle de rattachement (le cas échéant)
**Note V1.4** : pas dindication « jumeaux / triplés » comme champ dédié.
Les fiches enfants sont :
- Accessibles depuis la fiche parent, la fiche AM, longlet **Enfants** du dashboard staff
- Créables par le staff pour un foyer existant
- Partagées entre les responsables rattachés
## 3.6 Authentification et sécurité
- Tous les comptes utilisent une combinaison adresse e-mail + mot de passe
- Les gestionnaires et administrateurs doivent modifier leur mot de passe à la première connexion
- Des mécanismes de récupération sont disponibles en cas de perte
- Authentification par e-mail + mot de passe
- Comptes **en attente** ou **suspendus** : connexion refusée
- Les gestionnaires et administrateurs doivent modifier leur mot de passe à la première connexion (si exigé)
- **Création de mot de passe** post-validation : lien e-mail (jeton TTL, usage unique)
- **Mot de passe oublié** : parcours distinct (demande → e-mail → réinitialisation)
- Pas denvoi de lien de mot de passe par SMS (canal **e-mail** uniquement)
Un lien direct vers les **Mentions légales** et la **Politique de confidentialité** est accessible en permanence depuis le pied de page, y compris avant la connexion.
## 3.7 Numéro de dossier, validation, refus et reprise
### Numéro de dossier
- Format type **AAAA-NNNNNN**
- Affiché dans les listes staff, les modales et les e-mails concernés
- Sert de clé métier pour validation, refus et reprise
### Validation
- Le staff (gestionnaire / administrateur) examine le dossier (wizard de revue)
- Action **Valider** : activation du circuit comptes + e-mails de création de mot de passe
### Refus
- Action **Refuser** : le dossier est refusé **sans suppression** des données
- Lusager est informé par e-mail et peut reprendre sa demande
### Reprise
- Via le **lien** reçu par e-mail, ou depuis l’écran de connexion avec le **numéro de dossier**
- Formulaire prérempli / correction des informations
- Nouvelle soumission → retour en file « à valider »
### Création et édition de dossiers par le staff
- En plus de linscription publique, le staff peut **créer** un dossier famille ou AM (wizard)
- Le staff peut **éditer** un dossier existant (y compris ajout dun 2ᵉ parent pour une famille mono-parent)
## 3.8 Suppressions (vue métier)
Sous réserve des droits (détail technique : [SRS gestion utilisateurs](./12_SRS-GESTION-UTILISATEURS.md)) :
| Cible | Principe |
|-------|----------|
| Parent, AM, enfant, dossier | Gestionnaire, administrateur, super admin — avec confirmation |
| Gestionnaire | Administrateur / super admin uniquement |
| Administrateur | Admin / super admin, hors soi-même, hors cible super admin ; garde-fou sur le dernier administrateur |
| Super administrateur | Non supprimable |
Un gestionnaire ne peut pas supprimer **son propre** compte.
# 4. Tableaux de bord
Chaque rôle utilisateur dispose dun tableau de bord personnalisé, adapté à ses fonctions dans la plateforme. Ces interfaces sont pensées pour être lisibles, fonctionnelles et évolutives.
@@ -357,8 +424,8 @@ Chaque rôle utilisateur dispose dun tableau de bord personnalisé, adapté
|------------------------|-------------------------------------------------------------------|
| Parents | Recherche d'assistante maternelle, gestion des enfants, contrat, agenda |
| Assistantes maternelles| Dossiers reçus, enfants accueillis, heures sup, agenda, messagerie|
| Gestionnaires | Validation de comptes, contrats, messagerie, événements RPE |
| Administrateurs | Paramètres globaux, gestion des utilisateurs, statistiques |
| Gestionnaires | Dossiers à valider, gestion utilisateurs/enfants (partagée), contrats, messagerie, événements RPE |
| Administrateurs | Paramètres globaux, même gestion utilisateurs (droits staff élargis), statistiques |
Les tableaux de bord intègrent :
- Une **barre de navigation supérieure** (liens de navigation rapide)
@@ -502,17 +569,24 @@ Lassistante maternelle accède à une interface dédiée à la gestion de ses
Le gestionnaire RPE dispose dune vision transversale sur les utilisateurs, les dossiers et les activités de la structure. Son rôle est d'accompagner, superviser, et arbitrer si nécessaire.
### 4.4.1 Comptes à valider
### 4.4.1 Dossiers à valider
- File dattente des demandes de création de compte
- Détails de chaque demande : parent, assistante maternelle
- Actions possibles : Valider / Refuser / Demander des précisions
- File dattente des **dossiers** (famille / AM) en attente de validation
- Affichage du **numéro de dossier** et des informations essentielles
- Ouverture en **revue** (wizard étapes) : Valider / Refuser
- Le refus nefface pas le dossier : lusager peut reprendre (voir §3.7)
- Longlet **Dossiers** du dashboard regroupe aussi une **liste unifiée** des dossiers (au-delà de la seule file « à valider »)
### 4.4.2 Liste des utilisateurs
### 4.4.2 Gestion des utilisateurs (partagée)
- Filtres par rôle, statut, date dinscription
- Accès rapide aux informations et historiques
- Possibilité de contacter un utilisateur
> **V1.4** — La gestion opérationnelle des usagers (parents, AM, enfants, dossiers) est **partagée** entre gestionnaire et administrateur via le même dashboard. Seule la gestion des **comptes staff** (création / suppression gestionnaire ou admin) est réservée à ladministrateur.
- Onglets : Parents, Assistantes maternelles, Enfants, Gestionnaires (selon droits), Administrateurs (admin)
- Accès aux **fiches** (identité, rattachements, capacité AM, etc.)
- Création de dossiers / enfants par le staff
- Filtres et recherche ; ouverture des fiches depuis les cartes / listes
Le détail des panneaux admin historiques est décrit en §4.5.2 (même socle fonctionnel).
### 4.4.3 Contrats
@@ -566,47 +640,56 @@ Le menu profil de ladministrateur comprend uniquement :
Toutes les fonctionnalités de gestion sont accessibles via des onglets distincts dans le tableau de bord.
### 4.5.2 Gestion des utilisateurs
### 4.5.2 Gestion des utilisateurs (partagée admin + gestionnaire)
Ladministration des utilisateurs est organisée par panneaux distincts pour chaque type de profil.
Ladministration des utilisateurs est organisée par panneaux / onglets. **Admin et gestionnaire** partagent les panneaux usagers ; ladmin dispose en plus des droits staff.
#### a. Gestion des gestionnaires
- Liste des gestionnaires existants
- Création (nom, prénom, e-mail, mot de passe)
- Attribution à un ou plusieurs RPE
- Réinitialisation du mot de passe
- Suppression du compte
- Création / édition (nom, prénom, e-mail, téléphone, mot de passe, relais principal) — **administrateur uniquement** pour la création
- Réinitialisation / nouveau mot de passe en édition
- Suppression du compte — **administrateur uniquement** (pas dauto-suppression)
#### b. Gestion des parents
- Liste complète des parents enregistrés
- Recherche par nom, statut, enfants associés
- Modification des informations
- Suppression dun compte
- Consultation du statut des dossiers liés
- Liste des parents enregistrés
- Ouverture de la **fiche parent** (identité, statut, enfants, lien co-parent)
- Rattacher / détacher un enfant
- Création de dossier famille (wizard staff)
- Suppression dun compte / dossier selon droits (§3.8)
#### c. Gestion des assistantes maternelles
- Liste des assistantes avec numéro dagrément
- Modification ou suppression dun compte
- Filtrage par zone géographique ou capacité
- Liste des AM (agrément, capacité, etc.)
- **Fiche AM** (identité, professionnel, enfants accueillis)
- Respect de la **capacité max** pour les rattachements
- Création de dossier AM (wizard staff)
- Suppression selon droits
#### d. Gestion des administrateurs
- Création de nouveaux comptes administrateurs
- Création de nouveaux comptes administrateurs**administrateur / super admin**
- Suivi des droits
- Obligation de modification du mot de passe à la première connexion
- Obligation de modification du mot de passe à la première connexion si exigé
- Suppression avec garde-fous (pas soi-même, pas le super admin, dernier admin)
#### e. Onglet Dossiers
- Liste unifiée + dossiers à valider
- Revue / édition des dossiers (wizards)
### 4.5.3 Gestion des enfants
Deux accès possibles à la gestion des enfants :
#### a. Par fiche parent
- Consultation et édition des enfants associés à chaque parent
#### a. Par fiche parent (ou fiche AM)
- Consultation et édition des enfants associés
- Rattacher / détacher depuis la fiche
#### b. Vue globale “Enfants”
- Liste complète avec :
- Nom, prénom, date de naissance ou prévisionnelle
- Statut (à naître, actif, scolarisé)
- Parents associés
- Contrat en cours (si applicable)
- Parents associés (alerte si **sans responsable**)
- AM associée le cas échéant
- Contrat en cours (si applicable — module contrats)
- Création dun enfant rattaché à un **foyer existant**
- Possibilité de modifier ou supprimer une fiche enfant
### 4.5.4 Paramètres de la plateforme
@@ -905,10 +988,10 @@ Certains outils de la plateforme sont accessibles à plusieurs profils et favori
- Le gestionnaire (à titre dinformation)
- Contient :
- Identité
- Photo (obligatoire si né)
- Photo (selon configuration / consentement)
- Statut (à naître / actif / scolarisé)
- Parents associés
- Mention sil sagit de jumeaux, triplés, etc.
- AM associée le cas échéant
## 6.4 Contrats et avenants
@@ -1185,11 +1268,11 @@ P'titsPas sinscrit dans une démarche de service public, avec des fondements
| **Gestionnaires** | Suivi global des situations, outils de médiation, organisation d’événements |
| **Administrateurs** | Supervision complète, gouvernance multi-rôle, contrôle des données |
## 10.3 Périmètre fonctionnel de la V1
## 10.3 Périmètre fonctionnel
Fonctionnalités incluses dès la première version :
- Création de comptes
- Recherche et sélection de nounous
**Cible CDC (plateforme complète)** — fonctionnalités décrites dans ce document :
- Création de comptes et gestion des utilisateurs / dossiers
- Recherche et sélection dassistantes maternelles
- Suivi des dossiers
- Génération de contrat et avenants
- Messagerie
@@ -1198,6 +1281,8 @@ Fonctionnalités incluses dès la première version :
- Gestion des heures supplémentaires
- Suivi RGPD et administration
**Livré produit `v0.1.0`** (voir [bilan](./29_BILAN-VERSION-0.1.0.md)) : cœur **gestion des utilisateurs** — inscription, validation/refus/reprise, auth, dashboard staff (dossiers, fiches, rattachements, suppressions). Les autres modules du CDC restent la **feuille de route**.
## 10.4 Perspectives
La structure du produit permet une montée en charge progressive :
+15 -15
View File
@@ -44,22 +44,21 @@ Les **Phases 2, 3, 4+** sont des **ébauches indicatives** qui seront affinées
- ✅ Logging & Monitoring
- ✅ Tests & Documentation
### Versions incrémentales
### Versions incrémentales (semver / Gitea)
| Version | Objectif | Tickets | Estimation |
|---------|----------|---------|------------|
| **0.1.0** | MVP Fonctionnel | ~21 | ~45h |
| **0.2.0** | Sécurité & RGPD | ~10 | ~35h |
| **0.3.0** | Interfaces Complètes | ~17 | ~52h |
| **0.4.0** | Tests & Documentation | ~6 | ~24h |
| **0.5.0** | Monitoring & Optimisations | ~7 | ~17h |
| **1.0.0** | 🎉 **Release Phase 1** | **61** | **~173h** |
La table historique « ~21 tickets / 0.1.0 » est **obsolète**.
État réel des milestones, bilans et tag : **[05_VERSIONS-ET-MILESTONES.md](./05_VERSIONS-ET-MILESTONES.md)**.
### Livrable
| Version | Statut (sept. 2026) |
|---------|---------------------|
| **0.1.0** | **Terminée** — [bilan](./29_BILAN-VERSION-0.1.0.md) (48 tickets fermés) |
| **0.2.0+** | Ouvertes — voir Gitea + doc 05 |
Application installable avec création et validation de comptes utilisateurs.
### Livrable Phase 1 (visée)
**Référence** : [23_LISTE-TICKETS.md](./23_LISTE-TICKETS.md)
Application installable avec création et validation de comptes utilisateurs, puis enrichissements dashboard / dossiers (0.1.0 livré).
**Tickets** : Gitea — pointeur [23_SUIVI-TICKETS.md](./23_SUIVI-TICKETS.md).
---
@@ -216,7 +215,7 @@ Suivi quotidien des enfants + Fonctionnalités complémentaires.
Application mature, optimisée et riche en fonctionnalités.
**Référence** : [25_PHASE-2-BACKLOG.md](./25_PHASE-2-BACKLOG.md) (anciennes fonctionnalités techniques)
**Référence** : [archive/obsolete/25_PHASE-2-BACKLOG.md](./archive/obsolete/25_PHASE-2-BACKLOG.md) (figé) + [05_VERSIONS-ET-MILESTONES.md](./05_VERSIONS-ET-MILESTONES.md)
---
@@ -317,9 +316,10 @@ Exemples :
- [00_INDEX.md](./00_INDEX.md) - Index général de la documentation
- [01_CAHIER-DES-CHARGES.md](./01_CAHIER-DES-CHARGES.md) - Cahier des charges v1.3
- [20_WORKFLOW-CREATION-COMPTE.md](./20_WORKFLOW-CREATION-COMPTE.md) - Workflow création de comptes
- [23_LISTE-TICKETS.md](./23_LISTE-TICKETS.md) - Liste des 61 tickets Phase 1
- [23_SUIVI-TICKETS.md](./23_SUIVI-TICKETS.md) / [29_BILAN-VERSION-0.1.0.md](./29_BILAN-VERSION-0.1.0.md) — suivi Gitea + bilan 0.1.0
- [24_DECISIONS-PROJET.md](./24_DECISIONS-PROJET.md) - Décisions architecturales
- [25_PHASE-2-BACKLOG.md](./25_PHASE-2-BACKLOG.md) - Anciennes fonctionnalités techniques
- [05_VERSIONS-ET-MILESTONES.md](./05_VERSIONS-ET-MILESTONES.md) — milestones Gitea
- [archive/obsolete/25_PHASE-2-BACKLOG.md](./archive/obsolete/25_PHASE-2-BACKLOG.md) — backlog Phase 2 figé
---
+64
View File
@@ -0,0 +1,64 @@
# Versions & milestones — P'titsPas
**Source de vérité tickets** : Gitea [`jmartin/petitspas`](https://git.ptits-pas.fr/jmartin/petitspas)
**Bilans de version** : documents `29_BILAN-…` (et suivants)
Ce fichier remplace, pour le **semver / milestones**, les anciennes tables figées de la roadmap Phase 1.
---
## État des milestones
| Milestone | Rôle | Statut |
|-----------|------|--------|
| **0.1.0** | MVP opérable (auth, inscription, dashboard dossiers/fiches, suppressions, cleanups) | **Terminée** — [bilan](./29_BILAN-VERSION-0.1.0.md) |
| **0.2.0** | Suite produit (ex. recherche / échanges — sans contrat) | Ouverte |
| **0.3.0** | Contrat + planning | Ouverte |
| **0.4.0** | Carnet de liaison | Ouverte |
| **0.9.0** | Hors périmètre cleanup 0.1.0 (doublons, upload, tech auth/photos, UX erreurs…) | Ouverte |
| **1.0.0** | Release majeure Phase 1 (critères PO) | Réserve |
| **Backlog transverse** | Doc étendue, CI/tests, RGPD avancé, monitoring — hors semver dédié | Ouverte |
Liens Gitea : [milestones](https://git.ptits-pas.fr/jmartin/petitspas/milestones).
---
## Bilans
| Version | Document |
|---------|----------|
| 0.1.0 | [29_BILAN-VERSION-0.1.0.md](./29_BILAN-VERSION-0.1.0.md) |
## Documents produit de référence (post-0.1.0)
| Doc | Rôle |
|-----|------|
| [01_CAHIER-DES-CHARGES.md](./01_CAHIER-DES-CHARGES.md) | CDC **complet** V1.4 (users mis à jour ; reste = cible) |
| [12_SRS-GESTION-UTILISATEURS.md](./12_SRS-GESTION-UTILISATEURS.md) | SRS technique domaine utilisateurs |
| Archive CDC V1.3 | [archive/obsolete/01_CAHIER-DES-CHARGES-v1.3.md](./archive/obsolete/01_CAHIER-DES-CHARGES-v1.3.md) |
---
## Relation avec la roadmap phases
La vision long terme (Phases 25 : mise en relation, contrats, carnet…) reste dans [04_ROADMAP-GENERALE.md](./04_ROADMAP-GENERALE.md).
Les **jalons livrables** se gèrent ici + dans Gitea.
Ancien backlog « Phase 2 » technique ([archive](./archive/obsolete/25_PHASE-2-BACKLOG.md)) : à croiser avec les milestones ci-dessus ; ne plus maintenir en double.
---
## Suivi des tickets
- **Création / état** : Gitea uniquement.
- **Mémoire dune version livrée** : bilan `29_…` (pas de re-copie exhaustive dans un fichier tickets).
- Ancienne liste figée Phase 1 : [archive/obsolete/23_LISTE-TICKETS.md](./archive/obsolete/23_LISTE-TICKETS.md).
- Pointeur court : [23_SUIVI-TICKETS.md](./23_SUIVI-TICKETS.md).
---
## Tag Git
| Tag | Condition |
|-----|-----------|
| `v0.1.0` | Milestone 0.1.0 fermée + bilan mergé sur `master` |
-1
View File
@@ -117,7 +117,6 @@ Table des enfants pris en charge.
| `photo_url` | TEXT | | URL de la photo |
| `consentement_photo` | BOOLEAN | DEFAULT false | Consentement photo |
| `date_consentement_photo` | TIMESTAMPTZ | | Date du consentement |
| `est_multiple` | BOOLEAN | DEFAULT false | Indique si grossesse multiple |
---
+250
View File
@@ -0,0 +1,250 @@
# SRS — Gestion des utilisateurs (domaine livré v0.1.0)
**Version** : 1.0
**Date** : 15/09/2026
**Ticket** : [#117](https://git.ptits-pas.fr/jmartin/petitspas/issues/117)
**Niveau** : **technique** (dev / QA)
**CDC associé** : [01_CAHIER-DES-CHARGES.md](./01_CAHIER-DES-CHARGES.md) (V1.4 — **CDC complet** ; cette SRS ne couvre que le domaine utilisateurs)
> Périmètre SRS : comptes, auth liée, dossiers famille/AM, enfants, affiliations, dashboard staff, suppressions.
> Le CDC V1.4 conserve **lensemble** des chapitres cibles (contrats, messagerie, etc.) ; ils ne sont **pas** redécrits ici.
> Hors SRS technique : OpenAPI exhaustif, PRA/CI → voir [11_API](./11_API.md), [10_DATABASE](./10_DATABASE.md).
---
## 1. Glossaire
| Terme | Définition |
|-------|------------|
| **User / utilisateur** | Compte authentifiable (`utilisateurs`) avec `role` et `statut` |
| **Parent pivot** | Responsable principal du foyer à linscription / création staff |
| **Co-parent** | Second responsable optionnel (au plus un), lié au pivot |
| **Foyer** | Pivot + co-parent éventuel + enfants affiliés |
| **Dossier** | Unité métier identifiée par `numero_dossier` (format `AAAA-NNNNNN`) — famille ou AM |
| **Pending / en_attente** | Compte ou dossier en attente de validation staff |
| **Refus** | Rejet sans suppression ; ouvre la **reprise** |
| **Relais** | Structure RPE ; rattachement optionnel dun gestionnaire |
| **Staff** | `gestionnaire`, `administrateur`, `super_admin` |
---
## 2. Acteurs et rôles applicatifs
| `role` | Inscription publique | Dashboard staff | Créer staff | Suppressions métier usagers | Supprimer gestionnaire | Supprimer admin |
|--------|---------------------|-----------------|-------------|----------------------------|------------------------|-----------------|
| `parent` | oui | non | non | non | non | non |
| `assistante_maternelle` | oui | non | non | non | non | non |
| `gestionnaire` | non (créé staff) | oui | non | oui* | non | non |
| `administrateur` | non | oui | oui | oui* | oui | oui** |
| `super_admin` | non (install) | oui | oui | oui* | oui | oui** |
\* Sous réserve des garde-fous (pas soi-même pour sa fiche staff, etc.).
\*\* Pas soi-même ; pas de cible `super_admin` ; dernier admin : règles `canDeleteAdministrateur` (front `staff_deletion_rights.dart`).
Réf. front : `frontend/lib/utils/staff_deletion_rights.dart`
- `canCreateStaffAccounts` → admin / super_admin
- `canDeleteMetier` → gestionnaire / admin / super_admin
- `canDeleteGestionnaire` → admin / super_admin
---
## 3. Statuts
### 3.1 Utilisateur (`statut_utilisateur_type`)
| Statut | Signification |
|--------|----------------|
| `en_attente` | Inscrit, non validé ; **login refusé** |
| `actif` | Validé / utilisable |
| `suspendu` | Bloqué ; **login refusé** |
### 3.2 Enfant (`statut_enfant_type`)
Valeurs actuelles : `a_naitre`, `actif`, `scolarise` (évolution métier « gardé / sans garde » **hors** cette SRS — ticket dédié).
### 3.3 Validation dossier
Circuit staff : revue → **valider** ou **refuser**. Refus ≠ delete. Reprise → nouveau passage en attente.
---
## 4. Modèle de données (vue synthétique)
```mermaid
flowchart TB
user[utilisateurs]
parent[parents]
am[assistantes_maternelles]
enfant[enfants]
ep[enfants_parents]
df[dossier_famille]
user --> parent
user --> am
parent --> ep
enfant --> ep
parent --> df
```
| Concept | Tables / liens clés |
|---------|---------------------|
| Compte | `utilisateurs` (role, statut, email, numero_dossier, relais…) |
| Parent | `parents` (+ `id_co_parent` optionnel) |
| AM | `assistantes_maternelles` (agrément, capacité, NIR…) |
| Enfant | `enfants` |
| Affiliation parent | `enfants_parents` (NN) |
| Dossier famille | `dossier_famille` (+ enfants dossier) |
| Tokens MDP | tables / flux tokens création & reset (TTL, usage unique) |
Détail colonnes : [10_DATABASE.md](./10_DATABASE.md).
### Invariants
1. **Pas** de champ `est_multiple` / naissance multiple (#152).
2. Au plus **un** co-parent par fiche parent.
3. Affiliation création enfant : rattache **pivot + co-parent** sil existe (#158).
4. Détachement du **dernier** parent → enfant **sans responsable** possible (#157).
5. Rattachement AM plafonné par **capacité** (#148 / #149).
6. `super_admin` **non supprimable**.
---
## 5. Flux
### 5.1 Inscription → validation → mot de passe
```mermaid
sequenceDiagram
participant U as Usager
participant API as API
participant S as Staff
participant M as Mail
U->>API: register parent/AM
API-->>U: numero_dossier, statut en_attente
S->>API: review / valider
API->>M: mail lien create-password
U->>API: verify token + set password
API-->>U: compte actif, login OK
```
Variante **refus** : staff refuse → mail refus → usager **reprise** (token lien ou n° dossier) → resoumission → à valider.
### 5.2 Mot de passe oublié
Flux distinct de la création post-validation : demande → e-mail → reset (#127). Tokens durcis (#123).
### 5.3 Création dossier staff
- Famille : wizard + `POST` staff parent/dossier (#129).
- AM : wizard + API staff AM (#156).
- Édition : mode `edit` wizards + PATCH fiches ; ajout co-parent `POST …/co-parent` (#135).
### 5.4 Rattachements
| Lien | Opérations |
|------|------------|
| Parent ↔ enfant | attach / detach (fiche parent, fiche enfant) |
| AM ↔ enfant | attach / detach (fiche AM, fiche enfant) ; UI capacité |
API : tickets #115 / #116 ; liste enfants enrichie #136.
---
## 6. Matrice droits × actions (synthèse)
| Action | gestionnaire | administrateur | super_admin |
|--------|:------------:|:--------------:|:-----------:|
| Voir dashboard usagers / dossiers | ✓ | ✓ | ✓ |
| Valider / refuser dossier | ✓ | ✓ | ✓ |
| Créer dossier parent / AM | ✓ | ✓ | ✓ |
| Éditer fiches parent / AM / enfant | ✓ | ✓ | ✓ |
| Rattacher / détacher enfant | ✓ | ✓ | ✓ |
| GET liste relais (combo) | ✓ | ✓ | ✓ |
| Créer gestionnaire / admin | ✗ | ✓ | ✓ |
| Supprimer parent / AM / enfant / dossier | ✓ | ✓ | ✓ |
| Supprimer gestionnaire | ✗ | ✓ | ✓ |
| Supprimer admin (garde-fous) | ✗ | ✓* | ✓* |
| Supprimer super_admin | ✗ | ✗ | ✗ |
| Supprimer son propre compte staff | ✗ | ✗ | ✗ |
\* Voir `canDeleteAdministrateur` / messages `adminDeleteBlockedReason`.
---
## 7. UI staff (points dentrée code)
| Zone | Emplacement typique |
|------|---------------------|
| Panneau gestion users | `widgets/dashboard/user_management_panel.dart` |
| Onglets parents / AM / enfants / dossiers | `widgets/dashboard/*_management_widget.dart` |
| Fiche parent | `parent_edit_modal.dart` |
| Fiche AM | `am_edit_modal.dart` |
| Fiche enfant | `child_detail_modal.dart` |
| Wizards dossier | `parent_dossier_wizard.dart`, `am_dossier_wizard.dart` |
| Modale staff | `staff_user_form_modal.dart` (`StaffUserFormModal`) |
| Confirm suppressions | `widgets/dashboard/common/suppression_confirm_dialog.dart` |
| Champs contrôlés | `validation_detail_section.dart` (`ValidationEmailField`, `ValidationPhoneField`…) |
| Thème primaire modales | `validation_modal_theme.dart` |
Shell fiches / wizards : largeur **930**, labels au-dessus, primaire violet `ValidationModalTheme`.
---
## 8. API — groupes (renvoi)
Ne pas dupliquer OpenAPI ici. Groupes utiles au domaine :
| Groupe | Exemples dusage |
|--------|------------------|
| Auth / register | Inscription parent & AM, login, tokens MDP, oubli MDP |
| Dossiers | `GET /dossiers/:numero`, listes à valider / unifiées, validate/refuse |
| Parents | Fiche PATCH, co-parent POST, dossier staff POST |
| AM | Fiche PATCH, dossier staff POST |
| Enfants | CRUD staff, attach/detach parent & AM |
| Users / staff | CRUD gestionnaires / admins, DELETE métier |
| Relais | `GET /relais` (autorisé gestionnaire — #151) |
| Config | setup / configuration instance |
Référence : [11_API.md](./11_API.md) (à maintenir en parallèle si écart).
---
## 9. Exigences non fonctionnelles (domaine users)
| ID | Exigence |
|----|----------|
| NFR-U1 | Tokens création / reset MDP : TTL strict, usage unique |
| NFR-U2 | Mots de passe : politique min. longueur (création staff / usager selon flux) |
| NFR-U3 | E-mails métier (validation, refus, MDP) via config SMTP instance |
| NFR-U4 | Pas de login si `en_attente` ou `suspendu` |
| NFR-U5 | Confirmations explicites avant toute suppression |
| NFR-U6 | Champs e-mail / téléphone : validation & normalisation côté UI staff (SRS UX #164) |
Hors scope immédiat : normalisation erreurs auth globale (#121), observabilité (#122), audit trail complet (#128).
---
## 10. Limites modèle famille (assumées)
Documentées pour les développeurs — détail produit : [28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md).
- Un `numero_dossier` / user ; un seul co-parent.
- Familles recomposées multi-contextes : contournement (2ᵉ compte / composition staff) jusqu’à epic #139.
- La SRS **nexige pas** N responsables en v0.1.0.
---
## 11. Traçabilité livré 0.1.0
Source : [29_BILAN-VERSION-0.1.0.md](./29_BILAN-VERSION-0.1.0.md) — 48 tickets milestone 0.1.0 (auth, inscription, fiches, dossiers, suppressions, cleanups #152/#155/#162/#164).
---
## 12. Hors périmètre de cette SRS
- Contrats, avenants, fin de contrat (restent dans le **CDC** cible)
- Messagerie, agenda, événements RPE (idem CDC)
- Paie / Pajemploi
- Recherche AM côté parent
- PRA, CI/CD, monitoring infra
- Spécification OpenAPI ligne à ligne
- Texte fonctionnel complet → [01_CAHIER-DES-CHARGES.md](./01_CAHIER-DES-CHARGES.md)
-8
View File
@@ -1,8 +0,0 @@
# Fichier déplacé
La documentation **Documents légaux** a été déplacée vers :
**[juridique/22_DOCUMENTS-LEGAUX.md](./juridique/22_DOCUMENTS-LEGAUX.md)**
Voir aussi le dossier **[juridique/](./juridique/)** pour les sources **CGU**
et **CGC** en Markdown.
+12
View File
@@ -0,0 +1,12 @@
# Suivi des tickets — P'titsPas
**Source de vérité** : [Gitea — issues](https://git.ptits-pas.fr/jmartin/petitspas/issues)
**Milestones / versions** : [05_VERSIONS-ET-MILESTONES.md](./05_VERSIONS-ET-MILESTONES.md)
**API Gitea** : [26_GITEA-API.md](./26_GITEA-API.md)
Ne plus maintenir de catalogue exhaustif des tickets dans le dépôt : l’état (ouvert / fermé / milestone) change dans Gitea.
Pour une **version livrée**, lire le bilan correspondant (ex. [29_BILAN-VERSION-0.1.0.md](./29_BILAN-VERSION-0.1.0.md)).
Archive historique (liste Phase 1 figée, avril 2026) :
[archive/obsolete/23_LISTE-TICKETS.md](./archive/obsolete/23_LISTE-TICKETS.md).
+41 -25
View File
@@ -1,7 +1,7 @@
# 📋 Décisions Projet - P'titsPas
**Version** : 1.1
**Date** : 9 Février 2026
**Version** : 1.2
**Date** : 16 Juin 2026
**Auteur** : Équipe PtitsPas
---
@@ -122,6 +122,27 @@ ptitspas-app/
---
### 5bis. Familles recomposées — contournement v1.0.0
**Décision** : ✅ **Second compte / second email pour les cas multi-contextes en v1.0.0** ; évolution structurelle reportée post-1.0.0
**Contexte** :
- Le numéro de dossier et le graphe « famille » (co-parent + enfants partagés) conviennent aux cas simples (un couple, N enfants).
- Ils ne couvrent pas proprement une même personne responsable dans **plusieurs unités familiales** (recompositions, plusieurs co-responsables successifs, tuteur/GP sur plusieurs contextes).
**Décision v1.0.0** :
- **Contournement opérationnel** : créer un **second compte** avec **email distinct** et un **second numéro de dossier** (souvent via le gestionnaire).
- Le numéro de dossier reste la **norme** pour les cas simples, **sans obligation absolue** pour les cas complexes.
- Rôle applicatif unique **`parent`** pour tous les responsables (tuteur, GP inclus) ; qualification juridique = évolution ultérieure.
**Évolution post-1.0.0** :
- **Parcours gestionnaire « famille complexe »** (§ 7.5 doc 28) : N responsables, **M un seul compte** avec tous ses enfants ; **A / B** limités à leur enfant via `enfants_parents`.
- Visibilité et workflows **sans fusion graphe familial** ; numéro de dossier optionnel ; qualification du lien responsableenfant.
**Référence** : [28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md) — § 4.4, § 7.5
---
### 6. Genre enfant obligatoire (H/F)
**Décision** : ✅ **Genre obligatoire (H/F uniquement)**
@@ -402,31 +423,23 @@ ptitspas-app/
- Maintenance (tout au même endroit)
- Versioning (Git)
**Structure** :
**Structure** (sept. 2026) :
```
docs/
├── 00_INDEX.md
├── 01_CAHIER-DES-CHARGES.md
├── 02_ARCHITECTURE.md
├── 03_DEPLOYMENT.md
├── 10_DATABASE.md
├── 11_API.md
├── 20_WORKFLOW-CREATION-COMPTE.md
├── 21_CONFIGURATION-SYSTEME.md
├── 22_DOCUMENTS-LEGAUX.md # pointeur → juridique/
├── 27_BRIEFING-FRONTEND.md
├── PROCEDURE-API-GITEA.md # pointeur → 26_GITEA-API.md
├── juridique/
│ ├── README.md
│ ├── cgu.md
│ ├── cgc.md
│ └── 22_DOCUMENTS-LEGAUX.md
├── archive/
│ ├── README.md
│ ├── temporaires/
│ └── obsolete/
├── 23_LISTE-TICKETS.md
├── 01_CAHIER-DES-CHARGES.md # V1.4 fonctionnel
├── 05_VERSIONS-ET-MILESTONES.md
├── 12_SRS-GESTION-UTILISATEURS.md
├── 23_SUIVI-TICKETS.md
├── 24_DECISIONS-PROJET.md (ce document)
├── 26_GITEA-API.md
├── 27_BRIEFING-FRONTEND.md
├── 28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md
├── 29_BILAN-VERSION-0.1.0.md
├── 99_REGLES-CODAGE.md
├── CHARTE_GRAPHIQUE.md
├── juridique/ # CGU + 22_DOCUMENTS-LEGAUX.md
├── archive/ # obsolete / temporaires
├── 90_AUDIT.md
└── test-data/
```
@@ -562,6 +575,7 @@ docs/
| 14 | Migration données | ❌ Rejeté | N/A |
| 15 | Doc utilisateur | ⏸️ Phase 2 | Formation |
| 31 | Logs Winston | ✅ Phase 1 | Monitoring |
| 5bis | Familles recomposées — 2ᵉ compte v1.0.0 | ✅ v1.0.0 | Métier / dossier |
---
@@ -571,10 +585,12 @@ docs/
|------|---------|---------------|
| 25/11/2025 | 1.0 | Création du document - Toutes les décisions initiales |
| 09/02/2026 | 1.1 | Configuration initiale : un seul panneau Paramètres (3 sections) dans le dashboard, plus de Setup Wizard dédié ; navigation bloquée jusqu'à sauvegarde |
| 16/06/2026 | 1.2 | Décision 5bis — familles recomposées, contournement v1.0.0 ; lien doc [28](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md) |
| 16/06/2026 | 1.3 | Précision 5bis — cible post-1.0.0 : parcours gestionnaire § 7.5 (#139), visibilité par enfant |
---
**Dernière mise à jour** : 9 Février 2026
**Version** : 1.1
**Dernière mise à jour** : 16 Juin 2026
**Version** : 1.2
**Statut** : ✅ Document validé
@@ -0,0 +1,333 @@
# Évolution — Modèle famille, responsables légaux et dossiers
**Version** : 1.1
**Date** : 16 juin 2026
**Statut** : Réflexions produit / architecture — complément au [CDC](./01_CAHIER-DES-CHARGES.md)
**Documents liés** : [01_CAHIER-DES-CHARGES.md](./01_CAHIER-DES-CHARGES.md) (V1.4), [12_SRS-GESTION-UTILISATEURS.md](./12_SRS-GESTION-UTILISATEURS.md), [24_DECISIONS-PROJET.md](./24_DECISIONS-PROJET.md), [23_SUIVI-TICKETS.md](./23_SUIVI-TICKETS.md), [29_BILAN-VERSION-0.1.0.md](./29_BILAN-VERSION-0.1.0.md)
**Archive** : ancien patch CDC → [archive/obsolete/EVOLUTIONS_CDC.md](./archive/obsolete/EVOLUTIONS_CDC.md)
---
## 1. Objet de ce document
Ce document **trace les réflexions** menées en 2026 sur :
- les **limites du modèle « famille / numéro de dossier »** en v1.0.0 ;
- les **contournements** acceptés pour la release **1.0.0** ;
- les **évolutions** envisagées post-1.0.0 (unités de dossier, affiliation parentenfant, terminologie).
Il ne remplace pas le CDC : il documente l**écart assumé** entre le modèle idéal long terme et ce qui est livré en **1.0.0**, ainsi que la **feuille de route** pour aller plus loin.
---
## 2. Modèle actuel (v1.0.0) — rappel
| Concept | Implémentation |
|--------|----------------|
| **Responsable inscrit** | Rôle applicatif `parent` + entité `parents` |
| **Second adulte** | Co-parent optionnel (`id_co_parent`) — **un seul** |
| **Enfant** | Entité `enfants` |
| **Affiliation** | Table `enfants_parents` (liens many-to-many) |
| **Dossier famille** | `dossier_famille` + `dossier_famille_enfants` (motivation, etc.) |
| **Numéro de dossier** | Format `AAAA-NNNNNN`, sur `utilisateurs` et `parents` |
| **« Famille » calculée** | Graphe : co-parent **ou** enfants partagés (`getFamilyUserIds`) |
| **Workflows** | Validation, refus, reprise : souvent **par `numero_dossier`** ou par ce graphe |
Le numéro de dossier est aujourdhui une **aide forte** pour les cas simples (un couple, N enfants, une motivation), mais il tend à devenir **lidentifiant métier** de la famille — ce qui pose problème dans les cas complexes.
---
## 3. Limitation connue v1.0.0 — familles recomposées et multi-contextes
### 3.1 Exemple type (illustration)
```
Année 1 : Responsable M + co-responsable A → enfant α
Année 2 : Responsable M + co-responsable B → enfant β
```
Même personne **M** au centre, **deux contextes de vie** distincts. **A** nest pas parent de β ; **B** nest pas parent de α.
### 3.2 Autres cas couverts par la même limitation
Les exemples « maman / papa » sont **illustratifs**. La limitation sapplique **à toute configuration** :
| Configuration | Même règle |
|---------------|------------|
| Couple **HH** ou **FF** | Oui |
| **Père** avec deux partenaires et deux enfants (même ville, écarts d’âge courts) | Oui |
| **Grand-parent** en tutelle ou **tuteur légal** | Oui *(voir § 5)* |
| Recomposition, demi-fratrie, garde alternée complexe | Oui |
### 3.3 Pourquoi le modèle casse
1. **Un `numero_dossier` par user** — M ne peut pas appartenir proprement à deux unités.
2. **Un seul co-parent** par fiche `parents`.
3. **Graphe famille trop large** — M liée à A (via α) et à B (via β) → A, B et M peuvent être fusionnés en **une seule « famille »** pour validation/refus/reprise.
4. **`dossier_famille`** — une motivation / une ancre par numéro, pas deux contextes pour la même personne.
---
## 4. Décision v1.0.0 — contournement opérationnel
### 4.1 Principe
> **Le numéro de dossier reste la norme pour les cas simples, pas une obligation absolue.**
> Pour les cas trop complexes, le **gestionnaire** compose manuellement (création admin, rattachements) ou applique le contournement ci-dessous.
### 4.2 Contournement accepté pour la 1.0.0
**Créer un second compte** avec une **adresse e-mail distincte** et un **second dossier** (second `numero_dossier`).
| Dossier | Compte | Co-responsable | Enfant |
|---------|--------|----------------|--------|
| 1 | `m.personne@…` | A | α |
| 2 | `m.personne.famille2@…` *(ou alias)* | B | β |
**Conséquences assumées :**
- Une **même personne physique** peut avoir **deux identités** dans lapp.
- Pas de vue unifiée « une personne, deux contextes » en v1.0.0.
- Procédure interne gestionnaire recommandée (note « même personne physique »).
- Emails distincts **volontaires** (alias, +tag, boîte dédiée selon infra mail).
### 4.3 Nuance — inscription publique
Si le **dossier 1** existe déjà avec lemail de P (titulaire ou co-parent), une **2ᵉ inscription publique** où lon saisit **le même email** comme co-parent est **bloquée** (*email déjà utilisé*).
Le **2ᵉ dossier** passe donc surtout par :
- **création / composition par le gestionnaire** (tickets admin #129+), ou
- **2ᵉ email** dès le départ pour la même personne physique.
### 4.4 Piste cible — parcours gestionnaire « famille complexe » *(réflexion juin 2026)*
Le contournement § 4.2 reste valable en **v1.0.0**. La **solution produit visée** pour les cas complexes est différente :
> **Seul le gestionnaire** dispose dun **parcours de création dédié** permettant de constituer une configuration avec **plus de deux responsables** (parents, co-parents, tuteurs…) **sans** se limiter au seul champ `co_parent`, en sappuyant sur **`enfants_parents`** comme vérité de visibilité.
**Exemple M / A / B / α / β :**
| Compte | Enfants visibles / rattachés |
|--------|------------------------------|
| **M** (un seul compte, un email) | α **et** β |
| **A** | α uniquement |
| **B** | β uniquement |
```
α ─── M ─── β
│ │
A B
```
- **M** voit et gère **ses deux enfants** sur **le même compte** (plus besoin dun 2ᵉ email pour M).
- **A** et **B** ne voient **que** lenfant qui leur est rattaché via `enfants_parents` — pas de fusion « famille » qui mélange A et B.
- Le parcours nest **pas** proposé à linscription publique (trop error-prone) : **réservé au gestionnaire** (#129+ ou ticket dédié « famille complexe »).
**Conséquences techniques (post-1.0.0) :**
1. **Visibilité** : filtrer listes, fiches et actions parent par **liens `enfants_parents`**, pas par `getFamilyUserIds` / co-parent.
2. **Workflows** (validation, refus, reprise) : périmètre par **enfant** ou **soumission**, pas par graphe familial élargi.
3. **`co_parent`** : reste utile pour le cas simple (couple + N enfants communs) ; **insuffisant seul** pour les cas complexes — le gestionnaire compose les liens enfant par enfant.
4. **Numéro de dossier** : optionnel ou secondaire ; peut rester sur M ou sur une « unité » admin, sans imposer un numéro par co-responsable.
*Détail : § 7.5.*
---
## 5. Terminologie — « parent » aujourdhui, qualification demain
### 5.1 v1.0.0
- Rôle technique : **`parent`** pour tout responsable inscrit (y compris tuteur, grand-parent tutrice, etc.).
- UI : « Parent 1 », « co-parent », parfois exemples maman/papa — **pas de statut juridique distinct**.
### 5.2 Évolution post-1.0.0 (piste)
Séparer deux niveaux :
| Niveau | Évolution |
|--------|-----------|
| **Rôle applicatif** | Conserver `parent` = « responsable du dossier » (auth, API) |
| **Qualification métier** | Nouveau champ, ex. `qualite_responsable` / `lien_avec_enfant` |
**Valeurs possibles (exemples)** : parent biologique ou adoptif, co-parent, tuteur légal, grand-parent exerçant la garde, autre responsable légal.
**Emplacement recommandé** : sur le **lien** `enfants_parents` (par enfant), pas seulement sur le user global.
**UI** :
- Libellés neutres : « Responsable 1 / 2 », « 2ᵉ responsable » ;
- **Combobox** pour qualifier le lien (admin + éventuellement inscription).
---
## 6. Évolutions UI / fonctionnelles liées (backlog)
Réflexions dashboard admin / gestionnaire (complément CDC §4.5.24.5.3).
> **Statut implémentation (juin 2026)** : §6.1 et §6.2 **en cours de livraison** (tickets #130#131, #115#116, #137#138). §6.3 reporté post-1.0.0 (#129).
### 6.1 Fiche parent (#131)
- Modale **éditable** dès louverture (pas lecture seule + « Modifier » factice).
- **Retirer lID** UUID ; option : **n° de dossier** en lecture seule.
- **Statut** en combobox (règles métier à cadrer vs validation/refus #110).
- Bas de modale :
- `Nombre d'enfants : N` (lecture seule) ;
- **Liste des prénoms/noms** (cadre) — consultation, clic → fiche enfant (#138).
### 6.2 Affiliation parent ↔ enfant (#115, #116, #138)
- **Vérité métier** : `enfants_parents`.
- **Modifier laffiliation** ≠ modifier le téléphone : gestion des **liens** (détacher / rattacher), avec garde-fous :
- ne pas supprimer lenfant pour retirer un lien ;
- au moins un responsable par enfant ;
- prudence sur fusion « famille » et co-parents.
- **Création enfant** : prioritaire depuis **fiche parent** (contexte famille) ; onglet **Enfants** (#137) pour vue globale + rattachement.
### 6.3 Création admin sans numéro (tickets #129+)
Le gestionnaire doit pouvoir **tout créer** depuis linterface ; le numéro reste **généré si utile**, **optionnel** si le cas est trop complexe — **objectif post-1.0.0** (unité de dossier).
---
## 7. Évolution structurelle post-1.0.0 — « unité de dossier »
### 7.1 Principe cible
| Aujourdhui | Cible |
|-------------|--------|
| `numero_dossier` = clé de la famille | **Liens parent↔enfant** = vérité |
| Famille déduite du graphe | **Unité de dossier** = regroupement **optionnel** |
| Un numéro par inscription | Numéro **optionnel** ; plusieurs unités par personne possibles |
### 7.2 Exemple cible (cas M / A / B) — deux approches
**Approche A — unités de dossier séparées** *(piste initiale § 7)* :
```
Unité 1 : numero 2026-000021 — M, A, α
Unité 2 : sans numéro (ou 2026-000089) — M, B, β
```
M appartient à **deux unités** ; A et B ne partagent pas la même unité. Peut impliquer **deux contextes de connexion** ou une agrégation côté M.
**Approche B — parcours gestionnaire « famille complexe »** *(préférée, § 4.4)* :
```
Compte M → enfants α, β
Compte A → enfant α
Compte B → enfant β
(liens enfants_parents ; pas de fusion A↔B)
```
- **Un seul compte pour M** avec **les deux enfants**.
- **A** et **B** isolés sur **leur** enfant respectif.
- Création **uniquement** par le gestionnaire ; inscription publique inchangée (couple + co-parent classique).
Les deux approches supposent de **cesser de déduire une « famille » unique** pour les workflows lorsque les liens enfant par enfant divergent. Lapproche B maximise lUX du responsable central (M) sans dupliquer son identité.
### 7.5 Parcours gestionnaire — création « famille complexe »
#### 7.5.1 Objectif
Permettre au **gestionnaire** de monter un dossier où :
- **N responsables** (≥ 2, parents ou tuteurs) sont créés ou rattachés ;
- chaque enfant est lié **explicitement** à un ou plusieurs responsables via `enfants_parents` ;
- un responsable (ex. **M**) peut être lié à **plusieurs enfants** dont les **autres** responsables (A, B) ne partagent **pas** la garde.
#### 7.5.2 Règles produit
| Règle | Détail |
|-------|--------|
| **Accès** | Parcours **gestionnaire uniquement** (dashboard), pas inscription publique |
| **Responsables** | Création ou rattachement de comptes `parent` ; qualification future sur le lien (§ 5) |
| **Visibilité** | Chaque responsable ne voit que **ses** enfants (liens `enfants_parents`) |
| **Co-parent UI** | Ne pas forcer « Parent 1 + co-parent unique » ; composition libre côté staff |
| **Garde-fous** | Au moins un responsable par enfant ; pas de suppression enfant pour retirer un lien |
#### 7.5.3 Esquisse du parcours UI (gestionnaire)
1. **Créer ou identifier** le responsable principal (ex. M).
2. **Ajouter dautres responsables** (A, B, tuteur…) — comptes distincts.
3. **Créer les enfants** (α, β…) et, pour chaque enfant, **cocher les responsables** rattachés.
4. **Motivation / dossier** : texte global ou par enfant (à cadrer).
5. **Validation** : par soumission ou par enfant, sans valider « toute la famille » dun coup si A et B ne doivent pas être fusionnés.
#### 7.5.4 Impacts techniques majeurs
| Domaine | Changement |
|---------|------------|
| **Auth / API parent** | `GET` enfants, dashboard parent : filtre `enfants_parents` pour lutilisateur courant |
| **`getFamilyUserIds`** | Ne plus utiliser pour visibilité parent ; réservé au cas simple ou deprecated progressivement |
| **Validation / refus** | Périmètre enfant ou responsable, pas fusion A+B via graphe |
| **Reprise (#112)** | Token / périmètre = enfants liés au compte refusé, pas toute la composante connexe |
| **Admin (#115#138)** | Prérequis : rattachement/détachement déjà en place ; ce parcours **compose** ces briques |
#### 7.5.5 Lien avec v1.0.0
| Phase | Comportement |
|-------|--------------|
| **v1.0.0** | Contournement § 4.2 (2ᵉ email M) + composition manuelle gestionnaire (#115#138) |
| **Post-1.0.0** | Parcours § 7.5 + refonte visibilité / workflows |
### 7.3 Impacts workflows
| Flux | Adaptation future |
|------|-------------------|
| Validation / refus (#110) | Par **enfant / soumission**, pas par graphe global |
| Reprise (#112) | Périmètre = enfants liés au compte, pas composante connexe |
| Liste « à valider » | Par soumission ou par enfant |
| Recherche gestionnaire | Numéro **ou** nom **ou** enfant |
| Visibilité parent | **Uniquement** enfants via `enfants_parents` (approche B § 7.2) |
### 7.4 Migration
- **Court terme** : contournement § 4 + tickets admin.
- **Moyen terme** : table **unité de dossier**, `numero_dossier` nullable.
- **Long terme** : workflows branchés sur lunité.
- Dossiers **existants** (couple + enfants) : une unité = un numéro → **comportement actuel préservé**.
---
## 8. Tickets Gitea associés
| Ticket | Sujet |
|--------|--------|
| #110 | Refus dossier (token reprise) — livré |
| #112 | Reprise après refus — livré |
| #115 / #116 | Rattachement parent — backend / front |
| #129+ | Création dossier admin (parent / AM) |
| #131 | Fiche parent éditable (dashboard) |
| #137 | Onglet Enfants — liste globale |
| #138 | Fiche enfant + liste dans fiche parent |
| *(à créer)* | Epic « Unité de dossier / numéro optionnel » |
| #139 | **Parcours gestionnaire « famille complexe »** (§ 7.5) — N responsables, visibilité par enfant |
| *(à créer)* | Qualification responsable légal (combobox sur lien enfant) |
---
## 9. Formulation type — release notes / doc gestionnaire (v1.0.0)
> **Familles recomposées ou responsabilités multiples**
> Une même personne ne peut pas gérer proprement deux unités familiales distinctes (recompositions, plusieurs co-responsables successifs, tuteur/GP pour plusieurs contextes) avec **un seul compte**.
> **Contournement v1.0.0** : second compte avec **email distinct** et second numéro de dossier.
> Sapplique à **tous les responsables inscrits** (couples HH/FF, tuteurs, grands-parents, etc.).
> **Évolution post-1.0.0** : parcours **gestionnaire** « famille complexe » (un compte M, enfants α+β ; A et B limités à leur enfant) ; visibilité par `enfants_parents` ; workflows sans fusion graphe familial.
---
## 10. Historique des mises à jour
| Date | Auteur | Modification |
|------|--------|--------------|
| 2026-06-16 | Équipe / session produit | Création — synthèse réflexions famille, tutelle, v1.0.0 vs post-1.0.0 |
| 2026-06-16 | Implémentation ch.6 | Back : `PATCH /parents/:id/fiche`, attach/detach enfant. Front : modale parent éditable, onglet Enfants, fiche enfant |
| 2026-06-16 | Réflexion produit | § 4.4 / § 7.5 — parcours gestionnaire famille complexe : M un compte (α+β), A/B visibilité restreinte par enfant |
---
*Ce document sera enrichi au fil des décisions. Pour les décisions formelles archivées, voir aussi [24_DECISIONS-PROJET.md](./24_DECISIONS-PROJET.md).*
+197
View File
@@ -0,0 +1,197 @@
# Bilan — Version 0.1.0
**Statut** : terminée
**Milestone Gitea** : [0.1.0](https://git.ptits-pas.fr/jmartin/petitspas/milestone/10)
**Dépôt** : `jmartin/petitspas`
**Tag prévu** : `v0.1.0` (sur `master` après merge de cette doc)
Ce document est la **mémoire produit** de la version 0.1.0 : ce qui a été livré, ticket par ticket, et ce qui a été reporté.
---
## 1. Périmètre produit livré
La 0.1.0 couvre le **MVP opérable** pour une collectivité :
- Authentification, création / oubli de mot de passe, e-mails associés
- Inscription parent & AM, validation / refus gestionnaire, reprise après refus
- Dashboard staff (admin + gestionnaire) : listes, fiches, rattachements
- Onglet **Dossiers** + wizards création / édition (famille & AM)
- Suppressions métier (droits, confirms, cascades API)
- Cleanups structurels (préfixe `Admin*`, panels dashboard, modale staff, retrait `est_multiple`)
**Hors 0.1.0** (reporté) : doublons avancés, famille N responsables, statut enfant gardé/sans garde, combobox RPE AM, chantier CDC (#117), tickets tech/observabilité — voir §4 et [05_VERSIONS-ET-MILESTONES.md](./05_VERSIONS-ET-MILESTONES.md).
---
## 2. Thèmes livrés
### 2.1 Auth, mot de passe, e-mails
| # | Titre | Livré |
|---|-------|--------|
| 24 | API Création mot de passe | Endpoints token → création MDP post-validation |
| 28 | Templates Email — Validation | Mails validation avec lien MDP |
| 30 | Connexion — Vérification statut | Blocage comptes pending / suspendus à la connexion |
| 43 | Écran Création Mot de Passe | UI lien e-mail création MDP |
| 47 | Écran Changement MDP Obligatoire | Première connexion staff |
| 50 | Affichage dynamique CGU | CGU/Privacy versionnées à linscription |
| 118 | Page création mot de passe (Front + API) | Alignement front/API du flux lien e-mail |
| 123 | Durcissement token création MDP | TTL, usage unique, contrôles API |
| 127 | Mot de passe oublié | Demande → e-mail → réinitialisation (flux distinct de #24/#43) |
### 2.2 Inscription, numéros de dossier, reprise
| # | Titre | Livré |
|---|-------|--------|
| 104 | Numéro de dossier — frontend | Affichage listes / mails / modales ; format AAAA-… |
| 112 | Reprise après refus — frontend | Lien e-mail `/reprise` + reprise par n° dossier |
| 120 | Inscription AM — photo & UX | Chaîne photo / API / UX alignée parents |
| 144 | Consentement photo enfant | Persistance du consentement à linscription |
### 2.3 Dashboard — fiches, listes, rattachements
| # | Titre | Livré |
|---|-------|--------|
| 115 | Rattachement enfants — backend | Attach/detach parent↔enfant et AM↔enfant |
| 116 | Rattachement enfants — frontend | UI fiches parent / AM |
| 130 | UserService — APIs métier | Branchement parents / AM / enfants côté front |
| 131 | Édition fiche parent + AM | Modales édition dashboard |
| 132 | Création enfant (onglet Enfants) | Création + rattachement foyer |
| 136 | API enfants — droits & liste | Droits gestionnaire + enrichissement liste |
| 137 | Onglet Enfants — liste globale | Panneau liste dashboard |
| 138 | Fiche enfant + liste dans parent | Fiche enfant ; enfants dans fiche parent |
| 140 | Epic fiche parent / affiliation | Livraison regroupée dashboard admin/gestionnaire |
| 142 | Clic carte → modale | Ouverture fiche depuis les listes |
| 145 | Lien co-parent cliquable | Navigation fiche parent → co-parent |
| 146 | Modale sélection enfant | UX rattacher enfant (AM + parent) |
| 147 | Modale sélection AM | UX rattacher AM depuis fiche enfant |
| 148 | Capacité max AM | Désactivation rattachement si capacité atteinte |
| 149 | Case libre AM → rattacher | Clic emplacement vide pour rattacher |
| 151 | GET /relais pour gestionnaire | Combo relais dans modale staff |
| 157 | Enfant sans responsable | Détachement dernier parent + alerte liste |
| 158 | Affiliation foyer (pivot + co-parent) | Attach/detach cohérents sur le foyer |
### 2.4 Dossiers staff (création, édition, liste)
| # | Titre | Livré |
|---|-------|--------|
| 129 | Création dossier parent | Wizard + API staff famille |
| 135 | Édition dossier + 2ᵉ parent | Mode edit wizards + `POST …/co-parent` |
| 153 | Onglet Dossiers | Liste unifiée + à valider (sans création dans longlet) |
| 156 | Création dossier AM | Wizard + API staff AM |
### 2.5 Suppressions & droits staff
| # | Titre | Livré |
|---|-------|--------|
| 133 | Suppression parent + AM (UI) | Première vague UI (complétée par #160) |
| 134 | Droits « Ajouter gestionnaire » | Visibilité / API selon rôle |
| 143 | Bug supprimer sa propre fiche | Masquage / interdiction auto-suppression |
| 154 | Epic suppressions | Cadrage règles métier suppressions |
| 159 | Suppressions métier — backend | Cascades, garde-fous, droits API |
| 160 | Suppressions dashboard — frontend | Poubelles + dialogues de confirmation |
| 161 | Admin création staff 403 | Admin peut créer gestionnaire et administrateur |
### 2.6 Cleanups structure & UX
| # | Titre | Livré |
|---|-------|--------|
| 25 | API Liste comptes en attente | Historique ; couvert par flux dossiers / validation |
| 26 | API Validation / Refus | Historique ; couvert par flux dossiers / validation |
| 152 | Retrait `est_multiple` | Suppression full stack (BDD, API, front, docs) |
| 155 | Rename préfixe `Admin*` | Widgets partagés sans préfixe Admin (option C) |
| 162 | Panels → `widgets/dashboard/` | Suite #155 — panels staff sous dashboard |
| 164 | Modale staff uniformisée | `StaffUserFormModal` (shell 930, champs contrôlés) |
---
## 3. Inventaire exhaustif (48 tickets fermés, milestone 0.1.0)
| # | Titre |
|---|-------|
| 24 | [Backend] API Création mot de passe |
| 25 | [Backend] API Liste comptes en attente |
| 26 | [Backend] API Validation/Refus comptes |
| 28 | [Backend] Templates Email - Validation |
| 30 | [Backend] Connexion - Vérification statut |
| 43 | [Frontend] Écran Création Mot de Passe |
| 47 | [Frontend] Écran Changement MDP Obligatoire |
| 50 | [Frontend] Affichage dynamique CGU lors inscription |
| 104 | Numéro de dossier frontend |
| 112 | Reprise après refus frontend |
| 115 | [Backend] Rattachement enfants — parent et AM |
| 116 | [Frontend] Rattachement enfants — parent et AM |
| 118 | Page création mot de passe (lien email) Front + API |
| 120 | [Full-stack] Inscription AM — photo, API et UX alignés sur les parents |
| 123 | [Tech] Durcissement token création MDP |
| 127 | [Full-stack] Mot de passe oublié — flux complet |
| 129 | Création dossier parent (wizard + API staff) |
| 130 | [Frontend] UserService — brancher APIs parents, AM et enfants |
| 131 | [Frontend] Édition fiche parent + AM |
| 132 | Création enfant depuis longlet Enfants |
| 133 | [Frontend] Suppression compte parent + AM |
| 134 | Droits bouton « Ajouter gestionnaire » |
| 135 | Mode édition dossier (+ ajout 2ᵉ parent) |
| 136 | [Backend] API enfants — droits gestionnaire + enrichissement liste |
| 137 | [Frontend] Onglet Enfants — liste globale |
| 138 | [Frontend] Fiche enfant + liste enfants dans fiche parent |
| 140 | Dashboard admin — fiche parent, enfants et affiliation |
| 142 | Clic sur carte → ouvrir la modale |
| 143 | Bug — Gestionnaire Supprimer sur sa propre fiche |
| 144 | Bug — Consentement photo enfant non sauvegardé |
| 145 | Lien co-parent cliquable |
| 146 | UX — modale sélection d'enfant |
| 147 | UX — modale sélection d'AM |
| 148 | Bug — capacité max AM |
| 149 | Fiche AM — clic case libre pour rattacher |
| 151 | Bug — GET /relais gestionnaire |
| 152 | Cleanup — supprimer `est_multiple` |
| 153 | Onglet permanent « Dossiers » |
| 154 | Epic — suppressions utilisateurs / dossiers / enfants / AM |
| 155 | Cleanup — renommer préfixe Admin* |
| 156 | Création dossier AM (wizard + API staff) |
| 157 | Enfant sans responsable |
| 158 | Affiliation enfant au foyer (pivot + co-parent) |
| 159 | Backend suppressions métier (#154) |
| 160 | Frontend suppressions dashboard (#154) |
| 161 | Bug — Admin création gestionnaire / administrateur |
| 162 | Cleanup — panels vers `widgets/dashboard/` |
| 164 | Uniformisation modale staff + champs contrôlés |
Issues : https://git.ptits-pas.fr/jmartin/petitspas/issues?q=&type=all&state=closed&labels=&milestone=10&assignee=0
---
## 4. Reporté hors 0.1.0
| # | Titre | Destination typique |
|---|-------|---------------------|
| 113 / 114 | Doublons inscription / alerte gestionnaire | 0.9.0 |
| 117 | Évolution du cahier des charges | Doc (amendement CDC post-0.1.0) |
| 121122, 124125 | Tech auth / photos / DB | 0.9.0 |
| 126 | Upload documents légaux 500 | 0.9.0 |
| 128 | Audit / traçabilité modifications | 0.9.0 |
| 139 | Famille complexe N responsables | Post-0.1.0 / epic |
| 141 | Statut enfant gardé / sans garde | Post-0.1.0 |
| 150 | Combobox rattachement RPE (AM) | 0.2.0 |
Voir aussi [05_VERSIONS-ET-MILESTONES.md](./05_VERSIONS-ET-MILESTONES.md).
---
## 5. Suite documentaire
1. **Amendement CDC (#117)** — CDC V1.4 : mise à jour **gestion utilisateurs** ; le reste du CDC reste la cible. SRS technique : [12_SRS-GESTION-UTILISATEURS.md](./12_SRS-GESTION-UTILISATEURS.md). Intrants historiques archivés : [archive/obsolete/EVOLUTIONS_CDC.md](./archive/obsolete/EVOLUTIONS_CDC.md), [28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md).
2. **Tag** `v0.1.0` sur `master` lorsque milestone fermée + ce bilan mergé.
3. Enchaîner les milestones **0.2.0+** selon [05_VERSIONS-ET-MILESTONES.md](./05_VERSIONS-ET-MILESTONES.md).
---
## 6. Références code (points dentrée)
- Modale staff : `frontend/lib/widgets/dashboard/staff_user_form_modal.dart`
- Fiches : `parent_edit_modal.dart`, `am_edit_modal.dart`, `child_detail_modal.dart`
- Wizards dossiers : `parent_dossier_wizard.dart`, `am_dossier_wizard.dart`
- Règles suppressions : tickets #154 / #159 / #160
- Cleanup `est_multiple` : #152
-3
View File
@@ -276,9 +276,6 @@ export class Enfants {
@Column({ name: 'consentement_photo', type: 'boolean', default: false })
consentementPhoto: boolean;
@Column({ name: 'est_multiple', type: 'boolean', default: false })
estMultiple: boolean;
@Column({
type: 'enum',
enum: StatutEnfantType,
-8
View File
@@ -1,8 +0,0 @@
# Fichier déplacé / fusionné
La procédure **API Gitea** est désormais documentée sous :
**[26_GITEA-API.md](./26_GITEA-API.md)**
Lancienne copie `PROCEDURE-API-GITEA.md` est archivée dans
`docs/archive/obsolete/` (doublon).
+8 -19
View File
@@ -1,30 +1,19 @@
# Archive documentation · P'titsPas
Ce dossier regroupe les fichiers **sans préfixe numérique** à la racine de
`docs/` qui ne sont plus des **références actives**, ou qui sont des
**brouillons / temporaires**.
Fichiers **hors références actives** : brouillons livrés, CDC historiques, listes figées.
## Règle de nommage (racine `docs/`)
- Les documents **normatifs** à la racine portent un préfixe **`NN_`**
(deux chiffres), ex. `23_LISTE-TICKETS.md`.
- **Exceptions** (héritage ou outillage) listées dans
[**00_INDEX.md**](../00_INDEX.md#exceptions-de-nommage) : charte, CDC
historique, évolutions — **cible** : les renommer progressivement en `NN_`
et mettre à jour `.cursorrules` / liens.
- Documents **normatifs** : préfixe **`NN_`**.
- Exceptions héritage listées dans [00_INDEX.md](../00_INDEX.md) (`CHARTE_GRAPHIQUE.md`).
## Sous-dossiers ici
## Sous-dossiers
| Dossier | Usage |
|---------|--------|
| [**temporaires/**](./temporaires/) | Notes jetables, exports de travail.
**Supprimables** quand la tâche associée est close. |
| [**obsolete/**](./obsolete/) | Ancienne doc **remplacée** ou **doublon**
(conservée un temps pour historique). **Supprimer** après bascule confirmée
si plus aucune référence. |
| [**temporaires/**](./temporaires/) | Brouillons jetables. **Vider** dès livraison. |
| [**obsolete/**](./obsolete/) | Doc remplacée (CDC SuperNounou, ancienne liste tickets, notes ponctuelles, backlog Phase 2 figé). |
## Hors `docs/` racine
## Politique `tmp/`
Les dossiers thématiques (**`juridique/`**, **`test-data/`**, etc.) peuvent
contenir des fichiers sans `NN_` : la règle `NN_` sapplique surtout aux
fichiers **directement** sous `docs/`.
Le dossier `docs/tmp/` **nest plus utilisé**. Les mini-specs de tickets livrés sont purgés ; la mémoire produit = bilans de version (`29_…`) + tickets Gitea.
File diff suppressed because it is too large Load Diff
@@ -2,6 +2,10 @@
Ce document liste les modifications à apporter au cahier des charges original pour le rendre conforme à l'application développée.
> **Intrant pour #117** (amendement CDC post-0.1.0). Compléter avec le [bilan 0.1.0](./29_BILAN-VERSION-0.1.0.md) et **[28 - Évolution famille et responsables](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md)**.
> **Obsolète depuis #152** : ne plus proposer de champ « naissance multiple / `est_multiple` » — retiré de lapp (BDD, API, front).
## 1. Gestion des Enfants
### Modifications à apporter dans la section "Création de compte parent"
@@ -9,7 +13,6 @@ Ce document liste les modifications à apporter au cahier des charges original p
#### Situation actuelle dans le CDC :
- Mentionne uniquement la collecte d'informations sur l'enfant
- Ne précise pas la possibilité d'ajouter plusieurs enfants
- Ne mentionne pas la gestion des naissances multiples
- Ne mentionne pas la gestion des enfants à naître
#### Modifications proposées :
@@ -20,17 +23,18 @@ Ajouter le paragraphe suivant après la description de la collecte d'information
Les parents peuvent ajouter autant d'enfants que nécessaire. Pour chaque enfant, les informations suivantes sont collectées :
- Prénom
- Date de naissance (ou date prévue pour les enfants à naître)
- Genre
- Photo (optionnelle)
- Consentement pour l'utilisation de la photo
- Indication si l'enfant fait partie d'une naissance multiple (jumeaux, triplés, etc.)
Les parents peuvent :
- Ajouter un nouvel enfant à tout moment
- Supprimer un enfant ajouté
- Modifier les informations d'un enfant existant
- Indiquer si l'enfant est à naître
- Indiquer si l'enfant fait partie d'une naissance multiple
- Donner ou retirer leur consentement pour l'utilisation de la photo de l'enfant
Note : le concept de « naissance multiple » / jumeaux n'est pas géré par un champ dédié (retiré en 0.1.0, #152).
```
### Modifications à apporter dans la section "Workflow de création de compte"
@@ -48,9 +52,9 @@ Remplacer l'étape 3 par :
- Pour chaque enfant :
* Saisie du prénom
* Saisie de la date de naissance (ou date prévue)
* Genre
* Option d'ajout d'une photo
* Option de consentement photo
* Indication si naissance multiple
* Indication si enfant à naître
- Possibilité de modifier ou supprimer un enfant
```
@@ -275,4 +279,29 @@ Pour chaque évolution identifiée, ce document suivra la structure suivante :
- Évolution du modèle de données vers un RBAC intra-RPE.
- Adaptation des écrans d'administration pour gérer les rôles locaux.
- Renforcement des contrôles d'accès backend et des règles métier.
- Clarification des workflows décisionnels dans l'application.
- Clarification des workflows décisionnels dans l'application.
## 9. Modèle famille, numéro de dossier et responsables légaux
### 9.1 Situation actuelle (v1.0.0)
- Un **numéro de dossier** par inscription (`AAAA-NNNNNN`), fortement utilisé dans les workflows (validation, refus, reprise).
- **Un co-parent** par fiche `parents` ; affiliation réelle via `enfants_parents`.
- La « famille » est **déduite** du graphe (co-parent + enfants partagés) — fusion parfois trop large en cas de recompositions.
### 9.2 Limitation v1.0.0 — familles recomposées
Cas type : même responsable M avec co-responsable A (enfant α) puis co-responsable B (enfant β). Le modèle actuel ne permet pas de séparer proprement **deux unités familiales** pour une même personne.
**Contournement accepté pour la release 1.0.0** : second compte avec **email distinct** et second numéro de dossier (procédure gestionnaire). S'applique aussi aux couples HH/FF, tuteurs, grands-parents, etc.
### 9.3 Évolutions post-1.0.0 (pistes)
| Sujet | Piste |
|-------|--------|
| **Unité de dossier** | Numéro **optionnel** ; plusieurs unités par personne |
| **Affiliation** | Gestion admin des liens parent↔enfant (#115, #116, #138) |
| **Qualification** | Combobox « lien responsableenfant » (tuteur, GP, parent…) sur `enfants_parents` |
| **UI admin** | Fiche parent éditable (#131), liste enfants en bas de fiche |
**Détail complet, scénarios, tickets et formulation release notes** : [28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md).
+11 -7
View File
@@ -4,11 +4,15 @@ Ancienne documentation **déplacée** depuis `docs/` :
| Fichier | Motif |
|---------|--------|
| `PROCEDURE-API-GITEA.md` | Doublon fonctionnel de
[**26_GITEA-API.md**](../../26_GITEA-API.md). |
| `ARCHITECTURE_TECHNIQUE.md` | Non référencé ; la vue densemble est dans
[**02_ARCHITECTURE.md**](../../02_ARCHITECTURE.md). |
| `STATUS-APPLICATION.md` | Instantané daté ; non tenu comme doc vivante. |
| `01_CAHIER-DES-CHARGES-v1.3.md` | CDC V1.3 — remplacé par [01 V1.4](../../01_CAHIER-DES-CHARGES.md) |
| `EVOLUTIONS_CDC.md` | Patch CDC — absorbé dans V1.4 + [12_SRS](../../12_SRS-GESTION-UTILISATEURS.md) |
| `PROCEDURE-API-GITEA.md` | Doublon de [26_GITEA-API.md](../../26_GITEA-API.md) |
| `ARCHITECTURE_TECHNIQUE.md` | Remplacé par [02_ARCHITECTURE.md](../../02_ARCHITECTURE.md) |
| `STATUS-APPLICATION.md` | Instantané daté |
| `23_LISTE-TICKETS.md` | Liste Phase 1 figée — suivi = Gitea + bilans |
| `25_PHASE-2-BACKLOG.md` | Backlog technique figé — voir [05_VERSIONS…](../../05_VERSIONS-ET-MILESTONES.md) |
| `SuperNounou_*` | CDC / SSS historiques |
| `14_NOTE-BACKEND-CONFIG-SETUP.md` | Note ticket ponctuelle |
| `92_NOTE-BACKEND-GESTIONNAIRES.md` | Note ticket ponctuelle |
Après vérification quaucun lien externe ne pointe encore vers ces chemins, on
peut **supprimer** ce sous-dossier ou ne garder que des pointeurs minimalistes.
Références actives : [01 CDC V1.4](../../01_CAHIER-DES-CHARGES.md), [12 SRS users](../../12_SRS-GESTION-UTILISATEURS.md), [29 bilan 0.1.0](../../29_BILAN-VERSION-0.1.0.md).
@@ -1,36 +0,0 @@
# Archivé docs/archive/temporaires/ — export jetable, supprimer si inutile.
Point tickets frontend (API Gitea) - 27/01/2026
================================================
Issues avec label "frontend" : 20 (ouvertes: 12, fermees: 8)
Num | Etat | Titre
----+--------+--------------------------------------------------------
35 | open | [Frontend] Écran Création Gestionnaire
36 | closed | [Frontend] Inscription Parent - Étape 1 (Parent 1)
37 | closed | [Frontend] Inscription Parent - Étape 2 (Parent 2)
38 | closed | [Frontend] Inscription Parent - Étape 3 (Enfants)
39 | closed | [Frontend] Inscription Parent - Étapes 4-6 (Finalisatio
40 | closed | [Frontend] Inscription AM - Panneau 1 (Identité)
41 | closed | [Frontend] Inscription AM - Panneau 2 (Infos pro)
42 | closed | [Frontend] Inscription AM - Finalisation
43 | open | [Frontend] Écran Création Mot de Passe
44 | closed | [Frontend] Dashboard Gestionnaire - Structure
45 | open | [Frontend] Dashboard Gestionnaire - Liste Parents
46 | open | [Frontend] Dashboard Gestionnaire - Liste AM
47 | open | [Frontend] Écran Changement MDP Obligatoire
48 | open | [Frontend] Gestion Erreurs & Messages
49 | open | [Frontend] Écran Gestion Documents Légaux (Admin)
50 | open | [Frontend] Affichage dynamique CGU lors inscription
51 | open | [Frontend] Écran Logs Admin (optionnel v1.1)
54 | open | [Tests] Tests E2E Frontend
82 | closed | [Frontend] Adapter cran Login pour mobile
83 | closed | [Frontend] Adapter cran Choix Inscription pour mobile
Suivi doc 23_LISTE-TICKETS (Gitea #73,78,79,81,82,83):
#73 closed labels=[]
#78 closed labels=[]
#79 closed labels=[]
#81 closed labels=[]
#82 closed (écran Login mobile)
#83 closed labels=['frontend', 'p3', 'phase-1', 'ux']
+8 -7
View File
@@ -1,10 +1,11 @@
# Temporaires
Fichiers **non numérotés** de travail (brouillons, listes de tickets exportées,
alignements UI en cours, etc.).
Dossier **vide** après clôture 0.1.0 (purge sept. 2026).
- Préfixe conseillé pour les nouveaux fichiers jetables : **`TEMP_`** ou
**`WIP_`** dans ce dossier.
- **Suppression** : dès que la fonctionnalité est livrée ou le sujet clos,
supprimer le fichier (ou le déplacer vers `obsolete/` si une trace utile
reste nécessaire).
Si un brouillon de travail est nécessaire un temps :
- le placer ici avec préfixe `TEMP_` / `WIP_` ;
- le **supprimer** dès livraison (ne pas laisser pourrir) ;
- pour une trace utile durable → bilan de version ou archive `obsolete/`.
Ne plus utiliser `docs/tmp/`.
@@ -1,46 +0,0 @@
# TEMP — Alignement front / API (inscription AM & validation gestionnaire)
> **Archivé** (`docs/archive/temporaires/`) — **fichier temporaire** ; à
> **supprimer** une fois le front livré ou le sujet clos (voir
> `docs/archive/temporaires/README.md`).
Ce document décrit les changements **côté API** et ce que **Flutter** doit faire pour rester aligné. Aucune modification front na été faite dans le chantier backend associé.
## 1. `POST /auth/register/am` — lieu de naissance obligatoire
- **`lieu_naissance_ville`** et **`lieu_naissance_pays`** sont **obligatoires** (non vides après trim, min. **2 caractères** chacun, max 100).
- Réponses **400** si manquants ou invalides (messages class-validator).
- **Action front** : champs obligatoires dans le parcours AM (étapes identité / naissance), validation UI avant envoi ; afficher les erreurs renvoyées par lAPI.
## 2. Réponse `GET /dossiers/:numeroDossier` (type `am`)
Sous `dossier.user`, lAPI peut inclure :
| Clé JSON | Description |
|----------|-------------|
| `date_naissance` | Date (si renseignée à linscription) |
| `lieu_naissance_ville` | Ville de naissance |
| `lieu_naissance_pays` | Pays de naissance |
| `consentement_photo` | Booléen (exposé dans `dossier.user`) |
À la **racine** de `dossier` (objet AM), champs déjà renvoyés par le backend : `disponible`, `annees_experience`, `specialite`, `nb_max_enfants`, `place_disponible`, etc.
**Action front** :
- Étendre **`AppUser.fromJson` / `toJson`** (`lib/models/user.dart`) pour mapper `date_naissance`, `lieu_naissance_ville`, `lieu_naissance_pays`, `consentement_photo`.
- Étendre **`DossierAM.fromJson`** (`lib/models/dossier_unifie.dart`) pour parser `disponible`, `annees_experience`, `specialite` à la racine du dossier (noms snake_case comme dans la réponse JSON Nest).
## 3. `ValidationAmWizard` (admin)
Afficher pour cohérence avec le formulaire dinscription :
- **Informations personnelles** : date de naissance, ville / pays de naissance, consentement photo (Oui/Non).
- **Informations professionnelles** : disponibilité, années dexpérience, spécialité (afficher « » si `null`).
## 4. `place_disponible` à linscription
- Le backend initialise **`place_disponible`** sur la fiche AM à la **même valeur** que **`capacite_accueil`** à la création. Le wizard peut donc afficher une valeur cohérente avec la capacité sans champ séparé côté public.
---
*Dernière mise à jour : alignement backend branche `feature/120-inscription-am-photo-backend`.*
@@ -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": "."
}
]
}
@@ -1,244 +0,0 @@
# #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
```
+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`

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