diff --git a/backend/scripts/close-gitea-issue-131.js b/backend/scripts/close-gitea-issue-131.js new file mode 100644 index 0000000..756a8e8 --- /dev/null +++ b/backend/scripts/close-gitea-issue-131.js @@ -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); + } +})(); diff --git a/backend/scripts/create-gitea-issue-gestionnaire-self-delete.js b/backend/scripts/create-gitea-issue-gestionnaire-self-delete.js new file mode 100644 index 0000000..0bc75cb --- /dev/null +++ b/backend/scripts/create-gitea-issue-gestionnaire-self-delete.js @@ -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(); diff --git a/docs/serveur_ptitspas.code-workspace b/docs/serveur_ptitspas.code-workspace new file mode 100644 index 0000000..ef9f5d2 --- /dev/null +++ b/docs/serveur_ptitspas.code-workspace @@ -0,0 +1,7 @@ +{ + "folders": [ + { + "path": "." + } + ] +} \ No newline at end of file diff --git a/docs/tmp/112-back-reprise-alignement-front.md b/docs/tmp/112-back-reprise-alignement-front.md new file mode 100644 index 0000000..65eff55 --- /dev/null +++ b/docs/tmp/112-back-reprise-alignement-front.md @@ -0,0 +1,244 @@ +# #112 — Alignement front après évolution back (reprise dossier complet) + +**Branche déployée :** `feature/112-reprise-apres-refus-front` +**Commit back :** `d70577b1` — `feat(#112): reprise après refus — dossier complet GET/PATCH` +**Date :** 2026-06-16 + +Ce document décrit le **contrat API réel** après extension du back, et ce que le front doit encore brancher pour exploiter le dossier complet (au-delà de l’identité seule). + +--- + +## 1. Endpoints (inchangés côté URL) + +| Méthode | Route | Auth | +|---------|-------|------| +| `GET` | `/api/v1/auth/reprise-dossier?token={uuid}` | Public | +| `PATCH` | `/api/v1/auth/reprise-resoumettre` | Public | +| `POST` | `/api/v1/auth/reprise-identify` | Public (inchangé) | + +> **Note :** le ticket #111 parlait de `PUT` ; l’implémentation reste en **`PATCH`** (comme avant). + +--- + +## 2. `GET /auth/reprise-dossier` — réponse enrichie + +### Champs communs (toujours présents) + +Identiques à avant : `id`, `email`, `prenom`, `nom`, `telephone`, `adresse`, `ville`, `code_postal`, `numero_dossier`, `role`, `photo_url`, `genre`, `situation_familiale`. + +### Rôle `parent` (+ champs #119) + +Alignés sur `DossierFamilleCompletDto` : + +```json +{ + "parents": [ + { + "user_id": "uuid", + "email": "…", + "prenom": "…", + "nom": "…", + "telephone": "…", + "adresse": "…", + "ville": "…", + "code_postal": "…", + "statut": "refuse", + "co_parent_id": "uuid-parent-entity" + } + ], + "enfants": [ + { + "id": "uuid-enfant", + "first_name": "Emma", + "last_name": "MARTIN", + "genre": "F", + "status": "actif", + "birth_date": "2023-02-15T00:00:00.000Z", + "due_date": null, + "photo_url": "/uploads/photos/…", + "consent_photo": true, + "est_multiple": false + } + ], + "texte_motivation": "Nous recherchons…" +} +``` + +**Mapping front suggéré :** + +| JSON back | Modèle / wizard parent | +|-----------|-------------------------| +| `parents[]` | `UserRegistrationData.parent1` + `parent2` (matcher par `email` ou ordre : titulaire = `id` du GET racine) | +| `enfants[].first_name` / `last_name` | `ChildData.firstName` / `lastName` | +| `enfants[].birth_date` | `ChildData.birthDate` (ISO → `DateTime`) | +| `enfants[].due_date` | `ChildData.dueDate` (enfant `a_naitre`) | +| `enfants[].status` | `actif` = né, `a_naitre` = à naître | +| `enfants[].photo_url` | `ApiConfig.absoluteMediaUrl()` + conserver pour reprise sans re-upload | +| `enfants[].id` | **Obligatoire** pour le PATCH (update par id) | +| `enfants[].est_multiple` | `grossesse_multiple` si utilisé | +| `texte_motivation` | étape présentation / motivation | + +Si `numero_dossier` absent : pas de `parents[]` / `enfants[]` / `texte_motivation` (identité seule). + +### Rôle `assistante_maternelle` + +Champs racine + fiche pro (structure **aplatie**, pas de sous-objet `user`) : + +```json +{ + "consentement_photo": true, + "date_naissance": "1985-03-12T00:00:00.000Z", + "lieu_naissance_ville": "Paris", + "lieu_naissance_pays": "France", + "numero_agrement": "AGR-2024-12345", + "nir": "123456789012345", + "date_agrement": "2024-06-01T00:00:00.000Z", + "nb_max_enfants": 4, + "place_disponible": 2, + "biographie": "…" +} +``` + +**Mapping `AmRegistrationData` :** + +| JSON back | Champ front | +|-----------|-------------| +| `nb_max_enfants` | `capaciteAccueil` | +| `place_disponible` | `placesDisponibles` | +| `numero_agrement` | `numeroAgrement` | +| `biographie` | `biographie` / présentation | +| `photo_url` | déjà géré via `RepriseSession.photoUrl` | + +--- + +## 3. `PATCH /auth/reprise-resoumettre` — body étendu + +### Commun + +```json +{ "token": "uuid-reprise" } +``` + +### Parent — champs à envoyer depuis le wizard + +| Champ PATCH | Source wizard | Notes | +|-------------|---------------|-------| +| `prenom`, `nom`, `telephone`, `adresse`, `ville`, `code_postal` | Parent 1 (titulaire token) | Champs racine | +| `co_parent_prenom`, `co_parent_nom`, `co_parent_telephone` | Parent 2 | | +| `co_parent_meme_adresse`, `co_parent_adresse`, `co_parent_code_postal`, `co_parent_ville` | Parent 2 adresse | | +| `texte_motivation` **ou** `presentation_dossier` | Étape motivation | Les deux alias acceptés | +| `enfants[]` | Liste enfants | Voir ci-dessous | + +**Structure `enfants[]` (miroir inscription + `id` obligatoire) :** + +```json +{ + "id": "uuid-enfant-existant", + "prenom": "Emma", + "nom": "MARTIN", + "date_naissance": "2023-02-15", + "date_previsionnelle_naissance": null, + "genre": "F", + "photo_base64": "data:image/jpeg;base64,…", + "photo_filename": "emma.jpg", + "grossesse_multiple": false +} +``` + +- **v1 back :** update par `id` uniquement — pas de création/suppression d’enfant. +- Si `id` inconnu pour ce dossier → **400** `Enfant inconnu pour ce dossier : {id}`. +- Sans nouvelle photo : ne pas envoyer `photo_base64` (l’existant est conservé). + +### AM — champs à envoyer + +| Champ PATCH | Source | +|-------------|--------| +| Identité + `photo_url` ou `photo_base64` + `photo_filename` | Étapes 1–2 | +| `consentement_photo`, `date_naissance`, `lieu_naissance_ville`, `lieu_naissance_pays` | Identité | +| `numero_agrement`, `nir`, `date_agrement` | Pro | +| `capacite_accueil`, `places_disponibles` | Pro | +| `biographie` | Présentation | + +Validation NIR identique à l’inscription si `nir` fourni. + +### Réponse succès (nouveau format) + +```json +{ + "message": "Dossier resoumis avec succès. Il est de nouveau en attente de validation.", + "statut": "en_attente", + "user_id": "uuid", + "numero_dossier": "2026-000021" +} +``` + +Code HTTP : **200** (pas de corps `Users` brut comme l’ancien back). + +### Effet métier + +- **Parent :** tous les users `role=parent` avec le même `numero_dossier` passent en `en_attente` ; `token_reprise` invalidé sur **tous** (symétrique refus #110). +- **AM :** un seul user. + +### E-mail accusé resoumission (parent) + +Après `PATCH` réussi, un e-mail est envoyé à **chaque parent** du dossier (`sendResoumissionPendingEmail`) : +- confirmation de resoumission ; +- rappel du **numéro de dossier** ; +- mention « en attente de validation ». + +Échec SMTP : logué, **ne bloque pas** la resoumission (même règle que l'inscription initiale). + +--- + +## 4. Fichiers front à modifier (checklist) + +### Modèles + +- [ ] `lib/models/reprise_dossier.dart` — parser `parents[]`, `enfants[]`, `texte_motivation`, champs AM +- [ ] Réutiliser ou mapper vers `DossierFamilleEnfant` / structures existantes (#119 admin) si possible + +### Session / préremplissage + +- [ ] `lib/services/reprise_session.dart` + - `applyToParent` : remplir parent1/parent2 depuis `parents[]`, enfants, motivation + - `applyToAm` : remplir tous les champs AM + +### API + +- [ ] `lib/services/auth_service.dart` — `resoumettreReprise()` : accepter body complet (parent + AM), pas seulement identité +- [ ] Étendre `UserRegistrationData` / `AmRegistrationData` helpers `toReprisePatchBody()` si utile + +### Écrans fin de parcours + +- [ ] `parent_register_step5_screen.dart` — PATCH avec co-parent, enfants, motivation +- [ ] `am_register_step4_screen.dart` — PATCH avec fiche AM complète + +### Hors scope back (inchangé) + +RIB / IBAN / attestation CAF (étape 5 wizard parent) : **non persistés** — rien à envoyer en reprise. + +### Non implémenté front (ticket #112 initial) + +- [ ] Modale login « J’ai un numéro de dossier » → `POST /auth/reprise-identify` (back prêt, front absent) + +--- + +## 5. Tests manuels suggérés + +1. Refuser un dossier parent complet (≥1 enfant + co-parent + motivation). +2. Ouvrir le lien mail `/reprise?token=…`. +3. Vérifier dans DevTools que le GET contient `enfants[]` et `texte_motivation`. +4. Après branchement front : wizard prérempli sur toutes les étapes. +5. Resoumettre → statut `en_attente` pour les deux parents ; dossier visible file validation admin (#119). + +--- + +## 6. Références code back + +``` +backend/src/routes/auth/dto/reprise-dossier.dto.ts +backend/src/routes/auth/dto/resoumettre-reprise.dto.ts +backend/src/routes/auth/dto/enfant-reprise.dto.ts +backend/src/routes/auth/auth.service.ts → getRepriseDossier, resoumettreReprise +backend/src/routes/parents/dto/dossier-famille-complet.dto.ts +``` diff --git a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart index a1b5f92..4f89ab7 100644 --- a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart +++ b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart @@ -5,6 +5,7 @@ import 'package:p_tits_pas/utils/email_utils.dart'; import 'package:p_tits_pas/widgets/email_text_field.dart'; import 'package:p_tits_pas/widgets/french_phone_field.dart'; import 'package:p_tits_pas/models/user.dart'; +import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/relais_service.dart'; import 'package:p_tits_pas/services/user_service.dart'; @@ -41,9 +42,15 @@ class _AdminUserFormDialogState extends State { bool _isLoadingRelais = true; List _relais = []; String? _selectedRelaisId; + String? _currentUserId; bool get _isEditMode => widget.initialUser != null; bool get _isSuperAdminTarget => (widget.initialUser?.role ?? '').toLowerCase() == 'super_admin'; + bool get _isSelfTarget => + _isEditMode && + _currentUserId != null && + widget.initialUser!.id == _currentUserId; + bool get _canDeleteTarget => !_isSuperAdminTarget && !_isSelfTarget; bool get _isLockedAdminIdentity => _isEditMode && widget.adminMode && _isSuperAdminTarget; String get _targetRoleKey { @@ -109,6 +116,23 @@ class _AdminUserFormDialogState extends State { } else { _isLoadingRelais = false; } + _loadCurrentUserId(); + } + + Future _loadCurrentUserId() async { + final cached = await AuthService.getCurrentUser(); + if (!mounted) return; + if (cached != null) { + setState(() { + _currentUserId = cached.id; + }); + return; + } + final refreshed = await AuthService.refreshCurrentUser(); + if (!mounted || refreshed == null) return; + setState(() { + _currentUserId = refreshed.id; + }); } @override @@ -335,7 +359,7 @@ class _AdminUserFormDialogState extends State { Future _delete() async { if (widget.readOnly) return; - if (_isSuperAdminTarget) return; + if (!_canDeleteTarget) return; if (!_isEditMode || _isSubmitting) return; final confirmed = await showDialog( @@ -462,7 +486,7 @@ class _AdminUserFormDialogState extends State { child: const Text('Fermer'), ), ] else if (_isEditMode) ...[ - if (!_isSuperAdminTarget) + if (_canDeleteTarget) OutlinedButton( onPressed: _isSubmitting ? null : _delete, style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700),