Checkpoint: Ajout d'une interface d'administration complète pour personnaliser les templates d'emails avec prévisualisation en temps réel, stockage en base de données et intégration avec le service d'envoi

This commit is contained in:
Manus Sandbox
2025-11-17 17:11:27 -05:00
parent f06cd3e8a5
commit 20dc80cd74
14 changed files with 1480 additions and 40 deletions

View File

@@ -0,0 +1,9 @@
{
"query": "SELECT * FROM emailTemplates;",
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway02.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 4CrrYuB5tme73Qo.root --database 7PAT67UmWoxv8vwp8Bbcv6 --execute SELECT * FROM emailTemplates;",
"rows": [],
"messages": [],
"stdout": "",
"stderr": "",
"execution_time_ms": 57
}

View File

@@ -0,0 +1,9 @@
{
"query": "INSERT INTO emailTemplates (type, name, logoUrl, primaryColor, headerBgColor, headerTextColor, headerTitle, footerText, active) VALUES \n('inscription', 'Confirmation d''inscription', NULL, '#2563eb', '#2563eb', '#ffffff', 'Formation Manager Itinova', 'Cet email a été envoyé automatiquement par le système de gestion des formations Itinova. Pour toute question, veuillez contacter le service RH.', 1),\n('teaser', 'Email teaser', NULL, '#2563eb', '#2563eb', '#ffffff', 'Formation Manager Itinova', 'Cet email a été envoyé automatiquement par le système de gestion des formations Itinova. Pour toute question, veuillez contacter le service RH.', 1),\n('rappel', 'Rappel J-7', NULL, '#2563eb', '#2563eb', '#ffffff', 'Formation Manager Itinova', 'Cet email a été envoyé automatiquement par le système de gestion des formations Itinova. Pour toute question, veuillez contacter le service RH.', 1),\n('reset_password', 'Réinitialisation de mot de passe', NULL, '#2563eb', '#2563eb', '#ffffff', 'Formation Manager Itinova', 'Cet email a été envoyé automatiquement par le système de gestion des formations Itinova. Pour toute question, veuillez contacter le service RH.', 1);",
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway02.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 4CrrYuB5tme73Qo.root --database 7PAT67UmWoxv8vwp8Bbcv6 --execute INSERT INTO emailTemplates (type, name, logoUrl, primaryColor, headerBgColor, headerTextColor, headerTitle, footerText, active) VALUES \n('inscription', 'Confirmation d''inscription', NULL, '#2563eb', '#2563eb', '#ffffff', 'Formation Manager Itinova', 'Cet email a été envoyé automatiquement par le système de gestion des formations Itinova. Pour toute question, veuillez contacter le service RH.', 1),\n('teaser', 'Email teaser', NULL, '#2563eb', '#2563eb', '#ffffff', 'Formation Manager Itinova', 'Cet email a été envoyé automatiquement par le système de gestion des formations Itinova. Pour toute question, veuillez contacter le service RH.', 1),\n('rappel', 'Rappel J-7', NULL, '#2563eb', '#2563eb', '#ffffff', 'Formation Manager Itinova', 'Cet email a été envoyé automatiquement par le système de gestion des formations Itinova. Pour toute question, veuillez contacter le service RH.', 1),\n('reset_password', 'Réinitialisation de mot de passe', NULL, '#2563eb', '#2563eb', '#ffffff', 'Formation Manager Itinova', 'Cet email a été envoyé automatiquement par le système de gestion des formations Itinova. Pour toute question, veuillez contacter le service RH.', 1);",
"rows": [],
"messages": [],
"stdout": "",
"stderr": "",
"execution_time_ms": 64
}

View File

@@ -12,6 +12,7 @@ import AdminSequences from "./pages/AdminSequences";
import AdminApprenants from "./pages/AdminApprenants"; import AdminApprenants from "./pages/AdminApprenants";
import AdminSequenceInscrits from "./pages/AdminSequenceInscrits"; import AdminSequenceInscrits from "./pages/AdminSequenceInscrits";
import AdminUsers from "./pages/AdminUsers"; import AdminUsers from "./pages/AdminUsers";
import AdminEmailTemplates from "./pages/AdminEmailTemplates";
import Inscription from "./pages/Inscription"; import Inscription from "./pages/Inscription";
function Router() { function Router() {
@@ -26,6 +27,7 @@ function Router() {
<Route path={"/admin/apprenants"} component={AdminApprenants} /> <Route path={"/admin/apprenants"} component={AdminApprenants} />
<Route path={"/admin/sequences/:id/inscrits"} component={AdminSequenceInscrits} /> <Route path={"/admin/sequences/:id/inscrits"} component={AdminSequenceInscrits} />
<Route path={"/admin/users"} component={AdminUsers} /> <Route path={"/admin/users"} component={AdminUsers} />
<Route path={"/admin/email-templates"} component={AdminEmailTemplates} />
<Route path={"/404"} component={NotFound} /> <Route path={"/404"} component={NotFound} />
{/* Final fallback route */} {/* Final fallback route */}
<Route component={NotFound} /> <Route component={NotFound} />

View File

@@ -21,7 +21,7 @@ import {
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { APP_LOGO, APP_TITLE, getLoginUrl } from "@/const"; import { APP_LOGO, APP_TITLE, getLoginUrl } from "@/const";
import { useIsMobile } from "@/hooks/useMobile"; import { useIsMobile } from "@/hooks/useMobile";
import { LayoutDashboard, LogOut, PanelLeft, Users, GraduationCap, Calendar, BarChart3, UserCog } from "lucide-react"; import { LayoutDashboard, LogOut, PanelLeft, Users, GraduationCap, Calendar, BarChart3, UserCog, Mail } from "lucide-react";
import { CSSProperties, useEffect, useRef, useState } from "react"; import { CSSProperties, useEffect, useRef, useState } from "react";
import { useLocation } from "wouter"; import { useLocation } from "wouter";
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton'; import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
@@ -33,6 +33,7 @@ const menuItems = [
{ icon: Calendar, label: "Séquences", path: "/admin/sequences" }, { icon: Calendar, label: "Séquences", path: "/admin/sequences" },
{ icon: Users, label: "Apprenants", path: "/admin/apprenants" }, { icon: Users, label: "Apprenants", path: "/admin/apprenants" },
{ icon: UserCog, label: "Utilisateurs", path: "/admin/users" }, { icon: UserCog, label: "Utilisateurs", path: "/admin/users" },
{ icon: Mail, label: "Templates d'emails", path: "/admin/email-templates" },
{ icon: BarChart3, label: "Rapport Public Cible", path: "/admin/rapport-public-cible" }, { icon: BarChart3, label: "Rapport Public Cible", path: "/admin/rapport-public-cible" },
]; ];

View File

@@ -0,0 +1,364 @@
import { useState, useEffect } from "react";
import { trpc } from "@/lib/trpc";
import DashboardLayout from "@/components/DashboardLayout";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { toast } from "sonner";
import { Mail, Eye, Save, RotateCcw } from "lucide-react";
export default function AdminEmailTemplates() {
const [selectedType, setSelectedType] = useState<string>("inscription");
const [formData, setFormData] = useState({
type: "inscription",
name: "",
logoUrl: "",
primaryColor: "#2563eb",
headerBgColor: "#2563eb",
headerTextColor: "#ffffff",
headerTitle: "Formation Manager Itinova",
footerText: "",
active: true,
});
const { data: templates, refetch } = trpc.emailTemplates.list.useQuery();
const { data: currentTemplate } = trpc.emailTemplates.getByType.useQuery(
{ type: selectedType },
{ enabled: !!selectedType }
);
const upsertMutation = trpc.emailTemplates.upsert.useMutation({
onSuccess: () => {
toast.success("Template enregistré avec succès");
refetch();
},
onError: (error) => {
toast.error(`Erreur: ${error.message}`);
},
});
const initMutation = trpc.emailTemplates.initializeDefaults.useMutation({
onSuccess: () => {
toast.success("Templates par défaut initialisés");
refetch();
},
onError: (error) => {
toast.error(`Erreur: ${error.message}`);
},
});
// Charger le template sélectionné
useEffect(() => {
if (currentTemplate) {
setFormData({
type: currentTemplate.type,
name: currentTemplate.name,
logoUrl: currentTemplate.logoUrl || "",
primaryColor: currentTemplate.primaryColor,
headerBgColor: currentTemplate.headerBgColor,
headerTextColor: currentTemplate.headerTextColor,
headerTitle: currentTemplate.headerTitle,
footerText: currentTemplate.footerText || "",
active: currentTemplate.active,
});
}
}, [currentTemplate]);
const handleSave = () => {
upsertMutation.mutate({
...formData,
logoUrl: formData.logoUrl || null,
footerText: formData.footerText || null,
});
};
const handleReset = () => {
if (currentTemplate) {
setFormData({
type: currentTemplate.type,
name: currentTemplate.name,
logoUrl: currentTemplate.logoUrl || "",
primaryColor: currentTemplate.primaryColor,
headerBgColor: currentTemplate.headerBgColor,
headerTextColor: currentTemplate.headerTextColor,
headerTitle: currentTemplate.headerTitle,
footerText: currentTemplate.footerText || "",
active: currentTemplate.active,
});
}
};
const handleInitDefaults = () => {
if (confirm("Initialiser les templates par défaut ? Cela ne modifiera pas les templates existants.")) {
initMutation.mutate();
}
};
// Générer la prévisualisation HTML
const generatePreview = () => {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 20px; background-color: #f5f5f5; }
.container { max-width: 600px; margin: 0 auto; background-color: white; }
.header { background-color: ${formData.headerBgColor}; color: ${formData.headerTextColor}; padding: 30px 20px; text-align: center; }
.header h1 { margin: 0; font-size: 24px; }
${formData.logoUrl ? `.header img { max-width: 150px; margin-bottom: 15px; }` : ''}
.content { background-color: #f9fafb; padding: 30px 20px; }
.content h2 { color: ${formData.primaryColor}; margin-top: 0; }
.footer { background-color: #f3f4f6; padding: 20px; text-align: center; font-size: 12px; color: #6b7280; }
.button { display: inline-block; padding: 12px 24px; background-color: ${formData.primaryColor}; color: white; text-decoration: none; border-radius: 6px; margin: 10px 0; }
.info-box { background-color: #dbeafe; border-left: 4px solid ${formData.primaryColor}; padding: 15px; margin: 15px 0; }
</style>
</head>
<body>
<div class="container">
<div class="header">
${formData.logoUrl ? `<img src="${formData.logoUrl}" alt="Logo" />` : ''}
<h1>${formData.headerTitle}</h1>
</div>
<div class="content">
<h2>Exemple de contenu</h2>
<p>Bonjour,</p>
<p>Ceci est un exemple de prévisualisation de votre template d'email personnalisé.</p>
<div class="info-box">
<p><strong>Information importante</strong></p>
<p>Ce bloc utilise la couleur principale que vous avez choisie.</p>
</div>
<div style="text-align: center; margin: 20px 0;">
<a href="#" class="button">Bouton d'action</a>
</div>
<p>Cordialement,<br/>L'équipe Formation Manager Itinova</p>
</div>
<div class="footer">
<p>${formData.footerText || 'Texte du pied de page'}</p>
</div>
</div>
</body>
</html>
`.trim();
};
const templateTypes = [
{ value: "inscription", label: "Confirmation d'inscription" },
{ value: "teaser", label: "Email teaser" },
{ value: "rappel", label: "Rappel J-7" },
{ value: "reset_password", label: "Réinitialisation de mot de passe" },
];
return (
<DashboardLayout>
<div className="mb-6">
<h1 className="text-3xl font-bold mb-2">Templates d'emails</h1>
<p className="text-muted-foreground">
Personnalisez l'apparence de vos emails automatiques
</p>
</div>
{(!templates || templates.length === 0) && (
<Card className="mb-6 border-yellow-200 bg-yellow-50">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="h-5 w-5" />
Initialisation requise
</CardTitle>
<CardDescription>
Aucun template trouvé. Initialisez les templates par défaut pour commencer.
</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={handleInitDefaults} disabled={initMutation.isPending}>
{initMutation.isPending ? "Initialisation..." : "Initialiser les templates par défaut"}
</Button>
</CardContent>
</Card>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Éditeur */}
<Card>
<CardHeader>
<CardTitle>Éditeur de template</CardTitle>
<CardDescription>
Modifiez les paramètres du template sélectionné
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="templateType">Type de template</Label>
<Select
value={selectedType}
onValueChange={(value) => {
setSelectedType(value);
setFormData((prev) => ({ ...prev, type: value }));
}}
>
<SelectTrigger id="templateType">
<SelectValue />
</SelectTrigger>
<SelectContent>
{templateTypes.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="name">Nom du template</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Ex: Confirmation d'inscription"
/>
</div>
<div>
<Label htmlFor="logoUrl">URL du logo (optionnel)</Label>
<Input
id="logoUrl"
value={formData.logoUrl}
onChange={(e) => setFormData({ ...formData, logoUrl: e.target.value })}
placeholder="https://exemple.com/logo.png"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="headerBgColor">Couleur fond en-tête</Label>
<div className="flex gap-2">
<Input
id="headerBgColor"
type="color"
value={formData.headerBgColor}
onChange={(e) => setFormData({ ...formData, headerBgColor: e.target.value })}
className="w-16 h-10 p-1"
/>
<Input
value={formData.headerBgColor}
onChange={(e) => setFormData({ ...formData, headerBgColor: e.target.value })}
placeholder="#2563eb"
/>
</div>
</div>
<div>
<Label htmlFor="headerTextColor">Couleur texte en-tête</Label>
<div className="flex gap-2">
<Input
id="headerTextColor"
type="color"
value={formData.headerTextColor}
onChange={(e) => setFormData({ ...formData, headerTextColor: e.target.value })}
className="w-16 h-10 p-1"
/>
<Input
value={formData.headerTextColor}
onChange={(e) => setFormData({ ...formData, headerTextColor: e.target.value })}
placeholder="#ffffff"
/>
</div>
</div>
</div>
<div>
<Label htmlFor="primaryColor">Couleur principale</Label>
<div className="flex gap-2">
<Input
id="primaryColor"
type="color"
value={formData.primaryColor}
onChange={(e) => setFormData({ ...formData, primaryColor: e.target.value })}
className="w-16 h-10 p-1"
/>
<Input
value={formData.primaryColor}
onChange={(e) => setFormData({ ...formData, primaryColor: e.target.value })}
placeholder="#2563eb"
/>
</div>
<p className="text-xs text-muted-foreground mt-1">
Utilisée pour les boutons et les accents
</p>
</div>
<div>
<Label htmlFor="headerTitle">Titre de l'en-tête</Label>
<Input
id="headerTitle"
value={formData.headerTitle}
onChange={(e) => setFormData({ ...formData, headerTitle: e.target.value })}
placeholder="Formation Manager Itinova"
/>
</div>
<div>
<Label htmlFor="footerText">Texte du pied de page</Label>
<Textarea
id="footerText"
value={formData.footerText}
onChange={(e) => setFormData({ ...formData, footerText: e.target.value })}
placeholder="Cet email a été envoyé automatiquement..."
rows={3}
/>
</div>
<div className="flex gap-2 pt-4">
<Button onClick={handleSave} disabled={upsertMutation.isPending} className="flex-1">
<Save className="h-4 w-4 mr-2" />
{upsertMutation.isPending ? "Enregistrement..." : "Enregistrer"}
</Button>
<Button onClick={handleReset} variant="outline">
<RotateCcw className="h-4 w-4 mr-2" />
Réinitialiser
</Button>
</div>
</CardContent>
</Card>
{/* Prévisualisation */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Eye className="h-5 w-5" />
Prévisualisation en temps réel
</CardTitle>
<CardDescription>
Aperçu de votre template avec les paramètres actuels
</CardDescription>
</CardHeader>
<CardContent>
<div className="border rounded-lg overflow-hidden bg-gray-50">
<iframe
srcDoc={generatePreview()}
className="w-full h-[600px] border-0"
title="Prévisualisation du template"
/>
</div>
</CardContent>
</Card>
</div>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,16 @@
CREATE TABLE `emailTemplates` (
`id` int AUTO_INCREMENT NOT NULL,
`type` varchar(50) NOT NULL,
`name` varchar(255) NOT NULL,
`logoUrl` text,
`primaryColor` varchar(7) NOT NULL DEFAULT '#2563eb',
`headerBgColor` varchar(7) NOT NULL DEFAULT '#2563eb',
`headerTextColor` varchar(7) NOT NULL DEFAULT '#ffffff',
`headerTitle` varchar(255) NOT NULL DEFAULT 'Formation Manager Itinova',
`footerText` text,
`active` boolean NOT NULL DEFAULT true,
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `emailTemplates_id` PRIMARY KEY(`id`),
CONSTRAINT `emailTemplates_type_unique` UNIQUE(`type`)
);

View File

@@ -0,0 +1,684 @@
{
"version": "5",
"dialect": "mysql",
"id": "73d62a50-d4a5-41dd-b5a2-4d2d72b7f119",
"prevId": "44a452bc-0f54-464a-98ae-043474047a69",
"tables": {
"apprenants": {
"name": "apprenants",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"nom": {
"name": "nom",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"prenom": {
"name": "prenom",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "varchar(320)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"codeEtablissement": {
"name": "codeEtablissement",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"fonction": {
"name": "fonction",
"type": "enum('directeur','chef_service','autre')",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"apprenants_id": {
"name": "apprenants_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"apprenants_email_unique": {
"name": "apprenants_email_unique",
"columns": [
"email"
]
}
},
"checkConstraint": {}
},
"datesFormation": {
"name": "datesFormation",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"sequenceId": {
"name": "sequenceId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"dateDebut": {
"name": "dateDebut",
"type": "datetime",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"dateFin": {
"name": "dateFin",
"type": "datetime",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ordre": {
"name": "ordre",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"datesFormation_id": {
"name": "datesFormation_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"emailTemplates": {
"name": "emailTemplates",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"type": {
"name": "type",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"logoUrl": {
"name": "logoUrl",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"primaryColor": {
"name": "primaryColor",
"type": "varchar(7)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'#2563eb'"
},
"headerBgColor": {
"name": "headerBgColor",
"type": "varchar(7)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'#2563eb'"
},
"headerTextColor": {
"name": "headerTextColor",
"type": "varchar(7)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'#ffffff'"
},
"headerTitle": {
"name": "headerTitle",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'Formation Manager Itinova'"
},
"footerText": {
"name": "footerText",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"active": {
"name": "active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"emailTemplates_id": {
"name": "emailTemplates_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"emailTemplates_type_unique": {
"name": "emailTemplates_type_unique",
"columns": [
"type"
]
}
},
"checkConstraint": {}
},
"formations": {
"name": "formations",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"nom": {
"name": "nom",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"lienUnique": {
"name": "lienUnique",
"type": "varchar(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"actif": {
"name": "actif",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"formations_id": {
"name": "formations_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"formations_lienUnique_unique": {
"name": "formations_lienUnique_unique",
"columns": [
"lienUnique"
]
}
},
"checkConstraint": {}
},
"inscriptions": {
"name": "inscriptions",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"apprenantId": {
"name": "apprenantId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sequenceId": {
"name": "sequenceId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"statut": {
"name": "statut",
"type": "enum('confirmee','liste_attente','annulee')",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"dateInscription": {
"name": "dateInscription",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"inscriptions_id": {
"name": "inscriptions_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"passwordResetTokens": {
"name": "passwordResetTokens",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"userId": {
"name": "userId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expiresAt": {
"name": "expiresAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"used": {
"name": "used",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"passwordResetTokens_id": {
"name": "passwordResetTokens_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"passwordResetTokens_token_unique": {
"name": "passwordResetTokens_token_unique",
"columns": [
"token"
]
}
},
"checkConstraint": {}
},
"sequences": {
"name": "sequences",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"formationId": {
"name": "formationId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"nom": {
"name": "nom",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"lieu": {
"name": "lieu",
"type": "varchar(500)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"publicCible": {
"name": "publicCible",
"type": "enum('directeur','chef_service','autre')",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"capaciteMax": {
"name": "capaciteMax",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 12
},
"dateBlocage": {
"name": "dateBlocage",
"type": "datetime",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"statut": {
"name": "statut",
"type": "enum('ouverte','bloquee','terminee')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'ouverte'"
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"sequences_id": {
"name": "sequences_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"openId": {
"name": "openId",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "varchar(320)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"loginMethod": {
"name": "loginMethod",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "enum('user','admin')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'user'"
},
"isActive": {
"name": "isActive",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
},
"lastSignedIn": {
"name": "lastSignedIn",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"users_id": {
"name": "users_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"users_openId_unique": {
"name": "users_openId_unique",
"columns": [
"openId"
]
}
},
"checkConstraint": {}
}
},
"views": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"tables": {},
"indexes": {}
}
}

View File

@@ -57,6 +57,13 @@
"when": 1763414014386, "when": 1763414014386,
"tag": "0007_awesome_genesis", "tag": "0007_awesome_genesis",
"breakpoints": true "breakpoints": true
},
{
"idx": 8,
"version": "5",
"when": 1763416801277,
"tag": "0008_jittery_fenris",
"breakpoints": true
} }
] ]
} }

View File

@@ -141,3 +141,33 @@ export const passwordResetTokens = mysqlTable("passwordResetTokens", {
export type PasswordResetToken = typeof passwordResetTokens.$inferSelect; export type PasswordResetToken = typeof passwordResetTokens.$inferSelect;
export type InsertPasswordResetToken = typeof passwordResetTokens.$inferInsert; export type InsertPasswordResetToken = typeof passwordResetTokens.$inferInsert;
/**
* Table des templates d'emails personnalisables
*/
export const emailTemplates = mysqlTable("emailTemplates", {
id: int("id").autoincrement().primaryKey(),
/** Type de template (inscription, teaser, rappel, reset_password) */
type: varchar("type", { length: 50 }).notNull().unique(),
/** Nom affiché du template */
name: varchar("name", { length: 255 }).notNull(),
/** URL du logo (peut être vide pour utiliser le logo par défaut) */
logoUrl: text("logoUrl"),
/** Couleur principale (hex) */
primaryColor: varchar("primaryColor", { length: 7 }).default("#2563eb").notNull(),
/** Couleur de fond de l'en-tête (hex) */
headerBgColor: varchar("headerBgColor", { length: 7 }).default("#2563eb").notNull(),
/** Couleur du texte de l'en-tête (hex) */
headerTextColor: varchar("headerTextColor", { length: 7 }).default("#ffffff").notNull(),
/** Titre de l'en-tête */
headerTitle: varchar("headerTitle", { length: 255 }).default("Formation Manager Itinova").notNull(),
/** Texte du pied de page */
footerText: text("footerText"),
/** Actif ou non */
active: boolean("active").default(true).notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type EmailTemplate = typeof emailTemplates.$inferSelect;
export type InsertEmailTemplate = typeof emailTemplates.$inferInsert;

View File

@@ -18,7 +18,10 @@ import {
Sequence, Sequence,
DateFormation, DateFormation,
Apprenant, Apprenant,
Formation Formation,
emailTemplates,
EmailTemplate,
InsertEmailTemplate
} from "../drizzle/schema"; } from "../drizzle/schema";
import { ENV } from './_core/env'; import { ENV } from './_core/env';
@@ -467,3 +470,135 @@ export async function deleteExpiredTokens() {
await db.delete(passwordResetTokens) await db.delete(passwordResetTokens)
.where(sql`${passwordResetTokens.expiresAt} < ${now}`); .where(sql`${passwordResetTokens.expiresAt} < ${now}`);
} }
// ============================================
// Email Templates Management
// ============================================
/**
* Récupère tous les templates d'emails
*/
export async function getAllEmailTemplates() {
const db = await getDb();
if (!db) return [];
return await db.select().from(emailTemplates);
}
/**
* Récupère un template par son type
*/
export async function getEmailTemplateByType(type: string) {
const db = await getDb();
if (!db) return undefined;
const results = await db.select().from(emailTemplates).where(eq(emailTemplates.type, type)).limit(1);
return results.length > 0 ? results[0] : undefined;
}
/**
* Crée ou met à jour un template d'email
*/
export async function upsertEmailTemplate(template: InsertEmailTemplate) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const existing = await getEmailTemplateByType(template.type);
if (existing) {
// Mise à jour
await db.update(emailTemplates)
.set({
name: template.name,
logoUrl: template.logoUrl,
primaryColor: template.primaryColor,
headerBgColor: template.headerBgColor,
headerTextColor: template.headerTextColor,
headerTitle: template.headerTitle,
footerText: template.footerText,
active: template.active,
updatedAt: new Date(),
})
.where(eq(emailTemplates.type, template.type));
return await getEmailTemplateByType(template.type);
} else {
// Création
await db.insert(emailTemplates).values(template);
return await getEmailTemplateByType(template.type);
}
}
/**
* Supprime un template d'email
*/
export async function deleteEmailTemplate(type: string) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.delete(emailTemplates).where(eq(emailTemplates.type, type));
}
/**
* Initialise les templates par défaut si la table est vide
*/
export async function initializeDefaultEmailTemplates() {
const db = await getDb();
if (!db) return;
const existing = await getAllEmailTemplates();
if (existing.length > 0) return; // Déjà initialisé
const defaultTemplates: InsertEmailTemplate[] = [
{
type: "inscription",
name: "Confirmation d'inscription",
logoUrl: null,
primaryColor: "#2563eb",
headerBgColor: "#2563eb",
headerTextColor: "#ffffff",
headerTitle: "Formation Manager Itinova",
footerText: "Cet email a été envoyé automatiquement par le système de gestion des formations Itinova. Pour toute question, veuillez contacter le service RH.",
active: true,
},
{
type: "teaser",
name: "Email teaser",
logoUrl: null,
primaryColor: "#2563eb",
headerBgColor: "#2563eb",
headerTextColor: "#ffffff",
headerTitle: "Formation Manager Itinova",
footerText: "Cet email a été envoyé automatiquement par le système de gestion des formations Itinova. Pour toute question, veuillez contacter le service RH.",
active: true,
},
{
type: "rappel",
name: "Rappel J-7",
logoUrl: null,
primaryColor: "#2563eb",
headerBgColor: "#2563eb",
headerTextColor: "#ffffff",
headerTitle: "Formation Manager Itinova",
footerText: "Cet email a été envoyé automatiquement par le système de gestion des formations Itinova. Pour toute question, veuillez contacter le service RH.",
active: true,
},
{
type: "reset_password",
name: "Réinitialisation de mot de passe",
logoUrl: null,
primaryColor: "#2563eb",
headerBgColor: "#2563eb",
headerTextColor: "#ffffff",
headerTitle: "Formation Manager Itinova",
footerText: "Cet email a été envoyé automatiquement par le système de gestion des formations Itinova. Pour toute question, veuillez contacter le service RH.",
active: true,
},
];
for (const template of defaultTemplates) {
await db.insert(emailTemplates).values(template);
}
console.log("[DB] Templates d'emails par défaut initialisés");
}

View File

@@ -5,6 +5,7 @@
import { generateFormationICS } from "./icsGenerator"; import { generateFormationICS } from "./icsGenerator";
import { sendEmail as sendEmailViaService, isEmailServiceConfigured } from "./_core/emailSender"; import { sendEmail as sendEmailViaService, isEmailServiceConfigured } from "./_core/emailSender";
import { generateEmailFromTemplate } from "./emailTemplateGenerator";
interface EmailParams { interface EmailParams {
to: string; to: string;
@@ -26,41 +27,10 @@ async function sendEmail(params: EmailParams): Promise<boolean> {
} }
/** /**
* Template HTML de base pour les emails * Template HTML de base pour les emails (avec template personnalisé)
*/ */
function getEmailTemplate(content: string): string { async function getEmailTemplate(content: string, templateType: string = 'inscription'): Promise<string> {
return ` return await generateEmailFromTemplate(templateType, content);
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background-color: #2563eb; color: white; padding: 20px; text-align: center; }
.content { background-color: #f9fafb; padding: 30px; }
.footer { background-color: #f3f4f6; padding: 20px; text-align: center; font-size: 12px; color: #6b7280; }
.button { display: inline-block; padding: 12px 24px; background-color: #2563eb; color: white; text-decoration: none; border-radius: 6px; margin: 10px 0; }
.info-box { background-color: #dbeafe; border-left: 4px solid #2563eb; padding: 15px; margin: 15px 0; }
.date-item { margin: 10px 0; padding: 10px; background-color: white; border-radius: 4px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Formation Manager Itinova</h1>
</div>
<div class="content">
${content}
</div>
<div class="footer">
<p>Cet email a été envoyé automatiquement par le système de gestion des formations Itinova.</p>
<p>Pour toute question, veuillez contacter le service RH.</p>
</div>
</div>
</body>
</html>
`.trim();
} }
/** /**
@@ -152,7 +122,7 @@ export async function sendInscriptionConfirmation(params: {
subject: isConfirmed subject: isConfirmed
? `Confirmation d'inscription - ${params.formationNom}` ? `Confirmation d'inscription - ${params.formationNom}`
: `Liste d'attente - ${params.formationNom}`, : `Liste d'attente - ${params.formationNom}`,
html: getEmailTemplate(content), html: await getEmailTemplate(content, 'inscription'),
attachments, attachments,
}); });
} }
@@ -204,7 +174,7 @@ export async function sendTeaserEmail(params: {
return sendEmail({ return sendEmail({
to: params.apprenantEmail, to: params.apprenantEmail,
subject: `Votre formation ${params.formationNom} approche !`, subject: `Votre formation ${params.formationNom} approche !`,
html: getEmailTemplate(content), html: await getEmailTemplate(content, 'teaser'),
}); });
} }
@@ -263,7 +233,7 @@ export async function sendRappelJ7Email(params: {
return sendEmail({ return sendEmail({
to: params.apprenantEmail, to: params.apprenantEmail,
subject: `Rappel J-7 : ${params.formationNom}`, subject: `Rappel J-7 : ${params.formationNom}`,
html: getEmailTemplate(content), html: await getEmailTemplate(content, 'rappel'),
}); });
} }
@@ -356,6 +326,6 @@ export async function sendPasswordResetEmail(params: {
return sendEmail({ return sendEmail({
to: params.apprenantEmail, to: params.apprenantEmail,
subject: 'Réinitialisation de votre mot de passe - Manager Itinova', subject: 'Réinitialisation de votre mot de passe - Manager Itinova',
html: getEmailTemplate(content), html: await getEmailTemplate(content, 'reset_password'),
}); });
} }

View File

@@ -0,0 +1,161 @@
/**
* Générateur de templates d'emails personnalisés
* Utilise les templates stockés en base de données
*/
import * as db from "./db";
import { EmailTemplate } from "../drizzle/schema";
/**
* Génère le HTML d'un email en utilisant le template personnalisé
*/
export async function generateEmailFromTemplate(
templateType: string,
content: string
): Promise<string> {
// Récupérer le template depuis la base de données
const template = await db.getEmailTemplateByType(templateType);
// Si pas de template trouvé, utiliser le template par défaut
if (!template) {
return getDefaultEmailTemplate(content);
}
return buildEmailHTML(template, content);
}
/**
* Construit le HTML de l'email avec le template
*/
function buildEmailHTML(template: EmailTemplate, content: string): string {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
}
.container {
max-width: 600px;
margin: 0 auto;
background-color: white;
}
.header {
background-color: ${template.headerBgColor};
color: ${template.headerTextColor};
padding: 30px 20px;
text-align: center;
}
.header h1 {
margin: 0;
font-size: 24px;
}
${template.logoUrl ? `
.header img {
max-width: 150px;
margin-bottom: 15px;
display: block;
margin-left: auto;
margin-right: auto;
}` : ''}
.content {
background-color: #f9fafb;
padding: 30px 20px;
}
.content h2 {
color: ${template.primaryColor};
margin-top: 0;
}
.footer {
background-color: #f3f4f6;
padding: 20px;
text-align: center;
font-size: 12px;
color: #6b7280;
}
.button {
display: inline-block;
padding: 12px 24px;
background-color: ${template.primaryColor};
color: white;
text-decoration: none;
border-radius: 6px;
margin: 10px 0;
}
.info-box {
background-color: #dbeafe;
border-left: 4px solid ${template.primaryColor};
padding: 15px;
margin: 15px 0;
}
.date-item {
margin: 10px 0;
padding: 10px;
background-color: white;
border-radius: 4px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
${template.logoUrl ? `<img src="${template.logoUrl}" alt="Logo" />` : ''}
<h1>${template.headerTitle}</h1>
</div>
<div class="content">
${content}
</div>
<div class="footer">
<p>${template.footerText || 'Cet email a été envoyé automatiquement par le système de gestion des formations Itinova.'}</p>
</div>
</div>
</body>
</html>
`.trim();
}
/**
* Template par défaut si aucun template personnalisé n'est trouvé
*/
function getDefaultEmailTemplate(content: string): string {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 20px; background-color: #f5f5f5; }
.container { max-width: 600px; margin: 0 auto; background-color: white; }
.header { background-color: #2563eb; color: white; padding: 30px 20px; text-align: center; }
.header h1 { margin: 0; font-size: 24px; }
.content { background-color: #f9fafb; padding: 30px 20px; }
.footer { background-color: #f3f4f6; padding: 20px; text-align: center; font-size: 12px; color: #6b7280; }
.button { display: inline-block; padding: 12px 24px; background-color: #2563eb; color: white; text-decoration: none; border-radius: 6px; margin: 10px 0; }
.info-box { background-color: #dbeafe; border-left: 4px solid #2563eb; padding: 15px; margin: 15px 0; }
.date-item { margin: 10px 0; padding: 10px; background-color: white; border-radius: 4px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Formation Manager Itinova</h1>
</div>
<div class="content">
${content}
</div>
<div class="footer">
<p>Cet email a été envoyé automatiquement par le système de gestion des formations Itinova.</p>
<p>Pour toute question, veuillez contacter le service RH.</p>
</div>
</div>
</body>
</html>
`.trim();
}

View File

@@ -625,6 +625,47 @@ export const appRouter = router({
return { success: true, message: "Email de réinitialisation envoyé" }; return { success: true, message: "Email de réinitialisation envoyé" };
}), }),
}), }),
// ===== GESTION DES TEMPLATES D'EMAILS =====
emailTemplates: router({
list: adminProcedure.query(async () => {
return db.getAllEmailTemplates();
}),
getByType: adminProcedure
.input(z.object({ type: z.string() }))
.query(async ({ input }) => {
return db.getEmailTemplateByType(input.type);
}),
upsert: adminProcedure
.input(z.object({
type: z.string(),
name: z.string(),
logoUrl: z.string().nullable(),
primaryColor: z.string(),
headerBgColor: z.string(),
headerTextColor: z.string(),
headerTitle: z.string(),
footerText: z.string().nullable(),
active: z.boolean(),
}))
.mutation(async ({ input }) => {
return db.upsertEmailTemplate(input);
}),
delete: adminProcedure
.input(z.object({ type: z.string() }))
.mutation(async ({ input }) => {
await db.deleteEmailTemplate(input.type);
return { success: true };
}),
initializeDefaults: adminProcedure.mutation(async () => {
await db.initializeDefaultEmailTemplates();
return { success: true };
}),
}),
}); });
export type AppRouter = typeof appRouter; export type AppRouter = typeof appRouter;

11
todo.md
View File

@@ -216,3 +216,14 @@
- [x] Rechercher l'API d'envoi d'emails disponible dans Manus - [x] Rechercher l'API d'envoi d'emails disponible dans Manus
- [x] Intégrer un vrai service d'envoi d'emails (Resend) - [x] Intégrer un vrai service d'envoi d'emails (Resend)
- [x] Créer la documentation de configuration EMAIL_SETUP.md - [x] Créer la documentation de configuration EMAIL_SETUP.md
## Interface de personnalisation des templates d'emails
- [x] Créer la table email_templates dans le schéma
- [x] Créer les procédures backend pour CRUD des templates
- [x] Créer la page AdminEmailTemplates
- [x] Implémenter l'éditeur de template (logo, couleurs, textes)
- [x] Ajouter la prévisualisation en temps réel
- [x] Modifier le service d'envoi pour utiliser les templates personnalisés
- [x] Ajouter le lien dans le menu de navigation
- [x] Tester la personnalisation et l'envoi avec templates personnalisés