feat: add BackupCard component and corresponding Backup model to enhance settings functionality and data management

This commit is contained in:
Julien Froidefond
2025-11-30 07:50:47 +01:00
parent f17b83fb95
commit 7cb1d5f433
15 changed files with 1102 additions and 1 deletions

1
.gitignore vendored
View File

@@ -19,6 +19,7 @@ dist
/lib/generated/prisma /lib/generated/prisma
prisma/*.db prisma/*.db
prisma/*.db-journal prisma/*.db-journal
prisma/backups
# Debug # Debug
npm-debug.log* npm-debug.log*

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}

32
app/api/backups/route.ts Normal file
View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -2,7 +2,12 @@
import { useState } from "react"; import { useState } from "react";
import { PageLayout, LoadingState, PageHeader } from "@/components/layout"; 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 { useBankingData } from "@/lib/hooks";
import type { BankingData } from "@/lib/types"; import type { BankingData } from "@/lib/types";
@@ -100,6 +105,8 @@ export default function SettingsPage() {
onImport={importData} onImport={importData}
/> />
<BackupCard />
<DangerZoneCard <DangerZoneCard
categorizedCount={categorizedCount} categorizedCount={categorizedCount}
onClearCategories={clearAllCategories} onClearCategories={clearAllCategories}

View File

@@ -0,0 +1,437 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import {
Database,
Trash2,
RotateCcw,
Save,
Clock,
} from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { fr } from "date-fns/locale/fr";
import { toast } from "sonner";
interface Backup {
id: string;
filename: string;
size: number;
createdAt: Date;
}
interface BackupSettings {
enabled: boolean;
frequency: "hourly" | "daily" | "weekly" | "monthly";
lastBackup?: string;
nextBackup?: string;
}
export function BackupCard() {
const [backups, setBackups] = useState<Backup[]>([]);
const [settings, setSettings] = useState<BackupSettings>({
enabled: true,
frequency: "hourly",
});
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [restoring, setRestoring] = useState<string | null>(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<BackupSettings>) => {
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 (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="w-5 h-5" />
Sauvegardes automatiques
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">Chargement...</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="w-5 h-5" />
Sauvegardes automatiques
</CardTitle>
<CardDescription>
Configurez les sauvegardes périodiques de votre base de données
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Settings */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="backup-enabled">Sauvegardes automatiques</Label>
<p className="text-sm text-muted-foreground">
Activez les sauvegardes périodiques
</p>
</div>
<Switch
id="backup-enabled"
checked={settings.enabled}
onCheckedChange={(checked) =>
handleSettingsChange({ enabled: checked })
}
/>
</div>
{settings.enabled && (
<div className="space-y-2">
<Label htmlFor="backup-frequency">Fréquence</Label>
<Select
value={settings.frequency}
onValueChange={(value: "hourly" | "daily" | "weekly" | "monthly") =>
handleSettingsChange({ frequency: value })
}
>
<SelectTrigger id="backup-frequency">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hourly">Horaire</SelectItem>
<SelectItem value="daily">Quotidienne</SelectItem>
<SelectItem value="weekly">Hebdomadaire</SelectItem>
<SelectItem value="monthly">Mensuelle</SelectItem>
</SelectContent>
</Select>
</div>
)}
{settings.enabled && settings.lastBackup && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="w-4 h-4" />
<span>
Dernière sauvegarde:{" "}
{formatDistanceToNow(new Date(settings.lastBackup), {
addSuffix: true,
locale: fr,
})}
</span>
</div>
)}
{settings.enabled && settings.nextBackup && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="w-4 h-4" />
<span>
Prochaine sauvegarde:{" "}
{formatDistanceToNow(new Date(settings.nextBackup), {
addSuffix: true,
locale: fr,
})}
</span>
</div>
)}
</div>
{/* Manual backup */}
<div className="flex gap-2">
<Button
onClick={handleCreateBackup}
disabled={creating}
variant="outline"
className="flex-1"
>
<Save className="w-4 h-4 mr-2" />
{creating ? "Création..." : "Créer une sauvegarde"}
</Button>
</div>
{/* Backups list */}
{backups.length > 0 && (
<div className="space-y-2">
<Label>Sauvegardes ({backups.length}/10)</Label>
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Taille</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{backups.map((backup) => (
<TableRow key={backup.id}>
<TableCell>
<div className="flex flex-col">
<span className="font-medium">
{backup.createdAt.toLocaleDateString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</span>
<span className="text-xs text-muted-foreground">
{formatDistanceToNow(backup.createdAt, {
addSuffix: true,
locale: fr,
})}
</span>
</div>
</TableCell>
<TableCell>{formatFileSize(backup.size)}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={restoring === backup.id}
>
<RotateCcw className="w-4 h-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Restaurer cette sauvegarde ?
</AlertDialogTitle>
<AlertDialogDescription>
Cette action va remplacer votre base de données
actuelle par cette sauvegarde. Une sauvegarde
de sécurité sera créée avant la restauration.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
handleRestoreBackup(backup.id)
}
>
Restaurer
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm">
<Trash2 className="w-4 h-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Supprimer cette sauvegarde ?
</AlertDialogTitle>
<AlertDialogDescription>
Cette action est irréversible. La sauvegarde
sera définitivement supprimée.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
handleDeleteBackup(backup.id)
}
>
Supprimer
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
{backups.length === 0 && (
<div className="text-center py-8 text-sm text-muted-foreground">
Aucune sauvegarde pour le moment
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -1,4 +1,5 @@
export { DataCard } from "./data-card"; export { DataCard } from "./data-card";
export { DangerZoneCard } from "./danger-zone-card"; export { DangerZoneCard } from "./danger-zone-card";
export { OFXInfoCard } from "./ofx-info-card"; export { OFXInfoCard } from "./ofx-info-card";
export { BackupCard } from "./backup-card";

88
docs/BACKUP_SYSTEM.md Normal file
View File

@@ -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

View File

@@ -0,0 +1,6 @@
{
"enabled": true,
"frequency": "hourly",
"lastBackup": "2025-11-30T06:48:53.740Z",
"nextBackup": "2025-11-30T07:00:00.000Z"
}

View File

@@ -86,3 +86,14 @@ model Category {
@@index([parentId]) @@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])
}

31
scripts/run-backup.ts Normal file
View File

@@ -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();

370
services/backup.service.ts Normal file
View 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;
},
};

9
vercel.json Normal file
View File

@@ -0,0 +1,9 @@
{
"crons": [
{
"path": "/api/backups/auto",
"schedule": "0 2 * * *"
}
]
}