fix: GET /parents/pending-families 500 + #113 doublons inscription

- parents.service: normaliser parentIds (array ou string PG) pour éviter 500
- auth.service: doublons à l'inscription (#113) - parent/co-parent même email, NIR et numéro agrément AM
- docs: mise à jour statuts tickets

Made-with: Cursor
This commit is contained in:
2026-03-13 16:30:12 +01:00
parent d832559027
commit 7e9306de01
3 changed files with 95 additions and 50 deletions
+28
View File
@@ -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,
+53 -37
View File
@@ -79,47 +79,63 @@ 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 }[];
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(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
`);
} 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,
}));
}
/** 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 [];
}
/**
* 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