Checkpoint: Implémentation de trois fonctionnalités majeures :

1. Personnalisation des templates d'emails avec variables {{lieu}} et {{formateur}} (déjà disponible)
2. Aperçu avant envoi avec données réelles d'un apprenant (modal avec iframe)
3. Email de rappel J-1 avec bouton d'envoi et d'aperçu

Les templates personnalisés supportent maintenant toutes les variables disponibles.
L'aperçu permet de vérifier le rendu final avant l'envoi groupé.
Le rappel J-1 complète le système de communication avec les apprenants.
This commit is contained in:
Manus Sandbox
2025-11-30 13:33:32 -05:00
parent 9e1d574d2e
commit d73db2820c
5 changed files with 401 additions and 9 deletions

176
server/emailPreview.ts Normal file
View File

@@ -0,0 +1,176 @@
/**
* Génération d'aperçus d'emails avec données réelles
*/
import * as db from "./db";
import { generateEmailFromTemplate } from "./emailTemplateGenerator";
export interface EmailPreviewParams {
sequenceId: number;
type: 'teaser' | 'rappel' | 'rappel_j1';
apprenantId?: number; // Si non fourni, prendre le premier apprenant inscrit
}
export async function generateEmailPreview(params: EmailPreviewParams): Promise<{
html: string;
subject: string;
recipient: {
email: string;
nom: string;
prenom: string;
};
}> {
// Récupérer la séquence
const sequence = await db.getSequenceById(params.sequenceId);
if (!sequence) {
throw new Error('Séquence non trouvée');
}
// Récupérer la formation
const formation = await db.getFormationById(sequence.formationId);
if (!formation) {
throw new Error('Formation non trouvée');
}
// Récupérer les dates
const dates = await db.getDatesBySequence(sequence.id);
if (dates.length === 0) {
throw new Error('Aucune date trouvée pour cette séquence');
}
// Récupérer le formateur si disponible
let formateurNom: string | undefined;
if (sequence.formateurId) {
const formateur = await db.getFormateurById(sequence.formateurId);
formateurNom = formateur?.nom;
}
// Récupérer un apprenant inscrit
const inscriptions = await db.getInscriptionsBySequence(params.sequenceId);
const confirmedInscriptions = inscriptions.filter(i => i.inscription.statut === 'confirmee' && i.apprenant);
if (confirmedInscriptions.length === 0) {
throw new Error('Aucun apprenant confirmé trouvé pour cette séquence');
}
// Utiliser l'apprenant spécifié ou le premier de la liste
let selectedInscription = confirmedInscriptions[0];
if (params.apprenantId) {
const found = confirmedInscriptions.find(i => i.apprenant!.id === params.apprenantId);
if (found) {
selectedInscription = found;
}
}
const apprenant = selectedInscription.apprenant!;
// Préparer les données pour l'email
const fonctionLabel = apprenant.fonction === 'directeur' ? 'Directeur' :
apprenant.fonction === 'chef_service' ? 'Chef de service' : '';
const salutation = fonctionLabel ? `${fonctionLabel} ${apprenant.prenom} ${apprenant.nom}` : apprenant.prenom;
const datesHTML = dates.map(date => `
<div class="date-item">
<strong>Date ${date.ordre} :</strong> ${new Date(date.dateDebut).toLocaleDateString('fr-FR', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</div>
`).join('');
// Générer le contenu selon le type
let content: string;
let subject: string;
if (params.type === 'teaser') {
content = `
<h2>Votre formation approche !</h2>
<p>Bonjour ${salutation},</p>
<p>Nous sommes ravis de vous accueillir prochainement pour la formation <strong>${formation.nom}</strong>.</p>
<div class="info-box">
<p><strong>Séquence :</strong> ${sequence.nom}</p>
<h4>Dates :</h4>
${datesHTML}
${sequence.lieu ? `<p><strong>Lieu :</strong> ${sequence.lieu}</p>` : ''}
${formateurNom ? `<p><strong>Formateur :</strong> ${formateurNom}</p>` : ''}
</div>
<p>Préparez-vous à vivre une expérience enrichissante qui vous permettra de développer vos compétences managériales.</p>
<p>À très bientôt !</p>
`;
subject = `Votre formation ${formation.nom} approche !`;
} else if (params.type === 'rappel') {
// Rappel J-7
content = `
<h2>Rappel : Votre formation commence bientôt !</h2>
<p>Bonjour ${salutation},</p>
<p>Nous vous rappelons que votre formation <strong>${formation.nom}</strong> commence dans une semaine.</p>
<div class="info-box">
<p><strong>Séquence :</strong> ${sequence.nom}</p>
<h4>Dates :</h4>
${datesHTML}
${sequence.lieu ? `<p><strong>Lieu :</strong> ${sequence.lieu}</p>` : ''}
${formateurNom ? `<p><strong>Formateur :</strong> ${formateurNom}</p>` : ''}
</div>
<p>N'oubliez pas d'apporter le matériel nécessaire et de vous présenter à l'heure indiquée.</p>
<p>À très bientôt !</p>
`;
subject = `Rappel J-7 : Formation ${formation.nom}`;
} else {
// Rappel J-1
content = `
<h2>Rappel : Votre formation commence demain !</h2>
<p>Bonjour ${salutation},</p>
<p>Nous vous rappelons que votre formation <strong>${formation.nom}</strong> commence <strong>demain</strong>.</p>
<div class="info-box">
<p><strong>Séquence :</strong> ${sequence.nom}</p>
<h4>Dates :</h4>
${datesHTML}
${sequence.lieu ? `<p><strong>Lieu :</strong> ${sequence.lieu}</p>` : ''}
${formateurNom ? `<p><strong>Formateur :</strong> ${formateurNom}</p>` : ''}
</div>
<p><strong>Merci de vous présenter à l'heure indiquée.</strong></p>
<p>N'oubliez pas d'apporter le matériel nécessaire.</p>
<p>À demain !</p>
`;
subject = `Rappel J-1 : Formation ${formation.nom} - C'est demain !`;
}
// Préparer les variables
const variables = {
nomApprenant: apprenant.nom,
prenomApprenant: apprenant.prenom,
nomFormation: formation.nom,
nomSequence: sequence.nom,
dateDebut: new Date(dates[0].dateDebut).toLocaleDateString('fr-FR'),
dateFin: new Date(dates[dates.length - 1].dateFin).toLocaleDateString('fr-FR'),
lieu: sequence.lieu || '',
formateur: formateurNom || '',
};
// Générer le HTML final avec le template
const html = await generateEmailFromTemplate(params.type === 'teaser' ? 'teaser' : 'rappel', content, variables);
return {
html,
subject,
recipient: {
email: apprenant.email,
nom: apprenant.nom,
prenom: apprenant.prenom,
},
};
}

View File

@@ -278,6 +278,81 @@ export async function sendRappelJ7Email(params: {
});
}
/**
* Envoie un email de rappel J-1 à un apprenant
*/
export async function sendRappelJ1Email(params: {
apprenantEmail: string;
apprenantNom: string;
apprenantPrenom: string;
apprenantFonction: string;
formationNom: string;
sequenceNom: string;
dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>;
lieu?: string;
formateur?: string;
}): Promise<boolean> {
const fonctionLabel = params.apprenantFonction === 'directeur' ? 'Directeur' :
params.apprenantFonction === 'chef_service' ? 'Chef de service' : '';
const salutation = fonctionLabel ? `${fonctionLabel} ${params.apprenantPrenom} ${params.apprenantNom}` : params.apprenantPrenom;
const datesHTML = params.dates.map(date => `
<div class="date-item">
<strong>Date ${date.ordre} :</strong> ${date.dateDebut.toLocaleDateString('fr-FR', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})} - ${date.dateFin.toLocaleDateString('fr-FR', {
hour: '2-digit',
minute: '2-digit'
})}
</div>
`).join('');
const content = `
<h2>Rappel : Votre formation commence demain !</h2>
<p>Bonjour ${salutation},</p>
<p>Nous vous rappelons que votre formation <strong>${params.formationNom}</strong> commence <strong>demain</strong>.</p>
<div class="info-box">
<h3>Informations pratiques</h3>
<p><strong>Séquence :</strong> ${params.sequenceNom}</p>
${params.lieu ? `<p><strong>Lieu :</strong> ${params.lieu}</p>` : ''}
${params.formateur ? `<p><strong>Formateur :</strong> ${params.formateur}</p>` : ''}
<h4>Dates :</h4>
${datesHTML}
</div>
<p><strong>Merci de vous présenter à l'heure indiquée.</strong></p>
<p>N'oubliez pas d'apporter le matériel nécessaire.</p>
<p>Si vous avez des questions de dernière minute, n'hésitez pas à contacter le service RH.</p>
<p>À demain !</p>
`;
const variables = {
nomApprenant: params.apprenantNom,
prenomApprenant: params.apprenantPrenom,
nomFormation: params.formationNom,
nomSequence: params.sequenceNom,
dateDebut: params.dates[0]?.dateDebut.toLocaleDateString('fr-FR') || '',
dateFin: params.dates[params.dates.length - 1]?.dateFin.toLocaleDateString('fr-FR') || '',
lieu: params.lieu || '',
formateur: params.formateur || '',
};
return sendEmail({
to: params.apprenantEmail,
subject: `Rappel J-1 : ${params.formationNom} - C'est demain !`,
html: await getEmailTemplate(content, 'rappel', variables),
});
}
/**
* Envoie des emails groupés à tous les inscrits d'une séquence
*/
@@ -285,7 +360,7 @@ export async function sendGroupEmail(params: {
recipients: Array<{ email: string; prenom: string; nom: string; fonction: string }>;
formationNom: string;
sequenceNom: string;
type: 'teaser' | 'rappel';
type: 'teaser' | 'rappel' | 'rappel_j1';
dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>;
lieu?: string;
formateur?: string;
@@ -307,7 +382,7 @@ export async function sendGroupEmail(params: {
lieu: params.lieu,
formateur: params.formateur,
});
} else {
} else if (params.type === 'rappel') {
await sendRappelJ7Email({
apprenantEmail: recipient.email,
apprenantPrenom: recipient.prenom,
@@ -318,6 +393,19 @@ export async function sendGroupEmail(params: {
dates: params.dates,
lieu: params.lieu || '',
});
} else {
// rappel_j1
await sendRappelJ1Email({
apprenantEmail: recipient.email,
apprenantPrenom: recipient.prenom,
apprenantNom: recipient.nom,
apprenantFonction: recipient.fonction,
formationNom: params.formationNom,
sequenceNom: params.sequenceNom,
dates: params.dates,
lieu: params.lieu,
formateur: params.formateur,
});
}
sent++;
} catch (error) {

View File

@@ -493,10 +493,7 @@ export const appRouter = router({
return { success: true };
}),
sendGroupEmail: adminProcedure.input(z.object({
sequenceId: z.number(),
type: z.enum(['teaser', 'rappel']),
})).mutation(async ({ input }) => {
sendGroupEmail: adminProcedure.input(z.object({ sequenceId: z.number(), type: z.enum(['teaser', 'rappel', 'rappel_j1']) })).mutation(async ({ input }) => {
const sequence = await db.getSequenceById(input.sequenceId);
if (!sequence) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
@@ -553,6 +550,17 @@ export const appRouter = router({
};
}),
emailPreview: adminProcedure
.input(z.object({
sequenceId: z.number(),
type: z.enum(['teaser', 'rappel', 'rappel_j1']),
apprenantId: z.number().optional(),
}))
.query(async ({ input }) => {
const { generateEmailPreview } = await import('./emailPreview');
return await generateEmailPreview(input);
}),
exportExcel: adminProcedure.input(z.object({ sequenceId: z.number() })).mutation(async ({ input }) => {
const sequence = await db.getSequenceById(input.sequenceId);
if (!sequence) throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });