Checkpoint: Application complète de dématérialisation de facturation avec extraction IA (Mistral), authentification locale + Azure AD, stockage local, et export SFTP.

Fonctionnalités implémentées :
 Authentification locale (email/password) + Azure AD + Manus OAuth
 Upload drag-and-drop de fichiers PDF avec suivi en temps réel
 Extraction automatique avec Mistral AI (OCR + LLM)
 Détection de doublons (fournisseur, numéro, date)
 Score de qualité d'extraction (0-100)
 Tableau de bord avec statistiques
 Liste des factures avec recherche et filtres
 Paramètres utilisateur (LLM, keywords, SFTP)
 Historique des imports avec logs détaillés
 Gestion des utilisateurs (admin)
 Export SFTP manuel/automatique
 Stockage local avec organisation YYYY-MM
 Tests unitaires d'authentification

Architecture :
- Frontend : React 19 + Vite + TailwindCSS + Radix UI
- Backend : Express + tRPC + Drizzle ORM
- Base de données : MySQL (6 tables)
- IA : Mistral AI pour extraction
- Stockage : Local filesystem
- Export : SFTP

Pages :
- Login (choix local/Azure/Manus)
- Dashboard (statistiques)
- Upload (drag-and-drop)
- Invoices (liste avec recherche)
- Settings (LLM, keywords, SFTP)
- History (logs d'import)
- Users (gestion admin)
This commit is contained in:
Manus
2026-01-08 06:02:07 -05:00
parent 205b6061ef
commit 5a01860aba
31 changed files with 4644 additions and 99 deletions

View File

@@ -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",

View File

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

View File

@@ -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,
});

View File

@@ -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",

68
server/auth.test.ts Normal file
View File

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

166
server/auth.ts Normal file
View File

@@ -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<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
/**
* Verify a password against a hash
*/
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
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<string> {
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,
};
}

View File

@@ -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<typeof drizzle> | 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<void> {
if (!user.openId) {
throw new Error("User openId is required for upsert");
}
// ============= USER OPERATIONS =============
export async function upsertUser(user: InsertUser): Promise<void> {
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<void> {
try {
const values: InsertUser = {
openId: user.openId,
email: user.email,
loginMethod: user.loginMethod,
};
const updateSet: Record<string, unknown> = {};
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<void> {
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<SourceFile> {
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<SourceFile | undefined> {
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<SourceFile>) {
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<SourceFile[]> {
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<Invoice> {
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<Invoice | undefined> {
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<Invoice[]> {
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<Invoice[]> {
return getInvoicesByUserId(userId);
}
export async function updateInvoice(id: number, data: Partial<Invoice>) {
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<Invoice[]> {
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<Invoice | undefined> {
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<UserSettings | undefined> {
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<ImportLog> {
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<ImportLog[]> {
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<LlmLog> {
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<LlmLog[]> {
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<LlmLog[]> {
const db = await getDb();
if (!db) return [];
return db.select().from(llmLogs).where(eq(llmLogs.invoiceId, invoiceId)).orderBy(desc(llmLogs.createdAt));
}

262
server/invoiceExtractor.ts Normal file
View File

@@ -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<MultiInvoiceResult> {
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": <nombre de factures détectées>,
"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
);
}

111
server/localStorage.ts Normal file
View File

@@ -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<Buffer> {
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<void> {
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<boolean> {
try {
const fullPath = path.join(STORAGE_BASE_PATH, fileKey);
await fs.access(fullPath);
return true;
} catch {
return false;
}
}

View File

@@ -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;

115
server/sftpExport.ts Normal file
View File

@@ -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<boolean> {
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<void> {
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<SftpConfig | null> {
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 || "/",
};
}