Files
fintrack/services/backup.service.ts

388 lines
10 KiB
TypeScript

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;
},
};