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.
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -45,6 +45,8 @@ pids
|
||||
*.seed
|
||||
*.pid.lock
|
||||
*.bak
|
||||
backups/
|
||||
storage/
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage/
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
27
docs/maintenance.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Notes de maintenance
|
||||
|
||||
## Stockage et sauvegardes
|
||||
|
||||
Les fichiers PDF et les exports de sauvegarde sont des **données d’exécution**. Ils sont volontairement exclus de Git par `storage/` et `backups/` afin qu’aucune 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 l’interface.
|
||||
|
||||
## 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
|
||||
|
||||
L’endpoint d’import web accepte uniquement des PDF de 20 Mo maximum, valide l’en-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 ; l’API retourne un message générique pour ne pas exposer de secret ou de détail d’infrastructure.
|
||||
|
||||
## 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 d’exploitation ou contenant des identifiants ne doivent jamais rester dans le répertoire du projet ni être ajoutés au dépôt.
|
||||
@@ -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
2131
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
30
server/_core/cookies.test.ts
Normal file
30
server/_core/cookies.test.ts
Normal 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 l’environnement HTTP local", () => {
|
||||
expect(getSessionCookieOptions(requestFor("http"))).toMatchObject({
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
secure: false,
|
||||
sameSite: "lax",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,20 +5,43 @@ 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";
|
||||
import { parse as parseCookies } from "cookie";
|
||||
const execAsync = promisify(execCb);
|
||||
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, localStoragePut } from "../localStorage";
|
||||
|
||||
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 => {
|
||||
@@ -54,6 +77,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) {
|
||||
@@ -84,6 +108,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");
|
||||
@@ -110,6 +135,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é" });
|
||||
@@ -213,10 +239,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,
|
||||
});
|
||||
|
||||
@@ -232,94 +255,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 cookies = parseCookies(req.headers.cookie || "");
|
||||
const token = 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);
|
||||
|
||||
// Dump SQL via mysql2 (pas besoin de mysqldump)
|
||||
console.log(`[Backup] Generating SQL dump for database ${database}...`);
|
||||
const mysql = await import("mysql2/promise");
|
||||
const sslRequired = !dbUrl.searchParams.get("ssl-mode")?.includes("DISABLED");
|
||||
const conn = await mysql.createConnection({
|
||||
host, port: parseInt(port), user: username, password: decodeURIComponent(password),
|
||||
database, ssl: sslRequired ? { rejectUnauthorized: false } : undefined,
|
||||
});
|
||||
|
||||
let sql = `-- Backup généré le ${new Date().toISOString()}\n-- Base : ${database}\nSET FOREIGN_KEY_CHECKS=0;\n\n`;
|
||||
|
||||
// Lister les tables
|
||||
const [tables] = await conn.query<any[]>(`SHOW TABLES`);
|
||||
const tableNames: string[] = tables.map((r: any) => Object.values(r)[0] as string);
|
||||
|
||||
for (const table of tableNames) {
|
||||
// CREATE TABLE
|
||||
const [createRows] = await conn.query<any[]>(`SHOW CREATE TABLE \`${table}\``);
|
||||
const createSql: string = createRows[0]['Create Table'] || createRows[0][`Create Table`];
|
||||
sql += `\n-- Table: ${table}\nDROP TABLE IF EXISTS \`${table}\`;\n${createSql};\n\n`;
|
||||
|
||||
// INSERT DATA
|
||||
const [rows] = await conn.query<any[]>(`SELECT * FROM \`${table}\``);
|
||||
if (rows.length > 0) {
|
||||
const cols = Object.keys(rows[0]).map(c => `\`${c}\``).join(", ");
|
||||
const values = rows.map(row =>
|
||||
"(" + Object.values(row).map(v =>
|
||||
v === null ? "NULL" :
|
||||
v instanceof Date ? `'${v.toISOString().replace('T', ' ').replace('Z', '')}'` :
|
||||
typeof v === "number" ? v :
|
||||
`'${String(v).replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`
|
||||
).join(", ") + ")"
|
||||
).join(",\n");
|
||||
sql += `INSERT INTO \`${table}\` (${cols}) VALUES\n${values};\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
sql += `\nSET FOREIGN_KEY_CHECKS=1;\n-- Fin du dump\n`;
|
||||
await conn.end();
|
||||
|
||||
fs.writeFileSync(filePath, sql, "utf8");
|
||||
console.log(`[Backup] Dump saved to ${filePath} (${(sql.length / 1024).toFixed(1)} Ko)`);
|
||||
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 n’a 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 cookies2 = parseCookies(req.headers.cookie || "");
|
||||
const token = cookies2.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);
|
||||
@@ -332,35 +290,52 @@ 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 } = 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
|
||||
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 n’est pas un PDF valide" });
|
||||
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");
|
||||
const sourceFile = await createSourceFile({
|
||||
userId: source.userId,
|
||||
fileName,
|
||||
fileKey: `web-import/${source.userId}/${Date.now()}-${fileName}`,
|
||||
fileUrl: '',
|
||||
fileName: safeFileName,
|
||||
fileKey: storageKey,
|
||||
fileUrl,
|
||||
});
|
||||
const importSettings = await getImportSettingsByUser(source.userId);
|
||||
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 || []) {
|
||||
@@ -375,7 +350,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: "L’import web a échoué. Consultez les journaux serveur." });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -329,7 +329,6 @@ export async function invokeLLMWithUserSettings(
|
||||
}
|
||||
|
||||
const provider = userSettings?.aiProvider || "mistral";
|
||||
const isMistral = provider === "mistral";
|
||||
|
||||
const {
|
||||
messages,
|
||||
|
||||
@@ -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;
|
||||
|
||||
21
server/databaseBackup.test.ts
Normal file
21
server/databaseBackup.test.ts
Normal 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 d’une 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
170
server/databaseBackup.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -43,11 +43,8 @@ import {
|
||||
InsertBapHistory,
|
||||
BapHistory,
|
||||
invoiceLearnings,
|
||||
InsertInvoiceLearning,
|
||||
InvoiceLearning,
|
||||
deletedInvoices,
|
||||
InsertDeletedInvoice,
|
||||
DeletedInvoice,
|
||||
webImportSources,
|
||||
InsertWebImportSource,
|
||||
WebImportSource
|
||||
|
||||
@@ -343,7 +343,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();
|
||||
|
||||
@@ -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 ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -82,14 +82,10 @@ 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 { localStoragePut, generateStorageKey } from "./localStorage";
|
||||
import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport";
|
||||
|
||||
@@ -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()}`);
|
||||
9
todo.md
9
todo.md
@@ -692,3 +692,12 @@
|
||||
- [ ] 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 d’accès et la gestion d’erreur 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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user