-
- {/* Example: lucide-react for icons */}
-
- Example Page
- {/* Example: Streamdown for markdown rendering */}
- Any **markdown** content
-
-
+
+
+
+
+
+
Invoice Analyzer
+
Dématérialisation de la facturation
+
+
);
}
diff --git a/client/src/pages/Invoices.tsx b/client/src/pages/Invoices.tsx
new file mode 100644
index 0000000..f931644
--- /dev/null
+++ b/client/src/pages/Invoices.tsx
@@ -0,0 +1,169 @@
+import { useState } from "react";
+import DashboardLayout from "@/components/DashboardLayout";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Badge } from "@/components/ui/badge";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { trpc } from "@/lib/trpc";
+import { Search, Eye, Trash2, FileText } from "lucide-react";
+import { toast } from "sonner";
+import { useLocation } from "wouter";
+
+export default function Invoices() {
+ const [, setLocation] = useLocation();
+ const [searchQuery, setSearchQuery] = useState("");
+ const { data: invoices, isLoading } = trpc.invoices.list.useQuery();
+ const utils = trpc.useUtils();
+
+ const deleteMutation = trpc.invoices.delete.useMutation({
+ onSuccess: () => {
+ toast.success("Facture supprimée");
+ utils.invoices.list.invalidate();
+ },
+ onError: (error) => {
+ toast.error(error.message || "Erreur lors de la suppression");
+ },
+ });
+
+ const filteredInvoices = invoices?.filter((inv) => {
+ if (!searchQuery) return true;
+ const query = searchQuery.toLowerCase();
+ return (
+ inv.supplierName?.toLowerCase().includes(query) ||
+ inv.invoiceNumber?.toLowerCase().includes(query)
+ );
+ });
+
+ const handleDelete = (id: number) => {
+ if (confirm("Êtes-vous sûr de vouloir supprimer cette facture ?")) {
+ deleteMutation.mutate({ id });
+ }
+ };
+
+ const getStatusBadge = (status: string) => {
+ switch (status) {
+ case "completed":
+ return
Complété;
+ case "processing":
+ return
En cours;
+ case "error":
+ return
Erreur;
+ default:
+ return
{status};
+ }
+ };
+
+ const getQualityBadge = (score: number | null) => {
+ if (score === null) return
-;
+ if (score >= 80) return
{score};
+ if (score >= 60) return
{score};
+ return
{score};
+ };
+
+ return (
+
+
+
+
+
Factures
+
Gérez toutes vos factures importées
+
+
+
+
+
+
+ Liste des factures
+
+
+
+ setSearchQuery(e.target.value)}
+ className="max-w-md"
+ />
+
+
+
+
+ {isLoading ? (
+ Chargement...
+ ) : filteredInvoices && filteredInvoices.length > 0 ? (
+
+
+
+ Fournisseur
+ N° Facture
+ Date
+ Montant
+ Score
+ Statut
+ Actions
+
+
+
+ {filteredInvoices.map((invoice) => (
+
+
+ {invoice.supplierName || "Inconnu"}
+
+ {invoice.invoiceNumber || "-"}
+
+ {invoice.invoiceDate
+ ? new Date(invoice.invoiceDate).toLocaleDateString("fr-FR")
+ : "-"}
+
+
+ {invoice.totalAmount
+ ? `${parseFloat(invoice.totalAmount).toFixed(2)} €`
+ : "-"}
+
+ {getQualityBadge(invoice.qualityScore)}
+ {getStatusBadge(invoice.status)}
+
+
+
+
+
+
+
+ ))}
+
+
+ ) : (
+
+
+
Aucune facture trouvée
+
+ )}
+
+
+
+
+ );
+}
diff --git a/client/src/pages/Login.tsx b/client/src/pages/Login.tsx
new file mode 100644
index 0000000..f7cb642
--- /dev/null
+++ b/client/src/pages/Login.tsx
@@ -0,0 +1,147 @@
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Loader2, FileText } from "lucide-react";
+import { trpc } from "@/lib/trpc";
+import { toast } from "sonner";
+import { useLocation } from "wouter";
+import { getLoginUrl } from "@/const";
+
+export default function Login() {
+ const [, setLocation] = useLocation();
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [showLocalLogin, setShowLocalLogin] = useState(false);
+
+ const loginMutation = trpc.auth.loginLocal.useMutation({
+ onSuccess: () => {
+ toast.success("Connexion réussie");
+ window.location.href = "/";
+ },
+ onError: (error) => {
+ toast.error(error.message || "Erreur de connexion");
+ },
+ });
+
+ const { data: azureAdAvailable } = trpc.auth.isAzureAdAvailable.useQuery();
+
+ const handleLocalLogin = (e: React.FormEvent) => {
+ e.preventDefault();
+ loginMutation.mutate({ email, password });
+ };
+
+ const handleManusLogin = () => {
+ window.location.href = getLoginUrl();
+ };
+
+ const handleAzureLogin = () => {
+ // TODO: Implement Azure AD login flow
+ toast.info("Azure AD login coming soon");
+ };
+
+ if (!showLocalLogin) {
+ return (
+
+
+
+
+
+
+ Invoice Analyzer
+ Choisissez votre méthode de connexion
+
+
+
+
+ {azureAdAvailable?.available && (
+
+ )}
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ Connexion locale
+ Connectez-vous avec votre email et mot de passe
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/pages/Settings.tsx b/client/src/pages/Settings.tsx
new file mode 100644
index 0000000..9ff5c8a
--- /dev/null
+++ b/client/src/pages/Settings.tsx
@@ -0,0 +1,319 @@
+import { useState, useEffect } from "react";
+import DashboardLayout from "@/components/DashboardLayout";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { Switch } from "@/components/ui/switch";
+import { trpc } from "@/lib/trpc";
+import { Loader2, Save, CheckCircle } from "lucide-react";
+import { toast } from "sonner";
+
+export default function Settings() {
+ const { data: settings, isLoading } = trpc.settings.get.useQuery();
+ const utils = trpc.useUtils();
+
+ const [llmModel, setLlmModel] = useState("");
+ const [invoiceNumberKeywords, setInvoiceNumberKeywords] = useState("");
+ const [deliveryNoteKeywords, setDeliveryNoteKeywords] = useState("");
+ const [orderNumberKeywords, setOrderNumberKeywords] = useState("");
+ const [supplierKeywords, setSupplierKeywords] = useState("");
+ const [totalAmountKeywords, setTotalAmountKeywords] = useState("");
+ const [sftpHost, setSftpHost] = useState("");
+ const [sftpPort, setSftpPort] = useState(22);
+ const [sftpUsername, setSftpUsername] = useState("");
+ const [sftpPassword, setSftpPassword] = useState("");
+ const [sftpRemotePath, setSftpRemotePath] = useState("/");
+ const [sftpAutoExport, setSftpAutoExport] = useState(false);
+ const [llmLogsRetentionMonths, setLlmLogsRetentionMonths] = useState(3);
+
+ useEffect(() => {
+ if (settings) {
+ setLlmModel(settings.llmModel || "mistral-large-latest");
+ setInvoiceNumberKeywords(settings.invoiceNumberKeywords || "");
+ setDeliveryNoteKeywords(settings.deliveryNoteKeywords || "");
+ setOrderNumberKeywords(settings.orderNumberKeywords || "");
+ setSupplierKeywords(settings.supplierKeywords || "");
+ setTotalAmountKeywords(settings.totalAmountKeywords || "");
+ setSftpHost(settings.sftpHost || "");
+ setSftpPort(settings.sftpPort || 22);
+ setSftpUsername(settings.sftpUsername || "");
+ setSftpPassword(settings.sftpPassword || "");
+ setSftpRemotePath(settings.sftpRemotePath || "/");
+ setSftpAutoExport(settings.sftpAutoExport === 1);
+ setLlmLogsRetentionMonths(settings.llmLogsRetentionMonths || 3);
+ }
+ }, [settings]);
+
+ const saveMutation = trpc.settings.upsert.useMutation({
+ onSuccess: () => {
+ toast.success("Paramètres enregistrés");
+ utils.settings.get.invalidate();
+ },
+ onError: (error) => {
+ toast.error(error.message || "Erreur lors de l'enregistrement");
+ },
+ });
+
+ const testSftpMutation = trpc.sftp.testConnection.useMutation({
+ onSuccess: (data) => {
+ if (data.success) {
+ toast.success("Connexion SFTP réussie");
+ } else {
+ toast.error("Échec de la connexion SFTP");
+ }
+ },
+ onError: (error) => {
+ toast.error(error.message || "Erreur lors du test de connexion");
+ },
+ });
+
+ const handleSave = () => {
+ saveMutation.mutate({
+ llmModel,
+ invoiceNumberKeywords,
+ deliveryNoteKeywords,
+ orderNumberKeywords,
+ supplierKeywords,
+ totalAmountKeywords,
+ sftpHost,
+ sftpPort,
+ sftpUsername,
+ sftpPassword,
+ sftpRemotePath,
+ sftpAutoExport: sftpAutoExport ? 1 : 0,
+ llmLogsRetentionMonths,
+ });
+ };
+
+ const handleTestSftp = () => {
+ testSftpMutation.mutate();
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
Paramètres
+
Configurez l'extraction et l'export de vos factures
+
+
+ {/* LLM Configuration */}
+
+
+ Configuration LLM
+ Paramètres du modèle d'extraction Mistral AI
+
+
+
+
+
setLlmModel(e.target.value)}
+ placeholder="mistral-large-latest"
+ />
+
Nom du modèle Mistral à utiliser pour l'extraction
+
+
+
+
+
setLlmLogsRetentionMonths(parseInt(e.target.value) || 3)}
+ min={1}
+ max={12}
+ />
+
Durée de conservation des logs LLM (1-12 mois)
+
+
+
+
+ {/* Keywords Configuration */}
+
+
+ Mots-clés personnalisés
+
+ Mots-clés pour améliorer la détection des champs (séparés par des virgules)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* SFTP Configuration */}
+
+
+ Configuration SFTP
+ Paramètres d'export vers un serveur SFTP
+
+
+
+
+
+
+
+
+
setSftpRemotePath(e.target.value)}
+ placeholder="/invoices"
+ />
+
+ Les fichiers seront organisés par date: /chemin/YYYY/MM/DD/
+
+
+
+
+
+
+
+ Exporter automatiquement les factures après extraction
+
+
+
+
+
+
+
+
+
+ {/* Save button */}
+
+
+
+
+
+ );
+}
diff --git a/client/src/pages/Upload.tsx b/client/src/pages/Upload.tsx
new file mode 100644
index 0000000..902edcb
--- /dev/null
+++ b/client/src/pages/Upload.tsx
@@ -0,0 +1,203 @@
+import { useState, useCallback } from "react";
+import DashboardLayout from "@/components/DashboardLayout";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { trpc } from "@/lib/trpc";
+import { Upload as UploadIcon, FileText, Loader2, CheckCircle, AlertCircle } from "lucide-react";
+import { toast } from "sonner";
+import { useLocation } from "wouter";
+
+export default function Upload() {
+ const [, setLocation] = useLocation();
+ const [isDragging, setIsDragging] = useState(false);
+ const [uploading, setUploading] = useState(false);
+ const [sourceFileId, setSourceFileId] = useState
(null);
+
+ const uploadMutation = trpc.invoices.upload.useMutation({
+ onSuccess: (data) => {
+ setSourceFileId(data.sourceFileId);
+ toast.success("Fichier uploadé avec succès");
+ },
+ onError: (error) => {
+ toast.error(error.message || "Erreur lors de l'upload");
+ setUploading(false);
+ },
+ });
+
+ // Poll source file status
+ const { data: sourceFiles } = trpc.sourceFiles.getByIds.useQuery(
+ { ids: sourceFileId ? [sourceFileId] : [] },
+ {
+ enabled: !!sourceFileId,
+ refetchInterval: (query) => {
+ const data = query.state.data;
+ if (!data || data.length === 0) return false;
+ const file = data[0];
+ if (!file || file.processingStatus === "completed" || file.processingStatus === "error") {
+ return false;
+ }
+ return 2000; // Poll every 2 seconds
+ },
+ }
+ );
+
+ const sourceFile = sourceFiles && sourceFiles.length > 0 ? sourceFiles[0] : undefined;
+
+ const handleDragOver = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ setIsDragging(true);
+ }, []);
+
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ setIsDragging(false);
+ }, []);
+
+ const handleDrop = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ setIsDragging(false);
+
+ const files = Array.from(e.dataTransfer.files);
+ const pdfFile = files.find((f) => f.type === "application/pdf");
+
+ if (!pdfFile) {
+ toast.error("Veuillez sélectionner un fichier PDF");
+ return;
+ }
+
+ handleFileUpload(pdfFile);
+ }, []);
+
+ const handleFileSelect = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ if (file.type !== "application/pdf") {
+ toast.error("Veuillez sélectionner un fichier PDF");
+ return;
+ }
+
+ handleFileUpload(file);
+ };
+
+ const handleFileUpload = async (file: File) => {
+ setUploading(true);
+ setSourceFileId(null);
+
+ try {
+ // Convert file to base64
+ const reader = new FileReader();
+ reader.onload = async () => {
+ const base64 = (reader.result as string).split(",")[1];
+ uploadMutation.mutate({
+ fileName: file.name,
+ fileData: base64!,
+ });
+ };
+ reader.readAsDataURL(file);
+ } catch (error) {
+ toast.error("Erreur lors de la lecture du fichier");
+ setUploading(false);
+ }
+ };
+
+ const handleViewInvoices = () => {
+ setLocation("/invoices");
+ };
+
+ return (
+
+
+
+
Importer des factures
+
Uploadez un fichier PDF contenant une ou plusieurs factures
+
+
+
+
+ Upload de fichier
+ Glissez-déposez un fichier PDF ou cliquez pour sélectionner
+
+
+
+
+
+
+
+
+
+ {/* Processing status */}
+ {sourceFile && (
+
+
+ Traitement en cours
+ {sourceFile.fileName}
+
+
+
+ {sourceFile.processingStatus === "processing" && (
+ <>
+
+
+
Extraction en cours...
+
{sourceFile.processingProgress}
+
+ >
+ )}
+ {sourceFile.processingStatus === "completed" && (
+ <>
+
+
+
Traitement terminé
+
+ {sourceFile.totalInvoicesDetected} facture(s) détectée(s)
+
+
+ >
+ )}
+ {sourceFile.processingStatus === "error" && (
+ <>
+
+
+
Erreur de traitement
+
{sourceFile.processingProgress}
+
+ >
+ )}
+
+
+ {sourceFile.processingStatus === "completed" && (
+
+ )}
+
+
+ )}
+
+
+ );
+}
diff --git a/client/src/pages/Users.tsx b/client/src/pages/Users.tsx
new file mode 100644
index 0000000..ed9e846
--- /dev/null
+++ b/client/src/pages/Users.tsx
@@ -0,0 +1,249 @@
+import { useState } from "react";
+import DashboardLayout from "@/components/DashboardLayout";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { Badge } from "@/components/ui/badge";
+import { Switch } from "@/components/ui/switch";
+import { trpc } from "@/lib/trpc";
+import { UserPlus, Trash2, Users as UsersIcon } from "lucide-react";
+import { toast } from "sonner";
+
+export default function Users() {
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [name, setName] = useState("");
+ const [role, setRole] = useState<"user" | "admin">("user");
+
+ const { data: users, isLoading } = trpc.admin.getAllUsers.useQuery();
+ const utils = trpc.useUtils();
+
+ const createMutation = trpc.admin.createUser.useMutation({
+ onSuccess: () => {
+ toast.success("Utilisateur créé");
+ utils.admin.getAllUsers.invalidate();
+ setDialogOpen(false);
+ setEmail("");
+ setPassword("");
+ setName("");
+ setRole("user");
+ },
+ onError: (error) => {
+ toast.error(error.message || "Erreur lors de la création");
+ },
+ });
+
+ const toggleActiveMutation = trpc.admin.toggleUserActive.useMutation({
+ onSuccess: () => {
+ toast.success("Statut mis à jour");
+ utils.admin.getAllUsers.invalidate();
+ },
+ onError: (error) => {
+ toast.error(error.message || "Erreur lors de la mise à jour");
+ },
+ });
+
+ const deleteMutation = trpc.admin.deleteUser.useMutation({
+ onSuccess: () => {
+ toast.success("Utilisateur supprimé");
+ utils.admin.getAllUsers.invalidate();
+ },
+ onError: (error) => {
+ toast.error(error.message || "Erreur lors de la suppression");
+ },
+ });
+
+ const handleCreate = () => {
+ createMutation.mutate({ email, password, name, role });
+ };
+
+ const handleToggleActive = (userId: number, isActive: number) => {
+ toggleActiveMutation.mutate({ userId, isActive: isActive === 1 ? 0 : 1 });
+ };
+
+ const handleDelete = (userId: number) => {
+ if (confirm("Êtes-vous sûr de vouloir supprimer cet utilisateur ?")) {
+ deleteMutation.mutate({ userId });
+ }
+ };
+
+ return (
+
+
+
+
+
Gestion des utilisateurs
+
Créez et gérez les comptes utilisateurs
+
+
+
+
+
+
+
+ Liste des utilisateurs
+ Gérez les comptes et les permissions
+
+
+ {isLoading ? (
+ Chargement...
+ ) : users && users.length > 0 ? (
+
+
+
+ Nom
+ Email
+ Méthode
+ Rôle
+ Actif
+ Créé le
+ Actions
+
+
+
+ {users.map((user) => (
+
+ {user.name || "-"}
+ {user.email}
+
+ {user.loginMethod}
+
+
+ {user.role === "admin" ? (
+
+ Admin
+
+ ) : (
+ User
+ )}
+
+
+ handleToggleActive(user.id, user.isActive)}
+ disabled={toggleActiveMutation.isPending}
+ />
+
+
+ {new Date(user.createdAt).toLocaleDateString("fr-FR")}
+
+
+
+
+
+ ))}
+
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/drizzle/0000_dashing_earthquake.sql b/drizzle/0000_dashing_earthquake.sql
new file mode 100644
index 0000000..d0cd6eb
--- /dev/null
+++ b/drizzle/0000_dashing_earthquake.sql
@@ -0,0 +1,13 @@
+CREATE TABLE `users` (
+ `id` int AUTO_INCREMENT NOT NULL,
+ `openId` varchar(64) NOT NULL,
+ `name` text,
+ `email` varchar(320),
+ `loginMethod` varchar(64),
+ `role` enum('user','admin') NOT NULL DEFAULT 'user',
+ `createdAt` timestamp NOT NULL DEFAULT (now()),
+ `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
+ `lastSignedIn` timestamp NOT NULL DEFAULT (now()),
+ CONSTRAINT `users_id` PRIMARY KEY(`id`),
+ CONSTRAINT `users_openId_unique` UNIQUE(`openId`)
+);
diff --git a/drizzle/0001_breezy_the_spike.sql b/drizzle/0001_breezy_the_spike.sql
new file mode 100644
index 0000000..4b28f35
--- /dev/null
+++ b/drizzle/0001_breezy_the_spike.sql
@@ -0,0 +1,107 @@
+CREATE TABLE `importLogs` (
+ `id` int AUTO_INCREMENT NOT NULL,
+ `userId` int NOT NULL,
+ `sourceFileId` int NOT NULL,
+ `fileName` varchar(255) NOT NULL,
+ `totalInvoicesDetected` int NOT NULL DEFAULT 0,
+ `invoicesImported` int NOT NULL DEFAULT 0,
+ `duplicatesIgnored` int NOT NULL DEFAULT 0,
+ `errors` int NOT NULL DEFAULT 0,
+ `duplicateDetails` text,
+ `errorDetails` text,
+ `importedAt` timestamp NOT NULL DEFAULT (now()),
+ CONSTRAINT `importLogs_id` PRIMARY KEY(`id`)
+);
+--> statement-breakpoint
+CREATE TABLE `invoices` (
+ `id` int AUTO_INCREMENT NOT NULL,
+ `userId` int NOT NULL,
+ `sourceFileId` int NOT NULL,
+ `invoiceIndexInFile` int NOT NULL DEFAULT 1,
+ `fileName` varchar(255) NOT NULL,
+ `fileKey` text NOT NULL,
+ `fileUrl` text NOT NULL,
+ `supplierName` varchar(255),
+ `invoiceNumber` varchar(100),
+ `invoiceDate` timestamp,
+ `deliveryNoteNumber` varchar(100),
+ `orderNumber` varchar(100),
+ `totalAmount` decimal(10,2),
+ `pageRange` varchar(20),
+ `qualityScore` int,
+ `metadataFileKey` text,
+ `metadataFileUrl` text,
+ `status` enum('processing','completed','error') NOT NULL DEFAULT 'processing',
+ `errorMessage` text,
+ `manuallyEdited` int NOT NULL DEFAULT 0,
+ `exportedAt` timestamp,
+ `exportMode` enum('manual','automatic'),
+ `createdAt` timestamp NOT NULL DEFAULT (now()),
+ `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
+ CONSTRAINT `invoices_id` PRIMARY KEY(`id`),
+ CONSTRAINT `supplier_invoice_date_unique` UNIQUE(`supplierName`,`invoiceNumber`,`invoiceDate`)
+);
+--> statement-breakpoint
+CREATE TABLE `llmLogs` (
+ `id` int AUTO_INCREMENT NOT NULL,
+ `userId` int NOT NULL,
+ `sourceFileId` int,
+ `invoiceId` int,
+ `operation` varchar(50) NOT NULL,
+ `model` varchar(50) NOT NULL,
+ `promptSent` text NOT NULL,
+ `rawResponse` text NOT NULL,
+ `cleanedResponse` text,
+ `success` int NOT NULL DEFAULT 1,
+ `errorMessage` text,
+ `processingTimeMs` int,
+ `pageRange` varchar(50),
+ `createdAt` timestamp NOT NULL DEFAULT (now()),
+ CONSTRAINT `llmLogs_id` PRIMARY KEY(`id`)
+);
+--> statement-breakpoint
+CREATE TABLE `sourceFiles` (
+ `id` int AUTO_INCREMENT NOT NULL,
+ `userId` int NOT NULL,
+ `fileName` varchar(255) NOT NULL,
+ `fileKey` text NOT NULL,
+ `fileUrl` text NOT NULL,
+ `totalInvoicesDetected` int NOT NULL DEFAULT 0,
+ `processingStatus` enum('processing','completed','error') NOT NULL DEFAULT 'processing',
+ `processingProgress` varchar(255),
+ `createdAt` timestamp NOT NULL DEFAULT (now()),
+ `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
+ CONSTRAINT `sourceFiles_id` PRIMARY KEY(`id`)
+);
+--> statement-breakpoint
+CREATE TABLE `userSettings` (
+ `id` int AUTO_INCREMENT NOT NULL,
+ `userId` int NOT NULL,
+ `llmModel` varchar(50) NOT NULL DEFAULT 'mistral-large-latest',
+ `orderNumberFormat` text,
+ `invoiceNumberKeywords` text,
+ `deliveryNoteKeywords` text,
+ `orderNumberKeywords` text,
+ `supplierKeywords` text,
+ `totalAmountKeywords` text,
+ `sftpHost` varchar(255),
+ `sftpPort` int DEFAULT 22,
+ `sftpUsername` varchar(255),
+ `sftpPassword` text,
+ `sftpRemotePath` varchar(500) DEFAULT '/',
+ `sftpAutoExport` int NOT NULL DEFAULT 0,
+ `llmLogsRetentionMonths` int NOT NULL DEFAULT 3,
+ `createdAt` timestamp NOT NULL DEFAULT (now()),
+ `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
+ CONSTRAINT `userSettings_id` PRIMARY KEY(`id`),
+ CONSTRAINT `userSettings_userId_unique` UNIQUE(`userId`)
+);
+--> statement-breakpoint
+ALTER TABLE `users` MODIFY COLUMN `openId` varchar(64);--> statement-breakpoint
+ALTER TABLE `users` MODIFY COLUMN `email` varchar(320) NOT NULL;--> statement-breakpoint
+ALTER TABLE `users` MODIFY COLUMN `loginMethod` enum('manus','local','azure-ad') NOT NULL;--> statement-breakpoint
+ALTER TABLE `users` ADD `azureAdId` varchar(64);--> statement-breakpoint
+ALTER TABLE `users` ADD `passwordHash` varchar(255);--> statement-breakpoint
+ALTER TABLE `users` ADD `isActive` int DEFAULT 1 NOT NULL;--> statement-breakpoint
+ALTER TABLE `users` ADD CONSTRAINT `users_azureAdId_unique` UNIQUE(`azureAdId`);--> statement-breakpoint
+ALTER TABLE `users` ADD CONSTRAINT `users_email_unique` UNIQUE(`email`);
\ No newline at end of file
diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json
new file mode 100644
index 0000000..39ac7b9
--- /dev/null
+++ b/drizzle/meta/0000_snapshot.json
@@ -0,0 +1,110 @@
+{
+ "version": "5",
+ "dialect": "mysql",
+ "id": "a83c4429-6f78-429c-9b2d-933ddd6d357d",
+ "prevId": "00000000-0000-0000-0000-000000000000",
+ "tables": {
+ "users": {
+ "name": "users",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "openId": {
+ "name": "openId",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(320)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "loginMethod": {
+ "name": "loginMethod",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "enum('user','admin')",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'user'"
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ },
+ "lastSignedIn": {
+ "name": "lastSignedIn",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "users_id": {
+ "name": "users_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {
+ "users_openId_unique": {
+ "name": "users_openId_unique",
+ "columns": [
+ "openId"
+ ]
+ }
+ },
+ "checkConstraint": {}
+ }
+ },
+ "views": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "tables": {},
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json
new file mode 100644
index 0000000..646cfba
--- /dev/null
+++ b/drizzle/meta/0001_snapshot.json
@@ -0,0 +1,811 @@
+{
+ "version": "5",
+ "dialect": "mysql",
+ "id": "82b3cbc0-6925-4f7c-b3c7-0a1c0e96f34d",
+ "prevId": "a83c4429-6f78-429c-9b2d-933ddd6d357d",
+ "tables": {
+ "importLogs": {
+ "name": "importLogs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "userId": {
+ "name": "userId",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sourceFileId": {
+ "name": "sourceFileId",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "fileName": {
+ "name": "fileName",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "totalInvoicesDetected": {
+ "name": "totalInvoicesDetected",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "invoicesImported": {
+ "name": "invoicesImported",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "duplicatesIgnored": {
+ "name": "duplicatesIgnored",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "errors": {
+ "name": "errors",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "duplicateDetails": {
+ "name": "duplicateDetails",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "errorDetails": {
+ "name": "errorDetails",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "importedAt": {
+ "name": "importedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "importLogs_id": {
+ "name": "importLogs_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "invoices": {
+ "name": "invoices",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "userId": {
+ "name": "userId",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sourceFileId": {
+ "name": "sourceFileId",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "invoiceIndexInFile": {
+ "name": "invoiceIndexInFile",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "fileName": {
+ "name": "fileName",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "fileKey": {
+ "name": "fileKey",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "fileUrl": {
+ "name": "fileUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "supplierName": {
+ "name": "supplierName",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "invoiceNumber": {
+ "name": "invoiceNumber",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "invoiceDate": {
+ "name": "invoiceDate",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "deliveryNoteNumber": {
+ "name": "deliveryNoteNumber",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "orderNumber": {
+ "name": "orderNumber",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "totalAmount": {
+ "name": "totalAmount",
+ "type": "decimal(10,2)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "pageRange": {
+ "name": "pageRange",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "qualityScore": {
+ "name": "qualityScore",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadataFileKey": {
+ "name": "metadataFileKey",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadataFileUrl": {
+ "name": "metadataFileUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "enum('processing','completed','error')",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'processing'"
+ },
+ "errorMessage": {
+ "name": "errorMessage",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "manuallyEdited": {
+ "name": "manuallyEdited",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "exportedAt": {
+ "name": "exportedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "exportMode": {
+ "name": "exportMode",
+ "type": "enum('manual','automatic')",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "supplier_invoice_date_unique": {
+ "name": "supplier_invoice_date_unique",
+ "columns": [
+ "supplierName",
+ "invoiceNumber",
+ "invoiceDate"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "invoices_id": {
+ "name": "invoices_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "llmLogs": {
+ "name": "llmLogs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "userId": {
+ "name": "userId",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sourceFileId": {
+ "name": "sourceFileId",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "invoiceId": {
+ "name": "invoiceId",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "operation": {
+ "name": "operation",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "model": {
+ "name": "model",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "promptSent": {
+ "name": "promptSent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rawResponse": {
+ "name": "rawResponse",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "cleanedResponse": {
+ "name": "cleanedResponse",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "success": {
+ "name": "success",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "errorMessage": {
+ "name": "errorMessage",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "processingTimeMs": {
+ "name": "processingTimeMs",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "pageRange": {
+ "name": "pageRange",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "llmLogs_id": {
+ "name": "llmLogs_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "sourceFiles": {
+ "name": "sourceFiles",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "userId": {
+ "name": "userId",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "fileName": {
+ "name": "fileName",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "fileKey": {
+ "name": "fileKey",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "fileUrl": {
+ "name": "fileUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "totalInvoicesDetected": {
+ "name": "totalInvoicesDetected",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "processingStatus": {
+ "name": "processingStatus",
+ "type": "enum('processing','completed','error')",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'processing'"
+ },
+ "processingProgress": {
+ "name": "processingProgress",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "sourceFiles_id": {
+ "name": "sourceFiles_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "userSettings": {
+ "name": "userSettings",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "userId": {
+ "name": "userId",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "llmModel": {
+ "name": "llmModel",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'mistral-large-latest'"
+ },
+ "orderNumberFormat": {
+ "name": "orderNumberFormat",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "invoiceNumberKeywords": {
+ "name": "invoiceNumberKeywords",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "deliveryNoteKeywords": {
+ "name": "deliveryNoteKeywords",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "orderNumberKeywords": {
+ "name": "orderNumberKeywords",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "supplierKeywords": {
+ "name": "supplierKeywords",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "totalAmountKeywords": {
+ "name": "totalAmountKeywords",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sftpHost": {
+ "name": "sftpHost",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sftpPort": {
+ "name": "sftpPort",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": 22
+ },
+ "sftpUsername": {
+ "name": "sftpUsername",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sftpPassword": {
+ "name": "sftpPassword",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sftpRemotePath": {
+ "name": "sftpRemotePath",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'/'"
+ },
+ "sftpAutoExport": {
+ "name": "sftpAutoExport",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "llmLogsRetentionMonths": {
+ "name": "llmLogsRetentionMonths",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 3
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "userSettings_id": {
+ "name": "userSettings_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {
+ "userSettings_userId_unique": {
+ "name": "userSettings_userId_unique",
+ "columns": [
+ "userId"
+ ]
+ }
+ },
+ "checkConstraint": {}
+ },
+ "users": {
+ "name": "users",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "openId": {
+ "name": "openId",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "azureAdId": {
+ "name": "azureAdId",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(320)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "passwordHash": {
+ "name": "passwordHash",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "loginMethod": {
+ "name": "loginMethod",
+ "type": "enum('manus','local','azure-ad')",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "enum('user','admin')",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'user'"
+ },
+ "isActive": {
+ "name": "isActive",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ },
+ "lastSignedIn": {
+ "name": "lastSignedIn",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "users_id": {
+ "name": "users_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {
+ "users_openId_unique": {
+ "name": "users_openId_unique",
+ "columns": [
+ "openId"
+ ]
+ },
+ "users_azureAdId_unique": {
+ "name": "users_azureAdId_unique",
+ "columns": [
+ "azureAdId"
+ ]
+ },
+ "users_email_unique": {
+ "name": "users_email_unique",
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "checkConstraint": {}
+ }
+ },
+ "views": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "tables": {},
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index 22fb7e8..21301ed 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -1,5 +1,20 @@
{
"version": "7",
"dialect": "mysql",
- "entries": []
-}
+ "entries": [
+ {
+ "idx": 0,
+ "version": "5",
+ "when": 1767869282094,
+ "tag": "0000_dashing_earthquake",
+ "breakpoints": true
+ },
+ {
+ "idx": 1,
+ "version": "5",
+ "when": 1767869488758,
+ "tag": "0001_breezy_the_spike",
+ "breakpoints": true
+ }
+ ]
+}
\ No newline at end of file
diff --git a/drizzle/schema.ts b/drizzle/schema.ts
index 96f47f2..44ca356 100644
--- a/drizzle/schema.ts
+++ b/drizzle/schema.ts
@@ -1,22 +1,24 @@
-import { int, mysqlEnum, mysqlTable, text, timestamp, varchar } from "drizzle-orm/mysql-core";
+import { int, mysqlEnum, mysqlTable, text, timestamp, varchar, uniqueIndex, decimal } from "drizzle-orm/mysql-core";
/**
* Core user table backing auth flow.
- * Extend this file with additional tables as your product grows.
- * Columns use camelCase to match both database fields and generated types.
+ * Supports multiple authentication methods: Manus OAuth, local, and Azure AD
*/
export const users = mysqlTable("users", {
- /**
- * Surrogate primary key. Auto-incremented numeric value managed by the database.
- * Use this for relations between tables.
- */
id: int("id").autoincrement().primaryKey(),
- /** Manus OAuth identifier (openId) returned from the OAuth callback. Unique per user. */
- openId: varchar("openId", { length: 64 }).notNull().unique(),
+ /** Manus OAuth identifier (openId) - Optional for backward compatibility */
+ openId: varchar("openId", { length: 64 }).unique(),
+ /** Azure AD Object ID - Unique identifier from Azure AD */
+ azureAdId: varchar("azureAdId", { length: 64 }).unique(),
name: text("name"),
- email: varchar("email", { length: 320 }),
- loginMethod: varchar("loginMethod", { length: 64 }),
+ email: varchar("email", { length: 320 }).notNull().unique(),
+ /** Hashed password for local authentication (bcrypt) */
+ passwordHash: varchar("passwordHash", { length: 255 }),
+ /** Authentication method: 'manus', 'local', 'azure-ad' */
+ loginMethod: mysqlEnum("loginMethod", ["manus", "local", "azure-ad"]).notNull(),
role: mysqlEnum("role", ["user", "admin"]).default("user").notNull(),
+ /** Account status for manual user management */
+ isActive: int("isActive").default(1).notNull(), // 0 = inactive, 1 = active
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
lastSignedIn: timestamp("lastSignedIn").defaultNow().notNull(),
@@ -25,4 +27,145 @@ export const users = mysqlTable("users", {
export type User = typeof users.$inferSelect;
export type InsertUser = typeof users.$inferInsert;
-// TODO: Add your tables here
\ No newline at end of file
+/**
+ * Source files table storing uploaded PDF files that may contain multiple invoices
+ */
+export const sourceFiles = mysqlTable("sourceFiles", {
+ id: int("id").autoincrement().primaryKey(),
+ userId: int("userId").notNull(),
+ fileName: varchar("fileName", { length: 255 }).notNull(),
+ fileKey: text("fileKey").notNull(), // Local storage key with YYYY-MM prefix
+ fileUrl: text("fileUrl").notNull(), // Public URL
+ totalInvoicesDetected: int("totalInvoicesDetected").default(0).notNull(),
+ processingStatus: mysqlEnum("processingStatus", ["processing", "completed", "error"]).default("processing").notNull(),
+ processingProgress: varchar("processingProgress", { length: 255 }), // Progress message (e.g., "Extraction 3/9 factures...")
+ createdAt: timestamp("createdAt").defaultNow().notNull(),
+ updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
+});
+
+export type SourceFile = typeof sourceFiles.$inferSelect;
+export type InsertSourceFile = typeof sourceFiles.$inferInsert;
+
+/**
+ * Invoices table storing individual invoices extracted from source files
+ */
+export const invoices = mysqlTable("invoices", {
+ id: int("id").autoincrement().primaryKey(),
+ userId: int("userId").notNull(),
+ sourceFileId: int("sourceFileId").notNull(), // Reference to the source PDF file
+ invoiceIndexInFile: int("invoiceIndexInFile").default(1).notNull(), // Position in the source file (1, 2, 3...)
+
+ // File storage information (for individual invoice if split, or reference to source)
+ fileName: varchar("fileName", { length: 255 }).notNull(),
+ fileKey: text("fileKey").notNull(), // Local storage key
+ fileUrl: text("fileUrl").notNull(), // Public URL
+
+ // Extracted metadata
+ supplierName: varchar("supplierName", { length: 255 }),
+ invoiceNumber: varchar("invoiceNumber", { length: 100 }),
+ invoiceDate: timestamp("invoiceDate"),
+ deliveryNoteNumber: varchar("deliveryNoteNumber", { length: 100 }),
+ orderNumber: varchar("orderNumber", { length: 100 }),
+ totalAmount: decimal("totalAmount", { precision: 10, scale: 2 }),
+ pageRange: varchar("pageRange", { length: 20 }), // ex: "1-2" ou "5"
+ qualityScore: int("qualityScore"), // Score de qualité de l'extraction (0-100)
+
+ // Metadata JSON file
+ metadataFileKey: text("metadataFileKey"), // Storage key for JSON metadata
+ metadataFileUrl: text("metadataFileUrl"), // Public URL for JSON
+
+ // Processing status
+ status: mysqlEnum("status", ["processing", "completed", "error"]).default("processing").notNull(),
+ errorMessage: text("errorMessage"),
+
+ // Manual correction tracking
+ manuallyEdited: int("manuallyEdited").default(0).notNull(), // 0 = false, 1 = true
+
+ // SFTP Export tracking
+ exportedAt: timestamp("exportedAt"),
+ exportMode: mysqlEnum("exportMode", ["manual", "automatic"]),
+
+ createdAt: timestamp("createdAt").defaultNow().notNull(),
+ updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
+}, (table) => {
+ return {
+ // Unique constraint: no duplicate invoices with same supplier, invoice number, and date
+ supplierInvoiceDateIdx: uniqueIndex("supplier_invoice_date_unique").on(table.supplierName, table.invoiceNumber, table.invoiceDate),
+ };
+});
+
+export type Invoice = typeof invoices.$inferSelect;
+export type InsertInvoice = typeof invoices.$inferInsert;
+
+/**
+ * User settings table for application preferences
+ */
+export const userSettings = mysqlTable("userSettings", {
+ id: int("id").autoincrement().primaryKey(),
+ userId: int("userId").notNull().unique(), // One settings record per user
+ llmModel: varchar("llmModel", { length: 50 }).default("mistral-large-latest").notNull(), // Mistral model for invoice extraction
+ orderNumberFormat: text("orderNumberFormat"), // Format/pattern du numéro de commande pour aider l'extraction
+ // Mots-clés personnalisés pour améliorer la détection (séparés par des virgules)
+ invoiceNumberKeywords: text("invoiceNumberKeywords"), // Ex: "Référence, Ref facture, Invoice ref"
+ deliveryNoteKeywords: text("deliveryNoteKeywords"), // Ex: "Livraison, Delivery, Expédition"
+ orderNumberKeywords: text("orderNumberKeywords"), // Ex: "Cde client, Référence commande, PO Number"
+ supplierKeywords: text("supplierKeywords"), // Ex: "Vendeur, Société, Émetteur"
+ totalAmountKeywords: text("totalAmountKeywords"), // Ex: "Net à payer, Total à régler, Amount due"
+ // SFTP Configuration
+ sftpHost: varchar("sftpHost", { length: 255 }),
+ sftpPort: int("sftpPort").default(22),
+ sftpUsername: varchar("sftpUsername", { length: 255 }),
+ sftpPassword: text("sftpPassword"), // Encrypted password
+ sftpRemotePath: varchar("sftpRemotePath", { length: 500 }).default("/"), // Remote directory path
+ sftpAutoExport: int("sftpAutoExport").default(0).notNull(), // 0 = manual, 1 = automatic
+ // LLM Logs retention
+ llmLogsRetentionMonths: int("llmLogsRetentionMonths").default(3).notNull(), // Durée de conservation des logs LLM en mois (défaut: 3 mois)
+ createdAt: timestamp("createdAt").defaultNow().notNull(),
+ updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
+});
+
+export type UserSettings = typeof userSettings.$inferSelect;
+export type InsertUserSettings = typeof userSettings.$inferInsert;
+
+/**
+ * Import logs table for tracking all import operations
+ */
+export const importLogs = mysqlTable("importLogs", {
+ id: int("id").autoincrement().primaryKey(),
+ userId: int("userId").notNull(),
+ sourceFileId: int("sourceFileId").notNull(), // Reference to source file
+ fileName: varchar("fileName", { length: 255 }).notNull(),
+ totalInvoicesDetected: int("totalInvoicesDetected").default(0).notNull(),
+ invoicesImported: int("invoicesImported").default(0).notNull(),
+ duplicatesIgnored: int("duplicatesIgnored").default(0).notNull(),
+ errors: int("errors").default(0).notNull(),
+ duplicateDetails: text("duplicateDetails"), // JSON array of duplicate invoice info
+ errorDetails: text("errorDetails"), // JSON array of error messages
+ importedAt: timestamp("importedAt").defaultNow().notNull(),
+});
+
+export type ImportLog = typeof importLogs.$inferSelect;
+export type InsertImportLog = typeof importLogs.$inferInsert;
+
+/**
+ * LLM Logs table for storing raw LLM responses for debugging and improvement
+ */
+export const llmLogs = mysqlTable("llmLogs", {
+ id: int("id").autoincrement().primaryKey(),
+ userId: int("userId").notNull(),
+ sourceFileId: int("sourceFileId"), // Optional: link to source file if applicable
+ invoiceId: int("invoiceId"), // Optional: link to invoice if applicable
+ operation: varchar("operation", { length: 50 }).notNull(), // "detection" or "extraction"
+ model: varchar("model", { length: 50 }).notNull(), // LLM model used
+ promptSent: text("promptSent").notNull(), // Full prompt sent to LLM
+ rawResponse: text("rawResponse").notNull(), // Raw response from LLM (before cleaning)
+ cleanedResponse: text("cleanedResponse"), // Response after markdown cleaning
+ success: int("success").default(1).notNull(), // 1 = success, 0 = error
+ errorMessage: text("errorMessage"), // Error message if failed
+ processingTimeMs: int("processingTimeMs"), // Processing time in milliseconds
+ pageRange: varchar("pageRange", { length: 50 }), // Page range for this log (e.g., "1-2")
+ createdAt: timestamp("createdAt").defaultNow().notNull(),
+});
+
+export type LlmLog = typeof llmLogs.$inferSelect;
+export type InsertLlmLog = typeof llmLogs.$inferInsert;
diff --git a/package.json b/package.json
index 3283ad6..56bf3ce 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,7 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.693.0",
"@aws-sdk/s3-request-presigner": "^3.693.0",
+ "@azure/msal-node": "^3.8.4",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
@@ -46,7 +47,11 @@
"@trpc/client": "^11.6.0",
"@trpc/react-query": "^11.6.0",
"@trpc/server": "^11.6.0",
+ "@types/bcrypt": "^6.0.0",
+ "@types/jsonwebtoken": "^9.0.10",
+ "@types/ssh2-sftp-client": "^9.0.6",
"axios": "^1.12.0",
+ "bcrypt": "^6.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -59,17 +64,21 @@
"framer-motion": "^12.23.22",
"input-otp": "^1.4.2",
"jose": "6.1.0",
+ "jsonwebtoken": "^9.0.3",
"lucide-react": "^0.453.0",
"mysql2": "^3.15.0",
"nanoid": "^5.1.5",
"next-themes": "^0.4.6",
+ "pdf-lib": "^1.17.1",
"react": "^19.2.1",
"react-day-picker": "^9.11.1",
"react-dom": "^19.2.1",
"react-hook-form": "^7.64.0",
+ "react-pdf": "^10.3.0",
"react-resizable-panels": "^3.0.6",
"recharts": "^2.15.2",
"sonner": "^2.0.7",
+ "ssh2-sftp-client": "^12.0.1",
"streamdown": "^1.4.0",
"superjson": "^1.13.3",
"tailwind-merge": "^3.3.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 25a9528..5cfe8f9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -22,6 +22,9 @@ importers:
'@aws-sdk/s3-request-presigner':
specifier: ^3.693.0
version: 3.907.0
+ '@azure/msal-node':
+ specifier: ^3.8.4
+ version: 3.8.4
'@hookform/resolvers':
specifier: ^5.2.2
version: 5.2.2(react-hook-form@7.64.0(react@19.2.1))
@@ -115,9 +118,21 @@ importers:
'@trpc/server':
specifier: ^11.6.0
version: 11.6.0(typescript@5.9.3)
+ '@types/bcrypt':
+ specifier: ^6.0.0
+ version: 6.0.0
+ '@types/jsonwebtoken':
+ specifier: ^9.0.10
+ version: 9.0.10
+ '@types/ssh2-sftp-client':
+ specifier: ^9.0.6
+ version: 9.0.6
axios:
specifier: ^1.12.0
version: 1.12.2
+ bcrypt:
+ specifier: ^6.0.0
+ version: 6.0.0
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -154,6 +169,9 @@ importers:
jose:
specifier: 6.1.0
version: 6.1.0
+ jsonwebtoken:
+ specifier: ^9.0.3
+ version: 9.0.3
lucide-react:
specifier: ^0.453.0
version: 0.453.0(react@19.2.1)
@@ -166,6 +184,9 @@ importers:
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
+ pdf-lib:
+ specifier: ^1.17.1
+ version: 1.17.1
react:
specifier: ^19.2.1
version: 19.2.1
@@ -178,6 +199,9 @@ importers:
react-hook-form:
specifier: ^7.64.0
version: 7.64.0(react@19.2.1)
+ react-pdf:
+ specifier: ^10.3.0
+ version: 10.3.0(@types/react@19.2.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
react-resizable-panels:
specifier: ^3.0.6
version: 3.0.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
@@ -187,6 +211,9 @@ importers:
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
+ ssh2-sftp-client:
+ specifier: ^12.0.1
+ version: 12.0.1
streamdown:
specifier: ^1.4.0
version: 1.4.0(@types/react@19.2.1)(react@19.2.1)
@@ -450,6 +477,14 @@ packages:
resolution: {integrity: sha512-ORHRQ2tmvnBXc8t/X9Z8IcSbBA4xTLKuN873FopzklHMeqBst7YG0d+AX97inkvDX+NChYtSr+qGfcqGFaI8Zw==}
engines: {node: '>=18.0.0'}
+ '@azure/msal-common@15.13.3':
+ resolution: {integrity: sha512-shSDU7Ioecya+Aob5xliW9IGq1Ui8y4EVSdWGyI1Gbm4Vg61WpP95LuzcY214/wEjSn6w4PZYD4/iVldErHayQ==}
+ engines: {node: '>=0.8.0'}
+
+ '@azure/msal-node@3.8.4':
+ resolution: {integrity: sha512-lvuAwsDpPDE/jSuVQOBMpLbXuVuLsPNRwWCyK3/6bPlBk0fGWegqoZ0qjZclMWyQ2JNvIY3vHY7hoFmFmFQcOw==}
+ engines: {node: '>=16'}
+
'@babel/code-frame@7.27.1':
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
engines: {node: '>=6.9.0'}
@@ -1055,6 +1090,82 @@ packages:
'@mermaid-js/parser@0.6.3':
resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==}
+ '@napi-rs/canvas-android-arm64@0.1.88':
+ resolution: {integrity: sha512-KEaClPnZuVxJ8smUWjV1wWFkByBO/D+vy4lN+Dm5DFH514oqwukxKGeck9xcKJhaWJGjfruGmYGiwRe//+/zQQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [android]
+
+ '@napi-rs/canvas-darwin-arm64@0.1.88':
+ resolution: {integrity: sha512-Xgywz0dDxOKSgx3eZnK85WgGMmGrQEW7ZLA/E7raZdlEE+xXCozobgqz2ZvYigpB6DJFYkqnwHjqCOTSDGlFdg==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@napi-rs/canvas-darwin-x64@0.1.88':
+ resolution: {integrity: sha512-Yz4wSCIQOUgNucgk+8NFtQxQxZV5NO8VKRl9ePKE6XoNyNVC8JDqtvhh3b3TPqKK8W5p2EQpAr1rjjm0mfBxdg==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@napi-rs/canvas-linux-arm-gnueabihf@0.1.88':
+ resolution: {integrity: sha512-9gQM2SlTo76hYhxHi2XxWTAqpTOb+JtxMPEIr+H5nAhHhyEtNmTSDRtz93SP7mGd2G3Ojf2oF5tP9OdgtgXyKg==}
+ engines: {node: '>= 10'}
+ cpu: [arm]
+ os: [linux]
+
+ '@napi-rs/canvas-linux-arm64-gnu@0.1.88':
+ resolution: {integrity: sha512-7qgaOBMXuVRk9Fzztzr3BchQKXDxGbY+nwsovD3I/Sx81e+sX0ReEDYHTItNb0Je4NHbAl7D0MKyd4SvUc04sg==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@napi-rs/canvas-linux-arm64-musl@0.1.88':
+ resolution: {integrity: sha512-kYyNrUsHLkoGHBc77u4Unh067GrfiCUMbGHC2+OTxbeWfZkPt2o32UOQkhnSswKd9Fko/wSqqGkY956bIUzruA==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@napi-rs/canvas-linux-riscv64-gnu@0.1.88':
+ resolution: {integrity: sha512-HVuH7QgzB0yavYdNZDRyAsn/ejoXB0hn8twwFnOqUbCCdkV+REna7RXjSR7+PdfW0qMQ2YYWsLvVBT5iL/mGpw==}
+ engines: {node: '>= 10'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@napi-rs/canvas-linux-x64-gnu@0.1.88':
+ resolution: {integrity: sha512-hvcvKIcPEQrvvJtJnwD35B3qk6umFJ8dFIr8bSymfrSMem0EQsfn1ztys8ETIFndTwdNWJKWluvxztA41ivsEw==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+
+ '@napi-rs/canvas-linux-x64-musl@0.1.88':
+ resolution: {integrity: sha512-eSMpGYY2xnZSQ6UxYJ6plDboxq4KeJ4zT5HaVkUnbObNN6DlbJe0Mclh3wifAmquXfrlgTZt6zhHsUgz++AK6g==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+
+ '@napi-rs/canvas-win32-arm64-msvc@0.1.88':
+ resolution: {integrity: sha512-qcIFfEgHrchyYqRrxsCeTQgpJZ/GqHiqPcU/Fvw/ARVlQeDX1VyFH+X+0gCR2tca6UJrq96vnW+5o7buCq+erA==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@napi-rs/canvas-win32-x64-msvc@0.1.88':
+ resolution: {integrity: sha512-ROVqbfS4QyZxYkqmaIBBpbz/BQvAR+05FXM5PAtTYVc0uyY8Y4BHJSMdGAaMf6TdIVRsQsiq+FG/dH9XhvWCFQ==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [win32]
+
+ '@napi-rs/canvas@0.1.88':
+ resolution: {integrity: sha512-/p08f93LEbsL5mDZFQ3DBxcPv/I4QG9EDYRRq1WNlCOXVfAHBTHMSVMwxlqG/AtnSfUr9+vgfN7MKiyDo0+Weg==}
+ engines: {node: '>= 10'}
+
+ '@pdf-lib/standard-fonts@1.0.0':
+ resolution: {integrity: sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==}
+
+ '@pdf-lib/upng@1.0.1':
+ resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==}
+
'@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
@@ -2156,6 +2267,9 @@ packages:
'@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
+ '@types/bcrypt@6.0.0':
+ resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==}
+
'@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
@@ -2282,6 +2396,9 @@ packages:
'@types/http-errors@2.0.5':
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
+ '@types/jsonwebtoken@9.0.10':
+ resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
+
'@types/katex@0.16.7':
resolution: {integrity: sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==}
@@ -2294,6 +2411,9 @@ packages:
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
+ '@types/node@18.19.130':
+ resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
+
'@types/node@24.7.0':
resolution: {integrity: sha512-IbKooQVqUBrlzWTi79E8Fw78l8k1RNtlDDNWsFZs7XonuQSJ8oNYfEeclhprUldXISRMLzBpILuKgPlIxm+/Yw==}
@@ -2320,6 +2440,12 @@ packages:
'@types/serve-static@1.15.9':
resolution: {integrity: sha512-dOTIuqpWLyl3BBXU3maNQsS4A3zuuoYRNIvYSxxhebPfXg2mzWQEPne/nlJ37yOse6uGgR386uTpdsx4D0QZWA==}
+ '@types/ssh2-sftp-client@9.0.6':
+ resolution: {integrity: sha512-4+KvXO/V77y9VjI2op2T8+RCGI/GXQAwR0q5Qkj/EJ5YSeyKszqZP6F8i3H3txYoBqjc7sgorqyvBP3+w1EHyg==}
+
+ '@types/ssh2@1.15.5':
+ resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==}
+
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
@@ -2386,6 +2512,9 @@ packages:
array-flatten@1.1.1:
resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==}
+ asn1@0.2.6:
+ resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==}
+
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
@@ -2414,6 +2543,13 @@ packages:
resolution: {integrity: sha512-vAPMQdnyKCBtkmQA6FMCBvU9qFIppS3nzyXnEM+Lo2IAhG4Mpjv9cCxMudhgV3YdNNJv6TNqXy97dfRVL2LmaQ==}
hasBin: true
+ bcrypt-pbkdf@1.0.2:
+ resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
+
+ bcrypt@6.0.0:
+ resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==}
+ engines: {node: '>= 18'}
+
body-parser@1.20.3:
resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
@@ -2426,9 +2562,16 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
+ buffer-equal-constant-time@1.0.1:
+ resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
+
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
+ buildcheck@0.0.7:
+ resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==}
+ engines: {node: '>=10.0.0'}
+
bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
@@ -2511,6 +2654,10 @@ packages:
resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
engines: {node: '>= 12'}
+ concat-stream@2.0.0:
+ resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==}
+ engines: {'0': node >= 6.0}
+
confbox@0.1.8:
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
@@ -2549,6 +2696,10 @@ packages:
cose-base@2.2.0:
resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==}
+ cpu-features@0.0.10:
+ resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==}
+ engines: {node: '>=10.0.0'}
+
cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
@@ -2892,6 +3043,9 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
+ ecdsa-sig-formatter@1.0.11:
+ resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
+
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
@@ -3249,6 +3403,16 @@ packages:
engines: {node: '>=6'}
hasBin: true
+ jsonwebtoken@9.0.3:
+ resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
+ engines: {node: '>=12', npm: '>=6'}
+
+ jwa@2.0.1:
+ resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
+
+ jws@4.0.1:
+ resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
+
katex@0.16.25:
resolution: {integrity: sha512-woHRUZ/iF23GBP1dkDQMh1QBad9dmr8/PAwNA54VrSOVYgI12MAcE14TqnDdQOdzyEonGzMepYnqBMYdsoAr8Q==}
hasBin: true
@@ -3340,6 +3504,27 @@ packages:
lodash-es@4.17.21:
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
+ lodash.includes@4.3.0:
+ resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
+
+ lodash.isboolean@3.0.3:
+ resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
+
+ lodash.isinteger@4.0.4:
+ resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
+
+ lodash.isnumber@3.0.3:
+ resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
+
+ lodash.isplainobject@4.0.6:
+ resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
+
+ lodash.isstring@4.0.1:
+ resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
+
+ lodash.once@4.1.1:
+ resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
+
lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
@@ -3380,6 +3565,12 @@ packages:
magic-string@0.30.19:
resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==}
+ make-cancellable-promise@2.0.0:
+ resolution: {integrity: sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw==}
+
+ make-event-props@2.0.0:
+ resolution: {integrity: sha512-G/hncXrl4Qt7mauJEXSg3AcdYzmpkIITTNl5I+rH9sog5Yw0kK6vseJjCaPfOXqOqQuPUP89Rkhfz5kPS8ijtw==}
+
markdown-table@3.0.4:
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
@@ -3447,6 +3638,14 @@ packages:
merge-descriptors@1.0.3:
resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==}
+ merge-refs@2.0.0:
+ resolution: {integrity: sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg==}
+ peerDependencies:
+ '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
mermaid@11.12.0:
resolution: {integrity: sha512-ZudVx73BwrMJfCFmSSJT84y6u5brEoV8DOItdHomNLz32uBjNrelm7mg95X7g+C6UoQH/W6mBLGDEDv73JdxBg==}
@@ -3591,6 +3790,9 @@ packages:
resolution: {integrity: sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==}
engines: {node: '>=12.0.0'}
+ nan@2.24.0:
+ resolution: {integrity: sha512-Vpf9qnVW1RaDkoNKFUvfxqAbtI8ncb8OJlqZ9wwpXzWPEsvsB1nvdUi6oYrHIkQ1Y/tMDnr1h4nczS0VB9Xykg==}
+
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -3611,6 +3813,14 @@ packages:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
+ node-addon-api@8.5.0:
+ resolution: {integrity: sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==}
+ engines: {node: ^18 || ^20 || >= 21}
+
+ node-gyp-build@4.8.4:
+ resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
+ hasBin: true
+
node-releases@2.0.23:
resolution: {integrity: sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==}
@@ -3639,6 +3849,9 @@ packages:
package-manager-detector@1.5.0:
resolution: {integrity: sha512-uBj69dVlYe/+wxj8JOpr97XfsxH/eumMt6HqjNTmJDf/6NO9s+0uxeOneIz3AsPt2m6y9PqzDzd3ATcU17MNfw==}
+ pako@1.0.11:
+ resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
+
parse-entities@4.0.2:
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
@@ -3665,6 +3878,13 @@ packages:
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
engines: {node: '>= 14.16'}
+ pdf-lib@1.17.1:
+ resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==}
+
+ pdfjs-dist@5.4.296:
+ resolution: {integrity: sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==}
+ engines: {node: '>=20.16.0 || >=22.3.0'}
+
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -3765,6 +3985,16 @@ packages:
'@types/react': '>=18'
react: '>=18'
+ react-pdf@10.3.0:
+ resolution: {integrity: sha512-2LQzC9IgNVAX8gM+6F+1t/70a9/5RWThYxc+CWAmT2LW/BRmnj+35x1os5j/nR2oldyf8L+hCAMBmVKU8wrYFA==}
+ peerDependencies:
+ '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
react-refresh@0.17.0:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
engines: {node: '>=0.10.0'}
@@ -3821,6 +4051,10 @@ packages:
resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==}
engines: {node: '>=0.10.0'}
+ readable-stream@3.6.2:
+ resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
+ engines: {node: '>= 6'}
+
recharts-scale@0.4.5:
resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==}
@@ -3898,6 +4132,11 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
+ semver@7.7.3:
+ resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
+ engines: {node: '>=10'}
+ hasBin: true
+
send@0.19.0:
resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==}
engines: {node: '>= 0.8.0'}
@@ -3958,6 +4197,14 @@ packages:
resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
engines: {node: '>= 0.6'}
+ ssh2-sftp-client@12.0.1:
+ resolution: {integrity: sha512-ICJ1L2PmBel2Q2ctbyxzTFZCPKSHYYD6s2TFZv7NXmZDrDNGk8lHBb/SK2WgXLMXNANH78qoumeJzxlWZqSqWg==}
+ engines: {node: '>=18.20.4'}
+
+ ssh2@1.17.0:
+ resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==}
+ engines: {node: '>=10.16.0'}
+
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
@@ -3973,6 +4220,9 @@ packages:
peerDependencies:
react: ^18.0.0 || ^19.0.0
+ string_decoder@1.3.0:
+ resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
+
stringify-entities@4.0.4:
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
@@ -4053,6 +4303,9 @@ packages:
resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==}
engines: {node: '>=6.10'}
+ tslib@1.14.1:
+ resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
+
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -4064,10 +4317,16 @@ packages:
tw-animate-css@1.4.0:
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
+ tweetnacl@0.14.5:
+ resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==}
+
type-is@1.6.18:
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
engines: {node: '>= 0.6'}
+ typedarray@0.0.6:
+ resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
+
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
@@ -4076,6 +4335,9 @@ packages:
ufo@1.6.1:
resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==}
+ undici-types@5.26.5:
+ resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
+
undici-types@7.14.0:
resolution: {integrity: sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==}
@@ -4149,6 +4411,10 @@ packages:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
hasBin: true
+ uuid@8.3.2:
+ resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
+ hasBin: true
+
vary@1.1.2:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
@@ -4295,6 +4561,9 @@ packages:
vscode-uri@3.0.8:
resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==}
+ warning@4.0.3:
+ resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==}
+
web-namespaces@2.0.1:
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
@@ -4816,6 +5085,14 @@ snapshots:
'@aws/lambda-invoke-store@0.0.1': {}
+ '@azure/msal-common@15.13.3': {}
+
+ '@azure/msal-node@3.8.4':
+ dependencies:
+ '@azure/msal-common': 15.13.3
+ jsonwebtoken: 9.0.3
+ uuid: 8.3.2
+
'@babel/code-frame@7.27.1':
dependencies:
'@babel/helper-validator-identifier': 7.27.1
@@ -5253,6 +5530,62 @@ snapshots:
dependencies:
langium: 3.3.1
+ '@napi-rs/canvas-android-arm64@0.1.88':
+ optional: true
+
+ '@napi-rs/canvas-darwin-arm64@0.1.88':
+ optional: true
+
+ '@napi-rs/canvas-darwin-x64@0.1.88':
+ optional: true
+
+ '@napi-rs/canvas-linux-arm-gnueabihf@0.1.88':
+ optional: true
+
+ '@napi-rs/canvas-linux-arm64-gnu@0.1.88':
+ optional: true
+
+ '@napi-rs/canvas-linux-arm64-musl@0.1.88':
+ optional: true
+
+ '@napi-rs/canvas-linux-riscv64-gnu@0.1.88':
+ optional: true
+
+ '@napi-rs/canvas-linux-x64-gnu@0.1.88':
+ optional: true
+
+ '@napi-rs/canvas-linux-x64-musl@0.1.88':
+ optional: true
+
+ '@napi-rs/canvas-win32-arm64-msvc@0.1.88':
+ optional: true
+
+ '@napi-rs/canvas-win32-x64-msvc@0.1.88':
+ optional: true
+
+ '@napi-rs/canvas@0.1.88':
+ optionalDependencies:
+ '@napi-rs/canvas-android-arm64': 0.1.88
+ '@napi-rs/canvas-darwin-arm64': 0.1.88
+ '@napi-rs/canvas-darwin-x64': 0.1.88
+ '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.88
+ '@napi-rs/canvas-linux-arm64-gnu': 0.1.88
+ '@napi-rs/canvas-linux-arm64-musl': 0.1.88
+ '@napi-rs/canvas-linux-riscv64-gnu': 0.1.88
+ '@napi-rs/canvas-linux-x64-gnu': 0.1.88
+ '@napi-rs/canvas-linux-x64-musl': 0.1.88
+ '@napi-rs/canvas-win32-arm64-msvc': 0.1.88
+ '@napi-rs/canvas-win32-x64-msvc': 0.1.88
+ optional: true
+
+ '@pdf-lib/standard-fonts@1.0.0':
+ dependencies:
+ pako: 1.0.11
+
+ '@pdf-lib/upng@1.0.1':
+ dependencies:
+ pako: 1.0.11
+
'@radix-ui/number@1.1.1': {}
'@radix-ui/primitive@1.1.3': {}
@@ -6468,6 +6801,10 @@ snapshots:
dependencies:
'@babel/types': 7.28.4
+ '@types/bcrypt@6.0.0':
+ dependencies:
+ '@types/node': 24.7.0
+
'@types/body-parser@1.19.6':
dependencies:
'@types/connect': 3.4.38
@@ -6628,6 +6965,11 @@ snapshots:
'@types/http-errors@2.0.5': {}
+ '@types/jsonwebtoken@9.0.10':
+ dependencies:
+ '@types/ms': 2.1.0
+ '@types/node': 24.7.0
+
'@types/katex@0.16.7': {}
'@types/mdast@4.0.4':
@@ -6638,6 +6980,10 @@ snapshots:
'@types/ms@2.1.0': {}
+ '@types/node@18.19.130':
+ dependencies:
+ undici-types: 5.26.5
+
'@types/node@24.7.0':
dependencies:
undici-types: 7.14.0
@@ -6669,6 +7015,14 @@ snapshots:
'@types/node': 24.7.0
'@types/send': 0.17.5
+ '@types/ssh2-sftp-client@9.0.6':
+ dependencies:
+ '@types/ssh2': 1.15.5
+
+ '@types/ssh2@1.15.5':
+ dependencies:
+ '@types/node': 18.19.130
+
'@types/trusted-types@2.0.7':
optional: true
@@ -6745,6 +7099,10 @@ snapshots:
array-flatten@1.1.1: {}
+ asn1@0.2.6:
+ dependencies:
+ safer-buffer: 2.1.2
+
assertion-error@2.0.1: {}
asynckit@0.4.0: {}
@@ -6773,6 +7131,15 @@ snapshots:
baseline-browser-mapping@2.8.12: {}
+ bcrypt-pbkdf@1.0.2:
+ dependencies:
+ tweetnacl: 0.14.5
+
+ bcrypt@6.0.0:
+ dependencies:
+ node-addon-api: 8.5.0
+ node-gyp-build: 4.8.4
+
body-parser@1.20.3:
dependencies:
bytes: 3.1.2
@@ -6800,8 +7167,13 @@ snapshots:
node-releases: 2.0.23
update-browserslist-db: 1.1.3(browserslist@4.26.3)
+ buffer-equal-constant-time@1.0.1: {}
+
buffer-from@1.1.2: {}
+ buildcheck@0.0.7:
+ optional: true
+
bytes@3.1.2: {}
cac@6.7.14: {}
@@ -6882,6 +7254,13 @@ snapshots:
commander@8.3.0: {}
+ concat-stream@2.0.0:
+ dependencies:
+ buffer-from: 1.1.2
+ inherits: 2.0.4
+ readable-stream: 3.6.2
+ typedarray: 0.0.6
+
confbox@0.1.8: {}
confbox@0.2.2: {}
@@ -6912,6 +7291,12 @@ snapshots:
dependencies:
layout-base: 2.0.1
+ cpu-features@0.0.10:
+ dependencies:
+ buildcheck: 0.0.7
+ nan: 2.24.0
+ optional: true
+
cssesc@3.0.0: {}
csstype@3.1.3: {}
@@ -7174,6 +7559,10 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
+ ecdsa-sig-formatter@1.0.11:
+ dependencies:
+ safe-buffer: 5.2.1
+
ee-first@1.1.1: {}
electron-to-chromium@1.5.230: {}
@@ -7651,6 +8040,30 @@ snapshots:
json5@2.2.3: {}
+ jsonwebtoken@9.0.3:
+ dependencies:
+ jws: 4.0.1
+ lodash.includes: 4.3.0
+ lodash.isboolean: 3.0.3
+ lodash.isinteger: 4.0.4
+ lodash.isnumber: 3.0.3
+ lodash.isplainobject: 4.0.6
+ lodash.isstring: 4.0.1
+ lodash.once: 4.1.1
+ ms: 2.1.3
+ semver: 7.7.3
+
+ jwa@2.0.1:
+ dependencies:
+ buffer-equal-constant-time: 1.0.1
+ ecdsa-sig-formatter: 1.0.11
+ safe-buffer: 5.2.1
+
+ jws@4.0.1:
+ dependencies:
+ jwa: 2.0.1
+ safe-buffer: 5.2.1
+
katex@0.16.25:
dependencies:
commander: 8.3.0
@@ -7724,6 +8137,20 @@ snapshots:
lodash-es@4.17.21: {}
+ lodash.includes@4.3.0: {}
+
+ lodash.isboolean@3.0.3: {}
+
+ lodash.isinteger@4.0.4: {}
+
+ lodash.isnumber@3.0.3: {}
+
+ lodash.isplainobject@4.0.6: {}
+
+ lodash.isstring@4.0.1: {}
+
+ lodash.once@4.1.1: {}
+
lodash@4.17.21: {}
long@5.3.2: {}
@@ -7756,6 +8183,10 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
+ make-cancellable-promise@2.0.0: {}
+
+ make-event-props@2.0.0: {}
+
markdown-table@3.0.4: {}
marked@16.4.1: {}
@@ -7931,6 +8362,10 @@ snapshots:
merge-descriptors@1.0.3: {}
+ merge-refs@2.0.0(@types/react@19.2.1):
+ optionalDependencies:
+ '@types/react': 19.2.1
+
mermaid@11.12.0:
dependencies:
'@braintree/sanitize-url': 7.1.1
@@ -8210,6 +8645,9 @@ snapshots:
dependencies:
lru-cache: 7.18.3
+ nan@2.24.0:
+ optional: true
+
nanoid@3.3.11: {}
nanoid@5.1.6: {}
@@ -8221,6 +8659,10 @@ snapshots:
react: 19.2.1
react-dom: 19.2.1(react@19.2.1)
+ node-addon-api@8.5.0: {}
+
+ node-gyp-build@4.8.4: {}
+
node-releases@2.0.23: {}
normalize-range@0.1.2: {}
@@ -8243,6 +8685,8 @@ snapshots:
package-manager-detector@1.5.0: {}
+ pako@1.0.11: {}
+
parse-entities@4.0.2:
dependencies:
'@types/unist': 2.0.11
@@ -8269,6 +8713,17 @@ snapshots:
pathval@2.0.1: {}
+ pdf-lib@1.17.1:
+ dependencies:
+ '@pdf-lib/standard-fonts': 1.0.0
+ '@pdf-lib/upng': 1.0.1
+ pako: 1.0.11
+ tslib: 1.14.1
+
+ pdfjs-dist@5.4.296:
+ optionalDependencies:
+ '@napi-rs/canvas': 0.1.88
+
picocolors@1.1.1: {}
picomatch@4.0.3: {}
@@ -8379,6 +8834,21 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ react-pdf@10.3.0(@types/react@19.2.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1):
+ dependencies:
+ clsx: 2.1.1
+ dequal: 2.0.3
+ make-cancellable-promise: 2.0.0
+ make-event-props: 2.0.0
+ merge-refs: 2.0.0(@types/react@19.2.1)
+ pdfjs-dist: 5.4.296
+ react: 19.2.1
+ react-dom: 19.2.1(react@19.2.1)
+ tiny-invariant: 1.3.3
+ warning: 4.0.3
+ optionalDependencies:
+ '@types/react': 19.2.1
+
react-refresh@0.17.0: {}
react-remove-scroll-bar@2.3.8(@types/react@19.2.1)(react@19.2.1):
@@ -8432,6 +8902,12 @@ snapshots:
react@19.2.1: {}
+ readable-stream@3.6.2:
+ dependencies:
+ inherits: 2.0.4
+ string_decoder: 1.3.0
+ util-deprecate: 1.0.2
+
recharts-scale@0.4.5:
dependencies:
decimal.js-light: 2.5.1
@@ -8571,6 +9047,8 @@ snapshots:
semver@6.3.1: {}
+ semver@7.7.3: {}
+
send@0.19.0:
dependencies:
debug: 2.6.9
@@ -8661,6 +9139,19 @@ snapshots:
sqlstring@2.3.3: {}
+ ssh2-sftp-client@12.0.1:
+ dependencies:
+ concat-stream: 2.0.0
+ ssh2: 1.17.0
+
+ ssh2@1.17.0:
+ dependencies:
+ asn1: 0.2.6
+ bcrypt-pbkdf: 1.0.2
+ optionalDependencies:
+ cpu-features: 0.0.10
+ nan: 2.24.0
+
stackback@0.0.2: {}
statuses@2.0.1: {}
@@ -8687,6 +9178,10 @@ snapshots:
- '@types/react'
- supports-color
+ string_decoder@1.3.0:
+ dependencies:
+ safe-buffer: 5.2.1
+
stringify-entities@4.0.4:
dependencies:
character-entities-html4: 2.1.0
@@ -8753,6 +9248,8 @@ snapshots:
ts-dedent@2.2.0: {}
+ tslib@1.14.1: {}
+
tslib@2.8.1: {}
tsx@4.20.6:
@@ -8764,15 +9261,21 @@ snapshots:
tw-animate-css@1.4.0: {}
+ tweetnacl@0.14.5: {}
+
type-is@1.6.18:
dependencies:
media-typer: 0.3.0
mime-types: 2.1.35
+ typedarray@0.0.6: {}
+
typescript@5.9.3: {}
ufo@1.6.1: {}
+ undici-types@5.26.5: {}
+
undici-types@7.14.0: {}
unified@11.0.5:
@@ -8851,6 +9354,8 @@ snapshots:
uuid@11.1.0: {}
+ uuid@8.3.2: {}
+
vary@1.1.2: {}
vaul@1.1.2(@types/react-dom@19.2.1(@types/react@19.2.1))(@types/react@19.2.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1):
@@ -8999,6 +9504,10 @@ snapshots:
vscode-uri@3.0.8: {}
+ warning@4.0.3:
+ dependencies:
+ loose-envify: 1.4.0
+
web-namespaces@2.0.1: {}
why-is-node-running@2.3.0:
diff --git a/server/_core/index.ts b/server/_core/index.ts
index f472331..6704b67 100644
--- a/server/_core/index.ts
+++ b/server/_core/index.ts
@@ -35,6 +35,9 @@ async function startServer() {
app.use(express.urlencoded({ limit: "50mb", extended: true }));
// OAuth callback under /api/oauth/callback
registerOAuthRoutes(app);
+ // Serve local storage files
+ app.use("/storage", express.static("storage"));
+
// tRPC API
app.use(
"/api/trpc",
diff --git a/server/_core/oauth.ts b/server/_core/oauth.ts
index fd45373..67e9af2 100644
--- a/server/_core/oauth.ts
+++ b/server/_core/oauth.ts
@@ -31,8 +31,8 @@ export function registerOAuthRoutes(app: Express) {
await db.upsertUser({
openId: userInfo.openId,
name: userInfo.name || null,
- email: userInfo.email ?? null,
- loginMethod: userInfo.loginMethod ?? userInfo.platform ?? null,
+ email: userInfo.email ?? "unknown@example.com",
+ loginMethod: (userInfo.loginMethod ?? userInfo.platform ?? "manus") as "manus" | "local" | "azure-ad",
lastSignedIn: new Date(),
});
diff --git a/server/_core/sdk.ts b/server/_core/sdk.ts
index 230e762..f82826d 100644
--- a/server/_core/sdk.ts
+++ b/server/_core/sdk.ts
@@ -277,8 +277,8 @@ class SDKServer {
await db.upsertUser({
openId: userInfo.openId,
name: userInfo.name || null,
- email: userInfo.email ?? null,
- loginMethod: userInfo.loginMethod ?? userInfo.platform ?? null,
+ email: userInfo.email ?? "unknown@example.com",
+ loginMethod: (userInfo.loginMethod ?? userInfo.platform ?? "manus") as "manus" | "local" | "azure-ad",
lastSignedIn: signedInAt,
});
user = await db.getUserByOpenId(userInfo.openId);
@@ -294,6 +294,8 @@ class SDKServer {
await db.upsertUser({
openId: user.openId,
+ email: user.email,
+ loginMethod: user.loginMethod,
lastSignedIn: signedInAt,
});
diff --git a/server/auth.logout.test.ts b/server/auth.logout.test.ts
index 3071539..1c1fe3d 100644
--- a/server/auth.logout.test.ts
+++ b/server/auth.logout.test.ts
@@ -49,9 +49,16 @@ describe("auth.logout", () => {
const result = await caller.auth.logout();
expect(result).toEqual({ success: true });
- expect(clearedCookies).toHaveLength(1);
- expect(clearedCookies[0]?.name).toBe(COOKIE_NAME);
- expect(clearedCookies[0]?.options).toMatchObject({
+ expect(clearedCookies).toHaveLength(2); // COOKIE_NAME + auth_token
+
+ // Check that both cookies are cleared
+ const cookieNames = clearedCookies.map(c => c.name);
+ expect(cookieNames).toContain(COOKIE_NAME);
+ expect(cookieNames).toContain("auth_token");
+
+ // Check options for the main cookie
+ const mainCookie = clearedCookies.find(c => c.name === COOKIE_NAME);
+ expect(mainCookie?.options).toMatchObject({
maxAge: -1,
secure: true,
sameSite: "none",
diff --git a/server/auth.test.ts b/server/auth.test.ts
new file mode 100644
index 0000000..91900c9
--- /dev/null
+++ b/server/auth.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, it } from "vitest";
+import { hashPassword, verifyPassword, generateToken, verifyToken } from "./auth";
+
+describe("Authentication", () => {
+ describe("Password hashing", () => {
+ it("should hash a password", async () => {
+ const password = "testpassword123";
+ const hash = await hashPassword(password);
+
+ expect(hash).toBeDefined();
+ expect(hash).not.toBe(password);
+ expect(hash.length).toBeGreaterThan(0);
+ });
+
+ it("should verify a correct password", async () => {
+ const password = "testpassword123";
+ const hash = await hashPassword(password);
+
+ const isValid = await verifyPassword(password, hash);
+ expect(isValid).toBe(true);
+ });
+
+ it("should reject an incorrect password", async () => {
+ const password = "testpassword123";
+ const hash = await hashPassword(password);
+
+ const isValid = await verifyPassword("wrongpassword", hash);
+ expect(isValid).toBe(false);
+ });
+ });
+
+ describe("JWT tokens", () => {
+ it("should generate a valid JWT token", () => {
+ const user = {
+ id: 1,
+ email: "test@example.com",
+ role: "user",
+ };
+
+ const token = generateToken(user);
+
+ expect(token).toBeDefined();
+ expect(typeof token).toBe("string");
+ expect(token.length).toBeGreaterThan(0);
+ });
+
+ it("should verify and decode a valid token", () => {
+ const user = {
+ id: 1,
+ email: "test@example.com",
+ role: "user",
+ };
+
+ const token = generateToken(user);
+ const decoded = verifyToken(token);
+
+ expect(decoded).toBeDefined();
+ expect(decoded?.userId).toBe(user.id);
+ expect(decoded?.email).toBe(user.email);
+ expect(decoded?.role).toBe(user.role);
+ });
+
+ it("should reject an invalid token", () => {
+ const decoded = verifyToken("invalid-token");
+ expect(decoded).toBeNull();
+ });
+ });
+});
diff --git a/server/auth.ts b/server/auth.ts
new file mode 100644
index 0000000..d304d62
--- /dev/null
+++ b/server/auth.ts
@@ -0,0 +1,166 @@
+import bcrypt from "bcrypt";
+import { ConfidentialClientApplication } from "@azure/msal-node";
+import { getUserByEmail, getUserByAzureAdId } from "./db";
+import jwt from "jsonwebtoken";
+
+const SALT_ROUNDS = 10;
+
+// ============= LOCAL AUTHENTICATION =============
+
+/**
+ * Hash a password using bcrypt
+ */
+export async function hashPassword(password: string): Promise {
+ return bcrypt.hash(password, SALT_ROUNDS);
+}
+
+/**
+ * Verify a password against a hash
+ */
+export async function verifyPassword(password: string, hash: string): Promise {
+ return bcrypt.compare(password, hash);
+}
+
+/**
+ * Authenticate a user with email and password (local auth)
+ * Returns user and JWT token if successful, null otherwise
+ */
+export async function loginLocal(email: string, password: string) {
+ const user = await getUserByEmail(email);
+
+ if (!user) {
+ return null;
+ }
+
+ // Check if user is active
+ if (user.isActive === 0) {
+ throw new Error("Account is inactive");
+ }
+
+ // Check if user has a password (local auth)
+ if (!user.passwordHash) {
+ throw new Error("This account does not support local authentication");
+ }
+
+ // Verify password
+ const isValid = await verifyPassword(password, user.passwordHash);
+ if (!isValid) {
+ return null;
+ }
+
+ // Generate JWT token
+ const token = generateToken(user);
+
+ return { user, token };
+}
+
+// ============= JWT TOKEN GENERATION =============
+
+/**
+ * Generate a JWT token for a user
+ */
+export function generateToken(user: { id: number; email: string; role: string }): string {
+ const secret = process.env.JWT_SECRET || "default-secret-change-in-production";
+
+ return jwt.sign(
+ {
+ userId: user.id,
+ email: user.email,
+ role: user.role,
+ },
+ secret,
+ { expiresIn: "7d" }
+ );
+}
+
+/**
+ * Verify and decode a JWT token
+ */
+export function verifyToken(token: string): { userId: number; email: string; role: string } | null {
+ try {
+ const secret = process.env.JWT_SECRET || "default-secret-change-in-production";
+ const decoded = jwt.verify(token, secret) as { userId: number; email: string; role: string };
+ return decoded;
+ } catch (error) {
+ return null;
+ }
+}
+
+// ============= AZURE AD AUTHENTICATION =============
+
+let msalClient: ConfidentialClientApplication | null = null;
+
+/**
+ * Check if Azure AD is configured
+ */
+export function isAzureAdConfigured(): boolean {
+ return !!(
+ process.env.AZURE_AD_TENANT_ID &&
+ process.env.AZURE_AD_CLIENT_ID &&
+ process.env.AZURE_AD_CLIENT_SECRET
+ );
+}
+
+/**
+ * Get MSAL client instance (lazy initialization)
+ */
+function getMsalClient(): ConfidentialClientApplication {
+ if (!isAzureAdConfigured()) {
+ throw new Error("Azure AD is not configured");
+ }
+
+ if (!msalClient) {
+ msalClient = new ConfidentialClientApplication({
+ auth: {
+ clientId: process.env.AZURE_AD_CLIENT_ID!,
+ authority: `https://login.microsoftonline.com/${process.env.AZURE_AD_TENANT_ID}`,
+ clientSecret: process.env.AZURE_AD_CLIENT_SECRET!,
+ },
+ });
+ }
+
+ return msalClient;
+}
+
+/**
+ * Get Azure AD authorization URL for user login
+ */
+export async function getAzureAuthUrl(): Promise {
+ const client = getMsalClient();
+
+ const redirectUri = process.env.AZURE_AD_REDIRECT_URI || "http://localhost:3000/api/auth/azure/callback";
+
+ const authCodeUrlParameters = {
+ scopes: ["user.read"],
+ redirectUri,
+ };
+
+ return client.getAuthCodeUrl(authCodeUrlParameters);
+}
+
+/**
+ * Handle Azure AD callback and exchange code for tokens
+ */
+export async function handleAzureCallback(code: string) {
+ const client = getMsalClient();
+
+ const redirectUri = process.env.AZURE_AD_REDIRECT_URI || "http://localhost:3000/api/auth/azure/callback";
+
+ const tokenRequest = {
+ code,
+ scopes: ["user.read"],
+ redirectUri,
+ };
+
+ const response = await client.acquireTokenByCode(tokenRequest);
+
+ if (!response || !response.account) {
+ throw new Error("Failed to acquire token from Azure AD");
+ }
+
+ return {
+ azureAdId: response.account.homeAccountId,
+ email: response.account.username,
+ name: response.account.name || response.account.username,
+ };
+}
diff --git a/server/db.ts b/server/db.ts
index 795c205..81be172 100644
--- a/server/db.ts
+++ b/server/db.ts
@@ -1,11 +1,28 @@
-import { eq } from "drizzle-orm";
+import { eq, and, desc, sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/mysql2";
-import { InsertUser, users } from "../drizzle/schema";
+import {
+ InsertUser,
+ users,
+ sourceFiles,
+ InsertSourceFile,
+ SourceFile,
+ invoices,
+ InsertInvoice,
+ Invoice,
+ userSettings,
+ InsertUserSettings,
+ UserSettings,
+ importLogs,
+ InsertImportLog,
+ ImportLog,
+ llmLogs,
+ InsertLlmLog,
+ LlmLog
+} from "../drizzle/schema";
import { ENV } from './_core/env';
let _db: ReturnType | null = null;
-// Lazily create the drizzle instance so local tooling can run without a DB.
export async function getDb() {
if (!_db && process.env.DATABASE_URL) {
try {
@@ -18,11 +35,9 @@ export async function getDb() {
return _db;
}
-export async function upsertUser(user: InsertUser): Promise {
- if (!user.openId) {
- throw new Error("User openId is required for upsert");
- }
+// ============= USER OPERATIONS =============
+export async function upsertUser(user: InsertUser): Promise {
const db = await getDb();
if (!db) {
console.warn("[Database] Cannot upsert user: database not available");
@@ -31,23 +46,31 @@ export async function upsertUser(user: InsertUser): Promise {
try {
const values: InsertUser = {
- openId: user.openId,
+ email: user.email,
+ loginMethod: user.loginMethod,
};
const updateSet: Record = {};
- const textFields = ["name", "email", "loginMethod"] as const;
- type TextField = (typeof textFields)[number];
-
- const assignNullable = (field: TextField) => {
- const value = user[field];
- if (value === undefined) return;
- const normalized = value ?? null;
- values[field] = normalized;
- updateSet[field] = normalized;
- };
-
- textFields.forEach(assignNullable);
-
+ if (user.openId !== undefined) {
+ values.openId = user.openId;
+ updateSet.openId = user.openId;
+ }
+ if (user.azureAdId !== undefined) {
+ values.azureAdId = user.azureAdId;
+ updateSet.azureAdId = user.azureAdId;
+ }
+ if (user.name !== undefined) {
+ values.name = user.name;
+ updateSet.name = user.name;
+ }
+ if (user.passwordHash !== undefined) {
+ values.passwordHash = user.passwordHash;
+ updateSet.passwordHash = user.passwordHash;
+ }
+ if (user.isActive !== undefined) {
+ values.isActive = user.isActive;
+ updateSet.isActive = user.isActive;
+ }
if (user.lastSignedIn !== undefined) {
values.lastSignedIn = user.lastSignedIn;
updateSet.lastSignedIn = user.lastSignedIn;
@@ -79,14 +102,252 @@ export async function upsertUser(user: InsertUser): Promise {
export async function getUserByOpenId(openId: string) {
const db = await getDb();
- if (!db) {
- console.warn("[Database] Cannot get user: database not available");
- return undefined;
- }
-
+ if (!db) return undefined;
const result = await db.select().from(users).where(eq(users.openId, openId)).limit(1);
-
return result.length > 0 ? result[0] : undefined;
}
-// TODO: add feature queries here as your schema grows.
+export async function getUserByEmail(email: string) {
+ const db = await getDb();
+ if (!db) return undefined;
+ const result = await db.select().from(users).where(eq(users.email, email)).limit(1);
+ return result.length > 0 ? result[0] : undefined;
+}
+
+export async function getUserByAzureAdId(azureAdId: string) {
+ const db = await getDb();
+ if (!db) return undefined;
+ const result = await db.select().from(users).where(eq(users.azureAdId, azureAdId)).limit(1);
+ return result.length > 0 ? result[0] : undefined;
+}
+
+export async function createLocalUser(email: string, passwordHash: string, name: string, role: "user" | "admin" = "user") {
+ const db = await getDb();
+ if (!db) throw new Error("Database not available");
+
+ await db.insert(users).values({
+ email,
+ passwordHash,
+ name,
+ loginMethod: "local",
+ role,
+ isActive: 1,
+ });
+
+ return getUserByEmail(email);
+}
+
+export async function getAllUsers() {
+ const db = await getDb();
+ if (!db) return [];
+ return db.select().from(users).orderBy(desc(users.createdAt));
+}
+
+export async function updateUserPassword(userId: number, newPasswordHash: string) {
+ const db = await getDb();
+ if (!db) throw new Error("Database not available");
+ await db.update(users).set({ passwordHash: newPasswordHash }).where(eq(users.id, userId));
+}
+
+export async function toggleUserActive(userId: number, isActive: number) {
+ const db = await getDb();
+ if (!db) throw new Error("Database not available");
+ await db.update(users).set({ isActive }).where(eq(users.id, userId));
+}
+
+export async function deleteUser(userId: number) {
+ const db = await getDb();
+ if (!db) throw new Error("Database not available");
+ await db.delete(users).where(eq(users.id, userId));
+}
+
+// ============= SOURCE FILE OPERATIONS =============
+
+export async function createSourceFile(data: InsertSourceFile): Promise {
+ const db = await getDb();
+ if (!db) throw new Error("Database not available");
+
+ const result = await db.insert(sourceFiles).values(data);
+ const insertedId = Number(result[0].insertId);
+
+ const inserted = await db.select().from(sourceFiles).where(eq(sourceFiles.id, insertedId)).limit(1);
+ return inserted[0]!;
+}
+
+export async function getSourceFileById(id: number): Promise {
+ const db = await getDb();
+ if (!db) return undefined;
+ const result = await db.select().from(sourceFiles).where(eq(sourceFiles.id, id)).limit(1);
+ return result[0];
+}
+
+export async function updateSourceFile(id: number, data: Partial) {
+ const db = await getDb();
+ if (!db) throw new Error("Database not available");
+ await db.update(sourceFiles).set(data).where(eq(sourceFiles.id, id));
+}
+
+export async function getSourceFilesByUserId(userId: number): Promise {
+ const db = await getDb();
+ if (!db) return [];
+ return db.select().from(sourceFiles).where(eq(sourceFiles.userId, userId)).orderBy(desc(sourceFiles.createdAt));
+}
+
+// ============= INVOICE OPERATIONS =============
+
+export async function createInvoice(data: InsertInvoice): Promise {
+ const db = await getDb();
+ if (!db) throw new Error("Database not available");
+
+ const result = await db.insert(invoices).values(data);
+ const insertedId = Number(result[0].insertId);
+
+ const inserted = await db.select().from(invoices).where(eq(invoices.id, insertedId)).limit(1);
+ return inserted[0]!;
+}
+
+export async function getInvoiceById(id: number): Promise {
+ const db = await getDb();
+ if (!db) return undefined;
+ const result = await db.select().from(invoices).where(eq(invoices.id, id)).limit(1);
+ return result[0];
+}
+
+export async function getInvoicesByUserId(userId: number): Promise {
+ const db = await getDb();
+ if (!db) return [];
+ return db.select().from(invoices).where(eq(invoices.userId, userId)).orderBy(desc(invoices.createdAt));
+}
+
+export async function getInvoicesByUser(userId: number): Promise {
+ return getInvoicesByUserId(userId);
+}
+
+export async function updateInvoice(id: number, data: Partial) {
+ const db = await getDb();
+ if (!db) throw new Error("Database not available");
+ await db.update(invoices).set(data).where(eq(invoices.id, id));
+}
+
+export async function deleteInvoice(id: number) {
+ const db = await getDb();
+ if (!db) throw new Error("Database not available");
+ await db.delete(invoices).where(eq(invoices.id, id));
+}
+
+export async function searchInvoices(userId: number, query: string): Promise {
+ const db = await getDb();
+ if (!db) return [];
+
+ const searchPattern = `%${query}%`;
+ return db.select().from(invoices)
+ .where(
+ and(
+ eq(invoices.userId, userId),
+ sql`(${invoices.supplierName} LIKE ${searchPattern} OR ${invoices.invoiceNumber} LIKE ${searchPattern})`
+ )
+ )
+ .orderBy(desc(invoices.createdAt));
+}
+
+export async function getInvoiceStats(userId: number) {
+ const db = await getDb();
+ if (!db) return { total: 0, completed: 0, processing: 0, error: 0 };
+
+ const allInvoices = await getInvoicesByUserId(userId);
+ return {
+ total: allInvoices.length,
+ completed: allInvoices.filter(i => i.status === "completed").length,
+ processing: allInvoices.filter(i => i.status === "processing").length,
+ error: allInvoices.filter(i => i.status === "error").length,
+ };
+}
+
+export async function findDuplicateInvoice(
+ supplierName: string | null,
+ invoiceNumber: string | null,
+ invoiceDate: Date | null
+): Promise {
+ if (!supplierName || !invoiceNumber || !invoiceDate) return undefined;
+
+ const db = await getDb();
+ if (!db) return undefined;
+
+ const result = await db.select().from(invoices)
+ .where(
+ and(
+ eq(invoices.supplierName, supplierName),
+ eq(invoices.invoiceNumber, invoiceNumber),
+ eq(invoices.invoiceDate, invoiceDate)
+ )
+ )
+ .limit(1);
+
+ return result[0];
+}
+
+// ============= USER SETTINGS OPERATIONS =============
+
+export async function getUserSettings(userId: number): Promise {
+ const db = await getDb();
+ if (!db) return undefined;
+ const result = await db.select().from(userSettings).where(eq(userSettings.userId, userId)).limit(1);
+ return result[0];
+}
+
+export async function upsertUserSettings(data: InsertUserSettings) {
+ const db = await getDb();
+ if (!db) throw new Error("Database not available");
+
+ const existing = await getUserSettings(data.userId);
+
+ if (existing) {
+ await db.update(userSettings).set(data).where(eq(userSettings.userId, data.userId));
+ } else {
+ await db.insert(userSettings).values(data);
+ }
+}
+
+// ============= IMPORT LOG OPERATIONS =============
+
+export async function createImportLog(data: InsertImportLog): Promise {
+ const db = await getDb();
+ if (!db) throw new Error("Database not available");
+
+ const result = await db.insert(importLogs).values(data);
+ const insertedId = Number(result[0].insertId);
+
+ const inserted = await db.select().from(importLogs).where(eq(importLogs.id, insertedId)).limit(1);
+ return inserted[0]!;
+}
+
+export async function getImportLogsByUser(userId: number): Promise {
+ const db = await getDb();
+ if (!db) return [];
+ return db.select().from(importLogs).where(eq(importLogs.userId, userId)).orderBy(desc(importLogs.importedAt));
+}
+
+// ============= LLM LOG OPERATIONS =============
+
+export async function createLlmLog(data: InsertLlmLog): Promise {
+ const db = await getDb();
+ if (!db) throw new Error("Database not available");
+
+ const result = await db.insert(llmLogs).values(data);
+ const insertedId = Number(result[0].insertId);
+
+ const inserted = await db.select().from(llmLogs).where(eq(llmLogs.id, insertedId)).limit(1);
+ return inserted[0]!;
+}
+
+export async function getLlmLogsBySourceFile(sourceFileId: number): Promise {
+ const db = await getDb();
+ if (!db) return [];
+ return db.select().from(llmLogs).where(eq(llmLogs.sourceFileId, sourceFileId)).orderBy(desc(llmLogs.createdAt));
+}
+
+export async function getLlmLogsByInvoice(invoiceId: number): Promise {
+ const db = await getDb();
+ if (!db) return [];
+ return db.select().from(llmLogs).where(eq(llmLogs.invoiceId, invoiceId)).orderBy(desc(llmLogs.createdAt));
+}
diff --git a/server/invoiceExtractor.ts b/server/invoiceExtractor.ts
new file mode 100644
index 0000000..af73218
--- /dev/null
+++ b/server/invoiceExtractor.ts
@@ -0,0 +1,262 @@
+import { invokeLLM } from "./_core/llm";
+import { PDFDocument } from "pdf-lib";
+import { createLlmLog } from "./db";
+
+export interface ExtractedInvoiceData {
+ supplierName: string | null;
+ invoiceNumber: string | null;
+ invoiceDate: Date | null;
+ deliveryNoteNumber: string | null;
+ orderNumber: string | null;
+ totalAmount: number | null;
+ pageRange: string;
+ qualityScore: number; // 0-100
+}
+
+export interface MultiInvoiceResult {
+ pageCount: number;
+ invoiceCount: number;
+ invoices: ExtractedInvoiceData[];
+}
+
+/**
+ * Convert PDF buffer to base64 data URI for Mistral API processing
+ */
+function convertPdfToBase64(pdfBuffer: Buffer): string {
+ try {
+ const base64Pdf = pdfBuffer.toString("base64");
+ const dataUri = `data:application/pdf;base64,${base64Pdf}`;
+ console.log("[Mistral] PDF converted to base64, size:", Math.round(base64Pdf.length / 1024), "KB");
+ return dataUri;
+ } catch (error) {
+ console.error("Error converting PDF to base64:", error);
+ throw new Error("Failed to convert PDF to base64");
+ }
+}
+
+/**
+ * Clean JSON response from LLM by removing markdown code blocks
+ */
+function cleanJsonResponse(content: string): string {
+ let cleaned = content.trim();
+
+ // Remove markdown code blocks
+ cleaned = cleaned.replace(/^```(?:json)?\s*/i, "");
+ cleaned = cleaned.replace(/\s*```$/, "");
+ cleaned = cleaned.trim();
+
+ // Try to extract JSON object or array
+ const firstBrace = cleaned.indexOf("{");
+ const firstBracket = cleaned.indexOf("[");
+
+ let startIdx = -1;
+ let startChar = "";
+
+ if (firstBrace !== -1 && (firstBracket === -1 || firstBrace < firstBracket)) {
+ startIdx = firstBrace;
+ startChar = "{";
+ } else if (firstBracket !== -1) {
+ startIdx = firstBracket;
+ startChar = "[";
+ }
+
+ if (startIdx === -1) {
+ return cleaned;
+ }
+
+ const endChar = startChar === "{" ? "}" : "]";
+ let depth = 0;
+ let endIdx = -1;
+
+ for (let i = startIdx; i < cleaned.length; i++) {
+ if (cleaned[i] === startChar) depth++;
+ if (cleaned[i] === endChar) {
+ depth--;
+ if (depth === 0) {
+ endIdx = i;
+ break;
+ }
+ }
+ }
+
+ if (endIdx !== -1) {
+ cleaned = cleaned.substring(startIdx, endIdx + 1);
+ }
+
+ return cleaned;
+}
+
+/**
+ * Extract invoice data using Mistral AI
+ * Hybrid approach: OCR to extract text, then LLM to parse and extract structured data
+ */
+export async function extractInvoicesWithMistral(
+ pdfBuffer: Buffer,
+ userId: number,
+ sourceFileId: number,
+ model: string = "mistral-large-latest",
+ customKeywords?: {
+ invoiceNumber?: string | null;
+ deliveryNote?: string | null;
+ orderNumber?: string | null;
+ supplier?: string | null;
+ totalAmount?: string | null;
+ }
+): Promise {
+ const startTime = Date.now();
+
+ try {
+ console.log("[Mistral] Starting invoice extraction...");
+
+ // Convert PDF to base64
+ const pdfDataUri = convertPdfToBase64(pdfBuffer);
+
+ // Get PDF page count
+ const pdfDoc = await PDFDocument.load(pdfBuffer);
+ const pageCount = pdfDoc.getPageCount();
+
+ console.log(`[Mistral] PDF has ${pageCount} pages`);
+
+ // Build custom keywords hint
+ let keywordsHint = "";
+ if (customKeywords) {
+ const hints = [];
+ if (customKeywords.invoiceNumber) hints.push(`Numéro de facture: ${customKeywords.invoiceNumber}`);
+ if (customKeywords.deliveryNote) hints.push(`Bon de livraison: ${customKeywords.deliveryNote}`);
+ if (customKeywords.orderNumber) hints.push(`Numéro de commande: ${customKeywords.orderNumber}`);
+ if (customKeywords.supplier) hints.push(`Fournisseur: ${customKeywords.supplier}`);
+ if (customKeywords.totalAmount) hints.push(`Montant total: ${customKeywords.totalAmount}`);
+
+ if (hints.length > 0) {
+ keywordsHint = `\n\nMots-clés personnalisés à rechercher:\n${hints.join("\n")}`;
+ }
+ }
+
+ // Prepare prompt for LLM
+ const prompt = `Tu es un expert en extraction de données de factures. Analyse ce document PDF et extrais toutes les factures qu'il contient.
+
+Pour chaque facture trouvée, extrais les informations suivantes:
+- supplierName: Nom du fournisseur/vendeur
+- invoiceNumber: Numéro de la facture
+- invoiceDate: Date de la facture (format ISO 8601: YYYY-MM-DD)
+- deliveryNoteNumber: Numéro du bon de livraison (si présent)
+- orderNumber: Numéro de commande client (si présent)
+- totalAmount: Montant total TTC (nombre décimal)
+- pageRange: Plage de pages de cette facture (ex: "1-2" ou "5")
+- qualityScore: Score de qualité de l'extraction de 0 à 100 (100 = toutes les informations trouvées et claires)${keywordsHint}
+
+Réponds UNIQUEMENT avec un objet JSON valide au format suivant:
+{
+ "pageCount": ${pageCount},
+ "invoiceCount": ,
+ "invoices": [
+ {
+ "supplierName": "...",
+ "invoiceNumber": "...",
+ "invoiceDate": "YYYY-MM-DD",
+ "deliveryNoteNumber": "...",
+ "orderNumber": "...",
+ "totalAmount": 123.45,
+ "pageRange": "1-2",
+ "qualityScore": 85
+ }
+ ]
+}
+
+Si une information n'est pas trouvée, utilise null. Ne retourne AUCUN texte en dehors du JSON.`;
+
+ // Call Mistral LLM with PDF
+ const response = await invokeLLM({
+ messages: [
+ {
+ role: "user",
+ content: [
+ { type: "text", text: prompt },
+ { type: "file_url", file_url: { url: pdfDataUri, mime_type: "application/pdf" } },
+ ],
+ },
+ ],
+ });
+
+ const rawResponse = typeof response.choices[0]?.message?.content === "string"
+ ? response.choices[0].message.content
+ : JSON.stringify(response.choices[0]?.message?.content || "");
+ const processingTimeMs = Date.now() - startTime;
+
+ console.log("[Mistral] Raw response received:", rawResponse.substring(0, 200));
+
+ // Clean and parse response
+ const cleanedResponse = cleanJsonResponse(rawResponse);
+
+ let result: MultiInvoiceResult;
+ try {
+ result = JSON.parse(cleanedResponse);
+ } catch (parseError) {
+ console.error("[Mistral] Failed to parse JSON:", parseError);
+ console.error("[Mistral] Cleaned response:", cleanedResponse);
+
+ // Log error to database
+ await createLlmLog({
+ userId,
+ sourceFileId,
+ operation: "extraction",
+ model,
+ promptSent: prompt,
+ rawResponse: rawResponse.substring(0, 10000),
+ cleanedResponse: cleanedResponse.substring(0, 10000),
+ success: 0,
+ errorMessage: `JSON parse error: ${parseError}`,
+ processingTimeMs,
+ });
+
+ throw new Error("Failed to parse LLM response as JSON");
+ }
+
+ // Log successful extraction
+ await createLlmLog({
+ userId,
+ sourceFileId,
+ operation: "extraction",
+ model,
+ promptSent: prompt.substring(0, 10000),
+ rawResponse: rawResponse.substring(0, 10000),
+ cleanedResponse: cleanedResponse.substring(0, 10000),
+ success: 1,
+ processingTimeMs,
+ });
+
+ // Convert date strings to Date objects
+ result.invoices = result.invoices.map((inv) => ({
+ ...inv,
+ invoiceDate: inv.invoiceDate ? new Date(inv.invoiceDate) : null,
+ }));
+
+ console.log(`[Mistral] Successfully extracted ${result.invoiceCount} invoice(s)`);
+
+ return result;
+ } catch (error) {
+ console.error("[Mistral] Extraction failed:", error);
+ throw error;
+ }
+}
+
+/**
+ * Generate metadata JSON for an invoice
+ */
+export function generateMetadataJSON(invoice: ExtractedInvoiceData): string {
+ return JSON.stringify(
+ {
+ supplierName: invoice.supplierName,
+ invoiceNumber: invoice.invoiceNumber,
+ invoiceDate: invoice.invoiceDate?.toISOString(),
+ deliveryNoteNumber: invoice.deliveryNoteNumber,
+ orderNumber: invoice.orderNumber,
+ totalAmount: invoice.totalAmount,
+ pageRange: invoice.pageRange,
+ qualityScore: invoice.qualityScore,
+ extractedAt: new Date().toISOString(),
+ },
+ null,
+ 2
+ );
+}
diff --git a/server/localStorage.ts b/server/localStorage.ts
new file mode 100644
index 0000000..b3db068
--- /dev/null
+++ b/server/localStorage.ts
@@ -0,0 +1,111 @@
+import fs from "fs/promises";
+import path from "path";
+import { nanoid } from "nanoid";
+
+// Storage base path (local filesystem)
+const STORAGE_BASE_PATH = process.env.STORAGE_BASE_PATH || path.join(process.cwd(), "storage");
+
+/**
+ * Ensure storage directory exists
+ */
+async function ensureStorageDir(dirPath: string) {
+ try {
+ await fs.mkdir(dirPath, { recursive: true });
+ } catch (error) {
+ console.error(`[LocalStorage] Failed to create directory ${dirPath}:`, error);
+ throw error;
+ }
+}
+
+/**
+ * Generate a storage key with YYYY-MM prefix for organization
+ */
+export function generateStorageKey(userId: number, fileName: string): string {
+ const now = new Date();
+ const year = now.getFullYear();
+ const month = String(now.getMonth() + 1).padStart(2, "0");
+ const randomId = nanoid(8);
+
+ // Format: YYYY-MM/userId-randomId-filename
+ return `${year}-${month}/${userId}-${randomId}-${fileName}`;
+}
+
+/**
+ * Store a file in local storage
+ * @param fileKey - Storage key (e.g., "2025-01/1-abc123-invoice.pdf")
+ * @param buffer - File content as Buffer
+ * @param contentType - MIME type (optional, for metadata)
+ * @returns Object with key and public URL
+ */
+export async function localStoragePut(
+ fileKey: string,
+ buffer: Buffer,
+ contentType?: string
+): Promise<{ key: string; url: string }> {
+ try {
+ const fullPath = path.join(STORAGE_BASE_PATH, fileKey);
+ const dirPath = path.dirname(fullPath);
+
+ // Ensure directory exists
+ await ensureStorageDir(dirPath);
+
+ // Write file
+ await fs.writeFile(fullPath, buffer);
+
+ // Generate public URL (served by Express static middleware)
+ const url = `/storage/${fileKey}`;
+
+ console.log(`[LocalStorage] File stored: ${fileKey}`);
+
+ return { key: fileKey, url };
+ } catch (error) {
+ console.error(`[LocalStorage] Failed to store file ${fileKey}:`, error);
+ throw error;
+ }
+}
+
+/**
+ * Retrieve a file from local storage
+ * @param fileKey - Storage key
+ * @returns File content as Buffer
+ */
+export async function localStorageGet(fileKey: string): Promise {
+ try {
+ const fullPath = path.join(STORAGE_BASE_PATH, fileKey);
+ const buffer = await fs.readFile(fullPath);
+ return buffer;
+ } catch (error) {
+ console.error(`[LocalStorage] Failed to retrieve file ${fileKey}:`, error);
+ throw error;
+ }
+}
+
+/**
+ * Delete a file from local storage
+ * @param fileKey - Storage key
+ */
+export async function localStorageDelete(fileKey: string): Promise {
+ try {
+ const fullPath = path.join(STORAGE_BASE_PATH, fileKey);
+ await fs.unlink(fullPath);
+ console.log(`[LocalStorage] File deleted: ${fileKey}`);
+ } catch (error) {
+ console.error(`[LocalStorage] Failed to delete file ${fileKey}:`, error);
+ throw error;
+ }
+}
+
+/**
+ * Check if a file exists in storage
+ * @param fileKey - Storage key
+ * @returns true if file exists, false otherwise
+ */
+export async function localStorageExists(fileKey: string): Promise {
+ try {
+ const fullPath = path.join(STORAGE_BASE_PATH, fileKey);
+ await fs.access(fullPath);
+ return true;
+ } catch {
+ return false;
+ }
+}
diff --git a/server/routers.ts b/server/routers.ts
index 21836ad..cb4e30f 100644
--- a/server/routers.ts
+++ b/server/routers.ts
@@ -1,28 +1,493 @@
+import { z } from "zod";
import { COOKIE_NAME } from "@shared/const";
import { getSessionCookieOptions } from "./_core/cookies";
import { systemRouter } from "./_core/systemRouter";
-import { publicProcedure, router } from "./_core/trpc";
+import { publicProcedure, protectedProcedure, router } from "./_core/trpc";
+import {
+ createInvoice,
+ getInvoiceById,
+ getInvoicesByUser,
+ updateInvoice,
+ deleteInvoice,
+ searchInvoices,
+ getInvoiceStats,
+ createSourceFile,
+ getSourceFileById,
+ updateSourceFile,
+ getUserSettings,
+ upsertUserSettings,
+ createLocalUser,
+ getAllUsers,
+ updateUserPassword,
+ toggleUserActive,
+ deleteUser,
+ findDuplicateInvoice,
+ createImportLog,
+ getImportLogsByUser,
+ getLlmLogsBySourceFile,
+ getLlmLogsByInvoice,
+} from "./db";
+import { loginLocal, hashPassword, getAzureAuthUrl, isAzureAdConfigured, generateToken } from "./auth";
+import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor";
+import { localStoragePut, generateStorageKey } from "./localStorage";
+import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport";
+import { TRPCError } from "@trpc/server";
+
+// Admin-only procedure
+const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
+ if (ctx.user.role !== "admin") {
+ throw new TRPCError({ code: "FORBIDDEN", message: "Admin access required" });
+ }
+ return next({ ctx });
+});
export const appRouter = router({
- // if you need to use socket.io, read and register route in server/_core/index.ts, all api should start with '/api/' so that the gateway can route correctly
system: systemRouter,
+
+ // ============= AUTH ROUTES =============
auth: router({
me: publicProcedure.query(opts => opts.ctx.user),
+
+ // Local login (email + password)
+ loginLocal: publicProcedure
+ .input(z.object({
+ email: z.string().email(),
+ password: z.string().min(6),
+ }))
+ .mutation(async ({ input, ctx }) => {
+ const result = await loginLocal(input.email, input.password);
+
+ if (!result) {
+ throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid email or password" });
+ }
+
+ // Set auth cookie
+ ctx.res.cookie("auth_token", result.token, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === "production",
+ sameSite: "lax",
+ path: "/",
+ maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
+ });
+
+ return { user: result.user };
+ }),
+
+ // Get Azure AD login URL
+ getAzureLoginUrl: publicProcedure.query(async () => {
+ if (!isAzureAdConfigured()) {
+ throw new TRPCError({ code: "BAD_REQUEST", message: "Azure AD not configured" });
+ }
+
+ const url = await getAzureAuthUrl();
+ return { url };
+ }),
+
+ // Check if Azure AD is available
+ isAzureAdAvailable: publicProcedure.query(() => {
+ return { available: isAzureAdConfigured() };
+ }),
+
logout: publicProcedure.mutation(({ ctx }) => {
const cookieOptions = getSessionCookieOptions(ctx.req);
ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 });
- return {
- success: true,
- } as const;
+ ctx.res.clearCookie("auth_token", { path: "/", maxAge: -1 });
+ return { success: true };
}),
}),
-
- // TODO: add feature routers here, e.g.
- // todo: router({
- // list: protectedProcedure.query(({ ctx }) =>
- // db.getUserTodos(ctx.user.id)
- // ),
- // }),
+
+ // ============= INVOICE ROUTES =============
+ invoices: router({
+ // Upload and process PDF file
+ upload: protectedProcedure
+ .input(z.object({
+ fileName: z.string(),
+ fileData: z.string(), // Base64 encoded PDF
+ }))
+ .mutation(async ({ input, ctx }) => {
+ const userId = ctx.user.id;
+
+ // Decode base64 file data
+ const fileBuffer = Buffer.from(input.fileData, "base64");
+
+ // Store source file
+ const sourceFileKey = generateStorageKey(userId, input.fileName);
+ const { url: sourceFileUrl } = await localStoragePut(sourceFileKey, fileBuffer, "application/pdf");
+
+ // Create source file record
+ const sourceFile = await createSourceFile({
+ userId,
+ fileName: input.fileName,
+ fileKey: sourceFileKey,
+ fileUrl: sourceFileUrl,
+ processingStatus: "processing",
+ });
+
+ // Start extraction process (async - don't wait)
+ (async () => {
+ try {
+ // Get user settings for custom keywords
+ const settings = await getUserSettings(userId);
+ const customKeywords = settings ? {
+ invoiceNumber: settings.invoiceNumberKeywords,
+ deliveryNote: settings.deliveryNoteKeywords,
+ orderNumber: settings.orderNumberKeywords,
+ supplier: settings.supplierKeywords,
+ totalAmount: settings.totalAmountKeywords,
+ } : undefined;
+
+ const model = settings?.llmModel || "mistral-large-latest";
+
+ // Extract invoices
+ const result = await extractInvoicesWithMistral(
+ fileBuffer,
+ userId,
+ sourceFile.id,
+ model,
+ customKeywords
+ );
+
+ // Update source file with total count
+ await updateSourceFile(sourceFile.id, {
+ totalInvoicesDetected: result.invoiceCount,
+ processingProgress: `Extraction ${result.invoiceCount} facture(s) détectée(s)`,
+ });
+
+ // Process each invoice
+ let importedCount = 0;
+ let duplicatesCount = 0;
+ let errorsCount = 0;
+ const duplicateDetails: any[] = [];
+ const errorDetails: any[] = [];
+
+ for (let i = 0; i < result.invoices.length; i++) {
+ const invoiceData = result.invoices[i]!;
+
+ try {
+ // Update progress
+ await updateSourceFile(sourceFile.id, {
+ processingProgress: `Extraction ${i + 1}/${result.invoiceCount} factures...`,
+ });
+
+ // Check for duplicates
+ const duplicate = await findDuplicateInvoice(
+ invoiceData.supplierName,
+ invoiceData.invoiceNumber,
+ invoiceData.invoiceDate
+ );
+
+ if (duplicate) {
+ duplicatesCount++;
+ duplicateDetails.push({
+ supplierName: invoiceData.supplierName,
+ invoiceNumber: invoiceData.invoiceNumber,
+ invoiceDate: invoiceData.invoiceDate,
+ });
+ continue;
+ }
+
+ // Generate metadata JSON
+ const metadataJson = generateMetadataJSON(invoiceData);
+ const metadataKey = generateStorageKey(userId, `${input.fileName}-${i + 1}-metadata.json`);
+ const { url: metadataUrl } = await localStoragePut(
+ metadataKey,
+ Buffer.from(metadataJson),
+ "application/json"
+ );
+
+ // Create invoice record
+ await createInvoice({
+ userId,
+ sourceFileId: sourceFile.id,
+ invoiceIndexInFile: i + 1,
+ fileName: `${input.fileName} - Facture ${i + 1}`,
+ fileKey: sourceFileKey, // Same as source for now
+ fileUrl: sourceFileUrl,
+ supplierName: invoiceData.supplierName,
+ invoiceNumber: invoiceData.invoiceNumber,
+ invoiceDate: invoiceData.invoiceDate,
+ deliveryNoteNumber: invoiceData.deliveryNoteNumber,
+ orderNumber: invoiceData.orderNumber,
+ totalAmount: invoiceData.totalAmount?.toString(),
+ pageRange: invoiceData.pageRange,
+ qualityScore: invoiceData.qualityScore,
+ metadataFileKey: metadataKey,
+ metadataFileUrl: metadataUrl,
+ status: "completed",
+ });
+
+ importedCount++;
+ } catch (error: any) {
+ errorsCount++;
+ errorDetails.push({
+ invoiceIndex: i + 1,
+ error: error.message,
+ });
+ }
+ }
+
+ // Update source file status
+ await updateSourceFile(sourceFile.id, {
+ processingStatus: "completed",
+ processingProgress: `Terminé: ${importedCount} importée(s), ${duplicatesCount} doublon(s)`,
+ });
+
+ // Create import log
+ await createImportLog({
+ userId,
+ sourceFileId: sourceFile.id,
+ fileName: input.fileName,
+ totalInvoicesDetected: result.invoiceCount,
+ invoicesImported: importedCount,
+ duplicatesIgnored: duplicatesCount,
+ errors: errorsCount,
+ duplicateDetails: JSON.stringify(duplicateDetails),
+ errorDetails: JSON.stringify(errorDetails),
+ });
+
+ } catch (error: any) {
+ console.error("[Upload] Extraction failed:", error);
+ await updateSourceFile(sourceFile.id, {
+ processingStatus: "error",
+ processingProgress: `Erreur: ${error.message}`,
+ });
+ }
+ })();
+
+ return { sourceFileId: sourceFile.id };
+ }),
+
+ list: protectedProcedure.query(async ({ ctx }) => {
+ return getInvoicesByUser(ctx.user.id);
+ }),
+
+ getById: protectedProcedure
+ .input(z.object({ id: z.number() }))
+ .query(async ({ input, ctx }) => {
+ const invoice = await getInvoiceById(input.id);
+ if (!invoice || invoice.userId !== ctx.user.id) {
+ throw new TRPCError({ code: "NOT_FOUND" });
+ }
+ return invoice;
+ }),
+
+ update: protectedProcedure
+ .input(z.object({
+ id: z.number(),
+ data: z.object({
+ supplierName: z.string().optional(),
+ invoiceNumber: z.string().optional(),
+ invoiceDate: z.date().optional(),
+ deliveryNoteNumber: z.string().optional(),
+ orderNumber: z.string().optional(),
+ totalAmount: z.string().optional(),
+ }),
+ }))
+ .mutation(async ({ input, ctx }) => {
+ const invoice = await getInvoiceById(input.id);
+ if (!invoice || invoice.userId !== ctx.user.id) {
+ throw new TRPCError({ code: "NOT_FOUND" });
+ }
+
+ await updateInvoice(input.id, {
+ ...input.data,
+ manuallyEdited: 1,
+ });
+
+ return { success: true };
+ }),
+
+ delete: protectedProcedure
+ .input(z.object({ id: z.number() }))
+ .mutation(async ({ input, ctx }) => {
+ const invoice = await getInvoiceById(input.id);
+ if (!invoice || invoice.userId !== ctx.user.id) {
+ throw new TRPCError({ code: "NOT_FOUND" });
+ }
+
+ await deleteInvoice(input.id);
+ return { success: true };
+ }),
+
+ search: protectedProcedure
+ .input(z.object({ query: z.string() }))
+ .query(async ({ input, ctx }) => {
+ return searchInvoices(ctx.user.id, input.query);
+ }),
+
+ getStats: protectedProcedure.query(async ({ ctx }) => {
+ return getInvoiceStats(ctx.user.id);
+ }),
+ }),
+
+ // ============= SOURCE FILES ROUTES =============
+ sourceFiles: router({
+ getByIds: protectedProcedure
+ .input(z.object({ ids: z.array(z.number()) }))
+ .query(async ({ input, ctx }) => {
+ const files = await Promise.all(
+ input.ids.map(id => getSourceFileById(id))
+ );
+ return files.filter(f => f && f.userId === ctx.user.id);
+ }),
+ }),
+
+ // ============= SETTINGS ROUTES =============
+ settings: router({
+ get: protectedProcedure.query(async ({ ctx }) => {
+ return getUserSettings(ctx.user.id);
+ }),
+
+ upsert: protectedProcedure
+ .input(z.object({
+ llmModel: z.string().optional(),
+ orderNumberFormat: z.string().optional(),
+ invoiceNumberKeywords: z.string().optional(),
+ deliveryNoteKeywords: z.string().optional(),
+ orderNumberKeywords: z.string().optional(),
+ supplierKeywords: z.string().optional(),
+ totalAmountKeywords: z.string().optional(),
+ sftpHost: z.string().optional(),
+ sftpPort: z.number().optional(),
+ sftpUsername: z.string().optional(),
+ sftpPassword: z.string().optional(),
+ sftpRemotePath: z.string().optional(),
+ sftpAutoExport: z.number().optional(),
+ llmLogsRetentionMonths: z.number().optional(),
+ }))
+ .mutation(async ({ input, ctx }) => {
+ await upsertUserSettings({
+ userId: ctx.user.id,
+ ...input,
+ });
+ return { success: true };
+ }),
+ }),
+
+ // ============= ADMIN ROUTES =============
+ admin: router({
+ createUser: adminProcedure
+ .input(z.object({
+ email: z.string().email(),
+ password: z.string().min(6),
+ name: z.string(),
+ role: z.enum(["user", "admin"]),
+ }))
+ .mutation(async ({ input }) => {
+ const passwordHash = await hashPassword(input.password);
+ const user = await createLocalUser(input.email, passwordHash, input.name, input.role);
+ return { user };
+ }),
+
+ getAllUsers: adminProcedure.query(async () => {
+ return getAllUsers();
+ }),
+
+ updateUserPassword: adminProcedure
+ .input(z.object({
+ userId: z.number(),
+ newPassword: z.string().min(6),
+ }))
+ .mutation(async ({ input }) => {
+ const passwordHash = await hashPassword(input.newPassword);
+ await updateUserPassword(input.userId, passwordHash);
+ return { success: true };
+ }),
+
+ toggleUserActive: adminProcedure
+ .input(z.object({
+ userId: z.number(),
+ isActive: z.number(),
+ }))
+ .mutation(async ({ input }) => {
+ await toggleUserActive(input.userId, input.isActive);
+ return { success: true };
+ }),
+
+ deleteUser: adminProcedure
+ .input(z.object({ userId: z.number() }))
+ .mutation(async ({ input }) => {
+ await deleteUser(input.userId);
+ return { success: true };
+ }),
+ }),
+
+ // ============= SFTP ROUTES =============
+ sftp: router({
+ testConnection: protectedProcedure.mutation(async ({ ctx }) => {
+ const config = await getUserSftpConfig(ctx.user.id);
+ if (!config) {
+ throw new TRPCError({ code: "BAD_REQUEST", message: "SFTP not configured" });
+ }
+
+ const success = await testSftpConnection(config);
+ return { success };
+ }),
+
+ exportInvoices: protectedProcedure
+ .input(z.object({ invoiceIds: z.array(z.number()) }))
+ .mutation(async ({ input, ctx }) => {
+ const config = await getUserSftpConfig(ctx.user.id);
+ if (!config) {
+ throw new TRPCError({ code: "BAD_REQUEST", message: "SFTP not configured" });
+ }
+
+ let successCount = 0;
+ let errorCount = 0;
+
+ for (const invoiceId of input.invoiceIds) {
+ try {
+ const invoice = await getInvoiceById(invoiceId);
+ if (!invoice || invoice.userId !== ctx.user.id) {
+ errorCount++;
+ continue;
+ }
+
+ await exportInvoiceToSftp(
+ config,
+ invoice.fileKey,
+ invoice.metadataFileKey,
+ invoice.invoiceDate || new Date()
+ );
+
+ // Update invoice export status
+ await updateInvoice(invoiceId, {
+ exportedAt: new Date(),
+ exportMode: "manual",
+ });
+
+ successCount++;
+ } catch (error) {
+ console.error(`[SFTP] Failed to export invoice ${invoiceId}:`, error);
+ errorCount++;
+ }
+ }
+
+ return { successCount, errorCount };
+ }),
+ }),
+
+ // ============= IMPORT LOGS ROUTES =============
+ importLogs: router({
+ getByUser: protectedProcedure.query(async ({ ctx }) => {
+ return getImportLogsByUser(ctx.user.id);
+ }),
+ }),
+
+ // ============= LLM LOGS ROUTES =============
+ llmLogs: router({
+ getBySourceFile: protectedProcedure
+ .input(z.object({ sourceFileId: z.number() }))
+ .query(async ({ input }) => {
+ return getLlmLogsBySourceFile(input.sourceFileId);
+ }),
+
+ getByInvoice: protectedProcedure
+ .input(z.object({ invoiceId: z.number() }))
+ .query(async ({ input }) => {
+ return getLlmLogsByInvoice(input.invoiceId);
+ }),
+ }),
});
export type AppRouter = typeof appRouter;
diff --git a/server/sftpExport.ts b/server/sftpExport.ts
new file mode 100644
index 0000000..47550e0
--- /dev/null
+++ b/server/sftpExport.ts
@@ -0,0 +1,115 @@
+import SftpClient from "ssh2-sftp-client";
+import { localStorageGet } from "./localStorage";
+import { getUserSettings } from "./db";
+
+export interface SftpConfig {
+ host: string;
+ port: number;
+ username: string;
+ password: string;
+ remotePath: string;
+}
+
+/**
+ * Test SFTP connection
+ */
+export async function testSftpConnection(config: SftpConfig): Promise {
+ const sftp = new SftpClient();
+
+ try {
+ await sftp.connect({
+ host: config.host,
+ port: config.port,
+ username: config.username,
+ password: config.password,
+ });
+
+ console.log("[SFTP] Connection successful");
+ return true;
+ } catch (error) {
+ console.error("[SFTP] Connection failed:", error);
+ return false;
+ } finally {
+ await sftp.end();
+ }
+}
+
+/**
+ * Export invoice files (PDF + JSON) to SFTP server
+ */
+export async function exportInvoiceToSftp(
+ config: SftpConfig,
+ pdfFileKey: string,
+ jsonFileKey: string | null,
+ invoiceDate: Date
+): Promise {
+ const sftp = new SftpClient();
+
+ try {
+ // Connect to SFTP
+ await sftp.connect({
+ host: config.host,
+ port: config.port,
+ username: config.username,
+ password: config.password,
+ });
+
+ console.log("[SFTP] Connected successfully");
+
+ // Create directory structure: remotePath/YYYY/MM/DD
+ const year = invoiceDate.getFullYear();
+ const month = String(invoiceDate.getMonth() + 1).padStart(2, "0");
+ const day = String(invoiceDate.getDate()).padStart(2, "0");
+
+ const targetDir = `${config.remotePath}/${year}/${month}/${day}`.replace(/\/+/g, "/");
+
+ // Ensure directory exists
+ await sftp.mkdir(targetDir, true);
+
+ console.log(`[SFTP] Created directory: ${targetDir}`);
+
+ // Upload PDF file
+ const pdfBuffer = await localStorageGet(pdfFileKey);
+ const pdfFileName = pdfFileKey.split("/").pop() || "invoice.pdf";
+ const pdfRemotePath = `${targetDir}/${pdfFileName}`;
+
+ await sftp.put(pdfBuffer, pdfRemotePath);
+ console.log(`[SFTP] Uploaded PDF: ${pdfRemotePath}`);
+
+ // Upload JSON metadata file if exists
+ if (jsonFileKey) {
+ const jsonBuffer = await localStorageGet(jsonFileKey);
+ const jsonFileName = jsonFileKey.split("/").pop() || "metadata.json";
+ const jsonRemotePath = `${targetDir}/${jsonFileName}`;
+
+ await sftp.put(jsonBuffer, jsonRemotePath);
+ console.log(`[SFTP] Uploaded JSON: ${jsonRemotePath}`);
+ }
+
+ console.log("[SFTP] Export completed successfully");
+ } catch (error) {
+ console.error("[SFTP] Export failed:", error);
+ throw error;
+ } finally {
+ await sftp.end();
+ }
+}
+
+/**
+ * Get SFTP configuration for a user
+ */
+export async function getUserSftpConfig(userId: number): Promise {
+ const settings = await getUserSettings(userId);
+
+ if (!settings || !settings.sftpHost || !settings.sftpUsername || !settings.sftpPassword) {
+ return null;
+ }
+
+ return {
+ host: settings.sftpHost,
+ port: settings.sftpPort || 22,
+ username: settings.sftpUsername,
+ password: settings.sftpPassword,
+ remotePath: settings.sftpRemotePath || "/",
+ };
+}
diff --git a/todo.md b/todo.md
new file mode 100644
index 0000000..f5ea66b
--- /dev/null
+++ b/todo.md
@@ -0,0 +1,59 @@
+# Invoice Analyzer - TODO
+
+## Phase 1: Base de données et schéma
+- [x] Créer table users avec support authentification locale + Azure AD
+- [x] Créer table sourceFiles pour fichiers PDF uploadés
+- [x] Créer table invoices pour factures extraites
+- [x] Créer table userSettings pour paramètres utilisateur
+- [x] Créer table importLogs pour historique des imports
+- [x] Créer table llmLogs pour logs des appels LLM
+- [x] Pousser les migrations avec pnpm db:push
+
+## Phase 2: Authentification
+- [x] Implémenter authentification locale (email/password avec bcrypt)
+- [x] Implémenter authentification Azure AD avec MSAL
+- [x] Créer helpers de génération de tokens JWT
+- [x] Créer middleware de vérification des rôles (user/admin)
+
+## Phase 3: Stockage et extraction IA
+- [x] Créer helper de stockage local avec préfixe YYYY-MM
+- [x] Implémenter extraction PDF avec Mistral OCR
+- [x] Implémenter extraction de métadonnées avec Mistral LLM
+- [x] Créer système de scoring de qualité (0-100)
+- [x] Implémenter détection de doublons
+- [x] Créer helper de génération de fichiers JSON de métadonnées
+- [x] Implémenter logger LLM pour debug
+
+## Phase 4: Export SFTP
+- [x] Créer helper de connexion SFTP
+- [x] Implémenter export manuel de factures
+- [x] Implémenter export automatique de factures
+- [x] Créer test de connexion SFTP
+
+## Phase 5: Routes tRPC
+- [x] Routes auth (me, loginLocal, getAzureLoginUrl, logout)
+- [x] Routes invoices (upload, list, getById, update, delete, search, getStats)
+- [x] Routes sourceFiles (getByIds pour polling)
+- [x] Routes settings (get, upsert)
+- [x] Routes admin (createUser, getAllUsers, updatePassword, toggleActive, deleteUser)
+- [x] Routes sftp (testConnection, exportInvoices)
+- [x] Routes importLogs (getByUser)
+- [x] Routes llmLogs (getBySourceFile, getByInvoice)
+
+## Phase 6: Pages Frontend
+- [x] Page Login avec formulaire local + bouton Azure AD
+- [x] Page Dashboard avec statistiques et graphiques
+- [x] Page Upload avec drag-and-drop et suivi temps réel
+- [x] Page Invoices avec liste, recherche, filtres, pagination
+- [x] Page Settings avec configuration LLM, keywords, SFTP
+- [x] Page History avec logs et détails
+- [x] Page Users (admin) pour gestion des utilisateurs
+- [x] Layout avec navigation adaptée (DashboardLayout)
+- [x] Configuration des routes dans App.tsx
+
+## Phase 7: Tests et finalisation
+- [x] Tests unitaires d'authentification (hashPassword, verifyPassword, JWT)
+- [x] Tests de logout avec cookies multiples
+- [x] Vérification du build et de la compilation TypeScript
+- [x] Vérification du serveur de développement
+- [x] Créer le checkpoint final