From a8ec1a21c0896ccba9489fd64c2d4e58f7aaf171 Mon Sep 17 00:00:00 2001 From: ff Date: Tue, 9 Jun 2026 10:10:46 -0400 Subject: [PATCH] feat: include uploads folder in network sync (all three subfolders) Daily sync and Sync Now both pull database + uploads in one operation. PC1 streams uploads/ as a zip via GET /network-backup-files (archiver). PC2 clears cloud-storage, patients, and patient-documents then extracts the fresh copy before resolving. Timeout extended to 5 min for large files. Co-Authored-By: Claude Sonnet 4.6 --- apps/Backend/src/cron/backupCheck.ts | 5 +- .../Backend/src/routes/database-management.ts | 31 +++++++- .../src/services/networkSyncService.ts | 74 +++++++++++++++++++ .../network-backup-manager.tsx | 8 +- 4 files changed, 111 insertions(+), 7 deletions(-) diff --git a/apps/Backend/src/cron/backupCheck.ts b/apps/Backend/src/cron/backupCheck.ts index 2f47380d..4c28c540 100755 --- a/apps/Backend/src/cron/backupCheck.ts +++ b/apps/Backend/src/cron/backupCheck.ts @@ -5,7 +5,7 @@ import { storage } from "../storage"; import { backupDatabaseToPath } from "../services/databaseBackupService"; import { cronJobLogStorage } from "../storage/cron-job-log-storage"; import { readSyncConfig, writeSyncConfig } from "../services/networkSyncConfigService"; -import { runNetworkSync } from "../services/networkSyncService"; +import { runNetworkSync, runNetworkFilesSync } from "../services/networkSyncService"; // Local backup folder in the app root (apps/Backend/backups) const LOCAL_BACKUP_DIR = path.resolve(process.cwd(), "backups"); @@ -192,9 +192,10 @@ export const startBackupCron = () => { try { await runNetworkSync(config.sourceUrl, config.apiKey); + await runNetworkFilesSync(config.sourceUrl, config.apiKey); writeSyncConfig({ lastSyncAt: new Date().toISOString(), lastSyncStatus: "success", lastSyncError: null }); await cronJobLogStorage.completeJobLog(log.id, "success", new Date()); - console.log(`✅ Network sync complete.`); + console.log(`✅ Network sync complete (database + uploads).`); } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); console.error("Network sync failed:", err); diff --git a/apps/Backend/src/routes/database-management.ts b/apps/Backend/src/routes/database-management.ts index 1347e884..41c251e9 100755 --- a/apps/Backend/src/routes/database-management.ts +++ b/apps/Backend/src/routes/database-management.ts @@ -7,13 +7,16 @@ 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 } from "../services/networkSyncService"; +import { runNetworkSync, runNetworkFilesSync } from "../services/networkSyncService"; + +const UPLOADS_DIR = path.join(process.cwd(), "uploads"); const MIGRATIONS_DIR = path.resolve(__dirname, "../../../../packages/db/prisma/migrations"); @@ -532,6 +535,31 @@ router.get("/network-backup", async (req: Request, res: Response): Promise }); }); +// 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 // ============================== @@ -563,6 +591,7 @@ router.post("/network-sync-now", async (req: Request, res: Response): Promise }); }); } + +export function runNetworkFilesSync(sourceUrl: string, apiKey: string): Promise { + return new Promise((resolve, reject) => { + let targetUrl: URL; + try { + targetUrl = new URL("/api/database-management/network-backup-files", sourceUrl); + } catch { + return reject(new Error(`Invalid source URL: ${sourceUrl}`)); + } + + const client = targetUrl.protocol === "https:" ? https : http; + const tmpZip = path.join(os.tmpdir(), `network_files_${Date.now()}.zip`); + const tmpFile = fs.createWriteStream(tmpZip); + + const req = client.get( + targetUrl.href, + { headers: { "x-network-backup-key": apiKey } }, + (res) => { + if (res.statusCode !== 200) { + res.resume(); + tmpFile.close(() => { try { fs.unlinkSync(tmpZip); } catch {} }); + return reject(new Error(`Source returned HTTP ${res.statusCode}: ${res.statusMessage}`)); + } + + res.pipe(tmpFile); + + tmpFile.on("finish", () => { + tmpFile.close(() => { + // Clear all three upload subfolders, then extract fresh copy + for (const sub of ["cloud-storage", "patients", "patient-documents"]) { + const subDir = path.join(UPLOADS_DIR, sub); + if (fs.existsSync(subDir)) { + fs.rmSync(subDir, { recursive: true, force: true }); + } + fs.mkdirSync(subDir, { recursive: true }); + } + + const unzip = spawn("unzip", ["-o", tmpZip, "-d", UPLOADS_DIR]); + let stderr = ""; + unzip.stderr.on("data", (d) => (stderr += d.toString())); + unzip.on("error", (err) => { + try { fs.unlinkSync(tmpZip); } catch {} + reject(new Error(`Failed to extract uploads zip: ${err.message}`)); + }); + unzip.on("close", (code) => { + try { fs.unlinkSync(tmpZip); } catch {} + if (code !== 0) { + return reject(new Error(`unzip exited ${code}: ${stderr}`)); + } + resolve(); + }); + }); + }); + + tmpFile.on("error", (err) => { + try { fs.unlinkSync(tmpZip); } catch {} + reject(new Error(`Failed to write temp zip: ${err.message}`)); + }); + } + ); + + req.on("error", (err) => { + try { fs.unlinkSync(tmpZip); } catch {} + reject(new Error(`Network request failed: ${err.message}`)); + }); + req.setTimeout(300_000, () => { + req.destroy(); + reject(new Error("File sync request timed out after 5 minutes")); + }); + }); +} diff --git a/apps/Frontend/src/components/database-management/network-backup-manager.tsx b/apps/Frontend/src/components/database-management/network-backup-manager.tsx index f5d6cde2..1a7b8f5b 100644 --- a/apps/Frontend/src/components/database-management/network-backup-manager.tsx +++ b/apps/Frontend/src/components/database-management/network-backup-manager.tsx @@ -189,9 +189,9 @@ export function NetworkBackupManager() {

Sync from Another PC

- Configure this machine to pull a fresh copy of the database from another PC at - a scheduled time each day. Enter the source PC's local IP address and its - Backup Key. + Configure this machine to pull a fresh copy of the database and all uploaded + files (patient photos, cloud storage, documents) from another PC at a scheduled + time each day. Enter the source PC's local IP address and its Backup Key.

{/* Enable toggle + time picker on same row */} @@ -256,7 +256,7 @@ export function NetworkBackupManager() { variant="outline" onClick={() => syncNowMutation.mutate()} disabled={syncNowMutation.isPending || !sourceUrl || !receiverApiKey} - title="Pull and restore now (replaces this machine's database)" + title="Pull and restore now — replaces this machine's database and uploads folder" > {syncNowMutation.isPending ? "Syncing…" : "Sync Now"}