mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-11 17:27:31 +02:00
Replaced node-cron with native setInterval for backup scheduling to resolve production issues related to dynamic requires. Replit-Commit-Author: Agent Replit-Commit-Session-Id: db1bcb73-80c1-4bff-8236-4ced2c07f75f Replit-Commit-Checkpoint-Type: full_checkpoint
259 lines
8.3 KiB
TypeScript
259 lines
8.3 KiB
TypeScript
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<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
|
|
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<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];
|
|
}
|
|
|
|
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(); |