import { z } from "zod"; import { COOKIE_NAME } from "@shared/const"; interface Condition { field: string; operator: string; value: string; } interface Actions { typeAchat?: string; serviceConcerne?: string; ventilationComptable?: string; } 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, deleteAllImportLogs, getLlmLogsBySourceFile, getLlmLogsByInvoice, getImportSettingsByUser, upsertImportSettings, getDepartmentsByUser, createDepartment, deleteDepartment, getAccountingAllocationsByUser, createAccountingAllocation, deleteAccountingAllocation, initializeDefaultLists, getLlmFieldsConfigByUser, upsertLlmFieldConfig, initializeDefaultLlmFields, getAutomationRulesByUser, getAutomationRuleById, createAutomationRule, updateAutomationRule, deleteAutomationRule, } 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, triggerEmailCheck } 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: false, // Désactivé pour VPS sans HTTPS 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, subscription: settings.subscriptionKeywords, } : 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 const newInvoice = 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, extractedText: invoiceData.extractedText, isSubscription: invoiceData.isSubscription ? 1 : 0, metadataFileKey: metadataKey, metadataFileUrl: metadataUrl, status: "completed", }); // Apply automation rules to the newly created invoice try { const { applyAutomationRules } = await import("./automationEngine"); const automationUpdates = await applyAutomationRules(userId, newInvoice); // If automation rules suggest updates, apply them if (Object.keys(automationUpdates).length > 0) { await updateInvoice(newInvoice.id, automationUpdates); } } catch (autoError) { console.error("[Automation] Error applying rules:", autoError); // Don't fail the import if automation fails } 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); // Limit error message to 200 characters to avoid database field overflow const errorMsg = error.message ? String(error.message).substring(0, 200) : "Erreur inconnue"; await updateSourceFile(sourceFile.id, { processingStatus: "error", processingProgress: `Erreur: ${errorMsg}`, }); } })(); 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(), serviceConcerne: z.string().optional(), typeAchat: z.enum(["CAPEX", "OPEX"]).optional(), ventilationComptable: z.string().optional(), isSubscription: z.number().min(0).max(1).optional(), autoFilledFields: z.string().nullable().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(), subscriptionKeywords: 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 }) => { const fs = await import('fs/promises'); const path = await import('path'); // Get export folder from settings const settings = await getImportSettingsByUser(ctx.user.id); const exportFolder = settings?.exportFolder; if (!exportFolder) { throw new TRPCError({ code: "BAD_REQUEST", message: "Le dossier d'export n'est pas configuré. Veuillez le définir dans les paramètres de réception." }); } // 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é de 100% pour être exportées" }); } // Create export folder if it doesn't exist try { await fs.mkdir(exportFolder, { recursive: true }); } catch (error) { console.error('Error creating export folder:', error); throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: `Impossible de créer le dossier d'export: ${exportFolder}` }); } // Copy PDFs to export folder const copiedFiles: string[] = []; const errors: string[] = []; const STORAGE_BASE_PATH = process.env.STORAGE_BASE_PATH || path.join(process.cwd(), "storage"); for (const invoice of invoices) { if (invoice && invoice.fileKey) { try { // Build source path from fileKey const sourcePath = path.join(STORAGE_BASE_PATH, invoice.fileKey); // Extract filename from fileKey const filename = path.basename(invoice.fileKey); const destPath = path.join(exportFolder, filename); // Copy file await fs.copyFile(sourcePath, destPath); copiedFiles.push(destPath); // Update export status await updateInvoice(invoice.id, { exportStatus: "exported", exportedAt: new Date(), exportMode: "manual", }); } catch (error: any) { console.error(`Error copying file for invoice ${invoice.id}:`, error); errors.push(`${invoice.supplierName || 'Inconnu'} (${invoice.invoiceNumber || 'N/A'}): ${error.message}`); // Update export status to error await updateInvoice(invoice.id, { exportStatus: "export_error", exportMode: "manual", }); } } } if (errors.length > 0) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: `Erreurs lors de l'export:\n${errors.join('\n')}` }); } return { success: true, exportFolder, copiedCount: copiedFiles.length, invoices: invoices.filter(Boolean).map(inv => ({ id: inv!.id, 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); }), deleteAll: protectedProcedure.mutation(async ({ ctx }) => { await deleteAllImportLogs(ctx.user.id); return { success: true }; }), }), // ============= 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 }) => { return { isRunning: isEmailImportServiceRunning(ctx.user.id) }; }), checkNow: protectedProcedure.mutation(async ({ ctx }) => { const result = await triggerEmailCheck(ctx.user.id); return result; }), }), // ============= 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, exportFolder: null, }; } 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(), exportFolder: z.string().nullable().optional(), })) .mutation(async ({ input, ctx }) => { const settings = await upsertImportSettings({ userId: ctx.user.id, ...input, }); return settings; }), }), // ============= DEPARTMENT LIST ROUTES ============= departments: router({ getByUser: protectedProcedure.query(async ({ ctx }) => { return await getDepartmentsByUser(ctx.user.id); }), create: protectedProcedure .input(z.object({ name: z.string().min(1).max(100), })) .mutation(async ({ input, ctx }) => { // Initialize default lists if this is the first department const existing = await getDepartmentsByUser(ctx.user.id); if (existing.length === 0) { await initializeDefaultLists(ctx.user.id); } // Check if department already exists const duplicate = existing.find(d => d.name.toLowerCase() === input.name.toLowerCase()); if (duplicate) { throw new TRPCError({ code: "CONFLICT", message: `Le service "${input.name}" existe déjà` }); } return await createDepartment({ userId: ctx.user.id, name: input.name, }); }), delete: protectedProcedure .input(z.object({ id: z.number(), })) .mutation(async ({ input }) => { await deleteDepartment(input.id); return { success: true }; }), }), // ============= ACCOUNTING ALLOCATION LIST ROUTES ============= accountingAllocations: router({ getByUser: protectedProcedure.query(async ({ ctx }) => { return await getAccountingAllocationsByUser(ctx.user.id); }), create: protectedProcedure .input(z.object({ name: z.string().min(1).max(100), })) .mutation(async ({ input, ctx }) => { // Initialize default lists if this is the first allocation const existing = await getAccountingAllocationsByUser(ctx.user.id); if (existing.length === 0) { await initializeDefaultLists(ctx.user.id); } // Check if allocation already exists const duplicate = existing.find(a => a.name.toLowerCase() === input.name.toLowerCase()); if (duplicate) { throw new TRPCError({ code: "CONFLICT", message: `La ventilation comptable "${input.name}" existe déjà` }); } return await createAccountingAllocation({ userId: ctx.user.id, name: input.name, }); }), delete: protectedProcedure .input(z.object({ id: z.number(), })) .mutation(async ({ input }) => { await deleteAccountingAllocation(input.id); return { success: true }; }), }), // ============= AUTOMATION RULES ROUTES ============= automationRules: router({ list: protectedProcedure.query(async ({ ctx }) => { return await getAutomationRulesByUser(ctx.user.id); }), getById: protectedProcedure .input(z.object({ id: z.number(), })) .query(async ({ input }) => { return await getAutomationRuleById(input.id); }), create: protectedProcedure .input(z.object({ name: z.string().min(1), isActive: z.number().min(0).max(1).optional(), priority: z.number().optional(), conditions: z.string(), // JSON string conditionsLogic: z.enum(["AND", "OR"]).optional(), actions: z.string(), // JSON string })) .mutation(async ({ input, ctx }) => { const rule = await createAutomationRule({ userId: ctx.user.id, ...input, }); // If rule is active, reapply to existing BAP invoices if (rule.isActive === 1) { const { applyAutomationRules } = await import("./automationEngine"); const allInvoices = await getInvoicesByUser(ctx.user.id); const bapInvoices = allInvoices.filter(inv => inv.isSubscription === 0); for (const invoice of bapInvoices) { const updates = await applyAutomationRules(ctx.user.id, invoice); if (updates.typeAchat || updates.serviceConcerne || updates.ventilationComptable || updates.autoFilledFields) { await updateInvoice(invoice.id, updates); } } } return rule; }), update: protectedProcedure .input(z.object({ id: z.number(), name: z.string().min(1).optional(), isActive: z.number().min(0).max(1).optional(), priority: z.number().optional(), conditions: z.string().optional(), conditionsLogic: z.enum(["AND", "OR"]).optional(), actions: z.string().optional(), })) .mutation(async ({ input, ctx }) => { const { id, ...data } = input; const updated = await updateAutomationRule(id, data); // If rule is active, reapply to existing BAP invoices if (updated && updated.isActive === 1) { const { applyAutomationRules } = await import("./automationEngine"); const allInvoices = await getInvoicesByUser(ctx.user.id); const bapInvoices = allInvoices.filter(inv => inv.isSubscription === 0); for (const invoice of bapInvoices) { const updates = await applyAutomationRules(ctx.user.id, invoice); if (updates.typeAchat || updates.serviceConcerne || updates.ventilationComptable || updates.autoFilledFields) { await updateInvoice(invoice.id, updates); } } } return updated; }), delete: protectedProcedure .input(z.object({ id: z.number(), })) .mutation(async ({ input }) => { await deleteAutomationRule(input.id); return { success: true }; }), test: protectedProcedure .input(z.object({ conditions: z.string(), // JSON string conditionsLogic: z.enum(["AND", "OR"]), actions: z.string(), // JSON string })) .mutation(async ({ input, ctx }) => { // Import applyAutomationRules from automationEngine const { applyAutomationRules } = await import("./automationEngine"); // Get all user's invoices const invoices = await getInvoicesByUser(ctx.user.id); // Create a temporary rule object const tempRule = { id: -1, userId: ctx.user.id, name: "Test Rule", isActive: 1, priority: 999, conditions: input.conditions, conditionsLogic: input.conditionsLogic, actions: input.actions, createdAt: new Date(), updatedAt: new Date(), }; // Test the rule on each invoice and count matches let affectedCount = 0; const affectedInvoices = []; for (const invoice of invoices) { // Manually evaluate the rule for testing const conditions: Condition[] = JSON.parse(input.conditions); const actionsObj: Actions = JSON.parse(input.actions); // Evaluate conditions let conditionsMatch = true; if (input.conditionsLogic === "AND") { conditionsMatch = conditions.every(cond => { const fieldValue = invoice[cond.field as keyof typeof invoice]; if (!fieldValue) return false; const valueStr = String(fieldValue).toLowerCase(); const condValue = cond.value.toLowerCase(); if (cond.operator === "contains") return valueStr.includes(condValue); if (cond.operator === "equals") return valueStr === condValue; if (cond.operator === "startsWith") return valueStr.startsWith(condValue); if (cond.operator === "endsWith") return valueStr.endsWith(condValue); if (cond.operator === ">") return Number(fieldValue) > Number(cond.value); if (cond.operator === "<") return Number(fieldValue) < Number(cond.value); if (cond.operator === ">=") return Number(fieldValue) >= Number(cond.value); if (cond.operator === "<=") return Number(fieldValue) <= Number(cond.value); return false; }); } else { conditionsMatch = conditions.some(cond => { const fieldValue = invoice[cond.field as keyof typeof invoice]; if (!fieldValue) return false; const valueStr = String(fieldValue).toLowerCase(); const condValue = cond.value.toLowerCase(); if (cond.operator === "contains") return valueStr.includes(condValue); if (cond.operator === "equals") return valueStr === condValue; if (cond.operator === "startsWith") return valueStr.startsWith(condValue); if (cond.operator === "endsWith") return valueStr.endsWith(condValue); if (cond.operator === ">") return Number(fieldValue) > Number(cond.value); if (cond.operator === "<") return Number(fieldValue) < Number(cond.value); if (cond.operator === ">=") return Number(fieldValue) >= Number(cond.value); if (cond.operator === "<=") return Number(fieldValue) <= Number(cond.value); return false; }); } if (conditionsMatch) { affectedCount++; affectedInvoices.push({ id: invoice.id, supplierName: invoice.supplierName, invoiceNumber: invoice.invoiceNumber, changes: actionsObj, }); } } return { totalInvoices: invoices.length, affectedCount, affectedInvoices: affectedInvoices.slice(0, 10), // Return first 10 for preview }; }), duplicate: protectedProcedure .input(z.object({ id: z.number(), })) .mutation(async ({ input, ctx }) => { const original = await getAutomationRuleById(input.id); if (!original || original.userId !== ctx.user.id) { throw new TRPCError({ code: "NOT_FOUND" }); } // Create a copy with modified name const duplicate = await createAutomationRule({ userId: ctx.user.id, name: `${original.name} (copie)`, isActive: 0, // Inactive by default priority: original.priority, conditions: original.conditions, conditionsLogic: original.conditionsLogic, actions: original.actions, }); return duplicate; }), reapplyToExistingInvoices: protectedProcedure .mutation(async ({ ctx }) => { const { applyAutomationRules } = await import("./automationEngine"); // Get all BAP invoices (isSubscription = 0) const allInvoices = await getInvoicesByUser(ctx.user.id); const bapInvoices = allInvoices.filter(inv => inv.isSubscription === 0); let updatedCount = 0; // Apply automation rules to each BAP invoice for (const invoice of bapInvoices) { const updates = await applyAutomationRules(ctx.user.id, invoice); // Check if any updates were made if (updates.typeAchat || updates.serviceConcerne || updates.ventilationComptable || updates.autoFilledFields) { await updateInvoice(invoice.id, updates); updatedCount++; } } return { totalBapInvoices: bapInvoices.length, updatedCount, }; }), }), // ============= LLM FIELDS CONFIG ROUTES ============= llmFieldsConfig: router({ getAll: protectedProcedure .query(async ({ ctx }) => { // Initialize default fields if none exist const existing = await getLlmFieldsConfigByUser(ctx.user.id); if (existing.length === 0) { await initializeDefaultLlmFields(ctx.user.id); return await getLlmFieldsConfigByUser(ctx.user.id); } return existing; }), updateField: protectedProcedure .input(z.object({ fieldName: z.string(), isRequired: z.number().min(0).max(1), })) .mutation(async ({ input, ctx }) => { // Get existing field config const existing = await getLlmFieldsConfigByUser(ctx.user.id); const field = existing.find(f => f.fieldName === input.fieldName); if (!field) { throw new TRPCError({ code: "NOT_FOUND", message: "Field not found" }); } // Update the field return await upsertLlmFieldConfig({ userId: ctx.user.id, fieldName: input.fieldName, displayName: field.displayName, isRequired: input.isRequired, displayOrder: field.displayOrder, }); }), }), }); export type AppRouter = typeof appRouter;