feat(#110): refus dossier propagé aux co-parents + mail aligné
- `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 <cursoragent@cursor.com>
This commit is contained in:
parent
b6e83bf9ef
commit
9eb62ddd13
@ -214,21 +214,29 @@ export class MailService {
|
|||||||
token: string,
|
token: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const appName = this.configService.get<string>('app_name', "P'titsPas");
|
const appName = this.configService.get<string>('app_name', "P'titsPas");
|
||||||
const appUrl = this.configService.get<string>('app_url', 'https://app.ptits-pas.fr');
|
const appUrl = (this.configService.get<string>('app_url', 'https://app.ptits-pas.fr') || '').replace(/\/+$/, '');
|
||||||
const repriseLink = `${appUrl}/reprise?token=${encodeURIComponent(token)}`;
|
const repriseLink = `${appUrl}/reprise?token=${encodeURIComponent(token)}`;
|
||||||
|
|
||||||
|
const safe = (s: string) =>
|
||||||
|
(s || '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
|
||||||
const subject = `Votre dossier – compléments demandés`;
|
const subject = `Votre dossier – compléments demandés`;
|
||||||
const commentBlock = comment
|
const commentText = safe(comment || 'Le gestionnaire vous demande de compléter votre dossier avant une nouvelle validation.');
|
||||||
? `<p><strong>Message du gestionnaire :</strong></p><p>${comment.replace(/</g, '<').replace(/>/g, '>')}</p>`
|
|
||||||
: '';
|
|
||||||
const html = `
|
const html = `
|
||||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
<h2 style="color: #333;">Bonjour ${prenom} ${nom},</h2>
|
<h2 style="color: #4CAF50;">Bonjour ${safe(prenom)} ${safe(nom)},</h2>
|
||||||
<p>Votre dossier d'inscription sur <strong>${appName}</strong> n'a pas pu être validé en l'état.</p>
|
<p>Votre dossier d'inscription sur <strong>${safe(appName)}</strong> n'a pas pu être validé en l'état.</p>
|
||||||
${commentBlock}
|
<div style="background-color: #F4FBF5; border-left: 4px solid #4CAF50; padding: 12px 16px; margin: 20px 0;">
|
||||||
|
<p style="margin: 0 0 8px 0;"><strong>Message du gestionnaire :</strong></p>
|
||||||
|
<p style="margin: 0;">${commentText}</p>
|
||||||
|
</div>
|
||||||
<p>Vous pouvez corriger les éléments indiqués et soumettre à nouveau votre dossier en cliquant sur le lien ci-dessous.</p>
|
<p>Vous pouvez corriger les éléments indiqués et soumettre à nouveau votre dossier en cliquant sur le lien ci-dessous.</p>
|
||||||
<div style="text-align: center; margin: 30px 0;">
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
<a href="${repriseLink}" style="background-color: #2196F3; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Reprendre mon dossier</a>
|
<a href="${repriseLink}" style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Reprendre mon dossier</a>
|
||||||
</div>
|
</div>
|
||||||
<p style="color: #666; font-size: 12px;">Ce lien est valable 7 jours. Si vous n'avez pas demandé cette reprise, vous pouvez ignorer cet email.</p>
|
<p style="color: #666; font-size: 12px;">Ce lien est valable 7 jours. Si vous n'avez pas demandé cette reprise, vous pouvez ignorer cet email.</p>
|
||||||
<hr style="border: 1px solid #eee; margin: 20px 0;">
|
<hr style="border: 1px solid #eee; margin: 20px 0;">
|
||||||
|
|||||||
@ -332,7 +332,7 @@ export class UserService {
|
|||||||
return savedUser;
|
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<Users> {
|
async refuseUser(user_id: string, currentUser: Users, comment?: string): Promise<Users> {
|
||||||
if (![RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE].includes(currentUser.role)) {
|
if (![RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE].includes(currentUser.role)) {
|
||||||
throw new ForbiddenException('Accès réservé aux super admins, administrateurs et gestionnaires');
|
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 } });
|
const user = await this.usersRepository.findOne({ where: { id: user_id } });
|
||||||
if (!user) throw new NotFoundException('Utilisateur introuvable');
|
if (!user) throw new NotFoundException('Utilisateur introuvable');
|
||||||
if (user.statut !== StatutUtilisateurType.EN_ATTENTE) {
|
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 tokenReprise = crypto.randomUUID();
|
||||||
const expireLe = new Date();
|
const expireLe = new Date();
|
||||||
expireLe.setDate(expireLe.getDate() + 7);
|
expireLe.setDate(expireLe.getDate() + 7);
|
||||||
|
|
||||||
user.statut = StatutUtilisateurType.REFUSE;
|
const usersDossier = user.role === RoleType.PARENT && user.numero_dossier
|
||||||
user.token_reprise = tokenReprise;
|
? await this.usersRepository.find({
|
||||||
user.token_reprise_expire_le = expireLe;
|
where: {
|
||||||
const savedUser = await this.usersRepository.save(user);
|
role: RoleType.PARENT,
|
||||||
|
numero_dossier: user.numero_dossier,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: [user];
|
||||||
|
|
||||||
const validation = this.validationRepository.create({
|
const usersARefuser = usersDossier.length ? usersDossier : [user];
|
||||||
user: savedUser,
|
const utilisateurInvalide = usersARefuser.find(
|
||||||
type: 'refus_compte',
|
(u) => u.statut !== StatutUtilisateurType.EN_ATTENTE,
|
||||||
status: StatutValidationType.REFUSE,
|
);
|
||||||
validated_by: currentUser,
|
if (utilisateurInvalide) {
|
||||||
comment,
|
throw new BadRequestException(
|
||||||
});
|
'Tous les comptes du dossier doivent être en attente pour refuser le dossier.',
|
||||||
await this.validationRepository.save(validation);
|
|
||||||
|
|
||||||
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 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];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user