Compare commits
79
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
514d19236d | ||
|
|
01fb514bde | ||
|
|
c8cb82dd24 | ||
|
|
9cd180bf6a | ||
|
|
ca07d2e111 | ||
|
|
67336c64fe | ||
|
|
97afbbcf9a | ||
|
|
e235b30140 | ||
|
|
7f23356273 | ||
|
|
c8c9cfbc4d | ||
|
|
530e896b66 | ||
|
|
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 | ||
|
|
b99745e0fe |
@@ -89,6 +89,9 @@
|
|||||||
"transform": {
|
"transform": {
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
},
|
},
|
||||||
|
"moduleNameMapper": {
|
||||||
|
"^src/(.*)$": "<rootDir>/$1"
|
||||||
|
},
|
||||||
"collectCoverageFrom": [
|
"collectCoverageFrom": [
|
||||||
"**/*.(t|j)s"
|
"**/*.(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();
|
||||||
@@ -18,6 +18,7 @@ import { AppConfigModule } from './modules/config/config.module';
|
|||||||
import { DocumentsLegauxModule } from './modules/documents-legaux';
|
import { DocumentsLegauxModule } from './modules/documents-legaux';
|
||||||
import { RelaisModule } from './routes/relais/relais.module';
|
import { RelaisModule } from './routes/relais/relais.module';
|
||||||
import { DossiersModule } from './routes/dossiers/dossiers.module';
|
import { DossiersModule } from './routes/dossiers/dossiers.module';
|
||||||
|
import { SuppressionsModule } from './routes/suppressions/suppressions.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -57,6 +58,7 @@ import { DossiersModule } from './routes/dossiers/dossiers.module';
|
|||||||
DocumentsLegauxModule,
|
DocumentsLegauxModule,
|
||||||
RelaisModule,
|
RelaisModule,
|
||||||
DossiersModule,
|
DossiersModule,
|
||||||
|
SuppressionsModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { sanitizeUserForApi } from './sanitize-user-for-api';
|
||||||
|
import { RoleType, StatutUtilisateurType, Users } from '../../entities/users.entity';
|
||||||
|
|
||||||
|
describe('sanitizeUserForApi', () => {
|
||||||
|
const base: Users = {
|
||||||
|
id: 'u1',
|
||||||
|
email: 'a@b.fr',
|
||||||
|
prenom: 'Paul',
|
||||||
|
nom: 'Parent',
|
||||||
|
role: RoleType.PARENT,
|
||||||
|
statut: StatutUtilisateurType.ACTIF,
|
||||||
|
password: 'hash',
|
||||||
|
token_creation_mdp: 'tok',
|
||||||
|
token_creation_mdp_expire_le: new Date(),
|
||||||
|
password_reset_token: 'rst',
|
||||||
|
password_reset_expires: new Date(),
|
||||||
|
} as Users;
|
||||||
|
|
||||||
|
it('retire password et tokens', () => {
|
||||||
|
const out = sanitizeUserForApi(base)!;
|
||||||
|
expect(out.prenom).toBe('Paul');
|
||||||
|
expect(out.nom).toBe('Parent');
|
||||||
|
expect(out.password).toBeUndefined();
|
||||||
|
expect(out.token_creation_mdp).toBeUndefined();
|
||||||
|
expect(out.password_reset_token).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retourne undefined si user absent', () => {
|
||||||
|
expect(sanitizeUserForApi(null)).toBeUndefined();
|
||||||
|
expect(sanitizeUserForApi(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Users } from 'src/entities/users.entity';
|
||||||
|
|
||||||
|
/** Champs sensibles exclus des réponses API (ticket #131 — user / co_parent). */
|
||||||
|
const SENSITIVE_USER_KEYS: (keyof Users)[] = [
|
||||||
|
'password',
|
||||||
|
'token_creation_mdp',
|
||||||
|
'token_creation_mdp_expire_le',
|
||||||
|
'password_reset_token',
|
||||||
|
'password_reset_expires',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne une copie utilisateur sans secrets (hash MDP, tokens).
|
||||||
|
* Utilisé pour `user` et `co_parent` dans les réponses Parents.
|
||||||
|
*/
|
||||||
|
export function sanitizeUserForApi(user?: Users | null): Users | undefined {
|
||||||
|
if (!user) return undefined;
|
||||||
|
const safe = { ...user } as Users;
|
||||||
|
for (const key of SENSITIVE_USER_KEYS) {
|
||||||
|
delete safe[key];
|
||||||
|
}
|
||||||
|
return safe;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
CreateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { AssistanteMaternelle } from './assistantes_maternelles.entity';
|
||||||
|
import { Children } from './children.entity';
|
||||||
|
import { Users } from './users.entity';
|
||||||
|
|
||||||
|
@Entity('enfants_assistantes_maternelles', { schema: 'public' })
|
||||||
|
export class AmChildren {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'id_am', type: 'uuid' })
|
||||||
|
amId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'id_enfant', type: 'uuid' })
|
||||||
|
enfantId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'date_debut', type: 'date' })
|
||||||
|
date_debut: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'date_fin', type: 'date', nullable: true })
|
||||||
|
date_fin?: Date;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
|
||||||
|
cree_le: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'cree_par', type: 'uuid', nullable: true })
|
||||||
|
cree_par?: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => AssistanteMaternelle, (am) => am.amChildren, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'id_am', referencedColumnName: 'user_id' })
|
||||||
|
am: AssistanteMaternelle;
|
||||||
|
|
||||||
|
@ManyToOne(() => Children, (c) => c.amLinks, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'id_enfant', referencedColumnName: 'id' })
|
||||||
|
child: Children;
|
||||||
|
|
||||||
|
@ManyToOne(() => Users, { nullable: true, onDelete: 'SET NULL' })
|
||||||
|
@JoinColumn({ name: 'cree_par', referencedColumnName: 'id' })
|
||||||
|
createdBy?: Users;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Entity, PrimaryColumn, Column, OneToOne, JoinColumn } from 'typeorm';
|
import { Entity, PrimaryColumn, Column, OneToOne, OneToMany, JoinColumn } from 'typeorm';
|
||||||
import { Users } from './users.entity';
|
import { Users } from './users.entity';
|
||||||
|
import { AmChildren } from './am_children.entity';
|
||||||
|
|
||||||
@Entity('assistantes_maternelles')
|
@Entity('assistantes_maternelles')
|
||||||
export class AssistanteMaternelle {
|
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) */
|
/** Numéro de dossier (format AAAA-NNNNNN), même valeur que sur utilisateurs (ticket #103) */
|
||||||
@Column({ name: 'numero_dossier', length: 20, nullable: true })
|
@Column({ name: 'numero_dossier', length: 20, nullable: true })
|
||||||
numero_dossier?: string;
|
numero_dossier?: string;
|
||||||
|
|
||||||
|
@OneToMany(() => AmChildren, (ac) => ac.am)
|
||||||
|
amChildren: AmChildren[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import {
|
|||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { Parents } from './parents.entity';
|
import { Parents } from './parents.entity';
|
||||||
import { ParentsChildren } from './parents_children.entity';
|
import { ParentsChildren } from './parents_children.entity';
|
||||||
|
import { AmChildren } from './am_children.entity';
|
||||||
import { Dossier } from './dossiers.entity';
|
import { Dossier } from './dossiers.entity';
|
||||||
|
|
||||||
export enum StatutEnfantType {
|
export enum StatutEnfantType {
|
||||||
A_NAITRE = 'a_naitre',
|
A_NAITRE = 'a_naitre',
|
||||||
ACTIF = 'actif',
|
|
||||||
SCOLARISE = 'scolarise',
|
SCOLARISE = 'scolarise',
|
||||||
|
GARDE = 'garde',
|
||||||
|
SANS_GARDE = 'sans_garde',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum GenreType {
|
export enum GenreType {
|
||||||
@@ -68,6 +70,9 @@ export class Children {
|
|||||||
@OneToMany(() => ParentsChildren, pc => pc.child)
|
@OneToMany(() => ParentsChildren, pc => pc.child)
|
||||||
parentLinks: ParentsChildren[];
|
parentLinks: ParentsChildren[];
|
||||||
|
|
||||||
|
@OneToMany(() => AmChildren, (ac) => ac.child)
|
||||||
|
amLinks: AmChildren[];
|
||||||
|
|
||||||
// Relation avec Dossier
|
// Relation avec Dossier
|
||||||
@OneToMany(() => Dossier, d => d.child)
|
@OneToMany(() => Dossier, d => d.child)
|
||||||
dossiers: Dossier[];
|
dossiers: Dossier[];
|
||||||
|
|||||||
+51
-2
@@ -1,20 +1,69 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { AssistantesMaternellesController } from './assistantes_maternelles.controller';
|
import { AssistantesMaternellesController } from './assistantes_maternelles.controller';
|
||||||
import { AssistantesMaternellesService } from './assistantes_maternelles.service';
|
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', () => {
|
describe('AssistantesMaternellesController', () => {
|
||||||
let controller: AssistantesMaternellesController;
|
let controller: AssistantesMaternellesController;
|
||||||
|
const authServiceMock = {
|
||||||
|
createAmDossierStaff: jest.fn(),
|
||||||
|
};
|
||||||
|
const amServiceMock = {};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
controllers: [AssistantesMaternellesController],
|
controllers: [AssistantesMaternellesController],
|
||||||
providers: [AssistantesMaternellesService],
|
providers: [
|
||||||
}).compile();
|
{ provide: AssistantesMaternellesService, useValue: amServiceMock },
|
||||||
|
{ provide: AuthService, useValue: authServiceMock },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.overrideGuard(AuthGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
|
.overrideGuard(RolesGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
|
.compile();
|
||||||
|
|
||||||
controller = module.get<AssistantesMaternellesController>(AssistantesMaternellesController);
|
controller = module.get<AssistantesMaternellesController>(AssistantesMaternellesController);
|
||||||
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
expect(controller).toBeDefined();
|
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,
|
Param,
|
||||||
Delete,
|
Delete,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AssistantesMaternellesService } from './assistantes_maternelles.service';
|
import { AssistantesMaternellesService } from './assistantes_maternelles.service';
|
||||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||||
import { Roles } from 'src/common/decorators/roles.decorator';
|
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 { CreateAssistanteDto } from '../user/dto/create_assistante.dto';
|
||||||
import { UpdateAssistanteDto } from '../user/dto/update_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 { RolesGuard } from 'src/common/guards/roles.guard';
|
||||||
import { AuthGuard } from 'src/common/guards/auth.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")
|
@ApiTags("Assistantes Maternelles")
|
||||||
@ApiBearerAuth('access-token')
|
@ApiBearerAuth('access-token')
|
||||||
@UseGuards(AuthGuard, RolesGuard)
|
@UseGuards(AuthGuard, RolesGuard)
|
||||||
@Controller('assistantes-maternelles')
|
@Controller('assistantes-maternelles')
|
||||||
export class AssistantesMaternellesController {
|
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)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
|
||||||
@ApiOperation({ summary: 'Créer nounou' })
|
@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' })
|
@ApiResponse({ status: 403, description: 'Accès refusé : Réservé aux super_admins et gestionnaires' })
|
||||||
@ApiBody({ type: CreateAssistanteDto })
|
@ApiBody({ type: CreateAssistanteDto })
|
||||||
@Post()
|
@Post()
|
||||||
create(@Body() dto: CreateAssistanteDto): Promise<AssistanteMaternelle> {
|
async create(@Body() dto: CreateAssistanteDto): Promise<AssistanteMaternelle> {
|
||||||
return this.assistantesMaternellesService.create(dto);
|
const am = await this.assistantesMaternellesService.create(dto);
|
||||||
|
return mapAmForApi(am);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||||
@Get()
|
@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: 200, description: 'Liste des nounous' })
|
||||||
@ApiResponse({ status: 403, description: 'Accès refusé : Réservé aux super_admins et gestionnaires' })
|
@ApiResponse({ status: 403, description: 'Accès refusé : Réservé aux super_admins et gestionnaires' })
|
||||||
getAll(): Promise<AssistanteMaternelle[]> {
|
async getAll(): Promise<AssistanteMaternelle[]> {
|
||||||
return this.assistantesMaternellesService.findAll();
|
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')
|
@Get(':id')
|
||||||
@ApiParam({ name: 'id', description: "UUID de la nounou" })
|
@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: 200, description: 'Détails de la nounou' })
|
||||||
@ApiResponse({ status: 404, description: 'Nounou non trouvée' })
|
@ApiResponse({ status: 404, description: 'Nounou non trouvée' })
|
||||||
@ApiResponse({ status: 403, description: 'Accès refusé : Réservé aux super_admins et gestionnaires' })
|
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
||||||
getOne(@Param('id') user_id: string): Promise<AssistanteMaternelle> {
|
async getOne(@Param('id') user_id: string): Promise<AssistanteMaternelle> {
|
||||||
return this.assistantesMaternellesService.findOne(user_id);
|
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)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
|
||||||
@@ -63,14 +146,15 @@ export class AssistantesMaternellesController {
|
|||||||
@ApiResponse({ status: 404, description: 'Nounou non trouvée' })
|
@ApiResponse({ status: 404, description: 'Nounou non trouvée' })
|
||||||
@ApiParam({ name: 'id', description: "UUID de la nounou" })
|
@ApiParam({ name: 'id', description: "UUID de la nounou" })
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
update(@Param('id') id: string, @Body() dto: UpdateAssistanteDto): Promise<AssistanteMaternelle> {
|
async update(@Param('id') id: string, @Body() dto: UpdateAssistanteDto): Promise<AssistanteMaternelle> {
|
||||||
return this.assistantesMaternellesService.update(id, dto);
|
const am = await this.assistantesMaternellesService.update(id, dto);
|
||||||
|
return mapAmForApi(am);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||||
@ApiOperation({ summary: 'Supprimer une nounou' })
|
@ApiOperation({ summary: 'Supprimer une nounou' })
|
||||||
@ApiResponse({ status: 200, description: 'Nounou supprimée avec succès' })
|
@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' })
|
@ApiResponse({ status: 404, description: 'Nounou non trouvée' })
|
||||||
@ApiParam({ name: 'id', description: "UUID de la nounou" })
|
@ApiParam({ name: 'id', description: "UUID de la nounou" })
|
||||||
@Delete(':id')
|
@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 { AssistantesMaternellesService } from './assistantes_maternelles.service';
|
||||||
import { AssistantesMaternellesController } from './assistantes_maternelles.controller';
|
import { AssistantesMaternellesController } from './assistantes_maternelles.controller';
|
||||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
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 { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { Users } from 'src/entities/users.entity';
|
import { Users } from 'src/entities/users.entity';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([AssistanteMaternelle, Users]),
|
imports: [TypeOrmModule.forFeature([AssistanteMaternelle, AmChildren, Children, Users]),
|
||||||
AuthModule
|
AuthModule
|
||||||
],
|
],
|
||||||
controllers: [AssistantesMaternellesController],
|
controllers: [AssistantesMaternellesController],
|
||||||
|
|||||||
@@ -5,11 +5,17 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { IsNull, Repository } from 'typeorm';
|
||||||
import { RoleType, Users } from 'src/entities/users.entity';
|
import { RoleType, Users } from 'src/entities/users.entity';
|
||||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.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 { CreateAssistanteDto } from '../user/dto/create_assistante.dto';
|
||||||
import { UpdateAssistanteDto } from '../user/dto/update_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()
|
@Injectable()
|
||||||
export class AssistantesMaternellesService {
|
export class AssistantesMaternellesService {
|
||||||
@@ -17,10 +23,13 @@ export class AssistantesMaternellesService {
|
|||||||
@InjectRepository(AssistanteMaternelle)
|
@InjectRepository(AssistanteMaternelle)
|
||||||
private readonly assistantesMaternelleRepository: Repository<AssistanteMaternelle>,
|
private readonly assistantesMaternelleRepository: Repository<AssistanteMaternelle>,
|
||||||
@InjectRepository(Users)
|
@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> {
|
async create(dto: CreateAssistanteDto): Promise<AssistanteMaternelle> {
|
||||||
const user = await this.usersRepository.findOneBy({ id: dto.user_id });
|
const user = await this.usersRepository.findOneBy({ id: dto.user_id });
|
||||||
if (!user) throw new NotFoundException('Utilisateur introuvable');
|
if (!user) throw new NotFoundException('Utilisateur introuvable');
|
||||||
@@ -49,30 +58,208 @@ export class AssistantesMaternellesService {
|
|||||||
return this.assistantesMaternelleRepository.save(entity);
|
return this.assistantesMaternelleRepository.save(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Liste des assistantes maternelles
|
|
||||||
async findAll(): Promise<AssistanteMaternelle[]> {
|
async findAll(): Promise<AssistanteMaternelle[]> {
|
||||||
return this.assistantesMaternelleRepository.find({
|
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> {
|
async findOne(user_id: string): Promise<AssistanteMaternelle> {
|
||||||
const assistante = await this.assistantesMaternelleRepository.findOne({
|
const assistante = await this.assistantesMaternelleRepository.findOne({
|
||||||
where: { user_id },
|
where: { user_id },
|
||||||
relations: ['user'],
|
relations: [...AM_CHILDREN_RELATIONS],
|
||||||
});
|
});
|
||||||
if (!assistante) throw new NotFoundException('Assistante maternelle introuvable');
|
if (!assistante) throw new NotFoundException('Assistante maternelle introuvable');
|
||||||
return assistante;
|
return assistante;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mise à jour
|
|
||||||
async update(id: string, dto: UpdateAssistanteDto): Promise<AssistanteMaternelle> {
|
async update(id: string, dto: UpdateAssistanteDto): Promise<AssistanteMaternelle> {
|
||||||
await this.assistantesMaternelleRepository.update(id, dto);
|
await this.assistantesMaternelleRepository.update(id, dto);
|
||||||
return this.findOne(id);
|
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 }> {
|
async remove(id: string): Promise<{ message: string }> {
|
||||||
await this.assistantesMaternelleRepository.delete(id);
|
await this.assistantesMaternelleRepository.delete(id);
|
||||||
return { message: 'Assistante maternelle supprimée' };
|
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;
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ import { ParentsChildren } from 'src/entities/parents_children.entity';
|
|||||||
ParentsChildren,
|
ParentsChildren,
|
||||||
]),
|
]),
|
||||||
forwardRef(() => UserModule),
|
forwardRef(() => UserModule),
|
||||||
ParentsModule,
|
forwardRef(() => ParentsModule),
|
||||||
DossiersModule,
|
DossiersModule,
|
||||||
AppConfigModule,
|
AppConfigModule,
|
||||||
MailModule,
|
MailModule,
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ describe('AuthService (#118 create-password API)', () => {
|
|||||||
parentsServiceMock.getDossierFamilleByNumero.mockResolvedValue({
|
parentsServiceMock.getDossierFamilleByNumero.mockResolvedValue({
|
||||||
numero_dossier: '2026-000021',
|
numero_dossier: '2026-000021',
|
||||||
parents: [{ user_id: 'p1', email: 'claire@test.fr', statut: StatutUtilisateurType.REFUSE }],
|
parents: [{ user_id: 'p1', email: 'claire@test.fr', statut: StatutUtilisateurType.REFUSE }],
|
||||||
enfants: [{ id: 'e1', first_name: 'Emma', status: 'actif' }],
|
enfants: [{ id: 'e1', first_name: 'Emma', status: 'sans_garde' }],
|
||||||
texte_motivation: 'Motivation test',
|
texte_motivation: 'Motivation test',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { MailService } from 'src/modules/mail/mail.service';
|
|||||||
import { ParentsService } from '../parents/parents.service';
|
import { ParentsService } from '../parents/parents.service';
|
||||||
import { DossiersService } from '../dossiers/dossiers.service';
|
import { DossiersService } from '../dossiers/dossiers.service';
|
||||||
import { DossierAmCompletDto } from '../dossiers/dto/dossier-am-complet.dto';
|
import { DossierAmCompletDto } from '../dossiers/dto/dossier-am-complet.dto';
|
||||||
|
import { StaffAddCoParentDto } from '../parents/dto/staff-add-co-parent.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@@ -416,11 +417,20 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inscription Parent COMPLÈTE - Workflow CDC 6 étapes en 1 transaction
|
* Cœur partagé création dossier parent (#129).
|
||||||
* Gère : Parent 1 + Parent 2 (opt) + Enfants + Présentation + CGU
|
* - public : statut en_attente + mails pending
|
||||||
|
* - staff : statut actif + mails création MDP (pas de mail « dossier en attente »)
|
||||||
*/
|
*/
|
||||||
async inscrireParentComplet(dto: RegisterParentCompletDto) {
|
async createParentDossier(
|
||||||
if (!dto.acceptation_cgu || !dto.acceptation_privacy) {
|
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');
|
throw new BadRequestException('L\'acceptation des CGU et de la politique de confidentialité est obligatoire');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,7 +481,7 @@ export class AuthService {
|
|||||||
prenom: dto.prenom,
|
prenom: dto.prenom,
|
||||||
nom: dto.nom,
|
nom: dto.nom,
|
||||||
role: RoleType.PARENT,
|
role: RoleType.PARENT,
|
||||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
statut: options.statut,
|
||||||
telephone: dto.telephone,
|
telephone: dto.telephone,
|
||||||
adresse: dto.adresse,
|
adresse: dto.adresse,
|
||||||
code_postal: dto.code_postal,
|
code_postal: dto.code_postal,
|
||||||
@@ -496,7 +506,7 @@ export class AuthService {
|
|||||||
prenom: dto.co_parent_prenom,
|
prenom: dto.co_parent_prenom,
|
||||||
nom: dto.co_parent_nom,
|
nom: dto.co_parent_nom,
|
||||||
role: RoleType.PARENT,
|
role: RoleType.PARENT,
|
||||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
statut: options.statut,
|
||||||
telephone: dto.co_parent_telephone,
|
telephone: dto.co_parent_telephone,
|
||||||
adresse: dto.co_parent_meme_adresse ? dto.adresse : dto.co_parent_adresse,
|
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,
|
code_postal: dto.co_parent_meme_adresse ? dto.code_postal : dto.co_parent_code_postal,
|
||||||
@@ -551,8 +561,9 @@ export class AuthService {
|
|||||||
? new Date(enfantDto.date_previsionnelle_naissance)
|
? new Date(enfantDto.date_previsionnelle_naissance)
|
||||||
: undefined;
|
: undefined;
|
||||||
enfant.photo_url = urlPhoto || undefined;
|
enfant.photo_url = urlPhoto || undefined;
|
||||||
enfant.status = enfantDto.date_naissance ? StatutEnfantType.ACTIF : StatutEnfantType.A_NAITRE;
|
enfant.status = enfantDto.date_naissance ? StatutEnfantType.SANS_GARDE : StatutEnfantType.A_NAITRE;
|
||||||
enfant.consent_photo = false;
|
enfant.consent_photo = !!enfantDto.consent_photo;
|
||||||
|
enfant.consent_photo_at = enfant.consent_photo ? new Date() : null!;
|
||||||
enfant.is_multiple = enfantDto.grossesse_multiple || false;
|
enfant.is_multiple = enfantDto.grossesse_multiple || false;
|
||||||
|
|
||||||
const enfantEnregistre = await manager.save(Children, enfant);
|
const enfantEnregistre = await manager.save(Children, enfant);
|
||||||
@@ -611,44 +622,287 @@ export class AuthService {
|
|||||||
|
|
||||||
const numeroDossier = resultat.parent1.numero_dossier ?? '';
|
const numeroDossier = resultat.parent1.numero_dossier ?? '';
|
||||||
|
|
||||||
try {
|
if (options.sendPendingEmail) {
|
||||||
await this.mailService.sendRegistrationPendingEmail(
|
try {
|
||||||
resultat.parent1.email,
|
|
||||||
resultat.parent1.prenom ?? '',
|
|
||||||
resultat.parent1.nom ?? '',
|
|
||||||
numeroDossier,
|
|
||||||
);
|
|
||||||
if (resultat.parent2) {
|
|
||||||
await this.mailService.sendRegistrationPendingEmail(
|
await this.mailService.sendRegistrationPendingEmail(
|
||||||
resultat.parent2.email,
|
resultat.parent1.email,
|
||||||
resultat.parent2.prenom ?? '',
|
resultat.parent1.prenom ?? '',
|
||||||
resultat.parent2.nom ?? '',
|
resultat.parent1.nom ?? '',
|
||||||
numeroDossier,
|
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 {
|
return {
|
||||||
message: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.',
|
message,
|
||||||
parent_id: resultat.parent1.id,
|
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),
|
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,
|
numero_dossier: numeroDossier,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inscription Assistante Maternelle COMPLÈTE - Un seul endpoint (identité + pro + photo + CGU)
|
* Inscription Parent publique — CDC (statut en_attente + mail pending).
|
||||||
* Crée User (role AM) + entrée assistantes_maternelles, token création MDP
|
|
||||||
*/
|
*/
|
||||||
async inscrireAMComplet(dto: RegisterAMCompletDto) {
|
async inscrireParentComplet(dto: RegisterParentCompletDto) {
|
||||||
if (!dto.acceptation_cgu || !dto.acceptation_privacy) {
|
return this.createParentDossier(dto, {
|
||||||
|
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||||
|
sendPendingEmail: true,
|
||||||
|
sendPasswordSetupEmail: false,
|
||||||
|
requireCgu: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Création dossier parent par le staff (#129) — actif + mail création MDP.
|
||||||
|
*/
|
||||||
|
async createParentDossierStaff(dto: RegisterParentCompletDto) {
|
||||||
|
return this.createParentDossier(dto, {
|
||||||
|
statut: StatutUtilisateurType.ACTIF,
|
||||||
|
sendPendingEmail: false,
|
||||||
|
sendPasswordSetupEmail: true,
|
||||||
|
requireCgu: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ajoute un co-parent à un foyer existant (mono-parent) — ticket #135.
|
||||||
|
* Compte actif + mail création MDP + liens Parents bidirectionnels + enfants du foyer.
|
||||||
|
*/
|
||||||
|
async addCoParentStaff(pivotUserId: string, dto: StaffAddCoParentDto) {
|
||||||
|
const pivotParent = await this.parentsRepo.findOne({
|
||||||
|
where: { user_id: pivotUserId },
|
||||||
|
relations: ['user', 'co_parent', 'parentChildren'],
|
||||||
|
});
|
||||||
|
if (!pivotParent?.user) {
|
||||||
|
throw new NotFoundException('Parent introuvable');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pivotParent.co_parent) {
|
||||||
|
throw new BadRequestException('Ce foyer a déjà un co-parent.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const numeroDossier = pivotParent.numero_dossier?.trim() || pivotParent.user.numero_dossier?.trim();
|
||||||
|
if (!numeroDossier) {
|
||||||
|
throw new BadRequestException("Ce parent n'a pas de numéro de dossier.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sameDossierCount = await this.parentsRepo.count({
|
||||||
|
where: { numero_dossier: numeroDossier },
|
||||||
|
});
|
||||||
|
if (sameDossierCount >= 2) {
|
||||||
|
throw new BadRequestException('Ce dossier a déjà deux responsables.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = dto.email.trim().toLowerCase();
|
||||||
|
if (pivotParent.user.email.trim().toLowerCase() === email) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"L'email du co-parent doit être différent de celui du parent principal.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailExiste = await this.usersService.findByEmailOrNull(dto.email);
|
||||||
|
if (emailExiste) {
|
||||||
|
throw new ConflictException("L'email du co-parent est déjà utilisé");
|
||||||
|
}
|
||||||
|
|
||||||
|
const memeAdresse = dto.meme_adresse ?? true;
|
||||||
|
if (!memeAdresse) {
|
||||||
|
if (!dto.adresse?.trim() || !dto.ville?.trim() || !dto.code_postal?.trim()) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Adresse, code postal et ville du co-parent sont requis si meme_adresse est faux.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const joursExpirationToken = await this.appConfigService.get<number>(
|
||||||
|
'password_reset_token_expiry_days',
|
||||||
|
7,
|
||||||
|
);
|
||||||
|
const tokenCreationMdp = crypto.randomUUID();
|
||||||
|
const dateExpiration = new Date();
|
||||||
|
dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken);
|
||||||
|
|
||||||
|
let coParent: Users;
|
||||||
|
|
||||||
|
try {
|
||||||
|
coParent = await this.usersRepo.manager.transaction(async (manager) => {
|
||||||
|
const pivotUser = await manager.findOne(Users, {
|
||||||
|
where: { id: pivotUserId },
|
||||||
|
});
|
||||||
|
if (!pivotUser) {
|
||||||
|
throw new NotFoundException('Parent introuvable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pivotEntite = await manager.findOne(Parents, {
|
||||||
|
where: { user_id: pivotUserId },
|
||||||
|
relations: ['parentChildren'],
|
||||||
|
});
|
||||||
|
if (!pivotEntite) {
|
||||||
|
throw new NotFoundException('Parent introuvable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const coUser = manager.create(Users, {
|
||||||
|
email: dto.email.trim(),
|
||||||
|
prenom: dto.prenom,
|
||||||
|
nom: dto.nom,
|
||||||
|
role: RoleType.PARENT,
|
||||||
|
statut: StatutUtilisateurType.ACTIF,
|
||||||
|
telephone: dto.telephone,
|
||||||
|
adresse: memeAdresse ? pivotUser.adresse : dto.adresse,
|
||||||
|
code_postal: memeAdresse ? pivotUser.code_postal : dto.code_postal,
|
||||||
|
ville: memeAdresse ? pivotUser.ville : dto.ville,
|
||||||
|
token_creation_mdp: tokenCreationMdp,
|
||||||
|
token_creation_mdp_expire_le: dateExpiration,
|
||||||
|
numero_dossier: numeroDossier,
|
||||||
|
});
|
||||||
|
const coUserSaved = await manager.save(Users, coUser);
|
||||||
|
|
||||||
|
pivotEntite.co_parent = coUserSaved;
|
||||||
|
pivotEntite.numero_dossier = numeroDossier;
|
||||||
|
await manager.save(Parents, pivotEntite);
|
||||||
|
|
||||||
|
const coEntite = manager.create(Parents, {
|
||||||
|
user_id: coUserSaved.id,
|
||||||
|
numero_dossier: numeroDossier,
|
||||||
|
});
|
||||||
|
coEntite.user = coUserSaved;
|
||||||
|
coEntite.co_parent = pivotUser;
|
||||||
|
await manager.save(Parents, coEntite);
|
||||||
|
|
||||||
|
const enfantIds = (pivotEntite.parentChildren ?? [])
|
||||||
|
.map((pc) => pc.enfantId)
|
||||||
|
.filter(Boolean);
|
||||||
|
for (const enfantId of enfantIds) {
|
||||||
|
const existing = await manager.findOne(ParentsChildren, {
|
||||||
|
where: { parentId: coUserSaved.id, enfantId },
|
||||||
|
});
|
||||||
|
if (existing) continue;
|
||||||
|
await manager.save(
|
||||||
|
ParentsChildren,
|
||||||
|
manager.create(ParentsChildren, {
|
||||||
|
parentId: coUserSaved.id,
|
||||||
|
enfantId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return coUserSaved;
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (this.isPostgresUniqueViolation(err)) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'Un compte avec cet email existe déjà (contrainte unique en base).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.mailService.sendValidatedAccountPasswordSetupEmail(
|
||||||
|
{
|
||||||
|
email: coParent.email,
|
||||||
|
prenom: coParent.prenom ?? '',
|
||||||
|
nom: coParent.nom ?? '',
|
||||||
|
token: tokenCreationMdp,
|
||||||
|
numeroDossier,
|
||||||
|
},
|
||||||
|
'parent',
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
'[addCoParentStaff] Échec envoi email création MDP (co-parent conservé)',
|
||||||
|
err instanceof Error ? err.stack : String(err),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
message:
|
||||||
|
'Co-parent ajouté au foyer. Un e-mail de création de mot de passe a été envoyé.',
|
||||||
|
numero_dossier: numeroDossier,
|
||||||
|
parent_user_id: pivotUserId,
|
||||||
|
co_parent_user_id: coParent.id,
|
||||||
|
statut: StatutUtilisateurType.ACTIF,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cœur partagé création dossier AM (#156).
|
||||||
|
* - public : statut en_attente + mail pending
|
||||||
|
* - staff : statut actif + mail création MDP
|
||||||
|
*/
|
||||||
|
async createAmDossier(
|
||||||
|
dto: RegisterAMCompletDto,
|
||||||
|
options: {
|
||||||
|
statut: StatutUtilisateurType;
|
||||||
|
sendPendingEmail: boolean;
|
||||||
|
sendPasswordSetupEmail: boolean;
|
||||||
|
requireCgu: boolean;
|
||||||
|
logContext?: string;
|
||||||
|
},
|
||||||
|
): Promise<{
|
||||||
|
message: string;
|
||||||
|
user_id: string;
|
||||||
|
statut: StatutUtilisateurType;
|
||||||
|
numero_dossier: string;
|
||||||
|
}> {
|
||||||
|
const logCtx = options.logContext ?? 'createAmDossier';
|
||||||
|
|
||||||
|
if (options.requireCgu && (!dto.acceptation_cgu || !dto.acceptation_privacy)) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"L'acceptation des CGU et de la politique de confidentialité est obligatoire",
|
"L'acceptation des CGU et de la politique de confidentialité est obligatoire",
|
||||||
);
|
);
|
||||||
@@ -668,8 +922,7 @@ export class AuthService {
|
|||||||
throw new BadRequestException(nirValidation.error || 'NIR invalide');
|
throw new BadRequestException(nirValidation.error || 'NIR invalide');
|
||||||
}
|
}
|
||||||
if (nirValidation.warning) {
|
if (nirValidation.warning) {
|
||||||
// Warning uniquement : on ne bloque pas (AM souvent étrangères, DOM-TOM, Corse)
|
console.warn(`[${logCtx}] NIR warning:`, nirValidation.warning, 'email=', dto.email);
|
||||||
console.warn('[inscrireAMComplet] NIR warning:', nirValidation.warning, 'email=', dto.email);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const existe = await this.usersService.findByEmailOrNull(dto.email);
|
const existe = await this.usersService.findByEmailOrNull(dto.email);
|
||||||
@@ -720,79 +973,142 @@ export class AuthService {
|
|||||||
let resultat: { user: Users };
|
let resultat: { user: Users };
|
||||||
try {
|
try {
|
||||||
resultat = await this.usersRepo.manager.transaction(async (manager) => {
|
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, {
|
const user = manager.create(Users, {
|
||||||
email: dto.email,
|
email: dto.email,
|
||||||
prenom: dto.prenom,
|
prenom: dto.prenom,
|
||||||
nom: dto.nom,
|
nom: dto.nom,
|
||||||
role: RoleType.ASSISTANTE_MATERNELLE,
|
role: RoleType.ASSISTANTE_MATERNELLE,
|
||||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
statut: options.statut,
|
||||||
telephone: dto.telephone,
|
telephone: dto.telephone,
|
||||||
adresse: dto.adresse,
|
adresse: dto.adresse,
|
||||||
code_postal: dto.code_postal,
|
code_postal: dto.code_postal,
|
||||||
ville: dto.ville,
|
ville: dto.ville,
|
||||||
token_creation_mdp: tokenCreationMdp,
|
token_creation_mdp: tokenCreationMdp,
|
||||||
token_creation_mdp_expire_le: dateExpiration,
|
token_creation_mdp_expire_le: dateExpiration,
|
||||||
photo_url: urlPhoto ?? undefined,
|
photo_url: urlPhoto ?? undefined,
|
||||||
consentement_photo: dto.consentement_photo,
|
consentement_photo: dto.consentement_photo,
|
||||||
date_consentement_photo: dateConsentementPhoto,
|
date_consentement_photo: dateConsentementPhoto,
|
||||||
date_naissance: dto.date_naissance ? new Date(dto.date_naissance) : undefined,
|
date_naissance: dto.date_naissance
|
||||||
lieu_naissance_ville: dto.lieu_naissance_ville,
|
? new Date(dto.date_naissance)
|
||||||
lieu_naissance_pays: dto.lieu_naissance_pays,
|
: undefined,
|
||||||
numero_dossier: numeroDossier,
|
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) {
|
} catch (err) {
|
||||||
if (this.isPostgresUniqueViolation(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;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
const numeroDossier = resultat.user.numero_dossier ?? '';
|
const numeroDossier = resultat.user.numero_dossier ?? '';
|
||||||
|
|
||||||
try {
|
if (options.sendPendingEmail) {
|
||||||
await this.mailService.sendRegistrationPendingEmail(
|
try {
|
||||||
resultat.user.email,
|
await this.mailService.sendRegistrationPendingEmail(
|
||||||
resultat.user.prenom ?? '',
|
resultat.user.email,
|
||||||
resultat.user.nom ?? '',
|
resultat.user.prenom ?? '',
|
||||||
numeroDossier,
|
resultat.user.nom ?? '',
|
||||||
);
|
numeroDossier,
|
||||||
} catch (err) {
|
);
|
||||||
this.logger.error(
|
} catch (err) {
|
||||||
"[inscrireAMComplet] Échec envoi email d'accusé de réception (inscription conservée)",
|
this.logger.error(
|
||||||
err instanceof Error ? err.stack : String(err),
|
`[${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 {
|
return {
|
||||||
message:
|
message,
|
||||||
'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.',
|
|
||||||
user_id: resultat.user.id,
|
user_id: resultat.user.id,
|
||||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
statut: options.statut,
|
||||||
numero_dossier: numeroDossier,
|
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
|
* Sauvegarde une photo depuis base64 vers le système de fichiers
|
||||||
*/
|
*/
|
||||||
@@ -1063,7 +1379,7 @@ export class AuthService {
|
|||||||
if (enfantDto.genre !== undefined) enfant.gender = enfantDto.genre;
|
if (enfantDto.genre !== undefined) enfant.gender = enfantDto.genre;
|
||||||
if (enfantDto.date_naissance !== undefined) {
|
if (enfantDto.date_naissance !== undefined) {
|
||||||
enfant.birth_date = new Date(enfantDto.date_naissance);
|
enfant.birth_date = new Date(enfantDto.date_naissance);
|
||||||
enfant.status = StatutEnfantType.ACTIF;
|
enfant.status = StatutEnfantType.SANS_GARDE;
|
||||||
}
|
}
|
||||||
if (enfantDto.date_previsionnelle_naissance !== undefined) {
|
if (enfantDto.date_previsionnelle_naissance !== undefined) {
|
||||||
enfant.due_date = new Date(enfantDto.date_previsionnelle_naissance);
|
enfant.due_date = new Date(enfantDto.date_previsionnelle_naissance);
|
||||||
@@ -1074,6 +1390,12 @@ export class AuthService {
|
|||||||
if (enfantDto.grossesse_multiple !== undefined) {
|
if (enfantDto.grossesse_multiple !== undefined) {
|
||||||
enfant.is_multiple = enfantDto.grossesse_multiple;
|
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) {
|
if (enfantDto.photo_base64 && enfantDto.photo_filename) {
|
||||||
enfant.photo_url = await this.sauvegarderPhotoDepuisBase64(
|
enfant.photo_url = await this.sauvegarderPhotoDepuisBase64(
|
||||||
|
|||||||
@@ -59,5 +59,14 @@ export class EnfantInscriptionDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
grossesse_multiple?: boolean;
|
grossesse_multiple?: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: true,
|
||||||
|
required: false,
|
||||||
|
description: 'Consentement affichage / stockage photo (colonne enfants.consentement_photo)',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
consent_photo?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { DossiersController } from './dossiers.controller';
|
||||||
|
import { DossiersService } from './dossiers.service';
|
||||||
|
import { SuppressionService } from '../suppressions/suppression.service';
|
||||||
|
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||||
|
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||||
|
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||||
|
|
||||||
|
describe('DossiersController', () => {
|
||||||
|
let controller: DossiersController;
|
||||||
|
const dossiersServiceMock = {
|
||||||
|
listDossiers: jest.fn(),
|
||||||
|
getDossierByNumero: jest.fn(),
|
||||||
|
};
|
||||||
|
const suppressionServiceMock = {
|
||||||
|
deleteDossier: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [DossiersController],
|
||||||
|
providers: [
|
||||||
|
{ provide: DossiersService, useValue: dossiersServiceMock },
|
||||||
|
{ provide: SuppressionService, useValue: suppressionServiceMock },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.overrideGuard(AuthGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
|
.overrideGuard(RolesGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
controller = module.get<DossiersController>(DossiersController);
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('list delegates to dossiersService.listDossiers with q', async () => {
|
||||||
|
dossiersServiceMock.listDossiers.mockResolvedValue([
|
||||||
|
{
|
||||||
|
type: 'famille',
|
||||||
|
numero_dossier: '2026-000043',
|
||||||
|
libelle: 'Claire MARTIN',
|
||||||
|
emails: ['claire@test.fr'],
|
||||||
|
user_ids: ['u1'],
|
||||||
|
statut: StatutUtilisateurType.ACTIF,
|
||||||
|
a_valider: false,
|
||||||
|
date_reference: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await controller.list('martin');
|
||||||
|
expect(dossiersServiceMock.listDossiers).toHaveBeenCalledWith('martin');
|
||||||
|
expect(res).toHaveLength(1);
|
||||||
|
expect(res[0].numero_dossier).toBe('2026-000043');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getDossier delegates to getDossierByNumero', async () => {
|
||||||
|
dossiersServiceMock.getDossierByNumero.mockResolvedValue({
|
||||||
|
type: 'family',
|
||||||
|
dossier: { numero_dossier: '2026-000001' },
|
||||||
|
});
|
||||||
|
const res = await controller.getDossier('2026-000001');
|
||||||
|
expect(dossiersServiceMock.getDossierByNumero).toHaveBeenCalledWith('2026-000001');
|
||||||
|
expect(res.type).toBe('family');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remove delegates to suppressionService.deleteDossier', async () => {
|
||||||
|
const user = { id: 'u1', role: 'gestionnaire' } as never;
|
||||||
|
suppressionServiceMock.deleteDossier.mockResolvedValue({
|
||||||
|
type: 'famille',
|
||||||
|
deleted_user_ids: [],
|
||||||
|
deleted_enfant_ids: [],
|
||||||
|
message: 'ok',
|
||||||
|
});
|
||||||
|
await controller.remove('2026-000001', user);
|
||||||
|
expect(suppressionServiceMock.deleteDossier).toHaveBeenCalledWith(
|
||||||
|
'2026-000001',
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,17 +1,58 @@
|
|||||||
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
import {
|
||||||
import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiOperation,
|
||||||
|
ApiParam,
|
||||||
|
ApiQuery,
|
||||||
|
ApiResponse,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
import { Roles } from 'src/common/decorators/roles.decorator';
|
import { Roles } from 'src/common/decorators/roles.decorator';
|
||||||
import { RoleType } from 'src/entities/users.entity';
|
import { RoleType, Users } from 'src/entities/users.entity';
|
||||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||||
|
import { User } from 'src/common/decorators/user.decorator';
|
||||||
import { DossiersService } from './dossiers.service';
|
import { DossiersService } from './dossiers.service';
|
||||||
|
import { SuppressionService } from '../suppressions/suppression.service';
|
||||||
import { DossierUnifieDto } from './dto/dossier-unifie.dto';
|
import { DossierUnifieDto } from './dto/dossier-unifie.dto';
|
||||||
|
import { DossierListItemDto } from './dto/dossier-list-item.dto';
|
||||||
|
|
||||||
@ApiTags('Dossiers')
|
@ApiTags('Dossiers')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
@Controller('dossiers')
|
@Controller('dossiers')
|
||||||
@UseGuards(AuthGuard, RolesGuard)
|
@UseGuards(AuthGuard, RolesGuard)
|
||||||
export class DossiersController {
|
export class DossiersController {
|
||||||
constructor(private readonly dossiersService: DossiersService) {}
|
constructor(
|
||||||
|
private readonly dossiersService: DossiersService,
|
||||||
|
private readonly suppressionService: SuppressionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Liste unifiée des dossiers (familles + AM) — ticket #153',
|
||||||
|
description:
|
||||||
|
'1 entrée = 1 numero_dossier. Types `famille` | `assistante_maternelle`. ' +
|
||||||
|
'Filtre optionnel `q` (n°, nom, email). Tri : à valider d’abord, puis n° décroissant. ' +
|
||||||
|
'`sans_enfant` (#159) pour dossiers famille sans enfant.',
|
||||||
|
})
|
||||||
|
@ApiQuery({
|
||||||
|
name: 'q',
|
||||||
|
required: false,
|
||||||
|
description: 'Recherche libre : n° dossier, libellé, email…',
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, type: [DossierListItemDto] })
|
||||||
|
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
||||||
|
list(@Query('q') q?: string): Promise<DossierListItemDto[]> {
|
||||||
|
return this.dossiersService.listDossiers(q);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':numeroDossier')
|
@Get(':numeroDossier')
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||||
@@ -23,4 +64,21 @@ export class DossiersController {
|
|||||||
getDossier(@Param('numeroDossier') numeroDossier: string): Promise<DossierUnifieDto> {
|
getDossier(@Param('numeroDossier') numeroDossier: string): Promise<DossierUnifieDto> {
|
||||||
return this.dossiersService.getDossierByNumero(numeroDossier);
|
return this.dossiersService.getDossierByNumero(numeroDossier);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Delete(':numeroDossier')
|
||||||
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Supprimer un dossier (famille ou AM) — #159',
|
||||||
|
description:
|
||||||
|
'Famille : parents + enfants. AM : compte AM + dossier AM (enfants conservés, placements clos).',
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'numeroDossier', description: 'Numéro de dossier' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Résultat de suppression' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Dossier introuvable' })
|
||||||
|
remove(
|
||||||
|
@Param('numeroDossier') numeroDossier: string,
|
||||||
|
@User() currentUser: Users,
|
||||||
|
) {
|
||||||
|
return this.suppressionService.deleteDossier(numeroDossier, currentUser);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { JwtModule } from '@nestjs/jwt';
|
|||||||
import { Parents } from 'src/entities/parents.entity';
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||||
import { ParentsModule } from '../parents/parents.module';
|
import { ParentsModule } from '../parents/parents.module';
|
||||||
|
import { SuppressionsModule } from '../suppressions/suppressions.module';
|
||||||
import { DossiersController } from './dossiers.controller';
|
import { DossiersController } from './dossiers.controller';
|
||||||
import { DossiersService } from './dossiers.service';
|
import { DossiersService } from './dossiers.service';
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ import { DossiersService } from './dossiers.service';
|
|||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Parents, AssistanteMaternelle]),
|
TypeOrmModule.forFeature([Parents, AssistanteMaternelle]),
|
||||||
ParentsModule,
|
ParentsModule,
|
||||||
|
SuppressionsModule,
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
useFactory: (config: ConfigService) => ({
|
useFactory: (config: ConfigService) => ({
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
|
import { DossiersService } from './dossiers.service';
|
||||||
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
|
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||||
|
import { ParentsService } from '../parents/parents.service';
|
||||||
|
import { SuppressionService } from '../suppressions/suppression.service';
|
||||||
|
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||||
|
|
||||||
|
describe('DossiersService.listDossiers', () => {
|
||||||
|
let service: DossiersService;
|
||||||
|
const parentsQb = {
|
||||||
|
innerJoinAndSelect: jest.fn().mockReturnThis(),
|
||||||
|
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
|
getMany: jest.fn(),
|
||||||
|
};
|
||||||
|
const amQb = {
|
||||||
|
innerJoinAndSelect: jest.fn().mockReturnThis(),
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
|
getMany: jest.fn(),
|
||||||
|
};
|
||||||
|
const parentsRepo = {
|
||||||
|
createQueryBuilder: jest.fn(() => parentsQb),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
};
|
||||||
|
const amRepo = {
|
||||||
|
createQueryBuilder: jest.fn(() => amQb),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
};
|
||||||
|
const parentsService = {
|
||||||
|
getDossierFamilleByNumero: jest.fn(),
|
||||||
|
};
|
||||||
|
const suppressionService = {
|
||||||
|
countEnfantsForNumero: jest.fn().mockResolvedValue(1),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
DossiersService,
|
||||||
|
{ provide: getRepositoryToken(Parents), useValue: parentsRepo },
|
||||||
|
{ provide: getRepositoryToken(AssistanteMaternelle), useValue: amRepo },
|
||||||
|
{ provide: ParentsService, useValue: parentsService },
|
||||||
|
{ provide: SuppressionService, useValue: suppressionService },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get(DossiersService);
|
||||||
|
jest.clearAllMocks();
|
||||||
|
parentsRepo.createQueryBuilder.mockReturnValue(parentsQb);
|
||||||
|
amRepo.createQueryBuilder.mockReturnValue(amQb);
|
||||||
|
suppressionService.countEnfantsForNumero.mockResolvedValue(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aggregates famille (pivot+co-parent) and AM, sorts a_valider first', async () => {
|
||||||
|
parentsQb.getMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
user_id: 'p1',
|
||||||
|
numero_dossier: '2026-000010',
|
||||||
|
user: {
|
||||||
|
id: 'p1',
|
||||||
|
email: 'claire@test.fr',
|
||||||
|
prenom: 'Claire',
|
||||||
|
nom: 'Martin',
|
||||||
|
statut: StatutUtilisateurType.ACTIF,
|
||||||
|
cree_le: new Date('2026-01-01'),
|
||||||
|
},
|
||||||
|
co_parent: {
|
||||||
|
id: 'p2',
|
||||||
|
email: 'thomas@test.fr',
|
||||||
|
prenom: 'Thomas',
|
||||||
|
nom: 'Martin',
|
||||||
|
statut: StatutUtilisateurType.ACTIF,
|
||||||
|
cree_le: new Date('2026-01-02'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
user_id: 'p3',
|
||||||
|
numero_dossier: '2026-000020',
|
||||||
|
user: {
|
||||||
|
id: 'p3',
|
||||||
|
email: 'pending@test.fr',
|
||||||
|
prenom: 'Paul',
|
||||||
|
nom: 'Pending',
|
||||||
|
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||||
|
cree_le: new Date('2026-02-01'),
|
||||||
|
},
|
||||||
|
co_parent: undefined,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
amQb.getMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
user_id: 'am1',
|
||||||
|
numero_dossier: '2026-000015',
|
||||||
|
user: {
|
||||||
|
id: 'am1',
|
||||||
|
email: 'am@test.fr',
|
||||||
|
prenom: 'Marie',
|
||||||
|
nom: 'Dupont',
|
||||||
|
statut: StatutUtilisateurType.ACTIF,
|
||||||
|
cree_le: new Date('2026-01-15'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const list = await service.listDossiers();
|
||||||
|
expect(list).toHaveLength(3);
|
||||||
|
expect(list[0].a_valider).toBe(true);
|
||||||
|
expect(list[0].type).toBe('famille');
|
||||||
|
expect(list[0].numero_dossier).toBe('2026-000020');
|
||||||
|
|
||||||
|
const famille = list.find((i) => i.numero_dossier === '2026-000010')!;
|
||||||
|
expect(famille.type).toBe('famille');
|
||||||
|
expect(famille.user_ids).toEqual(expect.arrayContaining(['p1', 'p2']));
|
||||||
|
expect(famille.emails).toHaveLength(2);
|
||||||
|
expect(famille.libelle).toContain('MARTIN');
|
||||||
|
|
||||||
|
const am = list.find((i) => i.type === 'assistante_maternelle')!;
|
||||||
|
expect(am.numero_dossier).toBe('2026-000015');
|
||||||
|
expect(am.libelle).toContain('Marie');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters with q', async () => {
|
||||||
|
parentsQb.getMany.mockResolvedValue([]);
|
||||||
|
amQb.getMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
user_id: 'am1',
|
||||||
|
numero_dossier: '2026-000015',
|
||||||
|
user: {
|
||||||
|
id: 'am1',
|
||||||
|
email: 'am@test.fr',
|
||||||
|
prenom: 'Marie',
|
||||||
|
nom: 'Dupont',
|
||||||
|
statut: StatutUtilisateurType.ACTIF,
|
||||||
|
cree_le: new Date('2026-01-15'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const hit = await service.listDossiers('dupont');
|
||||||
|
expect(hit).toHaveLength(1);
|
||||||
|
const miss = await service.listDossiers('zzz');
|
||||||
|
expect(miss).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,12 +3,15 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { Parents } from 'src/entities/parents.entity';
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||||
|
import { StatutUtilisateurType, Users } from 'src/entities/users.entity';
|
||||||
import { ParentsService } from '../parents/parents.service';
|
import { ParentsService } from '../parents/parents.service';
|
||||||
|
import { SuppressionService } from '../suppressions/suppression.service';
|
||||||
import { DossierUnifieDto } from './dto/dossier-unifie.dto';
|
import { DossierUnifieDto } from './dto/dossier-unifie.dto';
|
||||||
import { DossierAmCompletDto, DossierAmUserDto } from './dto/dossier-am-complet.dto';
|
import { DossierAmCompletDto, DossierAmUserDto } from './dto/dossier-am-complet.dto';
|
||||||
|
import { DossierListItemDto } from './dto/dossier-list-item.dto';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Endpoint unifié GET /dossiers/:numeroDossier – AM ou famille. Ticket #119.
|
* Dossiers unifiés — détail (#119) + liste (#153) + sans_enfant (#159).
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DossiersService {
|
export class DossiersService {
|
||||||
@@ -18,8 +21,173 @@ export class DossiersService {
|
|||||||
@InjectRepository(AssistanteMaternelle)
|
@InjectRepository(AssistanteMaternelle)
|
||||||
private readonly amRepository: Repository<AssistanteMaternelle>,
|
private readonly amRepository: Repository<AssistanteMaternelle>,
|
||||||
private readonly parentsService: ParentsService,
|
private readonly parentsService: ParentsService,
|
||||||
|
private readonly suppressionService: SuppressionService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste unifiée tous dossiers (familles + AM) ayant un numero_dossier.
|
||||||
|
* Ticket #153 — optionnel `q` filtre n° / nom / prénom / email (côté serveur).
|
||||||
|
*/
|
||||||
|
async listDossiers(q?: string): Promise<DossierListItemDto[]> {
|
||||||
|
const items: DossierListItemDto[] = [
|
||||||
|
...(await this.listFamilleItems()),
|
||||||
|
...(await this.listAmItems()),
|
||||||
|
];
|
||||||
|
|
||||||
|
const needle = (q ?? '').trim().toLowerCase();
|
||||||
|
const filtered = needle
|
||||||
|
? items.filter((item) => this.matchesQuery(item, needle))
|
||||||
|
: items;
|
||||||
|
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
// À valider d'abord, puis n° dossier décroissant
|
||||||
|
if (a.a_valider !== b.a_valider) return a.a_valider ? -1 : 1;
|
||||||
|
return b.numero_dossier.localeCompare(a.numero_dossier, 'fr');
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const item of filtered) {
|
||||||
|
if (item.type === 'famille') {
|
||||||
|
const n = await this.suppressionService.countEnfantsForNumero(
|
||||||
|
item.numero_dossier,
|
||||||
|
);
|
||||||
|
item.sans_enfant = n === 0;
|
||||||
|
} else {
|
||||||
|
item.sans_enfant = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listFamilleItems(): Promise<DossierListItemDto[]> {
|
||||||
|
const parents = await this.parentsRepository
|
||||||
|
.createQueryBuilder('p')
|
||||||
|
.innerJoinAndSelect('p.user', 'u')
|
||||||
|
.leftJoinAndSelect('p.co_parent', 'cp')
|
||||||
|
.where('p.numero_dossier IS NOT NULL')
|
||||||
|
.andWhere("TRIM(p.numero_dossier) <> ''")
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
const byNum = new Map<string, Parents[]>();
|
||||||
|
for (const p of parents) {
|
||||||
|
const num = (p.numero_dossier ?? '').trim();
|
||||||
|
if (!num) continue;
|
||||||
|
const group = byNum.get(num) ?? [];
|
||||||
|
group.push(p);
|
||||||
|
byNum.set(num, group);
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: DossierListItemDto[] = [];
|
||||||
|
for (const [numero_dossier, group] of byNum) {
|
||||||
|
const usersMap = new Map<string, Users>();
|
||||||
|
for (const p of group) {
|
||||||
|
if (p.user) usersMap.set(p.user.id, p.user);
|
||||||
|
if (p.co_parent) usersMap.set(p.co_parent.id, p.co_parent);
|
||||||
|
}
|
||||||
|
const users = [...usersMap.values()].sort((a, b) => {
|
||||||
|
const an = `${a.nom ?? ''} ${a.prenom ?? ''}`.toLowerCase();
|
||||||
|
const bn = `${b.nom ?? ''} ${b.prenom ?? ''}`.toLowerCase();
|
||||||
|
return an.localeCompare(bn, 'fr') || a.id.localeCompare(b.id);
|
||||||
|
});
|
||||||
|
if (users.length === 0) continue;
|
||||||
|
|
||||||
|
const names = users.map((u) => this.formatPersonName(u)).filter(Boolean);
|
||||||
|
const libelle =
|
||||||
|
names.length === 0
|
||||||
|
? `Dossier ${numero_dossier}`
|
||||||
|
: names.length === 1
|
||||||
|
? names[0]
|
||||||
|
: names.join(' & ');
|
||||||
|
|
||||||
|
const emails = users.map((u) => u.email).filter(Boolean);
|
||||||
|
const user_ids = users.map((u) => u.id);
|
||||||
|
const a_valider = users.some((u) => u.statut === StatutUtilisateurType.EN_ATTENTE);
|
||||||
|
const statut = a_valider
|
||||||
|
? StatutUtilisateurType.EN_ATTENTE
|
||||||
|
: (users[0].statut ?? StatutUtilisateurType.ACTIF);
|
||||||
|
const date_reference = this.minCreeLeIso(users);
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
type: 'famille',
|
||||||
|
numero_dossier,
|
||||||
|
libelle,
|
||||||
|
emails,
|
||||||
|
user_ids,
|
||||||
|
statut,
|
||||||
|
a_valider,
|
||||||
|
date_reference,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listAmItems(): Promise<DossierListItemDto[]> {
|
||||||
|
const ams = await this.amRepository
|
||||||
|
.createQueryBuilder('am')
|
||||||
|
.innerJoinAndSelect('am.user', 'u')
|
||||||
|
.where('am.numero_dossier IS NOT NULL')
|
||||||
|
.andWhere("TRIM(am.numero_dossier) <> ''")
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
const byNum = new Map<string, AssistanteMaternelle>();
|
||||||
|
for (const am of ams) {
|
||||||
|
const num = (am.numero_dossier ?? '').trim();
|
||||||
|
if (!num || !am.user) continue;
|
||||||
|
// Un n° = une AM ; garder le premier
|
||||||
|
if (!byNum.has(num)) byNum.set(num, am);
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: DossierListItemDto[] = [];
|
||||||
|
for (const [numero_dossier, am] of byNum) {
|
||||||
|
const u = am.user!;
|
||||||
|
const libelle = this.formatPersonName(u) || `AM ${numero_dossier}`;
|
||||||
|
const a_valider = u.statut === StatutUtilisateurType.EN_ATTENTE;
|
||||||
|
items.push({
|
||||||
|
type: 'assistante_maternelle',
|
||||||
|
numero_dossier,
|
||||||
|
libelle,
|
||||||
|
emails: u.email ? [u.email] : [],
|
||||||
|
user_ids: [u.id],
|
||||||
|
statut: u.statut ?? StatutUtilisateurType.ACTIF,
|
||||||
|
a_valider,
|
||||||
|
date_reference: this.minCreeLeIso([u]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
private matchesQuery(item: DossierListItemDto, needle: string): boolean {
|
||||||
|
const hay = [
|
||||||
|
item.numero_dossier,
|
||||||
|
item.libelle,
|
||||||
|
...item.emails,
|
||||||
|
item.statut,
|
||||||
|
item.type,
|
||||||
|
]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase();
|
||||||
|
return hay.includes(needle);
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatPersonName(u: Users): string {
|
||||||
|
const prenom = (u.prenom ?? '').trim();
|
||||||
|
const nom = (u.nom ?? '').trim();
|
||||||
|
const nomFmt = nom ? nom.toUpperCase() : '';
|
||||||
|
return [prenom, nomFmt].filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
private minCreeLeIso(users: Users[]): string | null {
|
||||||
|
let min: Date | null = null;
|
||||||
|
for (const u of users) {
|
||||||
|
const d = u.cree_le;
|
||||||
|
if (!d) continue;
|
||||||
|
const date = d instanceof Date ? d : new Date(d);
|
||||||
|
if (Number.isNaN(date.getTime())) continue;
|
||||||
|
if (!min || date < min) min = date;
|
||||||
|
}
|
||||||
|
return min ? min.toISOString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
async getDossierByNumero(numeroDossier: string): Promise<DossierUnifieDto> {
|
async getDossierByNumero(numeroDossier: string): Promise<DossierUnifieDto> {
|
||||||
const num = numeroDossier?.trim();
|
const num = numeroDossier?.trim();
|
||||||
if (!num) {
|
if (!num) {
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||||
|
|
||||||
|
/** Ligne de liste GET /dossiers (#153). */
|
||||||
|
export class DossierListItemDto {
|
||||||
|
@ApiProperty({
|
||||||
|
enum: ['famille', 'assistante_maternelle'],
|
||||||
|
description: 'Type de dossier',
|
||||||
|
})
|
||||||
|
type: 'famille' | 'assistante_maternelle';
|
||||||
|
|
||||||
|
@ApiProperty({ example: '2026-000043' })
|
||||||
|
numero_dossier: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'Claire MARTIN & Thomas MARTIN',
|
||||||
|
description: 'Libellé affiché (noms)',
|
||||||
|
})
|
||||||
|
libelle: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: [String],
|
||||||
|
example: ['claire@example.com', 'thomas@example.com'],
|
||||||
|
})
|
||||||
|
emails: string[];
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: [String],
|
||||||
|
format: 'uuid',
|
||||||
|
description: 'IDs utilisateur liés au dossier (parents du foyer ou AM)',
|
||||||
|
})
|
||||||
|
user_ids: string[];
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
enum: StatutUtilisateurType,
|
||||||
|
description:
|
||||||
|
'Statut agrégé : en_attente si au moins un user en_attente, sinon statut du premier',
|
||||||
|
})
|
||||||
|
statut: StatutUtilisateurType;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'True si le dossier est en attente de validation (section haute UI)',
|
||||||
|
})
|
||||||
|
a_valider: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
nullable: true,
|
||||||
|
example: '2026-01-12T10:00:00.000Z',
|
||||||
|
description: 'Date de référence (MIN cree_le des users du dossier)',
|
||||||
|
})
|
||||||
|
date_reference: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'True si dossier famille sans enfant lié (#159). Omis ou false pour AM.',
|
||||||
|
})
|
||||||
|
sans_enfant?: boolean;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsDateString,
|
IsDateString,
|
||||||
@@ -6,13 +7,25 @@ import {
|
|||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
|
IsUUID,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
ValidateIf,
|
ValidateIf,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { GenreType, StatutEnfantType } from 'src/entities/children.entity';
|
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 {
|
export class CreateEnfantsDto {
|
||||||
@ApiProperty({ enum: StatutEnfantType, example: StatutEnfantType.ACTIF })
|
@ApiProperty({ enum: StatutEnfantType, example: StatutEnfantType.SANS_GARDE })
|
||||||
@IsEnum(StatutEnfantType)
|
@IsEnum(StatutEnfantType)
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
status: StatutEnfantType;
|
status: StatutEnfantType;
|
||||||
@@ -52,6 +65,7 @@ export class CreateEnfantsDto {
|
|||||||
photo_url?: string;
|
photo_url?: string;
|
||||||
|
|
||||||
@ApiProperty({ default: false })
|
@ApiProperty({ default: false })
|
||||||
|
@Transform(toBoolean)
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
consent_photo: boolean;
|
consent_photo: boolean;
|
||||||
|
|
||||||
@@ -61,6 +75,20 @@ export class CreateEnfantsDto {
|
|||||||
consent_photo_at?: string;
|
consent_photo_at?: string;
|
||||||
|
|
||||||
@ApiProperty({ default: false })
|
@ApiProperty({ default: false })
|
||||||
|
@Transform(toBoolean)
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
is_multiple: boolean;
|
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,35 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
|
CallHandler,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
|
ExecutionContext,
|
||||||
Get,
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Injectable,
|
||||||
|
NestInterceptor,
|
||||||
Param,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
Query,
|
||||||
|
UploadedFile,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
UploadedFile,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { ApiBearerAuth, ApiTags, ApiConsumes } from '@nestjs/swagger';
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiBody,
|
||||||
|
ApiConsumes,
|
||||||
|
ApiOperation,
|
||||||
|
ApiQuery,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
import { diskStorage } from 'multer';
|
import { diskStorage } from 'multer';
|
||||||
import { extname } from 'path';
|
import { extname } from 'path';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
import { EnfantsService } from './enfants.service';
|
import { EnfantsService } from './enfants.service';
|
||||||
import { CreateEnfantsDto } from './dto/create_enfants.dto';
|
import { CreateEnfantsDto } from './dto/create_enfants.dto';
|
||||||
import { UpdateEnfantsDto } from './dto/update_enfants.dto';
|
import { UpdateEnfantsDto } from './dto/update_enfants.dto';
|
||||||
@@ -23,38 +38,80 @@ import { User } from 'src/common/decorators/user.decorator';
|
|||||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||||
import { Roles } from 'src/common/decorators/roles.decorator';
|
import { Roles } from 'src/common/decorators/roles.decorator';
|
||||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||||
|
import { SuppressionService } from '../suppressions/suppression.service';
|
||||||
|
|
||||||
|
const photoMulterOptions = {
|
||||||
|
storage: diskStorage({
|
||||||
|
destination: './uploads/photos',
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||||
|
const ext = extname(file.originalname);
|
||||||
|
cb(null, `enfant-${uniqueSuffix}${ext}`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
if (!file.mimetype.match(/\/(jpg|jpeg|png|gif)$/)) {
|
||||||
|
return cb(new Error('Seules les images sont autorisées'), false);
|
||||||
|
}
|
||||||
|
cb(null, true);
|
||||||
|
},
|
||||||
|
limits: {
|
||||||
|
fileSize: 5 * 1024 * 1024,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multer uniquement si Content-Type multipart (parent ou staff + photo).
|
||||||
|
* JSON sans photo (#132) passe sans interceptor fichier.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
class OptionalEnfantPhotoInterceptor implements NestInterceptor {
|
||||||
|
private readonly multipart = new (FileInterceptor(
|
||||||
|
'photo',
|
||||||
|
photoMulterOptions,
|
||||||
|
))();
|
||||||
|
|
||||||
|
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> | Promise<Observable<unknown>> {
|
||||||
|
const req = context.switchToHttp().getRequest();
|
||||||
|
const ct = String(req.headers['content-type'] ?? '');
|
||||||
|
if (!ct.includes('multipart/form-data')) {
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
return this.multipart.intercept(context, next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ApiBearerAuth('access-token')
|
@ApiBearerAuth('access-token')
|
||||||
@ApiTags('Enfants')
|
@ApiTags('Enfants')
|
||||||
@UseGuards(AuthGuard, RolesGuard)
|
@UseGuards(AuthGuard, RolesGuard)
|
||||||
@Controller('enfants')
|
@Controller('enfants')
|
||||||
export class EnfantsController {
|
export class EnfantsController {
|
||||||
constructor(private readonly enfantsService: EnfantsService) { }
|
constructor(
|
||||||
|
private readonly enfantsService: EnfantsService,
|
||||||
|
private readonly suppressionService: SuppressionService,
|
||||||
|
) { }
|
||||||
|
|
||||||
@Roles(RoleType.PARENT)
|
@Roles(
|
||||||
@Post()
|
RoleType.PARENT,
|
||||||
@ApiConsumes('multipart/form-data')
|
RoleType.GESTIONNAIRE,
|
||||||
@UseInterceptors(
|
RoleType.ADMINISTRATEUR,
|
||||||
FileInterceptor('photo', {
|
RoleType.SUPER_ADMIN,
|
||||||
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,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
|
@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(
|
create(
|
||||||
@Body() dto: CreateEnfantsDto,
|
@Body() dto: CreateEnfantsDto,
|
||||||
@UploadedFile() photo: Express.Multer.File,
|
@UploadedFile() photo: Express.Multer.File,
|
||||||
@@ -90,17 +147,47 @@ export class EnfantsController {
|
|||||||
RoleType.GESTIONNAIRE,
|
RoleType.GESTIONNAIRE,
|
||||||
)
|
)
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Mettre à jour un enfant',
|
||||||
|
description:
|
||||||
|
'JSON sans photo OK ; avec nouvelle photo → multipart (champ fichier `photo`, max 5 Mo).',
|
||||||
|
})
|
||||||
|
@ApiConsumes('application/json', 'multipart/form-data')
|
||||||
|
@UseInterceptors(OptionalEnfantPhotoInterceptor)
|
||||||
update(
|
update(
|
||||||
@Param('id', new ParseUUIDPipe()) id: string,
|
@Param('id', new ParseUUIDPipe()) id: string,
|
||||||
@Body() dto: UpdateEnfantsDto,
|
@Body() dto: UpdateEnfantsDto,
|
||||||
|
@UploadedFile() photo: Express.Multer.File,
|
||||||
@User() currentUser: Users,
|
@User() currentUser: Users,
|
||||||
) {
|
) {
|
||||||
return this.enfantsService.update(id, dto, currentUser);
|
return this.enfantsService.update(id, dto, currentUser, photo);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleType.SUPER_ADMIN)
|
@Roles(
|
||||||
|
RoleType.SUPER_ADMIN,
|
||||||
|
RoleType.ADMINISTRATEUR,
|
||||||
|
RoleType.GESTIONNAIRE,
|
||||||
|
)
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
remove(@Param('id', new ParseUUIDPipe()) id: string) {
|
@ApiOperation({
|
||||||
return this.enfantsService.remove(id);
|
summary: 'Supprimer un enfant (#159)',
|
||||||
|
description:
|
||||||
|
'Query `deleteDossier=true` si dernier enfant et suppression du dossier famille souhaitée.',
|
||||||
|
})
|
||||||
|
@ApiQuery({
|
||||||
|
name: 'deleteDossier',
|
||||||
|
required: false,
|
||||||
|
description: 'Si true et dernier enfant : cascade dossier famille',
|
||||||
|
})
|
||||||
|
remove(
|
||||||
|
@Param('id', new ParseUUIDPipe()) id: string,
|
||||||
|
@Query('deleteDossier') deleteDossier: string | undefined,
|
||||||
|
@User() currentUser: Users,
|
||||||
|
) {
|
||||||
|
const flag =
|
||||||
|
deleteDossier === 'true' ||
|
||||||
|
deleteDossier === '1' ||
|
||||||
|
deleteDossier === 'yes';
|
||||||
|
return this.suppressionService.deleteEnfant(id, flag, currentUser);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,16 @@ import { Children } from 'src/entities/children.entity';
|
|||||||
import { Parents } from 'src/entities/parents.entity';
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { SuppressionsModule } from '../suppressions/suppressions.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Children, Parents, ParentsChildren]),
|
imports: [
|
||||||
AuthModule
|
TypeOrmModule.forFeature([Children, Parents, ParentsChildren]),
|
||||||
|
AuthModule,
|
||||||
|
SuppressionsModule,
|
||||||
],
|
],
|
||||||
controllers: [EnfantsController],
|
controllers: [EnfantsController],
|
||||||
providers: [EnfantsService]
|
providers: [EnfantsService],
|
||||||
|
exports: [EnfantsService],
|
||||||
})
|
})
|
||||||
export class EnfantsModule { }
|
export class EnfantsModule { }
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ import { ParentsChildren } from 'src/entities/parents_children.entity';
|
|||||||
import { RoleType, Users } from 'src/entities/users.entity';
|
import { RoleType, Users } from 'src/entities/users.entity';
|
||||||
import { CreateEnfantsDto } from './dto/create_enfants.dto';
|
import { CreateEnfantsDto } from './dto/create_enfants.dto';
|
||||||
|
|
||||||
|
const STAFF_ROLES: RoleType[] = [
|
||||||
|
RoleType.GESTIONNAIRE,
|
||||||
|
RoleType.ADMINISTRATEUR,
|
||||||
|
RoleType.SUPER_ADMIN,
|
||||||
|
];
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EnfantsService {
|
export class EnfantsService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -24,20 +30,35 @@ export class EnfantsService {
|
|||||||
private readonly parentsChildrenRepository: Repository<ParentsChildren>,
|
private readonly parentsChildrenRepository: Repository<ParentsChildren>,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
// Création d'un enfant
|
private isStaff(user: Users): boolean {
|
||||||
async create(dto: CreateEnfantsDto, currentUser: Users, photoFile?: Express.Multer.File): Promise<Children> {
|
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({
|
const parent = await this.parentsRepository.findOne({
|
||||||
where: { user_id: currentUser.id },
|
where: { user_id: pivotUserId },
|
||||||
relations: ['co_parent'],
|
relations: ['co_parent'],
|
||||||
});
|
});
|
||||||
if (!parent) throw new NotFoundException('Parent introuvable');
|
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) {
|
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({
|
const exist = await this.childrenRepository.findOne({
|
||||||
where: {
|
where: {
|
||||||
first_name: dto.first_name,
|
first_name: dto.first_name,
|
||||||
@@ -47,50 +68,108 @@ export class EnfantsService {
|
|||||||
});
|
});
|
||||||
if (exist) throw new ConflictException('Cet enfant existe déjà');
|
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) {
|
if (photoFile) {
|
||||||
dto.photo_url = `/uploads/photos/${photoFile.filename}`;
|
photoUrl = `/uploads/photos/${photoFile.filename}`;
|
||||||
if (dto.consent_photo) {
|
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({
|
||||||
const child = this.childrenRepository.create(dto);
|
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);
|
await this.childrenRepository.save(child);
|
||||||
|
|
||||||
// Lien parent-enfant (Parent 1)
|
// Lien parent-enfant (pivot)
|
||||||
const parentLink = this.parentsChildrenRepository.create({
|
await this.parentsChildrenRepository.save(
|
||||||
parentId: parent.user_id,
|
this.parentsChildrenRepository.create({
|
||||||
enfantId: child.id,
|
parentId: parent.user_id,
|
||||||
});
|
enfantId: child.id,
|
||||||
await this.parentsChildrenRepository.save(parentLink);
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// Rattachement automatique au co-parent s'il existe
|
// Rattachement automatique au co-parent s'il existe
|
||||||
if (parent.co_parent) {
|
if (parent.co_parent) {
|
||||||
const coParentLink = this.parentsChildrenRepository.create({
|
await this.parentsChildrenRepository.save(
|
||||||
parentId: parent.co_parent.id,
|
this.parentsChildrenRepository.create({
|
||||||
enfantId: child.id,
|
parentId: parent.co_parent.id,
|
||||||
});
|
enfantId: child.id,
|
||||||
await this.parentsChildrenRepository.save(coParentLink);
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.findOne(child.id, currentUser);
|
return this.findOne(child.id, currentUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Liste des enfants (admin/gestionnaire)
|
private resolvePivotParentUserId(
|
||||||
async findAll(): Promise<Children[]> {
|
dto: CreateEnfantsDto,
|
||||||
return this.childrenRepository.find({
|
currentUser: Users,
|
||||||
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'],
|
): string {
|
||||||
order: { last_name: 'ASC', first_name: 'ASC' },
|
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
|
// 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({
|
const child = await this.childrenRepository.findOne({
|
||||||
where: { id },
|
where: { id },
|
||||||
relations: ['parentLinks'],
|
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'],
|
||||||
});
|
});
|
||||||
if (!child) throw new NotFoundException('Enfant introuvable');
|
if (!child) throw new NotFoundException('Enfant introuvable');
|
||||||
|
|
||||||
@@ -104,23 +183,42 @@ export class EnfantsService {
|
|||||||
case RoleType.ADMINISTRATEUR:
|
case RoleType.ADMINISTRATEUR:
|
||||||
case RoleType.SUPER_ADMIN:
|
case RoleType.SUPER_ADMIN:
|
||||||
case RoleType.GESTIONNAIRE:
|
case RoleType.GESTIONNAIRE:
|
||||||
// accès complet
|
// accès complet (y compris orphelins)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new ForbiddenException('Accès interdit');
|
throw new ForbiddenException('Accès interdit');
|
||||||
}
|
}
|
||||||
|
|
||||||
return child;
|
return this.withSansResponsable(child);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Mise à jour
|
// Mise à jour
|
||||||
async update(id: string, dto: Partial<CreateEnfantsDto>, currentUser: Users): Promise<Children> {
|
async update(
|
||||||
|
id: string,
|
||||||
|
dto: Partial<CreateEnfantsDto>,
|
||||||
|
currentUser: Users,
|
||||||
|
photoFile?: Express.Multer.File,
|
||||||
|
): Promise<Children> {
|
||||||
const child = await this.childrenRepository.findOne({ where: { id } });
|
const child = await this.childrenRepository.findOne({ where: { id } });
|
||||||
if (!child) throw new NotFoundException('Enfant introuvable');
|
if (!child) throw new NotFoundException('Enfant introuvable');
|
||||||
|
|
||||||
await this.childrenRepository.update(id, dto);
|
const { parent_user_id: _ignored, ...rest } = dto;
|
||||||
|
const patch: Partial<Children> = { ...rest } as Partial<Children>;
|
||||||
|
if (dto.consent_photo !== undefined) {
|
||||||
|
patch.consent_photo = dto.consent_photo;
|
||||||
|
patch.consent_photo_at = dto.consent_photo ? new Date() : null!;
|
||||||
|
}
|
||||||
|
if (photoFile) {
|
||||||
|
patch.photo_url = `/uploads/photos/${photoFile.filename}`;
|
||||||
|
if (dto.consent_photo !== false) {
|
||||||
|
patch.consent_photo = true;
|
||||||
|
patch.consent_photo_at = new Date();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.childrenRepository.update(id, patch);
|
||||||
return this.findOne(id, currentUser);
|
return this.findOne(id, currentUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ export class ParentPendingSummaryDto {
|
|||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
email: string;
|
email: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ nullable: true })
|
||||||
|
nom?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ nullable: true })
|
||||||
|
prenom?: string | null;
|
||||||
|
|
||||||
@ApiPropertyOptional({ nullable: true })
|
@ApiPropertyOptional({ nullable: true })
|
||||||
telephone?: string | null;
|
telephone?: string | null;
|
||||||
|
|
||||||
@@ -18,7 +24,10 @@ export class ParentPendingSummaryDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class PendingFamilyDto {
|
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;
|
libelle: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||||
|
|
||||||
|
/** Réponse 201 POST /parents/:id/co-parent (#135). */
|
||||||
|
export class StaffAddCoParentResponseDto {
|
||||||
|
@ApiProperty()
|
||||||
|
message: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '2026-000043' })
|
||||||
|
numero_dossier: string;
|
||||||
|
|
||||||
|
@ApiProperty({ format: 'uuid', description: 'UUID du parent pivot' })
|
||||||
|
parent_user_id: string;
|
||||||
|
|
||||||
|
@ApiProperty({ format: 'uuid', description: 'UUID du co-parent créé' })
|
||||||
|
co_parent_user_id: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
enum: StatutUtilisateurType,
|
||||||
|
example: StatutUtilisateurType.ACTIF,
|
||||||
|
})
|
||||||
|
statut: StatutUtilisateurType;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsEmail,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
MaxLength,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ajout d’un co-parent sur un foyer existant (staff) — ticket #135.
|
||||||
|
* Corps sans préfixe `co_parent_*` (l’URL cible déjà le pivot).
|
||||||
|
*/
|
||||||
|
export class StaffAddCoParentDto {
|
||||||
|
@ApiProperty({ example: 'thomas.martin@ptits-pas.fr' })
|
||||||
|
@IsEmail({}, { message: 'Email invalide' })
|
||||||
|
@IsNotEmpty({ message: "L'email est requis" })
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Thomas' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: 'Le prénom est requis' })
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(100)
|
||||||
|
prenom: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'MARTIN' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: 'Le nom est requis' })
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(100)
|
||||||
|
nom: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '0678456789' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: 'Le téléphone est requis' })
|
||||||
|
@Matches(/^(\+33|0)[1-9](\d{2}){4}$/, {
|
||||||
|
message: 'Le numéro de téléphone doit être valide (ex: 0689567890 ou +33689567890)',
|
||||||
|
})
|
||||||
|
telephone: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: true,
|
||||||
|
description: 'Si true, copie l’adresse du parent pivot',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
meme_adresse?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
adresse?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(10)
|
||||||
|
code_postal?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(150)
|
||||||
|
ville?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||||
|
|
||||||
|
/** Réponse 201 POST /parents/dossier (#129). */
|
||||||
|
export class StaffCreateParentDossierResponseDto {
|
||||||
|
@ApiProperty()
|
||||||
|
message: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: '2026-000043',
|
||||||
|
description: 'Numéro de dossier famille attribué',
|
||||||
|
})
|
||||||
|
numero_dossier: string;
|
||||||
|
|
||||||
|
@ApiProperty({ format: 'uuid', description: 'UUID user du parent pivot' })
|
||||||
|
parent_user_id: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
format: 'uuid',
|
||||||
|
nullable: true,
|
||||||
|
description: 'UUID user du co-parent, ou null',
|
||||||
|
})
|
||||||
|
co_parent_user_id: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
enum: StatutUtilisateurType,
|
||||||
|
example: StatutUtilisateurType.ACTIF,
|
||||||
|
})
|
||||||
|
statut: StatutUtilisateurType;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: [String],
|
||||||
|
format: 'uuid',
|
||||||
|
description: 'IDs des enfants créés',
|
||||||
|
})
|
||||||
|
enfant_ids: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { ApiPropertyOptional, OmitType } from '@nestjs/swagger';
|
||||||
|
import { IsBoolean, IsOptional } from 'class-validator';
|
||||||
|
import { RegisterParentCompletDto } from 'src/routes/auth/dto/register-parent-complet.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Création dossier parent/famille par staff (#129).
|
||||||
|
* Mêmes champs que l'inscription publique, sans CGU/privacy obligatoires
|
||||||
|
* (acceptées côté serveur pour le compte du gestionnaire).
|
||||||
|
*/
|
||||||
|
export class StaffCreateParentDossierDto extends OmitType(RegisterParentCompletDto, [
|
||||||
|
'acceptation_cgu',
|
||||||
|
'acceptation_privacy',
|
||||||
|
] as const) {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Ignoré côté staff (CGU acceptées serveur). Conservé pour compat éventuelle.',
|
||||||
|
default: true,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
acceptation_cgu?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Ignoré côté staff (privacy acceptée serveur).',
|
||||||
|
default: true,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
acceptation_privacy?: boolean;
|
||||||
|
}
|
||||||
@@ -1,18 +1,104 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { ParentsController } from './parents.controller';
|
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', () => {
|
describe('ParentsController', () => {
|
||||||
let controller: ParentsController;
|
let controller: ParentsController;
|
||||||
|
const authServiceMock = {
|
||||||
|
createParentDossierStaff: jest.fn(),
|
||||||
|
addCoParentStaff: jest.fn(),
|
||||||
|
};
|
||||||
|
const parentsServiceMock = {};
|
||||||
|
const userServiceMock = {};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
controllers: [ParentsController],
|
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);
|
controller = module.get<ParentsController>(ParentsController);
|
||||||
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
expect(controller).toBeDefined();
|
expect(controller).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('createDossier delegates to authService.createParentDossierStaff with CGU accepted', async () => {
|
||||||
|
authServiceMock.createParentDossierStaff.mockResolvedValue({
|
||||||
|
message: 'Dossier famille créé et validé. Un e-mail de création de mot de passe a été envoyé.',
|
||||||
|
parent_user_id: 'p1',
|
||||||
|
co_parent_user_id: 'p2',
|
||||||
|
enfant_ids: ['e1'],
|
||||||
|
statut: StatutUtilisateurType.ACTIF,
|
||||||
|
numero_dossier: '2026-000043',
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
email: 'parent.staff@test.fr',
|
||||||
|
prenom: 'Claire',
|
||||||
|
nom: 'MARTIN',
|
||||||
|
telephone: '0689567890',
|
||||||
|
enfants: [
|
||||||
|
{
|
||||||
|
prenom: 'Emma',
|
||||||
|
nom: 'MARTIN',
|
||||||
|
date_naissance: '2023-02-15',
|
||||||
|
genre: 'F',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await controller.createDossier(body as any);
|
||||||
|
expect(authServiceMock.createParentDossierStaff).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
email: body.email,
|
||||||
|
acceptation_cgu: true,
|
||||||
|
acceptation_privacy: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.numero_dossier).toBe('2026-000043');
|
||||||
|
expect(res.parent_user_id).toBe('p1');
|
||||||
|
expect(res.co_parent_user_id).toBe('p2');
|
||||||
|
expect(res.enfant_ids).toEqual(['e1']);
|
||||||
|
expect(res.statut).toBe(StatutUtilisateurType.ACTIF);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addCoParent delegates to authService.addCoParentStaff', async () => {
|
||||||
|
authServiceMock.addCoParentStaff.mockResolvedValue({
|
||||||
|
message: 'ok',
|
||||||
|
numero_dossier: '2026-000043',
|
||||||
|
parent_user_id: 'p1',
|
||||||
|
co_parent_user_id: 'p2',
|
||||||
|
statut: StatutUtilisateurType.ACTIF,
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
email: 'coparent@test.fr',
|
||||||
|
prenom: 'Thomas',
|
||||||
|
nom: 'MARTIN',
|
||||||
|
telephone: '0678456789',
|
||||||
|
meme_adresse: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await controller.addCoParent('p1', body as any);
|
||||||
|
expect(authServiceMock.addCoParentStaff).toHaveBeenCalledWith('p1', body);
|
||||||
|
expect(res.co_parent_user_id).toBe('p2');
|
||||||
|
expect(res.statut).toBe(StatutUtilisateurType.ACTIF);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
@@ -10,29 +12,79 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ParentsService } from './parents.service';
|
import { ParentsService } from './parents.service';
|
||||||
import { UserService } from '../user/user.service';
|
import { UserService } from '../user/user.service';
|
||||||
|
import { AuthService } from '../auth/auth.service';
|
||||||
import { Parents } from 'src/entities/parents.entity';
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
import { Users } from 'src/entities/users.entity';
|
import { Users } from 'src/entities/users.entity';
|
||||||
import { Roles } from 'src/common/decorators/roles.decorator';
|
import { Roles } from 'src/common/decorators/roles.decorator';
|
||||||
import { RoleType, StatutUtilisateurType } from 'src/entities/users.entity';
|
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 { CreateParentDto } from '../user/dto/create_parent.dto';
|
||||||
import { UpdateParentsDto } from '../user/dto/update_parent.dto';
|
import { UpdateParentsDto } from '../user/dto/update_parent.dto';
|
||||||
import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto';
|
import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto';
|
||||||
|
import { StaffCreateParentDossierDto } from './dto/staff-create-parent-dossier.dto';
|
||||||
|
import { StaffCreateParentDossierResponseDto } from './dto/staff-create-parent-dossier-response.dto';
|
||||||
|
import { StaffAddCoParentDto } from './dto/staff-add-co-parent.dto';
|
||||||
|
import { StaffAddCoParentResponseDto } from './dto/staff-add-co-parent-response.dto';
|
||||||
|
import { RegisterParentCompletDto } from '../auth/dto/register-parent-complet.dto';
|
||||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||||
import { User } from 'src/common/decorators/user.decorator';
|
import { User } from 'src/common/decorators/user.decorator';
|
||||||
import { PendingFamilyDto } from './dto/pending-family.dto';
|
import { PendingFamilyDto } from './dto/pending-family.dto';
|
||||||
import { DossierFamilleCompletDto } from './dto/dossier-famille-complet.dto';
|
import { DossierFamilleCompletDto } from './dto/dossier-famille-complet.dto';
|
||||||
|
import { mapParentForApi, mapParentsForApi } from './parents.mapper';
|
||||||
|
|
||||||
@ApiTags('Parents')
|
@ApiTags('Parents')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
@Controller('parents')
|
@Controller('parents')
|
||||||
@UseGuards(AuthGuard, RolesGuard)
|
@UseGuards(AuthGuard, RolesGuard)
|
||||||
export class ParentsController {
|
export class ParentsController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly parentsService: ParentsService,
|
private readonly parentsService: ParentsService,
|
||||||
private readonly userService: UserService,
|
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')
|
@Get('pending-families')
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||||
@ApiOperation({ summary: 'Liste des familles en attente (une entrée par famille)' })
|
@ApiOperation({ summary: 'Liste des familles en attente (une entrée par famille)' })
|
||||||
@@ -81,21 +133,25 @@ export class ParentsController {
|
|||||||
return validated;
|
return validated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
|
||||||
@Get()
|
@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: 200, type: [Parents], description: 'Liste des parents' })
|
||||||
@ApiResponse({ status: 403, description: 'Accès refusé !' })
|
@ApiResponse({ status: 403, description: 'Accès refusé !' })
|
||||||
getAll(): Promise<Parents[]> {
|
async getAll(): Promise<Parents[]> {
|
||||||
return this.parentsService.findAll();
|
const parents = await this.parentsService.findAll();
|
||||||
|
return mapParentsForApi(parents);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||||
@Get(':id')
|
@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: 200, type: Parents, description: 'Détails du parent par ID utilisateur' })
|
||||||
@ApiResponse({ status: 404, description: 'Parent non trouvé' })
|
@ApiResponse({ status: 404, description: 'Parent non trouvé' })
|
||||||
@ApiResponse({ status: 403, description: 'Accès refusé !' })
|
@ApiResponse({ status: 403, description: 'Accès refusé !' })
|
||||||
getOne(@Param('id') user_id: string): Promise<Parents> {
|
async getOne(@Param('id') user_id: string): Promise<Parents> {
|
||||||
return this.parentsService.findOne(user_id);
|
const parent = await this.parentsService.findOne(user_id);
|
||||||
|
return mapParentForApi(parent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
|
||||||
@@ -103,8 +159,9 @@ export class ParentsController {
|
|||||||
@ApiBody({ type: CreateParentDto })
|
@ApiBody({ type: CreateParentDto })
|
||||||
@ApiResponse({ status: 201, type: Parents, description: 'Parent créé avec succès' })
|
@ApiResponse({ status: 201, type: Parents, description: 'Parent créé avec succès' })
|
||||||
@ApiResponse({ status: 403, description: 'Accès refusé !' })
|
@ApiResponse({ status: 403, description: 'Accès refusé !' })
|
||||||
create(@Body() dto: CreateParentDto): Promise<Parents> {
|
async create(@Body() dto: CreateParentDto): Promise<Parents> {
|
||||||
return this.parentsService.create(dto);
|
const parent = await this.parentsService.create(dto);
|
||||||
|
return mapParentForApi(parent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||||
@@ -113,11 +170,34 @@ export class ParentsController {
|
|||||||
@ApiParam({ name: 'id', description: "UUID utilisateur du parent" })
|
@ApiParam({ name: 'id', description: "UUID utilisateur du parent" })
|
||||||
@ApiBody({ type: UpdateParentFicheAdminDto })
|
@ApiBody({ type: UpdateParentFicheAdminDto })
|
||||||
@ApiResponse({ status: 200, type: Parents, description: 'Fiche parent mise à jour' })
|
@ApiResponse({ status: 200, type: Parents, description: 'Fiche parent mise à jour' })
|
||||||
updateFicheAdmin(
|
async updateFicheAdmin(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() dto: UpdateParentFicheAdminDto,
|
@Body() dto: UpdateParentFicheAdminDto,
|
||||||
): Promise<Parents> {
|
): Promise<Parents> {
|
||||||
return this.parentsService.updateFicheAdmin(id, dto);
|
const parent = await this.parentsService.updateFicheAdmin(id, dto);
|
||||||
|
return mapParentForApi(parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||||
|
@Post(':id/co-parent')
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Ajouter un co-parent à un foyer existant (staff) — ticket #135',
|
||||||
|
description:
|
||||||
|
'Foyer mono-parent uniquement. Crée le co-parent actif, liens foyer + enfants, ' +
|
||||||
|
'e-mail de création de mot de passe. Ne pas utiliser POST /auth/register/parent.',
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'id', description: 'UUID utilisateur du parent pivot' })
|
||||||
|
@ApiBody({ type: StaffAddCoParentDto })
|
||||||
|
@ApiResponse({ status: 201, type: StaffAddCoParentResponseDto })
|
||||||
|
@ApiResponse({ status: 400, description: 'Foyer déjà à 2 parents / validation' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Parent introuvable' })
|
||||||
|
@ApiResponse({ status: 409, description: 'Email déjà pris' })
|
||||||
|
async addCoParent(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: StaffAddCoParentDto,
|
||||||
|
): Promise<StaffAddCoParentResponseDto> {
|
||||||
|
return this.authService.addCoParentStaff(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||||
@@ -126,11 +206,12 @@ export class ParentsController {
|
|||||||
@ApiParam({ name: 'id', description: "UUID utilisateur du parent" })
|
@ApiParam({ name: 'id', description: "UUID utilisateur du parent" })
|
||||||
@ApiParam({ name: 'enfantId', description: "UUID de l'enfant" })
|
@ApiParam({ name: 'enfantId', description: "UUID de l'enfant" })
|
||||||
@ApiResponse({ status: 200, type: Parents, description: 'Parent avec enfants mis à jour' })
|
@ApiResponse({ status: 200, type: Parents, description: 'Parent avec enfants mis à jour' })
|
||||||
attachEnfant(
|
async attachEnfant(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Param('enfantId') enfantId: string,
|
@Param('enfantId') enfantId: string,
|
||||||
): Promise<Parents> {
|
): Promise<Parents> {
|
||||||
return this.parentsService.attachEnfant(id, enfantId);
|
const parent = await this.parentsService.attachEnfant(id, enfantId);
|
||||||
|
return mapParentForApi(parent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||||
@@ -139,11 +220,12 @@ export class ParentsController {
|
|||||||
@ApiParam({ name: 'id', description: "UUID utilisateur du parent" })
|
@ApiParam({ name: 'id', description: "UUID utilisateur du parent" })
|
||||||
@ApiParam({ name: 'enfantId', description: "UUID de l'enfant" })
|
@ApiParam({ name: 'enfantId', description: "UUID de l'enfant" })
|
||||||
@ApiResponse({ status: 200, type: Parents, description: 'Parent avec enfants mis à jour' })
|
@ApiResponse({ status: 200, type: Parents, description: 'Parent avec enfants mis à jour' })
|
||||||
detachEnfant(
|
async detachEnfant(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Param('enfantId') enfantId: string,
|
@Param('enfantId') enfantId: string,
|
||||||
): Promise<Parents> {
|
): Promise<Parents> {
|
||||||
return this.parentsService.detachEnfant(id, enfantId);
|
const parent = await this.parentsService.detachEnfant(id, enfantId);
|
||||||
|
return mapParentForApi(parent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
|
||||||
@@ -152,7 +234,8 @@ export class ParentsController {
|
|||||||
@ApiResponse({ status: 200, type: Parents, description: 'Parent mis à jour avec succès' })
|
@ApiResponse({ status: 200, type: Parents, description: 'Parent mis à jour avec succès' })
|
||||||
@ApiResponse({ status: 404, description: 'Parent introuvable' })
|
@ApiResponse({ status: 404, description: 'Parent introuvable' })
|
||||||
@ApiResponse({ status: 403, description: 'Accès refusé !' })
|
@ApiResponse({ status: 403, description: 'Accès refusé !' })
|
||||||
update(@Param('id') id: string, @Body() dto: UpdateParentsDto): Promise<Parents> {
|
async update(@Param('id') id: string, @Body() dto: UpdateParentsDto): Promise<Parents> {
|
||||||
return this.parentsService.update(id, dto);
|
const parent = await this.parentsService.update(id, dto);
|
||||||
|
return mapParentForApi(parent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { mapParentForApi } from './parents.mapper';
|
||||||
|
import { Parents } from '../../entities/parents.entity';
|
||||||
|
import { RoleType, StatutUtilisateurType, Users } from '../../entities/users.entity';
|
||||||
|
|
||||||
|
describe('mapParentForApi', () => {
|
||||||
|
it('expose co_parent avec prenom/nom sans secrets', () => {
|
||||||
|
const coParent = {
|
||||||
|
id: 'cp1',
|
||||||
|
email: 'co@b.fr',
|
||||||
|
prenom: 'Clara',
|
||||||
|
nom: 'Co',
|
||||||
|
role: RoleType.PARENT,
|
||||||
|
statut: StatutUtilisateurType.ACTIF,
|
||||||
|
password: 'secret',
|
||||||
|
} as Users;
|
||||||
|
|
||||||
|
const parent = {
|
||||||
|
user_id: 'u1',
|
||||||
|
numero_dossier: '2026-000042',
|
||||||
|
user: {
|
||||||
|
id: 'u1',
|
||||||
|
email: 'p@b.fr',
|
||||||
|
prenom: 'Paul',
|
||||||
|
nom: 'Parent',
|
||||||
|
role: RoleType.PARENT,
|
||||||
|
password: 'secret',
|
||||||
|
} as Users,
|
||||||
|
co_parent: coParent,
|
||||||
|
parentChildren: [],
|
||||||
|
} as Parents;
|
||||||
|
|
||||||
|
const out = mapParentForApi(parent);
|
||||||
|
expect(out.co_parent?.prenom).toBe('Clara');
|
||||||
|
expect(out.co_parent?.nom).toBe('Co');
|
||||||
|
expect(out.co_parent?.password).toBeUndefined();
|
||||||
|
expect(out.user.password).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
|
import { sanitizeUserForApi } from '../../common/utils/sanitize-user-for-api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sérialisation API fiche parent — ticket #131.
|
||||||
|
* Garantit `user`, `co_parent` (si présent) et relations sans champs sensibles.
|
||||||
|
*/
|
||||||
|
export function mapParentForApi(parent: Parents): Parents {
|
||||||
|
return {
|
||||||
|
...parent,
|
||||||
|
user: sanitizeUserForApi(parent.user)!,
|
||||||
|
co_parent: sanitizeUserForApi(parent.co_parent),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapParentsForApi(parents: Parents[]): Parents[] {
|
||||||
|
return parents.map(mapParentForApi);
|
||||||
|
}
|
||||||
@@ -9,11 +9,13 @@ import { ParentsController } from './parents.controller';
|
|||||||
import { ParentsService } from './parents.service';
|
import { ParentsService } from './parents.service';
|
||||||
import { Users } from 'src/entities/users.entity';
|
import { Users } from 'src/entities/users.entity';
|
||||||
import { UserModule } from '../user/user.module';
|
import { UserModule } from '../user/user.module';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Parents, Users, DossierFamille, DossierFamilleEnfant, ParentsChildren]),
|
TypeOrmModule.forFeature([Parents, Users, DossierFamille, DossierFamilleEnfant, ParentsChildren]),
|
||||||
forwardRef(() => UserModule),
|
forwardRef(() => UserModule),
|
||||||
|
forwardRef(() => AuthModule),
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
useFactory: (config: ConfigService) => ({
|
useFactory: (config: ConfigService) => ({
|
||||||
|
|||||||
@@ -114,34 +114,86 @@ export class ParentsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rattacher un enfant existant à un parent (enfants_parents). Ticket #115 / doc 28 §6.2.
|
* Membres du foyer (user ids) pour affiliation enfant.
|
||||||
|
* Pivot + co-parent (A→B et B→A) + même numero_dossier. Ticket #158.
|
||||||
*/
|
*/
|
||||||
async attachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
|
private async resolveFoyerParentUserIds(parent: Parents): Promise<string[]> {
|
||||||
await this.findOne(parentUserId);
|
const ids = new Set<string>([parent.user_id]);
|
||||||
|
|
||||||
const existing = await this.parentsChildrenRepository.findOne({
|
if (parent.co_parent?.id) {
|
||||||
where: { parentId: parentUserId, enfantId },
|
ids.add(parent.co_parent.id);
|
||||||
});
|
|
||||||
if (existing) {
|
|
||||||
throw new ConflictException('Cet enfant est déjà rattaché à ce parent');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const child = await this.parentsRepository.manager.findOne(Children, { where: { id: enfantId } });
|
// Sens inverse : parents qui déclarent ce user comme co-parent
|
||||||
|
const reverseLinks = await this.parentsRepository.find({
|
||||||
|
where: { co_parent: { id: parent.user_id } },
|
||||||
|
relations: ['co_parent'],
|
||||||
|
});
|
||||||
|
for (const p of reverseLinks) {
|
||||||
|
ids.add(p.user_id);
|
||||||
|
if (p.co_parent?.id) ids.add(p.co_parent.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dossier = parent.numero_dossier?.trim();
|
||||||
|
if (dossier) {
|
||||||
|
const sameDossier = await this.parentsRepository.find({
|
||||||
|
where: { numero_dossier: dossier },
|
||||||
|
relations: ['co_parent'],
|
||||||
|
});
|
||||||
|
for (const p of sameDossier) {
|
||||||
|
ids.add(p.user_id);
|
||||||
|
if (p.co_parent?.id) ids.add(p.co_parent.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...ids];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rattacher un enfant au foyer du parent (tous les responsables). Ticket #158.
|
||||||
|
* Un seul POST suffit : liens créés pour pivot + co-parent / même dossier.
|
||||||
|
*/
|
||||||
|
async attachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
|
||||||
|
const parent = await this.findOne(parentUserId);
|
||||||
|
|
||||||
|
const child = await this.parentsRepository.manager.findOne(Children, {
|
||||||
|
where: { id: enfantId },
|
||||||
|
});
|
||||||
if (!child) {
|
if (!child) {
|
||||||
throw new NotFoundException('Enfant introuvable');
|
throw new NotFoundException('Enfant introuvable');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.parentsChildrenRepository.save(
|
const foyerIds = await this.resolveFoyerParentUserIds(parent);
|
||||||
this.parentsChildrenRepository.create({ parentId: parentUserId, enfantId }),
|
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);
|
return this.findOne(parentUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Détacher un enfant d'un parent sans supprimer l'enfant. Ticket #115 / doc 28 §6.2.
|
* Détacher un enfant du foyer du parent (tous les responsables). Ticket #158.
|
||||||
|
* Si plus aucun lien ensuite → enfant orphelin (#157).
|
||||||
*/
|
*/
|
||||||
async detachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
|
async detachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
|
||||||
await this.findOne(parentUserId);
|
const parent = await this.findOne(parentUserId);
|
||||||
|
|
||||||
const link = await this.parentsChildrenRepository.findOne({
|
const link = await this.parentsChildrenRepository.findOne({
|
||||||
where: { parentId: parentUserId, enfantId },
|
where: { parentId: parentUserId, enfantId },
|
||||||
@@ -150,12 +202,12 @@ export class ParentsService {
|
|||||||
throw new NotFoundException('Lien parent-enfant introuvable');
|
throw new NotFoundException('Lien parent-enfant introuvable');
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalLinks = await this.parentsChildrenRepository.count({ where: { enfantId } });
|
const foyerIds = await this.resolveFoyerParentUserIds(parent);
|
||||||
if (totalLinks <= 1) {
|
await this.parentsChildrenRepository.delete({
|
||||||
throw new BadRequestException('Un enfant doit rester rattaché à au moins un responsable');
|
parentId: In(foyerIds),
|
||||||
}
|
enfantId,
|
||||||
|
});
|
||||||
|
|
||||||
await this.parentsChildrenRepository.delete({ parentId: parentUserId, enfantId });
|
|
||||||
return this.findOne(parentUserId);
|
return this.findOne(parentUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,7 +251,15 @@ export class ParentsService {
|
|||||||
SELECT id, (MIN(rep::text))::uuid AS rep FROM rec GROUP BY id
|
SELECT id, (MIN(rep::text))::uuid AS rep FROM rec GROUP BY id
|
||||||
)
|
)
|
||||||
SELECT
|
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.id_utilisateur ORDER BY u.nom, u.prenom, u.id) AS "parentIds",
|
||||||
(array_agg(p.numero_dossier))[1] AS numero_dossier,
|
(array_agg(p.numero_dossier))[1] AS numero_dossier,
|
||||||
MIN(u.cree_le) AS date_soumission,
|
MIN(u.cree_le) AS date_soumission,
|
||||||
@@ -215,6 +275,8 @@ export class ParentsService {
|
|||||||
json_build_object(
|
json_build_object(
|
||||||
'id', u.id::text,
|
'id', u.id::text,
|
||||||
'email', u.email,
|
'email', u.email,
|
||||||
|
'nom', u.nom,
|
||||||
|
'prenom', u.prenom,
|
||||||
'telephone', u.telephone,
|
'telephone', u.telephone,
|
||||||
'code_postal', u.code_postal,
|
'code_postal', u.code_postal,
|
||||||
'ville', u.ville
|
'ville', u.ville
|
||||||
@@ -265,11 +327,21 @@ export class ParentsService {
|
|||||||
return [];
|
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)) {
|
if (Array.isArray(parents)) {
|
||||||
return parents.map((p: any) => ({
|
return parents.map((p: any) => ({
|
||||||
id: String(p?.id ?? ''),
|
id: String(p?.id ?? ''),
|
||||||
email: String(p?.email ?? ''),
|
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,
|
telephone: p?.telephone != null ? String(p.telephone) : null,
|
||||||
code_postal: p?.code_postal != null ? String(p.code_postal) : null,
|
code_postal: p?.code_postal != null ? String(p.code_postal) : null,
|
||||||
ville: p?.ville != null ? String(p.ville) : null,
|
ville: p?.ville != null ? String(p.ville) : null,
|
||||||
|
|||||||
@@ -24,15 +24,19 @@ export class RelaisController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||||
@ApiOperation({ summary: 'Lister tous les relais' })
|
@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.' })
|
@ApiResponse({ status: 200, description: 'Liste des relais.' })
|
||||||
findAll() {
|
findAll() {
|
||||||
return this.relaisService.findAll();
|
return this.relaisService.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@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' })
|
@ApiOperation({ summary: 'Récupérer un relais par ID' })
|
||||||
@ApiResponse({ status: 200, description: 'Le relais trouvé.' })
|
@ApiResponse({ status: 200, description: 'Le relais trouvé.' })
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string) {
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { SuppressionService } from './suppression.service';
|
||||||
|
import { RoleType } from 'src/entities/users.entity';
|
||||||
|
|
||||||
|
describe('SuppressionService (#159)', () => {
|
||||||
|
const dataSource = {
|
||||||
|
transaction: jest.fn(async (cb: (m: unknown) => Promise<unknown>) =>
|
||||||
|
cb({
|
||||||
|
delete: jest.fn(),
|
||||||
|
query: jest.fn(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const usersRepository = {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
count: jest.fn(),
|
||||||
|
};
|
||||||
|
const parentsRepository = {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
find: jest.fn(),
|
||||||
|
query: jest.fn(),
|
||||||
|
};
|
||||||
|
const amRepository = {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
};
|
||||||
|
const childrenRepository = {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
save: jest.fn(),
|
||||||
|
};
|
||||||
|
const parentsChildrenRepository = {
|
||||||
|
find: jest.fn(),
|
||||||
|
};
|
||||||
|
const amChildrenRepository = {
|
||||||
|
find: jest.fn(),
|
||||||
|
save: jest.fn(),
|
||||||
|
count: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let service: SuppressionService;
|
||||||
|
|
||||||
|
const staff = {
|
||||||
|
id: 'staff-1',
|
||||||
|
role: RoleType.GESTIONNAIRE,
|
||||||
|
} as never;
|
||||||
|
|
||||||
|
const admin = {
|
||||||
|
id: 'admin-1',
|
||||||
|
role: RoleType.ADMINISTRATEUR,
|
||||||
|
} as never;
|
||||||
|
|
||||||
|
const superAdmin = {
|
||||||
|
id: 'sa-1',
|
||||||
|
role: RoleType.SUPER_ADMIN,
|
||||||
|
} as never;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
service = new SuppressionService(
|
||||||
|
dataSource as never,
|
||||||
|
usersRepository as never,
|
||||||
|
parentsRepository as never,
|
||||||
|
amRepository as never,
|
||||||
|
childrenRepository as never,
|
||||||
|
parentsChildrenRepository as never,
|
||||||
|
amChildrenRepository as never,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuse self-delete', async () => {
|
||||||
|
usersRepository.findOne.mockResolvedValue({
|
||||||
|
id: 'admin-1',
|
||||||
|
role: RoleType.ADMINISTRATEUR,
|
||||||
|
});
|
||||||
|
await expect(service.deleteUser('admin-1', admin)).rejects.toBeInstanceOf(
|
||||||
|
ForbiddenException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuse gestionnaire deleting another gestionnaire', async () => {
|
||||||
|
usersRepository.findOne.mockResolvedValue({
|
||||||
|
id: 'g2',
|
||||||
|
role: RoleType.GESTIONNAIRE,
|
||||||
|
});
|
||||||
|
await expect(service.deleteUser('g2', staff)).rejects.toBeInstanceOf(
|
||||||
|
ForbiddenException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dernier admin : refus si pas super_admin', async () => {
|
||||||
|
usersRepository.findOne.mockResolvedValue({
|
||||||
|
id: 'admin-2',
|
||||||
|
role: RoleType.ADMINISTRATEUR,
|
||||||
|
});
|
||||||
|
usersRepository.count.mockResolvedValue(1);
|
||||||
|
await expect(service.deleteUser('admin-2', admin)).rejects.toBeInstanceOf(
|
||||||
|
ForbiddenException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dernier admin : OK pour super_admin', async () => {
|
||||||
|
usersRepository.findOne.mockResolvedValue({
|
||||||
|
id: 'admin-2',
|
||||||
|
role: RoleType.ADMINISTRATEUR,
|
||||||
|
});
|
||||||
|
usersRepository.count.mockResolvedValue(1);
|
||||||
|
usersRepository.delete.mockResolvedValue({ affected: 1 });
|
||||||
|
const res = await service.deleteUser('admin-2', superAdmin);
|
||||||
|
expect(res.deleted_user_ids).toEqual(['admin-2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delete AM : clos placements, pas d’enfants deleted', async () => {
|
||||||
|
usersRepository.findOne.mockResolvedValue({
|
||||||
|
id: 'am-1',
|
||||||
|
role: RoleType.ASSISTANTE_MATERNELLE,
|
||||||
|
});
|
||||||
|
amRepository.findOne.mockResolvedValue({
|
||||||
|
user_id: 'am-1',
|
||||||
|
numero_dossier: '2026-000015',
|
||||||
|
});
|
||||||
|
amChildrenRepository.find.mockResolvedValue([
|
||||||
|
{
|
||||||
|
amId: 'am-1',
|
||||||
|
enfantId: 'e1',
|
||||||
|
child: { id: 'e1', status: 'garde' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
amChildrenRepository.count.mockResolvedValue(0);
|
||||||
|
amChildrenRepository.save.mockImplementation(async (x) => x);
|
||||||
|
childrenRepository.save.mockResolvedValue({});
|
||||||
|
usersRepository.delete.mockResolvedValue({ affected: 1 });
|
||||||
|
|
||||||
|
const res = await service.deleteUser('am-1', staff);
|
||||||
|
expect(res.deleted_enfant_ids).toEqual([]);
|
||||||
|
expect(res.deleted_user_ids).toEqual(['am-1']);
|
||||||
|
expect(res.type).toBe('assistante_maternelle');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delete dossier famille introuvable', async () => {
|
||||||
|
parentsRepository.findOne.mockResolvedValue(null);
|
||||||
|
amRepository.findOne.mockResolvedValue(null);
|
||||||
|
await expect(
|
||||||
|
service.deleteDossier('2026-999999', staff),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('co-parent : delete user seul', async () => {
|
||||||
|
usersRepository.findOne.mockResolvedValue({
|
||||||
|
id: 'p2',
|
||||||
|
role: RoleType.PARENT,
|
||||||
|
});
|
||||||
|
parentsRepository.findOne.mockResolvedValue({
|
||||||
|
user_id: 'p2',
|
||||||
|
numero_dossier: '2026-000010',
|
||||||
|
co_parent: { id: 'p1' },
|
||||||
|
});
|
||||||
|
parentsRepository.query.mockResolvedValue([
|
||||||
|
{ id: 'p1' },
|
||||||
|
{ id: 'p2' },
|
||||||
|
]);
|
||||||
|
dataSource.transaction.mockImplementation(async (cb) =>
|
||||||
|
cb({
|
||||||
|
delete: jest.fn(),
|
||||||
|
query: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await service.deleteUser('p2', staff);
|
||||||
|
expect(res.deleted_enfant_ids).toEqual([]);
|
||||||
|
expect(res.deleted_user_ids).toEqual(['p2']);
|
||||||
|
expect(res.message).toMatch(/co-parent/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { DataSource, In, IsNull, Repository } from 'typeorm';
|
||||||
|
import { RoleType, Users } from 'src/entities/users.entity';
|
||||||
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
|
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||||
|
import { Children, StatutEnfantType } from 'src/entities/children.entity';
|
||||||
|
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||||
|
import { AmChildren } from 'src/entities/am_children.entity';
|
||||||
|
|
||||||
|
export type SuppressionResult = {
|
||||||
|
type?: 'famille' | 'assistante_maternelle';
|
||||||
|
numero_dossier?: string;
|
||||||
|
deleted_user_ids: string[];
|
||||||
|
deleted_enfant_ids: string[];
|
||||||
|
dossier_supprime?: boolean;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STAFF_METIER: RoleType[] = [
|
||||||
|
RoleType.GESTIONNAIRE,
|
||||||
|
RoleType.ADMINISTRATEUR,
|
||||||
|
RoleType.SUPER_ADMIN,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cascades de suppression métier — tickets #154 / #159.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class SuppressionService {
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
@InjectRepository(Users)
|
||||||
|
private readonly usersRepository: Repository<Users>,
|
||||||
|
@InjectRepository(Parents)
|
||||||
|
private readonly parentsRepository: Repository<Parents>,
|
||||||
|
@InjectRepository(AssistanteMaternelle)
|
||||||
|
private readonly amRepository: Repository<AssistanteMaternelle>,
|
||||||
|
@InjectRepository(Children)
|
||||||
|
private readonly childrenRepository: Repository<Children>,
|
||||||
|
@InjectRepository(ParentsChildren)
|
||||||
|
private readonly parentsChildrenRepository: Repository<ParentsChildren>,
|
||||||
|
@InjectRepository(AmChildren)
|
||||||
|
private readonly amChildrenRepository: Repository<AmChildren>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
assertStaffMetier(currentUser: Users): void {
|
||||||
|
if (!STAFF_METIER.includes(currentUser.role)) {
|
||||||
|
throw new ForbiddenException('Accès refusé');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteDossier(
|
||||||
|
numeroDossier: string,
|
||||||
|
currentUser: Users,
|
||||||
|
): Promise<SuppressionResult> {
|
||||||
|
this.assertStaffMetier(currentUser);
|
||||||
|
const num = numeroDossier?.trim();
|
||||||
|
if (!num) {
|
||||||
|
throw new BadRequestException('Numéro de dossier requis.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentHit = await this.parentsRepository.findOne({
|
||||||
|
where: { numero_dossier: num },
|
||||||
|
});
|
||||||
|
if (parentHit) {
|
||||||
|
return this.deleteFamilleByNumero(num);
|
||||||
|
}
|
||||||
|
|
||||||
|
const amHit = await this.amRepository.findOne({
|
||||||
|
where: { numero_dossier: num },
|
||||||
|
relations: ['user'],
|
||||||
|
});
|
||||||
|
if (amHit?.user) {
|
||||||
|
return this.deleteAmUser(amHit.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new NotFoundException('Aucun dossier trouvé pour ce numéro.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteUser(
|
||||||
|
id: string,
|
||||||
|
currentUser: Users,
|
||||||
|
): Promise<SuppressionResult> {
|
||||||
|
const target = await this.usersRepository.findOne({ where: { id } });
|
||||||
|
if (!target) {
|
||||||
|
throw new NotFoundException('Utilisateur introuvable');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.id === currentUser.id) {
|
||||||
|
throw new ForbiddenException('Vous ne pouvez pas supprimer votre propre compte.');
|
||||||
|
}
|
||||||
|
if (target.role === RoleType.SUPER_ADMIN) {
|
||||||
|
throw new ForbiddenException('Le super administrateur ne peut pas être supprimé.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.role === RoleType.PARENT) {
|
||||||
|
this.assertStaffMetier(currentUser);
|
||||||
|
return this.deleteParentUser(target.id);
|
||||||
|
}
|
||||||
|
if (target.role === RoleType.ASSISTANTE_MATERNELLE) {
|
||||||
|
this.assertStaffMetier(currentUser);
|
||||||
|
return this.deleteAmUser(target.id);
|
||||||
|
}
|
||||||
|
if (target.role === RoleType.GESTIONNAIRE) {
|
||||||
|
if (
|
||||||
|
currentUser.role !== RoleType.ADMINISTRATEUR &&
|
||||||
|
currentUser.role !== RoleType.SUPER_ADMIN
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Seul un administrateur peut supprimer un gestionnaire.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.usersRepository.delete(target.id);
|
||||||
|
return {
|
||||||
|
deleted_user_ids: [target.id],
|
||||||
|
deleted_enfant_ids: [],
|
||||||
|
message: 'Gestionnaire supprimé.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (target.role === RoleType.ADMINISTRATEUR) {
|
||||||
|
await this.assertCanDeleteAdministrateur(target, currentUser);
|
||||||
|
await this.usersRepository.delete(target.id);
|
||||||
|
return {
|
||||||
|
deleted_user_ids: [target.id],
|
||||||
|
deleted_enfant_ids: [],
|
||||||
|
message: 'Administrateur supprimé.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new BadRequestException('Type d’utilisateur non supprimable via cet endpoint.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteEnfant(
|
||||||
|
enfantId: string,
|
||||||
|
deleteDossier: boolean,
|
||||||
|
currentUser: Users,
|
||||||
|
): Promise<SuppressionResult> {
|
||||||
|
this.assertStaffMetier(currentUser);
|
||||||
|
|
||||||
|
const child = await this.childrenRepository.findOne({
|
||||||
|
where: { id: enfantId },
|
||||||
|
relations: ['parentLinks', 'parentLinks.parent'],
|
||||||
|
});
|
||||||
|
if (!child) {
|
||||||
|
throw new NotFoundException('Enfant introuvable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentIds = (child.parentLinks ?? [])
|
||||||
|
.map((l) => l.parentId ?? l.parent?.user_id)
|
||||||
|
.filter(Boolean) as string[];
|
||||||
|
|
||||||
|
let numero: string | undefined;
|
||||||
|
if (parentIds.length > 0) {
|
||||||
|
const parents = await this.parentsRepository.find({
|
||||||
|
where: { user_id: In(parentIds) },
|
||||||
|
});
|
||||||
|
numero = parents.map((p) => p.numero_dossier?.trim()).find((n) => !!n);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!numero) {
|
||||||
|
await this.closePlacementsForEnfants([enfantId]);
|
||||||
|
await this.childrenRepository.delete(enfantId);
|
||||||
|
return {
|
||||||
|
deleted_user_ids: [],
|
||||||
|
deleted_enfant_ids: [enfantId],
|
||||||
|
dossier_supprime: false,
|
||||||
|
message: 'Enfant supprimé.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const siblingIds = await this.listEnfantIdsForNumero(numero);
|
||||||
|
const isLast = siblingIds.length <= 1;
|
||||||
|
|
||||||
|
if (isLast && deleteDossier) {
|
||||||
|
const result = await this.deleteFamilleByNumero(numero);
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
dossier_supprime: true,
|
||||||
|
message: 'Dernier enfant et dossier famille supprimés.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.closePlacementsForEnfants([enfantId]);
|
||||||
|
await this.childrenRepository.delete(enfantId);
|
||||||
|
return {
|
||||||
|
deleted_user_ids: [],
|
||||||
|
deleted_enfant_ids: [enfantId],
|
||||||
|
dossier_supprime: false,
|
||||||
|
numero_dossier: numero,
|
||||||
|
type: 'famille',
|
||||||
|
message: isLast
|
||||||
|
? 'Dernier enfant supprimé. Le dossier famille reste sans enfant.'
|
||||||
|
: 'Enfant supprimé du dossier famille.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compte enfants liés à un numero_dossier famille (pour flag sans_enfant). */
|
||||||
|
async countEnfantsForNumero(numeroDossier: string): Promise<number> {
|
||||||
|
const ids = await this.listEnfantIdsForNumero(numeroDossier);
|
||||||
|
return ids.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertCanDeleteAdministrateur(
|
||||||
|
target: Users,
|
||||||
|
currentUser: Users,
|
||||||
|
): Promise<void> {
|
||||||
|
if (
|
||||||
|
currentUser.role !== RoleType.ADMINISTRATEUR &&
|
||||||
|
currentUser.role !== RoleType.SUPER_ADMIN
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Seul un administrateur peut supprimer un administrateur.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const adminCount = await this.usersRepository.count({
|
||||||
|
where: { role: RoleType.ADMINISTRATEUR },
|
||||||
|
});
|
||||||
|
if (adminCount <= 1) {
|
||||||
|
if (currentUser.role !== RoleType.SUPER_ADMIN) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Seul le super administrateur peut supprimer le dernier administrateur.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deleteParentUser(userId: string): Promise<SuppressionResult> {
|
||||||
|
const parent = await this.parentsRepository.findOne({
|
||||||
|
where: { user_id: userId },
|
||||||
|
relations: ['co_parent'],
|
||||||
|
});
|
||||||
|
if (!parent) {
|
||||||
|
// Compte parent sans fiche — hard delete user
|
||||||
|
await this.usersRepository.delete(userId);
|
||||||
|
return {
|
||||||
|
deleted_user_ids: [userId],
|
||||||
|
deleted_enfant_ids: [],
|
||||||
|
message: 'Parent supprimé.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const numero = parent.numero_dossier?.trim();
|
||||||
|
const foyerIds = numero
|
||||||
|
? await this.listParentUserIdsForNumero(numero)
|
||||||
|
: [userId];
|
||||||
|
|
||||||
|
const isLast = foyerIds.filter((id) => id !== userId).length === 0;
|
||||||
|
|
||||||
|
if (!isLast) {
|
||||||
|
// Co-parent : retirer liens enfants de ce parent, clear co_parent refs, delete user
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
await manager.delete(ParentsChildren, { parentId: userId });
|
||||||
|
await manager.query(
|
||||||
|
`UPDATE parents SET id_co_parent = NULL WHERE id_co_parent = $1 OR id_utilisateur = $1`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
await manager.delete(Users, { id: userId });
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
deleted_user_ids: [userId],
|
||||||
|
deleted_enfant_ids: [],
|
||||||
|
numero_dossier: numero,
|
||||||
|
type: 'famille',
|
||||||
|
message: 'Parent retiré du dossier (co-parent).',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dernier parent : + enfants
|
||||||
|
const enfantIds = numero
|
||||||
|
? await this.listEnfantIdsForNumero(numero)
|
||||||
|
: await this.listEnfantIdsForParent(userId);
|
||||||
|
await this.closePlacementsForEnfants(enfantIds);
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
if (enfantIds.length) {
|
||||||
|
await manager.delete(Children, { id: In(enfantIds) });
|
||||||
|
}
|
||||||
|
await manager.query(
|
||||||
|
`UPDATE parents SET id_co_parent = NULL WHERE id_utilisateur = $1 OR id_co_parent = $1`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
await manager.delete(Users, { id: userId });
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
deleted_user_ids: [userId],
|
||||||
|
deleted_enfant_ids: enfantIds,
|
||||||
|
numero_dossier: numero,
|
||||||
|
type: 'famille',
|
||||||
|
message: 'Dernier parent et enfants rattachés supprimés.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deleteFamilleByNumero(numero: string): Promise<SuppressionResult> {
|
||||||
|
const parentIds = await this.listParentUserIdsForNumero(numero);
|
||||||
|
if (parentIds.length === 0) {
|
||||||
|
throw new NotFoundException('Aucun parent pour ce dossier.');
|
||||||
|
}
|
||||||
|
const enfantIds = await this.listEnfantIdsForNumero(numero);
|
||||||
|
await this.closePlacementsForEnfants(enfantIds);
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
if (enfantIds.length) {
|
||||||
|
await manager.delete(Children, { id: In(enfantIds) });
|
||||||
|
}
|
||||||
|
await manager.query(
|
||||||
|
`UPDATE parents SET id_co_parent = NULL WHERE id_utilisateur = ANY($1::uuid[]) OR id_co_parent = ANY($1::uuid[])`,
|
||||||
|
[parentIds],
|
||||||
|
);
|
||||||
|
await manager.delete(Users, { id: In(parentIds) });
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'famille',
|
||||||
|
numero_dossier: numero,
|
||||||
|
deleted_user_ids: parentIds,
|
||||||
|
deleted_enfant_ids: enfantIds,
|
||||||
|
message: 'Dossier famille supprimé.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deleteAmUser(userId: string): Promise<SuppressionResult> {
|
||||||
|
const am = await this.amRepository.findOne({ where: { user_id: userId } });
|
||||||
|
const numero = am?.numero_dossier?.trim();
|
||||||
|
|
||||||
|
const active = await this.amChildrenRepository.find({
|
||||||
|
where: { amId: userId, date_fin: IsNull() },
|
||||||
|
relations: ['child'],
|
||||||
|
});
|
||||||
|
const now = new Date();
|
||||||
|
for (const link of active) {
|
||||||
|
link.date_fin = now;
|
||||||
|
await this.amChildrenRepository.save(link);
|
||||||
|
if (link.child) {
|
||||||
|
await this.applySansGarde(link.child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.usersRepository.delete(userId);
|
||||||
|
return {
|
||||||
|
type: 'assistante_maternelle',
|
||||||
|
numero_dossier: numero,
|
||||||
|
deleted_user_ids: [userId],
|
||||||
|
deleted_enfant_ids: [],
|
||||||
|
message: 'Dossier assistante maternelle supprimé.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applySansGarde(child: Children): Promise<void> {
|
||||||
|
if (
|
||||||
|
child.status === StatutEnfantType.A_NAITRE ||
|
||||||
|
child.status === StatutEnfantType.SCOLARISE
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const remaining = await this.amChildrenRepository.count({
|
||||||
|
where: { enfantId: child.id, date_fin: IsNull() },
|
||||||
|
});
|
||||||
|
if (remaining === 0) {
|
||||||
|
child.status = StatutEnfantType.SANS_GARDE;
|
||||||
|
await this.childrenRepository.save(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async closePlacementsForEnfants(enfantIds: string[]): Promise<void> {
|
||||||
|
if (!enfantIds.length) return;
|
||||||
|
const links = await this.amChildrenRepository.find({
|
||||||
|
where: { enfantId: In(enfantIds), date_fin: IsNull() },
|
||||||
|
relations: ['child'],
|
||||||
|
});
|
||||||
|
const now = new Date();
|
||||||
|
for (const link of links) {
|
||||||
|
link.date_fin = now;
|
||||||
|
await this.amChildrenRepository.save(link);
|
||||||
|
if (link.child) {
|
||||||
|
await this.applySansGarde(link.child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listParentUserIdsForNumero(numero: string): Promise<string[]> {
|
||||||
|
const rows: Array<{ id: string }> = await this.parentsRepository.query(
|
||||||
|
`
|
||||||
|
SELECT DISTINCT x.id::text AS id FROM (
|
||||||
|
SELECT id_utilisateur AS id FROM parents WHERE TRIM(numero_dossier) = $1
|
||||||
|
UNION
|
||||||
|
SELECT id_co_parent AS id FROM parents
|
||||||
|
WHERE TRIM(numero_dossier) = $1 AND id_co_parent IS NOT NULL
|
||||||
|
UNION
|
||||||
|
SELECT p2.id_utilisateur AS id FROM parents p1
|
||||||
|
JOIN parents p2 ON p2.id_utilisateur = p1.id_co_parent
|
||||||
|
WHERE TRIM(p1.numero_dossier) = $1
|
||||||
|
) x WHERE x.id IS NOT NULL
|
||||||
|
`,
|
||||||
|
[numero],
|
||||||
|
);
|
||||||
|
return rows.map((r) => r.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listEnfantIdsForNumero(numero: string): Promise<string[]> {
|
||||||
|
const parentIds = await this.listParentUserIdsForNumero(numero);
|
||||||
|
if (!parentIds.length) return [];
|
||||||
|
return this.listEnfantIdsForParents(parentIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listEnfantIdsForParent(parentId: string): Promise<string[]> {
|
||||||
|
return this.listEnfantIdsForParents([parentId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listEnfantIdsForParents(parentIds: string[]): Promise<string[]> {
|
||||||
|
const links = await this.parentsChildrenRepository.find({
|
||||||
|
where: { parentId: In(parentIds) },
|
||||||
|
});
|
||||||
|
return [...new Set(links.map((l) => l.enfantId))];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { Users } from 'src/entities/users.entity';
|
||||||
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
|
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||||
|
import { Children } from 'src/entities/children.entity';
|
||||||
|
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||||
|
import { AmChildren } from 'src/entities/am_children.entity';
|
||||||
|
import { SuppressionService } from './suppression.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([
|
||||||
|
Users,
|
||||||
|
Parents,
|
||||||
|
AssistanteMaternelle,
|
||||||
|
Children,
|
||||||
|
ParentsChildren,
|
||||||
|
AmChildren,
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
providers: [SuppressionService],
|
||||||
|
exports: [SuppressionService],
|
||||||
|
})
|
||||||
|
export class SuppressionsModule {}
|
||||||
@@ -1,20 +1,21 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import 'reflect-metadata';
|
||||||
import { GestionnairesController } from './gestionnaires.controller';
|
import { GestionnairesController } from './gestionnaires.controller';
|
||||||
import { GestionnairesService } from './gestionnaires.service';
|
import { RoleType } from 'src/entities/users.entity';
|
||||||
|
|
||||||
describe('GestionnairesController', () => {
|
describe('GestionnairesController roles (#161)', () => {
|
||||||
let controller: GestionnairesController;
|
it('POST /gestionnaires autorise SUPER_ADMIN et ADMINISTRATEUR', () => {
|
||||||
|
const roles = Reflect.getMetadata('roles', GestionnairesController.prototype.create);
|
||||||
beforeEach(async () => {
|
expect(roles).toEqual(
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
expect.arrayContaining([RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR]),
|
||||||
controllers: [GestionnairesController],
|
);
|
||||||
providers: [GestionnairesService],
|
expect(roles).not.toContain(RoleType.GESTIONNAIRE);
|
||||||
}).compile();
|
|
||||||
|
|
||||||
controller = module.get<GestionnairesController>(GestionnairesController);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('PATCH /gestionnaires/:id autorise SUPER_ADMIN et ADMINISTRATEUR', () => {
|
||||||
expect(controller).toBeDefined();
|
const roles = Reflect.getMetadata('roles', GestionnairesController.prototype.update);
|
||||||
|
expect(roles).toEqual(
|
||||||
|
expect.arrayContaining([RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR]),
|
||||||
|
);
|
||||||
|
expect(roles).not.toContain(RoleType.GESTIONNAIRE);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,10 +25,10 @@ import { AuthGuard } from 'src/common/guards/auth.guard';
|
|||||||
export class GestionnairesController {
|
export class GestionnairesController {
|
||||||
constructor(private readonly gestionnairesService: GestionnairesService) { }
|
constructor(private readonly gestionnairesService: GestionnairesService) { }
|
||||||
|
|
||||||
@Roles(RoleType.SUPER_ADMIN)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||||
@ApiResponse({ status: 201, description: 'Le gestionnaire a été créé avec succès.', type: Users })
|
@ApiResponse({ status: 201, description: 'Le gestionnaire a été créé avec succès.', type: Users })
|
||||||
@ApiResponse({ status: 409, description: 'Conflit. L\'email est déjà utilisé.' })
|
@ApiResponse({ status: 409, description: 'Conflit. L\'email est déjà utilisé.' })
|
||||||
@ApiOperation({ summary: 'Création d\'un gestionnaire' })
|
@ApiOperation({ summary: 'Création d\'un gestionnaire (admin / super admin)' })
|
||||||
@ApiBody({ type: CreateGestionnaireDto })
|
@ApiBody({ type: CreateGestionnaireDto })
|
||||||
@Post()
|
@Post()
|
||||||
create(@Body() dto: CreateGestionnaireDto): Promise<Users> {
|
create(@Body() dto: CreateGestionnaireDto): Promise<Users> {
|
||||||
@@ -43,7 +43,7 @@ export class GestionnairesController {
|
|||||||
return this.gestionnairesService.findAll();
|
return this.gestionnairesService.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleType.GESTIONNAIRE, RoleType.SUPER_ADMIN)
|
@Roles(RoleType.GESTIONNAIRE, RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||||
@ApiOperation({ summary: 'Récupérer un gestionnaire par ID' })
|
@ApiOperation({ summary: 'Récupérer un gestionnaire par ID' })
|
||||||
@ApiResponse({ status: 400, description: 'ID invalide' })
|
@ApiResponse({ status: 400, description: 'ID invalide' })
|
||||||
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
||||||
@@ -56,8 +56,8 @@ export class GestionnairesController {
|
|||||||
return this.gestionnairesService.findOne(id);
|
return this.gestionnairesService.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleType.SUPER_ADMIN)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||||
@ApiOperation({ summary: 'Mettre à jour un gestionnaire' })
|
@ApiOperation({ summary: 'Mettre à jour un gestionnaire (admin / super admin)' })
|
||||||
@ApiResponse({ status: 200, description: 'Le gestionnaire a été mis à jour avec succès.', type: Users })
|
@ApiResponse({ status: 200, description: 'Le gestionnaire a été mis à jour avec succès.', type: Users })
|
||||||
@ApiResponse({ status: 404, description: 'Gestionnaire non trouvé' })
|
@ApiResponse({ status: 404, description: 'Gestionnaire non trouvé' })
|
||||||
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
||||||
|
|||||||
@@ -1,20 +1,13 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import 'reflect-metadata';
|
||||||
import { UserController } from './user.controller';
|
import { UserController } from './user.controller';
|
||||||
import { UserService } from './user.service';
|
import { RoleType } from 'src/entities/users.entity';
|
||||||
|
|
||||||
describe('UserController', () => {
|
describe('UserController roles (#161)', () => {
|
||||||
let controller: UserController;
|
it('POST /users/admin autorise SUPER_ADMIN et ADMINISTRATEUR', () => {
|
||||||
|
const roles = Reflect.getMetadata('roles', UserController.prototype.createAdmin);
|
||||||
beforeEach(async () => {
|
expect(roles).toEqual(
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
expect.arrayContaining([RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR]),
|
||||||
controllers: [UserController],
|
);
|
||||||
providers: [UserService],
|
expect(roles).not.toContain(RoleType.GESTIONNAIRE);
|
||||||
}).compile();
|
|
||||||
|
|
||||||
controller = module.get<UserController>(UserController);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be defined', () => {
|
|
||||||
expect(controller).toBeDefined();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,18 +10,22 @@ import { CreateUserDto } from './dto/create_user.dto';
|
|||||||
import { CreateAdminDto } from './dto/create_admin.dto';
|
import { CreateAdminDto } from './dto/create_admin.dto';
|
||||||
import { UpdateUserDto } from './dto/update_user.dto';
|
import { UpdateUserDto } from './dto/update_user.dto';
|
||||||
import { AffecterNumeroDossierDto } from './dto/affecter-numero-dossier.dto';
|
import { AffecterNumeroDossierDto } from './dto/affecter-numero-dossier.dto';
|
||||||
|
import { SuppressionService } from '../suppressions/suppression.service';
|
||||||
|
|
||||||
@ApiTags('Utilisateurs')
|
@ApiTags('Utilisateurs')
|
||||||
@ApiBearerAuth('access-token')
|
@ApiBearerAuth('access-token')
|
||||||
@UseGuards(AuthGuard, RolesGuard)
|
@UseGuards(AuthGuard, RolesGuard)
|
||||||
@Controller('users')
|
@Controller('users')
|
||||||
export class UserController {
|
export class UserController {
|
||||||
constructor(private readonly userService: UserService) { }
|
constructor(
|
||||||
|
private readonly userService: UserService,
|
||||||
|
private readonly suppressionService: SuppressionService,
|
||||||
|
) { }
|
||||||
|
|
||||||
// Création d'un administrateur (réservée aux super admins)
|
// Création d'un administrateur (admin + super admin) — #161
|
||||||
@Post('admin')
|
@Post('admin')
|
||||||
@Roles(RoleType.SUPER_ADMIN)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||||
@ApiOperation({ summary: 'Créer un nouvel administrateur (super admin seulement)' })
|
@ApiOperation({ summary: 'Créer un nouvel administrateur (admin / super admin)' })
|
||||||
createAdmin(
|
createAdmin(
|
||||||
@Body() dto: CreateAdminDto,
|
@Body() dto: CreateAdminDto,
|
||||||
@User() currentUser: Users
|
@User() currentUser: Users
|
||||||
@@ -146,12 +150,20 @@ export class UserController {
|
|||||||
return this.userService.suspendUser(id, currentUser, comment);
|
return this.userService.suspendUser(id, currentUser, comment);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Supprimer un utilisateur (super_admin uniquement)
|
// Supprimer un utilisateur — cascades métier #159
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@Roles(RoleType.SUPER_ADMIN)
|
@Roles(
|
||||||
@ApiOperation({ summary: 'Supprimer un utilisateur' })
|
RoleType.SUPER_ADMIN,
|
||||||
|
RoleType.ADMINISTRATEUR,
|
||||||
|
RoleType.GESTIONNAIRE,
|
||||||
|
)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Supprimer un utilisateur (cascades métier #159)',
|
||||||
|
description:
|
||||||
|
'Parent / AM / staff selon matrice. Gestionnaire ne peut pas supprimer un autre gestionnaire. Self interdit.',
|
||||||
|
})
|
||||||
@ApiParam({ name: 'id', description: "UUID de l'utilisateur" })
|
@ApiParam({ name: 'id', description: "UUID de l'utilisateur" })
|
||||||
remove(@Param('id') id: string, @User() currentUser: Users) {
|
remove(@Param('id') id: string, @User() currentUser: Users) {
|
||||||
return this.userService.remove(id, currentUser);
|
return this.suppressionService.deleteUser(id, currentUser);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Parents } from 'src/entities/parents.entity';
|
|||||||
import { GestionnairesModule } from './gestionnaires/gestionnaires.module';
|
import { GestionnairesModule } from './gestionnaires/gestionnaires.module';
|
||||||
import { MailModule } from 'src/modules/mail/mail.module';
|
import { MailModule } from 'src/modules/mail/mail.module';
|
||||||
import { AppConfigModule } from 'src/modules/config/config.module';
|
import { AppConfigModule } from 'src/modules/config/config.module';
|
||||||
|
import { SuppressionsModule } from '../suppressions/suppressions.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature(
|
imports: [TypeOrmModule.forFeature(
|
||||||
@@ -26,6 +27,7 @@ import { AppConfigModule } from 'src/modules/config/config.module';
|
|||||||
GestionnairesModule,
|
GestionnairesModule,
|
||||||
MailModule,
|
MailModule,
|
||||||
AppConfigModule,
|
AppConfigModule,
|
||||||
|
SuppressionsModule,
|
||||||
],
|
],
|
||||||
controllers: [UserController],
|
controllers: [UserController],
|
||||||
providers: [UserService],
|
providers: [UserService],
|
||||||
|
|||||||
@@ -1,18 +1,87 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||||
import { UserService } from './user.service';
|
import { UserService } from './user.service';
|
||||||
|
import { RoleType, StatutUtilisateurType } from 'src/entities/users.entity';
|
||||||
|
|
||||||
|
describe('UserService.createAdmin (#161)', () => {
|
||||||
|
const usersRepository = {
|
||||||
|
findOneBy: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
save: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
describe('UserService', () => {
|
|
||||||
let service: UserService;
|
let service: UserService;
|
||||||
|
|
||||||
beforeEach(async () => {
|
const dto = {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
email: 'nouveau.admin@ptits-pas.fr',
|
||||||
providers: [UserService],
|
password: 'Password1!',
|
||||||
}).compile();
|
prenom: 'Nina',
|
||||||
|
nom: 'Admin',
|
||||||
|
telephone: '0601020304',
|
||||||
|
};
|
||||||
|
|
||||||
service = module.get<UserService>(UserService);
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
service = new UserService(
|
||||||
|
usersRepository as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('autorise un administrateur à créer un admin', async () => {
|
||||||
expect(service).toBeDefined();
|
usersRepository.findOneBy.mockResolvedValue(null);
|
||||||
|
usersRepository.create.mockImplementation((data) => data);
|
||||||
|
usersRepository.save.mockImplementation(async (entity) => ({
|
||||||
|
id: 'new-admin',
|
||||||
|
...entity,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await service.createAdmin(dto as never, {
|
||||||
|
id: 'admin-1',
|
||||||
|
role: RoleType.ADMINISTRATEUR,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(result.role).toBe(RoleType.ADMINISTRATEUR);
|
||||||
|
expect(result.statut).toBe(StatutUtilisateurType.ACTIF);
|
||||||
|
expect(usersRepository.save).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('autorise un super_admin à créer un admin', async () => {
|
||||||
|
usersRepository.findOneBy.mockResolvedValue(null);
|
||||||
|
usersRepository.create.mockImplementation((data) => data);
|
||||||
|
usersRepository.save.mockImplementation(async (entity) => ({
|
||||||
|
id: 'new-admin',
|
||||||
|
...entity,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createAdmin(dto as never, {
|
||||||
|
id: 'sa-1',
|
||||||
|
role: RoleType.SUPER_ADMIN,
|
||||||
|
} as never),
|
||||||
|
).resolves.toMatchObject({ role: RoleType.ADMINISTRATEUR });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuse un gestionnaire (403 métier)', async () => {
|
||||||
|
await expect(
|
||||||
|
service.createAdmin(dto as never, {
|
||||||
|
id: 'gest-1',
|
||||||
|
role: RoleType.GESTIONNAIRE,
|
||||||
|
} as never),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
expect(usersRepository.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuse un email déjà utilisé', async () => {
|
||||||
|
usersRepository.findOneBy.mockResolvedValue({ id: 'exists' });
|
||||||
|
await expect(
|
||||||
|
service.createAdmin(dto as never, {
|
||||||
|
id: 'admin-1',
|
||||||
|
role: RoleType.ADMINISTRATEUR,
|
||||||
|
} as never),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -117,8 +117,14 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createAdmin(dto: CreateAdminDto, currentUser: Users): Promise<Users> {
|
async createAdmin(dto: CreateAdminDto, currentUser: Users): Promise<Users> {
|
||||||
if (currentUser.role !== RoleType.SUPER_ADMIN) {
|
// #161 — admin et super_admin peuvent créer un administrateur
|
||||||
throw new ForbiddenException('Seuls les super administrateurs peuvent créer un administrateur');
|
if (
|
||||||
|
currentUser.role !== RoleType.SUPER_ADMIN &&
|
||||||
|
currentUser.role !== RoleType.ADMINISTRATEUR
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Seuls les administrateurs et super administrateurs peuvent créer un administrateur',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const exist = await this.usersRepository.findOneBy({ email: dto.email });
|
const exist = await this.usersRepository.findOneBy({ email: dto.email });
|
||||||
@@ -520,6 +526,7 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: string, currentUser: Users): Promise<void> {
|
async remove(id: string, currentUser: Users): Promise<void> {
|
||||||
|
// Délégué historiquement ; préférer SuppressionService via controller (#159).
|
||||||
if (currentUser.role !== RoleType.SUPER_ADMIN) {
|
if (currentUser.role !== RoleType.SUPER_ADMIN) {
|
||||||
throw new ForbiddenException('Accès réservé aux super admins');
|
throw new ForbiddenException('Accès réservé aux super admins');
|
||||||
}
|
}
|
||||||
|
|||||||
+118
-110
@@ -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";
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||||
|
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
@@ -5,40 +11,63 @@ CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
|||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
DO $$ BEGIN
|
DO $$ BEGIN
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'role_type') THEN
|
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;
|
END IF;
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'genre_type') THEN
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'genre_type') THEN
|
||||||
CREATE TYPE genre_type AS ENUM ('H', 'F', 'Autre');
|
CREATE TYPE genre_type AS ENUM ('H', 'F', 'Autre');
|
||||||
END IF;
|
END IF;
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_utilisateur_type') THEN
|
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;
|
END IF;
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_enfant_type') THEN
|
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;
|
END IF;
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_dossier_type') THEN
|
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;
|
END IF;
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_contrat_type') THEN
|
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;
|
END IF;
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_avenant_type') THEN
|
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;
|
END IF;
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'type_evenement_type') THEN
|
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;
|
END IF;
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_evenement_type') THEN
|
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;
|
END IF;
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_validation_type') THEN
|
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;
|
END IF;
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'situation_familiale_type') THEN
|
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 IF;
|
||||||
END $$;
|
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
|
-- Table : utilisateurs
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
@@ -46,26 +75,33 @@ CREATE TABLE utilisateurs (
|
|||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
email VARCHAR(255) NOT NULL UNIQUE,
|
email VARCHAR(255) NOT NULL UNIQUE,
|
||||||
CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'),
|
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),
|
prenom VARCHAR(100),
|
||||||
nom VARCHAR(100),
|
nom VARCHAR(100),
|
||||||
genre genre_type,
|
genre genre_type,
|
||||||
role role_type NOT NULL,
|
role role_type NOT NULL,
|
||||||
statut statut_utilisateur_type DEFAULT 'en_attente',
|
statut statut_utilisateur_type DEFAULT 'en_attente',
|
||||||
telephone VARCHAR(20), -- Unifié (mobile privilégié)
|
telephone VARCHAR(20),
|
||||||
adresse TEXT,
|
adresse TEXT,
|
||||||
date_naissance DATE,
|
date_naissance DATE,
|
||||||
lieu_naissance_ville VARCHAR(100),
|
lieu_naissance_ville VARCHAR(100),
|
||||||
lieu_naissance_pays 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,
|
consentement_photo BOOLEAN DEFAULT false,
|
||||||
date_consentement_photo TIMESTAMPTZ,
|
date_consentement_photo TIMESTAMPTZ,
|
||||||
token_creation_mdp VARCHAR(255), -- Token pour créer MDP après validation
|
token_creation_mdp VARCHAR(255),
|
||||||
token_creation_mdp_expire_le TIMESTAMPTZ, -- Expiration 7 jours
|
token_creation_mdp_expire_le TIMESTAMPTZ,
|
||||||
-- Ticket #127 : réinitialisation mot de passe oublié (distinct de token_creation_mdp)
|
password_reset_token VARCHAR(255),
|
||||||
password_reset_token VARCHAR(255) NULL,
|
password_reset_expires TIMESTAMPTZ,
|
||||||
password_reset_expires TIMESTAMPTZ NULL,
|
token_reprise VARCHAR(255),
|
||||||
|
token_reprise_expire_le TIMESTAMPTZ,
|
||||||
changement_mdp_obligatoire BOOLEAN DEFAULT false,
|
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(),
|
cree_le TIMESTAMPTZ DEFAULT now(),
|
||||||
modifie_le TIMESTAMPTZ DEFAULT now(),
|
modifie_le TIMESTAMPTZ DEFAULT now(),
|
||||||
ville VARCHAR(150),
|
ville VARCHAR(150),
|
||||||
@@ -74,15 +110,22 @@ CREATE TABLE utilisateurs (
|
|||||||
situation_familiale situation_familiale_type
|
situation_familiale situation_familiale_type
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Index pour recherche par token
|
CREATE INDEX idx_utilisateurs_token_creation_mdp
|
||||||
CREATE INDEX idx_utilisateurs_token_creation_mdp
|
ON utilisateurs(token_creation_mdp)
|
||||||
ON utilisateurs(token_creation_mdp)
|
|
||||||
WHERE token_creation_mdp IS NOT NULL;
|
WHERE token_creation_mdp IS NOT NULL;
|
||||||
|
|
||||||
CREATE INDEX idx_utilisateurs_password_reset_token
|
CREATE INDEX idx_utilisateurs_password_reset_token
|
||||||
ON utilisateurs(password_reset_token)
|
ON utilisateurs(password_reset_token)
|
||||||
WHERE password_reset_token IS NOT NULL;
|
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
|
-- Table : assistantes_maternelles
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
@@ -97,17 +140,27 @@ CREATE TABLE assistantes_maternelles (
|
|||||||
date_agrement DATE,
|
date_agrement DATE,
|
||||||
annee_experience SMALLINT,
|
annee_experience SMALLINT,
|
||||||
specialite VARCHAR(100),
|
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
|
-- Table : parents
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
CREATE TABLE parents (
|
CREATE TABLE parents (
|
||||||
id_utilisateur UUID PRIMARY KEY REFERENCES utilisateurs(id) ON DELETE CASCADE,
|
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
|
-- Table : enfants
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
@@ -116,7 +169,7 @@ CREATE TABLE enfants (
|
|||||||
statut statut_enfant_type,
|
statut statut_enfant_type,
|
||||||
prenom VARCHAR(100),
|
prenom VARCHAR(100),
|
||||||
nom VARCHAR(100),
|
nom VARCHAR(100),
|
||||||
genre genre_type NOT NULL, -- Obligatoire selon CDC
|
genre genre_type NOT NULL,
|
||||||
date_naissance DATE,
|
date_naissance DATE,
|
||||||
date_prevue_naissance DATE,
|
date_prevue_naissance DATE,
|
||||||
photo_url TEXT,
|
photo_url TEXT,
|
||||||
@@ -135,7 +188,27 @@ CREATE TABLE enfants_parents (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
-- Table : dossier_famille (inscription parent, schéma simplifié — ticket #119)
|
-- Table : enfants_assistantes_maternelles (placement AM ↔ enfant — #131)
|
||||||
|
-- ==========================================================
|
||||||
|
CREATE TABLE enfants_assistantes_maternelles (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
id_am UUID NOT NULL REFERENCES assistantes_maternelles(id_utilisateur) ON DELETE CASCADE,
|
||||||
|
id_enfant UUID NOT NULL REFERENCES enfants(id) ON DELETE CASCADE,
|
||||||
|
date_debut DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||||
|
date_fin DATE NULL,
|
||||||
|
cree_le TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
cree_par UUID REFERENCES utilisateurs(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_eam_am ON enfants_assistantes_maternelles(id_am);
|
||||||
|
CREATE INDEX idx_eam_enfant ON enfants_assistantes_maternelles(id_enfant);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX uq_enfant_garde_active
|
||||||
|
ON enfants_assistantes_maternelles (id_enfant)
|
||||||
|
WHERE date_fin IS NULL;
|
||||||
|
|
||||||
|
-- ==========================================================
|
||||||
|
-- Table : dossier_famille (inscription parent — ticket #119)
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
CREATE TABLE dossier_famille (
|
CREATE TABLE dossier_famille (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
@@ -276,10 +349,6 @@ CREATE TABLE notifications (
|
|||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
-- Table : validations
|
-- Table : validations
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
-- Historique des décisions (validation / refus / suspension de compte).
|
|
||||||
-- Colonnes commentaire + valide_par : requises par l’API Nest (TypeORM).
|
|
||||||
-- FK en ON DELETE SET NULL : conserver la ligne si l’utilisateur référencé
|
|
||||||
-- est supprimé (voir database/docs/FK_POLICIES.md).
|
|
||||||
CREATE TABLE validations (
|
CREATE TABLE validations (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
id_utilisateur UUID REFERENCES utilisateurs(id) ON DELETE SET NULL,
|
id_utilisateur UUID REFERENCES utilisateurs(id) ON DELETE SET NULL,
|
||||||
@@ -305,13 +374,10 @@ CREATE TABLE configuration (
|
|||||||
modifie_par UUID REFERENCES utilisateurs(id)
|
modifie_par UUID REFERENCES utilisateurs(id)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Index pour performance
|
|
||||||
CREATE INDEX idx_configuration_cle ON configuration(cle);
|
CREATE INDEX idx_configuration_cle ON configuration(cle);
|
||||||
CREATE INDEX idx_configuration_categorie ON configuration(categorie);
|
CREATE INDEX idx_configuration_categorie ON configuration(categorie);
|
||||||
|
|
||||||
-- Seed initial de configuration
|
|
||||||
INSERT INTO configuration (cle, valeur, type, categorie, description) VALUES
|
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_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_port', '25', 'number', 'email', 'Port SMTP (25, 465, 587)'),
|
||||||
('smtp_secure', 'false', 'boolean', 'email', 'Utiliser SSL/TLS (true pour port 465)'),
|
('smtp_secure', 'false', 'boolean', 'email', 'Utiliser SSL/TLS (true pour port 465)'),
|
||||||
@@ -320,14 +386,10 @@ INSERT INTO configuration (cle, valeur, type, categorie, description) VALUES
|
|||||||
('smtp_password', '', 'encrypted', 'email', 'Mot de passe SMTP (chiffré en AES-256)'),
|
('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_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'),
|
('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_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_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'),
|
('app_logo_url', '/assets/logo.png', 'string', 'app', 'URL du logo de l''application'),
|
||||||
('setup_completed', 'false', 'boolean', 'app', 'Configuration initiale terminée'),
|
('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)'),
|
('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)'),
|
('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)'),
|
('max_upload_size_mb', '5', 'number', 'security', 'Taille maximale des fichiers uploadés (en MB)'),
|
||||||
@@ -338,19 +400,18 @@ INSERT INTO configuration (cle, valeur, type, categorie, description) VALUES
|
|||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
CREATE TABLE documents_legaux (
|
CREATE TABLE documents_legaux (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
type VARCHAR(50) NOT NULL, -- 'cgu' ou 'privacy'
|
type VARCHAR(50) NOT NULL,
|
||||||
version INTEGER NOT NULL, -- Numéro de version (auto-incrémenté)
|
version INTEGER NOT NULL,
|
||||||
fichier_nom VARCHAR(255) NOT NULL, -- Nom original du fichier
|
fichier_nom VARCHAR(255) NOT NULL,
|
||||||
fichier_path VARCHAR(500) NOT NULL, -- Chemin de stockage
|
fichier_path VARCHAR(500) NOT NULL,
|
||||||
fichier_hash VARCHAR(64) NOT NULL, -- Hash SHA-256 pour intégrité
|
fichier_hash VARCHAR(64) NOT NULL,
|
||||||
actif BOOLEAN DEFAULT false, -- Version actuellement active
|
actif BOOLEAN DEFAULT false,
|
||||||
televerse_par UUID REFERENCES utilisateurs(id), -- Qui a uploadé
|
televerse_par UUID REFERENCES utilisateurs(id),
|
||||||
televerse_le TIMESTAMPTZ DEFAULT now(), -- Date d'upload
|
televerse_le TIMESTAMPTZ DEFAULT now(),
|
||||||
active_le TIMESTAMPTZ, -- Date d'activation
|
active_le TIMESTAMPTZ,
|
||||||
UNIQUE(type, version) -- Pas de doublon version
|
UNIQUE(type, version)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Index pour performance
|
|
||||||
CREATE INDEX idx_documents_legaux_type_actif ON documents_legaux(type, actif);
|
CREATE INDEX idx_documents_legaux_type_actif ON documents_legaux(type, actif);
|
||||||
CREATE INDEX idx_documents_legaux_version ON documents_legaux(type, version DESC);
|
CREATE INDEX idx_documents_legaux_version ON documents_legaux(type, version DESC);
|
||||||
|
|
||||||
@@ -361,73 +422,23 @@ CREATE TABLE acceptations_documents (
|
|||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
id_utilisateur UUID REFERENCES utilisateurs(id) ON DELETE CASCADE,
|
id_utilisateur UUID REFERENCES utilisateurs(id) ON DELETE CASCADE,
|
||||||
id_document UUID REFERENCES documents_legaux(id),
|
id_document UUID REFERENCES documents_legaux(id),
|
||||||
type_document VARCHAR(50) NOT NULL, -- 'cgu' ou 'privacy'
|
type_document VARCHAR(50) NOT NULL,
|
||||||
version_document INTEGER NOT NULL, -- Version acceptée
|
version_document INTEGER NOT NULL,
|
||||||
accepte_le TIMESTAMPTZ DEFAULT now(), -- Date d'acceptation
|
accepte_le TIMESTAMPTZ DEFAULT now(),
|
||||||
ip_address INET, -- IP de l'utilisateur (RGPD)
|
ip_address INET,
|
||||||
user_agent TEXT -- Navigateur (preuve)
|
user_agent TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Index pour performance
|
|
||||||
CREATE INDEX idx_acceptations_utilisateur ON acceptations_documents(id_utilisateur);
|
CREATE INDEX idx_acceptations_utilisateur ON acceptations_documents(id_utilisateur);
|
||||||
CREATE INDEX idx_acceptations_document ON acceptations_documents(id_document);
|
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)
|
-- Ticket #103 : Numéro de dossier (AAAA-NNNNNN, séquence par année)
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
CREATE TABLE IF NOT EXISTS numero_dossier_sequence (
|
CREATE TABLE numero_dossier_sequence (
|
||||||
annee INT PRIMARY KEY,
|
annee INT PRIMARY KEY,
|
||||||
prochain INT NOT NULL DEFAULT 1
|
prochain INT NOT NULL DEFAULT 1
|
||||||
);
|
);
|
||||||
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL;
|
|
||||||
ALTER TABLE assistantes_maternelles ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL;
|
|
||||||
ALTER TABLE parents ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL;
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_utilisateurs_numero_dossier ON utilisateurs(numero_dossier) WHERE numero_dossier IS NOT NULL;
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_assistantes_maternelles_numero_dossier ON assistantes_maternelles(numero_dossier) WHERE numero_dossier IS NOT NULL;
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_parents_numero_dossier ON parents(numero_dossier) WHERE numero_dossier IS NOT NULL;
|
|
||||||
|
|
||||||
-- ==========================================================
|
|
||||||
-- Ticket #110 : Token reprise après refus (lien email)
|
|
||||||
-- ==========================================================
|
|
||||||
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS token_reprise VARCHAR(255) NULL;
|
|
||||||
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS token_reprise_expire_le TIMESTAMPTZ NULL;
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_utilisateurs_token_reprise ON utilisateurs(token_reprise) WHERE token_reprise IS NOT NULL;
|
|
||||||
|
|
||||||
-- ==========================================================
|
|
||||||
-- Ticket #127 : Mot de passe oublié (token dédié, ≠ inscription)
|
|
||||||
-- ==========================================================
|
|
||||||
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS password_reset_token VARCHAR(255) NULL;
|
|
||||||
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS password_reset_expires TIMESTAMPTZ NULL;
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_utilisateurs_password_reset_token ON utilisateurs(password_reset_token) WHERE password_reset_token IS NOT NULL;
|
|
||||||
|
|
||||||
-- Lieu de naissance (aligné CREATE TABLE utilisateurs — idempotent si colonnes déjà présentes)
|
|
||||||
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS lieu_naissance_ville VARCHAR(100) NULL;
|
|
||||||
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS lieu_naissance_pays VARCHAR(100) NULL;
|
|
||||||
|
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
-- Seed : Documents légaux génériques v1
|
-- Seed : Documents légaux génériques v1
|
||||||
@@ -438,10 +449,7 @@ INSERT INTO documents_legaux (type, version, fichier_nom, fichier_path, fichier_
|
|||||||
|
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
-- Seed : Super Administrateur par défaut
|
-- Seed : Super Administrateur par défaut
|
||||||
-- ==========================================================
|
-- Email: admin@ptits-pas.fr | Mot de passe: 4dm1n1strateur
|
||||||
-- Email: admin@ptits-pas.fr
|
|
||||||
-- Mot de passe: 4dm1n1strateur (hashé bcrypt)
|
|
||||||
-- IMPORTANT: Changer ce mot de passe en production !
|
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
INSERT INTO utilisateurs (
|
INSERT INTO utilisateurs (
|
||||||
email,
|
email,
|
||||||
|
|||||||
+6
-5
@@ -15,12 +15,13 @@ Ce projet contient la **base de données** pour l'application PtitsPas, avec scr
|
|||||||
|
|
||||||
## Structure du projet
|
## Structure du projet
|
||||||
|
|
||||||
- `migrations/` : scripts SQL pour la création et l'import de la base
|
- **`BDD.sql`** : schéma canonique (création from scratch — utilisé par `docker-compose.yml` racine)
|
||||||
- `bdd/data_test/` : fichiers CSV pour l'import de données de test
|
- `migrations/` : scripts SQL pour **mettre à jour** une BDD déjà en service
|
||||||
- `docs/` : documentation métier et technique
|
- `bdd/data_test/` : fichiers CSV (import legacy)
|
||||||
- `seed/` : scripts de seed
|
- `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
|
- `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"
|
"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
|
"5e8574b7-63e6-4d48-9af3-8d3bf7a6a6cf","sans_garde","Emma","Dupont","F","2020-06-01",,,False,,False
|
||||||
"a5c3268e-07eb-41a4-9f6c-2f9f16f37c3d","actif",,,,"2020-01-01","2025-01-01",,False,,False
|
"a5c3268e-07eb-41a4-9f6c-2f9f16f37c3d","sans_garde",,,,"2020-01-01","2025-01-01",,False,,False
|
||||||
"e1a2b3c4-d5e6-4f7a-8b9c-1d2e3f4a5b6c","actif","Emma","Martin",,"2023-02-15",,,False,,False
|
"e1a2b3c4-d5e6-4f7a-8b9c-1d2e3f4a5b6c","sans_garde","Emma","Martin",,"2023-02-15",,,False,,False
|
||||||
"e2b3c4d5-e6f7-4a8b-9c1d-2e3f4a5b6c7d","actif","Noah","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","actif","Léa","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","actif","Chloé","Rousseau",,"2022-04-20",,,False,,False
|
"e4d5e6f7-a8b9-4c1d-2e3f-4a5b6c7d8e9f","sans_garde","Chloé","Rousseau",,"2022-04-20",,,False,,False
|
||||||
"e5e6f7a8-b9c1-4d2e-3f4a-5b6c7d8e9f1a","actif","Hugo","Rousseau",,"2024-03-10",,,False,,False
|
"e5e6f7a8-b9c1-4d2e-3f4a-5b6c7d8e9f1a","sans_garde","Hugo","Rousseau",,"2024-03-10",,,False,,False
|
||||||
"e6f7a8b9-c1d2-4e3f-5a6b-7c8d9e0f1a2b","actif","Maxime","Lecomte",,"2023-04-15",,,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
|
"edd19cd1-bb67-4f14-8a37-c66b75c94537","scolarise","Lucas","Durand","H","2018-09-15",,,False,,False
|
||||||
|
|||||||
|
@@ -14,9 +14,8 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "5433:5432"
|
- "5433:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- ./migrations/01_init.sql:/docker-entrypoint-initdb.d/01_init.sql
|
- ./BDD.sql:/docker-entrypoint-initdb.d/01_init.sql
|
||||||
- ./migrations/07_import.sql:/docker-entrypoint-initdb.d/07_import.sql
|
- ./seed/03_seed_test_data.sql:/docker-entrypoint-initdb.d/02_seed_test_data.sql
|
||||||
- ./bdd/data_test:/bdd/data_test
|
|
||||||
- postgres_standalone_data:/var/lib/postgresql/data
|
- postgres_standalone_data:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
- ptitspas_dev
|
- ptitspas_dev
|
||||||
|
|||||||
@@ -51,12 +51,17 @@ Ce document recense **toutes les valeurs énumérées** utilisées dans la base
|
|||||||
| Valeur | Description |
|
| Valeur | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `a_naitre` | Enfant à naître (date prévue renseignée) |
|
| `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 |
|
| `scolarise` | Enfant scolarisé, garde potentiellement périscolaire |
|
||||||
|
|
||||||
**Contraintes associées** :
|
**Contraintes associées** :
|
||||||
- `a_naitre` → **`date_prevue_naissance` obligatoire**
|
- `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 |
|
| **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_parent)** → `parents(id_utilisateur)` | **CASCADE** | Nettoyage liaisons N:N |
|
||||||
| **enfants_parents(id_enfant)** → `enfants(id)` | **CASCADE** | Idem |
|
| **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_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 |
|
| **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 |
|
| **messages(id_dossier)** → `dossiers(id)` | **CASCADE** | Messages détruits avec le dossier |
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
-- Ticket #131 / #141 — Placement AM ↔ enfant + statuts garde/sans_garde
|
||||||
|
-- ⚠️ BDD EXISTANTES UNIQUEMENT — ne pas exécuter sur une base créée via BDD.sql à jour.
|
||||||
|
-- Idempotent : safe à rejouer sur recette / prod.
|
||||||
|
|
||||||
|
-- 1) Nouveaux statuts enfant
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TYPE statut_enfant_type ADD VALUE IF NOT EXISTS 'garde';
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TYPE statut_enfant_type ADD VALUE IF NOT EXISTS 'sans_garde';
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- actif → sans_garde (enfants sans placement AM connu au déploiement)
|
||||||
|
UPDATE enfants SET statut = 'sans_garde' WHERE statut = 'actif';
|
||||||
|
|
||||||
|
-- 2) Table de placement AM ↔ enfant
|
||||||
|
CREATE TABLE IF NOT EXISTS enfants_assistantes_maternelles (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
id_am UUID NOT NULL REFERENCES assistantes_maternelles(id_utilisateur) ON DELETE CASCADE,
|
||||||
|
id_enfant UUID NOT NULL REFERENCES enfants(id) ON DELETE CASCADE,
|
||||||
|
date_debut DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||||
|
date_fin DATE NULL,
|
||||||
|
cree_le TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
cree_par UUID REFERENCES utilisateurs(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_eam_am ON enfants_assistantes_maternelles(id_am);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_eam_enfant ON enfants_assistantes_maternelles(id_enfant);
|
||||||
|
|
||||||
|
-- Au plus une garde active par enfant
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_enfant_garde_active
|
||||||
|
ON enfants_assistantes_maternelles (id_enfant)
|
||||||
|
WHERE date_fin IS NULL;
|
||||||
@@ -65,12 +65,12 @@ ON CONFLICT (id_utilisateur) DO NOTHING;
|
|||||||
|
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
-- Enfants
|
-- 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)
|
-- - child B : à naître (statut = 'a_naitre' et date_prevue_naissance requise)
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
|
|
||||||
INSERT INTO enfants (id, prenom, nom, statut, date_naissance, jumeau_multiple)
|
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;
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
INSERT INTO enfants (id, prenom, nom, statut, date_prevue_naissance, jumeau_multiple)
|
INSERT INTO enfants (id, prenom, nom, statut, date_prevue_naissance, jumeau_multiple)
|
||||||
|
|||||||
@@ -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)
|
INSERT INTO utilisateurs (id, email, password, prenom, nom, role, statut, telephone, adresse, ville, code_postal, profession, situation_familiale, date_naissance, consentement_photo)
|
||||||
VALUES
|
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),
|
('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$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Lucas', 'MOREAU', 'gestionnaire', 'actif', '0687234567', '8 Rue Jean Jaurès', 'Bezons', '95870', 'Gestionnaire des placements', 'celibataire', '1985-09-22', 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$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Marie', 'DUBOIS', 'assistante_maternelle', 'actif', '0696345678', '25 Rue de la République', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1980-06-08', true),
|
('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$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Fatima', 'EL MANSOURI', 'assistante_maternelle', 'actif', '0675456789', '17 Boulevard Aristide Briand', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1975-11-12', 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$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Claire', 'MARTIN', 'parent', 'actif', '0689567890', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Infirmière', 'marie', '1990-04-03', false),
|
('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$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Thomas', 'MARTIN', 'parent', 'actif', '0678456789', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Ingénieur', 'marie', '1988-07-18', 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$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Amélie', 'DURAND', 'parent', 'actif', '0667788990', '23 Rue Victor Hugo', 'Bezons', '95870', 'Comptable', 'divorce', '1987-12-14', 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$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Julien', 'ROUSSEAU', 'parent', 'actif', '0656677889', '14 Rue Pasteur', 'Bezons', '95870', 'Commercial', 'divorce', '1985-08-29', 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$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'David', 'LECOMTE', 'parent', 'actif', '0645566778', '31 Rue Émile Zola', 'Bezons', '95870', 'Développeur web', 'parent_isole', '1992-10-07', 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;
|
ON CONFLICT (email) DO NOTHING;
|
||||||
|
|
||||||
-- ========== PARENTS (avec co-parent pour le couple Martin) ==========
|
-- ========== PARENTS (avec co-parent pour le couple Martin) ==========
|
||||||
@@ -51,12 +51,12 @@ ON CONFLICT (id_utilisateur) DO NOTHING;
|
|||||||
-- ========== ENFANTS ==========
|
-- ========== ENFANTS ==========
|
||||||
INSERT INTO enfants (id, prenom, nom, genre, date_naissance, statut, est_multiple)
|
INSERT INTO enfants (id, prenom, nom, genre, date_naissance, statut, est_multiple)
|
||||||
VALUES
|
VALUES
|
||||||
('e0000001-0001-0001-0001-000000000001', 'Emma', 'MARTIN', 'F', '2023-02-15', 'actif', true),
|
('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', 'actif', 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', 'actif', 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', 'actif', false),
|
('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', 'actif', 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', 'actif', false)
|
('e0000006-0006-0006-0006-000000000006', 'Maxime', 'LECOMTE', 'H', '2023-04-15', 'sans_garde', false)
|
||||||
ON CONFLICT (id) DO NOTHING;
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
-- ========== ENFANTS_PARENTS (liaison N:N) ==========
|
-- ========== ENFANTS_PARENTS (liaison N:N) ==========
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# #131 — En-tête fiche parent : co-parent (note front → back)
|
||||||
|
|
||||||
|
**Ticket :** #131 (fiche parent dashboard, doc `28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1)
|
||||||
|
**Date :** 2026-06-01
|
||||||
|
**Statut front :** livré (en-tête dynamique)
|
||||||
|
**Modif backend demandée :** **aucune fonctionnelle** — ce document fixe le contrat attendu ; le back valide `co_parent` et masque les champs sensibles.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Comportement UI (front)
|
||||||
|
|
||||||
|
Dans la modale **fiche parent** (`AdminParentEditModal`) :
|
||||||
|
|
||||||
|
| Zone | Contenu |
|
||||||
|
|------|---------|
|
||||||
|
| **Titre** | `prenom` + `nom` du parent affiché (plus le libellé fixe « Fiche parent ») |
|
||||||
|
| **Sous-titre** | `Co-parent : {prenom} {nom}` — affiché **uniquement** si un co-parent est connu |
|
||||||
|
|
||||||
|
Le titre se met à jour en direct pendant l’édition des champs nom/prénom.
|
||||||
|
Le sous-titre provient du co-parent **chargé depuis 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,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,73 @@
|
|||||||
|
# Mini-spec API — POST /parents/:id/co-parent (#135)
|
||||||
|
|
||||||
|
Contrat back pour l’ajout d’un **2ᵉ parent** sur un foyer mono-parent (staff).
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| **Méthode** | `POST` |
|
||||||
|
| **URL** | `{base}/api/v1/parents/{parentUserId}/co-parent` |
|
||||||
|
| **Auth** | Bearer JWT |
|
||||||
|
| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` |
|
||||||
|
| **Succès** | **201** |
|
||||||
|
|
||||||
|
`parentUserId` = UUID du **parent pivot** (déjà dans le dossier).
|
||||||
|
|
||||||
|
Ne **pas** appeler `POST /auth/register/parent` ni `POST /parents/dossier`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Body (JSON)
|
||||||
|
|
||||||
|
| Champ | Type | Obligatoire | Notes |
|
||||||
|
|-------|------|-------------|--------|
|
||||||
|
| `email` | string | oui | unique |
|
||||||
|
| `prenom` | string | oui | |
|
||||||
|
| `nom` | string | oui | |
|
||||||
|
| `telephone` | string | oui | `0X…` ou `+33…` |
|
||||||
|
| `meme_adresse` | bool | non | défaut **true** → copie adresse du pivot |
|
||||||
|
| `adresse` | string | si `meme_adresse=false` | |
|
||||||
|
| `code_postal` | string | si `meme_adresse=false` | |
|
||||||
|
| `ville` | string | si `meme_adresse=false` | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Comportement 201
|
||||||
|
|
||||||
|
- User co-parent **actif** + token création MDP
|
||||||
|
- Fiche `parents` + liens pivot ↔ co-parent + même `numero_dossier`
|
||||||
|
- Enfants du foyer rattachés au co-parent
|
||||||
|
- E-mail **création MDP** (pas mail « en attente »)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Co-parent ajouté au foyer. Un e-mail de création de mot de passe a été envoyé.",
|
||||||
|
"numero_dossier": "2026-000043",
|
||||||
|
"parent_user_id": "uuid-pivot",
|
||||||
|
"co_parent_user_id": "uuid-co",
|
||||||
|
"statut": "actif"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Erreurs
|
||||||
|
|
||||||
|
| Code | Cas |
|
||||||
|
|------|-----|
|
||||||
|
| 400 | Déjà un co-parent / 2 responsables / validation adresse |
|
||||||
|
| 401 | Token invalide |
|
||||||
|
| 403 | Rôle non staff |
|
||||||
|
| 404 | Pivot introuvable |
|
||||||
|
| 409 | Email déjà pris |
|
||||||
|
|
||||||
|
## Réemploi édition identité
|
||||||
|
|
||||||
|
| Endpoint | Usage |
|
||||||
|
|----------|--------|
|
||||||
|
| `GET /dossiers/:numero` | Préremplir wizard edit |
|
||||||
|
| `PATCH /parents/:id/fiche` | Sauver identité pivot / co-parent existant |
|
||||||
|
| `PATCH /assistantes-maternelles/:id/fiche` | Édition AM |
|
||||||
|
|
||||||
|
## Branche
|
||||||
|
|
||||||
|
`feature/135-edition-dossier`
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Mini-spec front — Mode édition dossier + ajout 2ᵉ parent (#135)
|
||||||
|
|
||||||
|
Branche : `feature/135-edition-dossier`
|
||||||
|
Ticket : **#135** (full-stack)
|
||||||
|
|
||||||
|
Prérequis : **#153** (liste Dossiers) livré.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
1. Clic sur un dossier (liste #153) → ouvrir le wizard en mode **`edit`**
|
||||||
|
2. Foyer **mono-parent** : page co-parent → **switch** ajouter un 2ᵉ parent
|
||||||
|
3. Sauvegarder les champs via APIs existantes + nouvel endpoint co-parent
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Modes wizard
|
||||||
|
|
||||||
|
| Mode | Famille | AM |
|
||||||
|
|------|---------|-----|
|
||||||
|
| `review` | déjà | déjà |
|
||||||
|
| `create` | déjà (#129) | déjà (#156) |
|
||||||
|
| **`edit`** | **à faire** | **à faire** |
|
||||||
|
|
||||||
|
Factories : `ParentDossierWizard.edit(...)` / `AmDossierWizard.edit(...)`
|
||||||
|
Préremplir via `UserService.getDossierByNumero(numero)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## APIs
|
||||||
|
|
||||||
|
| Action | Endpoint |
|
||||||
|
|--------|----------|
|
||||||
|
| Charger | `GET /dossiers/:numero` |
|
||||||
|
| Sauver parent | `PATCH /parents/:id/fiche` |
|
||||||
|
| Sauver AM | `PATCH /assistantes-maternelles/:id/fiche` |
|
||||||
|
| **Ajouter co-parent** | **`POST /parents/:pivotUserId/co-parent`** — voir `docs/tmp/135-contrat-api-ajout-co-parent.md` |
|
||||||
|
|
||||||
|
Body co-parent :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"email": "thomas@…",
|
||||||
|
"prenom": "Thomas",
|
||||||
|
"nom": "MARTIN",
|
||||||
|
"telephone": "0678456789",
|
||||||
|
"meme_adresse": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`UserService.addCoParent(pivotUserId, body)` → cet endpoint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UX
|
||||||
|
|
||||||
|
- Depuis `DossiersManagementWidget` / carte liste : clic → edit (plus seulement review pending)
|
||||||
|
- Pending : garder validation (review) ; dossiers actifs → edit
|
||||||
|
- Mono-parent : switch « Ajouter un co-parent » (comme create) → au save, `POST …/co-parent` si nouveau
|
||||||
|
- Déjà 2 parents : éditer les deux fiches ; pas de 3ᵉ
|
||||||
|
- Pas de bouton créer dans l’onglet Dossiers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hors scope
|
||||||
|
|
||||||
|
- Famille N responsables (#139)
|
||||||
|
- Suppressions (#154)
|
||||||
|
- Création dossier initial (#129 / #156)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critères d’acceptation
|
||||||
|
|
||||||
|
- [ ] Clic dossier actif → wizard edit prérempli
|
||||||
|
- [ ] PATCH fiche enregistre les modifs
|
||||||
|
- [ ] Mono-parent + switch → co-parent créé (actif + mail MDP)
|
||||||
|
- [ ] review / create inchangés
|
||||||
|
|
||||||
|
## Branche
|
||||||
|
|
||||||
|
`feature/135-edition-dossier`
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Mini-spec API — GET /dossiers (#153)
|
||||||
|
|
||||||
|
Contrat pour le **plan front** (onglet permanent Dossiers).
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| **Méthode** | `GET` |
|
||||||
|
| **URL** | `{base}/api/v1/dossiers` |
|
||||||
|
| **Auth** | Bearer JWT |
|
||||||
|
| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` |
|
||||||
|
| **Query** | `q` (optionnel) — recherche n° / libellé / email |
|
||||||
|
|
||||||
|
Complète `GET /dossiers/:numeroDossier` (#119) déjà existant.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Réponse 200
|
||||||
|
|
||||||
|
Tableau de lignes (1 entrée = 1 `numero_dossier`) :
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"type": "famille",
|
||||||
|
"numero_dossier": "2026-000043",
|
||||||
|
"libelle": "Claire MARTIN & Thomas MARTIN",
|
||||||
|
"emails": ["claire@test.fr", "thomas@test.fr"],
|
||||||
|
"user_ids": ["uuid-pivot", "uuid-co"],
|
||||||
|
"statut": "actif",
|
||||||
|
"a_valider": false,
|
||||||
|
"date_reference": "2026-01-12T10:00:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "assistante_maternelle",
|
||||||
|
"numero_dossier": "2026-000042",
|
||||||
|
"libelle": "Marie DUPONT",
|
||||||
|
"emails": ["marie@test.fr"],
|
||||||
|
"user_ids": ["uuid-am"],
|
||||||
|
"statut": "en_attente",
|
||||||
|
"a_valider": true,
|
||||||
|
"date_reference": "2026-02-01T08:00:00.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Champs
|
||||||
|
|
||||||
|
| Champ | Notes |
|
||||||
|
|-------|--------|
|
||||||
|
| `type` | `famille` \| `assistante_maternelle` |
|
||||||
|
| `numero_dossier` | Clé 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,35 @@
|
|||||||
|
# Matrice suppression — #154 / back **#159** / front **#160**
|
||||||
|
|
||||||
|
**Statut** : cadrage PO validé (sept. 2026)
|
||||||
|
**Milestone** : 0.1.0
|
||||||
|
**Email** : pas d’email de suppression (cas rare)
|
||||||
|
|
||||||
|
## Droits
|
||||||
|
|
||||||
|
| Cible | Qui peut supprimer |
|
||||||
|
|-------|-------------------|
|
||||||
|
| Dossier / parent / enfant / AM | `GESTIONNAIRE`, `ADMINISTRATEUR`, `SUPER_ADMIN` |
|
||||||
|
| Gestionnaire (user) | `ADMINISTRATEUR`, `SUPER_ADMIN` |
|
||||||
|
| Administrateur (user) | Autre admin OK ; **self interdit** ; **dernier admin** = `SUPER_ADMIN` only ; `SUPER_ADMIN` non supprimable |
|
||||||
|
|
||||||
|
## Matrice métier
|
||||||
|
|
||||||
|
| Point d’entrée | Action | Effet |
|
||||||
|
|----------------|--------|--------|
|
||||||
|
| Dossiers | Delete dossier **famille** | Tous **parents** + tous **enfants** ; clore placements AM des enfants |
|
||||||
|
| Dossiers / AM | Delete dossier **AM** ou compte AM | **Compte AM + dossier AM** ; enfants **conservés** ; placements **clos** |
|
||||||
|
| Parents | Co-parent (autre parent reste) | Compte parent seul ; dossier + enfants restent |
|
||||||
|
| Parents | Dernier parent | Parent + **enfants** rattachés |
|
||||||
|
| Enfants | Pas dernier | Enfant seul (retiré du dossier) |
|
||||||
|
| Enfants | Dernier + `deleteDossier=true` | Cascade dossier famille (parents + enfants) |
|
||||||
|
| Enfants | Dernier + `deleteDossier=false` | Enfant seul ; dossier peut apparaître **`sans_enfant`** |
|
||||||
|
| Pending / validé | — | **Mêmes règles** (pas de différenciation) |
|
||||||
|
|
||||||
|
## Warning
|
||||||
|
|
||||||
|
- `sans_enfant` sur liste `GET /dossiers` (dossier famille sans enfant lié).
|
||||||
|
- Miroir de `sans_responsable` (#157) côté enfants.
|
||||||
|
|
||||||
|
## Hors scope
|
||||||
|
|
||||||
|
Soft-delete RGPD, audit (#128), famille N (#139), restriction admin-only métier (plus tard).
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
# Mini-spec front — Suppressions dashboard
|
||||||
|
|
||||||
|
**Ticket front** : **#160** — https://git.ptits-pas.fr/jmartin/petitspas/issues/160
|
||||||
|
**Ticket back** : **#159** — https://git.ptits-pas.fr/jmartin/petitspas/issues/159
|
||||||
|
**Epic** : #154 (complète #133)
|
||||||
|
**Branche back** : `feature/159-suppressions-backend`
|
||||||
|
**Doc matrice** : [154-matrice-suppression.md](./154-matrice-suppression.md)
|
||||||
|
|
||||||
|
Travail **en parallèle** : ce contrat est la source de vérité UI ↔ API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UX commune
|
||||||
|
|
||||||
|
Sur chaque ligne / carte des listes :
|
||||||
|
|
||||||
|
- Icône **poubelle** en bout de ligne
|
||||||
|
- Clic → **dialog de confirmation** (texte d’impact) → DELETE → **refresh** liste
|
||||||
|
- Pending = **mêmes** règles que validés
|
||||||
|
- **Pas** d’email
|
||||||
|
|
||||||
|
| Liste | Poubelle visible si |
|
||||||
|
|-------|---------------------|
|
||||||
|
| Dossiers, Parents, Enfants, AM | gestionnaire **ou** admin |
|
||||||
|
| Gestionnaires | **admin** only |
|
||||||
|
| Administrateurs | admin+ ; **pas** sur sa propre ligne ; dernier admin : UI warning + réservé super_admin |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contrat API
|
||||||
|
|
||||||
|
Base : auth Bearer. Erreurs : `400` / `403` / `404` / `409` avec `message` FR.
|
||||||
|
|
||||||
|
### 1. `DELETE /dossiers/:numeroDossier`
|
||||||
|
|
||||||
|
- **Famille** → supprime tous parents + enfants du n° ; clos placements AM des enfants.
|
||||||
|
- **AM** → compte AM + dossier AM ; enfants gardés ; placements clos.
|
||||||
|
|
||||||
|
**Réponse 200** (exemple) :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "famille",
|
||||||
|
"numero_dossier": "2026-000043",
|
||||||
|
"deleted_user_ids": ["…"],
|
||||||
|
"deleted_enfant_ids": ["…"],
|
||||||
|
"message": "Dossier famille supprimé."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
ou
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "assistante_maternelle",
|
||||||
|
"numero_dossier": "2026-000015",
|
||||||
|
"deleted_user_ids": ["…"],
|
||||||
|
"deleted_enfant_ids": [],
|
||||||
|
"message": "Dossier assistante maternelle supprimé."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dialog UI** : lister libellé + n° + « X parent(s), Y enfant(s) » (ou « compte AM, enfants conservés »).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. `DELETE /users/:id`
|
||||||
|
|
||||||
|
Comportement selon le **rôle** de la cible :
|
||||||
|
|
||||||
|
| Cible | Effet |
|
||||||
|
|-------|--------|
|
||||||
|
| Parent **co-parent** | Delete ce user seul |
|
||||||
|
| Parent **dernier** du dossier | Delete user + enfants du foyer |
|
||||||
|
| AM | Delete user AM + dossier AM ; enfants conservés ; placements clos |
|
||||||
|
| Gestionnaire | Admin only ; self → 403 |
|
||||||
|
| Administrateur | Self → 403 ; dernier admin → super_admin only sinon 403 ; super_admin → 403 |
|
||||||
|
|
||||||
|
**Réponse 200** :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"deleted_user_ids": ["…"],
|
||||||
|
"deleted_enfant_ids": ["…"],
|
||||||
|
"message": "…"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dialogs** :
|
||||||
|
|
||||||
|
- Co-parent : « Ce parent sera retiré / supprimé du dossier {n°}. Les enfants restent avec le co-parent. »
|
||||||
|
- Dernier parent : « Dernier parent du dossier {n°}. Les enfants rattachés seront aussi supprimés. »
|
||||||
|
- AM : « Le compte et le dossier AM seront supprimés. Les enfants accueillis ne seront pas supprimés. »
|
||||||
|
|
||||||
|
Optionnel (si exposé) : `GET /users/:id/suppression-impact` — sinon calculer depuis données déjà en liste / détail dossier.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. `DELETE /enfants/:id?deleteDossier=true|false`
|
||||||
|
|
||||||
|
- Pas dernier enfant → delete enfant (`deleteDossier` ignoré ou false).
|
||||||
|
- Dernier enfant + `deleteDossier=false` → delete enfant ; dossier famille peut passer `sans_enfant`.
|
||||||
|
- Dernier enfant + `deleteDossier=true` → cascade dossier famille (parents + enfants).
|
||||||
|
|
||||||
|
**Réponse 200** :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"deleted_enfant_ids": ["…"],
|
||||||
|
"deleted_user_ids": ["…"],
|
||||||
|
"dossier_supprime": false,
|
||||||
|
"message": "…"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dialog** :
|
||||||
|
|
||||||
|
- Standard : « L’enfant sera supprimé du dossier de {famille} ({n°}). »
|
||||||
|
- Dernier : proposer **deux actions** :
|
||||||
|
1. Supprimer l’enfant seulement (`deleteDossier=false`)
|
||||||
|
2. Supprimer aussi le dossier / parents (`deleteDossier=true`)
|
||||||
|
|
||||||
|
Pour savoir si dernier : compter enfants du `numero_dossier` (détail dossier ou champ impact API).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. `GET /dossiers` — flag `sans_enfant`
|
||||||
|
|
||||||
|
Chaque item famille peut exposer :
|
||||||
|
|
||||||
|
```json
|
||||||
|
"sans_enfant": true
|
||||||
|
```
|
||||||
|
|
||||||
|
- `true` si dossier **famille** sans enfant lié.
|
||||||
|
- AM : `false` ou omis.
|
||||||
|
|
||||||
|
**UI** : badge / warning vigilance (comme `sans_responsable` / alertes AM).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UserService (Flutter) — signatures cibles
|
||||||
|
|
||||||
|
```dart
|
||||||
|
Future<void> deleteDossier(String numeroDossier);
|
||||||
|
Future<Map<String, dynamic>> deleteUser(String userId);
|
||||||
|
Future<Map<String, dynamic>> deleteEnfant(String enfantId, {bool deleteDossier = false});
|
||||||
|
```
|
||||||
|
|
||||||
|
(Adapter le parsing au JSON réel une fois le back mergé ; en parallèle, stubber sur ce contrat.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fichiers front probables
|
||||||
|
|
||||||
|
- Cartes listes : `admin_user_card.dart`, `admin_enfant_user_card.dart`, cartes dossiers
|
||||||
|
- Listes : `dossiers_management_widget.dart`, `parent_managmant_widget.dart`, `enfant_management_widget.dart`, `assistante_maternelle_management_widget.dart`, `gestionnaire_management_widget.dart`, `admin_management_widget.dart`
|
||||||
|
- `user_service.dart`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critères front (#160)
|
||||||
|
|
||||||
|
- [ ] Poubelle selon droits
|
||||||
|
- [ ] Confirmations avec impact
|
||||||
|
- [ ] Refresh après succès
|
||||||
|
- [ ] Dernier enfant : choix dossier oui/non
|
||||||
|
- [ ] Warning `sans_enfant`
|
||||||
|
- [ ] Self-admin / dernier admin gérés côté UI (masquer ou message 403)
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Mini-spec — Rename préfixe `Admin*` dashboard partagé (#155)
|
||||||
|
|
||||||
|
**Ticket** : **#155**
|
||||||
|
**Branche** : `feature/155-rename-admin-prefix-dashboard` (depuis `develop`)
|
||||||
|
**Décision naming** : **option C** — dossier neutre `widgets/dashboard/` + noms **sans** préfixe `Admin`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Principe
|
||||||
|
|
||||||
|
Les widgets partagés **admin + gestionnaire** ne doivent plus s’appeler `Admin*`.
|
||||||
|
On garde `Admin*` seulement là où c’est vraiment le rôle administrateur.
|
||||||
|
|
||||||
|
## Gardé `Admin*` (hors rename)
|
||||||
|
|
||||||
|
| Élément | Raison |
|
||||||
|
|---------|--------|
|
||||||
|
| `AdminManagementWidget` | Onglet **Administrateurs** |
|
||||||
|
| `screens/administrateurs/*` | `AdminDashboardScreen`, `AdminCreateDialog`, `AdminUserFormDialog` |
|
||||||
|
| `EnfantAdminModel` | Modèle API (pas un widget) — hors scope ticket |
|
||||||
|
|
||||||
|
## Renames faits
|
||||||
|
|
||||||
|
| Avant | Après |
|
||||||
|
|-------|--------|
|
||||||
|
| `widgets/admin/common/admin_child_detail_modal.dart` → `AdminChildDetailModal` | `widgets/dashboard/child_detail_modal.dart` → `ChildDetailModal` |
|
||||||
|
| `admin_am_edit_modal` → `AdminAmEditModal` | `am_edit_modal` → `AmEditModal` |
|
||||||
|
| `admin_parent_edit_modal` → `AdminParentEditModal` | `parent_edit_modal` → `ParentEditModal` |
|
||||||
|
| `admin_user_card` → `AdminUserCard` | `user_card` → `UserCard` |
|
||||||
|
| `admin_enfant_user_card` → `AdminEnfantUserCard` | `enfant_user_card` → `EnfantUserCard` |
|
||||||
|
| `admin_am_photo_frame` → `AdminAmPhotoFrame` | `am_photo_frame` → `AmPhotoFrame` |
|
||||||
|
| `admin_am_children_capacity_grid` | `am_children_capacity_grid` → `AmChildrenCapacityGrid` |
|
||||||
|
| `admin_children_affiliation_panel` | `children_affiliation_panel` → `ChildrenAffiliationPanel` |
|
||||||
|
| `admin_select_*` / `AdminSelect*` / `AdminFamilleFoyer` | `select_*` / `Select*` / `FamilleFoyer` |
|
||||||
|
| `admin_status_capsule` | `status_capsule` → `StatusCapsule` |
|
||||||
|
| `admin_list_state` → `AdminListState` | `user_list_state` → `UserListState` |
|
||||||
|
| `admin_detail_modal` → `AdminDetailModal` / `AdminDetailField` | `detail_modal` → `DetailModal` / `DetailField` |
|
||||||
|
| `dashboard_admin.dart` | `user_management_sub_bar.dart` (`DashboardUserManagementSubBar` inchangé) |
|
||||||
|
|
||||||
|
Dossier `widgets/admin/` conserve encore les panels métier (`user_management_panel`, wizards, etc.) + `AdminManagementWidget`.
|
||||||
|
|
||||||
|
**Phase 2** (même ticket #155) : déplacer ces panels → `widgets/dashboard/` — voir [155-suite-move-admin-panels-to-dashboard.md](./155-suite-move-admin-panels-to-dashboard.md).
|
||||||
|
|
||||||
|
## Hors scope
|
||||||
|
|
||||||
|
- Refonte UX modales (ticket dédié)
|
||||||
|
- Rename API / back
|
||||||
|
- Déplacer tout `widgets/admin/` → `widgets/dashboard/` (panels) — possible follow-up
|
||||||
|
|
||||||
|
## Critères
|
||||||
|
|
||||||
|
- [x] Plus de préfixe `Admin` sur les composants **partagés** listés
|
||||||
|
- [ ] Build Flutter / recette dashboard admin + gestionnaire OK
|
||||||
|
- [x] Pas de changement comportemental (rename mécanique)
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
# Mini-spec — Déplacer les panels `widgets/admin/` → `widgets/dashboard/`
|
||||||
|
|
||||||
|
**Ticket** : **#155** (phase 2 — même ticket que le rename `Admin*`)
|
||||||
|
**Phase 1** : widgets `Admin*` → `widgets/dashboard/` (déjà sur `feature/155-rename-admin-prefix-dashboard`)
|
||||||
|
**Branche** : poursuivre / rebaser `feature/155-rename-admin-prefix-dashboard` (ou nouvelle branche depuis `develop` après merge phase 1)
|
||||||
|
**Nature** : rename / move mécanique — **zéro** changement UX / métier
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
Après la phase 1 (#155), la situation est **hybride** :
|
||||||
|
|
||||||
|
| Emplacement | Contenu |
|
||||||
|
|-------------|---------|
|
||||||
|
| `widgets/dashboard/` | Composants partagés sans préfixe `Admin*` (modales, cartes, selects, sub-bar…) |
|
||||||
|
| `widgets/admin/` | **Panels** du dashboard staff (listes, wizards, validation, shell `UserManagementPanel`…) + `AdminManagementWidget` |
|
||||||
|
|
||||||
|
Le dossier `admin/` laisse encore croire « réservé administrateur », alors que **gestionnaire** consomme les mêmes panels (`GestionnaireDashboardScreen` → `UserManagementPanel`).
|
||||||
|
|
||||||
|
Ce ticket **termine l’option C** au niveau dossier : tout le dashboard staff vit sous `widgets/dashboard/`, sauf ce qui est **vraiment** rôle admin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/lib/widgets/admin/<panels & common partagés>
|
||||||
|
↓ git mv + update imports
|
||||||
|
frontend/lib/widgets/dashboard/…
|
||||||
|
```
|
||||||
|
|
||||||
|
Critère : un nouveau dev ne doit plus ouvrir `widgets/admin/` pour du code partagé admin+gestionnaire.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cible d’arborescence (proposée)
|
||||||
|
|
||||||
|
```
|
||||||
|
widgets/dashboard/
|
||||||
|
├── (déjà là #155) child_detail_modal.dart, am_edit_modal.dart, user_card.dart, …
|
||||||
|
├── user_management_panel.dart ← shell onglets
|
||||||
|
├── user_management_sub_bar.dart ← déjà déplacé #155
|
||||||
|
├── dossiers_management_widget.dart
|
||||||
|
├── dossier_list_card.dart
|
||||||
|
├── parent_management_widget.dart ← corriger le typo managmant au passage ?
|
||||||
|
├── enfant_management_widget.dart
|
||||||
|
├── assistante_maternelle_management_widget.dart
|
||||||
|
├── gestionnaire_management_widget.dart
|
||||||
|
├── pending_validation_widget.dart
|
||||||
|
├── parent_dossier_create_modal.dart
|
||||||
|
├── parent_dossier_wizard.dart
|
||||||
|
├── am_dossier_create_modal.dart
|
||||||
|
├── am_dossier_wizard.dart
|
||||||
|
├── validation_*.dart ← family/am wizards, refus, theme, confirm
|
||||||
|
├── parametres_panel.dart ← utilisé par écran admin (OK dans dashboard)
|
||||||
|
├── relais_management_panel.dart
|
||||||
|
├── common/ ← sous-dossier optionnel
|
||||||
|
│ ├── suppression_confirm_dialog.dart
|
||||||
|
│ ├── user_list.dart
|
||||||
|
│ └── validation_detail_section.dart
|
||||||
|
└── …
|
||||||
|
|
||||||
|
widgets/admin/ ← mince, rôle admin seulement
|
||||||
|
└── admin_management_widget.dart ← onglet Administrateurs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Variante B (plus stricte)
|
||||||
|
|
||||||
|
`AdminManagementWidget` + éventuels helpers purement admin →
|
||||||
|
`screens/administrateurs/widgets/`
|
||||||
|
et **suppression** du dossier `widgets/admin/`.
|
||||||
|
|
||||||
|
**Reco** : **variante A** (garder `widgets/admin/` minimal avec seulement `AdminManagementWidget`) — moins de churn screens, clair.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Inventaire à déplacer (état actuel)
|
||||||
|
|
||||||
|
### Racine `widgets/admin/` → `widgets/dashboard/`
|
||||||
|
|
||||||
|
| Fichier actuel | Notes |
|
||||||
|
|----------------|--------|
|
||||||
|
| `user_management_panel.dart` | Shell partagé admin + gestionnaire |
|
||||||
|
| `dossiers_management_widget.dart` | |
|
||||||
|
| `dossier_list_card.dart` | |
|
||||||
|
| `parent_managmant_widget.dart` | Typo historique `managmant` — **option** : renommer → `parent_management_widget.dart` dans le même ticket ou ticket typo séparé |
|
||||||
|
| `enfant_management_widget.dart` | |
|
||||||
|
| `assistante_maternelle_management_widget.dart` | |
|
||||||
|
| `gestionnaire_management_widget.dart` | |
|
||||||
|
| `pending_validation_widget.dart` | |
|
||||||
|
| `parent_dossier_create_modal.dart` | |
|
||||||
|
| `parent_dossier_wizard.dart` | |
|
||||||
|
| `am_dossier_create_modal.dart` | |
|
||||||
|
| `am_dossier_wizard.dart` | |
|
||||||
|
| `validation_am_wizard.dart` | |
|
||||||
|
| `validation_family_wizard.dart` | |
|
||||||
|
| `validation_dossier_modal.dart` | |
|
||||||
|
| `validation_modal_theme.dart` | |
|
||||||
|
| `validation_refus_form.dart` | |
|
||||||
|
| `validation_valider_confirm_dialog.dart` | |
|
||||||
|
| `parametres_panel.dart` | Écran admin seulement, mais pas préfixé Admin — OK dashboard |
|
||||||
|
| `relais_management_panel.dart` | |
|
||||||
|
|
||||||
|
### `widgets/admin/common/` → `widgets/dashboard/common/` (ou plat)
|
||||||
|
|
||||||
|
| Fichier | Notes |
|
||||||
|
|---------|--------|
|
||||||
|
| `suppression_confirm_dialog.dart` | Partagé (y compris `screens/administrateurs/creation/*`) |
|
||||||
|
| `user_list.dart` | |
|
||||||
|
| `validation_detail_section.dart` | |
|
||||||
|
|
||||||
|
### **Ne pas** déplacer
|
||||||
|
|
||||||
|
| Fichier | Destination |
|
||||||
|
|---------|-------------|
|
||||||
|
| `admin_management_widget.dart` | Reste `widgets/admin/` (ou variante B → screens) |
|
||||||
|
|
||||||
|
### Déjà fait (#155) — ne pas retraiter
|
||||||
|
|
||||||
|
Tout ce qui est déjà sous `widgets/dashboard/` (`child_detail_modal`, `am_edit_modal`, `user_card`, `select_*`, `user_management_sub_bar`, …).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consommateurs d’imports (à mettre à jour)
|
||||||
|
|
||||||
|
### Screens
|
||||||
|
- `screens/administrateurs/admin_dashboardScreen.dart` — `UserManagementPanel`, `ParametresPanel`
|
||||||
|
- `screens/gestionnaire/gestionnaire_dashboard_screen.dart` — `UserManagementPanel`
|
||||||
|
- `screens/administrateurs/creation/admin_create.dart` — `suppression_confirm_dialog`
|
||||||
|
- `screens/administrateurs/creation/gestionnaires_create.dart` — idem
|
||||||
|
|
||||||
|
### Widgets déjà en `dashboard/`
|
||||||
|
- `am_edit_modal`, `child_detail_modal`, `parent_edit_modal`, `select_*` — imports vers `widgets/admin/common/*` ou panels
|
||||||
|
|
||||||
|
### Divers
|
||||||
|
- `widgets/common/identity_block.dart` (si import admin)
|
||||||
|
- Tous les fichiers **déplacés** entre eux (imports relatifs / package)
|
||||||
|
|
||||||
|
### Hors scope rename classes
|
||||||
|
Sauf décision explicite sur le typo `parent_managmant_widget` → pas de rename de **classes** métier dans ce ticket (seulement chemins de fichiers + imports).
|
||||||
|
`AdminManagementWidget` **conserve** son nom.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plan d’exécution
|
||||||
|
|
||||||
|
1. Partir de `feature/155-rename-admin-prefix-dashboard` (phase 1) **ou** `develop` si phase 1 déjà mergée
|
||||||
|
2. `git mv` fichiers selon inventaire
|
||||||
|
3. Remplacer globalement
|
||||||
|
`package:p_tits_pas/widgets/admin/` → `package:p_tits_pas/widgets/dashboard/`
|
||||||
|
**sauf** `…/widgets/admin/admin_management_widget.dart`
|
||||||
|
4. Corriger imports relatifs cassés
|
||||||
|
5. Grep de contrôle (ci-dessous)
|
||||||
|
6. Build Flutter web (Docker) + smoke dashboard admin **et** gestionnaire
|
||||||
|
7. Merge → squash master si flux habituel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vérifs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Plus de panels partagés sous admin (seul AdminManagement attendu)
|
||||||
|
find frontend/lib/widgets/admin -name '*.dart'
|
||||||
|
|
||||||
|
# Plus d’imports panels vers l’ancien chemin (sauf AdminManagement)
|
||||||
|
rg -n "widgets/admin/(user_management|dossiers_|parent_|enfant_|assistante|gestionnaire|pending|validation_|parametres|relais|am_dossier|parent_dossier|dossier_list|common/)" frontend/lib
|
||||||
|
|
||||||
|
# Screens OK
|
||||||
|
rg -n "widgets/admin/" frontend/lib/screens
|
||||||
|
```
|
||||||
|
|
||||||
|
Attendu screens : **0** hit vers panels ; éventuellement plus aucun hit `widgets/admin/` sauf si import explicite `AdminManagementWidget` depuis `user_management_panel` (chemin `widgets/admin/admin_management_widget.dart`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hors scope
|
||||||
|
|
||||||
|
- Refonte UX des modales / panels (ticket dédié annoncé)
|
||||||
|
- Rename `EnfantAdminModel`
|
||||||
|
- Rename `screens/administrateurs/`
|
||||||
|
- Rename `AdminUserFormDialog` / `AdminCreateDialog`
|
||||||
|
- Changement API / back
|
||||||
|
- #152 (`est_multiple`) — autre branche
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critères d’acceptation
|
||||||
|
|
||||||
|
- [ ] Inventaire déplacé selon tableau
|
||||||
|
- [ ] `widgets/admin/` ne contient plus que `admin_management_widget.dart` (variante A)
|
||||||
|
- [ ] Imports screens + widgets à jour
|
||||||
|
- [ ] Build Flutter OK
|
||||||
|
- [ ] Recette : dashboard **administrateur** et **gestionnaire** (listes, ouverture fiches, validation, création dossier) sans régression
|
||||||
|
- [ ] Aucun changement comportemental volontaire
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risques / notes
|
||||||
|
|
||||||
|
- **Conflits de merge** si d’autres features touchent les panels → faire ce ticket quand la surface dashboard est calme (fin 0.1.0 OK)
|
||||||
|
- Typo `parent_managmant_widget` : soit inclus (bonus), soit ticket cleanup 1-ligne séparé
|
||||||
|
- Docs d’archive citant `widgets/admin/…` : pas obligatoire de mettre à jour ; `docs/27_BRIEFING-FRONTEND.md` oui si encore listé
|
||||||
@@ -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,30 +1,102 @@
|
|||||||
|
import 'package:p_tits_pas/models/parent_child_summary.dart';
|
||||||
import 'package:p_tits_pas/models/user.dart';
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
|
|
||||||
class AssistanteMaternelleModel {
|
class AssistanteMaternelleModel {
|
||||||
final AppUser user;
|
final AppUser user;
|
||||||
final String? approvalNumber;
|
final String? approvalNumber;
|
||||||
|
final String? nir;
|
||||||
final String? residenceCity;
|
final String? residenceCity;
|
||||||
final int? maxChildren;
|
final int? maxChildren;
|
||||||
final int? placesAvailable;
|
final int? placesAvailable;
|
||||||
|
final String? biography;
|
||||||
|
final bool? available;
|
||||||
|
final String? agreementDate;
|
||||||
|
final List<ParentChildSummary> children;
|
||||||
|
|
||||||
AssistanteMaternelleModel({
|
AssistanteMaternelleModel({
|
||||||
required this.user,
|
required this.user,
|
||||||
this.approvalNumber,
|
this.approvalNumber,
|
||||||
|
this.nir,
|
||||||
this.residenceCity,
|
this.residenceCity,
|
||||||
this.maxChildren,
|
this.maxChildren,
|
||||||
this.placesAvailable,
|
this.placesAvailable,
|
||||||
|
this.biography,
|
||||||
|
this.available,
|
||||||
|
this.agreementDate,
|
||||||
|
this.children = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
factory AssistanteMaternelleModel.fromJson(Map<String, dynamic> json) {
|
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 user = AppUser.fromJson(userJson);
|
||||||
|
final children = _parseChildren(root);
|
||||||
|
|
||||||
return AssistanteMaternelleModel(
|
return AssistanteMaternelleModel(
|
||||||
user: user,
|
user: user,
|
||||||
approvalNumber: json['numero_agrement'] as String?,
|
approvalNumber: _str(
|
||||||
residenceCity: json['ville_residence'] as String?,
|
root['approval_number'] ?? root['numero_agrement'],
|
||||||
maxChildren: json['nb_max_enfants'] as int?,
|
),
|
||||||
placesAvailable: json['place_disponible'] as int?,
|
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,239 @@
|
|||||||
|
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
|
||||||
|
import 'package:p_tits_pas/models/parent_model.dart';
|
||||||
|
import 'package:p_tits_pas/utils/name_format_utils.dart';
|
||||||
|
|
||||||
|
/// Ligne de liste unifiée dossiers (famille ou AM) — ticket #153.
|
||||||
|
enum DossierListType { famille, assistanteMaternelle }
|
||||||
|
|
||||||
|
class DossierListItem {
|
||||||
|
final DossierListType type;
|
||||||
|
final String numeroDossier;
|
||||||
|
final String libelle;
|
||||||
|
final List<String> emails;
|
||||||
|
final String? statut;
|
||||||
|
/// Photo profil (AM) — affichée à la place de l’icône si présente.
|
||||||
|
final String? photoUrl;
|
||||||
|
/// Dossier famille sans enfant lié (#159 / #160).
|
||||||
|
final bool sansEnfant;
|
||||||
|
/// Nombre d’enfants du foyer (famille uniquement ; null pour AM).
|
||||||
|
final int? enfantsCount;
|
||||||
|
|
||||||
|
const DossierListItem({
|
||||||
|
required this.type,
|
||||||
|
required this.numeroDossier,
|
||||||
|
required this.libelle,
|
||||||
|
this.emails = const [],
|
||||||
|
this.statut,
|
||||||
|
this.photoUrl,
|
||||||
|
this.sansEnfant = false,
|
||||||
|
this.enfantsCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
DossierListItem copyWith({
|
||||||
|
DossierListType? type,
|
||||||
|
String? numeroDossier,
|
||||||
|
String? libelle,
|
||||||
|
List<String>? emails,
|
||||||
|
String? statut,
|
||||||
|
String? photoUrl,
|
||||||
|
bool? sansEnfant,
|
||||||
|
int? enfantsCount,
|
||||||
|
}) {
|
||||||
|
return DossierListItem(
|
||||||
|
type: type ?? this.type,
|
||||||
|
numeroDossier: numeroDossier ?? this.numeroDossier,
|
||||||
|
libelle: libelle ?? this.libelle,
|
||||||
|
emails: emails ?? this.emails,
|
||||||
|
statut: statut ?? this.statut,
|
||||||
|
photoUrl: photoUrl ?? this.photoUrl,
|
||||||
|
sansEnfant: sansEnfant ?? this.sansEnfant,
|
||||||
|
enfantsCount: enfantsCount ?? this.enfantsCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get isFamille => type == DossierListType.famille;
|
||||||
|
bool get isAm => type == DossierListType.assistanteMaternelle;
|
||||||
|
|
||||||
|
String get typeLabel => isFamille ? 'Famille' : 'AM';
|
||||||
|
|
||||||
|
/// Sous-titre carte : `NOM Prénom` ou `NOM Prénom - NOM Prénom`.
|
||||||
|
String get namesLine => libelle;
|
||||||
|
|
||||||
|
String get emailsLine => emails.where((e) => e.trim().isNotEmpty).join(' · ');
|
||||||
|
|
||||||
|
/// Titre carte : numéro de dossier seul.
|
||||||
|
String get titleLine => numeroDossier;
|
||||||
|
|
||||||
|
bool matchesQuery(String query) {
|
||||||
|
final q = query.trim().toLowerCase();
|
||||||
|
if (q.isEmpty) return true;
|
||||||
|
if (numeroDossier.toLowerCase().contains(q)) return true;
|
||||||
|
if (libelle.toLowerCase().contains(q)) return true;
|
||||||
|
for (final e in emails) {
|
||||||
|
if (e.toLowerCase().contains(q)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Une ligne par `numero_dossier` (foyer dédupliqué).
|
||||||
|
static List<DossierListItem> fromParents(List<ParentModel> parents) {
|
||||||
|
final byDossier = <String, List<ParentModel>>{};
|
||||||
|
for (final p in parents) {
|
||||||
|
final num = (p.user.numeroDossier ?? '').trim();
|
||||||
|
if (num.isEmpty) continue;
|
||||||
|
byDossier.putIfAbsent(num, () => []).add(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
final items = <DossierListItem>[];
|
||||||
|
for (final entry in byDossier.entries) {
|
||||||
|
final seenIds = <String>{};
|
||||||
|
final names = <String>[];
|
||||||
|
final emails = <String>[];
|
||||||
|
final statuts = <String>[];
|
||||||
|
|
||||||
|
void consider(
|
||||||
|
String? id,
|
||||||
|
String? nom,
|
||||||
|
String? prenom,
|
||||||
|
String? email,
|
||||||
|
String? statut,
|
||||||
|
) {
|
||||||
|
final uid = (id ?? '').trim();
|
||||||
|
if (uid.isEmpty || !seenIds.add(uid)) return;
|
||||||
|
final label = formatDossierPersonLabel(
|
||||||
|
nom: nom,
|
||||||
|
prenom: prenom,
|
||||||
|
email: email,
|
||||||
|
);
|
||||||
|
if (label.isNotEmpty) names.add(label);
|
||||||
|
final e = (email ?? '').trim();
|
||||||
|
if (e.isNotEmpty) emails.add(e);
|
||||||
|
final s = (statut ?? '').trim();
|
||||||
|
if (s.isNotEmpty) statuts.add(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final p in entry.value) {
|
||||||
|
consider(
|
||||||
|
p.user.id,
|
||||||
|
p.user.nom,
|
||||||
|
p.user.prenom,
|
||||||
|
p.user.email,
|
||||||
|
p.user.statut,
|
||||||
|
);
|
||||||
|
final co = p.coParent;
|
||||||
|
if (co != null) {
|
||||||
|
consider(co.id, co.nom, co.prenom, co.email, co.statut);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final childIds = <String>{};
|
||||||
|
var maxCountFallback = 0;
|
||||||
|
for (final p in entry.value) {
|
||||||
|
for (final c in p.children) {
|
||||||
|
final id = c.id.trim();
|
||||||
|
if (id.isNotEmpty) childIds.add(id);
|
||||||
|
}
|
||||||
|
final n = p.children.isNotEmpty ? p.children.length : p.childrenCount;
|
||||||
|
if (n > maxCountFallback) maxCountFallback = n;
|
||||||
|
}
|
||||||
|
final enfantsCount =
|
||||||
|
childIds.isNotEmpty ? childIds.length : maxCountFallback;
|
||||||
|
|
||||||
|
items.add(
|
||||||
|
DossierListItem(
|
||||||
|
type: DossierListType.famille,
|
||||||
|
numeroDossier: entry.key,
|
||||||
|
libelle: names.isNotEmpty ? names.join(' - ') : 'Famille',
|
||||||
|
emails: emails,
|
||||||
|
statut: _preferStatut(statuts),
|
||||||
|
enfantsCount: enfantsCount,
|
||||||
|
sansEnfant: enfantsCount == 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<DossierListItem> fromAssistantes(
|
||||||
|
List<AssistanteMaternelleModel> ams,
|
||||||
|
) {
|
||||||
|
final byDossier = <String, AssistanteMaternelleModel>{};
|
||||||
|
for (final am in ams) {
|
||||||
|
final num = (am.user.numeroDossier ?? '').trim();
|
||||||
|
if (num.isEmpty) continue;
|
||||||
|
byDossier.putIfAbsent(num, () => am);
|
||||||
|
}
|
||||||
|
|
||||||
|
return byDossier.entries.map((e) {
|
||||||
|
final u = e.value.user;
|
||||||
|
final name = formatDossierPersonLabel(
|
||||||
|
nom: u.nom,
|
||||||
|
prenom: u.prenom,
|
||||||
|
email: u.email,
|
||||||
|
);
|
||||||
|
final photo = (u.photoUrl ?? '').trim();
|
||||||
|
return DossierListItem(
|
||||||
|
type: DossierListType.assistanteMaternelle,
|
||||||
|
numeroDossier: e.key,
|
||||||
|
libelle: name.isNotEmpty ? name : 'AM',
|
||||||
|
emails: u.email.trim().isEmpty ? const [] : [u.email.trim()],
|
||||||
|
statut: u.statut?.trim(),
|
||||||
|
photoUrl: photo.isEmpty ? null : photo,
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Priorité affichage : en_attente > suspendu > refuse > actif > autre.
|
||||||
|
static String? _preferStatut(List<String> raw) {
|
||||||
|
if (raw.isEmpty) return null;
|
||||||
|
const order = ['en_attente', 'suspendu', 'refuse', 'actif'];
|
||||||
|
for (final wanted in order) {
|
||||||
|
for (final s in raw) {
|
||||||
|
if (s.toLowerCase() == wanted) return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return raw.first;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Affichage carte dossier : `NOM Prénom` (repli email).
|
||||||
|
String formatDossierPersonLabel({
|
||||||
|
String? nom,
|
||||||
|
String? prenom,
|
||||||
|
String? email,
|
||||||
|
}) {
|
||||||
|
final n = (nom ?? '').trim().toUpperCase();
|
||||||
|
final p = formatPersonNameCase(prenom ?? '');
|
||||||
|
if (n.isNotEmpty && p.isNotEmpty) return '$n $p';
|
||||||
|
if (n.isNotEmpty) return n;
|
||||||
|
if (p.isNotEmpty) return p;
|
||||||
|
return (email ?? '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reformate un libellé famille API (`A & B` / `Famille …`) en `NOM Prénom - …`.
|
||||||
|
String formatDossierFamilyNamesLine(String libelle) {
|
||||||
|
var raw = libelle.trim();
|
||||||
|
if (raw.isEmpty) return '';
|
||||||
|
raw = raw.replaceFirst(RegExp(r'^famille\s+', caseSensitive: false), '');
|
||||||
|
raw = raw
|
||||||
|
.replaceAll(RegExp(r'\s+&\s+'), ' - ')
|
||||||
|
.replaceAll(RegExp(r'\s+et\s+', caseSensitive: false), ' - ');
|
||||||
|
final parts = raw
|
||||||
|
.split(RegExp(r'\s+-\s+'))
|
||||||
|
.map((part) => _formatLoosePersonSegment(part.trim()))
|
||||||
|
.where((s) => s.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
return parts.join(' - ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Segment libre type « martin sophie » ou « DURAND Amélie » → `NOM Prénom`.
|
||||||
|
String _formatLoosePersonSegment(String segment) {
|
||||||
|
final words =
|
||||||
|
segment.split(RegExp(r'\s+')).where((w) => w.isNotEmpty).toList();
|
||||||
|
if (words.isEmpty) return '';
|
||||||
|
if (words.length == 1) return words.first.toUpperCase();
|
||||||
|
// Convention affichage : premier mot = NOM, reste = prénom(s).
|
||||||
|
final nom = words.first.toUpperCase();
|
||||||
|
final prenom = formatPersonNameCase(words.sublist(1).join(' '));
|
||||||
|
return '$nom $prenom';
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:p_tits_pas/models/user.dart';
|
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.
|
/// Réponse unifiée GET /dossiers/:numeroDossier. Ticket #119, #107.
|
||||||
class DossierUnifie {
|
class DossierUnifie {
|
||||||
@@ -214,13 +215,18 @@ class EnfantDossier {
|
|||||||
factory EnfantDossier.fromJson(Map<String, dynamic> json) {
|
factory EnfantDossier.fromJson(Map<String, dynamic> json) {
|
||||||
final rawPhoto = json['photo_url'] ?? json['photoUrl'];
|
final rawPhoto = json['photo_url'] ?? json['photoUrl'];
|
||||||
final resolvedPhoto = _optionalPhotoUrl(rawPhoto);
|
final resolvedPhoto = _optionalPhotoUrl(rawPhoto);
|
||||||
|
final rawId = json['id'] ??
|
||||||
|
json['enfant_id'] ??
|
||||||
|
json['enfantId'] ??
|
||||||
|
json['child_id'] ??
|
||||||
|
json['childId'];
|
||||||
return EnfantDossier(
|
return EnfantDossier(
|
||||||
id: json['id']?.toString() ?? '',
|
id: rawId?.toString().trim() ?? '',
|
||||||
firstName: (json['first_name'] ?? json['prenom'])?.toString(),
|
firstName: (json['first_name'] ?? json['prenom'])?.toString(),
|
||||||
lastName: (json['last_name'] ?? json['nom'])?.toString(),
|
lastName: (json['last_name'] ?? json['nom'])?.toString(),
|
||||||
birthDate: json['birth_date']?.toString(),
|
birthDate: json['birth_date']?.toString(),
|
||||||
gender: (json['gender'] ?? json['genre'])?.toString(),
|
gender: (json['gender'] ?? json['genre'])?.toString(),
|
||||||
status: json['status']?.toString(),
|
status: normalizeEnfantStatus(json['status']?.toString()),
|
||||||
dueDate: json['due_date']?.toString(),
|
dueDate: json['due_date']?.toString(),
|
||||||
photoUrl: resolvedPhoto,
|
photoUrl: resolvedPhoto,
|
||||||
consentPhoto:
|
consentPhoto:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:p_tits_pas/models/user.dart';
|
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).
|
/// Enfant tel que renvoyé par `GET /enfants` (dashboard admin).
|
||||||
class EnfantAdminModel {
|
class EnfantAdminModel {
|
||||||
@@ -13,6 +14,8 @@ class EnfantAdminModel {
|
|||||||
final bool consentPhoto;
|
final bool consentPhoto;
|
||||||
final bool isMultiple;
|
final bool isMultiple;
|
||||||
final List<EnfantParentLink> parentLinks;
|
final List<EnfantParentLink> parentLinks;
|
||||||
|
/// Flag API #157 (sinon déduit de [parentLinks]).
|
||||||
|
final bool? sansResponsable;
|
||||||
|
|
||||||
EnfantAdminModel({
|
EnfantAdminModel({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -26,6 +29,7 @@ class EnfantAdminModel {
|
|||||||
this.consentPhoto = false,
|
this.consentPhoto = false,
|
||||||
this.isMultiple = false,
|
this.isMultiple = false,
|
||||||
this.parentLinks = const [],
|
this.parentLinks = const [],
|
||||||
|
this.sansResponsable,
|
||||||
});
|
});
|
||||||
|
|
||||||
String get fullName {
|
String get fullName {
|
||||||
@@ -36,6 +40,42 @@ class EnfantAdminModel {
|
|||||||
return '$fn $ln';
|
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) {
|
factory EnfantAdminModel.fromJson(Map<String, dynamic> json) {
|
||||||
final linksRaw = json['parentLinks'] as List?;
|
final linksRaw = json['parentLinks'] as List?;
|
||||||
final links = <EnfantParentLink>[];
|
final links = <EnfantParentLink>[];
|
||||||
@@ -47,18 +87,34 @@ class EnfantAdminModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final photoUrl = json['photo_url'] as String? ?? json['photoUrl'] as String?;
|
||||||
|
final consentPhoto = _parseBool(json['consent_photo']) ||
|
||||||
|
_parseBool(json['consentement_photo']) ||
|
||||||
|
_parseBool(json['consentPhoto']);
|
||||||
|
|
||||||
|
bool? sansResponsable;
|
||||||
|
if (json.containsKey('sans_responsable') ||
|
||||||
|
json.containsKey('sansResponsable')) {
|
||||||
|
sansResponsable = _parseBool(json['sans_responsable']) ||
|
||||||
|
_parseBool(json['sansResponsable']);
|
||||||
|
}
|
||||||
|
|
||||||
return EnfantAdminModel(
|
return EnfantAdminModel(
|
||||||
id: (json['id'] ?? '').toString(),
|
id: (json['id'] ?? '').toString(),
|
||||||
firstName: json['first_name'] as String?,
|
firstName: json['first_name'] as String? ?? json['prenom'] as String?,
|
||||||
lastName: json['last_name'] as String?,
|
lastName: json['last_name'] as String? ?? json['nom'] as String?,
|
||||||
gender: json['gender'] as String?,
|
gender: json['gender'] as String? ?? json['genre'] as String?,
|
||||||
birthDate: _dateString(json['birth_date']),
|
birthDate: _dateString(json['birth_date'] ?? json['date_naissance']),
|
||||||
dueDate: _dateString(json['due_date']),
|
dueDate: _dateString(json['due_date'] ?? json['date_prevue_naissance']),
|
||||||
status: (json['status'] ?? '').toString(),
|
status: normalizeEnfantStatus(
|
||||||
photoUrl: json['photo_url'] as String?,
|
(json['status'] ?? json['statut'])?.toString(),
|
||||||
consentPhoto: json['consent_photo'] == true,
|
),
|
||||||
isMultiple: json['is_multiple'] == true,
|
photoUrl: photoUrl,
|
||||||
|
consentPhoto: consentPhoto,
|
||||||
|
isMultiple: _parseBool(json['is_multiple']) ||
|
||||||
|
_parseBool(json['est_multiple']),
|
||||||
parentLinks: links,
|
parentLinks: links,
|
||||||
|
sansResponsable: sansResponsable,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +131,15 @@ class EnfantAdminModel {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool _parseBool(dynamic value) {
|
||||||
|
if (value == true || value == 1) return true;
|
||||||
|
if (value is String) {
|
||||||
|
final s = value.trim().toLowerCase();
|
||||||
|
return s == 'true' || s == '1' || s == 't' || s == 'yes';
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
static String? _dateString(dynamic v) {
|
static String? _dateString(dynamic v) {
|
||||||
if (v == null) return null;
|
if (v == null) return null;
|
||||||
if (v is String) return v.split('T').first;
|
if (v is String) return v.split('T').first;
|
||||||
@@ -88,6 +153,13 @@ class EnfantParentLink {
|
|||||||
|
|
||||||
EnfantParentLink({required this.parentId, this.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) {
|
factory EnfantParentLink.fromJson(Map<String, dynamic> json) {
|
||||||
final parentId =
|
final parentId =
|
||||||
(json['parentId'] ?? json['id_parent'] ?? '').toString();
|
(json['parentId'] ?? json['id_parent'] ?? '').toString();
|
||||||
@@ -98,6 +170,12 @@ class EnfantParentLink {
|
|||||||
if (user is Map<String, dynamic>) {
|
if (user is Map<String, dynamic>) {
|
||||||
final u = AppUser.fromJson(user);
|
final u = AppUser.fromJson(user);
|
||||||
name = u.fullName.isNotEmpty ? u.fullName : u.email;
|
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(
|
return EnfantParentLink(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
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).
|
/// Résumé enfant affiché dans la fiche parent (dashboard admin).
|
||||||
class ParentChildSummary {
|
class ParentChildSummary {
|
||||||
@@ -33,7 +34,9 @@ class ParentChildSummary {
|
|||||||
id: (json['id'] ?? '').toString(),
|
id: (json['id'] ?? '').toString(),
|
||||||
firstName: (json['first_name'] ?? json['prenom'])?.toString(),
|
firstName: (json['first_name'] ?? json['prenom'])?.toString(),
|
||||||
lastName: (json['last_name'] ?? json['nom'])?.toString(),
|
lastName: (json['last_name'] ?? json['nom'])?.toString(),
|
||||||
status: (json['status'] ?? json['statut'] ?? '').toString(),
|
status: normalizeEnfantStatus(
|
||||||
|
(json['status'] ?? json['statut'])?.toString(),
|
||||||
|
),
|
||||||
photoUrl: json['photo_url']?.toString(),
|
photoUrl: json['photo_url']?.toString(),
|
||||||
birthDate: _dateString(json['birth_date']),
|
birthDate: _dateString(json['birth_date']),
|
||||||
dueDate: _dateString(json['due_date']),
|
dueDate: _dateString(json['due_date']),
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import 'package:p_tits_pas/models/user.dart';
|
|||||||
|
|
||||||
class ParentModel {
|
class ParentModel {
|
||||||
final AppUser user;
|
final AppUser user;
|
||||||
|
final AppUser? coParent;
|
||||||
final int childrenCount;
|
final int childrenCount;
|
||||||
final List<ParentChildSummary> children;
|
final List<ParentChildSummary> children;
|
||||||
|
|
||||||
ParentModel({
|
ParentModel({
|
||||||
required this.user,
|
required this.user,
|
||||||
|
this.coParent,
|
||||||
this.childrenCount = 0,
|
this.childrenCount = 0,
|
||||||
this.children = const [],
|
this.children = const [],
|
||||||
});
|
});
|
||||||
@@ -20,12 +22,19 @@ class ParentModel {
|
|||||||
}
|
}
|
||||||
final user = AppUser.fromJson(userJson);
|
final user = AppUser.fromJson(userJson);
|
||||||
|
|
||||||
|
AppUser? coParent;
|
||||||
|
final coParentRaw = root['co_parent'];
|
||||||
|
if (coParentRaw is Map) {
|
||||||
|
coParent = AppUser.fromJson(Map<String, dynamic>.from(coParentRaw));
|
||||||
|
}
|
||||||
|
|
||||||
final children = _parseChildren(root);
|
final children = _parseChildren(root);
|
||||||
final links = root['parentChildren'] ?? root['parent_children'];
|
final links = root['parentChildren'] ?? root['parent_children'];
|
||||||
final linkCount = links is List ? links.length : 0;
|
final linkCount = links is List ? links.length : 0;
|
||||||
|
|
||||||
return ParentModel(
|
return ParentModel(
|
||||||
user: user,
|
user: user,
|
||||||
|
coParent: coParent,
|
||||||
childrenCount: children.isNotEmpty ? children.length : linkCount,
|
childrenCount: children.isNotEmpty ? children.length : linkCount,
|
||||||
children: children,
|
children: children,
|
||||||
);
|
);
|
||||||
@@ -53,4 +62,56 @@ class ParentModel {
|
|||||||
|
|
||||||
return children;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:p_tits_pas/models/user.dart';
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
import 'package:p_tits_pas/services/auth_service.dart';
|
import 'package:p_tits_pas/services/auth_service.dart';
|
||||||
import 'package:p_tits_pas/services/configuration_service.dart';
|
import 'package:p_tits_pas/services/configuration_service.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart';
|
import 'package:p_tits_pas/widgets/dashboard/user_management_sub_bar.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/parametres_panel.dart';
|
import 'package:p_tits_pas/widgets/admin/parametres_panel.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/user_management_panel.dart';
|
import 'package:p_tits_pas/widgets/admin/user_management_panel.dart';
|
||||||
import 'package:p_tits_pas/widgets/app_footer.dart';
|
import 'package:p_tits_pas/widgets/app_footer.dart';
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:p_tits_pas/utils/phone_utils.dart';
|
|||||||
import 'package:p_tits_pas/models/user.dart';
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
import 'package:p_tits_pas/services/user_service.dart';
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
import 'package:p_tits_pas/utils/email_utils.dart';
|
import 'package:p_tits_pas/utils/email_utils.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
|
||||||
import 'package:p_tits_pas/widgets/email_text_field.dart';
|
import 'package:p_tits_pas/widgets/email_text_field.dart';
|
||||||
import 'package:p_tits_pas/widgets/french_phone_field.dart';
|
import 'package:p_tits_pas/widgets/french_phone_field.dart';
|
||||||
|
|
||||||
@@ -151,30 +152,19 @@ class _AdminCreateDialogState extends State<AdminCreateDialog> {
|
|||||||
Future<void> _delete() async {
|
Future<void> _delete() async {
|
||||||
if (!_isEditMode || _isSubmitting) return;
|
if (!_isEditMode || _isSubmitting) return;
|
||||||
|
|
||||||
final confirmed = await showDialog<bool>(
|
final name = widget.initialUser!.fullName.isEmpty
|
||||||
context: context,
|
? widget.initialUser!.email
|
||||||
builder: (ctx) {
|
: widget.initialUser!.fullName;
|
||||||
return AlertDialog(
|
final confirmed = await showSuppressionConfirmDialog(
|
||||||
title: const Text('Confirmer la suppression'),
|
context,
|
||||||
content: Text(
|
title: 'Supprimer l\'administrateur',
|
||||||
'Supprimer ${widget.initialUser!.fullName.isEmpty ? widget.initialUser!.email : widget.initialUser!.fullName} ?',
|
people: [SuppressionPersonLine.administrateur(name)],
|
||||||
),
|
footnotes: const [
|
||||||
actions: [
|
'Le compte sera définitivement supprimé.',
|
||||||
TextButton(
|
],
|
||||||
onPressed: () => Navigator.of(ctx).pop(false),
|
|
||||||
child: const Text('Annuler'),
|
|
||||||
),
|
|
||||||
FilledButton(
|
|
||||||
onPressed: () => Navigator.of(ctx).pop(true),
|
|
||||||
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
|
|
||||||
child: const Text('Supprimer'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed != true) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isSubmitting = true;
|
_isSubmitting = true;
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import 'package:p_tits_pas/utils/email_utils.dart';
|
|||||||
import 'package:p_tits_pas/widgets/email_text_field.dart';
|
import 'package:p_tits_pas/widgets/email_text_field.dart';
|
||||||
import 'package:p_tits_pas/widgets/french_phone_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/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/relais_service.dart';
|
||||||
import 'package:p_tits_pas/services/user_service.dart';
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
|
import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
|
||||||
|
|
||||||
class AdminUserFormDialog extends StatefulWidget {
|
class AdminUserFormDialog extends StatefulWidget {
|
||||||
final AppUser? initialUser;
|
final AppUser? initialUser;
|
||||||
@@ -41,9 +44,21 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
|||||||
bool _isLoadingRelais = true;
|
bool _isLoadingRelais = true;
|
||||||
List<RelaisModel> _relais = [];
|
List<RelaisModel> _relais = [];
|
||||||
String? _selectedRelaisId;
|
String? _selectedRelaisId;
|
||||||
|
String? _currentUserId;
|
||||||
|
String? _currentUserRole;
|
||||||
bool get _isEditMode => widget.initialUser != null;
|
bool get _isEditMode => widget.initialUser != null;
|
||||||
bool get _isSuperAdminTarget =>
|
bool get _isSuperAdminTarget =>
|
||||||
(widget.initialUser?.role ?? '').toLowerCase() == 'super_admin';
|
(widget.initialUser?.role ?? '').toLowerCase() == 'super_admin';
|
||||||
|
bool get _isSelfTarget =>
|
||||||
|
_isEditMode &&
|
||||||
|
_currentUserId != null &&
|
||||||
|
widget.initialUser!.id == _currentUserId;
|
||||||
|
/// Gestionnaire : pas de delete staff. Admin+ seulement (#154 / #160).
|
||||||
|
bool get _canDeleteTarget {
|
||||||
|
if (!_isEditMode || widget.readOnly) return false;
|
||||||
|
if (_isSelfTarget || _isSuperAdminTarget) return false;
|
||||||
|
return canDeleteGestionnaire(_currentUserRole);
|
||||||
|
}
|
||||||
bool get _isLockedAdminIdentity =>
|
bool get _isLockedAdminIdentity =>
|
||||||
_isEditMode && widget.adminMode && _isSuperAdminTarget;
|
_isEditMode && widget.adminMode && _isSuperAdminTarget;
|
||||||
String get _targetRoleKey {
|
String get _targetRoleKey {
|
||||||
@@ -109,6 +124,25 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
|||||||
} else {
|
} else {
|
||||||
_isLoadingRelais = false;
|
_isLoadingRelais = false;
|
||||||
}
|
}
|
||||||
|
_loadCurrentUserId();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadCurrentUserId() async {
|
||||||
|
final cached = await AuthService.getCurrentUser();
|
||||||
|
if (!mounted) return;
|
||||||
|
if (cached != null) {
|
||||||
|
setState(() {
|
||||||
|
_currentUserId = cached.id;
|
||||||
|
_currentUserRole = cached.role;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final refreshed = await AuthService.refreshCurrentUser();
|
||||||
|
if (!mounted || refreshed == null) return;
|
||||||
|
setState(() {
|
||||||
|
_currentUserId = refreshed.id;
|
||||||
|
_currentUserRole = refreshed.role;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -122,6 +156,21 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
|||||||
super.dispose();
|
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 {
|
Future<void> _loadRelais() async {
|
||||||
try {
|
try {
|
||||||
final list = await RelaisService.getRelais();
|
final list = await RelaisService.getRelais();
|
||||||
@@ -138,7 +187,8 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
|||||||
if (selected != null) {
|
if (selected != null) {
|
||||||
filtered.add(selected);
|
filtered.add(selected);
|
||||||
} else {
|
} else {
|
||||||
_selectedRelaisId = null;
|
// Garder l'id sélectionné et afficher un item de secours (nom carte).
|
||||||
|
filtered.addAll(_fallbackRelaisFromUser());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,9 +198,9 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
|||||||
});
|
});
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
// Ne pas nullifier _selectedRelaisId (#151) — la carte a déjà le bon libellé.
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedRelaisId = null;
|
_relais = _fallbackRelaisFromUser();
|
||||||
_relais = [];
|
|
||||||
_isLoadingRelais = false;
|
_isLoadingRelais = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -335,33 +385,28 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
|||||||
|
|
||||||
Future<void> _delete() async {
|
Future<void> _delete() async {
|
||||||
if (widget.readOnly) return;
|
if (widget.readOnly) return;
|
||||||
if (_isSuperAdminTarget) return;
|
if (!_canDeleteTarget) return;
|
||||||
if (!_isEditMode || _isSubmitting) return;
|
if (!_isEditMode || _isSubmitting) return;
|
||||||
|
|
||||||
final confirmed = await showDialog<bool>(
|
final name = widget.initialUser!.fullName.isEmpty
|
||||||
context: context,
|
? widget.initialUser!.email
|
||||||
builder: (ctx) {
|
: widget.initialUser!.fullName;
|
||||||
return AlertDialog(
|
final confirmed = await showSuppressionConfirmDialog(
|
||||||
title: const Text('Confirmer la suppression'),
|
context,
|
||||||
content: Text(
|
title: widget.adminMode
|
||||||
'Supprimer ${widget.initialUser!.fullName.isEmpty ? widget.initialUser!.email : widget.initialUser!.fullName} ?',
|
? 'Supprimer l\'administrateur'
|
||||||
),
|
: 'Supprimer le gestionnaire',
|
||||||
actions: [
|
people: [
|
||||||
TextButton(
|
widget.adminMode
|
||||||
onPressed: () => Navigator.of(ctx).pop(false),
|
? SuppressionPersonLine.administrateur(name)
|
||||||
child: const Text('Annuler'),
|
: SuppressionPersonLine.gestionnaire(name),
|
||||||
),
|
],
|
||||||
FilledButton(
|
footnotes: const [
|
||||||
onPressed: () => Navigator.of(ctx).pop(true),
|
'Le compte sera définitivement supprimé.',
|
||||||
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
|
],
|
||||||
child: const Text('Supprimer'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed != true) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isSubmitting = true;
|
_isSubmitting = true;
|
||||||
@@ -462,7 +507,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
|||||||
child: const Text('Fermer'),
|
child: const Text('Fermer'),
|
||||||
),
|
),
|
||||||
] else if (_isEditMode) ...[
|
] else if (_isEditMode) ...[
|
||||||
if (!_isSuperAdminTarget)
|
if (_canDeleteTarget)
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onPressed: _isSubmitting ? null : _delete,
|
onPressed: _isSubmitting ? null : _delete,
|
||||||
style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700),
|
style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700),
|
||||||
|
|||||||
@@ -62,7 +62,10 @@ class _GestionnaireDashboardScreenState extends State<GestionnaireDashboardScree
|
|||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: UserManagementPanel(showAdministrateursTab: false),
|
child: UserManagementPanel(
|
||||||
|
showAdministrateursTab: false,
|
||||||
|
allowStaffAccountCreation: false,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const AppFooter(),
|
const AppFooter(),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -60,7 +60,12 @@ class ApiConfig {
|
|||||||
static const String userChildren = '/users/children';
|
static const String userChildren = '/users/children';
|
||||||
static const String gestionnaires = '/gestionnaires';
|
static const String gestionnaires = '/gestionnaires';
|
||||||
static const String parents = '/parents';
|
static const String parents = '/parents';
|
||||||
|
/// Création dossier famille actif par le staff (#129) — body type register parent.
|
||||||
|
static const String parentsDossier = '/parents/dossier';
|
||||||
static const String assistantesMaternelles = '/assistantes-maternelles';
|
static const String assistantesMaternelles = '/assistantes-maternelles';
|
||||||
|
/// Création dossier AM actif par le staff (#156) — body type register AM.
|
||||||
|
static const String assistantesMaternellesDossier =
|
||||||
|
'/assistantes-maternelles/dossier';
|
||||||
static const String enfants = '/enfants';
|
static const String enfants = '/enfants';
|
||||||
static const String relais = '/relais';
|
static const String relais = '/relais';
|
||||||
static const String dossiers = '/dossiers';
|
static const String dossiers = '/dossiers';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http_parser/http_parser.dart';
|
||||||
import 'package:p_tits_pas/models/user.dart';
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
||||||
import 'package:p_tits_pas/models/parent_model.dart';
|
import 'package:p_tits_pas/models/parent_model.dart';
|
||||||
@@ -8,6 +9,7 @@ import 'package:p_tits_pas/models/pending_family.dart';
|
|||||||
import 'package:p_tits_pas/models/dossier_unifie.dart';
|
import 'package:p_tits_pas/models/dossier_unifie.dart';
|
||||||
import 'package:p_tits_pas/services/api/api_config.dart';
|
import 'package:p_tits_pas/services/api/api_config.dart';
|
||||||
import 'package:p_tits_pas/services/api/tokenService.dart';
|
import 'package:p_tits_pas/services/api/tokenService.dart';
|
||||||
|
import 'package:p_tits_pas/utils/am_vigilance.dart';
|
||||||
|
|
||||||
class DocumentActifInfo {
|
class DocumentActifInfo {
|
||||||
final String id;
|
final String id;
|
||||||
@@ -56,6 +58,33 @@ class UserService {
|
|||||||
return v.toString();
|
return v.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// MIME pour upload photo enfant (filtre Nest : jpg/jpeg/png/gif).
|
||||||
|
static MediaType _imageMediaType(String filename, List<int> bytes) {
|
||||||
|
if (bytes.length >= 3 &&
|
||||||
|
bytes[0] == 0xFF &&
|
||||||
|
bytes[1] == 0xD8 &&
|
||||||
|
bytes[2] == 0xFF) {
|
||||||
|
return MediaType('image', 'jpeg');
|
||||||
|
}
|
||||||
|
if (bytes.length >= 8 &&
|
||||||
|
bytes[0] == 0x89 &&
|
||||||
|
bytes[1] == 0x50 &&
|
||||||
|
bytes[2] == 0x4E &&
|
||||||
|
bytes[3] == 0x47) {
|
||||||
|
return MediaType('image', 'png');
|
||||||
|
}
|
||||||
|
if (bytes.length >= 6 &&
|
||||||
|
bytes[0] == 0x47 &&
|
||||||
|
bytes[1] == 0x49 &&
|
||||||
|
bytes[2] == 0x46) {
|
||||||
|
return MediaType('image', 'gif');
|
||||||
|
}
|
||||||
|
final lower = filename.toLowerCase();
|
||||||
|
if (lower.endsWith('.png')) return MediaType('image', 'png');
|
||||||
|
if (lower.endsWith('.gif')) return MediaType('image', 'gif');
|
||||||
|
return MediaType('image', 'jpeg');
|
||||||
|
}
|
||||||
|
|
||||||
static String _errMessage(dynamic err) {
|
static String _errMessage(dynamic err) {
|
||||||
if (err == null) return 'Erreur inconnue';
|
if (err == null) return 'Erreur inconnue';
|
||||||
if (err is String) return err;
|
if (err is String) return err;
|
||||||
@@ -460,22 +489,259 @@ class UserService {
|
|||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
throw Exception(_extractErrorMessage(response.body, 'Erreur chargement enfant'));
|
throw Exception(_extractErrorMessage(response.body, 'Erreur chargement enfant'));
|
||||||
}
|
}
|
||||||
return EnfantAdminModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
|
final enfant = EnfantAdminModel.fromJson(
|
||||||
|
jsonDecode(response.body) as Map<String, dynamic>,
|
||||||
|
);
|
||||||
|
// GET /enfants/:id ne joint pas toujours parent.user → noms manquants.
|
||||||
|
return enrichEnfantParentNames(enfant);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Complète [parentName] via GET parent quand l'API n'a pas fourni le nested user.
|
||||||
|
static Future<EnfantAdminModel> enrichEnfantParentNames(
|
||||||
|
EnfantAdminModel enfant,
|
||||||
|
) async {
|
||||||
|
final links = enfant.parentLinks;
|
||||||
|
if (links.isEmpty) return enfant;
|
||||||
|
if (links.every((l) => (l.parentName ?? '').trim().isNotEmpty)) {
|
||||||
|
return enfant;
|
||||||
|
}
|
||||||
|
|
||||||
|
final enriched = await Future.wait(
|
||||||
|
links.map((link) async {
|
||||||
|
if ((link.parentName ?? '').trim().isNotEmpty) return link;
|
||||||
|
final id = link.parentId.trim();
|
||||||
|
if (id.isEmpty) return link;
|
||||||
|
try {
|
||||||
|
final parent = await getParent(id);
|
||||||
|
final name = parent.user.fullName.trim();
|
||||||
|
if (name.isEmpty) return link;
|
||||||
|
return link.copyWith(parentName: name);
|
||||||
|
} catch (_) {
|
||||||
|
return link;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return enfant.copyWith(parentLinks: enriched);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mise à jour enfant. Avec [photoBytes] : multipart (champ `photo`), sinon JSON.
|
||||||
static Future<EnfantAdminModel> updateEnfant({
|
static Future<EnfantAdminModel> updateEnfant({
|
||||||
required String enfantId,
|
required String enfantId,
|
||||||
required Map<String, dynamic> body,
|
required Map<String, dynamic> body,
|
||||||
|
List<int>? photoBytes,
|
||||||
|
String? photoFilename,
|
||||||
}) async {
|
}) async {
|
||||||
final response = await http.patch(
|
final id = enfantId.trim();
|
||||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'),
|
if (id.isEmpty) {
|
||||||
headers: await _headers(),
|
throw Exception('Identifiant enfant manquant.');
|
||||||
body: jsonEncode(body),
|
|
||||||
);
|
|
||||||
if (response.statusCode != 200) {
|
|
||||||
throw Exception(_extractErrorMessage(response.body, 'Erreur mise à jour enfant'));
|
|
||||||
}
|
}
|
||||||
return EnfantAdminModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
|
final hasPhoto = photoBytes != null && photoBytes.isNotEmpty;
|
||||||
|
final http.Response response;
|
||||||
|
if (hasPhoto) {
|
||||||
|
final token = await TokenService.getToken();
|
||||||
|
final req = http.MultipartRequest(
|
||||||
|
'PATCH',
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$id'),
|
||||||
|
);
|
||||||
|
req.headers['Accept'] = 'application/json';
|
||||||
|
if (token != null) {
|
||||||
|
req.headers['Authorization'] = 'Bearer $token';
|
||||||
|
}
|
||||||
|
body.forEach((key, value) {
|
||||||
|
if (value == null) return;
|
||||||
|
req.fields[key] = value is bool
|
||||||
|
? (value ? 'true' : 'false')
|
||||||
|
: value.toString();
|
||||||
|
});
|
||||||
|
final name = (photoFilename ?? '').trim();
|
||||||
|
final filename = name.isNotEmpty ? name : 'photo.jpg';
|
||||||
|
req.files.add(
|
||||||
|
http.MultipartFile.fromBytes(
|
||||||
|
'photo',
|
||||||
|
photoBytes,
|
||||||
|
filename: filename,
|
||||||
|
contentType: _imageMediaType(filename, photoBytes),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final streamed = await req.send();
|
||||||
|
response = await http.Response.fromStream(streamed);
|
||||||
|
} else {
|
||||||
|
response = await http.patch(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$id'),
|
||||||
|
headers: await _headers(),
|
||||||
|
body: jsonEncode(body),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception(
|
||||||
|
_extractErrorMessage(response.body, 'Erreur mise à jour enfant'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final enfant = EnfantAdminModel.fromJson(
|
||||||
|
jsonDecode(response.body) as Map<String, dynamic>,
|
||||||
|
);
|
||||||
|
return enrichEnfantParentNames(enfant);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Création enfant côté staff (#132) — nécessite `POST /enfants` étendu (voir mini-spec).
|
||||||
|
/// [parentUserId] : parent pivot du foyer (`parent_user_id`).
|
||||||
|
/// Avec [photoBytes] : `multipart/form-data` (champ fichier `photo`), sinon JSON.
|
||||||
|
static Future<EnfantAdminModel> createEnfant({
|
||||||
|
required String parentUserId,
|
||||||
|
required Map<String, dynamic> body,
|
||||||
|
List<int>? photoBytes,
|
||||||
|
String? photoFilename,
|
||||||
|
}) async {
|
||||||
|
final hasPhoto = photoBytes != null && photoBytes.isNotEmpty;
|
||||||
|
final http.Response response;
|
||||||
|
if (hasPhoto) {
|
||||||
|
final token = await TokenService.getToken();
|
||||||
|
final req = http.MultipartRequest(
|
||||||
|
'POST',
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}'),
|
||||||
|
);
|
||||||
|
req.headers['Accept'] = 'application/json';
|
||||||
|
if (token != null) {
|
||||||
|
req.headers['Authorization'] = 'Bearer $token';
|
||||||
|
}
|
||||||
|
body.forEach((key, value) {
|
||||||
|
if (value == null) return;
|
||||||
|
req.fields[key] = value is bool
|
||||||
|
? (value ? 'true' : 'false')
|
||||||
|
: value.toString();
|
||||||
|
});
|
||||||
|
req.fields['parent_user_id'] = parentUserId;
|
||||||
|
final name = (photoFilename ?? '').trim();
|
||||||
|
final filename = name.isNotEmpty ? name : 'photo.jpg';
|
||||||
|
req.files.add(
|
||||||
|
http.MultipartFile.fromBytes(
|
||||||
|
'photo',
|
||||||
|
photoBytes,
|
||||||
|
filename: filename,
|
||||||
|
contentType: _imageMediaType(filename, photoBytes),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final streamed = await req.send();
|
||||||
|
response = await http.Response.fromStream(streamed);
|
||||||
|
} else {
|
||||||
|
final payload = <String, dynamic>{
|
||||||
|
...body,
|
||||||
|
'parent_user_id': parentUserId,
|
||||||
|
};
|
||||||
|
response = await http.post(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}'),
|
||||||
|
headers: await _headers(),
|
||||||
|
body: jsonEncode(payload),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.statusCode != 200 && response.statusCode != 201) {
|
||||||
|
throw Exception(
|
||||||
|
_extractErrorMessage(response.body, 'Erreur création enfant'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final enfant = EnfantAdminModel.fromJson(
|
||||||
|
jsonDecode(response.body) as Map<String, dynamic>,
|
||||||
|
);
|
||||||
|
return enrichEnfantParentNames(enfant);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DELETE /enfants/:id?deleteDossier= — ticket #159 / #160.
|
||||||
|
static Future<Map<String, dynamic>> deleteEnfant(
|
||||||
|
String enfantId, {
|
||||||
|
bool deleteDossier = false,
|
||||||
|
}) async {
|
||||||
|
final uri = Uri.parse(
|
||||||
|
'${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId',
|
||||||
|
).replace(
|
||||||
|
queryParameters: {
|
||||||
|
'deleteDossier': deleteDossier ? 'true' : 'false',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final response = await http.delete(uri, headers: await _headers());
|
||||||
|
if (response.statusCode != 200 && response.statusCode != 204) {
|
||||||
|
throw Exception(
|
||||||
|
_extractErrorMessage(response.body, 'Erreur suppression enfant'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _parseSuppressionBody(response.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DELETE /dossiers/:numeroDossier — ticket #159 / #160.
|
||||||
|
static Future<Map<String, dynamic>> deleteDossier(
|
||||||
|
String numeroDossier,
|
||||||
|
) async {
|
||||||
|
final num = numeroDossier.trim();
|
||||||
|
if (num.isEmpty) {
|
||||||
|
throw Exception('Numéro de dossier manquant.');
|
||||||
|
}
|
||||||
|
final encoded = Uri.encodeComponent(num);
|
||||||
|
final response = await http.delete(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}/$encoded'),
|
||||||
|
headers: await _headers(),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200 && response.statusCode != 204) {
|
||||||
|
throw Exception(
|
||||||
|
_extractErrorMessage(response.body, 'Erreur suppression dossier'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _parseSuppressionBody(response.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liste unifiée GET /dossiers — flags `sans_enfant` (#159).
|
||||||
|
static Future<List<Map<String, dynamic>>> listDossiers({String? q}) async {
|
||||||
|
final uri = Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}')
|
||||||
|
.replace(
|
||||||
|
queryParameters: (q != null && q.trim().isNotEmpty)
|
||||||
|
? {'q': q.trim()}
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
final response = await http.get(uri, headers: await _headers());
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception(
|
||||||
|
_extractErrorMessage(response.body, 'Erreur chargement dossiers'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
if (decoded is! List) return const [];
|
||||||
|
return decoded
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((e) => Map<String, dynamic>.from(e))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map `numero_dossier` → `sans_enfant` (familles uniquement).
|
||||||
|
static Future<Map<String, bool>> getSansEnfantByNumero() async {
|
||||||
|
final rows = await listDossiers();
|
||||||
|
final out = <String, bool>{};
|
||||||
|
for (final row in rows) {
|
||||||
|
final num = (row['numero_dossier'] ?? '').toString().trim();
|
||||||
|
if (num.isEmpty) continue;
|
||||||
|
final type = (row['type'] ?? '').toString().toLowerCase();
|
||||||
|
if (type != 'famille') continue;
|
||||||
|
out[num] = row['sans_enfant'] == true;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, dynamic> _parseSuppressionBody(String body) {
|
||||||
|
if (body.trim().isEmpty) return <String, dynamic>{};
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(body);
|
||||||
|
if (decoded is Map) {
|
||||||
|
return Map<String, dynamic>.from(decoded);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return <String, dynamic>{};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AM dont la liste d'enfants actifs contient [enfantId] (API actuelle).
|
||||||
|
static Future<AssistanteMaternelleModel?> findAmForEnfant(String enfantId) async {
|
||||||
|
final ams = await getAssistantesMaternelles();
|
||||||
|
for (final am in ams) {
|
||||||
|
if (am.children.any((c) => c.id == enfantId)) return am;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
static ParentModel _parentModelFromBody(String body) {
|
static ParentModel _parentModelFromBody(String body) {
|
||||||
@@ -499,6 +765,155 @@ class UserService {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ajout d’un co-parent sur foyer mono-parent (staff, #135).
|
||||||
|
/// `POST /parents/:pivotUserId/co-parent` — succès 201.
|
||||||
|
static Future<Map<String, dynamic>> addCoParent(
|
||||||
|
String pivotUserId, {
|
||||||
|
required Map<String, dynamic> body,
|
||||||
|
}) async {
|
||||||
|
final id = pivotUserId.trim();
|
||||||
|
if (id.isEmpty) {
|
||||||
|
throw Exception('Identifiant du parent pivot manquant.');
|
||||||
|
}
|
||||||
|
final http.Response response;
|
||||||
|
try {
|
||||||
|
response = await http.post(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parents}/$id/co-parent'),
|
||||||
|
headers: await _headers(),
|
||||||
|
body: jsonEncode(body),
|
||||||
|
);
|
||||||
|
} on http.ClientException {
|
||||||
|
throw Exception(
|
||||||
|
'Connexion au serveur impossible. Vérifiez votre réseau puis réessayez.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.statusCode == 200 || response.statusCode == 201) {
|
||||||
|
if (response.body.trim().isEmpty) return <String, dynamic>{};
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
if (decoded is Map) {
|
||||||
|
return Map<String, dynamic>.from(decoded);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return <String, dynamic>{};
|
||||||
|
}
|
||||||
|
|
||||||
|
final message = _extractErrorMessage(
|
||||||
|
response.body,
|
||||||
|
'Erreur ajout co-parent',
|
||||||
|
);
|
||||||
|
if (response.statusCode == 409) {
|
||||||
|
throw Exception(
|
||||||
|
message.isNotEmpty ? message : 'Conflit : e-mail déjà utilisé.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.statusCode == 400) {
|
||||||
|
throw Exception(
|
||||||
|
message.isNotEmpty ? message : 'Données invalides (400).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw Exception(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Création dossier famille actif côté staff (#129).
|
||||||
|
/// `POST /parents/dossier` — body aligné sur register parent complet.
|
||||||
|
/// Succès 201 : dossier actif + `numero_dossier` (mail MDP côté serveur, par parent créé).
|
||||||
|
/// Ne pas utiliser `POST /auth/register/parent` (public / en_attente).
|
||||||
|
static Future<Map<String, dynamic>> createParentDossier(
|
||||||
|
Map<String, dynamic> body,
|
||||||
|
) async {
|
||||||
|
final http.Response response;
|
||||||
|
try {
|
||||||
|
response = await http.post(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parentsDossier}'),
|
||||||
|
headers: await _headers(),
|
||||||
|
body: jsonEncode(body),
|
||||||
|
);
|
||||||
|
} on http.ClientException {
|
||||||
|
throw Exception(
|
||||||
|
'Connexion au serveur impossible. Vérifiez votre réseau puis réessayez.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.statusCode == 200 || response.statusCode == 201) {
|
||||||
|
if (response.body.trim().isEmpty) return <String, dynamic>{};
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
if (decoded is Map) {
|
||||||
|
return Map<String, dynamic>.from(decoded);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return <String, dynamic>{};
|
||||||
|
}
|
||||||
|
|
||||||
|
final message = _extractErrorMessage(
|
||||||
|
response.body,
|
||||||
|
'Erreur création dossier famille',
|
||||||
|
);
|
||||||
|
if (response.statusCode == 409) {
|
||||||
|
throw Exception(
|
||||||
|
message.isNotEmpty ? message : 'Conflit : e-mail déjà utilisé.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.statusCode == 400) {
|
||||||
|
throw Exception(
|
||||||
|
message.isNotEmpty ? message : 'Données invalides (400).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw Exception(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Création dossier AM actif côté staff (#156).
|
||||||
|
/// `POST /assistantes-maternelles/dossier` — body aligné sur register AM.
|
||||||
|
/// Succès 201 : dossier actif + `numero_dossier` (mail MDP côté serveur).
|
||||||
|
/// Ne pas utiliser `POST /auth/register/am` (public / en_attente).
|
||||||
|
static Future<Map<String, dynamic>> createAmDossier(
|
||||||
|
Map<String, dynamic> body,
|
||||||
|
) async {
|
||||||
|
final http.Response response;
|
||||||
|
try {
|
||||||
|
response = await http.post(
|
||||||
|
Uri.parse(
|
||||||
|
'${ApiConfig.baseUrl}${ApiConfig.assistantesMaternellesDossier}',
|
||||||
|
),
|
||||||
|
headers: await _headers(),
|
||||||
|
body: jsonEncode(body),
|
||||||
|
);
|
||||||
|
} on http.ClientException {
|
||||||
|
throw Exception(
|
||||||
|
'Connexion au serveur impossible. Vérifiez votre réseau puis réessayez.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.statusCode == 200 || response.statusCode == 201) {
|
||||||
|
if (response.body.trim().isEmpty) return <String, dynamic>{};
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
if (decoded is Map) {
|
||||||
|
return Map<String, dynamic>.from(decoded);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return <String, dynamic>{};
|
||||||
|
}
|
||||||
|
|
||||||
|
final message = _extractErrorMessage(
|
||||||
|
response.body,
|
||||||
|
'Erreur création dossier AM',
|
||||||
|
);
|
||||||
|
if (response.statusCode == 409) {
|
||||||
|
throw Exception(message.isNotEmpty
|
||||||
|
? message
|
||||||
|
: 'Conflit : e-mail, NIR ou agrément déjà utilisé.');
|
||||||
|
}
|
||||||
|
if (response.statusCode == 400) {
|
||||||
|
throw Exception(
|
||||||
|
message.isNotEmpty ? message : 'Données invalides (400).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw Exception(message);
|
||||||
|
}
|
||||||
|
|
||||||
// Récupérer la liste des assistantes maternelles
|
// Récupérer la liste des assistantes maternelles
|
||||||
static Future<List<AssistanteMaternelleModel>>
|
static Future<List<AssistanteMaternelleModel>>
|
||||||
getAssistantesMaternelles() async {
|
getAssistantesMaternelles() async {
|
||||||
@@ -513,7 +928,170 @@ class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final List<dynamic> data = jsonDecode(response.body);
|
final List<dynamic> data = jsonDecode(response.body);
|
||||||
return data.map((e) => AssistanteMaternelleModel.fromJson(e)).toList();
|
return data
|
||||||
|
.map((e) => AssistanteMaternelleModel.fromJson(
|
||||||
|
Map<String, dynamic>.from(e as Map),
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<AssistanteMaternelleModel> getAssistanteMaternelle(
|
||||||
|
String userId,
|
||||||
|
) async {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}/$userId'),
|
||||||
|
headers: await _headers(),
|
||||||
|
);
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
return AssistanteMaternelleModel.fromJson(
|
||||||
|
Map<String, dynamic>.from(decoded is Map ? decoded : {}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.statusCode == 403 || response.statusCode == 404) {
|
||||||
|
final all = await getAssistantesMaternelles();
|
||||||
|
return all.firstWhere(
|
||||||
|
(a) => a.user.id == userId,
|
||||||
|
orElse: () => throw Exception('Assistante maternelle introuvable'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final err = jsonDecode(response.body) as Map<String, dynamic>?;
|
||||||
|
throw Exception(_toStr(err?['message']) ?? 'Erreur chargement AM');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mise à jour fiche AM (identité + champs pro). Ticket #131.
|
||||||
|
static Future<AssistanteMaternelleModel> updateAmFiche({
|
||||||
|
required String amUserId,
|
||||||
|
required Map<String, dynamic> body,
|
||||||
|
}) async {
|
||||||
|
final ficheResponse = await http.patch(
|
||||||
|
Uri.parse(
|
||||||
|
'${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}/$amUserId/fiche',
|
||||||
|
),
|
||||||
|
headers: await _headers(),
|
||||||
|
body: jsonEncode(body),
|
||||||
|
);
|
||||||
|
if (ficheResponse.statusCode == 200) {
|
||||||
|
return _amModelFromBody(ficheResponse.body);
|
||||||
|
}
|
||||||
|
if (ficheResponse.statusCode != 404) {
|
||||||
|
throw Exception(
|
||||||
|
_extractErrorMessage(ficheResponse.body, 'Erreur mise à jour AM'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final userFields = <String, dynamic>{};
|
||||||
|
for (final k in [
|
||||||
|
'nom',
|
||||||
|
'prenom',
|
||||||
|
'email',
|
||||||
|
'telephone',
|
||||||
|
'adresse',
|
||||||
|
'ville',
|
||||||
|
'code_postal',
|
||||||
|
'statut',
|
||||||
|
'date_naissance',
|
||||||
|
'lieu_naissance_ville',
|
||||||
|
'lieu_naissance_pays',
|
||||||
|
]) {
|
||||||
|
if (body.containsKey(k)) userFields[k] = body[k];
|
||||||
|
}
|
||||||
|
|
||||||
|
final proFields = <String, dynamic>{};
|
||||||
|
for (final entry in body.entries) {
|
||||||
|
if (!userFields.containsKey(entry.key)) {
|
||||||
|
proFields[entry.key] = entry.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userFields.isNotEmpty) {
|
||||||
|
final userResponse = await http.patch(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$amUserId'),
|
||||||
|
headers: await _headers(),
|
||||||
|
body: jsonEncode(userFields),
|
||||||
|
);
|
||||||
|
if (userResponse.statusCode != 200) {
|
||||||
|
throw Exception(
|
||||||
|
_extractErrorMessage(userResponse.body, 'Erreur mise à jour identité'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (proFields.isNotEmpty) {
|
||||||
|
final proResponse = await http.patch(
|
||||||
|
Uri.parse(
|
||||||
|
'${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}/$amUserId',
|
||||||
|
),
|
||||||
|
headers: await _headers(),
|
||||||
|
body: jsonEncode(proFields),
|
||||||
|
);
|
||||||
|
if (proResponse.statusCode != 200) {
|
||||||
|
throw Exception(
|
||||||
|
_extractErrorMessage(proResponse.body, 'Erreur mise à jour fiche pro'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return getAssistanteMaternelle(amUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<AssistanteMaternelleModel> attachEnfantToAm({
|
||||||
|
required String amUserId,
|
||||||
|
required String enfantId,
|
||||||
|
}) async {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse(
|
||||||
|
'${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}/$amUserId/enfants/$enfantId',
|
||||||
|
),
|
||||||
|
headers: await _headers(),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200 && response.statusCode != 201) {
|
||||||
|
throw Exception(_extractErrorMessage(response.body, 'Erreur rattachement enfant'));
|
||||||
|
}
|
||||||
|
final am = _amModelFromBody(response.body);
|
||||||
|
// Recalcule toujours places_available (capacité − enfants) après attachement.
|
||||||
|
return syncAmPlacesAvailable(am);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aligne `places_available` sur capacité − enfants rattachés.
|
||||||
|
static Future<AssistanteMaternelleModel> syncAmPlacesAvailable(
|
||||||
|
AssistanteMaternelleModel am,
|
||||||
|
) async {
|
||||||
|
final expected = amExpectedPlacesAvailable(
|
||||||
|
maxChildren: am.maxChildren,
|
||||||
|
childrenCount: am.children.length,
|
||||||
|
);
|
||||||
|
if (expected == null || am.placesAvailable == expected) return am;
|
||||||
|
return updateAmFiche(
|
||||||
|
amUserId: am.user.id,
|
||||||
|
body: {'places_available': expected},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<AssistanteMaternelleModel> detachEnfantFromAm({
|
||||||
|
required String amUserId,
|
||||||
|
required String enfantId,
|
||||||
|
}) async {
|
||||||
|
final response = await http.delete(
|
||||||
|
Uri.parse(
|
||||||
|
'${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}/$amUserId/enfants/$enfantId',
|
||||||
|
),
|
||||||
|
headers: await _headers(),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200 && response.statusCode != 204) {
|
||||||
|
throw Exception(_extractErrorMessage(response.body, 'Erreur détachement enfant'));
|
||||||
|
}
|
||||||
|
if (response.body.isEmpty) {
|
||||||
|
return getAssistanteMaternelle(amUserId);
|
||||||
|
}
|
||||||
|
return _amModelFromBody(response.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
static AssistanteMaternelleModel _amModelFromBody(String body) {
|
||||||
|
final decoded = jsonDecode(body);
|
||||||
|
return AssistanteMaternelleModel.fromJson(
|
||||||
|
Map<String, dynamic>.from(decoded is Map ? decoded : {}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Récupérer la liste des administrateurs (via /users filtré ou autre)
|
// Récupérer la liste des administrateurs (via /users filtré ou autre)
|
||||||
@@ -725,22 +1303,18 @@ class UserService {
|
|||||||
return AppUser.fromJson(data);
|
return AppUser.fromJson(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> deleteUser(String userId) async {
|
/// DELETE /users/:id — cascades métier #159 / #160.
|
||||||
|
static Future<Map<String, dynamic>> deleteUser(String userId) async {
|
||||||
final response = await http.delete(
|
final response = await http.delete(
|
||||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$userId'),
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$userId'),
|
||||||
headers: await _headers(),
|
headers: await _headers(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode != 200 && response.statusCode != 204) {
|
if (response.statusCode != 200 && response.statusCode != 204) {
|
||||||
final decoded = jsonDecode(response.body);
|
throw Exception(
|
||||||
if (decoded is Map<String, dynamic>) {
|
_extractErrorMessage(response.body, 'Erreur suppression utilisateur'),
|
||||||
final message = decoded['message'];
|
);
|
||||||
if (message is List && message.isNotEmpty) {
|
|
||||||
throw Exception(message.join(' - '));
|
|
||||||
}
|
|
||||||
throw Exception(_toStr(message) ?? 'Erreur suppression utilisateur');
|
|
||||||
}
|
|
||||||
throw Exception('Erreur suppression utilisateur');
|
|
||||||
}
|
}
|
||||||
|
return _parseSuppressionBody(response.body);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
|
||||||
|
|
||||||
|
/// Places disponibles attendues : capacité max − enfants rattachés.
|
||||||
|
int? amExpectedPlacesAvailable({
|
||||||
|
required int? maxChildren,
|
||||||
|
required int childrenCount,
|
||||||
|
}) {
|
||||||
|
if (maxChildren == null) return null;
|
||||||
|
return (maxChildren - childrenCount).clamp(0, maxChildren);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True s'il reste au moins une place d'accueil (capacité − enfants).
|
||||||
|
bool amHasFreePlace(AssistanteMaternelleModel am) {
|
||||||
|
final expected = amExpectedPlacesAvailable(
|
||||||
|
maxChildren: am.maxChildren,
|
||||||
|
childrenCount: am.children.length,
|
||||||
|
);
|
||||||
|
if (expected != null) return expected > 0;
|
||||||
|
final places = am.placesAvailable;
|
||||||
|
if (places != null) return places > 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True si la valeur déclarée par l'AM ne correspond pas au calcul métier.
|
||||||
|
bool amHasPlacesInconsistency({
|
||||||
|
required int? maxChildren,
|
||||||
|
required int? placesAvailable,
|
||||||
|
required int childrenCount,
|
||||||
|
}) {
|
||||||
|
final expected = amExpectedPlacesAvailable(
|
||||||
|
maxChildren: maxChildren,
|
||||||
|
childrenCount: childrenCount,
|
||||||
|
);
|
||||||
|
if (expected == null) return false;
|
||||||
|
if (placesAvailable == null) return childrenCount > (maxChildren ?? 0);
|
||||||
|
return placesAvailable != expected;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? amPlacesVigilanceMessage(AssistanteMaternelleModel am) {
|
||||||
|
if (!amHasPlacesInconsistency(
|
||||||
|
maxChildren: am.maxChildren,
|
||||||
|
placesAvailable: am.placesAvailable,
|
||||||
|
childrenCount: am.children.length,
|
||||||
|
)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final stored = am.placesAvailable;
|
||||||
|
final expected = amExpectedPlacesAvailable(
|
||||||
|
maxChildren: am.maxChildren,
|
||||||
|
childrenCount: am.children.length,
|
||||||
|
);
|
||||||
|
final storedLabel = stored?.toString() ?? 'non renseigné';
|
||||||
|
final expectedLabel = expected?.toString() ?? '–';
|
||||||
|
return 'Point de vigilance : l\'AM déclare $storedLabel place(s) disponible(s), '
|
||||||
|
'alors que capacité ${am.maxChildren ?? '–'} − '
|
||||||
|
'${am.children.length} enfant(s) rattaché(s) = $expectedLabel.';
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
||||||
|
|
||||||
/// Affiche une date ISO / parseable en `dd/MM/yyyy`, avec repli sur la chaîne ou [ifEmpty].
|
/// Affiche une date ISO / parseable en `dd/MM/yyyy`, avec repli sur la chaîne ou [ifEmpty].
|
||||||
String formatIsoDateFr(String? s, {String ifEmpty = '–'}) {
|
String formatIsoDateFr(String? s, {String ifEmpty = '–'}) {
|
||||||
@@ -10,7 +12,65 @@ String formatIsoDateFr(String? s, {String ifEmpty = '–'}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parties d'âge calendaire entre [birth] et [reference] (date-only).
|
/// Affiche une date ISO en saisie guidée `jj / mm / aaaa`.
|
||||||
|
String formatIsoDateFrInput(String? s, {String ifEmpty = ''}) {
|
||||||
|
if (s == null || s.trim().isEmpty) return ifEmpty;
|
||||||
|
try {
|
||||||
|
final dt = DateTime.parse(s.trim());
|
||||||
|
return formatFrenchDateDigits(
|
||||||
|
DateFormat('ddMMyyyy').format(dt),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return formatFrenchDateDigits(s.replaceAll(RegExp(r'\D'), ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formate jusqu’à 8 chiffres en `jj / mm / aaaa`.
|
||||||
|
String formatFrenchDateDigits(String digits) {
|
||||||
|
final d = digits.replaceAll(RegExp(r'\D'), '');
|
||||||
|
final limited = d.length > 8 ? d.substring(0, 8) : d;
|
||||||
|
final buf = StringBuffer();
|
||||||
|
for (var i = 0; i < limited.length; i++) {
|
||||||
|
if (i == 2 || i == 4) buf.write(' / ');
|
||||||
|
buf.write(limited[i]);
|
||||||
|
}
|
||||||
|
return buf.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convertit `dd/MM/yyyy`, `dd / MM / yyyy`, 8 chiffres ou ISO en `yyyy-MM-dd`.
|
||||||
|
String? parseFrDateToIso(String text) {
|
||||||
|
final t = text.trim();
|
||||||
|
if (t.isEmpty) return null;
|
||||||
|
|
||||||
|
final digits = t.replaceAll(RegExp(r'\D'), '');
|
||||||
|
if (digits.length == 8) {
|
||||||
|
final dd = digits.substring(0, 2);
|
||||||
|
final mm = digits.substring(2, 4);
|
||||||
|
final yyyy = digits.substring(4, 8);
|
||||||
|
try {
|
||||||
|
return DateFormat('dd/MM/yyyy')
|
||||||
|
.parseStrict('$dd/$mm/$yyyy')
|
||||||
|
.toIso8601String()
|
||||||
|
.split('T')
|
||||||
|
.first;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return DateFormat('dd/MM/yyyy')
|
||||||
|
.parseStrict(t)
|
||||||
|
.toIso8601String()
|
||||||
|
.split('T')
|
||||||
|
.first;
|
||||||
|
} catch (_) {
|
||||||
|
try {
|
||||||
|
return DateTime.parse(t).toIso8601String().split('T').first;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
({int years, int months, int days}) computeChildAgeParts(
|
({int years, int months, int days}) computeChildAgeParts(
|
||||||
DateTime birth,
|
DateTime birth,
|
||||||
DateTime reference,
|
DateTime reference,
|
||||||
@@ -45,7 +105,7 @@ String formatChildAgeLabel({
|
|||||||
String? dueDate,
|
String? dueDate,
|
||||||
String? status,
|
String? status,
|
||||||
}) {
|
}) {
|
||||||
if (status == 'a_naitre') {
|
if (status == 'a_naitre' || normalizeEnfantStatus(status) == 'a_naitre') {
|
||||||
final due = formatIsoDateFr(dueDate, ifEmpty: '');
|
final due = formatIsoDateFr(dueDate, ifEmpty: '');
|
||||||
if (due.isNotEmpty) return 'Naissance prévue : $due';
|
if (due.isNotEmpty) return 'Naissance prévue : $due';
|
||||||
return 'À naître';
|
return 'À naître';
|
||||||
@@ -77,3 +137,43 @@ String formatChildAgeLabel({
|
|||||||
return 'Né le ${formatIsoDateFr(birthDate)}';
|
return 'Né le ${formatIsoDateFr(birthDate)}';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Saisie date FR : 8 chiffres → `jj / mm / aaaa`.
|
||||||
|
class FrenchDateInputFormatter extends TextInputFormatter {
|
||||||
|
const FrenchDateInputFormatter();
|
||||||
|
|
||||||
|
@override
|
||||||
|
TextEditingValue formatEditUpdate(
|
||||||
|
TextEditingValue oldValue,
|
||||||
|
TextEditingValue newValue,
|
||||||
|
) {
|
||||||
|
final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
|
||||||
|
final limited = digits.length > 8 ? digits.substring(0, 8) : digits;
|
||||||
|
final formatted = formatFrenchDateDigits(limited);
|
||||||
|
|
||||||
|
final digitsBeforeCursor = newValue.text
|
||||||
|
.substring(0, newValue.selection.start.clamp(0, newValue.text.length))
|
||||||
|
.replaceAll(RegExp(r'\D'), '')
|
||||||
|
.length
|
||||||
|
.clamp(0, limited.length);
|
||||||
|
|
||||||
|
return TextEditingValue(
|
||||||
|
text: formatted,
|
||||||
|
selection: TextSelection.collapsed(
|
||||||
|
offset: _cursorOffset(formatted, digitsBeforeCursor),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int _cursorOffset(String formatted, int digitsBeforeCursor) {
|
||||||
|
if (digitsBeforeCursor <= 0) return 0;
|
||||||
|
var seen = 0;
|
||||||
|
for (var i = 0; i < formatted.length; i++) {
|
||||||
|
if (RegExp(r'\d').hasMatch(formatted[i])) {
|
||||||
|
seen++;
|
||||||
|
if (seen >= digitsBeforeCursor) return i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return formatted.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/// Statuts enfant alignés sur `statut_enfant_type` (BDD / back #131).
|
||||||
|
const enfantStatusValues = [
|
||||||
|
'a_naitre',
|
||||||
|
'sans_garde',
|
||||||
|
'garde',
|
||||||
|
'scolarise',
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Normalise une valeur API (legacy `actif` → `sans_garde`).
|
||||||
|
String normalizeEnfantStatus(String? raw) {
|
||||||
|
final s = (raw ?? '').trim().toLowerCase();
|
||||||
|
if (s.isEmpty) return 'sans_garde';
|
||||||
|
if (s == 'actif') return 'sans_garde';
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _accordeAuGenre(String masculin, String feminin, String? gender) {
|
||||||
|
final g = (gender ?? '').trim().toUpperCase();
|
||||||
|
if (g == 'F') return feminin;
|
||||||
|
return masculin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Libellé affiché pour un statut enfant.
|
||||||
|
String enfantStatusLabel(String? status, {String? gender}) {
|
||||||
|
switch (normalizeEnfantStatus(status)) {
|
||||||
|
case 'a_naitre':
|
||||||
|
return 'À naître';
|
||||||
|
case 'sans_garde':
|
||||||
|
return 'Sans garde';
|
||||||
|
case 'garde':
|
||||||
|
return _accordeAuGenre('Gardé', 'Gardée', gender);
|
||||||
|
case 'scolarise':
|
||||||
|
return _accordeAuGenre('Scolarisé', 'Scolarisée', gender);
|
||||||
|
default:
|
||||||
|
return status?.trim().isNotEmpty == true ? status!.trim() : '–';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Libellé court pour colonnes validation (null = pas de bandeau).
|
||||||
|
String? enfantColumnStatusLabel({String? status, String? gender}) {
|
||||||
|
final s = normalizeEnfantStatus(status);
|
||||||
|
switch (s) {
|
||||||
|
case 'a_naitre':
|
||||||
|
case 'sans_garde':
|
||||||
|
case 'garde':
|
||||||
|
case 'scolarise':
|
||||||
|
return enfantStatusLabel(s, gender: gender);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
||||||
|
|
||||||
|
/// Enfant sans lien parent (orphelins d’affiliation) — ticket #157.
|
||||||
|
bool enfantHasNoResponsable(EnfantAdminModel enfant) => enfant.hasNoResponsable;
|
||||||
|
|
||||||
|
/// Message vigilance liste Enfants (même usage que [amPlacesVigilanceMessage]).
|
||||||
|
String? enfantSansResponsableVigilanceMessage(EnfantAdminModel enfant) {
|
||||||
|
if (!enfantHasNoResponsable(enfant)) return null;
|
||||||
|
return 'Aucun responsable rattaché — à rattacher à un foyer';
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
/// Formatage affichage prénom / nom (capitalisation par mot et segments après `-` ou `'`).
|
/// Formatage affichage prénom / nom (capitalisation par mot et segments après `-` ou `'`).
|
||||||
|
|
||||||
String formatPersonNameCase(String raw) {
|
String formatPersonNameCase(String raw) {
|
||||||
@@ -9,6 +11,22 @@ String formatPersonNameCase(String raw) {
|
|||||||
return words.map(_capitalizeComposedWord).join(' ');
|
return words.map(_capitalizeComposedWord).join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Variante saisie live : pas de majuscule tant que le mot n’a qu’une lettre ;
|
||||||
|
/// dès la 2ᵉ lettre, capitalisation comme [formatPersonNameCase].
|
||||||
|
String formatPersonNameCaseTyping(String raw) {
|
||||||
|
if (raw.isEmpty) return raw;
|
||||||
|
final trailingMatch = RegExp(r'(\s*)$').firstMatch(raw);
|
||||||
|
final trailing = trailingMatch?.group(1) ?? '';
|
||||||
|
final core = raw.substring(0, raw.length - trailing.length);
|
||||||
|
if (core.isEmpty) return raw;
|
||||||
|
final words = core.split(RegExp(r'\s+'));
|
||||||
|
final formatted = words.map((w) {
|
||||||
|
if (w.length < 2) return w;
|
||||||
|
return _capitalizeComposedWord(w);
|
||||||
|
}).join(' ');
|
||||||
|
return formatted + trailing;
|
||||||
|
}
|
||||||
|
|
||||||
String _capitalizeComposedWord(String word) {
|
String _capitalizeComposedWord(String word) {
|
||||||
if (word.isEmpty) {
|
if (word.isEmpty) {
|
||||||
return word;
|
return word;
|
||||||
@@ -30,3 +48,26 @@ String _capitalizeComposedWord(String word) {
|
|||||||
}
|
}
|
||||||
return buffer.toString();
|
return buffer.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Formateur de saisie prénom / nom (capitalisation progressive).
|
||||||
|
class PersonNameInputFormatter extends TextInputFormatter {
|
||||||
|
const PersonNameInputFormatter();
|
||||||
|
|
||||||
|
@override
|
||||||
|
TextEditingValue formatEditUpdate(
|
||||||
|
TextEditingValue oldValue,
|
||||||
|
TextEditingValue newValue,
|
||||||
|
) {
|
||||||
|
final formatted = formatPersonNameCaseTyping(newValue.text);
|
||||||
|
if (formatted == newValue.text) return newValue;
|
||||||
|
|
||||||
|
final sel = newValue.selection;
|
||||||
|
final offset = sel.isValid
|
||||||
|
? sel.baseOffset.clamp(0, formatted.length)
|
||||||
|
: formatted.length;
|
||||||
|
return TextEditingValue(
|
||||||
|
text: formatted,
|
||||||
|
selection: TextSelection.collapsed(offset: offset),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -49,12 +49,21 @@ String nirToRaw(String normalized) {
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Formate pour affichage : 1 12 34 56 789 012 - 34 ou 1 12 34 2A 789 012 - 34 (Corse).
|
/// Formate pour affichage (complet ou en cours de saisie) :
|
||||||
|
/// `1 12 34 56 789 012 - 34` ou `1 12 34 2A 789 012 - 34` (Corse).
|
||||||
String formatNir(String raw) {
|
String formatNir(String raw) {
|
||||||
final r = nirToRaw(raw);
|
final r = nirToRaw(raw).toUpperCase();
|
||||||
if (r.length < 15) return r;
|
if (r.isEmpty) return '';
|
||||||
// Même structure pour tous : sexe + année + mois + département + commune + ordre-clé.
|
final buf = StringBuffer();
|
||||||
return '${r.substring(0, 1)} ${r.substring(1, 3)} ${r.substring(3, 5)} ${r.substring(5, 7)} ${r.substring(7, 10)} ${r.substring(10, 13)} - ${r.substring(13, 15)}';
|
for (var i = 0; i < r.length && i < 15; i++) {
|
||||||
|
if (i == 1 || i == 3 || i == 5 || i == 7 || i == 10) {
|
||||||
|
buf.write(' ');
|
||||||
|
} else if (i == 13) {
|
||||||
|
buf.write(' - ');
|
||||||
|
}
|
||||||
|
buf.write(r[i]);
|
||||||
|
}
|
||||||
|
return buf.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Aligné sur le backend (NIR côté API / nir.util.ts) : sexe 1–3, département 2A ou 2B pour la Corse.
|
/// Aligné sur le backend (NIR côté API / nir.util.ts) : sexe 1–3, département 2A ou 2B pour la Corse.
|
||||||
@@ -92,20 +101,39 @@ String? validateNir(String? value) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Formateur de saisie : affiche le NIR formaté (1 12 34 56 789 012 - 34) et limite à 15 caractères utiles.
|
/// Validation pendant la saisie : pas d’erreur si incomplet encore plausible ;
|
||||||
|
/// dès 15 caractères, même contrôles que [validateNir].
|
||||||
|
String? validateNirTyping(String? value) {
|
||||||
|
if (value == null || value.trim().isEmpty) return null;
|
||||||
|
final raw = nirToRaw(value).toUpperCase();
|
||||||
|
if (raw.isEmpty) return null;
|
||||||
|
if (raw[0] != '1' && raw[0] != '2' && raw[0] != '3') {
|
||||||
|
return 'Format NIR invalide (ex. 1 12 34 56 789 012 - 34 ou 2A pour la Corse)';
|
||||||
|
}
|
||||||
|
if (raw.length < 15) return null;
|
||||||
|
return validateNir(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formateur de saisie : affiche le NIR formaté au fil de la frappe et limite à 15 caractères utiles.
|
||||||
class NirInputFormatter extends TextInputFormatter {
|
class NirInputFormatter extends TextInputFormatter {
|
||||||
|
const NirInputFormatter();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
TextEditingValue formatEditUpdate(
|
TextEditingValue formatEditUpdate(
|
||||||
TextEditingValue oldValue,
|
TextEditingValue oldValue,
|
||||||
TextEditingValue newValue,
|
TextEditingValue newValue,
|
||||||
) {
|
) {
|
||||||
final raw = normalizeNir(newValue.text);
|
final raw = normalizeNir(newValue.text);
|
||||||
if (raw.isEmpty) return newValue;
|
if (raw.isEmpty) {
|
||||||
|
return const TextEditingValue(
|
||||||
|
text: '',
|
||||||
|
selection: TextSelection.collapsed(offset: 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
final formatted = formatNir(raw);
|
final formatted = formatNir(raw);
|
||||||
final offset = formatted.length;
|
|
||||||
return TextEditingValue(
|
return TextEditingValue(
|
||||||
text: formatted,
|
text: formatted,
|
||||||
selection: TextSelection.collapsed(offset: offset),
|
selection: TextSelection.collapsed(offset: formatted.length),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ class ParentRegistrationPayload {
|
|||||||
final map = <String, dynamic>{
|
final map = <String, dynamic>{
|
||||||
'genre': apiGenres.contains(c.genre) ? c.genre : 'Autre',
|
'genre': apiGenres.contains(c.genre) ? c.genre : 'Autre',
|
||||||
'grossesse_multiple': c.multipleBirth,
|
'grossesse_multiple': c.multipleBirth,
|
||||||
|
'consent_photo': c.photoConsent,
|
||||||
};
|
};
|
||||||
|
|
||||||
final prenom = c.firstName.trim();
|
final prenom = c.firstName.trim();
|
||||||
|
|||||||
@@ -38,15 +38,12 @@ class RepriseMapper {
|
|||||||
? isoToDdMmYyyy(e.dueDate)
|
? isoToDdMmYyyy(e.dueDate)
|
||||||
: isoToDdMmYyyy(e.birthDate);
|
: isoToDdMmYyyy(e.birthDate);
|
||||||
final photo = e.photoUrl?.trim();
|
final photo = e.photoUrl?.trim();
|
||||||
final hasPhoto = photo != null && photo.isNotEmpty;
|
|
||||||
return ChildData(
|
return ChildData(
|
||||||
firstName: e.firstName ?? '',
|
firstName: e.firstName ?? '',
|
||||||
lastName: e.lastName ?? '',
|
lastName: e.lastName ?? '',
|
||||||
dob: dob,
|
dob: dob,
|
||||||
genre: e.gender ?? '',
|
genre: e.gender ?? '',
|
||||||
// Inscription initiale exigeait la coche pour envoyer la photo ; le back
|
photoConsent: e.consentPhoto,
|
||||||
// ne persistait pas toujours consent_photo — on pré-coche si photo en base.
|
|
||||||
photoConsent: e.consentPhoto || hasPhoto,
|
|
||||||
multipleBirth: e.estMultiple,
|
multipleBirth: e.estMultiple,
|
||||||
isUnbornChild: isUnborn,
|
isUnbornChild: isUnborn,
|
||||||
cardColor: _childCardColors[index % _childCardColors.length],
|
cardColor: _childCardColors[index % _childCardColors.length],
|
||||||
@@ -200,7 +197,7 @@ class RepriseMapper {
|
|||||||
postalCode: dossier.codePostal ?? '',
|
postalCode: dossier.codePostal ?? '',
|
||||||
city: dossier.ville ?? '',
|
city: dossier.ville ?? '',
|
||||||
existingPhotoUrl: displayPhoto,
|
existingPhotoUrl: displayPhoto,
|
||||||
consentementPhoto: dossier.consentementPhoto || hasPhoto,
|
consentementPhoto: dossier.consentementPhoto,
|
||||||
dateOfBirth: parseIsoDate(dossier.dateNaissance),
|
dateOfBirth: parseIsoDate(dossier.dateNaissance),
|
||||||
birthCity: dossier.lieuNaissanceVille ?? '',
|
birthCity: dossier.lieuNaissanceVille ?? '',
|
||||||
birthCountry: dossier.lieuNaissancePays ?? '',
|
birthCountry: dossier.lieuNaissancePays ?? '',
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/// Création comptes staff (gestionnaire / admin) — ticket #161.
|
||||||
|
bool canCreateStaffAccounts(String? role) {
|
||||||
|
final r = (role ?? '').trim().toLowerCase();
|
||||||
|
return r == 'administrateur' || r == 'super_admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Droits d’affichage poubelle — tickets #154 / #160.
|
||||||
|
bool canDeleteMetier(String? role) {
|
||||||
|
final r = (role ?? '').trim().toLowerCase();
|
||||||
|
return r == 'gestionnaire' ||
|
||||||
|
r == 'administrateur' ||
|
||||||
|
r == 'super_admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
bool canDeleteGestionnaire(String? role) {
|
||||||
|
final r = (role ?? '').trim().toLowerCase();
|
||||||
|
return r == 'administrateur' || r == 'super_admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
bool canDeleteAdministrateur({
|
||||||
|
required String? currentRole,
|
||||||
|
required String? currentUserId,
|
||||||
|
required String targetUserId,
|
||||||
|
required String targetRole,
|
||||||
|
required int adminCount,
|
||||||
|
}) {
|
||||||
|
final me = (currentRole ?? '').trim().toLowerCase();
|
||||||
|
if (me != 'administrateur' && me != 'super_admin') return false;
|
||||||
|
if (targetUserId.trim().isEmpty) return false;
|
||||||
|
if (currentUserId != null &&
|
||||||
|
currentUserId.trim() == targetUserId.trim()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final target = targetRole.trim().toLowerCase();
|
||||||
|
if (target == 'super_admin') return false;
|
||||||
|
if (adminCount <= 1 && me != 'super_admin') return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? adminDeleteBlockedReason({
|
||||||
|
required String? currentRole,
|
||||||
|
required String? currentUserId,
|
||||||
|
required String targetUserId,
|
||||||
|
required String targetRole,
|
||||||
|
required int adminCount,
|
||||||
|
}) {
|
||||||
|
if (currentUserId != null &&
|
||||||
|
currentUserId.trim() == targetUserId.trim()) {
|
||||||
|
return 'Vous ne pouvez pas supprimer votre propre compte.';
|
||||||
|
}
|
||||||
|
if (targetRole.trim().toLowerCase() == 'super_admin') {
|
||||||
|
return 'Le super administrateur ne peut pas être supprimé.';
|
||||||
|
}
|
||||||
|
if (adminCount <= 1 &&
|
||||||
|
(currentRole ?? '').trim().toLowerCase() != 'super_admin') {
|
||||||
|
return 'Seul un super administrateur peut supprimer le dernier administrateur.';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -4,7 +4,9 @@ import 'package:p_tits_pas/utils/phone_utils.dart';
|
|||||||
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
|
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
|
||||||
import 'package:p_tits_pas/services/auth_service.dart';
|
import 'package:p_tits_pas/services/auth_service.dart';
|
||||||
import 'package:p_tits_pas/services/user_service.dart';
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/dashboard/user_card.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
||||||
|
|
||||||
class AdminManagementWidget extends StatefulWidget {
|
class AdminManagementWidget extends StatefulWidget {
|
||||||
@@ -24,6 +26,7 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
|||||||
String? _error;
|
String? _error;
|
||||||
List<AppUser> _admins = [];
|
List<AppUser> _admins = [];
|
||||||
String? _currentUserRole;
|
String? _currentUserRole;
|
||||||
|
String? _currentUserId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -62,6 +65,7 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
|||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_currentUserRole = (cached.role).toLowerCase();
|
_currentUserRole = (cached.role).toLowerCase();
|
||||||
|
_currentUserId = cached.id;
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -69,6 +73,7 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
|||||||
if (!mounted || refreshed == null) return;
|
if (!mounted || refreshed == null) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_currentUserRole = (refreshed.role).toLowerCase();
|
_currentUserRole = (refreshed.role).toLowerCase();
|
||||||
|
_currentUserId = refreshed.id;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +84,16 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
|||||||
return _currentUserRole == 'super_admin';
|
return _currentUserRole == 'super_admin';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _canDeleteAdmin(AppUser target) {
|
||||||
|
return canDeleteAdministrateur(
|
||||||
|
currentRole: _currentUserRole,
|
||||||
|
currentUserId: _currentUserId,
|
||||||
|
targetUserId: target.id,
|
||||||
|
targetRole: target.role,
|
||||||
|
adminCount: _admins.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _openAdminEditDialog(AppUser user) async {
|
Future<void> _openAdminEditDialog(AppUser user) async {
|
||||||
final canEdit = _canEditAdmin(user);
|
final canEdit = _canEditAdmin(user);
|
||||||
final changed = await showDialog<bool>(
|
final changed = await showDialog<bool>(
|
||||||
@@ -98,6 +113,56 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmDelete(AppUser user) async {
|
||||||
|
final blocked = adminDeleteBlockedReason(
|
||||||
|
currentRole: _currentUserRole,
|
||||||
|
currentUserId: _currentUserId,
|
||||||
|
targetUserId: user.id,
|
||||||
|
targetRole: user.role,
|
||||||
|
adminCount: _admins.length,
|
||||||
|
);
|
||||||
|
if (blocked != null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(blocked)),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final name = user.fullName.isNotEmpty ? user.fullName : user.email;
|
||||||
|
final isLast = _admins.length <= 1;
|
||||||
|
final confirmed = await showSuppressionConfirmDialog(
|
||||||
|
context,
|
||||||
|
title: 'Supprimer l\'administrateur',
|
||||||
|
people: [SuppressionPersonLine.administrateur(name)],
|
||||||
|
footnotes: [
|
||||||
|
if (isLast)
|
||||||
|
'Attention : c’est le dernier administrateur.',
|
||||||
|
'Le compte sera définitivement supprimé.',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (!confirmed || !mounted) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = await UserService.deleteUser(user.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
final msg =
|
||||||
|
(result['message'] ?? 'Administrateur supprimé.').toString();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
||||||
|
await _loadAdmins();
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
e is Exception
|
||||||
|
? e.toString().replaceFirst('Exception: ', '')
|
||||||
|
: 'Erreur suppression',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final query = widget.searchQuery.toLowerCase();
|
final query = widget.searchQuery.toLowerCase();
|
||||||
@@ -117,11 +182,13 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
|||||||
final user = filteredAdmins[index];
|
final user = filteredAdmins[index];
|
||||||
final isSuperAdmin = _isSuperAdmin(user);
|
final isSuperAdmin = _isSuperAdmin(user);
|
||||||
final canEdit = _canEditAdmin(user);
|
final canEdit = _canEditAdmin(user);
|
||||||
return AdminUserCard(
|
final canDelete = _canDeleteAdmin(user);
|
||||||
|
return UserCard(
|
||||||
title: user.fullName,
|
title: user.fullName,
|
||||||
fallbackIcon: isSuperAdmin
|
fallbackIcon: isSuperAdmin
|
||||||
? Icons.verified_user_outlined
|
? Icons.verified_user_outlined
|
||||||
: Icons.manage_accounts_outlined,
|
: Icons.manage_accounts_outlined,
|
||||||
|
onCardTap: () => _openAdminEditDialog(user),
|
||||||
subtitleLines: [
|
subtitleLines: [
|
||||||
user.email,
|
user.email,
|
||||||
'Téléphone : ${user.telephone?.trim().isNotEmpty == true ? formatPhoneForDisplay(user.telephone!) : 'Non renseigné'}',
|
'Téléphone : ${user.telephone?.trim().isNotEmpty == true ? formatPhoneForDisplay(user.telephone!) : 'Non renseigné'}',
|
||||||
@@ -147,6 +214,8 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
|||||||
_openAdminEditDialog(user);
|
_openAdminEditDialog(user);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
if (canDelete)
|
||||||
|
suppressionIconButton(onPressed: () => _confirmDelete(user)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user