Compare commits

...
Author SHA1 Message Date
jmartinandCursor 8b1b9e7917 feat(front): implement AM dashboard shell with 3 columns (#169)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 12:13:20 +02:00
jmartin 2380aa9826 Merge branch 'develop' of https://git.ptits-pas.fr/jmartin/petitspas into develop 2026-09-24 11:58:52 +02:00
jmartin c952113099 Merge branch 'feature/187-agenda-absences-stub' into develop 2026-09-24 11:58:38 +02:00
jmartinandCursor 8204669ec9 fix(ui): use TokenService to fix 401 Unauthorized when calling absences API
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 11:57:03 +02:00
jmartin 76ab0f340a Merge branch 'feature/195-realtime-cartes' into develop
Realtime SSE Cartes (#195).
2026-09-24 11:55:17 +02:00
jmartinandCursor a0a5e15b04 feat(front): add app icon for android, web and windows + agenda stub
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 11:53:43 +02:00
jmartinandCursor 823ea6cd22 feat(#195): SSE realtime Cartes (stream + emit audience)
GET /api/v1/cards/stream (Bearer ou ?access_token=).
Events card.created|updated|deleted, response.added + heartbeat.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 11:50:30 +02:00
jmartin dfac075a74 fix: JwtModule AbsencesGarde + Cards (AuthGuard DI)
Corrige crash-loop Nest AuthGuard DI.

Co-authored-by: Julien Martin <julien.martin@ptits-pas.fr>
2026-09-24 09:44:00 +00:00
jmartin 2d8b857a84 feat(#194): module Cartes SYSTEM
Closes #194

Co-authored-by: Julien Martin <julien.martin@ptits-pas.fr>
2026-09-24 09:38:39 +00:00
jmartin 147051821a feat(#172): API absences-garde (liste, CRUD, droits parent/AM)
Closes #172

Co-authored-by: Julien Martin <julien.martin@ptits-pas.fr>
2026-09-24 09:18:52 +00:00
jmartin 41f7006073 docs: Epic C — absences back ≠ cartes (§32)
Découpage + mini-spec + décision projet.

Co-authored-by: Julien Martin <julien.martin@ptits-pas.fr>
2026-09-24 09:13:19 +00:00
jmartin 8d8627cf38 feat(#193): table absences_garde + drop evenements
Closes #193

Co-authored-by: Julien Martin <julien.martin@ptits-pas.fr>
2026-09-24 09:09:57 +00:00
jmartin 6a586110e7 docs: norme merge squash (feature→develop→master)
Décision §26 + briefing + API Gitea. Les merges --no-ff #167/#168 restent inchangés.

Co-authored-by: Julien Martin <julien.martin@ptits-pas.fr>
2026-09-23 22:56:04 +00:00
jmartin f9fd8d73a8 Merge branch 'feature/168-api-couples-garde-parent' into develop
Closes #168 — API couples de garde parent.
2026-09-24 00:53:38 +02:00
jmartinandCursor 93ee3a5549 fix(#168): typage colonne placement garde courant compatible TypeORM.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 00:51:06 +02:00
jmartinandCursor db08aca714 feat(#168): API couples de garde parent (liste + couple courant).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 00:51:05 +02:00
jmartin 494f0e4c19 Merge branch 'feature/167-selecteur-couple-enfant-nounou' into develop 2026-09-24 00:47:10 +02:00
jmartinandCursor 9a4664a8ea fix(ui): align right tile by reserving chevron space
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 00:45:01 +02:00
jmartinandCursor bf00933f65 fix(ui): use pencil line asset for separation in CoupleSelectorBandeau
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 00:43:16 +02:00
jmartinandCursor 7b4650b81b fix(ui): show only first name and increase font size to 22 in CoupleSelectorBandeau
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 00:41:32 +02:00
jmartinandCursor b7ae07f5aa fix(ui): increase font size for names in CoupleSelectorBandeau
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 00:39:20 +02:00
jmartinandCursor a88f791317 fix(ui): increase avatar size in CoupleSelectorBandeau
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 00:38:32 +02:00
jmartinandCursor 138398a4a0 fix(ui): remove opacity on dropdown items and assign unique bandeau colors by index
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 00:35:52 +02:00
jmartin 7cf30643aa Merge branch 'feature/167-selecteur-couple-enfant-nounou' into develop 2026-09-24 00:33:12 +02:00
jmartinandCursor 5eb3468fe0 fix(ui): prevent dropdown menu from overlapping main button in CoupleSelectorBandeau
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 00:33:02 +02:00
jmartinandCursor df4f48e864 feat(front): selecteur couple enfant-nounou (#167)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 00:12:10 +02:00
jmartin 6f9136aae5 Merge branch 'feature/166-tdb-parent-coquille-bandeau' into develop (#166) 2026-09-23 23:39:56 +02:00
jmartinandCursor 461c70df05 refactor(front): suppression anciens widgets dashbord_parent remplaces par la coquille quotidien (#166)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 23:39:08 +02:00
jmartinandCursor fa88d00657 chore(assets): pastille corail en reserve (#166)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 23:36:06 +02:00
jmartinandCursor 6e18956a63 feat(front): coquille quotidien parent 3 colonnes + bandeau pastel (#166)
- QuotidienShell / QuotidienBandeau / QuotidienTheme reutilisables (parent, AM #169)
- Bandeau : logo, pastilles dessinees Cahier de liaison / Agenda / Contrat
  (jaune / peche / turquoise, inactive = meme couleur a 45 %), menu user sur
  pastille lavande
- Separateurs trait crayon gris (bandeau / colonnes / footer)
- ImageButton : shape stadium (focus suit le contour) + bgOpacity
- AppFooter : option showTopBorder
- ParentDashboardScreen branche sur la coquille avec placeholders
  cartes (#167/#173), blog (#178), messagerie (#184), stubs Agenda/Contrat (#187)
- Assets pastilles (meme silhouette 1190x299) et traits crayon

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 23:34:36 +02:00
jmartinandCursor 745349bc83 docs(0.2.0): mini-spec quotidien parent/AM, découpage tickets et maquettes.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 17:17:15 +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
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
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 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 c8cb82dd24 merge(#161): admin peut créer gestionnaire et administrateur. 2026-09-10 23:00:28 +02:00
jmartinandCursor 9cd180bf6a feat(#161): admin peut créer gestionnaire et administrateur.
Assouplit POST /gestionnaires, PATCH /gestionnaires/:id et POST /users/admin
(+ check createAdmin) pour ADMINISTRATEUR. Masque « Ajouter » staff sur le
dashboard gestionnaire. Tests droits Roles + createAdmin.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 22:54:49 +02:00
jmartin ca07d2e111 merge(#160): suppressions dashboard front + API #159. 2026-09-10 22:41:02 +02:00
jmartinandCursor 67336c64fe fix(#160): un gestionnaire ne peut plus supprimer un autre depuis la fiche.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 22:38:57 +02:00
jmartinandCursor 97afbbcf9a fix(am): hauteur modale fiche AM par onglet (sans scroll inutile).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 22:16:06 +02:00
jmartinandCursor e235b30140 feat(#160): suppressions dashboard — poubelle, confirmations et sans_enfant.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 22:16:05 +02:00
jmartinandCursor 7f23356273 feat(#159): suppressions métier dossiers/parents/enfants/AM/staff.
SuppressionService + DELETE /dossiers/:numero, cascades DELETE /users et
DELETE /enfants?deleteDossier, flag sans_enfant, specs front #160.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 18:01:44 +02:00
jmartinandCursor c8c9cfbc4d feat(#135): mode édition dossier (PATCH fiche, co-parent, enfants).
Wizard edit famille/AM depuis la liste Dossiers ; save parents +
co-parent ; enfants existants en PATCH (photo multipart) sans POST doublon.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 16:34:12 +02:00
jmartinandCursor ce474797c4 fix(#131): co_parent en réponse parents + masquage secrets user
Contrat API fiche parent : co_parent peuplé (déjà chargé), sans password
ni tokens sur user/co_parent. Doc tmp front→back.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 23:39:30 +02:00
jmartinandCursor ebf794e1ac feat(#131): en-tête fiche parent avec nom et co-parent.
Affiche le prénom/nom du parent en titre et le co-parent en sous-titre ; note handoff back dans archive/temporaires.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 23:37:04 +02:00
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
jmartinandCursor df776d8200 fix(#112): dates et places AM en reprise (resetForReprise)
Corrige l’ombre des paramètres dateOfBirth, agreementDate et placesAvailable
dans resetForReprise ; renforce le parsing reprise-dossier et ajoute un test.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 19:12:14 +02:00
jmartinandCursor c26ed00374 fix(#112): préremplissage AM complet en reprise + lien login repositionné
Parse dates/places/consentement de façon robuste, rafraîchit l'étape 2 AM
et place « J'ai un numéro de dossier » sous « Créer un compte ».

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 17:29:31 +02:00
jmartinandCursor 9d54d9b19b feat(#112): email accusé resoumission aux parents
Après PATCH reprise-resoumettre (parent), envoi à chaque parent du
dossier : confirmation resoumission + rappel numero_dossier.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 17:26:23 +02:00
jmartinandCursor c438009286 feat(#112): lien login « J'ai un numéro de dossier » + reprise-identify
Modale numéro + e-mail, obtention du token puis redirection vers /reprise.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 17:01:18 +02:00
jmartinandCursor 9b7231f1da fix(#112): afficher photos enfants en reprise + consentement cohérent
Charge existingPhotoUrl dans les cartes enfant (étapes 3 et 5) et pré-coche
le consentement photo lorsqu'une photo est déjà en base.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 17:00:15 +02:00
jmartinandCursor f300505225 feat(#112): aligner reprise front sur dossier complet GET/PATCH
Préremplit parents, enfants, motivation et fiche AM depuis reprise-dossier
et envoie le body PATCH complet (co-parent, enfants par id, champs pro AM).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 16:46:51 +02:00
jmartinandCursor 25c10c885a docs(#112): note alignement front reprise dossier complet
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 16:42:35 +02:00
jmartinandCursor d70577b1c3 feat(#112): reprise après refus — dossier complet GET/PATCH
- GET reprise-dossier : parents[], enfants[], texte_motivation (famille) ou fiche AM (#119)
- PATCH reprise-resoumettre : co-parent, enfants (update par id), motivation, fiche AM
- Resoumission famille : tous les parents en en_attente + invalidation token groupée
- DTOs étendus + est_multiple sur enfants dossier famille
- Tests unitaires getRepriseDossier parent/AM

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 16:39:53 +02:00
jmartinandCursor 671da71752 feat(#112): reprise après refus via lien e-mail (/reprise?token=)
Branche le flux front : chargement du dossier, session reprise, wizards
parent/AM préremplis et resoumission via reprise-resoumettre.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 16:28:41 +02:00
jmartinandCursor c226c2fcdf fix(mail): cast SMTP pool options pour build TypeScript Docker
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 16:07:03 +02:00
jmartinandCursor e1684676a7 fix(mail): retry SMTP transient + erreur API si email refus échoue
- Transporteur SMTP réutilisé (pool) au lieu d'une auth par email
- 3 tentatives avec backoff sur erreurs 454/timeout/coupure Dovecot
- refuseUser renvoie 503 si un email de refus échoue après retry
- Tests unitaires isTransientSmtpError

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 22:50:53 +02:00
jmartinandCursor f71943fa79 fix(front): afficher le statut Refusé pour les parents (#110)
_displayStatus refuse → Refusé ; filtre dropdown Parents aligné.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 22:19:29 +02:00
jmartinandCursor 65f10e9ca6 fix(front): refus famille en un seul appel API (#110)
Le back propage le refus et les mails aux co-parents ; évite la 2e requête en erreur.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 22:36:38 +02:00
jmartinandCursor 9eb62ddd13 feat(#110): refus dossier propagé aux co-parents + mail aligné
- `refuseUser` charge tous les parents du même numéro de dossier, leur
  applique le même token_reprise (7 jours), trace une validation refus
  par compte et envoie un mail de refus à chacun. Le refus devient une
  action dossier : peu importe quel parent ouvre ensuite le lien.
- Échec d'envoi mail loggé sans bloquer les autres destinataires.
- Mail de refus aligné sur le look des autres templates (titre/bouton
  verts, bloc motif gestionnaire en encart, échappement HTML cohérent,
  appUrl normalisée).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 19:44:30 +02:00
jmartinandCursor b6e83bf9ef fix(front): SnackBar de confirmation après refus dossier (#110)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 18:56:31 +02:00
jmartinandCursor 919148fa75 fix(front): en mode debug sans API_BASE_URL, cibler le back prod (web inclus)
Évite Uri.base.origin sur flutter run -d chrome qui visait le serveur de dev.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 17:52:54 +02:00
jmartinandCursor 7ba8d51668 feat(front): câbler PATCH refus utilisateur depuis wizards validation (#110)
- UserService.refuseUser → /users/:id/refuser
- AM + famille : envoi motifs, onSuccess + refresh liste
- Famille : refus séquentiel pour chaque parent en_attente
- ValidationRefusForm : état isSubmitting (UX)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 17:38:29 +02:00
jmartin 43dabd1885 merge(master): doc milestone #127 2026-05-04 22:35:14 +02:00
jmartinandCursor a4eee819cd docs(#127): préciser milestone 0.1.0 vs label v0.1.0 (Gitea)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 22:35:12 +02:00
jmartin 3fd4812a07 merge(master): doc #127 v0.1.0 2026-05-04 22:33:46 +02:00
jmartinandCursor 6f39813bb4 docs(#127): statut fermé + label v0.1.0 (alignement Gitea)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 22:33:43 +02:00
jmartin 18f3a7f276 feat(#127): mot de passe oublié — flux complet (API + web + e-mail)
- API publique POST /auth/forgot-password et /auth/reset-password (jeton
  password_reset_*, anti-énumération, e-mail P'titsPas)
- BDD: colonnes password_reset_token / password_reset_expires + index
- Front: écrans /forgot-password, /reset-password, lien login, mutualisation
  formulaires par token
- Template e-mail + alignement couleur; tests unitaires (auth, mail)

Merge depuis develop: branche feature/127.

Made-with: Cursor
2026-04-24 12:53:42 +02:00
jmartin e6a087147a merge(develop): #127 mot de passe oublié — flux complet (API, front, e-mail) 2026-04-24 12:53:32 +02:00
jmartin ca88710e49 fix(mail): aligner la couleur du template reset-password
Made-with: Cursor
2026-04-24 12:05:06 +02:00
jmartin 644ba6c9c9 refactor(front): mutualiser les formulaires token password (#127)
Extrait un composant commun PasswordTokenFormScreen pour les pages create/reset password, conserve les comportements métier via wrappers (verify/create vs reset) et active la soumission sur Entrée dans mot de passe oublié.

Made-with: Cursor
2026-04-24 12:03:43 +02:00
jmartin 15123f691c feat(auth): #127 forgot-password + reset-password API (BDD + mail)
Made-with: Cursor
2026-04-23 18:17:39 +02:00
jmartin e51e625d93 feat(front): mot de passe oublié — écrans et API (#127)
- Routes /forgot-password et /reset-password (distinct de /create-password).
- AuthService : POST forgot-password (réponse neutre côté UX) et reset-password.
- Login : lien « Mot de passe oublié ? » vers la demande de réinitialisation.

Made-with: Cursor
2026-04-23 17:02:59 +02:00
jmartin 7765350d9a merge develop: docs #127 liste tickets 2026-04-23 16:48:22 +02:00
jmartin e2188fbc87 docs(#127): liste tickets + issue Gitea mot de passe oublié; chmod create-gitea-issue.sh
Made-with: Cursor
2026-04-23 16:48:13 +02:00
jmartin 46d0f82f54 feat(#118): création du mot de passe depuis le lien e-mail
Livre le parcours complet « lien e-mail → page web → mot de passe initial » :
- API : vérification du token (comportement neutre si invalide/expiré) et création du mot de passe ; tests associés.
- Front : route /create-password, appels verify-token et create-password, validation alignée sur le backend, retour login après succès.
- Web : stratégie d’URL en path pour que les deep links /create-password?token=… fonctionnent sans redirection vers #/login.

Refs: #118
Made-with: Cursor
2026-04-23 16:45:47 +02:00
jmartin e887562a1c fix(front): activer path URL strategy pour create-password (#118)
Active usePathUrlStrategy pour que les deep links web /create-password?token=... soient correctement résolus sans redirection implicite vers #login, et déclare flutter_web_plugins côté front.

Made-with: Cursor
2026-04-23 12:09:46 +02:00
jmartin 29623f33b9 feat(front): implémenter la page create-password du ticket #118
Ajoute le flux frontend de création initiale du mot de passe via token email (route /create-password, vérification de token et soumission /auth/create-password), avec redirection login en succès et gestion neutre des liens invalides/expirés.

Made-with: Cursor
2026-04-23 11:58:10 +02:00
jmartin 3716dfe611 test(auth): couvrir verify-token/create-password pour le ticket #118
Ajoute des tests unitaires sur le flux API de création de mot de passe: forwarding verify-token, rejet confirmation mismatch et validation du comportement lien invalide/expiré côté service.

Made-with: Cursor
2026-04-23 11:53:34 +02:00
jmartin 29cbbb1d08 feat(auth): align verify-token with #118 invalid-link behavior
Treat missing token on GET /auth/verify-token as the same neutral invalid/expired/used response (404), matching ticket #118 expectations for /create-password flows.

Made-with: Cursor
2026-04-23 11:49:31 +02:00
jmartin dff108c7d5 merge(develop): clôture ticket #28 et stabilisation build/prod
Intègre en un seul commit master les changements de develop : templates email de validation (#28), alignement schéma validations + patches SQL, montée Flutter 3.29 (CI/Docker), correctifs bootstrap web/nginx/API origin et gestion HTTP 201 sur validation dossier famille.

Made-with: Cursor
2026-04-23 11:44:09 +02:00
jmartin e434fe2fbe Merge branch 'feature/28-templates-email-validation' into develop 2026-04-23 11:43:44 +02:00
jmartin 12296c917e merge develop: Flutter 3.29 web, nginx, index bootstrap (build Docker) 2026-04-22 19:10:08 +02:00
jmartin a140db8e9d fix(front): validation dossier famille — accepter HTTP 201 (Nest POST)
NestJS renvoie 201 Created sur POST /parents/:id/valider-dossier.
Le client ne gérait que 200 : la validation et l’envoi d’e-mails étaient déjà effectués mais l’UI affichait une erreur après lecture du corps (liste JSON).

Made-with: Cursor
2026-04-22 19:05:24 +02:00
jmartin 2c746ceff3 fix(front,db,ci): déploiement web, API même origine, Flutter 3.29, schéma validations
Frontend (prod web):
- Image builder Flutter 3.29.3 (Dockerfile + workflow CI alignés)
- web/index.html: bootstrap Flutter 3.29 (flutter_bootstrap.js), scripts pdf.js conservés
- Dockerfile: rm html avant COPY pour éviter l’index « Welcome to nginx »
- nginx: server_name _ pour Traefik
- env: sur le web, API = Uri.base.origin si pas de API_BASE_URL (évite mixed content http/https)
- auth: message d’erreur réseau avec type/détail si non-Exception
- CGU/PDF: commentaire sans mention Flutter 3.19 obsolète
- pubspec.lock: résolution dépendances Flutter 3.29

Base de données & doc:
- BDD.sql: validations.commentaire, valide_par, FK ON DELETE SET NULL + commentaire
- patch 2026-04-17: valide_par avec ON DELETE SET NULL
- patch 2026-04-18: FK validations en SET NULL sur bases existantes
- seed: statut validation « valide » (enum)
- FK_POLICIES.md: valide_par
- doc workflow: Flutter 3.29.3

Made-with: Cursor
2026-04-22 18:48:17 +02:00
jmartin fc86181a73 fix(frontend): CGU/PDF compatible Flutter 3.19 + pdfx 2.6 (build web Docker)
- Color.withOpacity à la place de withValues
- PdfViewPinch sans API absente (documentProgress, minScale)

Made-with: Cursor
2026-04-17 18:57:34 +02:00
jmartin f2daab1325 fix(db): validations — colonnes commentaire et valide_par
- BDD.sql alignée sur l’entité TypeORM
- Patch SQL pour bases existantes + README patches

Made-with: Cursor
2026-04-17 18:49:44 +02:00
jmartin 7590c95f06 refactor(mail): #28 un seul template email parent (principal + co-parent)
Made-with: Cursor
2026-04-17 18:37:47 +02:00
jmartin 94d9428c43 feat(backend): #28 templates email validation compte (Handlebars)
- Templates .hbs parent principal, co-parent, AM + lien create-password
- app_name / app_url / expéditeur via ConfigService (sendEmail)
- validateUser: renouvelle token création MDP + envoi email si sans password
- Dist: assets *.hbs (nest-cli)
- Tests unitaires MailService (mock sendEmail)
- Doc liste tickets #28 terminé

Made-with: Cursor
2026-04-17 18:34:04 +02:00
jmartin 4723c78bbb merge(develop): Android embedding v2 / build web Docker
Made-with: Cursor
2026-04-17 17:50:34 +02:00
jmartin 68efa91c39 fix(frontend): projet Android complet (embedding v2) pour build web Docker
- Régénération android/ (MainActivity, Gradle, manifestes, icônes)
- gradle-wrapper.jar versionné (wrapper requis hors machine locale)
- Ignore local.properties à la racine du dépôt
- pubspec.lock / .metadata alignés Flutter 3.19 (résolution pub get CI)

Made-with: Cursor
2026-04-17 17:49:42 +02:00
jmartin 699d163c6a feat: documents légaux upload stable (#126)
Squash develop: correction FK televersePar, droits Docker / volume
/app/documents/legaux pour l’upload PDF admin.

Made-with: Cursor
2026-04-17 17:35:32 +02:00
jmartin 25b5e0dd93 merge: fix upload documents légaux (#126) 2026-04-17 17:35:16 +02:00
jmartin d91caef0c5 merge(master): intégration develop — ticket #50, documents légaux et validation CGU
Squash merge de la branche develop dans master : un seul commit sur master pour regrouper la livraison liée au ticket #50 (affichage dynamique des CGU et de la politique de confidentialité à l'inscription) ainsi que la documentation et les ajustements associés.

Frontend (P'titsPas) : modale de validation bloquante avec onglets CGU / confidentialité, chargement des PDF depuis l'API des documents légaux actifs, affichage via pdfx (PdfViewPinch et barre de progression latérale sur le web, repli PdfView sous Windows). Branchement dans le flux d'inscription (écran de présentation), enrichissement du UserService et correction des chemins média pour les URL absolues.

Documentation: jeux de fichiers juridiques (markdown et PDF de référence), réorganisation (dossier docs/juridique, archives, renommage du briefing), mises à jour index, liste de tickets et décisions projet. Scripts et métadonnées mineures (Gitea, pubspec, index web).
Made-with: Cursor
2026-04-17 17:33:47 +02:00
jmartin 7381ce356d feat(frontend): #50 — validation CGU et politique de confidentialité
- Modale de validation avec chargement des PDF distants, PdfViewPinch et barre de progression latérale (repli PdfView sous Windows).

- Service documents légaux actifs, correction des URLs média, intégration au formulaire de présentation.

- Documentation juridique et réorganisation (archive, index, tickets).

Made-with: Cursor
2026-04-17 17:28:07 +02:00
jmartin 9acfc31e72 fix(infra): droits écriture /app/documents/legaux pour upload PDF (#126)
Le conteneur tournait en nestjs sans permission sur /app : mkdir sur
/app/documents/legaux échouait → 500 sur POST documents-legaux.
Création + chown dans l’image Docker + volume nommé pour persistance.

Made-with: Cursor
2026-04-17 13:42:35 +02:00
jmartin d8a81a56ae fix(documents-legaux): éviter 500 upload sans utilisateur valide (#126)
Ne plus associer televersePar à un UUID fictif inexistant ; laisser null tant que l’auth n’est pas branchée sur l’endpoint d’upload.

Made-with: Cursor
2026-04-17 13:33:36 +02:00
jmartin 241ce36b85 merge: doc tickets #24/#43 depuis develop 2026-04-16 22:56:47 +02:00
jmartin b0594c32e7 docs(tickets): #24 livré backend, #43 alignement API verify-token/create-password
Made-with: Cursor
2026-04-16 22:56:42 +02:00
jmartin 81ac8860f1 feat(auth): durcir la création du mot de passe via token
Squash de develop vers master pour intégrer verify-token/create-password avec TTL strict, usage unique atomique et invalidation explicite du token.

Made-with: Cursor
2026-04-16 11:33:49 +02:00
jmartin 8105c01501 Merge branch 'feature/123-durcissement-token-creation-mdp' into develop 2026-04-16 11:30:09 +02:00
jmartin 2fa1e4a981 feat(auth): durcir le flux de création de mot de passe par token
Ajoute les endpoints verify-token/create-password avec garde-fous TTL, usage unique atomique et invalidation explicite du token pour sécuriser l'activation initiale des comptes.

Made-with: Cursor
2026-04-16 11:30:09 +02:00
jmartin 0cc6fa3a9d docs: ticket #120 fermé — liste tickets + script clôture Gitea
- 23_LISTE-TICKETS : #120 marqué fermé.
- backend/scripts/close-gitea-issue-120.js : commentaire de livraison + PATCH state closed (réutilisable / traçabilité).

Made-with: Cursor
2026-04-13 17:40:58 +02:00
jmartin 0befd4eb2a feat(admin): validation AM/famille, À valider au survol, nettoyage logs
- À valider : ligne avec survol type AdminUserCard, icône Ouvrir centrée et plus grande (iconSize 34).
- Wizard AM : titres Identité et coordonnées / Dossier professionnel / Présentation ; photo à gauche, grille droite [2,2,2,2] (NIR, naissance, agrément, capacité) ; AppUser champs naissance ; dates dd/MM/yyyy via formatIsoDateFr ; ValidationDetailSection titre optionnel.
- Wizard famille : titre étape 4 « Présentation ».
- Suppression des debugPrint liés médias / images / dossier (api_config, auth_network_image, dossier_unifie, user_service, validation_family).

Made-with: Cursor
2026-04-13 17:22:08 +02:00
jmartin f442c85cb6 fix(auth): persister places_available depuis places_disponibles (inscription AM)
Made-with: Cursor
2026-04-13 16:08:20 +02:00
jmartin ab7845347b merge: résolution conflits frontend + scripts parent (#120)
- Conserver le parcours AM complet (photo obligatoire, consentement, MIME, lieux naissance, focus Tab).

- Scripts parent : chemin tests/ressources/photos unifié.

Made-with: Cursor
2026-04-13 16:02:03 +02:00
jmartin 011ab26828 merge: branche backend inscription AM photo (#120) 2026-04-13 15:59:35 +02:00
jmartin a9ce4a6756 fix(am): ordre Tab formulaire pro et chevrons accessibles
- Ordre de focus explicite (NumericFocusOrder) pour l’inscription AM étape 2 : chaîne des champs jusqu’aux chevrons, Pays → NIR, places → Suivant (10) puis Précédent (11).

- FocusTraversalGroup avec OrderedTraversalPolicy sur le Scaffold pour respecter cet ordre (desktop / web).

- Champ pays : TextInputAction.next, onFieldSubmitted et onEditingComplete vers le NIR ; CustomAppTextField expose onEditingComplete.

- NirTextField : focusNode et focusTraversalOrder optionnels.

Made-with: Cursor
2026-04-13 14:09:21 +02:00
jmartin d550e57cb8 fix(inscription-am): étape 1/2 obligatoires alignées API (min 2 car., photo)
- Étape 1 : prénom/nom min 2 caractères (minPersonNameLength sur PersonalInfoFormScreen pour AM)

- Étape 2 : ville/pays naissance min 2 ; photo réelle + consentement obligatoires ; isStep2Complete strict

- registerAM : lieu naissance trim + toujours envoyer photo_base64 ; lieu_* non null si valide

- Étape 4 : blocage envoi si isRegistrationComplete faux

Made-with: Cursor
2026-04-13 13:32:25 +02:00
jmartin b4b44cb1b9 feat(inscription-am): places disponibles, UI étape 2, plafond capacité 4 côté app
- POST /auth/register/am : places_disponibles, enregistrement place_disponible, contrôle ≤ capacité

- Formulaire pro AM : places sur la ligne capacité, mobile un champ par ligne, carte desktop plus compacte (espacements, polices, labelFieldSpacing)

- Capacité et places validées jusqu’à 4 dans l’app (kAmCapaciteAccueilMax) ; hint NIR « 13 chiffres + clé »

- Scripts d’inscription AM (Node + smoke shell) : places_disponibles

Made-with: Cursor
2026-04-13 13:20:26 +02:00
jmartin 58607cdbc9 feat(inscription AM #120): UI étape pro, photo unifiée, scripts et données test
- Panneau AM : validation NIR alignée API, date d’agrément requise côté app,
  capacité 1–10, n° agrément + date sur une ligne, ville/pays formatés au blur.
- Widget RegistrationPhotoSlot (cadre, croix) partagé avec les cartes enfant.
- AuthService : MIME PNG/JPEG pour la photo ; payload date_agrement.
- Scripts register-am-dubois / mansouri ; chemins tests/ressources/photos ;
  doc test-data + seed ; smoke curl inscription AM.

Made-with: Cursor
2026-04-13 13:19:32 +02:00
jmartin 12bf85b7bd chore: déplacer les scripts d'inscription parent vers tests/scripts
repoRoot corrigé (../..) ; usage: node tests/scripts/register-parent-*.mjs

Made-with: Cursor
2026-04-13 13:19:31 +02:00
jmartin 01c1be8f16 feat(am): inscription photo (picker, bytes web, photo_filename) (#120)
- ProfessionalInfoFormScreen : ImagePicker par défaut, MemoryImage, consentement si photo réelle
- AmRegistrationData : photoBytes + photoFilename ; récap étape 4
- registerAM : base64 + filename, erreurs réseau/JSON comme parents
- docs: 23_LISTE-TICKETS — issue #120

Made-with: Cursor
2026-04-13 13:19:31 +02:00
jmartin cdae9b5f7c feat(am): alignement inscription/validation + BDD init
- Lieu naissance obligatoire (DTO), persistance Users + migration SQL
- BDD.sql: colonnes lieu_naissance_*, tables dossier_famille / dossier_famille_enfants
- inscrireAMComplet: places_available = capacite_accueil
- GET dossier AM: user.lieu_naissance_*, consentement_photo
- docs/TEMP_FRONT_alignement_AM.md pour équipe Flutter
- Parité: email accusé AM, RegisterAmResponseDto (numero_dossier)

Made-with: Cursor
2026-04-13 13:14:38 +02:00
jmartin 73efac482f chore: déplacer les scripts d'inscription parent vers tests/scripts
repoRoot corrigé (../..) ; usage: node tests/scripts/register-parent-*.mjs

Made-with: Cursor
2026-04-13 11:40:41 +02:00
jmartin 62b2466cce feat(am): inscription photo (picker, bytes web, photo_filename) (#120)
- ProfessionalInfoFormScreen : ImagePicker par défaut, MemoryImage, consentement si photo réelle
- AmRegistrationData : photoBytes + photoFilename ; récap étape 4
- registerAM : base64 + filename, erreurs réseau/JSON comme parents
- docs: 23_LISTE-TICKETS — issue #120

Made-with: Cursor
2026-04-12 21:41:19 +02:00
jmartin d69768806c fix(auth): inscription AM — persister photo si photo_base64 sans photo_filename (#120)
- Défaut nom fichier photo_am.jpg ; extension finale dérivée du data-URL
- Swagger DTO + description endpoint register/am

Made-with: Cursor
2026-04-12 21:37:47 +02:00
jmartin eac108c9f4 Merge branch 'master' into develop — alignement avec correctifs master (withOpacity, formatters, script Gitea)
Made-with: Cursor
2026-04-12 21:15:07 +02:00
jmartin 4dedd9b87a fix(web): withOpacity à la place de withValues (Flutter 3.19 / dart2js)
Made-with: Cursor
2026-04-12 21:11:06 +02:00
jmartin 518b3f84df Revert "chore(master): alignement post-squash avec develop (téléphone, script Gitea)"
This reverts commit dc04e04598.
2026-04-11 18:08:36 +02:00
jmartin dc04e04598 chore(master): alignement post-squash avec develop (téléphone, script Gitea)
Made-with: Cursor
2026-04-11 18:08:04 +02:00
jmartin fdd1e06e77 [#101] [Frontend] Inscription parent — API, soumission et validation
Squash merge de develop vers master.

Livrables principaux (ticket #101 et mise au point associée) :
- Branchement du formulaire d'inscription parent sur POST /api/v1/auth/register/parent
- Payload DTO (parents, enfants, photos base64, CGU) et services Auth
- Parcours gestionnaire : cartes dossiers, wizard validation famille, images authentifiées
- Scripts d'inscription test (Martin, Durand/Rousseau, Lecomte) ; .gitignore .cursor/

Inclut également les ajustements develop fusionnés dans ce lot (inscription AM, champs relais, etc.).

Closes #101

Made-with: Cursor
2026-04-11 18:07:24 +02:00
jmartin f1c9db2e7b fix(admin): vignettes enfants du wizard validation (cover, sans fond blanc)
- BoxFit.cover pour remplir le cadre photo
- Suppression du fond blanc du slot, rayon factorisé

Made-with: Cursor
2026-04-11 18:02:52 +02:00
jmartin ccfcfa3287 chore: ignorer le dossier .cursor (config IDE Cursor)
Made-with: Cursor
2026-04-11 18:02:52 +02:00
jmartin dbca12d5d1 scripts: ajout des inscriptions parent test (Martin, Durand/Rousseau, Lecomte)
Scripts Node POST /api/v1/auth/register/parent alignés sur docs/test-data et le seed, avec photos ressources/Photos (réduction JPEG via sharp-cli pour Durand/Rousseau).

Made-with: Cursor
2026-04-11 18:02:52 +02:00
jmartin 4fe29799cd fix(api): CORS + CORP sur réponses statiques /uploads (Flutter web)
- enableCors avant les statics
- Access-Control-Allow-Origin * + Cross-Origin-Resource-Policy sur fichiers
- OPTIONS préflight pour /uploads et /api/v1/uploads

Made-with: Cursor
2026-04-11 17:11:23 +02:00
jmartin 3bfdadd301 fix(front): images validation dossier, traces debug, baseUrl via Env
- ApiConfig.baseUrl dérivé de Env.apiBaseUrl (alignement API / origine médias).
- AuthNetworkImage : uploads publics sans Bearer (CORS web) ; log erreurs en debug.
- debugPrint kDebugMode : GET dossier, EnfantDossier.fromJson, absoluteMediaUrl, carte enfant.
- ParentDossier id depuis user_id ; fallbacks présentation / photo.

Made-with: Cursor
2026-04-11 17:08:34 +02:00
jmartin 09289882d0 fix: photos admin — URL via /api/v1/uploads + static Express
- Nest: servir le volume uploads aussi sous /api/v1/uploads (Traefik /api)
- Flutter: _fullPhotoUrl = base API + chemin /uploads/... (plus d’origine site seul)
- EnfantDossier: tolérer photoUrl camelCase

Made-with: Cursor
2026-04-11 16:47:29 +02:00
jmartin e7f0037ac3 fix: motivation dossier famille à l’inscription + servir /uploads + Traefik
- Inscription parent: créer dossier_famille + dossier_famille_enfants (presentation_dossier)
- Nest: useStaticAssets sur le volume uploads (chemins /uploads/photos/…)
- docker-compose: route Traefik PathPrefix /uploads → API (priorité 18)
- Front: ParentDossier.id depuis user_id (aligné API)

Made-with: Cursor
2026-04-11 16:37:44 +02:00
jmartin 4969be5809 feat(inscription): payload enfant/photo et limite JSON 15 Mo côté API
- Express/Nest : body parser json et urlencoded à 15 Mo pour les photos base64.
- Front : envoi d’inscription parent (authService, payload, étape 3).

Made-with: Cursor
2026-04-11 16:36:15 +02:00
jmartin d7ccbfbe35 feat(api): dossier famille — photo_url et consent_photo par enfant
- DossierFamilleEnfantDto: champs optionnels Swagger
- Mapping getDossierFamilleByNumero + fallback dossier_famille_enfants
- GET dossiers/:num (type family) enrichi via délégation existante

Made-with: Cursor
2026-04-11 16:31:11 +02:00
jmartin 2b3128ce4a fix(api): inscription parent — body 15mb, uploads persistants, 409 si unique PG
- NestExpressApplication + useBodyParser json/urlencoded 15mb
- UPLOAD_PHOTOS_DIR: résolution chemin absolu/relatif + doc .env.example / proxy
- docker-compose: volume backend_uploads + UPLOAD_PHOTOS_DIR par défaut
- Map erreur PostgreSQL 23505 → ConflictException (parent + AM)
- Logs Nest si échec SMTP après inscription (email déjà géré)

Made-with: Cursor
2026-04-10 17:40:23 +02:00
jmartin 077a1ca3ed fix(bdd): genre_type inclut Autre (alignement API inscription enfants)
Made-with: Cursor
2026-03-31 00:08:41 +02:00
jmartin bf05b1d7d7 feat(inscription): email accusé réception, photos enfants, formulaires identité
- Backend: mail après inscription parent avec n° dossier, UPLOAD_PHOTOS_DIR, réponse API
- Frontend: imageBytes + payload photo, utils email/code postal/téléphone, champs dédiés
- Formulaires admin, login (focus), personal_info, child_card, custom_app_text_field

Made-with: Cursor
2026-03-31 00:04:38 +02:00
jmartin 110240b682 feat(frontend): inscription — UI cartes enfant, formatage noms, focus Tab
- Formulaire infos perso : ordre de tabulation explicite, Checkbox Material
  pour le consentement photo, formatage nom/prénom/ville à la perte de focus
  et à la soumission (name_format_utils).
- Carte enfant : cadre photo, croix, ombres, consentement ; formatage
  prénom/nom enfant ; normalisation avant passage étape 4.
- Asset photo_frame.png ; HoverReliefWidget clipBehavior.
- Ajustements payload / modèle / étapes inscription liés au parcours.

Made-with: Cursor
2026-03-28 20:40:27 +01:00
jmartin a723412043 revert(ui): ChildCardWidget identique au commit b18d5c8 (7 fév. 2026)
- Restauration fichier tel que dans feat(frontend): Refonte multi-modes (#78)
- Retrait onGenreChanged (step3/step5) ; genre API: défaut Autre si non renseigné
  jusqu’à nouvelle UI genre

Made-with: Cursor
2026-03-28 18:22:22 +01:00
jmartin 1e21fe25f3 fix(front): genre enfant — pastilles au lieu du dropdown sur fond beige
Le commit cd2bf63 ajoutait un Dropdown dans un bloc bg_beige, lourd sur
les cartes aquarellées. Remplacement par trois choix tactiles discrets
(Garçon / Fille / Autre), espacements alignés sur prénom/nom.

Made-with: Cursor
2026-03-28 18:15:02 +01:00
jmartin cd2bf63b8a feat(front): inscription parent — genre obligatoire H/F/Autre (#101)
- ChildData.genre + copyWith ; liste déroulante sur la carte enfant
- Validation et payload API alignés sur GenreType
- Étape 3 : mises à jour via copyWith (préservation du genre)

Made-with: Cursor
2026-03-28 18:08:32 +01:00
jmartin de60a001cc feat(front): inscription parent — soumission réelle vers POST /auth/register/parent (#101)
- ParentRegistrationPayload : validation client et mapping UserRegistrationData → JSON API
- AuthService.registerParent
- Étape 5 : appel API, chargement, erreurs en dialogue, succès inchangé puis /login

Made-with: Cursor
2026-03-28 18:07:52 +01:00
jmartin 481fa66630 chore(front): supprimer le préremplissage et les données aléatoires à l’inscription
- Parcours AM : plus de jeu de test Marie DUBOIS ni photo factice
- Parcours parent : plus de DataGenerator ; enfants vides à l’ajout
- Suppression de data_generator.dart (inutilisé)

Made-with: Cursor
2026-03-28 18:07:46 +01:00
jmartin cde676c4f9 feat: alignement master sur develop (squash)
- Dossiers unifiés #119, pending-families enrichi, validation admin (wizards)
- Front: modèles dossier_unifie / pending_family, NIR, auth
- Migrations dossier_famille, scripts de test API
- Résolution conflits: parents.*, docs tickets, auth_service, nir_utils

Made-with: Cursor
2026-03-26 00:20:47 +01:00
jmartin 0a07fade40 docs: liste tickets — fermeture #106 #107 #109 #119 (Gitea)
Made-with: Cursor
2026-03-26 00:20:32 +01:00
jmartin 0e8a999e41 Merge branch 'feature/107-onglet-a-valider' into develop
Conflits pending-families : conservation du DTO/SQL develop (date_soumission, emails, parents).

Made-with: Cursor
2026-03-26 00:16:53 +01:00
jmartin 2e02f60601 feat(admin): onglet À valider, dossier unifié et modales de validation
- Onglet « À valider » (AM + familles), pending-families et détail dossier par numéro.
- Wizards validation AM et famille, modale commune, chargement via GET /dossiers/:num.
- UI : cartes enfants, photo AM (cadre uniforme, ratio), NIR affiché formaté (espaces autour du tiret).
- Backend : DTO / routes parents pending ; scripts de test et mise à jour issue Gitea.

Tickets #107, #119.

Made-with: Cursor
2026-03-26 00:15:18 +01:00
jmartin 0e6e473e84 feat: pending-families ajoute parents[] (email/tel/ville/cp) + ordre stable
Made-with: Cursor
2026-03-25 10:58:29 +01:00
jmartin cc3639b19a fix: dossier AM expose texte_motivation (alias biographie) pour le front
Made-with: Cursor
2026-03-23 23:43:00 +01:00
jmartin cf9cd495bf feat: enrichissement GET pending-families (date_soumission, nombre_enfants, emails)
Made-with: Cursor
2026-03-23 23:37:50 +01:00
jmartin 76ba28a7ce fix: dossier famille - un seul texte_motivation, plus de tableau presentation
Made-with: Cursor
2026-03-18 01:56:58 +01:00
jmartin 1772744c81 feat(#119): dossier famille = 1 par famille, N enfants; retrait repas/type_contrat/budget; adresse dans API parents
Made-with: Cursor
2026-03-18 01:52:28 +01:00
jmartin 5465117238 fix: renvoyer le genre des enfants dans le dossier famille (GET dossiers/parents)
Made-with: Cursor
2026-03-18 01:35:27 +01:00
jmartin 6e2343087e feat(#119): GET /dossiers/:numeroDossier unifié AM ou famille (type + dossier)
Made-with: Cursor
2026-03-17 23:11:53 +01:00
jmartin 060e610a75 Merge branch 'develop' (squash) – Reprise après refus #111
Made-with: Cursor
2026-03-12 23:06:04 +01:00
jmartin 7e32eef0a7 Merge branch 'develop' (squash) – Refus sans suppression #110
Made-with: Cursor
2026-03-12 22:57:45 +01:00
jmartin aa4e240ad1 Merge branch 'develop' (squash) – Validation dossier famille #108
Made-with: Cursor
2026-03-12 22:47:41 +01:00
jmartin a92447aaf0 Merge branch 'develop' (squash) – Liste familles en attente #106
Made-with: Cursor
2026-03-12 22:37:41 +01:00
jmartin 94c8a0d97a Merge branch 'develop' (squash) – Statut refusé #105, script Gitea fallback ~/.bashrc
Made-with: Cursor
2026-03-12 22:28:13 +01:00
jmartin af489f39b4 Merge branch 'develop' (squash) – Numéro de dossier #103 et autres avancements
Made-with: Cursor
2026-03-12 22:14:21 +01:00
jmartin aefe590d2c Squash merge develop into master (câblage inscription AM #91)
Made-with: Cursor
2026-02-26 21:21:17 +01:00
jmartin f749484731 test(inscription AM): Préremplissage données de test Marie DUBOIS (squash develop)
Étapes 1 à 3 du formulaire d'inscription AM : données du jeu de test
officiel (03_seed_test_data.sql) au lieu du générateur aléatoire.

Made-with: Cursor
2026-02-26 19:10:58 +01:00
381 changed files with 38394 additions and 4223 deletions
+2
View File
@@ -12,6 +12,7 @@ dist/
.env.*.local
# IDE
.cursor/
.idea/
.vscode/
*.swp
@@ -32,6 +33,7 @@ yarn-error.log*
*.sqlite3
# Flutter
**/android/local.properties
.flutter-plugins
.flutter-plugins-dependencies
.pub-cache/
+8
View File
@@ -22,5 +22,13 @@ JWT_EXPIRATION_TIME=7d
# Environnement
NODE_ENV=development
# Photos inscription (fichiers écrits depuis base64). Préférer un chemin ABSOLU.
# Local : laisser vide → ./uploads/photos (relatif au cwd du processus).
# Docker (docker-compose) : UPLOAD_PHOTOS_DIR=/app/uploads/photos + volume nommé (voir docker-compose.yml).
# UPLOAD_PHOTOS_DIR=
#
# Reverse proxy : si Nginx devant lAPI, augmenter la taille du corps (ex. client_max_body_size 16m;).
# Traefik en reverse proxy simple ne limite en général pas le corps ; si middleware buffering, prévoir ~16 Mo+.
# Log de chaque appel API (mode debug) — mettre à true pour tracer les requêtes front
# LOG_API_REQUESTS=true
+3 -2
View File
@@ -32,8 +32,9 @@ COPY --from=builder /app/dist ./dist
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nestjs -u 1001
# Créer le dossier uploads et donner les permissions
RUN mkdir -p /app/uploads/photos && chown -R nestjs:nodejs /app/uploads
# Dossiers écriture runtime (nestjs non-root) : photos + documents légaux (PDF)
RUN mkdir -p /app/uploads/photos /app/documents/legaux && \
chown -R nestjs:nodejs /app/uploads /app/documents
USER nestjs
+7 -1
View File
@@ -3,6 +3,12 @@
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
"deleteOutDir": true,
"assets": [
{
"include": "**/*.hbs",
"watchAssets": true
}
]
}
}
+179 -20
View File
@@ -22,8 +22,11 @@
"bcryptjs": "^3.0.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"handlebars": "^4.7.8",
"joi": "^18.0.0",
"mapped-types": "^0.0.1",
"multer": "^1.4.5-lts.1",
"nodemailer": "^6.9.16",
"passport-jwt": "^4.0.1",
"pg": "^8.16.3",
"reflect-metadata": "^0.2.2",
@@ -39,8 +42,11 @@
"@nestjs/testing": "^11.0.1",
"@types/bcrypt": "^6.0.0",
"@types/express": "^5.0.0",
"@types/handlebars": "^4.1.0",
"@types/jest": "^30.0.0",
"@types/multer": "^1.4.12",
"@types/node": "^22.10.7",
"@types/nodemailer": "^6.4.16",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2",
"eslint": "^9.18.0",
@@ -2462,6 +2468,82 @@
"@nestjs/core": "^11.0.0"
}
},
"node_modules/@nestjs/platform-express/node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/@nestjs/platform-express/node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@nestjs/platform-express/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@nestjs/platform-express/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@nestjs/platform-express/node_modules/multer": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"mkdirp": "^0.5.6",
"object-assign": "^4.1.1",
"type-is": "^1.6.18",
"xtend": "^4.0.2"
},
"engines": {
"node": ">= 10.16.0"
}
},
"node_modules/@nestjs/platform-express/node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@nestjs/schematics": {
"version": "11.0.7",
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.0.7.tgz",
@@ -3613,6 +3695,17 @@
"@types/send": "*"
}
},
"node_modules/@types/handlebars": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@types/handlebars/-/handlebars-4.1.0.tgz",
"integrity": "sha512-gq9YweFKNNB1uFK71eRqsd4niVkXrxHugqWFQkeLRJvGjnxsLr16bYtcsG4tOFwmYi0Bax+wCkbf1reUfdl4kA==",
"deprecated": "This is a stub types definition. handlebars provides its own type definitions, so you do not need this installed.",
"dev": true,
"license": "MIT",
"dependencies": {
"handlebars": "*"
}
},
"node_modules/@types/http-errors": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
@@ -3688,6 +3781,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/multer": {
"version": "1.4.13",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.13.tgz",
"integrity": "sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/mysql": {
"version": "2.15.27",
"resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.27.tgz",
@@ -3706,6 +3809,16 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/nodemailer": {
"version": "6.4.23",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.23.tgz",
"integrity": "sha512-aFV3/NsYFLSx9mbb5gtirBSXJnAlrusoKNuPbxsASWc7vrKLmIrTQRpdcxNcSFL3VW2A2XpeLEavwb2qMi6nlQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/passport": {
"version": "1.0.17",
"resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz",
@@ -5578,20 +5691,56 @@
"license": "MIT"
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
"integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
"engines": [
"node >= 6.0"
"node >= 0.8"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"readable-stream": "^2.2.2",
"typedarray": "^0.0.6"
}
},
"node_modules/concat-stream/node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/concat-stream/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/concat-stream/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/concat-stream/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/consola": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
@@ -5658,7 +5807,6 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"dev": true,
"license": "MIT"
},
"node_modules/cors": {
@@ -7004,7 +7152,6 @@
"version": "4.7.8",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
"integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"minimist": "^1.2.5",
@@ -7026,7 +7173,6 @@
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -8889,21 +9035,22 @@
"license": "MIT"
},
"node_modules/multer": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
"version": "1.4.5-lts.2",
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz",
"integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==",
"deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"mkdirp": "^0.5.6",
"busboy": "^1.0.0",
"concat-stream": "^1.5.2",
"mkdirp": "^0.5.4",
"object-assign": "^4.1.1",
"type-is": "^1.6.18",
"xtend": "^4.0.2"
"type-is": "^1.6.4",
"xtend": "^4.0.0"
},
"engines": {
"node": ">= 10.16.0"
"node": ">= 6.0.0"
}
},
"node_modules/multer/node_modules/media-typer": {
@@ -8995,7 +9142,6 @@
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"dev": true,
"license": "MIT"
},
"node_modules/node-abort-controller": {
@@ -9049,6 +9195,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/nodemailer": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -9706,6 +9861,12 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -11534,7 +11695,6 @@
"version": "3.19.3",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
"integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==",
"dev": true,
"license": "BSD-2-Clause",
"optional": true,
"bin": {
@@ -12013,7 +12173,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
"dev": true,
"license": "MIT"
},
"node_modules/wrap-ansi": {
+7 -1
View File
@@ -19,7 +19,8 @@
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
"test:e2e": "jest --config ./test/jest-e2e.json",
"test:api-dossiers": "node scripts/test-api-dossiers.js"
},
"dependencies": {
"@nestjs/common": "^11.1.6",
@@ -35,6 +36,7 @@
"bcryptjs": "^3.0.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"handlebars": "^4.7.8",
"joi": "^18.0.0",
"mapped-types": "^0.0.1",
"multer": "^1.4.5-lts.1",
@@ -54,6 +56,7 @@
"@nestjs/testing": "^11.0.1",
"@types/bcrypt": "^6.0.0",
"@types/express": "^5.0.0",
"@types/handlebars": "^4.1.0",
"@types/jest": "^30.0.0",
"@types/multer": "^1.4.12",
"@types/node": "^22.10.7",
@@ -86,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])
+107
View File
@@ -0,0 +1,107 @@
/**
* Commentaire de clôture + fermeture issue Gitea #120.
* Usage: node backend/scripts/close-gitea-issue-120.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, '../..');
const ISSUE = 120;
const REPO = 'jmartin/petitspas';
const body = `## Fermeture ticket #120 — livré sur \`develop\`
Branche **\`feature/120-inscription-am-photo\`** mergée dans **\`develop\`** (livraison : inscription AM alignée parents + panneau validation gestionnaire).
### Inscription AM (alignement parents)
- Photo, consentement, lieux de naissance : parcours et API alignés sur le modèle parents (DTO, entité user, migration SQL, écrans inscription AM, \`registration_photo_slot\`, scripts de test Node).
### Panneau gestionnaire — onglet « À valider »
- Bouton **Ouvrir** : visible au **survol** (même principe que les cartes admin), **icône centrée** sur la ligne et **taille doublée** (\`iconSize\` 34).
### Wizard validation dossier AM
- Titres : **Identité et coordonnées** · **Dossier professionnel** (au-dessus des champs à droite, pas de la photo) · **Présentation**.
- **Photo** à gauche (ratio identité 35×45) ; **grille droite** \`[2,2,2,2]\` : NIR | date de naissance, ville | pays de naissance, n° agrément | date d'agrément, capacité | places.
- **\`AppUser\`** : \`date_naissance\`, \`lieu_naissance_ville\`, \`lieu_naissance_pays\` ; affichage dates **\`dd/MM/yyyy\`** (\`formatIsoDateFr\`).
- **\`ValidationDetailSection\`** : titre **optionnel** (wizard AM).
### Wizard validation famille
- Étape 4 : titre **« Présentation »** (plus « Présentation / Motivation »).
### Nettoyage
- Suppression des **\`debugPrint\`** liés aux médias / images / chargement dossier (\`api_config\`, \`auth_network_image\`, \`dossier_unifie\`, \`user_service\`, carte enfant validation).
---
*Issue fermée après merge sur \`develop\` et recette.*`;
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);
}
})();
+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();
@@ -1,7 +1,8 @@
/**
* Crée l'issue Gitea "[Frontend] Inscription Parent Branchement soumission formulaire à l'API"
* Usage: node backend/scripts/create-gitea-issue-parent-api.js
* Token : .gitea-token (racine du dépôt), sinon GITEA_TOKEN, sinon docs/BRIEFING-FRONTEND.md (voir PROCEDURE-API-GITEA.md)
* Token : .gitea-token (racine du dépôt), sinon GITEA_TOKEN, sinon
* docs/27_BRIEFING-FRONTEND.md (voir docs/26_GITEA-API.md)
*/
const https = require('https');
const fs = require('fs');
@@ -19,13 +20,18 @@ if (!token) {
}
if (!token) {
try {
const briefing = fs.readFileSync(path.join(repoRoot, 'docs/BRIEFING-FRONTEND.md'), 'utf8');
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é : créer .gitea-token à la racine ou export GITEA_TOKEN (voir docs/PROCEDURE-API-GITEA.md)');
console.error(
'Token non trouvé : créer .gitea-token à la racine ou export GITEA_TOKEN (voir docs/26_GITEA-API.md)',
);
process.exit(1);
}
+5 -2
View File
@@ -1,6 +1,6 @@
/**
* Liste toutes les issues Gitea (ouvertes + fermées) pour jmartin/petitspas.
* Token : .gitea-token (racine), GITEA_TOKEN, ou docs/BRIEFING-FRONTEND.md
* Token : .gitea-token (racine), GITEA_TOKEN, ou docs/27_BRIEFING-FRONTEND.md
*/
const https = require('https');
const fs = require('fs');
@@ -16,7 +16,10 @@ if (!token) {
}
if (!token) {
try {
const briefing = fs.readFileSync(path.join(repoRoot, 'docs/BRIEFING-FRONTEND.md'), 'utf8');
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 (_) {}
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env node
/**
* Test API GET /dossiers/:numeroDossier (dossier unifié AM ou famille).
*
* Prérequis : backend démarré (npm run start:dev dans backend/).
*
* Usage:
* node scripts/test-api-dossiers.js
* NUMERO_DOSSIER=2026-000001 node scripts/test-api-dossiers.js
* BASE_URL=https://app.ptits-pas.fr/api/v1 TEST_EMAIL=xxx TEST_PASSWORD=yyy NUMERO_DOSSIER=2026-000001 node scripts/test-api-dossiers.js
*
* Sans TEST_EMAIL/TEST_PASSWORD : 401 sur les routes protégées.
* NUMERO_DOSSIER : optionnel ; si absent, utilise le premier numero_dossier de pending-families (avec token).
*/
const BASE_URL = process.env.BASE_URL || 'http://localhost:3000/api/v1';
const TEST_EMAIL = process.env.TEST_EMAIL;
const TEST_PASSWORD = process.env.TEST_PASSWORD;
const NUMERO_DOSSIER = process.env.NUMERO_DOSSIER;
async function request(method, path, body = null, token = null) {
const url = path.startsWith('http') ? path : `${BASE_URL}${path}`;
const opts = {
method,
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
};
if (token) opts.headers.Authorization = `Bearer ${token}`;
if (body) opts.body = JSON.stringify(body);
const res = await fetch(url, opts);
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch (_) {
data = text;
}
return { status: res.status, data };
}
async function main() {
console.log('Base URL:', BASE_URL);
console.log('Numéro dossier (env):', NUMERO_DOSSIER ?? '(sera déduit si token fourni)');
console.log('');
let token = null;
if (TEST_EMAIL && TEST_PASSWORD) {
console.log('1. Login...');
const loginRes = await request('POST', '/auth/login', {
email: TEST_EMAIL,
password: TEST_PASSWORD,
});
if (loginRes.status !== 200 && loginRes.status !== 201) {
console.log(' Échec login:', loginRes.status, loginRes.data);
process.exit(1);
}
token = loginRes.data?.access_token ?? loginRes.data?.accessToken ?? null;
if (!token) {
console.log(' Réponse login sans token:', JSON.stringify(loginRes.data, null, 2));
process.exit(1);
}
console.log(' OK, token reçu.');
console.log('');
} else {
console.log('TEST_EMAIL / TEST_PASSWORD non définis : GET /dossiers/:numero nécessite un token (401 attendu).');
console.log('');
}
let numeroDossier = NUMERO_DOSSIER;
if (!numeroDossier && token) {
console.log('2. Récupération d\'un numéro de dossier (GET /parents/pending-families)...');
const pendingRes = await request('GET', '/parents/pending-families', null, token);
if (pendingRes.status === 200 && Array.isArray(pendingRes.data) && pendingRes.data.length > 0) {
numeroDossier = pendingRes.data[0].numero_dossier || null;
console.log(' Premier numero_dossier:', numeroDossier);
} else {
console.log(' Aucune famille en attente ou erreur. Utilisez NUMERO_DOSSIER=2026-000001');
}
console.log('');
}
if (!numeroDossier) {
numeroDossier = '2026-000001';
console.log('2. Pas de numéro fourni, test avec numéro par défaut:', numeroDossier);
} else {
console.log('2. GET /dossiers/' + encodeURIComponent(numeroDossier));
}
const dossierRes = await request(
'GET',
'/dossiers/' + encodeURIComponent(numeroDossier),
null,
token
);
console.log(' Status:', dossierRes.status);
if (dossierRes.status === 200 && dossierRes.data) {
const d = dossierRes.data;
console.log(' type:', d.type);
console.log(' dossier (clés):', d.dossier ? Object.keys(d.dossier) : '-');
if (d.dossier && Array.isArray(d.dossier.enfants)) {
console.log(' enfants:', d.dossier.enfants.length);
d.dossier.enfants.forEach((e, i) => {
console.log(
` [${i + 1}] id=${e.id} first_name=${e.first_name} last_name=${e.last_name} birth_date=${e.birth_date} gender=${e.gender} genre=${e.genre} status=${e.status}`
);
});
}
console.log('');
console.log('Réponse brute (dossier):');
console.log(JSON.stringify(d.dossier, null, 2));
} else {
console.log(' Réponse:', JSON.stringify(dossierRes.data, null, 2));
}
console.log('');
console.log('Fin du test.');
}
main().catch((err) => {
console.error('Erreur:', err.message || err);
process.exit(1);
});
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env node
/**
* Test des endpoints "comptes en attente" (ticket #107).
*
* Prérequis : backend démarré (npm run start:dev dans backend/).
*
* Usage:
* node scripts/test-pending-api.js
* TEST_EMAIL=xxx TEST_PASSWORD=yyy node scripts/test-pending-api.js
* BASE_URL=https://app.ptits-pas.fr/api/v1 TEST_EMAIL=xxx TEST_PASSWORD=yyy node scripts/test-pending-api.js
*
* Sans TEST_EMAIL/TEST_PASSWORD : les GET protégés renverront 401 (normal).
* Avec un compte gestionnaire ou admin : affiche les listes en attente.
*/
const BASE_URL = process.env.BASE_URL || 'http://localhost:3000/api/v1';
const TEST_EMAIL = process.env.TEST_EMAIL;
const TEST_PASSWORD = process.env.TEST_PASSWORD;
async function request(method, path, body = null, token = null) {
const url = path.startsWith('http') ? path : `${BASE_URL}${path}`;
const opts = {
method,
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
};
if (token) opts.headers.Authorization = `Bearer ${token}`;
if (body) opts.body = JSON.stringify(body);
const res = await fetch(url, opts);
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch (_) {
data = text;
}
return { status: res.status, data };
}
async function main() {
console.log('Base URL:', BASE_URL);
console.log('');
let token = null;
if (TEST_EMAIL && TEST_PASSWORD) {
console.log('1. Login...');
const loginRes = await request('POST', '/auth/login', {
email: TEST_EMAIL,
password: TEST_PASSWORD,
});
if (loginRes.status !== 200 && loginRes.status !== 201) {
console.log(' Échec login:', loginRes.status, loginRes.data);
process.exit(1);
}
token = loginRes.data?.access_token ?? loginRes.data?.accessToken ?? null;
if (!token) {
console.log(' Réponse login sans token:', JSON.stringify(loginRes.data, null, 2));
process.exit(1);
}
console.log(' OK, token reçu.');
console.log('');
} else {
console.log('TEST_EMAIL / TEST_PASSWORD non définis : les appels protégés vont renvoyer 401.');
console.log('Exemple: TEST_EMAIL=admin@example.com TEST_PASSWORD=xxx node scripts/test-pending-api.js');
console.log('');
}
console.log('2. GET /users/pending?role=assistante_maternelle');
const pendingUsersRes = await request(
'GET',
'/users/pending?role=assistante_maternelle',
null,
token
);
console.log(' Status:', pendingUsersRes.status);
if (pendingUsersRes.status === 200) {
const list = Array.isArray(pendingUsersRes.data) ? pendingUsersRes.data : [];
console.log(' Nombre d\'utilisateurs en attente (AM):', list.length);
list.forEach((u, i) => {
console.log(
` [${i + 1}] id=${u.id} email=${u.email} role=${u.role} statut=${u.statut} numero_dossier=${u.numero_dossier ?? '-'}`
);
});
} else {
console.log(' Réponse:', JSON.stringify(pendingUsersRes.data, null, 2));
}
console.log('');
console.log('3. GET /parents/pending-families');
const pendingFamiliesRes = await request('GET', '/parents/pending-families', null, token);
console.log(' Status:', pendingFamiliesRes.status);
if (pendingFamiliesRes.status === 200) {
const list = Array.isArray(pendingFamiliesRes.data) ? pendingFamiliesRes.data : [];
console.log(' Nombre de familles en attente:', list.length);
list.forEach((f, i) => {
console.log(
` [${i + 1}] libelle=${f.libelle} parentIds=${JSON.stringify(f.parentIds)} numero_dossier=${f.numero_dossier ?? '-'}`
);
});
} else {
console.log(' Réponse:', JSON.stringify(pendingFamiliesRes.data, null, 2));
}
console.log('');
console.log('Fin du test.');
}
main().catch((err) => {
console.error('Erreur:', err.message || err);
process.exit(1);
});
+19 -14
View File
@@ -1,27 +1,32 @@
#!/bin/bash
# Test POST /auth/register/am (ticket #90)
# Inscription AM complète : jeux officiels alignés sur database/seed/03_seed_test_data.sql
# node tests/scripts/register-am-dubois-test.mjs [BASE_URL]
# node tests/scripts/register-am-mansouri-test.mjs [BASE_URL]
#
# Smoke curl (email + NIR jetables, hors seed — ne pas mélanger avec Marie / Fatima) :
# Usage: ./scripts/test-register-am.sh [BASE_URL]
# Exemple: ./scripts/test-register-am.sh https://app.ptits-pas.fr/api/v1
# ./scripts/test-register-am.sh http://localhost:3000/api/v1
# Exemple: ./scripts/test-register-am.sh http://localhost:3000/api/v1
BASE_URL="${1:-http://localhost:3000/api/v1}"
echo "Testing POST $BASE_URL/auth/register/am"
echo "POST $BASE_URL/auth/register/am (profil smoke, pas les AM du seed)"
echo "---"
curl -s -w "\n\nHTTP %{http_code}\n" -X POST "$BASE_URL/auth/register/am" \
-H "Content-Type: application/json" \
-d '{
"email": "marie.dupont.test@ptits-pas.fr",
"prenom": "Marie",
"nom": "DUPONT",
"email": "smoke.am.curl@ptits-pas.fr",
"prenom": "Smoke",
"nom": "CURLTEST",
"telephone": "0612345678",
"adresse": "1 rue Test",
"code_postal": "75001",
"ville": "Paris",
"consentement_photo": true,
"nir": "123456789012345",
"numero_agrement": "AGR-2024-001",
"capacite_accueil": 4,
"adresse": "1 rue Smoke",
"code_postal": "95870",
"ville": "Bezons",
"consentement_photo": false,
"date_naissance": "1986-12-15",
"nir": "186127500100279",
"numero_agrement": "AGR-SMOKE-CURL-001",
"capacite_accueil": 3,
"places_disponibles": 2,
"acceptation_cgu": true,
"acceptation_privacy": true
}'
@@ -0,0 +1,100 @@
/**
* Met à jour l'issue Gitea #119 : endpoint unifié GET /dossiers/:numeroDossier (option A)
* Usage: node backend/scripts/update-gitea-issue-119-dossiers.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é');
process.exit(1);
}
const body = `## Besoin
Un **seul** endpoint **GET par numéro de dossier** qui renvoie le dossier complet, **AM ou famille** selon le numéro. Clé unique = numéro de dossier (usage : modale de validation, consultation gestionnaire, reprise, etc.).
**Option A Endpoint unifié**
- **Route** : \`GET /api/v1/dossiers/:numeroDossier\` (ou \`GET /dossiers/:numeroDossier\` selon préfixe API).
- Le backend détermine si le numéro appartient à une **AM** ou à une **famille** (ex. lookup \`users\` / \`parents\` / \`assistantes_maternelles\`).
- **Réponse** avec discriminent :
- \`{ type: 'family', dossier: { numero_dossier, parents, enfants, presentation } }\`
- \`{ type: 'am', dossier: { numero_dossier, user, ... } }\` (fiche AM complète, champs utiles sans secrets)
- **Rôles** : SUPER_ADMIN, ADMINISTRATEUR, GESTIONNAIRE.
- **Réponses** : 200 (dossier), 403, 404 (numéro inconnu).
Aucun filtre par statut : on renvoie le dossier s'il existe ; le front affiche Valider/Refuser selon le statut.
**Labels suggérés** : backend, api, dossiers, gestionnaire
---
## Implémentation
- **Nouveau module ou route** : \`GET /dossiers/:numeroDossier\`.
- **Service** : trouver qui possède ce \`numero_dossier\` (famille → \`parents\`, AM → \`users\` + \`assistantes_maternelles\`). Appeler la logique existante dossier-famille ou construire le payload AM, puis retourner \`{ type, dossier }\`.
- **Réutiliser** : la logique actuelle \`GET /parents/dossier-famille/:numeroDossier\` peut être appelée en interne pour \`type: 'family'\` ; ajouter une branche \`type: 'am'\` avec un DTO « dossier AM complet ».
- DTO(s) : garder \`DossierFamilleCompletDto\` pour la famille ; ajouter un DTO pour le dossier AM (user sans secrets + infos AM). Réponse unifiée : \`{ type: 'am' | 'family', dossier: ... }\`.`;
const payload = JSON.stringify({
title: 'Endpoint unifié GET /dossiers/:numeroDossier (AM ou famille)',
body,
});
const opts = {
hostname: 'git.ptits-pas.fr',
path: '/api/v1/repos/jmartin/petitspas/issues/119',
method: 'PATCH',
headers: {
Authorization: 'token ' + token,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
},
};
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 || o.id) {
console.log('Issue #119 mise à jour.');
console.log('URL:', o.html_url || 'https://git.ptits-pas.fr/jmartin/petitspas/issues/119');
} else {
console.error('Erreur API:', o.message || d);
process.exit(1);
}
} catch (e) {
console.error('Réponse:', d);
process.exit(1);
}
});
});
req.on('error', (e) => {
console.error(e);
process.exit(1);
});
req.write(payload);
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();
+8
View File
@@ -16,7 +16,11 @@ import { AllExceptionsFilter } from './common/filters/all_exceptions.filters';
import { EnfantsModule } from './routes/enfants/enfants.module';
import { AppConfigModule } from './modules/config/config.module';
import { DocumentsLegauxModule } from './modules/documents-legaux';
import { AbsencesGardeModule } from './modules/absences-garde';
import { CardsModule } from './modules/cards';
import { RelaisModule } from './routes/relais/relais.module';
import { DossiersModule } from './routes/dossiers/dossiers.module';
import { SuppressionsModule } from './routes/suppressions/suppressions.module';
@Module({
imports: [
@@ -54,7 +58,11 @@ import { RelaisModule } from './routes/relais/relais.module';
AuthModule,
AppConfigModule,
DocumentsLegauxModule,
AbsencesGardeModule,
CardsModule,
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,88 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { AmChildren } from './am_children.entity';
import { Users } from './users.entity';
export enum TypeAbsenceGardeType {
ABSENCE_ENFANT = 'absence_enfant',
CONGE_AM = 'conge_am',
ARRET_MALADIE_AM = 'arret_maladie_am',
}
export enum StatutAbsenceGardeType {
EN_ATTENTE = 'en_attente',
ACCEPTE = 'accepte',
REFUSE = 'refuse',
}
/**
* Vérité métier des absences / congés / arrêts par couple AM↔enfant.
* Les cartes (module ultérieur) collectent ; cette table stocke.
* Ticket #193.
*/
@Entity('absences_garde', { schema: 'public' })
export class AbsencesGarde {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'id_placement', type: 'uuid' })
id_placement: string;
@ManyToOne(() => AmChildren, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'id_placement', referencedColumnName: 'id' })
placement: AmChildren;
@Column({
type: 'enum',
enum: TypeAbsenceGardeType,
enumName: 'type_absence_garde_type',
name: 'type',
})
type: TypeAbsenceGardeType;
@Column({ name: 'date_debut', type: 'date' })
date_debut: string;
@Column({ name: 'date_fin', type: 'date' })
date_fin: string;
@Column({
type: 'enum',
enum: StatutAbsenceGardeType,
enumName: 'statut_absence_garde_type',
name: 'statut',
default: StatutAbsenceGardeType.EN_ATTENTE,
})
statut: StatutAbsenceGardeType;
/** Purge auto pour en_attente/refuse ; accepte → infinity. */
@Column({ name: 'expire_at', type: 'timestamptz' })
expire_at: Date;
@Column({ name: 'cree_par', type: 'uuid', nullable: true })
cree_par?: string;
@ManyToOne(() => Users, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'cree_par', referencedColumnName: 'id' })
createdBy?: Users;
/** Réf. carte de collecte (FK quand module Cartes existera). */
@Column({ name: 'id_card_instance', type: 'uuid', nullable: true })
id_card_instance?: string;
@Column({ name: 'motif', type: 'text', nullable: true })
motif?: string;
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
cree_le: Date;
@UpdateDateColumn({ name: 'modifie_le', type: 'timestamptz' })
modifie_le: Date;
}
@@ -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[];
}
@@ -0,0 +1,35 @@
import {
Column,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { CardInstance } from './card_instances.entity';
import { Users } from './users.entity';
@Entity('card_audience_members', { schema: 'public' })
export class CardAudienceMember {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'id_card', type: 'uuid' })
id_card: string;
@ManyToOne(() => CardInstance, (c) => c.audience, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'id_card', referencedColumnName: 'id' })
card: CardInstance;
@Column({ name: 'id_utilisateur', type: 'uuid' })
id_utilisateur: string;
@ManyToOne(() => Users, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'id_utilisateur', referencedColumnName: 'id' })
user: Users;
@Column({ name: 'role_snapshot', type: 'varchar', length: 64 })
role_snapshot: string;
@Column({ name: 'is_creator', type: 'boolean', default: false })
is_creator: boolean;
}
@@ -0,0 +1,97 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { AmChildren } from './am_children.entity';
import { AbsencesGarde } from './absences_garde.entity';
import { Users } from './users.entity';
import { CardType } from './card_types.entity';
import { CardAudienceMember } from './card_audience_members.entity';
import { CardResponse } from './card_responses.entity';
export enum CardInstanceStatutType {
OUVERTE = 'ouverte',
REFUSEE = 'refusee',
TRAITEE = 'traitee',
}
export enum CardOperationType {
CREATE = 'create',
UPDATE = 'update',
}
@Entity('card_instances', { schema: 'public' })
export class CardInstance {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'type_code', type: 'varchar', length: 64 })
type_code: string;
@ManyToOne(() => CardType, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'type_code', referencedColumnName: 'code' })
type: CardType;
@Column({ name: 'id_placement', type: 'uuid' })
id_placement: string;
@ManyToOne(() => AmChildren, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'id_placement', referencedColumnName: 'id' })
placement: AmChildren;
@Column({ name: 'id_absence', type: 'uuid', nullable: true })
id_absence?: string;
@ManyToOne(() => AbsencesGarde, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'id_absence', referencedColumnName: 'id' })
absence?: AbsencesGarde;
@Column({ name: 'cree_par', type: 'uuid', nullable: true })
cree_par?: string;
@ManyToOne(() => Users, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'cree_par', referencedColumnName: 'id' })
createdBy?: Users;
@Column({
type: 'enum',
enum: CardOperationType,
enumName: 'card_operation_type',
name: 'operation',
default: CardOperationType.CREATE,
})
operation: CardOperationType;
@Column({
type: 'enum',
enum: CardInstanceStatutType,
enumName: 'card_instance_statut_type',
name: 'statut',
default: CardInstanceStatutType.OUVERTE,
})
statut: CardInstanceStatutType;
@Column({ name: 'payload', type: 'jsonb', default: {} })
payload: Record<string, unknown>;
@Column({ name: 'purge_at', type: 'timestamptz' })
purge_at: Date;
@OneToMany(() => CardAudienceMember, (m) => m.card)
audience: CardAudienceMember[];
@OneToMany(() => CardResponse, (r) => r.card)
responses: CardResponse[];
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
cree_le: Date;
@UpdateDateColumn({ name: 'modifie_le', type: 'timestamptz' })
modifie_le: Date;
}
@@ -0,0 +1,50 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { CardInstance } from './card_instances.entity';
import { Users } from './users.entity';
export enum CardResponseActionType {
ACCEPT = 'accept',
REFUSE = 'refuse',
ACK = 'ack',
}
@Entity('card_responses', { schema: 'public' })
export class CardResponse {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'id_card', type: 'uuid' })
id_card: string;
@ManyToOne(() => CardInstance, (c) => c.responses, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'id_card', referencedColumnName: 'id' })
card: CardInstance;
@Column({ name: 'id_utilisateur', type: 'uuid' })
id_utilisateur: string;
@ManyToOne(() => Users, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'id_utilisateur', referencedColumnName: 'id' })
user: Users;
@Column({
type: 'enum',
enum: CardResponseActionType,
enumName: 'card_response_action_type',
name: 'action',
})
action: CardResponseActionType;
@Column({ name: 'comment', type: 'text', nullable: true })
comment?: string;
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
cree_le: Date;
}
+54
View File
@@ -0,0 +1,54 @@
import {
Column,
CreateDateColumn,
Entity,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
export enum CardResponseModeType {
NONE = 'none',
ACK = 'ack',
ACCEPT_REFUSE = 'accept_refuse',
}
@Entity('card_types', { schema: 'public' })
export class CardType {
@PrimaryColumn({ name: 'code', type: 'varchar', length: 64 })
code: string;
@Column({ name: 'system', type: 'boolean', default: true })
system: boolean;
@Column({ name: 'titre', type: 'varchar', length: 120 })
titre: string;
@Column({ name: 'emitter_roles', type: 'text', array: true })
emitter_roles: string[];
@Column({ name: 'recipient_roles', type: 'text', array: true })
recipient_roles: string[];
@Column({ name: 'audience_resolver', type: 'varchar', length: 64 })
audience_resolver: string;
@Column({
type: 'enum',
enum: CardResponseModeType,
enumName: 'card_response_mode_type',
name: 'response_mode',
})
response_mode: CardResponseModeType;
@Column({ name: 'retention_days', type: 'int', default: 14 })
retention_days: number;
@Column({ name: 'couleur', type: 'varchar', length: 32, nullable: true })
couleur?: string;
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
cree_le: Date;
@UpdateDateColumn({ name: 'modifie_le', type: 'timestamptz' })
modifie_le: Date;
}
+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[];
@@ -0,0 +1,65 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
OneToMany,
CreateDateColumn,
UpdateDateColumn,
JoinColumn,
} from 'typeorm';
import { Parents } from './parents.entity';
import { Children } from './children.entity';
import { StatutDossierType } from './dossiers.entity';
/** Un dossier = une famille, N enfants (texte de motivation unique, liste d'enfants). */
@Entity('dossier_famille')
export class DossierFamille {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'numero_dossier', length: 20 })
numero_dossier: string;
@ManyToOne(() => Parents, { onDelete: 'CASCADE', nullable: false })
@JoinColumn({ name: 'id_parent', referencedColumnName: 'user_id' })
parent: Parents;
@Column({ type: 'text', nullable: true })
presentation?: string;
@Column({
type: 'enum',
enum: StatutDossierType,
enumName: 'statut_dossier_type',
default: StatutDossierType.ENVOYE,
name: 'statut',
})
statut: StatutDossierType;
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
cree_le: Date;
@UpdateDateColumn({ name: 'modifie_le', type: 'timestamptz' })
modifie_le: Date;
@OneToMany(() => DossierFamilleEnfant, (dfe) => dfe.dossier_famille)
enfants: DossierFamilleEnfant[];
}
@Entity('dossier_famille_enfants')
export class DossierFamilleEnfant {
@Column({ name: 'id_dossier_famille', primary: true })
id_dossier_famille: string;
@Column({ name: 'id_enfant', primary: true })
id_enfant: string;
@ManyToOne(() => DossierFamille, (df) => df.enfants, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'id_dossier_famille' })
dossier_famille: DossierFamille;
@ManyToOne(() => Children, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'id_enfant' })
enfant: Children;
}
-79
View File
@@ -1,79 +0,0 @@
import { Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";
import { Children } from "./children.entity";
import { Users } from "./users.entity";
import { Parents } from "./parents.entity";
export enum TypeEvenementType {
ABSENCE_ENFANT = 'absence_enfant',
CONGE_AM = 'conge_am',
CONGE_PARENT = 'conge_parent',
ARRET_MALADIE_AM = 'arret_maladie_am',
EVENEMENT_RPE = 'evenement_rpe',
}
export enum StatutEvenementType {
PROPOSE = 'propose',
VALIDE = 'valide',
REFUSE = 'refuse',
}
@Entity('evenements')
export class Evenement {
// Define your columns and relationships here
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({
type: 'enum',
enum: TypeEvenementType,
enumName: 'type_evenement_type',
name: 'type'
})
type: TypeEvenementType;
@ManyToOne(() => Children, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'id_enfant', referencedColumnName: 'id' })
child?: Children;
@ManyToOne(() => Users, { nullable: true })
@JoinColumn({ name: 'id_am', referencedColumnName: 'id' })
assistanteMaternelle?: Users;
@ManyToOne(() => Parents, { nullable: true })
@JoinColumn({ name: 'id_parent', referencedColumnName: 'user_id' })
parent?: Parents;
@ManyToOne(() => Users, { nullable: true })
@JoinColumn({ name: 'cree_par', referencedColumnName: 'id' })
created_by?: Users;
@Column({ type: 'timestamptz', nullable: true, name: 'date_debut' })
start_date?: Date;
@Column({ type: 'timestamptz', nullable: true, name: 'date_fin' })
end_date?: Date;
@Column({ type: 'text', nullable: true, name: 'commentaires' })
comments?: string;
@Column({
type: 'enum',
enum: StatutEvenementType,
enumName: 'statut_evenement_type',
name: 'statut',
default: StatutEvenementType.PROPOSE
})
status: StatutEvenementType;
@Column({type: 'timestamptz', nullable: true, name: 'delai_grace'})
grace_deadline?: Date;
@Column({type: 'boolean', default: false, name: 'urgent'})
urgent: boolean;
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
created_at: Date;
@UpdateDateColumn({ name: 'modifie_le', type: 'timestamptz' })
updated_at: Date;
}
+12
View File
@@ -5,6 +5,7 @@ import {
import { Users } from './users.entity';
import { ParentsChildren } from './parents_children.entity';
import { Dossier } from './dossiers.entity';
import { AmChildren } from './am_children.entity';
@Entity('parents', { schema: 'public' })
export class Parents {
@@ -25,6 +26,17 @@ export class Parents {
@Column({ name: 'numero_dossier', length: 20, nullable: true })
numero_dossier?: string;
/**
* Placement AM↔enfant sélectionné sur le TdB parent (couple actif) — ticket #168.
* Null / absent = le front prend le premier couple actif retourné par lAPI.
*/
@Column({ name: 'id_placement_garde_courant', type: 'uuid', nullable: true })
id_placement_garde_courant?: string;
@ManyToOne(() => AmChildren, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'id_placement_garde_courant', referencedColumnName: 'id' })
placement_garde_courant?: AmChildren;
// Lien vers enfants via la table enfants_parents
@OneToMany(() => ParentsChildren, pc => pc.parent)
parentChildren: ParentsChildren[];
+13
View File
@@ -119,6 +119,13 @@ export class Users {
@Column({ type: 'timestamptz', nullable: true, name: 'token_creation_mdp_expire_le' })
token_creation_mdp_expire_le?: Date;
/** Ticket #127 — réinitialisation mot de passe oublié (distinct de token_creation_mdp / inscription) */
@Column({ nullable: true, name: 'password_reset_token', length: 255 })
password_reset_token?: string;
@Column({ type: 'timestamptz', nullable: true, name: 'password_reset_expires' })
password_reset_expires?: Date;
/** Token pour reprise après refus (lien email), ticket #110 */
@Column({ nullable: true, name: 'token_reprise', length: 255 })
token_reprise?: string;
@@ -138,6 +145,12 @@ export class Users {
@Column({ name: 'date_naissance', type: 'date', nullable: true })
date_naissance?: Date;
@Column({ name: 'lieu_naissance_ville', length: 100, nullable: true })
lieu_naissance_ville?: string;
@Column({ name: 'lieu_naissance_pays', length: 100, nullable: true })
lieu_naissance_pays?: string;
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
cree_le: Date;
+67 -6
View File
@@ -1,26 +1,87 @@
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { AppModule } from './app.module';
import { ConfigService } from '@nestjs/config';
import { SwaggerModule } from '@nestjs/swagger/dist/swagger-module';
import { DocumentBuilder } from '@nestjs/swagger';
import { ValidationPipe } from '@nestjs/common';
import { LogRequestInterceptor } from './common/interceptors/log-request.interceptor';
import * as path from 'path';
import * as express from 'express';
/** GET/HEAD photos : CORS + CORP pour Flutter web (cross-origin `Image.network`). Pas de cookie sur ces URLs. */
function setStaticImageCorsHeaders(res: express.Response): void {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
}
const staticImageServeOptions = {
index: false,
fallthrough: true,
setHeaders: (res: express.Response) => setStaticImageCorsHeaders(res),
};
/** Préflight si le navigateur interroge OPTIONS sur les médias. */
function uploadsCorsPreflight(
req: express.Request,
res: express.Response,
next: express.NextFunction,
): void {
if (req.method === 'OPTIONS') {
setStaticImageCorsHeaders(res);
res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
res.setHeader('Access-Control-Max-Age', '86400');
res.status(204).end();
return;
}
next();
}
/** Répertoire disque contenant le dossier `photos` (ex. /app/uploads si photos dans /app/uploads/photos). */
function resolveUploadsFilesystemRoot(): string {
const raw = process.env.UPLOAD_PHOTOS_DIR?.trim();
const photosDir = raw
? path.isAbsolute(raw)
? raw
: path.resolve(process.cwd(), raw)
: path.join(process.cwd(), 'uploads', 'photos');
return path.dirname(photosDir);
}
async function bootstrap() {
const app = await NestFactory.create(AppModule,
{ logger: ['error', 'warn', 'log', 'debug', 'verbose'] });
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
logger: ['error', 'warn', 'log', 'debug', 'verbose'],
});
// Log de chaque appel API si LOG_API_REQUESTS=true (mode debug)
app.useGlobalInterceptors(new LogRequestInterceptor());
// Inscription (photos base64 dans le JSON) : sans limite > défaut Express (~100 ko) → 413/500 ou corps tronqué.
app.useBodyParser('json', { limit: '15mb' });
app.useBodyParser('urlencoded', { extended: true, limit: '15mb' });
// Configuration CORS pour autoriser les requêtes depuis localhost (dev) et production
// CORS global **avant** les fichiers statiques pour que les réponses API et idéalement la chaîne Express restent cohérentes.
app.enableCors({
origin: true, // Autorise toutes les origines (dev) - à restreindre en prod
origin: true, // Reflète lOrigin (dev / prod) — routes API + cookies JWT
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'Accept'],
credentials: true,
});
const expressApp = app.getHttpAdapter().getInstance();
const uploadsRoot = resolveUploadsFilesystemRoot();
// Photos : chemins stockés en base type /uploads/photos/...
// En-têtes explicites sur les réponses fichier (les statics peuvent répondre sans repasser par la même couche que les contrôleurs).
expressApp.use('/uploads', uploadsCorsPreflight);
expressApp.use('/api/v1/uploads', uploadsCorsPreflight);
app.useStaticAssets(uploadsRoot, {
prefix: '/uploads/',
...staticImageServeOptions,
});
expressApp.use('/api/v1/uploads', express.static(uploadsRoot, staticImageServeOptions));
// Log de chaque appel API si LOG_API_REQUESTS=true (mode debug)
app.useGlobalInterceptors(new LogRequestInterceptor());
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
@@ -0,0 +1,73 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AbsencesGardeController } from './absences-garde.controller';
import { AbsencesGardeService } from './absences-garde.service';
import { AuthGuard } from 'src/common/guards/auth.guard';
import { RolesGuard } from 'src/common/guards/roles.guard';
import { RoleType } from 'src/entities/users.entity';
import { TypeAbsenceGardeType } from 'src/entities/absences_garde.entity';
describe('AbsencesGardeController (#172)', () => {
let controller: AbsencesGardeController;
const serviceMock = {
lister: jest.fn(),
creer: jest.fn(),
maj: jest.fn(),
supprimer: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AbsencesGardeController],
providers: [{ provide: AbsencesGardeService, useValue: serviceMock }],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(RolesGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get(AbsencesGardeController);
jest.clearAllMocks();
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
it('lister délègue au service', async () => {
serviceMock.lister.mockResolvedValue({ items: [] });
const res = await controller.lister(
'u1',
RoleType.PARENT,
'pl-1',
undefined,
undefined,
'2026-01-01',
'2026-12-31',
);
expect(serviceMock.lister).toHaveBeenCalledWith('u1', RoleType.PARENT, {
placementId: 'pl-1',
type: undefined,
statut: undefined,
from: '2026-01-01',
to: '2026-12-31',
});
expect(res.items).toEqual([]);
});
it('creer délègue au service', async () => {
serviceMock.creer.mockResolvedValue({ id: 'a1' });
const dto = {
id_placement: 'pl-1',
type: TypeAbsenceGardeType.ABSENCE_ENFANT,
date_debut: '2026-10-01',
date_fin: '2026-10-02',
};
await controller.creer('u1', RoleType.PARENT, dto);
expect(serviceMock.creer).toHaveBeenCalledWith(
'u1',
RoleType.PARENT,
dto,
);
});
});
@@ -0,0 +1,124 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiBody,
ApiOperation,
ApiQuery,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { AuthGuard } from 'src/common/guards/auth.guard';
import { RolesGuard } from 'src/common/guards/roles.guard';
import { Roles } from 'src/common/decorators/roles.decorator';
import { User } from 'src/common/decorators/user.decorator';
import {
StatutAbsenceGardeType,
TypeAbsenceGardeType,
} from 'src/entities/absences_garde.entity';
import { RoleType } from 'src/entities/users.entity';
import { AbsencesGardeService } from './absences-garde.service';
import {
AbsenceGardeDto,
CreerAbsenceGardeDto,
ListeAbsencesGardeDto,
MajAbsenceGardeDto,
} from './dto/absences-garde.dto';
@ApiTags('Absences garde')
@ApiBearerAuth('access-token')
@Controller('absences-garde')
@UseGuards(AuthGuard, RolesGuard)
export class AbsencesGardeController {
constructor(private readonly absencesGardeService: AbsencesGardeService) {}
@Get()
@Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE)
@ApiOperation({
summary: 'Lister les absences / congés / arrêts — ticket #172',
description:
'Sans placementId : tous les placements du user (parent = famille multi-AM ; AM = tous accueils). ' +
'Avec placementId : un couple enfantAM.',
})
@ApiQuery({ name: 'placementId', required: false })
@ApiQuery({ name: 'type', required: false, enum: TypeAbsenceGardeType })
@ApiQuery({ name: 'statut', required: false, enum: StatutAbsenceGardeType })
@ApiQuery({ name: 'from', required: false, description: 'YYYY-MM-DD' })
@ApiQuery({ name: 'to', required: false, description: 'YYYY-MM-DD' })
@ApiResponse({ status: 200, type: ListeAbsencesGardeDto })
lister(
@User('id') userId: string,
@User('role') role: RoleType,
@Query('placementId') placementId?: string,
@Query('type') type?: TypeAbsenceGardeType,
@Query('statut') statut?: StatutAbsenceGardeType,
@Query('from') from?: string,
@Query('to') to?: string,
): Promise<ListeAbsencesGardeDto> {
return this.absencesGardeService.lister(userId, role, {
placementId,
type,
statut,
from,
to,
});
}
@Post()
@Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE)
@ApiOperation({ summary: 'Créer une période dabsence / congé / arrêt — #172' })
@ApiBody({ type: CreerAbsenceGardeDto })
@ApiResponse({ status: 201, type: AbsenceGardeDto })
@HttpCode(HttpStatus.CREATED)
creer(
@User('id') userId: string,
@User('role') role: RoleType,
@Body() dto: CreerAbsenceGardeDto,
): Promise<AbsenceGardeDto> {
return this.absencesGardeService.creer(userId, role, dto);
}
@Patch(':id')
@Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE)
@ApiOperation({
summary: 'Mettre à jour dates / statut / motif — #172',
description:
'Parent : accept/refuse congé (motivation si refus), ack arrêt. ' +
'AM : modifier dates (en attente / refuse / accepté), republication après refus.',
})
@ApiBody({ type: MajAbsenceGardeDto })
@ApiResponse({ status: 200, type: AbsenceGardeDto })
maj(
@User('id') userId: string,
@User('role') role: RoleType,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: MajAbsenceGardeDto,
): Promise<AbsenceGardeDto> {
return this.absencesGardeService.maj(userId, role, id, dto);
}
@Delete(':id')
@Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Supprimer une absence (hard delete) — #172' })
@ApiResponse({ status: 204 })
async supprimer(
@User('id') userId: string,
@User('role') role: RoleType,
@Param('id', ParseUUIDPipe) id: string,
): Promise<void> {
await this.absencesGardeService.supprimer(userId, role, id);
}
}
@@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AbsencesGarde } from 'src/entities/absences_garde.entity';
import { AmChildren } from 'src/entities/am_children.entity';
import { ParentsChildren } from 'src/entities/parents_children.entity';
import { AbsencesGardeController } from './absences-garde.controller';
import { AbsencesGardeService } from './absences-garde.service';
@Module({
imports: [
TypeOrmModule.forFeature([AbsencesGarde, AmChildren, ParentsChildren]),
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
secret: config.get('jwt.accessSecret'),
signOptions: { expiresIn: config.get('jwt.accessExpiresIn') },
}),
inject: [ConfigService],
}),
],
controllers: [AbsencesGardeController],
providers: [AbsencesGardeService],
exports: [AbsencesGardeService],
})
export class AbsencesGardeModule {}
@@ -0,0 +1,168 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { ForbiddenException, BadRequestException } from '@nestjs/common';
import { AbsencesGardeService } from './absences-garde.service';
import {
AbsencesGarde,
StatutAbsenceGardeType,
TypeAbsenceGardeType,
} from 'src/entities/absences_garde.entity';
import { AmChildren } from 'src/entities/am_children.entity';
import { ParentsChildren } from 'src/entities/parents_children.entity';
import { RoleType } from 'src/entities/users.entity';
describe('AbsencesGardeService (#172)', () => {
let service: AbsencesGardeService;
const absencesRepo = {
create: jest.fn((x) => x),
save: jest.fn(async (x) => ({
...x,
id: x.id ?? 'abs-1',
cree_le: new Date(),
modifie_le: new Date(),
})),
findOne: jest.fn(),
delete: jest.fn(),
createQueryBuilder: jest.fn(),
};
const amChildrenRepo = {
find: jest.fn(),
findOne: jest.fn(),
};
const parentsChildrenRepo = {
find: jest.fn(),
findOne: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AbsencesGardeService,
{ provide: getRepositoryToken(AbsencesGarde), useValue: absencesRepo },
{ provide: getRepositoryToken(AmChildren), useValue: amChildrenRepo },
{
provide: getRepositoryToken(ParentsChildren),
useValue: parentsChildrenRepo,
},
],
}).compile();
service = module.get(AbsencesGardeService);
jest.clearAllMocks();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('parent crée absence_enfant → statut accepte', async () => {
amChildrenRepo.findOne.mockResolvedValue({
id: 'pl-1',
amId: 'am-1',
enfantId: 'e-1',
date_fin: null,
});
parentsChildrenRepo.findOne.mockResolvedValue({
parentId: 'p-1',
enfantId: 'e-1',
});
absencesRepo.findOne.mockResolvedValue({
id: 'abs-1',
id_placement: 'pl-1',
type: TypeAbsenceGardeType.ABSENCE_ENFANT,
date_debut: '2026-10-01',
date_fin: '2026-10-02',
statut: StatutAbsenceGardeType.ACCEPTE,
expire_at: new Date('9999-12-31'),
cree_le: new Date(),
modifie_le: new Date(),
placement: {
enfantId: 'e-1',
amId: 'am-1',
child: { id: 'e-1', first_name: 'Léo' },
am: { user: { prenom: 'Marie', nom: 'AM' } },
},
});
const res = await service.creer('p-1', RoleType.PARENT, {
id_placement: 'pl-1',
type: TypeAbsenceGardeType.ABSENCE_ENFANT,
date_debut: '2026-10-01',
date_fin: '2026-10-02',
});
expect(absencesRepo.save).toHaveBeenCalled();
const savedArg = absencesRepo.save.mock.calls[0][0];
expect(savedArg.statut).toBe(StatutAbsenceGardeType.ACCEPTE);
expect(res.type).toBe(TypeAbsenceGardeType.ABSENCE_ENFANT);
});
it('parent ne peut pas créer un congé AM', async () => {
amChildrenRepo.findOne.mockResolvedValue({
id: 'pl-1',
amId: 'am-1',
enfantId: 'e-1',
date_fin: null,
});
parentsChildrenRepo.findOne.mockResolvedValue({
parentId: 'p-1',
enfantId: 'e-1',
});
await expect(
service.creer('p-1', RoleType.PARENT, {
id_placement: 'pl-1',
type: TypeAbsenceGardeType.CONGE_AM,
date_debut: '2026-10-01',
date_fin: '2026-10-05',
}),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('refus congé sans motif → BadRequest', async () => {
absencesRepo.findOne.mockResolvedValue({
id: 'abs-1',
id_placement: 'pl-1',
type: TypeAbsenceGardeType.CONGE_AM,
date_debut: '2026-10-01',
date_fin: '2026-10-05',
statut: StatutAbsenceGardeType.EN_ATTENTE,
expire_at: new Date(),
});
amChildrenRepo.findOne.mockResolvedValue({
id: 'pl-1',
amId: 'am-1',
enfantId: 'e-1',
date_fin: null,
});
parentsChildrenRepo.findOne.mockResolvedValue({
parentId: 'p-1',
enfantId: 'e-1',
});
await expect(
service.maj('p-1', RoleType.PARENT, 'abs-1', {
statut: StatutAbsenceGardeType.REFUSE,
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it('dates invalides → BadRequest', async () => {
amChildrenRepo.findOne.mockResolvedValue({
id: 'pl-1',
amId: 'am-1',
enfantId: 'e-1',
date_fin: null,
});
parentsChildrenRepo.findOne.mockResolvedValue({
parentId: 'p-1',
enfantId: 'e-1',
});
await expect(
service.creer('p-1', RoleType.PARENT, {
id_placement: 'pl-1',
type: TypeAbsenceGardeType.ABSENCE_ENFANT,
date_debut: '2026-10-10',
date_fin: '2026-10-01',
}),
).rejects.toBeInstanceOf(BadRequestException);
});
});
@@ -0,0 +1,421 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, IsNull, Repository } from 'typeorm';
import {
AbsencesGarde,
StatutAbsenceGardeType,
TypeAbsenceGardeType,
} from 'src/entities/absences_garde.entity';
import { AmChildren } from 'src/entities/am_children.entity';
import { ParentsChildren } from 'src/entities/parents_children.entity';
import { RoleType } from 'src/entities/users.entity';
import {
AbsenceGardeDto,
CreerAbsenceGardeDto,
ListeAbsencesGardeDto,
MajAbsenceGardeDto,
} from './dto/absences-garde.dto';
const TTL_EN_ATTENTE_MS = 15 * 24 * 60 * 60 * 1000;
const TTL_REFUSE_MS = 7 * 24 * 60 * 60 * 1000;
/** Sentinel « pas de purge » pour les périodes acceptées */
const EXPIRE_ACCEPTE = new Date('9999-12-31T23:59:59.999Z');
@Injectable()
export class AbsencesGardeService {
constructor(
@InjectRepository(AbsencesGarde)
private readonly absencesRepo: Repository<AbsencesGarde>,
@InjectRepository(AmChildren)
private readonly amChildrenRepo: Repository<AmChildren>,
@InjectRepository(ParentsChildren)
private readonly parentsChildrenRepo: Repository<ParentsChildren>,
) {}
async lister(
userId: string,
role: RoleType,
opts: {
placementId?: string;
type?: TypeAbsenceGardeType;
statut?: StatutAbsenceGardeType;
from?: string;
to?: string;
},
): Promise<ListeAbsencesGardeDto> {
const placementIds = await this.resolvePlacementIds(
userId,
role,
opts.placementId,
);
if (placementIds.length === 0) {
return { items: [] };
}
const qb = this.absencesRepo
.createQueryBuilder('ag')
.leftJoinAndSelect('ag.placement', 'placement')
.leftJoinAndSelect('placement.child', 'child')
.leftJoinAndSelect('placement.am', 'am')
.leftJoinAndSelect('am.user', 'amUser')
.where('ag.id_placement IN (:...placementIds)', { placementIds })
.orderBy('ag.date_debut', 'DESC');
if (opts.type) {
qb.andWhere('ag.type = :type', { type: opts.type });
}
if (opts.statut) {
qb.andWhere('ag.statut = :statut', { statut: opts.statut });
}
if (opts.from) {
qb.andWhere('ag.date_fin >= :from', { from: opts.from });
}
if (opts.to) {
qb.andWhere('ag.date_debut <= :to', { to: opts.to });
}
const rows = await qb.getMany();
return { items: rows.map((r) => this.toDto(r)) };
}
async creer(
userId: string,
role: RoleType,
dto: CreerAbsenceGardeDto,
): Promise<AbsenceGardeDto> {
this.assertDates(dto.date_debut, dto.date_fin);
await this.assertCanAccessPlacement(userId, role, dto.id_placement);
this.assertCanCreateType(role, dto.type);
const statut = this.statutInitial(dto.type);
const entity = this.absencesRepo.create({
id_placement: dto.id_placement,
type: dto.type,
date_debut: dto.date_debut,
date_fin: dto.date_fin,
statut,
expire_at: this.expireAtFor(statut),
cree_par: userId,
motif: dto.motif?.trim() || undefined,
});
const saved = await this.absencesRepo.save(entity);
return this.getByIdForUser(saved.id, userId, role);
}
async maj(
userId: string,
role: RoleType,
id: string,
dto: MajAbsenceGardeDto,
): Promise<AbsenceGardeDto> {
const row = await this.absencesRepo.findOne({ where: { id } });
if (!row) {
throw new NotFoundException('Absence introuvable');
}
await this.assertCanAccessPlacement(userId, role, row.id_placement);
if (dto.date_debut !== undefined || dto.date_fin !== undefined) {
const debut = dto.date_debut ?? row.date_debut;
const fin = dto.date_fin ?? row.date_fin;
this.assertDates(debut, fin);
// Parent : peut modifier ses absences enfant
// AM : peut modifier congé/arrêt en_attente (avant accept) ou dates si créateur
this.assertCanEditDates(role, row);
row.date_debut = debut;
row.date_fin = fin;
if (row.statut === StatutAbsenceGardeType.EN_ATTENTE) {
row.expire_at = this.expireAtFor(StatutAbsenceGardeType.EN_ATTENTE);
}
}
if (dto.statut !== undefined && dto.statut !== row.statut) {
this.assertCanChangeStatut(role, row, dto.statut, dto.motif);
if (
dto.statut === StatutAbsenceGardeType.REFUSE &&
(!dto.motif || !dto.motif.trim())
) {
throw new BadRequestException(
'Une motivation est obligatoire en cas de refus',
);
}
row.statut = dto.statut;
row.expire_at = this.expireAtFor(dto.statut);
if (dto.motif?.trim()) {
row.motif = dto.motif.trim();
}
} else if (dto.motif !== undefined) {
row.motif = dto.motif.trim() || undefined;
}
await this.absencesRepo.save(row);
return this.getByIdForUser(id, userId, role);
}
async supprimer(
userId: string,
role: RoleType,
id: string,
): Promise<void> {
const row = await this.absencesRepo.findOne({ where: { id } });
if (!row) {
throw new NotFoundException('Absence introuvable');
}
await this.assertCanAccessPlacement(userId, role, row.id_placement);
if (
role === RoleType.PARENT &&
row.type !== TypeAbsenceGardeType.ABSENCE_ENFANT
) {
throw new ForbiddenException(
'Un parent ne peut supprimer que les absences enfant',
);
}
await this.absencesRepo.delete({ id });
}
private async getByIdForUser(
id: string,
userId: string,
role: RoleType,
): Promise<AbsenceGardeDto> {
const row = await this.absencesRepo.findOne({
where: { id },
relations: ['placement', 'placement.child', 'placement.am', 'placement.am.user'],
});
if (!row) {
throw new NotFoundException('Absence introuvable');
}
await this.assertCanAccessPlacement(userId, role, row.id_placement);
return this.toDto(row);
}
private statutInitial(type: TypeAbsenceGardeType): StatutAbsenceGardeType {
if (type === TypeAbsenceGardeType.ABSENCE_ENFANT) {
return StatutAbsenceGardeType.ACCEPTE;
}
return StatutAbsenceGardeType.EN_ATTENTE;
}
private expireAtFor(statut: StatutAbsenceGardeType): Date {
const now = Date.now();
if (statut === StatutAbsenceGardeType.ACCEPTE) {
return EXPIRE_ACCEPTE;
}
if (statut === StatutAbsenceGardeType.REFUSE) {
return new Date(now + TTL_REFUSE_MS);
}
return new Date(now + TTL_EN_ATTENTE_MS);
}
private assertDates(debut: string, fin: string): void {
if (fin < debut) {
throw new BadRequestException(
'date_fin doit être supérieure ou égale à date_debut',
);
}
}
private assertCanCreateType(role: RoleType, type: TypeAbsenceGardeType): void {
if (role === RoleType.PARENT) {
if (type !== TypeAbsenceGardeType.ABSENCE_ENFANT) {
throw new ForbiddenException(
'Un parent ne peut créer que des absences enfant',
);
}
return;
}
if (role === RoleType.ASSISTANTE_MATERNELLE) {
if (
type !== TypeAbsenceGardeType.CONGE_AM &&
type !== TypeAbsenceGardeType.ARRET_MALADIE_AM
) {
throw new ForbiddenException(
'Une AM ne peut créer que congé ou arrêt maladie',
);
}
return;
}
throw new ForbiddenException('Rôle non autorisé à créer une absence');
}
private assertCanEditDates(
role: RoleType,
row: AbsencesGarde,
): void {
if (role === RoleType.PARENT) {
if (row.type !== TypeAbsenceGardeType.ABSENCE_ENFANT) {
throw new ForbiddenException(
'Un parent ne peut modifier que les absences enfant',
);
}
return;
}
if (role === RoleType.ASSISTANTE_MATERNELLE) {
if (
row.type === TypeAbsenceGardeType.CONGE_AM ||
row.type === TypeAbsenceGardeType.ARRET_MALADIE_AM
) {
if (
row.statut !== StatutAbsenceGardeType.EN_ATTENTE &&
row.statut !== StatutAbsenceGardeType.REFUSE &&
row.statut !== StatutAbsenceGardeType.ACCEPTE
) {
throw new ForbiddenException('Statut incompatible avec une modification');
}
// Accepté : autorisé (S2b — re-validation via cartes plus tard ; API permet update dates)
return;
}
throw new ForbiddenException('Type non modifiable par lAM');
}
throw new ForbiddenException('Modification non autorisée');
}
private assertCanChangeStatut(
role: RoleType,
row: AbsencesGarde,
next: StatutAbsenceGardeType,
_motif?: string,
): void {
if (role === RoleType.PARENT) {
// Accept / refuse congé ; ack arrêt (accepte)
if (
row.type === TypeAbsenceGardeType.CONGE_AM ||
row.type === TypeAbsenceGardeType.ARRET_MALADIE_AM
) {
if (
next !== StatutAbsenceGardeType.ACCEPTE &&
next !== StatutAbsenceGardeType.REFUSE
) {
throw new BadRequestException('Transition de statut invalide');
}
if (
row.type === TypeAbsenceGardeType.ARRET_MALADIE_AM &&
next === StatutAbsenceGardeType.REFUSE
) {
throw new BadRequestException(
'Un arrêt maladie ne se refuse pas (accusé seulement)',
);
}
if (row.statut !== StatutAbsenceGardeType.EN_ATTENTE) {
throw new BadRequestException('Cette demande nest plus en attente');
}
return;
}
throw new ForbiddenException(
'Pas de changement de statut sur ce type pour un parent',
);
}
if (role === RoleType.ASSISTANTE_MATERNELLE) {
// Remise en attente après refus OU modification dun congé déjà accepté (S2b)
if (
next === StatutAbsenceGardeType.EN_ATTENTE &&
(row.statut === StatutAbsenceGardeType.REFUSE ||
row.statut === StatutAbsenceGardeType.ACCEPTE)
) {
return;
}
throw new ForbiddenException(
'LAM ne valide pas elle-même (sauf republication / modification)',
);
}
throw new ForbiddenException('Changement de statut non autorisé');
}
private async resolvePlacementIds(
userId: string,
role: RoleType,
placementId?: string,
): Promise<string[]> {
if (placementId) {
await this.assertCanAccessPlacement(userId, role, placementId);
return [placementId];
}
if (role === RoleType.PARENT) {
const liens = await this.parentsChildrenRepo.find({
where: { parentId: userId },
select: ['enfantId'],
});
const enfantIds = liens.map((l) => l.enfantId);
if (enfantIds.length === 0) return [];
const placements = await this.amChildrenRepo.find({
where: { enfantId: In(enfantIds), date_fin: IsNull() },
select: ['id'],
});
return placements.map((p) => p.id);
}
if (role === RoleType.ASSISTANTE_MATERNELLE) {
const placements = await this.amChildrenRepo.find({
where: { amId: userId, date_fin: IsNull() },
select: ['id'],
});
return placements.map((p) => p.id);
}
throw new ForbiddenException('Rôle non autorisé');
}
private async assertCanAccessPlacement(
userId: string,
role: RoleType,
placementId: string,
): Promise<AmChildren> {
const placement = await this.amChildrenRepo.findOne({
where: { id: placementId, date_fin: IsNull() },
});
if (!placement) {
throw new NotFoundException('Placement / couple introuvable ou inactif');
}
if (role === RoleType.ASSISTANTE_MATERNELLE) {
if (placement.amId !== userId) {
throw new ForbiddenException('Ce placement ne vous appartient pas');
}
return placement;
}
if (role === RoleType.PARENT) {
const lien = await this.parentsChildrenRepo.findOne({
where: { parentId: userId, enfantId: placement.enfantId },
});
if (!lien) {
throw new ForbiddenException(
'Ce placement ne concerne pas un de vos enfants',
);
}
return placement;
}
throw new ForbiddenException('Rôle non autorisé');
}
private toDto(row: AbsencesGarde): AbsenceGardeDto {
const child = row.placement?.child;
const amUser = row.placement?.am?.user;
return {
id: row.id,
id_placement: row.id_placement,
type: row.type,
date_debut: row.date_debut,
date_fin: row.date_fin,
statut: row.statut,
expire_at: row.expire_at?.toISOString?.() ?? String(row.expire_at),
cree_par: row.cree_par ?? null,
motif: row.motif ?? null,
id_enfant: child?.id ?? row.placement?.enfantId ?? null,
prenom_enfant: child?.first_name ?? null,
id_am: row.placement?.amId ?? null,
prenom_am: amUser?.prenom ?? null,
nom_am: amUser?.nom ?? null,
cree_le: row.cree_le?.toISOString?.() ?? String(row.cree_le),
modifie_le: row.modifie_le?.toISOString?.() ?? String(row.modifie_le),
};
}
}
@@ -0,0 +1,119 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsDateString,
IsEnum,
IsOptional,
IsString,
IsUUID,
MaxLength,
MinLength,
} from 'class-validator';
import {
StatutAbsenceGardeType,
TypeAbsenceGardeType,
} from 'src/entities/absences_garde.entity';
export class CreerAbsenceGardeDto {
@ApiProperty({ description: 'Placement AM↔enfant (couple)' })
@IsUUID()
id_placement: string;
@ApiProperty({ enum: TypeAbsenceGardeType })
@IsEnum(TypeAbsenceGardeType)
type: TypeAbsenceGardeType;
@ApiProperty({ example: '2026-10-01' })
@IsDateString()
date_debut: string;
@ApiProperty({ example: '2026-10-05' })
@IsDateString()
date_fin: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(2000)
motif?: string;
}
export class MajAbsenceGardeDto {
@ApiPropertyOptional({ example: '2026-10-02' })
@IsOptional()
@IsDateString()
date_debut?: string;
@ApiPropertyOptional({ example: '2026-10-06' })
@IsOptional()
@IsDateString()
date_fin?: string;
@ApiPropertyOptional({ enum: StatutAbsenceGardeType })
@IsOptional()
@IsEnum(StatutAbsenceGardeType)
statut?: StatutAbsenceGardeType;
@ApiPropertyOptional({
description: 'Motivation de refus (parent) ou motif créateur',
})
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(2000)
motif?: string;
}
export class AbsenceGardeDto {
@ApiProperty()
id: string;
@ApiProperty()
id_placement: string;
@ApiProperty({ enum: TypeAbsenceGardeType })
type: TypeAbsenceGardeType;
@ApiProperty()
date_debut: string;
@ApiProperty()
date_fin: string;
@ApiProperty({ enum: StatutAbsenceGardeType })
statut: StatutAbsenceGardeType;
@ApiProperty()
expire_at: string;
@ApiPropertyOptional()
cree_par?: string | null;
@ApiPropertyOptional()
motif?: string | null;
@ApiPropertyOptional()
id_enfant?: string | null;
@ApiPropertyOptional()
prenom_enfant?: string | null;
@ApiPropertyOptional()
id_am?: string | null;
@ApiPropertyOptional()
prenom_am?: string | null;
@ApiPropertyOptional()
nom_am?: string | null;
@ApiProperty()
cree_le: string;
@ApiProperty()
modifie_le: string;
}
export class ListeAbsencesGardeDto {
@ApiProperty({ type: [AbsenceGardeDto] })
items: AbsenceGardeDto[];
}
@@ -0,0 +1,2 @@
export { AbsencesGardeModule } from './absences-garde.module';
export { AbsencesGardeService } from './absences-garde.service';
@@ -0,0 +1,94 @@
import {
Controller,
Get,
Headers,
MessageEvent,
Query,
Sse,
UnauthorizedException,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiQuery,
ApiTags,
} from '@nestjs/swagger';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { Observable } from 'rxjs';
import { AuthGuard } from 'src/common/guards/auth.guard';
import { RolesGuard } from 'src/common/guards/roles.guard';
import { Roles } from 'src/common/decorators/roles.decorator';
import { Public } from 'src/common/decorators/public.decorator';
import { User } from 'src/common/decorators/user.decorator';
import { RoleType } from 'src/entities/users.entity';
import { CardsRealtimeService } from './cards-realtime.service';
/**
* SSE Cartes #195
* Auth : Bearer (recommandé) ou `?access_token=` (EventSource navigateur).
*/
@ApiTags('Cartes')
@Controller('cards')
export class CardsRealtimeController {
constructor(
private readonly realtime: CardsRealtimeService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
@Sse('stream')
@Public()
@ApiOperation({
summary: 'Flux SSE bulles (card.created|updated|deleted, response.added) — #195',
})
@ApiQuery({
name: 'access_token',
required: false,
description: 'JWT si pas de header Authorization (EventSource)',
})
@ApiBearerAuth('access-token')
async stream(
@Headers('authorization') authorization?: string,
@Query('access_token') accessToken?: string,
): Promise<Observable<MessageEvent>> {
const userId = await this.resolveUserId(authorization, accessToken);
return this.realtime.streamFor(userId);
}
/** Variante gardée (clients qui envoient Bearer correctement). */
@Get('stream/info')
@UseGuards(AuthGuard, RolesGuard)
@Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE)
@ApiBearerAuth('access-token')
@ApiOperation({ summary: 'Debug : abonnés SSE pour mon user — #195' })
info(@User('id') userId: string) {
return {
user_id: userId,
subscribers: this.realtime.subscriberCount(userId),
};
}
private async resolveUserId(
authorization?: string,
accessToken?: string,
): Promise<string> {
let token = accessToken?.trim();
if (!token && authorization?.startsWith('Bearer ')) {
token = authorization.slice(7).trim();
}
if (!token) {
throw new UnauthorizedException('Token manquant (Bearer ou access_token)');
}
try {
const payload = await this.jwtService.verifyAsync<{ sub: string }>(token, {
secret: this.configService.get<string>('jwt.accessSecret'),
});
if (!payload?.sub) throw new UnauthorizedException('Token invalide');
return payload.sub;
} catch {
throw new UnauthorizedException('Token invalide ou expiré');
}
}
}
@@ -0,0 +1,47 @@
import { MessageEvent } from '@nestjs/common';
import { CardsRealtimeService } from './cards-realtime.service';
describe('CardsRealtimeService (#195)', () => {
let service: CardsRealtimeService;
beforeEach(() => {
service = new CardsRealtimeService();
});
afterEach(() => {
service.onModuleDestroy();
});
it('émet card.created aux abonnés du user', async () => {
const events: MessageEvent[] = [];
const sub = service.streamFor('user-a').subscribe((e) => events.push(e));
await new Promise((r) => setTimeout(r, 20));
expect(events[0]?.type).toBe('heartbeat');
expect(service.subscriberCount('user-a')).toBe(1);
service.emitToUsers(['user-a', 'user-b'], 'card.created', 'card-1', {
id: 'card-1',
});
await new Promise((r) => setTimeout(r, 20));
const created = events.find((e) => e.type === 'card.created');
expect(created).toBeDefined();
expect((created!.data as { card_id: string }).card_id).toBe('card-1');
expect(service.subscriberCount('user-b')).toBe(0);
sub.unsubscribe();
expect(service.subscriberCount('user-a')).toBe(0);
});
it('ne diffuse pas aux users non abonnés', async () => {
const events: MessageEvent[] = [];
const sub = service.streamFor('user-a').subscribe((e) => events.push(e));
await new Promise((r) => setTimeout(r, 20));
const before = events.length;
service.emitToUsers(['user-b'], 'card.updated', 'x');
await new Promise((r) => setTimeout(r, 20));
expect(events.length).toBe(before);
sub.unsubscribe();
});
});
@@ -0,0 +1,111 @@
import { Injectable, MessageEvent, OnModuleDestroy } from '@nestjs/common';
import { Observable, Subject, interval, merge, takeUntil } from 'rxjs';
import { map } from 'rxjs/operators';
export type CardRealtimeEventType =
| 'card.created'
| 'card.updated'
| 'card.deleted'
| 'response.added'
| 'heartbeat';
export interface CardRealtimePayload {
event: CardRealtimeEventType;
card_id?: string;
data?: unknown;
at: string;
}
/**
* Bus SSE in-memory par utilisateur (V1 mono-instance).
* Rooms = userId (audience carte).
*/
@Injectable()
export class CardsRealtimeService implements OnModuleDestroy {
private readonly byUser = new Map<string, Set<Subject<CardRealtimePayload>>>();
private readonly destroy$ = new Subject<void>();
onModuleDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
for (const set of this.byUser.values()) {
for (const s of set) s.complete();
}
this.byUser.clear();
}
/** Flux SSE pour un utilisateur authentifié. */
streamFor(userId: string): Observable<MessageEvent> {
const subject = new Subject<CardRealtimePayload>();
let set = this.byUser.get(userId);
if (!set) {
set = new Set();
this.byUser.set(userId, set);
}
set.add(subject);
const heartbeat$ = interval(25_000).pipe(
map(
(): CardRealtimePayload => ({
event: 'heartbeat',
at: new Date().toISOString(),
}),
),
);
return new Observable<MessageEvent>((observer) => {
const sub = merge(subject, heartbeat$)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (payload) =>
observer.next({
type: payload.event,
data: payload,
} as MessageEvent),
error: (err) => observer.error(err),
complete: () => observer.complete(),
});
// ping initial
subject.next({
event: 'heartbeat',
at: new Date().toISOString(),
});
return () => {
sub.unsubscribe();
set!.delete(subject);
subject.complete();
if (set!.size === 0) this.byUser.delete(userId);
};
});
}
emitToUsers(
userIds: string[],
event: Exclude<CardRealtimeEventType, 'heartbeat'>,
cardId: string | undefined,
data?: unknown,
): void {
const unique = [...new Set(userIds.filter(Boolean))];
const payload: CardRealtimePayload = {
event,
card_id: cardId,
data,
at: new Date().toISOString(),
};
for (const uid of unique) {
const set = this.byUser.get(uid);
if (!set) continue;
for (const s of set) s.next(payload);
}
}
/** Test / debug : nombre dabonnés actifs. */
subscriberCount(userId?: string): number {
if (userId) return this.byUser.get(userId)?.size ?? 0;
let n = 0;
for (const s of this.byUser.values()) n += s.size;
return n;
}
}
@@ -0,0 +1,119 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiBody,
ApiOperation,
ApiQuery,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { AuthGuard } from 'src/common/guards/auth.guard';
import { RolesGuard } from 'src/common/guards/roles.guard';
import { Roles } from 'src/common/decorators/roles.decorator';
import { User } from 'src/common/decorators/user.decorator';
import { RoleType } from 'src/entities/users.entity';
import { CardsService } from './cards.service';
import {
CarteDto,
CreerCarteDto,
ListeCartesDto,
MajCarteDto,
RepondreCarteDto,
} from './dto/cards.dto';
@ApiTags('Cartes')
@ApiBearerAuth('access-token')
@Controller('cards')
@UseGuards(AuthGuard, RolesGuard)
export class CardsController {
constructor(private readonly cardsService: CardsService) {}
@Get('types')
@Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE)
@ApiOperation({ summary: 'Types SYSTEM émissibles pour mon rôle — #194' })
listerTypes(@User('role') role: RoleType) {
return this.cardsService.listerTypes(role);
}
@Get()
@Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE)
@ApiOperation({ summary: 'Feed bulles de lutilisateur — #194' })
@ApiQuery({ name: 'placementId', required: false })
@ApiResponse({ status: 200, type: ListeCartesDto })
lister(
@User('id') userId: string,
@User('role') role: RoleType,
@Query('placementId') placementId?: string,
): Promise<ListeCartesDto> {
return this.cardsService.lister(userId, role, placementId);
}
@Post()
@Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE)
@HttpCode(HttpStatus.CREATED)
@ApiOperation({
summary: 'Créer une bulle (+ hook absences_garde) — #194',
})
@ApiBody({ type: CreerCarteDto })
@ApiResponse({ status: 201, type: CarteDto })
creer(
@User('id') userId: string,
@User('role') role: RoleType,
@Body() dto: CreerCarteDto,
): Promise<CarteDto> {
return this.cardsService.creer(userId, role, dto);
}
@Post(':id/respond')
@Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE)
@ApiOperation({ summary: 'Répondre (accept / refuse / ack) — #194' })
@ApiBody({ type: RepondreCarteDto })
repondre(
@User('id') userId: string,
@User('role') role: RoleType,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RepondreCarteDto,
): Promise<CarteDto> {
return this.cardsService.repondre(userId, role, id, dto);
}
@Patch(':id')
@Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE)
@ApiOperation({
summary: 'Modifier dates (créateur, ouverte/refusée) — #194',
})
@ApiBody({ type: MajCarteDto })
maj(
@User('id') userId: string,
@User('role') role: RoleType,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: MajCarteDto,
): Promise<CarteDto> {
return this.cardsService.majEnAttente(userId, role, id, dto);
}
@Delete(':id')
@Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Supprimer bulle (+ absence si non traitée) — #194' })
async supprimer(
@User('id') userId: string,
@User('role') role: RoleType,
@Param('id', ParseUUIDPipe) id: string,
): Promise<void> {
await this.cardsService.supprimer(userId, role, id);
}
}
+41
View File
@@ -0,0 +1,41 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AbsencesGardeModule } from '../absences-garde';
import { CardType } from 'src/entities/card_types.entity';
import { CardInstance } from 'src/entities/card_instances.entity';
import { CardAudienceMember } from 'src/entities/card_audience_members.entity';
import { CardResponse } from 'src/entities/card_responses.entity';
import { AmChildren } from 'src/entities/am_children.entity';
import { ParentsChildren } from 'src/entities/parents_children.entity';
import { CardsController } from './cards.controller';
import { CardsRealtimeController } from './cards-realtime.controller';
import { CardsService } from './cards.service';
import { CardsRealtimeService } from './cards-realtime.service';
@Module({
imports: [
AbsencesGardeModule,
TypeOrmModule.forFeature([
CardType,
CardInstance,
CardAudienceMember,
CardResponse,
AmChildren,
ParentsChildren,
]),
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
secret: config.get('jwt.accessSecret'),
signOptions: { expiresIn: config.get('jwt.accessExpiresIn') },
}),
inject: [ConfigService],
}),
],
controllers: [CardsController, CardsRealtimeController],
providers: [CardsService, CardsRealtimeService],
exports: [CardsService, CardsRealtimeService],
})
export class CardsModule {}
@@ -0,0 +1,160 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { ForbiddenException } from '@nestjs/common';
import { CardsService } from './cards.service';
import { CardsRealtimeService } from './cards-realtime.service';
import { AbsencesGardeService } from '../absences-garde/absences-garde.service';
import { CardType, CardResponseModeType } from 'src/entities/card_types.entity';
import {
CardInstance,
CardInstanceStatutType,
CardOperationType,
} from 'src/entities/card_instances.entity';
import { CardAudienceMember } from 'src/entities/card_audience_members.entity';
import { CardResponse } from 'src/entities/card_responses.entity';
import { AmChildren } from 'src/entities/am_children.entity';
import { ParentsChildren } from 'src/entities/parents_children.entity';
import { RoleType } from 'src/entities/users.entity';
import { StatutAbsenceGardeType, TypeAbsenceGardeType } from 'src/entities/absences_garde.entity';
describe('CardsService (#194)', () => {
let service: CardsService;
const typesRepo = { find: jest.fn(), findOne: jest.fn() };
const cardsRepo = {
create: jest.fn((x) => x),
save: jest.fn(async (x) => ({ ...x, id: x.id ?? 'card-1', cree_le: new Date(), modifie_le: new Date() })),
findOne: jest.fn(),
delete: jest.fn(),
createQueryBuilder: jest.fn(),
manager: { query: jest.fn() },
};
const audienceRepo = {
create: jest.fn((x) => x),
save: jest.fn(),
findOne: jest.fn(),
find: jest.fn(),
};
const responsesRepo = {
create: jest.fn((x) => x),
save: jest.fn(),
};
const amChildrenRepo = { findOne: jest.fn() };
const parentsChildrenRepo = { find: jest.fn(), findOne: jest.fn() };
const absencesService = {
creer: jest.fn(),
maj: jest.fn(),
supprimer: jest.fn(),
};
const realtime = { emitToUsers: jest.fn() };
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
CardsService,
{ provide: getRepositoryToken(CardType), useValue: typesRepo },
{ provide: getRepositoryToken(CardInstance), useValue: cardsRepo },
{ provide: getRepositoryToken(CardAudienceMember), useValue: audienceRepo },
{ provide: getRepositoryToken(CardResponse), useValue: responsesRepo },
{ provide: getRepositoryToken(AmChildren), useValue: amChildrenRepo },
{ provide: getRepositoryToken(ParentsChildren), useValue: parentsChildrenRepo },
{ provide: AbsencesGardeService, useValue: absencesService },
{ provide: CardsRealtimeService, useValue: realtime },
],
}).compile();
service = module.get(CardsService);
jest.clearAllMocks();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('AM crée congé → absence + carte ouverte + audience parents', async () => {
typesRepo.findOne.mockResolvedValue({
code: 'conge_am',
system: true,
titre: 'Congé AM',
emitter_roles: [RoleType.ASSISTANTE_MATERNELLE],
recipient_roles: [RoleType.PARENT],
audience_resolver: 'couple_parents',
response_mode: CardResponseModeType.ACCEPT_REFUSE,
retention_days: 14,
couleur: 'lavender',
});
amChildrenRepo.findOne.mockResolvedValue({
id: 'pl-1',
amId: 'am-1',
enfantId: 'e-1',
date_fin: null,
});
absencesService.creer.mockResolvedValue({
id: 'abs-1',
type: TypeAbsenceGardeType.CONGE_AM,
statut: StatutAbsenceGardeType.EN_ATTENTE,
});
parentsChildrenRepo.find.mockResolvedValue([
{ parentId: 'p-1', enfantId: 'e-1' },
{ parentId: 'p-2', enfantId: 'e-1' },
]);
cardsRepo.findOne.mockResolvedValue({
id: 'card-1',
type_code: 'conge_am',
id_placement: 'pl-1',
id_absence: 'abs-1',
cree_par: 'am-1',
operation: CardOperationType.CREATE,
statut: CardInstanceStatutType.OUVERTE,
payload: { date_debut: '2026-11-01', date_fin: '2026-11-07' },
purge_at: new Date(),
cree_le: new Date(),
modifie_le: new Date(),
type: {
titre: 'Congé AM',
couleur: 'lavender',
response_mode: CardResponseModeType.ACCEPT_REFUSE,
retention_days: 14,
},
responses: [],
audience: [
{ id_utilisateur: 'am-1' },
{ id_utilisateur: 'p-1' },
{ id_utilisateur: 'p-2' },
],
});
const res = await service.creer('am-1', RoleType.ASSISTANTE_MATERNELLE, {
type_code: 'conge_am',
id_placement: 'pl-1',
date_debut: '2026-11-01',
date_fin: '2026-11-07',
});
expect(absencesService.creer).toHaveBeenCalled();
expect(audienceRepo.save).toHaveBeenCalled();
expect(realtime.emitToUsers).toHaveBeenCalledWith(
['am-1', 'p-1', 'p-2'],
'card.created',
'card-1',
expect.any(Object),
);
expect(res.type_code).toBe('conge_am');
expect(res.statut).toBe(CardInstanceStatutType.OUVERTE);
});
it('parent ne peut pas émettre conge_am', async () => {
typesRepo.findOne.mockResolvedValue({
code: 'conge_am',
system: true,
emitter_roles: [RoleType.ASSISTANTE_MATERNELLE],
});
await expect(
service.creer('p-1', RoleType.PARENT, {
type_code: 'conge_am',
id_placement: 'pl-1',
date_debut: '2026-11-01',
date_fin: '2026-11-07',
}),
).rejects.toBeInstanceOf(ForbiddenException);
});
});
+489
View File
@@ -0,0 +1,489 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Repository } from 'typeorm';
import { AbsencesGardeService } from '../absences-garde/absences-garde.service';
import {
StatutAbsenceGardeType,
TypeAbsenceGardeType,
} from 'src/entities/absences_garde.entity';
import { AmChildren } from 'src/entities/am_children.entity';
import { ParentsChildren } from 'src/entities/parents_children.entity';
import { RoleType } from 'src/entities/users.entity';
import { CardType, CardResponseModeType } from 'src/entities/card_types.entity';
import {
CardInstance,
CardInstanceStatutType,
CardOperationType,
} from 'src/entities/card_instances.entity';
import { CardAudienceMember } from 'src/entities/card_audience_members.entity';
import {
CardResponse,
CardResponseActionType,
} from 'src/entities/card_responses.entity';
import {
CarteDto,
CreerCarteDto,
ListeCartesDto,
MajCarteDto,
RepondreCarteDto,
} from './dto/cards.dto';
import { CardsRealtimeService } from './cards-realtime.service';
@Injectable()
export class CardsService {
constructor(
@InjectRepository(CardType)
private readonly typesRepo: Repository<CardType>,
@InjectRepository(CardInstance)
private readonly cardsRepo: Repository<CardInstance>,
@InjectRepository(CardAudienceMember)
private readonly audienceRepo: Repository<CardAudienceMember>,
@InjectRepository(CardResponse)
private readonly responsesRepo: Repository<CardResponse>,
@InjectRepository(AmChildren)
private readonly amChildrenRepo: Repository<AmChildren>,
@InjectRepository(ParentsChildren)
private readonly parentsChildrenRepo: Repository<ParentsChildren>,
private readonly absencesService: AbsencesGardeService,
private readonly realtime: CardsRealtimeService,
) {}
async listerTypes(role: RoleType): Promise<CardType[]> {
const all = await this.typesRepo.find({ where: { system: true } });
return all.filter((t) => t.emitter_roles.includes(role));
}
async lister(
userId: string,
role: RoleType,
placementId?: string,
): Promise<ListeCartesDto> {
const qb = this.cardsRepo
.createQueryBuilder('c')
.innerJoin('c.audience', 'aud', 'aud.id_utilisateur = :userId', { userId })
.leftJoinAndSelect('c.type', 'type')
.leftJoinAndSelect('c.responses', 'responses')
.where('c.purge_at > now()')
.orderBy(
`CASE c.statut WHEN 'refusee' THEN 0 WHEN 'ouverte' THEN 1 ELSE 2 END`,
'ASC',
)
.addOrderBy('c.modifie_le', 'DESC');
if (placementId) {
await this.assertPlacementAccess(userId, role, placementId);
qb.andWhere('c.id_placement = :placementId', { placementId });
}
const rows = await qb.getMany();
return {
items: rows.map((c) => this.toDto(c, userId)),
};
}
async creer(
userId: string,
role: RoleType,
dto: CreerCarteDto,
): Promise<CarteDto> {
if (dto.date_fin < dto.date_debut) {
throw new BadRequestException('date_fin < date_debut');
}
const type = await this.typesRepo.findOne({ where: { code: dto.type_code } });
if (!type || !type.system) {
throw new NotFoundException('Type de carte inconnu');
}
if (!type.emitter_roles.includes(role)) {
throw new ForbiddenException('Vous ne pouvez pas émettre ce type de carte');
}
const placement = await this.assertPlacementAccess(
userId,
role,
dto.id_placement,
);
const operation = dto.operation ?? CardOperationType.CREATE;
let absenceId = dto.id_absence;
const absenceType = this.mapAbsenceType(dto.type_code);
if (operation === CardOperationType.CREATE) {
const absence = await this.absencesService.creer(userId, role, {
id_placement: dto.id_placement,
type: absenceType,
date_debut: dto.date_debut,
date_fin: dto.date_fin,
motif: dto.motif,
});
absenceId = absence.id;
} else {
if (!absenceId) {
throw new BadRequestException('id_absence requis pour operation=update');
}
await this.absencesService.maj(userId, role, absenceId, {
date_debut: dto.date_debut,
date_fin: dto.date_fin,
motif: dto.motif,
// Congé accepté modifié → repasse en attente via cartes respond flow;
// pour absence enfant update immédiat déjà fait.
...(dto.type_code === 'conge_am'
? { statut: StatutAbsenceGardeType.EN_ATTENTE }
: {}),
});
}
const statutInitial =
type.response_mode === CardResponseModeType.NONE
? CardInstanceStatutType.TRAITEE
: CardInstanceStatutType.OUVERTE;
const card = this.cardsRepo.create({
type_code: type.code,
id_placement: dto.id_placement,
id_absence: absenceId,
cree_par: userId,
operation,
statut: statutInitial,
payload: {
date_debut: dto.date_debut,
date_fin: dto.date_fin,
motif: dto.motif ?? null,
},
purge_at: this.purgeAt(type.retention_days, statutInitial),
});
const saved = await this.cardsRepo.save(card);
if (absenceId) {
await this.linkAbsenceCard(absenceId, saved.id);
}
await this.buildAudience(saved, type, placement, userId, role);
const full = await this.loadCard(saved.id);
const dtoOut = this.toDto(full, userId);
this.emitAudience(full, 'card.created', dtoOut);
return dtoOut;
}
async repondre(
userId: string,
role: RoleType,
cardId: string,
dto: RepondreCarteDto,
): Promise<CarteDto> {
const card = await this.loadCard(cardId);
await this.assertInAudience(card, userId);
if (card.statut !== CardInstanceStatutType.OUVERTE) {
throw new BadRequestException('Cette carte nest plus ouverte');
}
if (card.cree_par === userId) {
throw new ForbiddenException('Le créateur ne répond pas à sa propre carte');
}
const mode = card.type.response_mode;
if (mode === CardResponseModeType.NONE) {
throw new BadRequestException('Ce type de carte ne demande pas de réponse');
}
if (mode === CardResponseModeType.ACK && dto.action !== CardResponseActionType.ACK) {
throw new BadRequestException('Action attendue : ack');
}
if (
mode === CardResponseModeType.ACCEPT_REFUSE &&
dto.action !== CardResponseActionType.ACCEPT &&
dto.action !== CardResponseActionType.REFUSE
) {
throw new BadRequestException('Action attendue : accept ou refuse');
}
if (dto.action === CardResponseActionType.REFUSE && !dto.comment?.trim()) {
throw new BadRequestException('Motivation obligatoire en cas de refus');
}
await this.responsesRepo.save(
this.responsesRepo.create({
id_card: card.id,
id_utilisateur: userId,
action: dto.action,
comment: dto.comment?.trim(),
}),
);
if (card.id_absence) {
if (dto.action === CardResponseActionType.ACCEPT || dto.action === CardResponseActionType.ACK) {
await this.absencesService.maj(userId, role, card.id_absence, {
statut: StatutAbsenceGardeType.ACCEPTE,
});
card.statut = CardInstanceStatutType.TRAITEE;
} else if (dto.action === CardResponseActionType.REFUSE) {
await this.absencesService.maj(userId, role, card.id_absence, {
statut: StatutAbsenceGardeType.REFUSE,
motif: dto.comment!.trim(),
});
card.statut = CardInstanceStatutType.REFUSEE;
}
} else if (dto.action === CardResponseActionType.ACK) {
card.statut = CardInstanceStatutType.TRAITEE;
}
card.purge_at = this.purgeAt(card.type.retention_days, card.statut);
await this.cardsRepo.save(card);
const full = await this.loadCard(cardId);
const dtoOut = this.toDto(full, userId);
this.emitAudience(full, 'response.added', {
action: dto.action,
card: dtoOut,
});
this.emitAudience(full, 'card.updated', dtoOut);
return dtoOut;
}
async majEnAttente(
userId: string,
role: RoleType,
cardId: string,
dto: MajCarteDto,
): Promise<CarteDto> {
const card = await this.loadCard(cardId);
if (card.cree_par !== userId) {
throw new ForbiddenException('Seul le créateur peut modifier cette carte');
}
if (
card.statut !== CardInstanceStatutType.OUVERTE &&
card.statut !== CardInstanceStatutType.REFUSEE
) {
throw new BadRequestException('Carte non modifiable dans cet état');
}
const debut =
dto.date_debut ?? String(card.payload?.['date_debut'] ?? '');
const fin = dto.date_fin ?? String(card.payload?.['date_fin'] ?? '');
if (!debut || !fin || fin < debut) {
throw new BadRequestException('Dates invalides');
}
if (card.id_absence) {
await this.absencesService.maj(userId, role, card.id_absence, {
date_debut: debut,
date_fin: fin,
motif: dto.motif,
statut: StatutAbsenceGardeType.EN_ATTENTE,
});
}
card.payload = {
...card.payload,
date_debut: debut,
date_fin: fin,
motif: dto.motif ?? card.payload?.['motif'] ?? null,
};
card.statut = CardInstanceStatutType.OUVERTE;
card.purge_at = this.purgeAt(card.type.retention_days, card.statut);
await this.cardsRepo.save(card);
const full = await this.loadCard(cardId);
const dtoOut = this.toDto(full, userId);
this.emitAudience(full, 'card.updated', dtoOut);
return dtoOut;
}
async supprimer(
userId: string,
role: RoleType,
cardId: string,
): Promise<void> {
const card = await this.loadCard(cardId);
if (card.cree_par !== userId) {
throw new ForbiddenException('Seul le créateur peut supprimer cette carte');
}
const audienceIds = await this.audienceUserIds(card);
if (card.id_absence) {
const absStatut =
card.statut === CardInstanceStatutType.TRAITEE
? null
: card.id_absence;
// Supprime labsence liée si pas encore acceptée définitivement
if (
card.statut === CardInstanceStatutType.OUVERTE ||
card.statut === CardInstanceStatutType.REFUSEE
) {
await this.absencesService.supprimer(userId, role, card.id_absence);
}
void absStatut;
}
await this.cardsRepo.delete({ id: cardId });
this.realtime.emitToUsers(audienceIds, 'card.deleted', cardId, {
id: cardId,
});
}
private emitAudience(
card: CardInstance,
event: 'card.created' | 'card.updated' | 'response.added',
data: unknown,
): void {
const ids = (card.audience ?? []).map((a) => a.id_utilisateur);
if (ids.length === 0) return;
this.realtime.emitToUsers(ids, event, card.id, data);
}
private async audienceUserIds(card: CardInstance): Promise<string[]> {
if (card.audience?.length) {
return card.audience.map((a) => a.id_utilisateur);
}
const rows = await this.audienceRepo.find({ where: { id_card: card.id } });
return rows.map((r) => r.id_utilisateur);
}
private mapAbsenceType(typeCode: string): TypeAbsenceGardeType {
if (typeCode === 'absence_enfant' || typeCode === 'absence_enfant_modif') {
return TypeAbsenceGardeType.ABSENCE_ENFANT;
}
if (typeCode === 'conge_am') return TypeAbsenceGardeType.CONGE_AM;
if (typeCode === 'arret_maladie_am') {
return TypeAbsenceGardeType.ARRET_MALADIE_AM;
}
throw new BadRequestException(`Type non mappé à une absence: ${typeCode}`);
}
private async linkAbsenceCard(absenceId: string, cardId: string): Promise<void> {
// Update direct pour éviter droits maj vides
await this.cardsRepo.manager.query(
`UPDATE absences_garde SET id_card_instance = $1, modifie_le = now() WHERE id = $2`,
[cardId, absenceId],
);
}
private async buildAudience(
card: CardInstance,
type: CardType,
placement: AmChildren,
creatorId: string,
creatorRole: RoleType,
): Promise<void> {
const members: Partial<CardAudienceMember>[] = [
{
id_card: card.id,
id_utilisateur: creatorId,
role_snapshot: creatorRole,
is_creator: true,
},
];
if (type.audience_resolver === 'couple_am') {
members.push({
id_card: card.id,
id_utilisateur: placement.amId,
role_snapshot: RoleType.ASSISTANTE_MATERNELLE,
is_creator: false,
});
} else if (type.audience_resolver === 'couple_parents') {
const liens = await this.parentsChildrenRepo.find({
where: { enfantId: placement.enfantId },
});
for (const l of liens) {
if (l.parentId === creatorId) continue;
members.push({
id_card: card.id,
id_utilisateur: l.parentId,
role_snapshot: RoleType.PARENT,
is_creator: false,
});
}
}
// dédup
const seen = new Set<string>();
const unique = members.filter((m) => {
const k = m.id_utilisateur!;
if (seen.has(k)) return false;
seen.add(k);
return true;
});
await this.audienceRepo.save(this.audienceRepo.create(unique));
}
private purgeAt(
retentionDays: number,
statut: CardInstanceStatutType,
): Date {
const days =
statut === CardInstanceStatutType.OUVERTE
? Math.max(retentionDays, 15)
: retentionDays;
return new Date(Date.now() + days * 24 * 60 * 60 * 1000);
}
private async loadCard(id: string): Promise<CardInstance> {
const card = await this.cardsRepo.findOne({
where: { id },
relations: ['type', 'responses', 'audience'],
});
if (!card) throw new NotFoundException('Carte introuvable');
return card;
}
private async assertInAudience(card: CardInstance, userId: string): Promise<void> {
const ok = (card.audience ?? []).some((a) => a.id_utilisateur === userId);
if (!ok) {
const row = await this.audienceRepo.findOne({
where: { id_card: card.id, id_utilisateur: userId },
});
if (!row) throw new ForbiddenException('Carte hors de votre audience');
}
}
private async assertPlacementAccess(
userId: string,
role: RoleType,
placementId: string,
): Promise<AmChildren> {
const placement = await this.amChildrenRepo.findOne({
where: { id: placementId, date_fin: IsNull() },
});
if (!placement) {
throw new NotFoundException('Placement introuvable ou inactif');
}
if (role === RoleType.ASSISTANTE_MATERNELLE) {
if (placement.amId !== userId) {
throw new ForbiddenException('Placement non autorisé');
}
return placement;
}
if (role === RoleType.PARENT) {
const lien = await this.parentsChildrenRepo.findOne({
where: { parentId: userId, enfantId: placement.enfantId },
});
if (!lien) throw new ForbiddenException('Placement non autorisé');
return placement;
}
throw new ForbiddenException('Rôle non autorisé');
}
private toDto(card: CardInstance, userId: string): CarteDto {
const lastRefuse = [...(card.responses ?? [])]
.reverse()
.find((r) => r.action === CardResponseActionType.REFUSE);
return {
id: card.id,
type_code: card.type_code,
titre: card.type?.titre ?? card.type_code,
couleur: card.type?.couleur ?? null,
id_placement: card.id_placement,
id_absence: card.id_absence ?? null,
operation: card.operation,
statut: card.statut,
payload: card.payload ?? {},
purge_at: card.purge_at?.toISOString?.() ?? String(card.purge_at),
cree_par: card.cree_par ?? null,
is_creator: card.cree_par === userId,
response_mode: card.type?.response_mode,
last_refuse_comment: lastRefuse?.comment ?? null,
cree_le: card.cree_le?.toISOString?.() ?? String(card.cree_le),
modifie_le: card.modifie_le?.toISOString?.() ?? String(card.modifie_le),
};
}
}
+137
View File
@@ -0,0 +1,137 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsDateString,
IsEnum,
IsOptional,
IsString,
IsUUID,
MaxLength,
MinLength,
ValidateIf,
} from 'class-validator';
import { CardInstanceStatutType, CardOperationType } from 'src/entities/card_instances.entity';
import { CardResponseActionType } from 'src/entities/card_responses.entity';
export class CreerCarteDto {
@ApiProperty({
description: 'absence_enfant | absence_enfant_modif | conge_am | arret_maladie_am',
})
@IsString()
type_code: string;
@ApiProperty()
@IsUUID()
id_placement: string;
@ApiProperty({ example: '2026-10-01' })
@IsDateString()
date_debut: string;
@ApiProperty({ example: '2026-10-05' })
@IsDateString()
date_fin: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(2000)
motif?: string;
@ApiPropertyOptional({
description: 'Requis pour operation=update (même id absences_garde)',
})
@IsOptional()
@IsUUID()
id_absence?: string;
@ApiPropertyOptional({ enum: CardOperationType, default: CardOperationType.CREATE })
@IsOptional()
@IsEnum(CardOperationType)
operation?: CardOperationType;
}
export class RepondreCarteDto {
@ApiProperty({ enum: CardResponseActionType })
@IsEnum(CardResponseActionType)
action: CardResponseActionType;
@ApiPropertyOptional({ description: 'Obligatoire si action=refuse' })
@ValidateIf((o) => o.action === CardResponseActionType.REFUSE)
@IsString()
@MinLength(1)
@MaxLength(2000)
comment?: string;
}
export class MajCarteDto {
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
date_debut?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
date_fin?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(2000)
motif?: string;
}
export class CarteDto {
@ApiProperty()
id: string;
@ApiProperty()
type_code: string;
@ApiProperty()
titre: string;
@ApiPropertyOptional()
couleur?: string | null;
@ApiProperty()
id_placement: string;
@ApiPropertyOptional()
id_absence?: string | null;
@ApiProperty({ enum: CardOperationType })
operation: CardOperationType;
@ApiProperty({ enum: CardInstanceStatutType })
statut: CardInstanceStatutType;
@ApiProperty()
payload: Record<string, unknown>;
@ApiProperty()
purge_at: string;
@ApiPropertyOptional()
cree_par?: string | null;
@ApiProperty()
is_creator: boolean;
@ApiPropertyOptional()
response_mode?: string;
@ApiPropertyOptional()
last_refuse_comment?: string | null;
@ApiProperty()
cree_le: string;
@ApiProperty()
modifie_le: string;
}
export class ListeCartesDto {
@ApiProperty({ type: [CarteDto] })
items: CarteDto[];
}
+3
View File
@@ -0,0 +1,3 @@
export { CardsModule } from './cards.module';
export { CardsService } from './cards.service';
export { CardsRealtimeService } from './cards-realtime.service';
@@ -101,8 +101,9 @@ export class DocumentsLegauxController {
throw new BadRequestException('Aucun fichier fourni');
}
// TODO: Récupérer l'ID utilisateur depuis le guard
const userId = '00000000-0000-0000-0000-000000000000'; // Temporaire
// TODO: Récupérer l'ID utilisateur depuis le guard quand l'auth sera branchée.
// En attendant, on n'associe pas d'utilisateur plutôt que d'envoyer un UUID fictif invalide.
const userId: string | null = null;
const document = await this.documentsService.uploadNouvelleVersion(
uploadDto.type,
@@ -43,7 +43,7 @@ export class DocumentsLegauxService {
async uploadNouvelleVersion(
type: 'cgu' | 'privacy',
file: Express.Multer.File,
userId: string,
userId?: string | null,
): Promise<DocumentLegal> {
// Validation du type de fichier
if (file.mimetype !== 'application/pdf') {
@@ -84,7 +84,7 @@ export class DocumentsLegauxService {
fichier_path: filePath,
fichier_hash: hash,
actif: false, // Pas actif par défaut
televersePar: { id: userId } as any,
televersePar: userId ? ({ id: userId } as any) : null,
televerseLe: new Date(),
});
@@ -0,0 +1,118 @@
import { Test, TestingModule } from '@nestjs/testing';
import { MailService, ValidationAccountEmailKind, isTransientSmtpError } from './mail.service';
import { AppConfigService } from '../config/config.service';
describe('isTransientSmtpError', () => {
it('détecte les erreurs SMTP temporaires (454, coupure auth, timeout)', () => {
expect(isTransientSmtpError(new Error('Invalid login: 454 4.7.0 Temporary authentication failure'))).toBe(true);
expect(isTransientSmtpError(new Error('Connection lost to authentication server'))).toBe(true);
expect(isTransientSmtpError(Object.assign(new Error('timeout'), { code: 'ETIMEDOUT' }))).toBe(true);
expect(isTransientSmtpError(Object.assign(new Error('auth failed'), { responseCode: 454 }))).toBe(true);
});
it('ignore les erreurs permanentes (mauvaise adresse, refus définitif)', () => {
expect(isTransientSmtpError(new Error('Invalid login: 535 Authentication failed'))).toBe(false);
expect(isTransientSmtpError(new Error('Mailbox unavailable'))).toBe(false);
});
});
describe('MailService (ticket #28)', () => {
let service: MailService;
let sendEmailSpy: jest.SpyInstance;
const mockConfigGet = jest.fn((key: string, defaultValue?: unknown) => {
const values: Record<string, unknown> = {
app_name: 'TestApp',
app_url: 'https://app.test/',
smtp_host: '127.0.0.1',
smtp_port: 1025,
smtp_secure: false,
smtp_auth_required: false,
smtp_user: '',
smtp_password: '',
email_from_name: 'Test',
email_from_address: 'noreply@test',
};
if (key in values) return values[key];
return defaultValue;
});
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
MailService,
{
provide: AppConfigService,
useValue: { get: mockConfigGet },
},
],
}).compile();
service = module.get<MailService>(MailService);
sendEmailSpy = jest.spyOn(service, 'sendEmail').mockResolvedValue(undefined);
});
afterEach(() => {
sendEmailSpy.mockRestore();
});
it.each<[ValidationAccountEmailKind, string]>([
['parent', 'Compte parent validé'],
['am', 'Inscription validée'],
])('sendValidatedAccountPasswordSetupEmail (%s) utilise Handlebars + lien token', async (kind, subjectPart) => {
const token = '11111111-2222-3333-4444-555555555555';
await service.sendValidatedAccountPasswordSetupEmail(
{
email: 'user@test.fr',
prenom: 'Jean',
nom: 'Dupont',
token,
numeroDossier: '2025-000001',
},
kind,
);
expect(sendEmailSpy).toHaveBeenCalledTimes(1);
const [, subject, html] = sendEmailSpy.mock.calls[0];
expect(subject).toContain('TestApp');
expect(subject).toContain(subjectPart);
expect(html).toContain('Jean');
expect(html).toContain('Dupont');
expect(html).toContain('2025-000001');
expect(html).toContain(`https://app.test/create-password?token=${encodeURIComponent(token)}`);
});
it('sendPasswordResetEmail utilise Handlebars + lien /reset-password', async () => {
const token = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
await service.sendPasswordResetEmail({
email: 'u@test.fr',
prenom: 'Marie',
nom: 'Curie',
token,
});
expect(sendEmailSpy).toHaveBeenCalledTimes(1);
const [, subject, html] = sendEmailSpy.mock.calls[0];
expect(subject).toContain('Réinitialisation');
expect(html).toContain('Marie');
expect(html).toContain(`https://app.test/reset-password?token=${encodeURIComponent(token)}`);
expect(html).not.toContain('create-password');
});
it('sendResoumissionPendingEmail — accusé resoumission avec n° dossier', async () => {
await service.sendResoumissionPendingEmail(
'parent@test.fr',
'Claire',
'MARTIN',
'2026-000024',
);
expect(sendEmailSpy).toHaveBeenCalledTimes(1);
const [to, subject, html] = sendEmailSpy.mock.calls[0];
expect(to).toBe('parent@test.fr');
expect(subject).toContain('resoumis');
expect(subject).toContain('2026-000024');
expect(html).toContain('Claire');
expect(html).toContain('2026-000024');
expect(html).toContain('en attente de validation');
});
});
+322 -47
View File
@@ -1,12 +1,179 @@
import { Injectable, Logger } from '@nestjs/common';
import { readFileSync } from 'fs';
import { join } from 'path';
import * as Handlebars from 'handlebars';
import type SMTPTransport from 'nodemailer/lib/smtp-transport';
import { AppConfigService } from '../config/config.service';
/** Ticket #28 — quel template d'email envoyer après validation de compte */
export type ValidationAccountEmailKind = 'parent' | 'am';
type MailTransporter = {
sendMail: (options: Record<string, unknown>) => Promise<unknown>;
};
/** Erreurs SMTP temporaires (surcharge auth, coupure réseau…) — retentables. */
export function isTransientSmtpError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
const code = typeof error === 'object' && error !== null && 'code' in error
? String((error as { code?: unknown }).code ?? '')
: '';
const responseCode =
typeof error === 'object' && error !== null && 'responseCode' in error
? Number((error as { responseCode?: unknown }).responseCode)
: undefined;
if (responseCode === 454 || responseCode === 421 || responseCode === 450 || responseCode === 451) {
return true;
}
const transientCodes = new Set(['ECONNRESET', 'ETIMEDOUT', 'ESOCKET', 'ECONNREFUSED', 'EPIPE']);
if (transientCodes.has(code)) {
return true;
}
const transientPatterns = [
/temporary authentication failure/i,
/connection lost to authentication server/i,
/connection reset/i,
/timeout/i,
/try again later/i,
];
return transientPatterns.some((pattern) => pattern.test(message));
}
@Injectable()
export class MailService {
private readonly logger = new Logger(MailService.name);
private readonly smtpMaxAttempts = 3;
private readonly smtpRetryBaseDelayMs = 1000;
/** Cache des templates Handlebars compilés (fichiers .hbs) */
private readonly compiledTemplates = new Map<ValidationAccountEmailKind, Handlebars.TemplateDelegate>();
/** Transporteur SMTP réutilisé (évite une auth TCP/SASL par email). */
private transporter: MailTransporter | null = null;
private transporterConfigKey: string | null = null;
constructor(private readonly configService: AppConfigService) {}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
private buildSmtpTransportConfig(): SMTPTransport.Options {
const smtpHost = this.configService.get<string>('smtp_host');
const smtpPort = this.configService.get<number>('smtp_port');
const smtpSecure = this.configService.get<boolean>('smtp_secure');
const smtpAuthRequired = this.configService.get<boolean>('smtp_auth_required');
const smtpUser = this.configService.get<string>('smtp_user');
const smtpPassword = this.configService.get<string>('smtp_password');
const transportConfig = {
host: smtpHost,
port: smtpPort,
secure: smtpSecure,
pool: true,
maxConnections: 1,
maxMessages: 100,
...(smtpAuthRequired && smtpUser && smtpPassword
? { auth: { user: smtpUser, pass: smtpPassword } }
: {}),
} as SMTPTransport.Options;
return transportConfig;
}
private getSmtpConfigKey(config: SMTPTransport.Options): string {
const auth = config.auth as { user?: string; pass?: string } | undefined;
return JSON.stringify({
host: config.host,
port: config.port,
secure: config.secure,
user: auth?.user ?? '',
pass: auth?.pass ?? '',
});
}
private resetTransporter(): void {
const current = this.transporter as { close?: () => void } | null;
current?.close?.();
this.transporter = null;
this.transporterConfigKey = null;
}
private async getTransporter(): Promise<MailTransporter> {
const transportConfig = this.buildSmtpTransportConfig();
const configKey = this.getSmtpConfigKey(transportConfig);
if (this.transporter && this.transporterConfigKey === configKey) {
return this.transporter;
}
this.resetTransporter();
const nodemailer = await import('nodemailer');
this.transporter = nodemailer.createTransport(transportConfig) as MailTransporter;
this.transporterConfigKey = configKey;
return this.transporter;
}
private templateBasename(kind: ValidationAccountEmailKind): string {
const map: Record<ValidationAccountEmailKind, string> = {
parent: 'account-validated-parent',
am: 'account-validated-am',
};
return map[kind];
}
private getCompiledTemplate(kind: ValidationAccountEmailKind): Handlebars.TemplateDelegate {
let compiled = this.compiledTemplates.get(kind);
if (!compiled) {
const filePath = join(__dirname, 'templates', `${this.templateBasename(kind)}.hbs`);
const source = readFileSync(filePath, 'utf8');
compiled = Handlebars.compile(source);
this.compiledTemplates.set(kind, compiled);
}
return compiled;
}
/**
* Email post-validation : lien création MDP (token existant ou régénéré côté appelant).
* Ticket #28 app_name, app_url, expéditeur via ConfigService (sendEmail).
*/
async sendValidatedAccountPasswordSetupEmail(
recipient: {
email: string;
prenom: string;
nom: string;
token: string;
numeroDossier?: string | null;
},
kind: ValidationAccountEmailKind,
): Promise<void> {
const appName = this.configService.get<string>('app_name', "P'titsPas");
const appUrl = (this.configService.get<string>('app_url', 'https://app.ptits-pas.fr') || '').replace(/\/+$/, '');
const createPasswordUrl = `${appUrl}/create-password?token=${encodeURIComponent(recipient.token)}`;
const data: Record<string, string> = {
prenom: recipient.prenom || '',
nom: recipient.nom || '',
appName,
appUrl,
createPasswordUrl,
numeroDossier: recipient.numeroDossier || '',
};
const html = this.getCompiledTemplate(kind)(data) as string;
const subjects: Record<ValidationAccountEmailKind, string> = {
parent: `${appName} — Compte parent validé : créez votre mot de passe`,
am: `${appName} — Inscription validée : créez votre mot de passe`,
};
await this.sendEmail(recipient.email, subjects[kind], html);
}
/**
* Envoi d'un email générique
* @param to Destinataire
@@ -15,50 +182,48 @@ export class MailService {
* @param text Contenu texte (optionnel)
*/
async sendEmail(to: string, subject: string, html: string, text?: string): Promise<void> {
try {
// Récupération de la configuration SMTP
const smtpHost = this.configService.get<string>('smtp_host');
const smtpPort = this.configService.get<number>('smtp_port');
const smtpSecure = this.configService.get<boolean>('smtp_secure');
const smtpAuthRequired = this.configService.get<boolean>('smtp_auth_required');
const smtpUser = this.configService.get<string>('smtp_user');
const smtpPassword = this.configService.get<string>('smtp_password');
const emailFromName = this.configService.get<string>('email_from_name');
const emailFromAddress = this.configService.get<string>('email_from_address');
const emailFromName = this.configService.get<string>('email_from_name');
const emailFromAddress = this.configService.get<string>('email_from_address');
const mailOptions = {
from: `"${emailFromName}" <${emailFromAddress}>`,
to,
subject,
text: text || html.replace(/<[^>]*>?/gm, ''),
html,
};
// Import dynamique de nodemailer
const nodemailer = await import('nodemailer');
let lastError: unknown;
// Configuration du transporteur
const transportConfig: any = {
host: smtpHost,
port: smtpPort,
secure: smtpSecure,
};
for (let attempt = 1; attempt <= this.smtpMaxAttempts; attempt++) {
try {
const transporter = await this.getTransporter();
await transporter.sendMail(mailOptions);
this.logger.log(`📧 Email envoyé à ${to} : ${subject}`);
return;
} catch (error) {
lastError = error;
const canRetry = attempt < this.smtpMaxAttempts && isTransientSmtpError(error);
if (smtpAuthRequired && smtpUser && smtpPassword) {
transportConfig.auth = {
user: smtpUser,
pass: smtpPassword,
};
if (canRetry) {
const delayMs = this.smtpRetryBaseDelayMs * 2 ** (attempt - 1);
this.logger.warn(
`SMTP temporairement indisponible pour ${to} (tentative ${attempt}/${this.smtpMaxAttempts}), nouvel essai dans ${delayMs}ms`,
error instanceof Error ? error.message : error,
);
this.resetTransporter();
await this.sleep(delayMs);
continue;
}
this.logger.error(`❌ Erreur lors de l'envoi de l'email à ${to}`, error);
this.resetTransporter();
throw error;
}
const transporter = nodemailer.createTransport(transportConfig);
// Envoi de l'email
await transporter.sendMail({
from: `"${emailFromName}" <${emailFromAddress}>`,
to,
subject,
text: text || html.replace(/<[^>]*>?/gm, ''), // Fallback texte simple
html,
});
this.logger.log(`📧 Email envoyé à ${to} : ${subject}`);
} catch (error) {
this.logger.error(`❌ Erreur lors de l'envoi de l'email à ${to}`, error);
throw error;
}
this.logger.error(`❌ Erreur lors de l'envoi de l'email à ${to}`, lastError);
this.resetTransporter();
throw lastError;
}
/**
@@ -98,6 +263,81 @@ export class MailService {
await this.sendEmail(to, subject, html);
}
/**
* Accusé de réception inscription (parent ou assistante maternelle) :
* demande enregistrée + n° de dossier. En attente de validation ; pas de lien création MDP à ce stade.
*/
async sendRegistrationPendingEmail(
to: string,
prenom: string,
nom: string,
numeroDossier: string,
): Promise<void> {
const appName = this.configService.get<string>('app_name', "P'titsPas");
const appUrl = this.configService.get<string>('app_url', 'https://app.ptits-pas.fr');
const safe = (s: string) =>
(s || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
const subject = `Votre demande d'inscription sur ${appName} — dossier ${safe(numeroDossier)}`;
const html = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #4CAF50;">Bonjour ${safe(prenom)} ${safe(nom)},</h2>
<p>Nous avons bien enregistré votre demande de création de compte sur <strong>${safe(appName)}</strong>.</p>
<p><strong>Numéro de dossier :</strong> ${safe(numeroDossier)}</p>
<p>Votre dossier est <strong>en attente de validation</strong> par notre équipe. Vous recevrez un email lorsquil aura é traité.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${appUrl}" style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Accéder au site</a>
</div>
<hr style="border: 1px solid #eee; margin: 20px 0;">
<p style="color: #666; font-size: 12px;">Cet email a é envoyé automatiquement. Merci de ne pas y répondre.</p>
</div>
`;
await this.sendEmail(to, subject, html);
}
/**
* Accusé de resoumission après refus (reprise #112) : dossier à nouveau en attente de validation.
*/
async sendResoumissionPendingEmail(
to: string,
prenom: string,
nom: string,
numeroDossier: string,
): Promise<void> {
const appName = this.configService.get<string>('app_name', "P'titsPas");
const appUrl = this.configService.get<string>('app_url', 'https://app.ptits-pas.fr');
const safe = (s: string) =>
(s || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
const subject = `Votre dossier a été resoumis — ${safe(numeroDossier)}`;
const html = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #4CAF50;">Bonjour ${safe(prenom)} ${safe(nom)},</h2>
<p>Nous avons bien reçu la <strong>resoumission</strong> de votre dossier sur <strong>${safe(appName)}</strong>.</p>
<p><strong>Numéro de dossier :</strong> ${safe(numeroDossier)}</p>
<p>Votre dossier est de nouveau <strong>en attente de validation</strong> par notre équipe. Vous recevrez un email lorsqu'il aura é traité.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${appUrl}" style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Accéder au site</a>
</div>
<hr style="border: 1px solid #eee; margin: 20px 0;">
<p style="color: #666; font-size: 12px;">Cet email a é envoyé automatiquement. Merci de ne pas y répondre.</p>
</div>
`;
await this.sendEmail(to, subject, html);
}
/**
* Email de refus de dossier avec lien reprise (token).
* Ticket #110 Refus sans suppression
@@ -110,21 +350,29 @@ export class MailService {
token: string,
): Promise<void> {
const appName = this.configService.get<string>('app_name', "P'titsPas");
const appUrl = this.configService.get<string>('app_url', 'https://app.ptits-pas.fr');
const appUrl = (this.configService.get<string>('app_url', 'https://app.ptits-pas.fr') || '').replace(/\/+$/, '');
const repriseLink = `${appUrl}/reprise?token=${encodeURIComponent(token)}`;
const safe = (s: string) =>
(s || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
const subject = `Votre dossier compléments demandés`;
const commentBlock = comment
? `<p><strong>Message du gestionnaire :</strong></p><p>${comment.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</p>`
: '';
const commentText = safe(comment || 'Le gestionnaire vous demande de compléter votre dossier avant une nouvelle validation.');
const html = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #333;">Bonjour ${prenom} ${nom},</h2>
<p>Votre dossier d'inscription sur <strong>${appName}</strong> n'a pas pu être validé en l'état.</p>
${commentBlock}
<h2 style="color: #4CAF50;">Bonjour ${safe(prenom)} ${safe(nom)},</h2>
<p>Votre dossier d'inscription sur <strong>${safe(appName)}</strong> n'a pas pu être validé en l'état.</p>
<div style="background-color: #F4FBF5; border-left: 4px solid #4CAF50; padding: 12px 16px; margin: 20px 0;">
<p style="margin: 0 0 8px 0;"><strong>Message du gestionnaire :</strong></p>
<p style="margin: 0;">${commentText}</p>
</div>
<p>Vous pouvez corriger les éléments indiqués et soumettre à nouveau votre dossier en cliquant sur le lien ci-dessous.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${repriseLink}" style="background-color: #2196F3; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Reprendre mon dossier</a>
<a href="${repriseLink}" style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Reprendre mon dossier</a>
</div>
<p style="color: #666; font-size: 12px;">Ce lien est valable 7 jours. Si vous n'avez pas demandé cette reprise, vous pouvez ignorer cet email.</p>
<hr style="border: 1px solid #eee; margin: 20px 0;">
@@ -134,4 +382,31 @@ export class MailService {
await this.sendEmail(to, subject, html);
}
/**
* Ticket #127 lien application `/reset-password?token=` (pas create-password).
*/
async sendPasswordResetEmail(recipient: {
email: string;
prenom: string;
nom: string;
token: string;
}): Promise<void> {
const appName = this.configService.get<string>('app_name', "P'titsPas");
const appUrl = (this.configService.get<string>('app_url', 'https://app.ptits-pas.fr') || '').replace(/\/+$/, '');
const resetPasswordUrl = `${appUrl}/reset-password?token=${encodeURIComponent(recipient.token)}`;
const filePath = join(__dirname, 'templates', 'password-reset.hbs');
const source = readFileSync(filePath, 'utf8');
const compiled = Handlebars.compile(source);
const html = compiled({
prenom: recipient.prenom || '',
nom: recipient.nom || '',
appName,
resetPasswordUrl,
}) as string;
const subject = `${appName} — Réinitialisation de votre mot de passe`;
await this.sendEmail(recipient.email, subject, html);
}
}
@@ -0,0 +1,14 @@
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #4CAF50;">Bonjour {{prenom}} {{nom}},</h2>
<p>Votre demande d'inscription en tant qu'<strong>assistante maternelle</strong> sur <strong>{{appName}}</strong> a été <strong>validée</strong>.</p>
{{#if numeroDossier}}
<p><strong>Numéro de dossier :</strong> {{numeroDossier}}</p>
{{/if}}
<p>Pour finaliser l'activation de votre compte, veuillez <strong>créer votre mot de passe</strong> en cliquant sur le bouton ci-dessous.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{{createPasswordUrl}}}" style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Créer mon mot de passe</a>
</div>
<p style="color: #666; font-size: 13px;">Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :<br /><span style="word-break: break-all;">{{{createPasswordUrl}}}</span></p>
<hr style="border: 1px solid #eee; margin: 20px 0;" />
<p style="color: #666; font-size: 12px;">Cet email a été envoyé automatiquement par {{appName}}. Merci de ne pas y répondre.</p>
</div>
@@ -0,0 +1,14 @@
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #4CAF50;">Bonjour {{prenom}} {{nom}},</h2>
<p>Votre compte parent sur <strong>{{appName}}</strong> a été <strong>validé</strong>.</p>
{{#if numeroDossier}}
<p><strong>Numéro de dossier :</strong> {{numeroDossier}}</p>
{{/if}}
<p>Pour accéder à l'application, veuillez <strong>définir votre mot de passe</strong> en cliquant sur le bouton ci-dessous.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{{createPasswordUrl}}}" style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Créer mon mot de passe</a>
</div>
<p style="color: #666; font-size: 13px;">Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :<br /><span style="word-break: break-all;">{{{createPasswordUrl}}}</span></p>
<hr style="border: 1px solid #eee; margin: 20px 0;" />
<p style="color: #666; font-size: 12px;">Cet email a été envoyé automatiquement par {{appName}}. Merci de ne pas y répondre.</p>
</div>
@@ -0,0 +1,12 @@
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #4CAF50;">Bonjour {{prenom}} {{nom}},</h2>
<p>Vous avez demandé à <strong>réinitialiser votre mot de passe</strong> sur <strong>{{appName}}</strong>.</p>
<p>Cliquez sur le bouton ci-dessous pour en choisir un nouveau. Ce lien est valable une durée limitée.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{{resetPasswordUrl}}}" style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Réinitialiser mon mot de passe</a>
</div>
<p style="color: #666; font-size: 13px;">Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :<br /><span style="word-break: break-all;">{{{resetPasswordUrl}}}</span></p>
<p style="color: #666; font-size: 12px;">Si vous n'êtes pas à l'origine de cette demande, vous pouvez ignorer cet e-mail.</p>
<hr style="border: 1px solid #eee; margin: 20px 0;" />
<p style="color: #666; font-size: 12px;">Cet email a été envoyé automatiquement par {{appName}}. Merci de ne pas y répondre.</p>
</div>
@@ -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,12 +1,26 @@
import { BadRequestException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { UserService } from '../user/user.service';
describe('AuthController', () => {
let controller: AuthController;
const authServiceMock = {
verifyCreatePasswordToken: jest.fn(),
createPasswordWithToken: jest.fn(),
requestPasswordReset: jest.fn(),
resetPasswordWithResetToken: jest.fn(),
};
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
controllers: [AuthController],
providers: [
{ provide: AuthService, useValue: authServiceMock },
{ provide: UserService, useValue: {} },
],
}).compile();
controller = module.get<AuthController>(AuthController);
@@ -15,4 +29,82 @@ describe('AuthController', () => {
it('should be defined', () => {
expect(controller).toBeDefined();
});
it('forwards token verification to auth service', async () => {
authServiceMock.verifyCreatePasswordToken.mockResolvedValue({
valid: true,
message: 'Token valide',
});
await expect(controller.verifyCreatePasswordToken('tok-123')).resolves.toEqual({
valid: true,
message: 'Token valide',
});
expect(authServiceMock.verifyCreatePasswordToken).toHaveBeenCalledWith('tok-123');
});
it('rejects create-password when confirmation mismatches', async () => {
await expect(
controller.createPassword({
token: 'tok-123',
password: 'Password1',
password_confirmation: 'Password2',
}),
).rejects.toThrow(BadRequestException);
expect(authServiceMock.createPasswordWithToken).not.toHaveBeenCalled();
});
it('calls auth service when create-password payload is valid', async () => {
authServiceMock.createPasswordWithToken.mockResolvedValue({
message: 'Mot de passe créé avec succès. Vous pouvez maintenant vous connecter.',
userId: 'user-1',
});
await expect(
controller.createPassword({
token: 'tok-123',
password: 'Password1',
password_confirmation: 'Password1',
}),
).resolves.toEqual({
message: 'Mot de passe créé avec succès. Vous pouvez maintenant vous connecter.',
userId: 'user-1',
});
expect(authServiceMock.createPasswordWithToken).toHaveBeenCalledWith('tok-123', 'Password1');
});
it('forgot-password délègue au service et renvoie le message générique', async () => {
authServiceMock.requestPasswordReset.mockResolvedValue({
message: 'Si cette adresse est associée…',
});
await expect(controller.forgotPassword({ email: 'user@test.fr' })).resolves.toEqual({
message: 'Si cette adresse est associée…',
});
expect(authServiceMock.requestPasswordReset).toHaveBeenCalledWith('user@test.fr');
});
it('rejects reset-password when confirmation mismatches', async () => {
await expect(
controller.resetPassword({
token: 'tok',
password: 'Password1',
password_confirmation: 'Password2',
}),
).rejects.toThrow(BadRequestException);
expect(authServiceMock.resetPasswordWithResetToken).not.toHaveBeenCalled();
});
it('calls reset-password when payload is valid', async () => {
authServiceMock.resetPasswordWithResetToken.mockResolvedValue({
message: 'Mot de passe mis à jour.',
});
await expect(
controller.resetPassword({
token: 'tok-123',
password: 'Password1',
password_confirmation: 'Password1',
}),
).resolves.toEqual({ message: 'Mot de passe mis à jour.' });
expect(authServiceMock.resetPasswordWithResetToken).toHaveBeenCalledWith('tok-123', 'Password1');
});
});
+76 -6
View File
@@ -1,11 +1,28 @@
import { Body, Controller, Get, Patch, Post, Query, Req, UnauthorizedException, BadRequestException, UseGuards } from '@nestjs/common';
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Patch,
Post,
Query,
Req,
UnauthorizedException,
BadRequestException,
UseGuards,
} from '@nestjs/common';
import { LoginDto } from './dto/login.dto';
import { AuthService } from './auth.service';
import { Public } from 'src/common/decorators/public.decorator';
import { RegisterDto } from './dto/register.dto';
import { RegisterParentCompletDto } from './dto/register-parent-complet.dto';
import { RegisterAMCompletDto } from './dto/register-am-complet.dto';
import { RegisterAmResponseDto } from './dto/register-am-response.dto';
import { ChangePasswordRequiredDto } from './dto/change-password.dto';
import { CreatePasswordDto } from './dto/create-password.dto';
import { ForgotPasswordDto } from './dto/forgot-password.dto';
import { ResetPasswordDto } from './dto/reset-password.dto';
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
import { AuthGuard } from 'src/common/guards/auth.guard';
import type { Request } from 'express';
@@ -59,12 +76,13 @@ export class AuthController {
@Post('register/am')
@ApiOperation({
summary: 'Inscription Assistante Maternelle COMPLÈTE',
description: 'Crée User AM + entrée assistantes_maternelles (identité + infos pro + photo + CGU) en une transaction',
description:
'Crée User AM + entrée assistantes_maternelles (identité + infos pro + photo + CGU) en une transaction. Si photo_base64 est fourni sans photo_filename, le serveur utilise le nom par défaut photo_am.jpg (extension du fichier stocké = type du data-URL).',
})
@ApiResponse({ status: 201, description: 'Inscription réussie - Dossier en attente de validation' })
@ApiResponse({ status: 201, description: 'Inscription réussie - Dossier en attente de validation', type: RegisterAmResponseDto })
@ApiResponse({ status: 400, description: 'Données invalides ou CGU non acceptées' })
@ApiResponse({ status: 409, description: 'Email déjà utilisé' })
async inscrireAMComplet(@Body() dto: RegisterAMCompletDto) {
async inscrireAMComplet(@Body() dto: RegisterAMCompletDto): Promise<RegisterAmResponseDto> {
return this.authService.inscrireAMComplet(dto);
}
@@ -84,8 +102,7 @@ export class AuthController {
@ApiResponse({ status: 200, description: 'Dossier resoumis' })
@ApiResponse({ status: 404, description: 'Token invalide ou expiré' })
async resoumettreReprise(@Body() dto: ResoumettreRepriseDto) {
const { token, ...fields } = dto;
return this.authService.resoumettreReprise(token, fields);
return this.authService.resoumettreReprise(dto);
}
@Public()
@@ -107,6 +124,59 @@ export class AuthController {
return this.authService.refreshTokens(dto.refresh_token);
}
@Public()
@Get('verify-token')
@ApiOperation({ summary: 'Vérifier un token de création de mot de passe' })
@ApiQuery({ name: 'token', required: true, description: 'Token UUID reçu par email' })
@ApiResponse({ status: 200, description: 'Token valide' })
@ApiResponse({ status: 404, description: 'Token invalide, expiré, ou déjà utilisé' })
async verifyCreatePasswordToken(@Query('token') token: string) {
return this.authService.verifyCreatePasswordToken(token);
}
@Public()
@Post('create-password')
@ApiOperation({ summary: 'Créer le mot de passe initial via token email' })
@ApiResponse({ status: 201, description: 'Mot de passe créé avec succès' })
@ApiResponse({ status: 400, description: 'Confirmation invalide ou mot de passe invalide' })
@ApiResponse({ status: 404, description: 'Token invalide, expiré, ou déjà utilisé' })
async createPassword(@Body() dto: CreatePasswordDto) {
if (dto.password !== dto.password_confirmation) {
throw new BadRequestException('Les mots de passe ne correspondent pas');
}
return this.authService.createPasswordWithToken(dto.token, dto.password);
}
@Public()
@Post('forgot-password')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Demande de réinitialisation du mot de passe (ticket #127)',
description:
'Réponse identique que le-mail existe ou non (anti-énumération). Si un compte avec mot de passe existe, un e-mail avec lien /reset-password est envoyé.',
})
@ApiResponse({ status: 200, description: 'Message générique' })
async forgotPassword(@Body() dto: ForgotPasswordDto) {
return this.authService.requestPasswordReset(dto.email ?? '');
}
@Public()
@Post('reset-password')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Réinitialiser le mot de passe via token e-mail (ticket #127)',
description: 'Distinct de POST /auth/create-password (inscription). Utilise password_reset_token.',
})
@ApiResponse({ status: 200, description: 'Mot de passe mis à jour' })
@ApiResponse({ status: 400, description: 'Confirmation ou règles de mot de passe' })
@ApiResponse({ status: 404, description: 'Token invalide, expiré, ou déjà utilisé' })
async resetPassword(@Body() dto: ResetPasswordDto) {
if (dto.password !== dto.password_confirmation) {
throw new BadRequestException('Les mots de passe ne correspondent pas');
}
return this.authService.resetPasswordWithResetToken(dto.token, dto.password);
}
@Get('me')
@UseGuards(AuthGuard)
@ApiBearerAuth('access-token')
+17 -1
View File
@@ -11,12 +11,28 @@ import { Children } from 'src/entities/children.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { AppConfigModule } from 'src/modules/config';
import { NumeroDossierModule } from 'src/modules/numero-dossier/numero-dossier.module';
import { MailModule } from 'src/modules/mail/mail.module';
import { ParentsModule } from '../parents/parents.module';
import { DossiersModule } from '../dossiers/dossiers.module';
import { DossierFamille, DossierFamilleEnfant } from 'src/entities/dossier_famille.entity';
import { ParentsChildren } from 'src/entities/parents_children.entity';
@Module({
imports: [
TypeOrmModule.forFeature([Users, Parents, Children, AssistanteMaternelle]),
TypeOrmModule.forFeature([
Users,
Parents,
Children,
AssistanteMaternelle,
DossierFamille,
DossierFamilleEnfant,
ParentsChildren,
]),
forwardRef(() => UserModule),
forwardRef(() => ParentsModule),
DossiersModule,
AppConfigModule,
MailModule,
NumeroDossierModule,
JwtModule.registerAsync({
imports: [ConfigModule],
+206 -2
View File
@@ -1,12 +1,79 @@
import { NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { JwtService } from '@nestjs/jwt';
import { getRepositoryToken } from '@nestjs/typeorm';
import { ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
import { UserService } from '../user/user.service';
import { ParentsService } from '../parents/parents.service';
import { DossiersService } from '../dossiers/dossiers.service';
import { AppConfigService } from 'src/modules/config/config.service';
import { MailService } from 'src/modules/mail/mail.service';
import { NumeroDossierService } from 'src/modules/numero-dossier/numero-dossier.service';
import { Parents } from 'src/entities/parents.entity';
import { Users, RoleType, StatutUtilisateurType } from 'src/entities/users.entity';
import { Children } from 'src/entities/children.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
describe('AuthService', () => {
describe('AuthService (#118 create-password API)', () => {
let service: AuthService;
const usersRepoMock = {
findOne: jest.fn(),
createQueryBuilder: jest.fn(),
update: jest.fn(),
manager: { transaction: jest.fn() },
};
const userServiceMock = {
findByTokenReprise: jest.fn(),
};
const parentsServiceMock = {
getDossierFamilleByNumero: jest.fn(),
getFamilyUserIds: jest.fn(),
};
const dossiersServiceMock = {
getDossierByNumero: jest.fn(),
};
const mailServiceMock = {
sendPasswordResetEmail: jest.fn().mockResolvedValue(undefined),
};
const appConfigMock = {
get: jest.fn().mockReturnValue(7),
};
beforeEach(async () => {
jest.clearAllMocks();
const qbChain: any = {};
qbChain.where = jest.fn().mockReturnValue(qbChain);
qbChain.select = jest.fn().mockReturnValue(qbChain);
qbChain.getOne = jest.fn().mockResolvedValue(null);
qbChain.update = jest.fn().mockReturnValue(qbChain);
qbChain.set = jest.fn().mockReturnValue(qbChain);
qbChain.andWhere = jest.fn().mockReturnValue(qbChain);
qbChain.execute = jest.fn().mockResolvedValue({ affected: 0 });
usersRepoMock.createQueryBuilder.mockReturnValue(qbChain);
usersRepoMock.update.mockResolvedValue({ affected: 1, raw: [] });
const module: TestingModule = await Test.createTestingModule({
providers: [AuthService],
providers: [
AuthService,
{ provide: UserService, useValue: userServiceMock },
{ provide: ParentsService, useValue: parentsServiceMock },
{ provide: DossiersService, useValue: dossiersServiceMock },
{ provide: JwtService, useValue: {} },
{ provide: ConfigService, useValue: { get: jest.fn() } },
{ provide: AppConfigService, useValue: appConfigMock },
{ provide: MailService, useValue: mailServiceMock },
{ provide: NumeroDossierService, useValue: {} },
{ provide: getRepositoryToken(Parents), useValue: {} },
{ provide: getRepositoryToken(Users), useValue: usersRepoMock },
{ provide: getRepositoryToken(Children), useValue: {} },
{ provide: getRepositoryToken(AssistanteMaternelle), useValue: {} },
],
}).compile();
service = module.get<AuthService>(AuthService);
@@ -15,4 +82,141 @@ describe('AuthService', () => {
it('should be defined', () => {
expect(service).toBeDefined();
});
it('returns 404-like error when verify token is missing', async () => {
await expect(service.verifyCreatePasswordToken('')).rejects.toThrow(NotFoundException);
});
it('returns valid=true when token exists and is not expired', async () => {
usersRepoMock.findOne.mockResolvedValue({
id: 'u1',
password: null,
token_creation_mdp_expire_le: new Date(Date.now() + 60_000),
});
await expect(service.verifyCreatePasswordToken('tok-123')).resolves.toEqual({
valid: true,
message: 'Token valide',
});
});
describe('#127 forgot / reset password', () => {
it('requestPasswordReset returns generic message when email unknown', async () => {
const r = await service.requestPasswordReset('unknown@test.fr');
expect(r.message).toContain('Si cette adresse');
expect(mailServiceMock.sendPasswordResetEmail).not.toHaveBeenCalled();
});
it('requestPasswordReset sends mail when user has password', async () => {
usersRepoMock.createQueryBuilder.mockReturnValueOnce({
where: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
getOne: jest.fn().mockResolvedValue({
id: 'u1',
email: 'a@test.fr',
prenom: 'A',
nom: 'B',
password: 'hashed',
}),
});
const r = await service.requestPasswordReset('a@test.fr');
expect(r.message).toContain('Si cette adresse');
expect(usersRepoMock.update).toHaveBeenCalled();
expect(mailServiceMock.sendPasswordResetEmail).toHaveBeenCalledWith(
expect.objectContaining({ email: 'a@test.fr', token: expect.any(String) }),
);
});
it('resetPasswordWithResetToken throws when token unknown', async () => {
usersRepoMock.findOne.mockResolvedValue(null);
await expect(service.resetPasswordWithResetToken('bad', 'Password1')).rejects.toThrow(
NotFoundException,
);
});
it('resetPasswordWithResetToken succeeds when update affects row', async () => {
usersRepoMock.findOne.mockResolvedValue({
id: 'u1',
password: 'oldhash',
password_reset_expires: new Date(Date.now() + 60_000),
});
const exec = jest.fn().mockResolvedValue({ affected: 1 });
const chain: any = {};
chain.update = jest.fn().mockReturnValue(chain);
chain.set = jest.fn().mockReturnValue(chain);
chain.where = jest.fn().mockReturnValue(chain);
chain.andWhere = jest.fn().mockReturnValue(chain);
chain.execute = exec;
usersRepoMock.createQueryBuilder.mockReturnValueOnce(chain);
const r = await service.resetPasswordWithResetToken('good-token', 'Password1');
expect(r.message.toLowerCase()).toContain('mis à jour');
expect(exec).toHaveBeenCalled();
});
});
describe('Reprise après refus (#112)', () => {
const token = '11111111-1111-1111-1111-111111111111';
it('getRepriseDossier throws when token invalid', async () => {
userServiceMock.findByTokenReprise.mockResolvedValue(null);
await expect(service.getRepriseDossier(token)).rejects.toThrow(NotFoundException);
});
it('getRepriseDossier returns famille complète pour parent', async () => {
userServiceMock.findByTokenReprise.mockResolvedValue({
id: 'p1',
email: 'claire@test.fr',
prenom: 'Claire',
nom: 'MARTIN',
role: RoleType.PARENT,
numero_dossier: '2026-000021',
statut: StatutUtilisateurType.REFUSE,
});
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: 'sans_garde' }],
texte_motivation: 'Motivation test',
});
const result = await service.getRepriseDossier(token);
expect(result.parents).toHaveLength(1);
expect(result.enfants).toHaveLength(1);
expect(result.texte_motivation).toBe('Motivation test');
expect(result.numero_dossier).toBe('2026-000021');
});
it('getRepriseDossier returns fiche AM complète', async () => {
userServiceMock.findByTokenReprise.mockResolvedValue({
id: 'am1',
email: 'am@test.fr',
role: RoleType.ASSISTANTE_MATERNELLE,
numero_dossier: '2026-000003',
});
dossiersServiceMock.getDossierByNumero.mockResolvedValue({
type: 'am',
dossier: {
numero_dossier: '2026-000003',
user: {
id: 'am1',
email: 'am@test.fr',
prenom: 'Marie',
nom: 'DUPONT',
statut: StatutUtilisateurType.REFUSE,
},
numero_agrement: 'AGR-1',
nir: '123456789012345',
biographie: 'Bio',
nb_max_enfants: 4,
place_disponible: 2,
},
});
const result = await service.getRepriseDossier(token);
expect(result.numero_agrement).toBe('AGR-1');
expect(result.biographie).toBe('Bio');
expect(result.nb_max_enfants).toBe(4);
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,23 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, MinLength, Matches } from 'class-validator';
export class CreatePasswordDto {
@ApiProperty({ description: 'Token de création de mot de passe reçu par email' })
@IsString()
token: string;
@ApiProperty({
description: 'Nouveau mot de passe (min 8 caractères, 1 majuscule, 1 chiffre)',
minLength: 8,
})
@IsString()
@MinLength(8, { message: 'Le mot de passe doit contenir au moins 8 caractères' })
@Matches(/^(?=.*[A-Z])(?=.*\d)/, {
message: 'Le mot de passe doit contenir au moins une majuscule et un chiffre',
})
password: string;
@ApiProperty({ description: 'Confirmation du nouveau mot de passe' })
@IsString()
password_confirmation: string;
}
@@ -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,11 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsUUID } from 'class-validator';
import { EnfantInscriptionDto } from './enfant-inscription.dto';
/** Enfant existant modifiable lors de la resoumission reprise (#112). */
export class EnfantRepriseDto extends EnfantInscriptionDto {
@ApiProperty({ description: 'UUID enfant existant (obligatoire en reprise)' })
@IsUUID()
@IsNotEmpty()
id: string;
}
@@ -0,0 +1,20 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsOptional, IsString, MaxLength } from 'class-validator';
/**
* Ticket #127 pas de @IsEmail / @MinLength stricts : éviter 400 sur saisies vides ou bizarres ;
* le service renvoie toujours la même réponse 200 (anti-énumération).
*/
export class ForgotPasswordDto {
@ApiProperty({
example: 'parent@example.com',
required: false,
description: 'E-mail (trim + lowercase via transform). Absent ou vide → même réponse générique.',
})
@IsOptional()
@IsString()
@MaxLength(320)
@Transform(({ value }) => (typeof value === 'string' ? value.trim().toLowerCase() : ''))
email?: string;
}
@@ -1,4 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
IsEmail,
IsNotEmpty,
@@ -76,7 +77,12 @@ export class RegisterAMCompletDto {
@IsString()
photo_base64?: string;
@ApiProperty({ example: 'photo_profil.jpg', required: false })
@ApiProperty({
example: 'photo_profil.jpg',
required: false,
description:
'Nom du fichier photo (optionnel si photo_base64 est fourni ; défaut serveur : photo_am.jpg). Lextension réelle du fichier stocké suit le type MIME du data-URL.',
})
@IsOptional()
@IsString()
photo_filename?: string;
@@ -91,17 +97,21 @@ export class RegisterAMCompletDto {
@IsDateString()
date_naissance?: string;
@ApiProperty({ example: 'Paris', required: false, description: 'Ville de naissance' })
@IsOptional()
@ApiProperty({ example: 'Paris', description: 'Ville de naissance (obligatoire)' })
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@IsNotEmpty({ message: 'La ville de naissance est requise' })
@MinLength(2, { message: 'La ville de naissance doit contenir au moins 2 caractères' })
@MaxLength(100)
lieu_naissance_ville?: string;
lieu_naissance_ville: string;
@ApiProperty({ example: 'France', required: false, description: 'Pays de naissance' })
@IsOptional()
@ApiProperty({ example: 'France', description: 'Pays de naissance (obligatoire)' })
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@IsNotEmpty({ message: 'Le pays de naissance est requis' })
@MinLength(2, { message: 'Le pays de naissance doit contenir au moins 2 caractères' })
@MaxLength(100)
lieu_naissance_pays?: string;
lieu_naissance_pays: string;
@ApiProperty({ example: '123456789012345', description: 'NIR 15 caractères (chiffres, ou 2A/2B pour la Corse)' })
@IsString()
@@ -128,6 +138,17 @@ export class RegisterAMCompletDto {
@Max(10, { message: 'La capacité ne peut pas dépasser 10' })
capacite_accueil: number;
@ApiProperty({
example: 2,
description: 'Nombre de places libres actuellement (≤ capacité d\'accueil)',
minimum: 0,
maximum: 10,
})
@IsInt()
@Min(0, { message: 'Les places disponibles ne peuvent pas être négatives' })
@Max(10, { message: 'Les places disponibles ne peuvent pas dépasser 10' })
places_disponibles: number;
// ============================================
// ÉTAPE 3 : PRÉSENTATION (Optionnel)
// ============================================
@@ -0,0 +1,17 @@
import { ApiProperty } from '@nestjs/swagger';
import { StatutUtilisateurType } from 'src/entities/users.entity';
/** Réponse 201 POST /auth/register/am (alignée sur les champs clés de /auth/register/parent). */
export class RegisterAmResponseDto {
@ApiProperty()
message: string;
@ApiProperty({ format: 'uuid' })
user_id: string;
@ApiProperty({ enum: StatutUtilisateurType, example: StatutUtilisateurType.EN_ATTENTE })
statut: StatutUtilisateurType;
@ApiProperty({ example: '2026-000015', description: 'Numéro de dossier attribué à linscription' })
numero_dossier: string;
}
@@ -1,7 +1,11 @@
import { ApiProperty } from '@nestjs/swagger';
import { RoleType } from 'src/entities/users.entity';
import {
DossierFamilleEnfantDto,
DossierFamilleParentDto,
} from '../../parents/dto/dossier-famille-complet.dto';
/** Réponse GET /auth/reprise-dossier données dossier pour préremplir le formulaire reprise. Ticket #111 */
/** Réponse GET /auth/reprise-dossier dossier complet pour préremplir le wizard reprise. #111 + #112 */
export class RepriseDossierDto {
@ApiProperty()
id: string;
@@ -41,4 +45,47 @@ export class RepriseDossierDto {
@ApiProperty({ required: false })
situation_familiale?: string;
// --- Parent (dossier famille, aligné #119) ---
@ApiProperty({ type: [DossierFamilleParentDto], required: false })
parents?: DossierFamilleParentDto[];
@ApiProperty({ type: [DossierFamilleEnfantDto], required: false })
enfants?: DossierFamilleEnfantDto[];
@ApiProperty({ required: false, description: 'Motivation / présentation dossier famille' })
texte_motivation?: string;
// --- AM (aligné DossierAmCompletDto) ---
@ApiProperty({ required: false })
consentement_photo?: boolean;
@ApiProperty({ required: false })
date_naissance?: Date;
@ApiProperty({ required: false })
lieu_naissance_ville?: string;
@ApiProperty({ required: false })
lieu_naissance_pays?: string;
@ApiProperty({ required: false })
numero_agrement?: string;
@ApiProperty({ required: false })
nir?: string;
@ApiProperty({ required: false })
date_agrement?: Date;
@ApiProperty({ required: false })
nb_max_enfants?: number;
@ApiProperty({ required: false })
place_disponible?: number;
@ApiProperty({ required: false })
biographie?: string;
}
@@ -0,0 +1,25 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, MinLength, Matches } from 'class-validator';
/** Ticket #127 — mêmes règles que [CreatePasswordDto] (inscription #118). */
export class ResetPasswordDto {
@ApiProperty({ description: 'Token UUID reçu par e-mail (lien reset-password)' })
@IsString()
@MinLength(1, { message: 'Token manquant' })
token: string;
@ApiProperty({
description: 'Nouveau mot de passe (min 8 caractères, 1 majuscule, 1 chiffre)',
minLength: 8,
})
@IsString()
@MinLength(8, { message: 'Le mot de passe doit contenir au moins 8 caractères' })
@Matches(/^(?=.*[A-Z])(?=.*\d)/, {
message: 'Le mot de passe doit contenir au moins une majuscule et un chiffre',
})
password: string;
@ApiProperty({ description: 'Confirmation du nouveau mot de passe' })
@IsString()
password_confirmation: string;
}
@@ -1,12 +1,29 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, IsString, MaxLength, IsUUID } from 'class-validator';
import {
IsOptional,
IsString,
MaxLength,
IsUUID,
IsBoolean,
IsArray,
ValidateNested,
IsInt,
Min,
Max,
IsDateString,
Matches,
} from 'class-validator';
import { Type } from 'class-transformer';
import { EnfantRepriseDto } from './enfant-reprise.dto';
/** Body PUT /auth/reprise-resoumettre token + champs modifiables. Ticket #111 */
/** Body PATCH /auth/reprise-resoumettre token + champs modifiables du wizard. #111 + #112 */
export class ResoumettreRepriseDto {
@ApiProperty({ description: 'Token reprise (reçu par email)' })
@IsUUID()
token: string;
// --- Identité titulaire (parent principal ou AM) ---
@ApiProperty({ required: false })
@IsOptional()
@IsString()
@@ -42,8 +59,147 @@ export class ResoumettreRepriseDto {
@MaxLength(10)
code_postal?: string;
@ApiProperty({ required: false, description: 'Pour AM' })
@ApiProperty({ required: false, description: 'Pour AM (URL photo existante)' })
@IsOptional()
@IsString()
photo_url?: string;
@ApiProperty({ required: false, description: 'Pour AM (nouvelle photo base64)' })
@IsOptional()
@IsString()
photo_base64?: string;
@ApiProperty({ required: false, description: 'Pour AM (nom fichier photo)' })
@IsOptional()
@IsString()
photo_filename?: string;
// --- Co-parent (reprise parent) ---
@ApiProperty({ required: false })
@IsOptional()
@IsString()
co_parent_email?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
co_parent_prenom?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
co_parent_nom?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
@Matches(/^(\+33|0)[1-9](\d{2}){4}$/, {
message: 'Le numéro de téléphone du co-parent doit être valide',
})
co_parent_telephone?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsBoolean()
co_parent_meme_adresse?: boolean;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
co_parent_adresse?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
co_parent_code_postal?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
co_parent_ville?: string;
// --- Motivation dossier famille ---
@ApiProperty({ required: false, description: 'Alias texte_motivation' })
@IsOptional()
@IsString()
@MaxLength(2000)
texte_motivation?: string;
@ApiProperty({ required: false, description: 'Alias presentation_dossier (inscription)' })
@IsOptional()
@IsString()
@MaxLength(2000)
presentation_dossier?: string;
@ApiProperty({ type: [EnfantRepriseDto], required: false })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => EnfantRepriseDto)
enfants?: EnfantRepriseDto[];
// --- AM ---
@ApiProperty({ required: false })
@IsOptional()
@IsBoolean()
consentement_photo?: boolean;
@ApiProperty({ required: false })
@IsOptional()
@IsDateString()
date_naissance?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
@MaxLength(100)
lieu_naissance_ville?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
@MaxLength(100)
lieu_naissance_pays?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
@MaxLength(50)
numero_agrement?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
@Matches(/^[1-3]\d{4}(?:2A|2B|\d{2})\d{6}\d{2}$/, {
message: 'Le NIR doit contenir 15 caractères (chiffres, ou 2A/2B pour la Corse)',
})
nir?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsDateString()
date_agrement?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsInt()
@Min(1)
@Max(10)
capacite_accueil?: number;
@ApiProperty({ required: false })
@IsOptional()
@IsInt()
@Min(0)
@Max(10)
places_disponibles?: number;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
@MaxLength(2000)
biographie?: string;
}
@@ -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,
);
});
});
@@ -0,0 +1,84 @@
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, 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,
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)
@ApiOperation({ summary: 'Dossier complet par numéro (AM ou famille) Ticket #119' })
@ApiParam({ name: 'numeroDossier', description: 'Numéro de dossier (ex: 2026-000001)' })
@ApiResponse({ status: 200, description: 'Dossier famille ou AM', type: DossierUnifieDto })
@ApiResponse({ status: 404, description: 'Aucun dossier pour ce numéro' })
@ApiResponse({ status: 403, description: 'Accès refusé' })
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);
}
}
+27 -1
View File
@@ -1,4 +1,30 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
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';
@Module({})
@Module({
imports: [
TypeOrmModule.forFeature([Parents, AssistanteMaternelle]),
ParentsModule,
SuppressionsModule,
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
secret: config.get('jwt.accessSecret'),
signOptions: { expiresIn: config.get('jwt.accessExpiresIn') },
}),
inject: [ConfigService],
}),
],
controllers: [DossiersController],
providers: [DossiersService],
exports: [DossiersService],
})
export class DossiersModule {}
@@ -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);
});
});
@@ -0,0 +1,268 @@
import { Injectable, NotFoundException } from '@nestjs/common';
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';
/**
* Dossiers unifiés détail (#119) + liste (#153) + sans_enfant (#159).
*/
@Injectable()
export class DossiersService {
constructor(
@InjectRepository(Parents)
private readonly parentsRepository: Repository<Parents>,
@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) {
throw new NotFoundException('Numéro de dossier requis.');
}
// 1) Famille : un parent a ce numéro ?
const parentWithNum = await this.parentsRepository.findOne({
where: { numero_dossier: num },
select: ['user_id'],
});
if (parentWithNum) {
const dossier = await this.parentsService.getDossierFamilleByNumero(num);
return { type: 'family', dossier };
}
// 2) AM : une assistante maternelle a ce numéro ?
const am = await this.amRepository.findOne({
where: { numero_dossier: num },
relations: ['user'],
});
if (am?.user) {
const dossier: DossierAmCompletDto = {
numero_dossier: num,
user: this.toDossierAmUserDto(am.user),
numero_agrement: am.approval_number,
nir: am.nir,
biographie: am.biography,
disponible: am.available,
ville_residence: am.residence_city,
date_agrement: am.agreement_date,
annees_experience: am.years_experience,
specialite: am.specialty,
nb_max_enfants: am.max_children,
place_disponible: am.places_available,
};
return { type: 'am', dossier };
}
throw new NotFoundException('Aucun dossier trouvé pour ce numéro.');
}
private toDossierAmUserDto(user: {
id: string;
email: string;
prenom?: string;
nom?: string;
telephone?: string;
adresse?: string;
ville?: string;
code_postal?: string;
profession?: string;
date_naissance?: Date;
lieu_naissance_ville?: string;
lieu_naissance_pays?: string;
photo_url?: string;
consentement_photo?: boolean;
statut: any;
}): DossierAmUserDto {
return {
id: user.id,
email: user.email,
prenom: user.prenom,
nom: user.nom,
telephone: user.telephone,
adresse: user.adresse,
ville: user.ville,
code_postal: user.code_postal,
profession: user.profession,
date_naissance: user.date_naissance,
lieu_naissance_ville: user.lieu_naissance_ville,
lieu_naissance_pays: user.lieu_naissance_pays,
photo_url: user.photo_url,
consentement_photo: user.consentement_photo,
statut: user.statut,
};
}
}
@@ -0,0 +1,64 @@
import { ApiProperty } from '@nestjs/swagger';
import { StatutUtilisateurType } from 'src/entities/users.entity';
/** Utilisateur AM sans données sensibles (pour dossier AM complet). Ticket #119 */
export class DossierAmUserDto {
@ApiProperty()
id: string;
@ApiProperty()
email: string;
@ApiProperty({ required: false })
prenom?: string;
@ApiProperty({ required: false })
nom?: string;
@ApiProperty({ required: false })
telephone?: string;
@ApiProperty({ required: false })
adresse?: string;
@ApiProperty({ required: false })
ville?: string;
@ApiProperty({ required: false })
code_postal?: string;
@ApiProperty({ required: false })
profession?: string;
@ApiProperty({ required: false })
date_naissance?: Date;
@ApiProperty({ required: false, description: 'Ville de naissance' })
lieu_naissance_ville?: string;
@ApiProperty({ required: false, description: 'Pays de naissance' })
lieu_naissance_pays?: string;
@ApiProperty({ required: false })
photo_url?: string;
@ApiProperty({ required: false, description: 'Consentement utilisation photo' })
consentement_photo?: boolean;
@ApiProperty({ enum: StatutUtilisateurType })
statut: StatutUtilisateurType;
}
/** Dossier AM complet (fiche AM sans secrets). Ticket #119 */
export class DossierAmCompletDto {
@ApiProperty({ example: '2026-000003', description: 'Numéro de dossier AM' })
numero_dossier: string;
@ApiProperty({ type: DossierAmUserDto, description: 'Utilisateur (sans mot de passe ni tokens)' })
user: DossierAmUserDto;
@ApiProperty({ required: false })
numero_agrement?: string;
@ApiProperty({ required: false })
nir?: string;
@ApiProperty({ required: false })
biographie?: string;
@ApiProperty({ required: false })
disponible?: boolean;
@ApiProperty({ required: false })
ville_residence?: string;
@ApiProperty({ required: false })
date_agrement?: Date;
@ApiProperty({ required: false })
annees_experience?: number;
@ApiProperty({ required: false })
specialite?: string;
@ApiProperty({ required: false })
nb_max_enfants?: number;
@ApiProperty({ required: false })
place_disponible?: number;
}
@@ -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;
}
@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { DossierFamilleCompletDto } from '../../parents/dto/dossier-famille-complet.dto';
import { DossierAmCompletDto } from './dossier-am-complet.dto';
/** Réponse unifiée GET /dossiers/:numeroDossier AM ou famille. Ticket #119 */
export class DossierUnifieDto {
@ApiProperty({ enum: ['family', 'am'], description: 'Type de dossier' })
type: 'family' | 'am';
@ApiProperty({
description: 'Dossier famille (si type=family) ou dossier AM (si type=am)',
})
dossier: DossierFamilleCompletDto | DossierAmCompletDto;
}
@@ -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);
}
@@ -0,0 +1,61 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
/** Identité minimale enfant pour le bandeau couple — ticket #168 */
export class CoupleGardeEnfantDto {
@ApiProperty({ format: 'uuid' })
id: string;
@ApiPropertyOptional()
prenom?: string | null;
@ApiPropertyOptional()
nom?: string | null;
@ApiPropertyOptional()
photo_url?: string | null;
}
/** Identité minimale AM pour le bandeau couple — ticket #168 */
export class CoupleGardeAmDto {
@ApiProperty({ format: 'uuid', description: 'UUID utilisateur de lAM' })
id: string;
@ApiPropertyOptional()
prenom?: string | null;
@ApiPropertyOptional()
nom?: string | null;
@ApiPropertyOptional()
photo_url?: string | null;
}
/** Un couple de garde = placement actif enfant ↔ AM */
export class CoupleGardeDto {
@ApiProperty({
format: 'uuid',
description: 'Id du placement (enfants_assistantes_maternelles.id)',
})
id: string;
@ApiProperty({ type: CoupleGardeEnfantDto })
enfant: CoupleGardeEnfantDto;
@ApiProperty({ type: CoupleGardeAmDto })
am: CoupleGardeAmDto;
@ApiProperty({ description: 'True si cest le couple actuellement sélectionné' })
courant: boolean;
}
export class CouplesGardeResponseDto {
@ApiProperty({ type: [CoupleGardeDto] })
couples: CoupleGardeDto[];
@ApiPropertyOptional({
format: 'uuid',
nullable: true,
description: 'Id du couple courant (null si aucun / premier couple implicite côté client)',
})
couple_courant_id: string | null;
}
@@ -0,0 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsUUID } from 'class-validator';
/** Corps PUT couple de garde courant — ticket #168 */
export class DefinirCoupleGardeCourantDto {
@ApiProperty({
format: 'uuid',
description: 'Id du placement (enfants_assistantes_maternelles.id) à sélectionner',
})
@IsUUID()
@IsNotEmpty()
couple_id: string;
}
@@ -1,7 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { StatutUtilisateurType } from 'src/entities/users.entity';
import { StatutDossierType } from 'src/entities/dossiers.entity';
import { StatutEnfantType } from 'src/entities/children.entity';
import { StatutEnfantType, GenreType } from 'src/entities/children.entity';
/** Parent dans le dossier famille (infos utilisateur + parent) */
export class DossierFamilleParentDto {
@@ -15,6 +14,12 @@ export class DossierFamilleParentDto {
nom?: string;
@ApiProperty({ required: false })
telephone?: string;
@ApiProperty({ required: false })
adresse?: string;
@ApiProperty({ required: false })
ville?: string;
@ApiProperty({ required: false })
code_postal?: string;
@ApiProperty({ enum: StatutUtilisateurType })
statut: StatutUtilisateurType;
@ApiProperty({ required: false, description: 'Id du co-parent si couple' })
@@ -29,32 +34,26 @@ export class DossierFamilleEnfantDto {
first_name?: string;
@ApiProperty({ required: false })
last_name?: string;
@ApiProperty({ required: false, enum: GenreType })
genre?: GenreType;
@ApiProperty({ required: false })
birth_date?: Date;
@ApiProperty({ required: false })
due_date?: Date;
@ApiProperty({ enum: StatutEnfantType })
status: StatutEnfantType;
}
/** Dossier (parent+enfant) avec presentation */
export class DossierFamillePresentationDto {
@ApiProperty()
id: string;
@ApiProperty()
id_parent: string;
@ApiProperty()
id_enfant: string;
@ApiProperty({ required: false, description: 'Texte de présentation' })
presentation?: string;
@ApiProperty({ required: false })
type_contrat?: string;
@ApiProperty()
repas: boolean;
@ApiProperty({ required: false })
budget?: number;
@ApiProperty({ enum: StatutDossierType })
statut: StatutDossierType;
@ApiProperty({
required: false,
description: 'Chemin ou URL de la photo (souvent relatif, ex. /uploads/photos/...)',
})
photo_url?: string;
@ApiProperty({
required: false,
description: 'Consentement affichage photo (colonne consentement_photo)',
})
consent_photo?: boolean;
}
/** Réponse GET /parents/dossier-famille/:numeroDossier dossier famille complet. Ticket #119 */
@@ -65,6 +64,6 @@ export class DossierFamilleCompletDto {
parents: DossierFamilleParentDto[];
@ApiProperty({ type: [DossierFamilleEnfantDto], description: 'Enfants de la famille' })
enfants: DossierFamilleEnfantDto[];
@ApiProperty({ type: [DossierFamillePresentationDto], description: 'Dossiers (présentation par parent/enfant)' })
presentation: DossierFamillePresentationDto[];
@ApiProperty({ required: false, description: 'Texte de présentation / motivation (un seul par famille)' })
texte_motivation?: string;
}
@@ -1,7 +1,33 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ParentPendingSummaryDto {
@ApiProperty({ description: 'UUID utilisateur' })
id: string;
@ApiProperty()
email: string;
@ApiPropertyOptional({ nullable: true })
nom?: string | null;
@ApiPropertyOptional({ nullable: true })
prenom?: string | null;
@ApiPropertyOptional({ nullable: true })
telephone?: string | null;
@ApiPropertyOptional({ nullable: true })
code_postal?: string | null;
@ApiPropertyOptional({ nullable: true })
ville?: string | null;
}
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({
@@ -17,4 +43,30 @@ export class PendingFamilyDto {
description: 'Numéro de dossier famille (format AAAA-NNNNNN)',
})
numero_dossier: string | null;
@ApiProperty({
nullable: true,
example: '2026-01-12T10:00:00.000Z',
description: 'Date de référence dossier soumis / en attente : MIN(cree_le) des parents en_attente du groupe (ISO 8601)',
})
date_soumission: string | null;
@ApiProperty({
example: 3,
description: 'Nombre denfants distincts liés aux parents de la famille (enfants_parents)',
})
nombre_enfants: number;
@ApiPropertyOptional({
type: [String],
example: ['parent1@example.com', 'parent2@example.com'],
description: 'Emails des parents du groupe (ordre stable : nom, prénom)',
})
emails?: string[];
@ApiPropertyOptional({
type: [ParentPendingSummaryDto],
description: 'Résumé des parents (ordre stable, aligné sur parentIds/emails)',
})
parents?: ParentPendingSummaryDto[];
}
@@ -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;
}

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