feat: alignement master sur develop (squash)
- Dossiers unifiés #119, pending-families enrichi, validation admin (wizards) - Front: modèles dossier_unifie / pending_family, NIR, auth - Migrations dossier_famille, scripts de test API - Résolution conflits: parents.*, docs tickets, auth_service, nir_utils Made-with: Cursor
This commit is contained in:
@@ -19,7 +19,8 @@
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"test:api-dossiers": "node scripts/test-api-dossiers.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.1.6",
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Test API GET /dossiers/:numeroDossier (dossier unifié AM ou famille).
|
||||
*
|
||||
* Prérequis : backend démarré (npm run start:dev dans backend/).
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/test-api-dossiers.js
|
||||
* NUMERO_DOSSIER=2026-000001 node scripts/test-api-dossiers.js
|
||||
* BASE_URL=https://app.ptits-pas.fr/api/v1 TEST_EMAIL=xxx TEST_PASSWORD=yyy NUMERO_DOSSIER=2026-000001 node scripts/test-api-dossiers.js
|
||||
*
|
||||
* Sans TEST_EMAIL/TEST_PASSWORD : 401 sur les routes protégées.
|
||||
* NUMERO_DOSSIER : optionnel ; si absent, utilise le premier numero_dossier de pending-families (avec token).
|
||||
*/
|
||||
|
||||
const BASE_URL = process.env.BASE_URL || 'http://localhost:3000/api/v1';
|
||||
const TEST_EMAIL = process.env.TEST_EMAIL;
|
||||
const TEST_PASSWORD = process.env.TEST_PASSWORD;
|
||||
const NUMERO_DOSSIER = process.env.NUMERO_DOSSIER;
|
||||
|
||||
async function request(method, path, body = null, token = null) {
|
||||
const url = path.startsWith('http') ? path : `${BASE_URL}${path}`;
|
||||
const opts = {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
};
|
||||
if (token) opts.headers.Authorization = `Bearer ${token}`;
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
const res = await fetch(url, opts);
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch (_) {
|
||||
data = text;
|
||||
}
|
||||
return { status: res.status, data };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Base URL:', BASE_URL);
|
||||
console.log('Numéro dossier (env):', NUMERO_DOSSIER ?? '(sera déduit si token fourni)');
|
||||
console.log('');
|
||||
|
||||
let token = null;
|
||||
if (TEST_EMAIL && TEST_PASSWORD) {
|
||||
console.log('1. Login...');
|
||||
const loginRes = await request('POST', '/auth/login', {
|
||||
email: TEST_EMAIL,
|
||||
password: TEST_PASSWORD,
|
||||
});
|
||||
if (loginRes.status !== 200 && loginRes.status !== 201) {
|
||||
console.log(' Échec login:', loginRes.status, loginRes.data);
|
||||
process.exit(1);
|
||||
}
|
||||
token = loginRes.data?.access_token ?? loginRes.data?.accessToken ?? null;
|
||||
if (!token) {
|
||||
console.log(' Réponse login sans token:', JSON.stringify(loginRes.data, null, 2));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(' OK, token reçu.');
|
||||
console.log('');
|
||||
} else {
|
||||
console.log('TEST_EMAIL / TEST_PASSWORD non définis : GET /dossiers/:numero nécessite un token (401 attendu).');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
let numeroDossier = NUMERO_DOSSIER;
|
||||
if (!numeroDossier && token) {
|
||||
console.log('2. Récupération d\'un numéro de dossier (GET /parents/pending-families)...');
|
||||
const pendingRes = await request('GET', '/parents/pending-families', null, token);
|
||||
if (pendingRes.status === 200 && Array.isArray(pendingRes.data) && pendingRes.data.length > 0) {
|
||||
numeroDossier = pendingRes.data[0].numero_dossier || null;
|
||||
console.log(' Premier numero_dossier:', numeroDossier);
|
||||
} else {
|
||||
console.log(' Aucune famille en attente ou erreur. Utilisez NUMERO_DOSSIER=2026-000001');
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
if (!numeroDossier) {
|
||||
numeroDossier = '2026-000001';
|
||||
console.log('2. Pas de numéro fourni, test avec numéro par défaut:', numeroDossier);
|
||||
} else {
|
||||
console.log('2. GET /dossiers/' + encodeURIComponent(numeroDossier));
|
||||
}
|
||||
|
||||
const dossierRes = await request(
|
||||
'GET',
|
||||
'/dossiers/' + encodeURIComponent(numeroDossier),
|
||||
null,
|
||||
token
|
||||
);
|
||||
|
||||
console.log(' Status:', dossierRes.status);
|
||||
if (dossierRes.status === 200 && dossierRes.data) {
|
||||
const d = dossierRes.data;
|
||||
console.log(' type:', d.type);
|
||||
console.log(' dossier (clés):', d.dossier ? Object.keys(d.dossier) : '-');
|
||||
if (d.dossier && Array.isArray(d.dossier.enfants)) {
|
||||
console.log(' enfants:', d.dossier.enfants.length);
|
||||
d.dossier.enfants.forEach((e, i) => {
|
||||
console.log(
|
||||
` [${i + 1}] id=${e.id} first_name=${e.first_name} last_name=${e.last_name} birth_date=${e.birth_date} gender=${e.gender} genre=${e.genre} status=${e.status}`
|
||||
);
|
||||
});
|
||||
}
|
||||
console.log('');
|
||||
console.log('Réponse brute (dossier):');
|
||||
console.log(JSON.stringify(d.dossier, null, 2));
|
||||
} else {
|
||||
console.log(' Réponse:', JSON.stringify(dossierRes.data, null, 2));
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('Fin du test.');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Erreur:', err.message || err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Test des endpoints "comptes en attente" (ticket #107).
|
||||
*
|
||||
* Prérequis : backend démarré (npm run start:dev dans backend/).
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/test-pending-api.js
|
||||
* TEST_EMAIL=xxx TEST_PASSWORD=yyy node scripts/test-pending-api.js
|
||||
* BASE_URL=https://app.ptits-pas.fr/api/v1 TEST_EMAIL=xxx TEST_PASSWORD=yyy node scripts/test-pending-api.js
|
||||
*
|
||||
* Sans TEST_EMAIL/TEST_PASSWORD : les GET protégés renverront 401 (normal).
|
||||
* Avec un compte gestionnaire ou admin : affiche les listes en attente.
|
||||
*/
|
||||
|
||||
const BASE_URL = process.env.BASE_URL || 'http://localhost:3000/api/v1';
|
||||
const TEST_EMAIL = process.env.TEST_EMAIL;
|
||||
const TEST_PASSWORD = process.env.TEST_PASSWORD;
|
||||
|
||||
async function request(method, path, body = null, token = null) {
|
||||
const url = path.startsWith('http') ? path : `${BASE_URL}${path}`;
|
||||
const opts = {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
};
|
||||
if (token) opts.headers.Authorization = `Bearer ${token}`;
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
const res = await fetch(url, opts);
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch (_) {
|
||||
data = text;
|
||||
}
|
||||
return { status: res.status, data };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Base URL:', BASE_URL);
|
||||
console.log('');
|
||||
|
||||
let token = null;
|
||||
if (TEST_EMAIL && TEST_PASSWORD) {
|
||||
console.log('1. Login...');
|
||||
const loginRes = await request('POST', '/auth/login', {
|
||||
email: TEST_EMAIL,
|
||||
password: TEST_PASSWORD,
|
||||
});
|
||||
if (loginRes.status !== 200 && loginRes.status !== 201) {
|
||||
console.log(' Échec login:', loginRes.status, loginRes.data);
|
||||
process.exit(1);
|
||||
}
|
||||
token = loginRes.data?.access_token ?? loginRes.data?.accessToken ?? null;
|
||||
if (!token) {
|
||||
console.log(' Réponse login sans token:', JSON.stringify(loginRes.data, null, 2));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(' OK, token reçu.');
|
||||
console.log('');
|
||||
} else {
|
||||
console.log('TEST_EMAIL / TEST_PASSWORD non définis : les appels protégés vont renvoyer 401.');
|
||||
console.log('Exemple: TEST_EMAIL=admin@example.com TEST_PASSWORD=xxx node scripts/test-pending-api.js');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
console.log('2. GET /users/pending?role=assistante_maternelle');
|
||||
const pendingUsersRes = await request(
|
||||
'GET',
|
||||
'/users/pending?role=assistante_maternelle',
|
||||
null,
|
||||
token
|
||||
);
|
||||
console.log(' Status:', pendingUsersRes.status);
|
||||
if (pendingUsersRes.status === 200) {
|
||||
const list = Array.isArray(pendingUsersRes.data) ? pendingUsersRes.data : [];
|
||||
console.log(' Nombre d\'utilisateurs en attente (AM):', list.length);
|
||||
list.forEach((u, i) => {
|
||||
console.log(
|
||||
` [${i + 1}] id=${u.id} email=${u.email} role=${u.role} statut=${u.statut} numero_dossier=${u.numero_dossier ?? '-'}`
|
||||
);
|
||||
});
|
||||
} else {
|
||||
console.log(' Réponse:', JSON.stringify(pendingUsersRes.data, null, 2));
|
||||
}
|
||||
console.log('');
|
||||
|
||||
console.log('3. GET /parents/pending-families');
|
||||
const pendingFamiliesRes = await request('GET', '/parents/pending-families', null, token);
|
||||
console.log(' Status:', pendingFamiliesRes.status);
|
||||
if (pendingFamiliesRes.status === 200) {
|
||||
const list = Array.isArray(pendingFamiliesRes.data) ? pendingFamiliesRes.data : [];
|
||||
console.log(' Nombre de familles en attente:', list.length);
|
||||
list.forEach((f, i) => {
|
||||
console.log(
|
||||
` [${i + 1}] libelle=${f.libelle} parentIds=${JSON.stringify(f.parentIds)} numero_dossier=${f.numero_dossier ?? '-'}`
|
||||
);
|
||||
});
|
||||
} else {
|
||||
console.log(' Réponse:', JSON.stringify(pendingFamiliesRes.data, null, 2));
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('Fin du test.');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Erreur:', err.message || err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Met à jour l'issue Gitea #119 : endpoint unifié GET /dossiers/:numeroDossier (option A)
|
||||
* Usage: node backend/scripts/update-gitea-issue-119-dossiers.js
|
||||
* Token : .gitea-token (racine), GITEA_TOKEN, ou docs/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/BRIEFING-FRONTEND.md'), 'utf8');
|
||||
const m = briefing.match(/Token:\s*(giteabu_[a-f0-9]+)/);
|
||||
if (m) token = m[1].trim();
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!token) {
|
||||
console.error('Token non trouvé');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const body = `## Besoin
|
||||
|
||||
Un **seul** endpoint **GET par numéro de dossier** qui renvoie le dossier complet, **AM ou famille** selon le numéro. Clé unique = numéro de dossier (usage : modale de validation, consultation gestionnaire, reprise, etc.).
|
||||
|
||||
**Option A – Endpoint unifié**
|
||||
|
||||
- **Route** : \`GET /api/v1/dossiers/:numeroDossier\` (ou \`GET /dossiers/:numeroDossier\` selon préfixe API).
|
||||
- Le backend détermine si le numéro appartient à une **AM** ou à une **famille** (ex. lookup \`users\` / \`parents\` / \`assistantes_maternelles\`).
|
||||
- **Réponse** avec discriminent :
|
||||
- \`{ type: 'family', dossier: { numero_dossier, parents, enfants, presentation } }\`
|
||||
- \`{ type: 'am', dossier: { numero_dossier, user, ... } }\` (fiche AM complète, champs utiles sans secrets)
|
||||
- **Rôles** : SUPER_ADMIN, ADMINISTRATEUR, GESTIONNAIRE.
|
||||
- **Réponses** : 200 (dossier), 403, 404 (numéro inconnu).
|
||||
|
||||
Aucun filtre par statut : on renvoie le dossier s'il existe ; le front affiche Valider/Refuser selon le statut.
|
||||
|
||||
**Labels suggérés** : backend, api, dossiers, gestionnaire
|
||||
|
||||
---
|
||||
|
||||
## Implémentation
|
||||
|
||||
- **Nouveau module ou route** : \`GET /dossiers/:numeroDossier\`.
|
||||
- **Service** : trouver qui possède ce \`numero_dossier\` (famille → \`parents\`, AM → \`users\` + \`assistantes_maternelles\`). Appeler la logique existante dossier-famille ou construire le payload AM, puis retourner \`{ type, dossier }\`.
|
||||
- **Réutiliser** : la logique actuelle \`GET /parents/dossier-famille/:numeroDossier\` peut être appelée en interne pour \`type: 'family'\` ; ajouter une branche \`type: 'am'\` avec un DTO « dossier AM complet ».
|
||||
- DTO(s) : garder \`DossierFamilleCompletDto\` pour la famille ; ajouter un DTO pour le dossier AM (user sans secrets + infos AM). Réponse unifiée : \`{ type: 'am' | 'family', dossier: ... }\`.`;
|
||||
|
||||
const payload = JSON.stringify({
|
||||
title: 'Endpoint unifié GET /dossiers/:numeroDossier (AM ou famille)',
|
||||
body,
|
||||
});
|
||||
|
||||
const opts = {
|
||||
hostname: 'git.ptits-pas.fr',
|
||||
path: '/api/v1/repos/jmartin/petitspas/issues/119',
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: 'token ' + token,
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(payload),
|
||||
},
|
||||
};
|
||||
|
||||
const req = https.request(opts, (res) => {
|
||||
let d = '';
|
||||
res.on('data', (c) => (d += c));
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const o = JSON.parse(d);
|
||||
if (o.number || o.id) {
|
||||
console.log('Issue #119 mise à jour.');
|
||||
console.log('URL:', o.html_url || 'https://git.ptits-pas.fr/jmartin/petitspas/issues/119');
|
||||
} else {
|
||||
console.error('Erreur API:', o.message || d);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Réponse:', d);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', (e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
req.write(payload);
|
||||
req.end();
|
||||
@@ -17,6 +17,7 @@ import { EnfantsModule } from './routes/enfants/enfants.module';
|
||||
import { AppConfigModule } from './modules/config/config.module';
|
||||
import { DocumentsLegauxModule } from './modules/documents-legaux';
|
||||
import { RelaisModule } from './routes/relais/relais.module';
|
||||
import { DossiersModule } from './routes/dossiers/dossiers.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -55,6 +56,7 @@ import { RelaisModule } from './routes/relais/relais.module';
|
||||
AppConfigModule,
|
||||
DocumentsLegauxModule,
|
||||
RelaisModule,
|
||||
DossiersModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Parents } from './parents.entity';
|
||||
import { Children } from './children.entity';
|
||||
import { StatutDossierType } from './dossiers.entity';
|
||||
|
||||
/** Un dossier = une famille, N enfants (texte de motivation unique, liste d'enfants). */
|
||||
@Entity('dossier_famille')
|
||||
export class DossierFamille {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'numero_dossier', length: 20 })
|
||||
numero_dossier: string;
|
||||
|
||||
@ManyToOne(() => Parents, { onDelete: 'CASCADE', nullable: false })
|
||||
@JoinColumn({ name: 'id_parent', referencedColumnName: 'user_id' })
|
||||
parent: Parents;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
presentation?: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: StatutDossierType,
|
||||
enumName: 'statut_dossier_type',
|
||||
default: StatutDossierType.ENVOYE,
|
||||
name: 'statut',
|
||||
})
|
||||
statut: StatutDossierType;
|
||||
|
||||
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
|
||||
cree_le: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'modifie_le', type: 'timestamptz' })
|
||||
modifie_le: Date;
|
||||
|
||||
@OneToMany(() => DossierFamilleEnfant, (dfe) => dfe.dossier_famille)
|
||||
enfants: DossierFamilleEnfant[];
|
||||
}
|
||||
|
||||
@Entity('dossier_famille_enfants')
|
||||
export class DossierFamilleEnfant {
|
||||
@Column({ name: 'id_dossier_famille', primary: true })
|
||||
id_dossier_famille: string;
|
||||
|
||||
@Column({ name: 'id_enfant', primary: true })
|
||||
id_enfant: string;
|
||||
|
||||
@ManyToOne(() => DossierFamille, (df) => df.enfants, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'id_dossier_famille' })
|
||||
dossier_famille: DossierFamille;
|
||||
|
||||
@ManyToOne(() => Children, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'id_enfant' })
|
||||
enfant: Children;
|
||||
}
|
||||
@@ -43,6 +43,8 @@ export class AuthService {
|
||||
private readonly usersRepo: Repository<Users>,
|
||||
@InjectRepository(Children)
|
||||
private readonly childrenRepo: Repository<Children>,
|
||||
@InjectRepository(AssistanteMaternelle)
|
||||
private readonly assistantesMaternellesRepo: Repository<AssistanteMaternelle>,
|
||||
) { }
|
||||
|
||||
/**
|
||||
@@ -189,6 +191,11 @@ export class AuthService {
|
||||
}
|
||||
|
||||
if (dto.co_parent_email) {
|
||||
if (dto.email.trim().toLowerCase() === dto.co_parent_email.trim().toLowerCase()) {
|
||||
throw new BadRequestException(
|
||||
'L\'email du parent et du co-parent doivent être différents.',
|
||||
);
|
||||
}
|
||||
const coParentExiste = await this.usersService.findByEmailOrNull(dto.co_parent_email);
|
||||
if (coParentExiste) {
|
||||
throw new ConflictException('L\'email du co-parent est déjà utilisé');
|
||||
@@ -360,6 +367,27 @@ export class AuthService {
|
||||
throw new ConflictException('Un compte avec cet email existe déjà');
|
||||
}
|
||||
|
||||
const nirDejaUtilise = await this.assistantesMaternellesRepo.findOne({
|
||||
where: { nir: nirNormalized },
|
||||
});
|
||||
if (nirDejaUtilise) {
|
||||
throw new ConflictException(
|
||||
'Un compte assistante maternelle avec ce numéro NIR existe déjà.',
|
||||
);
|
||||
}
|
||||
|
||||
const numeroAgrement = (dto.numero_agrement || '').trim();
|
||||
if (numeroAgrement) {
|
||||
const agrementDejaUtilise = await this.assistantesMaternellesRepo.findOne({
|
||||
where: { approval_number: numeroAgrement },
|
||||
});
|
||||
if (agrementDejaUtilise) {
|
||||
throw new ConflictException(
|
||||
'Un compte assistante maternelle avec ce numéro d\'agrément existe déjà.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const joursExpirationToken = await this.appConfigService.get<number>(
|
||||
'password_reset_token_expiry_days',
|
||||
7,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
||||
import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { Roles } from 'src/common/decorators/roles.decorator';
|
||||
import { RoleType } from 'src/entities/users.entity';
|
||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||
import { DossiersService } from './dossiers.service';
|
||||
import { DossierUnifieDto } from './dto/dossier-unifie.dto';
|
||||
|
||||
@ApiTags('Dossiers')
|
||||
@Controller('dossiers')
|
||||
@UseGuards(AuthGuard, RolesGuard)
|
||||
export class DossiersController {
|
||||
constructor(private readonly dossiersService: DossiersService) {}
|
||||
|
||||
@Get(':numeroDossier')
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||
@ApiOperation({ summary: 'Dossier complet par numéro (AM ou famille) – Ticket #119' })
|
||||
@ApiParam({ name: 'numeroDossier', description: 'Numéro de dossier (ex: 2026-000001)' })
|
||||
@ApiResponse({ status: 200, description: 'Dossier famille ou AM', type: DossierUnifieDto })
|
||||
@ApiResponse({ status: 404, description: 'Aucun dossier pour ce numéro' })
|
||||
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
||||
getDossier(@Param('numeroDossier') numeroDossier: string): Promise<DossierUnifieDto> {
|
||||
return this.dossiersService.getDossierByNumero(numeroDossier);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,28 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { Parents } from 'src/entities/parents.entity';
|
||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||
import { ParentsModule } from '../parents/parents.module';
|
||||
import { DossiersController } from './dossiers.controller';
|
||||
import { DossiersService } from './dossiers.service';
|
||||
|
||||
@Module({})
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Parents, AssistanteMaternelle]),
|
||||
ParentsModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.get('jwt.accessSecret'),
|
||||
signOptions: { expiresIn: config.get('jwt.accessExpiresIn') },
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
],
|
||||
controllers: [DossiersController],
|
||||
providers: [DossiersService],
|
||||
exports: [DossiersService],
|
||||
})
|
||||
export class DossiersModule {}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Parents } from 'src/entities/parents.entity';
|
||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||
import { ParentsService } from '../parents/parents.service';
|
||||
import { DossierUnifieDto } from './dto/dossier-unifie.dto';
|
||||
import { DossierAmCompletDto, DossierAmUserDto } from './dto/dossier-am-complet.dto';
|
||||
|
||||
/**
|
||||
* Endpoint unifié GET /dossiers/:numeroDossier – AM ou famille. Ticket #119.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DossiersService {
|
||||
constructor(
|
||||
@InjectRepository(Parents)
|
||||
private readonly parentsRepository: Repository<Parents>,
|
||||
@InjectRepository(AssistanteMaternelle)
|
||||
private readonly amRepository: Repository<AssistanteMaternelle>,
|
||||
private readonly parentsService: ParentsService,
|
||||
) {}
|
||||
|
||||
async getDossierByNumero(numeroDossier: string): Promise<DossierUnifieDto> {
|
||||
const num = numeroDossier?.trim();
|
||||
if (!num) {
|
||||
throw new NotFoundException('Numéro de dossier requis.');
|
||||
}
|
||||
|
||||
// 1) Famille : un parent a ce numéro ?
|
||||
const parentWithNum = await this.parentsRepository.findOne({
|
||||
where: { numero_dossier: num },
|
||||
select: ['user_id'],
|
||||
});
|
||||
if (parentWithNum) {
|
||||
const dossier = await this.parentsService.getDossierFamilleByNumero(num);
|
||||
return { type: 'family', dossier };
|
||||
}
|
||||
|
||||
// 2) AM : une assistante maternelle a ce numéro ?
|
||||
const am = await this.amRepository.findOne({
|
||||
where: { numero_dossier: num },
|
||||
relations: ['user'],
|
||||
});
|
||||
if (am?.user) {
|
||||
const dossier: DossierAmCompletDto = {
|
||||
numero_dossier: num,
|
||||
user: this.toDossierAmUserDto(am.user),
|
||||
numero_agrement: am.approval_number,
|
||||
nir: am.nir,
|
||||
biographie: am.biography,
|
||||
disponible: am.available,
|
||||
ville_residence: am.residence_city,
|
||||
date_agrement: am.agreement_date,
|
||||
annees_experience: am.years_experience,
|
||||
specialite: am.specialty,
|
||||
nb_max_enfants: am.max_children,
|
||||
place_disponible: am.places_available,
|
||||
};
|
||||
return { type: 'am', dossier };
|
||||
}
|
||||
|
||||
throw new NotFoundException('Aucun dossier trouvé pour ce numéro.');
|
||||
}
|
||||
|
||||
private toDossierAmUserDto(user: { id: string; email: string; prenom?: string; nom?: string; telephone?: string; adresse?: string; ville?: string; code_postal?: string; profession?: string; date_naissance?: Date; photo_url?: string; statut: any }): DossierAmUserDto {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
prenom: user.prenom,
|
||||
nom: user.nom,
|
||||
telephone: user.telephone,
|
||||
adresse: user.adresse,
|
||||
ville: user.ville,
|
||||
code_postal: user.code_postal,
|
||||
profession: user.profession,
|
||||
date_naissance: user.date_naissance,
|
||||
photo_url: user.photo_url,
|
||||
statut: user.statut,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||
|
||||
/** Utilisateur AM sans données sensibles (pour dossier AM complet). Ticket #119 */
|
||||
export class DossierAmUserDto {
|
||||
@ApiProperty()
|
||||
id: string;
|
||||
@ApiProperty()
|
||||
email: string;
|
||||
@ApiProperty({ required: false })
|
||||
prenom?: string;
|
||||
@ApiProperty({ required: false })
|
||||
nom?: string;
|
||||
@ApiProperty({ required: false })
|
||||
telephone?: string;
|
||||
@ApiProperty({ required: false })
|
||||
adresse?: string;
|
||||
@ApiProperty({ required: false })
|
||||
ville?: string;
|
||||
@ApiProperty({ required: false })
|
||||
code_postal?: string;
|
||||
@ApiProperty({ required: false })
|
||||
profession?: string;
|
||||
@ApiProperty({ required: false })
|
||||
date_naissance?: Date;
|
||||
@ApiProperty({ required: false })
|
||||
photo_url?: string;
|
||||
@ApiProperty({ enum: StatutUtilisateurType })
|
||||
statut: StatutUtilisateurType;
|
||||
}
|
||||
|
||||
/** Dossier AM complet (fiche AM sans secrets). Ticket #119 */
|
||||
export class DossierAmCompletDto {
|
||||
@ApiProperty({ example: '2026-000003', description: 'Numéro de dossier AM' })
|
||||
numero_dossier: string;
|
||||
@ApiProperty({ type: DossierAmUserDto, description: 'Utilisateur (sans mot de passe ni tokens)' })
|
||||
user: DossierAmUserDto;
|
||||
@ApiProperty({ required: false })
|
||||
numero_agrement?: string;
|
||||
@ApiProperty({ required: false })
|
||||
nir?: string;
|
||||
@ApiProperty({ required: false })
|
||||
biographie?: string;
|
||||
@ApiProperty({ required: false })
|
||||
disponible?: boolean;
|
||||
@ApiProperty({ required: false })
|
||||
ville_residence?: string;
|
||||
@ApiProperty({ required: false })
|
||||
date_agrement?: Date;
|
||||
@ApiProperty({ required: false })
|
||||
annees_experience?: number;
|
||||
@ApiProperty({ required: false })
|
||||
specialite?: string;
|
||||
@ApiProperty({ required: false })
|
||||
nb_max_enfants?: number;
|
||||
@ApiProperty({ required: false })
|
||||
place_disponible?: number;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { DossierFamilleCompletDto } from '../../parents/dto/dossier-famille-complet.dto';
|
||||
import { DossierAmCompletDto } from './dossier-am-complet.dto';
|
||||
|
||||
/** Réponse unifiée GET /dossiers/:numeroDossier – AM ou famille. Ticket #119 */
|
||||
export class DossierUnifieDto {
|
||||
@ApiProperty({ enum: ['family', 'am'], description: 'Type de dossier' })
|
||||
type: 'family' | 'am';
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Dossier famille (si type=family) ou dossier AM (si type=am)',
|
||||
})
|
||||
dossier: DossierFamilleCompletDto | DossierAmCompletDto;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||
import { StatutEnfantType, GenreType } from 'src/entities/children.entity';
|
||||
|
||||
/** Parent dans le dossier famille (infos utilisateur + parent) */
|
||||
export class DossierFamilleParentDto {
|
||||
@ApiProperty()
|
||||
user_id: string;
|
||||
@ApiProperty()
|
||||
email: string;
|
||||
@ApiProperty({ required: false })
|
||||
prenom?: string;
|
||||
@ApiProperty({ required: false })
|
||||
nom?: string;
|
||||
@ApiProperty({ required: false })
|
||||
telephone?: string;
|
||||
@ApiProperty({ required: false })
|
||||
adresse?: string;
|
||||
@ApiProperty({ required: false })
|
||||
ville?: string;
|
||||
@ApiProperty({ required: false })
|
||||
code_postal?: string;
|
||||
@ApiProperty({ enum: StatutUtilisateurType })
|
||||
statut: StatutUtilisateurType;
|
||||
@ApiProperty({ required: false, description: 'Id du co-parent si couple' })
|
||||
co_parent_id?: string;
|
||||
}
|
||||
|
||||
/** Enfant dans le dossier famille */
|
||||
export class DossierFamilleEnfantDto {
|
||||
@ApiProperty()
|
||||
id: string;
|
||||
@ApiProperty({ required: false })
|
||||
first_name?: string;
|
||||
@ApiProperty({ required: false })
|
||||
last_name?: string;
|
||||
@ApiProperty({ required: false, enum: GenreType })
|
||||
genre?: GenreType;
|
||||
@ApiProperty({ required: false })
|
||||
birth_date?: Date;
|
||||
@ApiProperty({ required: false })
|
||||
due_date?: Date;
|
||||
@ApiProperty({ enum: StatutEnfantType })
|
||||
status: StatutEnfantType;
|
||||
}
|
||||
|
||||
/** Réponse GET /parents/dossier-famille/:numeroDossier – dossier famille complet. Ticket #119 */
|
||||
export class DossierFamilleCompletDto {
|
||||
@ApiProperty({ example: '2026-000001', description: 'Numéro de dossier famille' })
|
||||
numero_dossier: string;
|
||||
@ApiProperty({ type: [DossierFamilleParentDto] })
|
||||
parents: DossierFamilleParentDto[];
|
||||
@ApiProperty({ type: [DossierFamilleEnfantDto], description: 'Enfants de la famille' })
|
||||
enfants: DossierFamilleEnfantDto[];
|
||||
@ApiProperty({ required: false, description: 'Texte de présentation / motivation (un seul par famille)' })
|
||||
texte_motivation?: string;
|
||||
}
|
||||
@@ -1,4 +1,21 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class ParentPendingSummaryDto {
|
||||
@ApiProperty({ description: 'UUID utilisateur' })
|
||||
id: string;
|
||||
|
||||
@ApiProperty()
|
||||
email: string;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
telephone?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
code_postal?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
ville?: string | null;
|
||||
}
|
||||
|
||||
export class PendingFamilyDto {
|
||||
@ApiProperty({ example: 'Famille Dupont', description: 'Libellé affiché pour la famille' })
|
||||
@@ -17,4 +34,30 @@ export class PendingFamilyDto {
|
||||
description: 'Numéro de dossier famille (format AAAA-NNNNNN)',
|
||||
})
|
||||
numero_dossier: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
nullable: true,
|
||||
example: '2026-01-12T10:00:00.000Z',
|
||||
description: 'Date de référence dossier soumis / en attente : MIN(cree_le) des parents en_attente du groupe (ISO 8601)',
|
||||
})
|
||||
date_soumission: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
example: 3,
|
||||
description: 'Nombre d’enfants distincts liés aux parents de la famille (enfants_parents)',
|
||||
})
|
||||
nombre_enfants: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: [String],
|
||||
example: ['parent1@example.com', 'parent2@example.com'],
|
||||
description: 'Emails des parents du groupe (ordre stable : nom, prénom)',
|
||||
})
|
||||
emails?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: [ParentPendingSummaryDto],
|
||||
description: 'Résumé des parents (ordre stable, aligné sur parentIds/emails)',
|
||||
})
|
||||
parents?: ParentPendingSummaryDto[];
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ 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';
|
||||
|
||||
@ApiTags('Parents')
|
||||
@Controller('parents')
|
||||
@@ -33,12 +34,28 @@ export class ParentsController {
|
||||
@Get('pending-families')
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||
@ApiOperation({ summary: 'Liste des familles en attente (une entrée par famille)' })
|
||||
@ApiResponse({ status: 200, description: 'Liste des familles (libellé, parentIds, numero_dossier)', type: [PendingFamilyDto] })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description:
|
||||
'Liste des familles (libellé, parentIds, numero_dossier, date_soumission, nombre_enfants, emails, parents)',
|
||||
type: [PendingFamilyDto],
|
||||
})
|
||||
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
||||
getPendingFamilies(): Promise<PendingFamilyDto[]> {
|
||||
return this.parentsService.getPendingFamilies();
|
||||
}
|
||||
|
||||
@Get('dossier-famille/:numeroDossier')
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||
@ApiOperation({ summary: 'Dossier famille complet par numéro de dossier (Ticket #119)' })
|
||||
@ApiParam({ name: 'numeroDossier', description: 'Numéro de dossier (ex: 2026-000001)' })
|
||||
@ApiResponse({ status: 200, description: 'Dossier famille (numero_dossier, parents, enfants, presentation)', type: DossierFamilleCompletDto })
|
||||
@ApiResponse({ status: 404, description: 'Aucun dossier pour ce numéro' })
|
||||
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
||||
getDossierFamille(@Param('numeroDossier') numeroDossier: string): Promise<DossierFamilleCompletDto> {
|
||||
return this.parentsService.getDossierFamilleByNumero(numeroDossier);
|
||||
}
|
||||
|
||||
@Post(':parentId/valider-dossier')
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||
@ApiOperation({ summary: 'Valider tout le dossier famille (les 2 parents en une fois)' })
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { Parents } from 'src/entities/parents.entity';
|
||||
import { DossierFamille, DossierFamilleEnfant } from 'src/entities/dossier_famille.entity';
|
||||
import { ParentsController } from './parents.controller';
|
||||
import { ParentsService } from './parents.service';
|
||||
import { Users } from 'src/entities/users.entity';
|
||||
@@ -8,8 +11,16 @@ import { UserModule } from '../user/user.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Parents, Users]),
|
||||
TypeOrmModule.forFeature([Parents, Users, DossierFamille, DossierFamilleEnfant]),
|
||||
forwardRef(() => UserModule),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.get('jwt.accessSecret'),
|
||||
signOptions: { expiresIn: config.get('jwt.accessExpiresIn') },
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
],
|
||||
controllers: [ParentsController],
|
||||
providers: [ParentsService],
|
||||
|
||||
@@ -5,12 +5,18 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { Parents } from 'src/entities/parents.entity';
|
||||
import { DossierFamille } from 'src/entities/dossier_famille.entity';
|
||||
import { RoleType, Users } from 'src/entities/users.entity';
|
||||
import { CreateParentDto } from '../user/dto/create_parent.dto';
|
||||
import { UpdateParentsDto } from '../user/dto/update_parent.dto';
|
||||
import { PendingFamilyDto } from './dto/pending-family.dto';
|
||||
import {
|
||||
DossierFamilleCompletDto,
|
||||
DossierFamilleParentDto,
|
||||
DossierFamilleEnfantDto,
|
||||
} from './dto/dossier-famille-complet.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ParentsService {
|
||||
@@ -19,6 +25,8 @@ export class ParentsService {
|
||||
private readonly parentsRepository: Repository<Parents>,
|
||||
@InjectRepository(Users)
|
||||
private readonly usersRepository: Repository<Users>,
|
||||
@InjectRepository(DossierFamille)
|
||||
private readonly dossierFamilleRepository: Repository<DossierFamille>,
|
||||
) {}
|
||||
|
||||
// Création d’un parent
|
||||
@@ -79,47 +87,214 @@ export class ParentsService {
|
||||
* Uniquement les parents dont l'utilisateur a statut = en_attente.
|
||||
*/
|
||||
async getPendingFamilies(): Promise<PendingFamilyDto[]> {
|
||||
const raw = await this.parentsRepository.query(`
|
||||
WITH RECURSIVE
|
||||
links AS (
|
||||
SELECT p.id_utilisateur AS p1, p.id_co_parent AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT p.id_co_parent AS p1, p.id_utilisateur AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT ep1.id_parent AS p1, ep2.id_parent AS p2
|
||||
FROM enfants_parents ep1
|
||||
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
|
||||
UNION ALL
|
||||
SELECT ep2.id_parent AS p1, ep1.id_parent AS p2
|
||||
FROM enfants_parents ep1
|
||||
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
|
||||
),
|
||||
rec AS (
|
||||
SELECT id_utilisateur AS id, id_utilisateur AS rep FROM parents
|
||||
UNION
|
||||
SELECT l.p2 AS id, LEAST(rec_alias.rep, l.p2) AS rep FROM links l JOIN rec rec_alias ON rec_alias.id = l.p1
|
||||
),
|
||||
family_rep AS (
|
||||
SELECT id, (MIN(rep::text))::uuid AS rep FROM rec GROUP BY id
|
||||
)
|
||||
SELECT
|
||||
'Famille ' || string_agg(u.nom, ' - ' ORDER BY u.nom, u.prenom) AS libelle,
|
||||
array_agg(DISTINCT p.id_utilisateur ORDER BY p.id_utilisateur) AS "parentIds",
|
||||
(array_agg(p.numero_dossier))[1] AS numero_dossier
|
||||
FROM family_rep fr
|
||||
JOIN parents p ON p.id_utilisateur = fr.id
|
||||
JOIN utilisateurs u ON u.id = p.id_utilisateur
|
||||
WHERE u.role = 'parent' AND u.statut = 'en_attente'
|
||||
GROUP BY fr.rep
|
||||
ORDER BY libelle
|
||||
`);
|
||||
return raw.map((r: { libelle: string; parentIds: unknown; numero_dossier: string | null }) => ({
|
||||
libelle: r.libelle,
|
||||
parentIds: Array.isArray(r.parentIds) ? r.parentIds.map(String) : [],
|
||||
let raw: {
|
||||
libelle: string;
|
||||
parentIds: unknown;
|
||||
numero_dossier: string | null;
|
||||
date_soumission: Date | string | null;
|
||||
nombre_enfants: string | number | null;
|
||||
emails: unknown;
|
||||
parents: unknown;
|
||||
}[];
|
||||
try {
|
||||
raw = await this.parentsRepository.query(`
|
||||
WITH RECURSIVE
|
||||
links AS (
|
||||
SELECT p.id_utilisateur AS p1, p.id_co_parent AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT p.id_co_parent AS p1, p.id_utilisateur AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT ep1.id_parent AS p1, ep2.id_parent AS p2
|
||||
FROM enfants_parents ep1
|
||||
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
|
||||
UNION ALL
|
||||
SELECT ep2.id_parent AS p1, ep1.id_parent AS p2
|
||||
FROM enfants_parents ep1
|
||||
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
|
||||
),
|
||||
rec AS (
|
||||
SELECT id_utilisateur AS id, id_utilisateur AS rep FROM parents
|
||||
UNION
|
||||
SELECT l.p2 AS id, LEAST(rec_alias.rep, l.p2) AS rep FROM links l JOIN rec rec_alias ON rec_alias.id = l.p1
|
||||
),
|
||||
family_rep AS (
|
||||
SELECT id, (MIN(rep::text))::uuid AS rep FROM rec GROUP BY id
|
||||
)
|
||||
SELECT
|
||||
'Famille ' || string_agg(u.nom, ' - ' ORDER BY u.nom, u.prenom) AS libelle,
|
||||
array_agg(p.id_utilisateur ORDER BY u.nom, u.prenom, u.id) AS "parentIds",
|
||||
(array_agg(p.numero_dossier))[1] AS numero_dossier,
|
||||
MIN(u.cree_le) AS date_soumission,
|
||||
COALESCE((
|
||||
SELECT COUNT(DISTINCT ep.id_enfant)::int
|
||||
FROM enfants_parents ep
|
||||
WHERE ep.id_parent IN (
|
||||
SELECT frx.id FROM family_rep frx WHERE frx.rep = fr.rep
|
||||
)
|
||||
), 0) AS nombre_enfants,
|
||||
array_agg(u.email ORDER BY u.nom, u.prenom, u.id) AS emails,
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'id', u.id::text,
|
||||
'email', u.email,
|
||||
'telephone', u.telephone,
|
||||
'code_postal', u.code_postal,
|
||||
'ville', u.ville
|
||||
)
|
||||
ORDER BY u.nom, u.prenom, u.id
|
||||
) AS parents
|
||||
FROM family_rep fr
|
||||
JOIN parents p ON p.id_utilisateur = fr.id
|
||||
JOIN utilisateurs u ON u.id = p.id_utilisateur
|
||||
WHERE u.role = 'parent' AND u.statut = 'en_attente'
|
||||
GROUP BY fr.rep
|
||||
ORDER BY libelle
|
||||
`);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.map((r) => ({
|
||||
libelle: r.libelle ?? '',
|
||||
parentIds: this.normalizeParentIds(r.parentIds),
|
||||
numero_dossier: r.numero_dossier ?? null,
|
||||
date_soumission: this.toIsoDateTimeOrNull(r.date_soumission),
|
||||
nombre_enfants: this.normalizeNombreEnfants(r.nombre_enfants),
|
||||
emails: this.normalizeEmails(r.emails),
|
||||
parents: this.normalizeParents(r.parents),
|
||||
}));
|
||||
}
|
||||
|
||||
private toIsoDateTimeOrNull(value: Date | string | null | undefined): string | null {
|
||||
if (value == null) return null;
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime()) ? null : d.toISOString();
|
||||
}
|
||||
|
||||
private normalizeNombreEnfants(v: string | number | null | undefined): number {
|
||||
if (v == null) return 0;
|
||||
const n = typeof v === 'number' ? v : parseInt(String(v), 10);
|
||||
return Number.isFinite(n) && n >= 0 ? n : 0;
|
||||
}
|
||||
|
||||
private normalizeEmails(emails: unknown): string[] {
|
||||
if (Array.isArray(emails)) return emails.map(String);
|
||||
if (typeof emails === 'string') {
|
||||
const s = emails.replace(/^\{|\}$/g, '').trim();
|
||||
return s ? s.split(',').map((x) => x.trim()) : [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private normalizeParents(parents: unknown): { id: string; email: string; telephone: string | null; code_postal: string | null; ville: string | null }[] {
|
||||
if (Array.isArray(parents)) {
|
||||
return parents.map((p: any) => ({
|
||||
id: String(p?.id ?? ''),
|
||||
email: String(p?.email ?? ''),
|
||||
telephone: p?.telephone != null ? String(p.telephone) : null,
|
||||
code_postal: p?.code_postal != null ? String(p.code_postal) : null,
|
||||
ville: p?.ville != null ? String(p.ville) : null,
|
||||
}));
|
||||
}
|
||||
if (typeof parents === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(parents);
|
||||
return this.normalizeParents(parsed);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Convertit parentIds (array ou chaîne PG) en string[] pour éviter 500 si le driver renvoie une chaîne. */
|
||||
private normalizeParentIds(parentIds: unknown): string[] {
|
||||
if (Array.isArray(parentIds)) return parentIds.map(String);
|
||||
if (typeof parentIds === 'string') {
|
||||
const s = parentIds.replace(/^\{|\}$/g, '').trim();
|
||||
return s ? s.split(',').map((x) => x.trim()) : [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dossier famille complet par numéro de dossier. Ticket #119.
|
||||
* Rôles : admin, gestionnaire.
|
||||
* @throws NotFoundException si aucun parent avec ce numéro de dossier
|
||||
*/
|
||||
async getDossierFamilleByNumero(numeroDossier: string): Promise<DossierFamilleCompletDto> {
|
||||
const num = numeroDossier?.trim();
|
||||
if (!num) {
|
||||
throw new NotFoundException('Numéro de dossier requis.');
|
||||
}
|
||||
const firstParent = await this.parentsRepository.findOne({
|
||||
where: { numero_dossier: num },
|
||||
relations: ['user'],
|
||||
});
|
||||
if (!firstParent || !firstParent.user) {
|
||||
throw new NotFoundException('Aucun dossier famille trouvé pour ce numéro.');
|
||||
}
|
||||
const familyUserIds = await this.getFamilyUserIds(firstParent.user_id);
|
||||
const parents = await this.parentsRepository.find({
|
||||
where: { user_id: In(familyUserIds) },
|
||||
relations: ['user', 'co_parent', 'parentChildren', 'parentChildren.child', 'dossiers', 'dossiers.child'],
|
||||
});
|
||||
const enfantsMap = new Map<string, DossierFamilleEnfantDto>();
|
||||
let texte_motivation: string | undefined;
|
||||
|
||||
// Un dossier = une famille, un seul texte de motivation
|
||||
const dossierFamille = await this.dossierFamilleRepository.findOne({
|
||||
where: { numero_dossier: num },
|
||||
relations: ['parent', 'enfants', 'enfants.enfant'],
|
||||
});
|
||||
if (dossierFamille?.presentation) {
|
||||
texte_motivation = dossierFamille.presentation;
|
||||
}
|
||||
|
||||
for (const p of parents) {
|
||||
// Enfants via parentChildren
|
||||
if (p.parentChildren) {
|
||||
for (const pc of p.parentChildren) {
|
||||
if (pc.child && !enfantsMap.has(pc.child.id)) {
|
||||
enfantsMap.set(pc.child.id, {
|
||||
id: pc.child.id,
|
||||
first_name: pc.child.first_name,
|
||||
last_name: pc.child.last_name,
|
||||
genre: pc.child.gender,
|
||||
birth_date: pc.child.birth_date,
|
||||
due_date: pc.child.due_date,
|
||||
status: pc.child.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback : anciens dossiers (un texte, on prend le premier)
|
||||
if (texte_motivation == null && p.dossiers?.length) {
|
||||
texte_motivation = p.dossiers[0].presentation ?? undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const parentsDto: DossierFamilleParentDto[] = parents.map((p) => ({
|
||||
user_id: p.user_id,
|
||||
email: p.user.email,
|
||||
prenom: p.user.prenom,
|
||||
nom: p.user.nom,
|
||||
telephone: p.user.telephone,
|
||||
adresse: p.user.adresse,
|
||||
ville: p.user.ville,
|
||||
code_postal: p.user.code_postal,
|
||||
statut: p.user.statut,
|
||||
co_parent_id: p.co_parent?.id,
|
||||
}));
|
||||
return {
|
||||
numero_dossier: num,
|
||||
parents: parentsDto,
|
||||
enfants: Array.from(enfantsMap.values()),
|
||||
texte_motivation,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne les user_id de tous les parents de la même famille (co_parent ou enfants partagés).
|
||||
* @throws NotFoundException si parentId n'est pas un parent
|
||||
|
||||
Reference in New Issue
Block a user