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:
parent
dbcb3611d4
commit
86d8189038
@ -119,6 +119,13 @@ export class Users {
|
|||||||
@Column({ type: 'timestamptz', nullable: true, name: 'token_creation_mdp_expire_le' })
|
@Column({ type: 'timestamptz', nullable: true, name: 'token_creation_mdp_expire_le' })
|
||||||
token_creation_mdp_expire_le?: Date;
|
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' })
|
@Column({ nullable: true, name: 'ville' })
|
||||||
ville?: string;
|
ville?: string;
|
||||||
|
|
||||||
|
|||||||
@ -97,4 +97,41 @@ export class MailService {
|
|||||||
|
|
||||||
await this.sendEmail(to, subject, html);
|
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, '<').replace(/>/g, '>')}</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 été envoyé automatiquement. Merci de ne pas y répondre.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
await this.sendEmail(to, subject, html);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entit
|
|||||||
import { AssistantesMaternellesModule } from '../assistantes_maternelles/assistantes_maternelles.module';
|
import { AssistantesMaternellesModule } from '../assistantes_maternelles/assistantes_maternelles.module';
|
||||||
import { Parents } from 'src/entities/parents.entity';
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
import { GestionnairesModule } from './gestionnaires/gestionnaires.module';
|
import { GestionnairesModule } from './gestionnaires/gestionnaires.module';
|
||||||
|
import { MailModule } from 'src/modules/mail/mail.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature(
|
imports: [TypeOrmModule.forFeature(
|
||||||
@ -22,6 +23,7 @@ import { GestionnairesModule } from './gestionnaires/gestionnaires.module';
|
|||||||
ParentsModule,
|
ParentsModule,
|
||||||
AssistantesMaternellesModule,
|
AssistantesMaternellesModule,
|
||||||
GestionnairesModule,
|
GestionnairesModule,
|
||||||
|
MailModule,
|
||||||
],
|
],
|
||||||
controllers: [UserController],
|
controllers: [UserController],
|
||||||
providers: [UserService],
|
providers: [UserService],
|
||||||
|
|||||||
@ -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 { InjectRepository } from "@nestjs/typeorm";
|
||||||
import { RoleType, StatutUtilisateurType, Users } from "src/entities/users.entity";
|
import { RoleType, StatutUtilisateurType, Users } from "src/entities/users.entity";
|
||||||
import { In, Repository } from "typeorm";
|
import { In, Repository } from "typeorm";
|
||||||
@ -9,9 +9,13 @@ import * as bcrypt from 'bcrypt';
|
|||||||
import { StatutValidationType, Validation } from "src/entities/validations.entity";
|
import { StatutValidationType, Validation } from "src/entities/validations.entity";
|
||||||
import { Parents } from "src/entities/parents.entity";
|
import { Parents } from "src/entities/parents.entity";
|
||||||
import { AssistanteMaternelle } from "src/entities/assistantes_maternelles.entity";
|
import { AssistanteMaternelle } from "src/entities/assistantes_maternelles.entity";
|
||||||
|
import { MailService } from "src/modules/mail/mail.service";
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserService {
|
export class UserService {
|
||||||
|
private readonly logger = new Logger(UserService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Users)
|
@InjectRepository(Users)
|
||||||
private readonly usersRepository: Repository<Users>,
|
private readonly usersRepository: Repository<Users>,
|
||||||
@ -23,7 +27,9 @@ export class UserService {
|
|||||||
private readonly parentsRepository: Repository<Parents>,
|
private readonly parentsRepository: Repository<Parents>,
|
||||||
|
|
||||||
@InjectRepository(AssistanteMaternelle)
|
@InjectRepository(AssistanteMaternelle)
|
||||||
private readonly assistantesRepository: Repository<AssistanteMaternelle>
|
private readonly assistantesRepository: Repository<AssistanteMaternelle>,
|
||||||
|
|
||||||
|
private readonly mailService: MailService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async createUser(dto: CreateUserDto, currentUser?: Users): Promise<Users> {
|
async createUser(dto: CreateUserDto, currentUser?: Users): Promise<Users> {
|
||||||
@ -284,7 +290,7 @@ export class UserService {
|
|||||||
return savedUser;
|
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> {
|
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');
|
||||||
@ -294,8 +300,16 @@ export class UserService {
|
|||||||
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 compte en attente peut être refusé.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tokenReprise = crypto.randomUUID();
|
||||||
|
const expireLe = new Date();
|
||||||
|
expireLe.setDate(expireLe.getDate() + 7);
|
||||||
|
|
||||||
user.statut = StatutUtilisateurType.REFUSE;
|
user.statut = StatutUtilisateurType.REFUSE;
|
||||||
|
user.token_reprise = tokenReprise;
|
||||||
|
user.token_reprise_expire_le = expireLe;
|
||||||
const savedUser = await this.usersRepository.save(user);
|
const savedUser = await this.usersRepository.save(user);
|
||||||
|
|
||||||
const validation = this.validationRepository.create({
|
const validation = this.validationRepository.create({
|
||||||
user: savedUser,
|
user: savedUser,
|
||||||
type: 'refus_compte',
|
type: 'refus_compte',
|
||||||
@ -304,6 +318,19 @@ export class UserService {
|
|||||||
comment,
|
comment,
|
||||||
});
|
});
|
||||||
await this.validationRepository.save(validation);
|
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;
|
return savedUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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_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;
|
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
|
-- Seed : Documents légaux génériques v1
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
|
|||||||
10
database/migrations/2026_token_reprise_refus.sql
Normal file
10
database/migrations/2026_token_reprise_refus.sql
Normal 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;
|
||||||
Loading…
x
Reference in New Issue
Block a user