- 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>
604 lines
19 KiB
TypeScript
Executable File
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;
|