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
+4
-1
@@ -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
|
||||||
|
|||||||
Generated
+72
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
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
|
// 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"));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user