import { prisma } from "@/lib/prisma"; import { promises as fs } from "fs"; import path from "path"; import { existsSync } from "fs"; import { createHash } from "crypto"; const MAX_BACKUPS = 10; const BACKUP_DIR = path.join(process.cwd(), "prisma", "backups"); export interface BackupSettings { enabled: boolean; frequency: "hourly" | "daily" | "weekly" | "monthly"; lastBackup?: string; nextBackup?: string; } const SETTINGS_FILE = path.join( process.cwd(), "prisma", "backup-settings.json", ); async function ensureBackupDir() { if (!existsSync(BACKUP_DIR)) { await fs.mkdir(BACKUP_DIR, { recursive: true }); } } async function loadSettings(): Promise { try { if (existsSync(SETTINGS_FILE)) { const content = await fs.readFile(SETTINGS_FILE, "utf-8"); return JSON.parse(content); } } catch (error) { console.error("Error loading backup settings:", error); } return { enabled: true, frequency: "hourly", }; } async function saveSettings(settings: BackupSettings): Promise { await fs.writeFile(SETTINGS_FILE, JSON.stringify(settings, null, 2), "utf-8"); } function getDatabasePath(): string { const dbUrl = process.env.DATABASE_URL; if (!dbUrl) { throw new Error("DATABASE_URL not set"); } // Remove "file:" prefix if present let cleanUrl = dbUrl.replace(/^file:/, ""); // Handle absolute paths if (path.isAbsolute(cleanUrl)) { return cleanUrl; } // Handle relative paths - normalize "./" prefix if (cleanUrl.startsWith("./")) { cleanUrl = cleanUrl.substring(2); } // Resolve relative to process.cwd() const resolvedPath = path.resolve(process.cwd(), cleanUrl); // If file doesn't exist, try common locations if (!existsSync(resolvedPath)) { // Try in prisma/ directory const prismaPath = path.resolve(process.cwd(), "prisma", cleanUrl); if (existsSync(prismaPath)) { return prismaPath; } // Try just the filename in prisma/ const filename = path.basename(cleanUrl); const prismaFilenamePath = path.resolve(process.cwd(), "prisma", filename); if (existsSync(prismaFilenamePath)) { return prismaFilenamePath; } } return resolvedPath; } function getNextBackupDate(frequency: BackupSettings["frequency"]): Date { const now = new Date(); const next = new Date(now); switch (frequency) { case "hourly": next.setHours(next.getHours() + 1); next.setMinutes(0, 0, 0); break; case "daily": next.setDate(next.getDate() + 1); next.setHours(2, 0, 0, 0); // 2 AM break; case "weekly": next.setDate(next.getDate() + 7); next.setHours(2, 0, 0, 0); break; case "monthly": next.setMonth(next.getMonth() + 1); next.setDate(1); next.setHours(2, 0, 0, 0); break; } return next; } async function calculateDataHash(): Promise { // Calculate hash of actual business data (excluding Backup table) // We do this by querying all the data and hashing it const [accounts, transactions, folders, categories] = await Promise.all([ prisma.account.findMany({ orderBy: { id: "asc" } }), prisma.transaction.findMany({ orderBy: { id: "asc" } }), prisma.folder.findMany({ orderBy: { id: "asc" } }), prisma.category.findMany({ orderBy: { id: "asc" } }), ]); // Create a deterministic string representation of all data const dataString = JSON.stringify({ accounts: accounts.map((a) => ({ id: a.id, name: a.name, bankId: a.bankId, accountNumber: a.accountNumber, type: a.type, folderId: a.folderId, balance: a.balance, currency: a.currency, lastImport: a.lastImport, externalUrl: a.externalUrl, })), transactions: transactions.map((t) => ({ id: t.id, accountId: t.accountId, date: t.date, amount: t.amount, description: t.description, type: t.type, categoryId: t.categoryId, isReconciled: t.isReconciled, fitId: t.fitId, memo: t.memo, checkNum: t.checkNum, })), folders: folders.map((f) => ({ id: f.id, name: f.name, parentId: f.parentId, color: f.color, icon: f.icon, })), categories: categories.map((c) => ({ id: c.id, name: c.name, color: c.color, icon: c.icon, keywords: c.keywords, parentId: c.parentId, })), }); return createHash("sha256").update(dataString).digest("hex"); } export const backupService = { async createBackup(force: boolean = false): Promise<{ id: string; filename: string; size: number; skipped?: boolean; }> { await ensureBackupDir(); const dbPath = getDatabasePath(); if (!existsSync(dbPath)) { throw new Error(`Database file not found: ${dbPath}`); } // Get the most recent backup first (to check if we have a dataHash stored) const lastBackup = await prisma.backup.findFirst({ orderBy: { createdAt: "desc" }, }); // Calculate hash of actual business data (excluding Backup table) // This hash represents the actual data state, not the database file state let currentDataHash: string; try { currentDataHash = await calculateDataHash(); } catch (error) { console.error("Error calculating data hash:", error); throw new Error("Failed to calculate data hash"); } // If a backup exists and not forcing, compare data hashes if (!force && lastBackup && lastBackup.dataHash) { if (currentDataHash === lastBackup.dataHash) { // Update settings to reflect that backup is still current const settings = await loadSettings(); settings.lastBackup = new Date().toISOString(); settings.nextBackup = getNextBackupDate( settings.frequency, ).toISOString(); await saveSettings(settings); // Return existing backup without creating a new file return { id: lastBackup.id, filename: lastBackup.filename, size: lastBackup.size, skipped: true, }; } } // Hashes are different or no backup exists, create new backup const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const filename = `backup-${timestamp}.db`; const backupPath = path.join(BACKUP_DIR, filename); // Copy database file await fs.copyFile(dbPath, backupPath); // Get file size const stats = await fs.stat(backupPath); const size = stats.size; // Save metadata to database (including dataHash) const backup = await prisma.backup.create({ data: { filename, filePath: backupPath, size, dataHash: currentDataHash, }, }); // Update settings const settings = await loadSettings(); settings.lastBackup = new Date().toISOString(); settings.nextBackup = getNextBackupDate(settings.frequency).toISOString(); await saveSettings(settings); // Rotate old backups await this.rotateBackups(); return { id: backup.id, filename: backup.filename, size: backup.size, }; }, async rotateBackups(): Promise { // Get all backups ordered by creation date (oldest first) const backups = await prisma.backup.findMany({ orderBy: { createdAt: "asc" }, }); // If we have more than MAX_BACKUPS, delete the oldest ones if (backups.length > MAX_BACKUPS) { const toDelete = backups.slice(0, backups.length - MAX_BACKUPS); for (const backup of toDelete) { // Delete file try { if (existsSync(backup.filePath)) { await fs.unlink(backup.filePath); } } catch (error) { console.error( `Error deleting backup file ${backup.filePath}:`, error, ); } // Delete metadata await prisma.backup.delete({ where: { id: backup.id }, }); } } }, async getAllBackups() { return prisma.backup.findMany({ orderBy: { createdAt: "desc" }, }); }, async getBackup(id: string) { return prisma.backup.findUnique({ where: { id }, }); }, async deleteBackup(id: string): Promise { const backup = await prisma.backup.findUnique({ where: { id }, }); if (!backup) { throw new Error("Backup not found"); } // Delete file try { if (existsSync(backup.filePath)) { await fs.unlink(backup.filePath); } } catch (error) { console.error(`Error deleting backup file ${backup.filePath}:`, error); } // Delete metadata await prisma.backup.delete({ where: { id }, }); }, async restoreBackup(id: string): Promise { const backup = await prisma.backup.findUnique({ where: { id }, }); if (!backup) { throw new Error("Backup not found"); } if (!existsSync(backup.filePath)) { throw new Error("Backup file not found"); } const dbPath = getDatabasePath(); // Create a backup of current database before restoring const currentBackupPath = `${dbPath}.pre-restore-${Date.now()}`; if (existsSync(dbPath)) { await fs.copyFile(dbPath, currentBackupPath); } // Restore backup await fs.copyFile(backup.filePath, dbPath); }, async getSettings(): Promise { return loadSettings(); }, async updateSettings( settings: Partial, ): Promise { const current = await loadSettings(); const updated = { ...current, ...settings }; // Recalculate next backup if frequency changed if (settings.frequency && settings.frequency !== current.frequency) { updated.nextBackup = getNextBackupDate(settings.frequency).toISOString(); } await saveSettings(updated); return updated; }, async shouldRunAutomaticBackup(): Promise { const settings = await loadSettings(); if (!settings.enabled) { return false; } if (!settings.nextBackup) { return true; // First backup } const nextBackupDate = new Date(settings.nextBackup); return new Date() >= nextBackupDate; }, };