diff --git a/backend/src/entities/users.entity.ts b/backend/src/entities/users.entity.ts index d44726e..94d12bb 100644 --- a/backend/src/entities/users.entity.ts +++ b/backend/src/entities/users.entity.ts @@ -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; diff --git a/backend/src/modules/mail/mail.service.ts b/backend/src/modules/mail/mail.service.ts index c064915..6a1e872 100644 --- a/backend/src/modules/mail/mail.service.ts +++ b/backend/src/modules/mail/mail.service.ts @@ -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 { + const appName = this.configService.get('app_name', "P'titsPas"); + const appUrl = this.configService.get('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 + ? `

Message du gestionnaire :

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

` + : ''; + const html = ` +
+

Bonjour ${prenom} ${nom},

+

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

+ ${commentBlock} +

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

+
+ Reprendre mon dossier +
+

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

+
+

Cet email a été envoyé automatiquement. Merci de ne pas y répondre.

+
+ `; + + await this.sendEmail(to, subject, html); + } } diff --git a/backend/src/routes/user/user.module.ts b/backend/src/routes/user/user.module.ts index 4d5d7cc..2924082 100644 --- a/backend/src/routes/user/user.module.ts +++ b/backend/src/routes/user/user.module.ts @@ -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], diff --git a/backend/src/routes/user/user.service.ts b/backend/src/routes/user/user.service.ts index e6c270c..83b6abb 100644 --- a/backend/src/routes/user/user.service.ts +++ b/backend/src/routes/user/user.service.ts @@ -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, @@ -23,7 +27,9 @@ export class UserService { private readonly parentsRepository: Repository, @InjectRepository(AssistanteMaternelle) - private readonly assistantesRepository: Repository + private readonly assistantesRepository: Repository, + + private readonly mailService: MailService, ) { } async createUser(dto: CreateUserDto, currentUser?: Users): Promise { @@ -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 { 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; } diff --git a/database/BDD.sql b/database/BDD.sql index 57d67ee..7a7ab17 100644 --- a/database/BDD.sql +++ b/database/BDD.sql @@ -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 -- ========================================================== diff --git a/database/migrations/2026_token_reprise_refus.sql b/database/migrations/2026_token_reprise_refus.sql new file mode 100644 index 0000000..f805840 --- /dev/null +++ b/database/migrations/2026_token_reprise_refus.sql @@ -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;