diff --git a/README.md b/README.md index 1e3f6da3..e6b19d4a 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/apps/Backend/src/cron/backupCheck.ts b/apps/Backend/src/cron/backupCheck.ts index 6d13482c..2f47380d 100755 --- a/apps/Backend/src/cron/backupCheck.ts +++ b/apps/Backend/src/cron/backupCheck.ts @@ -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}` + ); + } + } + }); }; diff --git a/apps/Backend/src/routes/database-management.ts b/apps/Backend/src/routes/database-management.ts index a80d422d..1347e884 100755 --- a/apps/Backend/src/routes/database-management.ts +++ b/apps/Backend/src/routes/database-management.ts @@ -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 => { + 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 => { + 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; diff --git a/apps/Backend/src/services/networkSyncConfigService.ts b/apps/Backend/src/services/networkSyncConfigService.ts new file mode 100644 index 00000000..f892d080 --- /dev/null +++ b/apps/Backend/src/services/networkSyncConfigService.ts @@ -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 { + const current = readSyncConfig(); + const updated = { ...current, ...patch }; + fs.writeFileSync(CONFIG_FILE, JSON.stringify(updated, null, 2), "utf8"); + return updated; +} diff --git a/apps/Backend/src/services/networkSyncService.ts b/apps/Backend/src/services/networkSyncService.ts new file mode 100644 index 00000000..d8046cec --- /dev/null +++ b/apps/Backend/src/services/networkSyncService.ts @@ -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; + 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 { + 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")); + }); + }); +} diff --git a/apps/Frontend/src/components/database-management/network-backup-manager.tsx b/apps/Frontend/src/components/database-management/network-backup-manager.tsx new file mode 100644 index 00000000..f5d6cde2 --- /dev/null +++ b/apps/Frontend/src/components/database-management/network-backup-manager.tsx @@ -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 ( + + + + + Network Backup + + + + + {/* ── Section A: Source role (this machine's API key) ── */} +
+

This Machine's Backup Key

+

+ 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). +

+
+ + + + +
+
+ +
+ + {/* ── Section B: Receiver role (pull config) ── */} +
+

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. +

+ + {/* Enable toggle + time picker on same row */} +
+
+ + +
+ +
+ + +
+
+ +
+ + setSourceUrl(e.target.value)} + /> +
+ +
+ + setReceiverApiKey(e.target.value)} + /> +
+ +
+ + +
+ + {/* Last sync status */} + +
+ + + {/* Confirm regenerate dialog */} + + + + Regenerate backup key? + + 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. + + + + setConfirmRegenOpen(false)}>Cancel + regenMutation.mutate()} + disabled={regenMutation.isPending} + > + Regenerate + + + + + + ); +} + +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 ( +
+ {ok ? "✅" : "❌"} Last sync: {date} + {!ok && data.lastSyncError && ( + {data.lastSyncError} + )} +
+ ); +} diff --git a/apps/Frontend/src/pages/database-management-page.tsx b/apps/Frontend/src/pages/database-management-page.tsx index c696e424..03a73823 100755 --- a/apps/Frontend/src/pages/database-management-page.tsx +++ b/apps/Frontend/src/pages/database-management-page.tsx @@ -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 */} + {/* Network Backup (PC-to-PC sync) */} + + {/* Import / Restore Database */} diff --git a/packages/db/shared/.prisma-zod-generator-manifest.json b/packages/db/shared/.prisma-zod-generator-manifest.json index 3cf03cbd..2f2abd4f 100755 --- a/packages/db/shared/.prisma-zod-generator-manifest.json +++ b/packages/db/shared/.prisma-zod-generator-manifest.json @@ -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",