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,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 localAuthRouter from "./localAuth";
import uploadImageRouter from "./uploadImage";
import uploadFileRouter from "./uploadFile";
import { serveStatic, setupVite } from "./vite";
import { initRappelScheduler } from "../rappelScheduler";
import { initRappelRetryScheduler } from "../rappelRetry";
@@ -43,6 +44,8 @@ async function startServer() {
app.use("/api/auth/local", localAuthRouter);
// Image upload under /api/upload-image
app.use("/api/upload-image", uploadImageRouter);
// File upload under /api/upload-file
app.use("/api/upload-file", uploadFileRouter);
// tRPC API
app.use(
"/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 ====================
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();
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> = {
nom: data.nom,
templateType: data.templateType,
timing: data.timing,
joursAvant: data.joursAvant,
heureEnvoi: data.heureEnvoi,
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);
return result;
}
@@ -976,12 +987,33 @@ export async function getActiveRappels(): Promise<Rappel[]> {
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();
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({
...data,
...updateData,
updatedAt: new Date(),
}).where(eq(rappels.id, id));
}

View File

@@ -229,6 +229,9 @@ export async function sendRappelJ7Email(params: {
dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>;
lieu: string;
formateur?: string;
attachmentUrl?: string;
attachmentFilename?: string;
attachmentMimeType?: string;
}): Promise<boolean> {
const fonctionLabel = params.apprenantFonction === 'directeur' ? 'Directeur' :
params.apprenantFonction === 'chef_service' ? 'Chef de service' : '';
@@ -282,10 +285,32 @@ export async function sendRappelJ7Email(params: {
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({
to: params.apprenantEmail,
subject: `Rappel J-7 : ${params.formationNom}`,
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 }>;
lieu?: string;
formateur?: string;
attachmentUrl?: string;
attachmentFilename?: string;
attachmentMimeType?: string;
}): Promise<boolean> {
const fonctionLabel = params.apprenantFonction === 'directeur' ? 'Directeur' :
params.apprenantFonction === 'chef_service' ? 'Chef de service' : '';
@@ -358,10 +386,32 @@ export async function sendRappelJ1Email(params: {
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({
to: params.apprenantEmail,
subject: `Rappel J-1 : ${params.formationNom} - C'est demain !`,
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 || "",
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}`);
} else if (rappel.templateType === "rappelJ1") {
@@ -177,6 +180,9 @@ async function processRappel(rappel: any) {
})),
lieu: sequence.lieu || "",
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}`);
}

View File

@@ -1070,6 +1070,13 @@ export const appRouter = router({
joursAvant: z.number(),
heureEnvoi: z.string().default("09:00"),
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 }) => {
return db.createRappel(input);
@@ -1084,6 +1091,13 @@ export const appRouter = router({
joursAvant: z.number().optional(),
heureEnvoi: z.string().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 }) => {
const { id, ...data } = input;