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",
|
"version": "2bfe4358",
|
||||||
"timestamp": 1783248703843
|
"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 { trpc } from "@/lib/trpc";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -23,6 +23,8 @@ import {
|
|||||||
Upload,
|
Upload,
|
||||||
FileSpreadsheet,
|
FileSpreadsheet,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
CalendarClock,
|
||||||
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -43,7 +45,96 @@ const SOURCE_LABELS: Record<SourceType, string> = {
|
|||||||
sharepoint: "SharePoint",
|
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 {
|
interface UploadResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -224,6 +315,7 @@ export default function SettingsPage() {
|
|||||||
fetch_interval_minutes: "1440",
|
fetch_interval_minutes: "1440",
|
||||||
fetch_day_of_week: "1",
|
fetch_day_of_week: "1",
|
||||||
fetch_day_of_month: "1",
|
fetch_day_of_month: "1",
|
||||||
|
retention_months: "0",
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -236,6 +328,21 @@ export default function SettingsPage() {
|
|||||||
const handleSave = () => saveMutation.mutate(form as Parameters<typeof saveMutation.mutate>[0]);
|
const handleSave = () => saveMutation.mutate(form as Parameters<typeof saveMutation.mutate>[0]);
|
||||||
const sourceType = (form.source_type || "local") as SourceType;
|
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 (
|
return (
|
||||||
<div className="p-6 space-y-6 max-w-4xl animate-fade-up">
|
<div className="p-6 space-y-6 max-w-4xl animate-fade-up">
|
||||||
{/* En-tête */}
|
{/* En-tête */}
|
||||||
@@ -515,7 +622,7 @@ export default function SettingsPage() {
|
|||||||
<div className="space-y-2 max-w-xs">
|
<div className="space-y-2 max-w-xs">
|
||||||
<Label>Jour de la semaine</Label>
|
<Label>Jour de la semaine</Label>
|
||||||
<div className="grid grid-cols-7 gap-1">
|
<div className="grid grid-cols-7 gap-1">
|
||||||
{["Dim", "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam"].map((day, i) => (
|
{DAYS_SHORT.map((day, i) => (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -578,6 +685,65 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { runFullImport } from "../importer";
|
|||||||
import uploadRoutes from "../uploadRoutes";
|
import uploadRoutes from "../uploadRoutes";
|
||||||
import scheduledRoutes from "../scheduledRoutes";
|
import scheduledRoutes from "../scheduledRoutes";
|
||||||
import { ensureAdminExists } from "../localAuth";
|
import { ensureAdminExists } from "../localAuth";
|
||||||
import { getSetting } from "../db";
|
import { getSetting, purgeOldArticles } from "../db";
|
||||||
import { runRssFetch } from "../rssEngine";
|
import { runRssFetch } from "../rssEngine";
|
||||||
|
|
||||||
function isPortAvailable(port: number): Promise<boolean> {
|
function isPortAvailable(port: number): Promise<boolean> {
|
||||||
@@ -91,6 +91,19 @@ export async function scheduleDailyImport() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[Cron] Erreur lors de la lecture RSS:", 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 {
|
try {
|
||||||
await ensureAdminExists();
|
await ensureAdminExists();
|
||||||
await scheduleDailyImport();
|
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) {
|
} catch (e) {
|
||||||
console.error("[Init] Erreur d'initialisation:", 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 ───────────────────────────────────────────────────────────────────
|
// ─── 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> {
|
export async function purgeVeilleItems(): Promise<number> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db) throw new Error("Database not available");
|
if (!db) throw new Error("Database not available");
|
||||||
|
|||||||
@@ -376,6 +376,7 @@ export const appRouter = router({
|
|||||||
fetch_interval_minutes: z.string().optional(),
|
fetch_interval_minutes: z.string().optional(),
|
||||||
fetch_day_of_week: z.string().optional(),
|
fetch_day_of_week: z.string().optional(),
|
||||||
fetch_day_of_month: z.string().optional(),
|
fetch_day_of_month: z.string().optional(),
|
||||||
|
retention_months: z.string().optional(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ input }) => {
|
.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] 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] Frontend Settings.tsx : sélecteur mode (heure fixe / intervalle) + sélecteur intervalle (1h, 2h, 4h, 6h, 12h, 24h)
|
||||||
- [x] Déployer en recette
|
- [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