Checkpoint: Refonte majeure du système de rappels : sélection granulaire au niveau des dates de formation individuelles (dateFormationId) au lieu des séquences. Permet de choisir précisément quelles dates d'une séquence recevront un rappel. Interface hiérarchique avec séquences dépliables et checkboxes par date. Scheduler adapté pour traiter date par date.

This commit is contained in:
Manus
2026-01-08 08:53:41 -05:00
parent 6ee87f9b22
commit f331f9bb5f
8 changed files with 363 additions and 113 deletions

View File

@@ -30,7 +30,7 @@ import {
rappels,
InsertRappel,
Rappel,
rappelSequences
rappelDates
} from "../drizzle/schema";
import { ENV } from './_core/env';
@@ -972,18 +972,18 @@ export async function getRappels() {
const allRappels = await db.select().from(rappels).orderBy(rappels.joursAvant);
// Pour chaque rappel, récupérer les séquences associées
const rappelsWithSequences = await Promise.all(
// Pour chaque rappel, récupérer les dates de formation associées
const rappelsWithDates = await Promise.all(
allRappels.map(async (rappel) => {
const sequenceIds = await getRappelSequences(rappel.id);
const dateFormationIds = await getRappelDates(rappel.id);
return {
...rappel,
sequenceIds,
dateFormationIds,
};
})
);
return rappelsWithSequences;
return rappelsWithDates;
}
export async function getRappelById(id: number): Promise<Rappel | undefined> {
@@ -1037,40 +1037,40 @@ export async function deleteRappel(id: number) {
if (!db) throw new Error("Database not available");
// Supprimer d'abord les associations
await db.delete(rappelSequences).where(eq(rappelSequences.rappelId, id));
await db.delete(rappelDates).where(eq(rappelDates.rappelId, id));
// Puis le rappel
await db.delete(rappels).where(eq(rappels.id, id));
}
// ==================== RAPPEL SEQUENCES ====================
// ==================== RAPPEL DATES ====================
export async function createRappelSequences(rappelId: number, sequenceIds: number[]) {
export async function createRappelDates(rappelId: number, dateFormationIds: number[]) {
const db = await getDb();
if (!db) throw new Error("Database not available");
if (sequenceIds.length === 0) return;
if (dateFormationIds.length === 0) return;
const values = sequenceIds.map(sequenceId => ({
const values = dateFormationIds.map(dateFormationId => ({
rappelId,
sequenceId,
dateFormationId,
}));
await db.insert(rappelSequences).values(values);
await db.insert(rappelDates).values(values);
}
export async function deleteRappelSequences(rappelId: number) {
export async function deleteRappelDates(rappelId: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.delete(rappelSequences).where(eq(rappelSequences.rappelId, rappelId));
await db.delete(rappelDates).where(eq(rappelDates.rappelId, rappelId));
}
export async function getRappelSequences(rappelId: number): Promise<number[]> {
export async function getRappelDates(rappelId: number): Promise<number[]> {
const db = await getDb();
if (!db) return [];
const results = await db.select().from(rappelSequences).where(eq(rappelSequences.rappelId, rappelId));
return results.map(r => r.sequenceId);
const results = await db.select().from(rappelDates).where(eq(rappelDates.rappelId, rappelId));
return results.map(r => r.dateFormationId);
}
export async function updateRappelExecution(id: number) {

View File

@@ -39,91 +39,101 @@ export async function processRappelsAutomatiques() {
}
async function processRappel(rappel: any) {
console.log(`[Rappels] Traitement du rappel: ${rappel.nom} (J-${rappel.joursAvant})`);
console.log(`[Rappels] Traitement du rappel: ${rappel.nom} (${rappel.timing === 'pre_formation' ? 'J-' : 'J+'}${rappel.joursAvant})`);
const db = await getDb();
if (!db) return;
try {
// Calculer la date cible (aujourd'hui + joursAvant)
// Calculer la date cible selon le timing (pré ou post-formation)
const dateCible = new Date();
dateCible.setDate(dateCible.getDate() + rappel.joursAvant);
if (rappel.timing === 'pre_formation') {
dateCible.setDate(dateCible.getDate() + rappel.joursAvant);
} else {
dateCible.setDate(dateCible.getDate() - rappel.joursAvant);
}
dateCible.setHours(0, 0, 0, 0);
const dateCibleFin = new Date(dateCible);
dateCibleFin.setHours(23, 59, 59, 999);
console.log(`[Rappels] Recherche des séquences débutant le ${dateCible.toLocaleDateString()}`);
console.log(`[Rappels] Recherche des dates de formation le ${dateCible.toLocaleDateString()}`);
// Vérifier si ce rappel est associé à des séquences spécifiques
const sequencesAssociees = rappel.sequenceIds || [];
const filtreSequences = sequencesAssociees.length > 0;
// Vérifier si ce rappel est associé à des dates spécifiques
const datesAssociees = rappel.dateFormationIds || [];
const filtreDates = datesAssociees.length > 0;
console.log(`[Rappels] ${filtreSequences ? `Filtré sur ${sequencesAssociees.length} séquence(s) spécifique(s)` : 'Toutes les séquences'}`);
console.log(`[Rappels] ${filtreDates ? `Filtré sur ${datesAssociees.length} date(s) spécifique(s)` : 'Toutes les dates'}`);
// Récupérer toutes les séquences (ou uniquement celles associées)
let toutesSequences;
if (filtreSequences) {
// Seulement les séquences associées à ce rappel
toutesSequences = await db
// Récupérer les dates de formation concernées
let datesConcernees;
if (filtreDates) {
// Seulement les dates associées à ce rappel
datesConcernees = await db
.select({
dateFormation: datesFormation,
sequence: sequences,
formation: formations,
formateur: formateurs,
})
.from(sequences)
.from(datesFormation)
.leftJoin(sequences, eq(datesFormation.sequenceId, sequences.id))
.leftJoin(formations, eq(sequences.formationId, formations.id))
.leftJoin(formateurs, eq(sequences.formateurId, formateurs.id))
.where(inArray(sequences.id, sequencesAssociees));
.where(
and(
inArray(datesFormation.id, datesAssociees),
gte(datesFormation.dateDebut, dateCible),
lte(datesFormation.dateDebut, dateCibleFin)
)
);
} else {
// Toutes les séquences
toutesSequences = await db
// Toutes les dates correspondant à la date cible
datesConcernees = await db
.select({
dateFormation: datesFormation,
sequence: sequences,
formation: formations,
formateur: formateurs,
})
.from(sequences)
.leftJoin(formations, eq(sequences.formationId, formations.id))
.leftJoin(formateurs, eq(sequences.formateurId, formateurs.id));
}
// Filtrer les séquences dont la première date correspond à la date cible
const sequencesConcernees = [];
for (const { sequence, formation, formateur } of toutesSequences) {
if (!sequence || !formation) continue;
// Récupérer la première date de cette séquence
const premiereDates = await db
.select()
.from(datesFormation)
.leftJoin(sequences, eq(datesFormation.sequenceId, sequences.id))
.leftJoin(formations, eq(sequences.formationId, formations.id))
.leftJoin(formateurs, eq(sequences.formateurId, formateurs.id))
.where(
and(
eq(datesFormation.sequenceId, sequence.id),
eq(datesFormation.ordre, 1)
gte(datesFormation.dateDebut, dateCible),
lte(datesFormation.dateDebut, dateCibleFin)
)
)
.limit(1);
if (premiereDates.length === 0) continue;
const premiereDate = premiereDates[0];
const dateDebut = new Date(premiereDate.dateDebut);
dateDebut.setHours(0, 0, 0, 0);
// Vérifier si cette première date correspond à la date cible
if (dateDebut >= dateCible && dateDebut <= dateCibleFin) {
sequencesConcernees.push({ sequence, formation, formateur });
}
);
}
console.log(`[Rappels] ${sequencesConcernees.length} séquence(s) trouvée(s)`);
console.log(`[Rappels] ${datesConcernees.length} date(s) de formation trouvée(s)`);
for (const { sequence, formation, formateur } of sequencesConcernees) {
// Grouper les dates par séquence pour envoyer un seul email par apprenant
const sequencesMap = new Map<number, { sequence: any; formation: any; formateur: any; dates: any[] }>();
for (const { dateFormation, sequence, formation, formateur } of datesConcernees) {
if (!dateFormation || !sequence || !formation) continue;
if (!sequencesMap.has(sequence.id)) {
sequencesMap.set(sequence.id, {
sequence,
formation,
formateur,
dates: [],
});
}
sequencesMap.get(sequence.id)!.dates.push(dateFormation);
}
console.log(`[Rappels] ${sequencesMap.size} séquence(s) concernée(s)`);
for (const { sequence, formation, formateur, dates: datesRappel } of Array.from(sequencesMap.values())) {
if (!sequence || !formation) continue;
// Récupérer toutes les dates de cette séquence
const dates = await db
// Récupérer toutes les dates de cette séquence pour l'email
const toutesLesDates = await db
.select()
.from(datesFormation)
.where(eq(datesFormation.sequenceId, sequence.id))
@@ -174,7 +184,7 @@ async function processRappel(rappel: any) {
apprenantFonction: apprenant.fonction,
formationNom: formation.nom,
sequenceNom: sequence.nom,
dates: dates.map(d => ({
dates: toutesLesDates.map(d => ({
dateDebut: d.dateDebut,
dateFin: d.dateFin,
ordre: d.ordre
@@ -195,7 +205,7 @@ async function processRappel(rappel: any) {
apprenantFonction: apprenant.fonction,
formationNom: formation.nom,
sequenceNom: sequence.nom,
dates: dates.map(d => ({
dates: toutesLesDates.map(d => ({
dateDebut: d.dateDebut,
dateFin: d.dateFin,
ordre: d.ordre
@@ -217,7 +227,7 @@ async function processRappel(rappel: any) {
emailDestinataire: apprenant.email,
typeRappel: rappel.templateType,
statut: "success",
dateSequence: dates[0].dateDebut,
dateSequence: toutesLesDates[0].dateDebut,
});
nbEnvoyes++;
} catch (emailError: any) {
@@ -233,7 +243,7 @@ async function processRappel(rappel: any) {
typeRappel: rappel.templateType,
statut: "failed",
messageErreur: messageErreur,
dateSequence: dates[0].dateDebut,
dateSequence: toutesLesDates[0].dateDebut,
});
nbEchecs++;
echecs.push({ email: apprenant.email, erreur: messageErreur });

View File

@@ -1070,7 +1070,7 @@ export const appRouter = router({
joursAvant: z.number(),
heureEnvoi: z.string().default("09:00"),
actif: z.boolean(),
sequenceIds: z.array(z.number()).optional(),
dateFormationIds: z.array(z.number()).optional(),
fichier: z.object({
nomFichier: z.string(),
urlFichier: z.string(),
@@ -1080,14 +1080,14 @@ export const appRouter = router({
}).nullable().optional(),
}))
.mutation(async ({ input }) => {
const { sequenceIds, ...rappelData } = input;
const { dateFormationIds, ...rappelData } = input;
const result: any = await db.createRappel(rappelData);
// Créer les associations rappel-séquence si des séquences sont spécifiées
if (sequenceIds && sequenceIds.length > 0) {
// Créer les associations rappel-date si des dates sont spécifiées
if (dateFormationIds && dateFormationIds.length > 0) {
const rappelId = result.insertId || result[0]?.insertId;
if (rappelId) {
await db.createRappelSequences(Number(rappelId), sequenceIds);
await db.createRappelDates(Number(rappelId), dateFormationIds);
}
}
@@ -1103,7 +1103,7 @@ export const appRouter = router({
joursAvant: z.number().optional(),
heureEnvoi: z.string().optional(),
actif: z.boolean().optional(),
sequenceIds: z.array(z.number()).optional(),
dateFormationIds: z.array(z.number()).optional(),
fichier: z.object({
nomFichier: z.string(),
urlFichier: z.string(),
@@ -1113,14 +1113,14 @@ export const appRouter = router({
}).nullable().optional(),
}))
.mutation(async ({ input }) => {
const { id, sequenceIds, ...data } = input;
const { id, dateFormationIds, ...data } = input;
await db.updateRappel(id, data);
// Mettre à jour les associations rappel-séquence si spécifiées
if (sequenceIds !== undefined) {
await db.deleteRappelSequences(id);
if (sequenceIds.length > 0) {
await db.createRappelSequences(id, sequenceIds);
// Mettre à jour les associations rappel-date si spécifiées
if (dateFormationIds !== undefined) {
await db.deleteRappelDates(id);
if (dateFormationIds.length > 0) {
await db.createRappelDates(id, dateFormationIds);
}
}