Checkpoint: Ajout de la possibilité de joindre un fichier dans l'envoi des rappels par email

Modifications apportées :
- Ajout des colonnes nomFichier, urlFichier, s3Key, typeFichier, tailleFichier dans la table rappels
- Création du composant FileUpload pour l'upload de fichiers vers S3 (max 10 Mo)
- Création de l'endpoint API /api/upload-file pour gérer l'upload
- Intégration du composant FileUpload dans les formulaires de création et modification de rappels
- Mise à jour des procédures tRPC create et update pour gérer les informations de fichier
- Modification des fonctions sendRappelJ7Email et sendRappelJ1Email pour inclure les pièces jointes
- Mise à jour du scheduler de rappels pour passer les informations de fichier lors de l'envoi
- Les fichiers sont téléchargés depuis S3 et convertis en base64 pour être attachés aux emails
This commit is contained in:
Manus
2026-01-08 06:00:27 -05:00
parent 6e25c00865
commit 5308b7921e
16 changed files with 2613 additions and 4 deletions

View File

@@ -0,0 +1,90 @@
{
"query": "DESCRIBE rappels;",
"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 DESCRIBE rappels;",
"rows": [
{
"Field": "id",
"Type": "int(11)",
"Null": "NO",
"Key": "PRI",
"Default": "NULL",
"Extra": "auto_increment"
},
{
"Field": "nom",
"Type": "varchar(255)",
"Null": "NO",
"Key": "",
"Default": "NULL",
"Extra": ""
},
{
"Field": "templateType",
"Type": "varchar(50)",
"Null": "NO",
"Key": "",
"Default": "NULL",
"Extra": ""
},
{
"Field": "timing",
"Type": "enum('pre_formation','post_formation')",
"Null": "NO",
"Key": "",
"Default": "pre_formation",
"Extra": ""
},
{
"Field": "joursAvant",
"Type": "int(11)",
"Null": "NO",
"Key": "",
"Default": "NULL",
"Extra": ""
},
{
"Field": "heureEnvoi",
"Type": "varchar(5)",
"Null": "NO",
"Key": "",
"Default": "09:00",
"Extra": ""
},
{
"Field": "actif",
"Type": "tinyint(1)",
"Null": "NO",
"Key": "",
"Default": "1",
"Extra": ""
},
{
"Field": "derniereExecution",
"Type": "timestamp",
"Null": "YES",
"Key": "",
"Default": "NULL",
"Extra": ""
},
{
"Field": "createdAt",
"Type": "timestamp",
"Null": "NO",
"Key": "",
"Default": "CURRENT_TIMESTAMP",
"Extra": ""
},
{
"Field": "updatedAt",
"Type": "timestamp",
"Null": "NO",
"Key": "",
"Default": "CURRENT_TIMESTAMP",
"Extra": "DEFAULT_GENERATED on update CURRENT_TIMESTAMP"
}
],
"messages": [],
"stdout": "Field\tType\tNull\tKey\tDefault\tExtra\nid\tint(11)\tNO\tPRI\tNULL\tauto_increment\nnom\tvarchar(255)\tNO\t\tNULL\t\ntemplateType\tvarchar(50)\tNO\t\tNULL\t\ntiming\tenum('pre_formation','post_formation')\tNO\t\tpre_formation\t\njoursAvant\tint(11)\tNO\t\tNULL\t\nheureEnvoi\tvarchar(5)\tNO\t\t09:00\t\nactif\ttinyint(1)\tNO\t\t1\t\nderniereExecution\ttimestamp\tYES\t\tNULL\t\ncreatedAt\ttimestamp\tNO\t\tCURRENT_TIMESTAMP\t\nupdatedAt\ttimestamp\tNO\t\tCURRENT_TIMESTAMP\tDEFAULT_GENERATED on update CURRENT_TIMESTAMP\n",
"stderr": "",
"execution_time_ms": 48
}

View File

@@ -0,0 +1,9 @@
{
"query": "ALTER TABLE rappels \nADD COLUMN IF NOT EXISTS nomFichier VARCHAR(255) NULL,\nADD COLUMN IF NOT EXISTS urlFichier TEXT NULL,\nADD COLUMN IF NOT EXISTS s3Key VARCHAR(500) NULL,\nADD COLUMN IF NOT EXISTS typeFichier VARCHAR(100) NULL,\nADD COLUMN IF NOT EXISTS tailleFichier INT NULL;",
"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 ALTER TABLE rappels \nADD COLUMN IF NOT EXISTS nomFichier VARCHAR(255) NULL,\nADD COLUMN IF NOT EXISTS urlFichier TEXT NULL,\nADD COLUMN IF NOT EXISTS s3Key VARCHAR(500) NULL,\nADD COLUMN IF NOT EXISTS typeFichier VARCHAR(100) NULL,\nADD COLUMN IF NOT EXISTS tailleFichier INT NULL;",
"rows": [],
"messages": [],
"stdout": "",
"stderr": "",
"execution_time_ms": 1458
}

View File

@@ -0,0 +1,8 @@
{
"query": "ALTER TABLE rappels \nADD COLUMN nomFichier VARCHAR(255) NULL AFTER derniereExecution,\nADD COLUMN urlFichier TEXT NULL AFTER nomFichier,\nADD COLUMN s3Key VARCHAR(500) NULL AFTER urlFichier,\nADD COLUMN typeFichier VARCHAR(100) NULL AFTER s3Key,\nADD COLUMN tailleFichier INT NULL AFTER typeFichier;",
"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 ALTER TABLE rappels \nADD COLUMN nomFichier VARCHAR(255) NULL AFTER derniereExecution,\nADD COLUMN urlFichier TEXT NULL AFTER nomFichier,\nADD COLUMN s3Key VARCHAR(500) NULL AFTER urlFichier,\nADD COLUMN typeFichier VARCHAR(100) NULL AFTER s3Key,\nADD COLUMN tailleFichier INT NULL AFTER typeFichier;",
"returncode": 1,
"logs": [
"ERROR 1054 (42S22) at line 1: Unknown column 'nomfichier' in 'rappels'"
]
}

View File

@@ -0,0 +1,136 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { X, Upload, FileText } from "lucide-react";
import { useState } from "react";
interface FileUploadProps {
value?: {
nomFichier: string;
urlFichier: string;
s3Key: string;
typeFichier: string;
tailleFichier: number;
} | null;
onChange: (file: {
nomFichier: string;
urlFichier: string;
s3Key: string;
typeFichier: string;
tailleFichier: number;
} | null) => void;
accept?: string;
maxSize?: number; // en Mo
}
export function FileUpload({ value, onChange, accept, maxSize = 10 }: FileUploadProps) {
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Vérifier la taille
const fileSizeMB = file.size / (1024 * 1024);
if (fileSizeMB > maxSize) {
setError(`Le fichier est trop volumineux (max ${maxSize} Mo)`);
return;
}
setError(null);
setUploading(true);
try {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/upload-file", {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error("Erreur lors de l'upload");
}
const data = await response.json();
onChange({
nomFichier: file.name,
urlFichier: data.url,
s3Key: data.key,
typeFichier: file.type,
tailleFichier: file.size,
});
} catch (err) {
console.error("Erreur upload:", err);
setError("Erreur lors de l'upload du fichier");
} finally {
setUploading(false);
}
};
const handleRemove = () => {
onChange(null);
setError(null);
};
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return bytes + " octets";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " Ko";
return (bytes / (1024 * 1024)).toFixed(1) + " Mo";
};
return (
<div className="space-y-2">
<Label>Pièce jointe (optionnel)</Label>
{!value ? (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Input
type="file"
onChange={handleFileChange}
accept={accept}
disabled={uploading}
className="flex-1"
/>
{uploading && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="animate-spin h-4 w-4 border-2 border-primary border-t-transparent rounded-full" />
Upload en cours...
</div>
)}
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<p className="text-xs text-muted-foreground">
Taille maximale : {maxSize} Mo
{accept && ` • Formats acceptés : ${accept}`}
</p>
</div>
) : (
<div className="flex items-center gap-2 p-3 border rounded-lg bg-muted/50">
<FileText className="h-5 w-5 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{value.nomFichier}</p>
<p className="text-xs text-muted-foreground">
{formatFileSize(value.tailleFichier)}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={handleRemove}
className="flex-shrink-0"
>
<X className="h-4 w-4" />
</Button>
</div>
)}
</div>
);
}

View File

@@ -24,6 +24,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
import { Bell, Edit, Plus, Trash2 } from "lucide-react"; import { Bell, Edit, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { FileUpload } from "@/components/FileUpload";
// Types de templates disponibles pour les rappels // Types de templates disponibles pour les rappels
const TEMPLATE_TYPES = [ const TEMPLATE_TYPES = [
@@ -46,6 +47,13 @@ export default function AdminRappels() {
joursAvant: 7, joursAvant: 7,
heureEnvoi: "09:00", heureEnvoi: "09:00",
actif: true, actif: true,
fichier: null as {
nomFichier: string;
urlFichier: string;
s3Key: string;
typeFichier: string;
tailleFichier: number;
} | null,
}); });
const { data: rappels, refetch } = trpc.rappels.list.useQuery(); const { data: rappels, refetch } = trpc.rappels.list.useQuery();
@@ -98,6 +106,7 @@ export default function AdminRappels() {
setFormData({ setFormData({
nom: "", nom: "",
templateType: "", templateType: "",
fichier: null,
timing: "pre_formation", timing: "pre_formation",
joursAvant: 7, joursAvant: 7,
heureEnvoi: "09:00", heureEnvoi: "09:00",
@@ -122,6 +131,13 @@ export default function AdminRappels() {
joursAvant: rappel.joursAvant, joursAvant: rappel.joursAvant,
heureEnvoi: rappel.heureEnvoi, heureEnvoi: rappel.heureEnvoi,
actif: rappel.actif, actif: rappel.actif,
fichier: rappel.nomFichier ? {
nomFichier: rappel.nomFichier,
urlFichier: rappel.urlFichier,
s3Key: rappel.s3Key,
typeFichier: rappel.typeFichier,
tailleFichier: rappel.tailleFichier,
} : null,
}); });
setIsEditOpen(true); setIsEditOpen(true);
}; };
@@ -358,6 +374,13 @@ export default function AdminRappels() {
</p> </p>
</div> </div>
<FileUpload
value={formData.fichier}
onChange={(fichier) => setFormData({ ...formData, fichier })}
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png"
maxSize={10}
/>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Switch <Switch
id="actif" id="actif"
@@ -478,6 +501,13 @@ export default function AdminRappels() {
/> />
</div> </div>
<FileUpload
value={formData.fichier}
onChange={(fichier) => setFormData({ ...formData, fichier })}
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png"
maxSize={10}
/>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Switch <Switch
id="edit-actif" id="edit-actif"

File diff suppressed because it is too large Load Diff

View File

@@ -204,6 +204,13 @@
"when": 1767868505577, "when": 1767868505577,
"tag": "0028_military_pandemic", "tag": "0028_military_pandemic",
"breakpoints": true "breakpoints": true
},
{
"idx": 29,
"version": "5",
"when": 1767869539717,
"tag": "0029_classy_warbound",
"breakpoints": true
} }
] ]
} }

View File

@@ -288,6 +288,16 @@ export const rappels = mysqlTable("rappels", {
actif: boolean("actif").default(true).notNull(), actif: boolean("actif").default(true).notNull(),
/** Dernière exécution */ /** Dernière exécution */
derniereExecution: timestamp("derniereExecution"), derniereExecution: timestamp("derniereExecution"),
/** Nom du fichier joint (optionnel) */
nomFichier: varchar("nomFichier", { length: 255 }),
/** URL publique du fichier sur S3 */
urlFichier: text("urlFichier"),
/** Clé S3 du fichier pour suppression */
s3Key: varchar("s3Key", { length: 500 }),
/** Type MIME du fichier */
typeFichier: varchar("typeFichier", { length: 100 }),
/** Taille du fichier en octets */
tailleFichier: int("tailleFichier"),
createdAt: timestamp("createdAt").defaultNow().notNull(), createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
}); });

View File

@@ -0,0 +1,59 @@
import { Request, Response } from "express";
import multer from "multer";
import { storagePut } from "../storage";
import crypto from "crypto";
import path from "path";
// Configuration de multer pour l'upload en mémoire
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 10 * 1024 * 1024, // 10 Mo max
},
});
// Middleware pour l'upload d'un seul fichier
export const uploadSingle = upload.single("file");
// Handler pour l'upload de fichier vers S3
export async function handleFileUpload(req: Request, res: Response) {
try {
if (!req.file) {
return res.status(400).json({ error: "Aucun fichier fourni" });
}
const file = req.file;
// Générer un nom de fichier unique
const randomSuffix = crypto.randomBytes(8).toString("hex");
const ext = path.extname(file.originalname);
const baseName = path.basename(file.originalname, ext);
const safeBaseName = baseName.replace(/[^a-zA-Z0-9-_]/g, "_");
const fileName = `${safeBaseName}-${randomSuffix}${ext}`;
// Chemin S3 pour les pièces jointes de rappels
const s3Key = `rappels-attachments/${fileName}`;
// Upload vers S3
const { url, key } = await storagePut(
s3Key,
file.buffer,
file.mimetype
);
console.log(`[FileUpload] Fichier uploadé: ${fileName} (${file.size} octets)`);
return res.json({
url,
key,
filename: file.originalname,
mimetype: file.mimetype,
size: file.size,
});
} catch (error) {
console.error("[FileUpload] Erreur:", error);
return res.status(500).json({
error: "Erreur lors de l'upload du fichier"
});
}
}

View File

@@ -8,6 +8,7 @@ import { appRouter } from "../routers";
import { createContext } from "./context"; import { createContext } from "./context";
import localAuthRouter from "./localAuth"; import localAuthRouter from "./localAuth";
import uploadImageRouter from "./uploadImage"; import uploadImageRouter from "./uploadImage";
import uploadFileRouter from "./uploadFile";
import { serveStatic, setupVite } from "./vite"; import { serveStatic, setupVite } from "./vite";
import { initRappelScheduler } from "../rappelScheduler"; import { initRappelScheduler } from "../rappelScheduler";
import { initRappelRetryScheduler } from "../rappelRetry"; import { initRappelRetryScheduler } from "../rappelRetry";
@@ -43,6 +44,8 @@ async function startServer() {
app.use("/api/auth/local", localAuthRouter); app.use("/api/auth/local", localAuthRouter);
// Image upload under /api/upload-image // Image upload under /api/upload-image
app.use("/api/upload-image", uploadImageRouter); app.use("/api/upload-image", uploadImageRouter);
// File upload under /api/upload-file
app.use("/api/upload-file", uploadFileRouter);
// tRPC API // tRPC API
app.use( app.use(
"/api/trpc", "/api/trpc",

View File

@@ -0,0 +1,67 @@
import { Router } from "express";
import multer from "multer";
import { storagePut } from "../storage";
import { randomBytes } from "crypto";
import path from "path";
const router = Router();
// Configuration de multer pour gérer les uploads en mémoire
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 10 * 1024 * 1024, // 10MB max
},
});
/**
* Route d'upload de fichiers vers S3
* POST /api/upload-file
* Body: multipart/form-data avec un champ "file"
*/
router.post("/", upload.single("file"), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({
success: false,
message: "Aucun fichier fourni",
});
}
const file = req.file;
// Générer un nom de fichier unique
const randomSuffix = randomBytes(8).toString("hex");
const ext = path.extname(file.originalname);
const baseName = path.basename(file.originalname, ext);
const safeBaseName = baseName.replace(/[^a-zA-Z0-9-_]/g, "_");
const fileName = `rappels-attachments/${Date.now()}-${randomSuffix}-${safeBaseName}${ext}`;
// Upload vers S3
const result = await storagePut(
fileName,
file.buffer,
file.mimetype
);
console.log(`[UploadFile] Fichier uploadé: ${file.originalname} (${file.size} octets)`);
return res.json({
success: true,
url: result.url,
key: fileName,
filename: file.originalname,
mimetype: file.mimetype,
size: file.size,
message: "Fichier uploadé avec succès",
});
} catch (error) {
console.error("[UploadFile] Error:", error);
return res.status(500).json({
success: false,
message: "Erreur lors de l'upload du fichier",
});
}
});
export default router;

View File

@@ -938,18 +938,29 @@ export async function deleteFormateur(id: number) {
// ==================== RAPPELS ==================== // ==================== RAPPELS ====================
export async function createRappel(data: InsertRappel) { export async function createRappel(data: InsertRappel & { fichier?: { nomFichier: string; urlFichier: string; s3Key: string; typeFichier: string; tailleFichier: number; } | null }) {
const db = await getDb(); const db = await getDb();
if (!db) throw new Error("Database not available"); if (!db) throw new Error("Database not available");
// Construire un objet d'insertion explicite sans derniereExecution // Construire un objet d'insertion explicite
const insertData: Partial<InsertRappel> = { const insertData: Partial<InsertRappel> = {
nom: data.nom, nom: data.nom,
templateType: data.templateType, templateType: data.templateType,
timing: data.timing,
joursAvant: data.joursAvant, joursAvant: data.joursAvant,
heureEnvoi: data.heureEnvoi, heureEnvoi: data.heureEnvoi,
actif: data.actif, actif: data.actif,
}; };
// Ajouter les champs de fichier si présents
if (data.fichier) {
insertData.nomFichier = data.fichier.nomFichier;
insertData.urlFichier = data.fichier.urlFichier;
insertData.s3Key = data.fichier.s3Key;
insertData.typeFichier = data.fichier.typeFichier;
insertData.tailleFichier = data.fichier.tailleFichier;
}
const result = await db.insert(rappels).values(insertData as InsertRappel); const result = await db.insert(rappels).values(insertData as InsertRappel);
return result; return result;
} }
@@ -976,12 +987,33 @@ export async function getActiveRappels(): Promise<Rappel[]> {
return await db.select().from(rappels).where(eq(rappels.actif, true)); return await db.select().from(rappels).where(eq(rappels.actif, true));
} }
export async function updateRappel(id: number, data: Partial<InsertRappel>) { export async function updateRappel(id: number, data: Partial<InsertRappel> & { fichier?: { nomFichier: string; urlFichier: string; s3Key: string; typeFichier: string; tailleFichier: number; } | null }) {
const db = await getDb(); const db = await getDb();
if (!db) throw new Error("Database not available"); if (!db) throw new Error("Database not available");
const updateData: any = { ...data };
// Gérer les champs de fichier
if ('fichier' in data) {
delete updateData.fichier;
if (data.fichier) {
updateData.nomFichier = data.fichier.nomFichier;
updateData.urlFichier = data.fichier.urlFichier;
updateData.s3Key = data.fichier.s3Key;
updateData.typeFichier = data.fichier.typeFichier;
updateData.tailleFichier = data.fichier.tailleFichier;
} else {
// Supprimer le fichier
updateData.nomFichier = null;
updateData.urlFichier = null;
updateData.s3Key = null;
updateData.typeFichier = null;
updateData.tailleFichier = null;
}
}
await db.update(rappels).set({ await db.update(rappels).set({
...data, ...updateData,
updatedAt: new Date(), updatedAt: new Date(),
}).where(eq(rappels.id, id)); }).where(eq(rappels.id, id));
} }

View File

@@ -229,6 +229,9 @@ export async function sendRappelJ7Email(params: {
dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>; dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>;
lieu: string; lieu: string;
formateur?: string; formateur?: string;
attachmentUrl?: string;
attachmentFilename?: string;
attachmentMimeType?: string;
}): Promise<boolean> { }): Promise<boolean> {
const fonctionLabel = params.apprenantFonction === 'directeur' ? 'Directeur' : const fonctionLabel = params.apprenantFonction === 'directeur' ? 'Directeur' :
params.apprenantFonction === 'chef_service' ? 'Chef de service' : ''; params.apprenantFonction === 'chef_service' ? 'Chef de service' : '';
@@ -282,10 +285,32 @@ export async function sendRappelJ7Email(params: {
formateur: params.formateur || '', formateur: params.formateur || '',
}; };
// Préparer les pièces jointes si nécessaire
const attachments: Array<{ filename: string; content: string; contentType: string }> = [];
if (params.attachmentUrl && params.attachmentFilename && params.attachmentMimeType) {
try {
// Télécharger le fichier depuis S3 et le convertir en base64
const response = await fetch(params.attachmentUrl);
const buffer = await response.arrayBuffer();
const base64 = Buffer.from(buffer).toString('base64');
attachments.push({
filename: params.attachmentFilename,
content: base64,
contentType: params.attachmentMimeType,
});
} catch (error) {
console.error('[Email] Erreur lors du téléchargement de la pièce jointe:', error);
// Continuer l'envoi sans la pièce jointe
}
}
return sendEmail({ return sendEmail({
to: params.apprenantEmail, to: params.apprenantEmail,
subject: `Rappel J-7 : ${params.formationNom}`, subject: `Rappel J-7 : ${params.formationNom}`,
html: await getEmailTemplate(content, 'rappel', variables), html: await getEmailTemplate(content, 'rappel', variables),
attachments: attachments.length > 0 ? attachments : undefined,
}); });
} }
@@ -302,6 +327,9 @@ export async function sendRappelJ1Email(params: {
dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>; dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>;
lieu?: string; lieu?: string;
formateur?: string; formateur?: string;
attachmentUrl?: string;
attachmentFilename?: string;
attachmentMimeType?: string;
}): Promise<boolean> { }): Promise<boolean> {
const fonctionLabel = params.apprenantFonction === 'directeur' ? 'Directeur' : const fonctionLabel = params.apprenantFonction === 'directeur' ? 'Directeur' :
params.apprenantFonction === 'chef_service' ? 'Chef de service' : ''; params.apprenantFonction === 'chef_service' ? 'Chef de service' : '';
@@ -358,10 +386,32 @@ export async function sendRappelJ1Email(params: {
formateur: params.formateur || '', formateur: params.formateur || '',
}; };
// Préparer les pièces jointes si nécessaire
const attachments: Array<{ filename: string; content: string; contentType: string }> = [];
if (params.attachmentUrl && params.attachmentFilename && params.attachmentMimeType) {
try {
// Télécharger le fichier depuis S3 et le convertir en base64
const response = await fetch(params.attachmentUrl);
const buffer = await response.arrayBuffer();
const base64 = Buffer.from(buffer).toString('base64');
attachments.push({
filename: params.attachmentFilename,
content: base64,
contentType: params.attachmentMimeType,
});
} catch (error) {
console.error('[Email] Erreur lors du téléchargement de la pièce jointe:', error);
// Continuer l'envoi sans la pièce jointe
}
}
return sendEmail({ return sendEmail({
to: params.apprenantEmail, to: params.apprenantEmail,
subject: `Rappel J-1 : ${params.formationNom} - C'est demain !`, subject: `Rappel J-1 : ${params.formationNom} - C'est demain !`,
html: await getEmailTemplate(content, 'rappel', variables), html: await getEmailTemplate(content, 'rappel', variables),
attachments: attachments.length > 0 ? attachments : undefined,
}); });
} }

View File

@@ -159,6 +159,9 @@ async function processRappel(rappel: any) {
})), })),
lieu: sequence.lieu || "", lieu: sequence.lieu || "",
formateur: formateurNom, formateur: formateurNom,
attachmentUrl: rappel.urlFichier || undefined,
attachmentFilename: rappel.nomFichier || undefined,
attachmentMimeType: rappel.typeFichier || undefined,
}); });
console.log(`[Rappels] Email J-7 envoyé à ${apprenant.email} pour la séquence ${sequence.nom}`); console.log(`[Rappels] Email J-7 envoyé à ${apprenant.email} pour la séquence ${sequence.nom}`);
} else if (rappel.templateType === "rappelJ1") { } else if (rappel.templateType === "rappelJ1") {
@@ -177,6 +180,9 @@ async function processRappel(rappel: any) {
})), })),
lieu: sequence.lieu || "", lieu: sequence.lieu || "",
formateur: formateurNom, formateur: formateurNom,
attachmentUrl: rappel.urlFichier || undefined,
attachmentFilename: rappel.nomFichier || undefined,
attachmentMimeType: rappel.typeFichier || undefined,
}); });
console.log(`[Rappels] Email J-1 envoyé à ${apprenant.email} pour la séquence ${sequence.nom}`); console.log(`[Rappels] Email J-1 envoyé à ${apprenant.email} pour la séquence ${sequence.nom}`);
} }

View File

@@ -1070,6 +1070,13 @@ export const appRouter = router({
joursAvant: z.number(), joursAvant: z.number(),
heureEnvoi: z.string().default("09:00"), heureEnvoi: z.string().default("09:00"),
actif: z.boolean(), actif: z.boolean(),
fichier: z.object({
nomFichier: z.string(),
urlFichier: z.string(),
s3Key: z.string(),
typeFichier: z.string(),
tailleFichier: z.number(),
}).nullable().optional(),
})) }))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
return db.createRappel(input); return db.createRappel(input);
@@ -1084,6 +1091,13 @@ export const appRouter = router({
joursAvant: z.number().optional(), joursAvant: z.number().optional(),
heureEnvoi: z.string().optional(), heureEnvoi: z.string().optional(),
actif: z.boolean().optional(), actif: z.boolean().optional(),
fichier: z.object({
nomFichier: z.string(),
urlFichier: z.string(),
s3Key: z.string(),
typeFichier: z.string(),
tailleFichier: z.number(),
}).nullable().optional(),
})) }))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const { id, ...data } = input; const { id, ...data } = input;

10
todo.md
View File

@@ -523,3 +523,13 @@
- [x] Modifier AdminRappels.tsx pour ajouter les radio buttons pré/post-formation - [x] Modifier AdminRappels.tsx pour ajouter les radio buttons pré/post-formation
- [x] Ajouter un champ de saisie du nombre de jours avec label dynamique - [x] Ajouter un champ de saisie du nombre de jours avec label dynamique
- [x] Mettre à jour les procédures tRPC (create et update) pour gérer timing - [x] Mettre à jour les procédures tRPC (create et update) pour gérer timing
## Ajout de pièces jointes dans les rappels par email
- [x] Modifier le schéma de la table rappels pour ajouter les champs de fichier (nomFichier, urlFichier, s3Key, typeFichier, tailleFichier)
- [x] Appliquer la migration en base de données
- [x] Créer un composant FileUpload pour l'upload de fichiers vers S3
- [x] Modifier AdminRappels.tsx pour intégrer le composant d'upload
- [x] Créer un endpoint API pour l.upload de fichiers vers S3
- [x] Mettre à jour les procédures tRPC create et update pour gérer les fichiers
- [x] Modifier le service d.envoi d.emails pour inclure les pièces jointes
- [x] Tester l.upload et l.envoi d.emails avec pièce jointe