From 66a3d271f1fc3777ddf26da48b5647c6e77d33c9 Mon Sep 17 00:00:00 2001 From: ff Date: Sat, 11 Apr 2026 23:37:43 -0400 Subject: [PATCH] feat: change backup format to plain SQL, admin-only cron, backup now button, import restore UI --- apps/Backend/src/cron/backupCheck.ts | 150 ++++++------- .../Backend/src/routes/database-management.ts | 209 ++++++------------ .../src/services/databaseBackupService.ts | 55 +---- .../backup-destination-manager.tsx | 43 +++- .../import-database-section.tsx | 137 ++++++++++++ .../src/pages/database-management-page.tsx | 4 + 6 files changed, 329 insertions(+), 269 deletions(-) create mode 100644 apps/Frontend/src/components/database-management/import-database-section.tsx diff --git a/apps/Backend/src/cron/backupCheck.ts b/apps/Backend/src/cron/backupCheck.ts index 41604da..2492bcb 100755 --- a/apps/Backend/src/cron/backupCheck.ts +++ b/apps/Backend/src/cron/backupCheck.ts @@ -2,7 +2,6 @@ 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"; // Local backup folder in the app root (apps/Backend/backups) @@ -17,24 +16,17 @@ function ensureLocalBackupDir() { } } -async function runForAllUsers( - handler: (user: Awaited>[number]) => Promise -) { +async function getAdminUser() { 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); - } - } + const admin = users.find((u) => u.username === "admin"); + if (admin) return admin; offset += batchSize; } + return null; } export const startBackupCron = () => { @@ -45,39 +37,31 @@ export const startBackupCron = () => { console.log("🔄 [8 PM] Running local auto-backup..."); ensureLocalBackupDir(); - 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; - } + const admin = await getAdminUser(); + if (!admin) { + console.warn("No admin user found, skipping local backup."); + return; + } - 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." - ); - } - }); + if (!admin.autoBackupEnabled) { + console.log("✅ [8 PM] Auto-backup is disabled for admin, skipped."); + return; + } + + try { + const filename = `dental_backup_${Date.now()}.sql`; + await backupDatabaseToPath({ destinationPath: LOCAL_BACKUP_DIR, filename }); + await storage.createBackup(admin.id); + await storage.deleteNotificationsByType(admin.id, "BACKUP"); + console.log(`✅ Local backup done → ${filename}`); + } catch (err) { + console.error("Local backup failed:", err); + await storage.createNotification( + admin.id, + "BACKUP", + "❌ Automatic backup failed. Please check the server backup folder." + ); + } console.log("✅ [8 PM] Local backup complete."); }); @@ -88,46 +72,52 @@ export const startBackupCron = () => { cron.schedule("0 21 * * *", async () => { console.log("🔄 [9 PM] Running USB backup..."); - await runForAllUsers(async (user) => { - if (!user.usbBackupEnabled) return; + const admin = await getAdminUser(); + if (!admin) { + console.warn("No admin user found, skipping USB backup."); + return; + } - const destination = await storage.getActiveBackupDestination(user.id); - if (!destination) { - await storage.createNotification( - user.id, - "BACKUP", - "❌ USB backup failed: no backup destination configured." - ); - return; - } + if (!admin.usbBackupEnabled) { + console.log("✅ [9 PM] USB backup is disabled for admin, skipped."); + return; + } - // The target is the "USB Backup" subfolder inside the configured drive path - const usbBackupPath = path.join(destination.path, USB_BACKUP_FOLDER_NAME); + const destination = await storage.getActiveBackupDestination(admin.id); + if (!destination) { + await storage.createNotification( + admin.id, + "BACKUP", + "❌ USB backup failed: no backup destination configured." + ); + return; + } - 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; - } + const usbBackupPath = path.join(destination.path, USB_BACKUP_FOLDER_NAME); - 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." - ); - } - }); + if (!fs.existsSync(usbBackupPath)) { + await storage.createNotification( + admin.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()}.sql`; + await backupDatabaseToPath({ destinationPath: usbBackupPath, filename }); + await storage.createBackup(admin.id); + await storage.deleteNotificationsByType(admin.id, "BACKUP"); + console.log(`✅ USB backup done → ${usbBackupPath}/${filename}`); + } catch (err) { + console.error("USB backup failed:", err); + await storage.createNotification( + admin.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 7b4b685..f7fa756 100755 --- a/apps/Backend/src/routes/database-management.ts +++ b/apps/Backend/src/routes/database-management.ts @@ -3,30 +3,22 @@ import { spawn } from "child_process"; import path from "path"; import os from "os"; import fs from "fs"; +import multer from "multer"; import { prisma } from "@repo/db/client"; import { storage } from "../storage"; -import archiver from "archiver"; import { backupDatabaseToPath } from "../services/databaseBackupService"; +const restoreUpload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 500 * 1024 * 1024 }, // 500 MB + fileFilter: (_req, file, cb) => { + if (file.originalname.toLowerCase().endsWith(".sql")) cb(null, true); + else cb(new Error("Only .sql files are allowed")); + }, +}); + const router = Router(); -/** - * Create a database backup - * - * - Uses pg_dump in directory format for parallel dump to a tmp dir - * - Uses 'archiver' to create zip or gzipped tar stream directly to response - * - Supports explicit override via BACKUP_ARCHIVE_FORMAT env var ('zip' or 'tar') - * - Ensures cleanup of tmp dir on success/error/client disconnect - */ - -// helper to remove directory (sync to keep code straightforward) -function safeRmDir(dir: string) { - try { - fs.rmSync(dir, { recursive: true, force: true }); - } catch (e) { - /* ignore */ - } -} router.post("/backup", async (req: Request, res: Response): Promise => { try { const userId = req.user?.id; @@ -34,32 +26,12 @@ router.post("/backup", async (req: Request, res: Response): Promise => { return res.status(401).json({ error: "Unauthorized" }); } - const destination = await storage.getActiveBackupDestination(userId); + const filename = `dental_backup_${Date.now()}.sql`; - // create a unique tmp directory for directory-format dump - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dental_backup_")); // MUST - - // Decide archive format - // BACKUP_ARCHIVE_FORMAT can be 'zip' or 'tar' (case-insensitive) - const forced = (process.env.BACKUP_ARCHIVE_FORMAT || "").toLowerCase(); - const useZip = - forced === "zip" - ? true - : forced === "tar" - ? false - : process.platform === "win32"; - - const filename = useZip - ? `dental_backup_${Date.now()}.zip` - : `dental_backup_${Date.now()}.tar.gz`; - - // Spawn pg_dump + // Spawn pg_dump in plain SQL format, streaming stdout directly to response const pgDump = spawn( "pg_dump", [ - "-Fd", // DIRECTORY format (required for parallel dump) - "-j", - "4", // number of parallel jobs — MUST be >0 for parallelism "--no-acl", "--no-owner", "-h", @@ -67,8 +39,6 @@ router.post("/backup", async (req: Request, res: Response): Promise => { "-U", process.env.DB_USER || "postgres", process.env.DB_NAME || "dental_db", - "-f", - tmpDir, // write parallely ], { env: { @@ -84,127 +54,51 @@ router.post("/backup", async (req: Request, res: Response): Promise => { }); pgDump.on("error", (err) => { - safeRmDir(tmpDir); console.error("Failed to start pg_dump:", err); - // If headers haven't been sent, respond; otherwise just end socket if (!res.headersSent) { - return res - .status(500) - .json({ error: "Failed to run pg_dump", details: err.message }); + res.status(500).json({ error: "Failed to run pg_dump", details: err.message }); } else { res.destroy(err); } }); + // Buffer first chunk to detect early failure before sending headers + let headersSent = false; + pgDump.stdout.once("data", (firstChunk) => { + res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); + res.setHeader("Content-Type", "application/sql"); + headersSent = true; + res.write(firstChunk); + pgDump.stdout.pipe(res); + }); + pgDump.on("close", async (code) => { if (code !== 0) { - safeRmDir(tmpDir); console.error("pg_dump failed:", pgStderr || `exit ${code}`); - if (!res.headersSent) { + if (!headersSent) { return res.status(500).json({ error: "Backup failed", details: pgStderr || `pg_dump exited with ${code}`, }); } else { - // headers already sent — destroy response res.destroy(new Error("pg_dump failed")); return; } } - // pg_dump succeeded — stream archive directly to response using archiver - // Set headers before piping - res.setHeader( - "Content-Disposition", - `attachment; filename="${filename}"` - ); - res.setHeader( - "Content-Type", - useZip ? "application/zip" : "application/gzip" - ); - - const archive = archiver( - useZip ? "zip" : "tar", - useZip ? {} : { gzip: true, gzipOptions: { level: 6 } } - ); - - let archErr: string | null = null; - archive.on("error", (err) => { - archErr = err.message; - console.error("Archiver error:", err); - // attempt to respond with error if possible - try { - if (!res.headersSent) { - res.status(500).json({ - error: "Failed to create archive", - details: err.message, - }); - } else { - // if streaming already started, destroy the connection - res.destroy(err); - } - } catch (e) { - // swallow - } finally { - safeRmDir(tmpDir); - } - }); - - // If client disconnects while streaming - res.once("close", () => { - // destroy archiver (stop processing) and cleanup tmpDir - try { - archive.destroy(); - } catch (e) {} - safeRmDir(tmpDir); - }); - - // When streaming finishes successfully - res.once("finish", async () => { - // cleanup the tmp dir used by pg_dump - safeRmDir(tmpDir); - - // update metadata (try/catch so it won't break response flow) - try { - await storage.createBackup(userId); - await storage.deleteNotificationsByType(userId, "BACKUP"); - } catch (err) { - console.error("Backup saved but metadata update failed:", err); - } - }); - - // Pipe archive into response - archive.pipe(res); - - // Add the dumped directory contents to the archive root - // `directory(source, dest)` where dest is false/'' to place contents at archive root - archive.directory(tmpDir + path.sep, false); - - // finalize archive (this starts streaming) try { - await archive.finalize(); - } catch (err: any) { - console.error("Failed to finalize archive:", err); - // if headers not sent, send 500; otherwise destroy - try { - if (!res.headersSent) { - res.status(500).json({ - error: "Failed to finalize archive", - details: String(err), - }); - } else { - res.destroy(err); - } - } catch (e) {} - safeRmDir(tmpDir); + await storage.createBackup(userId); + await storage.deleteNotificationsByType(userId, "BACKUP"); + } catch (err) { + console.error("Backup saved but metadata update failed:", err); } + + if (!res.writableEnded) res.end(); }); } catch (err: any) { console.error("Unexpected error in /backup:", err); if (!res.headersSent) { - return res - .status(500) - .json({ message: "Internal server error", details: String(err) }); + res.status(500).json({ message: "Internal server error", details: String(err) }); } else { res.destroy(err); } @@ -418,7 +312,7 @@ router.post("/backup-path", async (req, res) => { }); } - const filename = `dental_backup_${Date.now()}.zip`; + const filename = `dental_backup_${Date.now()}.sql`; try { await backupDatabaseToPath({ @@ -439,4 +333,43 @@ router.post("/backup-path", async (req, res) => { } }); +router.post("/restore", restoreUpload.single("file"), async (req: Request, res: Response): Promise => { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ error: "Unauthorized" }); + + if (!req.file) return res.status(400).json({ error: "No file provided" }); + + const psql = spawn( + "psql", + [ + "-h", process.env.DB_HOST || "localhost", + "-U", process.env.DB_USER || "postgres", + process.env.DB_NAME || "dental_db", + ], + { + env: { ...process.env, PGPASSWORD: process.env.DB_PASSWORD }, + } + ); + + let stderr = ""; + psql.stderr.on("data", (d) => (stderr += d.toString())); + + psql.on("error", (err) => { + console.error("Failed to start psql:", err); + if (!res.headersSent) + res.status(500).json({ error: "Failed to run psql", details: err.message }); + }); + + psql.on("close", (code) => { + if (code !== 0) { + console.error("psql restore failed:", stderr); + return res.status(500).json({ error: "Restore failed", details: stderr }); + } + res.json({ success: true }); + }); + + psql.stdin.write(req.file.buffer); + psql.stdin.end(); +}); + export default router; diff --git a/apps/Backend/src/services/databaseBackupService.ts b/apps/Backend/src/services/databaseBackupService.ts index c8ae6db..d6a9496 100755 --- a/apps/Backend/src/services/databaseBackupService.ts +++ b/apps/Backend/src/services/databaseBackupService.ts @@ -1,14 +1,4 @@ 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; @@ -19,24 +9,24 @@ export async function backupDatabaseToPath({ destinationPath, filename, }: BackupToPathParams): Promise { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dental_backup_")); + const path = await import("path"); + const fs = await import("fs"); + + const outputFile = path.join(destinationPath, filename); 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, + outputFile, + process.env.DB_NAME || "dental_db", ], { env: { @@ -50,36 +40,15 @@ export async function backupDatabaseToPath({ pgDump.stderr.on("data", (d) => (pgError += d.toString())); - pgDump.on("close", async (code) => { + pgDump.on("close", (code) => { if (code !== 0) { - safeRmDir(tmpDir); + // clean up partial file if it was created + try { + if (fs.existsSync(outputFile)) fs.unlinkSync(outputFile); + } catch {} 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(); - }); + resolve(); }); }); } 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 acab5ee..9ed17ed 100755 --- a/apps/Frontend/src/components/database-management/backup-destination-manager.tsx +++ b/apps/Frontend/src/components/database-management/backup-destination-manager.tsx @@ -14,7 +14,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; -import { FolderOpen, Trash2 } from "lucide-react"; +import { FolderOpen, HardDrive, Trash2 } from "lucide-react"; import { apiRequest, queryClient } from "@/lib/queryClient"; import { useToast } from "@/hooks/use-toast"; import { FolderBrowserModal } from "./folder-browser-modal"; @@ -102,6 +102,21 @@ export function BackupDestinationManager() { }, }); + const backupNowMutation = useMutation({ + mutationFn: async () => { + const res = await apiRequest("POST", "/api/database-management/backup-path"); + if (!res.ok) throw new Error((await res.json()).error || "Backup failed"); + return res.json(); + }, + onSuccess: (data) => { + toast({ title: "Backup complete", description: `Saved: ${data.filename}` }); + queryClient.invalidateQueries({ queryKey: ["/db/status"] }); + }, + onError: (err: any) => { + toast({ title: "Backup failed", description: err.message, variant: "destructive" }); + }, + }); + // ============================== // Folder browser // ============================== @@ -172,13 +187,25 @@ export function BackupDestinationManager() { className="flex justify-between items-center border rounded p-2" > {d.path} - +
+ + +
))} diff --git a/apps/Frontend/src/components/database-management/import-database-section.tsx b/apps/Frontend/src/components/database-management/import-database-section.tsx new file mode 100644 index 0000000..8f7345a --- /dev/null +++ b/apps/Frontend/src/components/database-management/import-database-section.tsx @@ -0,0 +1,137 @@ +import { useRef, useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Upload, UploadCloud } from "lucide-react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { useToast } from "@/hooks/use-toast"; + +export function ImportDatabaseSection() { + const { toast } = useToast(); + const fileInputRef = useRef(null); + const [selectedFile, setSelectedFile] = useState(null); + const [confirmOpen, setConfirmOpen] = useState(false); + + const restoreMutation = useMutation({ + mutationFn: async (file: File) => { + const formData = new FormData(); + formData.append("file", file); + + const token = localStorage.getItem("token"); + const res = await fetch("/api/database-management/restore", { + method: "POST", + headers: token ? { Authorization: `Bearer ${token}` } : {}, + body: formData, + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || "Restore failed"); + } + return res.json(); + }, + onSuccess: () => { + toast({ + title: "Database Restored", + description: "The database has been successfully restored from the backup file.", + }); + setSelectedFile(null); + if (fileInputRef.current) fileInputRef.current.value = ""; + }, + onError: (err: any) => { + toast({ + title: "Restore Failed", + description: err.message, + variant: "destructive", + }); + }, + }); + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] ?? null; + setSelectedFile(file); + }; + + const handleImportClick = () => { + if (!selectedFile) return; + setConfirmOpen(true); + }; + + const handleConfirm = () => { + setConfirmOpen(false); + if (selectedFile) restoreMutation.mutate(selectedFile); + }; + + return ( + <> + + + + + Import Database + + + +

+ Restore the database from a .sql backup file. + This will overwrite all existing data. +

+ +
+ +
+ + {selectedFile && ( +

+ Selected: {selectedFile.name}{" "} + ({(selectedFile.size / 1024 / 1024).toFixed(1)} MB) +

+ )} + + +
+
+ + + + + Restore database? + + This will overwrite all existing data with the contents of{" "} + {selectedFile?.name}. This action cannot be undone. + + + + Cancel + + Yes, restore + + + + + + ); +} diff --git a/apps/Frontend/src/pages/database-management-page.tsx b/apps/Frontend/src/pages/database-management-page.tsx index 31a4dea..c696e42 100755 --- a/apps/Frontend/src/pages/database-management-page.tsx +++ b/apps/Frontend/src/pages/database-management-page.tsx @@ -14,6 +14,7 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { apiRequest, queryClient } from "@/lib/queryClient"; import { formatDateToHumanReadable } from "@/utils/dateUtils"; import { BackupDestinationManager } from "@/components/database-management/backup-destination-manager"; +import { ImportDatabaseSection } from "@/components/database-management/import-database-section"; export default function DatabaseManagementPage() { const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); @@ -260,6 +261,9 @@ export default function DatabaseManagementPage() { {/* Externa Drive automatic backup manager */} + {/* Import / Restore Database */} + + {/* Database Status Section */}