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:
317
server/db.ts
317
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<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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user