diff --git a/.turbo/daemon/53b0054db79f7114-turbo.log.2026-04-10 b/.turbo/daemon/53b0054db79f7114-turbo.log.2026-04-10 new file mode 100644 index 0000000..e69de29 diff --git a/.turbo/daemon/53b0054db79f7114-turbo.log.2026-04-11 b/.turbo/daemon/53b0054db79f7114-turbo.log.2026-04-11 new file mode 100644 index 0000000..6e94491 --- /dev/null +++ b/.turbo/daemon/53b0054db79f7114-turbo.log.2026-04-11 @@ -0,0 +1 @@ +2026-04-11T03:16:27.115983Z WARN get_package_file_hashes{include_default_files=true telemetry=None}: turborepo_scm::package_deps: git hashing failed for AnchoredSystemPath("packages/db") with resource error: Git error on /home/ff/Desktop/DentalManagementMHAprilgg/packages/db/shared/schemas/enums/UserScalarFieldEnum.schema.ts: could not find '/home/ff/Desktop/DentalManagementMHAprilgg/packages/db/shared/schemas/enums/UserScalarFieldEnum.schema.ts' to open: No such file or directory; class=Os (2); code=NotFound (-3) diff --git a/apps/Backend/src/cron/backupCheck.ts b/apps/Backend/src/cron/backupCheck.ts index 7c35cea..41604da 100755 --- a/apps/Backend/src/cron/backupCheck.ts +++ b/apps/Backend/src/cron/backupCheck.ts @@ -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>[number]) => Promise +) { + 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."); }); }; diff --git a/apps/Backend/src/routes/database-management.ts b/apps/Backend/src/routes/database-management.ts index 895077d..7b4b685 100755 --- a/apps/Backend/src/routes/database-management.ts +++ b/apps/Backend/src/routes/database-management.ts @@ -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" }); diff --git a/apps/Frontend/src/components/database-management/backup-destination-manager.tsx b/apps/Frontend/src/components/database-management/backup-destination-manager.tsx index 6b520ec..acab5ee 100755 --- a/apps/Frontend/src/components/database-management/backup-destination-manager.tsx +++ b/apps/Frontend/src/components/database-management/backup-destination-manager.tsx @@ -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(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() { External Backup Destination +
+ usbToggleMutation.mutate(checked)} + disabled={usbToggleMutation.isPending} + /> + + + (daily at 9 PM โ†’ saves to the "USB Backup" folder on your drive) + +
+ +

+ Enter the root path of your USB drive below. The app will automatically back up to the{" "} + USB Backup folder inside it every night at 9 PM when the toggle is on. +

+
setPath(e.target.value)} /> -
+ setBrowserOpen(false)} + onSelect={handleFolderSelect} + /> + + )} + + {/* Directory list */} +
+ {isLoading && ( +
+ + Loading... +
+ )} + {isError && ( +

Cannot read this directory.

+ )} + {!isLoading && !isError && data?.dirs.length === 0 && ( +

No sub-folders here.

+ )} + {!isLoading && + !isError && + data?.dirs.map((dir) => ( + + ))} +
+ +

+ Single-click to select ยท Double-click to open +

+ + + + + + + + ); +} diff --git a/apps/Frontend/src/components/layout/sidebar.tsx b/apps/Frontend/src/components/layout/sidebar.tsx index e5096c4..3464738 100755 --- a/apps/Frontend/src/components/layout/sidebar.tsx +++ b/apps/Frontend/src/components/layout/sidebar.tsx @@ -77,7 +77,7 @@ export function Sidebar() { icon: , }, { - name: "Backup Database", + name: "Database Management", path: "/database-management", icon: , }, diff --git a/apps/Frontend/src/pages/database-management-page.tsx b/apps/Frontend/src/pages/database-management-page.tsx index eb70ab5..31a4dea 100755 --- a/apps/Frontend/src/pages/database-management-page.tsx +++ b/apps/Frontend/src/pages/database-management-page.tsx @@ -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.

+
+ autoBackupMutation.mutate(checked)} + disabled={autoBackupMutation.isPending} + /> + + (daily at 8 PM to server backup folder) +
+