mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-22 22:48:15 +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
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user