feat: purge import history by retention

This commit is contained in:
Manus
2026-10-09 10:22:46 +00:00
parent 6d72547509
commit 16759b4ad5
6 changed files with 176 additions and 20 deletions

View File

@@ -167,6 +167,12 @@ export default function History() {
const [selectedLog, setSelectedLog] = useState<DetailedImportLog | null>(null);
const [selectedAccountId, setSelectedAccountId] = useState("none");
const [confirmManualCheckOpen, setConfirmManualCheckOpen] = useState(false);
const [retentionMonths, setRetentionMonths] = useState<3 | 6 | 12>(6);
const { data: retentionPreview, isLoading: retentionPreviewLoading } = trpc.importLogs.retentionPreview.useQuery(
{ months: retentionMonths },
{ enabled: isAdmin },
);
const retentionPurgeMutation = trpc.importLogs.purgeBeforeRetention.useMutation();
const selectedAccount = useMemo(
() => configuredAccounts.find((account) => String(account.userId) === selectedAccountId),
@@ -183,6 +189,22 @@ export default function History() {
}
};
const handleRetentionPurge = async () => {
try {
const result = await retentionPurgeMutation.mutateAsync({ months: retentionMonths });
setPage(0);
await Promise.all([
utils.importLogs.getByUser.invalidate(),
utils.importLogs.retentionPreview.invalidate(),
]);
toast.success("Purge de l’historique terminée", {
description: `${result.deletedCount.toLocaleString("fr-FR")} journal${result.deletedCount > 1 ? "x" : ""} antérieur${result.deletedCount > 1 ? "s" : ""} à ${result.months} mois supprimé${result.deletedCount > 1 ? "s" : ""}.`,
});
} catch (error: any) {
toast.error(error?.message || "Impossible de purger l’historique des imports.");
}
};
const handleManualEmailCheck = async () => {
if (!selectedAccount) return;
try {
@@ -244,6 +266,39 @@ export default function History() {
<h1 className="text-3xl font-bold">Historique des imports</h1>
<p className="mt-1 text-gray-500">Suivez la date, la source, le compte et le mode de déclenchement de chaque ajout.</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{isAdmin && (
<>
<Select value={String(retentionMonths)} onValueChange={(value) => setRetentionMonths(Number(value) as 3 | 6 | 12)}>
<SelectTrigger className="h-9 w-40 text-sm"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="3">Conserver 3 mois</SelectItem>
<SelectItem value="6">Conserver 6 mois</SelectItem>
<SelectItem value="12">Conserver 1 an</SelectItem>
</SelectContent>
</Select>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" className="border-orange-300 bg-orange-50 text-orange-800 hover:bg-orange-100"><Trash2 className="mr-2 h-4 w-4" />Purger l’historique</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Purger les journaux anciens ?</AlertDialogTitle>
<AlertDialogDescription>
{retentionPreviewLoading ? "Calcul du volume à purger…" : `${(retentionPreview?.count ?? 0).toLocaleString("fr-FR")} journal${(retentionPreview?.count ?? 0) > 1 ? "x" : ""} antérieur${(retentionPreview?.count ?? 0) > 1 ? "s" : ""} à ${retentionMonths} mois seront supprimés.`}
{" Les factures, fichiers source et PDF ne seront pas supprimés."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction onClick={handleRetentionPurge} disabled={retentionPreviewLoading || retentionPurgeMutation.isPending} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
{retentionPurgeMutation.isPending ? "Purge…" : "Confirmer la purge"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)}
{detailedLogs.length > 0 && (
<AlertDialog>
<AlertDialogTrigger asChild>
@@ -264,6 +319,7 @@ export default function History() {
</AlertDialog>
)}
</div>
</div>
{isAdmin && (
<Card className="border-teal-200 bg-gradient-to-r from-teal-50 to-cyan-50">

View File

@@ -1,4 +1,4 @@
import { eq, and, desc, sql, inArray, getTableColumns, count } from "drizzle-orm";
import { eq, and, desc, sql, inArray, getTableColumns, count, lt } from "drizzle-orm";
import { drizzle } from "drizzle-orm/mysql2";
import {
InsertUser,
@@ -628,6 +628,25 @@ export async function deleteAllImportLogs(userId: number | null): Promise<void>
}
}
/** Compte les seuls journaux d'import qui seront concernés par une purge de rétention. */
export async function countImportLogsBefore(cutoff: Date): Promise<number> {
const db = await getDb();
if (!db) return 0;
const [result] = await db.select({ total: count() }).from(importLogs).where(lt(importLogs.importedAt, cutoff));
return Number(result?.total ?? 0);
}
/**
* Supprime uniquement les journaux d'import antérieurs à la borne donnée.
* Les factures, fichiers source et PDF ne sont jamais modifiés.
*/
export async function deleteImportLogsBefore(cutoff: Date): Promise<number> {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db.delete(importLogs).where(lt(importLogs.importedAt, cutoff));
return Number(result[0]?.affectedRows ?? 0);
}
// ============= LLM LOG OPERATIONS =============
export async function createLlmLog(data: InsertLlmLog): Promise<LlmLog> {

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import {
IMPORT_LOG_RETENTION_OPTIONS,
getImportLogRetentionCutoff,
isImportLogRetentionMonths,
} from "@shared/importLogRetention";
describe("rétention des journaux d'import", () => {
it("propose uniquement les périodes de purge autorisées", () => {
expect(IMPORT_LOG_RETENTION_OPTIONS).toEqual([3, 6, 12]);
expect(isImportLogRetentionMonths(3)).toBe(true);
expect(isImportLogRetentionMonths(6)).toBe(true);
expect(isImportLogRetentionMonths(12)).toBe(true);
expect(isImportLogRetentionMonths(9)).toBe(false);
});
it("calcule une date limite calendaire sans modifier la date de référence", () => {
const now = new Date("2026-10-09T12:00:00.000Z");
const cutoff = getImportLogRetentionCutoff(6, now);
expect(cutoff.toISOString()).toBe("2026-04-09T12:00:00.000Z");
expect(now.toISOString()).toBe("2026-10-09T12:00:00.000Z");
});
});

View File

@@ -4,6 +4,7 @@ import { BAP_MIN_QUALITY_SCORE, meetsBapQualityThreshold } from "@shared/bapElig
import { requiresBapBeforeOriginalPdfExport } from "@shared/bapExportGuard";
import { calculateInvoiceQualityScore, withCalculatedInvoiceQuality } from "@shared/invoiceQuality";
import { normalizeImportLogPagination } from "@shared/importLogPagination";
import { getImportLogRetentionCutoff, isImportLogRetentionMonths } from "@shared/importLogRetention";
import { webPortalUrlSchema } from "./webPortalUrl";
interface Condition {
@@ -50,6 +51,8 @@ import {
getImportLogsByUser,
getImportLogsWithDetailsByUser,
deleteAllImportLogs,
countImportLogsBefore,
deleteImportLogsBefore,
getLlmLogsBySourceFile,
getLlmLogsByInvoice,
getImportSettingsByUser,
@@ -1748,6 +1751,34 @@ export const appRouter = router({
}
return { success: true };
}),
retentionPreview: protectedProcedure
.input(z.object({ months: z.number().int() }))
.query(async ({ ctx, input }) => {
if (ctx.user.role !== "admin") {
throw new TRPCError({ code: "FORBIDDEN", message: "Réservé aux administrateurs." });
}
if (!isImportLogRetentionMonths(input.months)) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Durée de rétention invalide." });
}
const cutoff = getImportLogRetentionCutoff(input.months);
const count = await countImportLogsBefore(cutoff);
return { months: input.months, cutoff, count };
}),
purgeBeforeRetention: protectedProcedure
.input(z.object({ months: z.number().int() }))
.mutation(async ({ ctx, input }) => {
if (ctx.user.role !== "admin") {
throw new TRPCError({ code: "FORBIDDEN", message: "Réservé aux administrateurs." });
}
if (!isImportLogRetentionMonths(input.months)) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Durée de rétention invalide." });
}
const cutoff = getImportLogRetentionCutoff(input.months);
const deletedCount = await deleteImportLogsBefore(cutoff);
return { months: input.months, cutoff, deletedCount };
}),
}),
// ============= LLM LOGS ROUTES =============

View File

@@ -0,0 +1,21 @@
export const IMPORT_LOG_RETENTION_OPTIONS = [3, 6, 12] as const;
export type ImportLogRetentionMonths = (typeof IMPORT_LOG_RETENTION_OPTIONS)[number];
export function isImportLogRetentionMonths(value: number): value is ImportLogRetentionMonths {
return IMPORT_LOG_RETENTION_OPTIONS.includes(value as ImportLogRetentionMonths);
}
/**
* Retourne la borne calendaire exclusive de la purge : les journaux strictement
* antérieurs à cette date peuvent être supprimés. Les factures et PDF ne sont
* jamais concernés par cette politique de rétention.
*/
export function getImportLogRetentionCutoff(
months: ImportLogRetentionMonths,
referenceDate = new Date(),
): Date {
const cutoff = new Date(referenceDate);
cutoff.setMonth(cutoff.getMonth() - months);
return cutoff;
}

View File

@@ -1,5 +1,10 @@
# Invoice Analyzer - TODO
## Purge d’historique par rétention — 09/10/2026
- [x] Ajouter une purge administrateur pour les journaux antérieurs à 3 mois, 6 mois ou 1 an
- [x] Afficher le volume concerné et exiger une confirmation avant suppression
- [ ] Valider la purge ciblée en recette sans supprimer de factures ni de PDF
## Incident historique et rapport d’import — production — 09/10/2026
- [x] Tracer les requêtes et données : 101 886 journaux sont renvoyés sans limite à l’interface
- [x] Borner et paginer la récupération des journaux sans supprimer l’historique