feat: database management - auto/USB backup toggles, folder browser, cron jobs
This commit is contained in:
@@ -1,100 +1,134 @@
|
||||
import cron from "node-cron";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { storage } from "../storage";
|
||||
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
|
||||
* Creates a backup notification if overdue
|
||||
*/
|
||||
// Local backup folder in the app root (apps/Backend/backups)
|
||||
const LOCAL_BACKUP_DIR = path.resolve(process.cwd(), "backups");
|
||||
|
||||
// Name of the USB backup subfolder the user creates on their drive
|
||||
const USB_BACKUP_FOLDER_NAME = "USB Backup";
|
||||
|
||||
function ensureLocalBackupDir() {
|
||||
if (!fs.existsSync(LOCAL_BACKUP_DIR)) {
|
||||
fs.mkdirSync(LOCAL_BACKUP_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function runForAllUsers(
|
||||
handler: (user: Awaited<ReturnType<typeof storage.getUsers>>[number]) => Promise<void>
|
||||
) {
|
||||
const batchSize = 100;
|
||||
let offset = 0;
|
||||
while (true) {
|
||||
const users = await storage.getUsers(batchSize, offset);
|
||||
if (!users || users.length === 0) break;
|
||||
for (const user of users) {
|
||||
if (user.id == null) continue;
|
||||
try {
|
||||
await handler(user);
|
||||
} catch (err) {
|
||||
console.error(`Error processing user ${user.id}:`, err);
|
||||
}
|
||||
}
|
||||
offset += batchSize;
|
||||
}
|
||||
}
|
||||
|
||||
export const startBackupCron = () => {
|
||||
cron.schedule("0 22 * * *", async () => {
|
||||
// Every calendar days, at 10 PM
|
||||
// cron.schedule("*/10 * * * * *", async () => { // Every 10 seconds (for Test)
|
||||
// ============================================================
|
||||
// 8 PM — Local automatic backup to apps/Backend/backups/
|
||||
// ============================================================
|
||||
cron.schedule("0 20 * * *", async () => {
|
||||
console.log("🔄 [8 PM] Running local auto-backup...");
|
||||
ensureLocalBackupDir();
|
||||
|
||||
console.log("🔄 Running backup check...");
|
||||
|
||||
const userBatchSize = 100;
|
||||
let userOffset = 0;
|
||||
|
||||
while (true) {
|
||||
// Fetch a batch of users
|
||||
const users = await storage.getUsers(userBatchSize, userOffset);
|
||||
if (!users || users.length === 0) break;
|
||||
|
||||
for (const user of users) {
|
||||
try {
|
||||
if (user.id == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const destination = await storage.getActiveBackupDestination(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
|
||||
? (Date.now() - new Date(lastBackup.createdAt).getTime()) /
|
||||
(1000 * 60 * 60 * 24)
|
||||
: Infinity;
|
||||
|
||||
if (daysSince >= 7) {
|
||||
await storage.createNotification(
|
||||
user.id,
|
||||
"BACKUP" as NotificationTypes,
|
||||
"⚠️ It has been more than 7 days since your last backup."
|
||||
);
|
||||
console.log(`Notification created for user ${user.id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error processing user ${user.id}:`, err);
|
||||
await runForAllUsers(async (user) => {
|
||||
if (!user.autoBackupEnabled) {
|
||||
// No local backup — check if a 7-day reminder is needed
|
||||
const lastBackup = await storage.getLastBackup(user.id);
|
||||
const daysSince = lastBackup?.createdAt
|
||||
? (Date.now() - new Date(lastBackup.createdAt).getTime()) / (1000 * 60 * 60 * 24)
|
||||
: Infinity;
|
||||
if (daysSince >= 7) {
|
||||
await storage.createNotification(
|
||||
user.id,
|
||||
"BACKUP" as NotificationTypes,
|
||||
"⚠️ It has been more than 7 days since your last backup."
|
||||
);
|
||||
console.log(`Reminder notification created for user ${user.id}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
userOffset += userBatchSize; // next user batch
|
||||
}
|
||||
try {
|
||||
const filename = `dental_backup_user${user.id}_${Date.now()}.zip`;
|
||||
await backupDatabaseToPath({ destinationPath: LOCAL_BACKUP_DIR, filename });
|
||||
await storage.createBackup(user.id);
|
||||
await storage.deleteNotificationsByType(user.id, "BACKUP");
|
||||
console.log(`✅ Local backup done for user ${user.id} → ${filename}`);
|
||||
} catch (err) {
|
||||
console.error(`Local backup failed for user ${user.id}`, err);
|
||||
await storage.createNotification(
|
||||
user.id,
|
||||
"BACKUP",
|
||||
"❌ Automatic backup failed. Please check the server backup folder."
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
console.log("✅ Daily backup check completed.");
|
||||
console.log("✅ [8 PM] Local backup complete.");
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 9 PM — USB backup to the "USB Backup" folder on the drive
|
||||
// ============================================================
|
||||
cron.schedule("0 21 * * *", async () => {
|
||||
console.log("🔄 [9 PM] Running USB backup...");
|
||||
|
||||
await runForAllUsers(async (user) => {
|
||||
if (!user.usbBackupEnabled) return;
|
||||
|
||||
const destination = await storage.getActiveBackupDestination(user.id);
|
||||
if (!destination) {
|
||||
await storage.createNotification(
|
||||
user.id,
|
||||
"BACKUP",
|
||||
"❌ USB backup failed: no backup destination configured."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// The target is the "USB Backup" subfolder inside the configured drive path
|
||||
const usbBackupPath = path.join(destination.path, USB_BACKUP_FOLDER_NAME);
|
||||
|
||||
if (!fs.existsSync(usbBackupPath)) {
|
||||
await storage.createNotification(
|
||||
user.id,
|
||||
"BACKUP",
|
||||
`❌ USB backup failed: folder "${USB_BACKUP_FOLDER_NAME}" not found on the drive. Make sure the USB drive is connected and the folder exists.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const filename = `dental_backup_usb_${Date.now()}.zip`;
|
||||
await backupDatabaseToPath({ destinationPath: usbBackupPath, filename });
|
||||
await storage.createBackup(user.id);
|
||||
await storage.deleteNotificationsByType(user.id, "BACKUP");
|
||||
console.log(`✅ USB backup done for user ${user.id} → ${usbBackupPath}/${filename}`);
|
||||
} catch (err) {
|
||||
console.error(`USB backup failed for user ${user.id}`, err);
|
||||
await storage.createNotification(
|
||||
user.id,
|
||||
"BACKUP",
|
||||
"❌ USB backup failed. Please check the USB drive and try again."
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
console.log("✅ [9 PM] USB backup complete.");
|
||||
});
|
||||
};
|
||||
|
||||
@@ -318,6 +318,88 @@ router.delete("/destination/:id", async (req, res) => {
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// GET directory listing for folder browser
|
||||
router.get("/browse", async (req, res) => {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ error: "Unauthorized" });
|
||||
|
||||
const requestedPath = (req.query.path as string) || "/";
|
||||
|
||||
// Resolve and sanitize — must be absolute
|
||||
const resolved = path.resolve(requestedPath);
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(resolved, { withFileTypes: true });
|
||||
const dirs = entries
|
||||
.filter((e) => e.isDirectory())
|
||||
.map((e) => ({
|
||||
name: e.name,
|
||||
path: path.join(resolved, e.name),
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const parent = resolved !== "/" ? path.dirname(resolved) : null;
|
||||
|
||||
res.json({ current: resolved, parent, dirs });
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ error: err.message || "Cannot read directory" });
|
||||
}
|
||||
});
|
||||
|
||||
// GET usb backup setting
|
||||
router.get("/usb-backup-setting", async (req, res) => {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ error: "Unauthorized" });
|
||||
|
||||
const user = await storage.getUser(userId);
|
||||
if (!user) return res.status(404).json({ error: "User not found" });
|
||||
|
||||
res.json({ usbBackupEnabled: user.usbBackupEnabled });
|
||||
});
|
||||
|
||||
// PUT usb backup setting
|
||||
router.put("/usb-backup-setting", async (req, res) => {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ error: "Unauthorized" });
|
||||
|
||||
const { usbBackupEnabled } = req.body;
|
||||
if (typeof usbBackupEnabled !== "boolean") {
|
||||
return res.status(400).json({ error: "usbBackupEnabled must be a boolean" });
|
||||
}
|
||||
|
||||
const updated = await storage.updateUser(userId, { usbBackupEnabled });
|
||||
if (!updated) return res.status(404).json({ error: "User not found" });
|
||||
|
||||
res.json({ usbBackupEnabled: updated.usbBackupEnabled });
|
||||
});
|
||||
|
||||
// GET auto backup setting
|
||||
router.get("/auto-backup-setting", async (req, res) => {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ error: "Unauthorized" });
|
||||
|
||||
const user = await storage.getUser(userId);
|
||||
if (!user) return res.status(404).json({ error: "User not found" });
|
||||
|
||||
res.json({ autoBackupEnabled: user.autoBackupEnabled });
|
||||
});
|
||||
|
||||
// PUT auto backup setting
|
||||
router.put("/auto-backup-setting", async (req, res) => {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ error: "Unauthorized" });
|
||||
|
||||
const { autoBackupEnabled } = req.body;
|
||||
if (typeof autoBackupEnabled !== "boolean") {
|
||||
return res.status(400).json({ error: "autoBackupEnabled must be a boolean" });
|
||||
}
|
||||
|
||||
const updated = await storage.updateUser(userId, { autoBackupEnabled });
|
||||
if (!updated) return res.status(404).json({ error: "User not found" });
|
||||
|
||||
res.json({ autoBackupEnabled: updated.autoBackupEnabled });
|
||||
});
|
||||
|
||||
router.post("/backup-path", async (req, res) => {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ error: "Unauthorized" });
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -16,11 +17,13 @@ import {
|
||||
import { FolderOpen, Trash2 } from "lucide-react";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { FolderBrowserModal } from "./folder-browser-modal";
|
||||
|
||||
export function BackupDestinationManager() {
|
||||
const { toast } = useToast();
|
||||
const [path, setPath] = useState("");
|
||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||
const [browserOpen, setBrowserOpen] = useState(false);
|
||||
|
||||
// ==============================
|
||||
// Queries
|
||||
@@ -36,6 +39,39 @@ export function BackupDestinationManager() {
|
||||
},
|
||||
});
|
||||
|
||||
const { data: usbSettingData } = useQuery({
|
||||
queryKey: ["/db/usb-backup-setting"],
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest("GET", "/api/database-management/usb-backup-setting");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
const usbBackupEnabled = usbSettingData?.usbBackupEnabled ?? false;
|
||||
|
||||
const usbToggleMutation = useMutation({
|
||||
mutationFn: async (enabled: boolean) => {
|
||||
const res = await apiRequest("PUT", "/api/database-management/usb-backup-setting", {
|
||||
usbBackupEnabled: enabled,
|
||||
});
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(["/db/usb-backup-setting"], data);
|
||||
toast({
|
||||
title: "Setting Saved",
|
||||
description: `USB backup ${data.usbBackupEnabled ? "enabled" : "disabled"}.`,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to update USB backup setting.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// ==============================
|
||||
// Mutations
|
||||
// ==============================
|
||||
@@ -67,30 +103,10 @@ export function BackupDestinationManager() {
|
||||
});
|
||||
|
||||
// ==============================
|
||||
// Folder picker (browser limitation)
|
||||
// Folder browser
|
||||
// ==============================
|
||||
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
|
||||
}
|
||||
const handleFolderSelect = (selectedPath: string) => {
|
||||
setPath(selectedPath);
|
||||
};
|
||||
|
||||
// ==============================
|
||||
@@ -102,17 +118,46 @@ export function BackupDestinationManager() {
|
||||
<CardTitle>External Backup Destination</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Switch
|
||||
id="usb-backup-toggle"
|
||||
checked={usbBackupEnabled}
|
||||
onCheckedChange={(checked) => usbToggleMutation.mutate(checked)}
|
||||
disabled={usbToggleMutation.isPending}
|
||||
/>
|
||||
<label
|
||||
htmlFor="usb-backup-toggle"
|
||||
className="text-sm font-medium text-gray-700 cursor-pointer select-none"
|
||||
>
|
||||
USB Backup
|
||||
</label>
|
||||
<span className="text-xs text-gray-400">
|
||||
(daily at 9 PM → saves to the "USB Backup" folder on your drive)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-500">
|
||||
Enter the root path of your USB drive below. The app will automatically back up to the{" "}
|
||||
<span className="font-medium text-gray-700">USB Backup</span> folder inside it every night at 9 PM when the toggle is on.
|
||||
</p>
|
||||
|
||||
<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}>
|
||||
<Button variant="outline" onClick={() => setBrowserOpen(true)} title="Browse folders">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<FolderBrowserModal
|
||||
open={browserOpen}
|
||||
onClose={() => setBrowserOpen(false)}
|
||||
onSelect={handleFolderSelect}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={!path || saveMutation.isPending}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { Folder, FolderOpen, ChevronLeft, Loader2 } from "lucide-react";
|
||||
|
||||
interface BrowseResult {
|
||||
current: string;
|
||||
parent: string | null;
|
||||
dirs: { name: string; path: string }[];
|
||||
}
|
||||
|
||||
interface FolderBrowserModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (path: string) => void;
|
||||
}
|
||||
|
||||
export function FolderBrowserModal({ open, onClose, onSelect }: FolderBrowserModalProps) {
|
||||
const [browsePath, setBrowsePath] = useState("/");
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading, isError } = useQuery<BrowseResult>({
|
||||
queryKey: ["/db/browse", browsePath],
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest(
|
||||
"GET",
|
||||
`/api/database-management/browse?path=${encodeURIComponent(browsePath)}`
|
||||
);
|
||||
if (!res.ok) throw new Error((await res.json()).error);
|
||||
return res.json();
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const handleNavigate = (path: string) => {
|
||||
setSelected(null);
|
||||
setBrowsePath(path);
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selected) {
|
||||
onSelect(selected);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Select Folder</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Current path breadcrumb */}
|
||||
<div className="text-xs text-gray-500 bg-gray-50 rounded px-3 py-2 font-mono break-all">
|
||||
{data?.current ?? browsePath}
|
||||
</div>
|
||||
|
||||
{/* Back button */}
|
||||
{data?.parent && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="justify-start text-gray-600"
|
||||
onClick={() => handleNavigate(data.parent!)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Directory list */}
|
||||
<div className="border rounded-md overflow-y-auto max-h-64">
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-8 text-gray-400">
|
||||
<Loader2 className="h-5 w-5 animate-spin mr-2" />
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
{isError && (
|
||||
<p className="text-sm text-red-500 p-4">Cannot read this directory.</p>
|
||||
)}
|
||||
{!isLoading && !isError && data?.dirs.length === 0 && (
|
||||
<p className="text-sm text-gray-400 p-4">No sub-folders here.</p>
|
||||
)}
|
||||
{!isLoading &&
|
||||
!isError &&
|
||||
data?.dirs.map((dir) => (
|
||||
<button
|
||||
key={dir.path}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left hover:bg-gray-50 transition-colors ${
|
||||
selected === dir.path ? "bg-blue-50 text-blue-700 font-medium" : "text-gray-700"
|
||||
}`}
|
||||
onClick={() => setSelected(dir.path)}
|
||||
onDoubleClick={() => handleNavigate(dir.path)}
|
||||
>
|
||||
{selected === dir.path ? (
|
||||
<FolderOpen className="h-4 w-4 shrink-0 text-blue-500" />
|
||||
) : (
|
||||
<Folder className="h-4 w-4 shrink-0 text-yellow-500" />
|
||||
)}
|
||||
{dir.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-400">
|
||||
Single-click to select · Double-click to open
|
||||
</p>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={!selected}>
|
||||
Select Folder
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -77,7 +77,7 @@ export function Sidebar() {
|
||||
icon: <Cloud className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
name: "Backup Database",
|
||||
name: "Database Management",
|
||||
path: "/database-management",
|
||||
icon: <Database className="h-5 w-5" />,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
Database,
|
||||
@@ -75,6 +76,40 @@ export default function DatabaseManagementPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Auto backup setting query -----
|
||||
const { data: autoBackupData } = useQuery({
|
||||
queryKey: ["/db/auto-backup-setting"],
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest("GET", "/api/database-management/auto-backup-setting");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
const autoBackupEnabled = autoBackupData?.autoBackupEnabled ?? true;
|
||||
|
||||
const autoBackupMutation = useMutation({
|
||||
mutationFn: async (enabled: boolean) => {
|
||||
const res = await apiRequest("PUT", "/api/database-management/auto-backup-setting", {
|
||||
autoBackupEnabled: enabled,
|
||||
});
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(["/db/auto-backup-setting"], data);
|
||||
toast({
|
||||
title: "Setting Saved",
|
||||
description: `Automatic backup ${data.autoBackupEnabled ? "enabled" : "disabled"}.`,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to update automatic backup setting.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// ----- Backup mutation -----
|
||||
const backupMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
@@ -178,6 +213,22 @@ export default function DatabaseManagementPage() {
|
||||
including patients, appointments, claims, and all related data.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<Switch
|
||||
id="auto-backup-toggle"
|
||||
checked={autoBackupEnabled}
|
||||
onCheckedChange={(checked) => autoBackupMutation.mutate(checked)}
|
||||
disabled={autoBackupMutation.isPending}
|
||||
/>
|
||||
<label
|
||||
htmlFor="auto-backup-toggle"
|
||||
className="text-sm font-medium text-gray-700 cursor-pointer select-none"
|
||||
>
|
||||
Automatic Backup
|
||||
</label>
|
||||
<span className="text-xs text-gray-400">(daily at 8 PM to server backup folder)</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
onClick={() => backupMutation.mutate()}
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user