Checkpoint: Ajout de la prochaine date d'exécution dynamique dans les Paramètres (calcul frontend selon mode/heure/jour, aligné sur le cron réel backend). Ajout de la règle de rétention configurable (Illimité/3/6/12/18/24 mois) avec purge automatique au démarrage et après chaque import. 0 erreur TypeScript, 21 tests passés.

This commit is contained in:
Manus
2026-07-05 07:51:02 -04:00
parent f0be15d432
commit 55f3d5a5d4
6 changed files with 215 additions and 6 deletions

View File

@@ -1,4 +1,4 @@
{
"version": "af33c6e1",
"timestamp": 1783248703843
"version": "2bfe4358",
"timestamp": 1783252262810
}

View File

@@ -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<SourceType, string> = {
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<typeof saveMutation.mutate>[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 (
<div className="p-6 space-y-6 max-w-4xl animate-fade-up">
{/* En-tête */}
@@ -515,7 +622,7 @@ export default function SettingsPage() {
<div className="space-y-2 max-w-xs">
<Label>Jour de la semaine</Label>
<div className="grid grid-cols-7 gap-1">
{["Dim", "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam"].map((day, i) => (
{DAYS_SHORT.map((day, i) => (
<button
key={i}
type="button"
@@ -578,6 +685,65 @@ export default function SettingsPage() {
</div>
</div>
)}
{/* ── Prochaine date d'exécution */}
<div className="flex items-center gap-3 p-3 rounded-lg bg-primary/5 border border-primary/20 max-w-xl">
<CalendarClock size={16} className="text-primary shrink-0" />
<div className="text-sm">
<span className="text-muted-foreground">Prochain import prévu : </span>
<span className="font-semibold text-foreground">{nextRunLabel}</span>
</div>
</div>
</CardContent>
</Card>
{/* ── Rétention des articles ──────────────────────────────────── */}
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Trash2 size={16} className="text-muted-foreground" />
Rétention des articles
</CardTitle>
<CardDescription>
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.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-3 sm:grid-cols-6 gap-2 max-w-xl">
{[
{ 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) => (
<button
key={opt.value}
type="button"
onClick={() => set("retention_months", opt.value)}
className={cn(
"py-2 px-3 rounded-lg border-2 text-sm font-medium transition-all",
(form.retention_months || "0") === opt.value
? opt.value === "0"
? "border-emerald-500 bg-emerald-50 text-emerald-700"
: "border-primary bg-primary/5 text-primary"
: "border-border hover:border-primary/40 text-foreground"
)}
>
{opt.label}
</button>
))}
</div>
{(form.retention_months || "0") !== "0" && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-50 border border-amber-200 max-w-xl">
<AlertCircle size={14} className="text-amber-600 mt-0.5 shrink-0" />
<p className="text-xs text-amber-700">
Les articles importés il y a plus de <strong>{form.retention_months} mois</strong> seront supprimés définitivement lors du prochain démarrage ou import automatique.
</p>
</div>
)}
</CardContent>
</Card>

View File

@@ -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<boolean> {
@@ -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);
}

View File

@@ -444,6 +444,19 @@ export async function saveRssSettings(data: Partial<Omit<InsertRssSettings, "id"
}
// ─── Purge ───────────────────────────────────────────────────────────────────
export async function purgeOldArticles(retentionMonths: number): Promise<{ veille: number; aap: number }> {
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<number> {
const db = await getDb();
if (!db) throw new Error("Database not available");

View File

@@ -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 }) => {

View File

@@ -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