Back: PATCH /parents/:id/fiche, attach/detach enfant, GET /enfants enrichi. Front: modale parent éditable, onglet Enfants, fiche enfant, UserService. Couvre doc 28 §6.1–6.2 ; tickets liés #115 #116 #130 #131 #137 #138. Hors scope: fiche AM (#131), création admin (#129). Co-authored-by: Cursor <cursoragent@cursor.com>
438 lines
16 KiB
TypeScript
438 lines
16 KiB
TypeScript
import {
|
||
BadRequestException,
|
||
ConflictException,
|
||
Injectable,
|
||
NotFoundException,
|
||
} from '@nestjs/common';
|
||
import { InjectRepository } from '@nestjs/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';
|
||
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 {
|
||
constructor(
|
||
@InjectRepository(Parents)
|
||
private readonly parentsRepository: Repository<Parents>,
|
||
@InjectRepository(Users)
|
||
private readonly usersRepository: Repository<Users>,
|
||
@InjectRepository(DossierFamille)
|
||
private readonly dossierFamilleRepository: Repository<DossierFamille>,
|
||
@InjectRepository(ParentsChildren)
|
||
private readonly parentsChildrenRepository: Repository<ParentsChildren>,
|
||
) {}
|
||
|
||
// Création d’un parent
|
||
async create(dto: CreateParentDto): Promise<Parents> {
|
||
const user = await this.usersRepository.findOneBy({ id: dto.user_id });
|
||
if (!user) throw new NotFoundException('Utilisateur introuvable');
|
||
if (user.role !== RoleType.PARENT) {
|
||
throw new BadRequestException('Accès réservé aux parents');
|
||
}
|
||
|
||
const exist = await this.parentsRepository.findOneBy({ user_id: dto.user_id });
|
||
if (exist) throw new ConflictException('Ce parent existe déjà');
|
||
|
||
let co_parent: Users | null = null;
|
||
if (dto.co_parent_id) {
|
||
co_parent = await this.usersRepository.findOneBy({ id: dto.co_parent_id });
|
||
if (!co_parent) throw new NotFoundException('Co-parent introuvable');
|
||
if (co_parent.role !== RoleType.PARENT) {
|
||
throw new BadRequestException('Accès réservé aux parents');
|
||
}
|
||
}
|
||
|
||
const entity = this.parentsRepository.create({
|
||
user_id: dto.user_id,
|
||
user,
|
||
co_parent: co_parent ?? undefined,
|
||
});
|
||
|
||
return this.parentsRepository.save(entity);
|
||
}
|
||
|
||
// Liste des parents
|
||
async findAll(): Promise<Parents[]> {
|
||
return this.parentsRepository.find({
|
||
relations: ['user', 'co_parent', 'parentChildren', 'parentChildren.child', 'dossiers'],
|
||
});
|
||
}
|
||
|
||
// Récupérer un parent par user_id
|
||
async findOne(user_id: string): Promise<Parents> {
|
||
const parent = await this.parentsRepository.findOne({
|
||
where: { user_id },
|
||
relations: ['user', 'co_parent', 'parentChildren', 'parentChildren.child', 'dossiers'],
|
||
});
|
||
if (!parent) throw new NotFoundException('Parent introuvable');
|
||
return parent;
|
||
}
|
||
|
||
// Mise à jour
|
||
async update(id: string, dto: UpdateParentsDto): Promise<Parents> {
|
||
await this.parentsRepository.update(id, dto);
|
||
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).
|
||
* Uniquement les parents dont l'utilisateur a statut = en_attente.
|
||
*/
|
||
async getPendingFamilies(): Promise<PendingFamilyDto[]> {
|
||
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 childToDossierFamilleEnfantDto(child: Children): DossierFamilleEnfantDto {
|
||
return {
|
||
id: child.id,
|
||
first_name: child.first_name,
|
||
last_name: child.last_name,
|
||
genre: child.gender,
|
||
birth_date: child.birth_date,
|
||
due_date: child.due_date,
|
||
status: child.status,
|
||
photo_url: child.photo_url ?? undefined,
|
||
consent_photo: child.consent_photo,
|
||
est_multiple: child.is_multiple,
|
||
};
|
||
}
|
||
|
||
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, this.childToDossierFamilleEnfantDto(pc.child));
|
||
}
|
||
}
|
||
}
|
||
// Fallback : anciens dossiers (un texte, on prend le premier)
|
||
if (texte_motivation == null && p.dossiers?.length) {
|
||
texte_motivation = p.dossiers[0].presentation ?? undefined;
|
||
}
|
||
}
|
||
|
||
// Enfants uniquement liés via dossier_famille_enfants (legacy / parcours alternatif)
|
||
if (dossierFamille?.enfants?.length) {
|
||
for (const dfe of dossierFamille.enfants) {
|
||
const c = dfe.enfant;
|
||
if (c && !enfantsMap.has(c.id)) {
|
||
enfantsMap.set(c.id, this.childToDossierFamilleEnfantDto(c));
|
||
}
|
||
}
|
||
}
|
||
|
||
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
|
||
*/
|
||
async getFamilyUserIds(parentId: string): Promise<string[]> {
|
||
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
|
||
),
|
||
input_rep AS (
|
||
SELECT rep FROM family_rep WHERE id = $1::uuid LIMIT 1
|
||
)
|
||
SELECT fr.id::text AS id
|
||
FROM family_rep fr
|
||
CROSS JOIN input_rep ir
|
||
WHERE fr.rep = ir.rep
|
||
`,
|
||
[parentId],
|
||
);
|
||
if (!raw || raw.length === 0) {
|
||
throw new NotFoundException('Parent introuvable ou pas encore enregistré en base.');
|
||
}
|
||
return raw.map((r: { id: string }) => r.id);
|
||
}
|
||
}
|