Compare commits
115
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0029c5ab86 | ||
|
|
2ce9e9215f | ||
|
|
3fdd913367 | ||
|
|
6708f73b06 | ||
|
|
f596f062a6 | ||
|
|
86701731e3 | ||
|
|
b4abb7d6de | ||
|
|
cb5c1a5518 | ||
|
|
30ca99fb65 | ||
|
|
8ee2ca8ea6 | ||
|
|
e3552667bc | ||
|
|
4ae334b247 | ||
|
|
471a62ddb7 | ||
|
|
59afeb0a8d | ||
|
|
d2172eafdb | ||
|
|
d247867fa0 | ||
|
|
93912d1374 | ||
|
|
925b6d5cd4 | ||
|
|
b0dddd6695 | ||
|
|
ef7512dc1e | ||
|
|
2ececa711b | ||
|
|
7a3d997ff9 | ||
|
|
6b89d7b405 | ||
|
|
f7ac628445 | ||
|
|
26e9738ec7 | ||
|
|
0ec3410457 | ||
|
|
c3acf1970d | ||
|
|
6fe2b89a61 | ||
|
|
d6a9b3fd66 | ||
|
|
134b9781c8 | ||
|
|
b936d27445 | ||
|
|
18c1d1eba7 | ||
|
|
f74b14c203 | ||
|
|
f194b5f9e8 | ||
|
|
5ab8ae3423 | ||
|
|
3277f77846 | ||
|
|
2d80ad0d7e | ||
|
|
09386f8aa6 | ||
|
|
faa50f637c | ||
|
|
267fe63aec | ||
|
|
90b185740c | ||
|
|
b903dbf60b | ||
|
|
291ea26b34 | ||
|
|
59919834b9 | ||
|
|
cf6acbae7c | ||
|
|
7951c38d4d | ||
|
|
77d952a6f7 | ||
|
|
b4b546044d | ||
|
|
6788351070 | ||
|
|
d6dac181d2 | ||
|
|
7b69f27ca5 | ||
|
|
003fe6b762 | ||
|
|
8ed68797aa | ||
|
|
9ad371a342 | ||
|
|
18718670e9 | ||
|
|
fbf22f2540 | ||
|
|
43a2cd213b | ||
|
|
c865d11dc3 | ||
|
|
d03a8e6c8b | ||
|
|
66c7f22280 | ||
|
|
53721ffbb3 | ||
|
|
52e40d0001 | ||
|
|
4985726bc6 | ||
|
|
479a32b4bf | ||
|
|
2fd97ddecb | ||
|
|
ce474797c4 | ||
|
|
ebf794e1ac | ||
|
|
d55f240f56 | ||
|
|
966f4a9c9c | ||
|
|
8494341b56 | ||
|
|
1dddc67933 | ||
|
|
b99745e0fe | ||
|
|
df776d8200 | ||
|
|
c26ed00374 | ||
|
|
9d54d9b19b | ||
|
|
c438009286 | ||
|
|
9b7231f1da | ||
|
|
f300505225 | ||
|
|
25c10c885a | ||
|
|
d70577b1c3 | ||
|
|
671da71752 | ||
|
|
c226c2fcdf | ||
|
|
e1684676a7 | ||
|
|
f71943fa79 | ||
|
|
65f10e9ca6 | ||
|
|
9eb62ddd13 | ||
|
|
b6e83bf9ef | ||
|
|
919148fa75 | ||
|
|
7ba8d51668 | ||
|
|
43dabd1885 | ||
|
|
a4eee819cd | ||
|
|
3fd4812a07 | ||
|
|
6f39813bb4 | ||
|
|
18f3a7f276 | ||
|
|
e6a087147a | ||
|
|
ca88710e49 | ||
|
|
644ba6c9c9 | ||
|
|
15123f691c | ||
|
|
e51e625d93 | ||
|
|
7765350d9a | ||
|
|
e2188fbc87 | ||
|
|
46d0f82f54 | ||
|
|
e887562a1c | ||
|
|
29623f33b9 | ||
|
|
3716dfe611 | ||
|
|
29cbbb1d08 | ||
|
|
dff108c7d5 | ||
|
|
e434fe2fbe | ||
|
|
12296c917e | ||
|
|
a140db8e9d | ||
|
|
2c746ceff3 | ||
|
|
fc86181a73 | ||
|
|
4723c78bbb | ||
|
|
699d163c6a | ||
|
|
d91caef0c5 |
@@ -89,6 +89,9 @@
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"moduleNameMapper": {
|
||||
"^src/(.*)$": "<rootDir>/$1"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Commentaire de clôture + fermeture issue Gitea #131.
|
||||
* Usage: node backend/scripts/close-gitea-issue-131.js
|
||||
*/
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const repoRoot = path.join(__dirname, '../..');
|
||||
const ISSUE = 131;
|
||||
const REPO = 'jmartin/petitspas';
|
||||
|
||||
const body = `## Fermeture ticket #131 — livré sur \`develop\` et \`master\`
|
||||
|
||||
Branche **\`feature/131\`** mergée dans **\`develop\`**, puis squash merge **\`develop\` → \`master\`** (déploiement production).
|
||||
|
||||
### Fiche parent (dashboard admin)
|
||||
- Modale **\`AdminParentEditModal\`** éditable dès l'ouverture
|
||||
- En-tête dynamique : **nom/prénom** + sous-titre **co-parent** (si connu)
|
||||
- Gélule **statut** modifiable
|
||||
- **\`PATCH /api/v1/parents/:id/fiche\`** — identité + statut
|
||||
- **\`GET /api/v1/parents\`** — liste parents (fix décorateur \`@Get()\` manquant)
|
||||
- Réponses API : \`co_parent\` peuplé, secrets user masqués (\`sanitizeUserForApi\`)
|
||||
- Liste enfants en bas de fiche + rattachement/détachement
|
||||
|
||||
### Fiche AM (dashboard admin)
|
||||
- Modale **\`AdminAmEditModal\`** — 3 onglets : Identité | Fiche pro | Enfants accueillis
|
||||
- **\`PATCH /api/v1/assistantes-maternelles/:id/fiche\`** — identité + champs pro (NIR, date/lieu naissance, date agrément, places, disponibilité)
|
||||
- **\`POST/DELETE …/enfants/:enfantId\`** — rattacher / détacher un enfant
|
||||
- Grille capacité **\`AdminAmChildrenCapacityGrid\`** (2×2)
|
||||
- Rattachement enfants **différé jusqu'à Sauvegarder** (pas d'appel API immédiat)
|
||||
|
||||
### Back — placement AM ↔ enfant
|
||||
- Table **\`enfants_assistantes_maternelles\`** (placement temporel, 1 garde active/enfant)
|
||||
- Enum enfant : \`a_naitre\`, \`garde\`, \`sans_garde\`, \`scolarise\` — **plus \`actif\`**
|
||||
- Rattachement → statut enfant \`garde\` ; détachement → \`sans_garde\`
|
||||
- Migration : \`database/migrations/2026_enfants_assistantes_maternelles.sql\`
|
||||
- Schéma canonique : \`database/BDD.sql\`
|
||||
|
||||
### Front — statuts & polish
|
||||
- **\`enfant_status_utils.dart\`** — libellés/couleurs \`garde\`/\`sans_garde\`
|
||||
- Mise à jour cartes enfants, modale détail, filtres dashboard
|
||||
|
||||
### Correctifs recette
|
||||
- \`GET /parents\` 404 → ajout \`@Get()\` sur \`getAll()\`
|
||||
- Sauvegarde AM 400 → alignement DTO \`UpdateAmFicheAdminDto\` sur payload front
|
||||
- Crash NIR → fix \`toISOString\` + pas d'effacement NIR (colonne NOT NULL)
|
||||
|
||||
### Hors périmètre #131 (tickets voisins)
|
||||
- **#137** onglet Enfants global · **#138** fiche enfant complète · **#115/#116** affiliation avancée
|
||||
|
||||
---
|
||||
*Issue fermée après merge sur \`develop\` + \`master\` et déploiement production.*`;
|
||||
|
||||
let token = process.env.GITEA_TOKEN;
|
||||
if (!token) {
|
||||
try {
|
||||
const tokenFile = path.join(repoRoot, '.gitea-token');
|
||||
if (fs.existsSync(tokenFile)) token = fs.readFileSync(tokenFile, 'utf8').trim();
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!token) {
|
||||
try {
|
||||
const briefing = fs.readFileSync(
|
||||
path.join(repoRoot, 'docs/27_BRIEFING-FRONTEND.md'),
|
||||
'utf8',
|
||||
);
|
||||
const m = briefing.match(/Token:\s*(giteabu_[a-f0-9]+)/);
|
||||
if (m) token = m[1].trim();
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!token) {
|
||||
console.error('Token non trouvé : .gitea-token ou GITEA_TOKEN');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function request(method, apiPath, payloadObj) {
|
||||
const payload = payloadObj ? JSON.stringify(payloadObj) : null;
|
||||
return new Promise((resolve, reject) => {
|
||||
const opts = {
|
||||
hostname: 'git.ptits-pas.fr',
|
||||
path: `/api/v1/repos/${REPO}${apiPath}`,
|
||||
method,
|
||||
headers: {
|
||||
Authorization: 'token ' + token,
|
||||
'Content-Type': 'application/json',
|
||||
...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}),
|
||||
},
|
||||
};
|
||||
const req = https.request(opts, (res) => {
|
||||
let d = '';
|
||||
res.on('data', (c) => (d += c));
|
||||
res.on('end', () => {
|
||||
if (res.statusCode !== 200 && res.statusCode !== 201) {
|
||||
reject(new Error(`HTTP ${res.statusCode}: ${d}`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(d ? JSON.parse(d) : {});
|
||||
} catch (_) {
|
||||
resolve({});
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
if (payload) req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
console.log(`POST commentaire issue #${ISSUE}...`);
|
||||
await request('POST', `/issues/${ISSUE}/comments`, { body });
|
||||
console.log('Commentaire publié.');
|
||||
console.log(`PATCH fermeture issue #${ISSUE}...`);
|
||||
await request('PATCH', `/issues/${ISSUE}`, { state: 'closed' });
|
||||
console.log(`Issue #${ISSUE} fermée.`);
|
||||
} catch (e) {
|
||||
console.error(e.message || e);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Crée l'issue Gitea — gestionnaire ne doit pas pouvoir se supprimer.
|
||||
* Usage: node backend/scripts/create-gitea-issue-gestionnaire-self-delete.js
|
||||
*/
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const repoRoot = path.join(__dirname, '../..');
|
||||
const MILESTONE_0_1_0 = 10;
|
||||
|
||||
let token = process.env.GITEA_TOKEN;
|
||||
if (!token) {
|
||||
try {
|
||||
const tokenFile = path.join(repoRoot, '.gitea-token');
|
||||
if (fs.existsSync(tokenFile)) token = fs.readFileSync(tokenFile, 'utf8').trim();
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!token) {
|
||||
try {
|
||||
const briefing = fs.readFileSync(
|
||||
path.join(repoRoot, 'docs/27_BRIEFING-FRONTEND.md'),
|
||||
'utf8',
|
||||
);
|
||||
const m = briefing.match(/Token:\s*(gitebu_[a-f0-9]+)/);
|
||||
if (m) token = m[1].trim();
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!token) {
|
||||
console.error('Token non trouvé : .gitea-token ou GITEA_TOKEN');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const body = `## Contexte
|
||||
|
||||
Quand un **gestionnaire** connecté ouvre sa propre fiche dans l'onglet **Gestionnaires** (Gestion des utilisateurs), la modale **Modifier un "Gestionnaire"** affiche le bouton **Supprimer**.
|
||||
|
||||
Comportement actuel : le gestionnaire voit et peut tenter de supprimer son propre compte.
|
||||
|
||||
## Comportement attendu
|
||||
|
||||
Comme pour le **super administrateur** (bouton Supprimer masqué sur la fiche super admin) :
|
||||
|
||||
- **Pas de bouton Supprimer** quand l'utilisateur édite **sa propre fiche**
|
||||
- **Modification** des informations (prénom, nom, email, téléphone, relais, mot de passe) **toujours autorisée**
|
||||
|
||||
## Périmètre
|
||||
|
||||
- Frontend : \`AdminUserFormDialog\` (\`gestionnaires_create.dart\`)
|
||||
- Comparer \`initialUser.id\` avec l'utilisateur connecté (\`AuthService.getCurrentUser\`)
|
||||
- Masquer Supprimer si édition de soi-même ; conserver garde existante super admin
|
||||
|
||||
## Critères d'acceptation
|
||||
|
||||
- [ ] Gestionnaire connecté → ouvre sa fiche → **pas** de bouton Supprimer
|
||||
- [ ] Gestionnaire connecté → peut **Modifier** ses informations
|
||||
- [ ] Super admin / admin → peut toujours supprimer **un autre** gestionnaire (si droits API)
|
||||
- [ ] Pas de régression sur fiche super administrateur (Supprimer toujours masqué)
|
||||
|
||||
## Fichiers clés
|
||||
|
||||
- \`frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart\`
|
||||
- \`frontend/lib/widgets/admin/gestionnaire_management_widget.dart\`
|
||||
|
||||
## Milestone
|
||||
|
||||
**0.1.0** — correction UX / sécurité gestion utilisateurs.`;
|
||||
|
||||
const payloadClean = JSON.stringify({
|
||||
title: '[Bug] Gestionnaire peut voir Supprimer sur sa propre fiche',
|
||||
body,
|
||||
milestone: MILESTONE_0_1_0,
|
||||
});
|
||||
|
||||
const opts = {
|
||||
hostname: 'git.ptits-pas.fr',
|
||||
path: '/api/v1/repos/jmartin/petitspas/issues',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'token ' + token,
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(payloadClean),
|
||||
},
|
||||
};
|
||||
|
||||
const req = https.request(opts, (res) => {
|
||||
let d = '';
|
||||
res.on('data', (c) => (d += c));
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const o = JSON.parse(d);
|
||||
if (o.number) {
|
||||
console.log('NUMBER:', o.number);
|
||||
console.log('URL:', o.html_url);
|
||||
console.log('MILESTONE:', o.milestone?.title ?? '(aucun)');
|
||||
} else {
|
||||
console.error('Réponse:', d);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(d);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', (e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
req.write(payloadClean);
|
||||
req.end();
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Met à jour l'issue Gitea #140 — epic dashboard admin ch.6 famille.
|
||||
* Usage: node backend/scripts/update-gitea-issue-140-ch6-famille.js
|
||||
* Token : .gitea-token (racine), GITEA_TOKEN, ou docs/27_BRIEFING-FRONTEND.md
|
||||
*/
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const repoRoot = path.join(__dirname, '../..');
|
||||
let token = process.env.GITEA_TOKEN;
|
||||
if (!token) {
|
||||
try {
|
||||
const tokenFile = path.join(repoRoot, '.gitea-token');
|
||||
if (fs.existsSync(tokenFile)) token = fs.readFileSync(tokenFile, 'utf8').trim();
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!token) {
|
||||
try {
|
||||
const briefing = fs.readFileSync(
|
||||
path.join(repoRoot, 'docs/27_BRIEFING-FRONTEND.md'),
|
||||
'utf8',
|
||||
);
|
||||
const m = briefing.match(/Token:\s*(giteabu_[a-f0-9]+)/);
|
||||
if (m) token = m[1].trim();
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!token) {
|
||||
console.error('Token non trouvé : .gitea-token ou GITEA_TOKEN (voir docs/26_GITEA-API.md)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const body = `## Rôle de ce ticket
|
||||
|
||||
**#140 est un ticket epic / livraison** : il regroupe la mise en œuvre du **chapitre 6** du doc [28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md](../docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md) (§6.1 et §6.2) sur la branche \`feature/140-dashboard-admin-ch6-famille\`.
|
||||
|
||||
Il **ne remplace pas** les tickets détaillés ci-dessous : il sert de **fil de livraison** (PR, recette, fermeture coordonnée). Chaque sous-ticket garde son périmètre propre ; #140 est clos quand l'ensemble est **fonctionnel et homogène en UI**.
|
||||
|
||||
---
|
||||
|
||||
## Tickets couverts (périmètres embarqués)
|
||||
|
||||
| Ticket | Sujet | Rôle dans #140 |
|
||||
|--------|--------|----------------|
|
||||
| **#115** | Rattachement parent — **backend** | API \`POST/DELETE …/enfants/:enfantId\` |
|
||||
| **#116** | Rattachement parent — **frontend** | UI rattacher / détacher depuis la fiche parent |
|
||||
| **#130** | \`UserService\` — APIs admin | Appels \`getParents\`, \`getParent\`, \`getEnfants\`, \`updateParentFiche\`, etc. |
|
||||
| **#131** | Fiche parent éditable | Modale parent (dashboard) — *hors fiche AM* |
|
||||
| **#137** | Onglet **Enfants** | Liste globale admin + accès fiche enfant |
|
||||
| **#138** | Fiche enfant + liste dans fiche parent | \`AdminChildDetailModal\` + liste enfants en bas de fiche parent |
|
||||
|
||||
> **Note :** fermer #140 peut entraîner la fermeture **partielle ou totale** de ces tickets selon ce qui est réellement livré et recetté dans la PR.
|
||||
|
||||
---
|
||||
|
||||
## Hors scope #140
|
||||
|
||||
- **§6.3** — Création dossier admin sans numéro → **#129**
|
||||
- Fiche **AM** éditable (dashboard) → reste **#131** (partie AM)
|
||||
- Parcours gestionnaire « famille complexe » → **#139**
|
||||
- Qualification responsable légal (combobox sur lien) → ticket à créer
|
||||
|
||||
---
|
||||
|
||||
## Backend (attendu / livré)
|
||||
|
||||
- [x] \`PATCH /parents/:id/fiche\` — édition fiche parent (admin/gestionnaire)
|
||||
- [x] \`POST /parents/:id/enfants/:enfantId\` — rattacher un enfant existant
|
||||
- [x] \`DELETE /parents/:id/enfants/:enfantId\` — détacher (garde-fou : ≥1 responsable / enfant)
|
||||
- [x] \`GET /enfants\` enrichi ; \`PATCH /enfants/:id\` pour gestionnaire
|
||||
|
||||
---
|
||||
|
||||
## Frontend — fait
|
||||
|
||||
- [x] Modale **fiche parent** éditable (shell aligné validation, statut gélule, téléphone formaté, \`IdentityBlock\`)
|
||||
- [x] Onglet **Enfants** — liste globale (\`EnfantManagementWidget\`)
|
||||
- [x] Liste enfants dans fiche parent — cartes (photo, nom, âge ans/mois, statut), cadre blanc, scroll 2,5 lignes
|
||||
- [x] Rattacher / détacher un enfant **existant** (API branchée)
|
||||
- [x] Parsing robuste \`parentChildren\` + URLs médias \`/uploads\` en Flutter web
|
||||
- [x] \`UserService\` — APIs parents / enfants / affiliation
|
||||
|
||||
---
|
||||
|
||||
## Frontend — reste à faire (bloquant clôture)
|
||||
|
||||
### Fiche enfant — #138 (UI)
|
||||
- [ ] Reprendre \`AdminChildDetailModal\` : même **look & feel** que fiche parent / modales validation (largeur ~930 px, grille champs, photo enfant)
|
||||
- [ ] Aligner dates, genre, statut sur les wizards validation famille
|
||||
|
||||
### Modale **rattacher** un enfant — #116 (UI)
|
||||
- [ ] Remplacer le \`SimpleDialog\` actuel par une modale cohérente : liste type \`AdminEnfantUserCard\` (photo, nom, âge), recherche éventuelle
|
||||
|
||||
### Création d'un **nouvel** enfant depuis la fiche parent — doc §6.2
|
||||
- [ ] **Non implémenté** aujourd'hui (seul le rattachement d'un enfant déjà en base existe)
|
||||
- [ ] À trancher : inclus dans #140 si le back expose un \`POST\` admin depuis le contexte parent, sinon ticket dédié / extension #129
|
||||
|
||||
---
|
||||
|
||||
## Recette avant merge
|
||||
|
||||
1. Parent avec 0 / 1 / 3+ enfants — liste, scroll, compteur
|
||||
2. Rattacher puis détacher (message si dernier responsable)
|
||||
3. Clic enfant → fiche enfant (après refonte UI)
|
||||
4. Onglet Enfants — même rendu cartes
|
||||
5. Avatars photos (URLs absolues web)
|
||||
|
||||
---
|
||||
|
||||
## Branche
|
||||
|
||||
\`feature/140-dashboard-admin-ch6-famille\`
|
||||
|
||||
## Références
|
||||
|
||||
- Doc produit : \`docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md\` §6.1, §6.2`;
|
||||
|
||||
const payload = JSON.stringify({ body });
|
||||
|
||||
const req = https.request(
|
||||
{
|
||||
hostname: 'git.ptits-pas.fr',
|
||||
path: '/api/v1/repos/jmartin/petitspas/issues/140',
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `token ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(payload),
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
res.on('end', () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
const json = JSON.parse(data);
|
||||
console.log('Issue #140 mise à jour :', json.html_url);
|
||||
console.log('updated_at:', json.updated_at);
|
||||
} else {
|
||||
console.error('Erreur', res.statusCode, data);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on('error', (err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
req.write(payload);
|
||||
req.end();
|
||||
@@ -0,0 +1,32 @@
|
||||
import { sanitizeUserForApi } from './sanitize-user-for-api';
|
||||
import { RoleType, StatutUtilisateurType, Users } from '../../entities/users.entity';
|
||||
|
||||
describe('sanitizeUserForApi', () => {
|
||||
const base: Users = {
|
||||
id: 'u1',
|
||||
email: 'a@b.fr',
|
||||
prenom: 'Paul',
|
||||
nom: 'Parent',
|
||||
role: RoleType.PARENT,
|
||||
statut: StatutUtilisateurType.ACTIF,
|
||||
password: 'hash',
|
||||
token_creation_mdp: 'tok',
|
||||
token_creation_mdp_expire_le: new Date(),
|
||||
password_reset_token: 'rst',
|
||||
password_reset_expires: new Date(),
|
||||
} as Users;
|
||||
|
||||
it('retire password et tokens', () => {
|
||||
const out = sanitizeUserForApi(base)!;
|
||||
expect(out.prenom).toBe('Paul');
|
||||
expect(out.nom).toBe('Parent');
|
||||
expect(out.password).toBeUndefined();
|
||||
expect(out.token_creation_mdp).toBeUndefined();
|
||||
expect(out.password_reset_token).toBeUndefined();
|
||||
});
|
||||
|
||||
it('retourne undefined si user absent', () => {
|
||||
expect(sanitizeUserForApi(null)).toBeUndefined();
|
||||
expect(sanitizeUserForApi(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Users } from 'src/entities/users.entity';
|
||||
|
||||
/** Champs sensibles exclus des réponses API (ticket #131 — user / co_parent). */
|
||||
const SENSITIVE_USER_KEYS: (keyof Users)[] = [
|
||||
'password',
|
||||
'token_creation_mdp',
|
||||
'token_creation_mdp_expire_le',
|
||||
'password_reset_token',
|
||||
'password_reset_expires',
|
||||
];
|
||||
|
||||
/**
|
||||
* Retourne une copie utilisateur sans secrets (hash MDP, tokens).
|
||||
* Utilisé pour `user` et `co_parent` dans les réponses Parents.
|
||||
*/
|
||||
export function sanitizeUserForApi(user?: Users | null): Users | undefined {
|
||||
if (!user) return undefined;
|
||||
const safe = { ...user } as Users;
|
||||
for (const key of SENSITIVE_USER_KEYS) {
|
||||
delete safe[key];
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
CreateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { AssistanteMaternelle } from './assistantes_maternelles.entity';
|
||||
import { Children } from './children.entity';
|
||||
import { Users } from './users.entity';
|
||||
|
||||
@Entity('enfants_assistantes_maternelles', { schema: 'public' })
|
||||
export class AmChildren {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'id_am', type: 'uuid' })
|
||||
amId: string;
|
||||
|
||||
@Column({ name: 'id_enfant', type: 'uuid' })
|
||||
enfantId: string;
|
||||
|
||||
@Column({ name: 'date_debut', type: 'date' })
|
||||
date_debut: Date;
|
||||
|
||||
@Column({ name: 'date_fin', type: 'date', nullable: true })
|
||||
date_fin?: Date;
|
||||
|
||||
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
|
||||
cree_le: Date;
|
||||
|
||||
@Column({ name: 'cree_par', type: 'uuid', nullable: true })
|
||||
cree_par?: string;
|
||||
|
||||
@ManyToOne(() => AssistanteMaternelle, (am) => am.amChildren, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'id_am', referencedColumnName: 'user_id' })
|
||||
am: AssistanteMaternelle;
|
||||
|
||||
@ManyToOne(() => Children, (c) => c.amLinks, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'id_enfant', referencedColumnName: 'id' })
|
||||
child: Children;
|
||||
|
||||
@ManyToOne(() => Users, { nullable: true, onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'cree_par', referencedColumnName: 'id' })
|
||||
createdBy?: Users;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Entity, PrimaryColumn, Column, OneToOne, JoinColumn } from 'typeorm';
|
||||
import { Entity, PrimaryColumn, Column, OneToOne, OneToMany, JoinColumn } from 'typeorm';
|
||||
import { Users } from './users.entity';
|
||||
import { AmChildren } from './am_children.entity';
|
||||
|
||||
@Entity('assistantes_maternelles')
|
||||
export class AssistanteMaternelle {
|
||||
@@ -51,4 +52,7 @@ export class AssistanteMaternelle {
|
||||
/** Numéro de dossier (format AAAA-NNNNNN), même valeur que sur utilisateurs (ticket #103) */
|
||||
@Column({ name: 'numero_dossier', length: 20, nullable: true })
|
||||
numero_dossier?: string;
|
||||
|
||||
@OneToMany(() => AmChildren, (ac) => ac.am)
|
||||
amChildren: AmChildren[];
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@ import {
|
||||
} from 'typeorm';
|
||||
import { Parents } from './parents.entity';
|
||||
import { ParentsChildren } from './parents_children.entity';
|
||||
import { AmChildren } from './am_children.entity';
|
||||
import { Dossier } from './dossiers.entity';
|
||||
|
||||
export enum StatutEnfantType {
|
||||
A_NAITRE = 'a_naitre',
|
||||
ACTIF = 'actif',
|
||||
SCOLARISE = 'scolarise',
|
||||
GARDE = 'garde',
|
||||
SANS_GARDE = 'sans_garde',
|
||||
}
|
||||
|
||||
export enum GenreType {
|
||||
@@ -68,6 +70,9 @@ export class Children {
|
||||
@OneToMany(() => ParentsChildren, pc => pc.child)
|
||||
parentLinks: ParentsChildren[];
|
||||
|
||||
@OneToMany(() => AmChildren, (ac) => ac.child)
|
||||
amLinks: AmChildren[];
|
||||
|
||||
// Relation avec Dossier
|
||||
@OneToMany(() => Dossier, d => d.child)
|
||||
dossiers: Dossier[];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { MailService, ValidationAccountEmailKind } from './mail.service';
|
||||
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;
|
||||
@@ -68,4 +82,37 @@ describe('MailService (ticket #28)', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,20 +2,121 @@ 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',
|
||||
@@ -81,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -202,6 +301,43 @@ export class MailService {
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
|
||||
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 été 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 été 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
|
||||
@@ -214,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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
|
||||
const subject = `Votre dossier – compléments demandés`;
|
||||
const commentBlock = comment
|
||||
? `<p><strong>Message du gestionnaire :</strong></p><p>${comment.replace(/</g, '<').replace(/>/g, '>')}</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;">
|
||||
@@ -238,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,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>
|
||||
+51
-2
@@ -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 l’e-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 d’une 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 d’une 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' };
|
||||
|
||||
+23
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
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';
|
||||
@@ -8,6 +21,8 @@ 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';
|
||||
@@ -87,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()
|
||||
@@ -133,6 +147,36 @@ export class AuthController {
|
||||
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 l’e-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')
|
||||
|
||||
@@ -12,11 +12,25 @@ import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entit
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { QueryFailedError, Repository } from 'typeorm';
|
||||
import { EntityManager, In, QueryFailedError, Repository } from 'typeorm';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
@@ -27,11 +27,15 @@ import { DossierFamille, DossierFamilleEnfant } from 'src/entities/dossier_famil
|
||||
import { StatutDossierType } from 'src/entities/dossiers.entity';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RepriseDossierDto } from './dto/reprise-dossier.dto';
|
||||
import { ResoumettreRepriseDto } from './dto/resoumettre-reprise.dto';
|
||||
import { RepriseIdentifyResponseDto } from './dto/reprise-identify.dto';
|
||||
import { AppConfigService } from 'src/modules/config/config.service';
|
||||
import { validateNir } from 'src/common/utils/nir.util';
|
||||
import { NumeroDossierService } from 'src/modules/numero-dossier/numero-dossier.service';
|
||||
import { MailService } from 'src/modules/mail/mail.service';
|
||||
import { ParentsService } from '../parents/parents.service';
|
||||
import { DossiersService } from '../dossiers/dossiers.service';
|
||||
import { DossierAmCompletDto } from '../dossiers/dto/dossier-am-complet.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@@ -44,6 +48,8 @@ export class AuthService {
|
||||
private readonly appConfigService: AppConfigService,
|
||||
private readonly mailService: MailService,
|
||||
private readonly numeroDossierService: NumeroDossierService,
|
||||
private readonly parentsService: ParentsService,
|
||||
private readonly dossiersService: DossiersService,
|
||||
@InjectRepository(Parents)
|
||||
private readonly parentsRepo: Repository<Parents>,
|
||||
@InjectRepository(Users)
|
||||
@@ -140,7 +146,9 @@ export class AuthService {
|
||||
async verifyCreatePasswordToken(token: string) {
|
||||
const cleanedToken = token?.trim();
|
||||
if (!cleanedToken) {
|
||||
throw new BadRequestException('Token manquant');
|
||||
// #118: côté front, un token absent doit être traité comme lien invalide/expiré.
|
||||
// On renvoie donc la même réponse neutre 404 que pour les autres cas invalides.
|
||||
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
|
||||
}
|
||||
|
||||
const user = await this.usersRepo.findOne({
|
||||
@@ -247,6 +255,124 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
/** Réponse unique pour POST forgot-password (anti-énumération), ticket #127 */
|
||||
private static readonly FORGOT_PASSWORD_GENERIC_MESSAGE =
|
||||
'Si cette adresse est associée à un compte pour lequel un mot de passe existe, vous recevrez un e-mail avec les instructions.';
|
||||
|
||||
/**
|
||||
* Ticket #127 — demande de réinitialisation (e-mail avec lien /reset-password).
|
||||
* Toujours la même réponse 200 ; n’utilise pas token_creation_mdp (#118 inscription).
|
||||
*/
|
||||
async requestPasswordReset(rawEmail: string): Promise<{ message: string }> {
|
||||
const generic = { message: AuthService.FORGOT_PASSWORD_GENERIC_MESSAGE };
|
||||
const email = (rawEmail ?? '').trim().toLowerCase();
|
||||
if (!email) {
|
||||
return generic;
|
||||
}
|
||||
|
||||
const user = await this.usersRepo
|
||||
.createQueryBuilder('u')
|
||||
.where('LOWER(TRIM(u.email)) = :email', { email })
|
||||
.select(['u.id', 'u.email', 'u.prenom', 'u.nom', 'u.password'])
|
||||
.getOne();
|
||||
|
||||
if (!user?.password) {
|
||||
return generic;
|
||||
}
|
||||
|
||||
const expiryDaysRaw = this.appConfigService.get<number>('password_reset_token_expiry_days', 7);
|
||||
const expiryDays = Number(expiryDaysRaw) > 0 ? Number(expiryDaysRaw) : 7;
|
||||
const expires = new Date();
|
||||
expires.setDate(expires.getDate() + expiryDays);
|
||||
|
||||
const token = crypto.randomUUID();
|
||||
|
||||
await this.usersRepo.update(
|
||||
{ id: user.id },
|
||||
{
|
||||
password_reset_token: token,
|
||||
password_reset_expires: expires,
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
await this.mailService.sendPasswordResetEmail({
|
||||
email: user.email,
|
||||
prenom: user.prenom ?? '',
|
||||
nom: user.nom ?? '',
|
||||
token,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
'[requestPasswordReset] Échec envoi e-mail (réponse inchangée pour anti-énumération)',
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
return generic;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ticket #127 — consomme password_reset_token (distinct de create-password / token_creation_mdp).
|
||||
*/
|
||||
async resetPasswordWithResetToken(token: string, plainPassword: string): Promise<{ message: string }> {
|
||||
const cleanedToken = token?.trim();
|
||||
if (!cleanedToken) {
|
||||
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
|
||||
}
|
||||
|
||||
const existingUser = await this.usersRepo.findOne({
|
||||
where: { password_reset_token: cleanedToken },
|
||||
select: ['id', 'password', 'password_reset_expires'],
|
||||
});
|
||||
|
||||
if (!existingUser?.password) {
|
||||
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
|
||||
}
|
||||
|
||||
if (!existingUser.password_reset_expires || existingUser.password_reset_expires <= new Date()) {
|
||||
await this.usersRepo
|
||||
.createQueryBuilder()
|
||||
.update(Users)
|
||||
.set({
|
||||
password_reset_token: () => 'NULL',
|
||||
password_reset_expires: () => 'NULL',
|
||||
})
|
||||
.where('id = :id', { id: existingUser.id })
|
||||
.execute();
|
||||
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
|
||||
}
|
||||
|
||||
const sel = await bcrypt.genSalt(12);
|
||||
const hashedPassword = await bcrypt.hash(plainPassword, sel);
|
||||
|
||||
const updateResult = await this.usersRepo
|
||||
.createQueryBuilder()
|
||||
.update(Users)
|
||||
.set({
|
||||
password: hashedPassword,
|
||||
password_reset_token: () => 'NULL',
|
||||
password_reset_expires: () => 'NULL',
|
||||
changement_mdp_obligatoire: false,
|
||||
})
|
||||
.where('password_reset_token = :token', { token: cleanedToken })
|
||||
.andWhere('password_reset_expires > NOW()')
|
||||
.execute();
|
||||
|
||||
if (!updateResult.affected) {
|
||||
this.logger.warn(
|
||||
'[resetPasswordWithResetToken] Token non consommé (course concurrente ou token invalidé)',
|
||||
);
|
||||
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
|
||||
}
|
||||
|
||||
this.logger.log('[resetPasswordWithResetToken] Mot de passe réinitialisé avec succès');
|
||||
|
||||
return {
|
||||
message: 'Mot de passe mis à jour. Vous pouvez vous connecter.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Inscription utilisateur OBSOLÈTE - Utiliser inscrireParentComplet() ou registerAM()
|
||||
* @deprecated
|
||||
@@ -290,11 +416,20 @@ export class AuthService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Inscription Parent COMPLÈTE - Workflow CDC 6 étapes en 1 transaction
|
||||
* Gère : Parent 1 + Parent 2 (opt) + Enfants + Présentation + CGU
|
||||
* Cœur partagé création dossier parent (#129).
|
||||
* - public : statut en_attente + mails pending
|
||||
* - staff : statut actif + mails création MDP (pas de mail « dossier en attente »)
|
||||
*/
|
||||
async inscrireParentComplet(dto: RegisterParentCompletDto) {
|
||||
if (!dto.acceptation_cgu || !dto.acceptation_privacy) {
|
||||
async createParentDossier(
|
||||
dto: RegisterParentCompletDto,
|
||||
options: {
|
||||
statut: StatutUtilisateurType;
|
||||
sendPendingEmail: boolean;
|
||||
sendPasswordSetupEmail: boolean;
|
||||
requireCgu: boolean;
|
||||
},
|
||||
) {
|
||||
if (options.requireCgu && (!dto.acceptation_cgu || !dto.acceptation_privacy)) {
|
||||
throw new BadRequestException('L\'acceptation des CGU et de la politique de confidentialité est obligatoire');
|
||||
}
|
||||
|
||||
@@ -345,7 +480,7 @@ export class AuthService {
|
||||
prenom: dto.prenom,
|
||||
nom: dto.nom,
|
||||
role: RoleType.PARENT,
|
||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||
statut: options.statut,
|
||||
telephone: dto.telephone,
|
||||
adresse: dto.adresse,
|
||||
code_postal: dto.code_postal,
|
||||
@@ -370,7 +505,7 @@ export class AuthService {
|
||||
prenom: dto.co_parent_prenom,
|
||||
nom: dto.co_parent_nom,
|
||||
role: RoleType.PARENT,
|
||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||
statut: options.statut,
|
||||
telephone: dto.co_parent_telephone,
|
||||
adresse: dto.co_parent_meme_adresse ? dto.adresse : dto.co_parent_adresse,
|
||||
code_postal: dto.co_parent_meme_adresse ? dto.code_postal : dto.co_parent_code_postal,
|
||||
@@ -425,8 +560,9 @@ export class AuthService {
|
||||
? new Date(enfantDto.date_previsionnelle_naissance)
|
||||
: undefined;
|
||||
enfant.photo_url = urlPhoto || undefined;
|
||||
enfant.status = enfantDto.date_naissance ? StatutEnfantType.ACTIF : StatutEnfantType.A_NAITRE;
|
||||
enfant.consent_photo = false;
|
||||
enfant.status = enfantDto.date_naissance ? StatutEnfantType.SANS_GARDE : StatutEnfantType.A_NAITRE;
|
||||
enfant.consent_photo = !!enfantDto.consent_photo;
|
||||
enfant.consent_photo_at = enfant.consent_photo ? new Date() : null!;
|
||||
enfant.is_multiple = enfantDto.grossesse_multiple || false;
|
||||
|
||||
const enfantEnregistre = await manager.save(Children, enfant);
|
||||
@@ -485,44 +621,126 @@ export class AuthService {
|
||||
|
||||
const numeroDossier = resultat.parent1.numero_dossier ?? '';
|
||||
|
||||
try {
|
||||
await this.mailService.sendRegistrationPendingEmail(
|
||||
resultat.parent1.email,
|
||||
resultat.parent1.prenom ?? '',
|
||||
resultat.parent1.nom ?? '',
|
||||
numeroDossier,
|
||||
);
|
||||
if (resultat.parent2) {
|
||||
if (options.sendPendingEmail) {
|
||||
try {
|
||||
await this.mailService.sendRegistrationPendingEmail(
|
||||
resultat.parent2.email,
|
||||
resultat.parent2.prenom ?? '',
|
||||
resultat.parent2.nom ?? '',
|
||||
resultat.parent1.email,
|
||||
resultat.parent1.prenom ?? '',
|
||||
resultat.parent1.nom ?? '',
|
||||
numeroDossier,
|
||||
);
|
||||
if (resultat.parent2) {
|
||||
await this.mailService.sendRegistrationPendingEmail(
|
||||
resultat.parent2.email,
|
||||
resultat.parent2.prenom ?? '',
|
||||
resultat.parent2.nom ?? '',
|
||||
numeroDossier,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
"[createParentDossier] Échec envoi email d'accusé de réception (inscription conservée)",
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
"[inscrireParentComplet] Échec envoi email d'accusé de réception (inscription conservée)",
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
}
|
||||
|
||||
if (options.sendPasswordSetupEmail) {
|
||||
try {
|
||||
await this.mailService.sendValidatedAccountPasswordSetupEmail(
|
||||
{
|
||||
email: resultat.parent1.email,
|
||||
prenom: resultat.parent1.prenom ?? '',
|
||||
nom: resultat.parent1.nom ?? '',
|
||||
token: resultat.tokenCreationMdp,
|
||||
numeroDossier,
|
||||
},
|
||||
'parent',
|
||||
);
|
||||
if (resultat.parent2 && resultat.tokenCoParent) {
|
||||
await this.mailService.sendValidatedAccountPasswordSetupEmail(
|
||||
{
|
||||
email: resultat.parent2.email,
|
||||
prenom: resultat.parent2.prenom ?? '',
|
||||
nom: resultat.parent2.nom ?? '',
|
||||
token: resultat.tokenCoParent,
|
||||
numeroDossier,
|
||||
},
|
||||
'parent',
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
'[createParentDossier] Échec envoi email création MDP (dossier conservé)',
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const message = options.sendPasswordSetupEmail
|
||||
? 'Dossier famille créé et validé. Un e-mail de création de mot de passe a été envoyé.'
|
||||
: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.';
|
||||
|
||||
return {
|
||||
message: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.',
|
||||
message,
|
||||
parent_id: resultat.parent1.id,
|
||||
co_parent_id: resultat.parent2?.id,
|
||||
parent_user_id: resultat.parent1.id,
|
||||
co_parent_id: resultat.parent2?.id ?? null,
|
||||
co_parent_user_id: resultat.parent2?.id ?? null,
|
||||
enfants_ids: resultat.enfants.map(e => e.id),
|
||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||
enfant_ids: resultat.enfants.map(e => e.id),
|
||||
statut: options.statut,
|
||||
numero_dossier: numeroDossier,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Inscription Assistante Maternelle COMPLÈTE - Un seul endpoint (identité + pro + photo + CGU)
|
||||
* Crée User (role AM) + entrée assistantes_maternelles, token création MDP
|
||||
* Inscription Parent publique — CDC (statut en_attente + mail pending).
|
||||
*/
|
||||
async inscrireAMComplet(dto: RegisterAMCompletDto) {
|
||||
if (!dto.acceptation_cgu || !dto.acceptation_privacy) {
|
||||
async inscrireParentComplet(dto: RegisterParentCompletDto) {
|
||||
return this.createParentDossier(dto, {
|
||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||
sendPendingEmail: true,
|
||||
sendPasswordSetupEmail: false,
|
||||
requireCgu: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Création dossier parent par le staff (#129) — actif + mail création MDP.
|
||||
*/
|
||||
async createParentDossierStaff(dto: RegisterParentCompletDto) {
|
||||
return this.createParentDossier(dto, {
|
||||
statut: StatutUtilisateurType.ACTIF,
|
||||
sendPendingEmail: false,
|
||||
sendPasswordSetupEmail: true,
|
||||
requireCgu: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cœur partagé création dossier AM (#156).
|
||||
* - public : statut en_attente + mail pending
|
||||
* - staff : statut actif + mail création MDP
|
||||
*/
|
||||
async createAmDossier(
|
||||
dto: RegisterAMCompletDto,
|
||||
options: {
|
||||
statut: StatutUtilisateurType;
|
||||
sendPendingEmail: boolean;
|
||||
sendPasswordSetupEmail: boolean;
|
||||
requireCgu: boolean;
|
||||
logContext?: string;
|
||||
},
|
||||
): Promise<{
|
||||
message: string;
|
||||
user_id: string;
|
||||
statut: StatutUtilisateurType;
|
||||
numero_dossier: string;
|
||||
}> {
|
||||
const logCtx = options.logContext ?? 'createAmDossier';
|
||||
|
||||
if (options.requireCgu && (!dto.acceptation_cgu || !dto.acceptation_privacy)) {
|
||||
throw new BadRequestException(
|
||||
"L'acceptation des CGU et de la politique de confidentialité est obligatoire",
|
||||
);
|
||||
@@ -542,8 +760,7 @@ export class AuthService {
|
||||
throw new BadRequestException(nirValidation.error || 'NIR invalide');
|
||||
}
|
||||
if (nirValidation.warning) {
|
||||
// Warning uniquement : on ne bloque pas (AM souvent étrangères, DOM-TOM, Corse)
|
||||
console.warn('[inscrireAMComplet] NIR warning:', nirValidation.warning, 'email=', dto.email);
|
||||
console.warn(`[${logCtx}] NIR warning:`, nirValidation.warning, 'email=', dto.email);
|
||||
}
|
||||
|
||||
const existe = await this.usersService.findByEmailOrNull(dto.email);
|
||||
@@ -594,79 +811,142 @@ export class AuthService {
|
||||
let resultat: { user: Users };
|
||||
try {
|
||||
resultat = await this.usersRepo.manager.transaction(async (manager) => {
|
||||
const { numero: numeroDossier } = await this.numeroDossierService.getNextNumeroDossier(manager);
|
||||
const { numero: numeroDossier } =
|
||||
await this.numeroDossierService.getNextNumeroDossier(manager);
|
||||
|
||||
const user = manager.create(Users, {
|
||||
email: dto.email,
|
||||
prenom: dto.prenom,
|
||||
nom: dto.nom,
|
||||
role: RoleType.ASSISTANTE_MATERNELLE,
|
||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||
telephone: dto.telephone,
|
||||
adresse: dto.adresse,
|
||||
code_postal: dto.code_postal,
|
||||
ville: dto.ville,
|
||||
token_creation_mdp: tokenCreationMdp,
|
||||
token_creation_mdp_expire_le: dateExpiration,
|
||||
photo_url: urlPhoto ?? undefined,
|
||||
consentement_photo: dto.consentement_photo,
|
||||
date_consentement_photo: dateConsentementPhoto,
|
||||
date_naissance: dto.date_naissance ? new Date(dto.date_naissance) : undefined,
|
||||
lieu_naissance_ville: dto.lieu_naissance_ville,
|
||||
lieu_naissance_pays: dto.lieu_naissance_pays,
|
||||
numero_dossier: numeroDossier,
|
||||
const user = manager.create(Users, {
|
||||
email: dto.email,
|
||||
prenom: dto.prenom,
|
||||
nom: dto.nom,
|
||||
role: RoleType.ASSISTANTE_MATERNELLE,
|
||||
statut: options.statut,
|
||||
telephone: dto.telephone,
|
||||
adresse: dto.adresse,
|
||||
code_postal: dto.code_postal,
|
||||
ville: dto.ville,
|
||||
token_creation_mdp: tokenCreationMdp,
|
||||
token_creation_mdp_expire_le: dateExpiration,
|
||||
photo_url: urlPhoto ?? undefined,
|
||||
consentement_photo: dto.consentement_photo,
|
||||
date_consentement_photo: dateConsentementPhoto,
|
||||
date_naissance: dto.date_naissance
|
||||
? new Date(dto.date_naissance)
|
||||
: undefined,
|
||||
lieu_naissance_ville: dto.lieu_naissance_ville,
|
||||
lieu_naissance_pays: dto.lieu_naissance_pays,
|
||||
numero_dossier: numeroDossier,
|
||||
});
|
||||
const userEnregistre = await manager.save(Users, user);
|
||||
|
||||
const amRepo = manager.getRepository(AssistanteMaternelle);
|
||||
const am = amRepo.create({
|
||||
user_id: userEnregistre.id,
|
||||
approval_number: dto.numero_agrement,
|
||||
nir: nirNormalized,
|
||||
max_children: dto.capacite_accueil,
|
||||
places_available: dto.places_disponibles,
|
||||
biography: dto.biographie,
|
||||
residence_city: dto.ville ?? undefined,
|
||||
agreement_date: dto.date_agrement
|
||||
? new Date(dto.date_agrement)
|
||||
: undefined,
|
||||
available: true,
|
||||
numero_dossier: numeroDossier,
|
||||
});
|
||||
await amRepo.save(am);
|
||||
|
||||
return { user: userEnregistre };
|
||||
});
|
||||
const userEnregistre = await manager.save(Users, user);
|
||||
|
||||
const amRepo = manager.getRepository(AssistanteMaternelle);
|
||||
const am = amRepo.create({
|
||||
user_id: userEnregistre.id,
|
||||
approval_number: dto.numero_agrement,
|
||||
nir: nirNormalized,
|
||||
max_children: dto.capacite_accueil,
|
||||
places_available: dto.places_disponibles,
|
||||
biography: dto.biographie,
|
||||
residence_city: dto.ville ?? undefined,
|
||||
agreement_date: dto.date_agrement ? new Date(dto.date_agrement) : undefined,
|
||||
available: true,
|
||||
numero_dossier: numeroDossier,
|
||||
});
|
||||
await amRepo.save(am);
|
||||
|
||||
return { user: userEnregistre };
|
||||
});
|
||||
} catch (err) {
|
||||
if (this.isPostgresUniqueViolation(err)) {
|
||||
throw new ConflictException('Un compte avec cet email existe déjà (contrainte unique en base).');
|
||||
throw new ConflictException(
|
||||
'Un compte avec cet email existe déjà (contrainte unique en base).',
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const numeroDossier = resultat.user.numero_dossier ?? '';
|
||||
|
||||
try {
|
||||
await this.mailService.sendRegistrationPendingEmail(
|
||||
resultat.user.email,
|
||||
resultat.user.prenom ?? '',
|
||||
resultat.user.nom ?? '',
|
||||
numeroDossier,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
"[inscrireAMComplet] Échec envoi email d'accusé de réception (inscription conservée)",
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
if (options.sendPendingEmail) {
|
||||
try {
|
||||
await this.mailService.sendRegistrationPendingEmail(
|
||||
resultat.user.email,
|
||||
resultat.user.prenom ?? '',
|
||||
resultat.user.nom ?? '',
|
||||
numeroDossier,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`[${logCtx}] Échec envoi email d'accusé de réception (inscription conservée)`,
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.sendPasswordSetupEmail) {
|
||||
try {
|
||||
await this.mailService.sendValidatedAccountPasswordSetupEmail(
|
||||
{
|
||||
email: resultat.user.email,
|
||||
prenom: resultat.user.prenom ?? '',
|
||||
nom: resultat.user.nom ?? '',
|
||||
token: tokenCreationMdp,
|
||||
numeroDossier,
|
||||
},
|
||||
'am',
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`[${logCtx}] Échec envoi email création MDP (dossier conservé)`,
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const message =
|
||||
options.statut === StatutUtilisateurType.ACTIF
|
||||
? 'Dossier AM créé et validé. Un e-mail de création de mot de passe a été envoyé.'
|
||||
: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.';
|
||||
|
||||
return {
|
||||
message:
|
||||
'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.',
|
||||
message,
|
||||
user_id: resultat.user.id,
|
||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||
statut: options.statut,
|
||||
numero_dossier: numeroDossier,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Inscription Assistante Maternelle COMPLÈTE - Un seul endpoint (identité + pro + photo + CGU)
|
||||
* Crée User (role AM) + entrée assistantes_maternelles, token création MDP
|
||||
*/
|
||||
async inscrireAMComplet(dto: RegisterAMCompletDto) {
|
||||
return this.createAmDossier(dto, {
|
||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||
sendPendingEmail: true,
|
||||
sendPasswordSetupEmail: false,
|
||||
requireCgu: true,
|
||||
logContext: 'inscrireAMComplet',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Création dossier AM par staff (#156) — statut actif + e-mail création MDP.
|
||||
*/
|
||||
async createAmDossierStaff(dto: RegisterAMCompletDto) {
|
||||
return this.createAmDossier(dto, {
|
||||
statut: StatutUtilisateurType.ACTIF,
|
||||
sendPendingEmail: false,
|
||||
sendPasswordSetupEmail: true,
|
||||
requireCgu: false,
|
||||
logContext: 'createAmDossierStaff',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sauvegarde une photo depuis base64 vers le système de fichiers
|
||||
*/
|
||||
/**
|
||||
* Sauvegarde une photo depuis base64 vers le système de fichiers
|
||||
*/
|
||||
@@ -753,12 +1033,72 @@ export class AuthService {
|
||||
return { success: true, message: 'Deconnexion'}
|
||||
}
|
||||
|
||||
/** GET dossier reprise – token seul. Ticket #111 */
|
||||
/** GET dossier reprise – token seul. Ticket #111, enrichi #112 */
|
||||
async getRepriseDossier(token: string): Promise<RepriseDossierDto> {
|
||||
const user = await this.usersService.findByTokenReprise(token);
|
||||
if (!user) {
|
||||
throw new NotFoundException('Token reprise invalide ou expiré.');
|
||||
}
|
||||
|
||||
const base = this.userToRepriseBase(user);
|
||||
|
||||
if (user.role === RoleType.PARENT && user.numero_dossier) {
|
||||
try {
|
||||
const famille = await this.parentsService.getDossierFamilleByNumero(user.numero_dossier);
|
||||
return {
|
||||
...base,
|
||||
parents: famille.parents,
|
||||
enfants: famille.enfants,
|
||||
texte_motivation: famille.texte_motivation,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof NotFoundException) {
|
||||
return base;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (user.role === RoleType.ASSISTANTE_MATERNELLE && user.numero_dossier) {
|
||||
try {
|
||||
const unifie = await this.dossiersService.getDossierByNumero(user.numero_dossier);
|
||||
if (unifie.type === 'am') {
|
||||
return this.mergeAmRepriseDossier(base, unifie.dossier as DossierAmCompletDto);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof NotFoundException) {
|
||||
return base;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
/** PATCH resoumission reprise – dossier complet. Ticket #111, enrichi #112 */
|
||||
async resoumettreReprise(dto: ResoumettreRepriseDto): Promise<{
|
||||
message: string;
|
||||
statut: StatutUtilisateurType;
|
||||
user_id: string;
|
||||
numero_dossier?: string;
|
||||
}> {
|
||||
const user = await this.usersService.findByTokenReprise(dto.token);
|
||||
if (!user) {
|
||||
throw new NotFoundException('Token reprise invalide ou expiré.');
|
||||
}
|
||||
|
||||
if (user.role === RoleType.PARENT) {
|
||||
return this.resoumettreRepriseParent(user, dto);
|
||||
}
|
||||
if (user.role === RoleType.ASSISTANTE_MATERNELLE) {
|
||||
return this.resoumettreRepriseAM(user, dto);
|
||||
}
|
||||
|
||||
throw new BadRequestException('Rôle non pris en charge pour la reprise.');
|
||||
}
|
||||
|
||||
private userToRepriseBase(user: Users): RepriseDossierDto {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
@@ -776,12 +1116,297 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
/** PUT resoumission reprise. Ticket #111 */
|
||||
async resoumettreReprise(
|
||||
token: string,
|
||||
dto: { prenom?: string; nom?: string; telephone?: string; adresse?: string; ville?: string; code_postal?: string; photo_url?: string },
|
||||
): Promise<Users> {
|
||||
return this.usersService.resoumettreReprise(token, dto);
|
||||
private mergeAmRepriseDossier(base: RepriseDossierDto, dossier: DossierAmCompletDto): RepriseDossierDto {
|
||||
const u = dossier.user;
|
||||
return {
|
||||
...base,
|
||||
email: u.email,
|
||||
prenom: u.prenom,
|
||||
nom: u.nom,
|
||||
telephone: u.telephone,
|
||||
adresse: u.adresse,
|
||||
ville: u.ville,
|
||||
code_postal: u.code_postal,
|
||||
photo_url: u.photo_url,
|
||||
consentement_photo: u.consentement_photo,
|
||||
date_naissance: u.date_naissance,
|
||||
lieu_naissance_ville: u.lieu_naissance_ville,
|
||||
lieu_naissance_pays: u.lieu_naissance_pays,
|
||||
numero_agrement: dossier.numero_agrement,
|
||||
nir: dossier.nir,
|
||||
date_agrement: dossier.date_agrement,
|
||||
nb_max_enfants: dossier.nb_max_enfants,
|
||||
place_disponible: dossier.place_disponible,
|
||||
biographie: dossier.biographie,
|
||||
};
|
||||
}
|
||||
|
||||
private applyTitulaireUserFields(user: Users, dto: ResoumettreRepriseDto): void {
|
||||
if (dto.prenom !== undefined) user.prenom = dto.prenom;
|
||||
if (dto.nom !== undefined) user.nom = dto.nom;
|
||||
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;
|
||||
}
|
||||
|
||||
private async resoumettreRepriseParent(
|
||||
titulaire: Users,
|
||||
dto: ResoumettreRepriseDto,
|
||||
): Promise<{ message: string; statut: StatutUtilisateurType; user_id: string; numero_dossier?: string }> {
|
||||
const motivation =
|
||||
dto.texte_motivation?.trim() ??
|
||||
dto.presentation_dossier?.trim();
|
||||
|
||||
const result = await this.usersRepo.manager.transaction(async (manager) => {
|
||||
const familyUsers = titulaire.numero_dossier
|
||||
? await manager.find(Users, {
|
||||
where: {
|
||||
role: RoleType.PARENT,
|
||||
numero_dossier: titulaire.numero_dossier,
|
||||
},
|
||||
})
|
||||
: [titulaire];
|
||||
|
||||
const titulaireLive = familyUsers.find((u) => u.id === titulaire.id) ?? titulaire;
|
||||
this.applyTitulaireUserFields(titulaireLive, dto);
|
||||
|
||||
const coParentUser = familyUsers.find((u) => u.id !== titulaire.id);
|
||||
if (coParentUser) {
|
||||
if (dto.co_parent_prenom !== undefined) coParentUser.prenom = dto.co_parent_prenom;
|
||||
if (dto.co_parent_nom !== undefined) coParentUser.nom = dto.co_parent_nom;
|
||||
if (dto.co_parent_telephone !== undefined) coParentUser.telephone = dto.co_parent_telephone;
|
||||
|
||||
if (dto.co_parent_meme_adresse) {
|
||||
coParentUser.adresse = titulaireLive.adresse;
|
||||
coParentUser.code_postal = titulaireLive.code_postal;
|
||||
coParentUser.ville = titulaireLive.ville;
|
||||
} else {
|
||||
if (dto.co_parent_adresse !== undefined) coParentUser.adresse = dto.co_parent_adresse;
|
||||
if (dto.co_parent_code_postal !== undefined) coParentUser.code_postal = dto.co_parent_code_postal;
|
||||
if (dto.co_parent_ville !== undefined) coParentUser.ville = dto.co_parent_ville;
|
||||
}
|
||||
}
|
||||
|
||||
if (titulaire.numero_dossier && motivation !== undefined) {
|
||||
const dossierFamille = await manager.findOne(DossierFamille, {
|
||||
where: { numero_dossier: titulaire.numero_dossier },
|
||||
});
|
||||
if (dossierFamille) {
|
||||
dossierFamille.presentation = motivation || undefined;
|
||||
await manager.save(DossierFamille, dossierFamille);
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.enfants?.length) {
|
||||
const familyChildIds = await this.getFamilyChildrenIds(manager, titulaireLive.id);
|
||||
for (const enfantDto of dto.enfants) {
|
||||
if (!familyChildIds.has(enfantDto.id)) {
|
||||
throw new BadRequestException(
|
||||
`Enfant inconnu pour ce dossier : ${enfantDto.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
const enfant = await manager.findOne(Children, { where: { id: enfantDto.id } });
|
||||
if (!enfant) {
|
||||
throw new BadRequestException(`Enfant introuvable : ${enfantDto.id}`);
|
||||
}
|
||||
|
||||
if (enfantDto.prenom !== undefined) enfant.first_name = enfantDto.prenom;
|
||||
if (enfantDto.nom !== undefined) enfant.last_name = enfantDto.nom;
|
||||
if (enfantDto.genre !== undefined) enfant.gender = enfantDto.genre;
|
||||
if (enfantDto.date_naissance !== undefined) {
|
||||
enfant.birth_date = new Date(enfantDto.date_naissance);
|
||||
enfant.status = StatutEnfantType.SANS_GARDE;
|
||||
}
|
||||
if (enfantDto.date_previsionnelle_naissance !== undefined) {
|
||||
enfant.due_date = new Date(enfantDto.date_previsionnelle_naissance);
|
||||
if (!enfantDto.date_naissance) {
|
||||
enfant.status = StatutEnfantType.A_NAITRE;
|
||||
}
|
||||
}
|
||||
if (enfantDto.grossesse_multiple !== undefined) {
|
||||
enfant.is_multiple = enfantDto.grossesse_multiple;
|
||||
}
|
||||
if (enfantDto.consent_photo !== undefined) {
|
||||
enfant.consent_photo = !!enfantDto.consent_photo;
|
||||
enfant.consent_photo_at = enfant.consent_photo
|
||||
? new Date()
|
||||
: null!;
|
||||
}
|
||||
|
||||
if (enfantDto.photo_base64 && enfantDto.photo_filename) {
|
||||
enfant.photo_url = await this.sauvegarderPhotoDepuisBase64(
|
||||
enfantDto.photo_base64,
|
||||
enfantDto.photo_filename,
|
||||
);
|
||||
}
|
||||
|
||||
await manager.save(Children, enfant);
|
||||
}
|
||||
}
|
||||
|
||||
for (const familyUser of familyUsers) {
|
||||
familyUser.statut = StatutUtilisateurType.EN_ATTENTE;
|
||||
familyUser.token_reprise = undefined;
|
||||
familyUser.token_reprise_expire_le = undefined;
|
||||
await manager.save(Users, familyUser);
|
||||
}
|
||||
|
||||
return {
|
||||
message: 'Dossier resoumis avec succès. Il est de nouveau en attente de validation.',
|
||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||
user_id: titulaireLive.id,
|
||||
numero_dossier: titulaireLive.numero_dossier,
|
||||
parentsPourEmail: familyUsers,
|
||||
};
|
||||
});
|
||||
|
||||
const numeroDossier = result.numero_dossier ?? '';
|
||||
try {
|
||||
for (const parent of result.parentsPourEmail) {
|
||||
await this.mailService.sendResoumissionPendingEmail(
|
||||
parent.email,
|
||||
parent.prenom ?? '',
|
||||
parent.nom ?? '',
|
||||
numeroDossier,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
"[resoumettreRepriseParent] Échec envoi email accusé resoumission (dossier conservé)",
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
}
|
||||
|
||||
const { parentsPourEmail: _parentsPourEmail, ...response } = result;
|
||||
return response;
|
||||
}
|
||||
|
||||
private async resoumettreRepriseAM(
|
||||
user: Users,
|
||||
dto: ResoumettreRepriseDto,
|
||||
): Promise<{ message: string; statut: StatutUtilisateurType; user_id: string; numero_dossier?: string }> {
|
||||
if (
|
||||
dto.capacite_accueil !== undefined &&
|
||||
dto.places_disponibles !== undefined &&
|
||||
dto.places_disponibles > dto.capacite_accueil
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'Le nombre de places disponibles ne peut pas dépasser la capacité d\'accueil.',
|
||||
);
|
||||
}
|
||||
|
||||
let urlPhoto: string | undefined;
|
||||
const photoB64 = (dto.photo_base64 ?? '').trim();
|
||||
if (photoB64) {
|
||||
const nomFichier = (dto.photo_filename ?? '').trim() || 'photo_am.jpg';
|
||||
urlPhoto = await this.sauvegarderPhotoDepuisBase64(photoB64, nomFichier);
|
||||
} else if (dto.photo_url !== undefined) {
|
||||
urlPhoto = dto.photo_url;
|
||||
}
|
||||
|
||||
if (dto.nir !== undefined) {
|
||||
const nirNormalized = dto.nir.replace(/\s/g, '').toUpperCase();
|
||||
const nirValidation = validateNir(nirNormalized, {
|
||||
dateNaissance: dto.date_naissance ?? user.date_naissance?.toISOString().slice(0, 10),
|
||||
});
|
||||
if (!nirValidation.valid) {
|
||||
throw new BadRequestException(nirValidation.error || 'NIR invalide');
|
||||
}
|
||||
}
|
||||
|
||||
return this.usersRepo.manager.transaction(async (manager) => {
|
||||
const userLive = await manager.findOne(Users, { where: { id: user.id } });
|
||||
if (!userLive) {
|
||||
throw new NotFoundException('Utilisateur introuvable.');
|
||||
}
|
||||
|
||||
this.applyTitulaireUserFields(userLive, dto);
|
||||
if (urlPhoto !== undefined) userLive.photo_url = urlPhoto;
|
||||
if (dto.consentement_photo !== undefined) {
|
||||
userLive.consentement_photo = dto.consentement_photo;
|
||||
if (dto.consentement_photo) {
|
||||
userLive.date_consentement_photo = new Date();
|
||||
}
|
||||
}
|
||||
if (dto.date_naissance !== undefined) {
|
||||
userLive.date_naissance = new Date(dto.date_naissance);
|
||||
}
|
||||
if (dto.lieu_naissance_ville !== undefined) {
|
||||
userLive.lieu_naissance_ville = dto.lieu_naissance_ville;
|
||||
}
|
||||
if (dto.lieu_naissance_pays !== undefined) {
|
||||
userLive.lieu_naissance_pays = dto.lieu_naissance_pays;
|
||||
}
|
||||
|
||||
const am = await manager.findOne(AssistanteMaternelle, { where: { user_id: user.id } });
|
||||
if (!am) {
|
||||
throw new NotFoundException('Fiche assistante maternelle introuvable.');
|
||||
}
|
||||
|
||||
if (dto.nir !== undefined) {
|
||||
const nirNormalized = dto.nir.replace(/\s/g, '').toUpperCase();
|
||||
const nirDejaUtilise = await manager.findOne(AssistanteMaternelle, {
|
||||
where: { nir: nirNormalized },
|
||||
});
|
||||
if (nirDejaUtilise && nirDejaUtilise.user_id !== user.id) {
|
||||
throw new ConflictException(
|
||||
'Un compte assistante maternelle avec ce numéro NIR existe déjà.',
|
||||
);
|
||||
}
|
||||
am.nir = nirNormalized;
|
||||
}
|
||||
if (dto.numero_agrement !== undefined) {
|
||||
const agrement = dto.numero_agrement.trim();
|
||||
const agrementDejaUtilise = await manager.findOne(AssistanteMaternelle, {
|
||||
where: { approval_number: agrement },
|
||||
});
|
||||
if (agrementDejaUtilise && agrementDejaUtilise.user_id !== user.id) {
|
||||
throw new ConflictException(
|
||||
'Un compte assistante maternelle avec ce numéro d\'agrément existe déjà.',
|
||||
);
|
||||
}
|
||||
am.approval_number = agrement;
|
||||
}
|
||||
if (dto.date_agrement !== undefined) {
|
||||
am.agreement_date = new Date(dto.date_agrement);
|
||||
}
|
||||
if (dto.capacite_accueil !== undefined) {
|
||||
am.max_children = dto.capacite_accueil;
|
||||
}
|
||||
if (dto.places_disponibles !== undefined) {
|
||||
am.places_available = dto.places_disponibles;
|
||||
}
|
||||
if (dto.biographie !== undefined) {
|
||||
am.biography = dto.biographie;
|
||||
}
|
||||
|
||||
userLive.statut = StatutUtilisateurType.EN_ATTENTE;
|
||||
userLive.token_reprise = undefined;
|
||||
userLive.token_reprise_expire_le = undefined;
|
||||
|
||||
await manager.save(AssistanteMaternelle, am);
|
||||
await manager.save(Users, userLive);
|
||||
|
||||
return {
|
||||
message: 'Dossier resoumis avec succès. Il est de nouveau en attente de validation.',
|
||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||
user_id: userLive.id,
|
||||
numero_dossier: userLive.numero_dossier,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async getFamilyChildrenIds(
|
||||
manager: EntityManager,
|
||||
parentUserId: string,
|
||||
): Promise<Set<string>> {
|
||||
const familyUserIds = await this.parentsService.getFamilyUserIds(parentUserId);
|
||||
const links = await manager.find(ParentsChildren, {
|
||||
where: { parentId: In(familyUserIds) },
|
||||
});
|
||||
return new Set(links.map((l) => l.enfantId));
|
||||
}
|
||||
|
||||
/** POST reprise-identify : numero_dossier + email → type + token. Ticket #111 */
|
||||
|
||||
@@ -59,5 +59,14 @@ export class EnfantInscriptionDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
grossesse_multiple?: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
example: true,
|
||||
required: false,
|
||||
description: 'Consentement affichage / stockage photo (colonne enfants.consentement_photo)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
consent_photo?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,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,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,63 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { DossiersController } from './dossiers.controller';
|
||||
import { DossiersService } from './dossiers.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(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [DossiersController],
|
||||
providers: [{ provide: DossiersService, useValue: dossiersServiceMock }],
|
||||
})
|
||||
.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');
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,46 @@
|
||||
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
||||
import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { Roles } from 'src/common/decorators/roles.decorator';
|
||||
import { RoleType } from 'src/entities/users.entity';
|
||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||
import { DossiersService } from './dossiers.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) {}
|
||||
|
||||
@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 d’abord, puis n° décroissant.',
|
||||
})
|
||||
@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' })
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
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 { 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(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
DossiersService,
|
||||
{ provide: getRepositoryToken(Parents), useValue: parentsRepo },
|
||||
{ provide: getRepositoryToken(AssistanteMaternelle), useValue: amRepo },
|
||||
{ provide: ParentsService, useValue: parentsService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get(DossiersService);
|
||||
jest.clearAllMocks();
|
||||
parentsRepo.createQueryBuilder.mockReturnValue(parentsQb);
|
||||
amRepo.createQueryBuilder.mockReturnValue(amQb);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -3,12 +3,14 @@ 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 { DossierUnifieDto } from './dto/dossier-unifie.dto';
|
||||
import { DossierAmCompletDto, DossierAmUserDto } from './dto/dossier-am-complet.dto';
|
||||
import { DossierListItemDto } from './dto/dossier-list-item.dto';
|
||||
|
||||
/**
|
||||
* Endpoint unifié GET /dossiers/:numeroDossier – AM ou famille. Ticket #119.
|
||||
* Dossiers unifiés — détail (#119) + liste (#153).
|
||||
*/
|
||||
@Injectable()
|
||||
export class DossiersService {
|
||||
@@ -20,6 +22,159 @@ export class DossiersService {
|
||||
private readonly parentsService: ParentsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
private async listFamilleItems(): Promise<DossierListItemDto[]> {
|
||||
const parents = await this.parentsRepository
|
||||
.createQueryBuilder('p')
|
||||
.innerJoinAndSelect('p.user', 'u')
|
||||
.leftJoinAndSelect('p.co_parent', 'cp')
|
||||
.where('p.numero_dossier IS NOT NULL')
|
||||
.andWhere("TRIM(p.numero_dossier) <> ''")
|
||||
.getMany();
|
||||
|
||||
const byNum = new Map<string, Parents[]>();
|
||||
for (const p of parents) {
|
||||
const num = (p.numero_dossier ?? '').trim();
|
||||
if (!num) continue;
|
||||
const group = byNum.get(num) ?? [];
|
||||
group.push(p);
|
||||
byNum.set(num, group);
|
||||
}
|
||||
|
||||
const items: DossierListItemDto[] = [];
|
||||
for (const [numero_dossier, group] of byNum) {
|
||||
const usersMap = new Map<string, Users>();
|
||||
for (const p of group) {
|
||||
if (p.user) usersMap.set(p.user.id, p.user);
|
||||
if (p.co_parent) usersMap.set(p.co_parent.id, p.co_parent);
|
||||
}
|
||||
const users = [...usersMap.values()].sort((a, b) => {
|
||||
const an = `${a.nom ?? ''} ${a.prenom ?? ''}`.toLowerCase();
|
||||
const bn = `${b.nom ?? ''} ${b.prenom ?? ''}`.toLowerCase();
|
||||
return an.localeCompare(bn, 'fr') || a.id.localeCompare(b.id);
|
||||
});
|
||||
if (users.length === 0) continue;
|
||||
|
||||
const names = users.map((u) => this.formatPersonName(u)).filter(Boolean);
|
||||
const libelle =
|
||||
names.length === 0
|
||||
? `Dossier ${numero_dossier}`
|
||||
: names.length === 1
|
||||
? names[0]
|
||||
: names.join(' & ');
|
||||
|
||||
const emails = users.map((u) => u.email).filter(Boolean);
|
||||
const user_ids = users.map((u) => u.id);
|
||||
const a_valider = users.some((u) => u.statut === StatutUtilisateurType.EN_ATTENTE);
|
||||
const statut = a_valider
|
||||
? StatutUtilisateurType.EN_ATTENTE
|
||||
: (users[0].statut ?? StatutUtilisateurType.ACTIF);
|
||||
const date_reference = this.minCreeLeIso(users);
|
||||
|
||||
items.push({
|
||||
type: 'famille',
|
||||
numero_dossier,
|
||||
libelle,
|
||||
emails,
|
||||
user_ids,
|
||||
statut,
|
||||
a_valider,
|
||||
date_reference,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
private async listAmItems(): Promise<DossierListItemDto[]> {
|
||||
const ams = await this.amRepository
|
||||
.createQueryBuilder('am')
|
||||
.innerJoinAndSelect('am.user', 'u')
|
||||
.where('am.numero_dossier IS NOT NULL')
|
||||
.andWhere("TRIM(am.numero_dossier) <> ''")
|
||||
.getMany();
|
||||
|
||||
const byNum = new Map<string, AssistanteMaternelle>();
|
||||
for (const am of ams) {
|
||||
const num = (am.numero_dossier ?? '').trim();
|
||||
if (!num || !am.user) continue;
|
||||
// Un n° = une AM ; garder le premier
|
||||
if (!byNum.has(num)) byNum.set(num, am);
|
||||
}
|
||||
|
||||
const items: DossierListItemDto[] = [];
|
||||
for (const [numero_dossier, am] of byNum) {
|
||||
const u = am.user!;
|
||||
const libelle = this.formatPersonName(u) || `AM ${numero_dossier}`;
|
||||
const a_valider = u.statut === StatutUtilisateurType.EN_ATTENTE;
|
||||
items.push({
|
||||
type: 'assistante_maternelle',
|
||||
numero_dossier,
|
||||
libelle,
|
||||
emails: u.email ? [u.email] : [],
|
||||
user_ids: [u.id],
|
||||
statut: u.statut ?? StatutUtilisateurType.ACTIF,
|
||||
a_valider,
|
||||
date_reference: this.minCreeLeIso([u]),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
private matchesQuery(item: DossierListItemDto, needle: string): boolean {
|
||||
const hay = [
|
||||
item.numero_dossier,
|
||||
item.libelle,
|
||||
...item.emails,
|
||||
item.statut,
|
||||
item.type,
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return hay.includes(needle);
|
||||
}
|
||||
|
||||
private formatPersonName(u: Users): string {
|
||||
const prenom = (u.prenom ?? '').trim();
|
||||
const nom = (u.nom ?? '').trim();
|
||||
const nomFmt = nom ? nom.toUpperCase() : '';
|
||||
return [prenom, nomFmt].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
private minCreeLeIso(users: Users[]): string | null {
|
||||
let min: Date | null = null;
|
||||
for (const u of users) {
|
||||
const d = u.cree_le;
|
||||
if (!d) continue;
|
||||
const date = d instanceof Date ? d : new Date(d);
|
||||
if (Number.isNaN(date.getTime())) continue;
|
||||
if (!min || date < min) min = date;
|
||||
}
|
||||
return min ? min.toISOString() : null;
|
||||
}
|
||||
|
||||
async getDossierByNumero(numeroDossier: string): Promise<DossierUnifieDto> {
|
||||
const num = numeroDossier?.trim();
|
||||
if (!num) {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
@@ -6,13 +7,25 @@ import {
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
import { GenreType, StatutEnfantType } from 'src/entities/children.entity';
|
||||
|
||||
/** Multipart envoie des strings ("true"/"false") — JSON envoie déjà des booleans. */
|
||||
function toBoolean({ value }: { value: unknown }): boolean | unknown {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value === 'string') {
|
||||
const v = value.trim().toLowerCase();
|
||||
if (v === 'true' || v === '1') return true;
|
||||
if (v === 'false' || v === '0' || v === '') return false;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export class CreateEnfantsDto {
|
||||
@ApiProperty({ enum: StatutEnfantType, example: StatutEnfantType.ACTIF })
|
||||
@ApiProperty({ enum: StatutEnfantType, example: StatutEnfantType.SANS_GARDE })
|
||||
@IsEnum(StatutEnfantType)
|
||||
@IsNotEmpty()
|
||||
status: StatutEnfantType;
|
||||
@@ -52,6 +65,7 @@ export class CreateEnfantsDto {
|
||||
photo_url?: string;
|
||||
|
||||
@ApiProperty({ default: false })
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
consent_photo: boolean;
|
||||
|
||||
@@ -61,6 +75,20 @@ export class CreateEnfantsDto {
|
||||
consent_photo_at?: string;
|
||||
|
||||
@ApiProperty({ default: false })
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
is_multiple: boolean;
|
||||
|
||||
/**
|
||||
* Parent pivot du foyer — obligatoire pour staff (gestionnaire/admin).
|
||||
* Ignoré / interdit en externe pour un PARENT (ticket #132).
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'UUID du parent pivot (staff only). Obligatoire pour GESTIONNAIRE / ADMIN / SUPER_ADMIN.',
|
||||
format: 'uuid',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
parent_user_id?: string;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,33 @@
|
||||
import {
|
||||
Body,
|
||||
CallHandler,
|
||||
Controller,
|
||||
Delete,
|
||||
ExecutionContext,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
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,
|
||||
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';
|
||||
@@ -24,6 +37,47 @@ import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||
import { Roles } from 'src/common/decorators/roles.decorator';
|
||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||
|
||||
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)
|
||||
@@ -31,30 +85,27 @@ import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||
export class EnfantsController {
|
||||
constructor(private readonly enfantsService: EnfantsService) { }
|
||||
|
||||
@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,7 +134,12 @@ 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')
|
||||
update(
|
||||
@Param('id', new ParseUUIDPipe()) id: string,
|
||||
|
||||
@@ -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 né doit avoir une date de naissance');
|
||||
}
|
||||
|
||||
// Vérif doublon éventuel (ex: même prénom + date de naissance pour ce parent)
|
||||
// Vérif doublon éventuel (ex: même prénom + date de naissance)
|
||||
const exist = await this.childrenRepository.findOne({
|
||||
where: {
|
||||
first_name: dto.first_name,
|
||||
@@ -47,50 +68,108 @@ export class EnfantsService {
|
||||
});
|
||||
if (exist) throw new ConflictException('Cet enfant existe déjà');
|
||||
|
||||
// Gestion de la photo uploadée
|
||||
// Gestion de la photo uploadée (multipart parent ou staff)
|
||||
let photoUrl = dto.photo_url;
|
||||
let consentAt: Date | undefined;
|
||||
if (photoFile) {
|
||||
dto.photo_url = `/uploads/photos/${photoFile.filename}`;
|
||||
photoUrl = `/uploads/photos/${photoFile.filename}`;
|
||||
if (dto.consent_photo) {
|
||||
dto.consent_photo_at = new Date().toISOString();
|
||||
consentAt = new Date();
|
||||
}
|
||||
} else if (dto.consent_photo) {
|
||||
consentAt = dto.consent_photo_at
|
||||
? new Date(dto.consent_photo_at)
|
||||
: new Date();
|
||||
}
|
||||
|
||||
// Création
|
||||
const child = this.childrenRepository.create(dto);
|
||||
const child = this.childrenRepository.create({
|
||||
status: dto.status,
|
||||
first_name: dto.first_name,
|
||||
last_name: dto.last_name,
|
||||
gender: dto.gender,
|
||||
birth_date: dto.birth_date ? new Date(dto.birth_date) : undefined,
|
||||
due_date: dto.due_date ? new Date(dto.due_date) : undefined,
|
||||
photo_url: photoUrl,
|
||||
consent_photo: !!dto.consent_photo,
|
||||
consent_photo_at: consentAt,
|
||||
is_multiple: !!dto.is_multiple,
|
||||
});
|
||||
await this.childrenRepository.save(child);
|
||||
|
||||
// Lien parent-enfant (Parent 1)
|
||||
const parentLink = this.parentsChildrenRepository.create({
|
||||
parentId: parent.user_id,
|
||||
enfantId: child.id,
|
||||
});
|
||||
await this.parentsChildrenRepository.save(parentLink);
|
||||
// Lien parent-enfant (pivot)
|
||||
await this.parentsChildrenRepository.save(
|
||||
this.parentsChildrenRepository.create({
|
||||
parentId: parent.user_id,
|
||||
enfantId: child.id,
|
||||
}),
|
||||
);
|
||||
|
||||
// Rattachement automatique au co-parent s'il existe
|
||||
if (parent.co_parent) {
|
||||
const coParentLink = this.parentsChildrenRepository.create({
|
||||
parentId: parent.co_parent.id,
|
||||
enfantId: child.id,
|
||||
});
|
||||
await this.parentsChildrenRepository.save(coParentLink);
|
||||
await this.parentsChildrenRepository.save(
|
||||
this.parentsChildrenRepository.create({
|
||||
parentId: parent.co_parent.id,
|
||||
enfantId: child.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return this.findOne(child.id, currentUser);
|
||||
}
|
||||
|
||||
// Liste des enfants
|
||||
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,14 +183,14 @@ 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -120,7 +199,14 @@ export class EnfantsService {
|
||||
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!;
|
||||
}
|
||||
|
||||
await this.childrenRepository.update(id, patch);
|
||||
return this.findOne(id, currentUser);
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,9 @@ export class DossierFamilleEnfantDto {
|
||||
description: 'Consentement affichage photo (colonne consentement_photo)',
|
||||
})
|
||||
consent_photo?: boolean;
|
||||
|
||||
@ApiProperty({ required: false, description: 'Grossesse multiple (est_multiple)' })
|
||||
est_multiple?: boolean;
|
||||
}
|
||||
|
||||
/** Réponse GET /parents/dossier-famille/:numeroDossier – dossier famille complet. Ticket #119 */
|
||||
|
||||
@@ -7,6 +7,12 @@ export class ParentPendingSummaryDto {
|
||||
@ApiProperty()
|
||||
email: string;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
nom?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
prenom?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
telephone?: string | null;
|
||||
|
||||
@@ -18,7 +24,10 @@ export class ParentPendingSummaryDto {
|
||||
}
|
||||
|
||||
export class PendingFamilyDto {
|
||||
@ApiProperty({ example: 'Famille Dupont', description: 'Libellé affiché pour la famille' })
|
||||
@ApiProperty({
|
||||
example: 'MARTIN Claire - MARTIN Thomas',
|
||||
description: 'Libellé affiché : NOM Prénom (séparés par « - » si co-parent)',
|
||||
})
|
||||
libelle: string;
|
||||
|
||||
@ApiProperty({
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||
|
||||
/** Réponse 201 POST /parents/dossier (#129). */
|
||||
export class StaffCreateParentDossierResponseDto {
|
||||
@ApiProperty()
|
||||
message: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: '2026-000043',
|
||||
description: 'Numéro de dossier famille attribué',
|
||||
})
|
||||
numero_dossier: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid', description: 'UUID user du parent pivot' })
|
||||
parent_user_id: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
nullable: true,
|
||||
description: 'UUID user du co-parent, ou null',
|
||||
})
|
||||
co_parent_user_id: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
enum: StatutUtilisateurType,
|
||||
example: StatutUtilisateurType.ACTIF,
|
||||
})
|
||||
statut: StatutUtilisateurType;
|
||||
|
||||
@ApiProperty({
|
||||
type: [String],
|
||||
format: 'uuid',
|
||||
description: 'IDs des enfants créés',
|
||||
})
|
||||
enfant_ids: string[];
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ApiPropertyOptional, OmitType } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsOptional } from 'class-validator';
|
||||
import { RegisterParentCompletDto } from 'src/routes/auth/dto/register-parent-complet.dto';
|
||||
|
||||
/**
|
||||
* Création dossier parent/famille par staff (#129).
|
||||
* Mêmes champs que l'inscription publique, sans CGU/privacy obligatoires
|
||||
* (acceptées côté serveur pour le compte du gestionnaire).
|
||||
*/
|
||||
export class StaffCreateParentDossierDto extends OmitType(RegisterParentCompletDto, [
|
||||
'acceptation_cgu',
|
||||
'acceptation_privacy',
|
||||
] as const) {
|
||||
@ApiPropertyOptional({
|
||||
description: 'Ignoré côté staff (CGU acceptées serveur). Conservé pour compat éventuelle.',
|
||||
default: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
acceptation_cgu?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Ignoré côté staff (privacy acceptée serveur).',
|
||||
default: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
acceptation_privacy?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsEmail,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||
|
||||
/** Mise à jour fiche parent par admin/gestionnaire (doc 28 §6.1, ticket #131). */
|
||||
export class UpdateParentFicheAdminDto {
|
||||
@ApiPropertyOptional({ example: 'Dupont' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
nom?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Marie' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
prenom?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'marie.dupont@example.com' })
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '+33612345678' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
telephone?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '10 rue de la Paix' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
adresse?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Paris' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(150)
|
||||
ville?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '75001' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10)
|
||||
code_postal?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: StatutUtilisateurType })
|
||||
@IsOptional()
|
||||
@IsEnum(StatutUtilisateurType)
|
||||
statut?: StatutUtilisateurType;
|
||||
}
|
||||
@@ -1,18 +1,80 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ParentsController } from './parents.controller';
|
||||
import { ParentsService } from './parents.service';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||
|
||||
describe('ParentsController', () => {
|
||||
let controller: ParentsController;
|
||||
const authServiceMock = {
|
||||
createParentDossierStaff: jest.fn(),
|
||||
};
|
||||
const parentsServiceMock = {};
|
||||
const userServiceMock = {};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [ParentsController],
|
||||
}).compile();
|
||||
providers: [
|
||||
{ provide: ParentsService, useValue: parentsServiceMock },
|
||||
{ provide: UserService, useValue: userServiceMock },
|
||||
{ provide: AuthService, useValue: authServiceMock },
|
||||
],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.overrideGuard(RolesGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
controller = module.get<ParentsController>(ParentsController);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
it('createDossier delegates to authService.createParentDossierStaff with CGU accepted', async () => {
|
||||
authServiceMock.createParentDossierStaff.mockResolvedValue({
|
||||
message: 'Dossier famille créé et validé. Un e-mail de création de mot de passe a été envoyé.',
|
||||
parent_user_id: 'p1',
|
||||
co_parent_user_id: 'p2',
|
||||
enfant_ids: ['e1'],
|
||||
statut: StatutUtilisateurType.ACTIF,
|
||||
numero_dossier: '2026-000043',
|
||||
});
|
||||
|
||||
const body = {
|
||||
email: 'parent.staff@test.fr',
|
||||
prenom: 'Claire',
|
||||
nom: 'MARTIN',
|
||||
telephone: '0689567890',
|
||||
enfants: [
|
||||
{
|
||||
prenom: 'Emma',
|
||||
nom: 'MARTIN',
|
||||
date_naissance: '2023-02-15',
|
||||
genre: 'F',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const res = await controller.createDossier(body as any);
|
||||
expect(authServiceMock.createParentDossierStaff).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
email: body.email,
|
||||
acceptation_cgu: true,
|
||||
acceptation_privacy: true,
|
||||
}),
|
||||
);
|
||||
expect(res.numero_dossier).toBe('2026-000043');
|
||||
expect(res.parent_user_id).toBe('p1');
|
||||
expect(res.co_parent_user_id).toBe('p2');
|
||||
expect(res.enfant_ids).toEqual(['e1']);
|
||||
expect(res.statut).toBe(StatutUtilisateurType.ACTIF);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
@@ -9,28 +12,77 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ParentsService } from './parents.service';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { Parents } from 'src/entities/parents.entity';
|
||||
import { Users } from 'src/entities/users.entity';
|
||||
import { Roles } from 'src/common/decorators/roles.decorator';
|
||||
import { RoleType, StatutUtilisateurType } from 'src/entities/users.entity';
|
||||
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { CreateParentDto } from '../user/dto/create_parent.dto';
|
||||
import { UpdateParentsDto } from '../user/dto/update_parent.dto';
|
||||
import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto';
|
||||
import { StaffCreateParentDossierDto } from './dto/staff-create-parent-dossier.dto';
|
||||
import { StaffCreateParentDossierResponseDto } from './dto/staff-create-parent-dossier-response.dto';
|
||||
import { RegisterParentCompletDto } from '../auth/dto/register-parent-complet.dto';
|
||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||
import { User } from 'src/common/decorators/user.decorator';
|
||||
import { PendingFamilyDto } from './dto/pending-family.dto';
|
||||
import { DossierFamilleCompletDto } from './dto/dossier-famille-complet.dto';
|
||||
import { mapParentForApi, mapParentsForApi } from './parents.mapper';
|
||||
|
||||
@ApiTags('Parents')
|
||||
@ApiBearerAuth('access-token')
|
||||
@Controller('parents')
|
||||
@UseGuards(AuthGuard, RolesGuard)
|
||||
export class ParentsController {
|
||||
constructor(
|
||||
private readonly parentsService: ParentsService,
|
||||
private readonly userService: UserService,
|
||||
private readonly authService: AuthService,
|
||||
) {}
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||
@Post('dossier')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({
|
||||
summary: 'Créer un dossier famille/parent complet (staff) — ticket #129',
|
||||
description:
|
||||
'Crée parent (+ co-parent optionnel) + enfants + n° dossier avec statut actif, ' +
|
||||
'et envoie l’e-mail de création de mot de passe. ' +
|
||||
'Ne pas utiliser POST /auth/register/parent depuis le dashboard.',
|
||||
})
|
||||
@ApiBody({ type: StaffCreateParentDossierDto })
|
||||
@ApiResponse({ status: 201, type: StaffCreateParentDossierResponseDto })
|
||||
@ApiResponse({ status: 400, description: 'Validation DTO / métier' })
|
||||
@ApiResponse({ status: 403, description: 'Rôle non autorisé' })
|
||||
@ApiResponse({ status: 409, description: 'Email pivot et/ou co-parent déjà pris' })
|
||||
async createDossier(
|
||||
@Body() dto: StaffCreateParentDossierDto,
|
||||
): Promise<StaffCreateParentDossierResponseDto> {
|
||||
const registerDto = {
|
||||
...dto,
|
||||
acceptation_cgu: true,
|
||||
acceptation_privacy: true,
|
||||
} as RegisterParentCompletDto;
|
||||
const result = await this.authService.createParentDossierStaff(registerDto);
|
||||
return {
|
||||
message: result.message,
|
||||
numero_dossier: result.numero_dossier,
|
||||
parent_user_id: result.parent_user_id,
|
||||
co_parent_user_id: result.co_parent_user_id ?? null,
|
||||
statut: result.statut,
|
||||
enfant_ids: result.enfant_ids,
|
||||
};
|
||||
}
|
||||
|
||||
@Get('pending-families')
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||
@ApiOperation({ summary: 'Liste des familles en attente (une entrée par famille)' })
|
||||
@@ -79,21 +131,25 @@ export class ParentsController {
|
||||
return validated;
|
||||
}
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||
@Get()
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||
@ApiOperation({ summary: 'Liste des parents (user, co_parent, parentChildren) — ticket #131' })
|
||||
@ApiResponse({ status: 200, type: [Parents], description: 'Liste des parents' })
|
||||
@ApiResponse({ status: 403, description: 'Accès refusé !' })
|
||||
getAll(): Promise<Parents[]> {
|
||||
return this.parentsService.findAll();
|
||||
async getAll(): Promise<Parents[]> {
|
||||
const parents = await this.parentsService.findAll();
|
||||
return mapParentsForApi(parents);
|
||||
}
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Détail parent par user_id (inclut co_parent si id_co_parent renseigné) — ticket #131' })
|
||||
@ApiResponse({ status: 200, type: Parents, description: 'Détails du parent par ID utilisateur' })
|
||||
@ApiResponse({ status: 404, description: 'Parent non trouvé' })
|
||||
@ApiResponse({ status: 403, description: 'Accès refusé !' })
|
||||
getOne(@Param('id') user_id: string): Promise<Parents> {
|
||||
return this.parentsService.findOne(user_id);
|
||||
async getOne(@Param('id') user_id: string): Promise<Parents> {
|
||||
const parent = await this.parentsService.findOne(user_id);
|
||||
return mapParentForApi(parent);
|
||||
}
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
|
||||
@@ -101,8 +157,51 @@ export class ParentsController {
|
||||
@ApiBody({ type: CreateParentDto })
|
||||
@ApiResponse({ status: 201, type: Parents, description: 'Parent créé avec succès' })
|
||||
@ApiResponse({ status: 403, description: 'Accès refusé !' })
|
||||
create(@Body() dto: CreateParentDto): Promise<Parents> {
|
||||
return this.parentsService.create(dto);
|
||||
async create(@Body() dto: CreateParentDto): Promise<Parents> {
|
||||
const parent = await this.parentsService.create(dto);
|
||||
return mapParentForApi(parent);
|
||||
}
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||
@Patch(':id/fiche')
|
||||
@ApiOperation({ summary: 'Mettre à jour la fiche parent (admin/gestionnaire) — ticket #131' })
|
||||
@ApiParam({ name: 'id', description: "UUID utilisateur du parent" })
|
||||
@ApiBody({ type: UpdateParentFicheAdminDto })
|
||||
@ApiResponse({ status: 200, type: Parents, description: 'Fiche parent mise à jour' })
|
||||
async updateFicheAdmin(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateParentFicheAdminDto,
|
||||
): Promise<Parents> {
|
||||
const parent = await this.parentsService.updateFicheAdmin(id, dto);
|
||||
return mapParentForApi(parent);
|
||||
}
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||
@Post(':id/enfants/:enfantId')
|
||||
@ApiOperation({ summary: 'Rattacher un enfant à un parent — ticket #115' })
|
||||
@ApiParam({ name: 'id', description: "UUID utilisateur du parent" })
|
||||
@ApiParam({ name: 'enfantId', description: "UUID de l'enfant" })
|
||||
@ApiResponse({ status: 200, type: Parents, description: 'Parent avec enfants mis à jour' })
|
||||
async attachEnfant(
|
||||
@Param('id') id: string,
|
||||
@Param('enfantId') enfantId: string,
|
||||
): Promise<Parents> {
|
||||
const parent = await this.parentsService.attachEnfant(id, enfantId);
|
||||
return mapParentForApi(parent);
|
||||
}
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||
@Delete(':id/enfants/:enfantId')
|
||||
@ApiOperation({ summary: "Détacher un enfant d'un parent — ticket #115" })
|
||||
@ApiParam({ name: 'id', description: "UUID utilisateur du parent" })
|
||||
@ApiParam({ name: 'enfantId', description: "UUID de l'enfant" })
|
||||
@ApiResponse({ status: 200, type: Parents, description: 'Parent avec enfants mis à jour' })
|
||||
async detachEnfant(
|
||||
@Param('id') id: string,
|
||||
@Param('enfantId') enfantId: string,
|
||||
): Promise<Parents> {
|
||||
const parent = await this.parentsService.detachEnfant(id, enfantId);
|
||||
return mapParentForApi(parent);
|
||||
}
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
|
||||
@@ -111,7 +210,8 @@ export class ParentsController {
|
||||
@ApiResponse({ status: 200, type: Parents, description: 'Parent mis à jour avec succès' })
|
||||
@ApiResponse({ status: 404, description: 'Parent introuvable' })
|
||||
@ApiResponse({ status: 403, description: 'Accès refusé !' })
|
||||
update(@Param('id') id: string, @Body() dto: UpdateParentsDto): Promise<Parents> {
|
||||
return this.parentsService.update(id, dto);
|
||||
async update(@Param('id') id: string, @Body() dto: UpdateParentsDto): Promise<Parents> {
|
||||
const parent = await this.parentsService.update(id, dto);
|
||||
return mapParentForApi(parent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { mapParentForApi } from './parents.mapper';
|
||||
import { Parents } from '../../entities/parents.entity';
|
||||
import { RoleType, StatutUtilisateurType, Users } from '../../entities/users.entity';
|
||||
|
||||
describe('mapParentForApi', () => {
|
||||
it('expose co_parent avec prenom/nom sans secrets', () => {
|
||||
const coParent = {
|
||||
id: 'cp1',
|
||||
email: 'co@b.fr',
|
||||
prenom: 'Clara',
|
||||
nom: 'Co',
|
||||
role: RoleType.PARENT,
|
||||
statut: StatutUtilisateurType.ACTIF,
|
||||
password: 'secret',
|
||||
} as Users;
|
||||
|
||||
const parent = {
|
||||
user_id: 'u1',
|
||||
numero_dossier: '2026-000042',
|
||||
user: {
|
||||
id: 'u1',
|
||||
email: 'p@b.fr',
|
||||
prenom: 'Paul',
|
||||
nom: 'Parent',
|
||||
role: RoleType.PARENT,
|
||||
password: 'secret',
|
||||
} as Users,
|
||||
co_parent: coParent,
|
||||
parentChildren: [],
|
||||
} as Parents;
|
||||
|
||||
const out = mapParentForApi(parent);
|
||||
expect(out.co_parent?.prenom).toBe('Clara');
|
||||
expect(out.co_parent?.nom).toBe('Co');
|
||||
expect(out.co_parent?.password).toBeUndefined();
|
||||
expect(out.user.password).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Parents } from 'src/entities/parents.entity';
|
||||
import { sanitizeUserForApi } from '../../common/utils/sanitize-user-for-api';
|
||||
|
||||
/**
|
||||
* Sérialisation API fiche parent — ticket #131.
|
||||
* Garantit `user`, `co_parent` (si présent) et relations sans champs sensibles.
|
||||
*/
|
||||
export function mapParentForApi(parent: Parents): Parents {
|
||||
return {
|
||||
...parent,
|
||||
user: sanitizeUserForApi(parent.user)!,
|
||||
co_parent: sanitizeUserForApi(parent.co_parent),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapParentsForApi(parents: Parents[]): Parents[] {
|
||||
return parents.map(mapParentForApi);
|
||||
}
|
||||
@@ -4,15 +4,18 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { Parents } from 'src/entities/parents.entity';
|
||||
import { DossierFamille, DossierFamilleEnfant } from 'src/entities/dossier_famille.entity';
|
||||
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||
import { ParentsController } from './parents.controller';
|
||||
import { ParentsService } from './parents.service';
|
||||
import { Users } from 'src/entities/users.entity';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Parents, Users, DossierFamille, DossierFamilleEnfant]),
|
||||
TypeOrmModule.forFeature([Parents, Users, DossierFamille, DossierFamilleEnfant, ParentsChildren]),
|
||||
forwardRef(() => UserModule),
|
||||
forwardRef(() => AuthModule),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
|
||||
@@ -17,7 +17,9 @@ import {
|
||||
DossierFamilleParentDto,
|
||||
DossierFamilleEnfantDto,
|
||||
} from './dto/dossier-famille-complet.dto';
|
||||
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||
import { Children } from 'src/entities/children.entity';
|
||||
import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ParentsService {
|
||||
@@ -28,6 +30,8 @@ export class ParentsService {
|
||||
private readonly usersRepository: Repository<Users>,
|
||||
@InjectRepository(DossierFamille)
|
||||
private readonly dossierFamilleRepository: Repository<DossierFamille>,
|
||||
@InjectRepository(ParentsChildren)
|
||||
private readonly parentsChildrenRepository: Repository<ParentsChildren>,
|
||||
) {}
|
||||
|
||||
// Création d’un parent
|
||||
@@ -62,7 +66,7 @@ export class ParentsService {
|
||||
// Liste des parents
|
||||
async findAll(): Promise<Parents[]> {
|
||||
return this.parentsRepository.find({
|
||||
relations: ['user', 'co_parent', 'parentChildren', 'dossiers'],
|
||||
relations: ['user', 'co_parent', 'parentChildren', 'parentChildren.child', 'dossiers'],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -70,7 +74,7 @@ export class ParentsService {
|
||||
async findOne(user_id: string): Promise<Parents> {
|
||||
const parent = await this.parentsRepository.findOne({
|
||||
where: { user_id },
|
||||
relations: ['user', 'co_parent', 'parentChildren', 'dossiers'],
|
||||
relations: ['user', 'co_parent', 'parentChildren', 'parentChildren.child', 'dossiers'],
|
||||
});
|
||||
if (!parent) throw new NotFoundException('Parent introuvable');
|
||||
return parent;
|
||||
@@ -82,6 +86,131 @@ export class ParentsService {
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mise à jour fiche parent (champs user + statut) par admin/gestionnaire. Ticket #131 / doc 28 §6.1.
|
||||
*/
|
||||
async updateFicheAdmin(parentUserId: string, dto: UpdateParentFicheAdminDto): Promise<Parents> {
|
||||
const parent = await this.findOne(parentUserId);
|
||||
const user = parent.user;
|
||||
|
||||
if (dto.email && dto.email !== user.email) {
|
||||
const existing = await this.usersRepository.findOne({ where: { email: dto.email } });
|
||||
if (existing && existing.id !== user.id) {
|
||||
throw new ConflictException('Cet email est déjà utilisé');
|
||||
}
|
||||
user.email = dto.email;
|
||||
}
|
||||
|
||||
if (dto.nom !== undefined) user.nom = dto.nom;
|
||||
if (dto.prenom !== undefined) user.prenom = dto.prenom;
|
||||
if (dto.telephone !== undefined) user.telephone = dto.telephone;
|
||||
if (dto.adresse !== undefined) user.adresse = dto.adresse;
|
||||
if (dto.ville !== undefined) user.ville = dto.ville;
|
||||
if (dto.code_postal !== undefined) user.code_postal = dto.code_postal;
|
||||
if (dto.statut !== undefined) user.statut = dto.statut;
|
||||
|
||||
await this.usersRepository.save(user);
|
||||
return this.findOne(parentUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Membres du foyer (user ids) pour affiliation enfant.
|
||||
* Pivot + co-parent (A→B et B→A) + même numero_dossier. Ticket #158.
|
||||
*/
|
||||
private async resolveFoyerParentUserIds(parent: Parents): Promise<string[]> {
|
||||
const ids = new Set<string>([parent.user_id]);
|
||||
|
||||
if (parent.co_parent?.id) {
|
||||
ids.add(parent.co_parent.id);
|
||||
}
|
||||
|
||||
// Sens inverse : parents qui déclarent ce user comme co-parent
|
||||
const reverseLinks = await this.parentsRepository.find({
|
||||
where: { co_parent: { id: parent.user_id } },
|
||||
relations: ['co_parent'],
|
||||
});
|
||||
for (const p of reverseLinks) {
|
||||
ids.add(p.user_id);
|
||||
if (p.co_parent?.id) ids.add(p.co_parent.id);
|
||||
}
|
||||
|
||||
const dossier = parent.numero_dossier?.trim();
|
||||
if (dossier) {
|
||||
const sameDossier = await this.parentsRepository.find({
|
||||
where: { numero_dossier: dossier },
|
||||
relations: ['co_parent'],
|
||||
});
|
||||
for (const p of sameDossier) {
|
||||
ids.add(p.user_id);
|
||||
if (p.co_parent?.id) ids.add(p.co_parent.id);
|
||||
}
|
||||
}
|
||||
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rattacher un enfant au foyer du parent (tous les responsables). Ticket #158.
|
||||
* Un seul POST suffit : liens créés pour pivot + co-parent / même dossier.
|
||||
*/
|
||||
async attachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
|
||||
const parent = await this.findOne(parentUserId);
|
||||
|
||||
const child = await this.parentsRepository.manager.findOne(Children, {
|
||||
where: { id: enfantId },
|
||||
});
|
||||
if (!child) {
|
||||
throw new NotFoundException('Enfant introuvable');
|
||||
}
|
||||
|
||||
const foyerIds = await this.resolveFoyerParentUserIds(parent);
|
||||
let created = 0;
|
||||
|
||||
for (const memberId of foyerIds) {
|
||||
const existing = await this.parentsChildrenRepository.findOne({
|
||||
where: { parentId: memberId, enfantId },
|
||||
});
|
||||
if (existing) continue;
|
||||
|
||||
await this.parentsChildrenRepository.save(
|
||||
this.parentsChildrenRepository.create({
|
||||
parentId: memberId,
|
||||
enfantId,
|
||||
}),
|
||||
);
|
||||
created += 1;
|
||||
}
|
||||
|
||||
if (created === 0) {
|
||||
throw new ConflictException('Cet enfant est déjà rattaché à ce foyer');
|
||||
}
|
||||
|
||||
return this.findOne(parentUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Détacher un enfant du foyer du parent (tous les responsables). Ticket #158.
|
||||
* Si plus aucun lien ensuite → enfant orphelin (#157).
|
||||
*/
|
||||
async detachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
|
||||
const parent = await this.findOne(parentUserId);
|
||||
|
||||
const link = await this.parentsChildrenRepository.findOne({
|
||||
where: { parentId: parentUserId, enfantId },
|
||||
});
|
||||
if (!link) {
|
||||
throw new NotFoundException('Lien parent-enfant introuvable');
|
||||
}
|
||||
|
||||
const foyerIds = await this.resolveFoyerParentUserIds(parent);
|
||||
await this.parentsChildrenRepository.delete({
|
||||
parentId: In(foyerIds),
|
||||
enfantId,
|
||||
});
|
||||
|
||||
return this.findOne(parentUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste des familles en attente (une entrée par famille).
|
||||
* Famille = lien co_parent ou partage d'enfants (même logique que backfill #103).
|
||||
@@ -122,7 +251,15 @@ export class ParentsService {
|
||||
SELECT id, (MIN(rep::text))::uuid AS rep FROM rec GROUP BY id
|
||||
)
|
||||
SELECT
|
||||
'Famille ' || string_agg(u.nom, ' - ' ORDER BY u.nom, u.prenom) AS libelle,
|
||||
string_agg(
|
||||
UPPER(TRIM(u.nom))
|
||||
|| CASE
|
||||
WHEN u.prenom IS NOT NULL AND TRIM(u.prenom) <> ''
|
||||
THEN ' ' || INITCAP(TRIM(u.prenom))
|
||||
ELSE ''
|
||||
END,
|
||||
' - ' ORDER BY u.nom, u.prenom, u.id
|
||||
) AS libelle,
|
||||
array_agg(p.id_utilisateur ORDER BY u.nom, u.prenom, u.id) AS "parentIds",
|
||||
(array_agg(p.numero_dossier))[1] AS numero_dossier,
|
||||
MIN(u.cree_le) AS date_soumission,
|
||||
@@ -138,6 +275,8 @@ export class ParentsService {
|
||||
json_build_object(
|
||||
'id', u.id::text,
|
||||
'email', u.email,
|
||||
'nom', u.nom,
|
||||
'prenom', u.prenom,
|
||||
'telephone', u.telephone,
|
||||
'code_postal', u.code_postal,
|
||||
'ville', u.ville
|
||||
@@ -188,11 +327,21 @@ export class ParentsService {
|
||||
return [];
|
||||
}
|
||||
|
||||
private normalizeParents(parents: unknown): { id: string; email: string; telephone: string | null; code_postal: string | null; ville: string | null }[] {
|
||||
private normalizeParents(parents: unknown): {
|
||||
id: string;
|
||||
email: string;
|
||||
nom: string | null;
|
||||
prenom: string | null;
|
||||
telephone: string | null;
|
||||
code_postal: string | null;
|
||||
ville: string | null;
|
||||
}[] {
|
||||
if (Array.isArray(parents)) {
|
||||
return parents.map((p: any) => ({
|
||||
id: String(p?.id ?? ''),
|
||||
email: String(p?.email ?? ''),
|
||||
nom: p?.nom != null ? String(p.nom) : null,
|
||||
prenom: p?.prenom != null ? String(p.prenom) : null,
|
||||
telephone: p?.telephone != null ? String(p.telephone) : null,
|
||||
code_postal: p?.code_postal != null ? String(p.code_postal) : null,
|
||||
ville: p?.ville != null ? String(p.ville) : null,
|
||||
@@ -221,6 +370,7 @@ export class ParentsService {
|
||||
status: child.status,
|
||||
photo_url: child.photo_url ?? undefined,
|
||||
consent_photo: child.consent_photo,
|
||||
est_multiple: child.is_multiple,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -24,15 +24,19 @@ export class RelaisController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||
@ApiOperation({ summary: 'Lister tous les relais' })
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||
@ApiOperation({
|
||||
summary: 'Lister tous les relais',
|
||||
description:
|
||||
'Lecture ouverte aux gestionnaires (combobox fiches). CRUD write reste admin-only. Ticket #151.',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Liste des relais.' })
|
||||
findAll() {
|
||||
return this.relaisService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||
@ApiOperation({ summary: 'Récupérer un relais par ID' })
|
||||
@ApiResponse({ status: 200, description: 'Le relais trouvé.' })
|
||||
findOne(@Param('id') id: string) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, ServiceUnavailableException } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { RoleType, StatutUtilisateurType, Users } from "src/entities/users.entity";
|
||||
import { In, MoreThan, Repository } from "typeorm";
|
||||
@@ -332,7 +332,7 @@ export class UserService {
|
||||
return savedUser;
|
||||
}
|
||||
|
||||
/** Refuser un compte (en_attente -> refuse) ; tracé validations, token reprise, email. Ticket #110 */
|
||||
/** Refuser un dossier (en_attente -> refuse) ; tracé validations, token reprise partagé, emails. Ticket #110 */
|
||||
async refuseUser(user_id: string, currentUser: Users, comment?: string): Promise<Users> {
|
||||
if (![RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE].includes(currentUser.role)) {
|
||||
throw new ForbiddenException('Accès réservé aux super admins, administrateurs et gestionnaires');
|
||||
@@ -340,40 +340,79 @@ export class UserService {
|
||||
const user = await this.usersRepository.findOne({ where: { id: user_id } });
|
||||
if (!user) throw new NotFoundException('Utilisateur introuvable');
|
||||
if (user.statut !== StatutUtilisateurType.EN_ATTENTE) {
|
||||
throw new BadRequestException('Seul un compte en attente peut être refusé.');
|
||||
throw new BadRequestException('Seul un dossier en attente peut être refusé.');
|
||||
}
|
||||
|
||||
const tokenReprise = crypto.randomUUID();
|
||||
const expireLe = new Date();
|
||||
expireLe.setDate(expireLe.getDate() + 7);
|
||||
|
||||
user.statut = StatutUtilisateurType.REFUSE;
|
||||
user.token_reprise = tokenReprise;
|
||||
user.token_reprise_expire_le = expireLe;
|
||||
const savedUser = await this.usersRepository.save(user);
|
||||
const usersDossier = user.role === RoleType.PARENT && user.numero_dossier
|
||||
? await this.usersRepository.find({
|
||||
where: {
|
||||
role: RoleType.PARENT,
|
||||
numero_dossier: user.numero_dossier,
|
||||
},
|
||||
})
|
||||
: [user];
|
||||
|
||||
const validation = this.validationRepository.create({
|
||||
user: savedUser,
|
||||
type: 'refus_compte',
|
||||
status: StatutValidationType.REFUSE,
|
||||
validated_by: currentUser,
|
||||
comment,
|
||||
});
|
||||
await this.validationRepository.save(validation);
|
||||
|
||||
try {
|
||||
await this.mailService.sendRefusEmail(
|
||||
savedUser.email,
|
||||
savedUser.prenom ?? '',
|
||||
savedUser.nom ?? '',
|
||||
comment,
|
||||
tokenReprise,
|
||||
const usersARefuser = usersDossier.length ? usersDossier : [user];
|
||||
const utilisateurInvalide = usersARefuser.find(
|
||||
(u) => u.statut !== StatutUtilisateurType.EN_ATTENTE,
|
||||
);
|
||||
if (utilisateurInvalide) {
|
||||
throw new BadRequestException(
|
||||
'Tous les comptes du dossier doivent être en attente pour refuser le dossier.',
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Envoi email refus échoué pour ${savedUser.email}`, err);
|
||||
}
|
||||
|
||||
return savedUser;
|
||||
const savedUsers = await this.usersRepository.manager.transaction(async (manager) => {
|
||||
const updatedUsers: Users[] = [];
|
||||
|
||||
for (const userARefuser of usersARefuser) {
|
||||
userARefuser.statut = StatutUtilisateurType.REFUSE;
|
||||
userARefuser.token_reprise = tokenReprise;
|
||||
userARefuser.token_reprise_expire_le = expireLe;
|
||||
|
||||
const savedUser = await manager.save(Users, userARefuser);
|
||||
updatedUsers.push(savedUser);
|
||||
|
||||
const validation = manager.create(Validation, {
|
||||
user: savedUser,
|
||||
type: 'refus_compte',
|
||||
status: StatutValidationType.REFUSE,
|
||||
validated_by: currentUser,
|
||||
comment,
|
||||
});
|
||||
await manager.save(Validation, validation);
|
||||
}
|
||||
|
||||
return updatedUsers;
|
||||
});
|
||||
|
||||
const emailFailures: string[] = [];
|
||||
for (const savedUser of savedUsers) {
|
||||
try {
|
||||
await this.mailService.sendRefusEmail(
|
||||
savedUser.email,
|
||||
savedUser.prenom ?? '',
|
||||
savedUser.nom ?? '',
|
||||
comment,
|
||||
tokenReprise,
|
||||
);
|
||||
} catch (err) {
|
||||
emailFailures.push(savedUser.email);
|
||||
this.logger.error(`Envoi email refus échoué pour ${savedUser.email}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
if (emailFailures.length > 0) {
|
||||
throw new ServiceUnavailableException(
|
||||
`Le dossier a bien été refusé en base, mais l'envoi d'email a échoué pour : ${emailFailures.join(', ')}. Réessayez ou contactez le support.`,
|
||||
);
|
||||
}
|
||||
|
||||
return savedUsers.find((savedUser) => savedUser.id === user.id) ?? savedUsers[0];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+121
-95
@@ -1,3 +1,9 @@
|
||||
-- ==========================================================
|
||||
-- P'titsPas — Schéma PostgreSQL (création from scratch)
|
||||
-- Fichier canonique : toute nouvelle BDD doit partir d'ici.
|
||||
-- Migrations dans database/migrations/ : BDD existantes uniquement.
|
||||
-- ==========================================================
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
-- ==========================================================
|
||||
@@ -5,40 +11,63 @@ CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
-- ==========================================================
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'role_type') THEN
|
||||
CREATE TYPE role_type AS ENUM ('parent', 'gestionnaire', 'super_admin', 'administrateur', 'assistante_maternelle');
|
||||
CREATE TYPE role_type AS ENUM (
|
||||
'parent', 'gestionnaire', 'super_admin', 'administrateur', 'assistante_maternelle'
|
||||
);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'genre_type') THEN
|
||||
CREATE TYPE genre_type AS ENUM ('H', 'F', 'Autre');
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_utilisateur_type') THEN
|
||||
CREATE TYPE statut_utilisateur_type AS ENUM ('en_attente','actif','suspendu','refuse');
|
||||
CREATE TYPE statut_utilisateur_type AS ENUM ('en_attente', 'actif', 'suspendu', 'refuse');
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_enfant_type') THEN
|
||||
CREATE TYPE statut_enfant_type AS ENUM ('a_naitre','actif','scolarise');
|
||||
CREATE TYPE statut_enfant_type AS ENUM ('a_naitre', 'garde', 'sans_garde', 'scolarise');
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_dossier_type') THEN
|
||||
CREATE TYPE statut_dossier_type AS ENUM ('envoye','accepte','refuse');
|
||||
CREATE TYPE statut_dossier_type AS ENUM ('envoye', 'accepte', 'refuse');
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_contrat_type') THEN
|
||||
CREATE TYPE statut_contrat_type AS ENUM ('brouillon','en_attente_signature','valide','resilie');
|
||||
CREATE TYPE statut_contrat_type AS ENUM (
|
||||
'brouillon', 'en_attente_signature', 'valide', 'resilie'
|
||||
);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_avenant_type') THEN
|
||||
CREATE TYPE statut_avenant_type AS ENUM ('propose','accepte','refuse');
|
||||
CREATE TYPE statut_avenant_type AS ENUM ('propose', 'accepte', 'refuse');
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'type_evenement_type') THEN
|
||||
CREATE TYPE type_evenement_type AS ENUM ('absence_enfant','conge_am','conge_parent','arret_maladie_am','evenement_rpe');
|
||||
CREATE TYPE type_evenement_type AS ENUM (
|
||||
'absence_enfant', 'conge_am', 'conge_parent', 'arret_maladie_am', 'evenement_rpe'
|
||||
);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_evenement_type') THEN
|
||||
CREATE TYPE statut_evenement_type AS ENUM ('propose','valide','refuse');
|
||||
CREATE TYPE statut_evenement_type AS ENUM ('propose', 'valide', 'refuse');
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_validation_type') THEN
|
||||
CREATE TYPE statut_validation_type AS ENUM ('en_attente','valide','refuse');
|
||||
CREATE TYPE statut_validation_type AS ENUM ('en_attente', 'valide', 'refuse');
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'situation_familiale_type') THEN
|
||||
CREATE TYPE situation_familiale_type AS ENUM ('celibataire','marie','concubinage','pacse','separe','divorce','veuf','parent_isole');
|
||||
CREATE TYPE situation_familiale_type AS ENUM (
|
||||
'celibataire', 'marie', 'concubinage', 'pacse', 'separe', 'divorce', 'veuf', 'parent_isole'
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ==========================================================
|
||||
-- Table : relais (avant utilisateurs — FK relais_id)
|
||||
-- ==========================================================
|
||||
CREATE TABLE relais (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
nom VARCHAR(255) NOT NULL,
|
||||
adresse TEXT NOT NULL,
|
||||
horaires_ouverture JSONB,
|
||||
ligne_fixe VARCHAR(20),
|
||||
actif BOOLEAN DEFAULT true,
|
||||
notes TEXT,
|
||||
cree_le TIMESTAMPTZ DEFAULT now(),
|
||||
modifie_le TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- ==========================================================
|
||||
-- Table : utilisateurs
|
||||
-- ==========================================================
|
||||
@@ -46,23 +75,33 @@ CREATE TABLE utilisateurs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'),
|
||||
password TEXT, -- NULL avant création via token
|
||||
password TEXT,
|
||||
prenom VARCHAR(100),
|
||||
nom VARCHAR(100),
|
||||
genre genre_type,
|
||||
role role_type NOT NULL,
|
||||
statut statut_utilisateur_type DEFAULT 'en_attente',
|
||||
telephone VARCHAR(20), -- Unifié (mobile privilégié)
|
||||
telephone VARCHAR(20),
|
||||
adresse TEXT,
|
||||
date_naissance DATE,
|
||||
lieu_naissance_ville VARCHAR(100),
|
||||
lieu_naissance_pays VARCHAR(100),
|
||||
photo_url TEXT, -- Obligatoire pour AM, non utilisé pour parents
|
||||
photo_url TEXT,
|
||||
consentement_photo BOOLEAN DEFAULT false,
|
||||
date_consentement_photo TIMESTAMPTZ,
|
||||
token_creation_mdp VARCHAR(255), -- Token pour créer MDP après validation
|
||||
token_creation_mdp_expire_le TIMESTAMPTZ, -- Expiration 7 jours
|
||||
token_creation_mdp VARCHAR(255),
|
||||
token_creation_mdp_expire_le TIMESTAMPTZ,
|
||||
password_reset_token VARCHAR(255),
|
||||
password_reset_expires TIMESTAMPTZ,
|
||||
token_reprise VARCHAR(255),
|
||||
token_reprise_expire_le TIMESTAMPTZ,
|
||||
changement_mdp_obligatoire BOOLEAN DEFAULT false,
|
||||
numero_dossier VARCHAR(20),
|
||||
cgu_version_acceptee INTEGER,
|
||||
cgu_acceptee_le TIMESTAMPTZ,
|
||||
privacy_version_acceptee INTEGER,
|
||||
privacy_acceptee_le TIMESTAMPTZ,
|
||||
relais_id UUID REFERENCES relais(id) ON DELETE SET NULL,
|
||||
cree_le TIMESTAMPTZ DEFAULT now(),
|
||||
modifie_le TIMESTAMPTZ DEFAULT now(),
|
||||
ville VARCHAR(150),
|
||||
@@ -71,11 +110,22 @@ CREATE TABLE utilisateurs (
|
||||
situation_familiale situation_familiale_type
|
||||
);
|
||||
|
||||
-- Index pour recherche par token
|
||||
CREATE INDEX idx_utilisateurs_token_creation_mdp
|
||||
ON utilisateurs(token_creation_mdp)
|
||||
WHERE token_creation_mdp IS NOT NULL;
|
||||
|
||||
CREATE INDEX idx_utilisateurs_password_reset_token
|
||||
ON utilisateurs(password_reset_token)
|
||||
WHERE password_reset_token IS NOT NULL;
|
||||
|
||||
CREATE INDEX idx_utilisateurs_token_reprise
|
||||
ON utilisateurs(token_reprise)
|
||||
WHERE token_reprise IS NOT NULL;
|
||||
|
||||
CREATE INDEX idx_utilisateurs_numero_dossier
|
||||
ON utilisateurs(numero_dossier)
|
||||
WHERE numero_dossier IS NOT NULL;
|
||||
|
||||
-- ==========================================================
|
||||
-- Table : assistantes_maternelles
|
||||
-- ==========================================================
|
||||
@@ -90,17 +140,27 @@ CREATE TABLE assistantes_maternelles (
|
||||
date_agrement DATE,
|
||||
annee_experience SMALLINT,
|
||||
specialite VARCHAR(100),
|
||||
place_disponible INT
|
||||
place_disponible INT,
|
||||
numero_dossier VARCHAR(20)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_assistantes_maternelles_numero_dossier
|
||||
ON assistantes_maternelles(numero_dossier)
|
||||
WHERE numero_dossier IS NOT NULL;
|
||||
|
||||
-- ==========================================================
|
||||
-- Table : parents
|
||||
-- ==========================================================
|
||||
CREATE TABLE parents (
|
||||
id_utilisateur UUID PRIMARY KEY REFERENCES utilisateurs(id) ON DELETE CASCADE,
|
||||
id_co_parent UUID REFERENCES utilisateurs(id)
|
||||
id_co_parent UUID REFERENCES utilisateurs(id),
|
||||
numero_dossier VARCHAR(20)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_parents_numero_dossier
|
||||
ON parents(numero_dossier)
|
||||
WHERE numero_dossier IS NOT NULL;
|
||||
|
||||
-- ==========================================================
|
||||
-- Table : enfants
|
||||
-- ==========================================================
|
||||
@@ -109,7 +169,7 @@ CREATE TABLE enfants (
|
||||
statut statut_enfant_type,
|
||||
prenom VARCHAR(100),
|
||||
nom VARCHAR(100),
|
||||
genre genre_type NOT NULL, -- Obligatoire selon CDC
|
||||
genre genre_type NOT NULL,
|
||||
date_naissance DATE,
|
||||
date_prevue_naissance DATE,
|
||||
photo_url TEXT,
|
||||
@@ -128,7 +188,27 @@ CREATE TABLE enfants_parents (
|
||||
);
|
||||
|
||||
-- ==========================================================
|
||||
-- Table : dossier_famille (inscription parent, schéma simplifié — ticket #119)
|
||||
-- Table : enfants_assistantes_maternelles (placement AM ↔ enfant — #131)
|
||||
-- ==========================================================
|
||||
CREATE TABLE enfants_assistantes_maternelles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
id_am UUID NOT NULL REFERENCES assistantes_maternelles(id_utilisateur) ON DELETE CASCADE,
|
||||
id_enfant UUID NOT NULL REFERENCES enfants(id) ON DELETE CASCADE,
|
||||
date_debut DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||
date_fin DATE NULL,
|
||||
cree_le TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
cree_par UUID REFERENCES utilisateurs(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_eam_am ON enfants_assistantes_maternelles(id_am);
|
||||
CREATE INDEX idx_eam_enfant ON enfants_assistantes_maternelles(id_enfant);
|
||||
|
||||
CREATE UNIQUE INDEX uq_enfant_garde_active
|
||||
ON enfants_assistantes_maternelles (id_enfant)
|
||||
WHERE date_fin IS NULL;
|
||||
|
||||
-- ==========================================================
|
||||
-- Table : dossier_famille (inscription parent — ticket #119)
|
||||
-- ==========================================================
|
||||
CREATE TABLE dossier_famille (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -271,11 +351,11 @@ CREATE TABLE notifications (
|
||||
-- ==========================================================
|
||||
CREATE TABLE validations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
id_utilisateur UUID REFERENCES utilisateurs(id),
|
||||
id_utilisateur UUID REFERENCES utilisateurs(id) ON DELETE SET NULL,
|
||||
type VARCHAR(50),
|
||||
statut statut_validation_type DEFAULT 'en_attente',
|
||||
commentaire TEXT,
|
||||
valide_par UUID REFERENCES utilisateurs(id),
|
||||
valide_par UUID REFERENCES utilisateurs(id) ON DELETE SET NULL,
|
||||
cree_le TIMESTAMPTZ DEFAULT now(),
|
||||
modifie_le TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
@@ -294,13 +374,10 @@ CREATE TABLE configuration (
|
||||
modifie_par UUID REFERENCES utilisateurs(id)
|
||||
);
|
||||
|
||||
-- Index pour performance
|
||||
CREATE INDEX idx_configuration_cle ON configuration(cle);
|
||||
CREATE INDEX idx_configuration_categorie ON configuration(categorie);
|
||||
|
||||
-- Seed initial de configuration
|
||||
INSERT INTO configuration (cle, valeur, type, categorie, description) VALUES
|
||||
-- === Configuration Email (SMTP) ===
|
||||
('smtp_host', 'localhost', 'string', 'email', 'Serveur SMTP (ex: mail.mairie-bezons.fr, smtp.gmail.com)'),
|
||||
('smtp_port', '25', 'number', 'email', 'Port SMTP (25, 465, 587)'),
|
||||
('smtp_secure', 'false', 'boolean', 'email', 'Utiliser SSL/TLS (true pour port 465)'),
|
||||
@@ -309,14 +386,10 @@ INSERT INTO configuration (cle, valeur, type, categorie, description) VALUES
|
||||
('smtp_password', '', 'encrypted', 'email', 'Mot de passe SMTP (chiffré en AES-256)'),
|
||||
('email_from_name', 'P''titsPas', 'string', 'email', 'Nom de l''expéditeur affiché dans les emails'),
|
||||
('email_from_address', 'no-reply@ptits-pas.fr', 'string', 'email', 'Adresse email de l''expéditeur'),
|
||||
|
||||
-- === Configuration Application ===
|
||||
('app_name', 'P''titsPas', 'string', 'app', 'Nom de l''application (affiché dans l''interface)'),
|
||||
('app_url', 'https://app.ptits-pas.fr', 'string', 'app', 'URL publique de l''application (pour les liens dans emails)'),
|
||||
('app_logo_url', '/assets/logo.png', 'string', 'app', 'URL du logo de l''application'),
|
||||
('setup_completed', 'false', 'boolean', 'app', 'Configuration initiale terminée'),
|
||||
|
||||
-- === Configuration Sécurité ===
|
||||
('password_reset_token_expiry_days', '7', 'number', 'security', 'Durée de validité des tokens de création/réinitialisation de mot de passe (en jours)'),
|
||||
('jwt_expiry_hours', '24', 'number', 'security', 'Durée de validité des sessions JWT (en heures)'),
|
||||
('max_upload_size_mb', '5', 'number', 'security', 'Taille maximale des fichiers uploadés (en MB)'),
|
||||
@@ -327,19 +400,18 @@ INSERT INTO configuration (cle, valeur, type, categorie, description) VALUES
|
||||
-- ==========================================================
|
||||
CREATE TABLE documents_legaux (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
type VARCHAR(50) NOT NULL, -- 'cgu' ou 'privacy'
|
||||
version INTEGER NOT NULL, -- Numéro de version (auto-incrémenté)
|
||||
fichier_nom VARCHAR(255) NOT NULL, -- Nom original du fichier
|
||||
fichier_path VARCHAR(500) NOT NULL, -- Chemin de stockage
|
||||
fichier_hash VARCHAR(64) NOT NULL, -- Hash SHA-256 pour intégrité
|
||||
actif BOOLEAN DEFAULT false, -- Version actuellement active
|
||||
televerse_par UUID REFERENCES utilisateurs(id), -- Qui a uploadé
|
||||
televerse_le TIMESTAMPTZ DEFAULT now(), -- Date d'upload
|
||||
active_le TIMESTAMPTZ, -- Date d'activation
|
||||
UNIQUE(type, version) -- Pas de doublon version
|
||||
type VARCHAR(50) NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
fichier_nom VARCHAR(255) NOT NULL,
|
||||
fichier_path VARCHAR(500) NOT NULL,
|
||||
fichier_hash VARCHAR(64) NOT NULL,
|
||||
actif BOOLEAN DEFAULT false,
|
||||
televerse_par UUID REFERENCES utilisateurs(id),
|
||||
televerse_le TIMESTAMPTZ DEFAULT now(),
|
||||
active_le TIMESTAMPTZ,
|
||||
UNIQUE(type, version)
|
||||
);
|
||||
|
||||
-- Index pour performance
|
||||
CREATE INDEX idx_documents_legaux_type_actif ON documents_legaux(type, actif);
|
||||
CREATE INDEX idx_documents_legaux_version ON documents_legaux(type, version DESC);
|
||||
|
||||
@@ -350,66 +422,23 @@ CREATE TABLE acceptations_documents (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
id_utilisateur UUID REFERENCES utilisateurs(id) ON DELETE CASCADE,
|
||||
id_document UUID REFERENCES documents_legaux(id),
|
||||
type_document VARCHAR(50) NOT NULL, -- 'cgu' ou 'privacy'
|
||||
version_document INTEGER NOT NULL, -- Version acceptée
|
||||
accepte_le TIMESTAMPTZ DEFAULT now(), -- Date d'acceptation
|
||||
ip_address INET, -- IP de l'utilisateur (RGPD)
|
||||
user_agent TEXT -- Navigateur (preuve)
|
||||
type_document VARCHAR(50) NOT NULL,
|
||||
version_document INTEGER NOT NULL,
|
||||
accepte_le TIMESTAMPTZ DEFAULT now(),
|
||||
ip_address INET,
|
||||
user_agent TEXT
|
||||
);
|
||||
|
||||
-- Index pour performance
|
||||
CREATE INDEX idx_acceptations_utilisateur ON acceptations_documents(id_utilisateur);
|
||||
CREATE INDEX idx_acceptations_document ON acceptations_documents(id_document);
|
||||
|
||||
-- ==========================================================
|
||||
-- Table : relais
|
||||
-- ==========================================================
|
||||
CREATE TABLE relais (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
nom VARCHAR(255) NOT NULL,
|
||||
adresse TEXT NOT NULL,
|
||||
horaires_ouverture JSONB,
|
||||
ligne_fixe VARCHAR(20),
|
||||
actif BOOLEAN DEFAULT true,
|
||||
notes TEXT,
|
||||
cree_le TIMESTAMPTZ DEFAULT now(),
|
||||
modifie_le TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- ==========================================================
|
||||
-- Modification Table : utilisateurs (ajout colonnes documents et relais)
|
||||
-- ==========================================================
|
||||
ALTER TABLE utilisateurs
|
||||
ADD COLUMN IF NOT EXISTS cgu_version_acceptee INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS cgu_acceptee_le TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS privacy_version_acceptee INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS privacy_acceptee_le TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS relais_id UUID REFERENCES relais(id) ON DELETE SET NULL;
|
||||
|
||||
-- ==========================================================
|
||||
-- Ticket #103 : Numéro de dossier (AAAA-NNNNNN, séquence par année)
|
||||
-- ==========================================================
|
||||
CREATE TABLE IF NOT EXISTS numero_dossier_sequence (
|
||||
annee INT PRIMARY KEY,
|
||||
prochain INT NOT NULL DEFAULT 1
|
||||
CREATE TABLE numero_dossier_sequence (
|
||||
annee INT PRIMARY KEY,
|
||||
prochain INT NOT NULL DEFAULT 1
|
||||
);
|
||||
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL;
|
||||
ALTER TABLE assistantes_maternelles ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL;
|
||||
ALTER TABLE parents ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_utilisateurs_numero_dossier ON utilisateurs(numero_dossier) WHERE numero_dossier IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_assistantes_maternelles_numero_dossier ON assistantes_maternelles(numero_dossier) WHERE numero_dossier IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_parents_numero_dossier ON parents(numero_dossier) WHERE numero_dossier IS NOT NULL;
|
||||
|
||||
-- ==========================================================
|
||||
-- Ticket #110 : Token reprise après refus (lien email)
|
||||
-- ==========================================================
|
||||
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS token_reprise VARCHAR(255) NULL;
|
||||
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS token_reprise_expire_le TIMESTAMPTZ NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_utilisateurs_token_reprise ON utilisateurs(token_reprise) WHERE token_reprise IS NOT NULL;
|
||||
|
||||
-- Lieu de naissance (aligné CREATE TABLE utilisateurs — idempotent si colonnes déjà présentes)
|
||||
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS lieu_naissance_ville VARCHAR(100) NULL;
|
||||
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS lieu_naissance_pays VARCHAR(100) NULL;
|
||||
|
||||
-- ==========================================================
|
||||
-- Seed : Documents légaux génériques v1
|
||||
@@ -420,10 +449,7 @@ INSERT INTO documents_legaux (type, version, fichier_nom, fichier_path, fichier_
|
||||
|
||||
-- ==========================================================
|
||||
-- Seed : Super Administrateur par défaut
|
||||
-- ==========================================================
|
||||
-- Email: admin@ptits-pas.fr
|
||||
-- Mot de passe: 4dm1n1strateur (hashé bcrypt)
|
||||
-- IMPORTANT: Changer ce mot de passe en production !
|
||||
-- Email: admin@ptits-pas.fr | Mot de passe: 4dm1n1strateur
|
||||
-- ==========================================================
|
||||
INSERT INTO utilisateurs (
|
||||
email,
|
||||
|
||||
+6
-5
@@ -15,12 +15,13 @@ Ce projet contient la **base de données** pour l'application PtitsPas, avec scr
|
||||
|
||||
## Structure du projet
|
||||
|
||||
- `migrations/` : scripts SQL pour la création et l'import de la base
|
||||
- `bdd/data_test/` : fichiers CSV pour l'import de données de test
|
||||
- `docs/` : documentation métier et technique
|
||||
- `seed/` : scripts de seed
|
||||
- **`BDD.sql`** : schéma canonique (création from scratch — utilisé par `docker-compose.yml` racine)
|
||||
- `migrations/` : scripts SQL pour **mettre à jour** une BDD déjà en service
|
||||
- `bdd/data_test/` : fichiers CSV (import legacy)
|
||||
- `docs/` : documentation métier et technique (`ENUMS.md`, `FK_POLICIES.md`)
|
||||
- `seed/` : scripts de seed (`03_seed_test_data.sql` — jeu dashboard admin)
|
||||
- `tests/` : tests SQL
|
||||
- `docker-compose.dev.yml` : configuration Docker pour le développement
|
||||
- `docker-compose.dev.yml` : Postgres standalone (BDD.sql + seed auto)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"id","statut","prenom","nom","genre","date_naissance","date_prevue_naissance","photo_url","consentement_photo","date_consentement_photo","est_multiple"
|
||||
"5e8574b7-63e6-4d48-9af3-8d3bf7a6a6cf","actif","Emma","Dupont","F","2020-06-01",,,False,,False
|
||||
"a5c3268e-07eb-41a4-9f6c-2f9f16f37c3d","actif",,,,"2020-01-01","2025-01-01",,False,,False
|
||||
"e1a2b3c4-d5e6-4f7a-8b9c-1d2e3f4a5b6c","actif","Emma","Martin",,"2023-02-15",,,False,,False
|
||||
"e2b3c4d5-e6f7-4a8b-9c1d-2e3f4a5b6c7d","actif","Noah","Martin",,"2023-02-15",,,False,,False
|
||||
"e3c4d5e6-f7a8-4b9c-1d2e-3f4a5b6c7d8e","actif","Léa","Martin",,"2023-02-15",,,False,,False
|
||||
"e4d5e6f7-a8b9-4c1d-2e3f-4a5b6c7d8e9f","actif","Chloé","Rousseau",,"2022-04-20",,,False,,False
|
||||
"e5e6f7a8-b9c1-4d2e-3f4a-5b6c7d8e9f1a","actif","Hugo","Rousseau",,"2024-03-10",,,False,,False
|
||||
"e6f7a8b9-c1d2-4e3f-5a6b-7c8d9e0f1a2b","actif","Maxime","Lecomte",,"2023-04-15",,,False,,False
|
||||
"5e8574b7-63e6-4d48-9af3-8d3bf7a6a6cf","sans_garde","Emma","Dupont","F","2020-06-01",,,False,,False
|
||||
"a5c3268e-07eb-41a4-9f6c-2f9f16f37c3d","sans_garde",,,,"2020-01-01","2025-01-01",,False,,False
|
||||
"e1a2b3c4-d5e6-4f7a-8b9c-1d2e3f4a5b6c","sans_garde","Emma","Martin",,"2023-02-15",,,False,,False
|
||||
"e2b3c4d5-e6f7-4a8b-9c1d-2e3f4a5b6c7d","sans_garde","Noah","Martin",,"2023-02-15",,,False,,False
|
||||
"e3c4d5e6-f7a8-4b9c-1d2e-3f4a5b6c7d8e","sans_garde","Léa","Martin",,"2023-02-15",,,False,,False
|
||||
"e4d5e6f7-a8b9-4c1d-2e3f-4a5b6c7d8e9f","sans_garde","Chloé","Rousseau",,"2022-04-20",,,False,,False
|
||||
"e5e6f7a8-b9c1-4d2e-3f4a-5b6c7d8e9f1a","sans_garde","Hugo","Rousseau",,"2024-03-10",,,False,,False
|
||||
"e6f7a8b9-c1d2-4e3f-5a6b-7c8d9e0f1a2b","sans_garde","Maxime","Lecomte",,"2023-04-15",,,False,,False
|
||||
"edd19cd1-bb67-4f14-8a37-c66b75c94537","scolarise","Lucas","Durand","H","2018-09-15",,,False,,False
|
||||
|
||||
|
@@ -14,9 +14,8 @@ services:
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- ./migrations/01_init.sql:/docker-entrypoint-initdb.d/01_init.sql
|
||||
- ./migrations/07_import.sql:/docker-entrypoint-initdb.d/07_import.sql
|
||||
- ./bdd/data_test:/bdd/data_test
|
||||
- ./BDD.sql:/docker-entrypoint-initdb.d/01_init.sql
|
||||
- ./seed/03_seed_test_data.sql:/docker-entrypoint-initdb.d/02_seed_test_data.sql
|
||||
- postgres_standalone_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- ptitspas_dev
|
||||
|
||||
@@ -51,12 +51,17 @@ Ce document recense **toutes les valeurs énumérées** utilisées dans la base
|
||||
| Valeur | Description |
|
||||
|---|---|
|
||||
| `a_naitre` | Enfant à naître (date prévue renseignée) |
|
||||
| `actif` | Enfant pris en charge / en cours de garde |
|
||||
| `sans_garde` | Enfant né, pas encore placé chez une AM (défaut à l'inscription) |
|
||||
| `garde` | Enfant placé chez une AM (`enfants_assistantes_maternelles` actif) |
|
||||
| `scolarise` | Enfant scolarisé, garde potentiellement périscolaire |
|
||||
|
||||
**Contraintes associées** :
|
||||
- `a_naitre` → **`date_prevue_naissance` obligatoire**
|
||||
- `actif`/`scolarise` → **`date_naissance` obligatoire**
|
||||
- `sans_garde` / `garde` / `scolarise` → **`date_naissance` obligatoire**
|
||||
|
||||
**Transitions** (API admin #131) :
|
||||
- Rattachement AM ↔ enfant → `garde` (sauf `a_naitre` / `scolarise`)
|
||||
- Détachement sans autre placement actif → `sans_garde`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -32,6 +32,9 @@ Documenter, de façon unique et partagée, les règles de suppression/mise à jo
|
||||
| **parents(id_co_parent)** → `utilisateurs(id)` | **SET NULL** | Conserver le parent principal si co-parent disparaît |
|
||||
| **enfants_parents(id_parent)** → `parents(id_utilisateur)` | **CASCADE** | Nettoyage liaisons N:N |
|
||||
| **enfants_parents(id_enfant)** → `enfants(id)` | **CASCADE** | Idem |
|
||||
| **enfants_assistantes_maternelles(id_am)** → `assistantes_maternelles(id_utilisateur)` | **CASCADE** | Placement supprimé avec l’AM |
|
||||
| **enfants_assistantes_maternelles(id_enfant)** → `enfants(id)` | **CASCADE** | Idem |
|
||||
| **enfants_assistantes_maternelles(cree_par)** → `utilisateurs(id)` | **SET NULL** | Historique placement conservé |
|
||||
| **dossiers(id_parent)** → `parents(id_utilisateur)` | **CASCADE** | Dossier n’a pas de sens sans parent |
|
||||
| **dossiers(id_enfant)** → `enfants(id)` | **CASCADE** | Dossier n’a pas de sens sans enfant |
|
||||
| **messages(id_dossier)** → `dossiers(id)` | **CASCADE** | Messages détruits avec le dossier |
|
||||
@@ -47,6 +50,7 @@ Documenter, de façon unique et partagée, les règles de suppression/mise à jo
|
||||
| **uploads(id_utilisateur)** → `utilisateurs(id)` | **SET NULL** | Fichier reste référencé sans l’auteur |
|
||||
| **notifications(id_utilisateur)** → `utilisateurs(id)` | **CASCADE** | Notifications propres à l’utilisateur |
|
||||
| **validations(id_utilisateur)** → `utilisateurs(id)` | **SET NULL** | Garder l’historique de décision |
|
||||
| **validations(valide_par)** → `utilisateurs(id)` | **SET NULL** | Décideur supprimé : la ligne reste |
|
||||
|
||||
> **ON UPDATE** : **NO ACTION** partout (les UUID ne changent pas).
|
||||
|
||||
@@ -85,6 +89,7 @@ Documenter, de façon unique et partagée, les règles de suppression/mise à jo
|
||||
- `uploads.id_utilisateur` → **SET NULL**
|
||||
- `notifications.id_utilisateur` → **CASCADE**
|
||||
- `validations.id_utilisateur` → **SET NULL**
|
||||
- `validations.valide_par` → **SET NULL**
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
-- Ticket #131 / #141 — Placement AM ↔ enfant + statuts garde/sans_garde
|
||||
-- ⚠️ BDD EXISTANTES UNIQUEMENT — ne pas exécuter sur une base créée via BDD.sql à jour.
|
||||
-- Idempotent : safe à rejouer sur recette / prod.
|
||||
|
||||
-- 1) Nouveaux statuts enfant
|
||||
DO $$ BEGIN
|
||||
ALTER TYPE statut_enfant_type ADD VALUE IF NOT EXISTS 'garde';
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TYPE statut_enfant_type ADD VALUE IF NOT EXISTS 'sans_garde';
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- actif → sans_garde (enfants sans placement AM connu au déploiement)
|
||||
UPDATE enfants SET statut = 'sans_garde' WHERE statut = 'actif';
|
||||
|
||||
-- 2) Table de placement AM ↔ enfant
|
||||
CREATE TABLE IF NOT EXISTS enfants_assistantes_maternelles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
id_am UUID NOT NULL REFERENCES assistantes_maternelles(id_utilisateur) ON DELETE CASCADE,
|
||||
id_enfant UUID NOT NULL REFERENCES enfants(id) ON DELETE CASCADE,
|
||||
date_debut DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||
date_fin DATE NULL,
|
||||
cree_le TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
cree_par UUID REFERENCES utilisateurs(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_eam_am ON enfants_assistantes_maternelles(id_am);
|
||||
CREATE INDEX IF NOT EXISTS idx_eam_enfant ON enfants_assistantes_maternelles(id_enfant);
|
||||
|
||||
-- Au plus une garde active par enfant
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_enfant_garde_active
|
||||
ON enfants_assistantes_maternelles (id_enfant)
|
||||
WHERE date_fin IS NULL;
|
||||
@@ -5,4 +5,4 @@ ALTER TABLE validations
|
||||
ADD COLUMN IF NOT EXISTS commentaire TEXT;
|
||||
|
||||
ALTER TABLE validations
|
||||
ADD COLUMN IF NOT EXISTS valide_par UUID REFERENCES utilisateurs(id);
|
||||
ADD COLUMN IF NOT EXISTS valide_par UUID REFERENCES utilisateurs(id) ON DELETE SET NULL;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Alignement FK validations ↔ BDD.sql / FK_POLICIES (ON DELETE SET NULL).
|
||||
-- À exécuter sur les bases créées avant la mise à jour du 2026-04-18.
|
||||
-- Noms de contraintes par défaut PostgreSQL : {table}_{colonne}_fkey.
|
||||
|
||||
ALTER TABLE validations DROP CONSTRAINT IF EXISTS validations_id_utilisateur_fkey;
|
||||
ALTER TABLE validations
|
||||
ADD CONSTRAINT validations_id_utilisateur_fkey
|
||||
FOREIGN KEY (id_utilisateur) REFERENCES utilisateurs(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE validations DROP CONSTRAINT IF EXISTS validations_valide_par_fkey;
|
||||
ALTER TABLE validations
|
||||
ADD CONSTRAINT validations_valide_par_fkey
|
||||
FOREIGN KEY (valide_par) REFERENCES utilisateurs(id) ON DELETE SET NULL;
|
||||
@@ -5,5 +5,6 @@ Scripts à appliquer **manuellement** sur les bases déjà créées (ex. `psql`
|
||||
| Fichier | Objet |
|
||||
|---------|--------|
|
||||
| `2026-04-17-validations-commentaire-valide-par.sql` | Ajoute `commentaire` et `valide_par` sur `validations` (requis par l’API / TypeORM). |
|
||||
| `2026-04-18-validations-fk-on-delete-set-null.sql` | Passe les FK `id_utilisateur` et `valide_par` en **ON DELETE SET NULL** (comme `BDD.sql` à jour). |
|
||||
|
||||
Les nouvelles installs via `BDD.sql` à jour incluent déjà ces colonnes.
|
||||
Les nouvelles installs via `BDD.sql` à jour incluent déjà ces colonnes et ces politiques de suppression.
|
||||
|
||||
@@ -65,12 +65,12 @@ ON CONFLICT (id_utilisateur) DO NOTHING;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- Enfants
|
||||
-- - child A : déjà né (statut = 'actif' et date_naissance requise)
|
||||
-- - child A : déjà né (statut = 'sans_garde' et date_naissance requise)
|
||||
-- - child B : à naître (statut = 'a_naitre' et date_prevue_naissance requise)
|
||||
-- ------------------------------------------------------------
|
||||
|
||||
INSERT INTO enfants (id, prenom, nom, statut, date_naissance, jumeau_multiple)
|
||||
VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Léo', 'Parent', 'actif', '2022-04-12', false)
|
||||
VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Léo', 'Parent', 'sans_garde', '2022-04-12', false)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO enfants (id, prenom, nom, statut, date_prevue_naissance, jumeau_multiple)
|
||||
@@ -212,7 +212,7 @@ INSERT INTO validations (id, id_utilisateur, statut, commentaire, cree_le)
|
||||
VALUES (
|
||||
'v0000000-0000-0000-0000-000000000001',
|
||||
'66666666-6666-6666-6666-666666666666',
|
||||
'accepte',
|
||||
'valide',
|
||||
'Dossier AM vérifié par gestionnaire.',
|
||||
NOW()
|
||||
)
|
||||
|
||||
@@ -18,15 +18,15 @@ BEGIN;
|
||||
|
||||
INSERT INTO utilisateurs (id, email, password, prenom, nom, role, statut, telephone, adresse, ville, code_postal, profession, situation_familiale, date_naissance, consentement_photo)
|
||||
VALUES
|
||||
('a0000001-0001-0001-0001-000000000001', 'sophie.bernard@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Sophie', 'BERNARD', 'administrateur', 'actif', '0678123456', '12 Avenue Gabriel Péri', 'Bezons', '95870', 'Responsable administrative', 'marie', '1978-03-15', false),
|
||||
('a0000002-0002-0002-0002-000000000002', 'lucas.moreau@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Lucas', 'MOREAU', 'gestionnaire', 'actif', '0687234567', '8 Rue Jean Jaurès', 'Bezons', '95870', 'Gestionnaire des placements', 'celibataire', '1985-09-22', false),
|
||||
('a0000003-0003-0003-0003-000000000003', 'marie.dubois@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Marie', 'DUBOIS', 'assistante_maternelle', 'actif', '0696345678', '25 Rue de la République', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1980-06-08', true),
|
||||
('a0000004-0004-0004-0004-000000000004', 'fatima.elmansouri@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Fatima', 'EL MANSOURI', 'assistante_maternelle', 'actif', '0675456789', '17 Boulevard Aristide Briand', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1975-11-12', true),
|
||||
('a0000005-0005-0005-0005-000000000005', 'claire.martin@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Claire', 'MARTIN', 'parent', 'actif', '0689567890', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Infirmière', 'marie', '1990-04-03', false),
|
||||
('a0000006-0006-0006-0006-000000000006', 'thomas.martin@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Thomas', 'MARTIN', 'parent', 'actif', '0678456789', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Ingénieur', 'marie', '1988-07-18', false),
|
||||
('a0000007-0007-0007-0007-000000000007', 'amelie.durand@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Amélie', 'DURAND', 'parent', 'actif', '0667788990', '23 Rue Victor Hugo', 'Bezons', '95870', 'Comptable', 'divorce', '1987-12-14', false),
|
||||
('a0000008-0008-0008-0008-000000000008', 'julien.rousseau@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Julien', 'ROUSSEAU', 'parent', 'actif', '0656677889', '14 Rue Pasteur', 'Bezons', '95870', 'Commercial', 'divorce', '1985-08-29', false),
|
||||
('a0000009-0009-0009-0009-000000000009', 'david.lecomte@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'David', 'LECOMTE', 'parent', 'actif', '0645566778', '31 Rue Émile Zola', 'Bezons', '95870', 'Développeur web', 'parent_isole', '1992-10-07', false)
|
||||
('a0000001-0001-0001-0001-000000000001', 'sophie.bernard@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Sophie', 'BERNARD', 'administrateur', 'actif', '0678123456', '12 Avenue Gabriel Péri', 'Bezons', '95870', 'Responsable administrative', 'marie', '1978-03-15', false),
|
||||
('a0000002-0002-0002-0002-000000000002', 'lucas.moreau@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Lucas', 'MOREAU', 'gestionnaire', 'actif', '0687234567', '8 Rue Jean Jaurès', 'Bezons', '95870', 'Gestionnaire des placements', 'celibataire', '1985-09-22', false),
|
||||
('a0000003-0003-0003-0003-000000000003', 'marie.dubois@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Marie', 'DUBOIS', 'assistante_maternelle', 'actif', '0696345678', '25 Rue de la République', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1980-06-08', true),
|
||||
('a0000004-0004-0004-0004-000000000004', 'fatima.elmansouri@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Fatima', 'EL MANSOURI', 'assistante_maternelle', 'actif', '0675456789', '17 Boulevard Aristide Briand', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1975-11-12', true),
|
||||
('a0000005-0005-0005-0005-000000000005', 'claire.martin@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Claire', 'MARTIN', 'parent', 'actif', '0689567890', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Infirmière', 'marie', '1990-04-03', false),
|
||||
('a0000006-0006-0006-0006-000000000006', 'thomas.martin@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Thomas', 'MARTIN', 'parent', 'actif', '0678456789', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Ingénieur', 'marie', '1988-07-18', false),
|
||||
('a0000007-0007-0007-0007-000000000007', 'amelie.durand@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Amélie', 'DURAND', 'parent', 'actif', '0667788990', '23 Rue Victor Hugo', 'Bezons', '95870', 'Comptable', 'divorce', '1987-12-14', false),
|
||||
('a0000008-0008-0008-0008-000000000008', 'julien.rousseau@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Julien', 'ROUSSEAU', 'parent', 'actif', '0656677889', '14 Rue Pasteur', 'Bezons', '95870', 'Commercial', 'divorce', '1985-08-29', false),
|
||||
('a0000009-0009-0009-0009-000000000009', 'david.lecomte@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'David', 'LECOMTE', 'parent', 'actif', '0645566778', '31 Rue Émile Zola', 'Bezons', '95870', 'Développeur web', 'parent_isole', '1992-10-07', false)
|
||||
ON CONFLICT (email) DO NOTHING;
|
||||
|
||||
-- ========== PARENTS (avec co-parent pour le couple Martin) ==========
|
||||
@@ -51,12 +51,12 @@ ON CONFLICT (id_utilisateur) DO NOTHING;
|
||||
-- ========== ENFANTS ==========
|
||||
INSERT INTO enfants (id, prenom, nom, genre, date_naissance, statut, est_multiple)
|
||||
VALUES
|
||||
('e0000001-0001-0001-0001-000000000001', 'Emma', 'MARTIN', 'F', '2023-02-15', 'actif', true),
|
||||
('e0000002-0002-0002-0002-000000000002', 'Noah', 'MARTIN', 'H', '2023-02-15', 'actif', true),
|
||||
('e0000003-0003-0003-0003-000000000003', 'Léa', 'MARTIN', 'F', '2023-02-15', 'actif', true),
|
||||
('e0000004-0004-0004-0004-000000000004', 'Chloé', 'ROUSSEAU', 'F', '2022-04-20', 'actif', false),
|
||||
('e0000005-0005-0005-0005-000000000005', 'Hugo', 'ROUSSEAU', 'H', '2024-03-10', 'actif', false),
|
||||
('e0000006-0006-0006-0006-000000000006', 'Maxime', 'LECOMTE', 'H', '2023-04-15', 'actif', false)
|
||||
('e0000001-0001-0001-0001-000000000001', 'Emma', 'MARTIN', 'F', '2023-02-15', 'sans_garde', true),
|
||||
('e0000002-0002-0002-0002-000000000002', 'Noah', 'MARTIN', 'H', '2023-02-15', 'sans_garde', true),
|
||||
('e0000003-0003-0003-0003-000000000003', 'Léa', 'MARTIN', 'F', '2023-02-15', 'sans_garde', true),
|
||||
('e0000004-0004-0004-0004-000000000004', 'Chloé', 'ROUSSEAU', 'F', '2022-04-20', 'sans_garde', false),
|
||||
('e0000005-0005-0005-0005-000000000005', 'Hugo', 'ROUSSEAU', 'H', '2024-03-10', 'sans_garde', false),
|
||||
('e0000006-0006-0006-0006-000000000006', 'Maxime', 'LECOMTE', 'H', '2023-04-15', 'sans_garde', false)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ========== ENFANTS_PARENTS (liaison N:N) ==========
|
||||
|
||||
+3
-2
@@ -36,6 +36,7 @@ Ce fichier sert d'index pour naviguer dans toute la documentation du projet.
|
||||
- [**23 - Liste des Tickets**](./23_LISTE-TICKETS.md) - 61 tickets Phase 1 détaillés
|
||||
- [**24 - Décisions Projet**](./24_DECISIONS-PROJET.md) - Décisions architecturales et fonctionnelles
|
||||
- [**25 - Backlog Phase 2**](./25_PHASE-2-BACKLOG.md) - Fonctionnalités techniques reportées
|
||||
- [**28 - Évolution famille et responsables**](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md) - Modèle dossier/famille, recompositions, v1.0.0 vs post-1.0.0
|
||||
- [**26 - API Gitea**](./26_GITEA-API.md) - Procédure d'utilisation de l'API Gitea (issues, PR, branches, labels)
|
||||
- [**27 - Briefing frontend**](./27_BRIEFING-FRONTEND.md) - Accès Git, priorités, scripts Gitea (token)
|
||||
|
||||
@@ -49,7 +50,7 @@ Fichiers **sans préfixe numérique** encore à la racine par **héritage** ou
|
||||
références outils (`.cursorrules`, etc.) — **à renommer** en `NN_` quand
|
||||
possible :
|
||||
- `CHARTE_GRAPHIQUE.md`
|
||||
- `EVOLUTIONS_CDC.md`
|
||||
- [`EVOLUTIONS_CDC.md`](./EVOLUTIONS_CDC.md) — écarts CDC / app ; voir aussi [**28 - Évolution famille**](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md)
|
||||
- `SuperNounou_Cahier_Des_Charges_Complet_V1.1.md`
|
||||
- `SuperNounou_SSS-001.md`
|
||||
|
||||
@@ -89,5 +90,5 @@ PgAdmin: https://app.ptits-pas.fr/pgadmin
|
||||
|
||||
Cette documentation est maintenue par Julien Martin (julien.martin@ptits-pas.fr).
|
||||
|
||||
Dernière mise à jour : Novembre 2025
|
||||
Dernière mise à jour : Juin 2026
|
||||
|
||||
|
||||
@@ -1528,7 +1528,7 @@ sequenceDiagram
|
||||
|
||||
### Architecture Frontend
|
||||
|
||||
**Framework** : Flutter 3.19.0
|
||||
**Framework** : Flutter 3.29.3
|
||||
**Langage** : Dart 3.x
|
||||
**State Management** : Provider / Riverpod
|
||||
**HTTP Client** : Dio / http
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
# 🎫 Liste Complète des Tickets - Projet P'titsPas
|
||||
|
||||
**Version** : 1.7
|
||||
**Date** : 25 Mars 2026
|
||||
**Version** : 1.9
|
||||
**Date** : 23 Avril 2026
|
||||
**Auteur** : Équipe PtitsPas
|
||||
**Estimation totale** : ~208h
|
||||
**Estimation totale** : ~220h
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Liste des tickets Gitea
|
||||
|
||||
**Les numéros de section dans ce document = numéros d’issues Gitea.** Ticket #14 dans le doc = issue Gitea #14, etc. Source : dépôt `jmartin/petitspas` (état au 25 mars 2026 ; #106, #107, #109, #119 fermés via API).
|
||||
**Les numéros de section dans ce document = numéros d’issues Gitea** (ticket #14 = issue Gitea #14, etc.). Le **numéro d’une nouvelle issue** n’est pas choisi dans ce fichier : il est **attribué par Gitea** (champ `number` dans la réponse JSON du `POST` création d’issue, ou affiché dans l’UI après création). Procédure : [**26_GITEA-API.md**](./26_GITEA-API.md) (§ 2 authentification, § 3.2 issues). Source dépôt : `jmartin/petitspas` (état au 25 mars 2026 ; #106, #107, #109, #119 fermés via API).
|
||||
|
||||
| Gitea # | Titre (dépôt) | Statut |
|
||||
|--------|----------------|--------|
|
||||
@@ -102,6 +102,7 @@
|
||||
| 117 | Évolution du cahier des charges | Ouvert |
|
||||
| 119 | Endpoint unifié GET /dossiers/:numeroDossier (AM ou famille) | ✅ Fermé |
|
||||
| 120 | [Full-stack] Inscription AM — photo, API et UX alignés sur les parents (suite #91) | ✅ Fermé |
|
||||
| 127 | [Full-stack] Mot de passe oublié — flux complet (demande, e-mail, réinitialisation) | ✅ Fermé · **milestone 0.1.0** + label **v0.1.0** (Gitea) |
|
||||
|
||||
*Gitea #1 et #2 = anciens tickets de test (fermés). Liste complète : https://git.ptits-pas.fr/jmartin/petitspas/issues*
|
||||
|
||||
@@ -118,11 +119,11 @@
|
||||
| **P0** | 7 tickets | ~5h | Amendements BDD (BLOQUANT) |
|
||||
| **P1** | 7 tickets | ~22h | Configuration système (BLOQUANT) |
|
||||
| **P2** | 18 tickets | ~50h | Backend métier |
|
||||
| **P3** | 22 tickets | ~71h | Frontend |
|
||||
| **P3** | 23 tickets | ~83h | Frontend |
|
||||
| **P4** | 4 tickets | ~24h | Tests & Documentation |
|
||||
| **CRITIQUES** | 6 tickets | ~13h | Upload, Logs, Infra, CDC |
|
||||
| **JURIDIQUE** | 1 ticket | ~8h | Rédaction CGU/Privacy |
|
||||
| **TOTAL** | **65 tickets** | **~184h** | |
|
||||
| **TOTAL** | **66 tickets** | **~220h** | |
|
||||
|
||||
---
|
||||
|
||||
@@ -913,6 +914,37 @@ Créer l'écran de création de mot de passe (lien reçu par email).
|
||||
|
||||
---
|
||||
|
||||
### Ticket #127 : [Full-stack] Mot de passe oublié — flux complet (demande, e-mail, réinitialisation)
|
||||
**Issue Gitea** : https://git.ptits-pas.fr/jmartin/petitspas/issues/127 (créée le 2026-04-23 via API, voir [26_GITEA-API.md](./26_GITEA-API.md)). **Version** : **milestone Gitea `0.1.0`** (périmètre release) ; en complément, label **`v0.1.0`** pour le filtrage. Roadmap : [04_ROADMAP-GENERALE.md](./04_ROADMAP-GENERALE.md) § versions incrémentales.
|
||||
|
||||
**Estimation** : 12h
|
||||
**Labels** : `full-stack`, `p2`, `p3`, `auth`, `security`, `cdc`
|
||||
|
||||
**Description** :
|
||||
Couvrir **tout** le parcours « Mot de passe oublié » : distinct du flux **création** de mot de passe post-inscription (#24 / #43 / `GET verify-token` + `POST create-password`). Aujourd’hui, le lien sur l’écran de connexion (`frontend/lib/screens/auth/login_screen.dart`) envoie vers `/create-password`, ce qui est incorrect pour un utilisateur déjà enregistré qui a oublié son mot de passe.
|
||||
|
||||
**Tâches — Backend** :
|
||||
- [ ] Analyse : réutiliser `password_reset_token` / `password_reset_expires` (et config type `password_reset_token_expiry_days`) **ou** introduire des champs / flux séparés si le même couple token/expiry sert encore à l’inscription — documenter le choix
|
||||
- [ ] `POST` (ex.) `/api/v1/auth/forgot-password` : body `{ "email" }` ; toujours réponse **neutre** (ex. 204 ou message identique que l’e-mail existe ou non) pour limiter l’énumération
|
||||
- [ ] Génération token, persistance, expiration ; invalidation après usage ou rotation
|
||||
- [ ] Envoi e-mail via MailService (template Handlebars dédié « réinitialisation »)
|
||||
- [ ] `POST` (ex.) `/api/v1/auth/reset-password` : `{ "token", "password", "password_confirmation" }` — règles de complexité alignées sur `create-password` / changement MDP
|
||||
- [ ] Rate limiting / throttling par IP et/ou par e-mail
|
||||
- [ ] Tests unitaires / intégration ; entrées OpenAPI
|
||||
|
||||
**Tâches — Frontend** :
|
||||
- [ ] Écran ou bottom sheet « Saisir votre e-mail » + appel forgot-password + message générique de confirmation
|
||||
- [ ] Route dédiée « nouveau mot de passe » (ex. `/reset-password?token=`) **distincte** de `/create-password` ; vérification token si endpoint dédié
|
||||
- [ ] Corriger le `onPressed` du lien « Mot de passe oublié ? » sur le login : ne plus naviguer vers `/create-password` pour ce cas
|
||||
- [ ] Gestion erreurs (token expiré / invalide) avec libellés neutres
|
||||
|
||||
**Tâches — transverses** :
|
||||
- [ ] Mise à jour doc CDC / évolutions si besoin ; E2E ou scénario de test manuel référencé
|
||||
|
||||
**Synchronisation doc** : pour toute **nouvelle** issue à l’avenir, le numéro vient de la réponse Gitea (§ 3.2 [26_GITEA-API.md](./26_GITEA-API.md)) ou du script `bash ./scripts/create-gitea-issue.sh "Titre" "Corps"`.
|
||||
|
||||
---
|
||||
|
||||
### Ticket #44 : [Frontend] Dashboard Gestionnaire - Structure ✅
|
||||
**Estimation** : 2h
|
||||
**Labels** : `frontend`, `p3`, `gestionnaire`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 📋 Décisions Projet - P'titsPas
|
||||
|
||||
**Version** : 1.1
|
||||
**Date** : 9 Février 2026
|
||||
**Version** : 1.2
|
||||
**Date** : 16 Juin 2026
|
||||
**Auteur** : Équipe PtitsPas
|
||||
|
||||
---
|
||||
@@ -122,6 +122,27 @@ ptitspas-app/
|
||||
|
||||
---
|
||||
|
||||
### 5bis. Familles recomposées — contournement v1.0.0
|
||||
|
||||
**Décision** : ✅ **Second compte / second email pour les cas multi-contextes en v1.0.0** ; évolution structurelle reportée post-1.0.0
|
||||
|
||||
**Contexte** :
|
||||
- Le numéro de dossier et le graphe « famille » (co-parent + enfants partagés) conviennent aux cas simples (un couple, N enfants).
|
||||
- Ils ne couvrent pas proprement une même personne responsable dans **plusieurs unités familiales** (recompositions, plusieurs co-responsables successifs, tuteur/GP sur plusieurs contextes).
|
||||
|
||||
**Décision v1.0.0** :
|
||||
- **Contournement opérationnel** : créer un **second compte** avec **email distinct** et un **second numéro de dossier** (souvent via le gestionnaire).
|
||||
- Le numéro de dossier reste la **norme** pour les cas simples, **sans obligation absolue** pour les cas complexes.
|
||||
- Rôle applicatif unique **`parent`** pour tous les responsables (tuteur, GP inclus) ; qualification juridique = évolution ultérieure.
|
||||
|
||||
**Évolution post-1.0.0** :
|
||||
- **Parcours gestionnaire « famille complexe »** (§ 7.5 doc 28) : N responsables, **M un seul compte** avec tous ses enfants ; **A / B** limités à leur enfant via `enfants_parents`.
|
||||
- Visibilité et workflows **sans fusion graphe familial** ; numéro de dossier optionnel ; qualification du lien responsable–enfant.
|
||||
|
||||
**Référence** : [28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md) — § 4.4, § 7.5
|
||||
|
||||
---
|
||||
|
||||
### 6. Genre enfant obligatoire (H/F)
|
||||
|
||||
**Décision** : ✅ **Genre obligatoire (H/F uniquement)**
|
||||
@@ -562,6 +583,7 @@ docs/
|
||||
| 14 | Migration données | ❌ Rejeté | N/A |
|
||||
| 15 | Doc utilisateur | ⏸️ Phase 2 | Formation |
|
||||
| 31 | Logs Winston | ✅ Phase 1 | Monitoring |
|
||||
| 5bis | Familles recomposées — 2ᵉ compte v1.0.0 | ✅ v1.0.0 | Métier / dossier |
|
||||
|
||||
---
|
||||
|
||||
@@ -571,10 +593,12 @@ docs/
|
||||
|------|---------|---------------|
|
||||
| 25/11/2025 | 1.0 | Création du document - Toutes les décisions initiales |
|
||||
| 09/02/2026 | 1.1 | Configuration initiale : un seul panneau Paramètres (3 sections) dans le dashboard, plus de Setup Wizard dédié ; navigation bloquée jusqu'à sauvegarde |
|
||||
| 16/06/2026 | 1.2 | Décision 5bis — familles recomposées, contournement v1.0.0 ; lien doc [28](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md) |
|
||||
| 16/06/2026 | 1.3 | Précision 5bis — cible post-1.0.0 : parcours gestionnaire § 7.5 (#139), visibilité par enfant |
|
||||
|
||||
---
|
||||
|
||||
**Dernière mise à jour** : 9 Février 2026
|
||||
**Version** : 1.1
|
||||
**Dernière mise à jour** : 16 Juin 2026
|
||||
**Version** : 1.2
|
||||
**Statut** : ✅ Document validé
|
||||
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
# Évolution — Modèle famille, responsables légaux et dossiers
|
||||
|
||||
**Version** : 1.1
|
||||
**Date** : 16 juin 2026
|
||||
**Statut** : Réflexions produit / architecture — complément au [CDC](./01_CAHIER-DES-CHARGES.md)
|
||||
**Documents liés** : [EVOLUTIONS_CDC.md](./EVOLUTIONS_CDC.md), [24_DECISIONS-PROJET.md](./24_DECISIONS-PROJET.md), [23_LISTE-TICKETS.md](./23_LISTE-TICKETS.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. Objet de ce document
|
||||
|
||||
Ce document **trace les réflexions** menées en 2026 sur :
|
||||
|
||||
- les **limites du modèle « famille / numéro de dossier »** en v1.0.0 ;
|
||||
- les **contournements** acceptés pour la release **1.0.0** ;
|
||||
- les **évolutions** envisagées post-1.0.0 (unités de dossier, affiliation parent–enfant, terminologie).
|
||||
|
||||
Il ne remplace pas le CDC : il documente l’**écart assumé** entre le modèle idéal long terme et ce qui est livré en **1.0.0**, ainsi que la **feuille de route** pour aller plus loin.
|
||||
|
||||
---
|
||||
|
||||
## 2. Modèle actuel (v1.0.0) — rappel
|
||||
|
||||
| Concept | Implémentation |
|
||||
|--------|----------------|
|
||||
| **Responsable inscrit** | Rôle applicatif `parent` + entité `parents` |
|
||||
| **Second adulte** | Co-parent optionnel (`id_co_parent`) — **un seul** |
|
||||
| **Enfant** | Entité `enfants` |
|
||||
| **Affiliation** | Table `enfants_parents` (liens many-to-many) |
|
||||
| **Dossier famille** | `dossier_famille` + `dossier_famille_enfants` (motivation, etc.) |
|
||||
| **Numéro de dossier** | Format `AAAA-NNNNNN`, sur `utilisateurs` et `parents` |
|
||||
| **« Famille » calculée** | Graphe : co-parent **ou** enfants partagés (`getFamilyUserIds`) |
|
||||
| **Workflows** | Validation, refus, reprise : souvent **par `numero_dossier`** ou par ce graphe |
|
||||
|
||||
Le numéro de dossier est aujourd’hui une **aide forte** pour les cas simples (un couple, N enfants, une motivation), mais il tend à devenir **l’identifiant métier** de la famille — ce qui pose problème dans les cas complexes.
|
||||
|
||||
---
|
||||
|
||||
## 3. Limitation connue v1.0.0 — familles recomposées et multi-contextes
|
||||
|
||||
### 3.1 Exemple type (illustration)
|
||||
|
||||
```
|
||||
Année 1 : Responsable M + co-responsable A → enfant α
|
||||
Année 2 : Responsable M + co-responsable B → enfant β
|
||||
```
|
||||
|
||||
Même personne **M** au centre, **deux contextes de vie** distincts. **A** n’est pas parent de β ; **B** n’est pas parent de α.
|
||||
|
||||
### 3.2 Autres cas couverts par la même limitation
|
||||
|
||||
Les exemples « maman / papa » sont **illustratifs**. La limitation s’applique **à toute configuration** :
|
||||
|
||||
| Configuration | Même règle |
|
||||
|---------------|------------|
|
||||
| Couple **HH** ou **FF** | Oui |
|
||||
| **Père** avec deux partenaires et deux enfants (même ville, écarts d’âge courts) | Oui |
|
||||
| **Grand-parent** en tutelle ou **tuteur légal** | Oui *(voir § 5)* |
|
||||
| Recomposition, demi-fratrie, garde alternée complexe | Oui |
|
||||
|
||||
### 3.3 Pourquoi le modèle casse
|
||||
|
||||
1. **Un `numero_dossier` par user** — M ne peut pas appartenir proprement à deux unités.
|
||||
2. **Un seul co-parent** par fiche `parents`.
|
||||
3. **Graphe famille trop large** — M liée à A (via α) et à B (via β) → A, B et M peuvent être fusionnés en **une seule « famille »** pour validation/refus/reprise.
|
||||
4. **`dossier_famille`** — une motivation / une ancre par numéro, pas deux contextes pour la même personne.
|
||||
|
||||
---
|
||||
|
||||
## 4. Décision v1.0.0 — contournement opérationnel
|
||||
|
||||
### 4.1 Principe
|
||||
|
||||
> **Le numéro de dossier reste la norme pour les cas simples, pas une obligation absolue.**
|
||||
> Pour les cas trop complexes, le **gestionnaire** compose manuellement (création admin, rattachements) ou applique le contournement ci-dessous.
|
||||
|
||||
### 4.2 Contournement accepté pour la 1.0.0
|
||||
|
||||
**Créer un second compte** avec une **adresse e-mail distincte** et un **second dossier** (second `numero_dossier`).
|
||||
|
||||
| Dossier | Compte | Co-responsable | Enfant |
|
||||
|---------|--------|----------------|--------|
|
||||
| 1 | `m.personne@…` | A | α |
|
||||
| 2 | `m.personne.famille2@…` *(ou alias)* | B | β |
|
||||
|
||||
**Conséquences assumées :**
|
||||
|
||||
- Une **même personne physique** peut avoir **deux identités** dans l’app.
|
||||
- Pas de vue unifiée « une personne, deux contextes » en v1.0.0.
|
||||
- Procédure interne gestionnaire recommandée (note « même personne physique »).
|
||||
- Emails distincts **volontaires** (alias, +tag, boîte dédiée selon infra mail).
|
||||
|
||||
### 4.3 Nuance — inscription publique
|
||||
|
||||
Si le **dossier 1** existe déjà avec l’email de P (titulaire ou co-parent), une **2ᵉ inscription publique** où l’on saisit **le même email** comme co-parent est **bloquée** (*email déjà utilisé*).
|
||||
|
||||
Le **2ᵉ dossier** passe donc surtout par :
|
||||
|
||||
- **création / composition par le gestionnaire** (tickets admin #129+), ou
|
||||
- **2ᵉ email** dès le départ pour la même personne physique.
|
||||
|
||||
### 4.4 Piste cible — parcours gestionnaire « famille complexe » *(réflexion juin 2026)*
|
||||
|
||||
Le contournement § 4.2 reste valable en **v1.0.0**. La **solution produit visée** pour les cas complexes est différente :
|
||||
|
||||
> **Seul le gestionnaire** dispose d’un **parcours de création dédié** permettant de constituer une configuration avec **plus de deux responsables** (parents, co-parents, tuteurs…) **sans** se limiter au seul champ `co_parent`, en s’appuyant sur **`enfants_parents`** comme vérité de visibilité.
|
||||
|
||||
**Exemple M / A / B / α / β :**
|
||||
|
||||
| Compte | Enfants visibles / rattachés |
|
||||
|--------|------------------------------|
|
||||
| **M** (un seul compte, un email) | α **et** β |
|
||||
| **A** | α uniquement |
|
||||
| **B** | β uniquement |
|
||||
|
||||
```
|
||||
α ─── M ─── β
|
||||
│ │
|
||||
A B
|
||||
```
|
||||
|
||||
- **M** voit et gère **ses deux enfants** sur **le même compte** (plus besoin d’un 2ᵉ email pour M).
|
||||
- **A** et **B** ne voient **que** l’enfant qui leur est rattaché via `enfants_parents` — pas de fusion « famille » qui mélange A et B.
|
||||
- Le parcours n’est **pas** proposé à l’inscription publique (trop error-prone) : **réservé au gestionnaire** (#129+ ou ticket dédié « famille complexe »).
|
||||
|
||||
**Conséquences techniques (post-1.0.0) :**
|
||||
|
||||
1. **Visibilité** : filtrer listes, fiches et actions parent par **liens `enfants_parents`**, pas par `getFamilyUserIds` / co-parent.
|
||||
2. **Workflows** (validation, refus, reprise) : périmètre par **enfant** ou **soumission**, pas par graphe familial élargi.
|
||||
3. **`co_parent`** : reste utile pour le cas simple (couple + N enfants communs) ; **insuffisant seul** pour les cas complexes — le gestionnaire compose les liens enfant par enfant.
|
||||
4. **Numéro de dossier** : optionnel ou secondaire ; peut rester sur M ou sur une « unité » admin, sans imposer un numéro par co-responsable.
|
||||
|
||||
*Détail : § 7.5.*
|
||||
|
||||
---
|
||||
|
||||
## 5. Terminologie — « parent » aujourd’hui, qualification demain
|
||||
|
||||
### 5.1 v1.0.0
|
||||
|
||||
- Rôle technique : **`parent`** pour tout responsable inscrit (y compris tuteur, grand-parent tutrice, etc.).
|
||||
- UI : « Parent 1 », « co-parent », parfois exemples maman/papa — **pas de statut juridique distinct**.
|
||||
|
||||
### 5.2 Évolution post-1.0.0 (piste)
|
||||
|
||||
Séparer deux niveaux :
|
||||
|
||||
| Niveau | Évolution |
|
||||
|--------|-----------|
|
||||
| **Rôle applicatif** | Conserver `parent` = « responsable du dossier » (auth, API) |
|
||||
| **Qualification métier** | Nouveau champ, ex. `qualite_responsable` / `lien_avec_enfant` |
|
||||
|
||||
**Valeurs possibles (exemples)** : parent biologique ou adoptif, co-parent, tuteur légal, grand-parent exerçant la garde, autre responsable légal.
|
||||
|
||||
**Emplacement recommandé** : sur le **lien** `enfants_parents` (par enfant), pas seulement sur le user global.
|
||||
|
||||
**UI** :
|
||||
|
||||
- Libellés neutres : « Responsable 1 / 2 », « 2ᵉ responsable » ;
|
||||
- **Combobox** pour qualifier le lien (admin + éventuellement inscription).
|
||||
|
||||
---
|
||||
|
||||
## 6. Évolutions UI / fonctionnelles liées (backlog)
|
||||
|
||||
Réflexions dashboard admin / gestionnaire (complément CDC §4.5.2–4.5.3).
|
||||
|
||||
> **Statut implémentation (juin 2026)** : §6.1 et §6.2 **en cours de livraison** (tickets #130–#131, #115–#116, #137–#138). §6.3 reporté post-1.0.0 (#129).
|
||||
|
||||
### 6.1 Fiche parent (#131)
|
||||
|
||||
- Modale **éditable** dès l’ouverture (pas lecture seule + « Modifier » factice).
|
||||
- **Retirer l’ID** UUID ; option : **n° de dossier** en lecture seule.
|
||||
- **Statut** en combobox (règles métier à cadrer vs validation/refus #110).
|
||||
- Bas de modale :
|
||||
- `Nombre d'enfants : N` (lecture seule) ;
|
||||
- **Liste des prénoms/noms** (cadre) — consultation, clic → fiche enfant (#138).
|
||||
|
||||
### 6.2 Affiliation parent ↔ enfant (#115, #116, #138)
|
||||
|
||||
- **Vérité métier** : `enfants_parents`.
|
||||
- **Modifier l’affiliation** ≠ modifier le téléphone : gestion des **liens** (détacher / rattacher), avec garde-fous :
|
||||
- ne pas supprimer l’enfant pour retirer un lien ;
|
||||
- au moins un responsable par enfant ;
|
||||
- prudence sur fusion « famille » et co-parents.
|
||||
- **Création enfant** : prioritaire depuis **fiche parent** (contexte famille) ; onglet **Enfants** (#137) pour vue globale + rattachement.
|
||||
|
||||
### 6.3 Création admin sans numéro (tickets #129+)
|
||||
|
||||
Le gestionnaire doit pouvoir **tout créer** depuis l’interface ; le numéro reste **généré si utile**, **optionnel** si le cas est trop complexe — **objectif post-1.0.0** (unité de dossier).
|
||||
|
||||
---
|
||||
|
||||
## 7. Évolution structurelle post-1.0.0 — « unité de dossier »
|
||||
|
||||
### 7.1 Principe cible
|
||||
|
||||
| Aujourd’hui | Cible |
|
||||
|-------------|--------|
|
||||
| `numero_dossier` = clé de la famille | **Liens parent↔enfant** = vérité |
|
||||
| Famille déduite du graphe | **Unité de dossier** = regroupement **optionnel** |
|
||||
| Un numéro par inscription | Numéro **optionnel** ; plusieurs unités par personne possibles |
|
||||
|
||||
### 7.2 Exemple cible (cas M / A / B) — deux approches
|
||||
|
||||
**Approche A — unités de dossier séparées** *(piste initiale § 7)* :
|
||||
|
||||
```
|
||||
Unité 1 : numero 2026-000021 — M, A, α
|
||||
Unité 2 : sans numéro (ou 2026-000089) — M, B, β
|
||||
```
|
||||
|
||||
M appartient à **deux unités** ; A et B ne partagent pas la même unité. Peut impliquer **deux contextes de connexion** ou une agrégation côté M.
|
||||
|
||||
**Approche B — parcours gestionnaire « famille complexe »** *(préférée, § 4.4)* :
|
||||
|
||||
```
|
||||
Compte M → enfants α, β
|
||||
Compte A → enfant α
|
||||
Compte B → enfant β
|
||||
(liens enfants_parents ; pas de fusion A↔B)
|
||||
```
|
||||
|
||||
- **Un seul compte pour M** avec **les deux enfants**.
|
||||
- **A** et **B** isolés sur **leur** enfant respectif.
|
||||
- Création **uniquement** par le gestionnaire ; inscription publique inchangée (couple + co-parent classique).
|
||||
|
||||
Les deux approches supposent de **cesser de déduire une « famille » unique** pour les workflows lorsque les liens enfant par enfant divergent. L’approche B maximise l’UX du responsable central (M) sans dupliquer son identité.
|
||||
|
||||
### 7.5 Parcours gestionnaire — création « famille complexe »
|
||||
|
||||
#### 7.5.1 Objectif
|
||||
|
||||
Permettre au **gestionnaire** de monter un dossier où :
|
||||
|
||||
- **N responsables** (≥ 2, parents ou tuteurs) sont créés ou rattachés ;
|
||||
- chaque enfant est lié **explicitement** à un ou plusieurs responsables via `enfants_parents` ;
|
||||
- un responsable (ex. **M**) peut être lié à **plusieurs enfants** dont les **autres** responsables (A, B) ne partagent **pas** la garde.
|
||||
|
||||
#### 7.5.2 Règles produit
|
||||
|
||||
| Règle | Détail |
|
||||
|-------|--------|
|
||||
| **Accès** | Parcours **gestionnaire uniquement** (dashboard), pas inscription publique |
|
||||
| **Responsables** | Création ou rattachement de comptes `parent` ; qualification future sur le lien (§ 5) |
|
||||
| **Visibilité** | Chaque responsable ne voit que **ses** enfants (liens `enfants_parents`) |
|
||||
| **Co-parent UI** | Ne pas forcer « Parent 1 + co-parent unique » ; composition libre côté staff |
|
||||
| **Garde-fous** | Au moins un responsable par enfant ; pas de suppression enfant pour retirer un lien |
|
||||
|
||||
#### 7.5.3 Esquisse du parcours UI (gestionnaire)
|
||||
|
||||
1. **Créer ou identifier** le responsable principal (ex. M).
|
||||
2. **Ajouter d’autres responsables** (A, B, tuteur…) — comptes distincts.
|
||||
3. **Créer les enfants** (α, β…) et, pour chaque enfant, **cocher les responsables** rattachés.
|
||||
4. **Motivation / dossier** : texte global ou par enfant (à cadrer).
|
||||
5. **Validation** : par soumission ou par enfant, sans valider « toute la famille » d’un coup si A et B ne doivent pas être fusionnés.
|
||||
|
||||
#### 7.5.4 Impacts techniques majeurs
|
||||
|
||||
| Domaine | Changement |
|
||||
|---------|------------|
|
||||
| **Auth / API parent** | `GET` enfants, dashboard parent : filtre `enfants_parents` pour l’utilisateur courant |
|
||||
| **`getFamilyUserIds`** | Ne plus utiliser pour visibilité parent ; réservé au cas simple ou deprecated progressivement |
|
||||
| **Validation / refus** | Périmètre enfant ou responsable, pas fusion A+B via graphe |
|
||||
| **Reprise (#112)** | Token / périmètre = enfants liés au compte refusé, pas toute la composante connexe |
|
||||
| **Admin (#115–#138)** | Prérequis : rattachement/détachement déjà en place ; ce parcours **compose** ces briques |
|
||||
|
||||
#### 7.5.5 Lien avec v1.0.0
|
||||
|
||||
| Phase | Comportement |
|
||||
|-------|--------------|
|
||||
| **v1.0.0** | Contournement § 4.2 (2ᵉ email M) + composition manuelle gestionnaire (#115–#138) |
|
||||
| **Post-1.0.0** | Parcours § 7.5 + refonte visibilité / workflows |
|
||||
|
||||
### 7.3 Impacts workflows
|
||||
|
||||
| Flux | Adaptation future |
|
||||
|------|-------------------|
|
||||
| Validation / refus (#110) | Par **enfant / soumission**, pas par graphe global |
|
||||
| Reprise (#112) | Périmètre = enfants liés au compte, pas composante connexe |
|
||||
| Liste « à valider » | Par soumission ou par enfant |
|
||||
| Recherche gestionnaire | Numéro **ou** nom **ou** enfant |
|
||||
| Visibilité parent | **Uniquement** enfants via `enfants_parents` (approche B § 7.2) |
|
||||
|
||||
### 7.4 Migration
|
||||
|
||||
- **Court terme** : contournement § 4 + tickets admin.
|
||||
- **Moyen terme** : table **unité de dossier**, `numero_dossier` nullable.
|
||||
- **Long terme** : workflows branchés sur l’unité.
|
||||
- Dossiers **existants** (couple + enfants) : une unité = un numéro → **comportement actuel préservé**.
|
||||
|
||||
---
|
||||
|
||||
## 8. Tickets Gitea associés
|
||||
|
||||
| Ticket | Sujet |
|
||||
|--------|--------|
|
||||
| #110 | Refus dossier (token reprise) — livré |
|
||||
| #112 | Reprise après refus — livré |
|
||||
| #115 / #116 | Rattachement parent — backend / front |
|
||||
| #129+ | Création dossier admin (parent / AM) |
|
||||
| #131 | Fiche parent éditable (dashboard) |
|
||||
| #137 | Onglet Enfants — liste globale |
|
||||
| #138 | Fiche enfant + liste dans fiche parent |
|
||||
| *(à créer)* | Epic « Unité de dossier / numéro optionnel » |
|
||||
| #139 | **Parcours gestionnaire « famille complexe »** (§ 7.5) — N responsables, visibilité par enfant |
|
||||
| *(à créer)* | Qualification responsable légal (combobox sur lien enfant) |
|
||||
|
||||
---
|
||||
|
||||
## 9. Formulation type — release notes / doc gestionnaire (v1.0.0)
|
||||
|
||||
> **Familles recomposées ou responsabilités multiples**
|
||||
> Une même personne ne peut pas gérer proprement deux unités familiales distinctes (recompositions, plusieurs co-responsables successifs, tuteur/GP pour plusieurs contextes) avec **un seul compte**.
|
||||
> **Contournement v1.0.0** : second compte avec **email distinct** et second numéro de dossier.
|
||||
> S’applique à **tous les responsables inscrits** (couples HH/FF, tuteurs, grands-parents, etc.).
|
||||
> **Évolution post-1.0.0** : parcours **gestionnaire** « famille complexe » (un compte M, enfants α+β ; A et B limités à leur enfant) ; visibilité par `enfants_parents` ; workflows sans fusion graphe familial.
|
||||
|
||||
---
|
||||
|
||||
## 10. Historique des mises à jour
|
||||
|
||||
| Date | Auteur | Modification |
|
||||
|------|--------|--------------|
|
||||
| 2026-06-16 | Équipe / session produit | Création — synthèse réflexions famille, tutelle, v1.0.0 vs post-1.0.0 |
|
||||
| 2026-06-16 | Implémentation ch.6 | Back : `PATCH /parents/:id/fiche`, attach/detach enfant. Front : modale parent éditable, onglet Enfants, fiche enfant |
|
||||
| 2026-06-16 | Réflexion produit | § 4.4 / § 7.5 — parcours gestionnaire famille complexe : M un compte (α+β), A/B visibilité restreinte par enfant |
|
||||
|
||||
---
|
||||
|
||||
*Ce document sera enrichi au fil des décisions. Pour les décisions formelles archivées, voir aussi [24_DECISIONS-PROJET.md](./24_DECISIONS-PROJET.md).*
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Ce document liste les modifications à apporter au cahier des charges original pour le rendre conforme à l'application développée.
|
||||
|
||||
> **Document complémentaire (juin 2026)** — réflexions sur le **modèle famille / numéro de dossier**, familles recomposées, tuteurs et responsables légaux : voir **[28 - Évolution famille et responsables](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md)**.
|
||||
|
||||
## 1. Gestion des Enfants
|
||||
|
||||
### Modifications à apporter dans la section "Création de compte parent"
|
||||
@@ -276,3 +278,28 @@ Pour chaque évolution identifiée, ce document suivra la structure suivante :
|
||||
- Adaptation des écrans d'administration pour gérer les rôles locaux.
|
||||
- Renforcement des contrôles d'accès backend et des règles métier.
|
||||
- Clarification des workflows décisionnels dans l'application.
|
||||
|
||||
## 9. Modèle famille, numéro de dossier et responsables légaux
|
||||
|
||||
### 9.1 Situation actuelle (v1.0.0)
|
||||
|
||||
- Un **numéro de dossier** par inscription (`AAAA-NNNNNN`), fortement utilisé dans les workflows (validation, refus, reprise).
|
||||
- **Un co-parent** par fiche `parents` ; affiliation réelle via `enfants_parents`.
|
||||
- La « famille » est **déduite** du graphe (co-parent + enfants partagés) — fusion parfois trop large en cas de recompositions.
|
||||
|
||||
### 9.2 Limitation v1.0.0 — familles recomposées
|
||||
|
||||
Cas type : même responsable M avec co-responsable A (enfant α) puis co-responsable B (enfant β). Le modèle actuel ne permet pas de séparer proprement **deux unités familiales** pour une même personne.
|
||||
|
||||
**Contournement accepté pour la release 1.0.0** : second compte avec **email distinct** et second numéro de dossier (procédure gestionnaire). S'applique aussi aux couples HH/FF, tuteurs, grands-parents, etc.
|
||||
|
||||
### 9.3 Évolutions post-1.0.0 (pistes)
|
||||
|
||||
| Sujet | Piste |
|
||||
|-------|--------|
|
||||
| **Unité de dossier** | Numéro **optionnel** ; plusieurs unités par personne |
|
||||
| **Affiliation** | Gestion admin des liens parent↔enfant (#115, #116, #138) |
|
||||
| **Qualification** | Combobox « lien responsable–enfant » (tuteur, GP, parent…) sur `enfants_parents` |
|
||||
| **UI admin** | Fiche parent éditable (#131), liste enfants en bas de fiche |
|
||||
|
||||
**Détail complet, scénarios, tickets et formulation release notes** : [28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md).
|
||||
@@ -0,0 +1,127 @@
|
||||
# #131 — En-tête fiche parent : co-parent (note front → back)
|
||||
|
||||
**Ticket :** #131 (fiche parent dashboard, doc `28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1)
|
||||
**Date :** 2026-06-01
|
||||
**Statut front :** livré (en-tête dynamique)
|
||||
**Modif backend demandée :** **aucune fonctionnelle** — ce document fixe le contrat attendu ; le back valide `co_parent` et masque les champs sensibles.
|
||||
|
||||
---
|
||||
|
||||
## 1. Comportement UI (front)
|
||||
|
||||
Dans la modale **fiche parent** (`AdminParentEditModal`) :
|
||||
|
||||
| Zone | Contenu |
|
||||
|------|---------|
|
||||
| **Titre** | `prenom` + `nom` du parent affiché (plus le libellé fixe « Fiche parent ») |
|
||||
| **Sous-titre** | `Co-parent : {prenom} {nom}` — affiché **uniquement** si un co-parent est connu |
|
||||
|
||||
Le titre se met à jour en direct pendant l’édition des champs nom/prénom.
|
||||
Le sous-titre provient du co-parent **chargé depuis l’API** (pas saisi à la main dans la modale).
|
||||
|
||||
---
|
||||
|
||||
## 2. Endpoints consommés
|
||||
|
||||
| Méthode | Route | Usage front |
|
||||
|---------|-------|-------------|
|
||||
| `GET` | `/api/v1/parents` | Liste parents (onglet Parents) |
|
||||
| `GET` | `/api/v1/parents/:userId` | Rechargement fiche après rattachement/détachement enfant |
|
||||
| `PATCH` | `/api/v1/parents/:userId/fiche` | Sauvegarde identité + statut (inchangé) |
|
||||
|
||||
Rôles : `super_admin`, `gestionnaire`, `administrateur` (selon route).
|
||||
|
||||
---
|
||||
|
||||
## 3. Contrat JSON attendu pour `co_parent`
|
||||
|
||||
Le front parse `ParentModel.fromJson` avec la clé **`co_parent`** (snake_case), objet utilisateur imbriqué.
|
||||
|
||||
### Champs minimum utilisés pour le sous-titre
|
||||
|
||||
| Clé JSON | Usage |
|
||||
|----------|--------|
|
||||
| `co_parent` | Objet ou absent/`null` |
|
||||
| `co_parent.id` | Identifiant (futur lien cliquable éventuel) |
|
||||
| `co_parent.prenom` | Affichage |
|
||||
| `co_parent.nom` | Affichage |
|
||||
|
||||
Affichage front : `'{prenom} {nom}'.trim()` → libellé `Co-parent : …`.
|
||||
|
||||
### Exemple de fragment de réponse (`GET /parents/:id`)
|
||||
|
||||
```json
|
||||
{
|
||||
"user_id": "33333333-3333-3333-3333-333333333333",
|
||||
"numero_dossier": "2026-000042",
|
||||
"user": {
|
||||
"id": "33333333-3333-3333-3333-333333333333",
|
||||
"email": "parent1@example.com",
|
||||
"prenom": "Paul",
|
||||
"nom": "PARENT",
|
||||
"statut": "actif",
|
||||
"telephone": "0601020304"
|
||||
},
|
||||
"co_parent": {
|
||||
"id": "44444444-4444-4444-4444-444444444444",
|
||||
"email": "coparent1@example.com",
|
||||
"prenom": "Clara",
|
||||
"nom": "COPARENT",
|
||||
"role": "parent",
|
||||
"statut": "actif"
|
||||
},
|
||||
"parentChildren": []
|
||||
}
|
||||
```
|
||||
|
||||
> **Note :** le front lit `user` (pas `utilisateur`). La doc `11_API.md` § Parents mentionne encore `utilisateur` / `id_co_parent` seul — le contrat **effectif** côté Nest/TypeORM est l’entité `Parents` sérialisée (`user`, `co_parent`, `parentChildren`, …).
|
||||
|
||||
---
|
||||
|
||||
## 4. État backend
|
||||
|
||||
### Relations (déjà en place)
|
||||
|
||||
- `findAll()` et `findOne(user_id)` chargent **`co_parent`** ;
|
||||
- FK : `parents.id_co_parent` → `utilisateurs.id` ;
|
||||
- inscription couple : les deux sens renseignés en principe (`auth.service.ts`).
|
||||
|
||||
### Livraison back (#131)
|
||||
|
||||
- `mapParentForApi` / `sanitizeUserForApi` : réponses `GET/PATCH/POST/DELETE` parents **sans** `password`, `token_creation_mdp`, `password_reset_*` sur `user` et `co_parent`.
|
||||
|
||||
**Checklist validation :**
|
||||
|
||||
- [x] `GET /parents/:id` renvoie `co_parent` peuplé quand `id_co_parent` est non null
|
||||
- [x] `GET /parents` (liste) inclut `co_parent`
|
||||
- [x] `prenom` / `nom` du co-parent présents
|
||||
- [x] Pas de fuite `password` / tokens sur `user` ni `co_parent`
|
||||
|
||||
---
|
||||
|
||||
## 5. Points d’attention (hors périmètre immédiat)
|
||||
|
||||
| Sujet | Détail |
|
||||
|-------|--------|
|
||||
| **Lien inverse** | Si B est co-parent de A (`A.id_co_parent = B`) mais `B.id_co_parent` est `null`, le sous-titre **ne s’affichera pas** sur la fiche de B. Pas de résolution inverse côté front. |
|
||||
| **Familles > 2 adultes** | Sous-titre = co-parent direct (`id_co_parent`) uniquement. |
|
||||
| **Trou AM ↔ enfants en garde** | Pas de lien AM–enfant aujourd’hui (à documenter / traiter plus tard). |
|
||||
|
||||
---
|
||||
|
||||
## 6. Fichiers back concernés
|
||||
|
||||
| Fichier | Rôle |
|
||||
|---------|------|
|
||||
| `backend/src/routes/parents/parents.service.ts` | `findOne`, `findAll` + relations |
|
||||
| `backend/src/routes/parents/parents.controller.ts` | `mapParentForApi` sur les réponses |
|
||||
| `backend/src/routes/parents/parents.mapper.ts` | Sérialisation API |
|
||||
| `backend/src/common/utils/sanitize-user-for-api.ts` | Masquage secrets |
|
||||
| `backend/src/entities/parents.entity.ts` | relation `co_parent` |
|
||||
|
||||
---
|
||||
|
||||
## 7. Références
|
||||
|
||||
- `docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1
|
||||
- Ticket Gitea **#131**
|
||||
@@ -0,0 +1,244 @@
|
||||
# #112 — Alignement front après évolution back (reprise dossier complet)
|
||||
|
||||
**Branche déployée :** `feature/112-reprise-apres-refus-front`
|
||||
**Commit back :** `d70577b1` — `feat(#112): reprise après refus — dossier complet GET/PATCH`
|
||||
**Date :** 2026-06-16
|
||||
|
||||
Ce document décrit le **contrat API réel** après extension du back, et ce que le front doit encore brancher pour exploiter le dossier complet (au-delà de 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
|
||||
```
|
||||
@@ -0,0 +1,132 @@
|
||||
# #131 — Fiche AM éditable + affiliation enfants (note front → back)
|
||||
|
||||
**Ticket :** #131 (partie AM, doc `28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1)
|
||||
**Date :** 2026-06-01
|
||||
**Statut front :** modale livrée (2 onglets) — **API affiliation AM↔enfant à implémenter**
|
||||
|
||||
---
|
||||
|
||||
## 1. Comportement UI (front)
|
||||
|
||||
Modale `AdminAmEditModal` — même shell que la fiche parent (~930 px) :
|
||||
|
||||
| Onglet | Contenu |
|
||||
|--------|---------|
|
||||
| **Identité & professionnel** | `IdentityBlock` éditable + grille pro (agrément, ville résidence, capacité, places, NIR/agrément date en lecture seule, biographie, switch disponible) + gélule statut |
|
||||
| **Enfants accueillis** | Liste cartes enfants (réutilise `AdminChildrenAffiliationPanel` / `AdminEnfantUserCard`) + rattacher / détacher |
|
||||
|
||||
En-tête : prénom nom · sous-titre `Zone · Agrément · Dossier`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Endpoints consommés
|
||||
|
||||
### Déjà existants (partiels)
|
||||
|
||||
| Méthode | Route | Usage |
|
||||
|---------|-------|-------|
|
||||
| `GET` | `/api/v1/assistantes-maternelles` | Liste AM |
|
||||
| `GET` | `/api/v1/assistantes-maternelles/:userId` | Détail (403 possible pour `administrateur` → fallback liste) |
|
||||
| `PATCH` | `/api/v1/users/:userId` | Identité + statut (admin / super_admin uniquement) |
|
||||
| `PATCH` | `/api/v1/assistantes-maternelles/:userId` | Champs pro (gestionnaire / super_admin) |
|
||||
|
||||
### À créer (recommandé — miroir parent #131 / #115)
|
||||
|
||||
| Méthode | Route | Rôle |
|
||||
|---------|-------|------|
|
||||
| `PATCH` | `/api/v1/assistantes-maternelles/:userId/fiche` | Mise à jour unifiée identité + pro + statut (`super_admin`, `gestionnaire`, `administrateur`) |
|
||||
| `POST` | `/api/v1/assistantes-maternelles/:userId/enfants/:enfantId` | Rattacher un enfant |
|
||||
| `DELETE` | `/api/v1/assistantes-maternelles/:userId/enfants/:enfantId` | Détacher un enfant |
|
||||
| `GET` | `/api/v1/assistantes-maternelles/:userId` | Inclure `amChildren[]` (relation enfant) |
|
||||
|
||||
Le front appelle déjà ces routes ; en l’absence de `PATCH …/fiche`, il tente un fallback `PATCH users` + `PATCH assistantes-maternelles` (échoue selon le rôle connecté).
|
||||
|
||||
---
|
||||
|
||||
## 3. Modèle de données affiliation AM ↔ enfant
|
||||
|
||||
**À définir côté BDD** (pas de table dédiée aujourd’hui, contrairement à `enfants_parents`) :
|
||||
|
||||
Proposition alignée parent :
|
||||
|
||||
```sql
|
||||
-- Piste : enfants_assistantes_maternelles
|
||||
CREATE TABLE enfants_assistantes_maternelles (
|
||||
id_am UUID NOT NULL REFERENCES utilisateurs(id) ON DELETE CASCADE,
|
||||
id_enfant UUID NOT NULL REFERENCES enfants(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (id_am, id_enfant)
|
||||
);
|
||||
```
|
||||
|
||||
Réponse API attendue sur `GET /assistantes-maternelles/:id` :
|
||||
|
||||
```json
|
||||
{
|
||||
"user_id": "uuid-am",
|
||||
"user": { "id": "…", "prenom": "Claire", "nom": "MARTIN", "statut": "actif" },
|
||||
"approval_number": "AGR-2024-12345",
|
||||
"residence_city": "Bezons",
|
||||
"max_children": 4,
|
||||
"places_available": 2,
|
||||
"available": true,
|
||||
"amChildren": [
|
||||
{
|
||||
"child": {
|
||||
"id": "uuid-enfant",
|
||||
"first_name": "Emma",
|
||||
"last_name": "MARTIN",
|
||||
"status": "actif",
|
||||
"birth_date": "2023-02-15"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Le front parse `amChildren` / `am_children` / `assistanteChildren` (même logique que `parentChildren`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Body `PATCH …/fiche` suggéré
|
||||
|
||||
```json
|
||||
{
|
||||
"nom": "MARTIN",
|
||||
"prenom": "Claire",
|
||||
"email": "claire@example.com",
|
||||
"telephone": "0612345678",
|
||||
"adresse": "5 place Bellecour",
|
||||
"ville": "Lyon",
|
||||
"code_postal": "69002",
|
||||
"statut": "actif",
|
||||
"approval_number": "AGR-2024-12345",
|
||||
"residence_city": "Lyon",
|
||||
"max_children": 4,
|
||||
"places_available": 2,
|
||||
"biography": "…",
|
||||
"available": true
|
||||
}
|
||||
```
|
||||
|
||||
NIR et date d’agrément : lecture seule dans la modale (modification hors périmètre admin v1).
|
||||
|
||||
---
|
||||
|
||||
## 5. Fichiers front concernés
|
||||
|
||||
| Fichier | Rôle |
|
||||
|---------|------|
|
||||
| `frontend/lib/widgets/admin/common/admin_am_edit_modal.dart` | Modale 2 onglets |
|
||||
| `frontend/lib/widgets/admin/common/admin_children_affiliation_panel.dart` | Liste enfants partagée parent/AM |
|
||||
| `frontend/lib/widgets/admin/common/admin_status_capsule.dart` | Gélule statut partagée |
|
||||
| `frontend/lib/models/assistante_maternelle_model.dart` | Parse champs pro + `amChildren` |
|
||||
| `frontend/lib/services/user_service.dart` | `getAssistanteMaternelle`, `updateAmFiche`, `attachEnfantToAm`, `detachEnfantFromAm` |
|
||||
| `frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart` | Ouverture modale au clic Modifier |
|
||||
|
||||
---
|
||||
|
||||
## 6. Références
|
||||
|
||||
- Fiche parent : `PATCH /parents/:id/fiche`, `POST|DELETE /parents/:id/enfants/:enfantId`
|
||||
- Ticket Gitea **#131**, **#115**
|
||||
- `docs/archive/temporaires/TEMP_131-back-fiche-parent-co-parent.md`
|
||||
@@ -0,0 +1,124 @@
|
||||
# #131 — En-tête fiche parent : co-parent (note front → back)
|
||||
|
||||
**Ticket :** #131 (fiche parent dashboard, doc `28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1)
|
||||
**Date :** 2026-06-01
|
||||
**Statut front :** livré (en-tête dynamique)
|
||||
**Modif backend demandée :** **aucune** — ce document fixe le contrat attendu et invite à valider que l’existant le couvre.
|
||||
|
||||
---
|
||||
|
||||
## 1. Comportement UI (front)
|
||||
|
||||
Dans la modale **fiche parent** (`AdminParentEditModal`) :
|
||||
|
||||
| Zone | Contenu |
|
||||
|------|---------|
|
||||
| **Titre** | `prenom` + `nom` du parent affiché (plus le libellé fixe « Fiche parent ») |
|
||||
| **Sous-titre** | `Co-parent : {prenom} {nom}` — affiché **uniquement** si un co-parent est connu |
|
||||
|
||||
Le titre se met à jour en direct pendant l’édition des champs nom/prénom.
|
||||
Le sous-titre provient du co-parent **chargé depuis l’API** (pas saisi à la main dans la modale).
|
||||
|
||||
---
|
||||
|
||||
## 2. Endpoints consommés
|
||||
|
||||
| Méthode | Route | Usage front |
|
||||
|---------|-------|-------------|
|
||||
| `GET` | `/api/v1/parents` | Liste parents (onglet Parents) |
|
||||
| `GET` | `/api/v1/parents/:userId` | Rechargement fiche après rattachement/détachement enfant |
|
||||
| `PATCH` | `/api/v1/parents/:userId/fiche` | Sauvegarde identité + statut (inchangé) |
|
||||
|
||||
Rôles : `super_admin`, `gestionnaire`, `administrateur` (selon route).
|
||||
|
||||
---
|
||||
|
||||
## 3. Contrat JSON attendu pour `co_parent`
|
||||
|
||||
Le front parse `ParentModel.fromJson` avec la clé **`co_parent`** (snake_case), objet utilisateur imbriqué.
|
||||
|
||||
### Champs minimum utilisés pour le sous-titre
|
||||
|
||||
| Clé JSON | Usage |
|
||||
|----------|--------|
|
||||
| `co_parent` | Objet ou absent/`null` |
|
||||
| `co_parent.id` | Identifiant (futur lien cliquable éventuel) |
|
||||
| `co_parent.prenom` | Affichage |
|
||||
| `co_parent.nom` | Affichage |
|
||||
|
||||
Affichage front : `'{prenom} {nom}'.trim()` → libellé `Co-parent : …`.
|
||||
|
||||
### Exemple de fragment de réponse (`GET /parents/:id`)
|
||||
|
||||
```json
|
||||
{
|
||||
"user_id": "33333333-3333-3333-3333-333333333333",
|
||||
"numero_dossier": "2026-000042",
|
||||
"user": {
|
||||
"id": "33333333-3333-3333-3333-333333333333",
|
||||
"email": "parent1@example.com",
|
||||
"prenom": "Paul",
|
||||
"nom": "PARENT",
|
||||
"statut": "actif",
|
||||
"telephone": "0601020304"
|
||||
},
|
||||
"co_parent": {
|
||||
"id": "44444444-4444-4444-4444-444444444444",
|
||||
"email": "coparent1@example.com",
|
||||
"prenom": "Clara",
|
||||
"nom": "COPARENT",
|
||||
"role": "parent",
|
||||
"statut": "actif"
|
||||
},
|
||||
"parentChildren": []
|
||||
}
|
||||
```
|
||||
|
||||
> **Note :** le front lit `user` (pas `utilisateur`). La doc `11_API.md` § Parents mentionne encore `utilisateur` / `id_co_parent` seul — le contrat **effectif** côté Nest/TypeORM est l’entité `Parents` sérialisée (`user`, `co_parent`, `parentChildren`, …).
|
||||
|
||||
---
|
||||
|
||||
## 4. État backend (à valider, pas à refaire)
|
||||
|
||||
D’après le code actuel (`parents.service.ts`) :
|
||||
|
||||
- `findAll()` et `findOne(user_id)` chargent déjà la relation **`co_parent`** ;
|
||||
- la FK métier est `parents.id_co_parent` → `utilisateurs.id` ;
|
||||
- à l’inscription couple, les deux sens sont en principe renseignés (`auth.service.ts`).
|
||||
|
||||
**Checklist validation back :**
|
||||
|
||||
- [ ] `GET /parents/:id` renvoie bien `co_parent` peuplé quand `id_co_parent` est non null
|
||||
- [ ] `GET /parents` (liste) inclut aussi `co_parent` (sous-titre disponible dès l’ouverture sans re-fetch)
|
||||
- [ ] Les champs `prenom` / `nom` du co-parent sont présents dans la réponse JSON
|
||||
|
||||
Si ces trois points passent en recette, **aucun changement backend n’est nécessaire** pour cette fonctionnalité.
|
||||
|
||||
---
|
||||
|
||||
## 5. Points d’attention (hors périmètre immédiat)
|
||||
|
||||
| Sujet | Détail |
|
||||
|-------|--------|
|
||||
| **Lien inverse** | Si le parent B est le co-parent de A (`A.id_co_parent = B`) mais que `B.id_co_parent` est `null`, le sous-titre **ne s’affichera pas** sur la fiche de B. Le front ne fait pas de résolution inverse. À traiter côté back **seulement si** des données legacy ont un lien à sens unique. |
|
||||
| **Familles > 2 adultes** | Le sous-titre n’affiche que le co-parent direct (`id_co_parent`). Les autres responsables liés uniquement via `enfants_parents` ne sont pas listés ici (cf. doc 28 §6). |
|
||||
| **Données sensibles** | Vérifier que la sérialisation de `co_parent` n’expose pas `password` / tokens (même remarque que pour `user`). |
|
||||
|
||||
---
|
||||
|
||||
## 6. Fichiers front concernés
|
||||
|
||||
| Fichier | Rôle |
|
||||
|---------|------|
|
||||
| `frontend/lib/models/parent_model.dart` | Parse `co_parent` → `AppUser? coParent` |
|
||||
| `frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart` | Titre + sous-titre |
|
||||
| `frontend/lib/services/user_service.dart` | `getParents()` / `getParent()` |
|
||||
|
||||
---
|
||||
|
||||
## 7. Références
|
||||
|
||||
- `docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1
|
||||
- `backend/src/routes/parents/parents.service.ts` — `findOne`, `findAll`
|
||||
- `backend/src/entities/parents.entity.ts` — relation `co_parent`
|
||||
- Ticket Gitea **#131**
|
||||
@@ -0,0 +1,104 @@
|
||||
# Analyse d’empreinte — P'titsPas
|
||||
|
||||
**Date :** 2026-07-22
|
||||
**Contexte :** snapshot pour mémoire (après alerte disque plein + purge cache Gitea)
|
||||
**Périmètre :** application `jmartin/petitspas` déployée sur le VPS (`/home/deploy/dev/ptitspas-app`)
|
||||
|
||||
---
|
||||
|
||||
## 1. Lignes de code
|
||||
|
||||
Comptage brut (`wc -l`), hors `node_modules` / builds / `.dart_tool`.
|
||||
|
||||
| Zone | Lignes | Fichiers |
|
||||
|------|--------|----------|
|
||||
| **Frontend** (Dart `frontend/lib/`) | ~29 800 | 141 |
|
||||
| **Backend** (TypeScript `backend/src/`) | ~10 000 | 141 |
|
||||
| **BDD** (SQL sous `database/`) | ~1 250 | ~15 |
|
||||
| **Total** | **~41 000** | |
|
||||
|
||||
### Frontend (détail)
|
||||
|
||||
| Dossier | Lignes |
|
||||
|---------|--------|
|
||||
| `widgets/` | ~18 400 |
|
||||
| `screens/` | ~5 000 |
|
||||
| `services/` | ~2 300 |
|
||||
| `models/` | ~2 200 |
|
||||
| `utils/` | ~1 400 |
|
||||
| reste | ~500 |
|
||||
|
||||
### Backend (détail)
|
||||
|
||||
| Dossier | Lignes |
|
||||
|---------|--------|
|
||||
| `routes/` | ~6 500 |
|
||||
| `modules/` | ~1 700 |
|
||||
| `entities/` | ~1 000 |
|
||||
| `common/` + `config/` | ~400 |
|
||||
|
||||
### BDD (détail)
|
||||
|
||||
| Élément | Lignes |
|
||||
|---------|--------|
|
||||
| `BDD.sql` (schéma) | ~470 |
|
||||
| seeds | ~300 |
|
||||
| migrations / patches | ~280 |
|
||||
| tests SQL | ~205 |
|
||||
|
||||
---
|
||||
|
||||
## 2. Empreinte disque — runtime (Docker)
|
||||
|
||||
| Composant | Taille | Notes |
|
||||
|-----------|--------|--------|
|
||||
| Image `ptitspas-app-backend` | ~300 Mo | NestJS |
|
||||
| Image `ptitspas-app-frontend` | ~122 Mo | Flutter web + Nginx |
|
||||
| Image `postgres:17` | ~454 Mo | |
|
||||
| Image `dpage/pgadmin4` | ~534 Mo | optionnel |
|
||||
| Volume `postgres_data` | ~49 Mo | données BDD live |
|
||||
| Volume `backend_uploads` | ~20 Mo | photos |
|
||||
| Volume `backend_documents_legaux` | ~0 | |
|
||||
| **Stack complète (avec pgAdmin)** | **~1,5 Go** | |
|
||||
| **Stack prod sans pgAdmin** | **~945 Mo** | |
|
||||
|
||||
---
|
||||
|
||||
## 3. Empreinte disque — code source
|
||||
|
||||
| Élément | Taille |
|
||||
|---------|--------|
|
||||
| Clone `/home/deploy/dev/ptitspas-app` | ~701 Mo |
|
||||
| dont `backend/node_modules` | ~354 Mo |
|
||||
| dont `frontend` (+ `.dart_tool`) | ~114 Mo |
|
||||
| **Code utile** (hors deps / `.git` / builds) | **~51 Mo** |
|
||||
| Repo Gitea `petitspas.git` | ~109 Mo |
|
||||
| Dump `database/BDD.sql` | ~19 Ko |
|
||||
|
||||
---
|
||||
|
||||
## 4. Pour (re)déployer
|
||||
|
||||
Minimum requis :
|
||||
|
||||
1. Images custom backend + frontend (~422 Mo), ou code + build Docker
|
||||
2. Image `postgres:17` (~454 Mo)
|
||||
3. Volumes persistants (~70 Mo au snapshot)
|
||||
4. Optionnel : pgAdmin (~534 Mo)
|
||||
|
||||
**Ordre de grandeur :** ~1 Go pour faire tourner l’app en prod (sans pgAdmin).
|
||||
|
||||
---
|
||||
|
||||
## 5. Notes infra du jour (lié)
|
||||
|
||||
- Disque VPS passé de **100 %** à ~**44 %** après :
|
||||
- purge cache Docker / journals
|
||||
- purge officielle Gitea `delete_repo_archives` (~24 Go de zip/bundle `petitspas`)
|
||||
- Crons Gitea activés :
|
||||
- `archive_cleanup` @midnight (`OLDER_THAN = 24h`)
|
||||
- `delete_repo_archives` @weekly
|
||||
|
||||
---
|
||||
|
||||
*Fichier généré pour historique projet — ne pas considérer comme métrique CI automatisée.*
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,89 @@
|
||||
# Mini-spec API — POST /parents/dossier (#129)
|
||||
|
||||
Contrat pour le **plan front** (wizard création dossier famille staff).
|
||||
|
||||
Miroir de **#156** (`POST /assistantes-maternelles/dossier`).
|
||||
|
||||
## Endpoint
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| **Méthode** | `POST` |
|
||||
| **URL** | `{base}/parents/dossier` |
|
||||
| **Auth** | Bearer JWT |
|
||||
| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` |
|
||||
| **Content-Type** | `application/json` |
|
||||
|
||||
Ne **pas** appeler `POST /auth/register/parent` depuis le dashboard.
|
||||
|
||||
## Body (JSON)
|
||||
|
||||
Aligné `RegisterParentCompletDto`, **sans** CGU/privacy obligatoires (acceptées serveur).
|
||||
|
||||
### Parent 1 (obligatoire)
|
||||
|
||||
| Champ | Type | Obligatoire | Notes |
|
||||
|-------|------|-------------|--------|
|
||||
| `email` | string | oui | unique |
|
||||
| `prenom` | string | oui | |
|
||||
| `nom` | string | oui | |
|
||||
| `telephone` | string | oui | `0X…` ou `+33…` |
|
||||
| `adresse` | string | non | |
|
||||
| `code_postal` | string | non | |
|
||||
| `ville` | string | non | |
|
||||
|
||||
### Co-parent (optionnel)
|
||||
|
||||
`co_parent_email`, `co_parent_prenom`, `co_parent_nom`, `co_parent_telephone`,
|
||||
`co_parent_meme_adresse`, `co_parent_adresse`, `co_parent_code_postal`, `co_parent_ville`.
|
||||
|
||||
Si co-parent fourni : e-mail distinct ; mêmes règles téléphone / adresse que register.
|
||||
|
||||
### Enfants (≥ 1)
|
||||
|
||||
| Champ | Type | Notes |
|
||||
|-------|------|--------|
|
||||
| `enfants` | `EnfantInscriptionDto[]` | `prenom`, `nom`, `date_naissance` / `date_previsionnelle_naissance`, `genre`, `photo_base64`, `photo_filename`, etc. |
|
||||
|
||||
### Présentation
|
||||
|
||||
| Champ | Type | Obligatoire |
|
||||
|-------|------|-------------|
|
||||
| `presentation_dossier` | string | non (max 2000) |
|
||||
|
||||
## Réponses
|
||||
|
||||
### 201 Created
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Dossier famille créé et validé. Un e-mail de création de mot de passe a été envoyé.",
|
||||
"numero_dossier": "2026-000043",
|
||||
"parent_user_id": "uuid-pivot",
|
||||
"co_parent_user_id": "uuid-ou-null",
|
||||
"statut": "actif",
|
||||
"enfant_ids": ["uuid", "..."]
|
||||
}
|
||||
```
|
||||
|
||||
Effets serveur : user(s) parent **actif**, fiches `parents`, enfants + foyer, n° dossier,
|
||||
**e-mail création MDP** pour chaque compte sans MDP (pas d’accusé « en attente »).
|
||||
|
||||
### Erreurs
|
||||
|
||||
| Code | Cas |
|
||||
|------|-----|
|
||||
| 400 | Validation DTO / métier (enfants vides, dates, etc.) |
|
||||
| 401 | Token manquant / invalide |
|
||||
| 403 | Rôle non staff |
|
||||
| 409 | Conflit e-mail (pivot et/ou co-parent) |
|
||||
|
||||
## Front
|
||||
|
||||
- `UserService.createParentDossier(body)` → cet endpoint
|
||||
- Wizard create basé sur `ValidationFamilyWizard`
|
||||
- Ne pas envoyer `acceptation_cgu` / `acceptation_privacy` (optionnels)
|
||||
|
||||
## Branche
|
||||
|
||||
`feature/129-creation-dossier-parent`
|
||||
@@ -0,0 +1,77 @@
|
||||
# Mini-spec API — GET /dossiers (#153)
|
||||
|
||||
Contrat pour le **plan front** (onglet permanent Dossiers).
|
||||
|
||||
## Endpoint
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| **Méthode** | `GET` |
|
||||
| **URL** | `{base}/api/v1/dossiers` |
|
||||
| **Auth** | Bearer JWT |
|
||||
| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` |
|
||||
| **Query** | `q` (optionnel) — recherche n° / libellé / email |
|
||||
|
||||
Complète `GET /dossiers/:numeroDossier` (#119) déjà existant.
|
||||
|
||||
---
|
||||
|
||||
## Réponse 200
|
||||
|
||||
Tableau de lignes (1 entrée = 1 `numero_dossier`) :
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"type": "famille",
|
||||
"numero_dossier": "2026-000043",
|
||||
"libelle": "Claire MARTIN & Thomas MARTIN",
|
||||
"emails": ["claire@test.fr", "thomas@test.fr"],
|
||||
"user_ids": ["uuid-pivot", "uuid-co"],
|
||||
"statut": "actif",
|
||||
"a_valider": false,
|
||||
"date_reference": "2026-01-12T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"type": "assistante_maternelle",
|
||||
"numero_dossier": "2026-000042",
|
||||
"libelle": "Marie DUPONT",
|
||||
"emails": ["marie@test.fr"],
|
||||
"user_ids": ["uuid-am"],
|
||||
"statut": "en_attente",
|
||||
"a_valider": true,
|
||||
"date_reference": "2026-02-01T08:00:00.000Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Champs
|
||||
|
||||
| Champ | Notes |
|
||||
|-------|--------|
|
||||
| `type` | `famille` \| `assistante_maternelle` |
|
||||
| `numero_dossier` | Clé d’unité |
|
||||
| `libelle` | Noms formatés (foyer : `A & B`) |
|
||||
| `emails` / `user_ids` | Membres du foyer ou AM |
|
||||
| `statut` | Agrégé : `en_attente` si au moins un user pending |
|
||||
| `a_valider` | `true` si pending → section haute UI |
|
||||
| `date_reference` | `MIN(cree_le)` des users |
|
||||
|
||||
**Tri** : `a_valider` d’abord, puis `numero_dossier` décroissant.
|
||||
|
||||
**Famille** : dédupliquée par `numero_dossier` (pivot + co-parent = 1 ligne).
|
||||
|
||||
---
|
||||
|
||||
## Front
|
||||
|
||||
- `UserService.getDossiers({ q? })` → cet endpoint
|
||||
- Section haute : filtrer `a_valider == true` **ou** continuer pending APIs existantes
|
||||
- Section basse : liste complète (ou hors pending selon règle UX)
|
||||
- Clic → `GET /dossiers/:numero` (détail) / validation review
|
||||
|
||||
Composition client `getParents`+`getAM` **plus nécessaire** si cet endpoint est déployé.
|
||||
|
||||
## Branche
|
||||
|
||||
`feature/153-onglet-dossiers`
|
||||
@@ -0,0 +1,167 @@
|
||||
# Mini-spec front — Onglet permanent « Dossiers » (#153)
|
||||
|
||||
Branche Git (front + back) : `feature/153-onglet-dossiers`
|
||||
Ticket Gitea : **#153** (ticket normal, plus epic)
|
||||
|
||||
> Suite prévue : **#135** = au clic, mode **édition** wizard + ajout 2ᵉ parent.
|
||||
> **#153** = onglet + listes + navigation / validation pending. **Pas** de création, **pas** d’édition complète.
|
||||
|
||||
---
|
||||
|
||||
## Contexte / objectif
|
||||
|
||||
Remplacer l’onglet conditionnel **« À valider »** (apparaît/disparaît selon pending) par un onglet **permanent « Dossiers »** dans le dashboard admin/gestionnaire.
|
||||
|
||||
Quand on ouvre **Dossiers** :
|
||||
|
||||
1. **En haut** — section **Dossiers à valider** (AM + familles pending)
|
||||
2. **En dessous** — liste de **tous les dossiers** (familles **et** AM), 1 ligne = 1 `numero_dossier`
|
||||
3. Différenciation visuelle famille vs AM : **couleur + icône**
|
||||
4. **Barre de recherche** (n° dossier, nom, email…)
|
||||
|
||||
**Pas** de bouton « Créer un dossier » ici (création via **+ Parents** #129 / **+ Asmat** #156).
|
||||
|
||||
---
|
||||
|
||||
## UX cible
|
||||
|
||||
### Onglets dashboard (`UserManagementPanel`)
|
||||
|
||||
| Avant (#107) | Après (#153) |
|
||||
|--------------|--------------|
|
||||
| « À valider » **conditionnel** si pending | **« Dossiers » toujours visible** (admin + gestionnaire) |
|
||||
| Contenu = seulement pending | Pending **en haut** + liste complète **en bas** |
|
||||
|
||||
Ordre suggéré des onglets :
|
||||
|
||||
`Dossiers` | `Parents` | `Enfants` | `Assistantes maternelles` | `Gestionnaires` | (`Administrateurs`)
|
||||
|
||||
### Section haute — À valider
|
||||
|
||||
- Réutiliser / adapter `PendingValidationWidget` (ou extraire la liste dans un sous-widget).
|
||||
- Sources déjà branchées :
|
||||
- `UserService.getPendingUsers(role: 'assistante_maternelle')`
|
||||
- `UserService.getPendingFamilies()`
|
||||
- Clic ligne pending → **`ValidationDossierModal`** / wizards `.review` (inchangé).
|
||||
- Si section vide : ne pas afficher de gros vide ; masquer la section ou message court « Aucun dossier en attente ».
|
||||
|
||||
### Section basse — Tous les dossiers
|
||||
|
||||
1 ligne = **1 dossier** (`numero_dossier`), type :
|
||||
|
||||
| Type | Libellé UI | Couleur (suggestion) |
|
||||
|------|------------|----------------------|
|
||||
| `famille` | Famille / Parents | teinte existante parents (ex. violet / rose dashboard) |
|
||||
| `assistante_maternelle` | AM | teinte existante AM (ex. teal / bleu) |
|
||||
|
||||
Colonnes / infos utiles (cartes style `AdminUserCard` ou lignes type pending) :
|
||||
|
||||
- n° dossier
|
||||
- type (pastille couleur + icône)
|
||||
- libellé (noms parents ou AM)
|
||||
- email(s) principal(aux)
|
||||
- statut user / dossier si dispo (`actif`, `en_attente`, …)
|
||||
- date utile si dispo
|
||||
|
||||
**Déduplication** : un foyer (pivot + co-parent) = **une** ligne famille (même `numero_dossier`). Idem AM.
|
||||
|
||||
### Recherche
|
||||
|
||||
- La search bar du panel (aujourd’hui désactivée / hint « pas de recherche » sur À valider) doit **filtrer la liste unifiée** (et idéalement aussi le pending affiché).
|
||||
- Critères **minimum** : `numero_dossier`, nom, prénom, email.
|
||||
- Harmoniser le hint : `Rechercher un dossier (n°, nom, email)…`
|
||||
|
||||
### État vide liste complète
|
||||
|
||||
Aide optionnelle : *« Pour créer un dossier → onglet Parents (+ Parents) ou Assistantes maternelles (+ Asmat) »*.
|
||||
|
||||
### Clic sur un dossier de la liste complète (#153)
|
||||
|
||||
| Cas | Comportement #153 |
|
||||
|-----|-------------------|
|
||||
| Pending | Ouvrir validation (review) — déjà en place |
|
||||
| Dossier **actif** / non pending | Ouvrir consultation via `GET /dossiers/:numeroDossier` (`UserService.getDossierByNumero`) en **lecture / review** si possible **sans** save édition |
|
||||
|
||||
**Ne pas** implémenter le mode `edit` ni le switch 2ᵉ parent → **#135**.
|
||||
|
||||
Si l’ouverture « review » d’un dossier actif est trop lourde pour ce ticket : clic peut temporairement no-op / snackbar *« Édition dossier : prochainement (#135) »* — **à éviter** si `getDossierByNumero` + wizard review marche déjà pour les deux types.
|
||||
|
||||
---
|
||||
|
||||
## Données / APIs (front)
|
||||
|
||||
### Déjà disponibles (préférer composer côté front pour #153)
|
||||
|
||||
| Besoin | API / service |
|
||||
|--------|----------------|
|
||||
| Pending AM | `getPendingUsers(role: assistante_maternelle)` |
|
||||
| Pending familles | `getPendingFamilies()` |
|
||||
| Parents (avec `numero_dossier`) | `getParents()` |
|
||||
| AM (avec `numero_dossier`) | `getAssistantesMaternelles()` |
|
||||
| Détail unifié | `getDossierByNumero(numero)` → `GET /dossiers/:numeroDossier` |
|
||||
|
||||
**Pas d’endpoint `GET /dossiers` liste** aujourd’hui. Pour #153 :
|
||||
|
||||
- Construire la liste unifiée **côté client** à partir de `getParents()` + `getAssistantesMaternelles()` (group by `numero_dossier`).
|
||||
- Exclure ou marquer les pending déjà dans la section haute (éviter doublons visuels, ou les laisser dans les deux avec badge « à valider » — **préférence** : pending **uniquement** en haut ; liste basse = tous **hors** pending **ou** tous avec badge ; choisir une règle claire et documenter dans le PR).
|
||||
|
||||
**Règle recommandée** :
|
||||
- Haut = pending only
|
||||
- Bas = **tous** les dossiers ayant un `numero_dossier` (y compris pending) **OU** bas = non-pending only
|
||||
→ **Recommandation produit** : bas = **tous** (vision complète), pending aussi en haut pour action rapide. Si doublon gênant : bas = non-pending only.
|
||||
|
||||
### Si le back ajoute plus tard `GET /dossiers`
|
||||
|
||||
Brancher `UserService.getDossiers()` — hors scope bloquant #153 front si composition client OK.
|
||||
|
||||
---
|
||||
|
||||
## Fichiers front probables
|
||||
|
||||
| Fichier | Rôle |
|
||||
|---------|------|
|
||||
| `frontend/lib/widgets/admin/user_management_panel.dart` | Onglet permanent **Dossiers** ; retirer logique conditionnelle À valider ; search sur cet onglet |
|
||||
| `frontend/lib/widgets/admin/pending_validation_widget.dart` | Réemploi section haute (ou refactor léger) |
|
||||
| **Nouveau** `…/dossiers_management_widget.dart` (nom libre) | Shell onglet : pending + liste unifiée + refresh |
|
||||
| **Nouveau** modèle léger `DossierListItem` (type, numero, libelle, emails, statut…) | Mapping parents/AM → ligne |
|
||||
| `user_service.dart` / `api_config.dart` | Seulement si helper `getDossiersUnified()` côté client (pas forcément nouvel endpoint) |
|
||||
| `validation_dossier_modal.dart` | Réemploi ouverture pending / détail |
|
||||
|
||||
Réutiliser look & feel cartes / hover « Ouvrir » de `_PendingValidationRow` / `AdminUserCard`.
|
||||
|
||||
---
|
||||
|
||||
## Hors scope (#153)
|
||||
|
||||
- Bouton créer dossier
|
||||
- Mode `edit` wizard + ajout 2ᵉ parent → **#135**
|
||||
- Suppressions → **#154**
|
||||
- Famille N responsables → **#139**
|
||||
- Changer les onglets Parents / AM / Enfants (restent)
|
||||
|
||||
---
|
||||
|
||||
## Critères d’acceptation front
|
||||
|
||||
- [ ] Onglet **Dossiers** toujours visible (même 0 pending)
|
||||
- [ ] Plus d’onglet conditionnel **« À valider »**
|
||||
- [ ] Section haute pending si non vide ; validation au clic OK
|
||||
- [ ] Liste unifiée familles + AM en dessous ; 1 ligne / `numero_dossier`
|
||||
- [ ] Couleur + icône différencient famille / AM
|
||||
- [ ] Recherche filtre (n° + nom + email minimum)
|
||||
- [ ] **Aucun** bouton créer dans cet onglet
|
||||
- [ ] Pas de régression validation pending (valider / refuser)
|
||||
|
||||
---
|
||||
|
||||
## Back (info — Cursor back séparé si besoin)
|
||||
|
||||
- Liste unifiée : **pas bloquante** si composition front
|
||||
- Optionnel : `GET /api/v1/dossiers` (liste) pour perf / pagination plus tard
|
||||
- `GET /dossiers/:numero` déjà là (#119)
|
||||
|
||||
---
|
||||
|
||||
## Branche
|
||||
|
||||
`feature/153-onglet-dossiers` (depuis `develop`)
|
||||
@@ -0,0 +1,75 @@
|
||||
# Mini-spec API — POST /assistantes-maternelles/dossier (#156)
|
||||
|
||||
Contrat pour le **plan front** (wizard création AM staff).
|
||||
|
||||
## Endpoint
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| **Méthode** | `POST` |
|
||||
| **URL** | `{base}/assistantes-maternelles/dossier` |
|
||||
| **Auth** | Bearer JWT |
|
||||
| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` |
|
||||
| **Content-Type** | `application/json` |
|
||||
|
||||
Ne **pas** appeler `POST /auth/register/am` depuis le dashboard.
|
||||
|
||||
## Body (JSON)
|
||||
|
||||
Aligné inscription AM publique, **sans** CGU/privacy obligatoires (acceptées serveur).
|
||||
|
||||
| Champ | Type | Obligatoire | Notes |
|
||||
|-------|------|-------------|--------|
|
||||
| `email` | string | oui | unique |
|
||||
| `prenom` | string | oui | |
|
||||
| `nom` | string | oui | |
|
||||
| `telephone` | string | oui | `0X…` ou `+33…` |
|
||||
| `adresse` | string | non | |
|
||||
| `code_postal` | string | non | |
|
||||
| `ville` | string | non | |
|
||||
| `photo_base64` | string | non | data-URL `data:image/…;base64,…` |
|
||||
| `photo_filename` | string | non | hint nom fichier |
|
||||
| `consentement_photo` | bool | oui | |
|
||||
| `date_naissance` | date ISO | non | `YYYY-MM-DD` |
|
||||
| `lieu_naissance_ville` | string | oui | |
|
||||
| `lieu_naissance_pays` | string | oui | |
|
||||
| `nir` | string | oui | 15 car. (Corse 2A/2B OK) |
|
||||
| `numero_agrement` | string | oui | unique |
|
||||
| `date_agrement` | date ISO | non | |
|
||||
| `capacite_accueil` | int | oui | 1–10 |
|
||||
| `places_disponibles` | int | oui | 0–10, ≤ capacité |
|
||||
| `biographie` | string | non | max 2000 |
|
||||
|
||||
## Réponses
|
||||
|
||||
### 201 Created
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Dossier AM créé et validé. Un e-mail de création de mot de passe a été envoyé.",
|
||||
"user_id": "uuid",
|
||||
"statut": "actif",
|
||||
"numero_dossier": "2026-000042"
|
||||
}
|
||||
```
|
||||
|
||||
Effets serveur : user AM **actif**, fiche `assistantes_maternelles`, n° dossier, **e-mail création MDP** (pas d’accusé « en attente »).
|
||||
|
||||
### Erreurs
|
||||
|
||||
| Code | Cas |
|
||||
|------|-----|
|
||||
| 400 | Validation / NIR / places > capacité |
|
||||
| 403 | Rôle non staff |
|
||||
| 409 | Email, NIR ou agrément déjà pris |
|
||||
| 401 | Token manquant / invalide |
|
||||
|
||||
## Front
|
||||
|
||||
- `UserService.createAmDossier(body)` → cet endpoint
|
||||
- Après 201 : refresh liste AM ; snackbar OK
|
||||
- Wizard create : ne pas envoyer `acceptation_cgu` / `acceptation_privacy` (optionnels)
|
||||
|
||||
## Branche
|
||||
|
||||
`feature/156-creation-dossier-am`
|
||||
+1
-1
@@ -18,7 +18,7 @@ jobs:
|
||||
- name: 💡 Set up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.19.0' # ou celle que tu utilises
|
||||
flutter-version: '3.29.3'
|
||||
channel: stable
|
||||
|
||||
- name: 📦 Install dependencies
|
||||
|
||||
+5
-1
@@ -1,5 +1,5 @@
|
||||
# Stage builder
|
||||
FROM ghcr.io/cirruslabs/flutter:3.19.0 AS builder
|
||||
FROM ghcr.io/cirruslabs/flutter:3.29.3 AS builder
|
||||
WORKDIR /app
|
||||
COPY pubspec.* ./
|
||||
RUN flutter pub get
|
||||
@@ -8,6 +8,10 @@ RUN flutter build web --release
|
||||
|
||||
# Stage production
|
||||
FROM nginx:alpine
|
||||
# Image nginx:alpine contient déjà index.html « Welcome to nginx ».
|
||||
# Un merge de dossiers sans nettoyage peut laisser ce fichier si le build
|
||||
# Flutter n’écrase pas tous les fichiers → SPA invisible. On vide d’abord.
|
||||
RUN rm -rf /usr/share/nginx/html/*
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=builder /app/build/web /usr/share/nginx/html
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@ import '../screens/auth/am_register_step1_screen.dart';
|
||||
import '../screens/auth/am_register_step2_screen.dart';
|
||||
import '../screens/auth/am_register_step3_screen.dart';
|
||||
import '../screens/auth/am_register_step4_screen.dart';
|
||||
import '../screens/auth/create_password_screen.dart';
|
||||
import '../screens/auth/forgot_password_screen.dart';
|
||||
import '../screens/auth/reset_password_screen.dart';
|
||||
import '../screens/auth/reprise_entry_screen.dart';
|
||||
import '../screens/home/home_screen.dart';
|
||||
import '../screens/administrateurs/admin_dashboardScreen.dart';
|
||||
import '../screens/gestionnaire/gestionnaire_dashboard_screen.dart';
|
||||
@@ -49,6 +53,25 @@ class AppRouter {
|
||||
path: '/register-choice',
|
||||
builder: (BuildContext context, GoRouterState state) => const RegisterChoiceScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/create-password',
|
||||
builder: (BuildContext context, GoRouterState state) =>
|
||||
CreatePasswordScreen(token: state.uri.queryParameters['token']),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/forgot-password',
|
||||
builder: (BuildContext context, GoRouterState state) => const ForgotPasswordScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/reset-password',
|
||||
builder: (BuildContext context, GoRouterState state) =>
|
||||
ResetPasswordScreen(token: state.uri.queryParameters['token']),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/reprise',
|
||||
builder: (BuildContext context, GoRouterState state) =>
|
||||
RepriseEntryScreen(token: state.uri.queryParameters['token']),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/home',
|
||||
builder: (BuildContext context, GoRouterState state) => const HomeScreen(),
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
import 'package:flutter/foundation.dart' show kDebugMode, kIsWeb;
|
||||
|
||||
class Env {
|
||||
// Base URL de l'API, surchargeable à la compilation via --dart-define=API_BASE_URL
|
||||
static const String apiBaseUrl = String.fromEnvironment(
|
||||
'API_BASE_URL',
|
||||
defaultValue: 'https://app.ptits-pas.fr',
|
||||
);
|
||||
/// Base URL de l’API (sans `/api/v1`).
|
||||
///
|
||||
/// - **`API_BASE_URL` en `--dart-define`** : prioritaire (back local, staging, etc.).
|
||||
/// - **Mode debug sans define** : toujours **`https://app.ptits-pas.fr`** (web inclus) pour
|
||||
/// pointer sur le back de prod depuis l’IDE / `flutter run`.
|
||||
/// - **Web release / profile sans define** : [Uri.base.origin] (même hôte que la page déployée).
|
||||
/// - **Mobile release / profile sans define** : `https://app.ptits-pas.fr`.
|
||||
static String get apiBaseUrl {
|
||||
const fromEnv = String.fromEnvironment('API_BASE_URL');
|
||||
if (fromEnv.isNotEmpty) {
|
||||
return fromEnv;
|
||||
}
|
||||
if (kDebugMode) {
|
||||
return 'https://app.ptits-pas.fr';
|
||||
}
|
||||
if (kIsWeb) {
|
||||
return Uri.base.origin;
|
||||
}
|
||||
return 'https://app.ptits-pas.fr';
|
||||
}
|
||||
|
||||
// Construit une URL vers l'API v1 à partir d'un chemin (commençant par '/')
|
||||
static String apiV1(String path) => '$apiBaseUrl/api/v1$path';
|
||||
/// Construit une URL vers l'API v1 à partir d'un chemin (commençant par '/')
|
||||
static String apiV1(String path) => '${apiBaseUrl}/api/v1$path';
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_web_plugins/url_strategy.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart'; // Import pour la localisation
|
||||
// import 'package:provider/provider.dart'; // Supprimer Provider
|
||||
@@ -7,6 +8,8 @@ import 'config/app_router.dart'; // <-- Importer le bon routeur (GoRouter)
|
||||
// import 'theme/theme_provider.dart'; // Supprimer ThemeProvider
|
||||
|
||||
void main() {
|
||||
// Permet les URLs web propres (/create-password?token=...) sans hash.
|
||||
usePathUrlStrategy();
|
||||
runApp(const MyApp()); // Exécution simple
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,9 @@ class AmRegistrationData extends ChangeNotifier {
|
||||
/// Places libres actuellement (0 ≤ valeur ≤ capacité) — API `places_disponibles`.
|
||||
int? placesAvailable;
|
||||
|
||||
/// Photo déjà en base lors d'une reprise (#112) — sert pour l'affichage et `photo_url` API.
|
||||
String? repriseExistingPhotoUrl;
|
||||
|
||||
// Step 3: Presentation & CGU
|
||||
String presentationText = '';
|
||||
bool cguAccepted = false;
|
||||
@@ -104,6 +107,53 @@ class AmRegistrationData extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Reprise après refus (#112).
|
||||
void resetForReprise({
|
||||
required String firstName,
|
||||
required String lastName,
|
||||
required String phone,
|
||||
required String email,
|
||||
required String streetAddress,
|
||||
required String postalCode,
|
||||
required String city,
|
||||
String? existingPhotoUrl,
|
||||
bool consentementPhoto = false,
|
||||
DateTime? dateOfBirth,
|
||||
String birthCity = '',
|
||||
String birthCountry = '',
|
||||
String nir = '',
|
||||
String agrementNumber = '',
|
||||
DateTime? agreementDate,
|
||||
int? capacity,
|
||||
int? placesAvailable,
|
||||
String presentationText = '',
|
||||
}) {
|
||||
this.firstName = firstName;
|
||||
this.lastName = lastName;
|
||||
this.phone = phone;
|
||||
this.email = email;
|
||||
this.streetAddress = streetAddress;
|
||||
this.postalCode = postalCode;
|
||||
this.city = city;
|
||||
password = '';
|
||||
repriseExistingPhotoUrl = existingPhotoUrl;
|
||||
photoPath = existingPhotoUrl;
|
||||
photoBytes = null;
|
||||
photoFilename = null;
|
||||
photoConsent = consentementPhoto;
|
||||
this.dateOfBirth = dateOfBirth;
|
||||
this.birthCity = birthCity;
|
||||
this.birthCountry = birthCountry;
|
||||
this.nir = nir;
|
||||
this.agrementNumber = agrementNumber;
|
||||
this.agreementDate = agreementDate;
|
||||
this.capacity = capacity;
|
||||
this.placesAvailable = placesAvailable;
|
||||
this.presentationText = presentationText;
|
||||
cguAccepted = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// --- Getters for validation or display ---
|
||||
bool get isStep1Complete =>
|
||||
firstName.trim().length >= 2 &&
|
||||
@@ -118,6 +168,8 @@ class AmRegistrationData extends ChangeNotifier {
|
||||
/// Photo réelle (pas seulement un placeholder asset).
|
||||
bool get _hasUserPhoto =>
|
||||
(photoBytes != null && photoBytes!.isNotEmpty) ||
|
||||
(repriseExistingPhotoUrl != null &&
|
||||
repriseExistingPhotoUrl!.trim().isNotEmpty) ||
|
||||
(photoPath != null &&
|
||||
photoPath!.isNotEmpty &&
|
||||
!photoPath!.startsWith('assets/'));
|
||||
@@ -145,6 +197,9 @@ class AmRegistrationData extends ChangeNotifier {
|
||||
bool get isRegistrationComplete =>
|
||||
isStep1Complete && isStep2Complete && isStep3Complete;
|
||||
|
||||
/// Reprise (#112) : dossier AM complet renvoyé par PATCH reprise-resoumettre.
|
||||
bool get isRepriseSubmitReady => isRegistrationComplete;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AmRegistrationData('
|
||||
|
||||
@@ -1,30 +1,102 @@
|
||||
import 'package:p_tits_pas/models/parent_child_summary.dart';
|
||||
import 'package:p_tits_pas/models/user.dart';
|
||||
|
||||
class AssistanteMaternelleModel {
|
||||
final AppUser user;
|
||||
final String? approvalNumber;
|
||||
final String? nir;
|
||||
final String? residenceCity;
|
||||
final int? maxChildren;
|
||||
final int? placesAvailable;
|
||||
final String? biography;
|
||||
final bool? available;
|
||||
final String? agreementDate;
|
||||
final List<ParentChildSummary> children;
|
||||
|
||||
AssistanteMaternelleModel({
|
||||
required this.user,
|
||||
this.approvalNumber,
|
||||
this.nir,
|
||||
this.residenceCity,
|
||||
this.maxChildren,
|
||||
this.placesAvailable,
|
||||
this.biography,
|
||||
this.available,
|
||||
this.agreementDate,
|
||||
this.children = const [],
|
||||
});
|
||||
|
||||
factory AssistanteMaternelleModel.fromJson(Map<String, dynamic> json) {
|
||||
final userJson = json['user'] ?? json;
|
||||
final root = Map<String, dynamic>.from(json);
|
||||
final userJson = Map<String, dynamic>.from(root['user'] ?? root);
|
||||
if (root['numero_dossier'] != null && userJson['numero_dossier'] == null) {
|
||||
userJson['numero_dossier'] = root['numero_dossier'];
|
||||
}
|
||||
final user = AppUser.fromJson(userJson);
|
||||
final children = _parseChildren(root);
|
||||
|
||||
return AssistanteMaternelleModel(
|
||||
user: user,
|
||||
approvalNumber: json['numero_agrement'] as String?,
|
||||
residenceCity: json['ville_residence'] as String?,
|
||||
maxChildren: json['nb_max_enfants'] as int?,
|
||||
placesAvailable: json['place_disponible'] as int?,
|
||||
approvalNumber: _str(
|
||||
root['approval_number'] ?? root['numero_agrement'],
|
||||
),
|
||||
nir: _str(root['nir'] ?? root['nir_chiffre']),
|
||||
residenceCity: _str(
|
||||
root['residence_city'] ?? root['ville_residence'],
|
||||
),
|
||||
maxChildren: _int(root['max_children'] ?? root['nb_max_enfants']),
|
||||
placesAvailable: _int(
|
||||
root['places_available'] ?? root['place_disponible'],
|
||||
),
|
||||
biography: _str(root['biography'] ?? root['biographie']),
|
||||
available: root['available'] as bool? ?? root['disponible'] as bool?,
|
||||
agreementDate: _dateString(
|
||||
root['agreement_date'] ?? root['date_agrement'],
|
||||
),
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
|
||||
static String? _str(dynamic v) {
|
||||
if (v == null) return null;
|
||||
final s = v.toString().trim();
|
||||
return s.isEmpty ? null : s;
|
||||
}
|
||||
|
||||
static int? _int(dynamic v) {
|
||||
if (v == null) return null;
|
||||
if (v is int) return v;
|
||||
return int.tryParse(v.toString());
|
||||
}
|
||||
|
||||
static String? _dateString(dynamic v) {
|
||||
if (v == null) return null;
|
||||
return v.toString().split('T').first;
|
||||
}
|
||||
|
||||
static List<ParentChildSummary> _parseChildren(Map<String, dynamic> json) {
|
||||
final children = <ParentChildSummary>[];
|
||||
final seen = <String>{};
|
||||
|
||||
void add(ParentChildSummary? child) {
|
||||
if (child == null || child.id.isEmpty || seen.contains(child.id)) return;
|
||||
seen.add(child.id);
|
||||
children.add(child);
|
||||
}
|
||||
|
||||
final links = json['amChildren'] ??
|
||||
json['am_children'] ??
|
||||
json['assistanteChildren'] ??
|
||||
json['assistante_children'];
|
||||
if (links is List) {
|
||||
for (final link in links) {
|
||||
if (link is! Map) continue;
|
||||
add(ParentChildSummary.fromParentChildLink(
|
||||
Map<String, dynamic>.from(link),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
|
||||
import 'package:p_tits_pas/models/parent_model.dart';
|
||||
import 'package:p_tits_pas/utils/name_format_utils.dart';
|
||||
|
||||
/// Ligne de liste unifiée dossiers (famille ou AM) — ticket #153.
|
||||
enum DossierListType { famille, assistanteMaternelle }
|
||||
|
||||
class DossierListItem {
|
||||
final DossierListType type;
|
||||
final String numeroDossier;
|
||||
final String libelle;
|
||||
final List<String> emails;
|
||||
final String? statut;
|
||||
/// Photo profil (AM) — affichée à la place de l’icône si présente.
|
||||
final String? photoUrl;
|
||||
|
||||
const DossierListItem({
|
||||
required this.type,
|
||||
required this.numeroDossier,
|
||||
required this.libelle,
|
||||
this.emails = const [],
|
||||
this.statut,
|
||||
this.photoUrl,
|
||||
});
|
||||
|
||||
bool get isFamille => type == DossierListType.famille;
|
||||
bool get isAm => type == DossierListType.assistanteMaternelle;
|
||||
|
||||
String get typeLabel => isFamille ? 'Famille' : 'AM';
|
||||
|
||||
/// Sous-titre carte : `NOM Prénom` ou `NOM Prénom - NOM Prénom`.
|
||||
String get namesLine => libelle;
|
||||
|
||||
String get emailsLine => emails.where((e) => e.trim().isNotEmpty).join(' · ');
|
||||
|
||||
/// Titre carte : numéro de dossier seul.
|
||||
String get titleLine => numeroDossier;
|
||||
|
||||
bool matchesQuery(String query) {
|
||||
final q = query.trim().toLowerCase();
|
||||
if (q.isEmpty) return true;
|
||||
if (numeroDossier.toLowerCase().contains(q)) return true;
|
||||
if (libelle.toLowerCase().contains(q)) return true;
|
||||
for (final e in emails) {
|
||||
if (e.toLowerCase().contains(q)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Une ligne par `numero_dossier` (foyer dédupliqué).
|
||||
static List<DossierListItem> fromParents(List<ParentModel> parents) {
|
||||
final byDossier = <String, List<ParentModel>>{};
|
||||
for (final p in parents) {
|
||||
final num = (p.user.numeroDossier ?? '').trim();
|
||||
if (num.isEmpty) continue;
|
||||
byDossier.putIfAbsent(num, () => []).add(p);
|
||||
}
|
||||
|
||||
final items = <DossierListItem>[];
|
||||
for (final entry in byDossier.entries) {
|
||||
final seenIds = <String>{};
|
||||
final names = <String>[];
|
||||
final emails = <String>[];
|
||||
final statuts = <String>[];
|
||||
|
||||
void consider(
|
||||
String? id,
|
||||
String? nom,
|
||||
String? prenom,
|
||||
String? email,
|
||||
String? statut,
|
||||
) {
|
||||
final uid = (id ?? '').trim();
|
||||
if (uid.isEmpty || !seenIds.add(uid)) return;
|
||||
final label = formatDossierPersonLabel(
|
||||
nom: nom,
|
||||
prenom: prenom,
|
||||
email: email,
|
||||
);
|
||||
if (label.isNotEmpty) names.add(label);
|
||||
final e = (email ?? '').trim();
|
||||
if (e.isNotEmpty) emails.add(e);
|
||||
final s = (statut ?? '').trim();
|
||||
if (s.isNotEmpty) statuts.add(s);
|
||||
}
|
||||
|
||||
for (final p in entry.value) {
|
||||
consider(
|
||||
p.user.id,
|
||||
p.user.nom,
|
||||
p.user.prenom,
|
||||
p.user.email,
|
||||
p.user.statut,
|
||||
);
|
||||
final co = p.coParent;
|
||||
if (co != null) {
|
||||
consider(co.id, co.nom, co.prenom, co.email, co.statut);
|
||||
}
|
||||
}
|
||||
|
||||
items.add(
|
||||
DossierListItem(
|
||||
type: DossierListType.famille,
|
||||
numeroDossier: entry.key,
|
||||
libelle: names.isNotEmpty ? names.join(' - ') : 'Famille',
|
||||
emails: emails,
|
||||
statut: _preferStatut(statuts),
|
||||
),
|
||||
);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
static List<DossierListItem> fromAssistantes(
|
||||
List<AssistanteMaternelleModel> ams,
|
||||
) {
|
||||
final byDossier = <String, AssistanteMaternelleModel>{};
|
||||
for (final am in ams) {
|
||||
final num = (am.user.numeroDossier ?? '').trim();
|
||||
if (num.isEmpty) continue;
|
||||
byDossier.putIfAbsent(num, () => am);
|
||||
}
|
||||
|
||||
return byDossier.entries.map((e) {
|
||||
final u = e.value.user;
|
||||
final name = formatDossierPersonLabel(
|
||||
nom: u.nom,
|
||||
prenom: u.prenom,
|
||||
email: u.email,
|
||||
);
|
||||
final photo = (u.photoUrl ?? '').trim();
|
||||
return DossierListItem(
|
||||
type: DossierListType.assistanteMaternelle,
|
||||
numeroDossier: e.key,
|
||||
libelle: name.isNotEmpty ? name : 'AM',
|
||||
emails: u.email.trim().isEmpty ? const [] : [u.email.trim()],
|
||||
statut: u.statut?.trim(),
|
||||
photoUrl: photo.isEmpty ? null : photo,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Priorité affichage : en_attente > suspendu > refuse > actif > autre.
|
||||
static String? _preferStatut(List<String> raw) {
|
||||
if (raw.isEmpty) return null;
|
||||
const order = ['en_attente', 'suspendu', 'refuse', 'actif'];
|
||||
for (final wanted in order) {
|
||||
for (final s in raw) {
|
||||
if (s.toLowerCase() == wanted) return s;
|
||||
}
|
||||
}
|
||||
return raw.first;
|
||||
}
|
||||
}
|
||||
|
||||
/// Affichage carte dossier : `NOM Prénom` (repli email).
|
||||
String formatDossierPersonLabel({
|
||||
String? nom,
|
||||
String? prenom,
|
||||
String? email,
|
||||
}) {
|
||||
final n = (nom ?? '').trim().toUpperCase();
|
||||
final p = formatPersonNameCase(prenom ?? '');
|
||||
if (n.isNotEmpty && p.isNotEmpty) return '$n $p';
|
||||
if (n.isNotEmpty) return n;
|
||||
if (p.isNotEmpty) return p;
|
||||
return (email ?? '').trim();
|
||||
}
|
||||
|
||||
/// Reformate un libellé famille API (`A & B` / `Famille …`) en `NOM Prénom - …`.
|
||||
String formatDossierFamilyNamesLine(String libelle) {
|
||||
var raw = libelle.trim();
|
||||
if (raw.isEmpty) return '';
|
||||
raw = raw.replaceFirst(RegExp(r'^famille\s+', caseSensitive: false), '');
|
||||
raw = raw
|
||||
.replaceAll(RegExp(r'\s+&\s+'), ' - ')
|
||||
.replaceAll(RegExp(r'\s+et\s+', caseSensitive: false), ' - ');
|
||||
final parts = raw
|
||||
.split(RegExp(r'\s+-\s+'))
|
||||
.map((part) => _formatLoosePersonSegment(part.trim()))
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList();
|
||||
return parts.join(' - ');
|
||||
}
|
||||
|
||||
/// Segment libre type « martin sophie » ou « DURAND Amélie » → `NOM Prénom`.
|
||||
String _formatLoosePersonSegment(String segment) {
|
||||
final words =
|
||||
segment.split(RegExp(r'\s+')).where((w) => w.isNotEmpty).toList();
|
||||
if (words.isEmpty) return '';
|
||||
if (words.length == 1) return words.first.toUpperCase();
|
||||
// Convention affichage : premier mot = NOM, reste = prénom(s).
|
||||
final nom = words.first.toUpperCase();
|
||||
final prenom = formatPersonNameCase(words.sublist(1).join(' '));
|
||||
return '$nom $prenom';
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:p_tits_pas/models/user.dart';
|
||||
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
||||
|
||||
/// Réponse unifiée GET /dossiers/:numeroDossier. Ticket #119, #107.
|
||||
class DossierUnifie {
|
||||
@@ -184,6 +185,7 @@ class EnfantDossier {
|
||||
final String? dueDate;
|
||||
final String? photoUrl;
|
||||
final bool consentPhoto;
|
||||
final bool estMultiple;
|
||||
|
||||
EnfantDossier({
|
||||
required this.id,
|
||||
@@ -195,6 +197,7 @@ class EnfantDossier {
|
||||
this.dueDate,
|
||||
this.photoUrl,
|
||||
this.consentPhoto = false,
|
||||
this.estMultiple = false,
|
||||
});
|
||||
|
||||
String get fullName => '${firstName ?? ''} ${lastName ?? ''}'.trim();
|
||||
@@ -218,11 +221,13 @@ class EnfantDossier {
|
||||
lastName: (json['last_name'] ?? json['nom'])?.toString(),
|
||||
birthDate: json['birth_date']?.toString(),
|
||||
gender: (json['gender'] ?? json['genre'])?.toString(),
|
||||
status: json['status']?.toString(),
|
||||
status: normalizeEnfantStatus(json['status']?.toString()),
|
||||
dueDate: json['due_date']?.toString(),
|
||||
photoUrl: resolvedPhoto,
|
||||
consentPhoto:
|
||||
json['consent_photo'] == true || json['consentPhoto'] == true,
|
||||
estMultiple:
|
||||
json['est_multiple'] == true || json['estMultiple'] == true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import 'package:p_tits_pas/models/user.dart';
|
||||
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
||||
|
||||
/// Enfant tel que renvoyé par `GET /enfants` (dashboard admin).
|
||||
class EnfantAdminModel {
|
||||
final String id;
|
||||
final String? firstName;
|
||||
final String? lastName;
|
||||
final String? gender;
|
||||
final String? birthDate;
|
||||
final String? dueDate;
|
||||
final String status;
|
||||
final String? photoUrl;
|
||||
final bool consentPhoto;
|
||||
final bool isMultiple;
|
||||
final List<EnfantParentLink> parentLinks;
|
||||
/// Flag API #157 (sinon déduit de [parentLinks]).
|
||||
final bool? sansResponsable;
|
||||
|
||||
EnfantAdminModel({
|
||||
required this.id,
|
||||
this.firstName,
|
||||
this.lastName,
|
||||
this.gender,
|
||||
this.birthDate,
|
||||
this.dueDate,
|
||||
required this.status,
|
||||
this.photoUrl,
|
||||
this.consentPhoto = false,
|
||||
this.isMultiple = false,
|
||||
this.parentLinks = const [],
|
||||
this.sansResponsable,
|
||||
});
|
||||
|
||||
String get fullName {
|
||||
final fn = (firstName ?? '').trim();
|
||||
final ln = (lastName ?? '').trim();
|
||||
if (fn.isEmpty && ln.isEmpty) return 'Enfant';
|
||||
if (ln.isEmpty) return fn;
|
||||
return '$fn $ln';
|
||||
}
|
||||
|
||||
/// Aucun lien parent valide — ticket #157.
|
||||
bool get hasNoResponsable {
|
||||
if (sansResponsable != null) return sansResponsable!;
|
||||
return !parentLinks.any((l) => l.parentId.trim().isNotEmpty);
|
||||
}
|
||||
|
||||
EnfantAdminModel copyWith({
|
||||
String? id,
|
||||
String? firstName,
|
||||
String? lastName,
|
||||
String? gender,
|
||||
String? birthDate,
|
||||
String? dueDate,
|
||||
String? status,
|
||||
String? photoUrl,
|
||||
bool? consentPhoto,
|
||||
bool? isMultiple,
|
||||
List<EnfantParentLink>? parentLinks,
|
||||
bool? sansResponsable,
|
||||
}) {
|
||||
return EnfantAdminModel(
|
||||
id: id ?? this.id,
|
||||
firstName: firstName ?? this.firstName,
|
||||
lastName: lastName ?? this.lastName,
|
||||
gender: gender ?? this.gender,
|
||||
birthDate: birthDate ?? this.birthDate,
|
||||
dueDate: dueDate ?? this.dueDate,
|
||||
status: status ?? this.status,
|
||||
photoUrl: photoUrl ?? this.photoUrl,
|
||||
consentPhoto: consentPhoto ?? this.consentPhoto,
|
||||
isMultiple: isMultiple ?? this.isMultiple,
|
||||
parentLinks: parentLinks ?? this.parentLinks,
|
||||
sansResponsable: sansResponsable ?? this.sansResponsable,
|
||||
);
|
||||
}
|
||||
|
||||
factory EnfantAdminModel.fromJson(Map<String, dynamic> json) {
|
||||
final linksRaw = json['parentLinks'] as List?;
|
||||
final links = <EnfantParentLink>[];
|
||||
if (linksRaw != null) {
|
||||
for (final item in linksRaw) {
|
||||
if (item is Map<String, dynamic>) {
|
||||
links.add(EnfantParentLink.fromJson(item));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final photoUrl = json['photo_url'] as String? ?? json['photoUrl'] as String?;
|
||||
final consentPhoto = _parseBool(json['consent_photo']) ||
|
||||
_parseBool(json['consentement_photo']) ||
|
||||
_parseBool(json['consentPhoto']);
|
||||
|
||||
bool? sansResponsable;
|
||||
if (json.containsKey('sans_responsable') ||
|
||||
json.containsKey('sansResponsable')) {
|
||||
sansResponsable = _parseBool(json['sans_responsable']) ||
|
||||
_parseBool(json['sansResponsable']);
|
||||
}
|
||||
|
||||
return EnfantAdminModel(
|
||||
id: (json['id'] ?? '').toString(),
|
||||
firstName: json['first_name'] as String? ?? json['prenom'] as String?,
|
||||
lastName: json['last_name'] as String? ?? json['nom'] as String?,
|
||||
gender: json['gender'] as String? ?? json['genre'] as String?,
|
||||
birthDate: _dateString(json['birth_date'] ?? json['date_naissance']),
|
||||
dueDate: _dateString(json['due_date'] ?? json['date_prevue_naissance']),
|
||||
status: normalizeEnfantStatus(
|
||||
(json['status'] ?? json['statut'])?.toString(),
|
||||
),
|
||||
photoUrl: photoUrl,
|
||||
consentPhoto: consentPhoto,
|
||||
isMultiple: _parseBool(json['is_multiple']) ||
|
||||
_parseBool(json['est_multiple']),
|
||||
parentLinks: links,
|
||||
sansResponsable: sansResponsable,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toUpdateJson() {
|
||||
return {
|
||||
if (firstName != null) 'first_name': firstName,
|
||||
if (lastName != null) 'last_name': lastName,
|
||||
if (gender != null && gender!.isNotEmpty) 'gender': gender,
|
||||
'status': status,
|
||||
if (birthDate != null && birthDate!.isNotEmpty) 'birth_date': birthDate,
|
||||
if (dueDate != null && dueDate!.isNotEmpty) 'due_date': dueDate,
|
||||
'consent_photo': consentPhoto,
|
||||
'is_multiple': isMultiple,
|
||||
};
|
||||
}
|
||||
|
||||
static bool _parseBool(dynamic value) {
|
||||
if (value == true || value == 1) return true;
|
||||
if (value is String) {
|
||||
final s = value.trim().toLowerCase();
|
||||
return s == 'true' || s == '1' || s == 't' || s == 'yes';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static String? _dateString(dynamic v) {
|
||||
if (v == null) return null;
|
||||
if (v is String) return v.split('T').first;
|
||||
return v.toString().split('T').first;
|
||||
}
|
||||
}
|
||||
|
||||
class EnfantParentLink {
|
||||
final String parentId;
|
||||
final String? parentName;
|
||||
|
||||
EnfantParentLink({required this.parentId, this.parentName});
|
||||
|
||||
EnfantParentLink copyWith({String? parentId, String? parentName}) {
|
||||
return EnfantParentLink(
|
||||
parentId: parentId ?? this.parentId,
|
||||
parentName: parentName ?? this.parentName,
|
||||
);
|
||||
}
|
||||
|
||||
factory EnfantParentLink.fromJson(Map<String, dynamic> json) {
|
||||
final parentId =
|
||||
(json['parentId'] ?? json['id_parent'] ?? '').toString();
|
||||
String? name;
|
||||
final parent = json['parent'];
|
||||
if (parent is Map<String, dynamic>) {
|
||||
final user = parent['user'];
|
||||
if (user is Map<String, dynamic>) {
|
||||
final u = AppUser.fromJson(user);
|
||||
name = u.fullName.isNotEmpty ? u.fullName : u.email;
|
||||
} else {
|
||||
// Parfois le parent est aplati (prenom/nom) sans nested user.
|
||||
final prenom = (parent['prenom'] ?? parent['first_name'] ?? '').toString().trim();
|
||||
final nom = (parent['nom'] ?? parent['last_name'] ?? '').toString().trim();
|
||||
final flat = '$prenom $nom'.trim();
|
||||
if (flat.isNotEmpty) name = flat;
|
||||
}
|
||||
}
|
||||
return EnfantParentLink(
|
||||
parentId: parentId,
|
||||
parentName: name,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
||||
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
||||
|
||||
/// Résumé enfant affiché dans la fiche parent (dashboard admin).
|
||||
class ParentChildSummary {
|
||||
final String id;
|
||||
final String? firstName;
|
||||
final String? lastName;
|
||||
final String status;
|
||||
final String? photoUrl;
|
||||
final String? birthDate;
|
||||
final String? dueDate;
|
||||
|
||||
ParentChildSummary({
|
||||
required this.id,
|
||||
this.firstName,
|
||||
this.lastName,
|
||||
required this.status,
|
||||
this.photoUrl,
|
||||
this.birthDate,
|
||||
this.dueDate,
|
||||
});
|
||||
|
||||
String get fullName {
|
||||
final fn = (firstName ?? '').trim();
|
||||
final ln = (lastName ?? '').trim();
|
||||
if (fn.isEmpty && ln.isEmpty) return 'Enfant';
|
||||
if (ln.isEmpty) return fn;
|
||||
return '$fn $ln';
|
||||
}
|
||||
|
||||
factory ParentChildSummary.fromJson(Map<String, dynamic> json) {
|
||||
return ParentChildSummary(
|
||||
id: (json['id'] ?? '').toString(),
|
||||
firstName: (json['first_name'] ?? json['prenom'])?.toString(),
|
||||
lastName: (json['last_name'] ?? json['nom'])?.toString(),
|
||||
status: normalizeEnfantStatus(
|
||||
(json['status'] ?? json['statut'])?.toString(),
|
||||
),
|
||||
photoUrl: json['photo_url']?.toString(),
|
||||
birthDate: _dateString(json['birth_date']),
|
||||
dueDate: _dateString(json['due_date']),
|
||||
);
|
||||
}
|
||||
|
||||
factory ParentChildSummary.fromEnfant(EnfantAdminModel enfant) {
|
||||
return ParentChildSummary(
|
||||
id: enfant.id,
|
||||
firstName: enfant.firstName,
|
||||
lastName: enfant.lastName,
|
||||
status: enfant.status,
|
||||
photoUrl: enfant.photoUrl,
|
||||
birthDate: enfant.birthDate,
|
||||
dueDate: enfant.dueDate,
|
||||
);
|
||||
}
|
||||
|
||||
static String? _dateString(dynamic v) {
|
||||
if (v == null) return null;
|
||||
if (v is String) return v.split('T').first;
|
||||
return v.toString().split('T').first;
|
||||
}
|
||||
|
||||
/// Parse un lien `parentChildren` (objet enfant imbriqué ou id seul).
|
||||
static ParentChildSummary? fromParentChildLink(Map<String, dynamic> link) {
|
||||
final childRaw = link['child'] ?? link['enfant'];
|
||||
if (childRaw is Map) {
|
||||
return ParentChildSummary.fromJson(Map<String, dynamic>.from(childRaw));
|
||||
}
|
||||
|
||||
if (link.containsKey('first_name') ||
|
||||
link.containsKey('prenom') ||
|
||||
link.containsKey('id')) {
|
||||
final id = (link['id'] ?? '').toString();
|
||||
if (id.isNotEmpty &&
|
||||
(link.containsKey('first_name') || link.containsKey('prenom'))) {
|
||||
return ParentChildSummary.fromJson(link);
|
||||
}
|
||||
}
|
||||
|
||||
final enfantId =
|
||||
link['enfantId'] ?? link['id_enfant'] ?? link['enfant_id'];
|
||||
if (enfantId != null && enfantId.toString().isNotEmpty) {
|
||||
return ParentChildSummary(
|
||||
id: enfantId.toString(),
|
||||
status: '',
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,117 @@
|
||||
import 'package:p_tits_pas/models/parent_child_summary.dart';
|
||||
import 'package:p_tits_pas/models/user.dart';
|
||||
|
||||
class ParentModel {
|
||||
final AppUser user;
|
||||
final AppUser? coParent;
|
||||
final int childrenCount;
|
||||
final List<ParentChildSummary> children;
|
||||
|
||||
ParentModel({required this.user, this.childrenCount = 0});
|
||||
ParentModel({
|
||||
required this.user,
|
||||
this.coParent,
|
||||
this.childrenCount = 0,
|
||||
this.children = const [],
|
||||
});
|
||||
|
||||
factory ParentModel.fromJson(Map<String, dynamic> json) {
|
||||
final userJson = json['user'] ?? json;
|
||||
final root = Map<String, dynamic>.from(json);
|
||||
final userJson = Map<String, dynamic>.from(root['user'] ?? root);
|
||||
if (root['numero_dossier'] != null && userJson['numero_dossier'] == null) {
|
||||
userJson['numero_dossier'] = root['numero_dossier'];
|
||||
}
|
||||
final user = AppUser.fromJson(userJson);
|
||||
final children = json['parentChildren'] as List?;
|
||||
|
||||
AppUser? coParent;
|
||||
final coParentRaw = root['co_parent'];
|
||||
if (coParentRaw is Map) {
|
||||
coParent = AppUser.fromJson(Map<String, dynamic>.from(coParentRaw));
|
||||
}
|
||||
|
||||
final children = _parseChildren(root);
|
||||
final links = root['parentChildren'] ?? root['parent_children'];
|
||||
final linkCount = links is List ? links.length : 0;
|
||||
|
||||
return ParentModel(
|
||||
user: user,
|
||||
childrenCount: children?.length ?? 0,
|
||||
coParent: coParent,
|
||||
childrenCount: children.isNotEmpty ? children.length : linkCount,
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
|
||||
static List<ParentChildSummary> _parseChildren(Map<String, dynamic> json) {
|
||||
final children = <ParentChildSummary>[];
|
||||
final seen = <String>{};
|
||||
|
||||
void add(ParentChildSummary? child) {
|
||||
if (child == null || child.id.isEmpty || seen.contains(child.id)) return;
|
||||
seen.add(child.id);
|
||||
children.add(child);
|
||||
}
|
||||
|
||||
final links = json['parentChildren'] ?? json['parent_children'];
|
||||
if (links is List) {
|
||||
for (final link in links) {
|
||||
if (link is! Map) continue;
|
||||
add(ParentChildSummary.fromParentChildLink(
|
||||
Map<String, dynamic>.from(link),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
/// Nombre d’enfants distincts du foyer (ce parent + co-parent / même dossier).
|
||||
/// Évite Claire=7 / Thomas=6 quand un lien n’est que sur un des deux (#157).
|
||||
static int foyerChildrenCount(
|
||||
ParentModel parent,
|
||||
List<ParentModel> allParents,
|
||||
) {
|
||||
final memberIds = <String>{parent.user.id};
|
||||
final coId = parent.coParent?.id.trim();
|
||||
if (coId != null && coId.isNotEmpty) memberIds.add(coId);
|
||||
|
||||
final dossier = (parent.user.numeroDossier ?? '').trim();
|
||||
|
||||
for (final other in allParents) {
|
||||
if (memberIds.contains(other.user.id)) continue;
|
||||
final otherCo = other.coParent?.id.trim();
|
||||
if (otherCo != null && memberIds.contains(otherCo)) {
|
||||
memberIds.add(other.user.id);
|
||||
continue;
|
||||
}
|
||||
if (dossier.isNotEmpty &&
|
||||
(other.user.numeroDossier ?? '').trim() == dossier) {
|
||||
memberIds.add(other.user.id);
|
||||
final oc = other.coParent?.id.trim();
|
||||
if (oc != null && oc.isNotEmpty) memberIds.add(oc);
|
||||
}
|
||||
}
|
||||
|
||||
final childIds = <String>{};
|
||||
for (final p in allParents) {
|
||||
if (!memberIds.contains(p.user.id)) continue;
|
||||
for (final c in p.children) {
|
||||
final id = c.id.trim();
|
||||
if (id.isNotEmpty) childIds.add(id);
|
||||
}
|
||||
// Repli si la liste enfants n’est pas hydratée.
|
||||
if (p.children.isEmpty && p.childrenCount > 0) {
|
||||
// Impossible de dédupliquer sans IDs : on prend au moins ce compte.
|
||||
// (évite d’afficher 0 si l’API n’envoie que childrenCount)
|
||||
}
|
||||
}
|
||||
|
||||
if (childIds.isNotEmpty) return childIds.length;
|
||||
|
||||
var maxCount = 0;
|
||||
for (final p in allParents) {
|
||||
if (!memberIds.contains(p.user.id)) continue;
|
||||
final n = p.children.isNotEmpty ? p.children.length : p.childrenCount;
|
||||
if (n > maxCount) maxCount = n;
|
||||
}
|
||||
return maxCount;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import 'package:p_tits_pas/models/dossier_unifie.dart';
|
||||
import 'package:p_tits_pas/utils/reprise_mapper.dart';
|
||||
|
||||
/// Réponse GET /auth/reprise-dossier. Tickets #111, #112.
|
||||
class RepriseDossier {
|
||||
final String id;
|
||||
final String email;
|
||||
final String? prenom;
|
||||
final String? nom;
|
||||
final String? telephone;
|
||||
final String? adresse;
|
||||
final String? ville;
|
||||
final String? codePostal;
|
||||
final String? numeroDossier;
|
||||
final String role;
|
||||
final String? photoUrl;
|
||||
final String? genre;
|
||||
final String? situationFamiliale;
|
||||
|
||||
final List<ParentDossier> parents;
|
||||
final List<EnfantDossier> enfants;
|
||||
final String? texteMotivation;
|
||||
|
||||
final bool consentementPhoto;
|
||||
final String? dateNaissance;
|
||||
final String? lieuNaissanceVille;
|
||||
final String? lieuNaissancePays;
|
||||
final String? numeroAgrement;
|
||||
final String? nir;
|
||||
final String? dateAgrement;
|
||||
final int? nbMaxEnfants;
|
||||
final int? placeDisponible;
|
||||
final String? biographie;
|
||||
|
||||
const RepriseDossier({
|
||||
required this.id,
|
||||
required this.email,
|
||||
this.prenom,
|
||||
this.nom,
|
||||
this.telephone,
|
||||
this.adresse,
|
||||
this.ville,
|
||||
this.codePostal,
|
||||
this.numeroDossier,
|
||||
required this.role,
|
||||
this.photoUrl,
|
||||
this.genre,
|
||||
this.situationFamiliale,
|
||||
this.parents = const [],
|
||||
this.enfants = const [],
|
||||
this.texteMotivation,
|
||||
this.consentementPhoto = false,
|
||||
this.dateNaissance,
|
||||
this.lieuNaissanceVille,
|
||||
this.lieuNaissancePays,
|
||||
this.numeroAgrement,
|
||||
this.nir,
|
||||
this.dateAgrement,
|
||||
this.nbMaxEnfants,
|
||||
this.placeDisponible,
|
||||
this.biographie,
|
||||
});
|
||||
|
||||
bool get isParent => role == 'parent';
|
||||
bool get isAm => role == 'assistante_maternelle';
|
||||
|
||||
static Map<String, dynamic>? _nestedMap(dynamic value) {
|
||||
if (value is Map) return Map<String, dynamic>.from(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
static dynamic _firstNonNull(List<dynamic> values) {
|
||||
for (final v in values) {
|
||||
if (v != null) return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
factory RepriseDossier.fromJson(Map<String, dynamic> json) {
|
||||
final nestedUser = _nestedMap(json['user']);
|
||||
final nestedDossier = _nestedMap(json['dossier']);
|
||||
|
||||
final parentsRaw = json['parents'];
|
||||
final parentsList = parentsRaw is List
|
||||
? parentsRaw
|
||||
.where((e) => e is Map)
|
||||
.map((e) => ParentDossier.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||
.toList()
|
||||
: <ParentDossier>[];
|
||||
|
||||
final enfantsRaw = json['enfants'];
|
||||
final enfantsList = enfantsRaw is List
|
||||
? enfantsRaw
|
||||
.where((e) => e is Map)
|
||||
.map((e) => EnfantDossier.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||
.toList()
|
||||
: <EnfantDossier>[];
|
||||
|
||||
return RepriseDossier(
|
||||
id: json['id']?.toString() ?? '',
|
||||
email: json['email']?.toString() ?? '',
|
||||
prenom: json['prenom']?.toString(),
|
||||
nom: json['nom']?.toString(),
|
||||
telephone: json['telephone']?.toString(),
|
||||
adresse: json['adresse']?.toString(),
|
||||
ville: json['ville']?.toString(),
|
||||
codePostal: json['code_postal']?.toString(),
|
||||
numeroDossier: json['numero_dossier']?.toString(),
|
||||
role: json['role']?.toString() ?? '',
|
||||
photoUrl: json['photo_url']?.toString(),
|
||||
genre: json['genre']?.toString(),
|
||||
situationFamiliale: json['situation_familiale']?.toString(),
|
||||
parents: parentsList,
|
||||
enfants: enfantsList,
|
||||
texteMotivation: (json['texte_motivation'] ?? json['presentation_dossier'])
|
||||
?.toString(),
|
||||
consentementPhoto: RepriseMapper.optionalBool(json['consentement_photo']) ||
|
||||
RepriseMapper.optionalBool(json['consent_photo']) ||
|
||||
RepriseMapper.optionalBool(nestedUser?['consentement_photo']),
|
||||
dateNaissance: RepriseMapper.optionalDateString(
|
||||
_firstNonNull([
|
||||
json['date_naissance'],
|
||||
json['dateNaissance'],
|
||||
nestedUser?['date_naissance'],
|
||||
nestedUser?['dateNaissance'],
|
||||
]),
|
||||
),
|
||||
lieuNaissanceVille: _firstNonNull([
|
||||
json['lieu_naissance_ville'],
|
||||
json['lieuNaissanceVille'],
|
||||
nestedUser?['lieu_naissance_ville'],
|
||||
nestedUser?['lieuNaissanceVille'],
|
||||
])?.toString(),
|
||||
lieuNaissancePays: _firstNonNull([
|
||||
json['lieu_naissance_pays'],
|
||||
json['lieuNaissancePays'],
|
||||
nestedUser?['lieu_naissance_pays'],
|
||||
nestedUser?['lieuNaissancePays'],
|
||||
])?.toString(),
|
||||
numeroAgrement: _firstNonNull([
|
||||
json['numero_agrement'],
|
||||
json['numero_agrement_am'],
|
||||
json['numeroAgrement'],
|
||||
nestedDossier?['numero_agrement'],
|
||||
nestedDossier?['numeroAgrement'],
|
||||
])?.toString(),
|
||||
nir: _firstNonNull([
|
||||
json['nir'],
|
||||
nestedDossier?['nir'],
|
||||
])?.toString(),
|
||||
dateAgrement: RepriseMapper.optionalDateString(
|
||||
_firstNonNull([
|
||||
json['date_agrement'],
|
||||
json['dateAgrement'],
|
||||
nestedDossier?['date_agrement'],
|
||||
nestedDossier?['dateAgrement'],
|
||||
]),
|
||||
),
|
||||
nbMaxEnfants: RepriseMapper.optionalInt(
|
||||
_firstNonNull([
|
||||
json['nb_max_enfants'],
|
||||
json['capacite_accueil'],
|
||||
json['nbMaxEnfants'],
|
||||
nestedDossier?['nb_max_enfants'],
|
||||
nestedDossier?['capacite_accueil'],
|
||||
]),
|
||||
),
|
||||
placeDisponible: RepriseMapper.optionalInt(
|
||||
_firstNonNull([
|
||||
json['place_disponible'],
|
||||
json['places_disponibles'],
|
||||
json['placesDisponibles'],
|
||||
nestedDossier?['place_disponible'],
|
||||
nestedDossier?['places_disponibles'],
|
||||
]),
|
||||
),
|
||||
biographie: (json['biographie'] ?? json['presentation'])?.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ class ParentData {
|
||||
class ChildData {
|
||||
static const Object _unsetImage = Object();
|
||||
static const Object _unsetImageBytes = Object();
|
||||
static const Object _unsetExistingPhotoUrl = Object();
|
||||
|
||||
String firstName;
|
||||
String lastName;
|
||||
@@ -43,6 +44,10 @@ class ChildData {
|
||||
/// Octets de la photo (fiable à l’envoi API ; [imageFile] peut être absent sur le web).
|
||||
Uint8List? imageBytes;
|
||||
CardColorVertical cardColor; // Nouveau champ pour la couleur de la carte
|
||||
/// UUID enfant en base (reprise #112) — requis pour PATCH reprise-resoumettre.
|
||||
String? repriseChildId;
|
||||
/// Photo déjà stockée (affichage reprise sans re-upload).
|
||||
String? existingPhotoUrl;
|
||||
|
||||
ChildData({
|
||||
this.firstName = '',
|
||||
@@ -55,6 +60,8 @@ class ChildData {
|
||||
this.imageFile,
|
||||
this.imageBytes,
|
||||
required this.cardColor, // Rendre requis dans le constructeur
|
||||
this.repriseChildId,
|
||||
this.existingPhotoUrl,
|
||||
});
|
||||
|
||||
ChildData copyWith({
|
||||
@@ -68,6 +75,8 @@ class ChildData {
|
||||
Object? imageFile = _unsetImage,
|
||||
Object? imageBytes = _unsetImageBytes,
|
||||
CardColorVertical? cardColor,
|
||||
String? repriseChildId,
|
||||
Object? existingPhotoUrl = _unsetExistingPhotoUrl,
|
||||
}) {
|
||||
return ChildData(
|
||||
firstName: firstName ?? this.firstName,
|
||||
@@ -81,6 +90,10 @@ class ChildData {
|
||||
imageBytes:
|
||||
identical(imageBytes, _unsetImageBytes) ? this.imageBytes : imageBytes as Uint8List?,
|
||||
cardColor: cardColor ?? this.cardColor,
|
||||
repriseChildId: repriseChildId ?? this.repriseChildId,
|
||||
existingPhotoUrl: identical(existingPhotoUrl, _unsetExistingPhotoUrl)
|
||||
? this.existingPhotoUrl
|
||||
: existingPhotoUrl as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -178,6 +191,38 @@ class UserRegistrationData extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Reprise après refus (#112) : réinitialise le flux parent avec les données dossier.
|
||||
void resetForReprise({
|
||||
required ParentData parent1Data,
|
||||
ParentData? parent2Data,
|
||||
List<ChildData>? childrenData,
|
||||
String motivation = '',
|
||||
}) {
|
||||
parent1 = parent1Data;
|
||||
parent2 = parent2Data;
|
||||
children
|
||||
..clear()
|
||||
..addAll(childrenData ?? const []);
|
||||
motivationText = motivation;
|
||||
cguAccepted = false;
|
||||
bankDetails = null;
|
||||
attestationCafNumber = '';
|
||||
consentQuotientFamilial = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Reprise (#112) : coordonnées + enfants connus + CGU.
|
||||
bool get isRepriseSubmitReady =>
|
||||
parent1.firstName.isNotEmpty &&
|
||||
parent1.lastName.isNotEmpty &&
|
||||
parent1.email.isNotEmpty &&
|
||||
children.isNotEmpty &&
|
||||
children.every(
|
||||
(c) =>
|
||||
c.repriseChildId != null && c.repriseChildId!.trim().isNotEmpty,
|
||||
) &&
|
||||
cguAccepted;
|
||||
|
||||
// Méthode pour vérifier si toutes les données requises sont là (simplifié)
|
||||
bool isRegistrationComplete() {
|
||||
// Ajouter ici les validations nécessaires
|
||||
|
||||
@@ -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<AdminUserFormDialog> {
|
||||
bool _isLoadingRelais = true;
|
||||
List<RelaisModel> _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<AdminUserFormDialog> {
|
||||
} else {
|
||||
_isLoadingRelais = false;
|
||||
}
|
||||
_loadCurrentUserId();
|
||||
}
|
||||
|
||||
Future<void> _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
|
||||
@@ -122,6 +146,21 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Fallback si GET /relais échoue : conserve le relais déjà connu sur l'utilisateur.
|
||||
List<RelaisModel> _fallbackRelaisFromUser() {
|
||||
final id = _selectedRelaisId?.trim();
|
||||
if (id == null || id.isEmpty) return const [];
|
||||
final nom = (widget.initialUser?.relaisNom ?? '').trim();
|
||||
return [
|
||||
RelaisModel(
|
||||
id: id,
|
||||
nom: nom.isNotEmpty ? nom : 'Relais actuel',
|
||||
adresse: '',
|
||||
actif: true,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> _loadRelais() async {
|
||||
try {
|
||||
final list = await RelaisService.getRelais();
|
||||
@@ -138,7 +177,8 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
if (selected != null) {
|
||||
filtered.add(selected);
|
||||
} else {
|
||||
_selectedRelaisId = null;
|
||||
// Garder l'id sélectionné et afficher un item de secours (nom carte).
|
||||
filtered.addAll(_fallbackRelaisFromUser());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,9 +188,9 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
});
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
// Ne pas nullifier _selectedRelaisId (#151) — la carte a déjà le bon libellé.
|
||||
setState(() {
|
||||
_selectedRelaisId = null;
|
||||
_relais = [];
|
||||
_relais = _fallbackRelaisFromUser();
|
||||
_isLoadingRelais = false;
|
||||
});
|
||||
}
|
||||
@@ -335,7 +375,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
|
||||
Future<void> _delete() async {
|
||||
if (widget.readOnly) return;
|
||||
if (_isSuperAdminTarget) return;
|
||||
if (!_canDeleteTarget) return;
|
||||
if (!_isEditMode || _isSubmitting) return;
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
@@ -462,7 +502,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
] else if (_isEditMode) ...[
|
||||
if (!_isSuperAdminTarget)
|
||||
if (_canDeleteTarget)
|
||||
OutlinedButton(
|
||||
onPressed: _isSubmitting ? null : _delete,
|
||||
style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700),
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../models/am_registration_data.dart';
|
||||
import '../../widgets/personal_info_form_screen.dart';
|
||||
import '../../services/reprise_session.dart';
|
||||
import '../../models/card_assets.dart';
|
||||
|
||||
class AmRegisterStep1Screen extends StatelessWidget {
|
||||
@@ -29,7 +30,7 @@ class AmRegisterStep1Screen extends StatelessWidget {
|
||||
cardColor: CardColorHorizontal.blue,
|
||||
initialData: initialData,
|
||||
minPersonNameLength: 2,
|
||||
previousRoute: '/register-choice',
|
||||
previousRoute: RepriseSession.isActive ? '/login' : '/register-choice',
|
||||
onSubmit: (data, {hasSecondPerson, sameAddress}) {
|
||||
registrationData.updateIdentityInfo(
|
||||
firstName: data.firstName,
|
||||
|
||||
@@ -11,45 +11,53 @@ class AmRegisterStep2Screen extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final registrationData = Provider.of<AmRegistrationData>(context, listen: false);
|
||||
return Consumer<AmRegistrationData>(
|
||||
builder: (context, registrationData, _) {
|
||||
final initialData = ProfessionalInfoData(
|
||||
photoPath: registrationData.photoPath,
|
||||
photoBytes: registrationData.photoBytes,
|
||||
photoFilename: registrationData.photoFilename,
|
||||
photoConsent: registrationData.photoConsent,
|
||||
dateOfBirth: registrationData.dateOfBirth,
|
||||
birthCity: registrationData.birthCity,
|
||||
birthCountry: registrationData.birthCountry,
|
||||
nir: registrationData.nir,
|
||||
agrementNumber: registrationData.agrementNumber,
|
||||
agreementDate: registrationData.agreementDate,
|
||||
capacity: registrationData.capacity,
|
||||
placesAvailable: registrationData.placesAvailable,
|
||||
);
|
||||
|
||||
final initialData = ProfessionalInfoData(
|
||||
photoPath: registrationData.photoPath,
|
||||
photoBytes: registrationData.photoBytes,
|
||||
photoFilename: registrationData.photoFilename,
|
||||
photoConsent: registrationData.photoConsent,
|
||||
dateOfBirth: registrationData.dateOfBirth,
|
||||
birthCity: registrationData.birthCity,
|
||||
birthCountry: registrationData.birthCountry,
|
||||
nir: registrationData.nir,
|
||||
agrementNumber: registrationData.agrementNumber,
|
||||
agreementDate: registrationData.agreementDate,
|
||||
capacity: registrationData.capacity,
|
||||
placesAvailable: registrationData.placesAvailable,
|
||||
);
|
||||
|
||||
return ProfessionalInfoFormScreen(
|
||||
stepText: 'Étape 2/4',
|
||||
title: 'Vos informations professionnelles',
|
||||
cardColor: CardColorHorizontal.green,
|
||||
initialData: initialData,
|
||||
previousRoute: '/am-register-step1',
|
||||
onSubmit: (data) {
|
||||
registrationData.updateProfessionalInfo(
|
||||
photoPath: data.photoPath,
|
||||
photoBytes: data.photoBytes,
|
||||
photoFilename: data.photoFilename,
|
||||
photoConsent: data.photoConsent,
|
||||
dateOfBirth: data.dateOfBirth,
|
||||
birthCity: data.birthCity,
|
||||
birthCountry: data.birthCountry,
|
||||
nir: data.nir,
|
||||
agrementNumber: data.agrementNumber,
|
||||
agreementDate: data.agreementDate,
|
||||
capacity: data.capacity,
|
||||
placesAvailable: data.placesAvailable,
|
||||
return ProfessionalInfoFormScreen(
|
||||
key: ValueKey(
|
||||
'am-pro-${registrationData.dateOfBirth?.millisecondsSinceEpoch}'
|
||||
'-${registrationData.agreementDate?.millisecondsSinceEpoch}'
|
||||
'-${registrationData.placesAvailable}'
|
||||
'-${registrationData.nir}',
|
||||
),
|
||||
stepText: 'Étape 2/4',
|
||||
title: 'Vos informations professionnelles',
|
||||
cardColor: CardColorHorizontal.green,
|
||||
initialData: initialData,
|
||||
previousRoute: '/am-register-step1',
|
||||
onSubmit: (data) {
|
||||
registrationData.updateProfessionalInfo(
|
||||
photoPath: data.photoPath,
|
||||
photoBytes: data.photoBytes,
|
||||
photoFilename: data.photoFilename,
|
||||
photoConsent: data.photoConsent,
|
||||
dateOfBirth: data.dateOfBirth,
|
||||
birthCity: data.birthCity,
|
||||
birthCountry: data.birthCountry,
|
||||
nir: data.nir,
|
||||
agrementNumber: data.agrementNumber,
|
||||
agreementDate: data.agreementDate,
|
||||
capacity: data.capacity,
|
||||
placesAvailable: data.placesAvailable,
|
||||
);
|
||||
context.go('/am-register-step3');
|
||||
},
|
||||
);
|
||||
context.go('/am-register-step3');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import '../../models/am_registration_data.dart';
|
||||
import '../../models/card_assets.dart';
|
||||
import '../../config/display_config.dart';
|
||||
import '../../services/auth_service.dart';
|
||||
import '../../services/reprise_session.dart';
|
||||
import '../../utils/reprise_payload.dart';
|
||||
import '../../widgets/hover_relief_widget.dart';
|
||||
import '../../widgets/image_button.dart';
|
||||
import '../../widgets/custom_navigation_button.dart';
|
||||
@@ -27,7 +29,21 @@ class _AmRegisterStep4ScreenState extends State<AmRegisterStep4Screen> {
|
||||
|
||||
Future<void> _submitAMRegistration(AmRegistrationData registrationData) async {
|
||||
if (_isSubmitting) return;
|
||||
if (!registrationData.isRegistrationComplete) {
|
||||
if (RepriseSession.isActive) {
|
||||
if (!registrationData.isRepriseSubmitReady) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Vérifiez vos coordonnées et acceptez les conditions.',
|
||||
style: GoogleFonts.merienda(fontSize: 14),
|
||||
),
|
||||
backgroundColor: Colors.red.shade700,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else if (!registrationData.isRegistrationComplete) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
@@ -42,6 +58,19 @@ class _AmRegisterStep4ScreenState extends State<AmRegisterStep4Screen> {
|
||||
}
|
||||
setState(() => _isSubmitting = true);
|
||||
try {
|
||||
if (RepriseSession.isActive) {
|
||||
await AuthService.resoumettreReprise(
|
||||
await ReprisePayload.amPatch(
|
||||
registrationData,
|
||||
RepriseSession.token!,
|
||||
existingPhotoUrl: RepriseSession.photoUrlForApi,
|
||||
),
|
||||
);
|
||||
RepriseSession.clear();
|
||||
if (!mounted) return;
|
||||
_showRepriseConfirmationModal(context);
|
||||
return;
|
||||
}
|
||||
await AuthService.registerAM(registrationData);
|
||||
if (!mounted) return;
|
||||
_showConfirmationModal(context);
|
||||
@@ -244,6 +273,34 @@ class _AmRegisterStep4ScreenState extends State<AmRegisterStep4Screen> {
|
||||
);
|
||||
}
|
||||
|
||||
void _showRepriseConfirmationModal(BuildContext context) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext dialogContext) {
|
||||
return AlertDialog(
|
||||
title: Text(
|
||||
'Dossier resoumis',
|
||||
style: GoogleFonts.merienda(fontWeight: FontWeight.bold),
|
||||
),
|
||||
content: Text(
|
||||
'Vos modifications ont été enregistrées. Votre dossier est de nouveau en attente de validation.',
|
||||
style: GoogleFonts.merienda(fontSize: 14),
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
child: Text('OK', style: GoogleFonts.merienda(fontWeight: FontWeight.bold)),
|
||||
onPressed: () {
|
||||
Navigator.of(dialogContext).pop();
|
||||
context.go('/login');
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showConfirmationModal(BuildContext context) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user