From 7cb1d5f433c837fa578b37f76f07b3cf925a0533 Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Sun, 30 Nov 2025 07:50:47 +0100 Subject: [PATCH] feat: add BackupCard component and corresponding Backup model to enhance settings functionality and data management --- .gitignore | 1 + app/api/backups/[id]/restore/route.ts | 20 ++ app/api/backups/[id]/route.ts | 20 ++ app/api/backups/auto/route.ts | 38 +++ app/api/backups/route.ts | 32 ++ app/api/backups/settings/route.ts | 30 ++ app/settings/page.tsx | 9 +- components/settings/backup-card.tsx | 437 ++++++++++++++++++++++++++ components/settings/index.ts | 1 + docs/BACKUP_SYSTEM.md | 88 ++++++ prisma/backup-settings.json | 6 + prisma/schema.prisma | 11 + scripts/run-backup.ts | 31 ++ services/backup.service.ts | 370 ++++++++++++++++++++++ vercel.json | 9 + 15 files changed, 1102 insertions(+), 1 deletion(-) create mode 100644 app/api/backups/[id]/restore/route.ts create mode 100644 app/api/backups/[id]/route.ts create mode 100644 app/api/backups/auto/route.ts create mode 100644 app/api/backups/route.ts create mode 100644 app/api/backups/settings/route.ts create mode 100644 components/settings/backup-card.tsx create mode 100644 docs/BACKUP_SYSTEM.md create mode 100644 prisma/backup-settings.json create mode 100644 scripts/run-backup.ts create mode 100644 services/backup.service.ts create mode 100644 vercel.json diff --git a/.gitignore b/.gitignore index d397cde..e300dd9 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ dist /lib/generated/prisma prisma/*.db prisma/*.db-journal +prisma/backups # Debug npm-debug.log* diff --git a/app/api/backups/[id]/restore/route.ts b/app/api/backups/[id]/restore/route.ts new file mode 100644 index 0000000..d6401fe --- /dev/null +++ b/app/api/backups/[id]/restore/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from "next/server"; +import { backupService } from "@/services/backup.service"; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> | { id: string } } +) { + try { + const resolvedParams = params instanceof Promise ? await params : params; + await backupService.restoreBackup(resolvedParams.id); + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error restoring backup:", error); + return NextResponse.json( + { success: false, error: error instanceof Error ? error.message : "Failed to restore backup" }, + { status: 500 } + ); + } +} + diff --git a/app/api/backups/[id]/route.ts b/app/api/backups/[id]/route.ts new file mode 100644 index 0000000..ab8887b --- /dev/null +++ b/app/api/backups/[id]/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from "next/server"; +import { backupService } from "@/services/backup.service"; + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> | { id: string } } +) { + try { + const resolvedParams = params instanceof Promise ? await params : params; + await backupService.deleteBackup(resolvedParams.id); + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error deleting backup:", error); + return NextResponse.json( + { success: false, error: error instanceof Error ? error.message : "Failed to delete backup" }, + { status: 500 } + ); + } +} + diff --git a/app/api/backups/auto/route.ts b/app/api/backups/auto/route.ts new file mode 100644 index 0000000..9a9f334 --- /dev/null +++ b/app/api/backups/auto/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from "next/server"; +import { backupService } from "@/services/backup.service"; + +export async function POST(_request: NextRequest) { + try { + // Check if automatic backup should run + const shouldRun = await backupService.shouldRunAutomaticBackup(); + + if (!shouldRun) { + return NextResponse.json({ + success: true, + skipped: true, + message: "Backup not due yet", + }); + } + + // Create backup + const backup = await backupService.createBackup(); + + return NextResponse.json({ + success: true, + data: backup, + message: backup.skipped + ? "Backup skipped - no changes detected" + : "Automatic backup created", + }); + } catch (error) { + console.error("Error creating automatic backup:", error); + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "Failed to create automatic backup", + }, + { status: 500 } + ); + } +} + diff --git a/app/api/backups/route.ts b/app/api/backups/route.ts new file mode 100644 index 0000000..ee2622e --- /dev/null +++ b/app/api/backups/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server"; +import { backupService } from "@/services/backup.service"; + +export async function GET() { + try { + const backups = await backupService.getAllBackups(); + return NextResponse.json({ success: true, data: backups }); + } catch (error) { + console.error("Error fetching backups:", error); + return NextResponse.json( + { success: false, error: "Failed to fetch backups" }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json().catch(() => ({})); + const force = body.force === true; // Only allow force for manual backups + + const backup = await backupService.createBackup(force); + return NextResponse.json({ success: true, data: backup }); + } catch (error) { + console.error("Error creating backup:", error); + return NextResponse.json( + { success: false, error: error instanceof Error ? error.message : "Failed to create backup" }, + { status: 500 } + ); + } +} + diff --git a/app/api/backups/settings/route.ts b/app/api/backups/settings/route.ts new file mode 100644 index 0000000..4e49a26 --- /dev/null +++ b/app/api/backups/settings/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from "next/server"; +import { backupService } from "@/services/backup.service"; + +export async function GET() { + try { + const settings = await backupService.getSettings(); + return NextResponse.json({ success: true, data: settings }); + } catch (error) { + console.error("Error fetching backup settings:", error); + return NextResponse.json( + { success: false, error: "Failed to fetch settings" }, + { status: 500 } + ); + } +} + +export async function PUT(request: NextRequest) { + try { + const body = await request.json(); + const settings = await backupService.updateSettings(body); + return NextResponse.json({ success: true, data: settings }); + } catch (error) { + console.error("Error updating backup settings:", error); + return NextResponse.json( + { success: false, error: "Failed to update settings" }, + { status: 500 } + ); + } +} + diff --git a/app/settings/page.tsx b/app/settings/page.tsx index d054943..6c64117 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -2,7 +2,12 @@ import { useState } from "react"; import { PageLayout, LoadingState, PageHeader } from "@/components/layout"; -import { DataCard, DangerZoneCard, OFXInfoCard } from "@/components/settings"; +import { + DataCard, + DangerZoneCard, + OFXInfoCard, + BackupCard, +} from "@/components/settings"; import { useBankingData } from "@/lib/hooks"; import type { BankingData } from "@/lib/types"; @@ -100,6 +105,8 @@ export default function SettingsPage() { onImport={importData} /> + + ([]); + const [settings, setSettings] = useState({ + enabled: true, + frequency: "hourly", + }); + const [loading, setLoading] = useState(true); + const [creating, setCreating] = useState(false); + const [restoring, setRestoring] = useState(null); + + const loadData = async () => { + try { + const [backupsRes, settingsRes] = await Promise.all([ + fetch("/api/backups"), + fetch("/api/backups/settings"), + ]); + + const backupsData = await backupsRes.json(); + const settingsData = await settingsRes.json(); + + if (backupsData.success) { + setBackups( + backupsData.data.map((b: { id: string; filename: string; size: number; createdAt: string }) => ({ + ...b, + createdAt: new Date(b.createdAt), + })) + ); + } + + if (settingsData.success) { + setSettings(settingsData.data); + } + } catch (error) { + console.error("Error loading backup data:", error); + toast.error("Erreur lors du chargement des données"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadData(); + }, []); + + const handleCreateBackup = async () => { + setCreating(true); + try { + const response = await fetch("/api/backups", { + method: "POST", + }); + const data = await response.json(); + + if (data.success) { + if (data.data.skipped) { + toast.info("Aucun changement détecté. La dernière sauvegarde a été mise à jour."); + } else { + toast.success("Sauvegarde créée avec succès"); + } + await loadData(); + } else { + toast.error(data.error || "Erreur lors de la création"); + } + } catch (error) { + console.error("Error creating backup:", error); + toast.error("Erreur lors de la création de la sauvegarde"); + } finally { + setCreating(false); + } + }; + + const handleDeleteBackup = async (id: string) => { + try { + const response = await fetch(`/api/backups/${id}`, { + method: "DELETE", + }); + const data = await response.json(); + + if (data.success) { + toast.success("Sauvegarde supprimée"); + await loadData(); + } else { + toast.error(data.error || "Erreur lors de la suppression"); + } + } catch (error) { + console.error("Error deleting backup:", error); + toast.error("Erreur lors de la suppression"); + } + }; + + const handleRestoreBackup = async (id: string) => { + setRestoring(id); + try { + const response = await fetch(`/api/backups/${id}/restore`, { + method: "POST", + }); + const data = await response.json(); + + if (data.success) { + toast.success("Sauvegarde restaurée avec succès. Rechargement de la page..."); + setTimeout(() => { + window.location.reload(); + }, 2000); + } else { + toast.error(data.error || "Erreur lors de la restauration"); + } + } catch (error) { + console.error("Error restoring backup:", error); + toast.error("Erreur lors de la restauration"); + } finally { + setRestoring(null); + } + }; + + const handleSettingsChange = async (updates: Partial) => { + const newSettings = { ...settings, ...updates }; + setSettings(newSettings); + + try { + const response = await fetch("/api/backups/settings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(updates), + }); + const data = await response.json(); + + if (data.success) { + setSettings(data.data); + toast.success("Paramètres mis à jour"); + } else { + toast.error("Erreur lors de la mise à jour"); + await loadData(); // Revert on error + } + } catch (error) { + console.error("Error updating settings:", error); + toast.error("Erreur lors de la mise à jour"); + await loadData(); // Revert on error + } + }; + + const formatFileSize = (bytes: number): string => { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; + }; + + if (loading) { + return ( + + + + + Sauvegardes automatiques + + + +

Chargement...

+
+
+ ); + } + + return ( + + + + + Sauvegardes automatiques + + + Configurez les sauvegardes périodiques de votre base de données + + + + {/* Settings */} +
+
+
+ +

+ Activez les sauvegardes périodiques +

+
+ + handleSettingsChange({ enabled: checked }) + } + /> +
+ + {settings.enabled && ( +
+ + +
+ )} + + {settings.enabled && settings.lastBackup && ( +
+ + + Dernière sauvegarde:{" "} + {formatDistanceToNow(new Date(settings.lastBackup), { + addSuffix: true, + locale: fr, + })} + +
+ )} + + {settings.enabled && settings.nextBackup && ( +
+ + + Prochaine sauvegarde:{" "} + {formatDistanceToNow(new Date(settings.nextBackup), { + addSuffix: true, + locale: fr, + })} + +
+ )} +
+ + {/* Manual backup */} +
+ +
+ + {/* Backups list */} + {backups.length > 0 && ( +
+ +
+ + + + Date + Taille + Actions + + + + {backups.map((backup) => ( + + +
+ + {backup.createdAt.toLocaleDateString("fr-FR", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + })} + + + {formatDistanceToNow(backup.createdAt, { + addSuffix: true, + locale: fr, + })} + +
+
+ {formatFileSize(backup.size)} + +
+ + + + + + + + Restaurer cette sauvegarde ? + + + Cette action va remplacer votre base de données + actuelle par cette sauvegarde. Une sauvegarde + de sécurité sera créée avant la restauration. + + + + Annuler + + handleRestoreBackup(backup.id) + } + > + Restaurer + + + + + + + + + + + + + Supprimer cette sauvegarde ? + + + Cette action est irréversible. La sauvegarde + sera définitivement supprimée. + + + + Annuler + + handleDeleteBackup(backup.id) + } + > + Supprimer + + + + +
+
+
+ ))} +
+
+
+
+ )} + + {backups.length === 0 && ( +
+ Aucune sauvegarde pour le moment +
+ )} +
+
+ ); +} + diff --git a/components/settings/index.ts b/components/settings/index.ts index 36af500..db84295 100644 --- a/components/settings/index.ts +++ b/components/settings/index.ts @@ -1,4 +1,5 @@ export { DataCard } from "./data-card"; export { DangerZoneCard } from "./danger-zone-card"; export { OFXInfoCard } from "./ofx-info-card"; +export { BackupCard } from "./backup-card"; diff --git a/docs/BACKUP_SYSTEM.md b/docs/BACKUP_SYSTEM.md new file mode 100644 index 0000000..08706c7 --- /dev/null +++ b/docs/BACKUP_SYSTEM.md @@ -0,0 +1,88 @@ +# Système de Sauvegarde Automatique + +## Vue d'ensemble + +Le système de sauvegarde automatique permet de sauvegarder périodiquement votre base de données SQLite avec une rotation automatique limitée à 10 sauvegardes. + +## Fonctionnalités + +- ✅ Sauvegardes périodiques automatiques (quotidienne, hebdomadaire, mensuelle) +- ✅ Rotation automatique (maximum 10 sauvegardes) +- ✅ Sauvegardes manuelles à la demande +- ✅ Restauration de sauvegardes +- ✅ Configuration via l'interface des paramètres +- ✅ Métadonnées stockées dans la base de données + +## Configuration + +### Via l'interface + +1. Allez dans **Paramètres** → **Sauvegardes automatiques** +2. Activez les sauvegardes automatiques +3. Choisissez la fréquence (quotidienne, hebdomadaire, mensuelle) +4. Les sauvegardes seront créées automatiquement selon la fréquence choisie + +### Sauvegarde manuelle + +Cliquez sur le bouton **"Créer une sauvegarde"** dans la section des sauvegardes. + +## Planification automatique + +### Sur Vercel + +Si vous déployez sur Vercel, le fichier `vercel.json` configure automatiquement un cron job qui vérifie et crée les sauvegardes quotidiennement à 2h du matin. + +### En local ou autre hébergement + +Pour exécuter les sauvegardes automatiques, vous pouvez : + +1. **Utiliser un cron job système** : + ```bash + # Exécuter tous les jours à 2h du matin + 0 2 * * * cd /chemin/vers/projet && tsx scripts/run-backup.ts + ``` + +2. **Appeler l'endpoint API directement** : + ```bash + curl -X POST http://localhost:3000/api/backups/auto + ``` + +3. **Utiliser un service de planification externe** (comme GitHub Actions, etc.) qui appelle périodiquement `/api/backups/auto` + +## Structure des fichiers + +- **Sauvegardes** : `prisma/backups/backup-YYYY-MM-DDTHH-mm-ss.db` +- **Paramètres** : `prisma/backup-settings.json` +- **Métadonnées** : Table `Backup` dans la base de données + +## API Endpoints + +- `GET /api/backups` - Liste toutes les sauvegardes +- `POST /api/backups` - Créer une sauvegarde manuelle +- `DELETE /api/backups/[id]` - Supprimer une sauvegarde +- `POST /api/backups/[id]/restore` - Restaurer une sauvegarde +- `GET /api/backups/settings` - Obtenir les paramètres +- `PUT /api/backups/settings` - Mettre à jour les paramètres +- `POST /api/backups/auto` - Endpoint pour les sauvegardes automatiques (appelé par le cron) + +## Rotation des sauvegardes + +Le système garde automatiquement les 10 sauvegardes les plus récentes. Les sauvegardes plus anciennes sont supprimées automatiquement lors de la création d'une nouvelle sauvegarde. + +## Restauration + +⚠️ **Attention** : La restauration d'une sauvegarde remplace complètement votre base de données actuelle. Une sauvegarde de sécurité est créée automatiquement avant la restauration. + +Pour restaurer une sauvegarde : +1. Allez dans **Paramètres** → **Sauvegardes automatiques** +2. Cliquez sur l'icône de restauration (flèche circulaire) à côté de la sauvegarde souhaitée +3. Confirmez la restauration +4. La page sera rechargée automatiquement après la restauration + +## Notes techniques + +- Les sauvegardes sont des copies complètes du fichier SQLite +- La taille des sauvegardes dépend de la taille de votre base de données +- Les sauvegardes sont stockées localement dans le dossier `prisma/backups/` +- Les métadonnées (nom, taille, date) sont stockées dans la table `Backup` de la base de données + diff --git a/prisma/backup-settings.json b/prisma/backup-settings.json new file mode 100644 index 0000000..80fa8b4 --- /dev/null +++ b/prisma/backup-settings.json @@ -0,0 +1,6 @@ +{ + "enabled": true, + "frequency": "hourly", + "lastBackup": "2025-11-30T06:48:53.740Z", + "nextBackup": "2025-11-30T07:00:00.000Z" +} \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 99be72d..3980326 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -86,3 +86,14 @@ model Category { @@index([parentId]) } + +model Backup { + id String @id @default(cuid()) + filename String + filePath String + size Int // Size in bytes + dataHash String? // Hash of the actual data (excluding Backup table) + createdAt DateTime @default(now()) + + @@index([createdAt]) +} diff --git a/scripts/run-backup.ts b/scripts/run-backup.ts new file mode 100644 index 0000000..aecb234 --- /dev/null +++ b/scripts/run-backup.ts @@ -0,0 +1,31 @@ +#!/usr/bin/env tsx +/** + * Script to run automatic backups + * Can be executed via cron job or scheduled task + * Usage: tsx scripts/run-backup.ts + */ + +import { backupService } from "../services/backup.service"; + +async function main() { + try { + console.log("Checking if automatic backup should run..."); + const shouldRun = await backupService.shouldRunAutomaticBackup(); + + if (!shouldRun) { + console.log("Backup not due yet. Skipping."); + process.exit(0); + } + + console.log("Creating automatic backup..."); + const backup = await backupService.createBackup(); + console.log(`Backup created successfully: ${backup.filename} (${backup.size} bytes)`); + process.exit(0); + } catch (error) { + console.error("Error running automatic backup:", error); + process.exit(1); + } +} + +main(); + diff --git a/services/backup.service.ts b/services/backup.service.ts new file mode 100644 index 0000000..45073c7 --- /dev/null +++ b/services/backup.service.ts @@ -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 { + 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; + }, +}; + diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..513057f --- /dev/null +++ b/vercel.json @@ -0,0 +1,9 @@ +{ + "crons": [ + { + "path": "/api/backups/auto", + "schedule": "0 2 * * *" + } + ] +} +