diff --git a/client/src/pages/History.tsx b/client/src/pages/History.tsx index a90b0a6..6d51925 100644 --- a/client/src/pages/History.tsx +++ b/client/src/pages/History.tsx @@ -110,6 +110,7 @@ const MONTHS = [ ]; const YEARS = Array.from({ length: 5 }, (_, index) => String(new Date().getFullYear() - index)); +const IMPORT_LOG_PAGE_SIZE = 200; function formatAccount(account: Pick) { const owner = account.userName?.trim() || account.userEmail; @@ -139,7 +140,11 @@ function TriggerBadge({ trigger }: { trigger: DetailedImportLog["importTrigger"] export default function History() { const { user } = useAuth(); const isAdmin = user?.role === "admin"; - const { data: logs, isLoading } = trpc.importLogs.getByUser.useQuery(); + const [page, setPage] = useState(0); + const { data: logsPage, isLoading } = trpc.importLogs.getByUser.useQuery({ + limit: IMPORT_LOG_PAGE_SIZE, + offset: page * IMPORT_LOG_PAGE_SIZE, + }); const { data: emailAccounts, isLoading: accountsLoading } = trpc.emailImportService.getConfiguredAccounts.useQuery(undefined, { enabled: isAdmin, staleTime: 30_000, @@ -148,7 +153,8 @@ export default function History() { const checkAccountNowMutation = trpc.emailImportService.checkAccountNow.useMutation(); const utils = trpc.useUtils(); - const detailedLogs = (logs ?? []) as DetailedImportLog[]; + const detailedLogs = (logsPage?.items ?? []) as DetailedImportLog[]; + const totalLogs = logsPage?.total ?? 0; const accounts = (emailAccounts ?? []) as EmailAccount[]; const configuredAccounts = useMemo(() => accounts.filter((account) => account.isConfigured === 1), [accounts]); @@ -329,7 +335,14 @@ export default function History() { {hasFilters && } - {filteredLogs.length} import{filteredLogs.length > 1 ? "s" : ""} · {counts.totalDetected} facture{counts.totalDetected > 1 ? "s" : ""} détectée{counts.totalDetected > 1 ? "s" : ""} +
+ {filteredLogs.length} import{filteredLogs.length > 1 ? "s" : ""} · {counts.totalDetected} facture{counts.totalDetected > 1 ? "s" : ""} détectée{counts.totalDetected > 1 ? "s" : ""} + {totalLogs > IMPORT_LOG_PAGE_SIZE && <> + + Page {page + 1} + + } +
diff --git a/client/src/pages/ImportReport.tsx b/client/src/pages/ImportReport.tsx index 6a10340..bdb19bb 100644 --- a/client/src/pages/ImportReport.tsx +++ b/client/src/pages/ImportReport.tsx @@ -41,6 +41,8 @@ type ErrorDetail = { value?: string; }; +const IMPORT_LOG_PAGE_SIZE = 200; + function parseJson(str: string | null | undefined): T[] { if (!str) return []; try { @@ -59,7 +61,13 @@ function StatusBadge({ imported, duplicates, errors }: { imported: number; dupli } export default function ImportReport() { - const { data: logs, isLoading } = trpc.importLogs.getByUser.useQuery(); + const [page, setPage] = useState(0); + const { data: logsPage, isLoading } = trpc.importLogs.getByUser.useQuery({ + limit: IMPORT_LOG_PAGE_SIZE, + offset: page * IMPORT_LOG_PAGE_SIZE, + }); + const logs = (logsPage?.items ?? []) as ImportLog[]; + const totalLogs = logsPage?.total ?? 0; const [yearFilter, setYearFilter] = useState(String(new Date().getFullYear())); const [monthFilter, setMonthFilter] = useState("all"); const [statusFilter, setStatusFilter] = useState("all"); @@ -319,6 +327,14 @@ export default function ImportReport() { )} + {totalLogs > IMPORT_LOG_PAGE_SIZE && ( +
+ {totalLogs.toLocaleString("fr-FR")} journaux + + Page {page + 1} + +
+ )}
diff --git a/server/db.ts b/server/db.ts index 4047bb7..e6a8509 100644 --- a/server/db.ts +++ b/server/db.ts @@ -571,10 +571,15 @@ export async function getAllImportLogs(): Promise { * les paramètres sensibles de messagerie. L'administrateur peut ainsi relier * chaque import à son compte, fichier source et déclencheur. */ -export async function getImportLogsWithDetailsByUser(userId: number) { +export type ImportLogPageOptions = { + limit: number; + offset: number; +}; + +export async function getImportLogsWithDetailsByUser(userId: number, options: ImportLogPageOptions) { const db = await getDb(); - if (!db) return []; - return db.select({ + if (!db) return { items: [], total: 0 }; + const items = await db.select({ ...getTableColumns(importLogs), userName: users.name, userEmail: users.email, @@ -585,13 +590,17 @@ export async function getImportLogsWithDetailsByUser(userId: number) { .leftJoin(users, eq(importLogs.userId, users.id)) .leftJoin(sourceFiles, eq(importLogs.sourceFileId, sourceFiles.id)) .where(eq(importLogs.userId, userId)) - .orderBy(desc(importLogs.importedAt)); + .orderBy(desc(importLogs.importedAt)) + .limit(options.limit) + .offset(options.offset); + const [total] = await db.select({ total: count() }).from(importLogs).where(eq(importLogs.userId, userId)); + return { items, total: Number(total?.total ?? 0) }; } -export async function getAllImportLogsWithDetails() { +export async function getAllImportLogsWithDetails(options: ImportLogPageOptions) { const db = await getDb(); - if (!db) return []; - return db.select({ + if (!db) return { items: [], total: 0 }; + const items = await db.select({ ...getTableColumns(importLogs), userName: users.name, userEmail: users.email, @@ -601,7 +610,11 @@ export async function getAllImportLogsWithDetails() { .from(importLogs) .leftJoin(users, eq(importLogs.userId, users.id)) .leftJoin(sourceFiles, eq(importLogs.sourceFileId, sourceFiles.id)) - .orderBy(desc(importLogs.importedAt)); + .orderBy(desc(importLogs.importedAt)) + .limit(options.limit) + .offset(options.offset); + const [total] = await db.select({ total: count() }).from(importLogs); + return { items, total: Number(total?.total ?? 0) }; } export async function deleteAllImportLogs(userId: number | null): Promise { diff --git a/server/importLogPagination.test.ts b/server/importLogPagination.test.ts new file mode 100644 index 0000000..eebf3aa --- /dev/null +++ b/server/importLogPagination.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_IMPORT_LOG_PAGE_SIZE, + MAX_IMPORT_LOG_PAGE_SIZE, + normalizeImportLogPagination, +} from "../shared/importLogPagination"; + +describe("pagination des journaux d'import", () => { + it("retourne une page bornée par défaut au lieu de l'historique complet", () => { + expect(normalizeImportLogPagination()).toEqual({ + limit: DEFAULT_IMPORT_LOG_PAGE_SIZE, + offset: 0, + }); + }); + + it("borne les entrées utilisateur pour préserver la réactivité", () => { + expect(normalizeImportLogPagination({ limit: MAX_IMPORT_LOG_PAGE_SIZE + 1, offset: -20 })).toEqual({ + limit: MAX_IMPORT_LOG_PAGE_SIZE, + offset: 0, + }); + }); +}); diff --git a/server/routers.ts b/server/routers.ts index a4ac4e0..92c2dee 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -3,6 +3,7 @@ import { COOKIE_NAME } from "@shared/const"; import { BAP_MIN_QUALITY_SCORE, meetsBapQualityThreshold } from "@shared/bapEligibility"; import { requiresBapBeforeOriginalPdfExport } from "@shared/bapExportGuard"; import { calculateInvoiceQualityScore, withCalculatedInvoiceQuality } from "@shared/invoiceQuality"; +import { normalizeImportLogPagination } from "@shared/importLogPagination"; import { webPortalUrlSchema } from "./webPortalUrl"; interface Condition { @@ -1727,12 +1728,15 @@ export const appRouter = router({ // ============= IMPORT LOGS ROUTES ============= importLogs: router({ - getByUser: protectedProcedure.query(async ({ ctx }) => { + getByUser: protectedProcedure + .input(z.object({ limit: z.number().int().positive().optional(), offset: z.number().int().min(0).optional() }).optional()) + .query(async ({ ctx, input }) => { + const pagination = normalizeImportLogPagination(input); // Les admins voient tous les logs d'import if (ctx.user.role === 'admin') { - return getAllImportLogsWithDetails(); + return getAllImportLogsWithDetails(pagination); } - return getImportLogsWithDetailsByUser(ctx.user.id); + return getImportLogsWithDetailsByUser(ctx.user.id, pagination); }), deleteAll: protectedProcedure.mutation(async ({ ctx }) => { diff --git a/shared/importLogPagination.ts b/shared/importLogPagination.ts new file mode 100644 index 0000000..a2bdfe5 --- /dev/null +++ b/shared/importLogPagination.ts @@ -0,0 +1,22 @@ +export const DEFAULT_IMPORT_LOG_PAGE_SIZE = 200; +export const MAX_IMPORT_LOG_PAGE_SIZE = 500; + +export type ImportLogPaginationInput = { + limit?: number; + offset?: number; +}; + +/** + * Normalise les limites avant toute lecture de journal. Les imports historiques + * peuvent contenir plusieurs dizaines de milliers de lignes : l'interface ne + * doit jamais tenter de les transférer en une seule requête. + */ +export function normalizeImportLogPagination(input?: ImportLogPaginationInput) { + const requestedLimit = input?.limit ?? DEFAULT_IMPORT_LOG_PAGE_SIZE; + const requestedOffset = input?.offset ?? 0; + + return { + limit: Math.min(Math.max(Math.trunc(requestedLimit), 1), MAX_IMPORT_LOG_PAGE_SIZE), + offset: Math.max(Math.trunc(requestedOffset), 0), + }; +} diff --git a/todo.md b/todo.md index 222efd4..fc8199f 100644 --- a/todo.md +++ b/todo.md @@ -1,5 +1,10 @@ # Invoice Analyzer - TODO +## 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 +- [ ] Valider l’historique et le rapport en recette puis en production + ## Cohérence export / BAP — production — 09/10/2026 - [x] Tracer les états : Amazon et Inmac ont été exportées par le flux PDF générique le 30/09, sans BAP validé ni historique BAP - [x] Bloquer tout nouvel export PDF hors abonnement avant validation BAP et rendre l’état historique explicite dans l’interface