feat(#131): fiches parent/AM éditable, placement AM↔enfant, statuts garde/sans_garde

Squash merge develop → master.

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-11 22:49:57 +02:00
co-authored by Cursor
parent b99745e0fe
commit 003fe6b762
69 changed files with 6290 additions and 417 deletions
+123
View File
@@ -0,0 +1,123 @@
/**
* Commentaire de clôture + fermeture issue Gitea #131.
* Usage: node backend/scripts/close-gitea-issue-131.js
*/
const https = require('https');
const fs = require('fs');
const path = require('path');
const repoRoot = path.join(__dirname, '../..');
const ISSUE = 131;
const REPO = 'jmartin/petitspas';
const body = `## Fermeture ticket #131 — livré sur \`develop\` et \`master\`
Branche **\`feature/131\`** mergée dans **\`develop\`**, puis squash merge **\`develop\`\`master\`** (déploiement production).
### Fiche parent (dashboard admin)
- Modale **\`AdminParentEditModal\`** éditable dès l'ouverture
- En-tête dynamique : **nom/prénom** + sous-titre **co-parent** (si connu)
- Gélule **statut** modifiable
- **\`PATCH /api/v1/parents/:id/fiche\`** — identité + statut
- **\`GET /api/v1/parents\`** — liste parents (fix décorateur \`@Get()\` manquant)
- Réponses API : \`co_parent\` peuplé, secrets user masqués (\`sanitizeUserForApi\`)
- Liste enfants en bas de fiche + rattachement/détachement
### Fiche AM (dashboard admin)
- Modale **\`AdminAmEditModal\`** — 3 onglets : Identité | Fiche pro | Enfants accueillis
- **\`PATCH /api/v1/assistantes-maternelles/:id/fiche\`** — identité + champs pro (NIR, date/lieu naissance, date agrément, places, disponibilité)
- **\`POST/DELETE …/enfants/:enfantId\`** — rattacher / détacher un enfant
- Grille capacité **\`AdminAmChildrenCapacityGrid\`** (2×2)
- Rattachement enfants **différé jusqu'à Sauvegarder** (pas d'appel API immédiat)
### Back — placement AM ↔ enfant
- Table **\`enfants_assistantes_maternelles\`** (placement temporel, 1 garde active/enfant)
- Enum enfant : \`a_naitre\`, \`garde\`, \`sans_garde\`, \`scolarise\` — **plus \`actif\`**
- Rattachement → statut enfant \`garde\` ; détachement → \`sans_garde\`
- Migration : \`database/migrations/2026_enfants_assistantes_maternelles.sql\`
- Schéma canonique : \`database/BDD.sql\`
### Front — statuts & polish
- **\`enfant_status_utils.dart\`** — libellés/couleurs \`garde\`/\`sans_garde\`
- Mise à jour cartes enfants, modale détail, filtres dashboard
### Correctifs recette
- \`GET /parents\` 404 → ajout \`@Get()\` sur \`getAll()\`
- Sauvegarde AM 400 → alignement DTO \`UpdateAmFicheAdminDto\` sur payload front
- Crash NIR → fix \`toISOString\` + pas d'effacement NIR (colonne NOT NULL)
### Hors périmètre #131 (tickets voisins)
- **#137** onglet Enfants global · **#138** fiche enfant complète · **#115/#116** affiliation avancée
---
*Issue fermée après merge sur \`develop\` + \`master\` et déploiement production.*`;
let token = process.env.GITEA_TOKEN;
if (!token) {
try {
const tokenFile = path.join(repoRoot, '.gitea-token');
if (fs.existsSync(tokenFile)) token = fs.readFileSync(tokenFile, 'utf8').trim();
} catch (_) {}
}
if (!token) {
try {
const briefing = fs.readFileSync(
path.join(repoRoot, 'docs/27_BRIEFING-FRONTEND.md'),
'utf8',
);
const m = briefing.match(/Token:\s*(giteabu_[a-f0-9]+)/);
if (m) token = m[1].trim();
} catch (_) {}
}
if (!token) {
console.error('Token non trouvé : .gitea-token ou GITEA_TOKEN');
process.exit(1);
}
function request(method, apiPath, payloadObj) {
const payload = payloadObj ? JSON.stringify(payloadObj) : null;
return new Promise((resolve, reject) => {
const opts = {
hostname: 'git.ptits-pas.fr',
path: `/api/v1/repos/${REPO}${apiPath}`,
method,
headers: {
Authorization: 'token ' + token,
'Content-Type': 'application/json',
...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}),
},
};
const req = https.request(opts, (res) => {
let d = '';
res.on('data', (c) => (d += c));
res.on('end', () => {
if (res.statusCode !== 200 && res.statusCode !== 201) {
reject(new Error(`HTTP ${res.statusCode}: ${d}`));
return;
}
try {
resolve(d ? JSON.parse(d) : {});
} catch (_) {
resolve({});
}
});
});
req.on('error', reject);
if (payload) req.write(payload);
req.end();
});
}
(async () => {
try {
console.log(`POST commentaire issue #${ISSUE}...`);
await request('POST', `/issues/${ISSUE}/comments`, { body });
console.log('Commentaire publié.');
console.log(`PATCH fermeture issue #${ISSUE}...`);
await request('PATCH', `/issues/${ISSUE}`, { state: 'closed' });
console.log(`Issue #${ISSUE} fermée.`);
} catch (e) {
console.error(e.message || e);
process.exit(1);
}
})();
@@ -0,0 +1,153 @@
/**
* Met à jour l'issue Gitea #140 — epic dashboard admin ch.6 famille.
* Usage: node backend/scripts/update-gitea-issue-140-ch6-famille.js
* Token : .gitea-token (racine), GITEA_TOKEN, ou docs/27_BRIEFING-FRONTEND.md
*/
const https = require('https');
const fs = require('fs');
const path = require('path');
const repoRoot = path.join(__dirname, '../..');
let token = process.env.GITEA_TOKEN;
if (!token) {
try {
const tokenFile = path.join(repoRoot, '.gitea-token');
if (fs.existsSync(tokenFile)) token = fs.readFileSync(tokenFile, 'utf8').trim();
} catch (_) {}
}
if (!token) {
try {
const briefing = fs.readFileSync(
path.join(repoRoot, 'docs/27_BRIEFING-FRONTEND.md'),
'utf8',
);
const m = briefing.match(/Token:\s*(giteabu_[a-f0-9]+)/);
if (m) token = m[1].trim();
} catch (_) {}
}
if (!token) {
console.error('Token non trouvé : .gitea-token ou GITEA_TOKEN (voir docs/26_GITEA-API.md)');
process.exit(1);
}
const body = `## Rôle de ce ticket
**#140 est un ticket epic / livraison** : il regroupe la mise en œuvre du **chapitre 6** du doc [28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md](../docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md) (§6.1 et §6.2) sur la branche \`feature/140-dashboard-admin-ch6-famille\`.
Il **ne remplace pas** les tickets détaillés ci-dessous : il sert de **fil de livraison** (PR, recette, fermeture coordonnée). Chaque sous-ticket garde son périmètre propre ; #140 est clos quand l'ensemble est **fonctionnel et homogène en UI**.
---
## Tickets couverts (périmètres embarqués)
| Ticket | Sujet | Rôle dans #140 |
|--------|--------|----------------|
| **#115** | Rattachement parent — **backend** | API \`POST/DELETE …/enfants/:enfantId\` |
| **#116** | Rattachement parent — **frontend** | UI rattacher / détacher depuis la fiche parent |
| **#130** | \`UserService\` — APIs admin | Appels \`getParents\`, \`getParent\`, \`getEnfants\`, \`updateParentFiche\`, etc. |
| **#131** | Fiche parent éditable | Modale parent (dashboard) — *hors fiche AM* |
| **#137** | Onglet **Enfants** | Liste globale admin + accès fiche enfant |
| **#138** | Fiche enfant + liste dans fiche parent | \`AdminChildDetailModal\` + liste enfants en bas de fiche parent |
> **Note :** fermer #140 peut entraîner la fermeture **partielle ou totale** de ces tickets selon ce qui est réellement livré et recetté dans la PR.
---
## Hors scope #140
- **§6.3** — Création dossier admin sans numéro → **#129**
- Fiche **AM** éditable (dashboard) → reste **#131** (partie AM)
- Parcours gestionnaire « famille complexe » → **#139**
- Qualification responsable légal (combobox sur lien) → ticket à créer
---
## Backend (attendu / livré)
- [x] \`PATCH /parents/:id/fiche\` — édition fiche parent (admin/gestionnaire)
- [x] \`POST /parents/:id/enfants/:enfantId\` — rattacher un enfant existant
- [x] \`DELETE /parents/:id/enfants/:enfantId\` — détacher (garde-fou : ≥1 responsable / enfant)
- [x] \`GET /enfants\` enrichi ; \`PATCH /enfants/:id\` pour gestionnaire
---
## Frontend — fait
- [x] Modale **fiche parent** éditable (shell aligné validation, statut gélule, téléphone formaté, \`IdentityBlock\`)
- [x] Onglet **Enfants** — liste globale (\`EnfantManagementWidget\`)
- [x] Liste enfants dans fiche parent — cartes (photo, nom, âge ans/mois, statut), cadre blanc, scroll 2,5 lignes
- [x] Rattacher / détacher un enfant **existant** (API branchée)
- [x] Parsing robuste \`parentChildren\` + URLs médias \`/uploads\` en Flutter web
- [x] \`UserService\` — APIs parents / enfants / affiliation
---
## Frontend — reste à faire (bloquant clôture)
### Fiche enfant — #138 (UI)
- [ ] Reprendre \`AdminChildDetailModal\` : même **look & feel** que fiche parent / modales validation (largeur ~930 px, grille champs, photo enfant)
- [ ] Aligner dates, genre, statut sur les wizards validation famille
### Modale **rattacher** un enfant — #116 (UI)
- [ ] Remplacer le \`SimpleDialog\` actuel par une modale cohérente : liste type \`AdminEnfantUserCard\` (photo, nom, âge), recherche éventuelle
### Création d'un **nouvel** enfant depuis la fiche parent — doc §6.2
- [ ] **Non implémenté** aujourd'hui (seul le rattachement d'un enfant déjà en base existe)
- [ ] À trancher : inclus dans #140 si le back expose un \`POST\` admin depuis le contexte parent, sinon ticket dédié / extension #129
---
## Recette avant merge
1. Parent avec 0 / 1 / 3+ enfants — liste, scroll, compteur
2. Rattacher puis détacher (message si dernier responsable)
3. Clic enfant → fiche enfant (après refonte UI)
4. Onglet Enfants — même rendu cartes
5. Avatars photos (URLs absolues web)
---
## Branche
\`feature/140-dashboard-admin-ch6-famille\`
## Références
- Doc produit : \`docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md\` §6.1, §6.2`;
const payload = JSON.stringify({ body });
const req = https.request(
{
hostname: 'git.ptits-pas.fr',
path: '/api/v1/repos/jmartin/petitspas/issues/140',
method: 'PATCH',
headers: {
Authorization: `token ${token}`,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
},
},
(res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
const json = JSON.parse(data);
console.log('Issue #140 mise à jour :', json.html_url);
console.log('updated_at:', json.updated_at);
} else {
console.error('Erreur', res.statusCode, data);
process.exit(1);
}
});
},
);
req.on('error', (err) => {
console.error(err);
process.exit(1);
});
req.write(payload);
req.end();
@@ -0,0 +1,32 @@
import { sanitizeUserForApi } from './sanitize-user-for-api';
import { RoleType, StatutUtilisateurType, Users } from '../../entities/users.entity';
describe('sanitizeUserForApi', () => {
const base: Users = {
id: 'u1',
email: 'a@b.fr',
prenom: 'Paul',
nom: 'Parent',
role: RoleType.PARENT,
statut: StatutUtilisateurType.ACTIF,
password: 'hash',
token_creation_mdp: 'tok',
token_creation_mdp_expire_le: new Date(),
password_reset_token: 'rst',
password_reset_expires: new Date(),
} as Users;
it('retire password et tokens', () => {
const out = sanitizeUserForApi(base)!;
expect(out.prenom).toBe('Paul');
expect(out.nom).toBe('Parent');
expect(out.password).toBeUndefined();
expect(out.token_creation_mdp).toBeUndefined();
expect(out.password_reset_token).toBeUndefined();
});
it('retourne undefined si user absent', () => {
expect(sanitizeUserForApi(null)).toBeUndefined();
expect(sanitizeUserForApi(undefined)).toBeUndefined();
});
});
@@ -0,0 +1,23 @@
import { Users } from 'src/entities/users.entity';
/** Champs sensibles exclus des réponses API (ticket #131 — user / co_parent). */
const SENSITIVE_USER_KEYS: (keyof Users)[] = [
'password',
'token_creation_mdp',
'token_creation_mdp_expire_le',
'password_reset_token',
'password_reset_expires',
];
/**
* Retourne une copie utilisateur sans secrets (hash MDP, tokens).
* Utilisé pour `user` et `co_parent` dans les réponses Parents.
*/
export function sanitizeUserForApi(user?: Users | null): Users | undefined {
if (!user) return undefined;
const safe = { ...user } as Users;
for (const key of SENSITIVE_USER_KEYS) {
delete safe[key];
}
return safe;
}
@@ -0,0 +1,47 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
} from 'typeorm';
import { AssistanteMaternelle } from './assistantes_maternelles.entity';
import { Children } from './children.entity';
import { Users } from './users.entity';
@Entity('enfants_assistantes_maternelles', { schema: 'public' })
export class AmChildren {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'id_am', type: 'uuid' })
amId: string;
@Column({ name: 'id_enfant', type: 'uuid' })
enfantId: string;
@Column({ name: 'date_debut', type: 'date' })
date_debut: Date;
@Column({ name: 'date_fin', type: 'date', nullable: true })
date_fin?: Date;
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
cree_le: Date;
@Column({ name: 'cree_par', type: 'uuid', nullable: true })
cree_par?: string;
@ManyToOne(() => AssistanteMaternelle, (am) => am.amChildren, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'id_am', referencedColumnName: 'user_id' })
am: AssistanteMaternelle;
@ManyToOne(() => Children, (c) => c.amLinks, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'id_enfant', referencedColumnName: 'id' })
child: Children;
@ManyToOne(() => Users, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'cree_par', referencedColumnName: 'id' })
createdBy?: Users;
}
@@ -1,5 +1,6 @@
import { Entity, PrimaryColumn, Column, OneToOne, JoinColumn } from 'typeorm';
import { Entity, PrimaryColumn, Column, OneToOne, OneToMany, JoinColumn } from 'typeorm';
import { Users } from './users.entity';
import { AmChildren } from './am_children.entity';
@Entity('assistantes_maternelles')
export class AssistanteMaternelle {
@@ -51,4 +52,7 @@ export class AssistanteMaternelle {
/** Numéro de dossier (format AAAA-NNNNNN), même valeur que sur utilisateurs (ticket #103) */
@Column({ name: 'numero_dossier', length: 20, nullable: true })
numero_dossier?: string;
@OneToMany(() => AmChildren, (ac) => ac.am)
amChildren: AmChildren[];
}
+6 -1
View File
@@ -4,12 +4,14 @@ import {
} from 'typeorm';
import { Parents } from './parents.entity';
import { ParentsChildren } from './parents_children.entity';
import { AmChildren } from './am_children.entity';
import { Dossier } from './dossiers.entity';
export enum StatutEnfantType {
A_NAITRE = 'a_naitre',
ACTIF = 'actif',
SCOLARISE = 'scolarise',
GARDE = 'garde',
SANS_GARDE = 'sans_garde',
}
export enum GenreType {
@@ -68,6 +70,9 @@ export class Children {
@OneToMany(() => ParentsChildren, pc => pc.child)
parentLinks: ParentsChildren[];
@OneToMany(() => AmChildren, (ac) => ac.child)
amLinks: AmChildren[];
// Relation avec Dossier
@OneToMany(() => Dossier, d => d.child)
dossiers: Dossier[];
@@ -12,11 +12,14 @@ import { AssistantesMaternellesService } from './assistantes_maternelles.service
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { Roles } from 'src/common/decorators/roles.decorator';
import { RoleType } from 'src/entities/users.entity';
import { RoleType, Users } from 'src/entities/users.entity';
import { CreateAssistanteDto } from '../user/dto/create_assistante.dto';
import { UpdateAssistanteDto } from '../user/dto/update_assistante.dto';
import { UpdateAmFicheAdminDto } from './dto/update-am-fiche-admin.dto';
import { RolesGuard } from 'src/common/guards/roles.guard';
import { AuthGuard } from 'src/common/guards/auth.guard';
import { User } from 'src/common/decorators/user.decorator';
import { mapAmForApi, mapAmsForApi } from './assistantes_maternelles.mapper';
@ApiTags("Assistantes Maternelles")
@ApiBearerAuth('access-token')
@@ -31,28 +34,74 @@ export class AssistantesMaternellesController {
@ApiResponse({ status: 403, description: 'Accès refusé : Réservé aux super_admins et gestionnaires' })
@ApiBody({ type: CreateAssistanteDto })
@Post()
create(@Body() dto: CreateAssistanteDto): Promise<AssistanteMaternelle> {
return this.assistantesMaternellesService.create(dto);
async create(@Body() dto: CreateAssistanteDto): Promise<AssistanteMaternelle> {
const am = await this.assistantesMaternellesService.create(dto);
return mapAmForApi(am);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@Get()
@ApiOperation({ summary: 'Récupérer la liste des nounous' })
@ApiOperation({ summary: 'Récupérer la liste des nounous (inclut amChildren actifs) — ticket #131' })
@ApiResponse({ status: 200, description: 'Liste des nounous' })
@ApiResponse({ status: 403, description: 'Accès refusé : Réservé aux super_admins et gestionnaires' })
getAll(): Promise<AssistanteMaternelle[]> {
return this.assistantesMaternellesService.findAll();
async getAll(): Promise<AssistanteMaternelle[]> {
const ams = await this.assistantesMaternellesService.findAll();
return mapAmsForApi(ams);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@Get(':id')
@ApiParam({ name: 'id', description: "UUID de la nounou" })
@ApiOperation({ summary: 'Récupérer une nounou par id' })
@ApiOperation({ summary: 'Récupérer une nounou par id (inclut amChildren) — ticket #131' })
@ApiResponse({ status: 200, description: 'Détails de la nounou' })
@ApiResponse({ status: 404, description: 'Nounou non trouvée' })
@ApiResponse({ status: 403, description: 'Accès refusé : Réservé aux super_admins et gestionnaires' })
getOne(@Param('id') user_id: string): Promise<AssistanteMaternelle> {
return this.assistantesMaternellesService.findOne(user_id);
@ApiResponse({ status: 403, description: 'Accès refusé' })
async getOne(@Param('id') user_id: string): Promise<AssistanteMaternelle> {
const am = await this.assistantesMaternellesService.findOne(user_id);
return mapAmForApi(am);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@Patch(':id/fiche')
@ApiBody({ type: UpdateAmFicheAdminDto })
@ApiOperation({ summary: 'Mettre à jour la fiche AM (identité + pro) — ticket #131' })
@ApiParam({ name: 'id', description: "UUID utilisateur de l'AM" })
@ApiResponse({ status: 200, description: 'Fiche AM mise à jour' })
async updateFicheAdmin(
@Param('id') id: string,
@Body() dto: UpdateAmFicheAdminDto,
): Promise<AssistanteMaternelle> {
const am = await this.assistantesMaternellesService.updateFicheAdmin(id, dto);
return mapAmForApi(am);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@Post(':id/enfants/:enfantId')
@ApiOperation({ summary: 'Rattacher un enfant à une AM (statut enfant → garde) — ticket #131' })
@ApiParam({ name: 'id', description: "UUID utilisateur de l'AM" })
@ApiParam({ name: 'enfantId', description: "UUID de l'enfant" })
@ApiResponse({ status: 200, description: 'AM avec enfants mis à jour' })
async attachEnfant(
@Param('id') id: string,
@Param('enfantId') enfantId: string,
@User() currentUser: Users,
): Promise<AssistanteMaternelle> {
const am = await this.assistantesMaternellesService.attachEnfant(id, enfantId, currentUser);
return mapAmForApi(am);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@Delete(':id/enfants/:enfantId')
@ApiOperation({ summary: "Clôturer le placement d'un enfant chez une AM — ticket #131" })
@ApiParam({ name: 'id', description: "UUID utilisateur de l'AM" })
@ApiParam({ name: 'enfantId', description: "UUID de l'enfant" })
@ApiResponse({ status: 200, description: 'AM avec enfants mis à jour' })
async detachEnfant(
@Param('id') id: string,
@Param('enfantId') enfantId: string,
): Promise<AssistanteMaternelle> {
const am = await this.assistantesMaternellesService.detachEnfant(id, enfantId);
return mapAmForApi(am);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
@@ -63,14 +112,15 @@ export class AssistantesMaternellesController {
@ApiResponse({ status: 404, description: 'Nounou non trouvée' })
@ApiParam({ name: 'id', description: "UUID de la nounou" })
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateAssistanteDto): Promise<AssistanteMaternelle> {
return this.assistantesMaternellesService.update(id, dto);
async update(@Param('id') id: string, @Body() dto: UpdateAssistanteDto): Promise<AssistanteMaternelle> {
const am = await this.assistantesMaternellesService.update(id, dto);
return mapAmForApi(am);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Supprimer une nounou' })
@ApiResponse({ status: 200, description: 'Nounou supprimée avec succès' })
@ApiResponse({ status: 403, description: 'Accès refusé : Réservé aux super_admins, gestionnaires et administrateurs' })
@ApiResponse({ status: 403, description: 'Accès refusé' })
@ApiResponse({ status: 404, description: 'Nounou non trouvée' })
@ApiParam({ name: 'id', description: "UUID de la nounou" })
@Delete(':id')
@@ -0,0 +1,28 @@
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { AmChildren } from 'src/entities/am_children.entity';
import { sanitizeUserForApi } from '../../common/utils/sanitize-user-for-api';
/**
* Sérialisation API fiche AM — ticket #131.
* Expose `amChildren` actifs (date_fin null) avec enfant imbriqué, sans secrets user.
*/
export function mapAmForApi(am: AssistanteMaternelle): AssistanteMaternelle {
const activeChildren = (am.amChildren ?? []).filter((link) => !link.date_fin);
return {
...am,
user: sanitizeUserForApi(am.user)!,
amChildren: activeChildren.map((link) => ({
...link,
child: link.child,
})),
};
}
export function mapAmsForApi(ams: AssistanteMaternelle[]): AssistanteMaternelle[] {
return ams.map(mapAmForApi);
}
export function filterActiveAmChildren(links: AmChildren[] | undefined): AmChildren[] {
return (links ?? []).filter((link) => !link.date_fin);
}
@@ -2,12 +2,14 @@ import { Module } from '@nestjs/common';
import { AssistantesMaternellesService } from './assistantes_maternelles.service';
import { AssistantesMaternellesController } from './assistantes_maternelles.controller';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { AmChildren } from 'src/entities/am_children.entity';
import { Children } from 'src/entities/children.entity';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Users } from 'src/entities/users.entity';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [TypeOrmModule.forFeature([AssistanteMaternelle, Users]),
imports: [TypeOrmModule.forFeature([AssistanteMaternelle, AmChildren, Children, Users]),
AuthModule
],
controllers: [AssistantesMaternellesController],
@@ -5,11 +5,17 @@ import {
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { IsNull, Repository } from 'typeorm';
import { RoleType, Users } from 'src/entities/users.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { AmChildren } from 'src/entities/am_children.entity';
import { Children, StatutEnfantType } from 'src/entities/children.entity';
import { CreateAssistanteDto } from '../user/dto/create_assistante.dto';
import { UpdateAssistanteDto } from '../user/dto/update_assistante.dto';
import { UpdateAmFicheAdminDto } from './dto/update-am-fiche-admin.dto';
import { validateNir } from 'src/common/utils/nir.util';
const AM_CHILDREN_RELATIONS = ['user', 'amChildren', 'amChildren.child'] as const;
@Injectable()
export class AssistantesMaternellesService {
@@ -17,10 +23,13 @@ export class AssistantesMaternellesService {
@InjectRepository(AssistanteMaternelle)
private readonly assistantesMaternelleRepository: Repository<AssistanteMaternelle>,
@InjectRepository(Users)
private readonly usersRepository: Repository<Users>
private readonly usersRepository: Repository<Users>,
@InjectRepository(AmChildren)
private readonly amChildrenRepository: Repository<AmChildren>,
@InjectRepository(Children)
private readonly childrenRepository: Repository<Children>,
) {}
// Création dune assistante maternelle
async create(dto: CreateAssistanteDto): Promise<AssistanteMaternelle> {
const user = await this.usersRepository.findOneBy({ id: dto.user_id });
if (!user) throw new NotFoundException('Utilisateur introuvable');
@@ -49,30 +58,208 @@ export class AssistantesMaternellesService {
return this.assistantesMaternelleRepository.save(entity);
}
// Liste des assistantes maternelles
async findAll(): Promise<AssistanteMaternelle[]> {
return this.assistantesMaternelleRepository.find({
relations: ['user'],
relations: [...AM_CHILDREN_RELATIONS],
});
}
// Récupérer une assistante maternelle par user_id
async findOne(user_id: string): Promise<AssistanteMaternelle> {
const assistante = await this.assistantesMaternelleRepository.findOne({
where: { user_id },
relations: ['user'],
relations: [...AM_CHILDREN_RELATIONS],
});
if (!assistante) throw new NotFoundException('Assistante maternelle introuvable');
return assistante;
}
// Mise à jour
async update(id: string, dto: UpdateAssistanteDto): Promise<AssistanteMaternelle> {
await this.assistantesMaternelleRepository.update(id, dto);
return this.findOne(id);
}
// Suppression dune assistante maternelle
/**
* Mise à jour fiche AM (identité + champs pro) par admin/gestionnaire. Ticket #131.
*/
async updateFicheAdmin(amUserId: string, dto: UpdateAmFicheAdminDto): Promise<AssistanteMaternelle> {
const am = await this.findOne(amUserId);
const user = am.user;
if (dto.email && dto.email !== user.email) {
const existing = await this.usersRepository.findOne({ where: { email: dto.email } });
if (existing && existing.id !== user.id) {
throw new ConflictException('Cet email est déjà utilisé');
}
user.email = dto.email;
}
if (dto.nom !== undefined) user.nom = dto.nom;
if (dto.prenom !== undefined) user.prenom = dto.prenom;
if (dto.telephone !== undefined) user.telephone = dto.telephone;
if (dto.adresse !== undefined) user.adresse = dto.adresse;
if (dto.ville !== undefined) user.ville = dto.ville;
if (dto.code_postal !== undefined) user.code_postal = dto.code_postal;
if (dto.statut !== undefined) user.statut = dto.statut;
if (dto.date_naissance !== undefined) {
user.date_naissance = dto.date_naissance ? new Date(dto.date_naissance) : undefined;
}
if (dto.lieu_naissance_ville !== undefined) {
user.lieu_naissance_ville = dto.lieu_naissance_ville || undefined;
}
if (dto.lieu_naissance_pays !== undefined) {
user.lieu_naissance_pays = dto.lieu_naissance_pays || undefined;
}
await this.usersRepository.save(user);
const amPatch: Partial<AssistanteMaternelle> = {};
if (dto.approval_number !== undefined) amPatch.approval_number = dto.approval_number;
if (dto.residence_city !== undefined) amPatch.residence_city = dto.residence_city;
if (dto.max_children !== undefined) amPatch.max_children = dto.max_children;
if (dto.places_available !== undefined) amPatch.places_available = dto.places_available;
if (dto.biography !== undefined) amPatch.biography = dto.biography;
if (dto.available !== undefined) amPatch.available = dto.available;
if (dto.agreement_date !== undefined) {
amPatch.agreement_date = dto.agreement_date ? new Date(dto.agreement_date) : undefined;
}
if (dto.nir !== undefined) {
const nirNormalized = dto.nir.replace(/\s/g, '').toUpperCase();
if (nirNormalized) {
const dateNaissanceForNir =
dto.date_naissance ??
(user.date_naissance instanceof Date
? user.date_naissance.toISOString().slice(0, 10)
: user.date_naissance
? String(user.date_naissance).slice(0, 10)
: undefined);
const nirValidation = validateNir(nirNormalized, {
dateNaissance: dateNaissanceForNir,
});
if (!nirValidation.valid) {
throw new BadRequestException(nirValidation.error || 'NIR invalide');
}
const nirDejaUtilise = await this.assistantesMaternelleRepository.findOne({
where: { nir: nirNormalized },
});
if (nirDejaUtilise && nirDejaUtilise.user_id !== amUserId) {
throw new ConflictException(
'Un compte assistante maternelle avec ce numéro NIR existe déjà.',
);
}
amPatch.nir = nirNormalized;
}
// NIR vide : ne pas effacer (colonne NOT NULL en BDD) — le front renvoie toujours la clé.
}
if (Object.keys(amPatch).length > 0) {
await this.assistantesMaternelleRepository.update(amUserId, amPatch);
}
return this.findOne(amUserId);
}
/**
* Rattacher un enfant à une AM (placement actif). Ticket #131.
* Passe le statut enfant à `garde` (sauf a_naitre / scolarise).
*/
async attachEnfant(amUserId: string, enfantId: string, createdBy?: Users): Promise<AssistanteMaternelle> {
const am = await this.findOne(amUserId);
const existingForAm = await this.amChildrenRepository.findOne({
where: { amId: amUserId, enfantId, date_fin: IsNull() },
});
if (existingForAm) {
throw new ConflictException('Cet enfant est déjà rattaché à cette assistante maternelle');
}
const child = await this.childrenRepository.findOne({ where: { id: enfantId } });
if (!child) {
throw new NotFoundException('Enfant introuvable');
}
const activeForChild = await this.amChildrenRepository.findOne({
where: { enfantId, date_fin: IsNull() },
});
if (activeForChild && activeForChild.amId !== amUserId) {
throw new ConflictException(
'Cet enfant est déjà en garde chez une autre assistante maternelle',
);
}
const activeCount = await this.amChildrenRepository.count({
where: { amId: amUserId, date_fin: IsNull() },
});
if (am.max_children != null && activeCount >= am.max_children) {
throw new BadRequestException(
`Capacité maximale atteinte (${am.max_children} enfant(s))`,
);
}
await this.amChildrenRepository.save(
this.amChildrenRepository.create({
amId: amUserId,
enfantId,
date_debut: new Date(),
cree_par: createdBy?.id,
}),
);
await this.applyGardeStatusOnAttach(child);
return this.findOne(amUserId);
}
/**
* Clôturer le placement AM ↔ enfant. Ticket #131.
* Repasse l'enfant en `sans_garde` s'il n'a plus de placement actif.
*/
async detachEnfant(amUserId: string, enfantId: string): Promise<AssistanteMaternelle> {
await this.findOne(amUserId);
const link = await this.amChildrenRepository.findOne({
where: { amId: amUserId, enfantId, date_fin: IsNull() },
relations: ['child'],
});
if (!link) {
throw new NotFoundException('Lien assistante maternelle-enfant introuvable');
}
link.date_fin = new Date();
await this.amChildrenRepository.save(link);
const remaining = await this.amChildrenRepository.count({
where: { enfantId, date_fin: IsNull() },
});
if (remaining === 0 && link.child) {
await this.applySansGardeStatusOnDetach(link.child);
}
return this.findOne(amUserId);
}
private async applyGardeStatusOnAttach(child: Children): Promise<void> {
if (
child.status === StatutEnfantType.A_NAITRE ||
child.status === StatutEnfantType.SCOLARISE
) {
return;
}
child.status = StatutEnfantType.GARDE;
await this.childrenRepository.save(child);
}
private async applySansGardeStatusOnDetach(child: Children): Promise<void> {
if (
child.status === StatutEnfantType.A_NAITRE ||
child.status === StatutEnfantType.SCOLARISE
) {
return;
}
child.status = StatutEnfantType.SANS_GARDE;
await this.childrenRepository.save(child);
}
async remove(id: string): Promise<{ message: string }> {
await this.assistantesMaternelleRepository.delete(id);
return { message: 'Assistante maternelle supprimée' };
@@ -0,0 +1,126 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import {
IsBoolean,
IsDateString,
IsEmail,
IsEnum,
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
} from 'class-validator';
import { StatutUtilisateurType } from 'src/entities/users.entity';
/** Mise à jour fiche AM par admin/gestionnaire (doc 28 §6.1, ticket #131). */
export class UpdateAmFicheAdminDto {
@ApiPropertyOptional({ example: 'MARTIN' })
@IsOptional()
@IsString()
@MaxLength(100)
nom?: string;
@ApiPropertyOptional({ example: 'Claire' })
@IsOptional()
@IsString()
@MaxLength(100)
prenom?: string;
@ApiPropertyOptional({ example: 'claire@example.com' })
@IsOptional()
@IsEmail()
email?: string;
@ApiPropertyOptional({ example: '0612345678' })
@IsOptional()
@IsString()
@MaxLength(20)
telephone?: string;
@ApiPropertyOptional({ example: '5 place Bellecour' })
@IsOptional()
@IsString()
adresse?: string;
@ApiPropertyOptional({ example: 'Lyon' })
@IsOptional()
@IsString()
@MaxLength(150)
ville?: string;
@ApiPropertyOptional({ example: '69002' })
@IsOptional()
@IsString()
@MaxLength(10)
code_postal?: string;
@ApiPropertyOptional({ enum: StatutUtilisateurType })
@IsOptional()
@IsEnum(StatutUtilisateurType)
statut?: StatutUtilisateurType;
@ApiPropertyOptional({ example: '123456789012345' })
@IsOptional()
@IsString()
@MaxLength(15)
nir?: string;
@ApiPropertyOptional({ example: '1985-03-12' })
@IsOptional()
@IsDateString()
date_naissance?: string;
@ApiPropertyOptional({ example: 'Lyon' })
@IsOptional()
@IsString()
@MaxLength(100)
lieu_naissance_ville?: string;
@ApiPropertyOptional({ example: 'France' })
@IsOptional()
@IsString()
@MaxLength(100)
lieu_naissance_pays?: string;
@ApiPropertyOptional({ example: 'AGR-2024-12345' })
@IsOptional()
@IsString()
@MaxLength(50)
approval_number?: string;
@ApiPropertyOptional({ example: '2020-01-15' })
@IsOptional()
@IsDateString()
agreement_date?: string;
@ApiPropertyOptional({ example: 'Lyon' })
@IsOptional()
@IsString()
@MaxLength(100)
residence_city?: string;
@ApiPropertyOptional({ example: 4 })
@IsOptional()
@IsInt()
@Min(1)
@Max(10)
max_children?: number;
@ApiPropertyOptional({ example: 2 })
@IsOptional()
@IsInt()
@Min(0)
@Max(10)
places_available?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
biography?: string;
@ApiPropertyOptional({ example: true })
@IsOptional()
@IsBoolean()
available?: boolean;
}
+1 -1
View File
@@ -176,7 +176,7 @@ describe('AuthService (#118 create-password API)', () => {
parentsServiceMock.getDossierFamilleByNumero.mockResolvedValue({
numero_dossier: '2026-000021',
parents: [{ user_id: 'p1', email: 'claire@test.fr', statut: StatutUtilisateurType.REFUSE }],
enfants: [{ id: 'e1', first_name: 'Emma', status: 'actif' }],
enfants: [{ id: 'e1', first_name: 'Emma', status: 'sans_garde' }],
texte_motivation: 'Motivation test',
});
+2 -2
View File
@@ -551,7 +551,7 @@ export class AuthService {
? new Date(enfantDto.date_previsionnelle_naissance)
: undefined;
enfant.photo_url = urlPhoto || undefined;
enfant.status = enfantDto.date_naissance ? StatutEnfantType.ACTIF : StatutEnfantType.A_NAITRE;
enfant.status = enfantDto.date_naissance ? StatutEnfantType.SANS_GARDE : StatutEnfantType.A_NAITRE;
enfant.consent_photo = false;
enfant.is_multiple = enfantDto.grossesse_multiple || false;
@@ -1063,7 +1063,7 @@ export class AuthService {
if (enfantDto.genre !== undefined) enfant.gender = enfantDto.genre;
if (enfantDto.date_naissance !== undefined) {
enfant.birth_date = new Date(enfantDto.date_naissance);
enfant.status = StatutEnfantType.ACTIF;
enfant.status = StatutEnfantType.SANS_GARDE;
}
if (enfantDto.date_previsionnelle_naissance !== undefined) {
enfant.due_date = new Date(enfantDto.date_previsionnelle_naissance);
@@ -12,7 +12,7 @@ import {
import { GenreType, StatutEnfantType } from 'src/entities/children.entity';
export class CreateEnfantsDto {
@ApiProperty({ enum: StatutEnfantType, example: StatutEnfantType.ACTIF })
@ApiProperty({ enum: StatutEnfantType, example: StatutEnfantType.SANS_GARDE })
@IsEnum(StatutEnfantType)
@IsNotEmpty()
status: StatutEnfantType;
@@ -83,7 +83,12 @@ export class EnfantsController {
return this.enfantsService.findOne(id, currentUser);
}
@Roles(RoleType.ADMINISTRATEUR, RoleType.SUPER_ADMIN, RoleType.PARENT)
@Roles(
RoleType.PARENT,
RoleType.ADMINISTRATEUR,
RoleType.SUPER_ADMIN,
RoleType.GESTIONNAIRE,
)
@Patch(':id')
update(
@Param('id', new ParseUUIDPipe()) id: string,
@@ -34,7 +34,7 @@ export class EnfantsService {
// Vérif métier simple
if (dto.status !== StatutEnfantType.A_NAITRE && !dto.birth_date) {
throw new BadRequestException('Un enfant actif doit avoir une date de naissance');
throw new BadRequestException('Un enfant doit avoir une date de naissance');
}
// Vérif doublon éventuel (ex: même prénom + date de naissance pour ce parent)
@@ -78,10 +78,10 @@ export class EnfantsService {
return this.findOne(child.id, currentUser);
}
// Liste des enfants
// Liste des enfants (admin/gestionnaire)
async findAll(): Promise<Children[]> {
return this.childrenRepository.find({
relations: ['parentLinks'],
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'],
order: { last_name: 'ASC', first_name: 'ASC' },
});
}
@@ -0,0 +1,57 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import {
IsEmail,
IsEnum,
IsOptional,
IsString,
MaxLength,
} from 'class-validator';
import { StatutUtilisateurType } from 'src/entities/users.entity';
/** Mise à jour fiche parent par admin/gestionnaire (doc 28 §6.1, ticket #131). */
export class UpdateParentFicheAdminDto {
@ApiPropertyOptional({ example: 'Dupont' })
@IsOptional()
@IsString()
@MaxLength(100)
nom?: string;
@ApiPropertyOptional({ example: 'Marie' })
@IsOptional()
@IsString()
@MaxLength(100)
prenom?: string;
@ApiPropertyOptional({ example: 'marie.dupont@example.com' })
@IsOptional()
@IsEmail()
email?: string;
@ApiPropertyOptional({ example: '+33612345678' })
@IsOptional()
@IsString()
@MaxLength(20)
telephone?: string;
@ApiPropertyOptional({ example: '10 rue de la Paix' })
@IsOptional()
@IsString()
adresse?: string;
@ApiPropertyOptional({ example: 'Paris' })
@IsOptional()
@IsString()
@MaxLength(150)
ville?: string;
@ApiPropertyOptional({ example: '75001' })
@IsOptional()
@IsString()
@MaxLength(10)
code_postal?: string;
@ApiPropertyOptional({ enum: StatutUtilisateurType })
@IsOptional()
@IsEnum(StatutUtilisateurType)
statut?: StatutUtilisateurType;
}
@@ -1,6 +1,7 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
@@ -16,11 +17,13 @@ import { RoleType, StatutUtilisateurType } from 'src/entities/users.entity';
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { CreateParentDto } from '../user/dto/create_parent.dto';
import { UpdateParentsDto } from '../user/dto/update_parent.dto';
import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto';
import { AuthGuard } from 'src/common/guards/auth.guard';
import { RolesGuard } from 'src/common/guards/roles.guard';
import { User } from 'src/common/decorators/user.decorator';
import { PendingFamilyDto } from './dto/pending-family.dto';
import { DossierFamilleCompletDto } from './dto/dossier-famille-complet.dto';
import { mapParentForApi, mapParentsForApi } from './parents.mapper';
@ApiTags('Parents')
@Controller('parents')
@@ -79,21 +82,25 @@ export class ParentsController {
return validated;
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@Get()
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Liste des parents (user, co_parent, parentChildren) — ticket #131' })
@ApiResponse({ status: 200, type: [Parents], description: 'Liste des parents' })
@ApiResponse({ status: 403, description: 'Accès refusé !' })
getAll(): Promise<Parents[]> {
return this.parentsService.findAll();
async getAll(): Promise<Parents[]> {
const parents = await this.parentsService.findAll();
return mapParentsForApi(parents);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@Get(':id')
@ApiOperation({ summary: 'Détail parent par user_id (inclut co_parent si id_co_parent renseigné) — ticket #131' })
@ApiResponse({ status: 200, type: Parents, description: 'Détails du parent par ID utilisateur' })
@ApiResponse({ status: 404, description: 'Parent non trouvé' })
@ApiResponse({ status: 403, description: 'Accès refusé !' })
getOne(@Param('id') user_id: string): Promise<Parents> {
return this.parentsService.findOne(user_id);
async getOne(@Param('id') user_id: string): Promise<Parents> {
const parent = await this.parentsService.findOne(user_id);
return mapParentForApi(parent);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
@@ -101,8 +108,51 @@ export class ParentsController {
@ApiBody({ type: CreateParentDto })
@ApiResponse({ status: 201, type: Parents, description: 'Parent créé avec succès' })
@ApiResponse({ status: 403, description: 'Accès refusé !' })
create(@Body() dto: CreateParentDto): Promise<Parents> {
return this.parentsService.create(dto);
async create(@Body() dto: CreateParentDto): Promise<Parents> {
const parent = await this.parentsService.create(dto);
return mapParentForApi(parent);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@Patch(':id/fiche')
@ApiOperation({ summary: 'Mettre à jour la fiche parent (admin/gestionnaire) — ticket #131' })
@ApiParam({ name: 'id', description: "UUID utilisateur du parent" })
@ApiBody({ type: UpdateParentFicheAdminDto })
@ApiResponse({ status: 200, type: Parents, description: 'Fiche parent mise à jour' })
async updateFicheAdmin(
@Param('id') id: string,
@Body() dto: UpdateParentFicheAdminDto,
): Promise<Parents> {
const parent = await this.parentsService.updateFicheAdmin(id, dto);
return mapParentForApi(parent);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@Post(':id/enfants/:enfantId')
@ApiOperation({ summary: 'Rattacher un enfant à un parent — ticket #115' })
@ApiParam({ name: 'id', description: "UUID utilisateur du parent" })
@ApiParam({ name: 'enfantId', description: "UUID de l'enfant" })
@ApiResponse({ status: 200, type: Parents, description: 'Parent avec enfants mis à jour' })
async attachEnfant(
@Param('id') id: string,
@Param('enfantId') enfantId: string,
): Promise<Parents> {
const parent = await this.parentsService.attachEnfant(id, enfantId);
return mapParentForApi(parent);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@Delete(':id/enfants/:enfantId')
@ApiOperation({ summary: "Détacher un enfant d'un parent — ticket #115" })
@ApiParam({ name: 'id', description: "UUID utilisateur du parent" })
@ApiParam({ name: 'enfantId', description: "UUID de l'enfant" })
@ApiResponse({ status: 200, type: Parents, description: 'Parent avec enfants mis à jour' })
async detachEnfant(
@Param('id') id: string,
@Param('enfantId') enfantId: string,
): Promise<Parents> {
const parent = await this.parentsService.detachEnfant(id, enfantId);
return mapParentForApi(parent);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
@@ -111,7 +161,8 @@ export class ParentsController {
@ApiResponse({ status: 200, type: Parents, description: 'Parent mis à jour avec succès' })
@ApiResponse({ status: 404, description: 'Parent introuvable' })
@ApiResponse({ status: 403, description: 'Accès refusé !' })
update(@Param('id') id: string, @Body() dto: UpdateParentsDto): Promise<Parents> {
return this.parentsService.update(id, dto);
async update(@Param('id') id: string, @Body() dto: UpdateParentsDto): Promise<Parents> {
const parent = await this.parentsService.update(id, dto);
return mapParentForApi(parent);
}
}
@@ -0,0 +1,38 @@
import { mapParentForApi } from './parents.mapper';
import { Parents } from '../../entities/parents.entity';
import { RoleType, StatutUtilisateurType, Users } from '../../entities/users.entity';
describe('mapParentForApi', () => {
it('expose co_parent avec prenom/nom sans secrets', () => {
const coParent = {
id: 'cp1',
email: 'co@b.fr',
prenom: 'Clara',
nom: 'Co',
role: RoleType.PARENT,
statut: StatutUtilisateurType.ACTIF,
password: 'secret',
} as Users;
const parent = {
user_id: 'u1',
numero_dossier: '2026-000042',
user: {
id: 'u1',
email: 'p@b.fr',
prenom: 'Paul',
nom: 'Parent',
role: RoleType.PARENT,
password: 'secret',
} as Users,
co_parent: coParent,
parentChildren: [],
} as Parents;
const out = mapParentForApi(parent);
expect(out.co_parent?.prenom).toBe('Clara');
expect(out.co_parent?.nom).toBe('Co');
expect(out.co_parent?.password).toBeUndefined();
expect(out.user.password).toBeUndefined();
});
});
@@ -0,0 +1,18 @@
import { Parents } from 'src/entities/parents.entity';
import { sanitizeUserForApi } from '../../common/utils/sanitize-user-for-api';
/**
* Sérialisation API fiche parent — ticket #131.
* Garantit `user`, `co_parent` (si présent) et relations sans champs sensibles.
*/
export function mapParentForApi(parent: Parents): Parents {
return {
...parent,
user: sanitizeUserForApi(parent.user)!,
co_parent: sanitizeUserForApi(parent.co_parent),
};
}
export function mapParentsForApi(parents: Parents[]): Parents[] {
return parents.map(mapParentForApi);
}
+2 -1
View File
@@ -4,6 +4,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { Parents } from 'src/entities/parents.entity';
import { DossierFamille, DossierFamilleEnfant } from 'src/entities/dossier_famille.entity';
import { ParentsChildren } from 'src/entities/parents_children.entity';
import { ParentsController } from './parents.controller';
import { ParentsService } from './parents.service';
import { Users } from 'src/entities/users.entity';
@@ -11,7 +12,7 @@ import { UserModule } from '../user/user.module';
@Module({
imports: [
TypeOrmModule.forFeature([Parents, Users, DossierFamille, DossierFamilleEnfant]),
TypeOrmModule.forFeature([Parents, Users, DossierFamille, DossierFamilleEnfant, ParentsChildren]),
forwardRef(() => UserModule),
JwtModule.registerAsync({
imports: [ConfigModule],
+79 -2
View File
@@ -17,7 +17,9 @@ import {
DossierFamilleParentDto,
DossierFamilleEnfantDto,
} from './dto/dossier-famille-complet.dto';
import { ParentsChildren } from 'src/entities/parents_children.entity';
import { Children } from 'src/entities/children.entity';
import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto';
@Injectable()
export class ParentsService {
@@ -28,6 +30,8 @@ export class ParentsService {
private readonly usersRepository: Repository<Users>,
@InjectRepository(DossierFamille)
private readonly dossierFamilleRepository: Repository<DossierFamille>,
@InjectRepository(ParentsChildren)
private readonly parentsChildrenRepository: Repository<ParentsChildren>,
) {}
// Création dun parent
@@ -62,7 +66,7 @@ export class ParentsService {
// Liste des parents
async findAll(): Promise<Parents[]> {
return this.parentsRepository.find({
relations: ['user', 'co_parent', 'parentChildren', 'dossiers'],
relations: ['user', 'co_parent', 'parentChildren', 'parentChildren.child', 'dossiers'],
});
}
@@ -70,7 +74,7 @@ export class ParentsService {
async findOne(user_id: string): Promise<Parents> {
const parent = await this.parentsRepository.findOne({
where: { user_id },
relations: ['user', 'co_parent', 'parentChildren', 'dossiers'],
relations: ['user', 'co_parent', 'parentChildren', 'parentChildren.child', 'dossiers'],
});
if (!parent) throw new NotFoundException('Parent introuvable');
return parent;
@@ -82,6 +86,79 @@ export class ParentsService {
return this.findOne(id);
}
/**
* Mise à jour fiche parent (champs user + statut) par admin/gestionnaire. Ticket #131 / doc 28 §6.1.
*/
async updateFicheAdmin(parentUserId: string, dto: UpdateParentFicheAdminDto): Promise<Parents> {
const parent = await this.findOne(parentUserId);
const user = parent.user;
if (dto.email && dto.email !== user.email) {
const existing = await this.usersRepository.findOne({ where: { email: dto.email } });
if (existing && existing.id !== user.id) {
throw new ConflictException('Cet email est déjà utilisé');
}
user.email = dto.email;
}
if (dto.nom !== undefined) user.nom = dto.nom;
if (dto.prenom !== undefined) user.prenom = dto.prenom;
if (dto.telephone !== undefined) user.telephone = dto.telephone;
if (dto.adresse !== undefined) user.adresse = dto.adresse;
if (dto.ville !== undefined) user.ville = dto.ville;
if (dto.code_postal !== undefined) user.code_postal = dto.code_postal;
if (dto.statut !== undefined) user.statut = dto.statut;
await this.usersRepository.save(user);
return this.findOne(parentUserId);
}
/**
* Rattacher un enfant existant à un parent (enfants_parents). Ticket #115 / doc 28 §6.2.
*/
async attachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
await this.findOne(parentUserId);
const existing = await this.parentsChildrenRepository.findOne({
where: { parentId: parentUserId, enfantId },
});
if (existing) {
throw new ConflictException('Cet enfant est déjà rattaché à ce parent');
}
const child = await this.parentsRepository.manager.findOne(Children, { where: { id: enfantId } });
if (!child) {
throw new NotFoundException('Enfant introuvable');
}
await this.parentsChildrenRepository.save(
this.parentsChildrenRepository.create({ parentId: parentUserId, enfantId }),
);
return this.findOne(parentUserId);
}
/**
* Détacher un enfant d'un parent sans supprimer l'enfant. Ticket #115 / doc 28 §6.2.
*/
async detachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
await this.findOne(parentUserId);
const link = await this.parentsChildrenRepository.findOne({
where: { parentId: parentUserId, enfantId },
});
if (!link) {
throw new NotFoundException('Lien parent-enfant introuvable');
}
const totalLinks = await this.parentsChildrenRepository.count({ where: { enfantId } });
if (totalLinks <= 1) {
throw new BadRequestException('Un enfant doit rester rattaché à au moins un responsable');
}
await this.parentsChildrenRepository.delete({ parentId: parentUserId, enfantId });
return this.findOne(parentUserId);
}
/**
* Liste des familles en attente (une entrée par famille).
* Famille = lien co_parent ou partage d'enfants (même logique que backfill #103).