mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-20 13:38:15 +02:00
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:
1 parent
6760f43e87
commit
600627c632
146 files changed
+31521
-20331
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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)");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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"));
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
Reference in new issue
Block a user