388 lines
10 KiB
TypeScript
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;
|
|
},
|
|
};
|