feat(rust) : serveur réécrit en Rust, Node retiré ; Calendrier en tête du menu (#571)

- Serveur Rust (server-rs/, axum + sqlx) : les 148 adresses de l'API,
  l'interface, les migrations de démarrage et les tâches de fond ; même
  comportement que Node (banc : 2027 cas, 0 écart hors écarts déclarés
  USERS-02, RAPPRO-21, R4, R5).
- Node retiré : server/, dépendances serveur, image Docker sans Node
  (51 Mo), docker-compose à un seul service applicatif.
- R4 : écritures d'un autre site refusées (Sec-Fetch-Site / Origin).
- R5 : « aujourd'hui » et sauvegarde automatique à l'heure de Paris.
- R6 : mainteneur dans .github/CODEOWNERS.
- Menu latéral : Accueil puis Calendrier en tête.
- Plan et état : docs/PLAN-MIGRATION-RUST.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ke2qyS8rgacP3ap5iezDbM
This commit is contained in:
LogiFlowandClaude Opus 5.5 authored and GitHub committed 2026-10-15 20:31:14 +02:00
1 parent 6760f43e87
commit 600627c632
146 files changed
+31521 -20331

No files matched your search

-350
View File
@@ -1,350 +0,0 @@
import { DashboardMessage, InsertDashboardMessage, DashboardMessageWithRelations, User, Group } from "@shared/schema";
import { db } from "./db";
import { dashboardMessages, users, groups } from "@shared/schema";
import { eq, desc } from "drizzle-orm";
// Type aliases pour compatibilité
type Announcement = DashboardMessage;
type InsertAnnouncement = InsertDashboardMessage;
type AnnouncementWithRelations = DashboardMessageWithRelations;
// Interface commune pour le stockage
interface IAnnouncementStorage {
getAnnouncements(groupIds?: number[]): Promise<AnnouncementWithRelations[]>;
getAnnouncement(id: number): Promise<AnnouncementWithRelations | null | undefined>;
createAnnouncement(announcement: InsertAnnouncement): Promise<DashboardMessage>;
updateAnnouncement(id: number, announcement: Partial<InsertAnnouncement>): Promise<DashboardMessage>;
deleteAnnouncement(id: number): Promise<boolean>;
}
// Stockage PostgreSQL pour la production
class AnnouncementDatabaseStorage implements IAnnouncementStorage {
async getAnnouncements(groupIds?: number[]): Promise<AnnouncementWithRelations[]> {
let query = db
.select({
id: dashboardMessages.id,
title: dashboardMessages.title,
content: dashboardMessages.content,
type: dashboardMessages.type,
storeId: dashboardMessages.storeId,
createdBy: dashboardMessages.createdBy,
createdAt: dashboardMessages.createdAt,
author: {
id: users.id,
firstName: users.firstName,
lastName: users.lastName,
username: users.username,
},
group: {
id: groups.id,
name: groups.name,
},
})
.from(dashboardMessages)
.leftJoin(users, eq(dashboardMessages.createdBy, users.username))
.leftJoin(groups, eq(dashboardMessages.storeId, groups.id))
.orderBy(desc(dashboardMessages.createdAt));
const results = await query;
return results.map((result: any) => ({
id: result.id,
title: result.title,
content: result.content,
type: result.type,
storeId: result.storeId,
createdBy: result.createdBy,
createdAt: result.createdAt,
author: result.author as User,
group: result.group as Group | undefined,
}));
}
async getAnnouncement(id: number): Promise<AnnouncementWithRelations | null | undefined> {
console.log('🔍 [DB] Getting single announcement:', id);
const result = await db
.select({
id: dashboardMessages.id,
title: dashboardMessages.title,
content: dashboardMessages.content,
type: dashboardMessages.type,
storeId: dashboardMessages.storeId,
createdBy: dashboardMessages.createdBy,
createdAt: dashboardMessages.createdAt,
author: {
id: users.id,
firstName: users.firstName,
lastName: users.lastName,
username: users.username,
},
group: {
id: groups.id,
name: groups.name,
},
})
.from(dashboardMessages)
.leftJoin(users, eq(dashboardMessages.createdBy, users.username))
.leftJoin(groups, eq(dashboardMessages.storeId, groups.id))
.where(eq(dashboardMessages.id, id))
.limit(1);
if (result.length === 0) {
console.log('❌ [DB] Announcement not found:', id);
return null;
}
const announcement = result[0];
console.log('✅ [DB] Found announcement:', announcement.id, announcement.title);
return {
id: announcement.id,
title: announcement.title,
content: announcement.content,
type: announcement.type,
storeId: announcement.storeId,
createdBy: announcement.createdBy,
createdAt: announcement.createdAt,
author: announcement.author as User,
group: announcement.group as Group | undefined,
};
}
async createAnnouncement(announcement: InsertAnnouncement): Promise<DashboardMessage> {
const [created] = await db
.insert(dashboardMessages)
.values(announcement)
.returning();
return created;
}
async updateAnnouncement(id: number, announcement: Partial<InsertAnnouncement>): Promise<DashboardMessage> {
console.log('📝 [DB] Updating announcement with data:', announcement);
const [updated] = await db
.update(dashboardMessages)
.set(announcement)
.where(eq(dashboardMessages.id, id))
.returning();
console.log('📝 [DB] Updated announcement:', updated);
if (!updated) {
throw new Error(`Announcement with id ${id} not found`);
}
return updated;
}
async deleteAnnouncement(id: number): Promise<boolean> {
const deleted = await db
.delete(dashboardMessages)
.where(eq(dashboardMessages.id, id))
.returning({ id: dashboardMessages.id });
return deleted.length > 0;
}
}
// Stockage en mémoire pour le développement
class AnnouncementMemoryStorage implements IAnnouncementStorage {
private announcements = new Map<number, Announcement>();
private idCounter = 1;
private readonly MAX_ANNOUNCEMENTS = 5;
// Référence vers les utilisateurs et groupes pour les relations
private usersGetter: () => Promise<User[]>;
private groupsGetter: () => Promise<Group[]>;
constructor(usersGetter: () => Promise<User[]>, groupsGetter: () => Promise<Group[]>) {
this.usersGetter = usersGetter;
this.groupsGetter = groupsGetter;
this.initializeTestData();
}
private initializeTestData() {
// Créer quelques annonces de test avec la nouvelle structure DASHBOARD_MESSAGES
const testAnnouncement: Announcement = {
id: this.idCounter++,
title: "Bienvenue dans le système d'informations",
content: "Ce nouveau système remplace l'ancienne carte des commandes clients. Vous pouvez maintenant créer et gérer des annonces importantes.",
type: 'warning',
createdBy: 'admin',
storeId: 1,
createdAt: new Date(),
};
this.announcements.set(testAnnouncement.id, testAnnouncement);
const testAnnouncement2: Announcement = {
id: this.idCounter++,
title: "Maintenance programmée",
content: "Une maintenance système aura lieu dimanche prochain de 2h à 4h du matin.",
type: 'info',
createdBy: 'admin',
storeId: null, // Annonce globale
createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000), // Hier
};
this.announcements.set(testAnnouncement2.id, testAnnouncement2);
}
async getAnnouncements(groupIds?: number[]): Promise<AnnouncementWithRelations[]> {
const announcements = Array.from(this.announcements.values());
let filteredAnnouncements = announcements;
// Filtrer par groupIds si fourni (pour le filtrage admin par magasin)
if (groupIds && groupIds.length > 0) {
filteredAnnouncements = announcements.filter(announcement =>
!announcement.storeId || groupIds.includes(announcement.storeId)
);
}
// Récupérer les utilisateurs et groupes pour les relations
const [users, groups] = await Promise.all([
this.usersGetter(),
this.groupsGetter()
]);
const usersMap = new Map(users.map(u => [u.id, u]));
const groupsMap = new Map(groups.map(g => [g.id, g]));
// Ajouter les relations
const announcementsWithRelations = filteredAnnouncements.map(announcement => {
const author = usersMap.get(announcement.createdBy);
const group = announcement.storeId ? groupsMap.get(announcement.storeId) : undefined;
return {
...announcement,
author: author!,
group,
};
}).filter(a => a.author); // Filtrer celles sans auteur valide
// Trier par type (error > warning > success > info) puis par date (plus récent d'abord)
return announcementsWithRelations.sort((a, b) => {
const typeOrder = { error: 4, warning: 3, success: 2, info: 1 };
const typeDiff = (typeOrder[b.type as keyof typeof typeOrder] || 1) -
(typeOrder[a.type as keyof typeof typeOrder] || 1);
if (typeDiff !== 0) return typeDiff;
return new Date(b.createdAt!).getTime() - new Date(a.createdAt!).getTime();
});
}
async createAnnouncement(announcementData: InsertAnnouncement): Promise<Announcement> {
// Vérifier la limite de 5 annonces maximum
if (this.announcements.size >= this.MAX_ANNOUNCEMENTS) {
// Supprimer l'annonce la plus ancienne (par ordre de création)
const oldestAnnouncement = Array.from(this.announcements.values())
.sort((a, b) => new Date(a.createdAt!).getTime() - new Date(b.createdAt!).getTime())[0];
if (oldestAnnouncement) {
this.announcements.delete(oldestAnnouncement.id);
console.log(`🗑️ Annonce supprimée automatiquement (limite de ${this.MAX_ANNOUNCEMENTS}): ${oldestAnnouncement.title}`);
}
}
const id = this.idCounter++;
const announcement: Announcement = {
id,
...announcementData,
type: announcementData.type || 'info',
storeId: announcementData.storeId || null,
createdAt: new Date(),
};
this.announcements.set(id, announcement);
console.log(`✅ Nouvelle annonce créée: ${announcement.title} (${this.announcements.size}/${this.MAX_ANNOUNCEMENTS})`);
return announcement;
}
async getAnnouncement(id: number): Promise<AnnouncementWithRelations | undefined> {
const announcement = this.announcements.get(id);
if (!announcement) {
return undefined;
}
// Récupérer les utilisateurs et groupes pour les relations
const [users, groups] = await Promise.all([
this.usersGetter(),
this.groupsGetter()
]);
const usersMap = new Map(users.map(u => [u.id, u]));
const groupsMap = new Map(groups.map(g => [g.id, g]));
const author = usersMap.get(announcement.createdBy);
const group = announcement.storeId ? groupsMap.get(announcement.storeId) : undefined;
if (!author) {
return undefined;
}
return {
...announcement,
author,
group,
};
}
async updateAnnouncement(id: number, announcementData: Partial<InsertAnnouncement>): Promise<AnnouncementWithRelations> {
const existingAnnouncement = this.announcements.get(id);
if (!existingAnnouncement) {
throw new Error(`Announcement with ID ${id} not found`);
}
const updatedAnnouncement: Announcement = {
...existingAnnouncement,
...announcementData,
id, // Ensure ID doesn't change
// Note: DASHBOARD_MESSAGES table n'a pas de champ updatedAt
};
this.announcements.set(id, updatedAnnouncement);
console.log(`✏️ Annonce mise à jour: ${updatedAnnouncement.title} (ID: ${id})`);
// Return the announcement with relations
const announcementWithRelations = await this.getAnnouncement(id);
if (!announcementWithRelations) {
throw new Error(`Failed to get updated announcement with relations`);
}
return announcementWithRelations;
}
async deleteAnnouncement(id: number): Promise<boolean> {
const deleted = this.announcements.delete(id);
if (deleted) {
console.log(`🗑️ Annonce supprimée: ID ${id} (${this.announcements.size}/${this.MAX_ANNOUNCEMENTS} restantes)`);
}
return deleted;
}
}
// Instances globales pour les différents environnements
let memoryInstance: AnnouncementMemoryStorage | null = null;
let databaseInstance: AnnouncementDatabaseStorage | null = null;
export function getAnnouncementStorage(
usersGetter?: () => Promise<User[]>,
groupsGetter?: () => Promise<Group[]>
): IAnnouncementStorage {
const isProduction = process.env.NODE_ENV === 'production';
if (isProduction) {
if (!databaseInstance) {
databaseInstance = new AnnouncementDatabaseStorage();
console.log('📢 Stockage PostgreSQL des annonces initialisé pour la production');
}
return databaseInstance;
} else {
if (!memoryInstance) {
if (!usersGetter || !groupsGetter) {
throw new Error("usersGetter and groupsGetter are required for memory storage initialization");
}
memoryInstance = new AnnouncementMemoryStorage(usersGetter, groupsGetter);
console.log('📢 Stockage mémoire des annonces initialisé pour le développement');
}
return memoryInstance;
}
}
-390
View File
@@ -1,390 +0,0 @@
import { exec } from 'child_process';
import { promisify } from 'util';
import fs from 'fs';
import path from 'path';
import { nanoid } from 'nanoid';
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";
const execAsync = promisify(exec);
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
const isProduction = process.env.NODE_ENV === 'production';
this.backupDir = isProduction
? process.env.BACKUP_DIR || '/app/backups'
: path.join(process.cwd(), 'backups');
this.ensureBackupDirectory();
this.scheduleAutomaticBackup();
}
private ensureBackupDirectory(): void {
try {
if (!fs.existsSync(this.backupDir)) {
fs.mkdirSync(this.backupDir, { recursive: true });
console.log('📁 Backup directory created:', this.backupDir);
}
} catch (error) {
console.error('❌ Failed to create backup directory:', error);
// Fallback to /tmp if current directory fails
if (this.backupDir !== '/tmp/backups') {
this.backupDir = '/tmp/backups';
console.log('🔄 Falling back to /tmp/backups');
try {
if (!fs.existsSync(this.backupDir)) {
fs.mkdirSync(this.backupDir, { recursive: true });
console.log('📁 Fallback backup directory created:', this.backupDir);
}
} catch (fallbackError) {
console.error('❌ Failed to create fallback directory:', fallbackError);
throw fallbackError;
}
} else {
throw error;
}
}
}
private getDatabaseConfig() {
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) {
throw new Error('DATABASE_URL not found in environment variables');
}
// Parse PostgreSQL URL
const url = new URL(dbUrl);
return {
host: url.hostname,
port: url.port || '5432',
database: url.pathname.slice(1), // Remove leading slash
username: url.username,
password: url.password,
};
}
/**
* Resolve a valid user ID for system-initiated backups.
* Returns the first admin user ID, or the first user if no admin exists.
* This avoids FK violations on database_backups.created_by -> users.id.
*/
private async resolveSystemUserId(): Promise<string | null> {
try {
// Try to find an admin user first
const [adminUser] = await db.select({ id: users.id })
.from(users)
.where(eq(users.role, 'admin'))
.limit(1);
if (adminUser) return adminUser.id;
// Fallback: any existing user
const [anyUser] = await db.select({ id: users.id })
.from(users)
.limit(1);
return anyUser?.id ?? null;
} catch (error) {
console.error('❌ Failed to resolve system user ID for backup:', error);
return null;
}
}
async createBackup(type: 'manual' | 'automatic' = 'manual', createdBy: string = 'system'): Promise<DatabaseBackup> {
try {
const id = nanoid();
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `backup_${type}_${timestamp}.sql`;
const filepath = path.join(this.backupDir, filename);
// Create database record first with "creating" status
const [backupRecord] = await db.insert(databaseBackups).values({
id,
filename,
description: `${type === 'manual' ? 'Manuel' : 'Automatique'} backup du ${new Date().toLocaleDateString('fr-FR')}`,
size: 0,
createdBy,
tablesCount: 0,
status: 'creating',
backupType: type,
}).returning();
const dbConfig = this.getDatabaseConfig();
// Set environment variable for password to avoid prompt
const env = {
...process.env,
PGPASSWORD: dbConfig.password,
};
// Create pg_dump command
const command = `pg_dump -h ${dbConfig.host} -p ${dbConfig.port} -U ${dbConfig.username} -d ${dbConfig.database} --no-password --verbose --clean --if-exists --create > "${filepath}"`;
console.log(`🔄 Starting ${type} backup...`);
await execAsync(command, { env });
// Get file stats and count tables
// 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)
.set({
size: stats.size,
tablesCount,
status: 'completed',
})
.where(eq(databaseBackups.id, id))
.returning();
console.log(`✅ ${type} backup created: ${filename} (${this.formatFileSize(stats.size)}, ${tablesCount} tables)`);
// Clean old backups
await this.cleanOldBackups();
return updatedBackup;
} catch (error) {
console.error('❌ Backup failed:', error);
throw new Error(`Backup failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
// 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()
.from(databaseBackups)
.orderBy(desc(databaseBackups.createdAt))
.limit(this.maxBackups);
return backups;
} catch (error) {
console.error('❌ Failed to list backups:', error);
return [];
}
}
async deleteBackup(filename: string): Promise<void> {
try {
// Find backup record
const [backup] = await db.select()
.from(databaseBackups)
.where(eq(databaseBackups.filename, filename))
.limit(1);
if (!backup) {
throw new Error('Backup record not found');
}
// Delete physical file
const filepath = path.join(this.backupDir, filename);
if (fs.existsSync(filepath)) {
fs.unlinkSync(filepath);
}
// Delete database record
await db.delete(databaseBackups)
.where(eq(databaseBackups.filename, filename));
console.log(`🗑️ Backup deleted: ${filename}`);
} catch (error) {
console.error('❌ Failed to delete backup:', error);
throw error;
}
}
async downloadBackup(filename: string): Promise<string> {
const filepath = path.join(this.backupDir, filename);
if (!fs.existsSync(filepath)) {
throw new Error('Backup file not found');
}
return filepath;
}
private async cleanOldBackups(): Promise<void> {
try {
const allBackups = await db.select()
.from(databaseBackups)
.orderBy(desc(databaseBackups.createdAt));
if (allBackups.length > this.maxBackups) {
const toDelete = allBackups.slice(this.maxBackups);
for (const backup of toDelete) {
await this.deleteBackup(backup.filename);
}
console.log(`🧹 Cleaned ${toDelete.length} old backup(s)`);
}
} catch (error) {
console.error('❌ Failed to clean old backups:', error);
}
}
private formatFileSize(bytes: number): string {
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
if (bytes === 0) return '0 Bytes';
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
}
// 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()
.from(utilities)
.limit(1);
// Si les backups automatiques sont désactivés, ne rien faire
if (config && config.automaticBackupsEnabled === false) {
console.log('ℹ️ Sauvegardes automatiques désactivées - Aucune action effectuée');
return {
backupPerformed: false,
message: 'Sauvegardes automatiques désactivées'
};
}
const today = new Date().toISOString().split('T')[0]; // Format YYYY-MM-DD
// Vérifier s'il y a déjà une sauvegarde automatique aujourd'hui
const existingBackupToday = await db.select()
.from(databaseBackups)
.where(eq(databaseBackups.backupType, 'automatic'))
.orderBy(desc(databaseBackups.createdAt))
.limit(1);
const lastBackup = existingBackupToday[0];
const lastBackupDate = lastBackup ? lastBackup.createdAt.toISOString().split('T')[0] : null;
// Only perform backup if no automatic backup exists for today
if (lastBackupDate !== today) {
// Resolve a real user ID if called with default 'system'
let resolvedUserId = userId;
if (userId === 'system') {
const systemId = await this.resolveSystemUserId();
if (!systemId) {
console.warn('⚠️ No user found in database — skipping daily backup');
return { backupPerformed: false, message: 'Aucun utilisateur trouvé pour la sauvegarde' };
}
resolvedUserId = systemId;
}
console.log('🔄 Première connexion du jour - Création de la sauvegarde automatique...');
await this.createBackup('automatic', resolvedUserId);
console.log('✅ Sauvegarde quotidienne effectuée avec succès');
return {
backupPerformed: true,
message: 'Sauvegarde quotidienne effectuée avec succès'
};
} else {
console.log('ℹ️ Sauvegarde quotidienne déjà effectuée aujourd\'hui');
return {
backupPerformed: false,
message: 'Sauvegarde quotidienne déjà effectuée aujourd\'hui'
};
}
} catch (error) {
console.error('❌ Erreur lors de la vérification de sauvegarde quotidienne:', error);
return {
backupPerformed: false,
message: `Erreur: ${error instanceof Error ? error.message : 'Erreur inconnue'}`
};
}
}
private scheduleAutomaticBackup(): void {
// Check for automatic backup every hour
const checkBackupNeeded = async () => {
try {
// Vérifier si les backups automatiques sont activés
const [config] = await db.select()
.from(utilities)
.limit(1);
// Si les backups automatiques sont désactivés, ne rien faire
if (config && config.automaticBackupsEnabled === false) {
return;
}
const now = new Date();
const today = now.toISOString().split('T')[0]; // YYYY-MM-DD format
// Only run automatic backup if:
// 1. It's after 2:00 AM
// 2. We haven't done an automatic backup today yet
if (now.getHours() >= 2 && this.lastAutomaticBackupDate !== today) {
// Check if there's already an automatic backup today in the database
const existingBackupToday = await db.select()
.from(databaseBackups)
.where(eq(databaseBackups.backupType, 'automatic'))
.orderBy(desc(databaseBackups.createdAt))
.limit(1);
const lastBackup = existingBackupToday[0];
const lastBackupDate = lastBackup ? lastBackup.createdAt.toISOString().split('T')[0] : null;
// Only proceed if no automatic backup exists for today
if (lastBackupDate !== today) {
// Resolve a real user ID to satisfy FK constraint
const systemUserId = await this.resolveSystemUserId();
if (!systemUserId) {
console.warn('⚠️ No user found in database — skipping automatic backup');
return;
}
console.log('🔄 Starting automatic backup...');
await this.createBackup('automatic', systemUserId);
this.lastAutomaticBackupDate = today;
console.log('✅ Automatic backup completed');
}
}
} catch (error) {
console.error('❌ Automatic backup check failed:', error);
}
};
// Initial check
checkBackupNeeded();
// Check every hour (3600000 ms)
setInterval(checkBackupNeeded, 3600000);
console.log('⏰ Automatic backup scheduled for daily 2:00 AM+ (native timer)');
}
}
// Singleton instance
export const backupService = new BackupService();
-60
View File
@@ -1,60 +0,0 @@
import { sql } from 'drizzle-orm';
import { db } from './db.js';
export async function ensureWebhookBapConfigTable() {
console.log('🔧 [STARTUP] Vérification table webhook_bap_config...');
try {
// Vérifier si la table existe
const tableExists = await db.execute(sql`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema='public' AND table_name='webhook_bap_config'
);
`);
const exists = tableExists.rows[0]?.exists;
if (exists) {
console.log('✅ [STARTUP] Table webhook_bap_config existe déjà');
return;
}
console.log('🔧 [STARTUP] Création de la table webhook_bap_config...');
// Créer la table
await db.execute(sql`
CREATE TABLE webhook_bap_config (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL DEFAULT 'Configuration BAP',
webhook_url TEXT NOT NULL,
description TEXT,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`);
// Insérer la configuration par défaut
await db.execute(sql`
INSERT INTO webhook_bap_config (name, webhook_url, description, is_active)
VALUES (
'Configuration BAP',
'https://workflow.ffnancy.fr/webhook/a3d03176-b72f-412d-8fb9-f920b9fbab4d',
'Configuration par défaut pour envoi des fichiers BAP vers n8n',
true
);
`);
// Ajouter un commentaire
await db.execute(sql`
COMMENT ON TABLE webhook_bap_config IS 'Configuration pour webhook BAP n8n';
`);
console.log('✅ [STARTUP] Table webhook_bap_config créée avec succès!');
} catch (error) {
console.error('❌ [STARTUP] Erreur lors de la création de la table webhook_bap_config:', error);
// Ne pas arrêter l'application si la création échoue
}
}
-76
View File
@@ -1,76 +0,0 @@
import crypto from 'crypto';
/**
* Chiffrement au repos des secrets applicatifs (mot de passe SMTP des
* magasins, jeton API NocoDB).
*
* - AES-256-GCM (chiffrement authentifié : toute altération en base est détectée)
* - Clé dérivée de ENCRYPTION_KEY, avec repli sur SESSION_SECRET pour que la
* production existante fonctionne sans nouvelle variable d'environnement
* - Format stocké : enc:v1:<iv>:<tag>:<données>, tout en base64
* - Les valeurs héritées en clair sont acceptées en lecture (déchiffrement
* transparent) et re-chiffrées par le balayage de démarrage
*
* ATTENTION : changer ENCRYPTION_KEY/SESSION_SECRET après coup rend les
* secrets déjà chiffrés illisibles — il faudrait alors les ressaisir.
*/
const PREFIX = 'enc:v1:';
function getKey(): Buffer {
const secret = process.env.ENCRYPTION_KEY || process.env.SESSION_SECRET;
if (!secret) {
// Même repli figé que les sessions : mieux vaut un chiffrement à clé
// faible qu'un stockage en clair, et la production définit toujours
// SESSION_SECRET via docker-compose.
return crypto.createHash('sha256').update('logiflow-fallback-secret-key').digest();
}
return crypto.createHash('sha256').update(secret).digest();
}
export function isEncryptedSecret(value: string | null | undefined): boolean {
return typeof value === 'string' && value.startsWith(PREFIX);
}
/**
* Chiffre un secret. Les valeurs vides et les valeurs déjà chiffrées sont
* renvoyées telles quelles (idempotent : pas de double chiffrement possible).
*/
export function encryptSecret(value: string | null | undefined): string | null | undefined {
if (!value || isEncryptedSecret(value)) {
return value;
}
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', getKey(), iv);
const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return `${PREFIX}${iv.toString('base64')}:${tag.toString('base64')}:${encrypted.toString('base64')}`;
}
/**
* Déchiffre un secret. Une valeur non chiffrée (héritage d'avant le
* chiffrement) est renvoyée telle quelle.
*/
export function decryptSecret(value: string | null | undefined): string | null | undefined {
if (!value || !isEncryptedSecret(value)) {
return value;
}
const parts = value.slice(PREFIX.length).split(':');
if (parts.length !== 3) {
throw new Error('Secret chiffré illisible : format inattendu');
}
try {
const [iv, tag, data] = parts.map(p => Buffer.from(p, 'base64'));
const decipher = crypto.createDecipheriv('aes-256-gcm', getKey(), iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
} catch {
throw new Error(
'Secret chiffré illisible : clé de chiffrement changée ou donnée altérée. Ressaisissez la valeur.'
);
}
}
-73
View File
@@ -1,73 +0,0 @@
// Normalisation des dates d'échéance renvoyées par NocoDB (formats variés)
// vers YYYY-MM-DD. Partagée par les routes web et l'API externe.
export function normalizeDateString(dateString: string | null | undefined): string | null {
if (!dateString || typeof dateString !== 'string') return null;
const trimmed = dateString.trim();
if (!trimmed) return null;
try {
// Si déjà au format ISO (YYYY-MM-DD), le retourner tel quel
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
return trimmed;
}
// Format slash ou tiret : DD/MM/YYYY, MM/DD/YYYY, DD-MM-YYYY, MM-DD-YYYY
const slashMatch = trimmed.match(/^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})$/);
if (slashMatch) {
const [, first, second, year] = slashMatch;
const firstNum = parseInt(first);
const secondNum = parseInt(second);
// Validation basique des valeurs
if (firstNum > 31 || secondNum > 31 || firstNum === 0 || secondNum === 0) {
console.warn(`⚠️ Date invalide (valeurs hors limites): ${trimmed}`);
return null;
}
// Déterminer le format en fonction des valeurs
let day: string, month: string;
if (firstNum > 12) {
// first > 12 → forcément DD/MM (format français/européen)
day = first.padStart(2, '0');
month = second.padStart(2, '0');
} else if (secondNum > 12) {
// second > 12 → forcément MM/DD (format américain)
day = second.padStart(2, '0');
month = first.padStart(2, '0');
} else {
// Ambiguïté (les deux < 12) → on assume format français DD/MM par défaut
// Pour être plus sûr, on pourrait vérifier la configuration du groupe/locale
day = first.padStart(2, '0');
month = second.padStart(2, '0');
}
// Validation finale : mois entre 1-12, jour entre 1-31
const monthNum = parseInt(month);
const dayNum = parseInt(day);
if (monthNum < 1 || monthNum > 12 || dayNum < 1 || dayNum > 31) {
console.warn(`⚠️ Date invalide après parsing: ${trimmed} → month=${month}, day=${day}`);
return null;
}
return `${year}-${month}-${day}`;
}
// Essayer de parser avec Date (format ISO complet avec heures)
const date = new Date(trimmed);
if (!isNaN(date.getTime())) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
// Si aucun format reconnu, retourner null (ne pas persister une date invalide)
console.warn(`⚠️ Format de date non reconnu: ${trimmed}`);
return null;
} catch (error) {
console.error('❌ Erreur normalisation date:', error);
return null; // Retourner null en cas d'erreur pour éviter de persister des données invalides
}
}
-57
View File
@@ -1,57 +0,0 @@
import { Pool } from 'pg';
import { drizzle } from 'drizzle-orm/node-postgres';
import * as schema from "@shared/schema";
import { runProductionMigrations } from './migrations.production.js';
console.log('🐳 PRODUCTION: Configuring standard PostgreSQL database');
if (!process.env.DATABASE_URL) {
throw new Error("DATABASE_URL must be set for production");
}
// Log connection details (without password)
const dbUrl = new URL(process.env.DATABASE_URL);
console.log('🐳 Database connection:', {
host: dbUrl.hostname,
port: dbUrl.port,
database: dbUrl.pathname.slice(1),
user: dbUrl.username
});
export const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: false, // No SSL for local Docker
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 10000,
});
export const db = drizzle({ client: pool, schema });
// Test connection
pool.on('connect', () => {
console.log('✅ PostgreSQL connection established');
});
pool.on('error', (err) => {
console.error('❌ PostgreSQL connection error:', err);
});
// Run migrations automatically when database is configured
(async () => {
try {
console.log('🔄 DB: Running automatic database migrations...');
await runProductionMigrations();
console.log('✅ DB: Database migrations completed successfully');
} catch (error) {
console.error('❌ DB: Database migrations failed with error:', error);
console.error('❌ DB: Migration failure details:', {
name: (error as any)?.name,
message: (error as any)?.message,
code: (error as any)?.code
});
// Don't fail the application startup for migration errors
}
})();
console.log('✅ Production PostgreSQL database configured');
-70
View File
@@ -1,70 +0,0 @@
import * as schema from "@shared/schema";
// Simple environment detection
const isProduction = process.env.NODE_ENV === 'production';
const dbUrl = process.env.DATABASE_URL;
console.log('🔗 Database initialization:', {
NODE_ENV: process.env.NODE_ENV,
isProduction,
hasDbUrl: !!dbUrl,
dbHost: dbUrl ? new URL(dbUrl).hostname : 'none'
});
let db: any;
let pool: any;
if (isProduction && dbUrl && (dbUrl.includes('logiflow-db') || dbUrl.includes('localhost') || dbUrl.includes('127.0.0.1'))) {
// Standard PostgreSQL for production Docker
console.log('🐳 PRODUCTION: Using standard PostgreSQL');
const { Pool } = await import('pg');
const { drizzle } = await import('drizzle-orm/node-postgres');
pool = new Pool({
connectionString: dbUrl,
ssl: false,
max: 25, // Increased from 10 to handle concurrent verifications
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 15000, // Increased from 5000 to 15000
});
db = drizzle({ client: pool, schema });
// Une connexion inactive coupée par Postgres (redémarrage ou redéploiement de
// la base, coupure réseau) fait émettre 'error' au pool. Sans écouteur, Node
// arrête tout le serveur ; le pool écarte déjà la connexion fautive et en
// ouvre une neuve à la requête suivante, il suffit donc de journaliser.
pool.on('error', (err: any) => {
console.error('❌ PostgreSQL idle connection error (connexion écartée):', err?.code || '', err?.message);
});
// Test connection
try {
await pool.connect().then((client: any) => {
console.log('✅ PostgreSQL connection test successful');
client.release();
});
} catch (error) {
console.error('❌ PostgreSQL connection test failed:', error);
}
} else {
// Fallback: use Neon for development (but will be overridden by MemStorage)
console.log('🔧 DEV: Using Neon (fallback, will use MemStorage)');
const { Pool, neonConfig } = await import('@neondatabase/serverless');
const { drizzle } = await import('drizzle-orm/neon-serverless');
const ws = await import('ws');
neonConfig.webSocketConstructor = ws.default;
if (dbUrl) {
pool = new Pool({ connectionString: dbUrl });
db = drizzle({ client: pool, schema });
} else {
// Create dummy instances for development
pool = null;
db = null;
console.log('🔧 DEV: No DATABASE_URL, using MemStorage');
}
}
export { db, pool };
-169
View File
@@ -1,169 +0,0 @@
import nodemailer, { type Transporter } from 'nodemailer';
import { decryptSecret } from './crypto';
import {
buildSupplierMailSubject,
buildSupplierMailText,
buildSupplierMailHtml,
type SupplierMailDelivery,
} from '@shared/supplierMail';
/**
* Envoi des mails fournisseurs via la configuration SMTP propre à chaque magasin.
* Chaque magasin renseigne son serveur, ses identifiants et son adresse
* d'expédition sur sa fiche : aucun compte global n'est utilisé.
*/
export interface StoreSmtpConfig {
id?: number;
name?: string | null;
address?: string | null;
phone?: string | null;
logo?: string | null;
smtpEnabled?: boolean | null;
smtpHost?: string | null;
smtpPort?: number | null;
smtpSecure?: boolean | null;
smtpUser?: string | null;
smtpPassword?: string | null;
smtpSenderEmail?: string | null;
smtpSenderName?: string | null;
}
export class SmtpConfigError extends Error {
constructor(message: string) {
super(message);
this.name = 'SmtpConfigError';
}
}
/**
* Vérifie que le magasin dispose d'une configuration exploitable.
* Renvoie la liste des champs manquants plutôt qu'un simple booléen,
* pour pouvoir dire à l'utilisateur ce qu'il reste à renseigner.
*/
export function getMissingSmtpFields(group: StoreSmtpConfig | null | undefined): string[] {
if (!group) return ['magasin'];
const missing: string[] = [];
if (!group.smtpHost?.trim()) missing.push('serveur SMTP');
if (!group.smtpPort) missing.push('port SMTP');
if (!group.smtpSenderEmail?.trim()) missing.push('adresse expéditeur');
return missing;
}
/**
* Construit le transporteur nodemailer d'un magasin.
* L'authentification est optionnelle : certains relais internes acceptent
* les envois sans identifiants.
*/
export function createTransporter(group: StoreSmtpConfig): Transporter {
const missing = getMissingSmtpFields(group);
if (missing.length > 0) {
throw new SmtpConfigError(`Configuration SMTP incomplète : ${missing.join(', ')}`);
}
// Le mot de passe est stocké chiffré (AES-256-GCM) : déchiffré uniquement
// ici, au moment de la connexion au serveur SMTP
const smtpPassword = decryptSecret(group.smtpPassword);
const hasAuth = Boolean(group.smtpUser?.trim() && smtpPassword);
return nodemailer.createTransport({
host: group.smtpHost!.trim(),
port: Number(group.smtpPort),
secure: Boolean(group.smtpSecure),
auth: hasAuth
? { user: group.smtpUser!.trim(), pass: smtpPassword! }
: undefined,
});
}
/** Adresse "De :" du magasin, avec son nom affiché. */
function buildFromAddress(group: StoreSmtpConfig): string {
const senderEmail = group.smtpSenderEmail!.trim();
const senderName = group.smtpSenderName?.trim() || group.name?.trim();
return senderName ? `"${senderName}" <${senderEmail}>` : senderEmail;
}
/**
* Extrait le contenu binaire d'un logo stocké en data URI.
* Renvoie null si le logo est absent ou dans un format inattendu.
*/
function parseLogoDataUri(logo: string | null | undefined) {
if (!logo) return null;
const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/.exec(logo.trim());
if (!match) return null;
const [, mimeType, base64] = match;
const extension = mimeType.split('/')[1]?.split('+')[0] || 'png';
try {
return {
content: Buffer.from(base64, 'base64'),
contentType: mimeType,
filename: `logo.${extension}`,
};
} catch {
return null;
}
}
/**
* Teste la configuration SMTP d'un magasin sans envoyer de message.
*/
export async function verifySmtpConfig(group: StoreSmtpConfig): Promise<void> {
const transporter = createTransporter(group);
try {
await transporter.verify();
} finally {
transporter.close();
}
}
export interface SendSupplierMailResult {
messageId: string;
accepted: string[];
rejected: string[];
}
/**
* Envoie au fournisseur la demande de facture PDF / BL Excel pour une livraison.
*/
export async function sendSupplierDocumentRequest(
group: StoreSmtpConfig,
delivery: SupplierMailDelivery,
supplierEmail: string
): Promise<SendSupplierMailResult> {
const transporter = createTransporter(group);
try {
const store = {
name: group.name,
address: group.address,
phone: group.phone,
email: group.smtpSenderEmail,
};
const logo = parseLogoDataUri(group.logo);
const info = await transporter.sendMail({
from: buildFromAddress(group),
to: supplierEmail.trim(),
replyTo: group.smtpSenderEmail!.trim(),
subject: buildSupplierMailSubject(delivery),
text: buildSupplierMailText(delivery, store),
html: buildSupplierMailHtml(delivery, store, { hasLogo: Boolean(logo) }),
attachments: logo
? [{ ...logo, cid: 'logo', contentDisposition: 'inline' as const }]
: [],
});
return {
messageId: info.messageId,
accepted: (info.accepted || []).map(String),
rejected: (info.rejected || []).map(String),
};
} finally {
transporter.close();
}
}
-415
View File
@@ -1,415 +0,0 @@
// API externe de rapprochement BL / factures.
//
// Permet à un outil tiers (comptabilité, n8n, script...) de lire les magasins,
// les fournisseurs et les livraisons livrées (numéros de BL), puis d'écrire la
// référence, les montants et l'échéance de la facture sur une livraison (y
// compris déjà validée, pour les fournisseurs en rapprochement automatique).
//
// Authentification par clé d'API, indépendante des sessions du webUI :
// - clés créées par un admin dans Paramètres > API externe (server/externalApiKeys.ts) ;
// - et/ou variable d'environnement EXTERNAL_API_KEYS (une ou plusieurs clés
// séparées par des virgules) ;
// - aucune clé active => API désactivée ;
// - clé transmise dans l'en-tête "X-API-Key" ou "Authorization: Bearer <clé>".
//
// Documentation : docs/API-RAPPROCHEMENT.md
import type { Express, Request, Response, NextFunction } from "express";
import { createHash, timingSafeEqual } from "crypto";
import { z } from "zod";
import { storage } from "./storage";
import { normalizeDateString } from "./dateUtils";
import { invoiceVerificationService } from "./invoiceVerification";
import {
countActiveApiKeys,
countEnvApiKeys,
createApiKey,
listApiKeys,
revokeApiKey,
verifyStoredApiKey,
} from "./externalApiKeys";
export const EXTERNAL_API_PREFIX = "/api/ext/v1";
const MAX_LIMIT = 500;
const DEFAULT_LIMIT = 100;
function digest(value: string): Buffer {
return createHash("sha256").update(value).digest();
}
// Clés relues à chaque requête : une rotation via l'environnement ne demande
// qu'un redémarrage, et aucune clé n'est gardée en clair plus que nécessaire.
function configuredKeyDigests(): Buffer[] {
return (process.env.EXTERNAL_API_KEYS || "")
.split(",")
.map((k) => k.trim())
.filter((k) => k.length > 0)
.map(digest);
}
function extractKey(req: Request): string | null {
const header = req.headers["x-api-key"];
if (typeof header === "string" && header.trim()) return header.trim();
const auth = req.headers.authorization;
if (typeof auth === "string" && auth.toLowerCase().startsWith("bearer ")) {
const token = auth.slice(7).trim();
if (token) return token;
}
return null;
}
async function requireApiKey(req: Request, res: Response, next: NextFunction) {
try {
const provided = extractKey(req);
if (provided) {
// Clés de l'environnement : comparaison à temps constant sur les empreintes
const providedDigest = digest(provided);
if (configuredKeyDigests().some((k) => timingSafeEqual(k, providedDigest))) return next();
// Clés créées dans Paramètres (recherche par empreinte)
if (await verifyStoredApiKey(provided)) return next();
}
if (countEnvApiKeys() === 0 && (await countActiveApiKeys()) === 0) {
return res.status(503).json({ error: "API externe désactivée : aucune clé d'API active (Paramètres > API externe)" });
}
console.warn(`🚨 [EXT-API] Clé d'API invalide ou absente : ${req.method} ${req.path} depuis ${req.ip}`);
return res.status(401).json({ error: "Clé d'API invalide ou absente" });
} catch (error) {
console.error("❌ [EXT-API] vérification de la clé:", error);
return res.status(500).json({ error: "Erreur lors de la vérification de la clé d'API" });
}
}
// Montant : nombre, ou chaîne au format "1234.56", "1234,56" ou "1 234,56"
const amountSchema = z
.union([z.number(), z.string()])
.nullable()
.transform((value, ctx) => {
if (value === null) return null;
const raw = typeof value === "number" ? String(value) : value.replace(/[\s ]/g, "").replace(",", ".");
if (raw === "") return null;
const num = Number(raw);
if (!Number.isFinite(num) || Math.abs(num) >= 1e8) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Montant invalide" });
return z.NEVER;
}
return num.toFixed(2); // colonne decimal(10,2)
});
const updateInvoiceSchema = z
.object({
invoiceReference: z.string().trim().max(100).nullable().optional(),
invoiceAmount: amountSchema.optional(),
invoiceAmountTTC: amountSchema.optional(),
dueDate: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "Format attendu : YYYY-MM-DD")
.nullable()
.optional(),
reconciled: z.boolean().optional(),
})
.strict()
.refine((body) => Object.keys(body).length > 0, { message: "Aucun champ à mettre à jour" });
const INVOICE_FIELDS = ["invoiceReference", "invoiceAmount", "invoiceAmountTTC", "dueDate"] as const;
function toNumber(value: unknown): number | null {
if (value === null || value === undefined || value === "") return null;
const num = Number(value);
return Number.isFinite(num) ? num : null;
}
function toIsoDate(value: unknown): string | null {
if (!value) return null;
const date = value instanceof Date ? value : new Date(value as string);
return isNaN(date.getTime()) ? null : date.toISOString().slice(0, 10);
}
function toIsoDateTime(value: unknown): string | null {
if (!value) return null;
const date = value instanceof Date ? value : new Date(value as string);
return isNaN(date.getTime()) ? null : date.toISOString();
}
// Format public d'une livraison : champs utiles au rapprochement uniquement
function toApiDelivery(d: any) {
return {
id: d.id,
storeId: d.groupId,
storeName: d.group?.name ?? null,
supplierId: d.supplierId,
supplierName: d.supplier?.name ?? null,
supplierCode: d.supplier?.codefou ?? null,
automaticReconciliation: !!d.supplier?.automaticReconciliation,
status: d.status,
scheduledDate: toIsoDate(d.scheduledDate),
deliveredDate: toIsoDateTime(d.deliveredDate),
blNumber: d.blNumber ?? null,
blAmount: toNumber(d.blAmount),
invoiceReference: d.invoiceReference ?? null,
invoiceAmount: toNumber(d.invoiceAmount),
invoiceAmountTTC: toNumber(d.invoiceAmountTTC),
dueDate: toIsoDate(d.dueDate),
reconciled: !!d.reconciled,
validatedAt: toIsoDateTime(d.validatedAt),
updatedAt: toIsoDateTime(d.updatedAt),
};
}
function parseIntParam(value: unknown): number | undefined | null {
if (value === undefined || value === "") return undefined;
const num = Number(value);
return Number.isInteger(num) && num > 0 ? num : null; // null = invalide
}
function parseBoolParam(value: unknown): boolean | undefined | null {
if (value === undefined || value === "") return undefined;
if (value === "true") return true;
if (value === "false") return false;
return null;
}
function parseDateParam(value: unknown): string | undefined | null {
if (value === undefined || value === "") return undefined;
return typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : null;
}
// Échéance lue dans NocoDB pour une référence facture (même logique que le webUI)
async function lookupDueDate(invoiceReference: string, groupId: number, reconciled: boolean): Promise<Date | null> {
try {
const result = await invoiceVerificationService.verifyInvoice(invoiceReference, groupId, true, reconciled);
const normalized = result.exists ? normalizeDateString(result.dueDate) : null;
return normalized ? new Date(normalized) : null;
} catch (error) {
console.error("❌ [EXT-API] Erreur récupération échéance NocoDB:", error);
return null;
}
}
export function registerExternalApi(app: Express) {
const base = EXTERNAL_API_PREFIX;
app.use(base, requireApiKey);
// Magasins
app.get(`${base}/stores`, async (_req, res) => {
try {
const groups = await storage.getGroups();
res.json(groups.map((g: any) => ({ id: g.id, name: g.name })));
} catch (error) {
console.error("❌ [EXT-API] stores:", error);
res.status(500).json({ error: "Erreur lors de la lecture des magasins" });
}
});
// Fournisseurs
app.get(`${base}/suppliers`, async (_req, res) => {
try {
const suppliers = await storage.getSuppliers();
res.json(
suppliers.map((s: any) => ({
id: s.id,
name: s.name,
code: s.codefou ?? null,
paymentMethod: s.paymentMethod ?? null,
automaticReconciliation: !!s.automaticReconciliation,
})),
);
} catch (error) {
console.error("❌ [EXT-API] suppliers:", error);
res.status(500).json({ error: "Erreur lors de la lecture des fournisseurs" });
}
});
// Livraisons livrées (périmètre du rapprochement BL / factures)
app.get(`${base}/deliveries`, async (req, res) => {
const storeId = parseIntParam(req.query.storeId);
const supplierId = parseIntParam(req.query.supplierId);
const reconciled = parseBoolParam(req.query.reconciled);
const hasBl = parseBoolParam(req.query.hasBl);
const hasInvoice = parseBoolParam(req.query.hasInvoice);
const from = parseDateParam(req.query.from);
const to = parseDateParam(req.query.to);
const limit = parseIntParam(req.query.limit);
const offsetRaw = req.query.offset;
const offset = offsetRaw === undefined || offsetRaw === "" ? 0 : Number(offsetRaw);
const blNumber = typeof req.query.blNumber === "string" ? req.query.blNumber.trim().toLowerCase() : "";
const invalid = Object.entries({ storeId, supplierId, reconciled, hasBl, hasInvoice, from, to, limit })
.filter(([, v]) => v === null)
.map(([k]) => k);
if (!Number.isInteger(offset) || offset < 0) invalid.push("offset");
if (invalid.length > 0) {
return res.status(400).json({ error: `Paramètre(s) invalide(s) : ${invalid.join(", ")}` });
}
try {
let rows: any[] = await storage.getDeliveries(storeId ? [storeId] : undefined, { status: "delivered" });
rows = rows.filter((d) => {
if (supplierId && d.supplierId !== supplierId) return false;
if (reconciled !== undefined && !!d.reconciled !== reconciled) return false;
if (hasBl !== undefined && !!d.blNumber?.trim() !== hasBl) return false;
if (hasInvoice !== undefined && !!d.invoiceReference?.trim() !== hasInvoice) return false;
if (blNumber && (d.blNumber || "").trim().toLowerCase() !== blNumber) return false;
if (from || to) {
// Date de livraison effective, sinon date prévue
const day = toIsoDate(d.deliveredDate) ?? toIsoDate(d.scheduledDate);
if (!day) return false;
if (from && day < from) return false;
if (to && day > to) return false;
}
return true;
});
rows.sort((a, b) => {
const da = toIsoDateTime(a.deliveredDate) ?? toIsoDate(a.scheduledDate) ?? "";
const db = toIsoDateTime(b.deliveredDate) ?? toIsoDate(b.scheduledDate) ?? "";
return da < db ? 1 : da > db ? -1 : b.id - a.id;
});
const pageSize = Math.min(limit ?? DEFAULT_LIMIT, MAX_LIMIT);
res.json({
total: rows.length,
limit: pageSize,
offset,
items: rows.slice(offset, offset + pageSize).map(toApiDelivery),
});
} catch (error) {
console.error("❌ [EXT-API] deliveries:", error);
res.status(500).json({ error: "Erreur lors de la lecture des livraisons" });
}
});
app.get(`${base}/deliveries/:id`, async (req, res) => {
const id = parseIntParam(req.params.id);
if (!id) return res.status(400).json({ error: "Identifiant invalide" });
try {
const delivery = await storage.getDelivery(id);
if (!delivery) return res.status(404).json({ error: "Livraison introuvable" });
res.json(toApiDelivery(delivery));
} catch (error) {
console.error("❌ [EXT-API] delivery:", error);
res.status(500).json({ error: "Erreur lors de la lecture de la livraison" });
}
});
// Écriture de la facture (référence, montants, échéance) et validation optionnelle
app.patch(`${base}/deliveries/:id`, async (req, res) => {
const id = parseIntParam(req.params.id);
if (!id) return res.status(400).json({ error: "Identifiant invalide" });
const parsed = updateInvoiceSchema.safeParse(req.body ?? {});
if (!parsed.success) {
return res.status(400).json({
error: "Corps de requête invalide",
details: parsed.error.issues.map((i) => ({ field: i.path.join("."), message: i.message })),
});
}
const body = parsed.data;
try {
const delivery = await storage.getDelivery(id);
if (!delivery) return res.status(404).json({ error: "Livraison introuvable" });
if (delivery.status !== "delivered") {
return res.status(409).json({ error: "Seules les livraisons livrées peuvent être rapprochées" });
}
const touchesInvoice = INVOICE_FIELDS.some((f) => body[f] !== undefined);
// Une livraison rapprochée est figée, comme dans le webUI : il faut la
// dévalider (reconciled: false) pour modifier sa facture. Exception : les
// fournisseurs en rapprochement automatique, validés d'office dès la
// saisie du BL, dont la facture arrive après et se complète sans dévalider.
const isAutomatic = !!delivery.supplier?.automaticReconciliation;
if (delivery.reconciled && !isAutomatic && touchesInvoice && body.reconciled !== false) {
return res.status(409).json({
error: "Livraison déjà rapprochée : envoyer reconciled: false pour la dévalider avant de modifier la facture",
});
}
const update: Record<string, any> = {};
if (body.invoiceReference !== undefined) update.invoiceReference = body.invoiceReference || null;
if (body.invoiceAmount !== undefined) update.invoiceAmount = body.invoiceAmount;
if (body.invoiceAmountTTC !== undefined) update.invoiceAmountTTC = body.invoiceAmountTTC;
if (body.dueDate !== undefined) {
update.dueDate = body.dueDate ? new Date(body.dueDate) : null;
} else if (update.invoiceReference !== undefined && update.invoiceReference !== delivery.invoiceReference) {
// Échéance non fournie : reprise depuis NocoDB, ou vidée avec la référence
update.dueDate = update.invoiceReference
? await lookupDueDate(update.invoiceReference, delivery.groupId, !!delivery.reconciled)
: null;
}
if (body.reconciled !== undefined) {
update.reconciled = body.reconciled;
update.validatedAt = body.reconciled ? new Date() : null;
}
await storage.updateDelivery(id, update);
console.log(`🔌 [EXT-API] Livraison #${id} mise à jour : ${Object.keys(update).join(", ")}`);
const updated = await storage.getDelivery(id);
res.json(toApiDelivery(updated));
} catch (error) {
console.error("❌ [EXT-API] update delivery:", error);
res.status(500).json({ error: "Erreur lors de la mise à jour de la livraison" });
}
});
// Toute autre route sous le préfixe : 404 JSON plutôt que la page du webUI
app.use(base, (_req, res) => {
res.status(404).json({ error: "Route inconnue" });
});
}
// Gestion des clés depuis Paramètres > API externe : session webUI, admin
// uniquement. À enregistrer après la mise en place de l'authentification.
export function registerExternalApiKeyAdminRoutes(app: Express, requireSessionAdmin: Array<(req: any, res: any, next: any) => void>) {
const createKeySchema = z.object({ name: z.string().trim().min(1, "Nom requis").max(100) });
app.get("/api/external-api/keys", ...requireSessionAdmin, async (_req, res) => {
try {
res.json({
basePath: EXTERNAL_API_PREFIX,
envKeyCount: countEnvApiKeys(),
keys: await listApiKeys(),
});
} catch (error) {
console.error("❌ [EXT-API] list keys:", error);
res.status(500).json({ error: "Erreur lors de la lecture des clés d'API" });
}
});
app.post("/api/external-api/keys", ...requireSessionAdmin, async (req: any, res) => {
const parsed = createKeySchema.safeParse(req.body ?? {});
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.issues[0]?.message || "Nom invalide" });
}
try {
const created = await createApiKey(parsed.data.name, req.user?.id ?? null);
console.log(`🔑 [EXT-API] Clé « ${parsed.data.name} » créée par ${req.user?.username ?? "?"}`);
res.status(201).json(created);
} catch (error) {
console.error("❌ [EXT-API] create key:", error);
res.status(500).json({ error: "Erreur lors de la création de la clé d'API" });
}
});
app.delete("/api/external-api/keys/:id", ...requireSessionAdmin, async (req: any, res) => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ error: "Identifiant invalide" });
try {
if (!(await revokeApiKey(id))) return res.status(404).json({ error: "Clé introuvable ou déjà révoquée" });
console.log(`🔑 [EXT-API] Clé #${id} révoquée par ${req.user?.username ?? "?"}`);
res.json({ success: true });
} catch (error) {
console.error("❌ [EXT-API] revoke key:", error);
res.status(500).json({ error: "Erreur lors de la révocation de la clé d'API" });
}
});
}
-116
View File
@@ -1,116 +0,0 @@
// Clés de l'API externe de rapprochement créées depuis Paramètres > API externe.
//
// La clé n'est montrée qu'une fois, à sa création : seule son empreinte SHA-256
// est stockée (table external_api_keys), avec un préfixe lisible pour la
// reconnaître dans la liste. Une clé révoquée est conservée pour l'historique.
// Les clés de la variable d'environnement EXTERNAL_API_KEYS restent acceptées.
import { createHash, randomBytes } from "crypto";
import { and, desc, eq, isNull, sql } from "drizzle-orm";
import { db } from "./db";
import { externalApiKeys, type ExternalApiKey } from "@shared/schema";
const KEY_PREFIX = "lf_";
const DISPLAY_PREFIX_LENGTH = 10; // "lf_" + 7 caractères
// last_used_at n'est réécrit qu'au plus une fois par minute et par clé
const LAST_USED_THROTTLE_MS = 60_000;
export type PublicApiKey = Omit<ExternalApiKey, "keyHash">;
// Sans base (développement), les clés sont gardées en mémoire
const hasDatabase = !!process.env.DATABASE_URL;
const memoryKeys: ExternalApiKey[] = [];
let memoryNextId = 1;
export function hashApiKey(key: string): string {
return createHash("sha256").update(key).digest("hex");
}
function toPublic({ keyHash: _keyHash, ...key }: ExternalApiKey): PublicApiKey {
return key;
}
export async function listApiKeys(): Promise<PublicApiKey[]> {
if (!hasDatabase) return [...memoryKeys].reverse().map(toPublic);
const rows: ExternalApiKey[] = await db.select().from(externalApiKeys).orderBy(desc(externalApiKeys.createdAt));
return rows.map(toPublic);
}
// Crée une clé et la renvoie en clair : c'est la seule fois où elle est lisible
export async function createApiKey(name: string, createdBy: string | null): Promise<{ key: string; apiKey: PublicApiKey }> {
const key = `${KEY_PREFIX}${randomBytes(24).toString("base64url")}`;
const values = {
name,
keyPrefix: key.slice(0, DISPLAY_PREFIX_LENGTH),
keyHash: hashApiKey(key),
createdBy,
};
if (!hasDatabase) {
const row: ExternalApiKey = { id: memoryNextId++, ...values, createdAt: new Date(), lastUsedAt: null, revokedAt: null };
memoryKeys.push(row);
return { key, apiKey: toPublic(row) };
}
const [row]: ExternalApiKey[] = await db.insert(externalApiKeys).values(values).returning();
return { key, apiKey: toPublic(row) };
}
// Révoque une clé active ; false si elle n'existe pas ou l'était déjà
export async function revokeApiKey(id: number): Promise<boolean> {
if (!hasDatabase) {
const row = memoryKeys.find((k) => k.id === id && !k.revokedAt);
if (!row) return false;
row.revokedAt = new Date();
return true;
}
const rows = await db
.update(externalApiKeys)
.set({ revokedAt: new Date() })
.where(and(eq(externalApiKeys.id, id), isNull(externalApiKeys.revokedAt)))
.returning({ id: externalApiKeys.id });
return rows.length > 0;
}
export async function countActiveApiKeys(): Promise<number> {
if (!hasDatabase) return memoryKeys.filter((k) => !k.revokedAt).length;
const [row] = await db
.select({ count: sql<number>`count(*)::int` })
.from(externalApiKeys)
.where(isNull(externalApiKeys.revokedAt));
return row?.count ?? 0;
}
// Vérifie une clé reçue ; met à jour sa date de dernière utilisation
export async function verifyStoredApiKey(key: string): Promise<boolean> {
const keyHash = hashApiKey(key);
if (!hasDatabase) {
const row = memoryKeys.find((k) => k.keyHash === keyHash && !k.revokedAt);
if (row) row.lastUsedAt = new Date();
return !!row;
}
const [row]: ExternalApiKey[] = await db
.select()
.from(externalApiKeys)
.where(and(eq(externalApiKeys.keyHash, keyHash), isNull(externalApiKeys.revokedAt)))
.limit(1);
if (!row) return false;
const lastUsed = row.lastUsedAt ? new Date(row.lastUsedAt).getTime() : 0;
if (Date.now() - lastUsed > LAST_USED_THROTTLE_MS) {
// Sans attendre : une erreur d'écriture ne doit pas refuser la requête
db.update(externalApiKeys)
.set({ lastUsedAt: new Date() })
.where(eq(externalApiKeys.id, row.id))
.catch((error: any) => console.error("❌ [EXT-API] last_used_at:", error?.message));
}
return true;
}
// Nombre de clés fournies par la variable d'environnement EXTERNAL_API_KEYS
export function countEnvApiKeys(): number {
return (process.env.EXTERNAL_API_KEYS || "").split(",").filter((k) => k.trim().length > 0).length;
}
-194
View File
@@ -1,194 +0,0 @@
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';
// Database will be configured in storage.js
import { storage } from "./storage.js";
import { setupLocalAuth, requireAuth } from "./localAuth.production.js";
import { runProductionMigrations } from "./migrations.production.js";
// Get directory paths
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
console.log('🐳 PRODUCTION: Starting LogiFlow application');
// Force production mode and PostgreSQL storage when deployed in Docker
process.env.NODE_ENV = 'production';
console.log('🐳 Environment:', {
NODE_ENV: process.env.NODE_ENV,
DATABASE_URL: process.env.DATABASE_URL ? 'Present' : 'Missing',
PORT: process.env.PORT
});
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' }));
app.use((req, res, next) => {
const start = Date.now();
res.on("finish", () => {
const duration = Date.now() - start;
if (req.path.startsWith("/api")) {
console.log(`${req.method} ${req.path} ${res.statusCode} in ${duration}ms`);
}
});
next();
});
// Import all routes for production
import { registerRoutes } from "./routes.js";
// Initialize backup service for automatic backups
import { backupService } from "./backupService.js";
console.log('💾 [PRODUCTION] Backup service initialized for automatic daily backups');
async function registerProductionRoutes(app: Express): Promise<void> {
console.log('🔧 Registering all production routes...');
// Run automatic migrations before registering routes
console.log('🔄 Running automatic production migrations...');
await runProductionMigrations();
// Initialize weather system for production
console.log('🌤️ [PRODUCTION] Initializing weather system...');
const { initializeWeatherConfig } = await import('./weatherAutoConfig.js');
await initializeWeatherConfig();
console.log('✅ [PRODUCTION] Weather system initialized');
// Register ALL API routes (same as development)
await registerRoutes(app);
console.log('✅ All routes registered successfully for production');
// Additional health check endpoint for production monitoring
app.get('/api/health', (req: Request, res: Response) => {
res.status(200).json({
status: 'healthy',
timestamp: new Date().toISOString(),
environment: 'production',
database: 'connected',
port: process.env.PORT || 3000,
publicPath: join(__dirname, 'public')
});
});
// Emergency admin reset endpoint (production only)
// SÉCURITÉ : n'existe que si EMERGENCY_SECRET est défini dans l'environnement.
// L'ancien secret par défaut était codé en dur dans ce fichier, donc lisible
// par quiconque accède au dépôt — n'importe qui pouvait réinitialiser le
// compte admin en production.
app.post('/api/emergency-admin-reset', async (req: Request, res: Response) => {
try {
const emergencySecret = process.env.EMERGENCY_SECRET;
if (!emergencySecret) {
return res.status(404).json({ error: 'Not found' });
}
const { secret } = req.body;
if (!secret || secret !== emergencySecret) {
console.warn('🚨 Tentative de reset admin avec un secret invalide');
return res.status(403).json({ error: 'Invalid emergency secret' });
}
console.log('🚨 EMERGENCY: Admin password reset requested');
// Find and reset admin
const existingAdmin = await storage.getUserByUsername('admin');
if (existingAdmin) {
// Use simple hash for admin reset
const crypto = await import('crypto');
const { promisify } = await import('util');
const scryptAsync = promisify(crypto.scrypt);
const salt = crypto.randomBytes(16).toString("hex");
const buf = (await scryptAsync('admin', salt, 64)) as Buffer;
const newPassword = `${buf.toString("hex")}.${salt}`;
await storage.updateUser(existingAdmin.id, {
password: newPassword,
passwordChanged: false
});
console.log('✅ EMERGENCY: Admin password reset to admin/admin');
return res.json({
success: true,
message: 'Admin password reset to admin/admin',
adminId: existingAdmin.id
});
} else {
return res.status(404).json({ error: 'Admin user not found' });
}
} catch (error) {
console.error('❌ EMERGENCY: Admin reset failed:', error);
return res.status(500).json({ error: 'Reset failed', details: (error as Error).message });
}
});
}
// 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);
// 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 (y compris "/")
app.get('*', (req, res, next) => {
if (req.path.startsWith('/api/')) {
return next();
}
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);
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", () => {
console.log(`🐳 PRODUCTION: LogiFlow serving on port ${port}`);
});
-91
View File
@@ -1,91 +0,0 @@
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";
import {
setupSecurityHeaders,
setupRateLimiting,
setupInputSanitization,
setupCsrfProtection,
setupCsrfTokenEndpoint
} from "./security.js";
// Forcer la création de la table webhook_bap_config au démarrage de l'application
if (process.env.NODE_ENV === 'production') {
const { ensureWebhookBapConfigTable } = await import('./createWebhookTable.js');
await ensureWebhookBapConfigTable();
}
// Initialize weather system
console.log('🌤️ [STARTUP] Initializing weather system...');
const { initializeWeatherConfig } = await import('./weatherAutoConfig.js');
await initializeWeatherConfig();
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());
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: false, limit: '10mb' }));
// Setup security middlewares
console.log('🔐 [STARTUP] Setting up security middlewares...');
setupSecurityHeaders(app);
setupRateLimiting(app);
setupInputSanitization(app);
// CSRF Protection (only in production to avoid dev friction)
if (process.env.NODE_ENV === 'production') {
setupCsrfProtection(app);
console.log('✅ [STARTUP] CSRF protection enabled');
}
// CSRF token endpoint (always available for frontend to fetch token)
setupCsrfTokenEndpoint(app);
console.log('✅ [STARTUP] Security middlewares configured');
app.use((req, res, next) => {
const start = Date.now();
res.on("finish", () => {
const duration = Date.now() - start;
if (req.path.startsWith("/api")) {
console.log(`${req.method} ${req.path} ${res.statusCode} in ${duration}ms`);
}
});
next();
});
const server = await registerRoutes(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 });
});
// Setup Vite in development
if (app.get("env") === "development") {
await setupVite(app, server);
} else {
serveStatic(app);
}
const port = 5000;
server.listen(port, "0.0.0.0", () => {
console.log(`serving on port ${port}`);
});
-923
View File
@@ -1,923 +0,0 @@
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
*/
export class InvoiceVerificationService {
/**
* Générer une clé de cache unique
*/
private generateCacheKey(invoiceReference: string, groupId: number): string {
return `${groupId}_${invoiceReference.trim().toLowerCase()}`;
}
/**
* Vérifier dans le cache d'abord
*/
async checkCache(invoiceReference: string, groupId: number): Promise<any | null> {
try {
const cacheKey = this.generateCacheKey(invoiceReference, groupId);
const cached = await storage.getInvoiceVerificationCache(cacheKey);
// ✅ CACHE PERMANENT : Facture validée avec coche verte = JAMAIS re-vérifier
if (cached && cached.isReconciled && cached.exists) {
return {
exists: cached.exists,
matchType: cached.matchType,
errorMessage: cached.errorMessage,
invoiceReference: cached.invoiceReference,
supplierName: cached.supplierName,
invoiceAmount: cached.invoiceAmount ? parseFloat(cached.invoiceAmount) : undefined,
invoiceAmountTTC: cached.invoiceAmountTTC ? parseFloat(cached.invoiceAmountTTC) : undefined,
dueDate: cached.dueDate ? new Date(cached.dueDate).toISOString() : null,
fromCache: true,
permanent: true
};
}
// Cache temporaire non expiré
if (cached && new Date() < new Date(cached.expiresAt)) {
return {
exists: cached.exists,
matchType: cached.matchType,
errorMessage: cached.errorMessage,
invoiceReference: cached.invoiceReference,
supplierName: cached.supplierName,
invoiceAmount: cached.invoiceAmount ? parseFloat(cached.invoiceAmount) : undefined,
invoiceAmountTTC: cached.invoiceAmountTTC ? parseFloat(cached.invoiceAmountTTC) : undefined,
dueDate: cached.dueDate ? new Date(cached.dueDate).toISOString() : null,
fromCache: true,
permanent: false
};
}
return null;
} catch (error) {
console.error('❌ [CACHE] Erreur lecture cache:', error);
return null;
}
}
/**
* Sauvegarder dans le cache avec durée adaptative
*/
async saveToCache(invoiceReference: string, groupId: number, result: any, supplierName?: string, isReconciled: boolean = false): Promise<void> {
try {
const cacheKey = this.generateCacheKey(invoiceReference, groupId);
// Durée de cache adaptative selon les cas
const expiresAt = new Date();
if (isReconciled) {
// Cache PERMANENT pour factures validées - expire dans 50 ans
expiresAt.setFullYear(expiresAt.getFullYear() + 50);
} else if (result.exists) {
// Facture trouvée mais non validée - cache 6h pour permettre corrections
expiresAt.setHours(expiresAt.getHours() + 6);
} else {
// Facture non trouvée - cache 12h pour éviter spam
expiresAt.setHours(expiresAt.getHours() + 12);
}
const cacheData = {
cacheKey,
groupId,
invoiceReference,
supplierName: supplierName || null,
invoiceAmount: result.invoiceAmount || null,
invoiceAmountTTC: result.invoiceAmountTTC || null,
dueDate: result.dueDate ? new Date(result.dueDate) : null,
exists: result.exists,
matchType: result.matchType,
errorMessage: result.errorMessage || null,
cacheHit: false,
apiCallTime: null,
isReconciled, // Nouveau champ pour cache permanent
expiresAt
};
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') {
console.log('🔄 [CACHE] Cache déjà existant pour cette clé, ignoré:', { invoiceReference, groupId });
return;
}
console.error('❌ [CACHE] Erreur sauvegarde cache:', error);
console.error('❌ [CACHE] Détails erreur:', {
name: error instanceof Error ? error.name : 'Unknown',
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : 'No stack'
});
}
}
/**
* Met à jour le cache pour marquer une facture comme réconciliée (cache permanent)
*/
async updateCacheAsReconciled(invoiceReference: string, groupId: number): Promise<void> {
try {
const cacheKey = this.generateCacheKey(invoiceReference, groupId);
console.log('🔄 [CACHE] Mise à jour cache comme réconcilié:', { invoiceReference, groupId, cacheKey });
// Récupérer le cache existant
const cached = await storage.getInvoiceVerificationCache(cacheKey);
if (!cached) {
console.log('❌ [CACHE] Aucun cache trouvé à mettre à jour');
return;
}
// Si déjà réconcilié, ne rien faire
if (cached.isReconciled) {
console.log('✅ [CACHE] Cache déjà marqué comme réconcilié');
return;
}
// Mettre à jour avec cache permanent (50 ans)
const expiresAt = new Date();
expiresAt.setFullYear(expiresAt.getFullYear() + 50);
const updatedCacheData = {
...cached,
isReconciled: true,
expiresAt
};
await storage.saveInvoiceVerificationCache(updatedCacheData);
console.log('✅ [CACHE] Cache mis à jour comme PERMANENT:', {
invoiceReference,
groupId,
cacheKey,
newExpiresAt: expiresAt.toISOString()
});
} catch (error) {
console.error('❌ [CACHE] Erreur mise à jour cache réconcilié:', error);
}
}
/**
* Met à jour tous les caches des livraisons déjà validées pour les marquer comme permanents
*/
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);
console.log(`📊 [CACHE] ${reconciledDeliveries.length} livraisons validées trouvées`);
let updatedCount = 0;
for (const delivery of reconciledDeliveries) {
try {
// Mettre à jour le cache pour la référence de facture
if (delivery.invoiceReference && delivery.invoiceReference.trim()) {
await this.updateCacheAsReconciled(delivery.invoiceReference, delivery.groupId);
updatedCount++;
}
// Mettre à jour le cache pour le numéro BL
if (delivery.blNumber && delivery.blNumber.trim()) {
await this.updateCacheAsReconciled(delivery.blNumber, delivery.groupId);
updatedCount++;
}
} catch (error) {
console.error(`❌ [CACHE] Erreur mise à jour cache pour livraison ${delivery.id}:`, error);
}
}
console.log(`✅ [CACHE] Mise à jour terminée: ${updatedCount} caches mis à jour comme permanents`);
} catch (error) {
console.error('❌ [CACHE] Erreur lors de la mise à jour en lot:', error);
}
}
/**
* É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é
*/
async verifyInvoice(invoiceReference: string, groupId: number, forceRefresh: boolean = false, isReconciled: boolean = false): Promise<{
exists: boolean;
matchType: 'invoice_reference' | 'bl_number' | 'none';
errorMessage?: string;
invoiceReference?: string;
invoiceAmount?: number;
invoiceAmountTTC?: number;
supplierName?: string;
dueDate?: string | null;
fromCache?: boolean;
}> {
try {
if (!invoiceReference || !invoiceReference.trim()) {
return {
exists: false,
matchType: 'none',
errorMessage: 'Référence de facture vide'
};
}
// Vérifier le cache d'abord (sauf si refresh forcé)
if (!forceRefresh) {
const cachedResult = await this.checkCache(invoiceReference, groupId);
if (cachedResult) {
return cachedResult;
}
}
// Récupérer la configuration du groupe
const group = await storage.getGroup(groupId);
if (!group) {
return {
exists: false,
matchType: 'none',
errorMessage: 'Groupe non trouvé'
};
}
// Si pas de configuration NocoDB, retourner un résultat par défaut
if (!group.nocodbConfigId && !group.nocodbTableName && !group.webhookUrl) {
return {
exists: false,
matchType: 'none',
errorMessage: 'Configuration NocoDB manquante pour ce magasin'
};
}
// Pour le développement, simuler une vérification
if (process.env.NODE_ENV === 'development') {
console.log('🔧 Mode développement - simulation vérification');
// Simuler différents cas selon la référence
if (invoiceReference.toLowerCase().includes('test')) {
const result = {
exists: true,
matchType: 'invoice_reference' as const,
invoiceReference: invoiceReference,
invoiceAmount: 123.45,
supplierName: 'Fournisseur Test'
};
// Sauvegarder en cache
await this.saveToCache(invoiceReference, groupId, result, result.supplierName, isReconciled);
return result;
}
if (invoiceReference.toLowerCase().includes('bl')) {
const result = {
exists: true,
matchType: 'bl_number' as const,
invoiceReference: `FACT_${invoiceReference}`,
invoiceAmount: 67.89,
supplierName: 'Fournisseur BL'
};
// Sauvegarder en cache
await this.saveToCache(invoiceReference, groupId, result, result.supplierName, isReconciled);
return result;
}
const result = {
exists: false,
matchType: 'none' as const,
errorMessage: 'Facture non trouvée (mode développement)'
};
// Sauvegarder même les résultats "non trouvé" pour éviter les rappels répétés
await this.saveToCache(invoiceReference, groupId, result, undefined, isReconciled);
return result;
}
// En production, faire l'appel réel à NocoDB
try {
// Récupérer la configuration NocoDB active
const nocodbConfig = await storage.getActiveNocodbConfig();
if (!nocodbConfig) {
console.log('⚠️ Pas de configuration NocoDB active');
return {
exists: false,
matchType: 'none',
errorMessage: 'Configuration NocoDB non trouvée'
};
}
// Utiliser l'ID de table configuré dans le groupe
const tableId = group.nocodbTableId || 'mrr733dfb8wtt9b'; // Fallback par défaut
// Vérifier d'abord par référence de facture
let matchResult = await this.searchInNocoDB(
invoiceReference,
group.invoiceColumnName || 'RefFacture',
nocodbConfig,
tableId
);
// Gérer les erreurs d'API spécifiques
if (matchResult.error) {
const result = {
exists: false,
matchType: 'none' as const,
errorMessage: matchResult.error
};
// Sauvegarder en cache même les erreurs pour éviter les rappels répétés
await this.saveToCache(invoiceReference, groupId, result, undefined, isReconciled);
return result;
}
if (matchResult.found) {
const result = {
exists: true,
matchType: 'invoice_reference' as const,
invoiceReference: matchResult.data.invoice_reference || invoiceReference,
invoiceAmount: parseFloat(matchResult.data[group.nocodbAmountColumnName || 'amount'] || '0'),
invoiceAmountTTC: parseFloat(matchResult.data[group.nocodbInvoiceAmountTTCColumnName || 'Montant TTC'] || '0'),
supplierName: matchResult.data[group.nocodbSupplierColumnName || 'supplier'] || 'Inconnu',
dueDate: matchResult.data[group.nocodbDueDateColumnName || 'Echeance'] || null
};
// Sauvegarder en cache si succès
await this.saveToCache(invoiceReference, groupId, result, result.supplierName, isReconciled);
return result;
}
// Si pas trouvé par référence de facture, chercher par numéro de BL
if (group.nocodbBlColumnName) {
matchResult = await this.searchInNocoDB(
invoiceReference,
group.nocodbBlColumnName || 'Numero_BL',
nocodbConfig,
tableId
);
// Gérer les erreurs d'API pour la recherche BL
if (matchResult.error) {
const result = {
exists: false,
matchType: 'none' as const,
errorMessage: matchResult.error
};
// Sauvegarder en cache même les erreurs
await this.saveToCache(invoiceReference, groupId, result, undefined, isReconciled);
return result;
}
if (matchResult.found) {
const result = {
exists: true,
matchType: 'bl_number' as const,
invoiceReference: matchResult.data.invoice_reference || `BL_${invoiceReference}`,
invoiceAmount: parseFloat(matchResult.data[group.nocodbAmountColumnName || 'amount'] || '0'),
invoiceAmountTTC: parseFloat(matchResult.data[group.nocodbInvoiceAmountTTCColumnName || 'Montant TTC'] || '0'),
supplierName: matchResult.data[group.nocodbSupplierColumnName || 'supplier'] || 'Inconnu',
dueDate: matchResult.data[group.nocodbDueDateColumnName || 'Echeance'] || null
};
// Sauvegarder en cache si succès
await this.saveToCache(invoiceReference, groupId, result, result.supplierName, isReconciled);
return result;
}
}
// Aucune correspondance trouvée
const notFoundResult = {
exists: false,
matchType: 'none' as const,
errorMessage: 'Aucune correspondance trouvée dans NocoDB'
};
// Sauvegarder même les résultats "non trouvé" pour éviter les rappels répétés
await this.saveToCache(invoiceReference, groupId, notFoundResult, undefined, isReconciled);
return notFoundResult;
} catch (error) {
const errorMessage = `Erreur NocoDB: ${error instanceof Error ? error.message : 'Erreur inconnue'}`;
// Log condensé en production
if (process.env.NODE_ENV === 'production') {
console.error('❌ Erreur vérification NocoDB:', error instanceof Error ? error.message : 'Erreur inconnue');
} else {
console.error('❌ Erreur lors de la vérification NocoDB:', error);
}
const errorResult = {
exists: false,
matchType: 'none' as const,
errorMessage
};
// Sauvegarder les erreurs en cache pour éviter les rappels répétés
await this.saveToCache(invoiceReference, groupId, errorResult, undefined, isReconciled);
return errorResult;
}
} catch (error) {
console.error('❌ Erreur vérification facture:', error);
return {
exists: false,
matchType: 'none',
errorMessage: error instanceof Error ? error.message : 'Erreur inconnue'
};
}
}
/**
* Recherche dans NocoDB avec protection contre les erreurs
*/
private async searchInNocoDB(
searchValue: string,
columnName: string,
config: any,
tableId: string
): Promise<{ found: boolean; data?: any; error?: string }> {
try {
// Protection contre les appels avec des paramètres invalides
if (!searchValue || !columnName || !config || !tableId) {
return { found: false, error: 'Paramètres invalides' };
}
// Utiliser directement l'ID de table fourni
const searchUrl = `${config.baseUrl}/api/v1/db/data/v1/${config.projectId}/${tableId}`;
// Log condensé en production pour éviter le spam
if (process.env.NODE_ENV === 'production') {
console.log('🔍 Recherche NocoDB:', { tableId, column: columnName });
} else {
console.log('🔍 Recherche NocoDB:', {
url: searchUrl,
tableId: tableId,
column: columnName,
value: searchValue
});
}
// Créer un AbortController pour le timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 secondes timeout
try {
const response = await fetch(`${searchUrl}?where=(${columnName},eq,${encodeURIComponent(searchValue)})`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'xc-token': config.apiToken
},
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorMsg = `HTTP ${response.status}: ${response.statusText}`;
console.error('❌ Erreur réponse NocoDB:', errorMsg);
return { found: false, error: errorMsg };
}
const result = await response.json();
// Log condensé en production
if (process.env.NODE_ENV !== 'production') {
console.log('✅ Réponse NocoDB:', result);
}
if (result.list && result.list.length > 0) {
return {
found: true,
data: result.list[0]
};
}
return { found: false };
} catch (fetchError: any) {
clearTimeout(timeoutId);
// Gestion spécifique des erreurs réseau
if (fetchError.name === 'AbortError') {
const timeoutMsg = 'Timeout - API NocoDB non accessible';
console.error('⏱️ Timeout NocoDB:', timeoutMsg);
return { found: false, error: timeoutMsg };
}
if (fetchError.code === 'ERR_CERT_VERIFIER_CHANGED' || fetchError.message?.includes('certificate')) {
const certMsg = 'Erreur certificat SSL - Vérifiez la configuration NocoDB';
console.error('🔒 Erreur certificat NocoDB:', certMsg);
return { found: false, error: certMsg };
}
if (fetchError.code === 'ENOTFOUND' || fetchError.code === 'ECONNREFUSED') {
const connMsg = 'NocoDB non accessible - Vérifiez la connectivité réseau';
console.error('🌐 Erreur connexion NocoDB:', connMsg);
return { found: false, error: connMsg };
}
throw fetchError; // Relancer les autres erreurs
}
} catch (error: any) {
// Log d'erreur condensé en production
const errorMsg = error?.message || 'Erreur inconnue';
if (process.env.NODE_ENV === 'production') {
console.error('❌ Erreur NocoDB:', errorMsg);
} else {
console.error('❌ Erreur appel NocoDB:', error);
}
return { found: false, error: errorMsg };
}
}
/**
* Vérifie une facture par numéro de BL et nom de fournisseur
*/
async verifyInvoiceByBL(blNumber: string, supplierName: string, groupId: number, forceRefresh: boolean = false, isReconciled: boolean = false): Promise<{
exists: boolean;
matchType: 'invoice_reference' | 'bl_number' | 'none';
errorMessage?: string;
invoiceReference?: string;
invoiceAmount?: number;
invoiceAmountTTC?: number;
supplierName?: string;
dueDate?: string | null;
fromCache?: boolean;
}> {
try {
if (!blNumber || !blNumber.trim()) {
return {
exists: false,
matchType: 'none',
errorMessage: 'Numéro BL vide'
};
}
// Générer une clé de cache pour le BL
const cacheKey = `bl_${groupId}_${blNumber.trim().toLowerCase()}_${supplierName.toLowerCase()}`;
// Vérifier le cache d'abord (sauf si refresh forcé)
if (!forceRefresh) {
try {
const cached = await storage.getInvoiceVerificationCache(cacheKey);
if (cached && new Date() < new Date(cached.expiresAt)) {
return {
exists: cached.exists,
matchType: cached.matchType as 'invoice_reference' | 'bl_number' | 'none',
errorMessage: cached.errorMessage || undefined,
invoiceReference: cached.invoiceReference || undefined,
supplierName: cached.supplierName || undefined,
fromCache: true
};
}
} catch (error) {
console.error('❌ Erreur lecture cache BL:', error);
}
}
// Récupérer la configuration du groupe
const group = await storage.getGroup(groupId);
if (!group) {
return {
exists: false,
matchType: 'none',
errorMessage: 'Groupe non trouvé'
};
}
// Si pas de configuration NocoDB, retourner un résultat par défaut
if (!group.nocodbConfigId && !group.nocodbTableName && !group.webhookUrl) {
return {
exists: false,
matchType: 'none',
errorMessage: 'Configuration NocoDB manquante pour ce magasin'
};
}
// Pour le développement, simuler une vérification
if (process.env.NODE_ENV === 'development') {
console.log('🔧 Mode développement - simulation vérification BL');
if (blNumber && blNumber.trim()) {
const result = {
exists: true,
matchType: 'bl_number' as const,
invoiceReference: `FACT_${blNumber}`,
invoiceAmount: 156.78,
supplierName: supplierName
};
// Sauvegarder en cache
try {
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 24);
await storage.saveInvoiceVerificationCache({
cacheKey,
groupId,
invoiceReference: result.invoiceReference,
supplierName: supplierName,
exists: result.exists,
matchType: result.matchType,
errorMessage: null,
cacheHit: false,
apiCallTime: null,
expiresAt
});
} catch (error) {
console.error('❌ Erreur sauvegarde cache BL:', error);
}
return result;
}
return {
exists: false,
matchType: 'none',
errorMessage: 'BL non trouvé (mode développement)'
};
}
// En production, faire l'appel réel à NocoDB
try {
// Récupérer la configuration NocoDB active
const nocodbConfig = await storage.getActiveNocodbConfig();
if (!nocodbConfig) {
console.log('⚠️ Pas de configuration NocoDB active');
return {
exists: false,
matchType: 'none',
errorMessage: 'Configuration NocoDB non trouvée'
};
}
// Utiliser l'ID de table configuré dans le groupe
const tableId = group.nocodbTableId || 'mrr733dfb8wtt9b';
// Rechercher par numéro de BL
const blColumnName = group.nocodbBlColumnName || 'Numero_BL';
let matchResult = await this.searchInNocoDB(
blNumber,
blColumnName,
nocodbConfig,
tableId
);
// Gérer les erreurs d'API spécifiques
if (matchResult.error) {
const result = {
exists: false,
matchType: 'none' as const,
errorMessage: matchResult.error
};
// Sauvegarder en cache même les erreurs
try {
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 24);
await storage.saveInvoiceVerificationCache({
cacheKey,
groupId,
invoiceReference: blNumber,
supplierName: supplierName,
exists: false,
matchType: 'none',
errorMessage: result.errorMessage,
cacheHit: false,
apiCallTime: null,
isReconciled,
expiresAt
});
} catch (error) {
console.error('❌ Erreur sauvegarde cache erreur BL:', error);
}
return result;
}
if (matchResult.found) {
// Vérifier que le fournisseur correspond
const foundSupplier = matchResult.data[group.nocodbSupplierColumnName || 'supplier'] || '';
const supplierMatch = foundSupplier.toLowerCase().includes(supplierName.toLowerCase()) ||
supplierName.toLowerCase().includes(foundSupplier.toLowerCase());
if (!supplierMatch) {
console.log('⚠️ Fournisseur ne correspond pas:', {
expected: supplierName,
found: foundSupplier
});
return {
exists: false,
matchType: 'none',
errorMessage: `BL trouvé mais fournisseur différent: ${foundSupplier} vs ${supplierName}`
};
}
const result = {
exists: true,
matchType: 'bl_number' as const,
invoiceReference: matchResult.data[group.invoiceColumnName || 'RefFacture'] || `BL_${blNumber}`,
invoiceAmount: parseFloat(matchResult.data[group.nocodbAmountColumnName || 'amount'] || '0'),
invoiceAmountTTC: parseFloat(matchResult.data[group.nocodbInvoiceAmountTTCColumnName || 'Montant TTC'] || '0'),
supplierName: foundSupplier || supplierName,
dueDate: matchResult.data[group.nocodbDueDateColumnName || 'Echeance'] || null
};
// Sauvegarder en cache si succès
try {
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 24);
await storage.saveInvoiceVerificationCache({
cacheKey,
groupId,
invoiceReference: result.invoiceReference,
supplierName: result.supplierName,
exists: true,
matchType: 'bl_number',
errorMessage: null,
cacheHit: false,
apiCallTime: null,
isReconciled,
expiresAt
});
} catch (error) {
console.error('❌ Erreur sauvegarde cache succès BL:', error);
}
return result;
}
// Aucune correspondance trouvée
const notFoundResult = {
exists: false,
matchType: 'none' as const,
errorMessage: 'Aucun BL correspondant trouvé dans NocoDB'
};
// Sauvegarder même les résultats "non trouvé"
try {
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 24);
await storage.saveInvoiceVerificationCache({
cacheKey,
groupId,
invoiceReference: blNumber,
supplierName: supplierName,
exists: false,
matchType: 'none',
errorMessage: notFoundResult.errorMessage,
cacheHit: false,
apiCallTime: null,
isReconciled,
expiresAt
});
} catch (error) {
console.error('❌ Erreur sauvegarde cache not found BL:', error);
}
return notFoundResult;
} catch (error) {
const errorMessage = `Erreur NocoDB BL: ${error instanceof Error ? error.message : 'Erreur inconnue'}`;
console.error('❌ Erreur lors de la vérification BL NocoDB:', error);
const errorResult = {
exists: false,
matchType: 'none' as const,
errorMessage
};
// Sauvegarder les erreurs en cache
try {
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 24);
await storage.saveInvoiceVerificationCache({
cacheKey,
groupId,
invoiceReference: blNumber,
supplierName: supplierName,
exists: false,
matchType: 'none',
errorMessage: errorResult.errorMessage,
cacheHit: false,
apiCallTime: null,
isReconciled,
expiresAt
});
} catch (error) {
console.error('❌ Erreur sauvegarde cache erreur BL:', error);
}
return errorResult;
}
} catch (error) {
console.error('❌ Erreur vérification facture par BL:', error);
return {
exists: false,
matchType: 'none',
errorMessage: error instanceof Error ? error.message : 'Erreur inconnue'
};
}
}
/**
* Recherche par numéro de BL (méthode simplifiée pour compatibilité)
*/
async searchByBLNumber(blNumber: string, groupId: number): Promise<any> {
console.log('🔍 Recherche par BL (legacy):', { blNumber, groupId });
const result = await this.verifyInvoiceByBL(blNumber, '', groupId, false);
if (result.exists) {
return {
found: true,
data: {
invoiceReference: result.invoiceReference,
amount: result.invoiceAmount,
supplier: result.supplierName
}
};
} else {
return {
found: false,
error: result.errorMessage || 'BL non trouvé'
};
}
}
/**
* Validation d'une référence de facture
*/
validateInvoiceReference(reference: string): boolean {
if (!reference || typeof reference !== 'string') {
return false;
}
const trimmed = reference.trim();
return trimmed.length > 0 && trimmed.length <= 100;
}
/**
* Nettoyage d'une référence de facture
*/
cleanInvoiceReference(reference: string): string {
if (!reference || typeof reference !== 'string') {
return '';
}
return reference.trim().toUpperCase();
}
}
// Instance unique du service
export const invoiceVerificationService = new InvoiceVerificationService();
-285
View File
@@ -1,285 +0,0 @@
import passport from "passport";
import { Strategy as LocalStrategy } from "passport-local";
import type { Express } from "express";
import session from "express-session";
import { storage } from "./storage.js";
import connectPg from "connect-pg-simple";
import { scrypt, randomBytes, timingSafeEqual } from "crypto";
import { promisify } from "util";
import { backupService } from "./backupService.js";
const scryptAsync = promisify(scrypt);
console.log('🐳 PRODUCTION: Local auth configured with PostgreSQL sessions');
async function hashPassword(password: string) {
const salt = randomBytes(16).toString("hex");
const buf = (await scryptAsync(password, salt, 64)) as Buffer;
return `${buf.toString("hex")}.${salt}`;
}
async function comparePasswords(supplied: string, stored: string) {
console.log('🔐 Production password comparison', { suppliedLength: supplied.length, storedFormat: stored?.substring(0, 10) + '...' });
// Try different password formats for backward compatibility
// 1. Try new format: hash.salt
if (stored && stored.includes('.')) {
const [hashed, salt] = stored.split(".");
if (hashed && salt) {
try {
const hashedBuf = Buffer.from(hashed, "hex");
const suppliedBuf = (await scryptAsync(supplied, salt, 64)) as Buffer;
const result = timingSafeEqual(hashedBuf, suppliedBuf);
if (result) {
console.log('✅ Password matched with hash.salt format');
return true;
}
} catch (error) {
console.log('⚠️ hash.salt format failed, trying other formats');
}
}
}
// 2. Try bcrypt format (common in existing systems)
if (stored && stored.startsWith('$2')) {
try {
const bcrypt = await import('bcrypt');
const result = await bcrypt.compare(supplied, stored);
if (result) {
console.log('✅ Password matched with bcrypt format');
return true;
}
} catch (error) {
console.log('⚠️ bcrypt comparison failed');
}
}
// 3. Try plain text (for development/migration)
if (stored === supplied) {
console.log('✅ Password matched with plain text format');
return true;
}
// 4. Try simple hash format (legacy)
try {
const crypto = await import('crypto');
const hash = crypto.createHash('sha256').update(supplied).digest('hex');
if (stored === hash) {
console.log('✅ Password matched with SHA256 format');
return true;
}
} catch (error) {
console.log('⚠️ SHA256 comparison failed');
}
console.log('❌ No password format matched');
return false;
}
async function createDefaultAdminUser() {
try {
// Check for force reset flag
const forceReset = process.env.FORCE_ADMIN_RESET === 'true';
if (forceReset) {
console.log('🔄 FORCE_ADMIN_RESET detected, deleting existing admin user...');
const existingAdmin = await storage.getUserByUsername('admin');
if (existingAdmin) {
await storage.deleteUser(existingAdmin.id);
console.log('✅ Existing admin user deleted');
}
}
const existingAdmin = await storage.getUserByUsername('admin');
if (!existingAdmin) {
const hashedPassword = await hashPassword('admin');
await storage.createUser({
id: 'admin_prod',
username: 'admin',
email: 'admin@logiflow.com',
firstName: 'Administrateur',
lastName: 'Production',
password: hashedPassword,
role: 'admin',
passwordChanged: false,
});
console.log('✅ Production admin user created: admin/admin');
} else {
console.log('✅ Production admin user found:', {
id: existingAdmin.id,
username: existingAdmin.username,
passwordFormat: existingAdmin.password ? 'present' : 'missing'
});
// Ne plus forcer la réinitialisation du mot de passe admin
// L'admin peut maintenant changer son mot de passe et il sera conservé
console.log('✅ Admin user exists, preserving current password');
// Seulement réinitialiser si explicitement demandé via FORCE_ADMIN_RESET
if (process.env.FORCE_ADMIN_RESET === 'true') {
console.log('🔄 FORCE_ADMIN_RESET env var set, resetting admin password...');
try {
const newHashedPassword = await hashPassword('admin');
await storage.updateUser(existingAdmin.id, {
password: newHashedPassword,
passwordChanged: false
});
console.log('✅ Admin password manually reset to: admin/admin');
} catch (error) {
console.error('❌ Failed to reset admin password:', (error as Error).message);
}
}
}
} catch (error) {
console.error('Error managing admin user:', error);
}
}
export function setupLocalAuth(app: Express) {
// Create admin user on startup
createDefaultAdminUser();
const PostgresSessionStore = connectPg(session);
const sessionStore = new PostgresSessionStore({
conString: process.env.DATABASE_URL,
createTableIfMissing: true,
tableName: 'session',
});
const sessionSettings: session.SessionOptions = {
secret: process.env.SESSION_SECRET || 'production-fallback-secret-key',
resave: false,
saveUninitialized: false,
store: sessionStore,
cookie: {
httpOnly: true,
secure: false, // Set to true with HTTPS proxy
maxAge: 24 * 60 * 60 * 1000, // 24 hours
},
};
app.set("trust proxy", 1);
app.use(session(sessionSettings));
app.use(passport.initialize());
app.use(passport.session());
passport.use(
new LocalStrategy(
{
usernameField: 'username',
passwordField: 'password',
},
async (username, password, done) => {
try {
const user = await storage.getUserByUsername(username);
if (!user || !user.password) {
return done(null, false, { message: 'Invalid credentials' });
}
const isValidPassword = await comparePasswords(password, user.password);
if (!isValidPassword) {
return done(null, false, { message: 'Invalid credentials' });
}
return done(null, user);
} catch (error) {
return done(error);
}
}
)
);
passport.serializeUser((user, done) => done(null, user.id));
passport.deserializeUser(async (id: string, done) => {
try {
const user = await storage.getUserWithGroups(id);
done(null, user);
} catch (error) {
done(error);
}
});
// Login route
app.post("/api/login", (req, res, next) => {
passport.authenticate("local", (err: any, user: any, info: any) => {
if (err) return next(err);
if (!user) {
return res.status(400).json({ message: info?.message || "Invalid credentials" });
}
req.login(user, async (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,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
passwordChanged: user.passwordChanged
});
});
})(req, res, next);
});
// Logout route
app.post("/api/logout", (req: any, res: any, next: any) => {
req.logout((err: any) => {
if (err) return next(err);
res.json({ message: "Logout successful" });
});
});
// Get current user
app.get("/api/user", (req: any, res) => {
if (req.isAuthenticated && req.isAuthenticated()) {
console.log("🔍 PRODUCTION /api/user - req.user:", {
id: req.user.id,
role: req.user.role,
hasUserGroups: !!req.user.userGroups,
userGroupsLength: req.user.userGroups?.length,
userGroups: req.user.userGroups?.map((ug: any) => ({
groupId: ug.groupId || ug.group?.id,
groupName: ug.group?.name
}))
});
res.json({
id: req.user.id,
username: req.user.username,
email: req.user.email,
firstName: req.user.firstName,
lastName: req.user.lastName,
role: req.user.role,
passwordChanged: req.user.passwordChanged,
userGroups: req.user.userGroups || [] // ✅ AJOUT DES USER GROUPS !
});
} else {
res.status(401).json({ message: "Not authenticated" });
}
});
// Check default credentials endpoint
app.get("/api/default-credentials-check", (req, res) => {
res.json({ hasDefaultCredentials: true });
});
}
export function requireAuth(req: any, res: any, next: any) {
if (req.isAuthenticated && req.isAuthenticated()) {
return next();
}
res.status(401).json({ message: "Authentication required" });
}
-253
View File
@@ -1,253 +0,0 @@
import passport from "passport";
import { Strategy as LocalStrategy } from "passport-local";
import { Express } from "express";
import session from "express-session";
import { scrypt, randomBytes, timingSafeEqual } from "crypto";
import { promisify } from "util";
import { storage } from "./storage";
import { User as SelectUser, type UserWithGroups } from "@shared/schema";
import connectPg from "connect-pg-simple";
import { backupService } from "./backupService";
declare global {
namespace Express {
interface User extends SelectUser {}
}
}
const scryptAsync = promisify(scrypt);
export async function hashPassword(password: string) {
const salt = randomBytes(16).toString("hex");
const buf = (await scryptAsync(password, salt, 64)) as Buffer;
return `${buf.toString("hex")}.${salt}`;
}
async function comparePasswords(supplied: string, stored: string) {
console.log('🔐 comparePasswords:', { supplied: 'HIDDEN', stored: stored?.substring(0, 20) + '...', hasFormat: stored?.includes('.') });
// Simple password comparison for development (admin.salt format)
if (stored === 'admin.salt' && supplied === 'admin') {
console.log('🔐 Development auth: admin password accepted');
return true;
}
// Vérifier le format du mot de passe stocké pour production
if (!stored || !stored.includes('.')) {
console.error('❌ Invalid password format:', { stored });
return false;
}
const [hashed, salt] = stored.split(".");
if (!hashed || !salt) {
console.error('❌ Missing hash or salt:', { hasHash: !!hashed, hasSalt: !!salt });
return false;
}
try {
const hashedBuf = Buffer.from(hashed, "hex");
const suppliedBuf = (await scryptAsync(supplied, salt, 64)) as Buffer;
const result = timingSafeEqual(hashedBuf, suppliedBuf);
console.log('🔐 Password comparison result:', result);
return result;
} catch (error) {
console.error('❌ Error comparing passwords:', error);
return false;
}
}
async function createDefaultAdminUser() {
try {
const existingAdmin = await storage.getUserByUsername('admin');
if (!existingAdmin) {
const hashedPassword = await hashPassword('admin');
await storage.createUser({
id: 'admin_local',
username: 'admin',
email: 'admin@logiflow.com',
firstName: 'Administrateur',
lastName: 'Système',
password: hashedPassword,
role: 'admin',
passwordChanged: false,
});
console.log('✅ Admin user created: admin / admin');
} else {
// Corriger le mot de passe admin si le format est incorrect
if (!existingAdmin.password || !existingAdmin.password.includes('.')) {
console.log('🔧 Fixing admin password format...');
const hashedPassword = await hashPassword('admin');
await storage.updateUser(existingAdmin.id, {
password: hashedPassword,
passwordChanged: false
});
console.log('✅ Admin password format fixed');
}
}
} catch (error) {
console.error('Error managing admin user:', error);
}
}
export function setupLocalAuth(app: Express) {
// Create admin user on startup
createDefaultAdminUser();
console.log('🔧 Using memory session store for development');
const sessionSettings: session.SessionOptions = {
secret: process.env.SESSION_SECRET || 'fallback-secret-key',
resave: false,
saveUninitialized: false,
// Using default memory store (no database needed)
cookie: {
httpOnly: true,
secure: false, // Keep false for development
maxAge: 24 * 60 * 60 * 1000, // 24 hours
},
};
app.use(session(sessionSettings));
app.use(passport.initialize());
app.use(passport.session());
passport.use(
new LocalStrategy(
{
usernameField: 'username',
passwordField: 'password',
},
async (username, password, done) => {
try {
const user = await storage.getUserByUsername(username);
if (!user || !user.password) {
return done(null, false, { message: 'Identifiant ou mot de passe incorrect' });
}
const isValidPassword = await comparePasswords(password, user.password);
if (!isValidPassword) {
return done(null, false, { message: 'Identifiant ou mot de passe incorrect' });
}
return done(null, user);
} catch (error) {
return done(error);
}
}
)
);
passport.serializeUser((user, done) => done(null, user.id));
passport.deserializeUser(async (id: string, done) => {
try {
const user = await storage.getUserWithGroups(id);
// 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);
}
});
// Login route
app.post("/api/login", (req, res, next) => {
passport.authenticate("local", (err: any, user: any, info: any) => {
if (err) return next(err);
if (!user) {
return res.status(400).json({ message: info?.message || "Invalid credentials" });
}
req.login(user, (err) => {
if (err) return next(err);
res.json({
id: user.id,
username: user.username,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
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);
});
// Logout routes (both GET and POST for compatibility)
const logoutHandler = (req: any, res: any, next: any) => {
req.logout((err: any) => {
if (err) return next(err);
req.session.destroy((err: any) => {
if (err) return next(err);
res.clearCookie('connect.sid', { path: '/' });
// Force redirect to login page for both GET and POST
res.redirect('/auth');
});
});
};
app.post("/api/logout", logoutHandler);
app.get("/api/logout", logoutHandler);
// Get current user
app.get("/api/user", (req, res) => {
if (!req.isAuthenticated()) {
return res.status(401).json({ message: "Non authentifié" });
}
// 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
app.get("/api/default-credentials-check", async (req, res) => {
try {
const adminUser = await storage.getUserByUsername('admin');
const showDefault = adminUser && !adminUser.passwordChanged;
res.json({ showDefault: !!showDefault });
} catch (error) {
res.json({ showDefault: true }); // Default to showing credentials if error
}
});
// Health check endpoint
app.get("/api/health", (req, res) => {
res.status(200).json({ status: "ok", auth: "local" });
});
}
export const requireAuth = (req: any, res: any, next: any) => {
if (!req.isAuthenticated()) {
return res.status(401).json({ message: "Authentification requise" });
}
next();
};
-27
View File
@@ -1,27 +0,0 @@
import { storage } from "./storage.js";
// Purge périodique du cache de vérification des factures.
//
// La table invoice_verification_cache porte une colonne expires_at et
// storage.clearExpiredCache() sait la purger, mais rien ne l'appelait : les
// entrées expirées s'accumulaient indéfiniment et alourdissaient chaque lecture
// du cache. On planifie donc la purge ici.
const PURGE_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 heures
const STARTUP_DELAY_MS = 60 * 1000; // laisse le démarrage se terminer d'abord
async function purgeExpiredInvoiceCache(): Promise<void> {
try {
await storage.clearExpiredCache();
console.log("🧹 [MAINTENANCE] Cache de vérification des factures purgé");
} catch (error) {
// Une purge ratée ne doit jamais interrompre le service.
console.error("❌ [MAINTENANCE] Échec de la purge du cache factures:", error);
}
}
export function startMaintenanceJobs(): void {
setTimeout(purgeExpiredInvoiceCache, STARTUP_DELAY_MS);
setInterval(purgeExpiredInvoiceCache, PURGE_INTERVAL_MS);
console.log("✅ [MAINTENANCE] Purge du cache factures planifiée (toutes les 6h)");
}
-445
View File
@@ -1,445 +0,0 @@
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;
if (!databaseUrl) {
console.error('❌ MIGRATION: DATABASE_URL not found for migrations');
return;
}
console.log('🔄 MIGRATION: Starting SAV production migrations...');
console.log('🔄 MIGRATION: Database URL configured:', databaseUrl.substring(0, 30) + '...');
const client = new Client({
connectionString: databaseUrl,
ssl: false, // No SSL for local Docker
});
try {
await client.connect();
console.log('✅ MIGRATION: Connected to PostgreSQL database');
console.log('🔄 MIGRATION: Checking if priority column exists...');
// Vérifier si la colonne priority existe
const checkPriorityColumn = await client.query(`
SELECT 1 FROM information_schema.columns
WHERE table_name='sav_tickets' AND column_name='priority'
`);
console.log('🔄 MIGRATION: Priority column check result:', checkPriorityColumn.rows.length);
if (checkPriorityColumn.rows.length === 0) {
console.log('📝 MIGRATION: Priority column missing, adding it now...');
// Ajouter seulement la colonne priority manquante
await client.query(`
ALTER TABLE sav_tickets ADD COLUMN priority varchar(50) NOT NULL DEFAULT 'normale';
`);
console.log('✅ MIGRATION: Priority column added successfully!');
// Créer les index pour optimiser les performances
await client.query(`
CREATE INDEX IF NOT EXISTS idx_sav_tickets_priority ON sav_tickets(priority);
`);
console.log('✅ MIGRATION: Priority index created successfully!');
// Vérification finale
const verificationResult = await client.query(`
SELECT
COUNT(*) as total_records,
COUNT(CASE WHEN priority IS NOT NULL THEN 1 END) as records_with_priority
FROM sav_tickets;
`);
console.log('✅ MIGRATION: SAV migration completed successfully!');
console.log('📊 MIGRATION: Verification result:', verificationResult.rows[0]);
} else {
console.log('✅ MIGRATION: Priority column already exists, skipping migration');
}
// Coordonnées magasin + configuration SMTP par magasin (mails fournisseurs)
console.log('🔄 MIGRATION: Ensuring store contact and SMTP columns on groups...');
await client.query(`
ALTER TABLE groups ADD COLUMN IF NOT EXISTS address TEXT;
ALTER TABLE groups ADD COLUMN IF NOT EXISTS phone VARCHAR(50);
ALTER TABLE groups ADD COLUMN IF NOT EXISTS logo TEXT;
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_enabled BOOLEAN DEFAULT false;
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_host VARCHAR(255);
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_port INTEGER;
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_secure BOOLEAN DEFAULT false;
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_user VARCHAR(255);
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_password VARCHAR(255);
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_sender_email VARCHAR(255);
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_sender_name VARCHAR(255);
`);
console.log('✅ MIGRATION: Store contact and SMTP columns are present on groups');
// Historique des mails de relance fournisseurs
console.log('🔄 MIGRATION: Ensuring supplier_mail_logs table...');
await client.query(`
CREATE TABLE IF NOT EXISTS supplier_mail_logs (
id SERIAL PRIMARY KEY,
delivery_id INTEGER NOT NULL,
group_id INTEGER NOT NULL,
supplier_id INTEGER,
supplier_name VARCHAR(255),
sent_to VARCHAR(255) NOT NULL,
subject TEXT,
status VARCHAR(20) NOT NULL,
error_message TEXT,
message_id VARCHAR(255),
sent_by VARCHAR NOT NULL,
sent_by_name VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_delivery ON supplier_mail_logs(delivery_id);
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_group ON supplier_mail_logs(group_id);
`);
console.log('✅ MIGRATION: supplier_mail_logs table is present');
// Clés de l'API externe de rapprochement, gérées depuis Paramètres
console.log('🔄 MIGRATION: Ensuring external_api_keys table...');
await client.query(`
CREATE TABLE IF NOT EXISTS external_api_keys (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
key_prefix VARCHAR(16) NOT NULL,
key_hash VARCHAR(64) NOT NULL UNIQUE,
created_by VARCHAR,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_used_at TIMESTAMP,
revoked_at TIMESTAMP
);
`);
console.log('✅ MIGRATION: external_api_keys table is present');
// Chiffrement au repos des secrets encore stockés en clair
// (mots de passe SMTP des magasins, jetons API NocoDB). Idempotent :
// les valeurs déjà au format enc:v1: sont ignorées.
console.log('🔄 MIGRATION: Encrypting plaintext secrets at rest...');
let encryptedCount = 0;
const plainSmtp = await client.query(`
SELECT id, smtp_password FROM groups
WHERE smtp_password IS NOT NULL AND smtp_password != '' AND smtp_password NOT LIKE 'enc:v1:%'
`);
for (const row of plainSmtp.rows) {
await client.query('UPDATE groups SET smtp_password = $1 WHERE id = $2', [
encryptSecret(row.smtp_password),
row.id,
]);
encryptedCount++;
}
const plainTokens = await client.query(`
SELECT id, api_token FROM nocodb_config
WHERE api_token IS NOT NULL AND api_token != '' AND api_token NOT LIKE 'enc:v1:%'
`);
for (const row of plainTokens.rows) {
if (isEncryptedSecret(row.api_token)) continue;
await client.query('UPDATE nocodb_config SET api_token = $1 WHERE id = $2', [
encryptSecret(row.api_token),
row.id,
]);
encryptedCount++;
}
console.log(`✅ MIGRATION: Secrets encryption done (${encryptedCount} value(s) encrypted this run)`);
} catch (error) {
console.error('❌ MIGRATION ERROR: Failed to run SAV production migrations:', error);
console.error('❌ MIGRATION ERROR: Error details:', {
message: (error as any)?.message,
code: (error as any)?.code,
stack: (error as any)?.stack
});
// Ne pas faire échouer le démarrage du serveur pour les erreurs de migration
} finally {
await client.end();
console.log('🔌 MIGRATION: Database connection closed');
}
// Après les migrations de schéma ci-dessus (colonne priority, supplier_mail_logs)
startPerformanceIndexes();
}
-201
View File
@@ -1,201 +0,0 @@
import { sql } from 'drizzle-orm';
import { db } from './db.js';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Table pour suivre les migrations appliquées
const createMigrationsTable = async () => {
await db.execute(sql`
CREATE TABLE IF NOT EXISTS migrations (
id SERIAL PRIMARY KEY,
filename VARCHAR(255) UNIQUE NOT NULL,
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
};
interface MigrationFile {
filename: string;
sql: string;
}
export async function runMigrations() {
console.log('🔄 Vérification des migrations...');
try {
// Créer la table des migrations si elle n'existe pas
await createMigrationsTable();
// Récupérer les migrations déjà appliquées
const appliedMigrations = await db.execute(sql`SELECT filename FROM migrations`);
const appliedFilenames = new Set(appliedMigrations.rows.map((row: any) => row.filename));
// Ajouter les migrations hardcodées directement dans le code pour éviter les problèmes de chemins
const hardcodedMigrations = [
{
filename: '20260402000002_add_notified_fields_to_customer_orders.sql',
content: `
ALTER TABLE customer_orders ADD COLUMN IF NOT EXISTS notified_at TIMESTAMP;
ALTER TABLE customer_orders ADD COLUMN IF NOT EXISTS notified_comment TEXT;
`
},
{
filename: '20260402000000_add_codefou_to_suppliers.sql',
content: `ALTER TABLE suppliers ADD COLUMN IF NOT EXISTS codefou VARCHAR;`
},
{
filename: '20250903141000_create_webhook_bap_config.sql',
content: `CREATE TABLE IF NOT EXISTS webhook_bap_config (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL DEFAULT 'Configuration BAP',
webhook_url TEXT NOT NULL,
description TEXT,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO webhook_bap_config (name, webhook_url, description, is_active)
SELECT
'Configuration BAP',
'https://workflow.ffnancy.fr/webhook/a3d03176-b72f-412d-8fb9-f920b9fbab4d',
'Configuration par défaut pour envoi des fichiers BAP vers n8n',
true
WHERE NOT EXISTS (SELECT 1 FROM webhook_bap_config);`
},
{
filename: '20260814000000_add_store_contact_and_smtp_to_groups.sql',
content: `
-- Coordonnées du magasin (signature des mails fournisseurs)
ALTER TABLE groups ADD COLUMN IF NOT EXISTS address TEXT;
ALTER TABLE groups ADD COLUMN IF NOT EXISTS phone VARCHAR(50);
ALTER TABLE groups ADD COLUMN IF NOT EXISTS logo TEXT;
-- Configuration SMTP propre à chaque magasin
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_enabled BOOLEAN DEFAULT false;
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_host VARCHAR(255);
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_port INTEGER;
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_secure BOOLEAN DEFAULT false;
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_user VARCHAR(255);
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_password VARCHAR(255);
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_sender_email VARCHAR(255);
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_sender_name VARCHAR(255);
`
},
{
filename: '20260814000001_create_supplier_mail_logs.sql',
content: `
CREATE TABLE IF NOT EXISTS supplier_mail_logs (
id SERIAL PRIMARY KEY,
delivery_id INTEGER NOT NULL,
group_id INTEGER NOT NULL,
supplier_id INTEGER,
supplier_name VARCHAR(255),
sent_to VARCHAR(255) NOT NULL,
subject TEXT,
status VARCHAR(20) NOT NULL,
error_message TEXT,
message_id VARCHAR(255),
sent_by VARCHAR NOT NULL,
sent_by_name VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_delivery ON supplier_mail_logs(delivery_id);
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_group ON supplier_mail_logs(group_id);
`
},
{
filename: '20261010000000_create_external_api_keys.sql',
content: `
CREATE TABLE IF NOT EXISTS external_api_keys (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
key_prefix VARCHAR(16) NOT NULL,
key_hash VARCHAR(64) NOT NULL UNIQUE,
created_by VARCHAR,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_used_at TIMESTAMP,
revoked_at TIMESTAMP
);
`
}
];
// Ignorer les fichiers de migrations pour éviter les erreurs SQL
// Utiliser uniquement les migrations hardcodées
const allMigrations = hardcodedMigrations;
if (allMigrations.length === 0) {
console.log('✅ Aucune migration à exécuter');
return;
}
let executedCount = 0;
for (const migration of allMigrations) {
if (appliedFilenames.has(migration.filename)) {
console.log(`⏭️ Migration déjà appliquée: ${migration.filename}`);
continue;
}
console.log(`🔄 Exécution migration: ${migration.filename}`);
try {
// Exécuter la migration dans une transaction
await db.transaction(async (tx: any) => {
// Exécuter le SQL de migration
await tx.execute(sql.raw(migration.content));
// Marquer comme appliquée
await tx.execute(sql`
INSERT INTO migrations (filename)
VALUES (${migration.filename})
`);
});
console.log(`✅ Migration appliquée avec succès: ${migration.filename}`);
executedCount++;
} catch (error) {
console.error(`❌ Erreur lors de la migration ${migration.filename}:`, error);
throw error;
}
}
if (executedCount > 0) {
console.log(`✅ ${executedCount} migration(s) appliquée(s) avec succès`);
} else {
console.log('✅ Toutes les migrations sont déjà appliquées');
}
} catch (error) {
console.error('❌ Erreur lors de l\'exécution des migrations:', error);
throw error;
}
}
// Fonction pour créer une nouvelle migration
export function createMigration(name: string): string {
const timestamp = new Date().toISOString().replace(/[^\d]/g, '').slice(0, 14);
const filename = `${timestamp}_${name.toLowerCase().replace(/\s+/g, '_')}.sql`;
const migrationsDir = path.join(__dirname, '..', 'migrations');
if (!fs.existsSync(migrationsDir)) {
fs.mkdirSync(migrationsDir, { recursive: true });
}
const filePath = path.join(migrationsDir, filename);
const template = `-- Migration: ${name}
-- Created: ${new Date().toISOString()}
-- Add your SQL here
`;
fs.writeFileSync(filePath, template);
console.log(`📝 Migration créée: ${filePath}`);
return filePath;
}
-151
View File
@@ -1,151 +0,0 @@
import { Request, Response, NextFunction } from 'express';
// Interface pour les métriques
interface Metrics {
requestCount: number;
errorCount: number;
responseTime: number[];
memoryUsage: NodeJS.MemoryUsage;
activeConnections: number;
lastReset: Date;
}
class PerformanceMonitor {
private metrics: Metrics = {
requestCount: 0,
errorCount: 0,
responseTime: [],
memoryUsage: process.memoryUsage(),
activeConnections: 0,
lastReset: new Date()
};
private slowQueries: Array<{ query: string; duration: number; timestamp: Date }> = [];
private MAX_RESPONSE_TIMES = 1000; // Garder les 1000 derniers temps de réponse
// Middleware pour mesurer les performances
middleware() {
return (req: Request, res: Response, next: NextFunction) => {
const startTime = Date.now();
this.metrics.requestCount++;
this.metrics.activeConnections++;
// Capturer le temps de réponse
res.on('finish', () => {
const duration = Date.now() - startTime;
this.metrics.responseTime.push(duration);
// Garder seulement les N derniers temps de réponse
if (this.metrics.responseTime.length > this.MAX_RESPONSE_TIMES) {
this.metrics.responseTime.shift();
}
// Détecter les requêtes lentes
if (duration > 1000) {
this.logSlowQuery(req.originalUrl, duration);
}
// Compter les erreurs
if (res.statusCode >= 400) {
this.metrics.errorCount++;
}
this.metrics.activeConnections--;
this.metrics.memoryUsage = process.memoryUsage();
});
next();
};
}
// Enregistrer une requête lente
private logSlowQuery(query: string, duration: number) {
this.slowQueries.push({
query,
duration,
timestamp: new Date()
});
// Garder seulement les 100 dernières requêtes lentes
if (this.slowQueries.length > 100) {
this.slowQueries.shift();
}
console.warn(`⚠️ Slow query detected: ${query} (${duration}ms)`);
}
// Obtenir les statistiques
getStats() {
const responseTimes = this.metrics.responseTime;
const avgResponseTime = responseTimes.length > 0
? responseTimes.reduce((a, b) => a + b, 0) / responseTimes.length
: 0;
const p95ResponseTime = responseTimes.length > 0
? responseTimes.sort((a, b) => a - b)[Math.floor(responseTimes.length * 0.95)]
: 0;
return {
uptime: process.uptime(),
requestCount: this.metrics.requestCount,
errorCount: this.metrics.errorCount,
errorRate: this.metrics.requestCount > 0 ? (this.metrics.errorCount / this.metrics.requestCount) * 100 : 0,
avgResponseTime: Math.round(avgResponseTime),
p95ResponseTime: Math.round(p95ResponseTime),
activeConnections: this.metrics.activeConnections,
memoryUsage: {
rss: `${Math.round(this.metrics.memoryUsage.rss / 1024 / 1024)}MB`,
heapTotal: `${Math.round(this.metrics.memoryUsage.heapTotal / 1024 / 1024)}MB`,
heapUsed: `${Math.round(this.metrics.memoryUsage.heapUsed / 1024 / 1024)}MB`,
external: `${Math.round(this.metrics.memoryUsage.external / 1024 / 1024)}MB`
},
slowQueries: this.slowQueries.slice(-10), // 10 dernières requêtes lentes
lastReset: this.metrics.lastReset
};
}
// Réinitialiser les métriques
reset() {
this.metrics = {
requestCount: 0,
errorCount: 0,
responseTime: [],
memoryUsage: process.memoryUsage(),
activeConnections: 0,
lastReset: new Date()
};
this.slowQueries = [];
}
// Alerte si l'utilisation mémoire est trop élevée
checkMemoryUsage() {
const memUsage = process.memoryUsage();
const heapUsedMB = memUsage.heapUsed / 1024 / 1024;
if (heapUsedMB > 500) { // Plus de 500MB
console.warn(`⚠️ High memory usage: ${Math.round(heapUsedMB)}MB`);
return true;
}
return false;
}
}
// Instance globale
export const monitor = new PerformanceMonitor();
// Vérification périodique de la mémoire
setInterval(() => {
monitor.checkMemoryUsage();
}, 5 * 60 * 1000); // Toutes les 5 minutes
// Endpoint pour les métriques
export function setupMonitoringEndpoints(app: any) {
app.get('/api/metrics', (req: Request, res: Response) => {
res.json(monitor.getStats());
});
app.post('/api/metrics/reset', (req: Request, res: Response) => {
monitor.reset();
res.json({ message: 'Metrics reset successfully' });
});
}
-139
View File
@@ -1,139 +0,0 @@
// Service for interacting with NocoDB
import { storage } from "./storage";
export interface InvoiceVerificationResult {
exists: boolean;
error?: string;
}
export async function verifyInvoiceReference(
groupId: number,
invoiceReference: string,
supplierName?: string
): Promise<InvoiceVerificationResult> {
try {
// Get group with NocoDB configuration
const group = await storage.getGroup(groupId);
if (!group || !group.nocodbConfigId || !group.nocodbTableId) {
return { exists: false, error: "No NocoDB configuration for this group" };
}
// Get NocoDB configuration
const nocodbConfig = await storage.getNocodbConfig(group.nocodbConfigId);
if (!nocodbConfig) {
return { exists: false, error: "NocoDB configuration not found" };
}
const columnName = group.invoiceColumnName || "Ref Facture";
// Normalize search term (case insensitive)
const normalizedInvoiceRef = invoiceReference.trim().toLowerCase();
// Make request to NocoDB API to get all records and filter them case-insensitively
// URL format: {baseUrl}/api/v1/db/data/noco/{projectId}/{tableId}
const url = `${nocodbConfig.baseUrl}/api/v1/db/data/noco/${nocodbConfig.projectId}/${group.nocodbTableId}`;
console.log(`🔍 NocoDB API Request for ${invoiceReference} (supplier: ${supplierName || 'any'}):`, {
url,
columnName,
groupId,
projectId: nocodbConfig.projectId,
tableId: group.nocodbTableId,
normalizedSearch: normalizedInvoiceRef
});
const response = await fetch(url, {
method: "GET",
headers: {
"xc-token": nocodbConfig.apiToken,
"Content-Type": "application/json",
},
});
if (!response.ok) {
return { exists: false, error: `NocoDB API error: ${response.status}` };
}
const data = await response.json();
console.log(`📋 NocoDB Response:`, {
totalRows: data.pageInfo?.totalRows || 0,
returnedRows: data.list?.length || 0,
columnName,
searchValue: invoiceReference.trim()
});
// Filter results case-insensitively for invoice reference
let matchingRecords = [];
if (data.list && data.list.length > 0) {
matchingRecords = data.list.filter((record: any) => {
const recordInvoiceRef = record[columnName];
if (!recordInvoiceRef) return false;
// Case-insensitive comparison for invoice reference
const normalizedRecordRef = recordInvoiceRef.toString().toLowerCase();
const invoiceMatches = normalizedRecordRef === normalizedInvoiceRef;
// If supplier name is provided, also check supplier match (case-insensitive)
if (invoiceMatches && supplierName) {
const supplierColumn = record['Fournisseur'] || record['Supplier'] || record['fournisseur'] || record['supplier'];
if (supplierColumn) {
const normalizedRecordSupplier = supplierColumn.toString().toLowerCase();
const normalizedSearchSupplier = supplierName.toLowerCase();
const supplierMatches = normalizedRecordSupplier.includes(normalizedSearchSupplier) ||
normalizedSearchSupplier.includes(normalizedRecordSupplier);
console.log(`🏢 Supplier check:`, {
recordSupplier: supplierColumn,
searchSupplier: supplierName,
matches: supplierMatches
});
return supplierMatches;
}
}
return invoiceMatches;
});
}
const exists = matchingRecords.length > 0;
if (exists) {
console.log(`✅ Found matching invoice:`, {
found: matchingRecords[0][columnName],
searchValue: invoiceReference.trim(),
totalMatches: matchingRecords.length,
supplierMatch: supplierName ? 'verified' : 'not checked'
});
} else {
console.log(`❌ No matching invoice found for: ${invoiceReference}${supplierName ? ` (supplier: ${supplierName})` : ''}`);
}
console.log(`🔍 Search result for ${invoiceReference}:`, { exists });
return { exists };
} catch (error) {
console.error("Error verifying invoice reference:", error);
return { exists: false, error: error instanceof Error ? error.message : "Unknown error" };
}
}
// Bulk verify multiple invoice references
export async function verifyMultipleInvoiceReferences(
invoiceReferences: { groupId: number; invoiceReference: string; deliveryId: number; supplierName?: string }[]
): Promise<Record<number, InvoiceVerificationResult>> {
const results: Record<number, InvoiceVerificationResult> = {};
// Process in parallel for better performance
const promises = invoiceReferences.map(async ({ groupId, invoiceReference, deliveryId, supplierName }) => {
const result = await verifyInvoiceReference(groupId, invoiceReference, supplierName);
results[deliveryId] = result;
});
await Promise.all(promises);
return results;
}
-113
View File
@@ -1,113 +0,0 @@
// Middleware de permissions hardcodées
// Remplace l'ancien système flexible basé sur la base de données
import type { Request, Response, NextFunction } from 'express';
import { hasPermission, hasModuleAccess } from '@shared/permissions';
import type { Permission, Module } from '@shared/permissions';
// Interface pour les requêtes authentifiées
interface AuthRequest extends Request {
user: any;
}
// Middleware pour vérifier l'accès à un module
export function requireModuleAccess(module: Module) {
return (req: AuthRequest, res: Response, next: NextFunction) => {
const userRole = req.user?.role;
if (!userRole) {
return res.status(401).json({ message: "Utilisateur non authentifié" });
}
if (!hasModuleAccess(userRole, module)) {
return res.status(403).json({
message: `Accès refusé au module ${module}`,
required: "Permission d'accès au module",
userRole
});
}
next();
};
}
// Middleware pour vérifier une permission spécifique
export function requirePermission(module: Module, action: Permission) {
return (req: AuthRequest, res: Response, next: NextFunction) => {
const userRole = req.user?.role;
if (!userRole) {
return res.status(401).json({ message: "Utilisateur non authentifié" });
}
if (!hasPermission(userRole, module, action)) {
return res.status(403).json({
message: `Permission refusée: ${action} sur ${module}`,
required: `${action} permission on ${module}`,
userRole
});
}
next();
};
}
// Middleware combiné: accès module + permission
export function requireModulePermission(module: Module, action: Permission) {
return (req: AuthRequest, res: Response, next: NextFunction) => {
const userRole = req.user?.role;
if (!userRole) {
return res.status(401).json({ message: "Utilisateur non authentifié" });
}
// Vérifier l'accès au module ET la permission
if (!hasModuleAccess(userRole, module) || !hasPermission(userRole, module, action)) {
return res.status(403).json({
message: `Accès refusé: ${action} sur module ${module}`,
required: `${action} permission on ${module} module`,
userRole
});
}
next();
};
}
// Helper: vérifie si l'utilisateur est admin
export function requireAdmin(req: AuthRequest, res: Response, next: NextFunction) {
const userRole = req.user?.role;
if (!userRole) {
return res.status(401).json({ message: "Utilisateur non authentifié" });
}
if (userRole !== 'admin') {
return res.status(403).json({
message: "Accès réservé aux administrateurs",
required: "admin role",
userRole
});
}
next();
}
// Helper: vérifie si l'utilisateur est admin ou directeur
export function requireAdminOrDirecteur(req: AuthRequest, res: Response, next: NextFunction) {
const userRole = req.user?.role;
if (!userRole) {
return res.status(401).json({ message: "Utilisateur non authentifié" });
}
if (!['admin', 'directeur'].includes(userRole)) {
return res.status(403).json({
message: "Accès réservé aux administrateurs et directeurs",
required: "admin or directeur role",
userRole
});
}
next();
}
-6077
View File
File diff suppressed because it is too large. Load diff
-43
View File
@@ -1,43 +0,0 @@
/**
* Nettoyage des données sensibles avant envoi au client.
*/
/**
* Retire récursivement le mot de passe SMTP des réponses API et le remplace par
* un indicateur de présence (smtpPasswordSet). Traverse les objets et tableaux
* imbriqués car les magasins apparaissent sous plusieurs formes : liste de
* groupes, champ "group" d'une livraison ou d'une commande, relations
* utilisateur... Filtrer chaque requête serait fragile, on nettoie donc une
* seule fois à la sortie.
*/
export function stripSmtpPassword(value: any, depth = 0, seen = new WeakSet()): any {
if (depth > 8 || value === null || typeof value !== 'object') {
return value;
}
// Les structures cycliques sont renvoyées telles quelles plutôt que de
// faire boucler la récursion
if (seen.has(value)) {
return value;
}
seen.add(value);
if (Array.isArray(value)) {
return value.map(item => stripSmtpPassword(item, depth + 1, seen));
}
// Ne pas dénaturer les types non sérialisables en objets simples
if (value instanceof Date || Buffer.isBuffer(value)) {
return value;
}
const result: Record<string, any> = {};
for (const [key, entry] of Object.entries(value)) {
if (key === 'smtpPassword' || key === 'smtp_password') {
result.smtpPasswordSet = Boolean(entry);
continue;
}
result[key] = stripSmtpPassword(entry, depth + 1, seen);
}
return result;
}
-359
View File
@@ -1,359 +0,0 @@
import { Express, Request, Response, NextFunction } from 'express';
import rateLimit from 'express-rate-limit';
import validator from 'validator';
import { randomBytes } from 'crypto';
// ============================================================================
// CSRF Protection (Double Submit Cookie Pattern)
// ============================================================================
const CSRF_COOKIE_NAME = 'csrf_token';
const CSRF_HEADER_NAME = 'x-csrf-token';
/**
* Generate a cryptographically secure CSRF token
*/
export function generateCsrfToken(): string {
return randomBytes(32).toString('hex');
}
/**
* Setup CSRF protection middleware using Double Submit Cookie pattern
* This pattern works well with SPAs and doesn't require server-side session storage
*/
export function setupCsrfProtection(app: Express) {
// Middleware to set CSRF cookie on every response
app.use((req: Request, res: Response, next: NextFunction) => {
// Only set cookie if not already present
if (!req.cookies?.[CSRF_COOKIE_NAME]) {
const token = generateCsrfToken();
res.cookie(CSRF_COOKIE_NAME, token, {
httpOnly: false, // Must be readable by JS for double submit
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 24 * 60 * 60 * 1000 // 24 hours
});
}
next();
});
// Middleware to validate CSRF token on state-changing requests
app.use((req: Request, res: Response, next: NextFunction) => {
const safeMethods = ['GET', 'HEAD', 'OPTIONS'];
// Skip CSRF check for safe methods
if (safeMethods.includes(req.method)) {
return next();
}
// Skip CSRF check for API endpoints that use other auth (e.g., webhook callbacks)
const csrfExemptPaths = [
'/api/health',
'/api/webhook', // External webhook callbacks
'/api/ext/', // API externe : authentifiée par clé d'API, sans cookie de session
];
if (csrfExemptPaths.some(path => req.path.startsWith(path))) {
return next();
}
const cookieToken = req.cookies?.[CSRF_COOKIE_NAME];
const headerToken = req.headers[CSRF_HEADER_NAME] as string;
// Validate CSRF token
if (!cookieToken || !headerToken || cookieToken !== headerToken) {
console.warn(`🚨 CSRF validation failed for ${req.method} ${req.path} from IP: ${req.ip}`);
return res.status(403).json({
error: 'CSRF token validation failed',
message: 'Request rejected due to security validation failure'
});
}
next();
});
}
/**
* Get current CSRF token endpoint for frontend
*/
export function setupCsrfTokenEndpoint(app: Express) {
app.get('/api/csrf-token', (req: Request, res: Response) => {
let token = req.cookies?.[CSRF_COOKIE_NAME];
if (!token) {
token = generateCsrfToken();
res.cookie(CSRF_COOKIE_NAME, token, {
httpOnly: false,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 24 * 60 * 60 * 1000
});
}
res.json({ csrfToken: token });
});
}
// ============================================================================
// Security Headers
// ============================================================================
export function setupSecurityHeaders(app: Express) {
app.use((req: Request, res: Response, next: NextFunction) => {
// Protection contre les attaques XSS
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('X-XSS-Protection', '1; mode=block');
// Protection HTTPS
if (process.env.NODE_ENV === 'production') {
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
// Politique de sécurité du contenu (renforcée)
res.setHeader('Content-Security-Policy',
"default-src 'self'; " +
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: https:; " +
"connect-src 'self' ws: wss:; " +
"font-src 'self' data:; " +
"form-action 'self';" // Prevent form submissions to external sites
);
// Protection contre les attaques de référence
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
// Protection des données sensibles
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
next();
});
}
// ============================================================================
// Rate Limiting
// ============================================================================
export function setupRateLimiting(app: Express) {
// Limiteur général
const generalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 1000, // limite chaque IP à 1000 requêtes par fenêtre
message: {
error: 'Trop de requêtes depuis cette IP, veuillez réessayer plus tard.',
},
standardHeaders: true,
legacyHeaders: false,
skip: (req) => {
return req.path === '/api/health';
}
});
// Limiteur pour l'authentification (strict)
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // limite les tentatives de connexion
message: {
error: 'Trop de tentatives de connexion, veuillez réessayer plus tard.',
},
standardHeaders: true,
legacyHeaders: false,
});
// Limiteur pour l'API
const apiLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1 minute
max: process.env.NODE_ENV === 'development' ? 500 : 300,
message: {
error: 'Limite API atteinte, veuillez ralentir vos requêtes.',
},
standardHeaders: true,
legacyHeaders: false,
skip: (req) => {
return req.path === '/api/health' || req.path === '/api/user';
},
handler: (req, res) => {
console.warn(`🚨 Rate limit reached for IP: ${req.ip} on path: ${req.path} at ${new Date().toISOString()}`);
res.status(429).json({
error: 'Limite API atteinte, veuillez ralentir vos requêtes.',
});
}
});
app.use(generalLimiter);
app.use('/api/login', authLimiter);
app.use('/api/', apiLimiter);
}
// ============================================================================
// Input Sanitization (Using validator.js)
// ============================================================================
/**
* Sanitize a single string value using validator.js
* Protects against XSS, SQL injection patterns, and path traversal
*/
export function sanitizeString(input: string): string {
if (typeof input !== 'string') return input;
let sanitized = input.trim();
// Escape HTML entities to prevent XSS
sanitized = validator.escape(sanitized);
// Remove null bytes (used in some injection attacks)
sanitized = sanitized.replace(/\0/g, '');
// Remove path traversal attempts
sanitized = sanitized.replace(/\.\.\//g, '').replace(/\.\.\\/g, '');
return sanitized;
}
/**
* Recursively sanitize all string values in an object
*/
export function sanitizeInput(input: any): any {
if (typeof input === 'string') {
return sanitizeString(input);
}
if (Array.isArray(input)) {
return input.map(item => sanitizeInput(item));
}
if (typeof input === 'object' && input !== null) {
const sanitized: any = {};
for (const key in input) {
// Also sanitize object keys to prevent prototype pollution
const sanitizedKey = sanitizeString(key);
if (sanitizedKey === '__proto__' || sanitizedKey === 'constructor' || sanitizedKey === 'prototype') {
continue; // Skip prototype pollution attempts
}
sanitized[sanitizedKey] = sanitizeInput(input[key]);
}
return sanitized;
}
return input;
}
/**
* Validate and sanitize email
*/
export function sanitizeEmail(email: string): string | null {
if (!email || typeof email !== 'string') return null;
const normalized = validator.normalizeEmail(email);
if (!normalized || !validator.isEmail(normalized)) {
return null;
}
return normalized;
}
/**
* Validate and sanitize URL
*/
export function sanitizeUrl(url: string): string | null {
if (!url || typeof url !== 'string') return null;
if (!validator.isURL(url, {
protocols: ['http', 'https'],
require_protocol: true,
require_valid_protocol: true
})) {
return null;
}
return url;
}
/**
* Check for SQL injection patterns (for logging/monitoring)
*/
export function detectSqlInjection(input: string): boolean {
if (typeof input !== 'string') return false;
const sqlPatterns = [
/(\b(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|EXEC|UNION|OR|AND)\b.*\b(FROM|INTO|TABLE|WHERE|SET)\b)/i,
/(['"]?\s*(OR|AND)\s*['"]?\s*['"]?\s*=\s*['"]?)/i,
/(--|\#|\/\*|\*\/)/,
/(\bEXEC\b|\bEXECUTE\b|\bxp_)/i,
];
return sqlPatterns.some(pattern => pattern.test(input));
}
// Middleware de nettoyage des requêtes
export function setupInputSanitization(app: Express) {
app.use((req: Request, res: Response, next: NextFunction) => {
// Log potential SQL injection attempts
const checkAndLog = (data: any, source: string) => {
if (typeof data === 'object' && data !== null) {
for (const key in data) {
const value = data[key];
if (typeof value === 'string' && detectSqlInjection(value)) {
console.warn(`🚨 Potential SQL injection detected in ${source}:`, {
ip: req.ip,
path: req.path,
key,
value: value.substring(0, 100) // Truncate for logging
});
}
}
}
};
if (req.body) {
checkAndLog(req.body, 'body');
req.body = sanitizeInput(req.body);
}
if (req.query) {
checkAndLog(req.query, 'query');
req.query = sanitizeInput(req.query);
}
if (req.params) {
checkAndLog(req.params, 'params');
req.params = sanitizeInput(req.params);
}
next();
});
}
// ============================================================================
// Secure Logging
// ============================================================================
const SENSITIVE_KEYS = ['password', 'token', 'secret', 'apikey', 'api_key', 'authorization', 'cookie'];
export function secureLog(message: string, data?: any) {
const timestamp = new Date().toISOString();
// Mask sensitive data
const maskSensitive = (obj: any): any => {
if (!obj || typeof obj !== 'object') return obj;
const masked: any = Array.isArray(obj) ? [] : {};
for (const key in obj) {
if (SENSITIVE_KEYS.some(s => key.toLowerCase().includes(s))) {
masked[key] = '[REDACTED]';
} else if (typeof obj[key] === 'object') {
masked[key] = maskSensitive(obj[key]);
} else {
masked[key] = obj[key];
}
}
return masked;
};
const logData = data ? JSON.stringify(maskSensitive(data), null, 2) : '';
// Check message for sensitive content
if (SENSITIVE_KEYS.some(s => message.toLowerCase().includes(s))) {
console.log(`[${timestamp}] ${message} - [SENSITIVE DATA HIDDEN]`);
} else {
console.log(`[${timestamp}] ${message}`, logData);
}
}
-5915
View File
File diff suppressed because it is too large. Load diff
-99
View File
@@ -1,99 +0,0 @@
import express, { type Express } from "express";
import fs from "fs";
import path from "path";
import { createServer as createViteServer, createLogger } from "vite";
import { type Server } from "http";
import viteConfig from "../vite.config";
import { nanoid } from "nanoid";
const viteLogger = createLogger();
export function log(message: string, source = "express") {
const formattedTime = new Date().toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
second: "2-digit",
hour12: true,
});
console.log(`${formattedTime} [${source}] ${message}`);
}
export async function setupVite(app: Express, server: Server) {
const serverOptions = {
middlewareMode: true,
hmr: { server },
allowedHosts: true as const,
};
const vite = await createViteServer({
...viteConfig,
configFile: false,
customLogger: {
...viteLogger,
error: (msg, options) => {
viteLogger.error(msg, options);
process.exit(1);
},
},
server: serverOptions,
appType: "custom",
});
app.use(vite.middlewares);
app.use("*", async (req, res, next) => {
const url = req.originalUrl;
try {
const clientTemplate = path.resolve(
import.meta.dirname,
"..",
"client",
"index.html",
);
// always reload the index.html file from disk incase it changes
let template = await fs.promises.readFile(clientTemplate, "utf-8");
template = template.replace(
`src="/src/main.tsx"`,
`src="/src/main.tsx?v=${nanoid()}"`,
);
const page = await vite.transformIndexHtml(url, template);
res.status(200).set({ "Content-Type": "text/html" }).end(page);
} catch (e) {
vite.ssrFixStacktrace(e as Error);
next(e);
}
});
}
export function serveStatic(app: Express) {
const distPath = path.resolve(import.meta.dirname, "public");
if (!fs.existsSync(distPath)) {
throw new Error(
`Could not find the build directory: ${distPath}, make sure to build the client first`,
);
}
// 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"));
});
}
-64
View File
@@ -1,64 +0,0 @@
/**
* Auto-configuration du système météo pour la production
* Configure automatiquement les paramètres météo avec la clé API des variables d'environnement
*/
import { storage } from "./storage.js";
import { weatherService } from "./weatherService.js";
export async function initializeWeatherConfig() {
try {
console.log('🌤️ [AUTO-CONFIG] Initializing weather configuration...');
// Vérifier si la configuration météo existe déjà
const existingSettings = await storage.getWeatherSettings();
if (existingSettings && existingSettings.isActive) {
console.log('✅ [AUTO-CONFIG] Weather settings already configured and active');
return;
}
// Récupérer la clé API depuis les variables d'environnement
const apiKey = process.env.VISUAL_CROSSING_API_KEY;
if (!apiKey) {
console.log('⚠️ [AUTO-CONFIG] VISUAL_CROSSING_API_KEY not found in environment variables');
return;
}
// Configuration par défaut
const defaultConfig = {
apiKey: apiKey,
location: "Nancy, France",
isActive: true
};
console.log('🌤️ [AUTO-CONFIG] Testing API connection...');
// Tester la connexion API avant de sauvegarder
const testResult = await weatherService.testApiConnection(defaultConfig.apiKey, defaultConfig.location);
if (!testResult.success) {
console.error('❌ [AUTO-CONFIG] API test failed:', testResult.message);
return;
}
console.log('✅ [AUTO-CONFIG] API test successful');
// Créer ou mettre à jour la configuration
if (existingSettings) {
// Mettre à jour la configuration existante
await storage.updateWeatherSettings(existingSettings.id, defaultConfig);
console.log('🔄 [AUTO-CONFIG] Weather settings updated');
} else {
// Créer une nouvelle configuration
await storage.createWeatherSettings(defaultConfig);
console.log('🆕 [AUTO-CONFIG] Weather settings created');
}
console.log('✅ [AUTO-CONFIG] Weather system successfully configured');
} catch (error) {
console.error('❌ [AUTO-CONFIG] Failed to initialize weather config:', error);
}
}
-302
View File
@@ -1,302 +0,0 @@
import { WeatherData, WeatherSettings, InsertWeatherData } from "../shared/schema.js";
export interface WeatherApiResponse {
days: Array<{
datetime: string;
tempmax: number;
tempmin: number;
icon: string;
conditions: string;
}>;
}
export interface WeatherDisplayData {
currentYear: WeatherData | null;
previousYear: WeatherData | null;
}
export class WeatherService {
private baseUrl = "https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services/timeline";
/**
* Récupère les données météo actuelles depuis l'API Visual Crossing
*/
async fetchCurrentWeather(settings: WeatherSettings): Promise<WeatherApiResponse | null> {
try {
const url = `${this.baseUrl}/${encodeURIComponent(settings.location)}?unitGroup=metric&include=days&key=${settings.apiKey}&contentType=json&lang=fr`;
const response = await fetch(url);
if (!response.ok) {
const reason = (await response.text().catch(() => '')).slice(0, 300);
console.error(`Weather API error: ${response.status} ${response.statusText} ${reason}`);
return null;
}
const data = await response.json();
return data;
} catch (error) {
console.error("Erreur lors de la récupération de la météo actuelle:", error);
return null;
}
}
/**
* Récupère les données météo de l'année précédente pour la même date
*/
async fetchPreviousYearWeather(settings: WeatherSettings, targetDate: string): Promise<WeatherApiResponse | null> {
try {
// Pour les données historiques, Visual Crossing utilise la syntaxe: /timeline/location/date
const url = `${this.baseUrl}/${encodeURIComponent(settings.location)}/${targetDate}?unitGroup=metric&include=days&key=${settings.apiKey}&contentType=json&lang=fr`;
console.log(`🌤️ [FETCH-HISTORY] Requesting: ${settings.location} for ${targetDate}`);
const response = await fetch(url);
if (!response.ok) {
// Visual Crossing explique le refus dans le corps (quota, plan, clé...)
const reason = (await response.text().catch(() => '')).slice(0, 300);
console.error(`🌤️ [ERROR] Weather API error for previous year: ${response.status} ${response.statusText} ${reason}`);
// Pour les données historiques, certains comptes peuvent ne pas avoir accès
if (response.status === 401) {
console.warn(`⚠️ [WARNING] Historical data access may not be available with current API key`);
}
return null;
}
const data = await response.json();
console.log(`✅ [FETCH-HISTORY] Successfully retrieved data for ${targetDate}`);
return data;
} catch (error) {
console.error("🌤️ [ERROR] Erreur lors de la récupération de la météo de l'année précédente:", error);
return null;
}
}
/**
* Traduit les conditions météo en français
*/
private translateConditionToFrench(condition: string): string {
const conditionLower = condition.toLowerCase();
// Traductions anglais -> français
if (conditionLower.includes('clear')) return 'Dégagé';
if (conditionLower.includes('sunny')) return 'Ensoleillé';
if (conditionLower.includes('fair')) return 'Beau temps';
if (conditionLower.includes('partly cloudy')) return 'Partiellement nuageux';
if (conditionLower.includes('cloudy')) return 'Nuageux';
if (conditionLower.includes('overcast')) return 'Couvert';
if (conditionLower.includes('rain')) {
if (conditionLower.includes('heavy')) return 'Pluie forte';
if (conditionLower.includes('light')) return 'Pluie légère';
return 'Pluie';
}
if (conditionLower.includes('shower')) return 'Averses';
if (conditionLower.includes('drizzle')) return 'Bruine';
if (conditionLower.includes('thunderstorm') || conditionLower.includes('thunder')) return 'Orage';
if (conditionLower.includes('snow')) {
if (conditionLower.includes('heavy')) return 'Neige forte';
if (conditionLower.includes('light')) return 'Neige légère';
return 'Neige';
}
if (conditionLower.includes('fog')) return 'Brouillard';
if (conditionLower.includes('mist')) return 'Brume';
if (conditionLower.includes('wind')) return 'Venteux';
// Si déjà en français, retourner tel quel
return condition;
}
/**
* Convertit les données de l'API en format WeatherData avec traduction française forcée
*/
convertApiDataToWeatherData(apiData: WeatherApiResponse, location: string, isCurrentYear: boolean): InsertWeatherData | null {
if (!apiData.days || apiData.days.length === 0) {
return null;
}
const todayData = apiData.days[0];
return {
// Pas d'ID - sera auto-généré par PostgreSQL serial
date: todayData.datetime,
location,
tempMax: todayData.tempmax.toString(),
tempMin: todayData.tempmin.toString(),
icon: todayData.icon,
conditions: this.translateConditionToFrench(todayData.conditions), // Traduction forcée
isCurrentYear
// Pas de createdAt/updatedAt - gérés par la base de données
};
}
/**
* Calcule la date de l'année précédente pour le même jour de la semaine
*/
getPreviousYearDate(date: Date = new Date()): string {
const currentYear = date.getFullYear();
const currentMonth = date.getMonth();
const currentDayOfWeek = date.getDay(); // 0 = dimanche, 1 = lundi, etc.
// Créer une date pour l'année précédente, même mois
const previousYear = new Date(currentYear - 1, currentMonth, 1);
// Trouver le premier jour de la semaine correspondant dans ce mois
const firstDayOfMonth = previousYear.getDay();
const daysToAdd = (currentDayOfWeek - firstDayOfMonth + 7) % 7;
// Calculer quelle semaine nous sommes dans le mois actuel
const currentDate = date.getDate();
const currentWeekOfMonth = Math.ceil(currentDate / 7);
// Aller à la même semaine du mois l'année précédente
const targetDate = new Date(currentYear - 1, currentMonth, 1 + daysToAdd + (currentWeekOfMonth - 1) * 7);
// Si la date calculée dépasse le mois, prendre la dernière occurrence de ce jour dans le mois
if (targetDate.getMonth() !== currentMonth) {
// Revenir en arrière d'une semaine
targetDate.setDate(targetDate.getDate() - 7);
}
console.log(`🗓️ [DATE-CALC] Aujourd'hui: ${date.toISOString().split('T')[0]} (${this.getDayName(currentDayOfWeek)})`);
console.log(`🗓️ [DATE-CALC] Année précédente: ${targetDate.toISOString().split('T')[0]} (${this.getDayName(targetDate.getDay())})`);
return targetDate.toISOString().split('T')[0]; // Format YYYY-MM-DD
}
/**
* Retourne le nom du jour de la semaine
*/
private getDayName(dayOfWeek: number): string {
const days = ['Dimanche', 'Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi'];
return days[dayOfWeek];
}
/**
* Convertit l'icône météo en format d'affichage
*/
getWeatherIcon(iconCode: string): string {
const iconMap: { [key: string]: string } = {
'clear-day': '☀️',
'clear-night': '🌙',
'partly-cloudy-day': '⛅',
'partly-cloudy-night': '☁️',
'cloudy': '☁️',
'rain': '🌧️',
'snow': '❄️',
'wind': '💨',
'fog': '🌫️',
'thunder': '⛈️',
'hail': '🌨️'
};
return iconMap[iconCode] || '🌤️';
}
/**
* Valide les paramètres de configuration météo
*/
validateWeatherSettings(settings: Partial<WeatherSettings>): { isValid: boolean; errors: string[] } {
const errors: string[] = [];
if (!settings.apiKey || settings.apiKey.trim().length === 0) {
errors.push("La clé API Visual Crossing est requise");
}
if (!settings.location || settings.location.trim().length === 0) {
errors.push("La localisation est requise");
}
// Vérification basique du format de la clé API Visual Crossing
if (settings.apiKey && settings.apiKey.length < 10) {
errors.push("La clé API semble invalide (trop courte)");
}
return {
isValid: errors.length === 0,
errors
};
}
/**
* Test de connexion à l'API météo
*/
async testApiConnection(apiKey: string, location: string): Promise<{ success: boolean; message: string }> {
try {
const testUrl = `${this.baseUrl}/${encodeURIComponent(location)}?unitGroup=metric&include=days&key=${apiKey}&contentType=json`;
const response = await fetch(testUrl);
if (response.ok) {
const data = await response.json();
if (data.days && data.days.length > 0) {
return { success: true, message: "Connexion réussie à l'API météo" };
} else {
return { success: false, message: "Aucune donnée météo trouvée pour cette localisation" };
}
} else if (response.status === 401) {
return { success: false, message: "Clé API invalide" };
} else if (response.status === 404) {
return { success: false, message: "Localisation introuvable" };
} else {
return { success: false, message: `Erreur API: ${response.status} ${response.statusText}` };
}
} catch (error) {
console.error("Erreur test API météo:", error);
return { success: false, message: "Erreur de connexion à l'API météo" };
}
}
/**
* Convertit les coordonnées GPS en nom de ville via l'API de géocodage inverse Nominatim (OpenStreetMap)
*/
async getCityFromCoordinates(latitude: number, longitude: number, apiKey: string): Promise<{ city: string; country: string; fullLocation: string } | null> {
try {
// Utiliser l'API Nominatim (OpenStreetMap) pour la géolocalisation inverse
const nominatimUrl = `https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&accept-language=fr`;
console.log('🌍 Géolocalisation via Nominatim:', { latitude, longitude });
const response = await fetch(nominatimUrl, {
headers: {
'User-Agent': 'LogiFlow-Weather-System/1.0 (Enterprise Weather Management Platform)'
}
});
if (!response.ok) {
console.error(`Nominatim API error: ${response.status} ${response.statusText}`);
return null;
}
const data = await response.json();
console.log('🌍 Nominatim Response:', JSON.stringify(data, null, 2));
if (data && data.address) {
const address = data.address;
// Extraire la ville (peut être dans city, town, village, municipality, etc.)
const city = address.city || address.town || address.village || address.municipality || address.hamlet || 'Ville inconnue';
const country = address.country || 'Pays inconnu';
// Créer une adresse lisible pour Visual Crossing
const fullLocation = `${city}, ${country}`;
console.log('🌍 Parsed location:', { city, country, fullLocation });
return {
city,
country,
fullLocation
};
}
console.log('🌍 No address found in Nominatim response');
return null;
} catch (error) {
console.error("Erreur lors de la géolocalisation:", error);
return null;
}
}
}
export const weatherService = new WeatherService();