Files
Wellspringmanagement01/apps/Backend/src/routes/database-management.ts
T
Summit Dental CareandClaude Sonnet 4.6 7deba90db3 fix: use prisma db push after backup import to sync schema reliably
applyMissingMigrations ran the entire migration SQL as one transaction,
which rolled back all changes when any statement failed (e.g. adding a
NOT NULL column to a non-empty table). Replaced with prisma db push
which compares the schema and applies only what's needed. Also added
support for importing .sql files (not just .zip) in auto-import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 16:05:18 -04:00

664 lines
22 KiB
TypeScript
Executable File

import { Router, Request, Response } from "express";
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 { backupDatabaseToPath } from "../services/databaseBackupService";
import archiver from "archiver";
import {
getOrCreateApiKey,
regenerateApiKey,
readSyncConfig,
writeSyncConfig,
} from "../services/networkSyncConfigService";
import { runNetworkSync, runNetworkFilesSync } from "../services/networkSyncService";
import { readRcloneConfig, writeRcloneConfig } from "../services/rcloneConfigService";
import { checkRcloneInstalled, isServerRunning, startWebDavServer, stopWebDavServer, runRclonePull } from "../services/rcloneService";
import { importLatestBackup } from "../services/autoImportService";
const UPLOADS_DIR = path.join(process.cwd(), "uploads");
const SCHEMA_PATH = path.resolve(__dirname, "../../../../packages/db/prisma/schema.prisma");
function runDbPush(): Promise<void> {
return new Promise((resolve, reject) => {
const proc = spawn("npx", ["prisma", "db", "push", "--config", path.resolve(__dirname, "../../../../packages/db/prisma/prisma.config.ts"), "--schema", SCHEMA_PATH, "--accept-data-loss"], {
cwd: process.cwd(),
env: process.env,
});
let stderr = "";
proc.stdout.on("data", (d) => console.log(`[db-push] ${d.toString().trim()}`));
proc.stderr.on("data", (d) => {
const line = d.toString().trim();
stderr += line + "\n";
console.log(`[db-push] ${line}`);
});
proc.on("error", (err) => reject(new Error(`Failed to run prisma db push: ${err.message}`)));
proc.on("close", (code) => {
if (code !== 0) return reject(new Error(`prisma db push failed (exit ${code}): ${stderr}`));
console.log("[db-push] Schema synced successfully");
resolve();
});
});
}
const restoreUpload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 500 * 1024 * 1024 }, // 500 MB
fileFilter: (_req, file, cb) => {
const name = file.originalname.toLowerCase();
if (name.endsWith(".sql") || name.endsWith(".zip")) cb(null, true);
else cb(new Error("Only .sql or .zip files are allowed"));
},
});
const router = Router();
router.post("/backup", async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user?.id;
if (!userId) {
return res.status(401).json({ error: "Unauthorized" });
}
const filename = `dental_backup_${Date.now()}.sql`;
// Spawn pg_dump in plain SQL format, streaming stdout directly to response
const pgDump = spawn(
"pg_dump",
[
"--no-acl",
"--no-owner",
"-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 pgStderr = "";
pgDump.stderr.on("data", (chunk) => {
pgStderr += chunk.toString();
});
pgDump.on("error", (err) => {
console.error("Failed to start pg_dump:", err);
if (!res.headersSent) {
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) {
console.error("pg_dump failed:", pgStderr || `exit ${code}`);
if (!headersSent) {
return res.status(500).json({
error: "Backup failed",
details: pgStderr || `pg_dump exited with ${code}`,
});
} else {
res.destroy(new Error("pg_dump failed"));
return;
}
}
try {
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) {
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<any> => {
try {
const userId = req.user?.id;
if (!userId) {
return res.status(401).json({ error: "Unauthorized" });
}
const size = await prisma.$queryRaw<{ 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",
});
}
});
// ==============================
// Backup Destination CRUD
// ==============================
// CREATE / UPDATE destination
router.post("/destination", async (req, res) => {
const userId = req.user?.id;
const { path: destinationPath } = req.body;
if (!userId) return res.status(401).json({ error: "Unauthorized" });
if (!destinationPath)
return res.status(400).json({ error: "Path is required" });
// validate path exists
if (!fs.existsSync(destinationPath)) {
return res.status(400).json({
error: "Backup path does not exist or drive not connected",
});
}
try {
const destination = await storage.createBackupDestination(
userId,
destinationPath
);
res.json(destination);
} catch (err) {
console.error(err);
res.status(500).json({ error: "Failed to save backup destination" });
}
});
// GET all destinations
router.get("/destination", async (req, res) => {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ error: "Unauthorized" });
const destinations = await storage.getAllBackupDestination(userId);
res.json(destinations);
});
// UPDATE destination
router.put("/destination/:id", async (req, res) => {
const userId = req.user?.id;
const id = Number(req.params.id);
const { path: destinationPath } = req.body;
if (!userId) return res.status(401).json({ error: "Unauthorized" });
if (!destinationPath)
return res.status(400).json({ error: "Path is required" });
if (!fs.existsSync(destinationPath)) {
return res.status(400).json({ error: "Path does not exist" });
}
const updated = await storage.updateBackupDestination(
id,
userId,
destinationPath
);
res.json(updated);
});
// DELETE destination
router.delete("/destination/:id", async (req, res) => {
const userId = req.user?.id;
const id = Number(req.params.id);
if (!userId) return res.status(401).json({ error: "Unauthorized" });
await storage.deleteBackupDestination(id, userId);
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, usbBackupHour: user.usbBackupHour ?? 21 });
});
// 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, usbBackupHour } = req.body;
const patch: any = {};
if (typeof usbBackupEnabled === "boolean") patch.usbBackupEnabled = usbBackupEnabled;
if (typeof usbBackupHour === "number") patch.usbBackupHour = usbBackupHour;
const updated = await storage.updateUser(userId, patch);
if (!updated) return res.status(404).json({ error: "User not found" });
res.json({ usbBackupEnabled: updated.usbBackupEnabled, usbBackupHour: updated.usbBackupHour ?? 21 });
});
// 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, autoBackupHour: user.autoBackupHour ?? 20 });
});
// 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, autoBackupHour } = req.body;
const patch: any = {};
if (typeof autoBackupEnabled === "boolean") patch.autoBackupEnabled = autoBackupEnabled;
if (typeof autoBackupHour === "number") patch.autoBackupHour = autoBackupHour;
const updated = await storage.updateUser(userId, patch);
if (!updated) return res.status(404).json({ error: "User not found" });
res.json({ autoBackupEnabled: updated.autoBackupEnabled, autoBackupHour: updated.autoBackupHour ?? 20 });
});
router.post("/backup-path", async (req, res) => {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ error: "Unauthorized" });
const destination = await storage.getActiveBackupDestination(userId);
if (!destination) {
return res.status(400).json({
error: "No backup destination configured",
});
}
if (!fs.existsSync(destination.path)) {
return res.status(400).json({
error:
"Backup destination not found. External drive may be disconnected.",
});
}
const filename = `dental_backup_${Date.now()}.zip`;
try {
await backupDatabaseToPath({
destinationPath: destination.path,
filename,
});
await storage.createBackup(userId);
await storage.deleteNotificationsByType(userId, "BACKUP");
res.json({ success: true, filename });
} catch (err: any) {
console.error(err);
res.status(500).json({
error: "Backup to destination failed",
details: err.message,
});
}
});
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 isZip = req.file.originalname.toLowerCase().endsWith(".zip");
// For zip files, write to a temp file so unzip can read it
let tmpZipPath: string | null = null;
if (isZip) {
tmpZipPath = path.join(os.tmpdir(), `restore_${Date.now()}.zip`);
fs.writeFileSync(tmpZipPath, req.file.buffer);
}
// Drop and recreate the public schema so existing tables don't block the restore.
try {
await prisma.$executeRawUnsafe(`DROP SCHEMA public CASCADE`);
await prisma.$executeRawUnsafe(`CREATE SCHEMA public`);
} catch (err: any) {
if (tmpZipPath) try { fs.unlinkSync(tmpZipPath); } catch {}
console.error("Failed to reset schema before restore:", err);
return res.status(500).json({ error: "Failed to reset database schema", details: err.message });
}
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) => {
if (tmpZipPath) try { fs.unlinkSync(tmpZipPath); } catch {}
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", async (code) => {
if (tmpZipPath) try { fs.unlinkSync(tmpZipPath); } catch {}
if (code !== 0) {
console.error("psql restore failed:", stderr);
return res.status(500).json({ error: "Restore failed", details: stderr });
}
// Reconnect Prisma after schema was replaced
try {
await prisma.$disconnect();
await prisma.$connect();
} catch (_) {}
// Apply any migrations the backup may be missing. We run each migration
// SQL file directly instead of using `prisma migrate deploy` because the
// restored _prisma_migrations table may contain orphaned entries (migration
// names that have no matching file) which cause Prisma CLI to abort.
try {
await runDbPush();
} catch (err) {
console.error("[restore] db push failed after restore:", err);
}
res.json({ success: true });
});
if (isZip && tmpZipPath) {
// Pipe the first .sql entry from the zip directly into psql stdin
const unzip = spawn("unzip", ["-p", tmpZipPath, "*.sql"]);
let unzipErr = "";
unzip.stderr.on("data", (d) => (unzipErr += d.toString()));
unzip.on("error", (err) => {
if (tmpZipPath) try { fs.unlinkSync(tmpZipPath); } catch {}
if (!res.headersSent)
res.status(500).json({ error: "Failed to extract zip", details: err.message });
});
unzip.stdout.pipe(psql.stdin);
} else {
psql.stdin.write(req.file.buffer);
psql.stdin.end();
}
});
// ==============================
// Network Backup — Source Role
// ==============================
// GET /network-backup-key — return (or auto-generate) this machine's API key
router.get("/network-backup-key", async (req, res) => {
if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" });
res.json({ apiKey: getOrCreateApiKey() });
});
// POST /network-backup-key/regenerate — generate a new key
router.post("/network-backup-key/regenerate", async (req, res) => {
if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" });
res.json({ apiKey: regenerateApiKey() });
});
// GET /network-backup — streams a live pg_dump; authenticated by API key header only
router.get("/network-backup", async (req: Request, res: Response): Promise<any> => {
const providedKey = req.headers["x-network-backup-key"] as string | undefined;
if (!providedKey) return res.status(401).json({ error: "Missing X-Network-Backup-Key header" });
const storedKey = getOrCreateApiKey();
if (providedKey !== storedKey) return res.status(401).json({ error: "Invalid API key" });
const pg = spawn(
"pg_dump",
[
"--no-acl", "--no-owner",
"-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 } }
);
res.setHeader("Content-Type", "application/octet-stream");
res.setHeader(
"Content-Disposition",
`attachment; filename="network_backup_${Date.now()}.sql"`
);
pg.stdout.pipe(res);
let stderr = "";
pg.stderr.on("data", (d) => (stderr += d.toString()));
pg.on("error", (err) => {
if (!res.headersSent)
res.status(500).json({ error: "pg_dump failed", details: err.message });
});
pg.on("close", (code) => {
if (code !== 0) console.error("pg_dump for network backup failed:", stderr);
});
});
// GET /network-backup-files — streams uploads/ as a zip; authenticated by API key header only
router.get("/network-backup-files", (req: Request, res: Response): any => {
const providedKey = req.headers["x-network-backup-key"] as string | undefined;
if (!providedKey) return res.status(401).json({ error: "Missing X-Network-Backup-Key header" });
const storedKey = getOrCreateApiKey();
if (providedKey !== storedKey) return res.status(401).json({ error: "Invalid API key" });
if (!fs.existsSync(UPLOADS_DIR)) {
return res.status(200).end(); // nothing to send
}
res.setHeader("Content-Type", "application/zip");
res.setHeader("Content-Disposition", `attachment; filename="network_uploads_${Date.now()}.zip"`);
const archive = archiver("zip", { zlib: { level: 6 } });
archive.on("error", (err) => {
if (!res.headersSent) res.status(500).json({ error: "Failed to create archive", details: err.message });
});
archive.pipe(res);
archive.directory(UPLOADS_DIR, false); // zip contents without the "uploads" prefix
archive.finalize();
});
// ==============================
// Network Backup — Receiver Role
// ==============================
// GET /network-sync-config
router.get("/network-sync-config", (req, res) => {
if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" });
res.json(readSyncConfig());
});
// PUT /network-sync-config
router.put("/network-sync-config", (req: Request, res: Response): any => {
if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" });
const { enabled, syncHour, sourceUrl, apiKey } = req.body;
const updated = writeSyncConfig({
enabled,
syncHour,
sourceUrl: typeof sourceUrl === "string" ? sourceUrl.trim() : sourceUrl,
apiKey: typeof apiKey === "string" ? apiKey.trim() : apiKey,
});
res.json(updated);
});
// POST /network-sync-now — trigger an immediate pull sync
router.post("/network-sync-now", async (req: Request, res: Response): Promise<any> => {
if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" });
const config = readSyncConfig();
if (!config.sourceUrl || !config.apiKey) {
return res
.status(400)
.json({ error: "Source URL and API key must be configured before syncing" });
}
try {
await runNetworkSync(config.sourceUrl, config.apiKey);
await runNetworkFilesSync(config.sourceUrl, config.apiKey);
writeSyncConfig({ lastSyncAt: new Date().toISOString(), lastSyncStatus: "success", lastSyncError: null });
res.json({ success: true, syncedAt: new Date() });
} catch (err: any) {
writeSyncConfig({ lastSyncAt: new Date().toISOString(), lastSyncStatus: "failed", lastSyncError: err.message });
res.status(500).json({ error: "Sync failed", details: err.message });
}
});
// ==============================
// Rclone Backup
// ==============================
router.get("/rclone-config", (req, res) => {
if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" });
res.json(readRcloneConfig());
});
router.put("/rclone-config", (req: Request, res: Response): any => {
if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" });
const { serverEnabled, serverPort, receiverEnabled, receiverSyncHour, sourceIp, sourcePort, autoImportEnabled, autoImportHour } = req.body;
const patch: any = {};
if (typeof serverEnabled === "boolean") patch.serverEnabled = serverEnabled;
if (typeof serverPort === "number") patch.serverPort = serverPort;
if (typeof receiverEnabled === "boolean") patch.receiverEnabled = receiverEnabled;
if (typeof receiverSyncHour === "number") patch.receiverSyncHour = receiverSyncHour;
if (typeof sourceIp === "string") patch.sourceIp = sourceIp.trim();
if (typeof sourcePort === "number") patch.sourcePort = sourcePort;
if (typeof autoImportEnabled === "boolean") patch.autoImportEnabled = autoImportEnabled;
if (typeof autoImportHour === "number") patch.autoImportHour = autoImportHour;
const updated = writeRcloneConfig(patch);
res.json(updated);
});
router.get("/rclone-status", async (req, res) => {
if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" });
const installed = await checkRcloneInstalled();
const serverRunning = isServerRunning();
res.json({ installed, serverRunning });
});
router.post("/rclone-server/start", async (req: Request, res: Response): Promise<any> => {
if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" });
try {
await startWebDavServer();
res.json({ success: true, running: true });
} catch (err: any) {
res.status(500).json({ error: "Failed to start rclone server", details: err.message });
}
});
router.post("/rclone-server/stop", (req: Request, res: Response): any => {
if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" });
stopWebDavServer();
res.json({ success: true, running: false });
});
router.post("/rclone-pull-now", async (req: Request, res: Response): Promise<any> => {
if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" });
try {
await runRclonePull();
writeRcloneConfig({ lastSyncAt: new Date().toISOString(), lastSyncStatus: "success", lastSyncError: null });
res.json({ success: true, syncedAt: new Date() });
} catch (err: any) {
writeRcloneConfig({ lastSyncAt: new Date().toISOString(), lastSyncStatus: "failed", lastSyncError: err.message });
res.status(500).json({ error: "Rclone pull failed", details: err.message });
}
});
router.post("/auto-import-now", async (req: Request, res: Response): Promise<any> => {
if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" });
try {
await importLatestBackup();
writeRcloneConfig({ lastImportAt: new Date().toISOString(), lastImportStatus: "success", lastImportError: null });
res.json({ success: true, importedAt: new Date() });
} catch (err: any) {
writeRcloneConfig({ lastImportAt: new Date().toISOString(), lastImportStatus: "failed", lastImportError: err.message });
res.status(500).json({ error: "Auto-import failed", details: err.message });
}
});
export default router;