import { promises as fs } from 'fs'; import { exec } from 'child_process'; import { promisify } from 'util'; import path from 'path'; import { prisma } from './database'; import { userPreferencesService } from './user-preferences'; const execAsync = promisify(exec); export interface BackupConfig { enabled: boolean; interval: 'hourly' | 'daily' | 'weekly'; maxBackups: number; backupPath: string; includeUploads?: boolean; compression?: boolean; } export interface BackupInfo { id: string; filename: string; size: number; createdAt: Date; type: 'manual' | 'automatic'; status: 'success' | 'failed' | 'in_progress'; error?: string; } export class BackupService { private defaultConfig: BackupConfig = { enabled: true, interval: 'hourly', maxBackups: 5, backupPath: path.join(process.cwd(), 'backups'), includeUploads: true, compression: true, }; private config: BackupConfig; constructor(config?: Partial) { this.config = { ...this.defaultConfig, ...config }; // Charger la config depuis la DB de manière asynchrone this.loadConfigFromDB().catch(() => { // Ignorer les erreurs de chargement initial }); } /** * Charge la configuration depuis la base de données */ private async loadConfigFromDB(): Promise { try { const preferences = await userPreferencesService.getAllPreferences(); if (preferences.viewPreferences && typeof preferences.viewPreferences === 'object') { const backupConfig = (preferences.viewPreferences as Record).backupConfig; if (backupConfig) { this.config = { ...this.defaultConfig, ...backupConfig }; } } } catch (error) { console.warn('Could not load backup config from DB, using defaults:', error); } } /** * Sauvegarde la configuration dans la base de données */ private async saveConfigToDB(): Promise { try { // Pour l'instant, on stocke la config backup en tant que JSON dans viewPreferences // TODO: Ajouter un champ dédié dans le schéma pour la config backup await prisma.userPreferences.upsert({ where: { id: 'default' }, update: { viewPreferences: JSON.parse(JSON.stringify({ ...(await userPreferencesService.getViewPreferences()), backupConfig: this.config })) }, create: { id: 'default', kanbanFilters: {}, viewPreferences: JSON.parse(JSON.stringify({ backupConfig: this.config })), columnVisibility: {}, jiraConfig: {} } }); } catch (error) { console.error('Failed to save backup config to DB:', error); } } /** * Crée une sauvegarde complète de la base de données */ async createBackup(type: 'manual' | 'automatic' = 'manual'): Promise { const backupId = `backup_${Date.now()}`; const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const filename = `towercontrol_${timestamp}.db`; const backupPath = path.join(this.config.backupPath, filename); console.log(`🔄 Starting ${type} backup: ${filename}`); try { // Créer le dossier de backup si nécessaire await this.ensureBackupDirectory(); // Vérifier l'état de la base de données await this.verifyDatabaseHealth(); // Créer la sauvegarde SQLite await this.createSQLiteBackup(backupPath); // Compresser si activé let finalPath = backupPath; if (this.config.compression) { finalPath = await this.compressBackup(backupPath); await fs.unlink(backupPath); // Supprimer le fichier non compressé } // Obtenir les stats du fichier const stats = await fs.stat(finalPath); const backupInfo: BackupInfo = { id: backupId, filename: path.basename(finalPath), size: stats.size, createdAt: new Date(), type, status: 'success', }; // Nettoyer les anciennes sauvegardes await this.cleanOldBackups(); console.log(`✅ Backup completed: ${backupInfo.filename} (${this.formatFileSize(backupInfo.size)})`); return backupInfo; } catch (error) { console.error(`❌ Backup failed:`, error); return { id: backupId, filename, size: 0, createdAt: new Date(), type, status: 'failed', error: error instanceof Error ? error.message : 'Unknown error', }; } } /** * Crée une sauvegarde SQLite en utilisant la commande .backup */ private async createSQLiteBackup(backupPath: string): Promise { const dbPath = path.resolve(process.env.DATABASE_URL?.replace('file:', '') || './prisma/dev.db'); // Méthode 1: Utiliser sqlite3 CLI (plus fiable) try { const command = `sqlite3 "${dbPath}" ".backup '${backupPath}'"`; await execAsync(command); console.log(`✅ SQLite backup created using CLI: ${backupPath}`); return; } catch (cliError) { console.warn(`⚠️ SQLite CLI backup failed, trying copy method:`, cliError); } // Méthode 2: Copie simple du fichier (fallback) try { await fs.copyFile(dbPath, backupPath); console.log(`✅ SQLite backup created using file copy: ${backupPath}`); } catch (copyError) { throw new Error(`Failed to create SQLite backup: ${copyError}`); } } /** * Compresse une sauvegarde */ private async compressBackup(filePath: string): Promise { const compressedPath = `${filePath}.gz`; try { const command = `gzip -c "${filePath}" > "${compressedPath}"`; await execAsync(command); console.log(`✅ Backup compressed: ${compressedPath}`); return compressedPath; } catch (error) { console.warn(`⚠️ Compression failed, keeping uncompressed backup:`, error); return filePath; } } /** * Restaure une sauvegarde */ async restoreBackup(filename: string): Promise { const backupPath = path.join(this.config.backupPath, filename); const dbPath = path.resolve(process.env.DATABASE_URL?.replace('file:', '') || './prisma/dev.db'); console.log(`🔄 Restore paths - backup: ${backupPath}, target: ${dbPath}`); console.log(`🔄 Starting restore from: ${filename}`); try { // Vérifier que le fichier de sauvegarde existe await fs.access(backupPath); // Décompresser si nécessaire let sourceFile = backupPath; if (filename.endsWith('.gz')) { const tempFile = backupPath.replace('.gz', ''); console.log(`🔄 Decompressing ${backupPath} to ${tempFile}`); try { await execAsync(`gunzip -c "${backupPath}" > "${tempFile}"`); console.log(`✅ Decompression successful`); // Vérifier que le fichier décompressé existe await fs.access(tempFile); console.log(`✅ Decompressed file exists: ${tempFile}`); sourceFile = tempFile; } catch (decompError) { console.error(`❌ Decompression failed:`, decompError); throw decompError; } } // Créer une sauvegarde de la base actuelle avant restauration const currentBackup = await this.createBackup('manual'); console.log(`✅ Current database backed up as: ${currentBackup.filename}`); // Fermer toutes les connexions await prisma.$disconnect(); // Vérifier que le fichier source existe await fs.access(sourceFile); console.log(`✅ Source file verified: ${sourceFile}`); // Remplacer la base de données console.log(`🔄 Copying ${sourceFile} to ${dbPath}`); await fs.copyFile(sourceFile, dbPath); console.log(`✅ Database file copied successfully`); // Nettoyer le fichier temporaire si décompressé if (sourceFile !== backupPath) { await fs.unlink(sourceFile); } // Reconnecter à la base await prisma.$connect(); // Vérifier l'intégrité après restauration await this.verifyDatabaseHealth(); console.log(`✅ Database restored from: ${filename}`); } catch (error) { console.error(`❌ Restore failed:`, error); throw new Error(`Failed to restore backup: ${error}`); } } /** * Liste toutes les sauvegardes disponibles */ async listBackups(): Promise { try { await this.ensureBackupDirectory(); const files = await fs.readdir(this.config.backupPath); const backups: BackupInfo[] = []; for (const file of files) { if (file.startsWith('towercontrol_') && (file.endsWith('.db') || file.endsWith('.db.gz'))) { const filePath = path.join(this.config.backupPath, file); const stats = await fs.stat(filePath); // Extraire la date du nom de fichier const dateMatch = file.match(/towercontrol_(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)/); let createdAt = stats.birthtime; if (dateMatch) { // Convertir le format de fichier vers ISO string valide // Format: 2025-09-18T14-12-05-737Z -> 2025-09-18T14:12:05.737Z const isoString = dateMatch[1] .replace(/T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z/, 'T$1:$2:$3.$4Z'); createdAt = new Date(isoString); } backups.push({ id: file, filename: file, size: stats.size, createdAt, type: 'automatic', // On ne peut pas déterminer le type depuis le nom status: 'success', }); } } return backups.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); } catch (error) { console.error('Error listing backups:', error); return []; } } /** * Supprime une sauvegarde */ async deleteBackup(filename: string): Promise { const backupPath = path.join(this.config.backupPath, filename); try { await fs.unlink(backupPath); console.log(`✅ Backup deleted: ${filename}`); } catch (error) { console.error(`❌ Failed to delete backup ${filename}:`, error); throw error; } } /** * Vérifie l'intégrité de la base de données */ async verifyDatabaseHealth(): Promise { try { // Test de connexion simple await prisma.$queryRaw`SELECT 1`; // Vérification de l'intégrité SQLite const result = await prisma.$queryRaw<{integrity_check: string}[]>`PRAGMA integrity_check`; if (result.length > 0 && result[0].integrity_check !== 'ok') { throw new Error(`Database integrity check failed: ${result[0].integrity_check}`); } console.log('✅ Database health check passed'); } catch (error) { console.error('❌ Database health check failed:', error); throw error; } } /** * Nettoie les anciennes sauvegardes selon la configuration */ private async cleanOldBackups(): Promise { try { const backups = await this.listBackups(); if (backups.length > this.config.maxBackups) { const toDelete = backups.slice(this.config.maxBackups); for (const backup of toDelete) { await this.deleteBackup(backup.filename); } console.log(`🧹 Cleaned ${toDelete.length} old backups`); } } catch (error) { console.error('Error cleaning old backups:', error); } } /** * S'assure que le dossier de backup existe */ private async ensureBackupDirectory(): Promise { try { await fs.access(this.config.backupPath); } catch { await fs.mkdir(this.config.backupPath, { recursive: true }); console.log(`📁 Created backup directory: ${this.config.backupPath}`); } } /** * Formate la taille de fichier */ private formatFileSize(bytes: number): string { const units = ['B', 'KB', 'MB', 'GB']; let size = bytes; let unitIndex = 0; while (size >= 1024 && unitIndex < units.length - 1) { size /= 1024; unitIndex++; } return `${size.toFixed(1)} ${units[unitIndex]}`; } /** * Met à jour la configuration */ async updateConfig(newConfig: Partial): Promise { this.config = { ...this.config, ...newConfig }; await this.saveConfigToDB(); } /** * Obtient la configuration actuelle */ getConfig(): BackupConfig { return { ...this.config }; } } // Instance singleton export const backupService = new BackupService();