feat: add Network Backup section to Database Management

PC2 can now automatically pull and restore a fresh copy of PC1's database
on a daily schedule. Config and API key are stored in local JSON files so
they survive database restores.

- New networkSyncConfigService: file-based config (network-backup-key.json,
  network-sync-config.json) that persists through DB restores
- New networkSyncService: streams live pg_dump from source PC over HTTP and
  pipes into psql, then reconnects Prisma and applies missing migrations
- 6 new endpoints: get/regenerate API key, serve backup stream (key-auth
  only), get/save sync config, trigger immediate sync
- Hourly cron job that fires only when current hour matches configured syncHour
- NetworkBackupManager component: shows this machine's key (show/copy/regen)
  and receiver config (enable toggle, hour picker 0-23, source URL + key,
  Save + Sync Now, last sync status)
- README setup guide for both PCs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ff
2026-06-09 00:15:42 -04:00
parent 27d95ed752
commit f5f3768108
8 changed files with 668 additions and 1 deletions

View File

@@ -450,6 +450,38 @@ Paste this key into the Activation page in the app.
---
## Network Backup Setup (PC-to-PC Sync)
Two PCs running the app can be linked so the backup PC automatically pulls a fresh copy of the main PC's database every night. The config survives database restores because it is stored in local files, not in the database.
**Prerequisites:** Both PCs must be on the same local network (e.g. connected to the same router or switch). Set a static IP on the main PC so its address never changes after a reboot (set in the OS network settings, not in the router).
### On PC1 (main server)
1. Open the app → **Database Management** → **Network Backup**
2. Under **This Machine's Backup Key**, click the eye icon to reveal the key
3. Click the copy button to copy it
### On PC2 (backup PC)
1. Open the app → **Database Management** → **Network Backup**
2. Under **Sync from Another PC**:
- Toggle **Enable daily sync** on
- Select the hour you want the sync to run (e.g. `12:00 AM (midnight)`)
- Enter PC1's URL in the **Source PC URL** field, e.g. `http://192.168.0.94:3000`
- Paste PC1's key into the **Source PC API Key** field
3. Click **Save Settings**
4. Click **Sync Now** to test — PC2's database will be replaced with PC1's
After a successful test, the sync will run automatically at the scheduled hour every day.
**Notes:**
- The API key and sync config are stored in `apps/Backend/network-backup-key.json` and `apps/Backend/network-sync-config.json` — they survive database restores
- If you regenerate PC1's key, you must update it on PC2 as well
- The sync is one-way: PC2 always mirrors PC1; PC1 is never modified
---
## Claude Code Memory
Claude Code (the AI assistant used to build this project) stores its memory locally on the PC. This memory contains project context, architecture decisions, feature history, and working preferences — allowing Claude to pick up where it left off in new sessions.

View File

@@ -4,6 +4,8 @@ import path from "path";
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";
// Local backup folder in the app root (apps/Backend/backups)
const LOCAL_BACKUP_DIR = path.resolve(process.cwd(), "backups");
@@ -171,4 +173,40 @@ export const startBackupCron = () => {
console.log("✅ [9 PM] USB backup complete.");
});
// ============================================================
// Every hour — Network sync (runs only when hour matches config)
// ============================================================
cron.schedule("0 * * * *", async () => {
const config = readSyncConfig();
if (!config.enabled || !config.sourceUrl || !config.apiKey) return;
const currentHour = new Date().getHours();
if (currentHour !== config.syncHour) return;
console.log(`🔄 [${config.syncHour}:00] Running network sync from ${config.sourceUrl}...`);
const admin = await getAdminUser();
const startedAt = new Date();
const log = await cronJobLogStorage.createJobLog("network-sync", startedAt);
try {
await runNetworkSync(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.`);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
console.error("Network sync failed:", err);
writeSyncConfig({ lastSyncAt: new Date().toISOString(), lastSyncStatus: "failed", lastSyncError: errorMessage });
await cronJobLogStorage.completeJobLog(log.id, "failed", new Date(), errorMessage);
if (admin) {
await storage.createNotification(
admin.id,
"BACKUP",
`❌ Network sync failed: ${errorMessage}`
);
}
}
});
};

View File

@@ -7,6 +7,13 @@ import multer from "multer";
import { prisma } from "@repo/db/client";
import { storage } from "../storage";
import { backupDatabaseToPath } from "../services/databaseBackupService";
import {
getOrCreateApiKey,
regenerateApiKey,
readSyncConfig,
writeSyncConfig,
} from "../services/networkSyncConfigService";
import { runNetworkSync } from "../services/networkSyncService";
const MIGRATIONS_DIR = path.resolve(__dirname, "../../../../packages/db/prisma/migrations");
@@ -471,4 +478,97 @@ router.post("/restore", restoreUpload.single("file"), async (req: Request, res:
}
});
// ==============================
// 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);
});
});
// ==============================
// 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);
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;

View File

@@ -0,0 +1,58 @@
import fs from "fs";
import path from "path";
import crypto from "crypto";
const KEY_FILE = path.resolve(process.cwd(), "network-backup-key.json");
const CONFIG_FILE = path.resolve(process.cwd(), "network-sync-config.json");
export interface NetworkSyncConfig {
enabled: boolean;
syncHour: number;
sourceUrl: string;
apiKey: string;
lastSyncAt: string | null;
lastSyncStatus: string | null;
lastSyncError: string | null;
}
export function getOrCreateApiKey(): string {
if (fs.existsSync(KEY_FILE)) {
try {
const data = JSON.parse(fs.readFileSync(KEY_FILE, "utf8"));
if (data.apiKey) return data.apiKey;
} catch {}
}
const key = crypto.randomUUID();
fs.writeFileSync(KEY_FILE, JSON.stringify({ apiKey: key }), "utf8");
return key;
}
export function regenerateApiKey(): string {
const key = crypto.randomUUID();
fs.writeFileSync(KEY_FILE, JSON.stringify({ apiKey: key }), "utf8");
return key;
}
export function readSyncConfig(): NetworkSyncConfig {
if (fs.existsSync(CONFIG_FILE)) {
try {
return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
} catch {}
}
return {
enabled: false,
syncHour: 0,
sourceUrl: "",
apiKey: "",
lastSyncAt: null,
lastSyncStatus: null,
lastSyncError: null,
};
}
export function writeSyncConfig(patch: Partial<NetworkSyncConfig>): NetworkSyncConfig {
const current = readSyncConfig();
const updated = { ...current, ...patch };
fs.writeFileSync(CONFIG_FILE, JSON.stringify(updated, null, 2), "utf8");
return updated;
}

View File

@@ -0,0 +1,117 @@
import { spawn } from "child_process";
import http from "http";
import https from "https";
import { URL } from "url";
import path from "path";
import fs from "fs";
import { prisma } from "@repo/db/client";
const MIGRATIONS_DIR = path.resolve(__dirname, "../../../../packages/db/prisma/migrations");
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-sync migration.");
return;
}
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: { migration_name: string }) => r.migration_name));
} catch {
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) {
console.warn(`Migration ${folder} had errors (may already be applied):`, err.message);
}
}
}
export function runNetworkSync(sourceUrl: string, apiKey: string): Promise<void> {
return new Promise((resolve, reject) => {
let targetUrl: URL;
try {
targetUrl = new URL("/api/database-management/network-backup", sourceUrl);
} catch {
return reject(new Error(`Invalid source URL: ${sourceUrl}`));
}
const client = targetUrl.protocol === "https:" ? https : http;
const req = client.get(
targetUrl.href,
{ headers: { "x-network-backup-key": apiKey } },
async (res) => {
if (res.statusCode !== 200) {
res.resume();
return reject(
new Error(`Source returned HTTP ${res.statusCode}: ${res.statusMessage}`)
);
}
// Drop and recreate the public schema
try {
await prisma.$executeRawUnsafe(`DROP SCHEMA public CASCADE`);
await prisma.$executeRawUnsafe(`CREATE SCHEMA public`);
} catch (err: any) {
res.destroy();
return reject(new Error(`Failed to reset schema: ${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) => reject(new Error(`Failed to start psql: ${err.message}`)));
psql.on("close", async (code) => {
if (code !== 0) {
return reject(new Error(`psql exited ${code}: ${stderr}`));
}
try {
await prisma.$disconnect();
await prisma.$connect();
} catch (_) {}
try {
await applyMissingMigrations();
} catch (err) {
console.error("applyMissingMigrations failed after network sync:", err);
}
resolve();
});
res.pipe(psql.stdin);
}
);
req.on("error", (err) => reject(new Error(`Network request failed: ${err.message}`)));
req.setTimeout(120_000, () => {
req.destroy();
reject(new Error("Network backup request timed out after 120s"));
});
});
}

View File

@@ -0,0 +1,318 @@
import { useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Copy, Eye, EyeOff, RefreshCw, Network, RotateCcw } from "lucide-react";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
const HOUR_OPTIONS = Array.from({ length: 24 }, (_, h) => {
const label =
h === 0
? "12:00 AM (midnight)"
: h < 12
? `${h}:00 AM`
: h === 12
? "12:00 PM (noon)"
: `${h - 12}:00 PM`;
return { value: h, label };
});
export function NetworkBackupManager() {
const { toast } = useToast();
const [showKey, setShowKey] = useState(false);
const [confirmRegenOpen, setConfirmRegenOpen] = useState(false);
// receiver form state (initialised from query)
const [enabled, setEnabled] = useState(false);
const [syncHour, setSyncHour] = useState(0);
const [sourceUrl, setSourceUrl] = useState("");
const [receiverApiKey, setReceiverApiKey] = useState("");
const [formLoaded, setFormLoaded] = useState(false);
// ==============================
// Queries
// ==============================
const { data: keyData } = useQuery({
queryKey: ["/db/network-backup-key"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/database-management/network-backup-key");
return res.json() as Promise<{ apiKey: string }>;
},
});
useQuery({
queryKey: ["/db/network-sync-config"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/database-management/network-sync-config");
const data = await res.json();
if (!formLoaded) {
setEnabled(data.enabled ?? false);
setSyncHour(data.syncHour ?? 0);
setSourceUrl(data.sourceUrl ?? "");
setReceiverApiKey(data.apiKey ?? "");
setFormLoaded(true);
}
return data;
},
});
// ==============================
// Mutations
// ==============================
const regenMutation = useMutation({
mutationFn: async () => {
const res = await apiRequest("POST", "/api/database-management/network-backup-key/regenerate");
return res.json() as Promise<{ apiKey: string }>;
},
onSuccess: (data) => {
queryClient.setQueryData(["/db/network-backup-key"], data);
setConfirmRegenOpen(false);
toast({ title: "API key regenerated", description: "Update the key on your backup PC." });
},
onError: () => {
toast({ title: "Error", description: "Failed to regenerate key.", variant: "destructive" });
},
});
const saveMutation = useMutation({
mutationFn: async () => {
const res = await apiRequest("PUT", "/api/database-management/network-sync-config", {
enabled,
syncHour,
sourceUrl,
apiKey: receiverApiKey,
});
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/db/network-sync-config"] });
toast({ title: "Network sync settings saved" });
},
onError: () => {
toast({ title: "Error", description: "Failed to save settings.", variant: "destructive" });
},
});
const syncNowMutation = useMutation({
mutationFn: async () => {
const res = await apiRequest("POST", "/api/database-management/network-sync-now");
if (!res.ok) {
const body = await res.json();
throw new Error(body.details || body.error || "Sync failed");
}
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/db/network-sync-config"] });
toast({ title: "Sync complete", description: "Database synced from source PC." });
},
onError: (err: any) => {
queryClient.invalidateQueries({ queryKey: ["/db/network-sync-config"] });
toast({ title: "Sync failed", description: err.message, variant: "destructive" });
},
});
const displayKey = keyData?.apiKey ?? "";
const maskedKey = displayKey ? "••••••••-••••-••••-••••-" + displayKey.slice(-12) : "—";
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Network className="h-5 w-5" />
Network Backup
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* ── Section A: Source role (this machine's API key) ── */}
<div className="space-y-3">
<p className="text-sm font-semibold text-gray-800">This Machine's Backup Key</p>
<p className="text-xs text-gray-500">
Share this key with the backup PC so it can pull a copy of this machine's
database. The key survives database restores (stored in a local file).
</p>
<div className="flex items-center gap-2">
<Input
readOnly
value={showKey ? displayKey : maskedKey}
className="font-mono text-sm"
/>
<Button
variant="outline"
size="icon"
title={showKey ? "Hide key" : "Show key"}
onClick={() => setShowKey((v) => !v)}
>
{showKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
<Button
variant="outline"
size="icon"
title="Copy to clipboard"
onClick={() => {
navigator.clipboard.writeText(displayKey);
toast({ title: "Copied to clipboard" });
}}
disabled={!displayKey}
>
<Copy className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
title="Regenerate key"
onClick={() => setConfirmRegenOpen(true)}
disabled={regenMutation.isPending}
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</div>
<div className="border-t" />
{/* ── Section B: Receiver role (pull config) ── */}
<div className="space-y-4">
<p className="text-sm font-semibold text-gray-800">Sync from Another PC</p>
<p className="text-xs text-gray-500">
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.
</p>
{/* Enable toggle + time picker on same row */}
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<Switch
id="network-sync-toggle"
checked={enabled}
onCheckedChange={setEnabled}
/>
<label
htmlFor="network-sync-toggle"
className="text-sm font-medium text-gray-700 cursor-pointer select-none"
>
Enable daily sync
</label>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-gray-600 whitespace-nowrap">at</label>
<select
className="border rounded px-2 py-1 text-sm text-gray-700 bg-white"
value={syncHour}
onChange={(e) => setSyncHour(Number(e.target.value))}
>
{HOUR_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-gray-600">Source PC URL</label>
<Input
placeholder="http://192.168.0.94:3000"
value={sourceUrl}
onChange={(e) => setSourceUrl(e.target.value)}
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-gray-600">Source PC API Key</label>
<Input
type="password"
placeholder="Paste the key from the source PC"
value={receiverApiKey}
onChange={(e) => setReceiverApiKey(e.target.value)}
/>
</div>
<div className="flex gap-2">
<Button
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? "Saving…" : "Save Settings"}
</Button>
<Button
variant="outline"
onClick={() => syncNowMutation.mutate()}
disabled={syncNowMutation.isPending || !sourceUrl || !receiverApiKey}
title="Pull and restore now (replaces this machine's database)"
>
<RotateCcw className="h-4 w-4 mr-1" />
{syncNowMutation.isPending ? "Syncing" : "Sync Now"}
</Button>
</div>
{/* Last sync status */}
<SyncStatus />
</div>
</CardContent>
{/* Confirm regenerate dialog */}
<AlertDialog open={confirmRegenOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Regenerate backup key?</AlertDialogTitle>
<AlertDialogDescription>
The old key will stop working immediately. You will need to update the API key
on any backup PC that is currently configured to sync from this machine.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setConfirmRegenOpen(false)}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => regenMutation.mutate()}
disabled={regenMutation.isPending}
>
Regenerate
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Card>
);
}
function SyncStatus() {
const { data } = useQuery({
queryKey: ["/db/network-sync-config"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/database-management/network-sync-config");
return res.json();
},
});
if (!data?.lastSyncAt) return null;
const date = new Date(data.lastSyncAt).toLocaleString();
const ok = data.lastSyncStatus === "success";
return (
<div className={`text-xs rounded p-2 ${ok ? "bg-green-50 text-green-700" : "bg-red-50 text-red-700"}`}>
{ok ? "" : ""} Last sync: {date}
{!ok && data.lastSyncError && (
<span className="block mt-0.5 text-red-500">{data.lastSyncError}</span>
)}
</div>
);
}

View File

@@ -14,6 +14,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { formatDateToHumanReadable } from "@/utils/dateUtils";
import { BackupDestinationManager } from "@/components/database-management/backup-destination-manager";
import { NetworkBackupManager } from "@/components/database-management/network-backup-manager";
import { ImportDatabaseSection } from "@/components/database-management/import-database-section";
export default function DatabaseManagementPage() {
@@ -261,6 +262,9 @@ export default function DatabaseManagementPage() {
{/* Externa Drive automatic backup manager */}
<BackupDestinationManager />
{/* Network Backup (PC-to-PC sync) */}
<NetworkBackupManager />
{/* Import / Restore Database */}
<ImportDatabaseSection />

View File

@@ -1,7 +1,7 @@
{
"version": "1.0",
"generatorVersion": "1.0.0",
"generatedAt": "2026-06-06T04:01:46.483Z",
"generatedAt": "2026-06-09T04:01:43.616Z",
"outputPath": "/home/ff/Desktop/DentalManagementMH06/packages/db/shared",
"files": [
"schemas/enums/TransactionIsolationLevel.schema.ts",