From 55f3d5a5d4534f8c39cc7a317a350fcabb611f31 Mon Sep 17 00:00:00 2001 From: Manus Date: Sun, 5 Jul 2026 07:51:02 -0400 Subject: [PATCH] =?UTF-8?q?Checkpoint:=20Ajout=20de=20la=20prochaine=20dat?= =?UTF-8?q?e=20d'ex=C3=A9cution=20dynamique=20dans=20les=20Param=C3=A8tres?= =?UTF-8?q?=20(calcul=20frontend=20selon=20mode/heure/jour,=20align=C3=A9?= =?UTF-8?q?=20sur=20le=20cron=20r=C3=A9el=20backend).=20Ajout=20de=20la=20?= =?UTF-8?q?r=C3=A8gle=20de=20r=C3=A9tention=20configurable=20(Illimit?= =?UTF-8?q?=C3=A9/3/6/12/18/24=20mois)=20avec=20purge=20automatique=20au?= =?UTF-8?q?=20d=C3=A9marrage=20et=20apr=C3=A8s=20chaque=20import.=200=20er?= =?UTF-8?q?reur=20TypeScript,=2021=20tests=20pass=C3=A9s.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/public/__manus__/version.json | 4 +- client/src/pages/Settings.tsx | 172 ++++++++++++++++++++++++++- server/_core/index.ts | 24 +++- server/db.ts | 13 ++ server/routers.ts | 1 + todo.md | 7 ++ 6 files changed, 215 insertions(+), 6 deletions(-) diff --git a/client/public/__manus__/version.json b/client/public/__manus__/version.json index aedf76b..327c46f 100644 --- a/client/public/__manus__/version.json +++ b/client/public/__manus__/version.json @@ -1,4 +1,4 @@ { - "version": "af33c6e1", - "timestamp": 1783248703843 + "version": "2bfe4358", + "timestamp": 1783252262810 } \ No newline at end of file diff --git a/client/src/pages/Settings.tsx b/client/src/pages/Settings.tsx index a0508f1..f2b855e 100644 --- a/client/src/pages/Settings.tsx +++ b/client/src/pages/Settings.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback } from "react"; +import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { trpc } from "@/lib/trpc"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -23,6 +23,8 @@ import { Upload, FileSpreadsheet, AlertCircle, + CalendarClock, + Trash2, } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; @@ -43,7 +45,96 @@ const SOURCE_LABELS: Record = { sharepoint: "SharePoint", }; -// ─── Composant UploadZone (réutilisé depuis ImportLogs) ─────────────────────── +const DAYS_FR = ["Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"]; +const DAYS_SHORT = ["Dim", "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam"]; + +// ─── Calcul de la prochaine date d'exécution ───────────────────────────────── + +function computeNextRun( + mode: string, + importTime: string, + dayOfWeek: number, + dayOfMonth: number, + intervalMinutes: number +): Date { + const now = new Date(); + const [hour, minute] = importTime.split(":").map(Number); + + if (mode === "interval") { + const intervalMin = Math.max(60, intervalMinutes); + const next = new Date(now); + next.setSeconds(0, 0); + if (intervalMin % 60 === 0) { + const hours = intervalMin / 60; + if (hours >= 24) { + // cron `0 0 * * *` — prochain minuit + next.setHours(0, 0); + next.setDate(next.getDate() + 1); + } else { + // cron `0 */N * * *` — prochaine heure alignée + const currentHour = now.getHours(); + const nextHour = Math.ceil((currentHour * 60 + now.getMinutes() + 1) / 60 / hours) * hours; + next.setHours(nextHour, 0); + if (next <= now) next.setHours(next.getHours() + hours); + } + } else { + // cron `*/N * * * *` — prochain multiple de N minutes + const totalMin = now.getHours() * 60 + now.getMinutes(); + const nextMin = Math.ceil((totalMin + 1) / intervalMin) * intervalMin; + next.setHours(Math.floor(nextMin / 60), nextMin % 60); + if (next <= now) next.setMinutes(next.getMinutes() + intervalMin); + } + return next; + } + + if (mode === "weekly") { + const next = new Date(now); + next.setSeconds(0, 0); + next.setHours(hour ?? 6, minute ?? 0); + // Avancer jusqu'au prochain jour de la semaine voulu + const diff = (dayOfWeek - next.getDay() + 7) % 7; + next.setDate(next.getDate() + (diff === 0 && next <= now ? 7 : diff)); + return next; + } + + if (mode === "monthly") { + const next = new Date(now); + next.setSeconds(0, 0); + next.setHours(hour ?? 6, minute ?? 0); + next.setDate(dayOfMonth); + if (next <= now) { + next.setMonth(next.getMonth() + 1); + next.setDate(dayOfMonth); + } + return next; + } + + // scheduled (quotidien) + const next = new Date(now); + next.setSeconds(0, 0); + next.setHours(hour ?? 6, minute ?? 0); + if (next <= now) next.setDate(next.getDate() + 1); + return next; +} + +function formatNextRun(date: Date, mode: string, intervalMinutes: number): string { + if (mode === "interval") { + const mins = Math.max(60, intervalMinutes); + if (mins < 60) return `dans ${mins} minutes`; + const h = mins / 60; + return `dans ${h}h (à ${date.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })})`; + } + const dayName = DAYS_FR[date.getDay()]; + const dayNum = date.getDate(); + const monthName = date.toLocaleDateString("fr-FR", { month: "long" }); + const timeStr = date.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" }); + const isToday = new Date().toDateString() === date.toDateString(); + const isTomorrow = new Date(Date.now() + 86400000).toDateString() === date.toDateString(); + const dayLabel = isToday ? "aujourd'hui" : isTomorrow ? "demain" : `${dayName} ${dayNum} ${monthName}`; + return `${dayLabel} à ${timeStr}`; +} + +// ─── Composant UploadZone ───────────────────────────────────────────────────── interface UploadResult { success: boolean; @@ -224,6 +315,7 @@ export default function SettingsPage() { fetch_interval_minutes: "1440", fetch_day_of_week: "1", fetch_day_of_month: "1", + retention_months: "0", }); useEffect(() => { @@ -236,6 +328,21 @@ export default function SettingsPage() { const handleSave = () => saveMutation.mutate(form as Parameters[0]); const sourceType = (form.source_type || "local") as SourceType; + // ─── Calcul de la prochaine date d'exécution ────────────────────────────── + const nextRun = useMemo(() => { + return computeNextRun( + form.fetch_mode || "scheduled", + form.import_time || "06:00", + parseInt(form.fetch_day_of_week || "1", 10), + parseInt(form.fetch_day_of_month || "1", 10), + parseInt(form.fetch_interval_minutes || "1440", 10) + ); + }, [form.fetch_mode, form.import_time, form.fetch_day_of_week, form.fetch_day_of_month, form.fetch_interval_minutes]); + + const nextRunLabel = useMemo(() => { + return formatNextRun(nextRun, form.fetch_mode || "scheduled", parseInt(form.fetch_interval_minutes || "1440", 10)); + }, [nextRun, form.fetch_mode, form.fetch_interval_minutes]); + return (
{/* En-tête */} @@ -515,7 +622,7 @@ export default function SettingsPage() {
- {["Dim", "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam"].map((day, i) => ( + {DAYS_SHORT.map((day, i) => (
)} + + {/* ── Prochaine date d'exécution ──────────────────────────── */} +
+ +
+ Prochain import prévu : + {nextRunLabel} +
+
+ + + + {/* ── Rétention des articles ──────────────────────────────────── */} + + + + + Rétention des articles + + + Supprimez automatiquement les articles plus anciens que la durée choisie. + La purge s'exécute au démarrage du serveur et après chaque import automatique. + + + +
+ {[ + { label: "Illimité", value: "0" }, + { label: "3 mois", value: "3" }, + { label: "6 mois", value: "6" }, + { label: "12 mois", value: "12" }, + { label: "18 mois", value: "18" }, + { label: "24 mois", value: "24" }, + ].map((opt) => ( + + ))} +
+ {(form.retention_months || "0") !== "0" && ( +
+ +

+ Les articles importés il y a plus de {form.retention_months} mois seront supprimés définitivement lors du prochain démarrage ou import automatique. +

+
+ )}
diff --git a/server/_core/index.ts b/server/_core/index.ts index 850e7f1..31deca1 100644 --- a/server/_core/index.ts +++ b/server/_core/index.ts @@ -12,7 +12,7 @@ import { runFullImport } from "../importer"; import uploadRoutes from "../uploadRoutes"; import scheduledRoutes from "../scheduledRoutes"; import { ensureAdminExists } from "../localAuth"; -import { getSetting } from "../db"; +import { getSetting, purgeOldArticles } from "../db"; import { runRssFetch } from "../rssEngine"; function isPortAvailable(port: number): Promise { @@ -91,6 +91,19 @@ export async function scheduleDailyImport() { } catch (e) { console.error("[Cron] Erreur lors de la lecture RSS:", e); } + // 3. Purge des articles selon la règle de rétention + try { + const retentionStr = await getSetting("retention_months"); + const retentionMonths = retentionStr ? parseInt(retentionStr, 10) : 0; + if (retentionMonths > 0) { + const purged = await purgeOldArticles(retentionMonths); + if (purged.veille > 0 || purged.aap > 0) { + console.log(`[Cron] Purge rétention (${retentionMonths} mois) — Veille: -${purged.veille} | AAP: -${purged.aap}`); + } + } + } catch (e) { + console.error("[Cron] Erreur lors de la purge de rétention:", e); + } }); } @@ -136,6 +149,15 @@ async function startServer() { try { await ensureAdminExists(); await scheduleDailyImport(); + // Purge de rétention au démarrage + const retentionStr = await getSetting("retention_months"); + const retentionMonths = retentionStr ? parseInt(retentionStr, 10) : 0; + if (retentionMonths > 0) { + const purged = await purgeOldArticles(retentionMonths); + if (purged.veille > 0 || purged.aap > 0) { + console.log(`[Init] Purge rétention (${retentionMonths} mois) — Veille: -${purged.veille} | AAP: -${purged.aap}`); + } + } } catch (e) { console.error("[Init] Erreur d'initialisation:", e); } diff --git a/server/db.ts b/server/db.ts index 6a56a09..aa109b4 100644 --- a/server/db.ts +++ b/server/db.ts @@ -444,6 +444,19 @@ export async function saveRssSettings(data: Partial { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + const cutoff = new Date(); + cutoff.setMonth(cutoff.getMonth() - retentionMonths); + const veilleResult = await db.delete(veilleItems).where(lte(veilleItems.importedAt, cutoff)); + const aapResult = await db.delete(aapItems).where(lte(aapItems.importedAt, cutoff)); + return { + veille: (veilleResult as any).affectedRows ?? 0, + aap: (aapResult as any).affectedRows ?? 0, + }; +} + export async function purgeVeilleItems(): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); diff --git a/server/routers.ts b/server/routers.ts index 585beb7..1d79d7f 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -376,6 +376,7 @@ export const appRouter = router({ fetch_interval_minutes: z.string().optional(), fetch_day_of_week: z.string().optional(), fetch_day_of_month: z.string().optional(), + retention_months: z.string().optional(), }) ) .mutation(async ({ input }) => { diff --git a/todo.md b/todo.md index fb6ea24..277077b 100644 --- a/todo.md +++ b/todo.md @@ -182,3 +182,10 @@ - [x] Backend routers.ts : exposer et sauvegarder ces deux nouveaux paramètres - [x] Frontend Settings.tsx : sélecteur mode (heure fixe / intervalle) + sélecteur intervalle (1h, 2h, 4h, 6h, 12h, 24h) - [x] Déployer en recette + +## Évolutions planification et rétention +- [x] Afficher la prochaine date d'exécution dynamique dans les Paramètres (selon mode/heure/jour choisi) +- [x] Ajouter la règle de rétention configurable dans les Paramètres (supprimer articles > N mois) +- [x] Implémenter la purge automatique au démarrage du serveur selon la règle de rétention +- [ ] Déployer en recette +- [ ] Répliquer BDD recette vers production et déployer le code en production