From 9eb62ddd13ecba681c06f1ae6252079c2f757edd Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Wed, 13 May 2026 19:44:30 +0200 Subject: [PATCH] =?UTF-8?q?feat(#110):=20refus=20dossier=20propag=C3=A9=20?= =?UTF-8?q?aux=20co-parents=20+=20mail=20align=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `refuseUser` charge tous les parents du même numéro de dossier, leur applique le même token_reprise (7 jours), trace une validation refus par compte et envoie un mail de refus à chacun. Le refus devient une action dossier : peu importe quel parent ouvre ensuite le lien. - Échec d'envoi mail loggé sans bloquer les autres destinataires. - Mail de refus aligné sur le look des autres templates (titre/bouton verts, bloc motif gestionnaire en encart, échappement HTML cohérent, appUrl normalisée). Co-authored-by: Cursor --- backend/src/modules/mail/mail.service.ts | 24 ++++--- backend/src/routes/user/user.service.ts | 81 ++++++++++++++++-------- 2 files changed, 72 insertions(+), 33 deletions(-) diff --git a/backend/src/modules/mail/mail.service.ts b/backend/src/modules/mail/mail.service.ts index b2baa85..91d2dfb 100644 --- a/backend/src/modules/mail/mail.service.ts +++ b/backend/src/modules/mail/mail.service.ts @@ -214,21 +214,29 @@ export class MailService { token: string, ): Promise { const appName = this.configService.get('app_name', "P'titsPas"); - const appUrl = this.configService.get('app_url', 'https://app.ptits-pas.fr'); + const appUrl = (this.configService.get('app_url', 'https://app.ptits-pas.fr') || '').replace(/\/+$/, ''); const repriseLink = `${appUrl}/reprise?token=${encodeURIComponent(token)}`; + const safe = (s: string) => + (s || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + const subject = `Votre dossier – compléments demandés`; - const commentBlock = comment - ? `

Message du gestionnaire :

${comment.replace(//g, '>')}

` - : ''; + const commentText = safe(comment || 'Le gestionnaire vous demande de compléter votre dossier avant une nouvelle validation.'); const html = `
-

Bonjour ${prenom} ${nom},

-

Votre dossier d'inscription sur ${appName} n'a pas pu être validé en l'état.

- ${commentBlock} +

Bonjour ${safe(prenom)} ${safe(nom)},

+

Votre dossier d'inscription sur ${safe(appName)} n'a pas pu être validé en l'état.

+
+

Message du gestionnaire :

+

${commentText}

+

Vous pouvez corriger les éléments indiqués et soumettre à nouveau votre dossier en cliquant sur le lien ci-dessous.

Ce lien est valable 7 jours. Si vous n'avez pas demandé cette reprise, vous pouvez ignorer cet email.


diff --git a/backend/src/routes/user/user.service.ts b/backend/src/routes/user/user.service.ts index 9becd46..3dc2532 100644 --- a/backend/src/routes/user/user.service.ts +++ b/backend/src/routes/user/user.service.ts @@ -332,7 +332,7 @@ export class UserService { return savedUser; } - /** Refuser un compte (en_attente -> refuse) ; tracé validations, token reprise, email. Ticket #110 */ + /** Refuser un dossier (en_attente -> refuse) ; tracé validations, token reprise partagé, emails. Ticket #110 */ async refuseUser(user_id: string, currentUser: Users, comment?: string): Promise { if (![RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE].includes(currentUser.role)) { throw new ForbiddenException('Accès réservé aux super admins, administrateurs et gestionnaires'); @@ -340,40 +340,71 @@ export class UserService { const user = await this.usersRepository.findOne({ where: { id: user_id } }); if (!user) throw new NotFoundException('Utilisateur introuvable'); if (user.statut !== StatutUtilisateurType.EN_ATTENTE) { - throw new BadRequestException('Seul un compte en attente peut être refusé.'); + throw new BadRequestException('Seul un dossier en attente peut être refusé.'); } const tokenReprise = crypto.randomUUID(); const expireLe = new Date(); expireLe.setDate(expireLe.getDate() + 7); - user.statut = StatutUtilisateurType.REFUSE; - user.token_reprise = tokenReprise; - user.token_reprise_expire_le = expireLe; - const savedUser = await this.usersRepository.save(user); + const usersDossier = user.role === RoleType.PARENT && user.numero_dossier + ? await this.usersRepository.find({ + where: { + role: RoleType.PARENT, + numero_dossier: user.numero_dossier, + }, + }) + : [user]; - const validation = this.validationRepository.create({ - user: savedUser, - type: 'refus_compte', - status: StatutValidationType.REFUSE, - validated_by: currentUser, - comment, - }); - await this.validationRepository.save(validation); - - try { - await this.mailService.sendRefusEmail( - savedUser.email, - savedUser.prenom ?? '', - savedUser.nom ?? '', - comment, - tokenReprise, + const usersARefuser = usersDossier.length ? usersDossier : [user]; + const utilisateurInvalide = usersARefuser.find( + (u) => u.statut !== StatutUtilisateurType.EN_ATTENTE, + ); + if (utilisateurInvalide) { + throw new BadRequestException( + 'Tous les comptes du dossier doivent être en attente pour refuser le dossier.', ); - } catch (err) { - this.logger.warn(`Envoi email refus échoué pour ${savedUser.email}`, err); } - return savedUser; + const savedUsers = await this.usersRepository.manager.transaction(async (manager) => { + const updatedUsers: Users[] = []; + + for (const userARefuser of usersARefuser) { + userARefuser.statut = StatutUtilisateurType.REFUSE; + userARefuser.token_reprise = tokenReprise; + userARefuser.token_reprise_expire_le = expireLe; + + const savedUser = await manager.save(Users, userARefuser); + updatedUsers.push(savedUser); + + const validation = manager.create(Validation, { + user: savedUser, + type: 'refus_compte', + status: StatutValidationType.REFUSE, + validated_by: currentUser, + comment, + }); + await manager.save(Validation, validation); + } + + return updatedUsers; + }); + + for (const savedUser of savedUsers) { + try { + await this.mailService.sendRefusEmail( + savedUser.email, + savedUser.prenom ?? '', + savedUser.nom ?? '', + comment, + tokenReprise, + ); + } catch (err) { + this.logger.warn(`Envoi email refus échoué pour ${savedUser.email}`, err); + } + } + + return savedUsers.find((savedUser) => savedUser.id === user.id) ?? savedUsers[0]; } /**