feat: change backup format to plain SQL, admin-only cron, backup now button, import restore UI
This commit is contained in:
@@ -2,7 +2,6 @@ import cron from "node-cron";
|
|||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { storage } from "../storage";
|
import { storage } from "../storage";
|
||||||
import { NotificationTypes } from "@repo/db/types";
|
|
||||||
import { backupDatabaseToPath } from "../services/databaseBackupService";
|
import { backupDatabaseToPath } from "../services/databaseBackupService";
|
||||||
|
|
||||||
// Local backup folder in the app root (apps/Backend/backups)
|
// Local backup folder in the app root (apps/Backend/backups)
|
||||||
@@ -17,24 +16,17 @@ function ensureLocalBackupDir() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runForAllUsers(
|
async function getAdminUser() {
|
||||||
handler: (user: Awaited<ReturnType<typeof storage.getUsers>>[number]) => Promise<void>
|
|
||||||
) {
|
|
||||||
const batchSize = 100;
|
const batchSize = 100;
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
while (true) {
|
while (true) {
|
||||||
const users = await storage.getUsers(batchSize, offset);
|
const users = await storage.getUsers(batchSize, offset);
|
||||||
if (!users || users.length === 0) break;
|
if (!users || users.length === 0) break;
|
||||||
for (const user of users) {
|
const admin = users.find((u) => u.username === "admin");
|
||||||
if (user.id == null) continue;
|
if (admin) return admin;
|
||||||
try {
|
|
||||||
await handler(user);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Error processing user ${user.id}:`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
offset += batchSize;
|
offset += batchSize;
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const startBackupCron = () => {
|
export const startBackupCron = () => {
|
||||||
@@ -45,39 +37,31 @@ export const startBackupCron = () => {
|
|||||||
console.log("🔄 [8 PM] Running local auto-backup...");
|
console.log("🔄 [8 PM] Running local auto-backup...");
|
||||||
ensureLocalBackupDir();
|
ensureLocalBackupDir();
|
||||||
|
|
||||||
await runForAllUsers(async (user) => {
|
const admin = await getAdminUser();
|
||||||
if (!user.autoBackupEnabled) {
|
if (!admin) {
|
||||||
// No local backup — check if a 7-day reminder is needed
|
console.warn("No admin user found, skipping local backup.");
|
||||||
const lastBackup = await storage.getLastBackup(user.id);
|
return;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
if (!admin.autoBackupEnabled) {
|
||||||
const filename = `dental_backup_user${user.id}_${Date.now()}.zip`;
|
console.log("✅ [8 PM] Auto-backup is disabled for admin, skipped.");
|
||||||
await backupDatabaseToPath({ destinationPath: LOCAL_BACKUP_DIR, filename });
|
return;
|
||||||
await storage.createBackup(user.id);
|
}
|
||||||
await storage.deleteNotificationsByType(user.id, "BACKUP");
|
|
||||||
console.log(`✅ Local backup done for user ${user.id} → ${filename}`);
|
try {
|
||||||
} catch (err) {
|
const filename = `dental_backup_${Date.now()}.sql`;
|
||||||
console.error(`Local backup failed for user ${user.id}`, err);
|
await backupDatabaseToPath({ destinationPath: LOCAL_BACKUP_DIR, filename });
|
||||||
await storage.createNotification(
|
await storage.createBackup(admin.id);
|
||||||
user.id,
|
await storage.deleteNotificationsByType(admin.id, "BACKUP");
|
||||||
"BACKUP",
|
console.log(`✅ Local backup done → ${filename}`);
|
||||||
"❌ Automatic backup failed. Please check the server backup folder."
|
} 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.");
|
console.log("✅ [8 PM] Local backup complete.");
|
||||||
});
|
});
|
||||||
@@ -88,46 +72,52 @@ export const startBackupCron = () => {
|
|||||||
cron.schedule("0 21 * * *", async () => {
|
cron.schedule("0 21 * * *", async () => {
|
||||||
console.log("🔄 [9 PM] Running USB backup...");
|
console.log("🔄 [9 PM] Running USB backup...");
|
||||||
|
|
||||||
await runForAllUsers(async (user) => {
|
const admin = await getAdminUser();
|
||||||
if (!user.usbBackupEnabled) return;
|
if (!admin) {
|
||||||
|
console.warn("No admin user found, skipping USB backup.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const destination = await storage.getActiveBackupDestination(user.id);
|
if (!admin.usbBackupEnabled) {
|
||||||
if (!destination) {
|
console.log("✅ [9 PM] USB backup is disabled for admin, skipped.");
|
||||||
await storage.createNotification(
|
return;
|
||||||
user.id,
|
}
|
||||||
"BACKUP",
|
|
||||||
"❌ USB backup failed: no backup destination configured."
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The target is the "USB Backup" subfolder inside the configured drive path
|
const destination = await storage.getActiveBackupDestination(admin.id);
|
||||||
const usbBackupPath = path.join(destination.path, USB_BACKUP_FOLDER_NAME);
|
if (!destination) {
|
||||||
|
await storage.createNotification(
|
||||||
|
admin.id,
|
||||||
|
"BACKUP",
|
||||||
|
"❌ USB backup failed: no backup destination configured."
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!fs.existsSync(usbBackupPath)) {
|
const usbBackupPath = path.join(destination.path, USB_BACKUP_FOLDER_NAME);
|
||||||
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 {
|
if (!fs.existsSync(usbBackupPath)) {
|
||||||
const filename = `dental_backup_usb_${Date.now()}.zip`;
|
await storage.createNotification(
|
||||||
await backupDatabaseToPath({ destinationPath: usbBackupPath, filename });
|
admin.id,
|
||||||
await storage.createBackup(user.id);
|
"BACKUP",
|
||||||
await storage.deleteNotificationsByType(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.`
|
||||||
console.log(`✅ USB backup done for user ${user.id} → ${usbBackupPath}/${filename}`);
|
);
|
||||||
} catch (err) {
|
return;
|
||||||
console.error(`USB backup failed for user ${user.id}`, err);
|
}
|
||||||
await storage.createNotification(
|
|
||||||
user.id,
|
try {
|
||||||
"BACKUP",
|
const filename = `dental_backup_usb_${Date.now()}.sql`;
|
||||||
"❌ USB backup failed. Please check the USB drive and try again."
|
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.");
|
console.log("✅ [9 PM] USB backup complete.");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,30 +3,22 @@ import { spawn } from "child_process";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import os from "os";
|
import os from "os";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
|
import multer from "multer";
|
||||||
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 { backupDatabaseToPath } from "../services/databaseBackupService";
|
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();
|
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<any> => {
|
router.post("/backup", async (req: Request, res: Response): Promise<any> => {
|
||||||
try {
|
try {
|
||||||
const userId = req.user?.id;
|
const userId = req.user?.id;
|
||||||
@@ -34,32 +26,12 @@ 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);
|
const filename = `dental_backup_${Date.now()}.sql`;
|
||||||
|
|
||||||
// create a unique tmp directory for directory-format dump
|
// Spawn pg_dump in plain SQL format, streaming stdout directly to response
|
||||||
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
|
|
||||||
const pgDump = spawn(
|
const pgDump = spawn(
|
||||||
"pg_dump",
|
"pg_dump",
|
||||||
[
|
[
|
||||||
"-Fd", // DIRECTORY format (required for parallel dump)
|
|
||||||
"-j",
|
|
||||||
"4", // number of parallel jobs — MUST be >0 for parallelism
|
|
||||||
"--no-acl",
|
"--no-acl",
|
||||||
"--no-owner",
|
"--no-owner",
|
||||||
"-h",
|
"-h",
|
||||||
@@ -67,8 +39,6 @@ router.post("/backup", async (req: Request, res: Response): Promise<any> => {
|
|||||||
"-U",
|
"-U",
|
||||||
process.env.DB_USER || "postgres",
|
process.env.DB_USER || "postgres",
|
||||||
process.env.DB_NAME || "dental_db",
|
process.env.DB_NAME || "dental_db",
|
||||||
"-f",
|
|
||||||
tmpDir, // write parallely
|
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
env: {
|
env: {
|
||||||
@@ -84,127 +54,51 @@ router.post("/backup", async (req: Request, res: Response): Promise<any> => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
pgDump.on("error", (err) => {
|
pgDump.on("error", (err) => {
|
||||||
safeRmDir(tmpDir);
|
|
||||||
console.error("Failed to start pg_dump:", err);
|
console.error("Failed to start pg_dump:", err);
|
||||||
// If headers haven't been sent, respond; otherwise just end socket
|
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res
|
res.status(500).json({ error: "Failed to run pg_dump", details: err.message });
|
||||||
.status(500)
|
|
||||||
.json({ error: "Failed to run pg_dump", details: err.message });
|
|
||||||
} else {
|
} else {
|
||||||
res.destroy(err);
|
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) => {
|
pgDump.on("close", async (code) => {
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
safeRmDir(tmpDir);
|
|
||||||
console.error("pg_dump failed:", pgStderr || `exit ${code}`);
|
console.error("pg_dump failed:", pgStderr || `exit ${code}`);
|
||||||
if (!res.headersSent) {
|
if (!headersSent) {
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
error: "Backup failed",
|
error: "Backup failed",
|
||||||
details: pgStderr || `pg_dump exited with ${code}`,
|
details: pgStderr || `pg_dump exited with ${code}`,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// headers already sent — destroy response
|
|
||||||
res.destroy(new Error("pg_dump failed"));
|
res.destroy(new Error("pg_dump failed"));
|
||||||
return;
|
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 {
|
try {
|
||||||
await archive.finalize();
|
await storage.createBackup(userId);
|
||||||
} catch (err: any) {
|
await storage.deleteNotificationsByType(userId, "BACKUP");
|
||||||
console.error("Failed to finalize archive:", err);
|
} catch (err) {
|
||||||
// if headers not sent, send 500; otherwise destroy
|
console.error("Backup saved but metadata update failed:", err);
|
||||||
try {
|
|
||||||
if (!res.headersSent) {
|
|
||||||
res.status(500).json({
|
|
||||||
error: "Failed to finalize archive",
|
|
||||||
details: String(err),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
res.destroy(err);
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
safeRmDir(tmpDir);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!res.writableEnded) res.end();
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Unexpected error in /backup:", err);
|
console.error("Unexpected error in /backup:", err);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res
|
res.status(500).json({ message: "Internal server error", details: String(err) });
|
||||||
.status(500)
|
|
||||||
.json({ message: "Internal server error", details: String(err) });
|
|
||||||
} else {
|
} else {
|
||||||
res.destroy(err);
|
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 {
|
try {
|
||||||
await backupDatabaseToPath({
|
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<any> => {
|
||||||
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -1,14 +1,4 @@
|
|||||||
import { spawn } from "child_process";
|
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 {
|
interface BackupToPathParams {
|
||||||
destinationPath: string;
|
destinationPath: string;
|
||||||
@@ -19,24 +9,24 @@ export async function backupDatabaseToPath({
|
|||||||
destinationPath,
|
destinationPath,
|
||||||
filename,
|
filename,
|
||||||
}: BackupToPathParams): Promise<void> {
|
}: BackupToPathParams): Promise<void> {
|
||||||
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) => {
|
return new Promise((resolve, reject) => {
|
||||||
const pgDump = spawn(
|
const pgDump = spawn(
|
||||||
"pg_dump",
|
"pg_dump",
|
||||||
[
|
[
|
||||||
"-Fd",
|
|
||||||
"-j",
|
|
||||||
"4",
|
|
||||||
"--no-acl",
|
"--no-acl",
|
||||||
"--no-owner",
|
"--no-owner",
|
||||||
"-h",
|
"-h",
|
||||||
process.env.DB_HOST || "localhost",
|
process.env.DB_HOST || "localhost",
|
||||||
"-U",
|
"-U",
|
||||||
process.env.DB_USER || "postgres",
|
process.env.DB_USER || "postgres",
|
||||||
process.env.DB_NAME || "dental_db",
|
|
||||||
"-f",
|
"-f",
|
||||||
tmpDir,
|
outputFile,
|
||||||
|
process.env.DB_NAME || "dental_db",
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
env: {
|
env: {
|
||||||
@@ -50,36 +40,15 @@ export async function backupDatabaseToPath({
|
|||||||
|
|
||||||
pgDump.stderr.on("data", (d) => (pgError += d.toString()));
|
pgDump.stderr.on("data", (d) => (pgError += d.toString()));
|
||||||
|
|
||||||
pgDump.on("close", async (code) => {
|
pgDump.on("close", (code) => {
|
||||||
if (code !== 0) {
|
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"));
|
return reject(new Error(pgError || "pg_dump failed"));
|
||||||
}
|
}
|
||||||
|
resolve();
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} 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 { apiRequest, queryClient } from "@/lib/queryClient";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { FolderBrowserModal } from "./folder-browser-modal";
|
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
|
// Folder browser
|
||||||
// ==============================
|
// ==============================
|
||||||
@@ -172,13 +187,25 @@ export function BackupDestinationManager() {
|
|||||||
className="flex justify-between items-center border rounded p-2"
|
className="flex justify-between items-center border rounded p-2"
|
||||||
>
|
>
|
||||||
<span className="text-sm text-gray-700">{d.path}</span>
|
<span className="text-sm text-gray-700">{d.path}</span>
|
||||||
<Button
|
<div className="flex gap-2">
|
||||||
size="sm"
|
<Button
|
||||||
variant="destructive"
|
size="sm"
|
||||||
onClick={() => setDeleteId(d.id)}
|
variant="outline"
|
||||||
>
|
onClick={() => backupNowMutation.mutate()}
|
||||||
<Trash2 className="h-4 w-4" />
|
disabled={backupNowMutation.isPending}
|
||||||
</Button>
|
title="Backup now to this destination"
|
||||||
|
>
|
||||||
|
<HardDrive className="h-4 w-4 mr-1" />
|
||||||
|
{backupNowMutation.isPending ? "Backing up..." : "Backup Now"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setDeleteId(d.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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<HTMLInputElement>(null);
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(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<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center space-x-2">
|
||||||
|
<UploadCloud className="h-5 w-5" />
|
||||||
|
<span>Import Database</span>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Restore the database from a <span className="font-medium text-gray-700">.sql</span> backup file.
|
||||||
|
This will overwrite all existing data.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".sql"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="block text-sm text-gray-600 file:mr-3 file:py-1.5 file:px-3 file:rounded file:border file:border-gray-300 file:text-sm file:bg-white file:text-gray-700 hover:file:bg-gray-50 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedFile && (
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Selected: <span className="font-medium text-gray-800">{selectedFile.name}</span>{" "}
|
||||||
|
({(selectedFile.size / 1024 / 1024).toFixed(1)} MB)
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleImportClick}
|
||||||
|
disabled={!selectedFile || restoreMutation.isPending}
|
||||||
|
variant="destructive"
|
||||||
|
className="flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
<span>{restoreMutation.isPending ? "Restoring..." : "Import & Restore"}</span>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Restore database?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will overwrite <strong>all existing data</strong> with the contents of{" "}
|
||||||
|
<strong>{selectedFile?.name}</strong>. This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleConfirm} className="bg-red-600 hover:bg-red-700">
|
||||||
|
Yes, restore
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ 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";
|
import { BackupDestinationManager } from "@/components/database-management/backup-destination-manager";
|
||||||
|
import { ImportDatabaseSection } from "@/components/database-management/import-database-section";
|
||||||
|
|
||||||
export default function DatabaseManagementPage() {
|
export default function DatabaseManagementPage() {
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
@@ -260,6 +261,9 @@ export default function DatabaseManagementPage() {
|
|||||||
{/* Externa Drive automatic backup manager */}
|
{/* Externa Drive automatic backup manager */}
|
||||||
<BackupDestinationManager />
|
<BackupDestinationManager />
|
||||||
|
|
||||||
|
{/* Import / Restore Database */}
|
||||||
|
<ImportDatabaseSection />
|
||||||
|
|
||||||
{/* Database Status Section */}
|
{/* Database Status Section */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
Reference in New Issue
Block a user