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:
MARTIN Julien 2026-03-13 16:30:12 +01:00
parent d832559027
commit 7e9306de01
3 changed files with 95 additions and 50 deletions

View File

@ -43,6 +43,8 @@ export class AuthService {
private readonly usersRepo: Repository<Users>, private readonly usersRepo: Repository<Users>,
@InjectRepository(Children) @InjectRepository(Children)
private readonly childrenRepo: Repository<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.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); const coParentExiste = await this.usersService.findByEmailOrNull(dto.co_parent_email);
if (coParentExiste) { if (coParentExiste) {
throw new ConflictException('L\'email du co-parent est déjà utilisé'); 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à'); 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>( const joursExpirationToken = await this.appConfigService.get<number>(
'password_reset_token_expiry_days', 'password_reset_token_expiry_days',
7, 7,

View File

@ -79,47 +79,63 @@ export class ParentsService {
* Uniquement les parents dont l'utilisateur a statut = en_attente. * Uniquement les parents dont l'utilisateur a statut = en_attente.
*/ */
async getPendingFamilies(): Promise<PendingFamilyDto[]> { async getPendingFamilies(): Promise<PendingFamilyDto[]> {
const raw = await this.parentsRepository.query(` let raw: { libelle: string; parentIds: unknown; numero_dossier: string | null }[];
WITH RECURSIVE try {
links AS ( raw = await this.parentsRepository.query(`
SELECT p.id_utilisateur AS p1, p.id_co_parent AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL WITH RECURSIVE
UNION ALL links AS (
SELECT p.id_co_parent AS p1, p.id_utilisateur AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL 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 UNION ALL
SELECT ep1.id_parent AS p1, ep2.id_parent AS p2 SELECT p.id_co_parent AS p1, p.id_utilisateur AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
FROM enfants_parents ep1 UNION ALL
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent SELECT ep1.id_parent AS p1, ep2.id_parent AS p2
UNION ALL FROM enfants_parents ep1
SELECT ep2.id_parent AS p1, ep1.id_parent AS p2 JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
FROM enfants_parents ep1 UNION ALL
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent SELECT ep2.id_parent AS p1, ep1.id_parent AS p2
), FROM enfants_parents ep1
rec AS ( JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
SELECT id_utilisateur AS id, id_utilisateur AS rep FROM parents ),
UNION rec AS (
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 SELECT id_utilisateur AS id, id_utilisateur AS rep FROM parents
), UNION
family_rep AS ( 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
SELECT id, (MIN(rep::text))::uuid AS rep FROM rec GROUP BY id ),
) family_rep AS (
SELECT SELECT id, (MIN(rep::text))::uuid AS rep FROM rec GROUP BY id
'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", SELECT
(array_agg(p.numero_dossier))[1] AS numero_dossier 'Famille ' || string_agg(u.nom, ' - ' ORDER BY u.nom, u.prenom) AS libelle,
FROM family_rep fr array_agg(DISTINCT p.id_utilisateur ORDER BY p.id_utilisateur) AS "parentIds",
JOIN parents p ON p.id_utilisateur = fr.id (array_agg(p.numero_dossier))[1] AS numero_dossier
JOIN utilisateurs u ON u.id = p.id_utilisateur FROM family_rep fr
WHERE u.role = 'parent' AND u.statut = 'en_attente' JOIN parents p ON p.id_utilisateur = fr.id
GROUP BY fr.rep JOIN utilisateurs u ON u.id = p.id_utilisateur
ORDER BY libelle WHERE u.role = 'parent' AND u.statut = 'en_attente'
`); GROUP BY fr.rep
return raw.map((r: { libelle: string; parentIds: unknown; numero_dossier: string | null }) => ({ ORDER BY libelle
libelle: r.libelle, `);
parentIds: Array.isArray(r.parentIds) ? r.parentIds.map(String) : [], } 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, 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). * 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 * @throws NotFoundException si parentId n'est pas un parent

View File

@ -33,13 +33,13 @@
| 20 | [Backend] API Inscription Parent (étape 3 - Enfants) | ✅ Terminé | | 20 | [Backend] API Inscription Parent (étape 3 - Enfants) | ✅ Terminé |
| 21 | [Backend] API Inscription Parent (étape 4-6 - Finalisation) | ✅ Terminé | | 21 | [Backend] API Inscription Parent (étape 4-6 - Finalisation) | ✅ Terminé |
| 24 | [Backend] API Création mot de passe | Ouvert | | 24 | [Backend] API Création mot de passe | Ouvert |
| 25 | [Backend] API Liste comptes en attente | Ouvert | | 25 | [Backend] API Liste comptes en attente | ✅ Fermé (obsolète, couvert #103-#111) |
| 26 | [Backend] API Validation/Refus comptes | Ouvert | | 26 | [Backend] API Validation/Refus comptes | ✅ Fermé (obsolète, couvert #103-#111) |
| 27 | [Backend] Service Email - Installation Nodemailer | Ouvert | | 27 | [Backend] Service Email - Installation Nodemailer | ✅ Fermé (obsolète, couvert #103-#111) |
| 28 | [Backend] Templates Email - Validation | Ouvert | | 28 | [Backend] Templates Email - Validation | Ouvert |
| 29 | [Backend] Templates Email - Refus | Ouvert | | 29 | [Backend] Templates Email - Refus | ✅ Fermé (obsolète, couvert #103-#111) |
| 30 | [Backend] Connexion - Vérification statut | Ouvert | | 30 | [Backend] Connexion - Vérification statut | ✅ Fermé (obsolète, couvert #103-#111) |
| 31 | [Backend] Changement MDP obligatoire première connexion | Ouvert | | 31 | [Backend] Changement MDP obligatoire première connexion | ✅ Terminé |
| 32 | [Backend] Service Documents Légaux | Ouvert | | 32 | [Backend] Service Documents Légaux | Ouvert |
| 33 | [Backend] API Documents Légaux | Ouvert | | 33 | [Backend] API Documents Légaux | Ouvert |
| 34 | [Backend] Traçabilité acceptations documents | Ouvert | | 34 | [Backend] Traçabilité acceptations documents | Ouvert |
@ -53,8 +53,8 @@
| 42 | [Frontend] Inscription AM - Finalisation | ✅ Terminé | | 42 | [Frontend] Inscription AM - Finalisation | ✅ Terminé |
| 43 | [Frontend] Écran Création Mot de Passe | Ouvert | | 43 | [Frontend] Écran Création Mot de Passe | Ouvert |
| 44 | [Frontend] Dashboard Gestionnaire - Structure | ✅ Terminé | | 44 | [Frontend] Dashboard Gestionnaire - Structure | ✅ Terminé |
| 45 | [Frontend] Dashboard Gestionnaire - Liste Parents | Ouvert | | 45 | [Frontend] Dashboard Gestionnaire - Liste Parents | ✅ Fermé (obsolète, couvert #103-#111) |
| 46 | [Frontend] Dashboard Gestionnaire - Liste AM | Ouvert | | 46 | [Frontend] Dashboard Gestionnaire - Liste AM | ✅ Fermé (obsolète, couvert #103-#111) |
| 47 | [Frontend] Écran Changement MDP Obligatoire | Ouvert | | 47 | [Frontend] Écran Changement MDP Obligatoire | Ouvert |
| 48 | [Frontend] Gestion Erreurs & Messages | Ouvert | | 48 | [Frontend] Gestion Erreurs & Messages | Ouvert |
| 49 | [Frontend] Écran Gestion Documents Légaux (Admin) | Ouvert | | 49 | [Frontend] Écran Gestion Documents Légaux (Admin) | Ouvert |
@ -641,17 +641,18 @@ Modifier l'endpoint de connexion pour bloquer les comptes en attente ou suspendu
--- ---
### Ticket #31 : [Backend] Changement MDP obligatoire première connexion ### Ticket #31 : [Backend] Changement MDP obligatoire première connexion
**Estimation** : 2h **Estimation** : 2h
**Labels** : `backend`, `p2`, `auth`, `security` **Labels** : `backend`, `p2`, `auth`, `security`
**Statut** : ✅ TERMINÉ
**Description** : **Description** :
Implémenter le changement de mot de passe obligatoire pour les gestionnaires/admins à la première connexion. Implémenter le changement de mot de passe obligatoire pour les gestionnaires/admins à la première connexion.
**Tâches** : **Tâches** :
- [ ] Endpoint `POST /api/v1/auth/change-password-required` - [x] Endpoint `POST /api/v1/auth/change-password-required`
- [ ] Vérification flag `changement_mdp_obligatoire` - [x] Vérification flag `changement_mdp_obligatoire`
- [ ] Mise à jour flag après changement - [x] Mise à jour flag après changement
- [ ] Tests unitaires - [ ] Tests unitaires
--- ---