Files
demat-facturation/server/routers.ts
Manus cd61c75c4c Checkpoint: Checkpoint saved: Implémentation complète de l'import automatique depuis dossier et du système de notifications.
Nouvelles fonctionnalités :

**1. Import automatique depuis dossier (folderImportService.ts)**
 Surveillance périodique d'un dossier local configuré
 Détection automatique des nouveaux fichiers PDF
 Traitement identique à l'upload manuel (extraction Mistral AI, détection doublons, stockage)
 Déplacement automatique des fichiers traités vers un sous-dossier "processed"
 Scheduler configurable (fréquence en minutes)
 Routes tRPC pour démarrer/arrêter/vérifier le statut du service
 Boutons de contrôle dans la page Paramètres de réception

**2. Système de notifications (notificationService.ts)**
 Notifications automatiques après chaque import (email ou dossier)
 Résumé détaillé : nombre de factures importées, doublons ignorés, erreurs
 Format adapté selon la source (email avec nom de fichier, dossier avec nombre de fichiers)
 Intégration avec le système de notifications Manus (notifyOwner)
 Notifications envoyées au propriétaire du projet

**Fonctionnement de l'import dossier :**
1. L'utilisateur configure le chemin du dossier source dans "Paramètres de réception"
2. Il active l'import automatique et configure la fréquence de vérification
3. Il clique sur "Démarrer le service" pour lancer la surveillance
4. Le service scanne le dossier selon la fréquence configurée
5. Il détecte les nouveaux fichiers PDF (non encore traités)
6. Il traite chaque PDF avec la même logique que l'upload manuel
7. Les fichiers traités sont déplacés vers un sous-dossier "processed"
8. Une notification est envoyée avec le résumé des résultats

**Fonctionnement des notifications :**
- Après chaque import automatique (email ou dossier), une notification est envoyée
- La notification contient :
  * Source de l'import (📧 email ou 📁 dossier)
  * Nom du fichier (email) ou nombre de fichiers (dossier)
  *  Nombre de factures importées
  * 🔄 Nombre de doublons ignorés
  *  Nombre d'erreurs
  * Total de factures détectées
- Les notifications apparaissent dans l'interface Manus du propriétaire

**Interface utilisateur :**
- Chaque carte (Import dossier, Import email) dispose maintenant de :
  * Indicateur visuel "Service actif" avec point vert animé
  * Bouton "Démarrer le service" (désactivé si l'import n'est pas activé)
  * Bouton "Arrêter le service" (rouge) pour stopper la surveillance
  * Messages toast pour confirmer le démarrage/arrêt

**Dépendances ajoutées :**
- chokidar : Surveillance de fichiers et dossiers pour Node.js

**Architecture :**
- folderImportService.ts : Service d'import automatique depuis dossier
- notificationService.ts : Service de notifications centralisé
- Routes tRPC : folderImportService.{start, stop, status}
- Intégration des notifications dans emailImportService et folderImportService

Les deux services d'import automatique (email et dossier) sont maintenant opérationnels avec notifications intégrées.
2026-02-11 05:16:44 -05:00

666 lines
23 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 { startEmailImportService, stopEmailImportService, isEmailImportServiceRunning } from "./emailImportService";
import { startFolderImportService, stopFolderImportService, isFolderImportServiceRunning } from "./folderImportService";
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);
}),
}),
// ============= FOLDER IMPORT SERVICE ROUTES =============
folderImportService: router({
start: protectedProcedure.mutation(async ({ ctx }) => {
const started = await startFolderImportService(ctx.user.id);
return { success: started };
}),
stop: protectedProcedure.mutation(async ({ ctx }) => {
stopFolderImportService(ctx.user.id);
return { success: true };
}),
status: protectedProcedure.query(async ({ ctx }) => {
const isRunning = isFolderImportServiceRunning(ctx.user.id);
return { isRunning };
}),
}),
// ============= EMAIL IMPORT SERVICE ROUTES =============
emailImportService: router({
start: protectedProcedure.mutation(async ({ ctx }) => {
const started = await startEmailImportService(ctx.user.id);
return { success: started };
}),
stop: protectedProcedure.mutation(async ({ ctx }) => {
stopEmailImportService(ctx.user.id);
return { success: true };
}),
status: protectedProcedure.query(async ({ ctx }) => {
const isRunning = isEmailImportServiceRunning(ctx.user.id);
return { isRunning };
}),
}),
// ============= 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;