import { Router, Request, Response } from "express"; import { spawn } from "child_process"; import path from "path"; import os from "os"; import fs from "fs"; import { prisma } from "@repo/db/client"; import { storage } from "../storage"; import archiver from "archiver"; 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; if (!userId) { return res.status(401).json({ error: "Unauthorized" }); } // 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 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", process.env.DB_HOST || "localhost", "-U", process.env.DB_USER || "postgres", process.env.DB_NAME || "dental_db", "-f", tmpDir, // write parallely ], { env: { ...process.env, PGPASSWORD: process.env.DB_PASSWORD, }, } ); let pgStderr = ""; pgDump.stderr.on("data", (chunk) => { pgStderr += chunk.toString(); }); 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 }); } else { res.destroy(err); } }); pgDump.on("close", async (code) => { if (code !== 0) { safeRmDir(tmpDir); console.error("pg_dump failed:", pgStderr || `exit ${code}`); if (!res.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); } }); } 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) }); } else { res.destroy(err); } } }); /** * Get database status (connected, size, records count) */ router.get("/status", async (req: Request, res: Response): Promise => { try { const userId = req.user?.id; if (!userId) { return res.status(401).json({ error: "Unauthorized" }); } const size = await prisma.$queryRawUnsafe<{ size: string }[]>( "SELECT pg_size_pretty(pg_database_size(current_database())) as size" ); const patientsCount = await storage.getTotalPatientCount(); const lastBackup = await storage.getLastBackup(userId); res.json({ connected: true, size: size[0]?.size, patients: patientsCount, lastBackup: lastBackup?.createdAt ?? null, }); } catch (err) { console.error("Status error:", err); res.status(500).json({ connected: false, error: "Could not fetch database status", }); } }); export default router;