Files
demat-facturation/server/_core/index.ts
Manus 147dd6e5a0 Merge remote-tracking branch 'prod-dsi/main'
# Conflicts:
#	server/emailImportService.ts
2026-08-22 10:39:08 +00:00

434 lines
18 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 "dotenv/config";
import express from "express";
import { createServer } from "http";
import net from "net";
import path from "path";
import fs from "fs";
import archiver from "archiver";
import { parse as parseCookies } from "cookie";
import { createExpressMiddleware } from "@trpc/server/adapters/express";
import { registerOAuthRoutes } from "./oauth";
import { appRouter } from "../routers";
import { createContext } from "./context";
import { getSessionCookieOptions } from "./cookies";
import { serveStatic, setupVite } from "./vite";
import { getAllUsers, getImportSettingsByUser, getUserByAzureAdId, getUserByEmail, getUserSettings, upsertUser } from "../db";
import { startEmailImportService } from "../emailImportService";
import { startFolderImportService } from "../folderImportService";
import { handleAzureCallback, isAzureAdConfigured, generateToken, verifyToken } from "../auth";
import { createDatabaseBackup } from "../databaseBackup";
import { generateStorageKey, localStorageDelete, localStoragePut } from "../localStorage";
import { calculateFileSha256 } from "../fileFingerprint";
const MAX_WEB_IMPORT_BYTES = 20 * 1024 * 1024;
/** Returns the signed local session or sends the appropriate HTTP error. */
function requireAuthenticatedUser(req: express.Request, res: express.Response) {
const token = parseCookies(req.headers.cookie || "").auth_token;
const user = token ? verifyToken(token) : null;
if (!user) {
res.status(401).json({ error: "Non authentifié" });
return null;
}
return user;
}
/** Restricts a sensitive endpoint to administrators. */
function requireAdmin(req: express.Request, res: express.Response) {
const user = requireAuthenticatedUser(req, res);
if (!user) return null;
if (user.role !== "admin") {
res.status(403).json({ error: "Accès réservé aux administrateurs" });
return null;
}
return user;
}
function isPortAvailable(port: number): Promise<boolean> {
return new Promise(resolve => {
const server = net.createServer();
server.listen(port, () => {
server.close(() => resolve(true));
});
server.on("error", () => resolve(false));
});
}
async function findAvailablePort(startPort: number = 3000): Promise<number> {
for (let port = startPort; port < startPort + 20; port++) {
if (await isPortAvailable(port)) {
return port;
}
}
throw new Error(`No available port found starting from ${startPort}`);
}
async function startServer() {
const app = express();
const server = createServer(app);
// Configure body parser with larger size limit for file uploads
app.use(express.json({ limit: "50mb" }));
app.use(express.urlencoded({ limit: "50mb", extended: true }));
// OAuth callback under /api/oauth/callback
registerOAuthRoutes(app);
// Serve local storage files
app.use("/storage", express.static("storage"));
// Route de téléchargement forcé du PDF annoté BAP
// Accepte les chemins avec sous-dossiers : /api/download-bap/2026-04/filename.pdf
// ou via query param pdfPath : /api/download-bap/file.pdf?pdfPath=/storage/2026-04/file.pdf
app.get("/api/download-bap", (req, res) => {
if (!requireAuthenticatedUser(req, res)) return;
// Mode 1 : query param pdfPath (chemin complet depuis /storage/...)
const pdfPath = req.query.pdfPath as string | undefined;
if (!pdfPath) {
res.status(400).json({ error: "Paramètre pdfPath manquant" });
return;
}
// Sécurité : s'assurer que le chemin est bien dans le dossier storage
const normalized = path.normalize(pdfPath).replace(/^\/+/, '');
if (normalized.startsWith('..') || !normalized.startsWith('storage')) {
res.status(403).json({ error: "Accès refusé" });
return;
}
const storagePath = path.resolve(normalized);
if (!fs.existsSync(storagePath)) {
res.status(404).json({ error: "Fichier introuvable" });
return;
}
// Utiliser le nom de fichier fourni par le client (format Date - Fournisseur - N°Facture.pdf)
const customFilename = req.query.filename as string | undefined;
const fallbackFilename = path.basename(storagePath);
const finalFilename = customFilename ? customFilename : fallbackFilename;
// RFC 5987 : utiliser filename* pour les caractères non-ASCII
const encodedFilename = encodeURIComponent(finalFilename);
res.setHeader("Content-Disposition", `attachment; filename="${encodedFilename}"; filename*=UTF-8''${encodedFilename}`);
res.setHeader("Content-Type", "application/pdf");
res.sendFile(storagePath);
});
// Compat. ancienne route avec :filename (sans sous-dossier)
app.get("/api/download-bap/:filename", (req, res) => {
if (!requireAuthenticatedUser(req, res)) return;
const filename = path.basename(req.params.filename);
// Chercher dans tous les sous-dossiers de storage
const storageRoot = path.resolve("storage");
let found: string | null = null;
try {
const subdirs = fs.readdirSync(storageRoot);
for (const sub of subdirs) {
const candidate = path.join(storageRoot, sub, filename);
if (fs.existsSync(candidate)) { found = candidate; break; }
}
// Aussi essayer directement dans storage/
const direct = path.join(storageRoot, filename);
if (!found && fs.existsSync(direct)) found = direct;
} catch { /* ignore */ }
if (!found) {
res.status(404).json({ error: "Fichier introuvable" });
return;
}
res.setHeader("Content-Disposition", `attachment; filename="${encodeURIComponent(filename)}"`);
res.setHeader("Content-Type", "application/pdf");
res.sendFile(found);
});
// Route de téléchargement groupé ZIP des PDFs annotés BAP
// POST /api/download-bap-zip avec body { files: Array<{ pdfPath: string, filename: string }> }
app.post("/api/download-bap-zip", (req, res) => {
if (!requireAuthenticatedUser(req, res)) return;
const files: Array<{ pdfPath: string; filename: string }> = req.body.files || [];
if (!files.length) {
res.status(400).json({ error: "Aucun fichier spécifié" });
return;
}
// Vérifier que tous les chemins sont dans storage/
const resolvedFiles: Array<{ absPath: string; filename: string }> = [];
for (const f of files) {
const normalized = path.normalize(f.pdfPath).replace(/^\/+/, '');
if (normalized.startsWith('..') || !normalized.startsWith('storage')) continue;
const absPath = path.resolve(normalized);
if (fs.existsSync(absPath)) {
resolvedFiles.push({ absPath, filename: f.filename });
}
}
if (!resolvedFiles.length) {
res.status(404).json({ error: "Aucun fichier trouvé" });
return;
}
const zipFilename = `BAP_export_${new Date().toLocaleDateString('fr-CA')}.zip`;
const encodedZip = encodeURIComponent(zipFilename);
res.setHeader("Content-Type", "application/zip");
res.setHeader("Content-Disposition", `attachment; filename="${encodedZip}"; filename*=UTF-8''${encodedZip}`);
const archive = archiver('zip', { zlib: { level: 6 } });
archive.on('error', (err) => { console.error('ZIP error:', err); res.destroy(); });
archive.pipe(res);
// Gérer les doublons de noms de fichiers
const usedNames = new Map<string, number>();
for (const { absPath, filename } of resolvedFiles) {
const base = filename.replace(/\.pdf$/i, '');
const count = usedNames.get(base) || 0;
usedNames.set(base, count + 1);
const finalName = count === 0 ? filename : `${base} (${count}).pdf`;
archive.file(absPath, { name: finalName });
}
archive.finalize();
});
// ============= AZURE AD OAUTH2 CALLBACK =============
app.get("/api/auth/azure/callback", async (req, res) => {
const code = req.query.code as string | undefined;
const error = req.query.error as string | undefined;
if (error) {
console.error("[Azure AD] Erreur OAuth:", error, req.query.error_description);
res.redirect(`/login?error=${encodeURIComponent("Connexion Microsoft refusée")}`);
return;
}
if (!code) {
res.redirect("/login?error=" + encodeURIComponent("Code OAuth manquant"));
return;
}
if (!isAzureAdConfigured()) {
res.redirect("/login?error=" + encodeURIComponent("Azure AD non configuré"));
return;
}
try {
const azureUser = await handleAzureCallback(code);
// Chercher l'utilisateur par azureAdId ou par email
let user = await getUserByAzureAdId(azureUser.azureAdId);
if (!user) {
user = await getUserByEmail(azureUser.email);
}
if (!user) {
// Créer l'utilisateur automatiquement
await upsertUser({
email: azureUser.email,
name: azureUser.name,
azureAdId: azureUser.azureAdId,
loginMethod: "azure-ad",
isActive: 1,
role: "user",
});
user = await getUserByEmail(azureUser.email);
} else {
// Mettre à jour l'azureAdId si manquant
if (!user.azureAdId) {
await upsertUser({
email: user.email,
azureAdId: azureUser.azureAdId,
loginMethod: user.loginMethod,
});
}
}
if (!user) {
res.redirect("/login?error=" + encodeURIComponent("Impossible de créer le compte"));
return;
}
if (user.isActive === 0) {
res.redirect("/login?error=" + encodeURIComponent("Compte inactif"));
return;
}
// Générer le token JWT et poser le cookie
const token = generateToken(user);
res.cookie("auth_token", token, {
...getSessionCookieOptions(req),
maxAge: 7 * 24 * 60 * 60 * 1000,
});
console.log(`[Azure AD] Connexion réussie pour ${user.email}`);
res.redirect("/");
} catch (err: any) {
console.error("[Azure AD] Erreur callback:", err.message);
res.redirect("/login?error=" + encodeURIComponent("Erreur d'authentification Microsoft"));
}
});
// ============= WEB IMPORT SOURCES - Endpoint pour script cron externe =============
// ============= DB BACKUP - Génération et téléchargement dump MySQL =============
app.post("/api/db-backup", async (req, res) => {
if (!requireAdmin(req, res)) return;
try {
const backupDir = path.resolve("backups");
const backup = await createDatabaseBackup(process.env.DATABASE_URL, backupDir);
console.log(`[Backup] Dump saved to ${backup.filePath} (${backup.size} bytes)`);
// Retourner le fichier en téléchargement
const encodedName = encodeURIComponent(backup.fileName);
res.setHeader("Content-Disposition", `attachment; filename="${encodedName}"; filename*=UTF-8''${encodedName}`);
res.setHeader("Content-Type", "application/sql");
res.sendFile(backup.filePath, (err) => {
if (err) console.error("[Backup] Error sending file:", err);
});
} catch (err: any) {
console.error("[Backup] Error:", err.message);
res.status(500).json({ error: "La sauvegarde na pas pu être générée. Consultez les journaux serveur." });
}
});
// Télécharger une sauvegarde existante
app.get("/api/db-backup/:filename", async (req, res) => {
if (!requireAdmin(req, res)) return;
const fileName = path.basename(req.params.filename);
const filePath = path.join(path.resolve("backups"), fileName);
if (!fs.existsSync(filePath)) { res.status(404).json({ error: "Fichier introuvable" }); return; }
const encodedName = encodeURIComponent(fileName);
res.setHeader("Content-Disposition", `attachment; filename="${encodedName}"; filename*=UTF-8''${encodedName}`);
res.setHeader("Content-Type", "application/sql");
res.sendFile(filePath);
});
app.post("/api/web-import/push-invoice", async (req, res) => {
try {
const { apiToken, fileName, fileBase64 } = req.body;
if (typeof apiToken !== "string" || typeof fileName !== "string" || typeof fileBase64 !== "string") {
res.status(400).json({ error: "apiToken, fileName et fileBase64 sont requis" });
return;
}
const safeFileName = path.basename(fileName);
if (!safeFileName.toLowerCase().endsWith(".pdf")) {
res.status(400).json({ error: "Seuls les fichiers PDF sont acceptés" });
return;
}
if (Buffer.byteLength(fileBase64, "utf8") > Math.ceil(MAX_WEB_IMPORT_BYTES * 1.34)) {
res.status(413).json({ error: "Le fichier dépasse la taille maximale autorisée" });
return;
}
const { getWebImportSourceByToken, createInvoice, findDuplicateInvoice, isInvoiceBlacklisted, updateWebImportSourceStatus, createSourceFile, getSourceFileByContentHash } = await import('../db');
const source = await getWebImportSourceByToken(apiToken);
if (!source) {
res.status(401).json({ error: "Token invalide" });
return;
}
const pdfBuffer = Buffer.from(fileBase64, 'base64');
if (pdfBuffer.length === 0 || pdfBuffer.length > MAX_WEB_IMPORT_BYTES || !pdfBuffer.subarray(0, 4).equals(Buffer.from("%PDF"))) {
res.status(400).json({ error: "Le contenu reçu nest pas un PDF valide" });
return;
}
const contentHash = calculateFileSha256(pdfBuffer);
const existingSource = await getSourceFileByContentHash(contentHash);
if (existingSource) {
await updateWebImportSourceStatus(source.id, "success", 0, true);
res.json({ success: true, imported: 0, duplicates: 1, total: 1 });
return;
}
// Stocker d'abord le PDF de façon persistante, comme les autres sources d'import.
const storageKey = generateStorageKey(source.userId, safeFileName);
const { url: fileUrl } = await localStoragePut(storageKey, pdfBuffer, "application/pdf");
let sourceFile;
try {
sourceFile = await createSourceFile({
userId: source.userId,
fileName: safeFileName,
fileKey: storageKey,
fileUrl,
contentHash,
});
} catch (error: any) {
if (error?.code === "ER_DUP_ENTRY" || error?.errno === 1062) {
await localStorageDelete(storageKey).catch(() => undefined);
await updateWebImportSourceStatus(source.id, "success", 0, true);
res.json({ success: true, imported: 0, duplicates: 1, total: 1 });
return;
}
throw error;
}
const userSettings = await getUserSettings(source.userId);
const aiSettings = {
aiProvider: userSettings?.aiProvider || "manus",
mistralApiKey: userSettings?.mistralApiKey || undefined,
manusForgeApiUrl: userSettings?.manusForgeApiUrl || undefined,
manusForgeApiKey: userSettings?.manusForgeApiKey || undefined,
geminiApiKey: userSettings?.geminiApiKey || undefined,
};
const { extractInvoicesWithMistral } = await import('../invoiceExtractor');
const extractResult = await extractInvoicesWithMistral(pdfBuffer, source.userId, sourceFile.id, userSettings?.llmModel || "mistral-large-latest", undefined, aiSettings);
let imported = 0;
let duplicates = 0;
for (const inv of extractResult.invoices || []) {
const blacklisted = await isInvoiceBlacklisted(inv.invoiceNumber || null, source.userId);
if (blacklisted) { duplicates++; continue; }
const dup = await findDuplicateInvoice(inv.invoiceNumber || null, String(inv.totalAmount ?? ''), source.userId);
if (dup) { duplicates++; continue; }
await createInvoice({ ...inv, userId: source.userId, sourceFileId: sourceFile.id } as any);
imported++;
}
await updateWebImportSourceStatus(source.id, 'success', imported, true);
res.json({ success: true, imported, duplicates, total: (extractResult.invoices || []).length });
} catch (err: any) {
console.error('[WebImport] Erreur push-invoice:', err.message);
res.status(500).json({ error: "Limport web a échoué. Consultez les journaux serveur." });
}
});
// tRPC API
app.use(
"/api/trpc",
createExpressMiddleware({
router: appRouter,
createContext,
})
);
// development mode uses Vite, production mode uses static files
if (process.env.NODE_ENV === "development") {
await setupVite(app, server);
} else {
serveStatic(app);
}
const preferredPort = parseInt(process.env.PORT || "3000");
const port = await findAvailablePort(preferredPort);
if (port !== preferredPort) {
console.log(`Port ${preferredPort} is busy, using port ${port} instead`);
}
server.listen(port, () => {
console.log(`Server running on http://localhost:${port}/`);
});
// Redémarrer automatiquement les services actifs (IMAP, dossier) après redémarrage du serveur
setTimeout(async () => {
try {
const users = await getAllUsers();
for (const user of users) {
const settings = await getImportSettingsByUser(user.id);
if (!settings) continue;
if (settings.emailImportEnabled === 1) {
console.log(`[AutoRestart] Restarting email import service for user ${user.id}...`);
await startEmailImportService(user.id).catch(e =>
console.error(`[AutoRestart] Failed to restart email service for user ${user.id}:`, e.message)
);
}
if ((settings as any).autoImportEnabled === 1) {
console.log(`[AutoRestart] Restarting folder import service for user ${user.id}...`);
await startFolderImportService(user.id).catch(e =>
console.error(`[AutoRestart] Failed to restart folder service for user ${user.id}:`, e.message)
);
}
}
} catch (e: any) {
console.error('[AutoRestart] Error during service auto-restart:', e.message);
}
}, 5000); // Attendre 5s que le serveur soit prêt
}
// Vitest ne doit jamais démarrer un serveur HTTP.
if (!process.env.VITEST) {
startServer().catch(console.error);
}