feat(backend): #28 templates email validation compte (Handlebars)

- Templates .hbs parent principal, co-parent, AM + lien create-password
- app_name / app_url / expéditeur via ConfigService (sendEmail)
- validateUser: renouvelle token création MDP + envoi email si sans password
- Dist: assets *.hbs (nest-cli)
- Tests unitaires MailService (mock sendEmail)
- Doc liste tickets #28 terminé

Made-with: Cursor
This commit is contained in:
2026-04-17 18:34:04 +02:00
parent 68efa91c39
commit 94d9428c43
11 changed files with 439 additions and 32 deletions
@@ -0,0 +1,72 @@
import { Test, TestingModule } from '@nestjs/testing';
import { MailService, ValidationAccountEmailKind } from './mail.service';
import { AppConfigService } from '../config/config.service';
describe('MailService (ticket #28)', () => {
let service: MailService;
let sendEmailSpy: jest.SpyInstance;
const mockConfigGet = jest.fn((key: string, defaultValue?: unknown) => {
const values: Record<string, unknown> = {
app_name: 'TestApp',
app_url: 'https://app.test/',
smtp_host: '127.0.0.1',
smtp_port: 1025,
smtp_secure: false,
smtp_auth_required: false,
smtp_user: '',
smtp_password: '',
email_from_name: 'Test',
email_from_address: 'noreply@test',
};
if (key in values) return values[key];
return defaultValue;
});
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
MailService,
{
provide: AppConfigService,
useValue: { get: mockConfigGet },
},
],
}).compile();
service = module.get<MailService>(MailService);
sendEmailSpy = jest.spyOn(service, 'sendEmail').mockResolvedValue(undefined);
});
afterEach(() => {
sendEmailSpy.mockRestore();
});
it.each<[ValidationAccountEmailKind, string]>([
['parent_primary', 'Compte validé'],
['parent_coparent', 'Co-parent'],
['am', 'Inscription validée'],
])('sendValidatedAccountPasswordSetupEmail (%s) utilise Handlebars + lien token', async (kind, subjectPart) => {
const token = '11111111-2222-3333-4444-555555555555';
await service.sendValidatedAccountPasswordSetupEmail(
{
email: 'user@test.fr',
prenom: 'Jean',
nom: 'Dupont',
token,
numeroDossier: '2025-000001',
},
kind,
);
expect(sendEmailSpy).toHaveBeenCalledTimes(1);
const [, subject, html] = sendEmailSpy.mock.calls[0];
expect(subject).toContain('TestApp');
expect(subject).toContain(subjectPart);
expect(html).toContain('Jean');
expect(html).toContain('Dupont');
expect(html).toContain('2025-000001');
expect(html).toContain(`https://app.test/create-password?token=${encodeURIComponent(token)}`);
});
});
+68
View File
@@ -1,12 +1,80 @@
import { Injectable, Logger } from '@nestjs/common';
import { readFileSync } from 'fs';
import { join } from 'path';
import * as Handlebars from 'handlebars';
import { AppConfigService } from '../config/config.service';
/** Ticket #28 — quel template d'email envoyer après validation de compte */
export type ValidationAccountEmailKind = 'parent_primary' | 'parent_coparent' | 'am';
@Injectable()
export class MailService {
private readonly logger = new Logger(MailService.name);
/** Cache des templates Handlebars compilés (fichiers .hbs) */
private readonly compiledTemplates = new Map<ValidationAccountEmailKind, Handlebars.TemplateDelegate>();
constructor(private readonly configService: AppConfigService) {}
private templateBasename(kind: ValidationAccountEmailKind): string {
const map: Record<ValidationAccountEmailKind, string> = {
parent_primary: 'account-validated-parent-primary',
parent_coparent: 'account-validated-parent-coparent',
am: 'account-validated-am',
};
return map[kind];
}
private getCompiledTemplate(kind: ValidationAccountEmailKind): Handlebars.TemplateDelegate {
let compiled = this.compiledTemplates.get(kind);
if (!compiled) {
const filePath = join(__dirname, 'templates', `${this.templateBasename(kind)}.hbs`);
const source = readFileSync(filePath, 'utf8');
compiled = Handlebars.compile(source);
this.compiledTemplates.set(kind, compiled);
}
return compiled;
}
/**
* Email post-validation : lien création MDP (token existant ou régénéré côté appelant).
* Ticket #28 — app_name, app_url, expéditeur via ConfigService (sendEmail).
*/
async sendValidatedAccountPasswordSetupEmail(
recipient: {
email: string;
prenom: string;
nom: string;
token: string;
numeroDossier?: string | null;
},
kind: ValidationAccountEmailKind,
): 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') || '').replace(/\/+$/, '');
const createPasswordUrl = `${appUrl}/create-password?token=${encodeURIComponent(recipient.token)}`;
const data: Record<string, string> = {
prenom: recipient.prenom || '',
nom: recipient.nom || '',
appName,
appUrl,
createPasswordUrl,
numeroDossier: recipient.numeroDossier || '',
};
const html = this.getCompiledTemplate(kind)(data) as string;
const subjects: Record<ValidationAccountEmailKind, string> = {
parent_primary: `${appName} — Compte validé : créez votre mot de passe`,
parent_coparent: `${appName} — Co-parent : créez votre mot de passe`,
am: `${appName} — Inscription validée : créez votre mot de passe`,
};
await this.sendEmail(recipient.email, subjects[kind], html);
}
/**
* Envoi d'un email générique
* @param to Destinataire
@@ -0,0 +1,14 @@
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #4CAF50;">Bonjour {{prenom}} {{nom}},</h2>
<p>Votre demande d'inscription en tant qu'<strong>assistante maternelle</strong> sur <strong>{{appName}}</strong> a été <strong>validée</strong>.</p>
{{#if numeroDossier}}
<p><strong>Numéro de dossier :</strong> {{numeroDossier}}</p>
{{/if}}
<p>Pour finaliser l'activation de votre compte, veuillez <strong>créer votre mot de passe</strong> en cliquant sur le bouton ci-dessous.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{{createPasswordUrl}}}" style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Créer mon mot de passe</a>
</div>
<p style="color: #666; font-size: 13px;">Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :<br /><span style="word-break: break-all;">{{{createPasswordUrl}}}</span></p>
<hr style="border: 1px solid #eee; margin: 20px 0;" />
<p style="color: #666; font-size: 12px;">Cet email a été envoyé automatiquement par {{appName}}. Merci de ne pas y répondre.</p>
</div>
@@ -0,0 +1,14 @@
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #4CAF50;">Bonjour {{prenom}} {{nom}},</h2>
<p>Le dossier d'inscription sur <strong>{{appName}}</strong> auquel vous êtes rattaché·e en tant que <strong>co-parent</strong> a été <strong>validé</strong>.</p>
{{#if numeroDossier}}
<p><strong>Numéro de dossier :</strong> {{numeroDossier}}</p>
{{/if}}
<p>Vous devez maintenant <strong>définir votre mot de passe</strong> pour accéder à l'application avec votre propre compte.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{{createPasswordUrl}}}" style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Créer mon mot de passe</a>
</div>
<p style="color: #666; font-size: 13px;">Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :<br /><span style="word-break: break-all;">{{{createPasswordUrl}}}</span></p>
<hr style="border: 1px solid #eee; margin: 20px 0;" />
<p style="color: #666; font-size: 12px;">Cet email a été envoyé automatiquement par {{appName}}. Merci de ne pas y répondre.</p>
</div>
@@ -0,0 +1,14 @@
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #4CAF50;">Bonjour {{prenom}} {{nom}},</h2>
<p>Votre demande d'inscription sur <strong>{{appName}}</strong> a été <strong>validée</strong>.</p>
{{#if numeroDossier}}
<p><strong>Numéro de dossier :</strong> {{numeroDossier}}</p>
{{/if}}
<p>En tant que <strong>demandeur principal</strong>, vous devez maintenant <strong>définir votre mot de passe</strong> pour accéder à l'application.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{{createPasswordUrl}}}" style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Créer mon mot de passe</a>
</div>
<p style="color: #666; font-size: 13px;">Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :<br /><span style="word-break: break-all;">{{{createPasswordUrl}}}</span></p>
<hr style="border: 1px solid #eee; margin: 20px 0;" />
<p style="color: #666; font-size: 12px;">Cet email a été envoyé automatiquement par {{appName}}. Merci de ne pas y répondre.</p>
</div>
+2
View File
@@ -11,6 +11,7 @@ import { AssistantesMaternellesModule } from '../assistantes_maternelles/assista
import { Parents } from 'src/entities/parents.entity';
import { GestionnairesModule } from './gestionnaires/gestionnaires.module';
import { MailModule } from 'src/modules/mail/mail.module';
import { AppConfigModule } from 'src/modules/config/config.module';
@Module({
imports: [TypeOrmModule.forFeature(
@@ -24,6 +25,7 @@ import { MailModule } from 'src/modules/mail/mail.module';
AssistantesMaternellesModule,
GestionnairesModule,
MailModule,
AppConfigModule,
],
controllers: [UserController],
providers: [UserService],
+55 -1
View File
@@ -9,7 +9,8 @@ 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 { MailService, ValidationAccountEmailKind } from "src/modules/mail/mail.service";
import { AppConfigService } from "src/modules/config/config.service";
import * as crypto from 'crypto';
@Injectable()
@@ -30,6 +31,8 @@ export class UserService {
private readonly assistantesRepository: Repository<AssistanteMaternelle>,
private readonly mailService: MailService,
private readonly appConfigService: AppConfigService,
) { }
async createUser(dto: CreateUserDto, currentUser?: Users): Promise<Users> {
@@ -265,6 +268,57 @@ export class UserService {
comment,
});
await this.validationRepository.save(validation);
// Ticket #28 — email création MDP (parents / AM), Handlebars + lien app_url
const roleCibleMdp =
savedUser.role === RoleType.PARENT || savedUser.role === RoleType.ASSISTANTE_MATERNELLE;
if (roleCibleMdp && !savedUser.password) {
try {
const joursExpiration = await this.appConfigService.get<number>(
'password_reset_token_expiry_days',
7,
);
if (!savedUser.token_creation_mdp) {
savedUser.token_creation_mdp = crypto.randomUUID();
}
const exp = new Date();
exp.setDate(exp.getDate() + joursExpiration);
savedUser.token_creation_mdp_expire_le = exp;
await this.usersRepository.save(savedUser);
let kind: ValidationAccountEmailKind = 'am';
if (savedUser.role === RoleType.ASSISTANTE_MATERNELLE) {
kind = 'am';
} else if (savedUser.numero_dossier) {
const parentsDossier = await this.usersRepository.find({
where: { numero_dossier: savedUser.numero_dossier, role: RoleType.PARENT },
order: { cree_le: 'ASC' },
});
const premier = parentsDossier[0];
kind =
premier && premier.id !== savedUser.id ? 'parent_coparent' : 'parent_primary';
} else {
kind = 'parent_primary';
}
await this.mailService.sendValidatedAccountPasswordSetupEmail(
{
email: savedUser.email,
prenom: savedUser.prenom ?? '',
nom: savedUser.nom ?? '',
token: savedUser.token_creation_mdp,
numeroDossier: savedUser.numero_dossier,
},
kind,
);
} catch (err) {
this.logger.warn(
`Envoi email validation compte (#28) échoué pour ${savedUser.email}`,
err as Error,
);
}
}
return savedUser;
}