From 16759b4ad535f2a5d5ba7e8d58bba2f431492d4e Mon Sep 17 00:00:00 2001 From: Manus Date: Fri, 9 Oct 2026 10:22:46 +0000 Subject: [PATCH] feat: purge import history by retention --- client/src/pages/History.tsx | 94 ++++++++++++++++++++++++------- server/db.ts | 21 ++++++- server/importLogRetention.test.ts | 24 ++++++++ server/routers.ts | 31 ++++++++++ shared/importLogRetention.ts | 21 +++++++ todo.md | 5 ++ 6 files changed, 176 insertions(+), 20 deletions(-) create mode 100644 server/importLogRetention.test.ts create mode 100644 shared/importLogRetention.ts diff --git a/client/src/pages/History.tsx b/client/src/pages/History.tsx index 6d51925..b397dde 100644 --- a/client/src/pages/History.tsx +++ b/client/src/pages/History.tsx @@ -167,6 +167,12 @@ export default function History() { const [selectedLog, setSelectedLog] = useState(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,25 +266,59 @@ export default function History() {

Historique des imports

Suivez la date, la source, le compte et le mode de déclenchement de chaque ajout.

- {detailedLogs.length > 0 && ( - - - - - - - Supprimer l’historique ? - Cette action est irréversible et supprime les enregistrements accessibles avec votre rôle. - - - Annuler - - {deleteAllMutation.isPending ? "Suppression…" : "Supprimer"} - - - - - )} +
+ {isAdmin && ( + <> + + + + + + + + Purger les journaux anciens ? + + {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."} + + + + Annuler + + {retentionPurgeMutation.isPending ? "Purge…" : "Confirmer la purge"} + + + + + + )} + {detailedLogs.length > 0 && ( + + + + + + + Supprimer l’historique ? + Cette action est irréversible et supprime les enregistrements accessibles avec votre rôle. + + + Annuler + + {deleteAllMutation.isPending ? "Suppression…" : "Supprimer"} + + + + + )} +
{isAdmin && ( diff --git a/server/db.ts b/server/db.ts index e6a8509..1f9b3b9 100644 --- a/server/db.ts +++ b/server/db.ts @@ -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 } } +/** Compte les seuls journaux d'import qui seront concernés par une purge de rétention. */ +export async function countImportLogsBefore(cutoff: Date): Promise { + 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 { + 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 { diff --git a/server/importLogRetention.test.ts b/server/importLogRetention.test.ts new file mode 100644 index 0000000..986e839 --- /dev/null +++ b/server/importLogRetention.test.ts @@ -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"); + }); +}); diff --git a/server/routers.ts b/server/routers.ts index 92c2dee..26ab6ce 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -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 ============= diff --git a/shared/importLogRetention.ts b/shared/importLogRetention.ts new file mode 100644 index 0000000..91d51e4 --- /dev/null +++ b/shared/importLogRetention.ts @@ -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; +} diff --git a/todo.md b/todo.md index fc8199f..7fcfcb6 100644 --- a/todo.md +++ b/todo.md @@ -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