mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-11 17:27:31 +02:00
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:
13 files changed
+1113
-1116
No files matched your search
+30
-4
@@ -3,7 +3,7 @@ import { promisify } from 'util';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
import { eq, desc, sql } from "drizzle-orm";
|
||||
import { db } from "./db";
|
||||
import { databaseBackups, utilities, users } from "@shared/schema";
|
||||
import type { DatabaseBackup, InsertDatabaseBackup } from "@shared/schema";
|
||||
@@ -14,6 +14,8 @@ export class BackupService {
|
||||
private backupDir: string;
|
||||
private maxBackups = 10;
|
||||
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() {
|
||||
// Use /app/backups in production (with proper permissions), or use env variable
|
||||
@@ -130,9 +132,10 @@ export class BackupService {
|
||||
await execAsync(command, { env });
|
||||
|
||||
// Get file stats and count tables
|
||||
const stats = fs.statSync(filepath);
|
||||
const sqlContent = fs.readFileSync(filepath, 'utf8');
|
||||
const tablesCount = (sqlContent.match(/CREATE TABLE/g) || []).length;
|
||||
// Tables comptées en base plutôt qu'en relisant tout le dump en mémoire :
|
||||
// la lecture synchrone et la regex gelaient le serveur sur les grosses bases
|
||||
const stats = await fs.promises.stat(filepath);
|
||||
const tablesCount = await this.countDatabaseTables();
|
||||
|
||||
// Update database record with completion details
|
||||
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[]> {
|
||||
try {
|
||||
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
|
||||
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 {
|
||||
// Vérifier si les backups automatiques sont activés
|
||||
const [config] = await db.select()
|
||||
|
||||
-119
@@ -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
@@ -1,4 +1,5 @@
|
||||
import express, { type Express, type Request, type Response, type NextFunction } from "express";
|
||||
import compression from "compression";
|
||||
import { createServer, type Server } from "http";
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
@@ -24,6 +25,10 @@ console.log('🐳 Environment:', {
|
||||
|
||||
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.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)
|
||||
// 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');
|
||||
console.log('🐳 Serving static files from:', publicPath);
|
||||
|
||||
app.use('/assets', express.static(join(publicPath, 'assets')));
|
||||
app.use('/', express.static(publicPath));
|
||||
|
||||
// Add explicit root route handler
|
||||
app.get('/', (req, res) => {
|
||||
console.log('🏠 ROOT: Serving index.html for root request');
|
||||
res.sendFile(join(publicPath, 'index.html'), (err) => {
|
||||
if (err) {
|
||||
console.error('❌ ROOT: Error serving index.html:', err);
|
||||
res.status(500).send('Error loading application');
|
||||
// Les fichiers de /assets ont un nom haché par Vite : cache navigateur d'un an
|
||||
app.use('/assets', express.static(join(publicPath, 'assets'), {
|
||||
maxAge: '1y',
|
||||
immutable: true,
|
||||
index: false,
|
||||
}));
|
||||
// Autres fichiers publics (favicon, manifest...). L'index.html est toujours
|
||||
// revalidé pour qu'un nouveau déploiement soit pris en compte immédiatement.
|
||||
app.use(express.static(publicPath, {
|
||||
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) => {
|
||||
if (req.path.startsWith('/api/')) {
|
||||
return next();
|
||||
}
|
||||
console.log(`📄 SPA: Serving index.html for ${req.path}`);
|
||||
res.set('Cache-Control', 'no-cache');
|
||||
res.sendFile(join(publicPath, 'index.html'), (err) => {
|
||||
if (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 server = createServer(app);
|
||||
server.listen(port, "0.0.0.0", () => {
|
||||
|
||||
+10
-2
@@ -1,4 +1,5 @@
|
||||
import express, { type Request, Response, NextFunction } from "express";
|
||||
import compression from "compression";
|
||||
import cookieParser from "cookie-parser";
|
||||
import { registerRoutes } from "./routes.js";
|
||||
import { setupVite, serveStatic } from "./vite.js";
|
||||
@@ -24,6 +25,9 @@ console.log('✅ [STARTUP] Weather system initialized');
|
||||
|
||||
const app = express();
|
||||
|
||||
// Compression gzip des réponses, en premier pour couvrir toutes les réponses
|
||||
app.use(compression());
|
||||
|
||||
// Parse cookies (required for CSRF)
|
||||
app.use(cookieParser());
|
||||
|
||||
@@ -63,11 +67,15 @@ const server = await registerRoutes(app);
|
||||
const { startMaintenanceJobs } = await import('./maintenance.js');
|
||||
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 message = err.message || "Internal Server Error";
|
||||
console.error('Server error:', { status, message, error: err });
|
||||
res.status(status).json({ message });
|
||||
throw err;
|
||||
});
|
||||
|
||||
// Setup Vite in development
|
||||
|
||||
+50
-101
@@ -1,5 +1,10 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { db } from "./db.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
|
||||
* 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> {
|
||||
try {
|
||||
const cacheKey = this.generateCacheKey(invoiceReference, groupId);
|
||||
console.log('🔍 [CACHE] Recherche cache pour:', { invoiceReference, groupId, 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
|
||||
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 {
|
||||
exists: cached.exists,
|
||||
matchType: cached.matchType,
|
||||
@@ -55,7 +44,6 @@ export class InvoiceVerificationService {
|
||||
|
||||
// Cache temporaire non expiré
|
||||
if (cached && new Date() < new Date(cached.expiresAt)) {
|
||||
console.log('✅ [CACHE] Cache temporaire hit pour:', { invoiceReference, groupId, exists: cached.exists });
|
||||
return {
|
||||
exists: cached.exists,
|
||||
matchType: cached.matchType,
|
||||
@@ -69,13 +57,7 @@ export class InvoiceVerificationService {
|
||||
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;
|
||||
} catch (error) {
|
||||
console.error('❌ [CACHE] Erreur lecture cache:', error);
|
||||
@@ -92,32 +74,18 @@ export class InvoiceVerificationService {
|
||||
|
||||
// Durée de cache adaptative selon les cas
|
||||
const expiresAt = new Date();
|
||||
let cacheDescription = '';
|
||||
|
||||
|
||||
if (isReconciled) {
|
||||
// Cache PERMANENT pour factures validées - expire dans 50 ans
|
||||
expiresAt.setFullYear(expiresAt.getFullYear() + 50);
|
||||
cacheDescription = 'PERMANENT (validé)';
|
||||
} else if (result.exists) {
|
||||
// Facture trouvée mais non validée - cache 6h pour permettre corrections
|
||||
expiresAt.setHours(expiresAt.getHours() + 6);
|
||||
cacheDescription = 'temporaire 6h (trouvé)';
|
||||
} else {
|
||||
// Facture non trouvée - cache 12h pour éviter spam
|
||||
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 = {
|
||||
cacheKey,
|
||||
groupId,
|
||||
@@ -135,17 +103,7 @@ export class InvoiceVerificationService {
|
||||
expiresAt
|
||||
};
|
||||
|
||||
const savedCache = await storage.saveInvoiceVerificationCache(cacheData);
|
||||
|
||||
console.log('✅ [CACHE] Résultat sauvé en cache:', {
|
||||
id: savedCache.id,
|
||||
invoiceReference,
|
||||
groupId,
|
||||
exists: result.exists,
|
||||
isReconciled,
|
||||
cacheType: cacheDescription,
|
||||
cacheKey
|
||||
});
|
||||
await storage.saveInvoiceVerificationCache(cacheData);
|
||||
} catch (error) {
|
||||
// Gérer spécifiquement les erreurs de contrainte unique (duplicate key)
|
||||
if (error && typeof error === 'object' && 'code' in error && error.code === '23505') {
|
||||
@@ -211,7 +169,16 @@ export class InvoiceVerificationService {
|
||||
async updateExistingReconciledCaches(): Promise<void> {
|
||||
try {
|
||||
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)
|
||||
const deliveries = await storage.getDeliveries();
|
||||
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é
|
||||
*/
|
||||
@@ -258,8 +255,6 @@ export class InvoiceVerificationService {
|
||||
fromCache?: boolean;
|
||||
}> {
|
||||
try {
|
||||
console.log('🔍 [INVOICE] Début vérification facture:', { invoiceReference, groupId, forceRefresh });
|
||||
|
||||
if (!invoiceReference || !invoiceReference.trim()) {
|
||||
return {
|
||||
exists: false,
|
||||
@@ -270,15 +265,10 @@ export class InvoiceVerificationService {
|
||||
|
||||
// Vérifier le cache d'abord (sauf si refresh forcé)
|
||||
if (!forceRefresh) {
|
||||
console.log('🔍 [INVOICE] Vérification cache...');
|
||||
const cachedResult = await this.checkCache(invoiceReference, groupId);
|
||||
if (cachedResult) {
|
||||
console.log('✅ [INVOICE] Résultat depuis cache:', 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
|
||||
@@ -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
|
||||
if (!group.nocodbConfigId && !group.nocodbTableName && !group.webhookUrl) {
|
||||
console.log('⚠️ Pas de configuration NocoDB pour ce groupe');
|
||||
return {
|
||||
exists: false,
|
||||
matchType: 'none',
|
||||
@@ -353,8 +335,6 @@ export class InvoiceVerificationService {
|
||||
}
|
||||
|
||||
// En production, faire l'appel réel à NocoDB
|
||||
console.log('🔍 Vérification NocoDB en production...');
|
||||
|
||||
try {
|
||||
// Récupérer la configuration NocoDB active
|
||||
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
|
||||
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
|
||||
let matchResult = await this.searchInNocoDB(
|
||||
@@ -621,8 +589,6 @@ export class InvoiceVerificationService {
|
||||
fromCache?: boolean;
|
||||
}> {
|
||||
try {
|
||||
console.log('🔍 Début vérification facture par BL:', { blNumber, supplierName, groupId, forceRefresh });
|
||||
|
||||
if (!blNumber || !blNumber.trim()) {
|
||||
return {
|
||||
exists: false,
|
||||
@@ -639,7 +605,6 @@ export class InvoiceVerificationService {
|
||||
try {
|
||||
const cached = await storage.getInvoiceVerificationCache(cacheKey);
|
||||
if (cached && new Date() < new Date(cached.expiresAt)) {
|
||||
console.log('💾 Cache hit pour BL:', { blNumber, groupId });
|
||||
return {
|
||||
exists: cached.exists,
|
||||
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
|
||||
if (!group.nocodbConfigId && !group.nocodbTableName && !group.webhookUrl) {
|
||||
console.log('⚠️ Pas de configuration NocoDB pour ce groupe');
|
||||
return {
|
||||
exists: false,
|
||||
matchType: 'none',
|
||||
@@ -727,8 +683,6 @@ export class InvoiceVerificationService {
|
||||
}
|
||||
|
||||
// En production, faire l'appel réel à NocoDB
|
||||
console.log('🔍 Vérification BL NocoDB en production...');
|
||||
|
||||
try {
|
||||
// Récupérer la configuration NocoDB active
|
||||
const nocodbConfig = await storage.getActiveNocodbConfig();
|
||||
@@ -743,11 +697,6 @@ export class InvoiceVerificationService {
|
||||
|
||||
// Utiliser l'ID de table configuré dans le groupe
|
||||
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
|
||||
const blColumnName = group.nocodbBlColumnName || 'Numero_BL';
|
||||
|
||||
+35
-37
@@ -5,7 +5,7 @@ import session from "express-session";
|
||||
import { scrypt, randomBytes, timingSafeEqual } from "crypto";
|
||||
import { promisify } from "util";
|
||||
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 { backupService } from "./backupService";
|
||||
|
||||
@@ -141,7 +141,9 @@ export function setupLocalAuth(app: Express) {
|
||||
passport.deserializeUser(async (id: string, done) => {
|
||||
try {
|
||||
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) {
|
||||
done(error);
|
||||
}
|
||||
@@ -157,20 +159,9 @@ export function setupLocalAuth(app: Express) {
|
||||
return res.status(400).json({ message: info?.message || "Invalid credentials" });
|
||||
}
|
||||
|
||||
req.login(user, async (err) => {
|
||||
req.login(user, (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({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
@@ -180,6 +171,21 @@ export function setupLocalAuth(app: Express) {
|
||||
role: user.role,
|
||||
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);
|
||||
});
|
||||
@@ -201,33 +207,25 @@ export function setupLocalAuth(app: Express) {
|
||||
app.get("/api/logout", logoutHandler);
|
||||
|
||||
// Get current user
|
||||
app.get("/api/user", async (req, res) => {
|
||||
app.get("/api/user", (req, res) => {
|
||||
if (!req.isAuthenticated()) {
|
||||
return res.status(401).json({ message: "Non authentifié" });
|
||||
}
|
||||
|
||||
try {
|
||||
const userId = (req.user as SelectUser).id;
|
||||
const userWithGroups = await storage.getUserWithGroups(userId);
|
||||
|
||||
if (!userWithGroups) {
|
||||
return res.status(404).json({ message: "Utilisateur non trouvé" });
|
||||
}
|
||||
|
||||
res.json({
|
||||
id: userWithGroups.id,
|
||||
username: userWithGroups.username,
|
||||
email: userWithGroups.email,
|
||||
firstName: userWithGroups.firstName,
|
||||
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" });
|
||||
}
|
||||
// req.user est déjà rechargé avec ses magasins par deserializeUser à chaque
|
||||
// requête : inutile de relire l'utilisateur en base
|
||||
const userWithGroups = req.user as UserWithGroups;
|
||||
|
||||
res.json({
|
||||
id: userWithGroups.id,
|
||||
username: userWithGroups.username,
|
||||
email: userWithGroups.email,
|
||||
firstName: userWithGroups.firstName,
|
||||
lastName: userWithGroups.lastName,
|
||||
role: userWithGroups.role,
|
||||
passwordChanged: userWithGroups.passwordChanged,
|
||||
userGroups: userWithGroups.userGroups || []
|
||||
});
|
||||
});
|
||||
|
||||
// Check if default credentials should be shown
|
||||
|
||||
@@ -1,5 +1,283 @@
|
||||
import { Client } from 'pg';
|
||||
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() {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
@@ -145,4 +423,7 @@ export async function runProductionMigrations() {
|
||||
await client.end();
|
||||
console.log('🔌 MIGRATION: Database connection closed');
|
||||
}
|
||||
|
||||
// Après les migrations de schéma ci-dessus (colonne priority, supplier_mail_logs)
|
||||
startPerformanceIndexes();
|
||||
}
|
||||
+242
-560
File diff suppressed because it is too large.
Load diff
+328
-266
File diff suppressed because it is too large.
Load diff
+15
-1
@@ -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
|
||||
app.use("*", (_req, res) => {
|
||||
res.set("Cache-Control", "no-cache");
|
||||
res.sendFile(path.resolve(distPath, "index.html"));
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user