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 [selectedLog, setSelectedLog] = useState<DetailedImportLog | null>(null);
|
||||||
const [selectedAccountId, setSelectedAccountId] = useState("none");
|
const [selectedAccountId, setSelectedAccountId] = useState("none");
|
||||||
const [confirmManualCheckOpen, setConfirmManualCheckOpen] = useState(false);
|
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(
|
const selectedAccount = useMemo(
|
||||||
() => configuredAccounts.find((account) => String(account.userId) === selectedAccountId),
|
() => 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 () => {
|
const handleManualEmailCheck = async () => {
|
||||||
if (!selectedAccount) return;
|
if (!selectedAccount) return;
|
||||||
try {
|
try {
|
||||||
@@ -244,6 +266,39 @@ export default function History() {
|
|||||||
<h1 className="text-3xl font-bold">Historique des imports</h1>
|
<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>
|
<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>
|
||||||
|
<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 && (
|
{detailedLogs.length > 0 && (
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
@@ -264,6 +319,7 @@ export default function History() {
|
|||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Card className="border-teal-200 bg-gradient-to-r from-teal-50 to-cyan-50">
|
<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 { drizzle } from "drizzle-orm/mysql2";
|
||||||
import {
|
import {
|
||||||
InsertUser,
|
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 =============
|
// ============= LLM LOG OPERATIONS =============
|
||||||
|
|
||||||
export async function createLlmLog(data: InsertLlmLog): Promise<LlmLog> {
|
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 { requiresBapBeforeOriginalPdfExport } from "@shared/bapExportGuard";
|
||||||
import { calculateInvoiceQualityScore, withCalculatedInvoiceQuality } from "@shared/invoiceQuality";
|
import { calculateInvoiceQualityScore, withCalculatedInvoiceQuality } from "@shared/invoiceQuality";
|
||||||
import { normalizeImportLogPagination } from "@shared/importLogPagination";
|
import { normalizeImportLogPagination } from "@shared/importLogPagination";
|
||||||
|
import { getImportLogRetentionCutoff, isImportLogRetentionMonths } from "@shared/importLogRetention";
|
||||||
import { webPortalUrlSchema } from "./webPortalUrl";
|
import { webPortalUrlSchema } from "./webPortalUrl";
|
||||||
|
|
||||||
interface Condition {
|
interface Condition {
|
||||||
@@ -50,6 +51,8 @@ import {
|
|||||||
getImportLogsByUser,
|
getImportLogsByUser,
|
||||||
getImportLogsWithDetailsByUser,
|
getImportLogsWithDetailsByUser,
|
||||||
deleteAllImportLogs,
|
deleteAllImportLogs,
|
||||||
|
countImportLogsBefore,
|
||||||
|
deleteImportLogsBefore,
|
||||||
getLlmLogsBySourceFile,
|
getLlmLogsBySourceFile,
|
||||||
getLlmLogsByInvoice,
|
getLlmLogsByInvoice,
|
||||||
getImportSettingsByUser,
|
getImportSettingsByUser,
|
||||||
@@ -1748,6 +1751,34 @@ export const appRouter = router({
|
|||||||
}
|
}
|
||||||
return { success: true };
|
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 =============
|
// ============= 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
|
# 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
|
## 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] 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
|
- [x] Borner et paginer la récupération des journaux sans supprimer l’historique
|
||||||
|
|||||||
Reference in New Issue
Block a user