diff --git a/apps/Backend/src/cron/backupCheck.ts b/apps/Backend/src/cron/backupCheck.ts index 989aa203..6e8473cc 100755 --- a/apps/Backend/src/cron/backupCheck.ts +++ b/apps/Backend/src/cron/backupCheck.ts @@ -8,6 +8,7 @@ import { readSyncConfig, writeSyncConfig } from "../services/networkSyncConfigSe import { runNetworkSync, runNetworkFilesSync } from "../services/networkSyncService"; import { readRcloneConfig, writeRcloneConfig } from "../services/rcloneConfigService"; import { runRclonePull } from "../services/rcloneService"; +import { importLatestBackup } from "../services/autoImportService"; // Local backup folder in the app root (apps/Backend/backups) const LOCAL_BACKUP_DIR = path.resolve(process.cwd(), "backups"); @@ -194,6 +195,42 @@ export const startBackupCron = () => { } }); + // ============================================================ + // Every hour — Auto-import latest backup (runs when hour matches config) + // ============================================================ + cron.schedule("0 * * * *", async () => { + const importConfig = readRcloneConfig(); + if (!importConfig.autoImportEnabled) return; + + const currentHour = new Date().getHours(); + if (currentHour !== importConfig.autoImportHour) return; + + console.log(`[${importConfig.autoImportHour}:00] Running auto-import of latest backup...`); + + const admin = await getAdminUser(); + const startedAt = new Date(); + const log = await cronJobLogStorage.createJobLog("auto-import", startedAt); + + try { + await importLatestBackup(); + writeRcloneConfig({ lastImportAt: new Date().toISOString(), lastImportStatus: "success", lastImportError: null }); + await cronJobLogStorage.completeJobLog(log.id, "success", new Date()); + console.log(`Auto-import complete.`); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + console.error("Auto-import failed:", err); + writeRcloneConfig({ lastImportAt: new Date().toISOString(), lastImportStatus: "failed", lastImportError: errorMessage }); + await cronJobLogStorage.completeJobLog(log.id, "failed", new Date(), errorMessage); + if (admin) { + await storage.createNotification( + admin.id, + "BACKUP", + `Auto-import failed: ${errorMessage}` + ); + } + } + }); + // ============================================================ // Every hour — Network sync (runs only when hour matches config) // ============================================================ diff --git a/apps/Backend/src/routes/database-management.ts b/apps/Backend/src/routes/database-management.ts index 472d35ba..ead25605 100755 --- a/apps/Backend/src/routes/database-management.ts +++ b/apps/Backend/src/routes/database-management.ts @@ -17,6 +17,7 @@ import { import { runNetworkSync, runNetworkFilesSync } from "../services/networkSyncService"; import { readRcloneConfig, writeRcloneConfig } from "../services/rcloneConfigService"; import { checkRcloneInstalled, isServerRunning, startWebDavServer, stopWebDavServer, runRclonePull } from "../services/rcloneService"; +import { importLatestBackup } from "../services/autoImportService"; const UPLOADS_DIR = path.join(process.cwd(), "uploads"); @@ -618,7 +619,7 @@ router.get("/rclone-config", (req, res) => { router.put("/rclone-config", (req: Request, res: Response): any => { if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" }); - const { serverEnabled, serverPort, receiverEnabled, receiverSyncHour, sourceIp, sourcePort } = req.body; + const { serverEnabled, serverPort, receiverEnabled, receiverSyncHour, sourceIp, sourcePort, autoImportEnabled, autoImportHour } = req.body; const patch: any = {}; if (typeof serverEnabled === "boolean") patch.serverEnabled = serverEnabled; if (typeof serverPort === "number") patch.serverPort = serverPort; @@ -626,6 +627,8 @@ router.put("/rclone-config", (req: Request, res: Response): any => { if (typeof receiverSyncHour === "number") patch.receiverSyncHour = receiverSyncHour; if (typeof sourceIp === "string") patch.sourceIp = sourceIp.trim(); if (typeof sourcePort === "number") patch.sourcePort = sourcePort; + if (typeof autoImportEnabled === "boolean") patch.autoImportEnabled = autoImportEnabled; + if (typeof autoImportHour === "number") patch.autoImportHour = autoImportHour; const updated = writeRcloneConfig(patch); res.json(updated); }); @@ -666,4 +669,17 @@ router.post("/rclone-pull-now", async (req: Request, res: Response): Promise => { + if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" }); + + try { + await importLatestBackup(); + writeRcloneConfig({ lastImportAt: new Date().toISOString(), lastImportStatus: "success", lastImportError: null }); + res.json({ success: true, importedAt: new Date() }); + } catch (err: any) { + writeRcloneConfig({ lastImportAt: new Date().toISOString(), lastImportStatus: "failed", lastImportError: err.message }); + res.status(500).json({ error: "Auto-import failed", details: err.message }); + } +}); + export default router; diff --git a/apps/Backend/src/services/autoImportService.ts b/apps/Backend/src/services/autoImportService.ts new file mode 100644 index 00000000..039e1ce4 --- /dev/null +++ b/apps/Backend/src/services/autoImportService.ts @@ -0,0 +1,117 @@ +import { spawn } from "child_process"; +import fs from "fs"; +import path from "path"; +import { prisma } from "@repo/db/client"; + +const LOCAL_BACKUP_DIR = path.resolve(process.cwd(), "backups"); + +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-import 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); + } + } +} + +function getLatestBackupFile(): string | null { + if (!fs.existsSync(LOCAL_BACKUP_DIR)) return null; + + const files = fs.readdirSync(LOCAL_BACKUP_DIR) + .filter((f) => f.endsWith(".zip")) + .map((f) => ({ name: f, mtime: fs.statSync(path.join(LOCAL_BACKUP_DIR, f)).mtimeMs })) + .sort((a, b) => b.mtime - a.mtime); + + if (files.length === 0) return null; + return path.join(LOCAL_BACKUP_DIR, files[0].name); +} + +export async function importLatestBackup(): Promise { + const backupFile = getLatestBackupFile(); + if (!backupFile) { + throw new Error("No backup files found in backups folder"); + } + + console.log(`[auto-import] Importing ${path.basename(backupFile)}...`); + + try { + await prisma.$executeRawUnsafe(`DROP SCHEMA public CASCADE`); + await prisma.$executeRawUnsafe(`CREATE SCHEMA public`); + } catch (err: any) { + throw new Error(`Failed to reset schema: ${err.message}`); + } + + return new Promise((resolve, reject) => { + 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 restore failed (exit ${code}): ${stderr}`)); + } + + try { + await prisma.$disconnect(); + await prisma.$connect(); + } catch (_) {} + + try { + await applyMissingMigrations(); + } catch (err) { + console.error("applyMissingMigrations failed after auto-import:", err); + } + + console.log(`[auto-import] Successfully imported ${path.basename(backupFile)}`); + resolve(); + }); + + const unzip = spawn("unzip", ["-p", backupFile, "*.sql"]); + unzip.stderr.on("data", (d) => console.warn(`[auto-import] unzip: ${d.toString().trim()}`)); + unzip.on("error", (err) => { + reject(new Error(`Failed to extract backup zip: ${err.message}`)); + }); + unzip.stdout.pipe(psql.stdin); + }); +} diff --git a/apps/Backend/src/services/rcloneConfigService.ts b/apps/Backend/src/services/rcloneConfigService.ts index 79ee5cfc..5c76c57f 100644 --- a/apps/Backend/src/services/rcloneConfigService.ts +++ b/apps/Backend/src/services/rcloneConfigService.ts @@ -19,6 +19,13 @@ export interface RcloneConfig { lastSyncAt: string | null; lastSyncStatus: string | null; lastSyncError: string | null; + + // Auto-import: restore latest backup after rclone pull + autoImportEnabled: boolean; + autoImportHour: number; + lastImportAt: string | null; + lastImportStatus: string | null; + lastImportError: string | null; } const DEFAULT_CONFIG: RcloneConfig = { @@ -31,6 +38,11 @@ const DEFAULT_CONFIG: RcloneConfig = { lastSyncAt: null, lastSyncStatus: null, lastSyncError: null, + autoImportEnabled: false, + autoImportHour: 22, + lastImportAt: null, + lastImportStatus: null, + lastImportError: null, }; export function readRcloneConfig(): RcloneConfig { 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 4d7b839e..aab96d10 100644 --- a/apps/Frontend/src/components/database-management/network-backup-manager.tsx +++ b/apps/Frontend/src/components/database-management/network-backup-manager.tsx @@ -80,6 +80,10 @@ function RcloneBackupSection() { const [sourceIp, setSourceIp] = useState(""); const [sourcePort, setSourcePort] = useState(8080); + // Auto-import state + const [autoImportEnabled, setAutoImportEnabled] = useState(false); + const [autoImportHour, setAutoImportHour] = useState(22); + const [formLoaded, setFormLoaded] = useState(false); const { data: rcloneConfig } = useQuery({ @@ -107,6 +111,8 @@ function RcloneBackupSection() { setReceiverSyncHour(rcloneConfig.receiverSyncHour ?? 21); setSourceIp(rcloneConfig.sourceIp ?? ""); setSourcePort(rcloneConfig.sourcePort ?? 8080); + setAutoImportEnabled(rcloneConfig.autoImportEnabled ?? false); + setAutoImportHour(rcloneConfig.autoImportHour ?? 22); setFormLoaded(true); } }, [rcloneConfig, formLoaded]); @@ -120,6 +126,8 @@ function RcloneBackupSection() { receiverSyncHour, sourceIp, sourcePort, + autoImportEnabled, + autoImportHour, }); return res.json(); }, @@ -151,6 +159,25 @@ function RcloneBackupSection() { }, }); + const importNowMutation = useMutation({ + mutationFn: async () => { + const res = await apiRequest("POST", "/api/database-management/auto-import-now"); + if (!res.ok) { + const body = await res.json(); + throw new Error(body.details || body.error || "Import failed"); + } + return res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["/db/rclone-config"] }); + toast({ title: "Import complete", description: "Latest backup restored to database." }); + }, + onError: (err: any) => { + queryClient.invalidateQueries({ queryKey: ["/db/rclone-config"] }); + toast({ title: "Import failed", description: err.message, variant: "destructive" }); + }, + }); + const serverRunning = rcloneStatus?.serverRunning ?? false; return ( @@ -271,6 +298,60 @@ function RcloneBackupSection() { + {/* ── Auto-Import: restore latest backup to database ── */} +
+

Auto-Import Database

+

+ Automatically restore the latest backup file from the backups/ folder + into this PC's database after rclone finishes pulling. +

+ +
+
+ + +
+ +
+ + +
+
+ +
+ +
+ + +
+ {/* Save all settings */}