Files
demat-facturation/server/routers.ts

1960 lines
77 KiB
TypeScript

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,
getSignaturesByUser,
getSignatureById,
createSignature,
deleteSignature,
getServiceSignaturesByUser,
upsertServiceSignature,
deleteServiceSignature,
createBapHistoryEntry,
getBapHistoryByUser,
deleteBapHistoryEntry,
getLearningsByUser,
getLearningsBySupplier,
upsertLearning,
deleteLearning,
deleteAllLearnings,
} 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 (username/email + password)
loginLocal: publicProcedure
.input(z.object({
email: z.string().min(1),
password: z.string().min(1),
}))
.mutation(async ({ input, ctx }) => {
const result = await loginLocal(input.email, input.password);
if (!result) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Identifiant ou mot de passe incorrect" });
}
// 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,
recipient: settings.recipientKeywords,
} : 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(),
recipientName: invoiceData.recipientName,
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
}
// Apply learnings (corrections manuelles mémorisées) after automation rules
try {
if (newInvoice.supplierName) {
const learnings = await getLearningsBySupplier(userId, newInvoice.supplierName);
if (learnings.length > 0) {
const learningUpdates: Record<string, string> = {};
for (const learning of learnings) {
if (learning.fieldName === 'typeAchat' || learning.fieldName === 'serviceConcerne' || learning.fieldName === 'ventilationComptable') {
learningUpdates[learning.fieldName] = learning.correctedValue;
}
}
if (Object.keys(learningUpdates).length > 0) {
await updateInvoice(newInvoice.id, learningUpdates as any);
console.log(`[Learning] Applied ${Object.keys(learningUpdates).length} learning(s) to invoice ${newInvoice.id} (${newInvoice.supplierName})`);
}
}
}
} catch (learningError) {
console.error("[Learning] Error applying learnings:", learningError);
// Don't fail the import if learning application 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(),
recipientName: 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 };
}),
validateBAP: 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" });
}
// Vérifier les critères de validation BAP
const score = invoice.qualityScore || 0;
const isNotSubscription = invoice.isSubscription === 0;
const hasService = !!invoice.serviceConcerne;
const hasTypeAchat = !!invoice.typeAchat;
const hasVentilation = !!invoice.ventilationComptable;
if (score < 100) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Le score de qualité doit être à 100% pour valider" });
}
if (!isNotSubscription) {
throw new TRPCError({ code: "BAD_REQUEST", message: "La facture est marquée comme abonnement" });
}
if (!hasService || !hasTypeAchat || !hasVentilation) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Les champs Service, Type d'achat et Ventilation doivent être remplis" });
}
// ── Génération du PDF annoté ──────────────────────────────────────
const fs = await import('fs/promises');
const path = await import('path');
const { PDFDocument, rgb, StandardFonts } = await import('pdf-lib');
const { localStoragePut, generateStorageKey } = await import('./localStorage');
const importSettings = await getImportSettingsByUser(ctx.user.id);
const bapExportMode = importSettings?.bapExportMode || 'browser';
const exportFolder = importSettings?.exportFolder || null;
const STORAGE_BASE_PATH = process.env.STORAGE_BASE_PATH || path.join(process.cwd(), 'storage');
let pdfUrl: string | null = null;
let exportPath: string | null = null;
let signatureName: string | null = null;
try {
if (!invoice.fileKey && !invoice.fileUrl) throw new Error('Fichier PDF source introuvable');
let pdfBytes: Buffer;
// Essayer d'abord le chemin local, puis l'URL publique
const sourcePath = path.join(STORAGE_BASE_PATH, invoice.fileKey || '');
try {
pdfBytes = await fs.readFile(sourcePath);
} catch (_localErr) {
// Fichier non disponible localement → télécharger depuis l'URL
const fileUrl = invoice.fileUrl;
if (!fileUrl) throw new Error('Fichier PDF source introuvable (local et URL)');
// Construire l'URL absolue si relative
let absoluteUrl = fileUrl;
if (fileUrl.startsWith('/')) {
const baseUrl = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
absoluteUrl = `${baseUrl}${fileUrl}`;
}
// Node.js 22 a fetch natif
const response = await fetch(absoluteUrl);
if (!response.ok) throw new Error(`Impossible de télécharger le PDF: ${response.status}`);
const arrayBuffer = await response.arrayBuffer();
pdfBytes = Buffer.from(arrayBuffer);
}
const pdfDoc = await PDFDocument.load(pdfBytes);
const pages = pdfDoc.getPages();
const lastPage = pages[pages.length - 1];
const { width, height } = lastPage.getSize();
// ── Zone blanche BAP (bas de page, hauteur 160pt) ────────────────
const zoneHeight = 160;
const zoneX = 30;
const zoneY = 10;
const zoneW = width - 60;
// Fond blanc
lastPage.drawRectangle({
x: zoneX,
y: zoneY,
width: zoneW,
height: zoneHeight,
color: rgb(1, 1, 1),
borderColor: rgb(0.7, 0.7, 0.7),
borderWidth: 0.5,
});
const font = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
const fontNormal = await pdfDoc.embedFont(StandardFonts.Helvetica);
// Colonne gauche : texte BAP (2/3 de la largeur)
// Colonne droite : signature (1/3 de la largeur)
const leftColW = Math.floor(zoneW * 0.62);
const rightColX = zoneX + leftColW + 10;
const rightColW = zoneW - leftColW - 20;
// Ligne 1 : CAPEX/OPEX (type achat)
const typeAchatText = (invoice.typeAchat || 'N/A').toUpperCase();
lastPage.drawText(typeAchatText, {
x: zoneX + 10,
y: zoneY + zoneHeight - 22,
size: 11,
font,
color: rgb(0.1, 0.1, 0.5),
});
// Ligne 2 : BON À PAYER (en vert, plus grand)
lastPage.drawText('BON À PAYER', {
x: zoneX + 10,
y: zoneY + zoneHeight - 42,
size: 14,
font,
color: rgb(0, 0.5, 0),
});
// Ligne 3 : Destinataire
const recipientRaw = (invoice as any).recipientName || '';
const destinataireText = recipientRaw ? recipientRaw : 'TOUS';
lastPage.drawText(destinataireText, {
x: zoneX + 10,
y: zoneY + zoneHeight - 62,
size: 10,
font: fontNormal,
color: rgb(0.2, 0.2, 0.2),
});
// Séparateur horizontal
lastPage.drawLine({
start: { x: zoneX + 10, y: zoneY + zoneHeight - 72 },
end: { x: zoneX + leftColW - 10, y: zoneY + zoneHeight - 72 },
thickness: 0.5,
color: rgb(0.7, 0.7, 0.7),
});
// Ligne 4 : Service + Ventilation
const line2 = `Service : ${invoice.serviceConcerne || '-'} | Ventilation : ${invoice.ventilationComptable || '-'}`;
lastPage.drawText(line2, {
x: zoneX + 10,
y: zoneY + zoneHeight - 88,
size: 9,
font: fontNormal,
color: rgb(0.2, 0.2, 0.2),
});
// Ligne 5 : Date de validation
const now = new Date();
const dateStr = now.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' });
const timeStr = now.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
lastPage.drawText(`Validé le ${dateStr} à ${timeStr}`, {
x: zoneX + 10,
y: zoneY + zoneHeight - 104,
size: 8,
font: fontNormal,
color: rgb(0.4, 0.4, 0.4),
});
// Séparateur vertical entre colonne gauche et droite
lastPage.drawLine({
start: { x: zoneX + leftColW, y: zoneY + 10 },
end: { x: zoneX + leftColW, y: zoneY + zoneHeight - 10 },
thickness: 0.5,
color: rgb(0.8, 0.8, 0.8),
});
// ── Signature du service ──────────────────────────────────────────
const serviceAssociations = await getServiceSignaturesByUser(ctx.user.id);
const serviceName = invoice.serviceConcerne || '';
const assoc = serviceAssociations.find(
a => a.serviceName.toLowerCase() === serviceName.toLowerCase()
);
if (assoc) {
const sig = await getSignatureById(assoc.signatureId);
if (sig) {
signatureName = `${sig.firstName} ${sig.lastName}`;
try {
let sigImageBytes: Buffer;
const sigImagePath = path.join(STORAGE_BASE_PATH, sig.imageKey);
try {
sigImageBytes = await fs.readFile(sigImagePath);
} catch (_sigLocalErr) {
// Fichier signature non disponible localement → télécharger depuis l'URL
const sigUrl = sig.imageUrl;
if (!sigUrl) throw new Error('Image signature introuvable');
let absoluteSigUrl = sigUrl;
if (sigUrl.startsWith('/')) {
const baseUrl = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
absoluteSigUrl = `${baseUrl}${sigUrl}`;
}
const sigResp = await fetch(absoluteSigUrl);
if (!sigResp.ok) throw new Error(`Impossible de télécharger la signature: ${sigResp.status}`);
sigImageBytes = Buffer.from(await sigResp.arrayBuffer());
}
const mimeType = sig.imageKey.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg';
let embeddedSig;
if (mimeType === 'image/png') {
embeddedSig = await pdfDoc.embedPng(sigImageBytes);
} else {
embeddedSig = await pdfDoc.embedJpg(sigImageBytes);
}
// Signature dans la colonne droite, centrée verticalement
const sigWidth = Math.min(rightColW - 10, 110);
const sigHeight = Math.round(sigWidth * 0.45);
const sigX = rightColX + (rightColW - sigWidth) / 2;
const sigY = zoneY + 35;
lastPage.drawImage(embeddedSig, {
x: sigX,
y: sigY,
width: sigWidth,
height: sigHeight,
});
// Nom du signataire centré sous la signature
const nameW = fontNormal.widthOfTextAtSize(signatureName, 8);
lastPage.drawText(signatureName, {
x: sigX + (sigWidth - nameW) / 2,
y: zoneY + 22,
size: 8,
font: fontNormal,
color: rgb(0.3, 0.3, 0.3),
});
} catch (_) { /* ignore signature errors */ }
}
}
const signedPdfBytes = await pdfDoc.save();
const filename = path.basename(invoice.fileKey);
const bapFilename = `BAP_${Date.now()}_${filename}`;
if (bapExportMode === 'folder' && exportFolder) {
// Mode dossier : enregistrer sur le disque
await fs.mkdir(exportFolder, { recursive: true });
exportPath = path.join(exportFolder, bapFilename);
await fs.writeFile(exportPath, signedPdfBytes);
} else {
// Mode navigateur : stocker localement et retourner l'URL relative
const bapKey = generateStorageKey(ctx.user.id, bapFilename);
const { url } = await localStoragePut(bapKey, Buffer.from(signedPdfBytes), 'application/pdf');
pdfUrl = url;
}
} catch (pdfError: any) {
console.warn('[BAP] Erreur génération PDF:', pdfError.message);
// On continue même si le PDF échoue — on valide quand même
}
// ── Mise à jour de la facture ─────────────────────────────────────
const validatedAt = new Date();
await updateInvoice(input.id, {
bapValidated: 1,
bapValidatedAt: validatedAt,
});
// ── Enregistrement dans l'historique BAP ─────────────────────────
await createBapHistoryEntry({
userId: ctx.user.id,
invoiceId: invoice.id,
supplierName: invoice.supplierName || null,
invoiceNumber: invoice.invoiceNumber || null,
invoiceDate: invoice.invoiceDate || null,
totalAmount: invoice.totalAmount ? String(invoice.totalAmount) : null,
typeAchat: invoice.typeAchat || null,
serviceConcerne: invoice.serviceConcerne || null,
ventilationComptable: invoice.ventilationComptable || null,
recipientName: (invoice as any).recipientName || null,
exportMode: bapExportMode === 'folder' ? 'folder' : 'browser',
exportPath: exportPath || null,
pdfUrl: pdfUrl || null,
signatureName: signatureName || null,
validatedAt,
});
return {
success: true,
invoiceId: input.id,
validatedAt,
pdfUrl,
exportPath,
exportMode: bapExportMode,
};
}),
// ── Validation BAP en masse ──────────────────────────────
validateBAPBulk: protectedProcedure
.mutation(async ({ ctx }) => {
const allInvoices = await getInvoicesByUser(ctx.user.id);
// Filtrer les factures éligibles (non déjà validées)
const eligible = allInvoices.filter((inv: any) =>
(inv.qualityScore || 0) >= 100 &&
inv.isSubscription === 0 &&
!!inv.serviceConcerne &&
!!inv.typeAchat &&
!!inv.ventilationComptable &&
!inv.bapValidated
);
if (eligible.length === 0) {
return { success: true, processed: 0, errors: 0, results: [] };
}
const fs = await import('fs/promises');
const path = await import('path');
const { PDFDocument, rgb, StandardFonts } = await import('pdf-lib');
const { localStoragePut, generateStorageKey } = await import('./localStorage');
const importSettings = await getImportSettingsByUser(ctx.user.id);
const bapExportMode = importSettings?.bapExportMode || 'browser';
const exportFolder = importSettings?.exportFolder || null;
const STORAGE_BASE_PATH = process.env.STORAGE_BASE_PATH || path.join(process.cwd(), 'storage');
const serviceSignaturesList = await getServiceSignaturesByUser(ctx.user.id);
const results: Array<{ id: number; success: boolean; pdfUrl?: string | null; exportPath?: string | null; error?: string }> = [];
let processed = 0;
let errors = 0;
const validatedAt = new Date();
for (const invoice of eligible) {
let pdfUrl: string | null = null;
let exportPath: string | null = null;
let signatureName: string | null = null;
try {
// ─ Lecture du PDF source ─
let sourcePdfBytes: Buffer;
const localPath = path.join(STORAGE_BASE_PATH, invoice.fileKey);
try {
sourcePdfBytes = await fs.readFile(localPath);
} catch (_) {
const fileUrl = invoice.fileUrl;
if (!fileUrl) throw new Error('Fichier PDF introuvable');
let absoluteUrl = fileUrl;
if (fileUrl.startsWith('/')) {
const baseUrl = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
absoluteUrl = `${baseUrl}${fileUrl}`;
}
const resp = await fetch(absoluteUrl);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
sourcePdfBytes = Buffer.from(await resp.arrayBuffer());
}
const pdfDoc = await PDFDocument.load(sourcePdfBytes);
const pages = pdfDoc.getPages();
const lastPage = pages[pages.length - 1];
const { width, height } = lastPage.getSize();
const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
const fontNormal = await pdfDoc.embedFont(StandardFonts.Helvetica);
// Zone BAP en bas à droite, layout 2 colonnes
const zoneH = 160;
const zoneW = width - 60;
const zoneX = 30;
const zoneY = 10;
const leftColW = Math.floor(zoneW * 0.62);
const rightColX = zoneX + leftColW + 10;
const rightColW = zoneW - leftColW - 20;
lastPage.drawRectangle({
x: zoneX, y: zoneY, width: zoneW, height: zoneH,
color: rgb(1, 1, 1),
borderColor: rgb(0.2, 0.2, 0.2),
borderWidth: 1,
});
// Ligne 1 : Type achat
const typeAchatText = (invoice.typeAchat || '').toUpperCase();
lastPage.drawText(typeAchatText, {
x: zoneX + 10, y: zoneY + zoneH - 22,
size: 11, font: fontBold, color: rgb(0.1, 0.1, 0.5),
});
// Ligne 2 : BON À PAYER
lastPage.drawText('BON À PAYER', {
x: zoneX + 10, y: zoneY + zoneH - 42,
size: 14, font: fontBold, color: rgb(0, 0.5, 0),
});
// Ligne 3 : Destinataire
const recipientText = (invoice as any).recipientName || 'TOUS';
lastPage.drawText(recipientText, {
x: zoneX + 10, y: zoneY + zoneH - 62,
size: 10, font: fontNormal, color: rgb(0.2, 0.2, 0.2),
});
// Séparateur horizontal
lastPage.drawLine({
start: { x: zoneX + 10, y: zoneY + zoneH - 72 },
end: { x: zoneX + leftColW - 10, y: zoneY + zoneH - 72 },
thickness: 0.5, color: rgb(0.7, 0.7, 0.7),
});
// Ligne 4 : Service + Ventilation
const serviceVentLine = `Service : ${invoice.serviceConcerne || '-'} | Ventilation : ${invoice.ventilationComptable || '-'}`;
lastPage.drawText(serviceVentLine, {
x: zoneX + 10, y: zoneY + zoneH - 88,
size: 9, font: fontNormal, color: rgb(0.2, 0.2, 0.2),
});
// Ligne 5 : Date de validation
const bulkNow = new Date();
const bulkDateStr = bulkNow.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' });
const bulkTimeStr = bulkNow.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
lastPage.drawText(`Validé le ${bulkDateStr} à ${bulkTimeStr}`, {
x: zoneX + 10, y: zoneY + zoneH - 104,
size: 8, font: fontNormal, color: rgb(0.4, 0.4, 0.4),
});
// Séparateur vertical
lastPage.drawLine({
start: { x: zoneX + leftColW, y: zoneY + 10 },
end: { x: zoneX + leftColW, y: zoneY + zoneH - 10 },
thickness: 0.5, color: rgb(0.8, 0.8, 0.8),
});
// Signature du service
const serviceName = invoice.serviceConcerne || '';
const assoc = serviceSignaturesList.find(
a => a.serviceName.toLowerCase() === serviceName.toLowerCase()
);
if (assoc) {
const sig = await getSignatureById(assoc.signatureId);
if (sig) {
signatureName = `${sig.firstName} ${sig.lastName}`;
try {
let sigImageBytes: Buffer;
const sigImagePath = path.join(STORAGE_BASE_PATH, sig.imageKey);
try {
sigImageBytes = await fs.readFile(sigImagePath);
} catch (_) {
const sigUrl = sig.imageUrl;
if (!sigUrl) throw new Error('Image signature introuvable');
let absoluteSigUrl = sigUrl;
if (sigUrl.startsWith('/')) {
const baseUrl = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
absoluteSigUrl = `${baseUrl}${sigUrl}`;
}
const sigResp = await fetch(absoluteSigUrl);
if (!sigResp.ok) throw new Error(`HTTP ${sigResp.status}`);
sigImageBytes = Buffer.from(await sigResp.arrayBuffer());
}
const mimeType = sig.imageKey.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg';
const embeddedSig = mimeType === 'image/png'
? await pdfDoc.embedPng(sigImageBytes)
: await pdfDoc.embedJpg(sigImageBytes);
// Signature dans la colonne droite, centrée
const bulkSigWidth = Math.min(rightColW - 10, 110);
const bulkSigHeight = Math.round(bulkSigWidth * 0.45);
const bulkSigX = rightColX + (rightColW - bulkSigWidth) / 2;
const bulkSigY = zoneY + 35;
lastPage.drawImage(embeddedSig, {
x: bulkSigX, y: bulkSigY, width: bulkSigWidth, height: bulkSigHeight,
});
const bulkNameW = fontNormal.widthOfTextAtSize(signatureName, 8);
lastPage.drawText(signatureName, {
x: bulkSigX + (bulkSigWidth - bulkNameW) / 2, y: zoneY + 22,
size: 8, font: fontNormal, color: rgb(0.3, 0.3, 0.3),
});
} catch (_) { /* ignore signature errors */ }
}
}
const signedPdfBytes = await pdfDoc.save();
const filename = path.basename(invoice.fileKey);
const bapFilename = `BAP_${Date.now()}_${filename}`;
if (bapExportMode === 'folder' && exportFolder) {
await fs.mkdir(exportFolder, { recursive: true });
exportPath = path.join(exportFolder, bapFilename);
await fs.writeFile(exportPath, signedPdfBytes);
} else {
const bapKey = generateStorageKey(ctx.user.id, bapFilename);
const { url } = await localStoragePut(bapKey, Buffer.from(signedPdfBytes), 'application/pdf');
pdfUrl = url;
}
} catch (pdfError: any) {
console.warn(`[BAP Bulk] Erreur PDF facture ${invoice.id}:`, pdfError.message);
}
// Mise à jour de la facture
await updateInvoice(invoice.id, { bapValidated: 1, bapValidatedAt: validatedAt });
// Historique
await createBapHistoryEntry({
userId: ctx.user.id,
invoiceId: invoice.id,
supplierName: invoice.supplierName || null,
invoiceNumber: invoice.invoiceNumber || null,
invoiceDate: invoice.invoiceDate || null,
totalAmount: invoice.totalAmount ? String(invoice.totalAmount) : null,
typeAchat: invoice.typeAchat || null,
serviceConcerne: invoice.serviceConcerne || null,
ventilationComptable: invoice.ventilationComptable || null,
recipientName: (invoice as any).recipientName || null,
exportMode: bapExportMode === 'folder' ? 'folder' : 'browser',
exportPath: exportPath || null,
pdfUrl: pdfUrl || null,
signatureName: signatureName || null,
validatedAt,
});
results.push({ id: invoice.id, success: true, pdfUrl, exportPath });
processed++;
}
return { success: true, processed, errors, results };
}),
// ── Relancer les automatismes sur une sélection ─────────────────
reprocessSelected: protectedProcedure
.input(z.object({
invoiceIds: z.array(z.number()).min(1),
}))
.mutation(async ({ input, ctx }) => {
// Relance UNIQUEMENT les automatismes (sans re-extraction LLM)
const { applyAutomationRules } = await import("./automationEngine");
const allInvoices = await getInvoicesByUser(ctx.user.id);
const selected = allInvoices.filter(inv => input.invoiceIds.includes(inv.id));
if (selected.length === 0) throw new TRPCError({ code: 'NOT_FOUND', message: 'Aucune facture trouvée' });
let processed = 0;
let errors = 0;
const results: Array<{ id: number; success: boolean; error?: string }> = [];
for (const invoice of selected) {
try {
const automationUpdates = await applyAutomationRules(ctx.user.id, invoice);
if (Object.keys(automationUpdates).length > 0) {
await updateInvoice(invoice.id, automationUpdates);
}
results.push({ id: invoice.id, success: true });
processed++;
} catch (err: any) {
console.error(`[Reprocess] Error on invoice ${invoice.id}:`, err);
results.push({ id: invoice.id, success: false, error: err.message });
errors++;
}
}
return { success: true, processed, errors, results };
}),
// // ── Historique BAP ────────────────────
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);
}),
}),
// ============= BAP HISTORY ROUTES =============
bapHistory: router({
getAll: protectedProcedure.query(async ({ ctx }) => {
return getBapHistoryByUser(ctx.user.id);
}),
delete: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input, ctx }) => {
const entries = await getBapHistoryByUser(ctx.user.id);
const entry = entries.find(e => e.id === input.id);
if (!entry) throw new TRPCError({ code: 'NOT_FOUND' });
await deleteBapHistoryEntry(input.id);
return { success: true };
}),
}),
// ============= 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(),
recipientKeywords: z.string().optional(),
sftpRecipientFilter: 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().min(1),
password: z.string().min(1),
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');
const { PDFDocument } = await import('pdf-lib');
// Get export folder from settings
const settings = await getImportSettingsByUser(ctx.user.id);
const exportFolder = settings?.exportFolder;
// Load service→signature associations
const serviceAssociations = await getServiceSignaturesByUser(ctx.user.id);
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);
// Find signature associated with invoice service
const serviceName = invoice.serviceConcerne || '';
const assoc = serviceAssociations.find(
a => a.serviceName.toLowerCase() === serviceName.toLowerCase()
);
if (assoc) {
// Load signature image and embed it in the PDF
try {
const sig = await getSignatureById(assoc.signatureId);
if (sig) {
const sigImagePath = path.join(STORAGE_BASE_PATH, sig.imageKey);
// Lire le PDF source (local ou URL)
let pdfBytes: Buffer;
try {
pdfBytes = await fs.readFile(sourcePath);
} catch (_) {
const fileUrl = invoice!.fileUrl;
if (!fileUrl) throw new Error('PDF source introuvable');
let absUrl = fileUrl.startsWith('/') ? `${process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 3000}`}${fileUrl}` : fileUrl;
const r = await fetch(absUrl);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
pdfBytes = Buffer.from(await r.arrayBuffer());
}
const pdfDoc = await PDFDocument.load(pdfBytes);
const pages = pdfDoc.getPages();
const lastPage = pages[pages.length - 1];
const { width, height } = lastPage.getSize();
// Read signature image (local ou URL)
let sigImageBytes: Buffer;
try {
sigImageBytes = await fs.readFile(sigImagePath);
} catch (_) {
const sigUrl = sig.imageUrl;
if (!sigUrl) throw new Error('Signature introuvable');
let absSigUrl = sigUrl.startsWith('/') ? `${process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 3000}`}${sigUrl}` : sigUrl;
const sr = await fetch(absSigUrl);
if (!sr.ok) throw new Error(`HTTP ${sr.status}`);
sigImageBytes = Buffer.from(await sr.arrayBuffer());
}
const mimeType = sig.imageKey.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg';
let embeddedSig;
if (mimeType === 'image/png') {
embeddedSig = await pdfDoc.embedPng(sigImageBytes);
} else {
embeddedSig = await pdfDoc.embedJpg(sigImageBytes);
}
// Draw signature in bottom-right corner
const sigWidth = 120;
const sigHeight = 50;
const margin = 30;
lastPage.drawImage(embeddedSig, {
x: width - sigWidth - margin,
y: margin,
width: sigWidth,
height: sigHeight,
});
// Add signer name below signature
const { StandardFonts } = await import('pdf-lib');
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
lastPage.drawText(`${sig.firstName} ${sig.lastName}`, {
x: width - sigWidth - margin,
y: margin - 14,
size: 9,
font,
});
const signedPdfBytes = await pdfDoc.save();
await fs.writeFile(destPath, signedPdfBytes);
copiedFiles.push(destPath);
console.log(`[Export] Signature apposée pour ${serviceName} sur ${filename}`);
} else {
// Signature not found, copy without signature
try { await fs.copyFile(sourcePath, destPath); } catch (_) {
const fu = invoice!.fileUrl; if (!fu) throw new Error('PDF introuvable');
const au = fu.startsWith('/') ? `${process.env.APP_BASE_URL||`http://localhost:${process.env.PORT||3000}`}${fu}` : fu;
const rb = await fetch(au); if (!rb.ok) throw new Error(`HTTP ${rb.status}`);
await fs.writeFile(destPath, Buffer.from(await rb.arrayBuffer()));
}
copiedFiles.push(destPath);
}
} catch (sigError: any) {
console.warn(`[Export] Impossible d'apposer la signature: ${sigError.message}. Copie sans signature.`);
try { await fs.copyFile(sourcePath, destPath); } catch (_) {
const fu = invoice!.fileUrl; if (!fu) throw new Error('PDF introuvable');
const au = fu.startsWith('/') ? `${process.env.APP_BASE_URL||`http://localhost:${process.env.PORT||3000}`}${fu}` : fu;
const rb = await fetch(au); if (!rb.ok) throw new Error(`HTTP ${rb.status}`);
await fs.writeFile(destPath, Buffer.from(await rb.arrayBuffer()));
}
copiedFiles.push(destPath);
}
} else {
// No signature association, copy as-is
try { await fs.copyFile(sourcePath, destPath); } catch (_) {
const fu = invoice!.fileUrl; if (!fu) throw new Error('PDF introuvable');
const au = fu.startsWith('/') ? `${process.env.APP_BASE_URL||`http://localhost:${process.env.PORT||3000}`}${fu}` : fu;
const rb = await fetch(au); if (!rb.ok) throw new Error(`HTTP ${rb.status}`);
await fs.writeFile(destPath, Buffer.from(await rb.arrayBuffer()));
}
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" });
}
// Load user settings to check recipient filter
const userSettings = await getUserSettings(ctx.user.id);
const recipientFilter = (userSettings as any)?.sftpRecipientFilter?.trim() || "";
let successCount = 0;
let errorCount = 0;
let filteredCount = 0;
for (const invoiceId of input.invoiceIds) {
try {
const invoice = await getInvoiceById(invoiceId);
if (!invoice || invoice.userId !== ctx.user.id) {
errorCount++;
continue;
}
// Apply recipient filter if configured
if (recipientFilter) {
const invoiceRecipient = ((invoice as any).recipientName || "").toLowerCase();
if (!invoiceRecipient.includes(recipientFilter.toLowerCase())) {
filteredCount++;
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, filteredCount };
}),
}),
// ============= 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,
bapExportMode: "browser" as const,
};
}
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(),
bapExportMode: z.enum(["browser", "folder"]).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,
});
}),
}),
// ============= SIGNATURES ROUTES =============
signatures: router({
list: protectedProcedure.query(async ({ ctx }) => {
return await getSignaturesByUser(ctx.user.id);
}),
upload: protectedProcedure
.input(z.object({
firstName: z.string().min(1).max(100),
lastName: z.string().min(1).max(100),
fileName: z.string().min(1),
fileData: z.string(), // Base64 encoded image
mimeType: z.string().default("image/png"),
}))
.mutation(async ({ input, ctx }) => {
const userId = ctx.user.id;
const fileBuffer = Buffer.from(input.fileData, "base64");
const safeFileName = `${input.firstName}-${input.lastName}-${Date.now()}-${input.fileName}`
.replace(/[^a-zA-Z0-9._-]/g, "_");
const imageKey = generateStorageKey(userId, safeFileName);
const result = await localStoragePut(imageKey, fileBuffer, input.mimeType);
return await createSignature({
userId,
firstName: input.firstName,
lastName: input.lastName,
imageKey,
imageUrl: result.url,
});
}),
create: protectedProcedure
.input(z.object({
firstName: z.string().min(1).max(100),
lastName: z.string().min(1).max(100),
imageKey: z.string().min(1),
imageUrl: z.string().min(1),
}))
.mutation(async ({ input, ctx }) => {
return await createSignature({
userId: ctx.user.id,
firstName: input.firstName,
lastName: input.lastName,
imageKey: input.imageKey,
imageUrl: input.imageUrl,
});
}),
delete: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input, ctx }) => {
const sig = await getSignatureById(input.id);
if (!sig || sig.userId !== ctx.user.id) {
throw new TRPCError({ code: "NOT_FOUND" });
}
await deleteSignature(input.id);
return { success: true };
}),
}),
// ============= SERVICE SIGNATURES ROUTES =============
serviceSignatures: router({
list: protectedProcedure.query(async ({ ctx }) => {
return await getServiceSignaturesByUser(ctx.user.id);
}),
upsert: protectedProcedure
.input(z.object({
serviceName: z.string().min(1).max(100),
signatureId: z.number(),
}))
.mutation(async ({ input, ctx }) => {
await upsertServiceSignature({
userId: ctx.user.id,
serviceName: input.serviceName,
signatureId: input.signatureId,
});
return { success: true };
}),
delete: protectedProcedure
.input(z.object({ serviceName: z.string() }))
.mutation(async ({ input, ctx }) => {
await deleteServiceSignature(ctx.user.id, input.serviceName);
return { success: true };
}),
}),
// ============= LEARNINGS ROUTES =============
learnings: router({
/** Liste tous les apprentissages de l'utilisateur */
list: protectedProcedure.query(async ({ ctx }) => {
return await getLearningsByUser(ctx.user.id);
}),
/** Enregistre ou met à jour un apprentissage suite à une correction manuelle */
upsert: protectedProcedure
.input(z.object({
supplierName: z.string().min(1),
fieldName: z.string().min(1),
originalValue: z.string().optional(),
correctedValue: z.string(),
}))
.mutation(async ({ input, ctx }) => {
await upsertLearning({
userId: ctx.user.id,
supplierName: input.supplierName,
fieldName: input.fieldName,
originalValue: input.originalValue,
correctedValue: input.correctedValue,
});
return { success: true };
}),
/** Supprime un apprentissage par ID */
delete: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input, ctx }) => {
const all = await getLearningsByUser(ctx.user.id);
const entry = all.find(l => l.id === input.id);
if (!entry) throw new TRPCError({ code: 'NOT_FOUND' });
await deleteLearning(input.id);
return { success: true };
}),
/** Supprime tous les apprentissages de l'utilisateur */
deleteAll: protectedProcedure
.mutation(async ({ ctx }) => {
await deleteAllLearnings(ctx.user.id);
return { success: true };
}),
}),
});
export type AppRouter = typeof appRouter;