feat: add BackupCard component and corresponding Backup model to enhance settings functionality and data management
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -19,6 +19,7 @@ dist
|
||||
/lib/generated/prisma
|
||||
prisma/*.db
|
||||
prisma/*.db-journal
|
||||
prisma/backups
|
||||
|
||||
# Debug
|
||||
npm-debug.log*
|
||||
|
||||
20
app/api/backups/[id]/restore/route.ts
Normal file
20
app/api/backups/[id]/restore/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
20
app/api/backups/[id]/route.ts
Normal file
20
app/api/backups/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
38
app/api/backups/auto/route.ts
Normal file
38
app/api/backups/auto/route.ts
Normal 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
32
app/api/backups/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
30
app/api/backups/settings/route.ts
Normal file
30
app/api/backups/settings/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
<BackupCard />
|
||||
|
||||
<DangerZoneCard
|
||||
categorizedCount={categorizedCount}
|
||||
onClearCategories={clearAllCategories}
|
||||
|
||||
437
components/settings/backup-card.tsx
Normal file
437
components/settings/backup-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
88
docs/BACKUP_SYSTEM.md
Normal file
88
docs/BACKUP_SYSTEM.md
Normal 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
|
||||
|
||||
6
prisma/backup-settings.json
Normal file
6
prisma/backup-settings.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"frequency": "hourly",
|
||||
"lastBackup": "2025-11-30T06:48:53.740Z",
|
||||
"nextBackup": "2025-11-30T07:00:00.000Z"
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
31
scripts/run-backup.ts
Normal file
31
scripts/run-backup.ts
Normal 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
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;
|
||||
},
|
||||
};
|
||||
|
||||
9
vercel.json
Normal file
9
vercel.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"crons": [
|
||||
{
|
||||
"path": "/api/backups/auto",
|
||||
"schedule": "0 2 * * *"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user