feat(automatic-backup-to-usb) - done v1
This commit is contained in:
@@ -1,14 +1,19 @@
|
|||||||
import cron from "node-cron";
|
import cron from "node-cron";
|
||||||
|
import fs from "fs";
|
||||||
import { storage } from "../storage";
|
import { storage } from "../storage";
|
||||||
import { NotificationTypes } from "@repo/db/types";
|
import { NotificationTypes } from "@repo/db/types";
|
||||||
|
import { backupDatabaseToPath } from "../services/databaseBackupService";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Daily cron job to check if users haven't backed up in 7 days
|
* Daily cron job to check if users haven't backed up in 7 days
|
||||||
* Creates a backup notification if overdue
|
* Creates a backup notification if overdue
|
||||||
*/
|
*/
|
||||||
export const startBackupCron = () => {
|
export const startBackupCron = () => {
|
||||||
cron.schedule("0 9 * * *", async () => {
|
cron.schedule("0 2 */3 * *", async () => {
|
||||||
console.log("🔄 Running daily backup check...");
|
// Every 3 calendar days, at 2 AM
|
||||||
|
// cron.schedule("*/10 * * * * *", async () => { // Every 10 seconds (for Test)
|
||||||
|
|
||||||
|
console.log("🔄 Running backup check...");
|
||||||
|
|
||||||
const userBatchSize = 100;
|
const userBatchSize = 100;
|
||||||
let userOffset = 0;
|
let userOffset = 0;
|
||||||
@@ -23,7 +28,52 @@ export const startBackupCron = () => {
|
|||||||
if (user.id == null) {
|
if (user.id == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const destination = await storage.getActiveBackupDestination(user.id);
|
||||||
const lastBackup = await storage.getLastBackup(user.id);
|
const lastBackup = await storage.getLastBackup(user.id);
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// CASE 1: Destination exists → auto backup
|
||||||
|
// ==============================
|
||||||
|
if (destination) {
|
||||||
|
if (!fs.existsSync(destination.path)) {
|
||||||
|
await storage.createNotification(
|
||||||
|
user.id,
|
||||||
|
"BACKUP",
|
||||||
|
"❌ Automatic backup failed: external drive not connected."
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const filename = `dental_backup_${Date.now()}.zip`;
|
||||||
|
|
||||||
|
await backupDatabaseToPath({
|
||||||
|
destinationPath: destination.path,
|
||||||
|
filename,
|
||||||
|
});
|
||||||
|
|
||||||
|
await storage.createBackup(user.id);
|
||||||
|
await storage.deleteNotificationsByType(user.id, "BACKUP");
|
||||||
|
|
||||||
|
console.log(`✅ Auto backup successful for user ${user.id}`);
|
||||||
|
continue;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Auto backup failed for user ${user.id}`, err);
|
||||||
|
|
||||||
|
await storage.createNotification(
|
||||||
|
user.id,
|
||||||
|
"BACKUP",
|
||||||
|
"❌ Automatic backup failed. Please check your backup destination."
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// CASE 2: No destination → fallback to reminder
|
||||||
|
// ==============================
|
||||||
|
|
||||||
const daysSince = lastBackup?.createdAt
|
const daysSince = lastBackup?.createdAt
|
||||||
? (Date.now() - new Date(lastBackup.createdAt).getTime()) /
|
? (Date.now() - new Date(lastBackup.createdAt).getTime()) /
|
||||||
(1000 * 60 * 60 * 24)
|
(1000 * 60 * 60 * 24)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import fs from "fs";
|
|||||||
import { prisma } from "@repo/db/client";
|
import { prisma } from "@repo/db/client";
|
||||||
import { storage } from "../storage";
|
import { storage } from "../storage";
|
||||||
import archiver from "archiver";
|
import archiver from "archiver";
|
||||||
|
import { backupDatabaseToPath } from "../services/databaseBackupService";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -33,6 +34,8 @@ router.post("/backup", async (req: Request, res: Response): Promise<any> => {
|
|||||||
return res.status(401).json({ error: "Unauthorized" });
|
return res.status(401).json({ error: "Unauthorized" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const destination = await storage.getActiveBackupDestination(userId);
|
||||||
|
|
||||||
// create a unique tmp directory for directory-format dump
|
// create a unique tmp directory for directory-format dump
|
||||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dental_backup_")); // MUST
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dental_backup_")); // MUST
|
||||||
|
|
||||||
@@ -240,4 +243,118 @@ router.get("/status", async (req: Request, res: Response): Promise<any> => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// Backup Destination CRUD
|
||||||
|
// ==============================
|
||||||
|
|
||||||
|
// CREATE / UPDATE destination
|
||||||
|
router.post("/destination", async (req, res) => {
|
||||||
|
const userId = req.user?.id;
|
||||||
|
const { path: destinationPath } = req.body;
|
||||||
|
|
||||||
|
if (!userId) return res.status(401).json({ error: "Unauthorized" });
|
||||||
|
if (!destinationPath)
|
||||||
|
return res.status(400).json({ error: "Path is required" });
|
||||||
|
|
||||||
|
// validate path exists
|
||||||
|
if (!fs.existsSync(destinationPath)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Backup path does not exist or drive not connected",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const destination = await storage.createBackupDestination(
|
||||||
|
userId,
|
||||||
|
destinationPath
|
||||||
|
);
|
||||||
|
res.json(destination);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: "Failed to save backup destination" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET all destinations
|
||||||
|
router.get("/destination", async (req, res) => {
|
||||||
|
const userId = req.user?.id;
|
||||||
|
if (!userId) return res.status(401).json({ error: "Unauthorized" });
|
||||||
|
|
||||||
|
const destinations = await storage.getAllBackupDestination(userId);
|
||||||
|
res.json(destinations);
|
||||||
|
});
|
||||||
|
|
||||||
|
// UPDATE destination
|
||||||
|
router.put("/destination/:id", async (req, res) => {
|
||||||
|
const userId = req.user?.id;
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const { path: destinationPath } = req.body;
|
||||||
|
|
||||||
|
if (!userId) return res.status(401).json({ error: "Unauthorized" });
|
||||||
|
if (!destinationPath)
|
||||||
|
return res.status(400).json({ error: "Path is required" });
|
||||||
|
|
||||||
|
if (!fs.existsSync(destinationPath)) {
|
||||||
|
return res.status(400).json({ error: "Path does not exist" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await storage.updateBackupDestination(
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
destinationPath
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE destination
|
||||||
|
router.delete("/destination/:id", async (req, res) => {
|
||||||
|
const userId = req.user?.id;
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
|
||||||
|
if (!userId) return res.status(401).json({ error: "Unauthorized" });
|
||||||
|
|
||||||
|
await storage.deleteBackupDestination(id, userId);
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/backup-path", async (req, res) => {
|
||||||
|
const userId = req.user?.id;
|
||||||
|
if (!userId) return res.status(401).json({ error: "Unauthorized" });
|
||||||
|
|
||||||
|
const destination = await storage.getActiveBackupDestination(userId);
|
||||||
|
if (!destination) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "No backup destination configured",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(destination.path)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error:
|
||||||
|
"Backup destination not found. External drive may be disconnected.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = `dental_backup_${Date.now()}.zip`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await backupDatabaseToPath({
|
||||||
|
destinationPath: destination.path,
|
||||||
|
filename,
|
||||||
|
});
|
||||||
|
|
||||||
|
await storage.createBackup(userId);
|
||||||
|
await storage.deleteNotificationsByType(userId, "BACKUP");
|
||||||
|
|
||||||
|
res.json({ success: true, filename });
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({
|
||||||
|
error: "Backup to destination failed",
|
||||||
|
details: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
85
apps/Backend/src/services/databaseBackupService.ts
Normal file
85
apps/Backend/src/services/databaseBackupService.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { spawn } from "child_process";
|
||||||
|
import fs from "fs";
|
||||||
|
import os from "os";
|
||||||
|
import path from "path";
|
||||||
|
import archiver from "archiver";
|
||||||
|
|
||||||
|
function safeRmDir(dir: string) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackupToPathParams {
|
||||||
|
destinationPath: string;
|
||||||
|
filename: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function backupDatabaseToPath({
|
||||||
|
destinationPath,
|
||||||
|
filename,
|
||||||
|
}: BackupToPathParams): Promise<void> {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dental_backup_"));
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const pgDump = spawn(
|
||||||
|
"pg_dump",
|
||||||
|
[
|
||||||
|
"-Fd",
|
||||||
|
"-j",
|
||||||
|
"4",
|
||||||
|
"--no-acl",
|
||||||
|
"--no-owner",
|
||||||
|
"-h",
|
||||||
|
process.env.DB_HOST || "localhost",
|
||||||
|
"-U",
|
||||||
|
process.env.DB_USER || "postgres",
|
||||||
|
process.env.DB_NAME || "dental_db",
|
||||||
|
"-f",
|
||||||
|
tmpDir,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PGPASSWORD: process.env.DB_PASSWORD,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
let pgError = "";
|
||||||
|
|
||||||
|
pgDump.stderr.on("data", (d) => (pgError += d.toString()));
|
||||||
|
|
||||||
|
pgDump.on("close", async (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
safeRmDir(tmpDir);
|
||||||
|
return reject(new Error(pgError || "pg_dump failed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputFile = path.join(destinationPath, filename);
|
||||||
|
const outputStream = fs.createWriteStream(outputFile);
|
||||||
|
|
||||||
|
const archive = archiver("zip");
|
||||||
|
|
||||||
|
outputStream.on("error", (err) => {
|
||||||
|
safeRmDir(tmpDir);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
archive.on("error", (err) => {
|
||||||
|
safeRmDir(tmpDir);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
archive.pipe(outputStream);
|
||||||
|
archive.directory(tmpDir + path.sep, false);
|
||||||
|
|
||||||
|
archive.finalize();
|
||||||
|
|
||||||
|
archive.on("end", () => {
|
||||||
|
safeRmDir(tmpDir);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DatabaseBackup } from "@repo/db/types";
|
import { DatabaseBackup, BackupDestination } from "@repo/db/types";
|
||||||
import { prisma as db } from "@repo/db/client";
|
import { prisma as db } from "@repo/db/client";
|
||||||
|
|
||||||
export interface IStorage {
|
export interface IStorage {
|
||||||
@@ -7,6 +7,33 @@ export interface IStorage {
|
|||||||
getLastBackup(userId: number): Promise<DatabaseBackup | null>;
|
getLastBackup(userId: number): Promise<DatabaseBackup | null>;
|
||||||
getBackups(userId: number, limit?: number): Promise<DatabaseBackup[]>;
|
getBackups(userId: number, limit?: number): Promise<DatabaseBackup[]>;
|
||||||
deleteBackups(userId: number): Promise<number>; // clears all for user
|
deleteBackups(userId: number): Promise<number>; // clears all for user
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// Backup Destination methods
|
||||||
|
// ==============================
|
||||||
|
createBackupDestination(
|
||||||
|
userId: number,
|
||||||
|
path: string
|
||||||
|
): Promise<BackupDestination>;
|
||||||
|
|
||||||
|
getActiveBackupDestination(
|
||||||
|
userId: number
|
||||||
|
): Promise<BackupDestination | null>;
|
||||||
|
|
||||||
|
getAllBackupDestination(
|
||||||
|
userId: number
|
||||||
|
): Promise<BackupDestination[]>;
|
||||||
|
|
||||||
|
updateBackupDestination(
|
||||||
|
id: number,
|
||||||
|
userId: number,
|
||||||
|
path: string
|
||||||
|
): Promise<BackupDestination>;
|
||||||
|
|
||||||
|
deleteBackupDestination(
|
||||||
|
id: number,
|
||||||
|
userId: number
|
||||||
|
): Promise<BackupDestination>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const databaseBackupStorage: IStorage = {
|
export const databaseBackupStorage: IStorage = {
|
||||||
@@ -36,4 +63,51 @@ export const databaseBackupStorage: IStorage = {
|
|||||||
const result = await db.databaseBackup.deleteMany({ where: { userId } });
|
const result = await db.databaseBackup.deleteMany({ where: { userId } });
|
||||||
return result.count;
|
return result.count;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// Backup Destination methods
|
||||||
|
// ==============================
|
||||||
|
async createBackupDestination(userId, path) {
|
||||||
|
// deactivate existing destination
|
||||||
|
await db.backupDestination.updateMany({
|
||||||
|
where: { userId },
|
||||||
|
data: { isActive: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
return db.backupDestination.create({
|
||||||
|
data: { userId, path },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async getActiveBackupDestination(userId) {
|
||||||
|
return db.backupDestination.findFirst({
|
||||||
|
where: { userId, isActive: true },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async getAllBackupDestination(userId) {
|
||||||
|
return db.backupDestination.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateBackupDestination(id, userId, path) {
|
||||||
|
// optional: make this one active
|
||||||
|
await db.backupDestination.updateMany({
|
||||||
|
where: { userId },
|
||||||
|
data: { isActive: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
return db.backupDestination.update({
|
||||||
|
where: { id, userId },
|
||||||
|
data: { path, isActive: true },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteBackupDestination(id, userId) {
|
||||||
|
return db.backupDestination.delete({
|
||||||
|
where: { id, userId },
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { FolderOpen, Trash2 } from "lucide-react";
|
||||||
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
export function BackupDestinationManager() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [path, setPath] = useState("");
|
||||||
|
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// Queries
|
||||||
|
// ==============================
|
||||||
|
const { data: destinations = [] } = useQuery({
|
||||||
|
queryKey: ["/db/destination"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiRequest(
|
||||||
|
"GET",
|
||||||
|
"/api/database-management/destination"
|
||||||
|
);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// Mutations
|
||||||
|
// ==============================
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const res = await apiRequest(
|
||||||
|
"POST",
|
||||||
|
"/api/database-management/destination",
|
||||||
|
{ path }
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error((await res.json()).error);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: "Backup destination saved" });
|
||||||
|
setPath("");
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["/db/destination"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: async (id: number) => {
|
||||||
|
await apiRequest("DELETE", `/api/database-management/destination/${id}`);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: "Backup destination deleted" });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["/db/destination"] });
|
||||||
|
setDeleteId(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// Folder picker (browser limitation)
|
||||||
|
// ==============================
|
||||||
|
const openFolderPicker = async () => {
|
||||||
|
// @ts-ignore
|
||||||
|
if (!window.showDirectoryPicker) {
|
||||||
|
toast({
|
||||||
|
title: "Not supported",
|
||||||
|
description: "Your browser does not support folder picking",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// @ts-ignore
|
||||||
|
const dirHandle = await window.showDirectoryPicker();
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Folder selected",
|
||||||
|
description: `Selected folder: ${dirHandle.name}. Please enter the full path manually.`,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// user cancelled
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// UI
|
||||||
|
// ==============================
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>External Backup Destination</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="/media/usb-drive or D:\\Backups"
|
||||||
|
value={path}
|
||||||
|
onChange={(e) => setPath(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button variant="outline" onClick={openFolderPicker}>
|
||||||
|
<FolderOpen className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => saveMutation.mutate()}
|
||||||
|
disabled={!path || saveMutation.isPending}
|
||||||
|
>
|
||||||
|
Save Destination
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{destinations.map((d: any) => (
|
||||||
|
<div
|
||||||
|
key={d.id}
|
||||||
|
className="flex justify-between items-center border rounded p-2"
|
||||||
|
>
|
||||||
|
<span className="text-sm text-gray-700">{d.path}</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setDeleteId(d.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirm delete dialog */}
|
||||||
|
<AlertDialog open={deleteId !== null}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete backup destination?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will remove the destination and stop automatic backups.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={() => setDeleteId(null)}>
|
||||||
|
Cancel
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => deleteId && deleteMutation.mutate(deleteId)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||||
import { formatDateToHumanReadable } from "@/utils/dateUtils";
|
import { formatDateToHumanReadable } from "@/utils/dateUtils";
|
||||||
|
import { BackupDestinationManager } from "@/components/database-management/backup-destination-manager";
|
||||||
|
|
||||||
export default function DatabaseManagementPage() {
|
export default function DatabaseManagementPage() {
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
@@ -205,6 +206,9 @@ export default function DatabaseManagementPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Externa Drive automatic backup manager */}
|
||||||
|
<BackupDestinationManager />
|
||||||
|
|
||||||
{/* Database Status Section */}
|
{/* Database Status Section */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ model User {
|
|||||||
insuranceCredentials InsuranceCredential[]
|
insuranceCredentials InsuranceCredential[]
|
||||||
updatedPayments Payment[] @relation("PaymentUpdatedBy")
|
updatedPayments Payment[] @relation("PaymentUpdatedBy")
|
||||||
backups DatabaseBackup[]
|
backups DatabaseBackup[]
|
||||||
|
backupDestinations BackupDestination[]
|
||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
cloudFolders CloudFolder[]
|
cloudFolders CloudFolder[]
|
||||||
cloudFiles CloudFile[]
|
cloudFiles CloudFile[]
|
||||||
@@ -301,6 +302,7 @@ enum PaymentMethod {
|
|||||||
OTHER
|
OTHER
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Database management page
|
||||||
model DatabaseBackup {
|
model DatabaseBackup {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId Int
|
userId Int
|
||||||
@@ -312,6 +314,16 @@ model DatabaseBackup {
|
|||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model BackupDestination {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
userId Int
|
||||||
|
path String
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
}
|
||||||
|
|
||||||
model Notification {
|
model Notification {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId Int
|
userId Int
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { DatabaseBackupUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas";
|
import { DatabaseBackupUncheckedCreateInputObjectSchema, BackupDestinationUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export type DatabaseBackup = z.infer<
|
export type DatabaseBackup = z.infer<
|
||||||
typeof DatabaseBackupUncheckedCreateInputObjectSchema
|
typeof DatabaseBackupUncheckedCreateInputObjectSchema
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
export type BackupDestination = z.infer<
|
||||||
|
typeof BackupDestinationUncheckedCreateInputObjectSchema
|
||||||
|
>;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export * from '../shared/schemas/enums/PaymentStatus.schema'
|
|||||||
export * from '../shared/schemas/enums/NotificationTypes.schema'
|
export * from '../shared/schemas/enums/NotificationTypes.schema'
|
||||||
export * from '../shared/schemas/objects/NotificationUncheckedCreateInput.schema'
|
export * from '../shared/schemas/objects/NotificationUncheckedCreateInput.schema'
|
||||||
export * from '../shared/schemas/objects/DatabaseBackupUncheckedCreateInput.schema'
|
export * from '../shared/schemas/objects/DatabaseBackupUncheckedCreateInput.schema'
|
||||||
|
export * from '../shared/schemas/objects/BackupDestinationUncheckedCreateInput.schema'
|
||||||
export * from '../shared/schemas/objects/CloudFolderUncheckedCreateInput.schema'
|
export * from '../shared/schemas/objects/CloudFolderUncheckedCreateInput.schema'
|
||||||
export * from '../shared/schemas/objects/CloudFileUncheckedCreateInput.schema'
|
export * from '../shared/schemas/objects/CloudFileUncheckedCreateInput.schema'
|
||||||
export * from '../shared/schemas/objects/CommunicationUncheckedCreateInput.schema'
|
export * from '../shared/schemas/objects/CommunicationUncheckedCreateInput.schema'
|
||||||
Reference in New Issue
Block a user