diff --git a/.manus/db/db-query-1767869551826.json b/.manus/db/db-query-1767869551826.json new file mode 100644 index 0000000..0e56917 --- /dev/null +++ b/.manus/db/db-query-1767869551826.json @@ -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 +} \ No newline at end of file diff --git a/.manus/db/db-query-1767869558318.json b/.manus/db/db-query-1767869558318.json new file mode 100644 index 0000000..49825ad --- /dev/null +++ b/.manus/db/db-query-1767869558318.json @@ -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 +} \ No newline at end of file diff --git a/.manus/db/db-query-error-1767869547267.json b/.manus/db/db-query-error-1767869547267.json new file mode 100644 index 0000000..92c2a5b --- /dev/null +++ b/.manus/db/db-query-error-1767869547267.json @@ -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'" + ] +} \ No newline at end of file diff --git a/client/src/components/FileUpload.tsx b/client/src/components/FileUpload.tsx new file mode 100644 index 0000000..d993084 --- /dev/null +++ b/client/src/components/FileUpload.tsx @@ -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(null); + + const handleFileChange = async (e: React.ChangeEvent) => { + 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 ( +
+ + + {!value ? ( +
+
+ + {uploading && ( +
+
+ Upload en cours... +
+ )} +
+ {error && ( +

{error}

+ )} +

+ Taille maximale : {maxSize} Mo + {accept && ` • Formats acceptés : ${accept}`} +

+
+ ) : ( +
+ +
+

{value.nomFichier}

+

+ {formatFileSize(value.tailleFichier)} +

+
+ +
+ )} +
+ ); +} diff --git a/client/src/pages/AdminRappels.tsx b/client/src/pages/AdminRappels.tsx index 7a59fd9..0badf90 100644 --- a/client/src/pages/AdminRappels.tsx +++ b/client/src/pages/AdminRappels.tsx @@ -24,6 +24,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { trpc } from "@/lib/trpc"; import { Bell, Edit, Plus, Trash2 } from "lucide-react"; import { toast } from "sonner"; +import { FileUpload } from "@/components/FileUpload"; // Types de templates disponibles pour les rappels const TEMPLATE_TYPES = [ @@ -46,6 +47,13 @@ export default function AdminRappels() { joursAvant: 7, heureEnvoi: "09:00", actif: true, + fichier: null as { + nomFichier: string; + urlFichier: string; + s3Key: string; + typeFichier: string; + tailleFichier: number; + } | null, }); const { data: rappels, refetch } = trpc.rappels.list.useQuery(); @@ -98,6 +106,7 @@ export default function AdminRappels() { setFormData({ nom: "", templateType: "", + fichier: null, timing: "pre_formation", joursAvant: 7, heureEnvoi: "09:00", @@ -122,6 +131,13 @@ export default function AdminRappels() { joursAvant: rappel.joursAvant, heureEnvoi: rappel.heureEnvoi, actif: rappel.actif, + fichier: rappel.nomFichier ? { + nomFichier: rappel.nomFichier, + urlFichier: rappel.urlFichier, + s3Key: rappel.s3Key, + typeFichier: rappel.typeFichier, + tailleFichier: rappel.tailleFichier, + } : null, }); setIsEditOpen(true); }; @@ -358,6 +374,13 @@ export default function AdminRappels() {

+ setFormData({ ...formData, fichier })} + accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png" + maxSize={10} + /> +
+ setFormData({ ...formData, fichier })} + accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png" + maxSize={10} + /> +
{ + 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; diff --git a/server/db.ts b/server/db.ts index 35b0b71..828fb53 100644 --- a/server/db.ts +++ b/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 = { 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 { return await db.select().from(rappels).where(eq(rappels.actif, true)); } -export async function updateRappel(id: number, data: Partial) { +export async function updateRappel(id: number, data: Partial & { 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)); } diff --git a/server/emailService.ts b/server/emailService.ts index 9469805..5e072a4 100644 --- a/server/emailService.ts +++ b/server/emailService.ts @@ -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 { 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 { 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, }); } diff --git a/server/rappelScheduler.ts b/server/rappelScheduler.ts index 9be9dac..e9af8c3 100644 --- a/server/rappelScheduler.ts +++ b/server/rappelScheduler.ts @@ -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}`); } diff --git a/server/routers.ts b/server/routers.ts index d1ffdce..bbf6bef 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -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; diff --git a/todo.md b/todo.md index 7525514..269e174 100644 --- a/todo.md +++ b/todo.md @@ -523,3 +523,13 @@ - [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] 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