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:
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"version": "af33c6e1",
|
||||
"timestamp": 1783248703843
|
||||
"version": "2bfe4358",
|
||||
"timestamp": 1783252262810
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
13
server/db.ts
13
server/db.ts
@@ -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");
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
7
todo.md
7
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
|
||||
|
||||
Reference in New Issue
Block a user