feat: purge import history by retention
This commit is contained in:
@@ -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">
|
||||
|
||||
21
server/db.ts
21
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<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> {
|
||||
|
||||
24
server/importLogRetention.test.ts
Normal file
24
server/importLogRetention.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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 =============
|
||||
|
||||
21
shared/importLogRetention.ts
Normal file
21
shared/importLogRetention.ts
Normal 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;
|
||||
}
|
||||
5
todo.md
5
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
|
||||
|
||||
Reference in New Issue
Block a user