Files
DentalManagementMH06/apps/Backend/src/routes/database-management.ts
ff d4b9c1b889 feat: save claim attachments to cloud storage and documents page
- Claim file uploads (chatbot or manual) now save to both the Cloud
  Storage patient folder and the Documents page via new
  POST /api/claims/upload-to-cloud endpoint
- MH submit flow now calls uploadAttachmentsToLocalFolder (same as
  DDMA/United/Tufts) so chatbot-attached X-rays are persisted
- Removed old /upload-attachments disk route and attachmentDiskStorage
  multer config; deleted uploads/patients/ folder
- uploadAttachmentsToLocalFolder now points to /upload-to-cloud and
  sends patientId so the backend can create the patient folder

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 00:30:32 -04:00

604 lines
19 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";
const UPLOADS_DIR = path.join(process.cwd(), "uploads");
const MIGRATIONS_DIR = path.resolve(__dirname, "../../../../packages/db/prisma/migrations");
// Applies migration SQL files that are missing from the database.
// Reads each folder in the migrations directory in sorted order, checks
// whether the migration is already recorded as successfully applied in
// _prisma_migrations, and runs the SQL if not. Safe to call after any
// restore because it uses IF NOT EXISTS semantics or checks first.
async function applyMissingMigrations() {
let folders: string[];
try {
folders = fs.readdirSync(MIGRATIONS_DIR)
.filter((name) => fs.statSync(path.join(MIGRATIONS_DIR, name)).isDirectory())
.sort();
} catch {
console.warn("Could not read migrations directory, skipping post-restore migration.");
return;
}
// Fetch the set of successfully-applied migration names from the DB.
let applied: Set<string>;
try {
const rows = await prisma.$queryRaw<{ migration_name: string }[]>`
SELECT migration_name FROM "_prisma_migrations" WHERE finished_at IS NOT NULL
`;
applied = new Set(rows.map((r) => r.migration_name));
} catch {
// _prisma_migrations may not exist in very old backups; proceed anyway.
applied = new Set();
}
for (const folder of folders) {
if (applied.has(folder)) continue;
const sqlFile = path.join(MIGRATIONS_DIR, folder, "migration.sql");
if (!fs.existsSync(sqlFile)) continue;
const sql = fs.readFileSync(sqlFile, "utf8");
try {
await prisma.$executeRawUnsafe(sql);
console.log(`Applied migration: ${folder}`);
} catch (err: any) {
// Log but continue — some statements may already exist (e.g. after a
// partial restore) and that is acceptable.
console.warn(`Migration ${folder} had errors (may already be applied):`, err.message);
}
}
}
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 });
});
// 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" });
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 applyMissingMigrations();
} catch (err) {
console.error("applyMissingMigrations 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, 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 });
}
});
export default router;