feat: add BackupCard component and corresponding Backup model to enhance settings functionality and data management
This commit is contained in:
370
services/backup.service.ts
Normal file
370
services/backup.service.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
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<BackupSettings> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
// 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<void> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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<BackupSettings> {
|
||||
return loadSettings();
|
||||
},
|
||||
|
||||
async updateSettings(settings: Partial<BackupSettings>): Promise<BackupSettings> {
|
||||
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<boolean> {
|
||||
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;
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user