Modifications apportées : - Identification et correction du bug majeur dans la route upload : l'URL retournée par localStoragePut était écrasée par une URL locale invalide - Le système utilise maintenant correctement l'URL retournée par la fonction de stockage - Ajout de gestion d'erreur appropriée avec TRPCError en cas d'échec de stockage - Les fichiers PDF seront maintenant physiquement stockés dans le dossier storage/YYYY-MM/ - L'export PDF devrait fonctionner correctement après ré-importation des factures Note : Les factures existantes doivent être supprimées et ré-importées pour bénéficier de la correction.
578 lines
20 KiB
TypeScript
578 lines
20 KiB
TypeScript
import { z } from "zod";
|
|
import { COOKIE_NAME } from "@shared/const";
|
|
import { getSessionCookieOptions } from "./_core/cookies";
|
|
import { systemRouter } from "./_core/systemRouter";
|
|
import { publicProcedure, protectedProcedure, router } from "./_core/trpc";
|
|
import {
|
|
createInvoice,
|
|
getInvoiceById,
|
|
getInvoicesByUser,
|
|
updateInvoice,
|
|
deleteInvoice,
|
|
searchInvoices,
|
|
getInvoiceStats,
|
|
createSourceFile,
|
|
getSourceFileById,
|
|
updateSourceFile,
|
|
getUserSettings,
|
|
upsertUserSettings,
|
|
createLocalUser,
|
|
getAllUsers,
|
|
updateUserPassword,
|
|
toggleUserActive,
|
|
deleteUser,
|
|
findDuplicateInvoice,
|
|
createImportLog,
|
|
getImportLogsByUser,
|
|
getLlmLogsBySourceFile,
|
|
getLlmLogsByInvoice,
|
|
} from "./db";
|
|
import { loginLocal, hashPassword, getAzureAuthUrl, isAzureAdConfigured, generateToken } from "./auth";
|
|
import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor";
|
|
import { localStoragePut, generateStorageKey } from "./localStorage";
|
|
import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport";
|
|
import { TRPCError } from "@trpc/server";
|
|
|
|
// Admin-only procedure
|
|
const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
|
|
if (ctx.user.role !== "admin") {
|
|
throw new TRPCError({ code: "FORBIDDEN", message: "Admin access required" });
|
|
}
|
|
return next({ ctx });
|
|
});
|
|
|
|
export const appRouter = router({
|
|
system: systemRouter,
|
|
|
|
// ============= AUTH ROUTES =============
|
|
auth: router({
|
|
me: publicProcedure.query(opts => opts.ctx.user),
|
|
|
|
// Local login (email + password)
|
|
loginLocal: publicProcedure
|
|
.input(z.object({
|
|
email: z.string().email(),
|
|
password: z.string().min(6),
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const result = await loginLocal(input.email, input.password);
|
|
|
|
if (!result) {
|
|
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid email or password" });
|
|
}
|
|
|
|
// Set auth cookie
|
|
ctx.res.cookie("auth_token", result.token, {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === "production",
|
|
sameSite: "lax",
|
|
path: "/",
|
|
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
|
});
|
|
|
|
return { user: result.user };
|
|
}),
|
|
|
|
// Get Azure AD login URL
|
|
getAzureLoginUrl: publicProcedure.query(async () => {
|
|
if (!isAzureAdConfigured()) {
|
|
throw new TRPCError({ code: "BAD_REQUEST", message: "Azure AD not configured" });
|
|
}
|
|
|
|
const url = await getAzureAuthUrl();
|
|
return { url };
|
|
}),
|
|
|
|
// Check if Azure AD is available
|
|
isAzureAdAvailable: publicProcedure.query(() => {
|
|
return { available: isAzureAdConfigured() };
|
|
}),
|
|
|
|
logout: publicProcedure.mutation(({ ctx }) => {
|
|
const cookieOptions = getSessionCookieOptions(ctx.req);
|
|
ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 });
|
|
ctx.res.clearCookie("auth_token", { path: "/", maxAge: -1 });
|
|
return { success: true };
|
|
}),
|
|
}),
|
|
|
|
// ============= INVOICE ROUTES =============
|
|
invoices: router({
|
|
// Upload and process PDF file
|
|
upload: protectedProcedure
|
|
.input(z.object({
|
|
fileName: z.string(),
|
|
fileData: z.string(), // Base64 encoded PDF
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const userId = ctx.user.id;
|
|
|
|
// Decode base64 file data
|
|
const fileBuffer = Buffer.from(input.fileData, "base64");
|
|
console.log(`[Upload] Received file: ${input.fileName}, size: ${fileBuffer.length} bytes`);
|
|
|
|
// Store source file
|
|
const sourceFileKey = generateStorageKey(userId, input.fileName);
|
|
console.log(`[Upload] Generated storage key: ${sourceFileKey}`);
|
|
|
|
let sourceFileUrl: string;
|
|
try {
|
|
const result = await localStoragePut(sourceFileKey, fileBuffer, "application/pdf");
|
|
sourceFileUrl = result.url;
|
|
console.log(`[Upload] File stored successfully at: ${sourceFileUrl}`);
|
|
} catch (error) {
|
|
console.error(`[Upload] FAILED to store file:`, error);
|
|
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to store PDF file" });
|
|
}
|
|
|
|
// Create source file record
|
|
const sourceFile = await createSourceFile({
|
|
userId,
|
|
fileName: input.fileName,
|
|
fileKey: sourceFileKey,
|
|
fileUrl: sourceFileUrl,
|
|
processingStatus: "processing",
|
|
});
|
|
|
|
// Start extraction process (async - don't wait)
|
|
(async () => {
|
|
try {
|
|
// Get user settings for custom keywords
|
|
const settings = await getUserSettings(userId);
|
|
const customKeywords = settings ? {
|
|
invoiceNumber: settings.invoiceNumberKeywords,
|
|
deliveryNote: settings.deliveryNoteKeywords,
|
|
orderNumber: settings.orderNumberKeywords,
|
|
supplier: settings.supplierKeywords,
|
|
totalAmount: settings.totalAmountKeywords,
|
|
} : undefined;
|
|
|
|
const model = settings?.llmModel || "mistral-large-latest";
|
|
|
|
// Extract invoices
|
|
const result = await extractInvoicesWithMistral(
|
|
fileBuffer,
|
|
userId,
|
|
sourceFile.id,
|
|
model,
|
|
customKeywords
|
|
);
|
|
|
|
// Update source file with total count
|
|
await updateSourceFile(sourceFile.id, {
|
|
totalInvoicesDetected: result.invoiceCount,
|
|
processingProgress: `Extraction ${result.invoiceCount} facture(s) détectée(s)`,
|
|
});
|
|
|
|
// Process each invoice
|
|
let importedCount = 0;
|
|
let duplicatesCount = 0;
|
|
let errorsCount = 0;
|
|
const duplicateDetails: any[] = [];
|
|
const errorDetails: any[] = [];
|
|
|
|
for (let i = 0; i < result.invoices.length; i++) {
|
|
const invoiceData = result.invoices[i]!;
|
|
|
|
try {
|
|
// Update progress
|
|
await updateSourceFile(sourceFile.id, {
|
|
processingProgress: `Extraction ${i + 1}/${result.invoiceCount} factures...`,
|
|
});
|
|
|
|
// Check for duplicates
|
|
const duplicate = await findDuplicateInvoice(
|
|
invoiceData.supplierName,
|
|
invoiceData.invoiceNumber,
|
|
invoiceData.invoiceDate
|
|
);
|
|
|
|
if (duplicate) {
|
|
duplicatesCount++;
|
|
duplicateDetails.push({
|
|
supplierName: invoiceData.supplierName,
|
|
invoiceNumber: invoiceData.invoiceNumber,
|
|
invoiceDate: invoiceData.invoiceDate,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// Generate metadata JSON
|
|
const metadataJson = generateMetadataJSON(invoiceData);
|
|
const metadataKey = generateStorageKey(userId, `${input.fileName}-${i + 1}-metadata.json`);
|
|
const { url: metadataUrl } = await localStoragePut(
|
|
metadataKey,
|
|
Buffer.from(metadataJson),
|
|
"application/json"
|
|
);
|
|
|
|
// Create invoice record
|
|
await createInvoice({
|
|
userId,
|
|
sourceFileId: sourceFile.id,
|
|
invoiceIndexInFile: i + 1,
|
|
fileName: `${input.fileName} - Facture ${i + 1}`,
|
|
fileKey: sourceFileKey, // Same as source for now
|
|
fileUrl: sourceFileUrl,
|
|
supplierName: invoiceData.supplierName,
|
|
invoiceNumber: invoiceData.invoiceNumber,
|
|
invoiceDate: invoiceData.invoiceDate,
|
|
deliveryNoteNumber: invoiceData.deliveryNoteNumber,
|
|
orderNumber: invoiceData.orderNumber,
|
|
totalAmount: invoiceData.totalAmount?.toString(),
|
|
pageRange: invoiceData.pageRange,
|
|
qualityScore: invoiceData.qualityScore,
|
|
metadataFileKey: metadataKey,
|
|
metadataFileUrl: metadataUrl,
|
|
status: "completed",
|
|
});
|
|
|
|
importedCount++;
|
|
} catch (error: any) {
|
|
errorsCount++;
|
|
errorDetails.push({
|
|
invoiceIndex: i + 1,
|
|
error: error.message,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Update source file status
|
|
await updateSourceFile(sourceFile.id, {
|
|
processingStatus: "completed",
|
|
processingProgress: `Terminé: ${importedCount} importée(s), ${duplicatesCount} doublon(s)`,
|
|
});
|
|
|
|
// Create import log
|
|
await createImportLog({
|
|
userId,
|
|
sourceFileId: sourceFile.id,
|
|
fileName: input.fileName,
|
|
totalInvoicesDetected: result.invoiceCount,
|
|
invoicesImported: importedCount,
|
|
duplicatesIgnored: duplicatesCount,
|
|
errors: errorsCount,
|
|
duplicateDetails: JSON.stringify(duplicateDetails),
|
|
errorDetails: JSON.stringify(errorDetails),
|
|
});
|
|
|
|
} catch (error: any) {
|
|
console.error("[Upload] Extraction failed:", error);
|
|
await updateSourceFile(sourceFile.id, {
|
|
processingStatus: "error",
|
|
processingProgress: `Erreur: ${error.message}`,
|
|
});
|
|
}
|
|
})();
|
|
|
|
return { sourceFileId: sourceFile.id };
|
|
}),
|
|
|
|
list: protectedProcedure.query(async ({ ctx }) => {
|
|
return getInvoicesByUser(ctx.user.id);
|
|
}),
|
|
|
|
getById: protectedProcedure
|
|
.input(z.object({ id: z.number() }))
|
|
.query(async ({ input, ctx }) => {
|
|
const invoice = await getInvoiceById(input.id);
|
|
if (!invoice || invoice.userId !== ctx.user.id) {
|
|
throw new TRPCError({ code: "NOT_FOUND" });
|
|
}
|
|
return invoice;
|
|
}),
|
|
|
|
update: protectedProcedure
|
|
.input(z.object({
|
|
id: z.number(),
|
|
data: z.object({
|
|
supplierName: z.string().optional(),
|
|
invoiceNumber: z.string().optional(),
|
|
invoiceDate: z.date().optional(),
|
|
deliveryNoteNumber: z.string().optional(),
|
|
orderNumber: z.string().optional(),
|
|
totalAmount: z.string().optional(),
|
|
}),
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const invoice = await getInvoiceById(input.id);
|
|
if (!invoice || invoice.userId !== ctx.user.id) {
|
|
throw new TRPCError({ code: "NOT_FOUND" });
|
|
}
|
|
|
|
await updateInvoice(input.id, {
|
|
...input.data,
|
|
manuallyEdited: 1,
|
|
});
|
|
|
|
return { success: true };
|
|
}),
|
|
|
|
delete: protectedProcedure
|
|
.input(z.object({ id: z.number() }))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const invoice = await getInvoiceById(input.id);
|
|
if (!invoice || invoice.userId !== ctx.user.id) {
|
|
throw new TRPCError({ code: "NOT_FOUND" });
|
|
}
|
|
|
|
await deleteInvoice(input.id);
|
|
return { success: true };
|
|
}),
|
|
|
|
search: protectedProcedure
|
|
.input(z.object({ query: z.string() }))
|
|
.query(async ({ input, ctx }) => {
|
|
return searchInvoices(ctx.user.id, input.query);
|
|
}),
|
|
|
|
getStats: protectedProcedure.query(async ({ ctx }) => {
|
|
return getInvoiceStats(ctx.user.id);
|
|
}),
|
|
}),
|
|
|
|
// ============= SOURCE FILES ROUTES =============
|
|
sourceFiles: router({
|
|
getByIds: protectedProcedure
|
|
.input(z.object({ ids: z.array(z.number()) }))
|
|
.query(async ({ input, ctx }) => {
|
|
const files = await Promise.all(
|
|
input.ids.map(id => getSourceFileById(id))
|
|
);
|
|
return files.filter(f => f && f.userId === ctx.user.id);
|
|
}),
|
|
}),
|
|
|
|
// ============= SETTINGS ROUTES =============
|
|
settings: router({
|
|
get: protectedProcedure.query(async ({ ctx }) => {
|
|
return getUserSettings(ctx.user.id);
|
|
}),
|
|
|
|
upsert: protectedProcedure
|
|
.input(z.object({
|
|
llmModel: z.string().optional(),
|
|
orderNumberFormat: z.string().optional(),
|
|
invoiceNumberKeywords: z.string().optional(),
|
|
deliveryNoteKeywords: z.string().optional(),
|
|
orderNumberKeywords: z.string().optional(),
|
|
supplierKeywords: z.string().optional(),
|
|
totalAmountKeywords: z.string().optional(),
|
|
sftpHost: z.string().optional(),
|
|
sftpPort: z.number().optional(),
|
|
sftpUsername: z.string().optional(),
|
|
sftpPassword: z.string().optional(),
|
|
sftpRemotePath: z.string().optional(),
|
|
sftpAutoExport: z.number().optional(),
|
|
llmLogsRetentionMonths: z.number().optional(),
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
await upsertUserSettings({
|
|
userId: ctx.user.id,
|
|
...input,
|
|
});
|
|
return { success: true };
|
|
}),
|
|
}),
|
|
|
|
// ============= ADMIN ROUTES =============
|
|
admin: router({
|
|
createUser: adminProcedure
|
|
.input(z.object({
|
|
email: z.string().email(),
|
|
password: z.string().min(6),
|
|
name: z.string(),
|
|
role: z.enum(["user", "admin"]),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const passwordHash = await hashPassword(input.password);
|
|
const user = await createLocalUser(input.email, passwordHash, input.name, input.role);
|
|
return { user };
|
|
}),
|
|
|
|
getAllUsers: adminProcedure.query(async () => {
|
|
return getAllUsers();
|
|
}),
|
|
|
|
updateUserPassword: adminProcedure
|
|
.input(z.object({
|
|
userId: z.number(),
|
|
newPassword: z.string().min(6),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const passwordHash = await hashPassword(input.newPassword);
|
|
await updateUserPassword(input.userId, passwordHash);
|
|
return { success: true };
|
|
}),
|
|
|
|
toggleUserActive: adminProcedure
|
|
.input(z.object({
|
|
userId: z.number(),
|
|
isActive: z.number(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
await toggleUserActive(input.userId, input.isActive);
|
|
return { success: true };
|
|
}),
|
|
|
|
deleteUser: adminProcedure
|
|
.input(z.object({ userId: z.number() }))
|
|
.mutation(async ({ input }) => {
|
|
await deleteUser(input.userId);
|
|
return { success: true };
|
|
}),
|
|
}),
|
|
|
|
// ============= SFTP ROUTES =============
|
|
sftp: router({
|
|
testConnection: protectedProcedure.mutation(async ({ ctx }) => {
|
|
const config = await getUserSftpConfig(ctx.user.id);
|
|
if (!config) {
|
|
throw new TRPCError({ code: "BAD_REQUEST", message: "SFTP not configured" });
|
|
}
|
|
|
|
const success = await testSftpConnection(config);
|
|
return { success };
|
|
}),
|
|
|
|
exportToExcel: protectedProcedure
|
|
.input(z.object({ invoiceIds: z.array(z.number()) }))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const invoices = await Promise.all(
|
|
input.invoiceIds.map(id => getInvoiceById(id))
|
|
);
|
|
|
|
const validInvoices = invoices.filter(
|
|
inv => inv && inv.userId === ctx.user.id
|
|
);
|
|
|
|
// Return invoice data for Excel generation on client side
|
|
return {
|
|
success: true,
|
|
invoices: validInvoices.map(inv => ({
|
|
id: inv!.id,
|
|
supplierName: inv!.supplierName || '',
|
|
invoiceNumber: inv!.invoiceNumber || '',
|
|
invoiceDate: inv!.invoiceDate ? new Date(inv!.invoiceDate).toLocaleDateString('fr-FR') : '',
|
|
deliveryNoteNumber: inv!.deliveryNoteNumber || '',
|
|
orderNumber: inv!.orderNumber || '',
|
|
totalAmount: inv!.totalAmount ? parseFloat(inv!.totalAmount as string) : 0,
|
|
qualityScore: inv!.qualityScore || 0,
|
|
exportStatus: inv!.exportStatus || 'not_exported',
|
|
exportedAt: inv!.exportedAt ? new Date(inv!.exportedAt).toLocaleDateString('fr-FR') : '',
|
|
createdAt: new Date(inv!.createdAt).toLocaleDateString('fr-FR'),
|
|
}))
|
|
};
|
|
}),
|
|
|
|
exportToPdf: protectedProcedure
|
|
.input(z.object({ invoiceIds: z.array(z.number()) }))
|
|
.mutation(async ({ input, ctx }) => {
|
|
// Validate that all invoices have quality score of 100
|
|
const invoices = await Promise.all(
|
|
input.invoiceIds.map(id => getInvoiceById(id))
|
|
);
|
|
|
|
const invalidInvoices = invoices.filter(
|
|
inv => !inv || inv.userId !== ctx.user.id || (inv.qualityScore || 0) < 100
|
|
);
|
|
|
|
if (invalidInvoices.length > 0) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: "Toutes les factures doivent avoir un score de qualit\u00e9 de 100% pour \u00eatre export\u00e9es"
|
|
});
|
|
}
|
|
|
|
// Update export status
|
|
for (const invoice of invoices) {
|
|
if (invoice) {
|
|
await updateInvoice(invoice.id, {
|
|
exportStatus: "exported",
|
|
exportedAt: new Date(),
|
|
exportMode: "manual",
|
|
});
|
|
}
|
|
}
|
|
|
|
// Return the file URLs for PDF generation on client side
|
|
return {
|
|
success: true,
|
|
invoices: invoices.filter(Boolean).map(inv => ({
|
|
id: inv!.id,
|
|
fileUrl: inv!.fileUrl,
|
|
supplierName: inv!.supplierName,
|
|
invoiceNumber: inv!.invoiceNumber,
|
|
invoiceDate: inv!.invoiceDate,
|
|
}))
|
|
};
|
|
}),
|
|
|
|
exportInvoices: protectedProcedure
|
|
.input(z.object({ invoiceIds: z.array(z.number()) }))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const config = await getUserSftpConfig(ctx.user.id);
|
|
if (!config) {
|
|
throw new TRPCError({ code: "BAD_REQUEST", message: "SFTP not configured" });
|
|
}
|
|
|
|
let successCount = 0;
|
|
let errorCount = 0;
|
|
|
|
for (const invoiceId of input.invoiceIds) {
|
|
try {
|
|
const invoice = await getInvoiceById(invoiceId);
|
|
if (!invoice || invoice.userId !== ctx.user.id) {
|
|
errorCount++;
|
|
continue;
|
|
}
|
|
|
|
await exportInvoiceToSftp(
|
|
config,
|
|
invoice.fileKey,
|
|
invoice.metadataFileKey,
|
|
invoice.invoiceDate || new Date()
|
|
);
|
|
|
|
// Update invoice export status
|
|
await updateInvoice(invoiceId, {
|
|
exportedAt: new Date(),
|
|
exportMode: "manual",
|
|
});
|
|
|
|
successCount++;
|
|
} catch (error) {
|
|
console.error(`[SFTP] Failed to export invoice ${invoiceId}:`, error);
|
|
errorCount++;
|
|
}
|
|
}
|
|
|
|
return { successCount, errorCount };
|
|
}),
|
|
}),
|
|
|
|
// ============= IMPORT LOGS ROUTES =============
|
|
importLogs: router({
|
|
getByUser: protectedProcedure.query(async ({ ctx }) => {
|
|
return getImportLogsByUser(ctx.user.id);
|
|
}),
|
|
}),
|
|
|
|
// ============= LLM LOGS ROUTES =============
|
|
llmLogs: router({
|
|
getBySourceFile: protectedProcedure
|
|
.input(z.object({ sourceFileId: z.number() }))
|
|
.query(async ({ input }) => {
|
|
return getLlmLogsBySourceFile(input.sourceFileId);
|
|
}),
|
|
|
|
getByInvoice: protectedProcedure
|
|
.input(z.object({ invoiceId: z.number() }))
|
|
.query(async ({ input }) => {
|
|
return getLlmLogsByInvoice(input.invoiceId);
|
|
}),
|
|
}),
|
|
});
|
|
|
|
export type AppRouter = typeof appRouter;
|