Files
demat-facturation/server/routers.ts

2802 lines
120 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { z } from "zod";
import { COOKIE_NAME } from "@shared/const";
import { BAP_MIN_QUALITY_SCORE, meetsBapQualityThreshold } from "@shared/bapEligibility";
interface Condition {
field: string;
operator: string;
value: string;
}
interface Actions {
typeAchat?: string;
serviceConcerne?: string;
ventilationComptable?: string;
isSubscription?: 0 | 1;
}
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,
getSourceFileByContentHash,
getSourceFileById,
updateSourceFile,
getUserSettings,
upsertUserSettings,
createLocalUser,
getAllUsers,
updateUserPassword,
toggleUserActive,
deleteUser,
findDuplicateInvoice,
isInvoiceBlacklisted,
getAllInvoices,
getAllImportLogs,
getAllImportLogsWithDetails,
getAllBapHistory,
createImportLog,
getImportLogsByUser,
getImportLogsWithDetailsByUser,
deleteAllImportLogs,
getLlmLogsBySourceFile,
getLlmLogsByInvoice,
getImportSettingsByUser,
getEmailImportAccounts,
upsertImportSettings,
getDepartmentsByUser,
createDepartment,
deleteDepartment,
getAccountingAllocationsByUser,
createAccountingAllocation,
deleteAccountingAllocation,
initializeDefaultLists,
getLlmFieldsConfigByUser,
upsertLlmFieldConfig,
initializeDefaultLlmFields,
getAutomationRulesByUser,
getAutomationRuleById,
createAutomationRule,
updateAutomationRule,
deleteAutomationRule,
getExportAutomationRulesByUser,
getExportAutomationRuleById,
createExportAutomationRule,
updateExportAutomationRule,
deleteExportAutomationRule,
getSignaturesByUser,
getSignatureById,
createSignature,
deleteSignature,
getServiceSignaturesByUser,
upsertServiceSignature,
deleteServiceSignature,
createBapHistoryEntry,
getBapHistoryByUser,
deleteBapHistoryEntry,
updateBapHistoryPdfUrl,
getLearningsByUser,
getLearningsBySupplier,
upsertLearning,
deleteLearning,
deleteAllLearnings,
getBapPdfUrlsByInvoiceIds,
getWebImportSourcesByUser,
getWebImportSourceById,
createWebImportSource,
updateWebImportSource,
deleteWebImportSource,
} from "./db";
import { loginLocal, hashPassword, getAzureAuthUrl, isAzureAdConfigured } from "./auth";
import fsSync from "fs";
import pathSync from "path";
import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor";
import { calculateFileSha256 } from "./fileFingerprint";
import { localStoragePut, generateStorageKey } from "./localStorage";
import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport";
import { drawBapCartouche } from "./bapCartouche";
import { resolveBapExportDestination } from "./exportDestinationResolver";
import { startEmailImportService, stopEmailImportService, isEmailImportServiceRunning, triggerEmailCheck, triggerManualEmailCheck, testImapConnection } from "./emailImportService";
import { startFolderImportService, stopFolderImportService, isFolderImportServiceRunning } from "./folderImportService";
import { TRPCError } from "@trpc/server";
import { processFreeproExcel } from "./freeproService";
import { runFreeproAutoImport, testFreeproConnection, startFreeproAutoJob, stopFreeproAutoJob, frequencyToMs } from "./freeproAutoImport";
import {
createFreeproImport,
getFreeproImportsByUser,
getFreeproImportWithLines,
deleteFreeproImport,
getFreeproSettings,
upsertFreeproSettings,
} from "./db";
// 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`);
// Le contrôle sur les octets du PDF intervient avant le stockage et l'appel IA.
// Il reste fiable même si le nom du fichier ou le compte utilisateur diffère.
const contentHash = calculateFileSha256(fileBuffer);
const existingSource = await getSourceFileByContentHash(contentHash);
if (existingSource) {
throw new TRPCError({
code: "CONFLICT",
message: `Ce PDF a déjà été importé (${existingSource.fileName}).`,
});
}
// 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,
contentHash,
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";
const aiSettings = settings ? {
aiProvider: (settings as any).aiProvider || "mistral",
mistralApiKey: (settings as any).mistralApiKey || null,
manusForgeApiKey: (settings as any).manusForgeApiKey || null,
manusForgeApiUrl: (settings as any).manusForgeApiUrl || null,
geminiApiKey: (settings as any).geminiApiKey || null,
} : undefined;
// Extract invoices
const result = await extractInvoicesWithMistral(
fileBuffer,
userId,
sourceFile.id,
model,
customKeywords,
aiSettings
);
// 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...`,
});
// Vérifier la blacklist (factures supprimées manuellement)
const blacklisted = await isInvoiceBlacklisted(invoiceData.invoiceNumber, userId);
if (blacklisted) {
duplicatesCount++;
duplicateDetails.push({
supplierName: invoiceData.supplierName,
invoiceNumber: invoiceData.invoiceNumber,
totalAmount: invoiceData.totalAmount,
reason: 'blacklisted',
});
continue;
}
// Check for duplicates (numéro de facture + montant)
const duplicate = await findDuplicateInvoice(
invoiceData.invoiceNumber,
invoiceData.totalAmount?.toString() ?? null,
userId
);
if (duplicate) {
duplicatesCount++;
duplicateDetails.push({
supplierName: invoiceData.supplierName,
invoiceNumber: invoiceData.invoiceNumber,
totalAmount: invoiceData.totalAmount,
});
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> = {};
const isSubscriptionLearning = learnings.find(l => l.fieldName === 'isSubscription');
for (const learning of learnings) {
if (learning.fieldName === 'typeAchat' || learning.fieldName === 'serviceConcerne' || learning.fieldName === 'ventilationComptable') {
learningUpdates[learning.fieldName] = learning.correctedValue;
}
}
// Appliquer l'apprentissage isSubscription
if (isSubscriptionLearning) {
(learningUpdates as any)['isSubscription'] = isSubscriptionLearning.correctedValue === 'OUI' ? 1 : 0;
}
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),
importSource: "file",
importTrigger: "manual",
});
} 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 }) => {
// Les admins voient toutes les factures, les utilisateurs standard voient les leurs
if (ctx.user.role === 'admin') {
return getAllInvoices();
}
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 || (ctx.user.role !== 'admin' && 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 || (ctx.user.role !== 'admin' && 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 || (ctx.user.role !== 'admin' && 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 || (ctx.user.role !== 'admin' && 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 (!meetsBapQualityThreshold(score)) {
throw new TRPCError({ code: "BAD_REQUEST", message: `Le score de qualité doit être au moins de ${BAP_MIN_QUALITY_SCORE}% 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 resolvedDestination = await resolveBapExportDestination(ctx.user.id, invoice, importSettings);
const bapExportMode = resolvedDestination.exportMode;
const exportFolder = resolvedDestination.destinationPath;
const exportFolderType = resolvedDestination.destinationType;
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;
let sharepointUploadStatus: 'success' | 'error' | 'skipped' | null = null;
let sharepointUploadPath: string | null = null;
let sharepointUploadError: 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);
// ── Récupération de la signature du service ───────────────────────
let sigBytesForCartouche: Buffer | undefined;
let sigMimeForCartouche: "image/png" | "image/jpeg" | undefined;
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 {
const sigImagePath = path.join(STORAGE_BASE_PATH, sig.imageKey);
try {
sigBytesForCartouche = await fs.readFile(sigImagePath);
} catch (_sigLocalErr) {
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}`);
sigBytesForCartouche = Buffer.from(await sigResp.arrayBuffer());
}
sigMimeForCartouche = sig.imageKey.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg';
} catch (_) { /* ignore signature errors */ }
}
}
// ── Placement intelligent du cartouche BAP ────────────────────────
await drawBapCartouche(pdfDoc, pdfBytes, {
typeAchat: invoice.typeAchat || 'N/A',
destinataire: (invoice as any).recipientName || 'TOUS',
serviceConcerne: invoice.serviceConcerne || '-',
ventilationComptable: invoice.ventilationComptable || '-',
validatedAt: new Date(),
signatureImageBytes: sigBytesForCartouche,
signatureMimeType: sigMimeForCartouche,
signatureName: signatureName || undefined,
});
const signedPdfBytes = await pdfDoc.save();
const _bapDateStr = invoice.invoiceDate ? new Date(invoice.invoiceDate).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
const _bapSupplier = (invoice.supplierName || 'Fournisseur').replace(/[^a-zA-Z0-9\u00e0-\u00ff \-]/g, '').trim();
const _bapNumber = (invoice.invoiceNumber || '').replace(/[^a-zA-Z0-9\-]/g, '').trim();
const bapFilename = [_bapDateStr, _bapSupplier, _bapNumber].filter(Boolean).join(' - ') + '.pdf';
console.log(`[BAP] Mode export: ${bapExportMode}, type: ${exportFolderType}, source: ${resolvedDestination.source}, dossier: ${exportFolder ? 'configuré' : 'non configuré'}`);
if ((bapExportMode === 'folder' || bapExportMode === 'both') && exportFolder) {
if (exportFolderType === 'sharepoint' || exportFolderType === 'teams') {
// Les fichiers Teams sont déposés via le site SharePoint de léquipe.
console.log('[BAP] Démarrage upload Microsoft 365 pour:', bapFilename);
const { uploadToSharePoint } = await import('./sharepoint');
const spResult = await uploadToSharePoint(
{
tenantId: (importSettings as any)?.azureTenantId || '',
clientId: (importSettings as any)?.azureClientId || '',
clientSecret: (importSettings as any)?.azureClientSecret || '',
sharepointUrl: exportFolder,
},
Buffer.from(signedPdfBytes),
bapFilename
);
if (spResult.success) {
exportPath = spResult.webUrl || exportFolder;
sharepointUploadStatus = 'success';
sharepointUploadPath = spResult.webUrl || exportFolder;
console.log('[BAP] Upload SharePoint réussi:', spResult.webUrl);
} else {
console.error('[BAP] Erreur upload SharePoint:', spResult.error, '| Debug:', spResult.debugInfo);
sharepointUploadStatus = 'error';
sharepointUploadError = spResult.error || 'Erreur inconnue';
// Fallback : stocker localement
const bapKey = generateStorageKey(ctx.user.id, bapFilename);
const { url } = await localStoragePut(bapKey, Buffer.from(signedPdfBytes), 'application/pdf');
pdfUrl = url;
}
} else {
// Mode dossier local : enregistrer sur le disque
await fs.mkdir(exportFolder, { recursive: true });
exportPath = path.join(exportFolder, bapFilename);
await fs.writeFile(exportPath, signedPdfBytes);
}
}
if (bapExportMode === 'browser' || bapExportMode === 'both' || !exportFolder) {
// 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();
const newExportStatus = (exportPath || pdfUrl) ? 'exported' : 'not_exported';
await updateInvoice(input.id, {
bapValidated: 1,
bapValidatedAt: validatedAt,
exportStatus: newExportStatus as any,
});
// ── 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,
sharepointUploadStatus: (sharepointUploadStatus as any) || null,
sharepointUploadPath: sharepointUploadPath || null,
sharepointUploadError: sharepointUploadError || null,
validatedAt,
});
return {
success: true,
invoiceId: input.id,
validatedAt,
pdfUrl,
exportPath,
exportMode: bapExportMode,
sharepointUploadStatus: sharepointUploadStatus || null,
sharepointUploadError: sharepointUploadError || null,
sharepointUploadPath: sharepointUploadPath || null,
};
}),
// ── Dévalidation BAP ──────────────────────────────────────
devalidateBAP: protectedProcedure
.input(z.object({ invoiceIds: z.array(z.number()) }))
.mutation(async ({ input, ctx }) => {
let processed = 0;
for (const id of input.invoiceIds) {
const invoice = await getInvoiceById(id);
if (!invoice || (ctx.user.role !== 'admin' && invoice.userId !== ctx.user.id)) continue;
await updateInvoice(id, {
bapValidated: 0,
bapValidatedAt: null,
exportStatus: "not_exported",
});
processed++;
}
return { success: true, processed };
}),
// ── Validation BAP en masse ──────────────────────────────
validateBAPBulk: protectedProcedure
.mutation(async ({ ctx }) => {
const allInvoices = ctx.user.role === 'admin' ? await getAllInvoices() : await getInvoicesByUser(ctx.user.id);
// Filtrer les factures éligibles (non déjà validées)
const eligible = allInvoices.filter((inv: any) =>
meetsBapQualityThreshold(inv.qualityScore) &&
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 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;
let sharepointUploadStatus: 'success' | 'error' | 'skipped' | null = null;
let sharepointUploadPath: string | null = null;
let sharepointUploadError: string | null = null;
const resolvedDestination = await resolveBapExportDestination(ctx.user.id, invoice, importSettings);
const bapExportMode = resolvedDestination.exportMode;
const exportFolder = resolvedDestination.destinationPath;
const exportFolderType = resolvedDestination.destinationType;
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);
// ── Récupération de la signature du service ───────────────────────
let sigBytesForCartouche2: Buffer | undefined;
let sigMimeForCartouche2: "image/png" | "image/jpeg" | undefined;
const serviceName2 = invoice.serviceConcerne || '';
const assoc2 = serviceSignaturesList.find(
a => a.serviceName.toLowerCase() === serviceName2.toLowerCase()
);
if (assoc2) {
const sig2 = await getSignatureById(assoc2.signatureId);
if (sig2) {
signatureName = `${sig2.firstName} ${sig2.lastName}`;
try {
const sigImagePath2 = path.join(STORAGE_BASE_PATH, sig2.imageKey);
try {
sigBytesForCartouche2 = await fs.readFile(sigImagePath2);
} catch (_) {
const sigUrl2 = sig2.imageUrl;
if (!sigUrl2) throw new Error('Image signature introuvable');
let absoluteSigUrl2 = sigUrl2;
if (sigUrl2.startsWith('/')) {
const baseUrl = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
absoluteSigUrl2 = `${baseUrl}${sigUrl2}`;
}
const sigResp2 = await fetch(absoluteSigUrl2);
if (!sigResp2.ok) throw new Error(`HTTP ${sigResp2.status}`);
sigBytesForCartouche2 = Buffer.from(await sigResp2.arrayBuffer());
}
sigMimeForCartouche2 = sig2.imageKey.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg';
} catch (_) { /* ignore */ }
}
}
// ── Placement intelligent du cartouche BAP ────────────────────────
await drawBapCartouche(pdfDoc, sourcePdfBytes, {
typeAchat: invoice.typeAchat || '',
destinataire: (invoice as any).recipientName || 'TOUS',
serviceConcerne: invoice.serviceConcerne || '-',
ventilationComptable: invoice.ventilationComptable || '-',
validatedAt: validatedAt,
signatureImageBytes: sigBytesForCartouche2,
signatureMimeType: sigMimeForCartouche2,
signatureName: signatureName || undefined,
});
const signedPdfBytes = await pdfDoc.save();
const _bapDateStr2 = invoice.invoiceDate ? new Date(invoice.invoiceDate).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
const _bapSupplier2 = (invoice.supplierName || 'Fournisseur').replace(/[^a-zA-Z0-9\u00e0-\u00ff \-]/g, '').trim();
const _bapNumber2 = (invoice.invoiceNumber || '').replace(/[^a-zA-Z0-9\-]/g, '').trim();
const bapFilename = [_bapDateStr2, _bapSupplier2, _bapNumber2].filter(Boolean).join(' - ') + '.pdf';
if ((bapExportMode === 'folder' || bapExportMode === 'both') && exportFolder) {
if (exportFolderType === 'sharepoint' || exportFolderType === 'teams') {
const { uploadToSharePoint } = await import('./sharepoint');
const spResult = await uploadToSharePoint(
{
tenantId: (importSettings as any)?.azureTenantId || '',
clientId: (importSettings as any)?.azureClientId || '',
clientSecret: (importSettings as any)?.azureClientSecret || '',
sharepointUrl: exportFolder,
},
Buffer.from(signedPdfBytes),
bapFilename
);
if (spResult.success) {
exportPath = spResult.webUrl || exportFolder;
} else {
console.warn('[BAP Bulk] Erreur upload SharePoint:', spResult.error);
const bapKey = generateStorageKey(ctx.user.id, bapFilename);
const { url } = await localStoragePut(bapKey, Buffer.from(signedPdfBytes), 'application/pdf');
pdfUrl = url;
}
} else {
await fs.mkdir(exportFolder, { recursive: true });
exportPath = path.join(exportFolder, bapFilename);
await fs.writeFile(exportPath, signedPdfBytes);
}
}
if (bapExportMode === 'browser' || bapExportMode === 'both' || !exportFolder) {
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
const bulkExportStatus = (exportPath || pdfUrl) ? 'exported' : 'not_exported';
await updateInvoice(invoice.id, { bapValidated: 1, bapValidatedAt: validatedAt, exportStatus: bulkExportStatus as any });
// 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,
sharepointUploadStatus: (sharepointUploadStatus as any) || null,
sharepointUploadPath: sharepointUploadPath || null,
sharepointUploadError: sharepointUploadError || null,
validatedAt,
});
results.push({ id: invoice.id, success: true, pdfUrl, exportPath });
processed++;
}
return { success: true, processed, errors, results };
}),
// ── Récupérer les pdfUrl BAP pour une liste de factures validées ─────────────────
getBapPdfUrls: protectedProcedure
.input(z.object({ invoiceIds: z.array(z.number()) }))
.query(async ({ input }) => {
if (input.invoiceIds.length === 0) return {};
return getBapPdfUrlsByInvoiceIds(input.invoiceIds);
}),
// ── 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 = ctx.user.role === 'admin' ? await getAllInvoices() : 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 };
}),
// ── Régénérer le PDF BAP pour une facture validée sans PDF ─────────────────
regenerateBapPdf: protectedProcedure
.input(z.object({ invoiceId: z.number() }))
.mutation(async ({ input, ctx }) => {
const invoice = await getInvoiceById(input.invoiceId);
if (!invoice || (ctx.user.role !== 'admin' && invoice.userId !== ctx.user.id)) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Facture introuvable' });
}
if (!invoice.bapValidated) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'La facture n\'est pas validée BAP' });
}
const fs = await import('fs/promises');
const path = await import('path');
const { PDFDocument } = await import('pdf-lib');
const { localStoragePut, generateStorageKey } = await import('./localStorage');
const STORAGE_BASE_PATH = process.env.STORAGE_BASE_PATH || path.join(process.cwd(), 'storage');
if (!invoice.fileKey && !invoice.fileUrl) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Fichier PDF source introuvable' });
}
let pdfBytes: Buffer;
const sourcePath = path.join(STORAGE_BASE_PATH, invoice.fileKey || '');
try {
pdfBytes = await fs.readFile(sourcePath);
} catch (_) {
const fileUrl = invoice.fileUrl;
if (!fileUrl) throw new TRPCError({ code: 'BAD_REQUEST', message: 'Fichier PDF source introuvable' });
let absoluteUrl = fileUrl;
if (fileUrl.startsWith('/')) {
const baseUrl = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
absoluteUrl = `${baseUrl}${fileUrl}`;
}
const response = await fetch(absoluteUrl);
if (!response.ok) throw new TRPCError({ code: 'BAD_REQUEST', message: 'Impossible de télécharger le PDF source' });
pdfBytes = Buffer.from(await response.arrayBuffer());
}
const pdfDoc = await PDFDocument.load(pdfBytes);
// Récupération de la signature du service
let sigBytesForCartouche: Buffer | undefined;
let sigMimeForCartouche: 'image/png' | 'image/jpeg' | undefined;
let signatureName: string | null = null;
if (invoice.serviceConcerne) {
const serviceAssociations = await getServiceSignaturesByUser(ctx.user.id);
const assoc = serviceAssociations.find(a => a.serviceName.toLowerCase() === (invoice.serviceConcerne || '').toLowerCase());
if (assoc) {
const sig = await getSignatureById(assoc.signatureId);
if (sig) {
signatureName = `${sig.firstName} ${sig.lastName}`;
try {
const sigImagePath = path.join(STORAGE_BASE_PATH, sig.imageKey);
try { sigBytesForCartouche = await fs.readFile(sigImagePath); } catch (_) {
const sigUrl = sig.imageUrl;
if (sigUrl) {
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) sigBytesForCartouche = Buffer.from(await sigResp.arrayBuffer());
}
}
sigMimeForCartouche = sig.imageKey.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg';
} catch (_) { /* ignore */ }
}
}
}
// Placement du cartouche BAP
const validatedAt = invoice.bapValidatedAt ? new Date(invoice.bapValidatedAt) : new Date();
await drawBapCartouche(pdfDoc, pdfBytes, {
typeAchat: invoice.typeAchat || 'N/A',
destinataire: (invoice as any).recipientName || 'TOUS',
serviceConcerne: invoice.serviceConcerne || '-',
ventilationComptable: invoice.ventilationComptable || '-',
validatedAt,
signatureImageBytes: sigBytesForCartouche,
signatureMimeType: sigMimeForCartouche,
signatureName: signatureName || undefined,
});
const signedPdfBytes = await pdfDoc.save();
const _bapDateStr = invoice.invoiceDate ? new Date(invoice.invoiceDate).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
const _bapSupplier = (invoice.supplierName || 'Fournisseur').replace(/[^a-zA-Z0-9\u00e0-\u00ff \-]/g, '').trim();
const _bapNumber = (invoice.invoiceNumber || '').replace(/[^a-zA-Z0-9\-]/g, '').trim();
const bapFilename = [_bapDateStr, _bapSupplier, _bapNumber].filter(Boolean).join(' - ') + '.pdf';
const bapKey = generateStorageKey(ctx.user.id, bapFilename);
const { url } = await localStoragePut(bapKey, Buffer.from(signedPdfBytes), 'application/pdf');
// Mettre à jour la dernière entrée bapHistory de cette facture avec le nouveau pdfUrl
const allEntries = ctx.user.role === 'admin' ? await getAllBapHistory() : await getBapHistoryByUser(ctx.user.id);
const latestEntry = allEntries
.filter(e => e.invoiceId === input.invoiceId)
.sort((a, b) => new Date(b.validatedAt).getTime() - new Date(a.validatedAt).getTime())[0];
if (latestEntry) {
await updateBapHistoryPdfUrl(latestEntry.id, url);
}
// Mettre à jour le statut export de la facture
await updateInvoice(input.invoiceId, { exportStatus: 'exported' as any });
return { success: true, pdfUrl: url };
}),
// // ── Historique BAP ────────────────────
search: protectedProcedure
.input(z.object({ query: z.string() }))
.query(async ({ input, ctx }) => {
if (ctx.user.role === 'admin') {
return searchInvoices(null, input.query);
}
return searchInvoices(ctx.user.id, input.query);
}),
getStats: protectedProcedure.query(async ({ ctx }) => {
// Les administrateurs consultent les mêmes données globales que les listes Factures.
return getInvoiceStats(ctx.user.role === "admin" ? undefined : ctx.user.id);
}),
}),
// ============= BAP HISTORY ROUTES =============
bapHistory: router({
getAll: protectedProcedure.query(async ({ ctx }) => {
// Les admins voient tout l'historique BAP
if (ctx.user.role === 'admin') {
return getAllBapHistory();
}
return getBapHistoryByUser(ctx.user.id);
}),
delete: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input, ctx }) => {
const entries = ctx.user.role === 'admin' ? await getAllBapHistory() : 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 };
}),
// Re-génération du PDF annoté BAP à partir des données en base
regenerate: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input, ctx }) => {
const entries = ctx.user.role === 'admin' ? await getAllBapHistory() : await getBapHistoryByUser(ctx.user.id);
const entry = entries.find(e => e.id === input.id);
if (!entry) throw new TRPCError({ code: 'NOT_FOUND' });
const invoice = await getInvoiceById(entry.invoiceId);
if (!invoice || (ctx.user.role !== 'admin' && invoice.userId !== ctx.user.id)) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Facture source introuvable' });
}
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 STORAGE_BASE_PATH = process.env.STORAGE_BASE_PATH || path.join(process.cwd(), 'storage');
if (!invoice.fileKey && !invoice.fileUrl) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Fichier PDF source introuvable' });
}
let pdfBytes: Buffer;
const sourcePath = path.join(STORAGE_BASE_PATH, invoice.fileKey || '');
try {
pdfBytes = await fs.readFile(sourcePath);
} catch (_) {
const fileUrl = invoice.fileUrl;
if (!fileUrl) throw new TRPCError({ code: 'BAD_REQUEST', message: 'Fichier PDF source introuvable' });
let absoluteUrl = fileUrl;
if (fileUrl.startsWith('/')) {
const baseUrl = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
absoluteUrl = `${baseUrl}${fileUrl}`;
}
const response = await fetch(absoluteUrl);
if (!response.ok) throw new TRPCError({ code: 'BAD_REQUEST', message: 'Impossible de télécharger le PDF source' });
pdfBytes = Buffer.from(await response.arrayBuffer());
}
const pdfDoc = await PDFDocument.load(pdfBytes);
// ── Récupération de la signature du service ───────────────────────
let sigBytesForCartouche3: Buffer | undefined;
let sigMimeForCartouche3: "image/png" | "image/jpeg" | undefined;
let signatureName3: string | null = entry.signatureName || null;
if (entry.serviceConcerne) {
const serviceAssociations3 = await getServiceSignaturesByUser(ctx.user.id);
const assoc3 = serviceAssociations3.find(a => a.serviceName.toLowerCase() === (entry.serviceConcerne || '').toLowerCase());
if (assoc3) {
const sig3 = await getSignatureById(assoc3.signatureId);
if (sig3) {
signatureName3 = `${sig3.firstName} ${sig3.lastName}`;
try {
const sigImagePath3 = path.join(STORAGE_BASE_PATH, sig3.imageKey);
try { sigBytesForCartouche3 = await fs.readFile(sigImagePath3); } catch (_) {
const sigUrl3 = sig3.imageUrl;
if (!sigUrl3) throw new Error('Image signature introuvable');
let absoluteSigUrl3 = sigUrl3;
if (sigUrl3.startsWith('/')) {
const baseUrl = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
absoluteSigUrl3 = `${baseUrl}${sigUrl3}`;
}
const sigResp3 = await fetch(absoluteSigUrl3);
if (!sigResp3.ok) throw new Error('Impossible de télécharger la signature');
sigBytesForCartouche3 = Buffer.from(await sigResp3.arrayBuffer());
}
sigMimeForCartouche3 = sig3.imageKey.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg';
} catch (_) { /* ignore */ }
}
}
}
// ── Placement intelligent du cartouche BAP ────────────────────────
const validatedDate3 = entry.validatedAt ? new Date(entry.validatedAt) : new Date();
await drawBapCartouche(pdfDoc, pdfBytes, {
typeAchat: entry.typeAchat || 'N/A',
destinataire: entry.recipientName || 'TOUS',
serviceConcerne: entry.serviceConcerne || '-',
ventilationComptable: entry.ventilationComptable || '-',
validatedAt: validatedDate3,
signatureImageBytes: sigBytesForCartouche3,
signatureMimeType: sigMimeForCartouche3,
signatureName: signatureName3 || undefined,
});
const signedPdfBytes = await pdfDoc.save();
const _bapDateStr3 = invoice.invoiceDate ? new Date(invoice.invoiceDate).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
const _bapSupplier3 = (invoice.supplierName || 'Fournisseur').replace(/[^a-zA-Z0-9\u00e0-\u00ff \-]/g, '').trim();
const _bapNumber3 = (invoice.invoiceNumber || '').replace(/[^a-zA-Z0-9\-]/g, '').trim();
const bapFilename = [_bapDateStr3, _bapSupplier3, _bapNumber3].filter(Boolean).join(' - ') + '.pdf';
const bapKey = generateStorageKey(ctx.user.id, bapFilename);
const { url } = await localStoragePut(bapKey, Buffer.from(signedPdfBytes), 'application/pdf');
// Mettre à jour l'entrée bapHistory avec le nouveau pdfUrl
await updateBapHistoryPdfUrl(input.id, url);
return { success: true, pdfUrl: url };
}),
}),
// ============= 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(),
learningConfidenceThreshold: z.number().min(1).optional(),
aiProvider: z.enum(["mistral", "manus", "gemini"]).optional(),
mistralApiKey: z.string().optional(),
manusForgeApiKey: z.string().optional(),
manusForgeApiUrl: z.string().optional(),
geminiApiKey: z.string().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 && (ctx.user.role === 'admin' || 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;
const exportFolderType = (settings as any)?.exportFolderType || 'local';
const isSharePoint = exportFolderType === 'sharepoint';
// 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 || (ctx.user.role !== 'admin' && 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 not SharePoint
if (!isSharePoint) {
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}`
});
}
}
console.log(`[exportToPdf] Mode: ${exportFolderType}, dossier: ${exportFolder}, ${invoices.length} facture(s)`);
// Copy/upload 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);
// Build export filename: date-fournisseur-numero.pdf
const invoiceDateStr = invoice.invoiceDate
? new Date(invoice.invoiceDate).toISOString().slice(0, 10)
: new Date().toISOString().slice(0, 10);
const supplierSafe = (invoice.supplierName || 'INCONNU').replace(/[^a-zA-Z0-9\u00C0-\u017F\s-]/g, '').trim().replace(/\s+/g, '_');
const invoiceNumSafe = (invoice.invoiceNumber || `id${invoice.id}`).replace(/[^a-zA-Z0-9\u00C0-\u017F\s-]/g, '').trim().replace(/\s+/g, '_');
const filename = `${invoiceDateStr}-${supplierSafe}-${invoiceNumSafe}.pdf`;
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();
console.log(`[Export] Signature apposée pour ${serviceName} sur ${filename}`);
if (isSharePoint) {
const { uploadToSharePoint } = await import('./sharepoint');
console.log(`[exportToPdf] Upload SharePoint: ${filename}`);
const spResult = await uploadToSharePoint(
{ tenantId: (settings as any)?.azureTenantId || '', clientId: (settings as any)?.azureClientId || '', clientSecret: (settings as any)?.azureClientSecret || '', sharepointUrl: exportFolder },
Buffer.from(signedPdfBytes), filename
);
if (!spResult.success) throw new Error(`SharePoint: ${spResult.error}`);
console.log(`[exportToPdf] Upload SharePoint réussi: ${spResult.webUrl}`);
copiedFiles.push(spResult.webUrl || filename);
} else {
await fs.writeFile(destPath, signedPdfBytes);
copiedFiles.push(destPath);
}
} else {
// Signature not found, upload/copy without signature
let pdfBuf: Buffer;
try { pdfBuf = await fs.readFile(sourcePath); } 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}`);
pdfBuf = Buffer.from(await rb.arrayBuffer());
}
if (isSharePoint) {
const { uploadToSharePoint } = await import('./sharepoint');
const spResult = await uploadToSharePoint(
{ tenantId: (settings as any)?.azureTenantId || '', clientId: (settings as any)?.azureClientId || '', clientSecret: (settings as any)?.azureClientSecret || '', sharepointUrl: exportFolder },
pdfBuf, filename
);
if (!spResult.success) throw new Error(`SharePoint: ${spResult.error}`);
copiedFiles.push(spResult.webUrl || filename);
} else {
try { await fs.copyFile(sourcePath, destPath); } catch (_) { await fs.writeFile(destPath, pdfBuf); }
copiedFiles.push(destPath);
}
}
} catch (sigError: any) {
console.warn(`[Export] Impossible d'apposer la signature: ${sigError.message}. Copie sans signature.`);
let pdfBuf: Buffer;
try { pdfBuf = await fs.readFile(sourcePath); } 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}`);
pdfBuf = Buffer.from(await rb.arrayBuffer());
}
if (isSharePoint) {
const { uploadToSharePoint } = await import('./sharepoint');
const spResult = await uploadToSharePoint(
{ tenantId: (settings as any)?.azureTenantId || '', clientId: (settings as any)?.azureClientId || '', clientSecret: (settings as any)?.azureClientSecret || '', sharepointUrl: exportFolder },
pdfBuf, filename
);
if (!spResult.success) throw new Error(`SharePoint: ${spResult.error}`);
copiedFiles.push(spResult.webUrl || filename);
} else {
try { await fs.copyFile(sourcePath, destPath); } catch (_) { await fs.writeFile(destPath, pdfBuf); }
copiedFiles.push(destPath);
}
}
} else {
// No signature association, upload/copy as-is
let pdfBuf: Buffer;
try { pdfBuf = await fs.readFile(sourcePath); } 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}`);
pdfBuf = Buffer.from(await rb.arrayBuffer());
}
if (isSharePoint) {
const { uploadToSharePoint } = await import('./sharepoint');
console.log(`[exportToPdf] Upload SharePoint sans signature: ${filename}`);
const spResult = await uploadToSharePoint(
{ tenantId: (settings as any)?.azureTenantId || '', clientId: (settings as any)?.azureClientId || '', clientSecret: (settings as any)?.azureClientSecret || '', sharepointUrl: exportFolder },
pdfBuf, filename
);
if (!spResult.success) throw new Error(`SharePoint: ${spResult.error}`);
console.log(`[exportToPdf] Upload SharePoint réussi: ${spResult.webUrl}`);
copiedFiles.push(spResult.webUrl || filename);
} else {
try { await fs.copyFile(sourcePath, destPath); } catch (_) { await fs.writeFile(destPath, pdfBuf); }
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 || (ctx.user.role !== 'admin' && 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 }) => {
// Les admins voient tous les logs d'import
if (ctx.user.role === 'admin') {
return getAllImportLogsWithDetails();
}
return getImportLogsWithDetailsByUser(ctx.user.id);
}),
deleteAll: protectedProcedure.mutation(async ({ ctx }) => {
// Les admins suppriment tous les logs, les utilisateurs standard suppriment les leurs
if (ctx.user.role === 'admin') {
await deleteAllImportLogs(null);
} else {
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;
}),
/** Liste sans secrets les boîtes IMAP disponibles pour une action manuelle dadministrateur. */
getConfiguredAccounts: adminProcedure.query(async () => {
return getEmailImportAccounts();
}),
/** Déclenche une seule lecture IMAP sans activer ni planifier le service automatique. */
checkAccountNow: adminProcedure
.input(z.object({ userId: z.number().int().positive() }))
.mutation(async ({ input }) => {
return triggerManualEmailCheck(input.userId);
}),
}),
// ============= 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,
exportFolderType: "local" as const,
bapExportMode: "browser" as const,
azureTenantId: null,
azureClientId: null,
azureClientSecret: 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(),
emailImportSinceDate: z.number().nullable().optional(), // Unix timestamp (s)
emailImportAuthMode: z.enum(["basic", "oauth2"]).optional(),
exportFolder: z.string().nullable().optional(),
exportFolderType: z.enum(["local", "teams", "sharepoint"]).optional(),
bapExportMode: z.enum(["browser", "folder", "both"]).optional(),
azureTenantId: z.string().nullable().optional(),
azureClientId: z.string().nullable().optional(),
azureClientSecret: z.string().nullable().optional(),
azureSecretExpiresAt: z.union([z.date(), z.string().datetime({ offset: true }).transform(s => new Date(s)), z.string().regex(/^\d{4}-\d{2}-\d{2}$/).transform(s => new Date(s + 'T12:00:00.000Z'))]).nullable().optional(),
}))
.mutation(async ({ input, ctx }) => {
const settings = await upsertImportSettings({
userId: ctx.user.id,
...input,
});
return settings;
}),
testAzureConnection: protectedProcedure
.mutation(async ({ ctx }) => {
const settings = await getImportSettingsByUser(ctx.user.id);
const tenantId = (settings as any)?.azureTenantId;
const clientId = (settings as any)?.azureClientId;
const clientSecret = (settings as any)?.azureClientSecret;
const sharepointUrl = settings?.exportFolder;
if (!tenantId || !clientId || !clientSecret) {
return { success: false, error: 'Credentials Azure AD manquants (Tenant ID, Client ID ou Secret)' };
}
try {
const { uploadToSharePoint } = await import('./sharepoint');
// Test : obtenir un token OAuth2 uniquement (sans upload)
const tokenRes = await fetch(
`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: clientId,
client_secret: clientSecret,
scope: 'https://graph.microsoft.com/.default',
}),
}
);
const tokenData = await tokenRes.json() as any;
if (tokenData.error) {
return { success: false, error: `Erreur Azure AD : ${tokenData.error_description || tokenData.error}` };
}
// Test accès SharePoint si URL configurée
if (sharepointUrl) {
const urlObj = new URL(sharepointUrl);
const hostname = urlObj.hostname; // ex: itinova.sharepoint.com
const siteTestRes = await fetch(
`https://graph.microsoft.com/v1.0/sites/${hostname}`,
{ headers: { Authorization: `Bearer ${tokenData.access_token}` } }
);
if (!siteTestRes.ok) {
const siteErr = await siteTestRes.json() as any;
return { success: false, error: `Token OK mais accès SharePoint refusé : ${siteErr?.error?.message || siteTestRes.status}` };
}
}
return { success: true, message: 'Connexion Azure AD réussie' + (sharepointUrl ? ' et accès SharePoint vérifié' : '') };
} catch (err: any) {
return { success: false, error: err.message || 'Erreur inconnue' };
}
}),
testEmailConnection: protectedProcedure
.mutation(async ({ ctx }) => {
const settings = await getImportSettingsByUser(ctx.user.id);
if (!settings?.emailImportAddress || !settings?.emailImportHost) {
return { success: false, message: 'Configuration IMAP incomplète (adresse email ou serveur IMAP manquant)' };
}
const authMode = (settings as any).emailImportAuthMode as 'basic' | 'oauth2' || 'basic';
if (authMode === 'basic' && !settings.emailImportPassword) {
return { success: false, message: 'Mot de passe IMAP manquant' };
}
if (authMode === 'oauth2' && (!settings.azureTenantId || !settings.azureClientId || !settings.azureClientSecret)) {
return { success: false, message: 'Credentials Azure AD incomplets pour OAuth2 (Tenant ID, Client ID, Client Secret requis)' };
}
const result = await testImapConnection({
userId: ctx.user.id,
emailAddress: settings.emailImportAddress,
password: settings.emailImportPassword || '',
host: settings.emailImportHost,
port: settings.emailImportPort || 993,
authMode,
azureTenantId: settings.azureTenantId || undefined,
azureClientId: settings.azureClientId || undefined,
azureClientSecret: settings.azureClientSecret || undefined,
});
return result;
}),
testSharePointUpload: protectedProcedure
.mutation(async ({ ctx }) => {
const settings = await getImportSettingsByUser(ctx.user.id);
const tenantId = (settings as any)?.azureTenantId;
const clientId = (settings as any)?.azureClientId;
const clientSecret = (settings as any)?.azureClientSecret;
const sharepointUrl = settings?.exportFolder;
if (!tenantId || !clientId || !clientSecret) {
return { success: false, error: 'Credentials Azure AD manquants (Tenant ID, Client ID ou Secret)' };
}
if (!sharepointUrl) {
return { success: false, error: 'URL SharePoint non configurée dans le dossier d\'export' };
}
try {
const { uploadToSharePoint } = await import('./sharepoint');
// Créer un fichier test de 1 Ko
const testContent = `Test upload SharePoint - ${new Date().toISOString()}\nApplication : Dématérialisation Facturation\nCe fichier peut être supprimé.`;
const testBuffer = Buffer.from(testContent, 'utf-8');
const testFileName = `test-upload-${Date.now()}.txt`;
const result = await uploadToSharePoint(
{ tenantId, clientId, clientSecret, sharepointUrl },
testBuffer,
testFileName
);
if (result.success) {
return {
success: true,
message: `Fichier test déposé avec succès dans SharePoint`,
webUrl: result.webUrl,
fileName: testFileName,
debugInfo: result.debugInfo,
};
} else {
return {
success: false,
error: result.error || 'Échec de l\'upload test',
debugInfo: result.debugInfo,
};
}
} catch (err: any) {
return { success: false, error: err.message || 'Erreur inconnue' };
}
}),
}),
// ============= 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.isSubscription !== undefined || 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.isSubscription !== undefined || 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 AUTOMATION RULES =============
exportAutomationRules: router({
list: protectedProcedure.query(async ({ ctx }) => {
return getExportAutomationRulesByUser(ctx.user.id);
}),
create: protectedProcedure
.input(z.object({
name: z.string().trim().min(1).max(255),
conditionField: z.enum(["recipientName", "ventilationComptable"]),
conditionValue: z.string().trim().min(1).max(255),
destinationType: z.enum(["local", "teams", "sharepoint"]),
destinationPath: z.string().trim().min(1),
openInBrowser: z.number().int().min(0).max(1).default(0),
isActive: z.number().int().min(0).max(1).default(1),
priority: z.number().int().min(0).default(0),
}))
.mutation(async ({ input, ctx }) => createExportAutomationRule({ userId: ctx.user.id, ...input })),
update: protectedProcedure
.input(z.object({
id: z.number().int().positive(),
name: z.string().trim().min(1).max(255).optional(),
conditionField: z.enum(["recipientName", "ventilationComptable"]).optional(),
conditionValue: z.string().trim().min(1).max(255).optional(),
destinationType: z.enum(["local", "teams", "sharepoint"]).optional(),
destinationPath: z.string().trim().min(1).optional(),
openInBrowser: z.number().int().min(0).max(1).optional(),
isActive: z.number().int().min(0).max(1).optional(),
priority: z.number().int().min(0).optional(),
}))
.mutation(async ({ input, ctx }) => {
const existing = await getExportAutomationRuleById(input.id);
if (!existing || (ctx.user.role !== "admin" && existing.userId !== ctx.user.id)) {
throw new TRPCError({ code: "NOT_FOUND" });
}
const { id, ...data } = input;
return updateExportAutomationRule(id, data);
}),
delete: protectedProcedure
.input(z.object({ id: z.number().int().positive() }))
.mutation(async ({ input, ctx }) => {
const existing = await getExportAutomationRuleById(input.id);
if (!existing || (ctx.user.role !== "admin" && existing.userId !== ctx.user.id)) {
throw new TRPCError({ code: "NOT_FOUND" });
}
await deleteExportAutomationRule(input.id);
return { success: true };
}),
}),
// ============= 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 };
}),
}),
// ============= FREEPRO VENTILATION =============
freepro: router({
/** Importe un fichier Excel FreePro et calcule la ventilation */
import: protectedProcedure
.input(
z.object({
moisLabel: z.string().regex(/^\d{2}\/\d{4}$/, "Format MM/YYYY requis"),
fileName: z.string(),
fileBase64: z.string(), // fichier Excel encodé en base64
})
)
.mutation(async ({ input, ctx }) => {
const buffer = Buffer.from(input.fileBase64, "base64");
const result = processFreeproExcel(buffer, input.moisLabel, input.fileName);
const importId = await createFreeproImport(
{
userId: ctx.user.id,
moisLabel: result.moisLabel,
annee: result.annee,
mois: result.mois,
refPiece: result.refPiece || null,
fileName: input.fileName,
nbLignes: result.nbLignes,
totalTtc: result.totalTtc.toFixed(2),
},
result.lines.map((l) => ({
structure: l.structure ?? null,
type: l.type,
montantCentimes: Math.round(l.montant * 100),
}))
);
return { importId, ...result };
}),
/** Liste tous les imports FreePro de l'utilisateur */
list: protectedProcedure.query(async ({ ctx }) => {
return getFreeproImportsByUser(ctx.user.id);
}),
/** Récupère un import FreePro avec ses lignes de ventilation */
getById: protectedProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input, ctx }) => {
const data = await getFreeproImportWithLines(input.id);
if (!data) throw new TRPCError({ code: "NOT_FOUND" });
if (data.import.userId !== ctx.user.id)
throw new TRPCError({ code: "FORBIDDEN" });
return data;
}),
/** Supprime un import FreePro */
delete: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input, ctx }) => {
const data = await getFreeproImportWithLines(input.id);
if (!data) throw new TRPCError({ code: "NOT_FOUND" });
if (data.import.userId !== ctx.user.id)
throw new TRPCError({ code: "FORBIDDEN" });
await deleteFreeproImport(input.id);
return { success: true };
}),
/** Génère le PDF côté serveur et le retourne en base64 */
generatePdf: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input, ctx }) => {
const data = await getFreeproImportWithLines(input.id);
if (!data) throw new TRPCError({ code: "NOT_FOUND" });
if (data.import.userId !== ctx.user.id)
throw new TRPCError({ code: "FORBIDDEN" });
const { generateFreeproPdf } = await import('./freeproPdfService');
const pdfBytes = await generateFreeproPdf(
{ mois: data.import.moisLabel, refPiece: data.import.refPiece || '' },
data.lines.map(l => ({
structure: l.structure || '',
type: l.type || '',
montantCentimes: l.montantCentimes,
}))
);
const base64 = Buffer.from(pdfBytes).toString('base64');
const [mm, yyyy] = data.import.moisLabel.split('/');
const fileName = `FreePro - ventilation facture ${mm.padStart(2,'0')}.${yyyy.slice(2)}.pdf`;
return { base64, fileName };
}),
/** Récupère les paramètres de connexion automatique FreePro */
getSettings: protectedProcedure.query(async ({ ctx }) => {
const s = await getFreeproSettings(ctx.user.id);
// Ne pas exposer le mot de passe en clair
if (s) {
return {
...s,
loginPassword: s.loginPassword ? '••••••••' : null,
hasPassword: !!s.loginPassword,
};
}
return null;
}),
/** Sauvegarde les paramètres de connexion automatique FreePro */
saveSettings: protectedProcedure
.input(
z.object({
portalUrl: z.string().url().optional(),
loginEmail: z.string().email().optional().or(z.literal('')),
loginPassword: z.string().optional(), // vide = ne pas changer
frequency: z.enum(['manual', 'daily', 'weekly', 'monthly']).optional(),
maxAnteriority: z.number().nullable().optional(), // timestamp Unix en secondes
autoEnabled: z.number().min(0).max(1).optional(),
})
)
.mutation(async ({ input, ctx }) => {
const existing = await getFreeproSettings(ctx.user.id);
const updateData: any = {};
if (input.portalUrl !== undefined) updateData.portalUrl = input.portalUrl;
if (input.loginEmail !== undefined) updateData.loginEmail = input.loginEmail || null;
// Ne mettre à jour le mot de passe que si une vraie valeur est fournie
if (input.loginPassword && input.loginPassword !== '••••••••') {
updateData.loginPassword = input.loginPassword;
}
if (input.frequency !== undefined) updateData.frequency = input.frequency;
if (input.maxAnteriority !== undefined) updateData.maxAnteriority = input.maxAnteriority;
if (input.autoEnabled !== undefined) updateData.autoEnabled = input.autoEnabled;
await upsertFreeproSettings(ctx.user.id, updateData);
// Gérer le job périodique
const newSettings = await getFreeproSettings(ctx.user.id);
if (newSettings?.autoEnabled && newSettings.frequency !== 'manual') {
const ms = frequencyToMs(newSettings.frequency);
if (ms > 0) startFreeproAutoJob(ctx.user.id, ms);
} else {
stopFreeproAutoJob(ctx.user.id);
}
return { success: true };
}),
/** Teste la connexion au portail FreePro.
* Le mot de passe est récupéré depuis la base de données (le frontend ne le reçoit jamais en clair).
* L'email peut être passé en paramètre pour tester avant sauvegarde, ou laisser vide pour utiliser celui en base.
*/
testConnection: protectedProcedure
.input(
z.object({
email: z.string().optional(),
password: z.string().optional(),
})
)
.mutation(async ({ input, ctx }) => {
// Récupérer les vrais credentials depuis la base de données
const settings = await getFreeproSettings(ctx.user.id);
if (!settings?.loginPassword) {
return {
success: false,
error: "Aucun mot de passe configuré. Veuillez sauvegarder les paramètres d'abord.",
};
}
const email = input?.email && !input.email.includes("•") ? input.email : settings.loginEmail ?? "";
const password = input?.password && !input.password.includes("•") ? input.password : settings.loginPassword;
if (!email || !email.includes("@")) {
return {
success: false,
error: "Aucun email configuré. Veuillez sauvegarder les paramètres d'abord.",
};
}
return testFreeproConnection(email, password);
}),
/** Force la récupération immédiate des factures FreePro */
forceImport: protectedProcedure.mutation(async ({ ctx }) => {
const result = await runFreeproAutoImport(ctx.user.id);
return result;
}),
/** Exporte la ventilation FreePro vers SharePoint */
exportToSharePoint: protectedProcedure
.input(z.object({ id: z.number(), pdfBase64: z.string().optional() }))
.mutation(async ({ input, ctx }) => {
const data = await getFreeproImportWithLines(input.id);
if (!data) throw new TRPCError({ code: "NOT_FOUND" });
if (data.import.userId !== ctx.user.id)
throw new TRPCError({ code: "FORBIDDEN" });
// Récupérer les paramètres SharePoint depuis importSettings
const settings = await getImportSettingsByUser(ctx.user.id);
const tenantId = (settings as any)?.azureTenantId || '';
const clientId = (settings as any)?.azureClientId || '';
const clientSecret = (settings as any)?.azureClientSecret || '';
const sharepointUrl = (settings as any)?.exportFolder || '';
if (!tenantId || !clientId || !clientSecret) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Credentials Azure AD non configurés dans les paramètres d'export" });
}
if (!sharepointUrl) {
throw new TRPCError({ code: "BAD_REQUEST", message: "URL SharePoint non configurée dans le dossier d'export" });
}
// Construire le nom du fichier
const [moisStr, anneeStr] = data.import.moisLabel.split('/');
const moisPad = moisStr.padStart(2, '0');
const anneeCourt = anneeStr.slice(2);
const fileName = `FreePro - ventilation facture ${moisPad}.${anneeCourt}.pdf`;
// Générer le PDF côté serveur
const { generateFreeproPdf } = await import('./freeproPdfService');
const pdfBytes = await generateFreeproPdf(
{ mois: data.import.moisLabel, refPiece: data.import.refPiece || '' },
data.lines.map(l => ({
structure: l.structure || '',
type: l.type || '',
montantCentimes: l.montantCentimes,
}))
);
const pdfBuffer = Buffer.from(pdfBytes);
const { uploadToSharePoint } = await import('./sharepoint');
const result = await uploadToSharePoint(
{ tenantId, clientId, clientSecret, sharepointUrl },
pdfBuffer,
fileName
);
if (!result.success) {
// Sauvegarder le statut d'erreur en base
const { updateFreeproSharepointStatus } = await import('./db');
await updateFreeproSharepointStatus(input.id, 'error', null, result.error || 'Erreur inconnue');
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: result.error || 'Erreur upload SharePoint' });
}
// Sauvegarder le statut de succès en base
const { updateFreeproSharepointStatus } = await import('./db');
await updateFreeproSharepointStatus(input.id, 'success', result.webUrl || null, null);
return { success: true, webUrl: result.webUrl, fileName };
}),
}),
// ============= WEB IMPORT SOURCES =============
webImportSources: router({
list: protectedProcedure.query(async ({ ctx }) => {
return getWebImportSourcesByUser(ctx.user.id);
}),
create: protectedProcedure
.input(z.object({
name: z.string().min(1).max(100),
connectorType: z.string().min(1).max(50),
portalUrl: z.string().url(),
loginEmail: z.string().min(1),
loginPassword: z.string().min(1),
frequency: z.enum(['manual', 'daily', 'weekly', 'monthly']).default('monthly'),
autoEnabled: z.number().min(0).max(1).default(0),
}))
.mutation(async ({ input, ctx }) => {
return createWebImportSource({ ...input, userId: ctx.user.id });
}),
update: protectedProcedure
.input(z.object({
id: z.number(),
name: z.string().min(1).max(100).optional(),
connectorType: z.string().min(1).max(50).optional(),
portalUrl: z.string().url().optional(),
loginEmail: z.string().min(1).optional(),
loginPassword: z.string().optional(),
frequency: z.enum(['manual', 'daily', 'weekly', 'monthly']).optional(),
autoEnabled: z.number().min(0).max(1).optional(),
}))
.mutation(async ({ input, ctx }) => {
const source = await getWebImportSourceById(input.id);
if (!source || source.userId !== ctx.user.id) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Source introuvable' });
}
const { id, ...data } = input;
await updateWebImportSource(id, data);
return { success: true };
}),
delete: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input, ctx }) => {
const source = await getWebImportSourceById(input.id);
if (!source || source.userId !== ctx.user.id) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Source introuvable' });
}
await deleteWebImportSource(input.id);
return { success: true };
}),
getToken: protectedProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input, ctx }) => {
const source = await getWebImportSourceById(input.id);
if (!source || source.userId !== ctx.user.id) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Source introuvable' });
}
return { apiToken: source.apiToken };
}),
}),
// ============= BACKUP ROUTES =============
backup: router({
// Liste les sauvegardes existantes dans le dossier backups/
list: adminProcedure.query(async () => {
const backupDir = pathSync.resolve("backups");
if (!fsSync.existsSync(backupDir)) return [];
const files = fsSync.readdirSync(backupDir)
.filter(f => f.endsWith(".sql") || f.endsWith(".sql.gz"))
.map(f => {
const stat = fsSync.statSync(pathSync.join(backupDir, f));
return { name: f, size: stat.size, createdAt: stat.mtime };
})
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
return files;
}),
// Supprime une sauvegarde
delete: adminProcedure
.input(z.object({ name: z.string() }))
.mutation(async ({ input }) => {
const backupDir = pathSync.resolve("backups");
const filePath = pathSync.join(backupDir, pathSync.basename(input.name));
if (!fsSync.existsSync(filePath)) throw new TRPCError({ code: 'NOT_FOUND', message: 'Fichier introuvable' });
fsSync.unlinkSync(filePath);
return { success: true };
}),
}),
});
export type AppRouter = typeof appRouter;