import { promises as fs } from 'fs'; import path from 'path'; import { prisma } from '@/services/core/database'; import { userPreferencesService } from '@/services/core/user-preferences'; import { BackupUtils } from '@/lib/backup-utils'; 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; databaseHash?: string; } export class BackupService { private get defaultConfig(): BackupConfig { return { enabled: true, interval: 'hourly', maxBackups: 5, backupPath: this.getDefaultBackupPath(), includeUploads: true, compression: true, }; } private getDefaultBackupPath(): string { return BackupUtils.resolveBackupStoragePath(); } private config: BackupConfig; private configLoaded: boolean = false; constructor(config?: Partial) { this.config = { ...this.defaultConfig, ...config }; // Ne pas charger la config depuis la DB dans le constructeur // pour éviter les erreurs pendant le build // La config sera chargée de manière lazy quand nécessaire } /** * Charge la configuration depuis la base de données (lazy loading) */ private async loadConfigFromDB(): Promise { if (this.configLoaded) { return; // Déjà chargé } try { // Pour le service de backup, on utilise un userId par défaut // car il n'a pas accès à la session const preferences = await userPreferencesService.getAllPreferences('default'); if ( preferences.viewPreferences && typeof preferences.viewPreferences === 'object' ) { const backupConfig = ( preferences.viewPreferences as Record ).backupConfig; if (backupConfig) { this.config = { ...this.defaultConfig, ...backupConfig }; } } this.configLoaded = true; } catch (error) { console.warn( 'Could not load backup config from DB, using defaults:', error ); // Marquer comme chargé même en cas d'erreur pour éviter de réessayer this.configLoaded = true; } } /** * 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: { userId: 'default' }, update: { viewPreferences: JSON.parse( JSON.stringify({ ...(await userPreferencesService.getViewPreferences('default')), backupConfig: this.config, }) ), }, create: { userId: 'default', kanbanFilters: {}, viewPreferences: JSON.parse( JSON.stringify({ backupConfig: this.config }) ), columnVisibility: {}, jiraConfig: {}, }, }); } catch (error) { console.error('Failed to save backup config to DB:', error); } } /** * Calcule un hash de la base de données pour détecter les changements */ private async calculateDatabaseHash(): Promise { try { const dbPath = BackupUtils.resolveDatabasePath(); return await BackupUtils.calculateFileHash(dbPath); } catch (error) { console.error('Error calculating database hash:', error); throw new Error(`Failed to calculate database hash: ${error}`); } } /** * Vérifie si la base de données a changé depuis le dernier backup */ async hasChangedSinceLastBackup(): Promise { try { const currentHash = await this.calculateDatabaseHash(); const backups = await this.listBackups(); if (backups.length === 0) { // Pas de backup précédent, donc il y a forcément des changements return true; } // Récupérer le hash du dernier backup const lastBackup = backups[0]; // Les backups sont triés par date décroissante const lastBackupHash = await this.getBackupHash(lastBackup.filename); if (!lastBackupHash) { // Pas de hash disponible pour le dernier backup, considérer qu'il y a des changements console.log('No hash available for last backup, assuming changes'); return true; } const hasChanged = currentHash !== lastBackupHash; console.log( `Database hash comparison: current=${currentHash.substring(0, 8)}..., last=${lastBackupHash.substring(0, 8)}..., changed=${hasChanged}` ); return hasChanged; } catch (error) { console.error('Error checking database changes:', error); // En cas d'erreur, on assume qu'il y a des changements pour être sûr return true; } } /** * Récupère le hash d'un backup depuis ses métadonnées */ private async getBackupHash(filename: string): Promise { try { const metadataPath = path.join( this.getCurrentBackupPath(), `${filename}.meta.json` ); try { const metadataContent = await fs.readFile(metadataPath, 'utf-8'); const metadata = JSON.parse(metadataContent); return metadata.databaseHash || null; } catch { // Fichier de métadonnées n'existe pas, essayer de calculer le hash du backup return await this.calculateBackupFileHash(filename); } } catch (error) { console.error(`Error getting backup hash for ${filename}:`, error); return null; } } /** * Calcule le hash d'un fichier de backup existant */ private async calculateBackupFileHash( filename: string ): Promise { try { const backupPath = path.join(this.getCurrentBackupPath(), filename); // Si le fichier est compressé, il faut le décompresser temporairement if (filename.endsWith('.gz')) { const tempFile = path.join( this.getCurrentBackupPath(), `temp_${Date.now()}.db` ); try { await BackupUtils.decompressFileTemp(backupPath, tempFile); const hash = await BackupUtils.calculateFileHash(tempFile); // Nettoyer le fichier temporaire await fs.unlink(tempFile); return hash; } catch (error) { // Nettoyer le fichier temporaire en cas d'erreur try { await fs.unlink(tempFile); } catch {} throw error; } } else { // Fichier non compressé return await BackupUtils.calculateFileHash(backupPath); } } catch (error) { console.error( `Error calculating hash for backup file ${filename}:`, error ); return null; } } /** * Sauvegarde les métadonnées d'un backup */ private async saveBackupMetadata( filename: string, metadata: { databaseHash: string; createdAt: Date; type: string } ): Promise { try { const metadataPath = path.join( this.getCurrentBackupPath(), `${filename}.meta.json` ); await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2)); } catch (error) { console.error(`Error saving backup metadata for ${filename}:`, error); // Ne pas faire échouer le backup si on ne peut pas sauvegarder les métadonnées } } /** * Écrit une entrée dans le fichier de log des backups */ private async logBackupAction( type: 'manual' | 'automatic', action: 'created' | 'skipped' | 'failed', details: string, extra?: { hash?: string; size?: number; previousHash?: string } ): Promise { const logPath = path.join(this.getCurrentBackupPath(), 'backup.log'); await BackupUtils.writeLogEntry(logPath, type, action, details, extra); } /** * Crée une sauvegarde complète de la base de données * Vérifie d'abord s'il y a eu des changements (sauf si forcé) */ async createBackup( type: 'manual' | 'automatic' = 'manual', forceCreate: boolean = false ): Promise { const backupId = `backup_${Date.now()}`; const filename = BackupUtils.generateBackupFilename(type); const backupPath = path.join(this.getCurrentBackupPath(), filename); console.log(`🔄 Starting ${type} backup: ${filename}`); try { // Vérifier les changements (sauf si forcé) if (!forceCreate) { const hasChanged = await this.hasChangedSinceLastBackup(); if (!hasChanged) { const currentHash = await this.calculateDatabaseHash(); const backups = await this.listBackups(); const lastBackupHash = backups.length > 0 ? await this.getBackupHash(backups[0].filename) : null; const message = `No changes detected since last backup`; console.log(`⏭️ Skipping ${type} backup: ${message}`); await this.logBackupAction(type, 'skipped', message, { hash: currentHash, previousHash: lastBackupHash || undefined, }); return null; } console.log(`📝 Changes detected, proceeding with ${type} backup`); } else { console.log(`🔧 Forced ${type} backup, skipping change detection`); } // Calculer le hash de la base de données avant le backup const databaseHash = await this.calculateDatabaseHash(); // Créer le dossier de backup si nécessaire await BackupUtils.ensureDirectory(this.getCurrentBackupPath()); // Créer la sauvegarde SQLite const dbPath = BackupUtils.resolveDatabasePath(); await BackupUtils.createSQLiteBackup(dbPath, backupPath); // Compresser si activé let finalPath = backupPath; if (this.config.compression) { finalPath = await BackupUtils.compressFile(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(), // Utiliser l'heure actuelle au lieu de getToday() type, status: 'success', databaseHash, }; // Sauvegarder les métadonnées du backup await this.saveBackupMetadata(path.basename(finalPath), { databaseHash, createdAt: new Date(), // Utiliser l'heure actuelle type, }); // Nettoyer les anciennes sauvegardes await this.cleanOldBackups(); const successMessage = `${backupInfo.filename} created successfully`; console.log(`✅ Backup completed: ${successMessage}`); await this.logBackupAction(type, 'created', successMessage, { hash: databaseHash, size: backupInfo.size, }); return backupInfo; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; console.error(`❌ Backup failed:`, error); await this.logBackupAction( type, 'failed', `${filename} failed: ${errorMessage}` ); return { id: backupId, filename, size: 0, createdAt: new Date(), // Utiliser l'heure actuelle même en cas d'erreur type, status: 'failed', error: errorMessage, }; } } /** * Restaure une sauvegarde */ async restoreBackup(filename: string): Promise { const backupPath = path.join(this.getCurrentBackupPath(), filename); // Résoudre le chemin de la base de données let dbPath: string; if (process.env.BACKUP_DATABASE_PATH) { // Utiliser la variable spécifique aux backups dbPath = path.resolve(process.cwd(), process.env.BACKUP_DATABASE_PATH); } else if (process.env.DATABASE_URL) { // Fallback sur DATABASE_URL si BACKUP_DATABASE_PATH n'est pas défini dbPath = path.resolve(process.env.DATABASE_URL.replace('file:', '')); } else { // Chemin par défaut vers prisma/dev.db dbPath = path.resolve(process.cwd(), '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 BackupUtils.decompressFileTemp(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', true); // Forcer la création if (currentBackup) { 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}`); } } /** * Obtient le chemin de sauvegarde actuel (toujours à jour) * Force la relecture des variables d'environnement à chaque appel */ private getCurrentBackupPath(): string { // Toujours recalculer depuis les variables d'environnement // pour éviter les problèmes de cache lors des refresh return this.getDefaultBackupPath(); } /** * Liste toutes les sauvegardes disponibles */ async listBackups(): Promise { try { const currentBackupPath = this.getCurrentBackupPath(); await BackupUtils.ensureDirectory(currentBackupPath); const files = await fs.readdir(currentBackupPath); const backups: BackupInfo[] = []; for (const file of files) { if ( file.startsWith('towercontrol_') && (file.endsWith('.db') || file.endsWith('.db.gz')) ) { const filePath = path.join(currentBackupPath, file); const stats = await fs.stat(filePath); // Utiliser l'utilitaire pour parser le nom de fichier const { type, date } = BackupUtils.parseBackupFilename(file); const createdAt = date || stats.birthtime; backups.push({ id: file, filename: file, size: stats.size, createdAt, type, 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.getCurrentBackupPath(), filename); const metadataPath = path.join( this.getCurrentBackupPath(), `${filename}.meta.json` ); try { // Supprimer le fichier de backup await fs.unlink(backupPath); // Supprimer le fichier de métadonnées s'il existe try { await fs.unlink(metadataPath); } catch { // Ignorer si le fichier de métadonnées n'existe pas } 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); } } /** * Met à jour la configuration */ async updateConfig(newConfig: Partial): Promise { this.config = { ...this.config, ...newConfig }; await this.saveConfigToDB(); } /** * Obtient la configuration actuelle (synchrone) * Retourne la config par défaut si elle n'a pas encore été chargée depuis la DB */ getConfigSync(): BackupConfig { return { ...this.config, backupPath: this.getCurrentBackupPath(), }; } /** * Obtient la configuration actuelle * Charge la config depuis la DB si ce n'est pas déjà fait */ async getConfig(): Promise { // Charger la config depuis la DB si ce n'est pas déjà fait await this.loadConfigFromDB(); // Retourner une config avec le chemin à jour return { ...this.config, backupPath: this.getCurrentBackupPath(), }; } /** * Lit le fichier de log des backups */ async getBackupLogs(maxLines: number = 100): Promise { try { const logPath = path.join(this.getCurrentBackupPath(), 'backup.log'); try { const logContent = await fs.readFile(logPath, 'utf-8'); const lines = logContent .trim() .split('\n') .filter((line) => line.length > 0); // Retourner les dernières lignes (les plus récentes) return lines.slice(-maxLines).reverse(); } catch { // Fichier de log n'existe pas encore return []; } } catch (error) { console.error('Error reading backup logs:', error); return []; } } /** * Récupère les statistiques de sauvegarde par jour pour les N derniers jours */ async getBackupStats(days: number = 30): Promise< Array<{ date: string; manual: number; automatic: number; total: number; }> > { try { const backups = await this.listBackups(); const now = new Date(); const stats: { [date: string]: { manual: number; automatic: number; total: number }; } = {}; // Initialiser les stats pour chaque jour for (let i = 0; i < days; i++) { const date = new Date(now); date.setDate(date.getDate() - i); // Utiliser la date locale pour éviter les décalages UTC const localDate = new Date( date.getTime() - date.getTimezoneOffset() * 60000 ); const dateStr = localDate.toISOString().split('T')[0]; // Format YYYY-MM-DD stats[dateStr] = { manual: 0, automatic: 0, total: 0 }; } // Compter les sauvegardes par jour et par type backups.forEach((backup) => { // Utiliser la date locale pour éviter les décalages UTC const backupDate = new Date( backup.createdAt.getTime() - backup.createdAt.getTimezoneOffset() * 60000 ) .toISOString() .split('T')[0]; if (stats[backupDate]) { if (backup.type === 'manual') { stats[backupDate].manual++; } else { stats[backupDate].automatic++; } stats[backupDate].total++; } }); // Convertir en tableau et trier par date return Object.entries(stats) .map(([date, counts]) => ({ date, ...counts, })) .sort((a, b) => a.date.localeCompare(b.date)); } catch (error) { console.error('Error getting backup stats:', error); return []; } } } // Instance singleton export const backupService = new BackupService();