Compare commits

..

6 Commits

Author SHA1 Message Date
Manus
b7525ab51e Merge remote-tracking branch 'prod/main' 2026-08-22 10:33:05 +00:00
Manus
d729a94a96 Checkpoint: Correction robuste des doublons de factures : empreinte SHA-256 unique des PDF sources, détection globale avant import manuel/email/dossier/web, verrou contre les vérifications IMAP concurrentes, attente réelle de fin de traitement avant marquage lu, migration et tests de non-régression. 2026-08-22 10:31:54 +00:00
Manus
d8b2a8fe6f Checkpoint: Correction complémentaire : normalisation stricte des valeurs OAuth de loginMethod avant insertion DB, afin d’éviter les erreurs de troncature causées par les identifiants de plateforme externes. Ajout de tests associés et validation build complète. 2026-08-17 20:25:16 +00:00
Manus
54164b1b33 Checkpoint: Audit technique : suppression des composants, scripts et dépendances inutilisés ; chargement différé des pages ; génération de sauvegardes SQL robuste par lots ; centralisation des contrôles d’accès ; sécurisation des cookies Azure et imports web ; documentation de maintenance et 5 tests de non-régression ajoutés. 2026-08-17 20:21:58 +00:00
Manus
6b83361056 Checkpoint: mysqldump n'est pas disponible dans le container Node.js. Remplacement par un dump SQL généré directement via mysql2 : SHOW CREATE TABLE + SELECT * pour chaque table, avec échappement correct des valeurs. 2026-08-08 10:52:58 +00:00
Manus
d3426c1663 Checkpoint: Correction de l'erreur "non authentifié" sur /api/db-backup : utilisation de parseCookies(req.headers.cookie) au lieu de req.cookies (qui nécessitait cookie-parser non installé). 2026-08-08 10:41:01 +00:00
38 changed files with 3051 additions and 4411 deletions

2
.gitignore vendored
View File

@@ -45,6 +45,8 @@ pids
*.seed
*.pid.lock
*.bak
backups/
storage/
# Coverage directory used by tools like istanbul
coverage/

View File

@@ -1,27 +1,35 @@
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import NotFound from "@/pages/NotFound";
import { lazy, Suspense } from "react";
import { Route, Switch } from "wouter";
import ErrorBoundary from "./components/ErrorBoundary";
import { ThemeProvider } from "./contexts/ThemeContext";
import Home from "./pages/Home";
import Login from "./pages/Login";
import Dashboard from "./pages/Dashboard";
import Upload from "./pages/Upload";
import Invoices from "./pages/Invoices";
import InvoicesBAP from "./pages/InvoicesBAP";
import InvoiceDetail from "./pages/InvoiceDetail";
import Settings from "./pages/Settings";
import ImportSettings from "./pages/ImportSettings";
import History from "./pages/History";
import Users from "./pages/Users";
import ListsAdmin from "./pages/ListsAdmin";
import AutomationRules from "./pages/AutomationRules";
import BapHistory from "./pages/BapHistory";
import ImportReport from "./pages/ImportReport";
import LearningSettings from "./pages/LearningSettings";
import VentilationFreePro from "./pages/VentilationFreePro";
import WebImportSources from "./pages/WebImportSources";
// Pages chargées à la demande : l'écran de connexion reste léger et chaque
// module métier ne télécharge son code qu'au moment où l'utilisateur l'ouvre.
const Home = lazy(() => import("./pages/Home"));
const Login = lazy(() => import("./pages/Login"));
const Dashboard = lazy(() => import("./pages/Dashboard"));
const Upload = lazy(() => import("./pages/Upload"));
const Invoices = lazy(() => import("./pages/Invoices"));
const InvoicesBAP = lazy(() => import("./pages/InvoicesBAP"));
const InvoiceDetail = lazy(() => import("./pages/InvoiceDetail"));
const Settings = lazy(() => import("./pages/Settings"));
const ImportSettings = lazy(() => import("./pages/ImportSettings"));
const History = lazy(() => import("./pages/History"));
const Users = lazy(() => import("./pages/Users"));
const ListsAdmin = lazy(() => import("./pages/ListsAdmin"));
const AutomationRules = lazy(() => import("./pages/AutomationRules"));
const BapHistory = lazy(() => import("./pages/BapHistory"));
const ImportReport = lazy(() => import("./pages/ImportReport"));
const LearningSettings = lazy(() => import("./pages/LearningSettings"));
const VentilationFreePro = lazy(() => import("./pages/VentilationFreePro"));
const WebImportSources = lazy(() => import("./pages/WebImportSources"));
function RouteFallback() {
return <div className="min-h-screen bg-background" aria-busy="true" aria-label="Chargement" />;
}
function Router() {
return (
@@ -56,7 +64,9 @@ function App() {
<ThemeProvider defaultTheme="light">
<TooltipProvider>
<Toaster />
<Router />
<Suspense fallback={<RouteFallback />}>
<Router />
</Suspense>
</TooltipProvider>
</ThemeProvider>
</ErrorBoundary>

View File

@@ -1,335 +0,0 @@
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { Loader2, Send, User, Sparkles } from "lucide-react";
import { useState, useEffect, useRef } from "react";
import { Streamdown } from "streamdown";
/**
* Message type matching server-side LLM Message interface
*/
export type Message = {
role: "system" | "user" | "assistant";
content: string;
};
export type AIChatBoxProps = {
/**
* Messages array to display in the chat.
* Should match the format used by invokeLLM on the server.
*/
messages: Message[];
/**
* Callback when user sends a message.
* Typically you'll call a tRPC mutation here to invoke the LLM.
*/
onSendMessage: (content: string) => void;
/**
* Whether the AI is currently generating a response
*/
isLoading?: boolean;
/**
* Placeholder text for the input field
*/
placeholder?: string;
/**
* Custom className for the container
*/
className?: string;
/**
* Height of the chat box (default: 600px)
*/
height?: string | number;
/**
* Empty state message to display when no messages
*/
emptyStateMessage?: string;
/**
* Suggested prompts to display in empty state
* Click to send directly
*/
suggestedPrompts?: string[];
};
/**
* A ready-to-use AI chat box component that integrates with the LLM system.
*
* Features:
* - Matches server-side Message interface for seamless integration
* - Markdown rendering with Streamdown
* - Auto-scrolls to latest message
* - Loading states
* - Uses global theme colors from index.css
*
* @example
* ```tsx
* const ChatPage = () => {
* const [messages, setMessages] = useState<Message[]>([
* { role: "system", content: "You are a helpful assistant." }
* ]);
*
* const chatMutation = trpc.ai.chat.useMutation({
* onSuccess: (response) => {
* // Assuming your tRPC endpoint returns the AI response as a string
* setMessages(prev => [...prev, {
* role: "assistant",
* content: response
* }]);
* },
* onError: (error) => {
* console.error("Chat error:", error);
* // Optionally show error message to user
* }
* });
*
* const handleSend = (content: string) => {
* const newMessages = [...messages, { role: "user", content }];
* setMessages(newMessages);
* chatMutation.mutate({ messages: newMessages });
* };
*
* return (
* <AIChatBox
* messages={messages}
* onSendMessage={handleSend}
* isLoading={chatMutation.isPending}
* suggestedPrompts={[
* "Explain quantum computing",
* "Write a hello world in Python"
* ]}
* />
* );
* };
* ```
*/
export function AIChatBox({
messages,
onSendMessage,
isLoading = false,
placeholder = "Type your message...",
className,
height = "600px",
emptyStateMessage = "Start a conversation with AI",
suggestedPrompts,
}: AIChatBoxProps) {
const [input, setInput] = useState("");
const scrollAreaRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const inputAreaRef = useRef<HTMLFormElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Filter out system messages
const displayMessages = messages.filter((msg) => msg.role !== "system");
// Calculate min-height for last assistant message to push user message to top
const [minHeightForLastMessage, setMinHeightForLastMessage] = useState(0);
useEffect(() => {
if (containerRef.current && inputAreaRef.current) {
const containerHeight = containerRef.current.offsetHeight;
const inputHeight = inputAreaRef.current.offsetHeight;
const scrollAreaHeight = containerHeight - inputHeight;
// Reserve space for:
// - padding (p-4 = 32px top+bottom)
// - user message: 40px (item height) + 16px (margin-top from space-y-4) = 56px
// Note: margin-bottom is not counted because it naturally pushes the assistant message down
const userMessageReservedHeight = 56;
const calculatedHeight = scrollAreaHeight - 32 - userMessageReservedHeight;
setMinHeightForLastMessage(Math.max(0, calculatedHeight));
}
}, []);
// Scroll to bottom helper function with smooth animation
const scrollToBottom = () => {
const viewport = scrollAreaRef.current?.querySelector(
'[data-radix-scroll-area-viewport]'
) as HTMLDivElement;
if (viewport) {
requestAnimationFrame(() => {
viewport.scrollTo({
top: viewport.scrollHeight,
behavior: 'smooth'
});
});
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmedInput = input.trim();
if (!trimmedInput || isLoading) return;
onSendMessage(trimmedInput);
setInput("");
// Scroll immediately after sending
scrollToBottom();
// Keep focus on input
textareaRef.current?.focus();
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
};
return (
<div
ref={containerRef}
className={cn(
"flex flex-col bg-card text-card-foreground rounded-lg border shadow-sm",
className
)}
style={{ height }}
>
{/* Messages Area */}
<div ref={scrollAreaRef} className="flex-1 overflow-hidden">
{displayMessages.length === 0 ? (
<div className="flex h-full flex-col p-4">
<div className="flex flex-1 flex-col items-center justify-center gap-6 text-muted-foreground">
<div className="flex flex-col items-center gap-3">
<Sparkles className="size-12 opacity-20" />
<p className="text-sm">{emptyStateMessage}</p>
</div>
{suggestedPrompts && suggestedPrompts.length > 0 && (
<div className="flex max-w-2xl flex-wrap justify-center gap-2">
{suggestedPrompts.map((prompt, index) => (
<button
key={index}
onClick={() => onSendMessage(prompt)}
disabled={isLoading}
className="rounded-lg border border-border bg-card px-4 py-2 text-sm transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
>
{prompt}
</button>
))}
</div>
)}
</div>
</div>
) : (
<ScrollArea className="h-full">
<div className="flex flex-col space-y-4 p-4">
{displayMessages.map((message, index) => {
// Apply min-height to last message only if NOT loading (when loading, the loading indicator gets it)
const isLastMessage = index === displayMessages.length - 1;
const shouldApplyMinHeight =
isLastMessage && !isLoading && minHeightForLastMessage > 0;
return (
<div
key={index}
className={cn(
"flex gap-3",
message.role === "user"
? "justify-end items-start"
: "justify-start items-start"
)}
style={
shouldApplyMinHeight
? { minHeight: `${minHeightForLastMessage}px` }
: undefined
}
>
{message.role === "assistant" && (
<div className="size-8 shrink-0 mt-1 rounded-full bg-primary/10 flex items-center justify-center">
<Sparkles className="size-4 text-primary" />
</div>
)}
<div
className={cn(
"max-w-[80%] rounded-lg px-4 py-2.5",
message.role === "user"
? "bg-primary text-primary-foreground"
: "bg-muted text-foreground"
)}
>
{message.role === "assistant" ? (
<div className="prose prose-sm dark:prose-invert max-w-none">
<Streamdown>{message.content}</Streamdown>
</div>
) : (
<p className="whitespace-pre-wrap text-sm">
{message.content}
</p>
)}
</div>
{message.role === "user" && (
<div className="size-8 shrink-0 mt-1 rounded-full bg-secondary flex items-center justify-center">
<User className="size-4 text-secondary-foreground" />
</div>
)}
</div>
);
})}
{isLoading && (
<div
className="flex items-start gap-3"
style={
minHeightForLastMessage > 0
? { minHeight: `${minHeightForLastMessage}px` }
: undefined
}
>
<div className="size-8 shrink-0 mt-1 rounded-full bg-primary/10 flex items-center justify-center">
<Sparkles className="size-4 text-primary" />
</div>
<div className="rounded-lg bg-muted px-4 py-2.5">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
</div>
)}
</div>
</ScrollArea>
)}
</div>
{/* Input Area */}
<form
ref={inputAreaRef}
onSubmit={handleSubmit}
className="flex gap-2 p-4 border-t bg-background/50 items-end"
>
<Textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="flex-1 max-h-32 resize-none min-h-9"
rows={1}
/>
<Button
type="submit"
size="icon"
disabled={!input.trim() || isLoading}
className="shrink-0 h-[38px] w-[38px]"
>
{isLoading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
</Button>
</form>
</div>
);
}

View File

@@ -23,13 +23,11 @@ import {
useSidebar,
} from "@/components/ui/sidebar";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { getLoginUrl } from "@/const";
import { useIsMobile } from "@/hooks/useMobile";
import { LayoutDashboard, LogOut, PanelLeft, Users, FileText, Upload, History, Settings, Download, List, Zap, ChevronDown, Receipt, Cog, ClipboardList, CheckSquare, Brain, BarChart2, BarChart3, Globe } from "lucide-react";
import { CSSProperties, useEffect, useRef, useState } from "react";
import { useLocation } from "wouter";
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
import { Button } from "./ui/button";
type MenuItem = {
icon: any;

View File

@@ -1,155 +0,0 @@
/**
* GOOGLE MAPS FRONTEND INTEGRATION - ESSENTIAL GUIDE
*
* USAGE FROM PARENT COMPONENT:
* ======
*
* const mapRef = useRef<google.maps.Map | null>(null);
*
* <MapView
* initialCenter={{ lat: 40.7128, lng: -74.0060 }}
* initialZoom={15}
* onMapReady={(map) => {
* mapRef.current = map; // Store to control map from parent anytime, google map itself is in charge of the re-rendering, not react state.
* </MapView>
*
* ======
* Available Libraries and Core Features:
* -------------------------------
* 📍 MARKER (from `marker` library)
* - Attaches to map using { map, position }
* new google.maps.marker.AdvancedMarkerElement({
* map,
* position: { lat: 37.7749, lng: -122.4194 },
* title: "San Francisco",
* });
*
* -------------------------------
* 🏢 PLACES (from `places` library)
* - Does not attach directly to map; use data with your map manually.
* const place = new google.maps.places.Place({ id: PLACE_ID });
* await place.fetchFields({ fields: ["displayName", "location"] });
* map.setCenter(place.location);
* new google.maps.marker.AdvancedMarkerElement({ map, position: place.location });
*
* -------------------------------
* 🧭 GEOCODER (from `geocoding` library)
* - Standalone service; manually apply results to map.
* const geocoder = new google.maps.Geocoder();
* geocoder.geocode({ address: "New York" }, (results, status) => {
* if (status === "OK" && results[0]) {
* map.setCenter(results[0].geometry.location);
* new google.maps.marker.AdvancedMarkerElement({
* map,
* position: results[0].geometry.location,
* });
* }
* });
*
* -------------------------------
* 📐 GEOMETRY (from `geometry` library)
* - Pure utility functions; not attached to map.
* const dist = google.maps.geometry.spherical.computeDistanceBetween(p1, p2);
*
* -------------------------------
* 🛣️ ROUTES (from `routes` library)
* - Combines DirectionsService (standalone) + DirectionsRenderer (map-attached)
* const directionsService = new google.maps.DirectionsService();
* const directionsRenderer = new google.maps.DirectionsRenderer({ map });
* directionsService.route(
* { origin, destination, travelMode: "DRIVING" },
* (res, status) => status === "OK" && directionsRenderer.setDirections(res)
* );
*
* -------------------------------
* 🌦️ MAP LAYERS (attach directly to map)
* - new google.maps.TrafficLayer().setMap(map);
* - new google.maps.TransitLayer().setMap(map);
* - new google.maps.BicyclingLayer().setMap(map);
*
* -------------------------------
* ✅ SUMMARY
* - “map-attached” → AdvancedMarkerElement, DirectionsRenderer, Layers.
* - “standalone” → Geocoder, DirectionsService, DistanceMatrixService, ElevationService.
* - “data-only” → Place, Geometry utilities.
*/
/// <reference types="@types/google.maps" />
import { useEffect, useRef } from "react";
import { usePersistFn } from "@/hooks/usePersistFn";
import { cn } from "@/lib/utils";
declare global {
interface Window {
google?: typeof google;
}
}
const API_KEY = import.meta.env.VITE_FRONTEND_FORGE_API_KEY;
const FORGE_BASE_URL =
import.meta.env.VITE_FRONTEND_FORGE_API_URL ||
"https://forge.butterfly-effect.dev";
const MAPS_PROXY_URL = `${FORGE_BASE_URL}/v1/maps/proxy`;
function loadMapScript() {
return new Promise(resolve => {
const script = document.createElement("script");
script.src = `${MAPS_PROXY_URL}/maps/api/js?key=${API_KEY}&v=weekly&libraries=marker,places,geocoding,geometry`;
script.async = true;
script.crossOrigin = "anonymous";
script.onload = () => {
resolve(null);
script.remove(); // Clean up immediately
};
script.onerror = () => {
console.error("Failed to load Google Maps script");
};
document.head.appendChild(script);
});
}
interface MapViewProps {
className?: string;
initialCenter?: google.maps.LatLngLiteral;
initialZoom?: number;
onMapReady?: (map: google.maps.Map) => void;
}
export function MapView({
className,
initialCenter = { lat: 37.7749, lng: -122.4194 },
initialZoom = 12,
onMapReady,
}: MapViewProps) {
const mapContainer = useRef<HTMLDivElement>(null);
const map = useRef<google.maps.Map | null>(null);
const init = usePersistFn(async () => {
await loadMapScript();
if (!mapContainer.current) {
console.error("Map container not found");
return;
}
map.current = new window.google.maps.Map(mapContainer.current, {
zoom: initialZoom,
center: initialCenter,
mapTypeControl: true,
fullscreenControl: true,
zoomControl: true,
streetViewControl: true,
mapId: "DEMO_MAP_ID",
});
if (onMapReady) {
onMapReady(map.current);
}
});
useEffect(() => {
init();
}, [init]);
return (
<div ref={mapContainer} className={cn("w-full h-[500px]", className)} />
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,6 @@ import { useEffect } from "react";
import { useAuth } from "@/_core/hooks/useAuth";
import { Loader2, FileText } from "lucide-react";
import { useLocation } from "wouter";
import { Button } from "@/components/ui/button";
export default function Home() {
const [, setLocation] = useLocation();

27
docs/maintenance.md Normal file
View File

@@ -0,0 +1,27 @@
# Notes de maintenance
## Stockage et sauvegardes
Les fichiers PDF et les exports de sauvegarde sont des **données dexécution**. Ils sont volontairement exclus de Git par `storage/` et `backups/` afin quaucune facture ni sauvegarde ne soit envoyée au dépôt source. En recette et en production, ces dossiers doivent être montés sur des volumes Docker persistants.
Le module `server/databaseBackup.ts` produit un dump SQL autonome sans dépendre de `mysqldump`. Il exporte la structure de chaque table puis ses données par lots de 500 lignes. Il conserve au plus dix fichiers `.sql` locaux ; une sauvegarde est également téléchargée immédiatement depuis linterface.
## Authentification et accès
Les routes de téléchargement BAP, de sauvegarde DB et de récupération de sauvegardes vérifient désormais la session JWT `auth_token`. Les sauvegardes DB sont strictement réservées aux administrateurs. Les cookies utilisent `Secure; SameSite=None` derrière HTTPS et basculent vers `SameSite=Lax` en HTTP local pour rester compatibles avec les navigateurs.
## Imports web
Lendpoint dimport web accepte uniquement des PDF de 20 Mo maximum, valide len-tête `%PDF`, normalise le nom du fichier et sauvegarde le document dans le stockage persistant avant analyse IA. Les erreurs détaillées restent dans les logs du serveur ; lAPI retourne un message générique pour ne pas exposer de secret ou de détail dinfrastructure.
## Contrôles avant livraison
Avant chaque livraison, exécuter les commandes suivantes depuis la racine du projet :
```bash
pnpm test
pnpm run check
pnpm run build
```
Les scripts ponctuels dexploitation ou contenant des identifiants ne doivent jamais rester dans le répertoire du projet ni être ajoutés au dépôt.

View File

@@ -0,0 +1,2 @@
ALTER TABLE `sourceFiles` ADD `contentHash` varchar(64);--> statement-breakpoint
ALTER TABLE `sourceFiles` ADD CONSTRAINT `source_file_content_hash_unique` UNIQUE(`contentHash`);

File diff suppressed because it is too large Load Diff

View File

@@ -260,6 +260,13 @@
"when": 1785419093588,
"tag": "0036_broken_rattler",
"breakpoints": true
},
{
"idx": 37,
"version": "5",
"when": 1787394418464,
"tag": "0037_goofy_quentin_quire",
"breakpoints": true
}
]
}

View File

@@ -36,12 +36,16 @@ export const sourceFiles = mysqlTable("sourceFiles", {
fileName: varchar("fileName", { length: 255 }).notNull(),
fileKey: text("fileKey").notNull(), // Local storage key with YYYY-MM prefix
fileUrl: text("fileUrl").notNull(), // Public URL
/** Empreinte du PDF source, globale à l'application pour bloquer tout réimport identique. */
contentHash: varchar("contentHash", { length: 64 }),
totalInvoicesDetected: int("totalInvoicesDetected").default(0).notNull(),
processingStatus: mysqlEnum("processingStatus", ["processing", "completed", "error"]).default("processing").notNull(),
processingProgress: varchar("processingProgress", { length: 255 }), // Progress message (e.g., "Extraction 3/9 factures...")
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
}, (table) => ({
contentHashIdx: uniqueIndex("source_file_content_hash_unique").on(table.contentHash),
}));
export type SourceFile = typeof sourceFiles.$inferSelect;
export type InsertSourceFile = typeof sourceFiles.$inferInsert;

View File

@@ -95,7 +95,6 @@
"sharp": "^0.34.5",
"sonner": "^2.0.7",
"ssh2-sftp-client": "^12.0.1",
"streamdown": "^1.4.0",
"superjson": "^1.13.3",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
@@ -105,16 +104,13 @@
"zod": "^4.1.12"
},
"devDependencies": {
"@builder.io/vite-plugin-jsx-loc": "^0.1.1",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.1.3",
"@types/express": "4.17.21",
"@types/google.maps": "^3.58.1",
"@types/node": "^24.7.0",
"@types/react": "^19.2.1",
"@types/react-dom": "^19.2.1",
"@vitejs/plugin-react": "^5.0.4",
"add": "^2.0.6",
"autoprefixer": "^10.4.20",
"drizzle-kit": "^0.31.4",
"esbuild": "^0.25.0",

2131
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import type { Request } from "express";
import { getSessionCookieOptions } from "./cookies";
function requestFor(protocol: "http" | "https", forwardedProto?: string): Request {
return {
protocol,
headers: forwardedProto ? { "x-forwarded-proto": forwardedProto } : {},
} as Request;
}
describe("getSessionCookieOptions", () => {
it("utilise des cookies sécurisés derrière le proxy HTTPS", () => {
expect(getSessionCookieOptions(requestFor("http", "https"))).toMatchObject({
httpOnly: true,
path: "/",
secure: true,
sameSite: "none",
});
});
it("reste compatible avec lenvironnement HTTP local", () => {
expect(getSessionCookieOptions(requestFor("http"))).toMatchObject({
httpOnly: true,
path: "/",
secure: false,
sameSite: "lax",
});
});
});

View File

@@ -1,13 +1,6 @@
import type { CookieOptions, Request } from "express";
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
function isIpAddress(host: string) {
// Basic IPv4 check and IPv6 presence detection.
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return true;
return host.includes(":");
}
/** Detects HTTPS after direct access or a reverse proxy such as Traefik. */
function isSecureRequest(req: Request) {
if (req.protocol === "https") return true;
@@ -24,25 +17,13 @@ function isSecureRequest(req: Request) {
export function getSessionCookieOptions(
req: Request
): Pick<CookieOptions, "domain" | "httpOnly" | "path" | "sameSite" | "secure"> {
// const hostname = req.hostname;
// const shouldSetDomain =
// hostname &&
// !LOCAL_HOSTS.has(hostname) &&
// !isIpAddress(hostname) &&
// hostname !== "127.0.0.1" &&
// hostname !== "::1";
// const domain =
// shouldSetDomain && !hostname.startsWith(".")
// ? `.${hostname}`
// : shouldSetDomain
// ? hostname
// : undefined;
const secure = isSecureRequest(req);
return {
httpOnly: true,
path: "/",
sameSite: "none",
secure: isSecureRequest(req),
// Browsers reject SameSite=None without Secure; use Lax for local HTTP.
sameSite: secure ? "none" : "lax",
secure,
};
}

View File

@@ -5,19 +5,44 @@ import net from "net";
import path from "path";
import fs from "fs";
import archiver from "archiver";
import { exec as execCb } from "child_process";
import { promisify } from "util";
const execAsync = promisify(execCb);
import { parse as parseCookies } from "cookie";
import { createExpressMiddleware } from "@trpc/server/adapters/express";
import { registerOAuthRoutes } from "./oauth";
import { appRouter } from "../routers";
import { createContext } from "./context";
import { getSessionCookieOptions } from "./cookies";
import { serveStatic, setupVite } from "./vite";
import { getAllUsers, getUserByAzureAdId, getUserByEmail, upsertUser } from "../db";
import { getAllUsers, getImportSettingsByUser, getUserByAzureAdId, getUserByEmail, getUserSettings, upsertUser } from "../db";
import { startEmailImportService } from "../emailImportService";
import { startFolderImportService } from "../folderImportService";
import { getImportSettingsByUser } from "../db";
import { handleAzureCallback, isAzureAdConfigured, generateToken } from "../auth";
import { handleAzureCallback, isAzureAdConfigured, generateToken, verifyToken } from "../auth";
import { createDatabaseBackup } from "../databaseBackup";
import { generateStorageKey, localStorageDelete, localStoragePut } from "../localStorage";
import { calculateFileSha256 } from "../fileFingerprint";
const MAX_WEB_IMPORT_BYTES = 20 * 1024 * 1024;
/** Returns the signed local session or sends the appropriate HTTP error. */
function requireAuthenticatedUser(req: express.Request, res: express.Response) {
const token = parseCookies(req.headers.cookie || "").auth_token;
const user = token ? verifyToken(token) : null;
if (!user) {
res.status(401).json({ error: "Non authentifié" });
return null;
}
return user;
}
/** Restricts a sensitive endpoint to administrators. */
function requireAdmin(req: express.Request, res: express.Response) {
const user = requireAuthenticatedUser(req, res);
if (!user) return null;
if (user.role !== "admin") {
res.status(403).json({ error: "Accès réservé aux administrateurs" });
return null;
}
return user;
}
function isPortAvailable(port: number): Promise<boolean> {
return new Promise(resolve => {
@@ -53,6 +78,7 @@ async function startServer() {
// Accepte les chemins avec sous-dossiers : /api/download-bap/2026-04/filename.pdf
// ou via query param pdfPath : /api/download-bap/file.pdf?pdfPath=/storage/2026-04/file.pdf
app.get("/api/download-bap", (req, res) => {
if (!requireAuthenticatedUser(req, res)) return;
// Mode 1 : query param pdfPath (chemin complet depuis /storage/...)
const pdfPath = req.query.pdfPath as string | undefined;
if (!pdfPath) {
@@ -83,6 +109,7 @@ async function startServer() {
// Compat. ancienne route avec :filename (sans sous-dossier)
app.get("/api/download-bap/:filename", (req, res) => {
if (!requireAuthenticatedUser(req, res)) return;
const filename = path.basename(req.params.filename);
// Chercher dans tous les sous-dossiers de storage
const storageRoot = path.resolve("storage");
@@ -109,6 +136,7 @@ async function startServer() {
// Route de téléchargement groupé ZIP des PDFs annotés BAP
// POST /api/download-bap-zip avec body { files: Array<{ pdfPath: string, filename: string }> }
app.post("/api/download-bap-zip", (req, res) => {
if (!requireAuthenticatedUser(req, res)) return;
const files: Array<{ pdfPath: string; filename: string }> = req.body.files || [];
if (!files.length) {
res.status(400).json({ error: "Aucun fichier spécifié" });
@@ -212,10 +240,7 @@ async function startServer() {
// Générer le token JWT et poser le cookie
const token = generateToken(user);
res.cookie("auth_token", token, {
httpOnly: true,
secure: false,
sameSite: "lax",
path: "/",
...getSessionCookieOptions(req),
maxAge: 7 * 24 * 60 * 60 * 1000,
});
@@ -231,57 +256,29 @@ async function startServer() {
// ============= DB BACKUP - Génération et téléchargement dump MySQL =============
app.post("/api/db-backup", async (req, res) => {
// Vérifier l'auth JWT
const { verifyToken } = await import("../auth");
const token = req.cookies?.auth_token;
if (!token) { res.status(401).json({ error: "Non authentifié" }); return; }
const user = verifyToken(token);
if (!user || user.role !== "admin") { res.status(403).json({ error: "Accès réservé aux admins" }); return; }
if (!requireAdmin(req, res)) return;
try {
const dbUrl = new URL(process.env.DATABASE_URL || "");
const host = dbUrl.hostname;
const port = dbUrl.port || "3306";
const username = dbUrl.username;
const password = dbUrl.password;
const database = dbUrl.pathname.slice(1);
// Créer le dossier backups/
const backupDir = path.resolve("backups");
if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
const fileName = `backup-${database}-${timestamp}.sql`;
const filePath = path.join(backupDir, fileName);
// Construire la commande mysqldump
const sslFlag = dbUrl.searchParams.get("ssl-mode") === "DISABLED" ? "" : "--ssl-mode=REQUIRED";
const cmd = `mysqldump ${sslFlag} -h "${host}" -P ${port} -u "${username}" --password="${password}" "${database}" > "${filePath}"`;
console.log(`[Backup] Generating dump for database ${database}...`);
await execAsync(cmd);
console.log(`[Backup] Dump saved to ${filePath}`);
const backup = await createDatabaseBackup(process.env.DATABASE_URL, backupDir);
console.log(`[Backup] Dump saved to ${backup.filePath} (${backup.size} bytes)`);
// Retourner le fichier en téléchargement
const encodedName = encodeURIComponent(fileName);
const encodedName = encodeURIComponent(backup.fileName);
res.setHeader("Content-Disposition", `attachment; filename="${encodedName}"; filename*=UTF-8''${encodedName}`);
res.setHeader("Content-Type", "application/sql");
res.sendFile(filePath, (err) => {
res.sendFile(backup.filePath, (err) => {
if (err) console.error("[Backup] Error sending file:", err);
});
} catch (err: any) {
console.error("[Backup] Error:", err.message);
res.status(500).json({ error: "Erreur lors de la génération du dump : " + err.message });
res.status(500).json({ error: "La sauvegarde na pas pu être générée. Consultez les journaux serveur." });
}
});
// Télécharger une sauvegarde existante
app.get("/api/db-backup/:filename", async (req, res) => {
const { verifyToken } = await import("../auth");
const token = req.cookies?.auth_token;
if (!token) { res.status(401).json({ error: "Non authentifié" }); return; }
const user = verifyToken(token);
if (!user || user.role !== "admin") { res.status(403).json({ error: "Accès réservé aux admins" }); return; }
if (!requireAdmin(req, res)) return;
const fileName = path.basename(req.params.filename);
const filePath = path.join(path.resolve("backups"), fileName);
@@ -294,35 +291,72 @@ async function startServer() {
app.post("/api/web-import/push-invoice", async (req, res) => {
try {
const { apiToken, fileName, fileBase64, mimeType } = req.body;
if (!apiToken || !fileName || !fileBase64) {
const { apiToken, fileName, fileBase64 } = req.body;
if (typeof apiToken !== "string" || typeof fileName !== "string" || typeof fileBase64 !== "string") {
res.status(400).json({ error: "apiToken, fileName et fileBase64 sont requis" });
return;
}
const { getWebImportSourceByToken, getImportSettingsByUser, createInvoice, findDuplicateInvoice, isInvoiceBlacklisted, updateWebImportSourceStatus, createSourceFile } = await import('../db');
const safeFileName = path.basename(fileName);
if (!safeFileName.toLowerCase().endsWith(".pdf")) {
res.status(400).json({ error: "Seuls les fichiers PDF sont acceptés" });
return;
}
if (Buffer.byteLength(fileBase64, "utf8") > Math.ceil(MAX_WEB_IMPORT_BYTES * 1.34)) {
res.status(413).json({ error: "Le fichier dépasse la taille maximale autorisée" });
return;
}
const { getWebImportSourceByToken, createInvoice, findDuplicateInvoice, isInvoiceBlacklisted, updateWebImportSourceStatus, createSourceFile, getSourceFileByContentHash } = await import('../db');
const source = await getWebImportSourceByToken(apiToken);
if (!source) {
res.status(401).json({ error: "Token invalide" });
return;
}
const pdfBuffer = Buffer.from(fileBase64, 'base64');
const fileMime = mimeType || 'application/pdf';
// Stocker le fichier source en DB
const sourceFile = await createSourceFile({
userId: source.userId,
fileName,
fileKey: `web-import/${source.userId}/${Date.now()}-${fileName}`,
fileUrl: '',
});
const importSettings = await getImportSettingsByUser(source.userId);
if (pdfBuffer.length === 0 || pdfBuffer.length > MAX_WEB_IMPORT_BYTES || !pdfBuffer.subarray(0, 4).equals(Buffer.from("%PDF"))) {
res.status(400).json({ error: "Le contenu reçu nest pas un PDF valide" });
return;
}
const contentHash = calculateFileSha256(pdfBuffer);
const existingSource = await getSourceFileByContentHash(contentHash);
if (existingSource) {
await updateWebImportSourceStatus(source.id, "success", 0, true);
res.json({ success: true, imported: 0, duplicates: 1, total: 1 });
return;
}
// Stocker d'abord le PDF de façon persistante, comme les autres sources d'import.
const storageKey = generateStorageKey(source.userId, safeFileName);
const { url: fileUrl } = await localStoragePut(storageKey, pdfBuffer, "application/pdf");
let sourceFile;
try {
sourceFile = await createSourceFile({
userId: source.userId,
fileName: safeFileName,
fileKey: storageKey,
fileUrl,
contentHash,
});
} catch (error: any) {
if (error?.code === "ER_DUP_ENTRY" || error?.errno === 1062) {
await localStorageDelete(storageKey).catch(() => undefined);
await updateWebImportSourceStatus(source.id, "success", 0, true);
res.json({ success: true, imported: 0, duplicates: 1, total: 1 });
return;
}
throw error;
}
const userSettings = await getUserSettings(source.userId);
const aiSettings = {
aiProvider: importSettings?.aiProvider || 'manus',
mistralApiKey: importSettings?.mistralApiKey || undefined,
manusForgeApiUrl: importSettings?.manusForgeApiUrl || undefined,
manusForgeApiKey: importSettings?.manusForgeApiKey || undefined,
aiProvider: userSettings?.aiProvider || "manus",
mistralApiKey: userSettings?.mistralApiKey || undefined,
manusForgeApiUrl: userSettings?.manusForgeApiUrl || undefined,
manusForgeApiKey: userSettings?.manusForgeApiKey || undefined,
geminiApiKey: userSettings?.geminiApiKey || undefined,
};
const { extractInvoicesWithMistral } = await import('../invoiceExtractor');
const extractResult = await extractInvoicesWithMistral(pdfBuffer, source.userId, sourceFile.id, 'mistral-large-latest', undefined, aiSettings);
const extractResult = await extractInvoicesWithMistral(pdfBuffer, source.userId, sourceFile.id, userSettings?.llmModel || "mistral-large-latest", undefined, aiSettings);
let imported = 0;
let duplicates = 0;
for (const inv of extractResult.invoices || []) {
@@ -337,7 +371,7 @@ async function startServer() {
res.json({ success: true, imported, duplicates, total: (extractResult.invoices || []).length });
} catch (err: any) {
console.error('[WebImport] Erreur push-invoice:', err.message);
res.status(500).json({ error: err.message });
res.status(500).json({ error: "Limport web a échoué. Consultez les journaux serveur." });
}
});

View File

@@ -329,7 +329,6 @@ export async function invokeLLMWithUserSettings(
}
const provider = userSettings?.aiProvider || "mistral";
const isMistral = provider === "mistral";
const {
messages,

View File

@@ -1,6 +1,7 @@
import { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
import type { Express, Request, Response } from "express";
import * as db from "../db";
import { normalizeLoginMethod } from "../authMethod";
import { getSessionCookieOptions } from "./cookies";
import { sdk } from "./sdk";
@@ -32,7 +33,7 @@ export function registerOAuthRoutes(app: Express) {
openId: userInfo.openId,
name: userInfo.name || null,
email: userInfo.email ?? "unknown@example.com",
loginMethod: (userInfo.loginMethod ?? userInfo.platform ?? "manus") as "manus" | "local" | "azure-ad",
loginMethod: normalizeLoginMethod(userInfo.loginMethod ?? userInfo.platform),
lastSignedIn: new Date(),
});

View File

@@ -2,6 +2,7 @@ import { AXIOS_TIMEOUT_MS, COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
import { ForbiddenError } from "@shared/_core/errors";
import axios, { type AxiosInstance } from "axios";
import { parse as parseCookieHeader } from "cookie";
import { normalizeLoginMethod } from "../authMethod";
import type { Request } from "express";
import { SignJWT, jwtVerify } from "jose";
import type { User } from "../../drizzle/schema";
@@ -296,7 +297,7 @@ class SDKServer {
openId: userInfo.openId,
name: userInfo.name || null,
email: userInfo.email ?? "unknown@example.com",
loginMethod: (userInfo.loginMethod ?? userInfo.platform ?? "manus") as "manus" | "local" | "azure-ad",
loginMethod: normalizeLoginMethod(userInfo.loginMethod ?? userInfo.platform),
lastSignedIn: signedInAt,
});
user = await db.getUserByOpenId(userInfo.openId);

View File

@@ -1,6 +1,6 @@
import bcrypt from "bcrypt";
import { ConfidentialClientApplication } from "@azure/msal-node";
import { getUserByEmail, getUserByAzureAdId } from "./db";
import { getUserByEmail } from "./db";
import jwt from "jsonwebtoken";
const SALT_ROUNDS = 10;

19
server/authMethod.test.ts Normal file
View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { normalizeLoginMethod } from "./authMethod";
describe("normalizeLoginMethod", () => {
it("conserve les valeurs de lenum DB", () => {
expect(normalizeLoginMethod("local")).toBe("local");
expect(normalizeLoginMethod("azure-ad")).toBe("azure-ad");
});
it("convertit les identifiants de fournisseur Microsoft", () => {
expect(normalizeLoginMethod("Microsoft OAuth")).toBe("azure-ad");
expect(normalizeLoginMethod("AZURE_ENTRA")).toBe("azure-ad");
});
it("utilise Manus pour toute valeur inconnue au lieu déchouer en base", () => {
expect(normalizeLoginMethod("platform-v2")).toBe("manus");
expect(normalizeLoginMethod(undefined)).toBe("manus");
});
});

23
server/authMethod.ts Normal file
View File

@@ -0,0 +1,23 @@
/** Values accepted by the `users.loginMethod` database enum. */
export type LoginMethod = "manus" | "local" | "azure-ad";
const LOGIN_METHODS = new Set<LoginMethod>(["manus", "local", "azure-ad"]);
/**
* Maps provider-specific identifiers to the limited database enum.
* OAuth identity payloads are external input and must never be persisted verbatim.
*/
export function normalizeLoginMethod(value: unknown): LoginMethod {
if (typeof value !== "string") return "manus";
const normalized = value.trim().toLowerCase();
if (LOGIN_METHODS.has(normalized as LoginMethod)) {
return normalized as LoginMethod;
}
if (normalized.includes("azure") || normalized.includes("microsoft")) {
return "azure-ad";
}
return "manus";
}

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { toSqlLiteral } from "./databaseBackup";
describe("toSqlLiteral", () => {
it("sérialise les valeurs primitives de manière importable", () => {
expect(toSqlLiteral(null)).toBe("NULL");
expect(toSqlLiteral(undefined)).toBe("NULL");
expect(toSqlLiteral(42.5)).toBe("42.5");
expect(toSqlLiteral(true)).toBe("1");
expect(toSqlLiteral(false)).toBe("0");
});
it("échappe les caractères sensibles dune chaîne SQL", () => {
expect(toSqlLiteral("O'Hara\\facture\nligne")).toBe("'O\\'Hara\\\\facture\\nligne'");
});
it("conserve les données binaires et dates sans conversion ambiguë", () => {
expect(toSqlLiteral(Buffer.from([0, 255]))).toBe("X'00ff'");
expect(toSqlLiteral(new Date("2026-08-17T12:34:56.000Z"))).toBe("'2026-08-17 12:34:56.000'");
});
});

170
server/databaseBackup.ts Normal file
View File

@@ -0,0 +1,170 @@
import fs from "fs/promises";
import path from "path";
import mysql, { type RowDataPacket } from "mysql2/promise";
/** Number of rows exported per INSERT statement to bound memory usage. */
const EXPORT_BATCH_SIZE = 500;
/** Keep a short local history while preventing unbounded disk growth. */
const MAX_BACKUP_FILES = 10;
export type DatabaseBackupResult = {
fileName: string;
filePath: string;
size: number;
tableCount: number;
};
/**
* Converts one MySQL value to a portable SQL literal.
* Binary values, booleans, dates, quotes and backslashes are handled explicitly
* so a generated dump can be imported without corrupting invoice data.
*/
export function toSqlLiteral(value: unknown): string {
if (value === null || value === undefined) return "NULL";
if (Buffer.isBuffer(value)) return `X'${value.toString("hex")}'`;
if (value instanceof Date) {
return `'${value.toISOString().replace("T", " ").replace("Z", "")}'`;
}
if (typeof value === "number") return Number.isFinite(value) ? String(value) : "NULL";
if (typeof value === "boolean") return value ? "1" : "0";
return `'${String(value)
.replace(/\\/g, "\\\\")
.replace(/'/g, "\\'")
.replace(/\u0000/g, "\\0")
.replace(/\n/g, "\\n")
.replace(/\r/g, "\\r")}'`;
}
function quoteIdentifier(identifier: string): string {
if (!/^[A-Za-z0-9_$]+$/.test(identifier)) {
throw new Error("Identifiant SQL inattendu lors de la sauvegarde");
}
return `\`${identifier}\``;
}
function buildFileName(database: string, now = new Date()): string {
const safeDatabase = database.replace(/[^A-Za-z0-9_-]/g, "_");
const timestamp = now.toISOString().replace(/[:.]/g, "-").slice(0, 19);
return `backup-${safeDatabase}-${timestamp}.sql`;
}
function buildSslConfig(databaseUrl: URL) {
const sslMode = databaseUrl.searchParams.get("ssl-mode")?.toUpperCase();
const sslParameter = databaseUrl.searchParams.get("ssl");
if (sslMode === "REQUIRED" || sslMode === "VERIFY_CA" || sslMode === "VERIFY_IDENTITY") {
return { rejectUnauthorized: sslMode === "VERIFY_IDENTITY" };
}
if (sslParameter && sslParameter !== "false") {
try {
return JSON.parse(sslParameter) as { rejectUnauthorized?: boolean };
} catch {
return { rejectUnauthorized: false };
}
}
return undefined;
}
async function pruneOldBackups(backupDir: string): Promise<void> {
const entries = await fs.readdir(backupDir, { withFileTypes: true });
const backups = await Promise.all(
entries
.filter(entry => entry.isFile() && entry.name.endsWith(".sql"))
.map(async entry => ({
name: entry.name,
modifiedAt: (await fs.stat(path.join(backupDir, entry.name))).mtimeMs,
}))
);
backups.sort((a, b) => b.modifiedAt - a.modifiedAt);
await Promise.all(
backups.slice(MAX_BACKUP_FILES).map(backup => fs.unlink(path.join(backupDir, backup.name)))
);
}
/**
* Creates a self-contained SQL dump without requiring the mysqldump binary.
* Rows are exported in batches to avoid keeping the entire database in memory.
*/
export async function createDatabaseBackup(
databaseUrlValue: string | undefined,
backupDir: string
): Promise<DatabaseBackupResult> {
if (!databaseUrlValue) {
throw new Error("DATABASE_URL est absente");
}
const databaseUrl = new URL(databaseUrlValue);
if (!databaseUrl.protocol.startsWith("mysql")) {
throw new Error("La sauvegarde requiert une base de données MySQL compatible");
}
const database = databaseUrl.pathname.replace(/^\//, "");
if (!database) {
throw new Error("Nom de base de données absent de DATABASE_URL");
}
await fs.mkdir(backupDir, { recursive: true });
const fileName = buildFileName(database);
const filePath = path.join(backupDir, fileName);
const connection = await mysql.createConnection({
host: databaseUrl.hostname,
port: Number(databaseUrl.port || "3306"),
user: decodeURIComponent(databaseUrl.username),
password: decodeURIComponent(databaseUrl.password),
database,
ssl: buildSslConfig(databaseUrl),
});
try {
await fs.writeFile(
filePath,
`-- Backup généré le ${new Date().toISOString()}\n-- Base : ${database}\nSET FOREIGN_KEY_CHECKS=0;\n\n`,
"utf8"
);
const [tables] = await connection.query<RowDataPacket[]>("SHOW TABLES");
const tableNames = tables.map(row => String(Object.values(row)[0]));
for (const tableName of tableNames) {
const table = quoteIdentifier(tableName);
const [createRows] = await connection.query<RowDataPacket[]>(`SHOW CREATE TABLE ${table}`);
const createStatement = String(createRows[0]?.["Create Table"] ?? "");
if (!createStatement) throw new Error(`Structure introuvable pour la table ${tableName}`);
await fs.appendFile(
filePath,
`-- Table: ${tableName}\nDROP TABLE IF EXISTS ${table};\n${createStatement};\n\n`,
"utf8"
);
let offset = 0;
while (true) {
const [rows] = await connection.query<RowDataPacket[]>(
`SELECT * FROM ${table} LIMIT ${EXPORT_BATCH_SIZE} OFFSET ${offset}`
);
if (rows.length === 0) break;
const columns = Object.keys(rows[0]!).map(quoteIdentifier).join(", ");
const values = rows
.map(row => `(${Object.values(row).map(toSqlLiteral).join(", ")})`)
.join(",\n");
await fs.appendFile(filePath, `INSERT INTO ${table} (${columns}) VALUES\n${values};\n\n`, "utf8");
offset += rows.length;
}
}
await fs.appendFile(filePath, "SET FOREIGN_KEY_CHECKS=1;\n-- Fin du dump\n", "utf8");
await pruneOldBackups(backupDir);
const { size } = await fs.stat(filePath);
return { fileName, filePath, size, tableCount: tableNames.length };
} catch (error) {
await fs.rm(filePath, { force: true });
throw error;
} finally {
await connection.end();
}
}

View File

@@ -43,11 +43,8 @@ import {
InsertBapHistory,
BapHistory,
invoiceLearnings,
InsertInvoiceLearning,
InvoiceLearning,
deletedInvoices,
InsertDeletedInvoice,
DeletedInvoice,
webImportSources,
InsertWebImportSource,
WebImportSource
@@ -207,6 +204,22 @@ export async function createSourceFile(data: InsertSourceFile): Promise<SourceFi
return inserted[0]!;
}
/**
* Recherche un PDF déjà ingéré, quel que soit le compte utilisateur.
* L'import email est partagé entre plusieurs identités : la détection doit donc
* être globale pour éviter qu'une même pièce soit retraitée sous chaque compte.
*/
export async function getSourceFileByContentHash(contentHash: string): Promise<SourceFile | undefined> {
const db = await getDb();
if (!db) return undefined;
const result = await db
.select()
.from(sourceFiles)
.where(eq(sourceFiles.contentHash, contentHash))
.limit(1);
return result[0];
}
export async function getSourceFileById(id: number): Promise<SourceFile | undefined> {
const db = await getDb();
if (!db) return undefined;

View File

@@ -5,13 +5,15 @@ import {
createSourceFile,
updateSourceFile,
getUserSettings,
getSourceFileByContentHash,
findDuplicateInvoice,
isInvoiceBlacklisted,
createInvoice,
createImportLog,
} from "./db";
import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor";
import { localStoragePut, generateStorageKey } from "./localStorage";
import { localStorageDelete, localStoragePut, generateStorageKey } from "./localStorage";
import { calculateFileSha256 } from "./fileFingerprint";
import { sendImportNotification } from "./notificationService";
import { getOffice365ImapToken, buildXOAuth2String } from "./office365OAuth";
import { applyAutomationRules } from "./automationEngine";
@@ -32,6 +34,23 @@ interface EmailImportConfig {
// Store active intervals for each user
const activeIntervals = new Map<number, NodeJS.Timeout>();
// Une extraction IA peut dépasser la fréquence configurée : ce verrou évite
// qu'un second cycle IMAP traite les mêmes messages avant la fin du premier.
const activeChecks = new Map<number, Promise<void>>();
function runEmailCheckExclusive(config: EmailImportConfig): Promise<void> {
const runningCheck = activeChecks.get(config.userId);
if (runningCheck) {
console.log(`[EmailImport] Vérification déjà en cours pour user ${config.userId}, cycle ignoré`);
return runningCheck;
}
const check = checkEmailsForPDFs(config).finally(() => {
if (activeChecks.get(config.userId) === check) activeChecks.delete(config.userId);
});
activeChecks.set(config.userId, check);
return check;
}
/**
* Process a single email attachment (PDF)
@@ -49,6 +68,21 @@ async function processEmailAttachment(
// Convert attachment content to Buffer
const fileBuffer = attachment.content;
console.log(`[EmailImport] File size: ${fileBuffer.length} bytes`);
const contentHash = calculateFileSha256(fileBuffer);
const existingSource = await getSourceFileByContentHash(contentHash);
if (existingSource) {
console.log(
`[EmailImport] PDF déjà importé, extraction ignorée: ${fileName} -> source ${existingSource.id}`,
);
return {
success: true,
totalInvoices: Math.max(existingSource.totalInvoicesDetected, 1),
imported: 0,
duplicates: Math.max(existingSource.totalInvoicesDetected, 1),
errors: 0,
};
}
// Store source file
const sourceFileKey = generateStorageKey(userId, fileName);
@@ -65,13 +99,25 @@ async function processEmailAttachment(
}
// Create source file record
const sourceFile = await createSourceFile({
userId,
fileName,
fileKey: sourceFileKey,
fileUrl: sourceFileUrl,
processingStatus: "processing",
});
let sourceFile;
try {
sourceFile = await createSourceFile({
userId,
fileName,
fileKey: sourceFileKey,
fileUrl: sourceFileUrl,
contentHash,
processingStatus: "processing",
});
} catch (error: any) {
// La contrainte unique protège également contre deux imports concurrents.
if (error?.code === "ER_DUP_ENTRY" || error?.errno === 1062) {
await localStorageDelete(sourceFileKey).catch(() => undefined);
console.log(`[EmailImport] PDF réservé par un autre traitement: ${fileName}`);
return { success: true, totalInvoices: 1, imported: 0, duplicates: 1, errors: 0 };
}
throw error;
}
console.log(`[EmailImport] Source file record created with ID: ${sourceFile.id}`);
@@ -343,7 +389,7 @@ async function checkEmailsForPDFs(config: EmailImportConfig): Promise<void> {
imap.once("ready", () => {
console.log(`[EmailImport] Connected to IMAP server for user ${config.userId} (mode: ${config.authMode || "basic"})`);
openInbox((err, box) => {
openInbox((err) => {
if (err) {
console.error("[EmailImport] Error opening inbox:", err);
imap.end();
@@ -383,64 +429,69 @@ async function checkEmailsForPDFs(config: EmailImportConfig): Promise<void> {
markSeen: false, // Don't mark as seen yet
});
const processedEmails: number[] = [];
const processedEmailUids: number[] = [];
const messageTasks: Promise<void>[] = [];
fetch.on("message", (msg, seqno) => {
const uidPromise = new Promise<number>((resolveUid) => {
msg.once("attributes", (attributes) => resolveUid(attributes.uid));
});
msg.on("body", (stream) => {
simpleParser(stream as any, async (err, parsed: ParsedMail) => {
if (err) {
console.error("[EmailImport] Error parsing email:", err);
return;
}
const task = (async () => {
try {
const parsed: ParsedMail = await simpleParser(stream as any);
const pdfAttachments = parsed.attachments.filter(
(attachment) =>
attachment.contentType === "application/pdf" ||
attachment.filename?.toLowerCase().endsWith(".pdf"),
);
// Check if email has PDF attachments
const pdfAttachments = parsed.attachments.filter(
(att) =>
att.contentType === "application/pdf" ||
att.filename?.toLowerCase().endsWith(".pdf")
);
if (pdfAttachments.length === 0) return;
if (pdfAttachments.length === 0) {
return;
}
console.log(
`[EmailImport] Email ${seqno} has ${pdfAttachments.length} PDF attachment(s)`,
);
console.log(
`[EmailImport] Email ${seqno} has ${pdfAttachments.length} PDF attachment(s)`
);
let allAttachmentsSucceeded = true;
for (const attachment of pdfAttachments) {
try {
const result = await processEmailAttachment(
config.userId,
attachment,
parsed.subject || "No subject",
);
allAttachmentsSucceeded = allAttachmentsSucceeded && result.success;
// Process each PDF attachment
for (const attachment of pdfAttachments) {
try {
const result = await processEmailAttachment(
config.userId,
attachment,
parsed.subject || "No subject"
);
// Mark this email as successfully processed
if (!processedEmails.includes(seqno)) {
processedEmails.push(seqno);
if (result.success) {
await sendImportNotification(config.userId, {
source: "email",
fileName: attachment.filename || "email-attachment.pdf",
totalInvoices: result.totalInvoices,
imported: result.imported,
duplicates: result.duplicates,
errors: result.errors,
});
}
} catch (error) {
allAttachmentsSucceeded = false;
console.error(
`[EmailImport] Failed to process attachment from email ${seqno}:`,
error,
);
}
// Send notification after successful processing
if (result.success) {
await sendImportNotification(config.userId, {
source: "email",
fileName: attachment.filename || "email-attachment.pdf",
totalInvoices: result.totalInvoices,
imported: result.imported,
duplicates: result.duplicates,
errors: result.errors,
});
}
} catch (error) {
console.error(
`[EmailImport] Failed to process attachment from email ${seqno}:`,
error
);
}
if (allAttachmentsSucceeded) {
const uid = await uidPromise;
if (!processedEmailUids.includes(uid)) processedEmailUids.push(uid);
}
} catch (error) {
console.error(`[EmailImport] Error parsing email ${seqno}:`, error);
}
});
})();
messageTasks.push(task);
});
});
@@ -450,16 +501,19 @@ async function checkEmailsForPDFs(config: EmailImportConfig): Promise<void> {
reject(err);
});
fetch.once("end", () => {
fetch.once("end", async () => {
console.log(`[EmailImport] Finished fetching emails for user ${config.userId}`);
// Mark successfully processed emails as seen
if (processedEmails.length > 0) {
imap.addFlags(processedEmails, ["\\Seen"], (err) => {
// Le flux IMAP peut se terminer avant les traitements IA asynchrones.
// On attend explicitement chaque message avant de le marquer comme lu.
await Promise.allSettled(messageTasks);
if (processedEmailUids.length > 0) {
imap.addFlags(processedEmailUids, ["\\Seen"], (err) => {
if (err) {
console.error("[EmailImport] Error marking emails as seen:", err);
} else {
console.log(`[EmailImport] Marked ${processedEmails.length} emails as seen`);
console.log(`[EmailImport] Marked ${processedEmailUids.length} emails as seen`);
}
imap.end();
resolve();
@@ -598,13 +652,13 @@ export async function startEmailImportService(userId: number): Promise<boolean>
);
// Run immediately on start
checkEmailsForPDFs(config).catch((error) => {
runEmailCheckExclusive(config).catch((error) => {
console.error(`[EmailImport] Error checking emails for user ${userId}:`, error);
});
// Set up interval for periodic checks
const interval = setInterval(() => {
checkEmailsForPDFs(config).catch((error) => {
runEmailCheckExclusive(config).catch((error) => {
console.error(`[EmailImport] Error checking emails for user ${userId}:`, error);
});
}, frequencyMs);
@@ -670,7 +724,7 @@ export async function triggerEmailCheck(userId: number): Promise<{ success: bool
};
console.log(`[EmailImport] Manual check triggered for user ${userId}`);
await checkEmailsForPDFs(config);
await runEmailCheckExclusive(config);
return { success: true, message: "Vérification terminée avec succès" };
} catch (error: any) {

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { calculateFileSha256 } from "./fileFingerprint";
describe("calculateFileSha256", () => {
it("retourne la même empreinte pour un contenu identique", () => {
const content = Buffer.from("facture-pdf");
expect(calculateFileSha256(content)).toBe(calculateFileSha256(Buffer.from(content)));
});
it("distingue deux contenus différents", () => {
expect(calculateFileSha256(Buffer.from("facture-a"))).not.toBe(
calculateFileSha256(Buffer.from("facture-b")),
);
});
it("produit une empreinte SHA-256 hexadécimale", () => {
expect(calculateFileSha256(Buffer.from("facture"))).toMatch(/^[a-f0-9]{64}$/);
});
});

12
server/fileFingerprint.ts Normal file
View File

@@ -0,0 +1,12 @@
import { createHash } from "node:crypto";
/**
* Calcule une empreinte déterministe sur les octets du document original.
*
* L'empreinte est calculée avant tout stockage ou traitement IA : deux imports
* du même PDF sont donc reconnus même si le nom du fichier ou l'utilisateur
* diffèrent.
*/
export function calculateFileSha256(buffer: Buffer): string {
return createHash("sha256").update(buffer).digest("hex");
}

View File

@@ -4,6 +4,7 @@ import path from "path";
import {
getImportSettingsByUser,
createSourceFile,
getSourceFileByContentHash,
updateSourceFile,
getUserSettings,
findDuplicateInvoice,
@@ -11,7 +12,8 @@ import {
createImportLog,
} from "./db";
import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor";
import { localStoragePut, generateStorageKey } from "./localStorage";
import { localStorageDelete, localStoragePut, generateStorageKey } from "./localStorage";
import { calculateFileSha256 } from "./fileFingerprint";
import { sendImportNotification } from "./notificationService";
interface FolderImportConfig {
@@ -41,6 +43,13 @@ async function processFolderFile(
// Read the file
const fileBuffer = await fs.readFile(filePath);
console.log(`[FolderImport] File size: ${fileBuffer.length} bytes`);
const contentHash = calculateFileSha256(fileBuffer);
const existingSource = await getSourceFileByContentHash(contentHash);
if (existingSource) {
console.log(`[FolderImport] PDF déjà importé, fichier ignoré: ${fileName}`);
return { success: true, imported: 0, duplicates: 1, errors: 0 };
}
// Store source file
const sourceFileKey = generateStorageKey(userId, fileName);
@@ -57,13 +66,23 @@ async function processFolderFile(
}
// Create source file record
const sourceFile = await createSourceFile({
userId,
fileName,
fileKey: sourceFileKey,
fileUrl: sourceFileUrl,
processingStatus: "processing",
});
let sourceFile;
try {
sourceFile = await createSourceFile({
userId,
fileName,
fileKey: sourceFileKey,
fileUrl: sourceFileUrl,
contentHash,
processingStatus: "processing",
});
} catch (error: any) {
if (error?.code === "ER_DUP_ENTRY" || error?.errno === 1062) {
await localStorageDelete(sourceFileKey).catch(() => undefined);
return { success: true, imported: 0, duplicates: 1, errors: 0 };
}
throw error;
}
console.log(`[FolderImport] Source file record created with ID: ${sourceFile.id}`);

View File

@@ -38,7 +38,6 @@ interface AutoImportResult {
// ── Constantes ─────────────────────────────────────────────────────────────
const FREEPRO_BASE_URL = "https://pro.free.fr";
const LOGIN_URL = `${FREEPRO_BASE_URL}/espace-client/connexion/#/`;
const LOGIN_FORM_URL = `${FREEPRO_BASE_URL}/espace-client/connexion/`;
// Endpoint réel capturé via analyse réseau du portail FreePro (XHR POST)
const DO_LOGIN_URL = `${FREEPRO_BASE_URL}/account/security/do_login`;
@@ -77,17 +76,6 @@ function extractCookies(setCookieHeader: string | null): string {
.join("; ");
}
/**
* Calcule le label du mois précédent (les factures FreePro arrivent en début de mois suivant)
*/
function previousMoisLabel(): string {
const now = new Date();
now.setMonth(now.getMonth() - 1);
const m = String(now.getMonth() + 1).padStart(2, "0");
const y = String(now.getFullYear());
return `${m}/${y}`;
}
// ── Connexion au portail FreePro ───────────────────────────────────────────
/**

View File

@@ -1,4 +1,4 @@
import { invokeLLM, invokeLLMWithUserSettings } from "./_core/llm";
import { invokeLLMWithUserSettings } from "./_core/llm";
import { PDFDocument } from "pdf-lib";
import { createLlmLog } from "./db";
import PDFParser from "pdf2json";

View File

@@ -40,7 +40,7 @@ export function generateStorageKey(userId: number, fileName: string): string {
export async function localStoragePut(
fileKey: string,
buffer: Buffer,
contentType?: string
_contentType?: string
): Promise<{ key: string; url: string }> {
try {
const fullPath = path.join(STORAGE_BASE_PATH, fileKey);

View File

@@ -16,7 +16,6 @@ import { promisify } from "util";
import * as os from "os";
import * as path from "path";
import * as fs from "fs/promises";
import * as fsSync from "fs";
const execFileAsync = promisify(execFile);

View File

@@ -24,6 +24,7 @@ import {
searchInvoices,
getInvoiceStats,
createSourceFile,
getSourceFileByContentHash,
getSourceFileById,
updateSourceFile,
getUserSettings,
@@ -82,15 +83,12 @@ import {
createWebImportSource,
updateWebImportSource,
deleteWebImportSource,
updateWebImportSourceStatus,
} from "./db";
import { loginLocal, hashPassword, getAzureAuthUrl, isAzureAdConfigured, generateToken } from "./auth";
import { exec as execCb } from "child_process";
import { promisify } from "util";
import { loginLocal, hashPassword, getAzureAuthUrl, isAzureAdConfigured } from "./auth";
import fsSync from "fs";
import pathSync from "path";
const execAsync = promisify(execCb);
import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor";
import { calculateFileSha256 } from "./fileFingerprint";
import { localStoragePut, generateStorageKey } from "./localStorage";
import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport";
import { drawBapCartouche } from "./bapCartouche";
@@ -185,6 +183,17 @@ export const appRouter = router({
// Decode base64 file data
const fileBuffer = Buffer.from(input.fileData, "base64");
console.log(`[Upload] Received file: ${input.fileName}, size: ${fileBuffer.length} bytes`);
// Le contrôle sur les octets du PDF intervient avant le stockage et l'appel IA.
// Il reste fiable même si le nom du fichier ou le compte utilisateur diffère.
const contentHash = calculateFileSha256(fileBuffer);
const existingSource = await getSourceFileByContentHash(contentHash);
if (existingSource) {
throw new TRPCError({
code: "CONFLICT",
message: `Ce PDF a déjà été importé (${existingSource.fileName}).`,
});
}
// Store source file
const sourceFileKey = generateStorageKey(userId, input.fileName);
@@ -206,6 +215,7 @@ export const appRouter = router({
fileName: input.fileName,
fileKey: sourceFileKey,
fileUrl: sourceFileUrl,
contentHash,
processingStatus: "processing",
});

View File

@@ -1,135 +0,0 @@
import jsPDFModule from "jspdf";
import * as fs from "fs";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const jsPDF = (jsPDFModule as any).default ?? jsPDFModule;
const lines = [
{ structure: "1001BPT", type: "Lien 5G", montantCentimes: 0 },
{ structure: "1001VAR - ITEP SESSAD VAREY", type: "Tél. mobile", montantCentimes: 48 },
{ structure: "1031MER", type: "Lien 5G", montantCentimes: 2398 },
{ structure: "1038MBN", type: "Lien fibre", montantCentimes: 5999 },
{ structure: "1038RAC", type: "Lien fibre", montantCentimes: 5999 },
{ structure: "1042CLA", type: "Lien fibre", montantCentimes: 5999 },
{ structure: "1069BOUIME", type: "Lien fibre", montantCentimes: 5999 },
{ structure: "1069IVP", type: "Lien fibre", montantCentimes: 5999 },
{ structure: "1083ADV", type: "Lien 5G", montantCentimes: 1199 },
{ structure: "1083ADV", type: "Lien fibre", montantCentimes: 23996 },
{ structure: "1083ADV", type: "Tél. mobile", montantCentimes: 14261 },
{ structure: "1083MIS", type: "Tél. mobile", montantCentimes: 9592 },
{ structure: "1083QVT", type: "Tél. mobile", montantCentimes: 1199 },
{ structure: "1083SYL", type: "Lien 5G", montantCentimes: 1199 },
{ structure: "1083SYL", type: "Lien fibre", montantCentimes: 11998 },
{ structure: "1083SYL", type: "Tél. mobile", montantCentimes: 7194 },
{ structure: "1084CAS", type: "Tél. mobile", montantCentimes: 1199 },
{ structure: "2001BRP", type: "Lien 5G", montantCentimes: 1199 },
{ structure: "2001MUS", type: "Lien 5G", montantCentimes: 0 },
{ structure: "2001ROS", type: "Tél. mobile", montantCentimes: 1223 },
{ structure: "2011MON", type: "Lien fibre", montantCentimes: 5999 },
{ structure: "2013ANG", type: "Lien 5G", montantCentimes: 1199 },
{ structure: "2021MOU", type: "Lien fibre", montantCentimes: 5999 },
{ structure: "2063VSJ", type: "Lien fibre", montantCentimes: 5999 },
{ structure: "2069MAU", type: "Lien 5G", montantCentimes: 1199 },
{ structure: "2069SAL", type: "Tél. mobile", montantCentimes: 1199 },
{ structure: "2081ANC", type: "Lien 5G", montantCentimes: 1199 },
{ structure: "2081BLA", type: "Lien 5G", montantCentimes: 0 },
{ structure: "3069UDA", type: "Lien 5G", montantCentimes: 1199 },
{ structure: "3069UDA", type: "Lien fibre", montantCentimes: 27599 },
{ structure: "3069UDA", type: "Tél. mobile", montantCentimes: -1 },
];
const formatMontant = (centimes: number): string => {
const euros = centimes / 100;
const abs = Math.abs(euros);
const str = abs.toFixed(2).replace(".", ",");
const parts = str.split(",");
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, " ");
return (euros < 0 ? "-" : "") + parts.join(",") + " EUR";
};
const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" });
const pageW = 210;
const pageH = 297;
const margin = 14;
const tableStartY = 31;
const tableEndY = pageH - 10;
const usableW = pageW - margin * 2;
// En-tête
doc.setFontSize(7);
doc.setFont("helvetica", "normal");
doc.text("Edite le 05/06/2026 - 17:00", pageW - margin, 7, { align: "right" });
doc.setFontSize(13);
doc.setFont("helvetica", "bold");
doc.text("Ventilation facture FREE PRO", pageW / 2, 13, { align: "center" });
doc.setFontSize(10);
doc.text("01/01/2025", pageW / 2, 20, { align: "center" });
doc.setFontSize(8);
doc.setFont("helvetica", "bold");
doc.text("ref_piece :", margin, 27);
doc.setFont("helvetica", "normal");
doc.text("F202501004510", margin + 22, 27);
// Calcul dimensions
const nbRows = lines.length + 2;
const availH = tableEndY - tableStartY;
const rowH = availH / nbRows;
const fontSize = Math.max(5, Math.min(9, Math.floor(rowH * 0.55 / 0.353)));
console.log(`nbRows=${nbRows}, availH=${availH.toFixed(1)}mm, rowH=${rowH.toFixed(2)}mm, fontSize=${fontSize}pt`);
console.log(`Tableau: ${tableStartY}mm → ${(tableStartY + nbRows * rowH).toFixed(1)}mm (limite=${tableEndY}mm)`);
const col0W = usableW * 0.45;
const col1W = usableW * 0.32;
const col2W = usableW * 0.23;
const col1X = margin + col0W;
const col2X = col1X + col1W;
doc.setFontSize(fontSize);
const drawRow = (y: number, c0: string, c1: string, c2: string, bold: boolean, bg?: [number,number,number]) => {
if (bg) { doc.setFillColor(bg[0], bg[1], bg[2]); doc.rect(margin, y, usableW, rowH, "F"); }
doc.setFont("helvetica", bold ? "bold" : "normal");
const textY = y + rowH * 0.65;
const pad = 1.5;
doc.text(c0, margin + pad, textY, { maxWidth: col0W - pad * 2 });
doc.text(c1, col1X + pad, textY, { maxWidth: col1W - pad * 2 });
doc.text(c2, col2X + col2W - pad, textY, { align: "right", maxWidth: col2W - pad * 2 });
};
const drawHLine = (y: number, lw: number, r: number, g: number, b: number) => {
doc.setDrawColor(r, g, b); doc.setLineWidth(lw);
doc.line(margin, y, margin + usableW, y);
};
const drawVLines = (y: number, h: number) => {
doc.setDrawColor(180, 180, 180); doc.setLineWidth(0.1);
doc.line(margin, y, margin, y + h);
doc.line(col1X, y, col1X, y + h);
doc.line(col2X, y, col2X, y + h);
doc.line(margin + usableW, y, margin + usableW, y + h);
};
const headerY = tableStartY;
drawHLine(headerY, 0.4, 0, 0, 0);
drawRow(headerY, "Structure", "Type", "Montant TTC", true);
drawHLine(headerY + rowH, 0.4, 0, 0, 0);
drawVLines(headerY, rowH);
for (let i = 0; i < lines.length; i++) {
const l = lines[i];
const y = headerY + rowH * (i + 1);
const bg: [number,number,number] | undefined = i % 2 === 1 ? [248,248,248] : undefined;
drawRow(y, l.structure, l.type, formatMontant(l.montantCentimes), false, bg);
drawHLine(y + rowH, 0.1, 200, 200, 200);
drawVLines(y, rowH);
}
const totalCentimes = lines.reduce((s, l) => s + l.montantCentimes, 0);
const footerY = headerY + rowH * (lines.length + 1);
drawHLine(footerY, 0.4, 0, 0, 0);
drawRow(footerY, "Total general", "", formatMontant(totalCentimes), true, [240,240,240]);
drawHLine(footerY + rowH, 0.4, 0, 0, 0);
drawVLines(footerY, rowH);
const pdfBytes = doc.output("arraybuffer");
fs.writeFileSync("/tmp/test_freepro_01_25.pdf", Buffer.from(pdfBytes));
console.log("PDF généré : /tmp/test_freepro_01_25.pdf");
console.log(`Nombre de pages : ${doc.getNumberOfPages()}`);

25
todo.md
View File

@@ -692,3 +692,28 @@
- [ ] Endpoint API sécurisé pour déclencher l'import et recevoir les PDFs
- [ ] Script cron externe Node.js + Playwright pour SFR
- [ ] Framework connecteur générique extensible
## Audit technique et robustesse
- [x] Inventorier les artefacts, scripts et dépendances inutilisés
- [x] Supprimer les artefacts de développement et le code mort confirmés
- [x] Charger les pages à la demande pour réduire le JavaScript initial
- [x] Consolider le mécanisme de sauvegarde de base de données et ses validations
- [x] Renforcer les contrôles daccès et la gestion derreur des endpoints critiques
- [x] Documenter les modules métier, les invariants et les décisions techniques critiques
- [x] Ajouter des tests de non-régression ciblés et vérifier build, types et tests
- [x] Normaliser les valeurs OAuth de loginMethod avant écriture en base
## Incident production — erreur HTTP 404
- [x] Reproduire la 404 et contrôler le domaine, Traefik et les conteneurs
- [x] Identifier et corriger la cause racine sans modifier les données
- [x] Vérifier le retour HTTP 200 et la santé des conteneurs
## Audit production — 674 factures affichées
- [x] Compter les factures par utilisateur, source et statut
- [x] Identifier les groupes de doublons selon plusieurs clés métier
- [x] Vérifier les références de stockage et les effets de la fusion précédente
- [x] Préparer une correction réversible sans suppression immédiate
- [x] Sauvegarder la base et le volume puis suspendre les imports email
- [x] Bloquer les réimports par empreinte PDF et fiabiliser le traitement IMAP
- [ ] Déployer le correctif anti-réimport et migrer la base de production
- [ ] Appliquer la correction confirmée et vérifier le comptage final

View File

@@ -1,13 +1,11 @@
import { jsxLocPlugin } from "@builder.io/vite-plugin-jsx-loc";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import fs from "node:fs";
import path from "path";
import { defineConfig } from "vite";
import { vitePluginManusRuntime } from "vite-plugin-manus-runtime";
const plugins = [react(), tailwindcss(), jsxLocPlugin(), vitePluginManusRuntime()];
const plugins = [react(), tailwindcss(), vitePluginManusRuntime()];
export default defineConfig({
plugins,