Checkpoint: Évolution du système de rappels pour permettre jusqu'à 2 pièces jointes au lieu d'une seule. Migration SQL appliquée en local et sur le VPS de production. Les rappels existants sont préservés.
This commit is contained in:
9
.manus/db/db-query-1769611868212.json
Normal file
9
.manus/db/db-query-1769611868212.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -59,6 +59,13 @@ export default function AdminRappels() {
|
|||||||
typeFichier: string;
|
typeFichier: string;
|
||||||
tailleFichier: number;
|
tailleFichier: number;
|
||||||
} | null,
|
} | null,
|
||||||
|
fichier2: 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();
|
||||||
@@ -113,6 +120,7 @@ export default function AdminRappels() {
|
|||||||
nom: "",
|
nom: "",
|
||||||
templateType: "",
|
templateType: "",
|
||||||
fichier: null,
|
fichier: null,
|
||||||
|
fichier2: null,
|
||||||
timing: "pre_formation",
|
timing: "pre_formation",
|
||||||
joursAvant: 7,
|
joursAvant: 7,
|
||||||
heureEnvoi: "09:00",
|
heureEnvoi: "09:00",
|
||||||
@@ -146,6 +154,13 @@ export default function AdminRappels() {
|
|||||||
typeFichier: rappel.typeFichier,
|
typeFichier: rappel.typeFichier,
|
||||||
tailleFichier: rappel.tailleFichier,
|
tailleFichier: rappel.tailleFichier,
|
||||||
} : null,
|
} : null,
|
||||||
|
fichier2: rappel.nomFichier2 ? {
|
||||||
|
nomFichier: rappel.nomFichier2,
|
||||||
|
urlFichier: rappel.urlFichier2,
|
||||||
|
s3Key: rappel.s3Key2,
|
||||||
|
typeFichier: rappel.typeFichier2,
|
||||||
|
tailleFichier: rappel.tailleFichier2,
|
||||||
|
} : null,
|
||||||
});
|
});
|
||||||
setIsEditOpen(true);
|
setIsEditOpen(true);
|
||||||
};
|
};
|
||||||
@@ -694,12 +709,25 @@ export default function AdminRappels() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Pièce jointe 1 (optionnel)</Label>
|
||||||
<FileUpload
|
<FileUpload
|
||||||
value={formData.fichier}
|
value={formData.fichier}
|
||||||
onChange={(fichier) => setFormData({ ...formData, fichier })}
|
onChange={(fichier) => setFormData({ ...formData, fichier })}
|
||||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png"
|
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png"
|
||||||
maxSize={10}
|
maxSize={10}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Pièce jointe 2 (optionnel)</Label>
|
||||||
|
<FileUpload
|
||||||
|
value={formData.fichier2}
|
||||||
|
onChange={(fichier2) => setFormData({ ...formData, fichier2 })}
|
||||||
|
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png"
|
||||||
|
maxSize={10}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Switch
|
<Switch
|
||||||
@@ -840,12 +868,25 @@ export default function AdminRappels() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Pièce jointe 1 (optionnel)</Label>
|
||||||
<FileUpload
|
<FileUpload
|
||||||
value={formData.fichier}
|
value={formData.fichier}
|
||||||
onChange={(fichier) => setFormData({ ...formData, fichier })}
|
onChange={(fichier) => setFormData({ ...formData, fichier })}
|
||||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png"
|
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png"
|
||||||
maxSize={10}
|
maxSize={10}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Pièce jointe 2 (optionnel)</Label>
|
||||||
|
<FileUpload
|
||||||
|
value={formData.fichier2}
|
||||||
|
onChange={(fichier2) => setFormData({ ...formData, fichier2 })}
|
||||||
|
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png"
|
||||||
|
maxSize={10}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Switch
|
<Switch
|
||||||
|
|||||||
2269
drizzle/meta/0008_snapshot.json
Normal file
2269
drizzle/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -57,6 +57,13 @@
|
|||||||
"when": 1769506135258,
|
"when": 1769506135258,
|
||||||
"tag": "0007_lively_shiver_man",
|
"tag": "0007_lively_shiver_man",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 8,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1769611851240,
|
||||||
|
"tag": "0008_sturdy_albert_cleary",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -243,6 +243,16 @@ export const rappels = mysqlTable("rappels", {
|
|||||||
typeFichier: varchar("typeFichier", { length: 100 }),
|
typeFichier: varchar("typeFichier", { length: 100 }),
|
||||||
/** Taille du fichier en octets (optionnel) */
|
/** Taille du fichier en octets (optionnel) */
|
||||||
tailleFichier: int("tailleFichier"),
|
tailleFichier: int("tailleFichier"),
|
||||||
|
/** Nom du fichier joint 2 (optionnel) */
|
||||||
|
nomFichier2: varchar("nomFichier2", { length: 255 }),
|
||||||
|
/** URL du fichier 2 sur S3 (optionnel) */
|
||||||
|
urlFichier2: varchar("urlFichier2", { length: 500 }),
|
||||||
|
/** Clé S3 du fichier 2 (optionnel) */
|
||||||
|
s3Key2: varchar("s3Key2", { length: 500 }),
|
||||||
|
/** Type MIME du fichier 2 (optionnel) */
|
||||||
|
typeFichier2: varchar("typeFichier2", { length: 100 }),
|
||||||
|
/** Taille du fichier 2 en octets (optionnel) */
|
||||||
|
tailleFichier2: int("tailleFichier2"),
|
||||||
actif: boolean("actif").default(true).notNull(),
|
actif: boolean("actif").default(true).notNull(),
|
||||||
derniereExecution: datetime("derniereExecution"),
|
derniereExecution: datetime("derniereExecution"),
|
||||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
|
|||||||
38
server/db.ts
38
server/db.ts
@@ -1058,7 +1058,7 @@ export async function deleteFormateur(id: number) {
|
|||||||
|
|
||||||
// ==================== RAPPELS ====================
|
// ==================== RAPPELS ====================
|
||||||
|
|
||||||
export async function createRappel(data: InsertRappel & { fichier?: { nomFichier: string; urlFichier: string; s3Key: string; typeFichier: string; tailleFichier: number; } | null }) {
|
export async function createRappel(data: InsertRappel & { 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();
|
const db = await getDb();
|
||||||
if (!db) throw new Error("Database not available");
|
if (!db) throw new Error("Database not available");
|
||||||
|
|
||||||
@@ -1072,7 +1072,7 @@ export async function createRappel(data: InsertRappel & { fichier?: { nomFichier
|
|||||||
actif: data.actif,
|
actif: data.actif,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ajouter les champs de fichier si présents
|
// Ajouter les champs de fichier 1 si présents
|
||||||
if (data.fichier) {
|
if (data.fichier) {
|
||||||
insertData.nomFichier = data.fichier.nomFichier;
|
insertData.nomFichier = data.fichier.nomFichier;
|
||||||
insertData.urlFichier = data.fichier.urlFichier;
|
insertData.urlFichier = data.fichier.urlFichier;
|
||||||
@@ -1081,6 +1081,15 @@ export async function createRappel(data: InsertRappel & { fichier?: { nomFichier
|
|||||||
insertData.tailleFichier = data.fichier.tailleFichier;
|
insertData.tailleFichier = data.fichier.tailleFichier;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ajouter les champs de fichier 2 si présents
|
||||||
|
if (data.fichier2) {
|
||||||
|
insertData.nomFichier2 = data.fichier2.nomFichier;
|
||||||
|
insertData.urlFichier2 = data.fichier2.urlFichier;
|
||||||
|
insertData.s3Key2 = data.fichier2.s3Key;
|
||||||
|
insertData.typeFichier2 = data.fichier2.typeFichier;
|
||||||
|
insertData.tailleFichier2 = data.fichier2.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;
|
||||||
}
|
}
|
||||||
@@ -1151,13 +1160,13 @@ 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> & { fichier?: { nomFichier: string; urlFichier: string; s3Key: string; typeFichier: string; tailleFichier: number; } | null }) {
|
export async function updateRappel(id: number, data: Partial<InsertRappel> & { 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();
|
const db = await getDb();
|
||||||
if (!db) throw new Error("Database not available");
|
if (!db) throw new Error("Database not available");
|
||||||
|
|
||||||
const updateData: any = { ...data };
|
const updateData: any = { ...data };
|
||||||
|
|
||||||
// Gérer les champs de fichier
|
// Gérer les champs de fichier 1
|
||||||
if ('fichier' in data) {
|
if ('fichier' in data) {
|
||||||
delete updateData.fichier;
|
delete updateData.fichier;
|
||||||
if (data.fichier) {
|
if (data.fichier) {
|
||||||
@@ -1167,7 +1176,7 @@ export async function updateRappel(id: number, data: Partial<InsertRappel> & { f
|
|||||||
updateData.typeFichier = data.fichier.typeFichier;
|
updateData.typeFichier = data.fichier.typeFichier;
|
||||||
updateData.tailleFichier = data.fichier.tailleFichier;
|
updateData.tailleFichier = data.fichier.tailleFichier;
|
||||||
} else {
|
} else {
|
||||||
// Supprimer le fichier
|
// Supprimer le fichier 1
|
||||||
updateData.nomFichier = null;
|
updateData.nomFichier = null;
|
||||||
updateData.urlFichier = null;
|
updateData.urlFichier = null;
|
||||||
updateData.s3Key = null;
|
updateData.s3Key = null;
|
||||||
@@ -1176,6 +1185,25 @@ export async function updateRappel(id: number, data: Partial<InsertRappel> & { 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({
|
await db.update(rappels).set({
|
||||||
...updateData,
|
...updateData,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
|
|||||||
@@ -233,6 +233,9 @@ export async function sendRappelEmail(params: {
|
|||||||
attachmentUrl?: string;
|
attachmentUrl?: string;
|
||||||
attachmentFilename?: string;
|
attachmentFilename?: string;
|
||||||
attachmentMimeType?: string;
|
attachmentMimeType?: string;
|
||||||
|
attachmentUrl2?: string;
|
||||||
|
attachmentFilename2?: string;
|
||||||
|
attachmentMimeType2?: 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' : '';
|
||||||
@@ -319,6 +322,37 @@ export async function sendRappelEmail(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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({
|
return sendEmail({
|
||||||
to: params.apprenantEmail,
|
to: params.apprenantEmail,
|
||||||
subject: `Rappel : ${params.formationNom}`,
|
subject: `Rappel : ${params.formationNom}`,
|
||||||
|
|||||||
@@ -216,6 +216,9 @@ async function processRappel(rappel: any) {
|
|||||||
attachmentUrl: rappel.urlFichier || undefined,
|
attachmentUrl: rappel.urlFichier || undefined,
|
||||||
attachmentFilename: rappel.nomFichier || undefined,
|
attachmentFilename: rappel.nomFichier || undefined,
|
||||||
attachmentMimeType: rappel.typeFichier || 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}`);
|
console.log(`[Rappels] Email ${rappel.templateType} envoyé à ${apprenant.email} pour la séquence ${sequence.nom}`);
|
||||||
|
|
||||||
|
|||||||
@@ -1413,6 +1413,13 @@ export const appRouter = router({
|
|||||||
typeFichier: z.string(),
|
typeFichier: z.string(),
|
||||||
tailleFichier: z.number(),
|
tailleFichier: z.number(),
|
||||||
}).nullable().optional(),
|
}).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 }) => {
|
.mutation(async ({ input }) => {
|
||||||
const { dateFormationIds, ...rappelData } = input;
|
const { dateFormationIds, ...rappelData } = input;
|
||||||
@@ -1446,6 +1453,13 @@ export const appRouter = router({
|
|||||||
typeFichier: z.string(),
|
typeFichier: z.string(),
|
||||||
tailleFichier: z.number(),
|
tailleFichier: z.number(),
|
||||||
}).nullable().optional(),
|
}).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 }) => {
|
.mutation(async ({ input }) => {
|
||||||
const { id, dateFormationIds, ...data } = input;
|
const { id, dateFormationIds, ...data } = input;
|
||||||
|
|||||||
13
todo.md
13
todo.md
@@ -1394,3 +1394,16 @@
|
|||||||
- [x] Retirer les logs de debug
|
- [x] Retirer les logs de debug
|
||||||
- [x] Déployer la version finale sur VPS
|
- [x] Déployer la version finale sur VPS
|
||||||
- [x] Créer un checkpoint final
|
- [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
|
||||||
|
|||||||
Reference in New Issue
Block a user