Compare commits

...

23 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
89c65ca979 chore: versionner le manifeste de déploiement 2026-08-18 09:40:27 +02: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
Manus
4ce2d2877a Checkpoint: Ajout onglet Sauvegarde DB dans ImportSettings : endpoint POST /api/db-backup (mysqldump), GET /api/db-backup/:filename, procédures tRPC backup.list et backup.delete, composant BackupSection avec liste des sauvegardes et téléchargement. 2026-08-08 10:04:57 +00:00
Manus
711ce6b83a Checkpoint: Ajout du système de connecteurs web : table webImportSources, CRUD tRPC, page WebImportSources.tsx, endpoint /api/web-import/push-invoice, script cron SFR (scripts/web-import/sfr-connector.mjs) 2026-07-30 13:53:07 +00:00
Manus
bcb307bde1 Checkpoint: Tous les boutons d'action (Excel, ZIP, Valider tout BAP, Importer, Supprimer, Dévalider BAP, Relancer) et la recherche sont maintenant sur une seule ligne dans le cartouche vert pastel, avec les filtres période et statut en dessous. 2026-07-30 12:48:20 +00:00
Manus
295ba29329 Checkpoint: Zone de filtres Factures BAP refactorisée en 3 lignes dans un cartouche vert émeraude pastel : ligne 1 (recherche), ligne 2 (filtres période année/mois), ligne 3 (boutons statut + tri aligné à droite). 2026-07-30 12:37:32 +00:00
Manus
ac0594f5db Checkpoint: Zone de filtres Factures refactorisée en 3 lignes dans un cartouche bleu pastel : ligne 1 (recherche + compact/détail), ligne 2 (destinataire, abonnement, entité, ventilation comptable), ligne 3 (boutons statut export + tri par date). 2026-07-30 12:24:15 +00:00
Manus
5d18dfa468 Checkpoint: Ajout de la table deletedInvoices (blacklist) : quand une facture est supprimée, son numéro est enregistré et l'import email/fichier ne la réimporte plus. 2026-07-30 09:48:00 +00:00
Manus
aebc607e8a Checkpoint: Ajout de deux nouveaux filtres sur la page Factures : Abonnement (OUI/NON basé sur isSubscription) et Entité (SANTINOVA/ITINOVA basé sur ventilationComptable === 'SANTINOVA'). Filtrage côté client, aucune modification backend nécessaire. 2026-07-30 08:15:56 +00:00
Manus
827c9ec41e Checkpoint: Ajout de Google Gemini (gemini-2.0-flash) comme 3ème moteur IA : schéma DB mis à jour (enum aiProvider + colonne geminiApiKey dans userSettings et importSettings), llm.ts mis à jour avec routing vers l'API Google AI Studio (compatible OpenAI), fenêtre de configuration IA mise à jour avec 3 cartes (Mistral / Gemini / Manus) et champ clé API Gemini. 2026-07-27 15:55:07 +00:00
Manus
46a61fca0e Checkpoint: Ventilations, automatismes, services et signatures sont maintenant globaux (sans filtre userId). Doublons supprimés en production. 2026-07-13 10:21:46 -04:00
Manus
1951b9b9d1 Checkpoint: Les admins voient désormais toutes les factures (tous userId) : getById, update, delete, validateBAP, devalidateBAP, validateBAPBulk, reprocessSelected, regenerateBapPdf, search, exportToExcel, exportToPdf, exportSFTP, bapHistory.delete/regenerate — tous corrigés pour les admins. 2026-07-13 08:13:49 -04:00
Manus
a994ec2e61 Checkpoint: Fix: colonne azureAdId agrandie à VARCHAR(128) pour accepter les identifiants Azure AD composés (objectId.tenantId). Migration appliquée en production. 2026-07-07 06:20:33 -04:00
Manus
8ee10602d3 Checkpoint: Ajout de la connexion OAuth2 Microsoft 365 (Azure AD) : route callback /api/auth/azure/callback, bouton Microsoft sur la page de login, variables d'environnement Azure AD configurées 2026-07-07 05:54:46 -04:00
Manus
d4c9bf635a Checkpoint: Fix: la procédure deleteAll des importLogs permet maintenant aux admins de supprimer tous les logs (pas seulement les leurs). Correction de deleteAllImportLogs dans db.ts pour accepter userId=null (suppression totale). 2026-06-17 06:20:40 -04:00
Manus
f7d3f6d5b3 Checkpoint: Ajout colonne "Source import" (Email / Dossier / Fichier) dans l'historique des imports : schéma DB migré, backend mis à jour (routers.ts, emailImportService.ts, folderImportService.ts), frontend History.tsx avec badges colorés dans le tableau et le dialog de détails. 2026-06-17 06:11:46 -04:00
Manus
b97efb5c57 Checkpoint: Fix: bouton de téléchargement PDF BAP pour factures validées sans PDF (NOVRH VF11521, EVOLUCARE ET098857). Ajout procédure regenerateBapPdf côté serveur + bouton orange RefreshCw côté frontend pour régénérer le PDF à la volée. Déployé en recette et production. 2026-06-17 03:41:18 -04:00
Manus
756f8ab392 fix: stabiliser validatedInvoiceIds avec useMemo pour bouton téléchargement BAP 2026-06-16 12:29:58 -04:00
61 changed files with 16202 additions and 4633 deletions

3
.gitignore vendored
View File

@@ -45,6 +45,8 @@ pids
*.seed *.seed
*.pid.lock *.pid.lock
*.bak *.bak
backups/
storage/
# Coverage directory used by tools like istanbul # Coverage directory used by tools like istanbul
coverage/ coverage/
@@ -105,3 +107,4 @@ temp/
*.db *.db
*.sqlite *.sqlite
*.sqlite3 *.sqlite3
.project-config.json

View File

@@ -0,0 +1,9 @@
{
"query": "\nSELECT i.id, i.supplierName, i.invoiceNumber, i.bapValidated, i.exportStatus, \n bh.id as bapHistoryId, bh.pdfUrl, bh.validatedAt\nFROM invoices i \nLEFT JOIN bapHistory bh ON bh.invoiceId = i.id \nWHERE i.supplierName LIKE '%ALPES%' OR i.supplierName LIKE '%KOESIO%'\nORDER BY i.supplierName;\n",
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway02.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 4CrrYuB5tme73Qo.bd4328423008 --database fo4DRyBgjsuiigFAgNLuWm --execute \nSELECT i.id, i.supplierName, i.invoiceNumber, i.bapValidated, i.exportStatus, \n bh.id as bapHistoryId, bh.pdfUrl, bh.validatedAt\nFROM invoices i \nLEFT JOIN bapHistory bh ON bh.invoiceId = i.id \nWHERE i.supplierName LIKE '%ALPES%' OR i.supplierName LIKE '%KOESIO%'\nORDER BY i.supplierName;\n",
"rows": [],
"messages": [],
"stdout": "",
"stderr": "",
"execution_time_ms": 287
}

10
app.json Normal file
View File

@@ -0,0 +1,10 @@
{
"id": "demat-facturation-dsi",
"name": "Démat. Facturation DSI",
"category": "SANTINOVA",
"urls": {"recette": "https://demat-facturation.recette.santinova-soft.org", "prod": "https://demat-facturation.santinova-soft.org"},
"containerName": "demat-facturation-app",
"image": "images/demat-facturation-dsi.jpg",
"giteaRepo": "demat-facturation-dsi",
"giteaOwner": "manus-admin"
}

View File

@@ -1,26 +1,35 @@
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import NotFound from "@/pages/NotFound"; import NotFound from "@/pages/NotFound";
import { lazy, Suspense } from "react";
import { Route, Switch } from "wouter"; import { Route, Switch } from "wouter";
import ErrorBoundary from "./components/ErrorBoundary"; import ErrorBoundary from "./components/ErrorBoundary";
import { ThemeProvider } from "./contexts/ThemeContext"; import { ThemeProvider } from "./contexts/ThemeContext";
import Home from "./pages/Home";
import Login from "./pages/Login"; // Pages chargées à la demande : l'écran de connexion reste léger et chaque
import Dashboard from "./pages/Dashboard"; // module métier ne télécharge son code qu'au moment où l'utilisateur l'ouvre.
import Upload from "./pages/Upload"; const Home = lazy(() => import("./pages/Home"));
import Invoices from "./pages/Invoices"; const Login = lazy(() => import("./pages/Login"));
import InvoicesBAP from "./pages/InvoicesBAP"; const Dashboard = lazy(() => import("./pages/Dashboard"));
import InvoiceDetail from "./pages/InvoiceDetail"; const Upload = lazy(() => import("./pages/Upload"));
import Settings from "./pages/Settings"; const Invoices = lazy(() => import("./pages/Invoices"));
import ImportSettings from "./pages/ImportSettings"; const InvoicesBAP = lazy(() => import("./pages/InvoicesBAP"));
import History from "./pages/History"; const InvoiceDetail = lazy(() => import("./pages/InvoiceDetail"));
import Users from "./pages/Users"; const Settings = lazy(() => import("./pages/Settings"));
import ListsAdmin from "./pages/ListsAdmin"; const ImportSettings = lazy(() => import("./pages/ImportSettings"));
import AutomationRules from "./pages/AutomationRules"; const History = lazy(() => import("./pages/History"));
import BapHistory from "./pages/BapHistory"; const Users = lazy(() => import("./pages/Users"));
import ImportReport from "./pages/ImportReport"; const ListsAdmin = lazy(() => import("./pages/ListsAdmin"));
import LearningSettings from "./pages/LearningSettings"; const AutomationRules = lazy(() => import("./pages/AutomationRules"));
import VentilationFreePro from "./pages/VentilationFreePro"; 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() { function Router() {
return ( return (
@@ -42,6 +51,7 @@ function Router() {
<Route path="/import-report" component={ImportReport} /> <Route path="/import-report" component={ImportReport} />
<Route path="/learning-settings" component={LearningSettings} /> <Route path="/learning-settings" component={LearningSettings} />
<Route path="/ventilation-freepro" component={VentilationFreePro} /> <Route path="/ventilation-freepro" component={VentilationFreePro} />
<Route path="/web-import-sources" component={WebImportSources} />
<Route path="/404" component={NotFound} /> <Route path="/404" component={NotFound} />
<Route component={NotFound} /> <Route component={NotFound} />
</Switch> </Switch>
@@ -54,7 +64,9 @@ function App() {
<ThemeProvider defaultTheme="light"> <ThemeProvider defaultTheme="light">
<TooltipProvider> <TooltipProvider>
<Toaster /> <Toaster />
<Suspense fallback={<RouteFallback />}>
<Router /> <Router />
</Suspense>
</TooltipProvider> </TooltipProvider>
</ThemeProvider> </ThemeProvider>
</ErrorBoundary> </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, useSidebar,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { getLoginUrl } from "@/const";
import { useIsMobile } from "@/hooks/useMobile"; 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 } from "lucide-react"; 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 { CSSProperties, useEffect, useRef, useState } from "react";
import { useLocation } from "wouter"; import { useLocation } from "wouter";
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton'; import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
import { Button } from "./ui/button";
type MenuItem = { type MenuItem = {
icon: any; icon: any;
@@ -75,6 +73,7 @@ const menuStructure: MenuItem[] = [
{ icon: List, label: "Administration des listes", path: "/lists-admin" }, { icon: List, label: "Administration des listes", path: "/lists-admin" },
{ icon: Zap, label: "Automatismes", path: "/automation-rules" }, { icon: Zap, label: "Automatismes", path: "/automation-rules" },
{ icon: Brain, label: "Apprentissages IA", path: "/learning-settings" }, { icon: Brain, label: "Apprentissages IA", path: "/learning-settings" },
{ icon: Globe, label: "Connecteurs web", path: "/web-import-sources" },
{ icon: Users, label: "Utilisateurs", path: "/users", adminOnly: true }, { icon: Users, label: "Utilisateurs", path: "/users", adminOnly: true },
], ],
}, },

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

@@ -23,6 +23,9 @@ import {
RotateCcw, RotateCcw,
Package, Package,
X, X,
Upload,
FolderOpen,
Mail,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -307,6 +310,7 @@ export default function History() {
<TableRow className="bg-gray-50"> <TableRow className="bg-gray-50">
<TableHead className="font-semibold">Fichier importé</TableHead> <TableHead className="font-semibold">Fichier importé</TableHead>
<TableHead className="font-semibold">Date d'import</TableHead> <TableHead className="font-semibold">Date d'import</TableHead>
<TableHead className="font-semibold text-center">Source</TableHead>
<TableHead className="font-semibold text-center">Détectées</TableHead> <TableHead className="font-semibold text-center">Détectées</TableHead>
<TableHead className="font-semibold text-center">Importées</TableHead> <TableHead className="font-semibold text-center">Importées</TableHead>
<TableHead className="font-semibold text-center">Doublons</TableHead> <TableHead className="font-semibold text-center">Doublons</TableHead>
@@ -326,6 +330,24 @@ export default function History() {
<TableCell className="text-sm text-gray-600"> <TableCell className="text-sm text-gray-600">
{new Date(log.importedAt).toLocaleString("fr-FR")} {new Date(log.importedAt).toLocaleString("fr-FR")}
</TableCell> </TableCell>
<TableCell className="text-center">
{log.importSource === "email" ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700 border border-blue-200">
<Mail className="h-3 w-3" />
Email
</span>
) : log.importSource === "folder" ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-700 border border-purple-200">
<FolderOpen className="h-3 w-3" />
Dossier
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-700 border border-gray-200">
<Upload className="h-3 w-3" />
Fichier
</span>
)}
</TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
<Badge variant="outline" className="font-mono"> <Badge variant="outline" className="font-mono">
{log.totalInvoicesDetected} {log.totalInvoicesDetected}
@@ -408,6 +430,18 @@ export default function History() {
<p className="text-xs text-gray-500 font-medium mb-1">Date d'import</p> <p className="text-xs text-gray-500 font-medium mb-1">Date d'import</p>
<p className="text-sm font-semibold">{new Date(selectedLog.importedAt).toLocaleString("fr-FR")}</p> <p className="text-sm font-semibold">{new Date(selectedLog.importedAt).toLocaleString("fr-FR")}</p>
</div> </div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-500 font-medium mb-1">Source import</p>
<p className="text-sm font-semibold">
{selectedLog.importSource === "email" ? (
<span className="inline-flex items-center gap-1 text-blue-700"><Mail className="h-4 w-4" /> Email</span>
) : selectedLog.importSource === "folder" ? (
<span className="inline-flex items-center gap-1 text-purple-700"><FolderOpen className="h-4 w-4" /> Dossier</span>
) : (
<span className="inline-flex items-center gap-1 text-gray-700"><Upload className="h-4 w-4" /> Fichier</span>
)}
</p>
</div>
</div> </div>
{/* Statistiques */} {/* Statistiques */}

View File

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

View File

@@ -11,10 +11,146 @@ import { toast } from "sonner";
import { import {
Loader2, Save, Upload, FolderOpen, Mail, Play, Square, Download, Loader2, Save, Upload, FolderOpen, Mail, Play, Square, Download,
Inbox, CheckCircle2, Monitor, FolderOutput, Wifi, AlertTriangle, Calendar, Inbox, CheckCircle2, Monitor, FolderOutput, Wifi, AlertTriangle, Calendar,
ArrowDownToLine, Share2 ArrowDownToLine, Share2, DatabaseBackup, HardDrive, CheckCircle, Clock
} from "lucide-react"; } from "lucide-react";
import DashboardLayout from "@/components/DashboardLayout"; import DashboardLayout from "@/components/DashboardLayout";
// ============= COMPOSANT SAUVEGARDE DB =============
function BackupSection() {
const [isGenerating, setIsGenerating] = useState(false);
const { data: backupList, refetch: refetchList } = trpc.backup.list.useQuery();
const deleteBackupMutation = trpc.backup.delete.useMutation({
onSuccess: () => { refetchList(); toast.success("Sauvegarde supprimée"); },
onError: (e) => toast.error("Erreur : " + e.message),
});
const handleGenerateBackup = async () => {
setIsGenerating(true);
try {
const response = await fetch("/api/db-backup", { method: "POST", credentials: "include" });
if (!response.ok) {
const err = await response.json().catch(() => ({ error: "Erreur inconnue" }));
toast.error("Erreur : " + (err.error || response.statusText));
return;
}
// Déclencher le téléchargement
const blob = await response.blob();
const contentDisposition = response.headers.get("Content-Disposition") || "";
const match = contentDisposition.match(/filename\*?=(?:UTF-8'')?["']?([^"';\n]+)/i);
const fileName = match ? decodeURIComponent(match[1]) : `backup-${new Date().toISOString().slice(0,10)}.sql`;
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
toast.success("Sauvegarde générée et téléchargée");
refetchList();
} catch (e: any) {
toast.error("Erreur : " + e.message);
} finally {
setIsGenerating(false);
}
};
const formatSize = (bytes: number) => {
if (bytes < 1024) return bytes + " o";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " Ko";
return (bytes / (1024 * 1024)).toFixed(1) + " Mo";
};
return (
<Card className="border-2 hover:border-primary/50 transition-colors">
<CardHeader className="bg-gradient-to-r from-slate-50 to-gray-50 dark:from-slate-950/20 dark:to-gray-950/20 border-b">
<div className="flex items-center gap-3">
<div className="p-2 bg-slate-600 rounded-lg">
<DatabaseBackup className="w-6 h-6 text-white" />
</div>
<div>
<CardTitle className="text-xl">Sauvegarde de la base de données</CardTitle>
<CardDescription className="mt-1">
Générer un dump SQL de la base de données et l'enregistrer localement dans le dossier <code className="bg-muted px-1 rounded text-xs">backups/</code>
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="pt-6 space-y-6">
{/* Bouton générer */}
<div className="flex items-center gap-4">
<Button
onClick={handleGenerateBackup}
disabled={isGenerating}
className="gap-2 bg-slate-700 hover:bg-slate-800 text-white"
size="lg"
>
{isGenerating ? (
<><Loader2 className="h-5 w-5 animate-spin" />Génération en cours...</>
) : (
<><HardDrive className="h-5 w-5" />Générer une sauvegarde</>
)}
</Button>
<p className="text-sm text-muted-foreground">
Le dump SQL sera généré, enregistré dans <code className="bg-muted px-1 rounded text-xs">backups/</code> et téléchargé automatiquement.
</p>
</div>
{/* Liste des sauvegardes existantes */}
{backupList && backupList.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Clock className="h-4 w-4" />
Sauvegardes enregistrées ({backupList.length})
</div>
<div className="border rounded-lg divide-y">
{backupList.map((backup) => (
<div key={backup.name} className="flex items-center justify-between px-4 py-3 hover:bg-muted/30">
<div className="flex items-center gap-3">
<CheckCircle className="h-4 w-4 text-green-500 shrink-0" />
<div>
<p className="text-sm font-medium font-mono">{backup.name}</p>
<p className="text-xs text-muted-foreground">
{new Date(backup.createdAt).toLocaleString("fr-FR")} — {formatSize(backup.size)}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="gap-1 text-xs"
onClick={() => { window.open(`/api/db-backup/${encodeURIComponent(backup.name)}`, "_blank"); }}
>
<Download className="h-3 w-3" />
Télécharger
</Button>
<Button
variant="outline"
size="sm"
className="gap-1 text-xs text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={() => deleteBackupMutation.mutate({ name: backup.name })}
disabled={deleteBackupMutation.isPending}
>
Supprimer
</Button>
</div>
</div>
))}
</div>
</div>
)}
{backupList && backupList.length === 0 && (
<div className="text-center py-8 text-muted-foreground text-sm">
<HardDrive className="h-8 w-8 mx-auto mb-2 opacity-30" />
Aucune sauvegarde enregistrée
</div>
)}
</CardContent>
</Card>
);
}
export default function ImportSettings() { export default function ImportSettings() {
// Chargement différé : on ne charge les statuts de services qu'après le montage // Chargement différé : on ne charge les statuts de services qu'après le montage
const { data: settings, isLoading } = trpc.importSettings.get.useQuery(undefined, { const { data: settings, isLoading } = trpc.importSettings.get.useQuery(undefined, {
@@ -207,7 +343,7 @@ export default function ImportSettings() {
{/* Onglets Import / Export */} {/* Onglets Import / Export */}
<Tabs defaultValue="import" className="w-full"> <Tabs defaultValue="import" className="w-full">
<TabsList className="grid w-full grid-cols-2 h-12 mb-6"> <TabsList className="grid w-full grid-cols-3 h-12 mb-6">
<TabsTrigger value="import" className="flex items-center gap-2 text-base"> <TabsTrigger value="import" className="flex items-center gap-2 text-base">
<ArrowDownToLine className="w-4 h-4" /> <ArrowDownToLine className="w-4 h-4" />
Paramètres d'import Paramètres d'import
@@ -216,6 +352,10 @@ export default function ImportSettings() {
<Share2 className="w-4 h-4" /> <Share2 className="w-4 h-4" />
Paramètres d'export Paramètres d'export
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="backup" className="flex items-center gap-2 text-base">
<DatabaseBackup className="w-4 h-4" />
Sauvegarde DB
</TabsTrigger>
</TabsList> </TabsList>
{/* ===== ONGLET IMPORT ===== */} {/* ===== ONGLET IMPORT ===== */}
@@ -955,6 +1095,10 @@ export default function ImportSettings() {
</Button> </Button>
</div> </div>
</TabsContent> </TabsContent>
{/* ===== ONGLET SAUVEGARDE ===== */}
<TabsContent value="backup" className="space-y-6">
<BackupSection />
</TabsContent>
</Tabs> </Tabs>
</div> </div>
</DashboardLayout> </DashboardLayout>

View File

@@ -52,6 +52,9 @@ export default function Invoices() {
const [selectedIds, setSelectedIds] = useState<number[]>([]); const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [statusFilter, setStatusFilter] = useState<string>("all"); const [statusFilter, setStatusFilter] = useState<string>("all");
const [recipientFilter, setRecipientFilter] = useState<string>("all"); const [recipientFilter, setRecipientFilter] = useState<string>("all");
const [subscriptionFilter, setSubscriptionFilter] = useState<string>("all"); // all | yes | no
const [entityFilter, setEntityFilter] = useState<string>("all"); // all | santinova | itinova
const [ventilationFilter, setVentilationFilter] = useState<string>("all");
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [invoiceToDelete, setInvoiceToDelete] = useState<number | null>(null); const [invoiceToDelete, setInvoiceToDelete] = useState<number | null>(null);
const [addDialogOpen, setAddDialogOpen] = useState(false); const [addDialogOpen, setAddDialogOpen] = useState(false);
@@ -197,6 +200,12 @@ export default function Invoices() {
) )
).sort(); ).sort();
const uniqueVentilations = Array.from(
new Set(
(invoices || []).map(inv => (inv as any).ventilationComptable).filter(Boolean)
)
).sort();
const handleSort = (field: SortField) => { const handleSort = (field: SortField) => {
if (sortField === field) { if (sortField === field) {
setSortDir(d => d === "asc" ? "desc" : "asc"); setSortDir(d => d === "asc" ? "desc" : "asc");
@@ -236,7 +245,26 @@ export default function Invoices() {
if ((inv as any).recipientName !== recipientFilter) return false; if ((inv as any).recipientName !== recipientFilter) return false;
} }
} }
// Filter by subscription
if (subscriptionFilter !== "all") {
const isSub = (inv as any).isSubscription === 1 || (inv as any).isSubscription === true;
if (subscriptionFilter === "yes" && !isSub) return false;
if (subscriptionFilter === "no" && isSub) return false;
}
// Filter by entity (SANTINOVA = serviceConcerne === 'DSI SANTINOVA', ITINOVA = autre)
if (entityFilter !== "all") {
const service = ((inv as any).serviceConcerne || "").trim().toUpperCase();
if (entityFilter === "santinova" && service !== "DSI SANTINOVA") return false;
if (entityFilter === "itinova" && service === "DSI SANTINOVA") return false;
}
// Filter by ventilation comptable
if (ventilationFilter !== "all") {
if (ventilationFilter === "__empty__") {
if ((inv as any).ventilationComptable) return false;
} else {
if ((inv as any).ventilationComptable !== ventilationFilter) return false;
}
}
return true; return true;
}); });
@@ -392,44 +420,27 @@ export default function Invoices() {
</div> </div>
</div> </div>
<Card> {/* ===== CARTOUCHE FILTRES ===== */}
<CardContent className="pt-6"> <div className="rounded-xl border border-blue-100 bg-blue-50/60 px-4 py-3 mb-4 shadow-sm">
{/* Search + Recipient Filter */}
<div className="mb-4 flex gap-3 flex-wrap"> {/* Ligne 1 : Recherche + affichage compact/détail */}
<div className="relative flex-1 min-w-[200px]"> <div className="flex gap-3 items-center flex-wrap mb-3">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" /> <div className="relative flex-1 min-w-[220px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-blue-400 w-4 h-4" />
<Input <Input
placeholder="Rechercher par fournisseur, destinataire ou numéro..." placeholder="Rechercher par fournisseur, destinataire ou numéro..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10" className="pl-10 bg-white border-blue-200 focus:border-blue-400"
/> />
</div> </div>
<div className="flex items-center gap-2 min-w-[220px]"> <div className="flex gap-1 shrink-0">
<Filter className="w-4 h-4 text-gray-400 shrink-0" />
<Select value={recipientFilter} onValueChange={setRecipientFilter}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Filtrer par destinataire" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les destinataires</SelectItem>
<SelectItem value="__empty__">Sans destinataire</SelectItem>
{uniqueRecipients.map((r) => (
<SelectItem key={r} value={r}>{r}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Mode compact/détail + Tri */}
<div className="flex gap-2 mb-4 flex-wrap items-center justify-between">
<div className="flex gap-2">
<Button <Button
variant={compactMode ? "default" : "outline"} variant={compactMode ? "default" : "outline"}
size="sm" size="sm"
onClick={() => setCompactMode(true)} onClick={() => setCompactMode(true)}
title="Mode compact" title="Mode compact"
className={compactMode ? "bg-blue-600 hover:bg-blue-700" : "bg-white border-blue-200 text-blue-700 hover:bg-blue-50"}
> >
<LayoutList className="w-4 h-4 mr-1" /> Compact <LayoutList className="w-4 h-4 mr-1" /> Compact
</Button> </Button>
@@ -438,35 +449,69 @@ export default function Invoices() {
size="sm" size="sm"
onClick={() => setCompactMode(false)} onClick={() => setCompactMode(false)}
title="Mode détail" title="Mode détail"
className={!compactMode ? "bg-blue-600 hover:bg-blue-700" : "bg-white border-blue-200 text-blue-700 hover:bg-blue-50"}
> >
<LayoutGrid className="w-4 h-4 mr-1" /> Détail <LayoutGrid className="w-4 h-4 mr-1" /> Détail
</Button> </Button>
</div> </div>
<div className="flex gap-2 items-center">
<span className="text-sm text-gray-500">Trier par :</span>
<Button
variant={sortField === "createdAt" ? "default" : "outline"}
size="sm"
onClick={() => handleSort("createdAt")}
>
Date réception <SortIcon field="createdAt" />
</Button>
<Button
variant={sortField === "invoiceDate" ? "default" : "outline"}
size="sm"
onClick={() => handleSort("invoiceDate")}
>
Date facture <SortIcon field="invoiceDate" />
</Button>
</div>
</div> </div>
{/* Status Filters */} {/* Ligne 2 : Filtres destinataire, abonnement, entité, ventilation */}
<div className="flex gap-2 mb-4"> <div className="flex gap-2 items-center flex-wrap mb-3">
<Filter className="w-4 h-4 text-blue-400 shrink-0" />
<Select value={recipientFilter} onValueChange={setRecipientFilter}>
<SelectTrigger className="w-[180px] bg-white border-blue-200 text-sm">
<SelectValue placeholder="Destinataire" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous destinataires</SelectItem>
<SelectItem value="__empty__">Sans destinataire</SelectItem>
{uniqueRecipients.map((r) => (
<SelectItem key={r} value={r}>{r}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={subscriptionFilter} onValueChange={setSubscriptionFilter}>
<SelectTrigger className="w-[170px] bg-white border-blue-200 text-sm">
<SelectValue placeholder="Abonnement" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Abonnement : tous</SelectItem>
<SelectItem value="yes">Abonnement : OUI</SelectItem>
<SelectItem value="no">Abonnement : NON</SelectItem>
</SelectContent>
</Select>
<Select value={entityFilter} onValueChange={setEntityFilter}>
<SelectTrigger className="w-[150px] bg-white border-blue-200 text-sm">
<SelectValue placeholder="Entité" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toutes entités</SelectItem>
<SelectItem value="santinova">SANTINOVA</SelectItem>
<SelectItem value="itinova">ITINOVA</SelectItem>
</SelectContent>
</Select>
<Select value={ventilationFilter} onValueChange={setVentilationFilter}>
<SelectTrigger className="w-[170px] bg-white border-blue-200 text-sm">
<SelectValue placeholder="Ventilation" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toutes ventilations</SelectItem>
<SelectItem value="__empty__">Sans ventilation</SelectItem>
{uniqueVentilations.map((v) => (
<SelectItem key={v} value={v}>{v}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Ligne 3 : Boutons statut export + tri */}
<div className="flex gap-2 items-center flex-wrap">
<Button <Button
variant={statusFilter === "all" ? "default" : "outline"} variant={statusFilter === "all" ? "default" : "outline"}
onClick={() => setStatusFilter("all")} onClick={() => setStatusFilter("all")}
size="sm" size="sm"
className={statusFilter === "all" ? "bg-blue-600 hover:bg-blue-700" : "bg-white border-blue-200 text-blue-700 hover:bg-blue-50"}
> >
Tous ({statusCounts.all}) Tous ({statusCounts.all})
</Button> </Button>
@@ -474,7 +519,7 @@ export default function Invoices() {
variant={statusFilter === "exported" ? "default" : "outline"} variant={statusFilter === "exported" ? "default" : "outline"}
onClick={() => setStatusFilter("exported")} onClick={() => setStatusFilter("exported")}
size="sm" size="sm"
className={statusFilter === "exported" ? "bg-green-600 hover:bg-green-700" : ""} className={statusFilter === "exported" ? "bg-green-600 hover:bg-green-700 text-white" : "bg-white border-green-200 text-green-700 hover:bg-green-50"}
> >
Exportés ({statusCounts.exported}) Exportés ({statusCounts.exported})
</Button> </Button>
@@ -482,7 +527,7 @@ export default function Invoices() {
variant={statusFilter === "not_exported" ? "default" : "outline"} variant={statusFilter === "not_exported" ? "default" : "outline"}
onClick={() => setStatusFilter("not_exported")} onClick={() => setStatusFilter("not_exported")}
size="sm" size="sm"
className={statusFilter === "not_exported" ? "bg-blue-600 hover:bg-blue-700" : ""} className={statusFilter === "not_exported" ? "bg-indigo-600 hover:bg-indigo-700 text-white" : "bg-white border-indigo-200 text-indigo-700 hover:bg-indigo-50"}
> >
Non exportés ({statusCounts.not_exported}) Non exportés ({statusCounts.not_exported})
</Button> </Button>
@@ -490,11 +535,35 @@ export default function Invoices() {
variant={statusFilter === "export_error" ? "default" : "outline"} variant={statusFilter === "export_error" ? "default" : "outline"}
onClick={() => setStatusFilter("export_error")} onClick={() => setStatusFilter("export_error")}
size="sm" size="sm"
className={statusFilter === "export_error" ? "bg-red-600 hover:bg-red-700" : ""} className={statusFilter === "export_error" ? "bg-red-600 hover:bg-red-700 text-white" : "bg-white border-red-200 text-red-700 hover:bg-red-50"}
> >
Erreurs ({statusCounts.export_error}) Erreurs ({statusCounts.export_error})
</Button> </Button>
<div className="ml-auto flex gap-1 items-center">
<span className="text-xs text-blue-500 font-medium mr-1">Trier :</span>
<Button
variant={sortField === "createdAt" ? "default" : "outline"}
size="sm"
onClick={() => handleSort("createdAt")}
className={sortField === "createdAt" ? "bg-blue-600 hover:bg-blue-700" : "bg-white border-blue-200 text-blue-700 hover:bg-blue-50"}
>
Réception <SortIcon field="createdAt" />
</Button>
<Button
variant={sortField === "invoiceDate" ? "default" : "outline"}
size="sm"
onClick={() => handleSort("invoiceDate")}
className={sortField === "invoiceDate" ? "bg-blue-600 hover:bg-blue-700" : "bg-white border-blue-200 text-blue-700 hover:bg-blue-50"}
>
Facture <SortIcon field="invoiceDate" />
</Button>
</div> </div>
</div>
</div>
{/* ===== FIN CARTOUCHE ===== */}
<Card>
<CardContent className="pt-4">
{/* Table */} {/* Table */}
{isLoading ? ( {isLoading ? (

View File

@@ -1,4 +1,4 @@
import React, { useState } from "react"; import React, { useState, useMemo } from "react";
import DashboardLayout from "@/components/DashboardLayout"; import DashboardLayout from "@/components/DashboardLayout";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -155,7 +155,12 @@ export default function InvoicesBAP() {
// Filter for BAP invoices only (Abonnement = NON, isSubscription = 0) // Filter for BAP invoices only (Abonnement = NON, isSubscription = 0)
const invoices = allInvoices?.filter(inv => inv.isSubscription === 0); const invoices = allInvoices?.filter(inv => inv.isSubscription === 0);
// IDs des factures déjà validées BAP (pour charger leurs pdfUrl depuis bapHistory) // IDs des factures déjà validées BAP (pour charger leurs pdfUrl depuis bapHistory)
const validatedInvoiceIds = (invoices || []).filter(inv => inv.bapValidated === 1).map(inv => inv.id); // Stabilisé avec useMemo pour éviter les re-renders infinis (anti-pattern tRPC)
const validatedInvoiceIds = useMemo(
() => (invoices || []).filter(inv => inv.bapValidated === 1).map(inv => inv.id),
// eslint-disable-next-line react-hooks/exhaustive-deps
[invoices?.map(i => i.id).join(',')]
);
const { data: persistedBapPdfUrls } = trpc.invoices.getBapPdfUrls.useQuery( const { data: persistedBapPdfUrls } = trpc.invoices.getBapPdfUrls.useQuery(
{ invoiceIds: validatedInvoiceIds }, { invoiceIds: validatedInvoiceIds },
{ enabled: validatedInvoiceIds.length > 0 } { enabled: validatedInvoiceIds.length > 0 }
@@ -284,6 +289,21 @@ export default function InvoicesBAP() {
}, },
}); });
const regenerateBapPdfMutation = trpc.invoices.regenerateBapPdf.useMutation({
onSuccess: (data, variables) => {
if (data.pdfUrl) {
setBapPdfUrls(prev => ({ ...prev, [variables.invoiceId]: data.pdfUrl as string }));
toast.success('PDF BAP régénéré avec succès !');
downloadBapPdf(data.pdfUrl);
}
utils.invoices.list.invalidate();
utils.invoices.getBapPdfUrls.invalidate();
},
onError: (error) => {
toast.error(error.message || 'Erreur lors de la régénération du PDF BAP');
},
});
const devalidateBAPMutation = trpc.invoices.devalidateBAP.useMutation({ const devalidateBAPMutation = trpc.invoices.devalidateBAP.useMutation({
onSuccess: (data) => { onSuccess: (data) => {
toast.success(`${data.processed} facture(s) dévalidée(s) BAP avec succès`); toast.success(`${data.processed} facture(s) dévalidée(s) BAP avec succès`);
@@ -567,17 +587,19 @@ export default function InvoicesBAP() {
<h1 className="text-3xl font-bold">Factures BAP</h1> <h1 className="text-3xl font-bold">Factures BAP</h1>
<p className="text-gray-500 mt-1">Factures non-abonnement (Abonnement = NON)</p> <p className="text-gray-500 mt-1">Factures non-abonnement (Abonnement = NON)</p>
</div> </div>
{/* Barre d'actions : 2 lignes */} {/* ===== CARTOUCHE FILTRES + ACTIONS BAP ===== */}
<div className="space-y-2"> <div className="rounded-xl border border-emerald-100 bg-emerald-50/60 px-4 py-3 mb-4 shadow-sm">
{/* Ligne 1 : Excel, ZIP, Valider tout en BAP, Importer */}
<div className="flex flex-wrap gap-2"> {/* Ligne 1 : Boutons d'action + Recherche */}
<div className="flex gap-2 items-center flex-wrap mb-3">
<Button <Button
onClick={handleExportExcel} onClick={handleExportExcel}
disabled={selectedIds.length === 0 || exportExcelMutation.isPending} disabled={selectedIds.length === 0 || exportExcelMutation.isPending}
variant="outline" variant="outline"
className="border-green-600 text-green-600 hover:bg-green-50" size="sm"
className="bg-white border-green-300 text-green-700 hover:bg-green-50"
> >
<FileSpreadsheet className="w-4 h-4 mr-2" /> <FileSpreadsheet className="w-4 h-4 mr-1" />
Excel ({selectedIds.length}) Excel ({selectedIds.length})
</Button> </Button>
<Button <Button
@@ -611,9 +633,10 @@ export default function InvoicesBAP() {
}} }}
disabled={selectedIds.length === 0 || isZipDownloading} disabled={selectedIds.length === 0 || isZipDownloading}
variant="outline" variant="outline"
className="border-indigo-500 text-indigo-600 hover:bg-indigo-50" size="sm"
className="bg-white border-indigo-300 text-indigo-700 hover:bg-indigo-50"
> >
<FolderDown className={`w-4 h-4 mr-2 ${isZipDownloading ? 'animate-bounce' : ''}`} /> <FolderDown className={`w-4 h-4 mr-1 ${isZipDownloading ? 'animate-bounce' : ''}`} />
{isZipDownloading ? 'ZIP...' : `ZIP (${selectedIds.length})`} {isZipDownloading ? 'ZIP...' : `ZIP (${selectedIds.length})`}
</Button> </Button>
<Button <Button
@@ -623,18 +646,16 @@ export default function InvoicesBAP() {
} }
}} }}
disabled={validateBAPBulkMutation.isPending} disabled={validateBAPBulkMutation.isPending}
size="sm"
className="bg-green-700 hover:bg-green-800 text-white" className="bg-green-700 hover:bg-green-800 text-white"
> >
<ShieldCheck className="w-4 h-4 mr-2" /> <ShieldCheck className="w-4 h-4 mr-1" />
{validateBAPBulkMutation.isPending ? "Validation en cours..." : "Valider tout en BAP"} {validateBAPBulkMutation.isPending ? "Validation..." : "Valider tout BAP"}
</Button> </Button>
<Button onClick={() => setLocation("/upload")}> <Button onClick={() => setLocation("/upload")} size="sm" className="bg-white border-emerald-300 text-emerald-700 hover:bg-emerald-50" variant="outline">
<FileText className="w-4 h-4 mr-2" /> <FileText className="w-4 h-4 mr-1" />
Importer Importer
</Button> </Button>
</div>
{/* Ligne 2 : Supprimer, Dévalider BAP, Relancer */}
<div className="flex flex-wrap gap-2">
<Button <Button
onClick={async () => { onClick={async () => {
if (confirm(`Voulez-vous vraiment supprimer ${selectedIds.length} facture(s) ?`)) { if (confirm(`Voulez-vous vraiment supprimer ${selectedIds.length} facture(s) ?`)) {
@@ -652,8 +673,9 @@ export default function InvoicesBAP() {
}} }}
disabled={selectedIds.length === 0} disabled={selectedIds.length === 0}
variant="destructive" variant="destructive"
size="sm"
> >
<Trash2 className="w-4 h-4 mr-2" /> <Trash2 className="w-4 h-4 mr-1" />
Supprimer ({selectedIds.length}) Supprimer ({selectedIds.length})
</Button> </Button>
<Button <Button
@@ -669,9 +691,10 @@ export default function InvoicesBAP() {
}} }}
disabled={selectedIds.length === 0 || devalidateBAPMutation.isPending} disabled={selectedIds.length === 0 || devalidateBAPMutation.isPending}
variant="outline" variant="outline"
className="border-orange-500 text-orange-600 hover:bg-orange-50" size="sm"
className="bg-white border-orange-300 text-orange-700 hover:bg-orange-50"
> >
<ShieldCheck className="w-4 h-4 mr-2 rotate-180" /> <ShieldCheck className="w-4 h-4 mr-1 rotate-180" />
{devalidateBAPMutation.isPending ? "Dévalidation..." : `Dévalider BAP (${selectedIds.length})`} {devalidateBAPMutation.isPending ? "Dévalidation..." : `Dévalider BAP (${selectedIds.length})`}
</Button> </Button>
<Button <Button
@@ -686,38 +709,32 @@ export default function InvoicesBAP() {
}} }}
disabled={selectedIds.length === 0 || reprocessMutation.isPending} disabled={selectedIds.length === 0 || reprocessMutation.isPending}
variant="outline" variant="outline"
className="border-purple-500 text-purple-600 hover:bg-purple-50" size="sm"
className="bg-white border-purple-300 text-purple-700 hover:bg-purple-50"
> >
<RefreshCw className={`w-4 h-4 mr-2 ${reprocessMutation.isPending ? 'animate-spin' : ''}`} /> <RefreshCw className={`w-4 h-4 mr-1 ${reprocessMutation.isPending ? 'animate-spin' : ''}`} />
{reprocessMutation.isPending ? `Retraitement...` : `Relancer (${selectedIds.length})`} {reprocessMutation.isPending ? `Retraitement...` : `Relancer (${selectedIds.length})`}
</Button> </Button>
</div> {/* Recherche à droite */}
</div> <div className="relative flex-1 min-w-[200px] ml-auto">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-emerald-400 w-4 h-4" />
<Card>
<CardContent className="pt-6">
{/* Search */}
<div className="mb-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input <Input
placeholder="Rechercher par fournisseur ou numéro..." placeholder="Rechercher par fournisseur ou numéro..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10" className="pl-10 bg-white border-emerald-200 focus:border-emerald-400 h-8 text-sm"
/> />
</div> </div>
</div> </div>
{/* Period Filter + Status Filters */} {/* Ligne 2 : Filtres période (année + mois) */}
<div className="flex flex-wrap items-center gap-2 mb-3 p-3 bg-slate-50 dark:bg-slate-900/50 rounded-lg border border-slate-200 dark:border-slate-700"> <div className="flex flex-wrap items-center gap-2 mb-3">
<div className="flex items-center gap-1.5 text-sm font-medium text-slate-600 dark:text-slate-400"> <div className="flex items-center gap-1.5 text-sm font-medium text-emerald-600">
<CalendarDays className="w-4 h-4" /> <CalendarDays className="w-4 h-4" />
Période : Période :
</div> </div>
{/* Year selector */}
<Select value={selectedYear} onValueChange={(v) => { setSelectedYear(v); if (v === "all") setSelectedMonth("all"); }}> <Select value={selectedYear} onValueChange={(v) => { setSelectedYear(v); if (v === "all") setSelectedMonth("all"); }}>
<SelectTrigger className="w-28 h-8 text-sm"> <SelectTrigger className="w-28 h-8 text-sm bg-white border-emerald-200">
<SelectValue placeholder="Année" /> <SelectValue placeholder="Année" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -727,13 +744,12 @@ export default function InvoicesBAP() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
{/* Month selector — disabled when year = all */}
<Select <Select
value={selectedMonth} value={selectedMonth}
onValueChange={setSelectedMonth} onValueChange={setSelectedMonth}
disabled={selectedYear === "all"} disabled={selectedYear === "all"}
> >
<SelectTrigger className="w-36 h-8 text-sm"> <SelectTrigger className="w-36 h-8 text-sm bg-white border-emerald-200">
<SelectValue placeholder="Mois" /> <SelectValue placeholder="Mois" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -750,38 +766,20 @@ export default function InvoicesBAP() {
{(selectedYear !== "all" || selectedMonth !== "all") && ( {(selectedYear !== "all" || selectedMonth !== "all") && (
<button <button
onClick={() => { setSelectedYear(String(currentYear)); setSelectedMonth("all"); }} onClick={() => { setSelectedYear(String(currentYear)); setSelectedMonth("all"); }}
className="text-xs text-slate-500 hover:text-slate-700 underline" className="text-xs text-emerald-600 hover:text-emerald-800 underline"
> >
Réinitialiser Réinitialiser
</button> </button>
)} )}
</div> </div>
{/* Tri */} {/* Ligne 3 : Boutons statut + tri */}
<div className="flex gap-2 items-center mb-3"> <div className="flex gap-2 items-center flex-wrap">
<span className="text-sm text-gray-500">Trier par :</span>
<Button
variant={sortField === "createdAt" ? "default" : "outline"}
size="sm"
onClick={() => handleSort("createdAt")}
>
Date réception <SortIcon field="createdAt" />
</Button>
<Button
variant={sortField === "invoiceDate" ? "default" : "outline"}
size="sm"
onClick={() => handleSort("invoiceDate")}
>
Date facture <SortIcon field="invoiceDate" />
</Button>
</div>
{/* Status Filters */}
<div className="flex flex-wrap gap-2 mb-4">
<Button <Button
variant={statusFilter === "all" ? "default" : "outline"} variant={statusFilter === "all" ? "default" : "outline"}
onClick={() => setStatusFilter("all")} onClick={() => setStatusFilter("all")}
size="sm" size="sm"
className={statusFilter === "all" ? "bg-emerald-600 hover:bg-emerald-700" : "bg-white border-emerald-200 text-emerald-700 hover:bg-emerald-50"}
> >
Tous ({statusCounts.all}) Tous ({statusCounts.all})
</Button> </Button>
@@ -789,7 +787,7 @@ export default function InvoicesBAP() {
variant={statusFilter === "exported" ? "default" : "outline"} variant={statusFilter === "exported" ? "default" : "outline"}
onClick={() => setStatusFilter("exported")} onClick={() => setStatusFilter("exported")}
size="sm" size="sm"
className={statusFilter === "exported" ? "bg-green-600 hover:bg-green-700" : ""} className={statusFilter === "exported" ? "bg-green-600 hover:bg-green-700 text-white" : "bg-white border-green-200 text-green-700 hover:bg-green-50"}
> >
Exportés ({statusCounts.exported}) Exportés ({statusCounts.exported})
</Button> </Button>
@@ -797,7 +795,7 @@ export default function InvoicesBAP() {
variant={statusFilter === "not_exported" ? "default" : "outline"} variant={statusFilter === "not_exported" ? "default" : "outline"}
onClick={() => setStatusFilter("not_exported")} onClick={() => setStatusFilter("not_exported")}
size="sm" size="sm"
className={statusFilter === "not_exported" ? "bg-blue-600 hover:bg-blue-700" : ""} className={statusFilter === "not_exported" ? "bg-indigo-600 hover:bg-indigo-700 text-white" : "bg-white border-indigo-200 text-indigo-700 hover:bg-indigo-50"}
> >
Non exportés ({statusCounts.not_exported}) Non exportés ({statusCounts.not_exported})
</Button> </Button>
@@ -805,7 +803,7 @@ export default function InvoicesBAP() {
variant={statusFilter === "export_error" ? "default" : "outline"} variant={statusFilter === "export_error" ? "default" : "outline"}
onClick={() => setStatusFilter("export_error")} onClick={() => setStatusFilter("export_error")}
size="sm" size="sm"
className={statusFilter === "export_error" ? "bg-red-600 hover:bg-red-700" : ""} className={statusFilter === "export_error" ? "bg-red-600 hover:bg-red-700 text-white" : "bg-white border-red-200 text-red-700 hover:bg-red-50"}
> >
Erreurs ({statusCounts.export_error}) Erreurs ({statusCounts.export_error})
</Button> </Button>
@@ -813,7 +811,7 @@ export default function InvoicesBAP() {
variant={statusFilter === "bap_validated" ? "default" : "outline"} variant={statusFilter === "bap_validated" ? "default" : "outline"}
onClick={() => setStatusFilter("bap_validated")} onClick={() => setStatusFilter("bap_validated")}
size="sm" size="sm"
className={statusFilter === "bap_validated" ? "bg-emerald-600 hover:bg-emerald-700" : ""} className={statusFilter === "bap_validated" ? "bg-emerald-600 hover:bg-emerald-700 text-white" : "bg-white border-emerald-200 text-emerald-700 hover:bg-emerald-50"}
> >
Validées BAP ({statusCounts.bap_validated}) Validées BAP ({statusCounts.bap_validated})
</Button> </Button>
@@ -821,7 +819,7 @@ export default function InvoicesBAP() {
variant={statusFilter === "bap_pending" ? "default" : "outline"} variant={statusFilter === "bap_pending" ? "default" : "outline"}
onClick={() => setStatusFilter("bap_pending")} onClick={() => setStatusFilter("bap_pending")}
size="sm" size="sm"
className={statusFilter === "bap_pending" ? "bg-orange-600 hover:bg-orange-700" : ""} className={statusFilter === "bap_pending" ? "bg-orange-600 hover:bg-orange-700 text-white" : "bg-white border-orange-200 text-orange-700 hover:bg-orange-50"}
> >
En attente BAP ({statusCounts.bap_pending}) En attente BAP ({statusCounts.bap_pending})
</Button> </Button>
@@ -829,11 +827,35 @@ export default function InvoicesBAP() {
variant={statusFilter === "to_complete" ? "default" : "outline"} variant={statusFilter === "to_complete" ? "default" : "outline"}
onClick={() => setStatusFilter("to_complete")} onClick={() => setStatusFilter("to_complete")}
size="sm" size="sm"
className={statusFilter === "to_complete" ? "bg-amber-600 hover:bg-amber-700" : "border-amber-400 text-amber-700 hover:bg-amber-50"} className={statusFilter === "to_complete" ? "bg-amber-600 hover:bg-amber-700 text-white" : "bg-white border-amber-200 text-amber-700 hover:bg-amber-50"}
> >
À compléter ({statusCounts.to_complete}) À compléter ({statusCounts.to_complete})
</Button> </Button>
<div className="ml-auto flex gap-1 items-center">
<span className="text-xs text-emerald-500 font-medium mr-1">Trier :</span>
<Button
variant={sortField === "createdAt" ? "default" : "outline"}
size="sm"
onClick={() => handleSort("createdAt")}
className={sortField === "createdAt" ? "bg-emerald-600 hover:bg-emerald-700" : "bg-white border-emerald-200 text-emerald-700 hover:bg-emerald-50"}
>
Réception <SortIcon field="createdAt" />
</Button>
<Button
variant={sortField === "invoiceDate" ? "default" : "outline"}
size="sm"
onClick={() => handleSort("invoiceDate")}
className={sortField === "invoiceDate" ? "bg-emerald-600 hover:bg-emerald-700" : "bg-white border-emerald-200 text-emerald-700 hover:bg-emerald-50"}
>
Facture <SortIcon field="invoiceDate" />
</Button>
</div> </div>
</div>
</div>
{/* ===== FIN CARTOUCHE BAP ===== */}
<Card>
<CardContent className="pt-4">
{/* Table */} {/* Table */}
{isLoading ? ( {isLoading ? (
@@ -995,11 +1017,14 @@ export default function InvoicesBAP() {
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
className="h-8 px-2 text-gray-400 border-gray-200 cursor-not-allowed" className="h-8 px-2 text-orange-500 border-orange-300 hover:bg-orange-50"
title="PDF non disponible" title="PDF non disponible — cliquer pour régénérer"
disabled disabled={regenerateBapPdfMutation.isPending}
onClick={() => regenerateBapPdfMutation.mutate({ invoiceId: invoice.id })}
> >
<Download className="h-4 w-4" /> {regenerateBapPdfMutation.isPending && regenerateBapPdfMutation.variables?.invoiceId === invoice.id
? <RefreshCw className="h-4 w-4 animate-spin" />
: <RefreshCw className="h-4 w-4" />}
</Button> </Button>
)} )}
</div> </div>

View File

@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -10,9 +10,33 @@ import { toast } from "sonner";
const ITINOVA_LOGO = "https://d2xsxph8kpxj0f.cloudfront.net/310519663070627318/fo4DRyBgjsuiigFAgNLuWm/itinova-logo_2a2ba00a.jpg"; const ITINOVA_LOGO = "https://d2xsxph8kpxj0f.cloudfront.net/310519663070627318/fo4DRyBgjsuiigFAgNLuWm/itinova-logo_2a2ba00a.jpg";
const SANTINOVA_LOGO = "https://d2xsxph8kpxj0f.cloudfront.net/310519663070627318/fo4DRyBgjsuiigFAgNLuWm/santinova-logo_5ae0d248.webp"; const SANTINOVA_LOGO = "https://d2xsxph8kpxj0f.cloudfront.net/310519663070627318/fo4DRyBgjsuiigFAgNLuWm/santinova-logo_5ae0d248.webp";
// Logo Microsoft SVG officiel
function MicrosoftLogo() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 21 21" width="18" height="18">
<rect x="1" y="1" width="9" height="9" fill="#f25022"/>
<rect x="11" y="1" width="9" height="9" fill="#7fba00"/>
<rect x="1" y="11" width="9" height="9" fill="#00a4ef"/>
<rect x="11" y="11" width="9" height="9" fill="#ffb900"/>
</svg>
);
}
export default function Login() { export default function Login() {
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [azureLoading, setAzureLoading] = useState(false);
// Afficher les erreurs transmises via query param (ex: depuis le callback Azure)
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const error = params.get("error");
if (error) {
toast.error(decodeURIComponent(error));
// Nettoyer l'URL
window.history.replaceState({}, "", "/login");
}
}, []);
const loginMutation = trpc.auth.loginLocal.useMutation({ const loginMutation = trpc.auth.loginLocal.useMutation({
onSuccess: () => { onSuccess: () => {
@@ -24,11 +48,36 @@ export default function Login() {
}, },
}); });
const azureLoginQuery = trpc.auth.getAzureLoginUrl.useQuery(undefined, {
enabled: false,
retry: false,
});
const azureAvailableQuery = trpc.auth.isAzureAdAvailable.useQuery();
const handleLocalLogin = (e: React.FormEvent) => { const handleLocalLogin = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
loginMutation.mutate({ email: username, password }); loginMutation.mutate({ email: username, password });
}; };
const handleMicrosoftLogin = async () => {
setAzureLoading(true);
try {
const result = await azureLoginQuery.refetch();
if (result.data?.url) {
window.location.href = result.data.url;
} else {
toast.error("Impossible d'obtenir l'URL de connexion Microsoft");
setAzureLoading(false);
}
} catch {
toast.error("Erreur lors de la connexion Microsoft");
setAzureLoading(false);
}
};
const azureAvailable = azureAvailableQuery.data?.available ?? false;
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-blue-50 p-4"> <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-blue-50 p-4">
<div className="w-full max-w-sm flex flex-col items-center gap-6"> <div className="w-full max-w-sm flex flex-col items-center gap-6">
@@ -50,6 +99,35 @@ export default function Login() {
<p className="text-sm text-slate-500 mt-1">Connectez-vous pour accéder à l'application</p> <p className="text-sm text-slate-500 mt-1">Connectez-vous pour accéder à l'application</p>
</div> </div>
{/* Bouton Microsoft 365 */}
{azureAvailable && (
<>
<Button
type="button"
variant="outline"
className="w-full flex items-center gap-3 border-slate-300 bg-white hover:bg-slate-50 text-slate-700 font-medium mb-4"
onClick={handleMicrosoftLogin}
disabled={azureLoading}
>
{azureLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MicrosoftLogo />
)}
Se connecter avec Microsoft 365
</Button>
<div className="relative mb-4">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-slate-200" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-slate-400">ou</span>
</div>
</div>
</>
)}
<form onSubmit={handleLocalLogin} className="space-y-4"> <form onSubmit={handleLocalLogin} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="username">Identifiant</Label> <Label htmlFor="username">Identifiant</Label>

View File

@@ -677,12 +677,14 @@ export default function Settings() {
const [sftpAutoExport, setSftpAutoExport] = useState(false); const [sftpAutoExport, setSftpAutoExport] = useState(false);
const [sftpRecipientFilter, setSftpRecipientFilter] = useState(""); const [sftpRecipientFilter, setSftpRecipientFilter] = useState("");
const [llmLogsRetentionMonths, setLlmLogsRetentionMonths] = useState(3); const [llmLogsRetentionMonths, setLlmLogsRetentionMonths] = useState(3);
const [aiProvider, setAiProvider] = useState<"mistral" | "manus">("mistral"); const [aiProvider, setAiProvider] = useState<"mistral" | "manus" | "gemini">("mistral");
const [mistralApiKey, setMistralApiKey] = useState(""); const [mistralApiKey, setMistralApiKey] = useState("");
const [manusForgeApiKey, setManusForgeApiKey] = useState(""); const [manusForgeApiKey, setManusForgeApiKey] = useState("");
const [manusForgeApiUrl, setManusForgeApiUrl] = useState(""); const [manusForgeApiUrl, setManusForgeApiUrl] = useState("");
const [geminiApiKey, setGeminiApiKey] = useState("");
const [showMistralKey, setShowMistralKey] = useState(false); const [showMistralKey, setShowMistralKey] = useState(false);
const [showManusKey, setShowManusKey] = useState(false); const [showManusKey, setShowManusKey] = useState(false);
const [showGeminiKey, setShowGeminiKey] = useState(false);
useEffect(() => { useEffect(() => {
if (settings) { if (settings) {
@@ -706,6 +708,7 @@ export default function Settings() {
setMistralApiKey((settings as any).mistralApiKey || ""); setMistralApiKey((settings as any).mistralApiKey || "");
setManusForgeApiKey((settings as any).manusForgeApiKey || ""); setManusForgeApiKey((settings as any).manusForgeApiKey || "");
setManusForgeApiUrl((settings as any).manusForgeApiUrl || ""); setManusForgeApiUrl((settings as any).manusForgeApiUrl || "");
setGeminiApiKey((settings as any).geminiApiKey || "");
} }
}, [settings]); }, [settings]);
@@ -754,6 +757,7 @@ export default function Settings() {
mistralApiKey, mistralApiKey,
manusForgeApiKey, manusForgeApiKey,
manusForgeApiUrl, manusForgeApiUrl,
geminiApiKey,
}); });
}; };
@@ -853,7 +857,7 @@ export default function Settings() {
<CardContent className="space-y-6 pt-6"> <CardContent className="space-y-6 pt-6">
{/* Provider selector */} {/* Provider selector */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-3 gap-4">
<button <button
type="button" type="button"
onClick={() => setAiProvider("mistral")} onClick={() => setAiProvider("mistral")}
@@ -880,6 +884,36 @@ export default function Settings() {
)} )}
</button> </button>
{/* Google Gemini */}
<button
type="button"
onClick={() => setAiProvider("gemini")}
className={`relative flex flex-col items-center gap-3 p-5 rounded-xl border-2 transition-all cursor-pointer ${
aiProvider === "gemini"
? "border-green-500 bg-green-50 dark:bg-green-950/20 shadow-md"
: "border-muted hover:border-green-300 bg-background"
}`}
>
{aiProvider === "gemini" && (
<div className="absolute top-2 right-2">
<CheckCircle className="w-5 h-5 text-green-500" />
</div>
)}
<div className="p-3 bg-green-100 dark:bg-green-900/30 rounded-xl">
<svg className="w-8 h-8" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z" fill="#4285F4"/>
<path d="M12 6l1.5 4.5H18l-3.75 2.75L15.75 18 12 15.25 8.25 18l1.5-4.75L6 10.5h4.5L12 6z" fill="white"/>
</svg>
</div>
<div className="text-center">
<p className="font-bold text-base">Google Gemini</p>
<p className="text-xs text-muted-foreground mt-1">Gemini 2.0 Flash<br/>Gratuit (1500 req/j)</p>
</div>
{aiProvider === "gemini" && (
<Badge className="bg-green-500 text-white text-xs">Actif</Badge>
)}
</button>
<button <button
type="button" type="button"
onClick={() => setAiProvider("manus")} onClick={() => setAiProvider("manus")}
@@ -938,6 +972,46 @@ export default function Settings() {
</div> </div>
)} )}
{/* Gemini config */}
{aiProvider === "gemini" && (
<div className="space-y-4 p-4 bg-green-50 dark:bg-green-950/10 rounded-xl border border-green-200 dark:border-green-800">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-green-700 dark:text-green-400 uppercase tracking-wide">Configuration Google Gemini</p>
<Badge className="bg-green-100 text-green-700 border border-green-300 text-xs font-normal">Gratuit — 1 500 req/jour</Badge>
</div>
<div className="space-y-2">
<Label htmlFor="geminiApiKey" className="font-medium">Clé API Google AI Studio</Label>
<div className="relative">
<Input
id="geminiApiKey"
type={showGeminiKey ? "text" : "password"}
value={geminiApiKey}
onChange={(e) => setGeminiApiKey(e.target.value)}
placeholder="Votre clé API Google (ex: AIzaSy...)"
className="h-11 pr-10"
/>
<button
type="button"
onClick={() => setShowGeminiKey(!showGeminiKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showGeminiKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<p className="text-xs text-muted-foreground flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
Obtenez votre clé gratuite sur{" "}
<a href="https://aistudio.google.com/apikey" target="_blank" rel="noopener noreferrer" className="text-green-600 hover:underline">aistudio.google.com/apikey</a>.
Laissez vide pour utiliser la variable d'environnement GEMINI_API_KEY du serveur.
</p>
</div>
<div className="p-3 bg-green-100 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
<p className="text-xs text-green-800 dark:text-green-300 font-medium">Modèle utilisé : <span className="font-bold">gemini-2.0-flash</span></p>
<p className="text-xs text-green-700 dark:text-green-400 mt-1">Analyse directe des PDF, extraction structurée JSON, compatible avec l'interface OpenAI.</p>
</div>
</div>
)}
{/* Manus config */} {/* Manus config */}
{aiProvider === "manus" && ( {aiProvider === "manus" && (
<div className="space-y-4 p-4 bg-blue-50 dark:bg-blue-950/10 rounded-xl border border-blue-200 dark:border-blue-800"> <div className="space-y-4 p-4 bg-blue-50 dark:bg-blue-950/10 rounded-xl border border-blue-200 dark:border-blue-800">
@@ -993,14 +1067,14 @@ export default function Settings() {
<div> <div>
<CardTitle className="text-xl">Configuration du modèle AI</CardTitle> <CardTitle className="text-xl">Configuration du modèle AI</CardTitle>
<CardDescription className="mt-1"> <CardDescription className="mt-1">
Paramètres du modèle d'extraction Mistral AI Paramètres avancés du modèle d'extraction IA
</CardDescription> </CardDescription>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-6 pt-6"> <CardContent className="space-y-6 pt-6">
<div className="space-y-3"> <div className="space-y-3">
<Label htmlFor="llmModel" className="text-base font-semibold">Modèle Mistral</Label> <Label htmlFor="llmModel" className="text-base font-semibold">Modèle IA (override)</Label>
<Input <Input
id="llmModel" id="llmModel"
value={llmModel} value={llmModel}

View File

@@ -0,0 +1,404 @@
import { useState } from "react";
import { trpc } from "@/lib/trpc";
import DashboardLayout from "@/components/DashboardLayout";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Globe, Plus, Pencil, Trash2, Key, CheckCircle, XCircle, Clock, RefreshCw } from "lucide-react";
import { toast } from "sonner";
const CONNECTOR_TYPES = [
{ value: "sfr", label: "SFR Pro", url: "https://www.sfr.fr/mon-espace-client/" },
{ value: "orange", label: "Orange Pro", url: "https://espaceclient.orange.fr/" },
{ value: "bouygues", label: "Bouygues Telecom", url: "https://www.bouyguestelecom.fr/mon-compte/" },
{ value: "free", label: "Free Pro", url: "https://pro.free.fr/" },
{ value: "starlink", label: "Starlink", url: "https://www.starlink.com/account/" },
{ value: "custom", label: "Autre (personnalisé)", url: "" },
];
const FREQUENCY_LABELS: Record<string, string> = {
manual: "Manuel",
daily: "Quotidien",
weekly: "Hebdomadaire",
monthly: "Mensuel",
};
type Source = {
id: number;
name: string;
connectorType: string;
portalUrl: string;
loginEmail: string;
loginPassword: string;
frequency: "manual" | "daily" | "weekly" | "monthly";
autoEnabled: number;
lastSuccessAt: Date | null;
lastStatus: string | null;
lastImportCount: number | null;
apiToken: string;
createdAt: Date;
updatedAt: Date;
};
type FormData = {
name: string;
connectorType: string;
portalUrl: string;
loginEmail: string;
loginPassword: string;
frequency: "manual" | "daily" | "weekly" | "monthly";
autoEnabled: number;
};
const emptyForm: FormData = {
name: "",
connectorType: "sfr",
portalUrl: "https://www.sfr.fr/mon-espace-client/",
loginEmail: "",
loginPassword: "",
frequency: "monthly",
autoEnabled: 0,
};
export default function WebImportSources() {
const [showForm, setShowForm] = useState(false);
const [editSource, setEditSource] = useState<Source | null>(null);
const [deleteId, setDeleteId] = useState<number | null>(null);
const [showToken, setShowToken] = useState<number | null>(null);
const [form, setForm] = useState<FormData>(emptyForm);
const { data: sources = [], refetch } = trpc.webImportSources.list.useQuery();
const { data: tokenData } = trpc.webImportSources.getToken.useQuery(
{ id: showToken! },
{ enabled: showToken !== null }
);
const createMutation = trpc.webImportSources.create.useMutation({
onSuccess: () => {
toast.success("Source créée", { description: "Le connecteur web a été ajouté." });
setShowForm(false);
setForm(emptyForm);
refetch();
},
onError: (e) => toast.error("Erreur", { description: e.message }),
});
const updateMutation = trpc.webImportSources.update.useMutation({
onSuccess: () => {
toast.success("Source mise à jour");
setEditSource(null);
setForm(emptyForm);
refetch();
},
onError: (e) => toast.error("Erreur", { description: e.message }),
});
const deleteMutation = trpc.webImportSources.delete.useMutation({
onSuccess: () => {
toast.success("Source supprimée");
setDeleteId(null);
refetch();
},
onError: (e) => toast.error("Erreur", { description: e.message }),
});
function openCreate() {
setForm(emptyForm);
setEditSource(null);
setShowForm(true);
}
function openEdit(source: Source) {
setForm({
name: source.name,
connectorType: source.connectorType,
portalUrl: source.portalUrl,
loginEmail: source.loginEmail,
loginPassword: "", // Ne pas pré-remplir le mot de passe
frequency: source.frequency,
autoEnabled: source.autoEnabled,
});
setEditSource(source);
setShowForm(true);
}
function handleConnectorTypeChange(value: string) {
const connector = CONNECTOR_TYPES.find(c => c.value === value);
setForm(f => ({
...f,
connectorType: value,
name: f.name || connector?.label || "",
portalUrl: connector?.url || f.portalUrl,
}));
}
function handleSubmit() {
if (!form.name || !form.loginEmail || (!editSource && !form.loginPassword)) {
toast.error("Champs requis", { description: "Nom, identifiant et mot de passe sont obligatoires." });
return;
}
if (editSource) {
const updateData: any = { id: editSource.id, ...form };
if (!form.loginPassword) delete updateData.loginPassword; // Ne pas écraser si vide
updateMutation.mutate(updateData);
} else {
createMutation.mutate(form);
}
}
return (
<DashboardLayout>
<div className="p-6 max-w-5xl mx-auto">
{/* En-tête */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-indigo-100 rounded-lg">
<Globe className="w-6 h-6 text-indigo-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">Connecteurs web</h1>
<p className="text-sm text-gray-500">Import automatique de factures depuis des espaces clients</p>
</div>
</div>
<Button onClick={openCreate} className="bg-indigo-600 hover:bg-indigo-700 text-white gap-2">
<Plus className="w-4 h-4" />
Ajouter un connecteur
</Button>
</div>
{/* Info technique */}
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6 text-sm text-amber-800">
<strong>Fonctionnement :</strong> Un script cron tourne sur le serveur LWS et se connecte automatiquement aux espaces clients configurés pour télécharger les nouvelles factures. Le token API affiché ci-dessous est utilisé par ce script pour s'authentifier auprès de l'application.
</div>
{/* Liste des sources */}
{sources.length === 0 ? (
<div className="text-center py-16 text-gray-400">
<Globe className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p className="text-lg font-medium">Aucun connecteur configuré</p>
<p className="text-sm mt-1">Ajoutez un connecteur pour importer automatiquement des factures depuis un espace client web.</p>
</div>
) : (
<div className="space-y-3">
{(sources as Source[]).map((source) => (
<div key={source.id} className="bg-white border border-gray-200 rounded-xl p-5 shadow-sm hover:shadow-md transition-shadow">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4 flex-1 min-w-0">
<div className="p-2 bg-indigo-50 rounded-lg shrink-0">
<Globe className="w-5 h-5 text-indigo-500" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-gray-900">{source.name}</span>
<Badge variant="outline" className="text-xs capitalize">{source.connectorType}</Badge>
<Badge variant={source.autoEnabled ? "default" : "secondary"} className="text-xs">
{source.autoEnabled ? "Auto activé" : "Manuel"}
</Badge>
<Badge variant="outline" className="text-xs">{FREQUENCY_LABELS[source.frequency]}</Badge>
</div>
<p className="text-sm text-gray-500 mt-1 truncate">{source.portalUrl}</p>
<p className="text-sm text-gray-600 mt-0.5">
<span className="font-medium">Identifiant :</span> {source.loginEmail}
</p>
<div className="flex items-center gap-4 mt-2 text-xs text-gray-400">
{source.lastSuccessAt ? (
<span className="flex items-center gap-1 text-green-600">
<CheckCircle className="w-3 h-3" />
Dernier import : {new Date(source.lastSuccessAt).toLocaleDateString("fr-FR")}
{source.lastImportCount !== null && ` (${source.lastImportCount} facture(s))`}
</span>
) : (
<span className="flex items-center gap-1 text-gray-400">
<Clock className="w-3 h-3" />
Jamais importé
</span>
)}
{source.lastStatus && !source.lastSuccessAt && (
<span className="flex items-center gap-1 text-red-500">
<XCircle className="w-3 h-3" />
{source.lastStatus}
</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
variant="outline"
size="sm"
className="gap-1 text-xs"
onClick={() => setShowToken(showToken === source.id ? null : source.id)}
>
<Key className="w-3 h-3" />
Token
</Button>
<Button variant="outline" size="sm" onClick={() => openEdit(source)}>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
className="text-red-500 hover:text-red-700 hover:border-red-300"
onClick={() => setDeleteId(source.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
{/* Token API affiché inline */}
{showToken === source.id && tokenData && (
<div className="mt-3 p-3 bg-gray-50 rounded-lg border border-gray-200">
<p className="text-xs text-gray-500 mb-1 font-medium">Token API (à configurer dans le script cron) :</p>
<code className="text-xs font-mono text-indigo-700 break-all select-all">{tokenData.apiToken}</code>
</div>
)}
</div>
))}
</div>
)}
{/* Dialog création / édition */}
<Dialog open={showForm} onOpenChange={(open) => { if (!open) { setShowForm(false); setEditSource(null); } }}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{editSource ? "Modifier le connecteur" : "Nouveau connecteur web"}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div>
<Label>Type de connecteur</Label>
<Select value={form.connectorType} onValueChange={handleConnectorTypeChange}>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONNECTOR_TYPES.map(c => (
<SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Nom affiché</Label>
<Input
className="mt-1"
value={form.name}
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
placeholder="Ex : SFR Pro - Itinova"
/>
</div>
<div>
<Label>URL de l'espace client</Label>
<Input
className="mt-1"
value={form.portalUrl}
onChange={e => setForm(f => ({ ...f, portalUrl: e.target.value }))}
placeholder="https://..."
/>
</div>
<div>
<Label>Identifiant (email ou login)</Label>
<Input
className="mt-1"
value={form.loginEmail}
onChange={e => setForm(f => ({ ...f, loginEmail: e.target.value }))}
placeholder="votre@email.com"
/>
</div>
<div>
<Label>{editSource ? "Nouveau mot de passe (laisser vide pour ne pas changer)" : "Mot de passe"}</Label>
<Input
className="mt-1"
type="password"
value={form.loginPassword}
onChange={e => setForm(f => ({ ...f, loginPassword: e.target.value }))}
placeholder={editSource ? "••••••••" : "Mot de passe"}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Fréquence</Label>
<Select value={form.frequency} onValueChange={(v: any) => setForm(f => ({ ...f, frequency: v }))}>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="manual">Manuel</SelectItem>
<SelectItem value="daily">Quotidien</SelectItem>
<SelectItem value="weekly">Hebdomadaire</SelectItem>
<SelectItem value="monthly">Mensuel</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col justify-end pb-1">
<div className="flex items-center gap-2">
<Switch
checked={form.autoEnabled === 1}
onCheckedChange={v => setForm(f => ({ ...f, autoEnabled: v ? 1 : 0 }))}
/>
<Label>Import auto activé</Label>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setShowForm(false); setEditSource(null); }}>Annuler</Button>
<Button
onClick={handleSubmit}
disabled={createMutation.isPending || updateMutation.isPending}
className="bg-indigo-600 hover:bg-indigo-700 text-white"
>
{createMutation.isPending || updateMutation.isPending ? (
<RefreshCw className="w-4 h-4 animate-spin mr-2" />
) : null}
{editSource ? "Enregistrer" : "Créer"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Confirmation suppression */}
<AlertDialog open={deleteId !== null} onOpenChange={(open) => { if (!open) setDeleteId(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Supprimer ce connecteur ?</AlertDialogTitle>
<AlertDialogDescription>
Cette action est irréversible. Le connecteur et son token API seront supprimés.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 hover:bg-red-700"
onClick={() => deleteId !== null && deleteMutation.mutate({ id: deleteId })}
>
Supprimer
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</DashboardLayout>
);
}

View File

@@ -6,7 +6,7 @@ import bcrypt from "bcrypt";
async function createAdmin() { async function createAdmin() {
const db = drizzle(process.env.DATABASE_URL); const db = drizzle(process.env.DATABASE_URL);
const email = "adminServFacturation"; const email = "adminItinova";
const password = "Itinova69!"; const password = "Itinova69!";
const name = "Administrateur"; const name = "Administrateur";
@@ -29,7 +29,7 @@ async function createAdmin() {
}); });
console.log("✅ Utilisateur administrateur créé avec succès !"); console.log("✅ Utilisateur administrateur créé avec succès !");
console.log("📧 Email/Login: adminServFacturation"); console.log("📧 Email/Login: adminItinova");
console.log("🔑 Mot de passe: Itinova69!"); console.log("🔑 Mot de passe: Itinova69!");
console.log("👤 Rôle: admin"); console.log("👤 Rôle: admin");
} catch (error) { } catch (error) {

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 @@
ALTER TABLE `importLogs` ADD `importSource` enum('file','folder','email') DEFAULT 'file' NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE `users` MODIFY COLUMN `azureAdId` varchar(128);

View File

@@ -0,0 +1,4 @@
ALTER TABLE `importSettings` MODIFY COLUMN `aiProvider` enum('mistral','manus','gemini') NOT NULL DEFAULT 'mistral';--> statement-breakpoint
ALTER TABLE `userSettings` MODIFY COLUMN `aiProvider` enum('mistral','manus','gemini') NOT NULL DEFAULT 'mistral';--> statement-breakpoint
ALTER TABLE `importSettings` ADD `geminiApiKey` text;--> statement-breakpoint
ALTER TABLE `userSettings` ADD `geminiApiKey` text;

View File

@@ -0,0 +1,9 @@
CREATE TABLE `deletedInvoices` (
`id` int AUTO_INCREMENT NOT NULL,
`userId` int NOT NULL,
`invoiceNumber` varchar(100) NOT NULL,
`totalAmount` varchar(50),
`supplierName` varchar(255),
`deletedAt` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `deletedInvoices_id` PRIMARY KEY(`id`)
);

View File

@@ -0,0 +1,18 @@
CREATE TABLE `webImportSources` (
`id` int AUTO_INCREMENT NOT NULL,
`userId` int NOT NULL,
`name` varchar(100) NOT NULL,
`connectorType` varchar(50) NOT NULL,
`portalUrl` varchar(500) NOT NULL,
`loginEmail` varchar(320) NOT NULL,
`loginPassword` text NOT NULL,
`frequency` enum('manual','daily','weekly','monthly') NOT NULL DEFAULT 'monthly',
`autoEnabled` int NOT NULL DEFAULT 0,
`lastSuccessAt` timestamp,
`lastStatus` text,
`lastImportCount` int DEFAULT 0,
`apiToken` varchar(128) NOT NULL,
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `webImportSources_id` PRIMARY KEY(`id`)
);

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -225,6 +225,48 @@
"when": 1781021650065, "when": 1781021650065,
"tag": "0031_mean_squadron_supreme", "tag": "0031_mean_squadron_supreme",
"breakpoints": true "breakpoints": true
},
{
"idx": 32,
"version": "5",
"when": 1781690960721,
"tag": "0032_volatile_jocasta",
"breakpoints": true
},
{
"idx": 33,
"version": "5",
"when": 1783419609559,
"tag": "0033_strange_manta",
"breakpoints": true
},
{
"idx": 34,
"version": "5",
"when": 1785167359371,
"tag": "0034_black_shadowcat",
"breakpoints": true
},
{
"idx": 35,
"version": "5",
"when": 1785404752594,
"tag": "0035_eminent_dreadnoughts",
"breakpoints": true
},
{
"idx": 36,
"version": "5",
"when": 1785419093588,
"tag": "0036_broken_rattler",
"breakpoints": true
},
{
"idx": 37,
"version": "5",
"when": 1787394418464,
"tag": "0037_goofy_quentin_quire",
"breakpoints": true
} }
] ]
} }

View File

@@ -9,7 +9,7 @@ export const users = mysqlTable("users", {
/** Manus OAuth identifier (openId) - Optional for backward compatibility */ /** Manus OAuth identifier (openId) - Optional for backward compatibility */
openId: varchar("openId", { length: 64 }).unique(), openId: varchar("openId", { length: 64 }).unique(),
/** Azure AD Object ID - Unique identifier from Azure AD */ /** Azure AD Object ID - Unique identifier from Azure AD */
azureAdId: varchar("azureAdId", { length: 64 }).unique(), azureAdId: varchar("azureAdId", { length: 128 }).unique(),
name: text("name"), name: text("name"),
email: varchar("email", { length: 320 }).notNull().unique(), email: varchar("email", { length: 320 }).notNull().unique(),
/** Hashed password for local authentication (bcrypt) */ /** Hashed password for local authentication (bcrypt) */
@@ -36,12 +36,16 @@ export const sourceFiles = mysqlTable("sourceFiles", {
fileName: varchar("fileName", { length: 255 }).notNull(), fileName: varchar("fileName", { length: 255 }).notNull(),
fileKey: text("fileKey").notNull(), // Local storage key with YYYY-MM prefix fileKey: text("fileKey").notNull(), // Local storage key with YYYY-MM prefix
fileUrl: text("fileUrl").notNull(), // Public URL 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(), totalInvoicesDetected: int("totalInvoicesDetected").default(0).notNull(),
processingStatus: mysqlEnum("processingStatus", ["processing", "completed", "error"]).default("processing").notNull(), processingStatus: mysqlEnum("processingStatus", ["processing", "completed", "error"]).default("processing").notNull(),
processingProgress: varchar("processingProgress", { length: 255 }), // Progress message (e.g., "Extraction 3/9 factures...") processingProgress: varchar("processingProgress", { length: 255 }), // Progress message (e.g., "Extraction 3/9 factures...")
createdAt: timestamp("createdAt").defaultNow().notNull(), createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().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 SourceFile = typeof sourceFiles.$inferSelect;
export type InsertSourceFile = typeof sourceFiles.$inferInsert; export type InsertSourceFile = typeof sourceFiles.$inferInsert;
@@ -149,10 +153,11 @@ export const userSettings = mysqlTable("userSettings", {
// Seuil de confiance pour les apprentissages IA // Seuil de confiance pour les apprentissages IA
learningConfidenceThreshold: int("learningConfidenceThreshold").default(2).notNull(), // Nombre minimum d'applications pour marquer un apprentissage comme Confirmé learningConfidenceThreshold: int("learningConfidenceThreshold").default(2).notNull(), // Nombre minimum d'applications pour marquer un apprentissage comme Confirmé
// AI Engine configuration // AI Engine configuration
aiProvider: mysqlEnum("aiProvider", ["mistral", "manus"]).default("mistral").notNull(), // AI provider: 'mistral' or 'manus' aiProvider: mysqlEnum("aiProvider", ["mistral", "manus", "gemini"]).default("mistral").notNull(), // AI provider: 'mistral', 'manus' or 'gemini'
mistralApiKey: text("mistralApiKey"), // Mistral API key (overrides env MISTRAL_API_KEY) mistralApiKey: text("mistralApiKey"), // Mistral API key (overrides env MISTRAL_API_KEY)
manusForgeApiKey: text("manusForgeApiKey"), // Manus Forge API key (overrides env BUILT_IN_FORGE_API_KEY) manusForgeApiKey: text("manusForgeApiKey"), // Manus Forge API key (overrides env BUILT_IN_FORGE_API_KEY)
manusForgeApiUrl: text("manusForgeApiUrl"), // Manus Forge API URL (overrides env BUILT_IN_FORGE_API_URL) manusForgeApiUrl: text("manusForgeApiUrl"), // Manus Forge API URL (overrides env BUILT_IN_FORGE_API_URL)
geminiApiKey: text("geminiApiKey"), // Google Gemini API key (overrides env GEMINI_API_KEY)
createdAt: timestamp("createdAt").defaultNow().notNull(), createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
}); });
@@ -196,10 +201,11 @@ export const importSettings = mysqlTable("importSettings", {
azureSecretExpiresAt: timestamp("azureSecretExpiresAt"), // Azure AD Client Secret expiration date azureSecretExpiresAt: timestamp("azureSecretExpiresAt"), // Azure AD Client Secret expiration date
// AI Engine settings // AI Engine settings
aiProvider: mysqlEnum("aiProvider", ["mistral", "manus"]).default("mistral").notNull(), // AI provider for invoice extraction aiProvider: mysqlEnum("aiProvider", ["mistral", "manus", "gemini"]).default("mistral").notNull(), // AI provider for invoice extraction
mistralApiKey: text("mistralApiKey"), // Mistral API key (overrides env MISTRAL_API_KEY) mistralApiKey: text("mistralApiKey"), // Mistral API key (overrides env MISTRAL_API_KEY)
manusForgeApiKey: text("manusForgeApiKey"), // Manus Forge API key (overrides env BUILT_IN_FORGE_API_KEY) manusForgeApiKey: text("manusForgeApiKey"), // Manus Forge API key (overrides env BUILT_IN_FORGE_API_KEY)
manusForgeApiUrl: text("manusForgeApiUrl"), // Manus Forge API URL (overrides env BUILT_IN_FORGE_API_URL) manusForgeApiUrl: text("manusForgeApiUrl"), // Manus Forge API URL (overrides env BUILT_IN_FORGE_API_URL)
geminiApiKey: text("geminiApiKey"), // Google Gemini API key (overrides env GEMINI_API_KEY)
createdAt: timestamp("createdAt").defaultNow().notNull(), createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
@@ -223,6 +229,8 @@ export const importLogs = mysqlTable("importLogs", {
duplicateDetails: text("duplicateDetails"), // JSON array of duplicate invoice info duplicateDetails: text("duplicateDetails"), // JSON array of duplicate invoice info
errorDetails: text("errorDetails"), // JSON array of error messages errorDetails: text("errorDetails"), // JSON array of error messages
warningMessage: text("warningMessage"), // Warning message (e.g. quota exhausted) warningMessage: text("warningMessage"), // Warning message (e.g. quota exhausted)
/** Source du mode d'import : 'file' = upload manuel, 'folder' = dossier automatique, 'email' = import par email */
importSource: mysqlEnum("importSource", ["file", "folder", "email"]).default("file").notNull(),
importedAt: timestamp("importedAt").defaultNow().notNull(), importedAt: timestamp("importedAt").defaultNow().notNull(),
}); });
@@ -504,3 +512,55 @@ export const freeproSettings = mysqlTable("freeproSettings", {
}); });
export type FreeproSettings = typeof freeproSettings.$inferSelect; export type FreeproSettings = typeof freeproSettings.$inferSelect;
export type InsertFreeproSettings = typeof freeproSettings.$inferInsert; export type InsertFreeproSettings = typeof freeproSettings.$inferInsert;
/**
* Deleted invoices blacklist — prevents re-import of manually deleted invoices.
* When a user deletes an invoice, its invoiceNumber + totalAmount are stored here.
* The email/file import service checks this table before inserting a new invoice.
*/
export const deletedInvoices = mysqlTable("deletedInvoices", {
id: int("id").autoincrement().primaryKey(),
userId: int("userId").notNull(),
invoiceNumber: varchar("invoiceNumber", { length: 100 }).notNull(),
totalAmount: varchar("totalAmount", { length: 50 }),
supplierName: varchar("supplierName", { length: 255 }),
deletedAt: timestamp("deletedAt").defaultNow().notNull(),
});
export type DeletedInvoice = typeof deletedInvoices.$inferSelect;
export type InsertDeletedInvoice = typeof deletedInvoices.$inferInsert;
/**
* Web import sources — connecteurs web pour scraper des factures depuis des sites
* (ex: espace client SFR, Starlink, Orange...) avec login/mot de passe.
* Le scraping est exécuté par un script cron externe (Node.js + Playwright) sur LWS.
*/
export const webImportSources = mysqlTable("webImportSources", {
id: int("id").autoincrement().primaryKey(),
userId: int("userId").notNull(),
/** Nom affiché (ex: "SFR Pro", "Starlink") */
name: varchar("name", { length: 100 }).notNull(),
/** Type de connecteur — détermine le script Playwright à utiliser */
connectorType: varchar("connectorType", { length: 50 }).notNull(), // ex: "sfr", "starlink", "orange"
/** URL de l'espace client */
portalUrl: varchar("portalUrl", { length: 500 }).notNull(),
/** Identifiant de connexion (email ou login) */
loginEmail: varchar("loginEmail", { length: 320 }).notNull(),
/** Mot de passe chiffré (AES-256) */
loginPassword: text("loginPassword").notNull(),
/** Fréquence de vérification automatique */
frequency: mysqlEnum("frequency", ["manual", "daily", "weekly", "monthly"]).default("monthly").notNull(),
/** Activation de l'import automatique */
autoEnabled: int("autoEnabled").default(0).notNull(), // 0 = désactivé, 1 = activé
/** Date du dernier import réussi */
lastSuccessAt: timestamp("lastSuccessAt"),
/** Statut du dernier import */
lastStatus: text("lastStatus"),
/** Nombre de factures importées lors du dernier run */
lastImportCount: int("lastImportCount").default(0),
/** Token d'API pour que le script externe puisse s'authentifier */
apiToken: varchar("apiToken", { length: 128 }).notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type WebImportSource = typeof webImportSources.$inferSelect;
export type InsertWebImportSource = typeof webImportSources.$inferInsert;

View File

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

2131
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,70 @@
# Connecteurs Web - Scripts d'import automatique
## Prérequis sur le serveur LWS
```bash
cd /opt/web-import
npm install playwright
npx playwright install chromium --with-deps
```
## Configuration
1. Depuis l'application : **Configuration > Connecteurs web** → créer une source SFR → copier le token API
2. Créer un fichier de configuration :
```bash
cp config.example.json config.json
# Éditer config.json avec vos valeurs
```
Contenu de `config.json` :
```json
{
"sfrLogin": "votre-login@sfr.fr",
"sfrPassword": "votre-mot-de-passe",
"appUrl": "https://demat-facturation.santinova-soft.org",
"apiToken": "votre-token-api-copié-depuis-lappli",
"downloadDir": "/tmp/sfr-invoices",
"processedFile": "/tmp/sfr-processed.json"
}
```
Ou utiliser des variables d'environnement :
```bash
export SFR_LOGIN=votre-login@sfr.fr
export SFR_PASSWORD=votre-mot-de-passe
export APP_URL=https://demat-facturation.santinova-soft.org
export API_TOKEN=votre-token-api
```
## Exécution manuelle
```bash
node sfr-connector.mjs
```
## Planification (cron)
Ajouter dans le crontab (`crontab -e`) :
```
# Import SFR le 5 de chaque mois à 8h00
0 8 5 * * /usr/bin/node /opt/web-import/sfr-connector.mjs >> /var/log/sfr-import.log 2>&1
```
## Ajouter un nouveau connecteur
Dupliquer `sfr-connector.mjs` et adapter :
1. L'URL du portail (`portalUrl`)
2. Les sélecteurs CSS pour le login et les liens de factures
3. Le nom du fichier de suivi (`processedFile`)
## Fonctionnement
1. Le script se connecte au site SFR avec les identifiants fournis
2. Il navigue vers la section factures
3. Il télécharge les PDFs non encore traités
4. Il les envoie à l'application via l'endpoint `/api/web-import/push-invoice`
5. L'application extrait les données avec l'IA et crée les factures
6. Le script marque les factures comme traitées pour éviter les doublons

View File

@@ -0,0 +1,216 @@
/**
* Connecteur SFR Pro - Script cron pour import automatique de factures
*
* Prérequis sur le serveur LWS :
* npm install playwright @playwright/test
* npx playwright install chromium
*
* Configuration :
* Copier .env.example en .env et remplir les variables
*
* Utilisation :
* node sfr-connector.mjs
*
* Cron (mensuel le 5 du mois à 8h) :
* 0 8 5 * * /usr/bin/node /opt/web-import/sfr-connector.mjs >> /var/log/sfr-import.log 2>&1
*/
import { chromium } from 'playwright';
import fs from 'fs';
import path from 'path';
import https from 'https';
import http from 'http';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// ============ CONFIGURATION ============
// Ces variables peuvent être définies dans un fichier .env ou directement ici
const CONFIG = {
// URL de l'espace client SFR Pro
portalUrl: process.env.SFR_PORTAL_URL || 'https://www.sfr-business.fr/espace-client/',
// Identifiants SFR
login: process.env.SFR_LOGIN || '',
password: process.env.SFR_PASSWORD || '',
// URL de l'application de dématérialisation
appUrl: process.env.APP_URL || 'https://demat-facturation.santinova-soft.org',
// Token API de la source web (récupéré depuis l'interface Connecteurs web)
apiToken: process.env.API_TOKEN || '',
// Dossier temporaire pour les PDFs téléchargés
downloadDir: process.env.DOWNLOAD_DIR || '/tmp/sfr-invoices',
// Ne pas réimporter les factures déjà traitées (fichier de suivi)
processedFile: process.env.PROCESSED_FILE || '/tmp/sfr-processed.json',
};
// ============ HELPERS ============
function log(msg) {
console.log(`[${new Date().toISOString()}] [SFR] ${msg}`);
}
function loadProcessed() {
try {
if (fs.existsSync(CONFIG.processedFile)) {
return JSON.parse(fs.readFileSync(CONFIG.processedFile, 'utf8'));
}
} catch {}
return [];
}
function saveProcessed(list) {
fs.writeFileSync(CONFIG.processedFile, JSON.stringify(list, null, 2));
}
async function pushInvoiceToApp(filePath, fileName) {
const fileBuffer = fs.readFileSync(filePath);
const fileBase64 = fileBuffer.toString('base64');
const body = JSON.stringify({
apiToken: CONFIG.apiToken,
fileName,
fileBase64,
mimeType: 'application/pdf',
});
return new Promise((resolve, reject) => {
const url = new URL(`${CONFIG.appUrl}/api/web-import/push-invoice`);
const options = {
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
},
};
const lib = url.protocol === 'https:' ? https : http;
const req = lib.request(options, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
resolve({ status: res.statusCode, body: JSON.parse(data) });
} catch {
resolve({ status: res.statusCode, body: data });
}
});
});
req.on('error', reject);
req.write(body);
req.end();
});
}
// ============ CONNECTEUR SFR ============
async function runSfrConnector() {
log('Démarrage du connecteur SFR Pro');
if (!CONFIG.login || !CONFIG.password || !CONFIG.apiToken) {
log('ERREUR : SFR_LOGIN, SFR_PASSWORD et API_TOKEN sont requis');
process.exit(1);
}
// Créer le dossier de téléchargement
if (!fs.existsSync(CONFIG.downloadDir)) {
fs.mkdirSync(CONFIG.downloadDir, { recursive: true });
}
const processed = loadProcessed();
let newInvoices = 0;
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
acceptDownloads: true,
});
const page = await context.newPage();
try {
// 1. Naviguer vers l'espace client SFR
log(`Navigation vers ${CONFIG.portalUrl}`);
await page.goto(CONFIG.portalUrl, { waitUntil: 'networkidle', timeout: 30000 });
// 2. Accepter les cookies si présent
try {
await page.click('[id*="accept"], [class*="accept-cookie"], #didomi-notice-agree-button', { timeout: 3000 });
log('Cookies acceptés');
} catch {}
// 3. Remplir le formulaire de connexion
log('Connexion en cours...');
await page.fill('input[type="email"], input[name="login"], input[id*="login"], input[id*="email"]', CONFIG.login);
await page.fill('input[type="password"], input[name="password"], input[id*="password"]', CONFIG.password);
await page.click('button[type="submit"], input[type="submit"], button:has-text("Connexion"), button:has-text("Se connecter")');
await page.waitForNavigation({ waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
log('Connecté');
// 4. Naviguer vers la section factures
// Adapter selon la structure réelle du site SFR Pro
await page.goto(`${CONFIG.portalUrl}factures`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
// Chercher les liens de factures PDF
const invoiceLinks = await page.$$eval(
'a[href*=".pdf"], a[href*="facture"], a[href*="invoice"], a[download]',
links => links.map(a => ({
href: a.href,
text: a.textContent?.trim() || '',
download: a.getAttribute('download') || '',
}))
);
log(`${invoiceLinks.length} lien(s) de facture trouvé(s)`);
// 5. Télécharger et envoyer chaque facture
for (const link of invoiceLinks) {
const invoiceId = link.href || link.text;
if (processed.includes(invoiceId)) {
log(`Déjà traité : ${link.text}`);
continue;
}
try {
// Télécharger le PDF
const [download] = await Promise.all([
context.waitForEvent('download', { timeout: 15000 }),
page.click(`a[href="${link.href}"]`).catch(() => page.goto(link.href)),
]);
const fileName = download?.suggestedFilename() || `sfr-facture-${Date.now()}.pdf`;
const filePath = path.join(CONFIG.downloadDir, fileName);
await download?.saveAs(filePath);
log(`Téléchargé : ${fileName}`);
// Envoyer à l'application
const result = await pushInvoiceToApp(filePath, fileName);
log(`Envoyé : ${fileName}${JSON.stringify(result.body)}`);
// Marquer comme traité
processed.push(invoiceId);
saveProcessed(processed);
newInvoices++;
// Nettoyer le fichier temporaire
fs.unlinkSync(filePath);
} catch (err) {
log(`ERREUR sur ${link.text} : ${err.message}`);
}
}
} catch (err) {
log(`ERREUR FATALE : ${err.message}`);
await page.screenshot({ path: path.join(CONFIG.downloadDir, 'error-screenshot.png') }).catch(() => {});
throw err;
} finally {
await browser.close();
}
log(`Terminé : ${newInvoices} nouvelle(s) facture(s) importée(s)`);
return newInvoices;
}
// ============ POINT D'ENTRÉE ============
runSfrConnector().catch(err => {
console.error(`[FATAL] ${err.message}`);
process.exit(1);
});

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"; import type { CookieOptions, Request } from "express";
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]); /** Detects HTTPS after direct access or a reverse proxy such as Traefik. */
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(":");
}
function isSecureRequest(req: Request) { function isSecureRequest(req: Request) {
if (req.protocol === "https") return true; if (req.protocol === "https") return true;
@@ -24,25 +17,13 @@ function isSecureRequest(req: Request) {
export function getSessionCookieOptions( export function getSessionCookieOptions(
req: Request req: Request
): Pick<CookieOptions, "domain" | "httpOnly" | "path" | "sameSite" | "secure"> { ): Pick<CookieOptions, "domain" | "httpOnly" | "path" | "sameSite" | "secure"> {
// const hostname = req.hostname; const secure = isSecureRequest(req);
// 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;
return { return {
httpOnly: true, httpOnly: true,
path: "/", path: "/",
sameSite: "none", // Browsers reject SameSite=None without Secure; use Lax for local HTTP.
secure: isSecureRequest(req), sameSite: secure ? "none" : "lax",
secure,
}; };
} }

View File

@@ -8,4 +8,5 @@ export const ENV = {
forgeApiUrl: process.env.BUILT_IN_FORGE_API_URL ?? "", forgeApiUrl: process.env.BUILT_IN_FORGE_API_URL ?? "",
forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "", forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "",
mistralApiKey: process.env.MISTRAL_API_KEY ?? "", mistralApiKey: process.env.MISTRAL_API_KEY ?? "",
geminiApiKey: process.env.GEMINI_API_KEY ?? "",
}; };

View File

@@ -5,15 +5,44 @@ import net from "net";
import path from "path"; import path from "path";
import fs from "fs"; import fs from "fs";
import archiver from "archiver"; import archiver from "archiver";
import { parse as parseCookies } from "cookie";
import { createExpressMiddleware } from "@trpc/server/adapters/express"; import { createExpressMiddleware } from "@trpc/server/adapters/express";
import { registerOAuthRoutes } from "./oauth"; import { registerOAuthRoutes } from "./oauth";
import { appRouter } from "../routers"; import { appRouter } from "../routers";
import { createContext } from "./context"; import { createContext } from "./context";
import { getSessionCookieOptions } from "./cookies";
import { serveStatic, setupVite } from "./vite"; import { serveStatic, setupVite } from "./vite";
import { getAllUsers } from "../db"; import { getAllUsers, getImportSettingsByUser, getUserByAzureAdId, getUserByEmail, getUserSettings, upsertUser } from "../db";
import { startEmailImportService } from "../emailImportService"; import { startEmailImportService } from "../emailImportService";
import { startFolderImportService } from "../folderImportService"; import { startFolderImportService } from "../folderImportService";
import { getImportSettingsByUser } from "../db"; 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> { function isPortAvailable(port: number): Promise<boolean> {
return new Promise(resolve => { return new Promise(resolve => {
@@ -49,6 +78,7 @@ async function startServer() {
// Accepte les chemins avec sous-dossiers : /api/download-bap/2026-04/filename.pdf // 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 // ou via query param pdfPath : /api/download-bap/file.pdf?pdfPath=/storage/2026-04/file.pdf
app.get("/api/download-bap", (req, res) => { app.get("/api/download-bap", (req, res) => {
if (!requireAuthenticatedUser(req, res)) return;
// Mode 1 : query param pdfPath (chemin complet depuis /storage/...) // Mode 1 : query param pdfPath (chemin complet depuis /storage/...)
const pdfPath = req.query.pdfPath as string | undefined; const pdfPath = req.query.pdfPath as string | undefined;
if (!pdfPath) { if (!pdfPath) {
@@ -79,6 +109,7 @@ async function startServer() {
// Compat. ancienne route avec :filename (sans sous-dossier) // Compat. ancienne route avec :filename (sans sous-dossier)
app.get("/api/download-bap/:filename", (req, res) => { app.get("/api/download-bap/:filename", (req, res) => {
if (!requireAuthenticatedUser(req, res)) return;
const filename = path.basename(req.params.filename); const filename = path.basename(req.params.filename);
// Chercher dans tous les sous-dossiers de storage // Chercher dans tous les sous-dossiers de storage
const storageRoot = path.resolve("storage"); const storageRoot = path.resolve("storage");
@@ -105,6 +136,7 @@ async function startServer() {
// Route de téléchargement groupé ZIP des PDFs annotés BAP // 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 }> } // POST /api/download-bap-zip avec body { files: Array<{ pdfPath: string, filename: string }> }
app.post("/api/download-bap-zip", (req, res) => { app.post("/api/download-bap-zip", (req, res) => {
if (!requireAuthenticatedUser(req, res)) return;
const files: Array<{ pdfPath: string; filename: string }> = req.body.files || []; const files: Array<{ pdfPath: string; filename: string }> = req.body.files || [];
if (!files.length) { if (!files.length) {
res.status(400).json({ error: "Aucun fichier spécifié" }); res.status(400).json({ error: "Aucun fichier spécifié" });
@@ -143,6 +175,206 @@ async function startServer() {
archive.finalize(); archive.finalize();
}); });
// ============= AZURE AD OAUTH2 CALLBACK =============
app.get("/api/auth/azure/callback", async (req, res) => {
const code = req.query.code as string | undefined;
const error = req.query.error as string | undefined;
if (error) {
console.error("[Azure AD] Erreur OAuth:", error, req.query.error_description);
res.redirect(`/login?error=${encodeURIComponent("Connexion Microsoft refusée")}`);
return;
}
if (!code) {
res.redirect("/login?error=" + encodeURIComponent("Code OAuth manquant"));
return;
}
if (!isAzureAdConfigured()) {
res.redirect("/login?error=" + encodeURIComponent("Azure AD non configuré"));
return;
}
try {
const azureUser = await handleAzureCallback(code);
// Chercher l'utilisateur par azureAdId ou par email
let user = await getUserByAzureAdId(azureUser.azureAdId);
if (!user) {
user = await getUserByEmail(azureUser.email);
}
if (!user) {
// Créer l'utilisateur automatiquement
await upsertUser({
email: azureUser.email,
name: azureUser.name,
azureAdId: azureUser.azureAdId,
loginMethod: "azure-ad",
isActive: 1,
role: "user",
});
user = await getUserByEmail(azureUser.email);
} else {
// Mettre à jour l'azureAdId si manquant
if (!user.azureAdId) {
await upsertUser({
email: user.email,
azureAdId: azureUser.azureAdId,
loginMethod: user.loginMethod,
});
}
}
if (!user) {
res.redirect("/login?error=" + encodeURIComponent("Impossible de créer le compte"));
return;
}
if (user.isActive === 0) {
res.redirect("/login?error=" + encodeURIComponent("Compte inactif"));
return;
}
// Générer le token JWT et poser le cookie
const token = generateToken(user);
res.cookie("auth_token", token, {
...getSessionCookieOptions(req),
maxAge: 7 * 24 * 60 * 60 * 1000,
});
console.log(`[Azure AD] Connexion réussie pour ${user.email}`);
res.redirect("/");
} catch (err: any) {
console.error("[Azure AD] Erreur callback:", err.message);
res.redirect("/login?error=" + encodeURIComponent("Erreur d'authentification Microsoft"));
}
});
// ============= WEB IMPORT SOURCES - Endpoint pour script cron externe =============
// ============= DB BACKUP - Génération et téléchargement dump MySQL =============
app.post("/api/db-backup", async (req, res) => {
if (!requireAdmin(req, res)) return;
try {
const backupDir = path.resolve("backups");
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(backup.fileName);
res.setHeader("Content-Disposition", `attachment; filename="${encodedName}"; filename*=UTF-8''${encodedName}`);
res.setHeader("Content-Type", "application/sql");
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: "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) => {
if (!requireAdmin(req, res)) return;
const fileName = path.basename(req.params.filename);
const filePath = path.join(path.resolve("backups"), fileName);
if (!fs.existsSync(filePath)) { res.status(404).json({ error: "Fichier introuvable" }); return; }
const encodedName = encodeURIComponent(fileName);
res.setHeader("Content-Disposition", `attachment; filename="${encodedName}"; filename*=UTF-8''${encodedName}`);
res.setHeader("Content-Type", "application/sql");
res.sendFile(filePath);
});
app.post("/api/web-import/push-invoice", async (req, res) => {
try {
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 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');
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: 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, userSettings?.llmModel || "mistral-large-latest", undefined, aiSettings);
let imported = 0;
let duplicates = 0;
for (const inv of extractResult.invoices || []) {
const blacklisted = await isInvoiceBlacklisted(inv.invoiceNumber || null, source.userId);
if (blacklisted) { duplicates++; continue; }
const dup = await findDuplicateInvoice(inv.invoiceNumber || null, String(inv.totalAmount ?? ''), source.userId);
if (dup) { duplicates++; continue; }
await createInvoice({ ...inv, userId: source.userId, sourceFileId: sourceFile.id } as any);
imported++;
}
await updateWebImportSourceStatus(source.id, 'success', imported, true);
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: "Limport web a échoué. Consultez les journaux serveur." });
}
});
// tRPC API // tRPC API
app.use( app.use(
"/api/trpc", "/api/trpc",

View File

@@ -283,31 +283,45 @@ const normalizeResponseFormat = ({
/** /**
* Résout l'URL API en tenant compte des paramètres utilisateur (DB) en priorité sur les variables d'environnement. * Résout l'URL API en tenant compte des paramètres utilisateur (DB) en priorité sur les variables d'environnement.
*/ */
function resolveApiUrlWithSettings(settings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null; manusForgeApiUrl?: string | null }): string { function resolveApiUrlWithSettings(settings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null; manusForgeApiUrl?: string | null; geminiApiKey?: string | null }): string {
const provider = settings?.aiProvider || "mistral"; const provider = settings?.aiProvider || "mistral";
if (provider === "mistral") { if (provider === "mistral") {
return "https://api.mistral.ai/v1/chat/completions"; return "https://api.mistral.ai/v1/chat/completions";
} }
if (provider === "gemini") {
// Google AI Studio — interface compatible OpenAI
return "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions";
}
// Manus provider // Manus provider
const forgeUrl = settings?.manusForgeApiUrl || ENV.forgeApiUrl || "https://forge.manus.ai"; const forgeUrl = settings?.manusForgeApiUrl || ENV.forgeApiUrl || "https://forge.manus.ai";
return `${forgeUrl.replace(/\/$/, "")}/v1/chat/completions`; return `${forgeUrl.replace(/\/$/, "")}/v1/chat/completions`;
} }
function getApiKeyWithSettings(settings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null }): string { function getApiKeyWithSettings(settings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null; geminiApiKey?: string | null }): string {
const provider = settings?.aiProvider || "mistral"; const provider = settings?.aiProvider || "mistral";
if (provider === "mistral") { if (provider === "mistral") {
return settings?.mistralApiKey || ENV.mistralApiKey || ""; return settings?.mistralApiKey || ENV.mistralApiKey || "";
} }
if (provider === "gemini") {
return settings?.geminiApiKey || ENV.geminiApiKey || "";
}
return settings?.manusForgeApiKey || ENV.forgeApiKey || ""; return settings?.manusForgeApiKey || ENV.forgeApiKey || "";
} }
function getModelForProvider(provider: string): string {
if (provider === "mistral") return "mistral-large-latest";
if (provider === "gemini") return "gemini-2.0-flash";
// Manus Forge → Gemini 2.5 Flash via proxy
return "gemini-2.5-flash";
}
/** /**
* Version de invokeLLM qui accepte les paramètres utilisateur depuis la DB * Version de invokeLLM qui accepte les paramètres utilisateur depuis la DB
* pour choisir dynamiquement le moteur IA (Mistral ou Manus). * pour choisir dynamiquement le moteur IA (Mistral, Manus ou Gemini).
*/ */
export async function invokeLLMWithUserSettings( export async function invokeLLMWithUserSettings(
params: InvokeParams, params: InvokeParams,
userSettings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null; manusForgeApiUrl?: string | null } userSettings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null; manusForgeApiUrl?: string | null; geminiApiKey?: string | null }
): Promise<InvokeResult> { ): Promise<InvokeResult> {
const apiKey = getApiKeyWithSettings(userSettings); const apiKey = getApiKeyWithSettings(userSettings);
if (!apiKey || apiKey.trim().length === 0) { if (!apiKey || apiKey.trim().length === 0) {
@@ -315,7 +329,6 @@ export async function invokeLLMWithUserSettings(
} }
const provider = userSettings?.aiProvider || "mistral"; const provider = userSettings?.aiProvider || "mistral";
const isMistral = provider === "mistral";
const { const {
messages, messages,
@@ -328,7 +341,7 @@ export async function invokeLLMWithUserSettings(
response_format, response_format,
} = params; } = params;
const model = isMistral ? "mistral-large-latest" : "gemini-2.5-flash"; const model = getModelForProvider(provider);
const payload: Record<string, unknown> = { const payload: Record<string, unknown> = {
model, model,
@@ -342,7 +355,8 @@ export async function invokeLLMWithUserSettings(
payload.max_tokens = 32768; payload.max_tokens = 32768;
if (!isMistral) { // thinking uniquement pour Manus Forge (proxy Gemini)
if (provider === "manus") {
payload.thinking = { budget_tokens: 128 }; payload.thinking = { budget_tokens: 128 };
} }

View File

@@ -1,6 +1,7 @@
import { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const"; import { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
import type { Express, Request, Response } from "express"; import type { Express, Request, Response } from "express";
import * as db from "../db"; import * as db from "../db";
import { normalizeLoginMethod } from "../authMethod";
import { getSessionCookieOptions } from "./cookies"; import { getSessionCookieOptions } from "./cookies";
import { sdk } from "./sdk"; import { sdk } from "./sdk";
@@ -32,7 +33,7 @@ export function registerOAuthRoutes(app: Express) {
openId: userInfo.openId, openId: userInfo.openId,
name: userInfo.name || null, name: userInfo.name || null,
email: userInfo.email ?? "unknown@example.com", 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(), 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 { ForbiddenError } from "@shared/_core/errors";
import axios, { type AxiosInstance } from "axios"; import axios, { type AxiosInstance } from "axios";
import { parse as parseCookieHeader } from "cookie"; import { parse as parseCookieHeader } from "cookie";
import { normalizeLoginMethod } from "../authMethod";
import type { Request } from "express"; import type { Request } from "express";
import { SignJWT, jwtVerify } from "jose"; import { SignJWT, jwtVerify } from "jose";
import type { User } from "../../drizzle/schema"; import type { User } from "../../drizzle/schema";
@@ -296,7 +297,7 @@ class SDKServer {
openId: userInfo.openId, openId: userInfo.openId,
name: userInfo.name || null, name: userInfo.name || null,
email: userInfo.email ?? "unknown@example.com", 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, lastSignedIn: signedInAt,
}); });
user = await db.getUserByOpenId(userInfo.openId); user = await db.getUserByOpenId(userInfo.openId);

View File

@@ -1,6 +1,6 @@
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import { ConfidentialClientApplication } from "@azure/msal-node"; import { ConfidentialClientApplication } from "@azure/msal-node";
import { getUserByEmail, getUserByAzureAdId } from "./db"; import { getUserByEmail } from "./db";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
const SALT_ROUNDS = 10; 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,8 +43,11 @@ import {
InsertBapHistory, InsertBapHistory,
BapHistory, BapHistory,
invoiceLearnings, invoiceLearnings,
InsertInvoiceLearning, InvoiceLearning,
InvoiceLearning deletedInvoices,
webImportSources,
InsertWebImportSource,
WebImportSource
} from "../drizzle/schema"; } from "../drizzle/schema";
import { ENV } from './_core/env'; import { ENV } from './_core/env';
@@ -201,6 +204,22 @@ export async function createSourceFile(data: InsertSourceFile): Promise<SourceFi
return inserted[0]!; 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> { export async function getSourceFileById(id: number): Promise<SourceFile | undefined> {
const db = await getDb(); const db = await getDb();
if (!db) return undefined; if (!db) return undefined;
@@ -265,14 +284,44 @@ export async function updateInvoice(id: number, data: Partial<Invoice>) {
export async function deleteInvoice(id: number) { export async function deleteInvoice(id: number) {
const db = await getDb(); const db = await getDb();
if (!db) throw new Error("Database not available"); if (!db) throw new Error("Database not available");
// Enregistrer dans la blacklist avant suppression
const invoice = await db.select().from(invoices).where(eq(invoices.id, id)).limit(1);
if (invoice[0] && invoice[0].invoiceNumber) {
await db.insert(deletedInvoices).values({
userId: invoice[0].userId,
invoiceNumber: invoice[0].invoiceNumber,
totalAmount: invoice[0].totalAmount ?? undefined,
supplierName: invoice[0].supplierName ?? undefined,
}).onDuplicateKeyUpdate({ set: { deletedAt: new Date() } });
}
await db.delete(invoices).where(eq(invoices.id, id)); await db.delete(invoices).where(eq(invoices.id, id));
} }
export async function searchInvoices(userId: number, query: string): Promise<Invoice[]> { /** Vérifie si une facture est dans la blacklist (supprimée manuellement) */
export async function isInvoiceBlacklisted(
invoiceNumber: string | null,
userId: number
): Promise<boolean> {
if (!invoiceNumber) return false;
const db = await getDb();
if (!db) return false;
const result = await db.select().from(deletedInvoices)
.where(and(eq(deletedInvoices.userId, userId), eq(deletedInvoices.invoiceNumber, invoiceNumber)))
.limit(1);
return result.length > 0;
}
export async function searchInvoices(userId: number | null, query: string): Promise<Invoice[]> {
const db = await getDb(); const db = await getDb();
if (!db) return []; if (!db) return [];
const searchPattern = `%${query}%`; const searchPattern = `%${query}%`;
if (userId === null) {
// Admin : recherche globale sans filtre userId
return db.select().from(invoices)
.where(sql`(${invoices.supplierName} LIKE ${searchPattern} OR ${invoices.invoiceNumber} LIKE ${searchPattern})`)
.orderBy(desc(invoices.createdAt));
}
return db.select().from(invoices) return db.select().from(invoices)
.where( .where(
and( and(
@@ -485,11 +534,16 @@ export async function getAllImportLogs(): Promise<ImportLog[]> {
return db.select().from(importLogs).orderBy(desc(importLogs.importedAt)); return db.select().from(importLogs).orderBy(desc(importLogs.importedAt));
} }
export async function deleteAllImportLogs(userId: number): Promise<void> { export async function deleteAllImportLogs(userId: number | null): Promise<void> {
const db = await getDb(); const db = await getDb();
if (!db) throw new Error("Database not available"); if (!db) throw new Error("Database not available");
if (userId === null) {
// Admin : supprime tous les logs
await db.delete(importLogs);
} else {
await db.delete(importLogs).where(eq(importLogs.userId, userId)); await db.delete(importLogs).where(eq(importLogs.userId, userId));
} }
}
// ============= LLM LOG OPERATIONS ============= // ============= LLM LOG OPERATIONS =============
@@ -555,10 +609,10 @@ export async function upsertImportSettings(data: InsertImportSettings): Promise<
// ============= DEPARTMENT LIST OPERATIONS ============= // ============= DEPARTMENT LIST OPERATIONS =============
export async function getDepartmentsByUser(userId: number): Promise<Department[]> { export async function getDepartmentsByUser(_userId?: number): Promise<Department[]> {
const db = await getDb(); const db = await getDb();
if (!db) return []; if (!db) return [];
return await db.select().from(departmentList).where(eq(departmentList.userId, userId)).orderBy(departmentList.name); return await db.select().from(departmentList).orderBy(departmentList.name);
} }
export async function createDepartment(data: InsertDepartment): Promise<Department> { export async function createDepartment(data: InsertDepartment): Promise<Department> {
@@ -576,10 +630,10 @@ export async function deleteDepartment(id: number): Promise<void> {
// ============= ACCOUNTING ALLOCATION LIST OPERATIONS ============= // ============= ACCOUNTING ALLOCATION LIST OPERATIONS =============
export async function getAccountingAllocationsByUser(userId: number): Promise<AccountingAllocation[]> { export async function getAccountingAllocationsByUser(_userId?: number): Promise<AccountingAllocation[]> {
const db = await getDb(); const db = await getDb();
if (!db) return []; if (!db) return [];
return await db.select().from(accountingAllocationList).where(eq(accountingAllocationList.userId, userId)).orderBy(accountingAllocationList.name); return await db.select().from(accountingAllocationList).orderBy(accountingAllocationList.name);
} }
export async function createAccountingAllocation(data: InsertAccountingAllocation): Promise<AccountingAllocation> { export async function createAccountingAllocation(data: InsertAccountingAllocation): Promise<AccountingAllocation> {
@@ -731,10 +785,10 @@ export async function initializeDefaultLlmFields(userId: number): Promise<void>
// ============= SIGNATURES HELPERS ============= // ============= SIGNATURES HELPERS =============
export async function getSignaturesByUser(userId: number): Promise<Signature[]> { export async function getSignaturesByUser(_userId?: number): Promise<Signature[]> {
const db = await getDb(); const db = await getDb();
if (!db) return []; if (!db) return [];
return db.select().from(signatures).where(eq(signatures.userId, userId)); return db.select().from(signatures);
} }
export async function getSignatureById(id: number): Promise<Signature | undefined> { export async function getSignatureById(id: number): Promise<Signature | undefined> {
@@ -762,10 +816,10 @@ export async function deleteSignature(id: number): Promise<void> {
// ============= SERVICE SIGNATURES HELPERS ============= // ============= SERVICE SIGNATURES HELPERS =============
export async function getServiceSignaturesByUser(userId: number): Promise<ServiceSignature[]> { export async function getServiceSignaturesByUser(_userId?: number): Promise<ServiceSignature[]> {
const db = await getDb(); const db = await getDb();
if (!db) return []; if (!db) return [];
return db.select().from(serviceSignatures).where(eq(serviceSignatures.userId, userId)); return db.select().from(serviceSignatures);
} }
export async function getServiceSignatureByService(userId: number, serviceName: string): Promise<ServiceSignature | undefined> { export async function getServiceSignatureByService(userId: number, serviceName: string): Promise<ServiceSignature | undefined> {
@@ -1109,3 +1163,74 @@ export async function updateFreeproLastRun(
.set(update) .set(update)
.where(eq(freeproSettings.userId, userId)); .where(eq(freeproSettings.userId, userId));
} }
// ============= WEB IMPORT SOURCES =============
/** Génère un token API aléatoire de 64 caractères */
function generateApiToken(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let token = '';
for (let i = 0; i < 64; i++) {
token += chars.charAt(Math.floor(Math.random() * chars.length));
}
return token;
}
export async function getWebImportSourcesByUser(userId: number): Promise<WebImportSource[]> {
const db = await getDb();
if (!db) return [];
return db.select().from(webImportSources).where(eq(webImportSources.userId, userId)).orderBy(desc(webImportSources.createdAt));
}
export async function getWebImportSourceById(id: number): Promise<WebImportSource | undefined> {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(webImportSources).where(eq(webImportSources.id, id)).limit(1);
return result[0];
}
export async function getWebImportSourceByToken(token: string): Promise<WebImportSource | undefined> {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(webImportSources).where(eq(webImportSources.apiToken, token)).limit(1);
return result[0];
}
export async function createWebImportSource(data: Omit<InsertWebImportSource, 'apiToken'>): Promise<WebImportSource> {
const db = await getDb();
if (!db) throw new Error("Database not available");
const apiToken = generateApiToken();
const result = await db.insert(webImportSources).values({ ...data, apiToken });
const insertedId = Number(result[0].insertId);
const inserted = await db.select().from(webImportSources).where(eq(webImportSources.id, insertedId)).limit(1);
return inserted[0]!;
}
export async function updateWebImportSource(id: number, data: Partial<InsertWebImportSource>): Promise<void> {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.update(webImportSources).set({ ...data, updatedAt: new Date() }).where(eq(webImportSources.id, id));
}
export async function deleteWebImportSource(id: number): Promise<void> {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.delete(webImportSources).where(eq(webImportSources.id, id));
}
export async function updateWebImportSourceStatus(
id: number,
status: string,
importCount: number,
success: boolean
): Promise<void> {
const db = await getDb();
if (!db) return;
const update: Partial<InsertWebImportSource> = {
lastStatus: status,
lastImportCount: importCount,
updatedAt: new Date(),
};
if (success) update.lastSuccessAt = new Date();
await db.update(webImportSources).set(update).where(eq(webImportSources.id, id));
}

View File

@@ -5,12 +5,15 @@ import {
createSourceFile, createSourceFile,
updateSourceFile, updateSourceFile,
getUserSettings, getUserSettings,
getSourceFileByContentHash,
findDuplicateInvoice, findDuplicateInvoice,
isInvoiceBlacklisted,
createInvoice, createInvoice,
createImportLog, createImportLog,
} from "./db"; } from "./db";
import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor"; 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 { sendImportNotification } from "./notificationService";
import { getOffice365ImapToken, buildXOAuth2String } from "./office365OAuth"; import { getOffice365ImapToken, buildXOAuth2String } from "./office365OAuth";
import { applyAutomationRules } from "./automationEngine"; import { applyAutomationRules } from "./automationEngine";
@@ -31,6 +34,23 @@ interface EmailImportConfig {
// Store active intervals for each user // Store active intervals for each user
const activeIntervals = new Map<number, NodeJS.Timeout>(); 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) * Process a single email attachment (PDF)
@@ -49,6 +69,21 @@ async function processEmailAttachment(
const fileBuffer = attachment.content; const fileBuffer = attachment.content;
console.log(`[EmailImport] File size: ${fileBuffer.length} bytes`); 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 // Store source file
const sourceFileKey = generateStorageKey(userId, fileName); const sourceFileKey = generateStorageKey(userId, fileName);
console.log(`[EmailImport] Generated storage key: ${sourceFileKey}`); console.log(`[EmailImport] Generated storage key: ${sourceFileKey}`);
@@ -64,13 +99,25 @@ async function processEmailAttachment(
} }
// Create source file record // Create source file record
const sourceFile = await createSourceFile({ let sourceFile;
try {
sourceFile = await createSourceFile({
userId, userId,
fileName, fileName,
fileKey: sourceFileKey, fileKey: sourceFileKey,
fileUrl: sourceFileUrl, fileUrl: sourceFileUrl,
contentHash,
processingStatus: "processing", 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}`); console.log(`[EmailImport] Source file record created with ID: ${sourceFile.id}`);
@@ -92,6 +139,7 @@ async function processEmailAttachment(
mistralApiKey: settings.mistralApiKey, mistralApiKey: settings.mistralApiKey,
manusForgeApiKey: settings.manusForgeApiKey, manusForgeApiKey: settings.manusForgeApiKey,
manusForgeApiUrl: settings.manusForgeApiUrl, manusForgeApiUrl: settings.manusForgeApiUrl,
geminiApiKey: (settings as any).geminiApiKey || null,
} : undefined; } : undefined;
// Extract invoices // Extract invoices
@@ -129,6 +177,19 @@ async function processEmailAttachment(
processingProgress: `Extraction ${i + 1}/${result.invoiceCount} factures...`, processingProgress: `Extraction ${i + 1}/${result.invoiceCount} factures...`,
}); });
// Vérifier la blacklist (factures supprimées manuellement)
const blacklisted = await isInvoiceBlacklisted(invoiceData.invoiceNumber, userId);
if (blacklisted) {
duplicatesCount++;
duplicateDetails.push({
supplierName: invoiceData.supplierName,
invoiceNumber: invoiceData.invoiceNumber,
totalAmount: invoiceData.totalAmount,
reason: 'blacklisted',
});
continue;
}
// Check for duplicates (numéro de facture + montant) // Check for duplicates (numéro de facture + montant)
const duplicate = await findDuplicateInvoice( const duplicate = await findDuplicateInvoice(
invoiceData.invoiceNumber, invoiceData.invoiceNumber,
@@ -232,6 +293,7 @@ async function processEmailAttachment(
duplicateDetails: duplicateDetails.length > 0 ? JSON.stringify(duplicateDetails) : null, duplicateDetails: duplicateDetails.length > 0 ? JSON.stringify(duplicateDetails) : null,
errorDetails: errorDetails.length > 0 ? JSON.stringify(errorDetails) : null, errorDetails: errorDetails.length > 0 ? JSON.stringify(errorDetails) : null,
warningMessage, warningMessage,
importSource: "email",
}); });
console.log(`[EmailImport] Successfully processed attachment: ${fileName}`); console.log(`[EmailImport] Successfully processed attachment: ${fileName}`);
@@ -327,7 +389,7 @@ async function checkEmailsForPDFs(config: EmailImportConfig): Promise<void> {
imap.once("ready", () => { imap.once("ready", () => {
console.log(`[EmailImport] Connected to IMAP server for user ${config.userId} (mode: ${config.authMode || "basic"})`); console.log(`[EmailImport] Connected to IMAP server for user ${config.userId} (mode: ${config.authMode || "basic"})`);
openInbox((err, box) => { openInbox((err) => {
if (err) { if (err) {
console.error("[EmailImport] Error opening inbox:", err); console.error("[EmailImport] Error opening inbox:", err);
imap.end(); imap.end();
@@ -367,46 +429,40 @@ async function checkEmailsForPDFs(config: EmailImportConfig): Promise<void> {
markSeen: false, // Don't mark as seen yet markSeen: false, // Don't mark as seen yet
}); });
const processedEmails: number[] = []; const processedEmailUids: number[] = [];
const messageTasks: Promise<void>[] = [];
fetch.on("message", (msg, seqno) => { fetch.on("message", (msg, seqno) => {
msg.on("body", (stream) => { const uidPromise = new Promise<number>((resolveUid) => {
simpleParser(stream as any, async (err, parsed: ParsedMail) => { msg.once("attributes", (attributes) => resolveUid(attributes.uid));
if (err) { });
console.error("[EmailImport] Error parsing email:", err);
return;
}
// Check if email has PDF attachments msg.on("body", (stream) => {
const task = (async () => {
try {
const parsed: ParsedMail = await simpleParser(stream as any);
const pdfAttachments = parsed.attachments.filter( const pdfAttachments = parsed.attachments.filter(
(att) => (attachment) =>
att.contentType === "application/pdf" || attachment.contentType === "application/pdf" ||
att.filename?.toLowerCase().endsWith(".pdf") attachment.filename?.toLowerCase().endsWith(".pdf"),
); );
if (pdfAttachments.length === 0) { if (pdfAttachments.length === 0) return;
return;
}
console.log( console.log(
`[EmailImport] Email ${seqno} has ${pdfAttachments.length} PDF attachment(s)` `[EmailImport] Email ${seqno} has ${pdfAttachments.length} PDF attachment(s)`,
); );
// Process each PDF attachment let allAttachmentsSucceeded = true;
for (const attachment of pdfAttachments) { for (const attachment of pdfAttachments) {
try { try {
const result = await processEmailAttachment( const result = await processEmailAttachment(
config.userId, config.userId,
attachment, attachment,
parsed.subject || "No subject" parsed.subject || "No subject",
); );
allAttachmentsSucceeded = allAttachmentsSucceeded && result.success;
// Mark this email as successfully processed
if (!processedEmails.includes(seqno)) {
processedEmails.push(seqno);
}
// Send notification after successful processing
if (result.success) { if (result.success) {
await sendImportNotification(config.userId, { await sendImportNotification(config.userId, {
source: "email", source: "email",
@@ -418,13 +474,24 @@ async function checkEmailsForPDFs(config: EmailImportConfig): Promise<void> {
}); });
} }
} catch (error) { } catch (error) {
allAttachmentsSucceeded = false;
console.error( console.error(
`[EmailImport] Failed to process attachment from email ${seqno}:`, `[EmailImport] Failed to process attachment from email ${seqno}:`,
error 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);
}); });
}); });
@@ -434,16 +501,19 @@ async function checkEmailsForPDFs(config: EmailImportConfig): Promise<void> {
reject(err); reject(err);
}); });
fetch.once("end", () => { fetch.once("end", async () => {
console.log(`[EmailImport] Finished fetching emails for user ${config.userId}`); console.log(`[EmailImport] Finished fetching emails for user ${config.userId}`);
// Mark successfully processed emails as seen // Le flux IMAP peut se terminer avant les traitements IA asynchrones.
if (processedEmails.length > 0) { // On attend explicitement chaque message avant de le marquer comme lu.
imap.addFlags(processedEmails, ["\\Seen"], (err) => { await Promise.allSettled(messageTasks);
if (processedEmailUids.length > 0) {
imap.addFlags(processedEmailUids, ["\\Seen"], (err) => {
if (err) { if (err) {
console.error("[EmailImport] Error marking emails as seen:", err); console.error("[EmailImport] Error marking emails as seen:", err);
} else { } else {
console.log(`[EmailImport] Marked ${processedEmails.length} emails as seen`); console.log(`[EmailImport] Marked ${processedEmailUids.length} emails as seen`);
} }
imap.end(); imap.end();
resolve(); resolve();
@@ -582,13 +652,13 @@ export async function startEmailImportService(userId: number): Promise<boolean>
); );
// Run immediately on start // Run immediately on start
checkEmailsForPDFs(config).catch((error) => { runEmailCheckExclusive(config).catch((error) => {
console.error(`[EmailImport] Error checking emails for user ${userId}:`, error); console.error(`[EmailImport] Error checking emails for user ${userId}:`, error);
}); });
// Set up interval for periodic checks // Set up interval for periodic checks
const interval = setInterval(() => { const interval = setInterval(() => {
checkEmailsForPDFs(config).catch((error) => { runEmailCheckExclusive(config).catch((error) => {
console.error(`[EmailImport] Error checking emails for user ${userId}:`, error); console.error(`[EmailImport] Error checking emails for user ${userId}:`, error);
}); });
}, frequencyMs); }, frequencyMs);
@@ -654,7 +724,7 @@ export async function triggerEmailCheck(userId: number): Promise<{ success: bool
}; };
console.log(`[EmailImport] Manual check triggered for user ${userId}`); 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" }; return { success: true, message: "Vérification terminée avec succès" };
} catch (error: any) { } 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 { import {
getImportSettingsByUser, getImportSettingsByUser,
createSourceFile, createSourceFile,
getSourceFileByContentHash,
updateSourceFile, updateSourceFile,
getUserSettings, getUserSettings,
findDuplicateInvoice, findDuplicateInvoice,
@@ -11,7 +12,8 @@ import {
createImportLog, createImportLog,
} from "./db"; } from "./db";
import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor"; 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 { sendImportNotification } from "./notificationService";
interface FolderImportConfig { interface FolderImportConfig {
@@ -42,6 +44,13 @@ async function processFolderFile(
const fileBuffer = await fs.readFile(filePath); const fileBuffer = await fs.readFile(filePath);
console.log(`[FolderImport] File size: ${fileBuffer.length} bytes`); 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 // Store source file
const sourceFileKey = generateStorageKey(userId, fileName); const sourceFileKey = generateStorageKey(userId, fileName);
console.log(`[FolderImport] Generated storage key: ${sourceFileKey}`); console.log(`[FolderImport] Generated storage key: ${sourceFileKey}`);
@@ -57,13 +66,23 @@ async function processFolderFile(
} }
// Create source file record // Create source file record
const sourceFile = await createSourceFile({ let sourceFile;
try {
sourceFile = await createSourceFile({
userId, userId,
fileName, fileName,
fileKey: sourceFileKey, fileKey: sourceFileKey,
fileUrl: sourceFileUrl, fileUrl: sourceFileUrl,
contentHash,
processingStatus: "processing", 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}`); console.log(`[FolderImport] Source file record created with ID: ${sourceFile.id}`);
@@ -191,6 +210,7 @@ async function processFolderFile(
errors: errorsCount, errors: errorsCount,
duplicateDetails: duplicateDetails.length > 0 ? JSON.stringify(duplicateDetails) : null, duplicateDetails: duplicateDetails.length > 0 ? JSON.stringify(duplicateDetails) : null,
errorDetails: errorDetails.length > 0 ? JSON.stringify(errorDetails) : null, errorDetails: errorDetails.length > 0 ? JSON.stringify(errorDetails) : null,
importSource: "folder",
}); });
// Move file to processed folder // Move file to processed folder

View File

@@ -38,7 +38,6 @@ interface AutoImportResult {
// ── Constantes ───────────────────────────────────────────────────────────── // ── Constantes ─────────────────────────────────────────────────────────────
const FREEPRO_BASE_URL = "https://pro.free.fr"; 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/`; const LOGIN_FORM_URL = `${FREEPRO_BASE_URL}/espace-client/connexion/`;
// Endpoint réel capturé via analyse réseau du portail FreePro (XHR POST) // Endpoint réel capturé via analyse réseau du portail FreePro (XHR POST)
const DO_LOGIN_URL = `${FREEPRO_BASE_URL}/account/security/do_login`; const DO_LOGIN_URL = `${FREEPRO_BASE_URL}/account/security/do_login`;
@@ -77,17 +76,6 @@ function extractCookies(setCookieHeader: string | null): string {
.join("; "); .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 ─────────────────────────────────────────── // ── 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 { PDFDocument } from "pdf-lib";
import { createLlmLog } from "./db"; import { createLlmLog } from "./db";
import PDFParser from "pdf2json"; import PDFParser from "pdf2json";

View File

@@ -40,7 +40,7 @@ export function generateStorageKey(userId: number, fileName: string): string {
export async function localStoragePut( export async function localStoragePut(
fileKey: string, fileKey: string,
buffer: Buffer, buffer: Buffer,
contentType?: string _contentType?: string
): Promise<{ key: string; url: string }> { ): Promise<{ key: string; url: string }> {
try { try {
const fullPath = path.join(STORAGE_BASE_PATH, fileKey); 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 os from "os";
import * as path from "path"; import * as path from "path";
import * as fs from "fs/promises"; import * as fs from "fs/promises";
import * as fsSync from "fs";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);

View File

@@ -24,6 +24,7 @@ import {
searchInvoices, searchInvoices,
getInvoiceStats, getInvoiceStats,
createSourceFile, createSourceFile,
getSourceFileByContentHash,
getSourceFileById, getSourceFileById,
updateSourceFile, updateSourceFile,
getUserSettings, getUserSettings,
@@ -34,6 +35,7 @@ import {
toggleUserActive, toggleUserActive,
deleteUser, deleteUser,
findDuplicateInvoice, findDuplicateInvoice,
isInvoiceBlacklisted,
getAllInvoices, getAllInvoices,
getAllImportLogs, getAllImportLogs,
getAllBapHistory, getAllBapHistory,
@@ -76,9 +78,17 @@ import {
deleteLearning, deleteLearning,
deleteAllLearnings, deleteAllLearnings,
getBapPdfUrlsByInvoiceIds, getBapPdfUrlsByInvoiceIds,
getWebImportSourcesByUser,
getWebImportSourceById,
createWebImportSource,
updateWebImportSource,
deleteWebImportSource,
} from "./db"; } from "./db";
import { loginLocal, hashPassword, getAzureAuthUrl, isAzureAdConfigured, generateToken } from "./auth"; import { loginLocal, hashPassword, getAzureAuthUrl, isAzureAdConfigured } from "./auth";
import fsSync from "fs";
import pathSync from "path";
import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor"; import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor";
import { calculateFileSha256 } from "./fileFingerprint";
import { localStoragePut, generateStorageKey } from "./localStorage"; import { localStoragePut, generateStorageKey } from "./localStorage";
import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport"; import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport";
import { drawBapCartouche } from "./bapCartouche"; import { drawBapCartouche } from "./bapCartouche";
@@ -174,6 +184,17 @@ export const appRouter = router({
const fileBuffer = Buffer.from(input.fileData, "base64"); const fileBuffer = Buffer.from(input.fileData, "base64");
console.log(`[Upload] Received file: ${input.fileName}, size: ${fileBuffer.length} bytes`); 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 // Store source file
const sourceFileKey = generateStorageKey(userId, input.fileName); const sourceFileKey = generateStorageKey(userId, input.fileName);
console.log(`[Upload] Generated storage key: ${sourceFileKey}`); console.log(`[Upload] Generated storage key: ${sourceFileKey}`);
@@ -194,6 +215,7 @@ export const appRouter = router({
fileName: input.fileName, fileName: input.fileName,
fileKey: sourceFileKey, fileKey: sourceFileKey,
fileUrl: sourceFileUrl, fileUrl: sourceFileUrl,
contentHash,
processingStatus: "processing", processingStatus: "processing",
}); });
@@ -218,6 +240,7 @@ export const appRouter = router({
mistralApiKey: (settings as any).mistralApiKey || null, mistralApiKey: (settings as any).mistralApiKey || null,
manusForgeApiKey: (settings as any).manusForgeApiKey || null, manusForgeApiKey: (settings as any).manusForgeApiKey || null,
manusForgeApiUrl: (settings as any).manusForgeApiUrl || null, manusForgeApiUrl: (settings as any).manusForgeApiUrl || null,
geminiApiKey: (settings as any).geminiApiKey || null,
} : undefined; } : undefined;
// Extract invoices // Extract invoices
@@ -252,6 +275,19 @@ export const appRouter = router({
processingProgress: `Extraction ${i + 1}/${result.invoiceCount} factures...`, processingProgress: `Extraction ${i + 1}/${result.invoiceCount} factures...`,
}); });
// Vérifier la blacklist (factures supprimées manuellement)
const blacklisted = await isInvoiceBlacklisted(invoiceData.invoiceNumber, userId);
if (blacklisted) {
duplicatesCount++;
duplicateDetails.push({
supplierName: invoiceData.supplierName,
invoiceNumber: invoiceData.invoiceNumber,
totalAmount: invoiceData.totalAmount,
reason: 'blacklisted',
});
continue;
}
// Check for duplicates (numéro de facture + montant) // Check for duplicates (numéro de facture + montant)
const duplicate = await findDuplicateInvoice( const duplicate = await findDuplicateInvoice(
invoiceData.invoiceNumber, invoiceData.invoiceNumber,
@@ -370,6 +406,7 @@ export const appRouter = router({
errors: errorsCount, errors: errorsCount,
duplicateDetails: JSON.stringify(duplicateDetails), duplicateDetails: JSON.stringify(duplicateDetails),
errorDetails: JSON.stringify(errorDetails), errorDetails: JSON.stringify(errorDetails),
importSource: "file",
}); });
} catch (error: any) { } catch (error: any) {
@@ -398,7 +435,7 @@ export const appRouter = router({
.input(z.object({ id: z.number() })) .input(z.object({ id: z.number() }))
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
const invoice = await getInvoiceById(input.id); const invoice = await getInvoiceById(input.id);
if (!invoice || invoice.userId !== ctx.user.id) { if (!invoice || (ctx.user.role !== 'admin' && invoice.userId !== ctx.user.id)) {
throw new TRPCError({ code: "NOT_FOUND" }); throw new TRPCError({ code: "NOT_FOUND" });
} }
return invoice; return invoice;
@@ -424,7 +461,7 @@ export const appRouter = router({
})) }))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const invoice = await getInvoiceById(input.id); const invoice = await getInvoiceById(input.id);
if (!invoice || invoice.userId !== ctx.user.id) { if (!invoice || (ctx.user.role !== 'admin' && invoice.userId !== ctx.user.id)) {
throw new TRPCError({ code: "NOT_FOUND" }); throw new TRPCError({ code: "NOT_FOUND" });
} }
@@ -440,7 +477,7 @@ export const appRouter = router({
.input(z.object({ id: z.number() })) .input(z.object({ id: z.number() }))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const invoice = await getInvoiceById(input.id); const invoice = await getInvoiceById(input.id);
if (!invoice || invoice.userId !== ctx.user.id) { if (!invoice || (ctx.user.role !== 'admin' && invoice.userId !== ctx.user.id)) {
throw new TRPCError({ code: "NOT_FOUND" }); throw new TRPCError({ code: "NOT_FOUND" });
} }
@@ -452,7 +489,7 @@ export const appRouter = router({
.input(z.object({ id: z.number() })) .input(z.object({ id: z.number() }))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const invoice = await getInvoiceById(input.id); const invoice = await getInvoiceById(input.id);
if (!invoice || invoice.userId !== ctx.user.id) { if (!invoice || (ctx.user.role !== 'admin' && invoice.userId !== ctx.user.id)) {
throw new TRPCError({ code: "NOT_FOUND" }); throw new TRPCError({ code: "NOT_FOUND" });
} }
// Vérifier les critères de validation BAP // Vérifier les critères de validation BAP
@@ -665,7 +702,7 @@ export const appRouter = router({
let processed = 0; let processed = 0;
for (const id of input.invoiceIds) { for (const id of input.invoiceIds) {
const invoice = await getInvoiceById(id); const invoice = await getInvoiceById(id);
if (!invoice || invoice.userId !== ctx.user.id) continue; if (!invoice || (ctx.user.role !== 'admin' && invoice.userId !== ctx.user.id)) continue;
await updateInvoice(id, { await updateInvoice(id, {
bapValidated: 0, bapValidated: 0,
bapValidatedAt: null, bapValidatedAt: null,
@@ -679,7 +716,7 @@ export const appRouter = router({
// ── Validation BAP en masse ────────────────────────────── // ── Validation BAP en masse ──────────────────────────────
validateBAPBulk: protectedProcedure validateBAPBulk: protectedProcedure
.mutation(async ({ ctx }) => { .mutation(async ({ ctx }) => {
const allInvoices = await getInvoicesByUser(ctx.user.id); const allInvoices = ctx.user.role === 'admin' ? await getAllInvoices() : await getInvoicesByUser(ctx.user.id);
// Filtrer les factures éligibles (non déjà validées) // Filtrer les factures éligibles (non déjà validées)
const eligible = allInvoices.filter((inv: any) => const eligible = allInvoices.filter((inv: any) =>
(inv.qualityScore || 0) >= 100 && (inv.qualityScore || 0) >= 100 &&
@@ -861,7 +898,7 @@ export const appRouter = router({
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
// Relance UNIQUEMENT les automatismes (sans re-extraction LLM) // Relance UNIQUEMENT les automatismes (sans re-extraction LLM)
const { applyAutomationRules } = await import("./automationEngine"); const { applyAutomationRules } = await import("./automationEngine");
const allInvoices = await getInvoicesByUser(ctx.user.id); const allInvoices = ctx.user.role === 'admin' ? await getAllInvoices() : await getInvoicesByUser(ctx.user.id);
const selected = allInvoices.filter(inv => input.invoiceIds.includes(inv.id)); const selected = allInvoices.filter(inv => input.invoiceIds.includes(inv.id));
if (selected.length === 0) throw new TRPCError({ code: 'NOT_FOUND', message: 'Aucune facture trouvée' }); if (selected.length === 0) throw new TRPCError({ code: 'NOT_FOUND', message: 'Aucune facture trouvée' });
@@ -887,10 +924,121 @@ export const appRouter = router({
return { success: true, processed, errors, results }; return { success: true, processed, errors, results };
}), }),
// ── Régénérer le PDF BAP pour une facture validée sans PDF ─────────────────
regenerateBapPdf: protectedProcedure
.input(z.object({ invoiceId: z.number() }))
.mutation(async ({ input, ctx }) => {
const invoice = await getInvoiceById(input.invoiceId);
if (!invoice || (ctx.user.role !== 'admin' && invoice.userId !== ctx.user.id)) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Facture introuvable' });
}
if (!invoice.bapValidated) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'La facture n\'est pas validée BAP' });
}
const fs = await import('fs/promises');
const path = await import('path');
const { PDFDocument } = await import('pdf-lib');
const { localStoragePut, generateStorageKey } = await import('./localStorage');
const STORAGE_BASE_PATH = process.env.STORAGE_BASE_PATH || path.join(process.cwd(), 'storage');
if (!invoice.fileKey && !invoice.fileUrl) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Fichier PDF source introuvable' });
}
let pdfBytes: Buffer;
const sourcePath = path.join(STORAGE_BASE_PATH, invoice.fileKey || '');
try {
pdfBytes = await fs.readFile(sourcePath);
} catch (_) {
const fileUrl = invoice.fileUrl;
if (!fileUrl) throw new TRPCError({ code: 'BAD_REQUEST', message: 'Fichier PDF source introuvable' });
let absoluteUrl = fileUrl;
if (fileUrl.startsWith('/')) {
const baseUrl = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
absoluteUrl = `${baseUrl}${fileUrl}`;
}
const response = await fetch(absoluteUrl);
if (!response.ok) throw new TRPCError({ code: 'BAD_REQUEST', message: 'Impossible de télécharger le PDF source' });
pdfBytes = Buffer.from(await response.arrayBuffer());
}
const pdfDoc = await PDFDocument.load(pdfBytes);
// Récupération de la signature du service
let sigBytesForCartouche: Buffer | undefined;
let sigMimeForCartouche: 'image/png' | 'image/jpeg' | undefined;
let signatureName: string | null = null;
if (invoice.serviceConcerne) {
const serviceAssociations = await getServiceSignaturesByUser(ctx.user.id);
const assoc = serviceAssociations.find(a => a.serviceName.toLowerCase() === (invoice.serviceConcerne || '').toLowerCase());
if (assoc) {
const sig = await getSignatureById(assoc.signatureId);
if (sig) {
signatureName = `${sig.firstName} ${sig.lastName}`;
try {
const sigImagePath = path.join(STORAGE_BASE_PATH, sig.imageKey);
try { sigBytesForCartouche = await fs.readFile(sigImagePath); } catch (_) {
const sigUrl = sig.imageUrl;
if (sigUrl) {
let absoluteSigUrl = sigUrl;
if (sigUrl.startsWith('/')) {
const baseUrl = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
absoluteSigUrl = `${baseUrl}${sigUrl}`;
}
const sigResp = await fetch(absoluteSigUrl);
if (sigResp.ok) sigBytesForCartouche = Buffer.from(await sigResp.arrayBuffer());
}
}
sigMimeForCartouche = sig.imageKey.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg';
} catch (_) { /* ignore */ }
}
}
}
// Placement du cartouche BAP
const validatedAt = invoice.bapValidatedAt ? new Date(invoice.bapValidatedAt) : new Date();
await drawBapCartouche(pdfDoc, pdfBytes, {
typeAchat: invoice.typeAchat || 'N/A',
destinataire: (invoice as any).recipientName || 'TOUS',
serviceConcerne: invoice.serviceConcerne || '-',
ventilationComptable: invoice.ventilationComptable || '-',
validatedAt,
signatureImageBytes: sigBytesForCartouche,
signatureMimeType: sigMimeForCartouche,
signatureName: signatureName || undefined,
});
const signedPdfBytes = await pdfDoc.save();
const _bapDateStr = invoice.invoiceDate ? new Date(invoice.invoiceDate).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
const _bapSupplier = (invoice.supplierName || 'Fournisseur').replace(/[^a-zA-Z0-9\u00e0-\u00ff \-]/g, '').trim();
const _bapNumber = (invoice.invoiceNumber || '').replace(/[^a-zA-Z0-9\-]/g, '').trim();
const bapFilename = [_bapDateStr, _bapSupplier, _bapNumber].filter(Boolean).join(' - ') + '.pdf';
const bapKey = generateStorageKey(ctx.user.id, bapFilename);
const { url } = await localStoragePut(bapKey, Buffer.from(signedPdfBytes), 'application/pdf');
// Mettre à jour la dernière entrée bapHistory de cette facture avec le nouveau pdfUrl
const allEntries = ctx.user.role === 'admin' ? await getAllBapHistory() : await getBapHistoryByUser(ctx.user.id);
const latestEntry = allEntries
.filter(e => e.invoiceId === input.invoiceId)
.sort((a, b) => new Date(b.validatedAt).getTime() - new Date(a.validatedAt).getTime())[0];
if (latestEntry) {
await updateBapHistoryPdfUrl(latestEntry.id, url);
}
// Mettre à jour le statut export de la facture
await updateInvoice(input.invoiceId, { exportStatus: 'exported' as any });
return { success: true, pdfUrl: url };
}),
// // ── Historique BAP ──────────────────── // // ── Historique BAP ────────────────────
search: protectedProcedure search: protectedProcedure
.input(z.object({ query: z.string() })) .input(z.object({ query: z.string() }))
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
if (ctx.user.role === 'admin') {
return searchInvoices(null, input.query);
}
return searchInvoices(ctx.user.id, input.query); return searchInvoices(ctx.user.id, input.query);
}), }),
@@ -911,7 +1059,7 @@ export const appRouter = router({
delete: protectedProcedure delete: protectedProcedure
.input(z.object({ id: z.number() })) .input(z.object({ id: z.number() }))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const entries = await getBapHistoryByUser(ctx.user.id); const entries = ctx.user.role === 'admin' ? await getAllBapHistory() : await getBapHistoryByUser(ctx.user.id);
const entry = entries.find(e => e.id === input.id); const entry = entries.find(e => e.id === input.id);
if (!entry) throw new TRPCError({ code: 'NOT_FOUND' }); if (!entry) throw new TRPCError({ code: 'NOT_FOUND' });
await deleteBapHistoryEntry(input.id); await deleteBapHistoryEntry(input.id);
@@ -922,12 +1070,12 @@ export const appRouter = router({
regenerate: protectedProcedure regenerate: protectedProcedure
.input(z.object({ id: z.number() })) .input(z.object({ id: z.number() }))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const entries = await getBapHistoryByUser(ctx.user.id); const entries = ctx.user.role === 'admin' ? await getAllBapHistory() : await getBapHistoryByUser(ctx.user.id);
const entry = entries.find(e => e.id === input.id); const entry = entries.find(e => e.id === input.id);
if (!entry) throw new TRPCError({ code: 'NOT_FOUND' }); if (!entry) throw new TRPCError({ code: 'NOT_FOUND' });
const invoice = await getInvoiceById(entry.invoiceId); const invoice = await getInvoiceById(entry.invoiceId);
if (!invoice || invoice.userId !== ctx.user.id) { if (!invoice || (ctx.user.role !== 'admin' && invoice.userId !== ctx.user.id)) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Facture source introuvable' }); throw new TRPCError({ code: 'NOT_FOUND', message: 'Facture source introuvable' });
} }
@@ -1059,10 +1207,11 @@ export const appRouter = router({
sftpAutoExport: z.number().optional(), sftpAutoExport: z.number().optional(),
llmLogsRetentionMonths: z.number().optional(), llmLogsRetentionMonths: z.number().optional(),
learningConfidenceThreshold: z.number().min(1).optional(), learningConfidenceThreshold: z.number().min(1).optional(),
aiProvider: z.enum(["mistral", "manus"]).optional(), aiProvider: z.enum(["mistral", "manus", "gemini"]).optional(),
mistralApiKey: z.string().optional(), mistralApiKey: z.string().optional(),
manusForgeApiKey: z.string().optional(), manusForgeApiKey: z.string().optional(),
manusForgeApiUrl: z.string().optional(), manusForgeApiUrl: z.string().optional(),
geminiApiKey: z.string().optional(),
})) }))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
await upsertUserSettings({ await upsertUserSettings({
@@ -1141,7 +1290,7 @@ export const appRouter = router({
); );
const validInvoices = invoices.filter( const validInvoices = invoices.filter(
inv => inv && inv.userId === ctx.user.id inv => inv && (ctx.user.role === 'admin' || inv.userId === ctx.user.id)
); );
// Return invoice data for Excel generation on client side // Return invoice data for Excel generation on client side
@@ -1192,7 +1341,7 @@ export const appRouter = router({
); );
const invalidInvoices = invoices.filter( const invalidInvoices = invoices.filter(
inv => !inv || inv.userId !== ctx.user.id || (inv.qualityScore || 0) < 100 inv => !inv || (ctx.user.role !== 'admin' && inv.userId !== ctx.user.id) || (inv.qualityScore || 0) < 100
); );
if (invalidInvoices.length > 0) { if (invalidInvoices.length > 0) {
@@ -1451,7 +1600,7 @@ export const appRouter = router({
for (const invoiceId of input.invoiceIds) { for (const invoiceId of input.invoiceIds) {
try { try {
const invoice = await getInvoiceById(invoiceId); const invoice = await getInvoiceById(invoiceId);
if (!invoice || invoice.userId !== ctx.user.id) { if (!invoice || (ctx.user.role !== 'admin' && invoice.userId !== ctx.user.id)) {
errorCount++; errorCount++;
continue; continue;
} }
@@ -1500,7 +1649,12 @@ export const appRouter = router({
}), }),
deleteAll: protectedProcedure.mutation(async ({ ctx }) => { deleteAll: protectedProcedure.mutation(async ({ ctx }) => {
// Les admins suppriment tous les logs, les utilisateurs standard suppriment les leurs
if (ctx.user.role === 'admin') {
await deleteAllImportLogs(null);
} else {
await deleteAllImportLogs(ctx.user.id); await deleteAllImportLogs(ctx.user.id);
}
return { success: true }; return { success: true };
}), }),
}), }),
@@ -2476,5 +2630,96 @@ export const appRouter = router({
return { success: true, webUrl: result.webUrl, fileName }; return { success: true, webUrl: result.webUrl, fileName };
}), }),
}), }),
// ============= WEB IMPORT SOURCES =============
webImportSources: router({
list: protectedProcedure.query(async ({ ctx }) => {
return getWebImportSourcesByUser(ctx.user.id);
}),
create: protectedProcedure
.input(z.object({
name: z.string().min(1).max(100),
connectorType: z.string().min(1).max(50),
portalUrl: z.string().url(),
loginEmail: z.string().min(1),
loginPassword: z.string().min(1),
frequency: z.enum(['manual', 'daily', 'weekly', 'monthly']).default('monthly'),
autoEnabled: z.number().min(0).max(1).default(0),
}))
.mutation(async ({ input, ctx }) => {
return createWebImportSource({ ...input, userId: ctx.user.id });
}),
update: protectedProcedure
.input(z.object({
id: z.number(),
name: z.string().min(1).max(100).optional(),
connectorType: z.string().min(1).max(50).optional(),
portalUrl: z.string().url().optional(),
loginEmail: z.string().min(1).optional(),
loginPassword: z.string().optional(),
frequency: z.enum(['manual', 'daily', 'weekly', 'monthly']).optional(),
autoEnabled: z.number().min(0).max(1).optional(),
}))
.mutation(async ({ input, ctx }) => {
const source = await getWebImportSourceById(input.id);
if (!source || source.userId !== ctx.user.id) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Source introuvable' });
}
const { id, ...data } = input;
await updateWebImportSource(id, data);
return { success: true };
}),
delete: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input, ctx }) => {
const source = await getWebImportSourceById(input.id);
if (!source || source.userId !== ctx.user.id) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Source introuvable' });
}
await deleteWebImportSource(input.id);
return { success: true };
}),
getToken: protectedProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input, ctx }) => {
const source = await getWebImportSourceById(input.id);
if (!source || source.userId !== ctx.user.id) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Source introuvable' });
}
return { apiToken: source.apiToken };
}),
}),
// ============= BACKUP ROUTES =============
backup: router({
// Liste les sauvegardes existantes dans le dossier backups/
list: adminProcedure.query(async () => {
const backupDir = pathSync.resolve("backups");
if (!fsSync.existsSync(backupDir)) return [];
const files = fsSync.readdirSync(backupDir)
.filter(f => f.endsWith(".sql") || f.endsWith(".sql.gz"))
.map(f => {
const stat = fsSync.statSync(pathSync.join(backupDir, f));
return { name: f, size: stat.size, createdAt: stat.mtime };
})
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
return files;
}),
// Supprime une sauvegarde
delete: adminProcedure
.input(z.object({ name: z.string() }))
.mutation(async ({ input }) => {
const backupDir = pathSync.resolve("backups");
const filePath = pathSync.join(backupDir, pathSync.basename(input.name));
if (!fsSync.existsSync(filePath)) throw new TRPCError({ code: 'NOT_FOUND', message: 'Fichier introuvable' });
fsSync.unlinkSync(filePath);
return { success: true };
}),
}),
}); });
export type AppRouter = typeof appRouter; export type AppRouter = typeof appRouter;

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()}`);

50
todo.md
View File

@@ -667,3 +667,53 @@
- [x] Job cron WebDev : vérification périodique selon fréquence configurée (setInterval en mémoire) - [x] Job cron WebDev : vérification périodique selon fréquence configurée (setInterval en mémoire)
- [x] Frontend VentilationFreePro.tsx : wrapper Tabs (onglet 1 = import manuel, onglet 2 = paramétrage) - [x] Frontend VentilationFreePro.tsx : wrapper Tabs (onglet 1 = import manuel, onglet 2 = paramétrage)
- [x] Onglet Paramétrage : formulaire credentials FreePro web, fréquence, date antériorité, bouton "Forcer récupération", statut dernière récupération - [x] Onglet Paramétrage : formulaire credentials FreePro web, fréquence, date antériorité, bouton "Forcer récupération", statut dernière récupération
## Colonne Source import dans l'historique des imports
- [x] Ajouter colonne importSource (file/folder/email) dans la table importLogs (schéma + migration)
- [x] Enregistrer importSource="file" lors d'un import manuel (routers.ts)
- [x] Enregistrer importSource="email" lors d'un import par email (emailImportService.ts)
- [x] Enregistrer importSource="folder" lors d'un import par dossier (folderImportService.ts)
- [x] Afficher la colonne "Source" avec badge coloré dans le tableau History.tsx (Email=bleu, Dossier=violet, Fichier=gris)
- [x] Afficher la source dans le dialog de détails de l'import
## Listes globales partagées entre tous les utilisateurs
- [ ] Modifier getAccountingAllocationsByUser → retourner toutes les ventilations (sans filtre userId)
- [ ] Modifier getAutomationRulesByUser → retourner tous les automatismes (sans filtre userId)
- [ ] Modifier getDepartmentsByUser → retourner tous les services (sans filtre userId)
- [ ] Modifier getSignaturesByUser → retourner toutes les signatures (sans filtre userId)
- [ ] Modifier getServiceSignaturesByUser → retourner toutes les signatures de service (sans filtre userId)
- [ ] Migrer les données en production : dédoublonner les listes fusionnées
- [ ] Déployer en production
## Connecteurs web (scraping login/mdp)
- [ ] Table webImportSources dans le schéma DB
- [ ] Procédures tRPC CRUD pour webImportSources
- [ ] Interface de gestion des sources web dans les paramètres
- [ ] 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 tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import fs from "node:fs";
import path from "path"; import path from "path";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import { vitePluginManusRuntime } from "vite-plugin-manus-runtime"; import { vitePluginManusRuntime } from "vite-plugin-manus-runtime";
const plugins = [react(), tailwindcss(), jsxLocPlugin(), vitePluginManusRuntime()]; const plugins = [react(), tailwindcss(), vitePluginManusRuntime()];
export default defineConfig({ export default defineConfig({
plugins, plugins,