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:
59
server/_core/fileUpload.ts
Normal file
59
server/_core/fileUpload.ts
Normal 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"
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
67
server/_core/uploadFile.ts
Normal file
67
server/_core/uploadFile.ts
Normal 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;
|
||||
40
server/db.ts
40
server/db.ts
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user