Files
demat-facturation/server/routers.ts
Manus 35efbf632d Checkpoint: Checkpoint saved: Ajout d'une page "Paramètres de réception" complète pour configurer les différentes méthodes d'import de factures.
Nouvelles fonctionnalités :
 Table importSettings dans la base de données (7 tables au total)
 Routes tRPC pour gérer les paramètres de réception (get, update)
 Page "Paramètres de réception" accessible via le menu de navigation

Configuration disponible :
1. **Import manuel** : Activation/désactivation de l'upload manuel de fichiers PDF via l'interface

2. **Import automatique depuis dossier** :
   - Activation/désactivation
   - Configuration du chemin du dossier source à surveiller
   - Fréquence de lecture du dossier (en minutes)

3. **Import par email** :
   - Activation/désactivation
   - Configuration du compte email (adresse + mot de passe)
   - Serveur IMAP (host + port)
   - Fréquence de lecture des emails (en minutes)

Interface utilisateur :
- 3 cartes distinctes avec icônes (Upload, FolderOpen, Mail)
- Switches pour activer/désactiver chaque méthode
- Champs de configuration qui apparaissent uniquement si la méthode est activée
- Bouton "Enregistrer les paramètres" avec feedback visuel
- Notifications toast pour confirmer la sauvegarde ou signaler les erreurs

Architecture :
- Schéma de base de données : table importSettings avec 14 colonnes
- Backend : Fonctions db.ts (getImportSettingsByUser, upsertImportSettings)
- Routes tRPC : importSettings.get et importSettings.update
- Frontend : Page ImportSettings.tsx avec gestion d'état React
- Navigation : Nouveau lien "Paramètres de réception" dans le menu latéral

La page est maintenant accessible via /import-settings et permet aux utilisateurs de configurer toutes les méthodes d'importation de factures de manière centralisée.
2026-02-10 12:05:23 -05:00

628 lines
21 KiB
TypeScript

import { z } from "zod";
import { COOKIE_NAME } from "@shared/const";
import { getSessionCookieOptions } from "./_core/cookies";
import { systemRouter } from "./_core/systemRouter";
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,
getImportSettingsByUser,
upsertImportSettings,
} 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({
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 });
ctx.res.clearCookie("auth_token", { path: "/", maxAge: -1 });
return { success: true };
}),
}),
// ============= 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");
console.log(`[Upload] Received file: ${input.fileName}, size: ${fileBuffer.length} bytes`);
// Store source file
const sourceFileKey = generateStorageKey(userId, input.fileName);
console.log(`[Upload] Generated storage key: ${sourceFileKey}`);
let sourceFileUrl: string;
try {
const result = await localStoragePut(sourceFileKey, fileBuffer, "application/pdf");
sourceFileUrl = result.url;
console.log(`[Upload] File stored successfully at: ${sourceFileUrl}`);
} catch (error) {
console.error(`[Upload] FAILED to store file:`, error);
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to store PDF file" });
}
// 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 };
}),
exportToExcel: protectedProcedure
.input(z.object({ invoiceIds: z.array(z.number()) }))
.mutation(async ({ input, ctx }) => {
const invoices = await Promise.all(
input.invoiceIds.map(id => getInvoiceById(id))
);
const validInvoices = invoices.filter(
inv => inv && inv.userId === ctx.user.id
);
// Return invoice data for Excel generation on client side
return {
success: true,
invoices: validInvoices.map(inv => ({
id: inv!.id,
supplierName: inv!.supplierName || '',
invoiceNumber: inv!.invoiceNumber || '',
invoiceDate: inv!.invoiceDate ? new Date(inv!.invoiceDate).toLocaleDateString('fr-FR') : '',
deliveryNoteNumber: inv!.deliveryNoteNumber || '',
orderNumber: inv!.orderNumber || '',
totalAmount: inv!.totalAmount ? parseFloat(inv!.totalAmount as string) : 0,
qualityScore: inv!.qualityScore || 0,
exportStatus: inv!.exportStatus || 'not_exported',
exportedAt: inv!.exportedAt ? new Date(inv!.exportedAt).toLocaleDateString('fr-FR') : '',
createdAt: new Date(inv!.createdAt).toLocaleDateString('fr-FR'),
}))
};
}),
exportToPdf: protectedProcedure
.input(z.object({ invoiceIds: z.array(z.number()) }))
.mutation(async ({ input, ctx }) => {
// Validate that all invoices have quality score of 100
const invoices = await Promise.all(
input.invoiceIds.map(id => getInvoiceById(id))
);
const invalidInvoices = invoices.filter(
inv => !inv || inv.userId !== ctx.user.id || (inv.qualityScore || 0) < 100
);
if (invalidInvoices.length > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Toutes les factures doivent avoir un score de qualit\u00e9 de 100% pour \u00eatre export\u00e9es"
});
}
// Update export status
for (const invoice of invoices) {
if (invoice) {
await updateInvoice(invoice.id, {
exportStatus: "exported",
exportedAt: new Date(),
exportMode: "manual",
});
}
}
// Return the file URLs for PDF generation on client side
return {
success: true,
invoices: invoices.filter(Boolean).map(inv => ({
id: inv!.id,
fileUrl: inv!.fileUrl,
supplierName: inv!.supplierName,
invoiceNumber: inv!.invoiceNumber,
invoiceDate: inv!.invoiceDate,
}))
};
}),
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);
}),
}),
// ============= IMPORT SETTINGS ROUTES =============
importSettings: router({
get: protectedProcedure.query(async ({ ctx }) => {
const settings = await getImportSettingsByUser(ctx.user.id);
// Return default settings if none exist
if (!settings) {
return {
userId: ctx.user.id,
manualImportEnabled: 1,
autoImportEnabled: 0,
autoImportSourcePath: null,
autoImportFrequency: 60,
emailImportEnabled: 0,
emailImportAddress: null,
emailImportPassword: null,
emailImportHost: null,
emailImportPort: 993,
emailImportFrequency: 30,
};
}
return settings;
}),
update: protectedProcedure
.input(z.object({
manualImportEnabled: z.number().min(0).max(1),
autoImportEnabled: z.number().min(0).max(1),
autoImportSourcePath: z.string().nullable().optional(),
autoImportFrequency: z.number().min(1).optional(),
emailImportEnabled: z.number().min(0).max(1),
emailImportAddress: z.string().email().nullable().optional(),
emailImportPassword: z.string().nullable().optional(),
emailImportHost: z.string().nullable().optional(),
emailImportPort: z.number().min(1).max(65535).optional(),
emailImportFrequency: z.number().min(1).optional(),
}))
.mutation(async ({ input, ctx }) => {
const settings = await upsertImportSettings({
userId: ctx.user.id,
...input,
});
return settings;
}),
}),
});
export type AppRouter = typeof appRouter;