perf(serveur): réponses API allégées, compression, cache des assets et index

- Fichiers statiques servis avant la session (plus de requêtes SQL par
  asset), /assets en cache immuable 1 an, index.html en no-cache, et
  compression gzip/brotli des réponses (dépendance compression, externe
  dans le bundle esbuild du Dockerfile).
- req.user (déjà chargé par deserializeUser) réutilisé dans les handlers
  au lieu de relire l'utilisateur et ses magasins à chaque appel ;
  GET /api/user ne refait plus de requête.
- Listes : le magasin joint est réduit aux champs lus par l'interface
  (plus de logo base64 ni de configuration SMTP/NocoDB dans chaque ligne),
  plus aucune empreinte de mot de passe dans les créateurs/auteurs ni dans
  /api/users.
- N+1 supprimés (/api/users, annonces, historique SAV, caches de
  vérification des factures), requêtes indépendantes en parallèle (stats,
  analytics, météo, getDelivery, getUserWithGroups), jointure
  multiplicative des statistiques par magasin corrigée.
- Échéancier limité au magasin demandé ; filtre status sur
  GET /api/deliveries.
- Index de performance créés en arrière-plan au démarrage
  (CREATE INDEX CONCURRENTLY, reliquats invalides purgés sans verrou
  exclusif).
- La connexion n'attend plus la sauvegarde quotidienne ; purge du cache
  des factures active en production ; logs volumineux retirés des
  chemins chauds ; NODE_ENV fixé dans l'image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MsDJjQrAggcJwbbBtKhgyb
This commit is contained in:
Claude committed 2026-10-03 04:29:51 +00:00
1 parent 3f2660c506
commit a5785f0244
13 files changed
+1113 -1116

No files matched your search

+4 -1
View File
@@ -62,7 +62,8 @@ RUN npx esbuild server/index.production.ts \
--external:memoizee \ --external:memoizee \
--external:nanoid \ --external:nanoid \
--external:date-fns \ --external:date-fns \
--external:nodemailer --external:nodemailer \
--external:compression
# Production stage # Production stage
FROM node:20-alpine AS production FROM node:20-alpine AS production
@@ -99,6 +100,8 @@ USER nextjs
# Expose port # Expose port
EXPOSE 3000 EXPOSE 3000
ENV PORT=3000 ENV PORT=3000
# Fixé ici car db.ts lit NODE_ENV dès l'import, avant le forçage de index.production.ts
ENV NODE_ENV=production
# Install wget for health check and postgresql-client for pg_dump # Install wget for health check and postgresql-client for pg_dump
USER root USER root
+72
View File
@@ -54,6 +54,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"compression": "^1.8.2",
"connect-pg-simple": "^10.0.0", "connect-pg-simple": "^10.0.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"csrf-csrf": "^4.0.3", "csrf-csrf": "^4.0.3",
@@ -106,6 +107,7 @@
"@replit/vite-plugin-runtime-error-modal": "^0.0.3", "@replit/vite-plugin-runtime-error-modal": "^0.0.3",
"@tailwindcss/typography": "^0.5.15", "@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.1.3", "@tailwindcss/vite": "^4.1.3",
"@types/compression": "^1.8.1",
"@types/connect-pg-simple": "^7.0.3", "@types/connect-pg-simple": "^7.0.3",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/express-session": "^1.18.0", "@types/express-session": "^1.18.0",
@@ -3643,6 +3645,17 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/compression": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz",
"integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*",
"@types/node": "*"
}
},
"node_modules/@types/connect": { "node_modules/@types/connect": {
"version": "3.4.38", "version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
@@ -4532,6 +4545,65 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/compressible": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
"license": "MIT",
"dependencies": {
"mime-db": ">= 1.43.0 < 2"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/compression": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz",
"integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"compressible": "~2.0.18",
"debug": "2.6.9",
"destroy": "1.2.0",
"negotiator": "~0.6.4",
"on-headers": "~1.1.0",
"safe-buffer": "5.2.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.8.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/compression/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/compression/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/compression/node_modules/negotiator": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/concat-stream": { "node_modules/concat-stream": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
+2
View File
@@ -56,6 +56,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"compression": "^1.8.2",
"connect-pg-simple": "^10.0.0", "connect-pg-simple": "^10.0.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"csrf-csrf": "^4.0.3", "csrf-csrf": "^4.0.3",
@@ -108,6 +109,7 @@
"@replit/vite-plugin-runtime-error-modal": "^0.0.3", "@replit/vite-plugin-runtime-error-modal": "^0.0.3",
"@tailwindcss/typography": "^0.5.15", "@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.1.3", "@tailwindcss/vite": "^4.1.3",
"@types/compression": "^1.8.1",
"@types/connect-pg-simple": "^7.0.3", "@types/connect-pg-simple": "^7.0.3",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/express-session": "^1.18.0", "@types/express-session": "^1.18.0",
+30 -4
View File
@@ -3,7 +3,7 @@ import { promisify } from 'util';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { eq, desc } from "drizzle-orm"; import { eq, desc, sql } from "drizzle-orm";
import { db } from "./db"; import { db } from "./db";
import { databaseBackups, utilities, users } from "@shared/schema"; import { databaseBackups, utilities, users } from "@shared/schema";
import type { DatabaseBackup, InsertDatabaseBackup } from "@shared/schema"; import type { DatabaseBackup, InsertDatabaseBackup } from "@shared/schema";
@@ -14,6 +14,8 @@ export class BackupService {
private backupDir: string; private backupDir: string;
private maxBackups = 10; private maxBackups = 10;
private lastAutomaticBackupDate: string | null = null; private lastAutomaticBackupDate: string | null = null;
// Vérification quotidienne en cours, partagée par les connexions simultanées
private dailyBackupCheck: Promise<{ backupPerformed: boolean; message: string }> | null = null;
constructor() { constructor() {
// Use /app/backups in production (with proper permissions), or use env variable // Use /app/backups in production (with proper permissions), or use env variable
@@ -130,9 +132,10 @@ export class BackupService {
await execAsync(command, { env }); await execAsync(command, { env });
// Get file stats and count tables // Get file stats and count tables
const stats = fs.statSync(filepath); // Tables comptées en base plutôt qu'en relisant tout le dump en mémoire :
const sqlContent = fs.readFileSync(filepath, 'utf8'); // la lecture synchrone et la regex gelaient le serveur sur les grosses bases
const tablesCount = (sqlContent.match(/CREATE TABLE/g) || []).length; const stats = await fs.promises.stat(filepath);
const tablesCount = await this.countDatabaseTables();
// Update database record with completion details // Update database record with completion details
const [updatedBackup] = await db.update(databaseBackups) const [updatedBackup] = await db.update(databaseBackups)
@@ -156,6 +159,18 @@ export class BackupService {
} }
} }
// Nombre de tables utilisateur (hors schémas système), proche de ce que
// pg_dump inclut dans la sauvegarde
private async countDatabaseTables(): Promise<number> {
const result = await db.execute(sql`
SELECT count(*)::int AS count
FROM information_schema.tables
WHERE table_type = 'BASE TABLE'
AND table_schema NOT IN ('pg_catalog', 'information_schema')
`);
return Number(result.rows[0]?.count ?? 0);
}
async getBackupList(): Promise<DatabaseBackup[]> { async getBackupList(): Promise<DatabaseBackup[]> {
try { try {
const backups = await db.select() const backups = await db.select()
@@ -238,6 +253,17 @@ export class BackupService {
// Nouvelle méthode : Vérifier et effectuer une sauvegarde quotidienne si nécessaire // Nouvelle méthode : Vérifier et effectuer une sauvegarde quotidienne si nécessaire
async checkAndPerformDailyBackup(userId: string = 'system'): Promise<{ backupPerformed: boolean; message: string }> { async checkAndPerformDailyBackup(userId: string = 'system'): Promise<{ backupPerformed: boolean; message: string }> {
// Plusieurs connexions simultanées (ouverture des magasins) partagent la
// même vérification : un seul pg_dump est lancé
if (!this.dailyBackupCheck) {
this.dailyBackupCheck = this.performDailyBackupCheck(userId).finally(() => {
this.dailyBackupCheck = null;
});
}
return this.dailyBackupCheck;
}
private async performDailyBackupCheck(userId: string): Promise<{ backupPerformed: boolean; message: string }> {
try { try {
// Vérifier si les backups automatiques sont activés // Vérifier si les backups automatiques sont activés
const [config] = await db.select() const [config] = await db.select()
-119
View File
@@ -1,119 +0,0 @@
import { Request, Response, NextFunction } from 'express';
// Cache en mémoire simple pour les données fréquemment utilisées
class MemoryCache {
private cache: Map<string, { data: any; timestamp: number; ttl: number }> = new Map();
private readonly DEFAULT_TTL = 5 * 60 * 1000; // 5 minutes
set(key: string, data: any, ttl: number = this.DEFAULT_TTL): void {
this.cache.set(key, {
data,
timestamp: Date.now(),
ttl
});
}
get(key: string): any | null {
const item = this.cache.get(key);
if (!item) return null;
if (Date.now() - item.timestamp > item.ttl) {
this.cache.delete(key);
return null;
}
return item.data;
}
delete(key: string): void {
this.cache.delete(key);
}
clear(): void {
this.cache.clear();
}
// Nettoyer les éléments expirés
cleanup(): void {
const now = Date.now();
for (const [key, item] of this.cache.entries()) {
if (now - item.timestamp > item.ttl) {
this.cache.delete(key);
}
}
}
}
export const cache = new MemoryCache();
// Nettoyage automatique toutes les 10 minutes
setInterval(() => cache.cleanup(), 10 * 60 * 1000);
// Middleware de mise en cache pour les réponses API
export function cacheMiddleware(ttl: number = 5 * 60 * 1000) {
return (req: Request, res: Response, next: NextFunction) => {
// Ne pas mettre en cache les requêtes POST/PUT/DELETE
if (req.method !== 'GET') {
return next();
}
const key = `${req.originalUrl}:${req.user?.id || 'anonymous'}`;
const cachedData = cache.get(key);
if (cachedData) {
res.setHeader('X-Cache', 'HIT');
return res.json(cachedData);
}
res.setHeader('X-Cache', 'MISS');
// Intercepter la réponse pour la mettre en cache
const originalSend = res.json;
res.json = function(data: any) {
if (res.statusCode === 200) {
cache.set(key, data, ttl);
}
return originalSend.call(this, data);
};
next();
};
}
// Invalidation de cache par pattern
export function invalidateCache(pattern: string): void {
for (const key of cache['cache'].keys()) {
if (key.includes(pattern)) {
cache.delete(key);
}
}
}
// Middleware de compression des réponses
export function setupCompression(app: any) {
app.use((req: Request, res: Response, next: NextFunction) => {
// Activer la compression pour les réponses JSON
if (req.accepts('gzip') && String(res.getHeader('Content-Type') ?? '').includes('json')) {
res.setHeader('Content-Encoding', 'gzip');
}
next();
});
}
// Optimisation des requêtes base de données
export function createOptimizedQuery(baseQuery: string, params: any[]): { query: string; params: any[] } {
// Optimiser les requêtes en évitant les SELECT * inutiles
let optimizedQuery = baseQuery;
// Limiter les résultats pour éviter les grosses requêtes
if (!optimizedQuery.includes('LIMIT') && !optimizedQuery.includes('COUNT')) {
optimizedQuery += ' LIMIT 1000';
}
// Ajouter des index hints si nécessaire
if (optimizedQuery.includes('ORDER BY created_at')) {
optimizedQuery = optimizedQuery.replace('ORDER BY created_at', 'ORDER BY created_at DESC');
}
return { query: optimizedQuery, params };
}
+44 -25
View File
@@ -1,4 +1,5 @@
import express, { type Express, type Request, type Response, type NextFunction } from "express"; import express, { type Express, type Request, type Response, type NextFunction } from "express";
import compression from "compression";
import { createServer, type Server } from "http"; import { createServer, type Server } from "http";
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { dirname, join } from 'path'; import { dirname, join } from 'path';
@@ -24,6 +25,10 @@ console.log('🐳 Environment:', {
const app = express(); const app = express();
// Compression gzip des réponses (assets JS/CSS, index.html et JSON de l'API).
// En premier pour couvrir toutes les réponses.
app.use(compression());
app.use(express.json({ limit: '10mb' })); app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: false, limit: '10mb' })); app.use(express.urlencoded({ extended: false, limit: '10mb' }));
@@ -126,48 +131,62 @@ async function registerProductionRoutes(app: Express): Promise<void> {
}); });
} }
await registerProductionRoutes(app);
app.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
const status = err.status || err.statusCode || 500;
const message = err.message || "Internal Server Error";
console.error('Server error:', { status, message, error: err });
res.status(status).json({ message });
throw err;
});
// Serve static files directly in production (no Vite) // Serve static files directly in production (no Vite)
// Déclarés AVANT les routes : les fichiers statiques et l'index.html ne passent
// ni par la session ni par passport (aucune requête SQL par fichier).
const publicPath = join(__dirname, 'public'); const publicPath = join(__dirname, 'public');
console.log('🐳 Serving static files from:', publicPath); console.log('🐳 Serving static files from:', publicPath);
app.use('/assets', express.static(join(publicPath, 'assets'))); // Les fichiers de /assets ont un nom haché par Vite : cache navigateur d'un an
app.use('/', express.static(publicPath)); app.use('/assets', express.static(join(publicPath, 'assets'), {
maxAge: '1y',
// Add explicit root route handler immutable: true,
app.get('/', (req, res) => { index: false,
console.log('🏠 ROOT: Serving index.html for root request'); }));
res.sendFile(join(publicPath, 'index.html'), (err) => { // Autres fichiers publics (favicon, manifest...). L'index.html est toujours
if (err) { // revalidé pour qu'un nouveau déploiement soit pris en compte immédiatement.
console.error('❌ ROOT: Error serving index.html:', err); app.use(express.static(publicPath, {
res.status(500).send('Error loading application'); index: false,
setHeaders: (res, filePath) => {
if (filePath.endsWith('.html')) {
res.setHeader('Cache-Control', 'no-cache');
} }
}); },
}); }));
// SPA fallback - serve index.html for all non-API routes // SPA fallback - serve index.html for all non-API routes (y compris "/")
app.get('*', (req, res, next) => { app.get('*', (req, res, next) => {
if (req.path.startsWith('/api/')) { if (req.path.startsWith('/api/')) {
return next(); return next();
} }
console.log(`📄 SPA: Serving index.html for ${req.path}`); res.set('Cache-Control', 'no-cache');
res.sendFile(join(publicPath, 'index.html'), (err) => { res.sendFile(join(publicPath, 'index.html'), (err) => {
if (err) { if (err) {
console.error(`❌ SPA: Error serving index.html for ${req.path}:`, err); console.error(`❌ SPA: Error serving index.html for ${req.path}:`, err);
res.status(500).send('Error loading application'); if (!res.headersSent) {
res.status(500).send('Error loading application');
}
} }
}); });
}); });
await registerProductionRoutes(app);
// Tâches de maintenance périodiques (purge du cache factures expiré)
const { startMaintenanceJobs } = await import('./maintenance.js');
startMaintenanceJobs();
app.use((err: any, _req: Request, res: Response, next: NextFunction) => {
// Réponse déjà commencée : on laisse Express clore la connexion
if (res.headersSent) {
return next(err);
}
const status = err.status || err.statusCode || 500;
const message = err.message || "Internal Server Error";
console.error('Server error:', { status, message, error: err });
res.status(status).json({ message });
});
const port = process.env.PORT ? parseInt(process.env.PORT) : 3000; const port = process.env.PORT ? parseInt(process.env.PORT) : 3000;
const server = createServer(app); const server = createServer(app);
server.listen(port, "0.0.0.0", () => { server.listen(port, "0.0.0.0", () => {
+10 -2
View File
@@ -1,4 +1,5 @@
import express, { type Request, Response, NextFunction } from "express"; import express, { type Request, Response, NextFunction } from "express";
import compression from "compression";
import cookieParser from "cookie-parser"; import cookieParser from "cookie-parser";
import { registerRoutes } from "./routes.js"; import { registerRoutes } from "./routes.js";
import { setupVite, serveStatic } from "./vite.js"; import { setupVite, serveStatic } from "./vite.js";
@@ -24,6 +25,9 @@ console.log('✅ [STARTUP] Weather system initialized');
const app = express(); const app = express();
// Compression gzip des réponses, en premier pour couvrir toutes les réponses
app.use(compression());
// Parse cookies (required for CSRF) // Parse cookies (required for CSRF)
app.use(cookieParser()); app.use(cookieParser());
@@ -63,11 +67,15 @@ const server = await registerRoutes(app);
const { startMaintenanceJobs } = await import('./maintenance.js'); const { startMaintenanceJobs } = await import('./maintenance.js');
startMaintenanceJobs(); startMaintenanceJobs();
app.use((err: any, _req: Request, res: Response, _next: NextFunction) => { app.use((err: any, _req: Request, res: Response, next: NextFunction) => {
// Réponse déjà commencée : on laisse Express clore la connexion
if (res.headersSent) {
return next(err);
}
const status = err.status || err.statusCode || 500; const status = err.status || err.statusCode || 500;
const message = err.message || "Internal Server Error"; const message = err.message || "Internal Server Error";
console.error('Server error:', { status, message, error: err });
res.status(status).json({ message }); res.status(status).json({ message });
throw err;
}); });
// Setup Vite in development // Setup Vite in development
+50 -101
View File
@@ -1,5 +1,10 @@
import { sql } from "drizzle-orm";
import { db } from "./db.js";
import { storage } from "./storage.js"; import { storage } from "./storage.js";
// Blancs ASCII retirés par String.prototype.trim(), pour reproduire en SQL les clés de cache
const ASCII_WHITESPACE = ' \t\n\v\f\r';
/** /**
* Service de vérification des factures avec NocoDB * Service de vérification des factures avec NocoDB
* Gère la vérification automatique des références de factures * Gère la vérification automatique des références de factures
@@ -19,26 +24,10 @@ export class InvoiceVerificationService {
async checkCache(invoiceReference: string, groupId: number): Promise<any | null> { async checkCache(invoiceReference: string, groupId: number): Promise<any | null> {
try { try {
const cacheKey = this.generateCacheKey(invoiceReference, groupId); const cacheKey = this.generateCacheKey(invoiceReference, groupId);
console.log('🔍 [CACHE] Recherche cache pour:', { invoiceReference, groupId, cacheKey });
const cached = await storage.getInvoiceVerificationCache(cacheKey); const cached = await storage.getInvoiceVerificationCache(cacheKey);
console.log('🔍 [CACHE] Résultat cache:', {
found: !!cached,
isReconciled: cached?.isReconciled,
expired: cached ? new Date() >= new Date(cached.expiresAt) : 'N/A',
expiresAt: cached?.expiresAt,
currentTime: new Date().toISOString()
});
// ✅ CACHE PERMANENT : Facture validée avec coche verte = JAMAIS re-vérifier // ✅ CACHE PERMANENT : Facture validée avec coche verte = JAMAIS re-vérifier
if (cached && cached.isReconciled && cached.exists) { if (cached && cached.isReconciled && cached.exists) {
console.log('🛡️ [CACHE] PERMANENT - Facture validée avec coche verte, AUCUNE vérification:', {
invoiceReference,
groupId,
exists: cached.exists,
invoiceAmount: cached.invoiceAmount,
dueDate: cached.dueDate
});
return { return {
exists: cached.exists, exists: cached.exists,
matchType: cached.matchType, matchType: cached.matchType,
@@ -55,7 +44,6 @@ export class InvoiceVerificationService {
// Cache temporaire non expiré // Cache temporaire non expiré
if (cached && new Date() < new Date(cached.expiresAt)) { if (cached && new Date() < new Date(cached.expiresAt)) {
console.log('✅ [CACHE] Cache temporaire hit pour:', { invoiceReference, groupId, exists: cached.exists });
return { return {
exists: cached.exists, exists: cached.exists,
matchType: cached.matchType, matchType: cached.matchType,
@@ -69,13 +57,7 @@ export class InvoiceVerificationService {
permanent: false permanent: false
}; };
} }
if (cached && new Date() >= new Date(cached.expiresAt)) {
console.log('⏰ [CACHE] Cache temporaire expiré pour:', { invoiceReference, groupId });
} else {
console.log('❌ [CACHE] Cache miss pour:', { invoiceReference, groupId });
}
return null; return null;
} catch (error) { } catch (error) {
console.error('❌ [CACHE] Erreur lecture cache:', error); console.error('❌ [CACHE] Erreur lecture cache:', error);
@@ -92,32 +74,18 @@ export class InvoiceVerificationService {
// Durée de cache adaptative selon les cas // Durée de cache adaptative selon les cas
const expiresAt = new Date(); const expiresAt = new Date();
let cacheDescription = '';
if (isReconciled) { if (isReconciled) {
// Cache PERMANENT pour factures validées - expire dans 50 ans // Cache PERMANENT pour factures validées - expire dans 50 ans
expiresAt.setFullYear(expiresAt.getFullYear() + 50); expiresAt.setFullYear(expiresAt.getFullYear() + 50);
cacheDescription = 'PERMANENT (validé)';
} else if (result.exists) { } else if (result.exists) {
// Facture trouvée mais non validée - cache 6h pour permettre corrections // Facture trouvée mais non validée - cache 6h pour permettre corrections
expiresAt.setHours(expiresAt.getHours() + 6); expiresAt.setHours(expiresAt.getHours() + 6);
cacheDescription = 'temporaire 6h (trouvé)';
} else { } else {
// Facture non trouvée - cache 12h pour éviter spam // Facture non trouvée - cache 12h pour éviter spam
expiresAt.setHours(expiresAt.getHours() + 12); expiresAt.setHours(expiresAt.getHours() + 12);
cacheDescription = 'temporaire 12h (pas trouvé)';
} }
console.log('💾 [CACHE] Tentative sauvegarde:', {
invoiceReference,
groupId,
cacheKey,
exists: result.exists,
isReconciled,
cacheType: cacheDescription,
expiresAt: expiresAt.toISOString()
});
const cacheData = { const cacheData = {
cacheKey, cacheKey,
groupId, groupId,
@@ -135,17 +103,7 @@ export class InvoiceVerificationService {
expiresAt expiresAt
}; };
const savedCache = await storage.saveInvoiceVerificationCache(cacheData); await storage.saveInvoiceVerificationCache(cacheData);
console.log('✅ [CACHE] Résultat sauvé en cache:', {
id: savedCache.id,
invoiceReference,
groupId,
exists: result.exists,
isReconciled,
cacheType: cacheDescription,
cacheKey
});
} catch (error) { } catch (error) {
// Gérer spécifiquement les erreurs de contrainte unique (duplicate key) // Gérer spécifiquement les erreurs de contrainte unique (duplicate key)
if (error && typeof error === 'object' && 'code' in error && error.code === '23505') { if (error && typeof error === 'object' && 'code' in error && error.code === '23505') {
@@ -211,7 +169,16 @@ export class InvoiceVerificationService {
async updateExistingReconciledCaches(): Promise<void> { async updateExistingReconciledCaches(): Promise<void> {
try { try {
console.log('🔄 [CACHE] Mise à jour en lot des caches pour livraisons validées...'); console.log('🔄 [CACHE] Mise à jour en lot des caches pour livraisons validées...');
// Base PostgreSQL : une seule requête ensembliste au lieu de charger toutes
// les livraisons puis 2 requêtes (lecture + upsert) par référence
if (db) {
const updatedCount = await this.markReconciledCachesInDatabase();
console.log(`✅ [CACHE] Mise à jour terminée: ${updatedCount} caches mis à jour comme permanents`);
return;
}
// Stockage mémoire (développement sans base) : parcours livraison par livraison
// Récupérer toutes les livraisons validées (reconciled = true) // Récupérer toutes les livraisons validées (reconciled = true)
const deliveries = await storage.getDeliveries(); const deliveries = await storage.getDeliveries();
const reconciledDeliveries = deliveries.filter(d => d.reconciled); const reconciledDeliveries = deliveries.filter(d => d.reconciled);
@@ -243,6 +210,36 @@ export class InvoiceVerificationService {
} }
} }
/**
* Équivalent SQL de updateCacheAsReconciled appliqué à toutes les livraisons validées.
* Mêmes clés que generateCacheKey (référence facture et numéro de BL non vides après
* trim), mêmes conditions (entrée présente, non expirée, pas encore réconciliée) et
* mêmes colonnes modifiées que l'upsert de DatabaseStorage : is_reconciled et
* updated_at (expires_at n'y est pas mis à jour). Écart accepté : seuls les blancs
* ASCII sont retirés, et lower() suit la locale de la base.
* Retourne le nombre d'entrées de cache mises à jour.
*/
private async markReconciledCachesInDatabase(): Promise<number> {
// Heure UTC, comme drizzle écrit et relit les colonnes timestamp
const now = new Date().toISOString();
const result = await db.execute(sql`
UPDATE invoice_verification_cache
SET is_reconciled = true, updated_at = ${now}::timestamp
WHERE is_reconciled IS NOT TRUE
AND expires_at > ${now}::timestamp
AND cache_key IN (
SELECT group_id::text || '_' || lower(btrim(invoice_reference, ${ASCII_WHITESPACE}))
FROM deliveries
WHERE reconciled = true AND btrim(invoice_reference, ${ASCII_WHITESPACE}) <> ''
UNION
SELECT group_id::text || '_' || lower(btrim(bl_number, ${ASCII_WHITESPACE}))
FROM deliveries
WHERE reconciled = true AND btrim(bl_number, ${ASCII_WHITESPACE}) <> ''
)
`);
return result?.rowCount ?? 0;
}
/** /**
* Vérifie une référence de facture pour un groupe donné * Vérifie une référence de facture pour un groupe donné
*/ */
@@ -258,8 +255,6 @@ export class InvoiceVerificationService {
fromCache?: boolean; fromCache?: boolean;
}> { }> {
try { try {
console.log('🔍 [INVOICE] Début vérification facture:', { invoiceReference, groupId, forceRefresh });
if (!invoiceReference || !invoiceReference.trim()) { if (!invoiceReference || !invoiceReference.trim()) {
return { return {
exists: false, exists: false,
@@ -270,15 +265,10 @@ export class InvoiceVerificationService {
// Vérifier le cache d'abord (sauf si refresh forcé) // Vérifier le cache d'abord (sauf si refresh forcé)
if (!forceRefresh) { if (!forceRefresh) {
console.log('🔍 [INVOICE] Vérification cache...');
const cachedResult = await this.checkCache(invoiceReference, groupId); const cachedResult = await this.checkCache(invoiceReference, groupId);
if (cachedResult) { if (cachedResult) {
console.log('✅ [INVOICE] Résultat depuis cache:', cachedResult);
return cachedResult; return cachedResult;
} }
console.log('🔍 [INVOICE] Pas de cache, requête API...');
} else {
console.log('🔄 [INVOICE] Refresh forcé, ignorant le cache');
} }
// Récupérer la configuration du groupe // Récupérer la configuration du groupe
@@ -291,16 +281,8 @@ export class InvoiceVerificationService {
}; };
} }
console.log('🔧 Configuration groupe:', {
groupName: group.name,
hasNocodbConfig: !!group.nocodbConfigId,
hasTableName: !!group.nocodbTableName,
hasWebhook: !!group.webhookUrl
});
// Si pas de configuration NocoDB, retourner un résultat par défaut // Si pas de configuration NocoDB, retourner un résultat par défaut
if (!group.nocodbConfigId && !group.nocodbTableName && !group.webhookUrl) { if (!group.nocodbConfigId && !group.nocodbTableName && !group.webhookUrl) {
console.log('⚠️ Pas de configuration NocoDB pour ce groupe');
return { return {
exists: false, exists: false,
matchType: 'none', matchType: 'none',
@@ -353,8 +335,6 @@ export class InvoiceVerificationService {
} }
// En production, faire l'appel réel à NocoDB // En production, faire l'appel réel à NocoDB
console.log('🔍 Vérification NocoDB en production...');
try { try {
// Récupérer la configuration NocoDB active // Récupérer la configuration NocoDB active
const nocodbConfig = await storage.getActiveNocodbConfig(); const nocodbConfig = await storage.getActiveNocodbConfig();
@@ -367,20 +347,8 @@ export class InvoiceVerificationService {
}; };
} }
console.log('🔧 Configuration NocoDB trouvée:', {
configName: nocodbConfig.name,
baseUrl: nocodbConfig.baseUrl,
projectId: nocodbConfig.projectId,
hasToken: !!nocodbConfig.apiToken
});
// Utiliser l'ID de table configuré dans le groupe // Utiliser l'ID de table configuré dans le groupe
const tableId = group.nocodbTableId || 'mrr733dfb8wtt9b'; // Fallback par défaut const tableId = group.nocodbTableId || 'mrr733dfb8wtt9b'; // Fallback par défaut
console.log('🔧 Utilisation table ID:', {
groupTable: group.nocodbTableName,
configuredId: group.nocodbTableId,
resolvedId: tableId
});
// Vérifier d'abord par référence de facture // Vérifier d'abord par référence de facture
let matchResult = await this.searchInNocoDB( let matchResult = await this.searchInNocoDB(
@@ -621,8 +589,6 @@ export class InvoiceVerificationService {
fromCache?: boolean; fromCache?: boolean;
}> { }> {
try { try {
console.log('🔍 Début vérification facture par BL:', { blNumber, supplierName, groupId, forceRefresh });
if (!blNumber || !blNumber.trim()) { if (!blNumber || !blNumber.trim()) {
return { return {
exists: false, exists: false,
@@ -639,7 +605,6 @@ export class InvoiceVerificationService {
try { try {
const cached = await storage.getInvoiceVerificationCache(cacheKey); const cached = await storage.getInvoiceVerificationCache(cacheKey);
if (cached && new Date() < new Date(cached.expiresAt)) { if (cached && new Date() < new Date(cached.expiresAt)) {
console.log('💾 Cache hit pour BL:', { blNumber, groupId });
return { return {
exists: cached.exists, exists: cached.exists,
matchType: cached.matchType as 'invoice_reference' | 'bl_number' | 'none', matchType: cached.matchType as 'invoice_reference' | 'bl_number' | 'none',
@@ -664,17 +629,8 @@ export class InvoiceVerificationService {
}; };
} }
console.log('🔧 Configuration groupe pour BL:', {
groupName: group.name,
hasNocodbConfig: !!group.nocodbConfigId,
hasTableName: !!group.nocodbTableName,
hasWebhook: !!group.webhookUrl,
blColumnName: group.nocodbBlColumnName
});
// Si pas de configuration NocoDB, retourner un résultat par défaut // Si pas de configuration NocoDB, retourner un résultat par défaut
if (!group.nocodbConfigId && !group.nocodbTableName && !group.webhookUrl) { if (!group.nocodbConfigId && !group.nocodbTableName && !group.webhookUrl) {
console.log('⚠️ Pas de configuration NocoDB pour ce groupe');
return { return {
exists: false, exists: false,
matchType: 'none', matchType: 'none',
@@ -727,8 +683,6 @@ export class InvoiceVerificationService {
} }
// En production, faire l'appel réel à NocoDB // En production, faire l'appel réel à NocoDB
console.log('🔍 Vérification BL NocoDB en production...');
try { try {
// Récupérer la configuration NocoDB active // Récupérer la configuration NocoDB active
const nocodbConfig = await storage.getActiveNocodbConfig(); const nocodbConfig = await storage.getActiveNocodbConfig();
@@ -743,11 +697,6 @@ export class InvoiceVerificationService {
// Utiliser l'ID de table configuré dans le groupe // Utiliser l'ID de table configuré dans le groupe
const tableId = group.nocodbTableId || 'mrr733dfb8wtt9b'; const tableId = group.nocodbTableId || 'mrr733dfb8wtt9b';
console.log('🔧 Utilisation table ID pour BL:', {
groupTable: group.nocodbTableName,
configuredId: group.nocodbTableId,
resolvedId: tableId
});
// Rechercher par numéro de BL // Rechercher par numéro de BL
const blColumnName = group.nocodbBlColumnName || 'Numero_BL'; const blColumnName = group.nocodbBlColumnName || 'Numero_BL';
+35 -37
View File
@@ -5,7 +5,7 @@ import session from "express-session";
import { scrypt, randomBytes, timingSafeEqual } from "crypto"; import { scrypt, randomBytes, timingSafeEqual } from "crypto";
import { promisify } from "util"; import { promisify } from "util";
import { storage } from "./storage"; import { storage } from "./storage";
import { User as SelectUser } from "@shared/schema"; import { User as SelectUser, type UserWithGroups } from "@shared/schema";
import connectPg from "connect-pg-simple"; import connectPg from "connect-pg-simple";
import { backupService } from "./backupService"; import { backupService } from "./backupService";
@@ -141,7 +141,9 @@ export function setupLocalAuth(app: Express) {
passport.deserializeUser(async (id: string, done) => { passport.deserializeUser(async (id: string, done) => {
try { try {
const user = await storage.getUserWithGroups(id); const user = await storage.getUserWithGroups(id);
done(null, user); // Utilisateur supprimé depuis la connexion : false vide la session (401)
// au lieu de l'erreur « Failed to deserialize user out of session » (500)
done(null, user || false);
} catch (error) { } catch (error) {
done(error); done(error);
} }
@@ -157,20 +159,9 @@ export function setupLocalAuth(app: Express) {
return res.status(400).json({ message: info?.message || "Invalid credentials" }); return res.status(400).json({ message: info?.message || "Invalid credentials" });
} }
req.login(user, async (err) => { req.login(user, (err) => {
if (err) return next(err); if (err) return next(err);
// Vérifier et effectuer une sauvegarde quotidienne si nécessaire
try {
const backupResult = await backupService.checkAndPerformDailyBackup(user.id);
if (backupResult.backupPerformed) {
console.log('💾 Sauvegarde quotidienne effectuée lors de la connexion de', user.username);
}
} catch (error) {
console.error('⚠️ Erreur lors de la vérification de sauvegarde:', error);
// Ne pas faire échouer la connexion si la sauvegarde échoue
}
res.json({ res.json({
id: user.id, id: user.id,
username: user.username, username: user.username,
@@ -180,6 +171,21 @@ export function setupLocalAuth(app: Express) {
role: user.role, role: user.role,
passwordChanged: user.passwordChanged passwordChanged: user.passwordChanged
}); });
// Vérifier et effectuer une sauvegarde quotidienne si nécessaire, après
// la réponse : la connexion n'attend plus le pg_dump
setImmediate(() => {
backupService.checkAndPerformDailyBackup(user.id)
.then((backupResult) => {
if (backupResult.backupPerformed) {
console.log('💾 Sauvegarde quotidienne effectuée lors de la connexion de', user.username);
}
})
.catch((error) => {
// Ne pas faire échouer la connexion si la sauvegarde échoue
console.error('⚠️ Erreur lors de la vérification de sauvegarde:', error);
});
});
}); });
})(req, res, next); })(req, res, next);
}); });
@@ -201,33 +207,25 @@ export function setupLocalAuth(app: Express) {
app.get("/api/logout", logoutHandler); app.get("/api/logout", logoutHandler);
// Get current user // Get current user
app.get("/api/user", async (req, res) => { app.get("/api/user", (req, res) => {
if (!req.isAuthenticated()) { if (!req.isAuthenticated()) {
return res.status(401).json({ message: "Non authentifié" }); return res.status(401).json({ message: "Non authentifié" });
} }
try { // req.user est déjà rechargé avec ses magasins par deserializeUser à chaque
const userId = (req.user as SelectUser).id; // requête : inutile de relire l'utilisateur en base
const userWithGroups = await storage.getUserWithGroups(userId); const userWithGroups = req.user as UserWithGroups;
if (!userWithGroups) { res.json({
return res.status(404).json({ message: "Utilisateur non trouvé" }); id: userWithGroups.id,
} username: userWithGroups.username,
email: userWithGroups.email,
res.json({ firstName: userWithGroups.firstName,
id: userWithGroups.id, lastName: userWithGroups.lastName,
username: userWithGroups.username, role: userWithGroups.role,
email: userWithGroups.email, passwordChanged: userWithGroups.passwordChanged,
firstName: userWithGroups.firstName, userGroups: userWithGroups.userGroups || []
lastName: userWithGroups.lastName, });
role: userWithGroups.role,
passwordChanged: userWithGroups.passwordChanged,
userGroups: userWithGroups.userGroups || []
});
} catch (error) {
console.error("Error fetching user with groups:", error);
res.status(500).json({ message: "Erreur lors de la récupération des données utilisateur" });
}
}); });
// Check if default credentials should be shown // Check if default credentials should be shown
+281
View File
@@ -1,5 +1,283 @@
import { Client } from 'pg'; import { Client } from 'pg';
import { encryptSecret, isEncryptedSecret } from './crypto.js'; import { encryptSecret, isEncryptedSecret } from './crypto.js';
import { pool } from './db.js';
// Index de performance garantis à chaque démarrage.
// Union de migrations/20260814_add_performance_indexes.sql, des index d'init.sql
// et des index composites issus de l'audit base de données (DB-14, DB-15, DB-34,
// DB-35, TASKS-29, SAV-27). Tables et colonnes conformes à shared/schema.ts.
// Volontairement absents : IDX_session_expire (table sessions inutilisée),
// pg_trgm, et l'index unique de user_groups (dédoublonnage préalable requis).
type PerformanceIndex = { name: string; table: string; columns: string; where?: string };
const PERFORMANCE_INDEXES: PerformanceIndex[] = [
// user_groups : lu à chaque requête authentifiée via getUserWithGroups()
{ name: 'idx_user_groups_user_id', table: 'user_groups', columns: 'user_id' },
{ name: 'idx_user_groups_group_id', table: 'user_groups', columns: 'group_id' },
// orders
{ name: 'idx_orders_group_id', table: 'orders', columns: 'group_id' },
{ name: 'idx_orders_supplier_id', table: 'orders', columns: 'supplier_id' },
{ name: 'idx_orders_created_by', table: 'orders', columns: 'created_by' },
{ name: 'idx_orders_planned_date', table: 'orders', columns: 'planned_date' },
{ name: 'idx_orders_group_planned_date', table: 'orders', columns: 'group_id, planned_date' },
{ name: 'idx_orders_created_at', table: 'orders', columns: 'created_at DESC' },
{ name: 'idx_orders_status', table: 'orders', columns: 'status' },
// Comptage des commandes en attente par magasin (statistiques)
{ name: 'idx_orders_group_status', table: 'orders', columns: 'group_id, status' },
// deliveries : order_id est la clé de jointure la plus sollicitée
{ name: 'idx_deliveries_order_id', table: 'deliveries', columns: 'order_id' },
{ name: 'idx_deliveries_group_id', table: 'deliveries', columns: 'group_id' },
{ name: 'idx_deliveries_supplier_id', table: 'deliveries', columns: 'supplier_id' },
{ name: 'idx_deliveries_created_by', table: 'deliveries', columns: 'created_by' },
{ name: 'idx_deliveries_scheduled_date', table: 'deliveries', columns: 'scheduled_date' },
{ name: 'idx_deliveries_group_scheduled_date', table: 'deliveries', columns: 'group_id, scheduled_date' },
{ name: 'idx_deliveries_created_at', table: 'deliveries', columns: 'created_at DESC' },
{ name: 'idx_deliveries_invoice_reference', table: 'deliveries', columns: 'invoice_reference', where: 'invoice_reference IS NOT NULL' },
{ name: 'idx_deliveries_status', table: 'deliveries', columns: 'status' },
{ name: 'idx_deliveries_bl_number', table: 'deliveries', columns: 'bl_number' },
{ name: 'idx_deliveries_due_date', table: 'deliveries', columns: 'due_date' },
// Livraisons livrées par magasin (rapprochement, statistiques)
{ name: 'idx_deliveries_group_status', table: 'deliveries', columns: 'group_id, status' },
{ name: 'idx_deliveries_group_delivered_date', table: 'deliveries', columns: 'group_id, delivered_date' },
// reconciliation_comments
{ name: 'idx_reconciliation_comments_delivery_id', table: 'reconciliation_comments', columns: 'delivery_id' },
{ name: 'idx_reconciliation_comments_group_id', table: 'reconciliation_comments', columns: 'group_id' },
// contacts
{ name: 'idx_contacts_group_id', table: 'contacts', columns: 'group_id' },
// customer_orders
{ name: 'idx_customer_orders_group_id', table: 'customer_orders', columns: 'group_id' },
{ name: 'idx_customer_orders_supplier_id', table: 'customer_orders', columns: 'supplier_id' },
{ name: 'idx_customer_orders_created_at', table: 'customer_orders', columns: 'created_at DESC' },
{ name: 'idx_customer_orders_group_status', table: 'customer_orders', columns: 'group_id, status' },
// Appels clients en attente : même prédicat que getPendingClientCalls()
{ name: 'idx_customer_orders_pending_calls', table: 'customer_orders', columns: 'group_id, created_at DESC', where: 'customer_notified = false' },
// dlc_products
{ name: 'idx_dlc_products_group_id', table: 'dlc_products', columns: 'group_id' },
{ name: 'idx_dlc_products_supplier_id', table: 'dlc_products', columns: 'supplier_id' },
{ name: 'idx_dlc_products_expiry_date', table: 'dlc_products', columns: 'expiry_date' },
{ name: 'idx_dlc_products_status', table: 'dlc_products', columns: 'status' },
{ name: 'idx_dlc_products_group_status', table: 'dlc_products', columns: 'group_id, status' },
{ name: 'idx_dlc_products_group_expiry', table: 'dlc_products', columns: 'group_id, expiry_date' },
// tasks
{ name: 'idx_tasks_group_id', table: 'tasks', columns: 'group_id' },
{ name: 'idx_tasks_created_by', table: 'tasks', columns: 'created_by' },
{ name: 'idx_tasks_due_date', table: 'tasks', columns: 'due_date' },
{ name: 'idx_tasks_group_status', table: 'tasks', columns: 'group_id, status' },
{ name: 'idx_tasks_group_created', table: 'tasks', columns: 'group_id, created_at DESC' },
// avoirs
{ name: 'idx_avoirs_group_id', table: 'avoirs', columns: 'group_id' },
{ name: 'idx_avoirs_supplier_id', table: 'avoirs', columns: 'supplier_id' },
{ name: 'idx_avoirs_created_at', table: 'avoirs', columns: 'created_at DESC' },
{ name: 'idx_avoirs_group_status', table: 'avoirs', columns: 'group_id, status' },
// sav_tickets
{ name: 'idx_sav_tickets_group_id', table: 'sav_tickets', columns: 'group_id' },
{ name: 'idx_sav_tickets_supplier_id', table: 'sav_tickets', columns: 'supplier_id' },
{ name: 'idx_sav_tickets_created_at', table: 'sav_tickets', columns: 'created_at DESC' },
{ name: 'idx_sav_tickets_status', table: 'sav_tickets', columns: 'status' },
{ name: 'idx_sav_tickets_priority', table: 'sav_tickets', columns: 'priority' },
{ name: 'idx_sav_tickets_group_status', table: 'sav_tickets', columns: 'group_id, status' },
{ name: 'idx_sav_tickets_group_created', table: 'sav_tickets', columns: 'group_id, created_at DESC' },
// invoice_verification_cache : expires_at sert la purge périodique
{ name: 'idx_invoice_cache_expires_at', table: 'invoice_verification_cache', columns: 'expires_at' },
{ name: 'idx_invoice_cache_group_id', table: 'invoice_verification_cache', columns: 'group_id' },
// publicity_participations : la clé primaire (publicity_id, group_id) ne couvre pas group_id seul
{ name: 'idx_publicity_participations_group_id', table: 'publicity_participations', columns: 'group_id' },
// publicities
{ name: 'idx_publicities_year', table: 'publicities', columns: 'year' },
{ name: 'idx_publicities_start_date', table: 'publicities', columns: 'start_date' },
// dashboard_messages
{ name: 'idx_dashboard_messages_store_id', table: 'dashboard_messages', columns: 'store_id' },
{ name: 'idx_dashboard_messages_created_at', table: 'dashboard_messages', columns: 'created_at DESC' },
// weather_data
{ name: 'idx_weather_data_date_location', table: 'weather_data', columns: 'date, location' },
{ name: 'idx_weather_data_date_year', table: 'weather_data', columns: 'date, is_current_year' },
// supplier_mail_logs : historique récent par magasin
{ name: 'idx_supplier_mail_logs_delivery', table: 'supplier_mail_logs', columns: 'delivery_id' },
{ name: 'idx_supplier_mail_logs_group', table: 'supplier_mail_logs', columns: 'group_id' },
{ name: 'idx_supplier_mail_logs_group_created', table: 'supplier_mail_logs', columns: 'group_id, created_at DESC' },
];
let performanceIndexesRunning = false;
/**
* Crée les index de performance manquants sans verrouiller les tables en écriture.
*
* CREATE INDEX CONCURRENTLY est interdit dans une transaction, et pg en ouvre une
* implicite pour une chaîne de plusieurs instructions : chaque index part donc
* dans sa propre requête, avec son propre try/catch pour qu'un échec (table
* absente, etc.) n'empêche pas les suivants.
*/
export async function ensurePerformanceIndexes(): Promise<void> {
if (!pool) {
console.warn('⚠️ MIGRATION: No database pool, performance indexes not checked');
return;
}
const startedAt = Date.now();
console.log('🔄 MIGRATION: Checking performance indexes in background...');
// 1. Une création CONCURRENTLY interrompue laisse un index invalide, que
// IF NOT EXISTS considère ensuite comme présent : on purge ces reliquats.
// Un index en cours de construction (par exemple par le processus d'avant un
// redémarrage) est lui aussi invalide : on l'écarte via
// pg_stat_progress_create_index. DROP INDEX CONCURRENTLY ne prend pas de
// verrou exclusif, donc la purge ne bloque jamais les lectures de l'app.
try {
const invalid = await pool.query(`
SELECT c.relname
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE NOT i.indisvalid AND n.nspname = 'public' AND c.relname LIKE 'idx_%'
AND NOT EXISTS (
SELECT 1 FROM pg_stat_progress_create_index p WHERE p.index_relid = i.indexrelid
)
`);
for (const row of invalid.rows) {
const indexName = String(row.relname).replace(/"/g, '""');
try {
await pool.query(`DROP INDEX CONCURRENTLY IF EXISTS public."${indexName}"`);
console.log(`🧹 MIGRATION: Invalid index ${row.relname} dropped`);
} catch (error) {
console.warn(`⚠️ MIGRATION: Invalid index ${row.relname} could not be dropped:`, (error as any)?.message);
}
}
} catch (error) {
console.warn('⚠️ MIGRATION: Invalid indexes purge failed:', (error as any)?.message);
}
// 2. État actuel : tables présentes, index par nom (valides ou non) et colonnes
// déjà indexées sous un autre nom (ex. idx_deliveries_invoice_ref d'init.sql),
// pour ne pas créer de doublon. En cas d'échec, chaque index est tenté.
let existingTables: Set<string> | null = null;
const existingIndexNames = new Set<string>();
const invalidIndexNames = new Set<string>();
const indexedColumns = new Set<string>();
try {
const tables = await pool.query(`
SELECT c.relname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind IN ('r', 'p')
`);
const indexes = await pool.query(`
SELECT t.relname AS table_name,
c.relname AS index_name,
i.indisvalid AS valid,
(i.indpred IS NULL AND i.indexprs IS NULL) AS plain,
array_to_string(ARRAY(
SELECT a.attname
FROM unnest(i.indkey) WITH ORDINALITY AS k(attnum, ord)
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum
WHERE k.ord <= i.indnkeyatts
ORDER BY k.ord
), ',') AS columns
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
JOIN pg_class t ON t.oid = i.indrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = 'public'
`);
existingTables = new Set(tables.rows.map((row: any) => row.relname));
for (const row of indexes.rows) {
if (!row.valid) {
invalidIndexNames.add(row.index_name);
continue;
}
existingIndexNames.add(row.index_name);
if (row.plain) indexedColumns.add(`${row.table_name}(${row.columns})`);
}
} catch (error) {
console.warn('⚠️ MIGRATION: Could not read existing indexes:', (error as any)?.message);
}
// 3. Création des index manquants, une requête par index
let created = 0;
let present = 0;
let skipped = 0;
const failed: string[] = [];
const tablesToAnalyze = new Set<string>();
for (const index of PERFORMANCE_INDEXES) {
// Le sens de tri n'importe pas : un btree se parcourt dans les deux sens
const keyColumns = index.columns
.split(',')
.map((column) => column.trim().split(/\s+/)[0])
.join(',');
if (existingIndexNames.has(index.name) || indexedColumns.has(`${index.table}(${keyColumns})`)) {
present++;
continue;
}
if (existingTables && !existingTables.has(index.table)) {
skipped++;
continue;
}
// Reliquat invalide que la purge n'a pas pu supprimer : IF NOT EXISTS l'ignorerait
if (invalidIndexNames.has(index.name)) {
failed.push(index.name);
console.warn(`⚠️ MIGRATION: Index ${index.name} is invalid and could not be purged`);
continue;
}
try {
await pool.query(
`CREATE INDEX CONCURRENTLY IF NOT EXISTS ${index.name} ON ${index.table} (${index.columns})` +
(index.where ? ` WHERE ${index.where}` : '')
);
created++;
tablesToAnalyze.add(index.table);
console.log(`✅ MIGRATION: Index ${index.name} created`);
} catch (error) {
failed.push(index.name);
console.warn(`⚠️ MIGRATION: Index ${index.name} failed:`, (error as any)?.message);
}
}
// 4. Statistiques à jour pour que le planificateur utilise les nouveaux index
// sans attendre le prochain autovacuum
for (const table of tablesToAnalyze) {
try {
await pool.query(`ANALYZE ${table}`);
} catch (error) {
console.warn(`⚠️ MIGRATION: ANALYZE ${table} failed:`, (error as any)?.message);
}
}
const seconds = ((Date.now() - startedAt) / 1000).toFixed(1);
console.log(
`📊 MIGRATION: Performance indexes: ${created} created, ${present} already present, ` +
`${skipped} skipped (missing table), ${failed.length} failed in ${seconds}s` +
(failed.length > 0 ? ` (${failed.join(', ')})` : '')
);
}
// Lance la vérification des index en tâche de fond : ni le démarrage ni le
// healthcheck n'attendent la fin des créations. La route d'urgence pouvant
// relancer les migrations, un seul passage à la fois.
function startPerformanceIndexes(): void {
if (performanceIndexesRunning) return;
performanceIndexesRunning = true;
ensurePerformanceIndexes()
.catch((error) => console.error('❌ MIGRATION ERROR: Performance indexes check failed:', error))
.finally(() => {
performanceIndexesRunning = false;
});
}
export async function runProductionMigrations() { export async function runProductionMigrations() {
const databaseUrl = process.env.DATABASE_URL; const databaseUrl = process.env.DATABASE_URL;
@@ -145,4 +423,7 @@ export async function runProductionMigrations() {
await client.end(); await client.end();
console.log('🔌 MIGRATION: Database connection closed'); console.log('🔌 MIGRATION: Database connection closed');
} }
// Après les migrations de schéma ci-dessus (colonne priority, supplier_mail_logs)
startPerformanceIndexes();
} }
+242 -560
View File
File diff suppressed because it is too large. Load diff
+328 -266
View File
File diff suppressed because it is too large. Load diff
+15 -1
View File
@@ -76,10 +76,24 @@ export function serveStatic(app: Express) {
); );
} }
app.use(express.static(distPath)); // Fichiers de /assets hachés par Vite : cache navigateur d'un an
app.use("/assets", express.static(path.resolve(distPath, "assets"), {
maxAge: "1y",
immutable: true,
index: false,
}));
// index.html toujours revalidé pour prendre en compte un nouveau déploiement
app.use(express.static(distPath, {
setHeaders: (res, filePath) => {
if (filePath.endsWith(".html")) {
res.setHeader("Cache-Control", "no-cache");
}
},
}));
// fall through to index.html if the file doesn't exist // fall through to index.html if the file doesn't exist
app.use("*", (_req, res) => { app.use("*", (_req, res) => {
res.set("Cache-Control", "no-cache");
res.sendFile(path.resolve(distPath, "index.html")); res.sendFile(path.resolve(distPath, "index.html"));
}); });
} }