feat(#110): Refus sans suppression – token reprise + email

- Colonnes token_reprise, token_reprise_expire_le (migration + BDD.sql)
- refuser: génère token (7j), enregistre, trace validations, envoie email (template refus + lien reprise)
- MailService.sendRefusEmail ; échec email ne bloque pas le refus

Made-with: Cursor
This commit is contained in:
MARTIN Julien 2026-03-12 22:56:27 +01:00
parent dbcb3611d4
commit 86d8189038
6 changed files with 93 additions and 3 deletions

View File

@ -119,6 +119,13 @@ export class Users {
@Column({ type: 'timestamptz', nullable: true, name: 'token_creation_mdp_expire_le' })
token_creation_mdp_expire_le?: Date;
/** Token pour reprise après refus (lien email), ticket #110 */
@Column({ nullable: true, name: 'token_reprise', length: 255 })
token_reprise?: string;
@Column({ type: 'timestamptz', nullable: true, name: 'token_reprise_expire_le' })
token_reprise_expire_le?: Date;
@Column({ nullable: true, name: 'ville' })
ville?: string;

View File

@ -97,4 +97,41 @@ export class MailService {
await this.sendEmail(to, subject, html);
}
/**
* Email de refus de dossier avec lien reprise (token).
* Ticket #110 Refus sans suppression
*/
async sendRefusEmail(
to: string,
prenom: string,
nom: string,
comment: string | undefined,
token: string,
): Promise<void> {
const appName = this.configService.get<string>('app_name', "P'titsPas");
const appUrl = this.configService.get<string>('app_url', 'https://app.ptits-pas.fr');
const repriseLink = `${appUrl}/reprise?token=${encodeURIComponent(token)}`;
const subject = `Votre dossier compléments demandés`;
const commentBlock = comment
? `<p><strong>Message du gestionnaire :</strong></p><p>${comment.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</p>`
: '';
const html = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #333;">Bonjour ${prenom} ${nom},</h2>
<p>Votre dossier d'inscription sur <strong>${appName}</strong> n'a pas pu être validé en l'état.</p>
${commentBlock}
<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;">
<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>
</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>
<hr style="border: 1px solid #eee; margin: 20px 0;">
<p style="color: #666; font-size: 12px;">Cet email a é envoyé automatiquement. Merci de ne pas y répondre.</p>
</div>
`;
await this.sendEmail(to, subject, html);
}
}

View File

@ -10,6 +10,7 @@ import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entit
import { AssistantesMaternellesModule } from '../assistantes_maternelles/assistantes_maternelles.module';
import { Parents } from 'src/entities/parents.entity';
import { GestionnairesModule } from './gestionnaires/gestionnaires.module';
import { MailModule } from 'src/modules/mail/mail.module';
@Module({
imports: [TypeOrmModule.forFeature(
@ -22,6 +23,7 @@ import { GestionnairesModule } from './gestionnaires/gestionnaires.module';
ParentsModule,
AssistantesMaternellesModule,
GestionnairesModule,
MailModule,
],
controllers: [UserController],
providers: [UserService],

View File

@ -1,4 +1,4 @@
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { RoleType, StatutUtilisateurType, Users } from "src/entities/users.entity";
import { In, Repository } from "typeorm";
@ -9,9 +9,13 @@ import * as bcrypt from 'bcrypt';
import { StatutValidationType, Validation } from "src/entities/validations.entity";
import { Parents } from "src/entities/parents.entity";
import { AssistanteMaternelle } from "src/entities/assistantes_maternelles.entity";
import { MailService } from "src/modules/mail/mail.service";
import * as crypto from 'crypto';
@Injectable()
export class UserService {
private readonly logger = new Logger(UserService.name);
constructor(
@InjectRepository(Users)
private readonly usersRepository: Repository<Users>,
@ -23,7 +27,9 @@ export class UserService {
private readonly parentsRepository: Repository<Parents>,
@InjectRepository(AssistanteMaternelle)
private readonly assistantesRepository: Repository<AssistanteMaternelle>
private readonly assistantesRepository: Repository<AssistanteMaternelle>,
private readonly mailService: MailService,
) { }
async createUser(dto: CreateUserDto, currentUser?: Users): Promise<Users> {
@ -284,7 +290,7 @@ export class UserService {
return savedUser;
}
/** Refuser un compte (en_attente -> refuse) ; tracé dans validations */
/** Refuser un compte (en_attente -> refuse) ; tracé validations, token reprise, email. Ticket #110 */
async refuseUser(user_id: string, currentUser: Users, comment?: string): Promise<Users> {
if (![RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE].includes(currentUser.role)) {
throw new ForbiddenException('Accès réservé aux super admins, administrateurs et gestionnaires');
@ -294,8 +300,16 @@ export class UserService {
if (user.statut !== StatutUtilisateurType.EN_ATTENTE) {
throw new BadRequestException('Seul un compte 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 validation = this.validationRepository.create({
user: savedUser,
type: 'refus_compte',
@ -304,6 +318,19 @@ export class UserService {
comment,
});
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;
}

View File

@ -369,6 +369,13 @@ CREATE INDEX IF NOT EXISTS idx_utilisateurs_numero_dossier ON utilisateurs(numer
CREATE INDEX IF NOT EXISTS idx_assistantes_maternelles_numero_dossier ON assistantes_maternelles(numero_dossier) WHERE numero_dossier IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_parents_numero_dossier ON parents(numero_dossier) WHERE numero_dossier IS NOT NULL;
-- ==========================================================
-- Ticket #110 : Token reprise après refus (lien email)
-- ==========================================================
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS token_reprise VARCHAR(255) NULL;
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS token_reprise_expire_le TIMESTAMPTZ NULL;
CREATE INDEX IF NOT EXISTS idx_utilisateurs_token_reprise ON utilisateurs(token_reprise) WHERE token_reprise IS NOT NULL;
-- ==========================================================
-- Seed : Documents légaux génériques v1
-- ==========================================================

View File

@ -0,0 +1,10 @@
-- Migration #110 : Token reprise après refus (lien email)
-- Permet à l'utilisateur refusé de corriger et resoumettre via un lien sécurisé.
ALTER TABLE utilisateurs
ADD COLUMN IF NOT EXISTS token_reprise VARCHAR(255) NULL,
ADD COLUMN IF NOT EXISTS token_reprise_expire_le TIMESTAMPTZ NULL;
CREATE INDEX IF NOT EXISTS idx_utilisateurs_token_reprise
ON utilisateurs(token_reprise)
WHERE token_reprise IS NOT NULL;