import { exec } from 'child_process'; import { promisify } from 'util'; import fs from 'fs'; import path from 'path'; import { nanoid } from 'nanoid'; import { eq, desc } from "drizzle-orm"; import { db } from "./db"; import { databaseBackups } 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; 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, }; } async createBackup(type: 'manual' | 'automatic' = 'manual', createdBy: string = 'system'): Promise { 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 const stats = fs.statSync(filepath); const sqlContent = fs.readFileSync(filepath, 'utf8'); const tablesCount = (sqlContent.match(/CREATE TABLE/g) || []).length; // 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'}`); } } async getBackupList(): Promise { 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 { 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 { const filepath = path.join(this.backupDir, filename); if (!fs.existsSync(filepath)) { throw new Error('Backup file not found'); } return filepath; } private async cleanOldBackups(): Promise { 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 }> { try { 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; // Si aucune sauvegarde automatique aujourd'hui, en créer une if (lastBackupDate !== today) { console.log('🔄 Première connexion du jour - Création de la sauvegarde automatique...'); await this.createBackup('automatic', userId); 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 { 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) { console.log('🔄 Starting automatic backup...'); await this.createBackup('automatic', 'system'); 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();