diff --git a/.manus/db/db-query-1769611868212.json b/.manus/db/db-query-1769611868212.json new file mode 100644 index 0000000..9177eea --- /dev/null +++ b/.manus/db/db-query-1769611868212.json @@ -0,0 +1,9 @@ +{ + "query": "ALTER TABLE `rappels` ADD `nomFichier2` varchar(255);\nALTER TABLE `rappels` ADD `urlFichier2` varchar(500);\nALTER TABLE `rappels` ADD `s3Key2` varchar(500);\nALTER TABLE `rappels` ADD `typeFichier2` varchar(100);\nALTER TABLE `rappels` ADD `tailleFichier2` int;", + "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` ADD `nomFichier2` varchar(255);\nALTER TABLE `rappels` ADD `urlFichier2` varchar(500);\nALTER TABLE `rappels` ADD `s3Key2` varchar(500);\nALTER TABLE `rappels` ADD `typeFichier2` varchar(100);\nALTER TABLE `rappels` ADD `tailleFichier2` int;", + "rows": [], + "messages": [], + "stdout": "", + "stderr": "", + "execution_time_ms": 2554 +} \ No newline at end of file diff --git a/client/src/pages/AdminRappels.tsx b/client/src/pages/AdminRappels.tsx index aec9acc..66bdb06 100644 --- a/client/src/pages/AdminRappels.tsx +++ b/client/src/pages/AdminRappels.tsx @@ -59,6 +59,13 @@ export default function AdminRappels() { typeFichier: string; tailleFichier: number; } | null, + fichier2: null as { + nomFichier: string; + urlFichier: string; + s3Key: string; + typeFichier: string; + tailleFichier: number; + } | null, }); const { data: rappels, refetch } = trpc.rappels.list.useQuery(); @@ -113,6 +120,7 @@ export default function AdminRappels() { nom: "", templateType: "", fichier: null, + fichier2: null, timing: "pre_formation", joursAvant: 7, heureEnvoi: "09:00", @@ -146,6 +154,13 @@ export default function AdminRappels() { typeFichier: rappel.typeFichier, tailleFichier: rappel.tailleFichier, } : null, + fichier2: rappel.nomFichier2 ? { + nomFichier: rappel.nomFichier2, + urlFichier: rappel.urlFichier2, + s3Key: rappel.s3Key2, + typeFichier: rappel.typeFichier2, + tailleFichier: rappel.tailleFichier2, + } : null, }); setIsEditOpen(true); }; @@ -694,12 +709,25 @@ 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} + /> +
+ +
+ + setFormData({ ...formData, fichier2 })} + 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} - /> +
+ + setFormData({ ...formData, fichier })} + accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png" + maxSize={10} + /> +
+ +
+ + setFormData({ ...formData, fichier2 })} + accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png" + maxSize={10} + /> +
{ return await db.select().from(rappels).where(eq(rappels.actif, true)); } -export async function updateRappel(id: number, data: Partial & { fichier?: { nomFichier: string; urlFichier: string; s3Key: string; typeFichier: string; tailleFichier: number; } | null }) { +export async function updateRappel(id: number, data: Partial & { fichier?: { nomFichier: string; urlFichier: string; s3Key: string; typeFichier: string; tailleFichier: number; } | null; fichier2?: { 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 + // Gérer les champs de fichier 1 if ('fichier' in data) { delete updateData.fichier; if (data.fichier) { @@ -1167,7 +1176,7 @@ export async function updateRappel(id: number, data: Partial & { f updateData.typeFichier = data.fichier.typeFichier; updateData.tailleFichier = data.fichier.tailleFichier; } else { - // Supprimer le fichier + // Supprimer le fichier 1 updateData.nomFichier = null; updateData.urlFichier = null; updateData.s3Key = null; @@ -1176,6 +1185,25 @@ export async function updateRappel(id: number, data: Partial & { f } } + // Gérer les champs de fichier 2 + if ('fichier2' in data) { + delete updateData.fichier2; + if (data.fichier2) { + updateData.nomFichier2 = data.fichier2.nomFichier; + updateData.urlFichier2 = data.fichier2.urlFichier; + updateData.s3Key2 = data.fichier2.s3Key; + updateData.typeFichier2 = data.fichier2.typeFichier; + updateData.tailleFichier2 = data.fichier2.tailleFichier; + } else { + // Supprimer le fichier 2 + updateData.nomFichier2 = null; + updateData.urlFichier2 = null; + updateData.s3Key2 = null; + updateData.typeFichier2 = null; + updateData.tailleFichier2 = null; + } + } + await db.update(rappels).set({ ...updateData, updatedAt: new Date(), diff --git a/server/emailService.ts b/server/emailService.ts index 815c53d..6ba4d5f 100644 --- a/server/emailService.ts +++ b/server/emailService.ts @@ -233,6 +233,9 @@ export async function sendRappelEmail(params: { attachmentUrl?: string; attachmentFilename?: string; attachmentMimeType?: string; + attachmentUrl2?: string; + attachmentFilename2?: string; + attachmentMimeType2?: string; }): Promise { const fonctionLabel = params.apprenantFonction === 'directeur' ? 'Directeur' : params.apprenantFonction === 'chef_service' ? 'Chef de service' : ''; @@ -318,6 +321,37 @@ export async function sendRappelEmail(params: { // Continuer l'envoi sans la pièce jointe } } + + // Charger la deuxième pièce jointe si présente + if (params.attachmentUrl2 && params.attachmentFilename2 && params.attachmentMimeType2) { + try { + let base64: string; + + // Vérifier si c'est une URL locale (commence par /uploads/) + if (params.attachmentUrl2.startsWith('/uploads/')) { + // Lire le fichier depuis le système de fichiers + const fs = await import('fs/promises'); + const path = await import('path'); + const filePath = path.join(process.cwd(), params.attachmentUrl2); + const fileBuffer = await fs.readFile(filePath); + base64 = fileBuffer.toString('base64'); + } else { + // Télécharger le fichier depuis une URL externe (S3) + const response = await fetch(params.attachmentUrl2); + const arrayBuffer = await response.arrayBuffer(); + base64 = Buffer.from(arrayBuffer).toString('base64'); + } + + attachments.push({ + filename: params.attachmentFilename2, + content: base64, + contentType: params.attachmentMimeType2, + }); + } catch (error) { + console.error('[Email] Erreur lors du chargement de la pièce jointe 2:', error); + // Continuer l'envoi sans la pièce jointe + } + } return sendEmail({ to: params.apprenantEmail, diff --git a/server/rappelScheduler.ts b/server/rappelScheduler.ts index 1b19934..3c0bc57 100644 --- a/server/rappelScheduler.ts +++ b/server/rappelScheduler.ts @@ -216,6 +216,9 @@ async function processRappel(rappel: any) { attachmentUrl: rappel.urlFichier || undefined, attachmentFilename: rappel.nomFichier || undefined, attachmentMimeType: rappel.typeFichier || undefined, + attachmentUrl2: rappel.urlFichier2 || undefined, + attachmentFilename2: rappel.nomFichier2 || undefined, + attachmentMimeType2: rappel.typeFichier2 || undefined, }); console.log(`[Rappels] Email ${rappel.templateType} envoyé à ${apprenant.email} pour la séquence ${sequence.nom}`); diff --git a/server/routers.ts b/server/routers.ts index d66bf7e..0cdc0ab 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -1413,6 +1413,13 @@ export const appRouter = router({ typeFichier: z.string(), tailleFichier: z.number(), }).nullable().optional(), + fichier2: z.object({ + nomFichier: z.string(), + urlFichier: z.string(), + s3Key: z.string(), + typeFichier: z.string(), + tailleFichier: z.number(), + }).nullable().optional(), })) .mutation(async ({ input }) => { const { dateFormationIds, ...rappelData } = input; @@ -1446,6 +1453,13 @@ export const appRouter = router({ typeFichier: z.string(), tailleFichier: z.number(), }).nullable().optional(), + fichier2: z.object({ + nomFichier: z.string(), + urlFichier: z.string(), + s3Key: z.string(), + typeFichier: z.string(), + tailleFichier: z.number(), + }).nullable().optional(), })) .mutation(async ({ input }) => { const { id, dateFormationIds, ...data } = input; diff --git a/todo.md b/todo.md index 00877cc..e113024 100644 --- a/todo.md +++ b/todo.md @@ -1394,3 +1394,16 @@ - [x] Retirer les logs de debug - [x] Déployer la version finale sur VPS - [x] Créer un checkpoint final + +## Évolution rappels - 2 pièces jointes + +- [x] Analyser le schéma actuel de la table rappels +- [x] Planifier la migration pour passer de 1 à 2 pièces jointes +- [x] Modifier le schéma Drizzle (drizzle/schema.ts) +- [x] Créer et appliquer la migration SQL en local +- [x] Adapter le backend (emailService.ts, rappelScheduler.ts, routers.ts, db.ts) +- [x] Adapter le frontend (AdminRappels.tsx) +- [x] Tester localement avec rappels existants +- [x] Déployer sur VPS avec migration +- [x] Vérifier que les rappels existants sont préservés (migration additive) +- [x] Créer un checkpoint