Adds autoImportEnabled/autoImportHour to rclone config. A separate hourly cron finds the latest .zip in backups/ and restores it to the database (drop schema, psql restore, apply migrations). Frontend shows toggle + time picker + Import Now button in the Receiver PC section. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
271 lines
11 KiB
TypeScript
Executable File
271 lines
11 KiB
TypeScript
Executable File
import cron from "node-cron";
|
|
import fs from "fs";
|
|
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, 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");
|
|
|
|
// Name of the USB backup subfolder the user creates on their drive
|
|
const USB_BACKUP_FOLDER_NAME = "USB Backup";
|
|
|
|
const MAX_BACKUPS = 15;
|
|
|
|
function ensureLocalBackupDir() {
|
|
if (!fs.existsSync(LOCAL_BACKUP_DIR)) {
|
|
fs.mkdirSync(LOCAL_BACKUP_DIR, { recursive: true });
|
|
}
|
|
}
|
|
|
|
function pruneOldBackups(dir: string) {
|
|
try {
|
|
const files = fs.readdirSync(dir)
|
|
.filter((f) => f.endsWith(".sql") || f.endsWith(".zip"))
|
|
.map((f) => ({ name: f, mtime: fs.statSync(path.join(dir, f)).mtimeMs }))
|
|
.sort((a, b) => b.mtime - a.mtime);
|
|
|
|
const toDelete = files.slice(MAX_BACKUPS);
|
|
for (const file of toDelete) {
|
|
fs.unlinkSync(path.join(dir, file.name));
|
|
console.log(`🗑️ Pruned old backup: ${file.name}`);
|
|
}
|
|
} catch (err) {
|
|
console.warn("Failed to prune old backups:", err);
|
|
}
|
|
}
|
|
|
|
async function getAdminUser() {
|
|
const batchSize = 100;
|
|
let offset = 0;
|
|
while (true) {
|
|
const users = await storage.getUsers(batchSize, offset);
|
|
if (!users || users.length === 0) break;
|
|
const admin = users.find((u) => u.username === "admin");
|
|
if (admin) return admin;
|
|
offset += batchSize;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export const startBackupCron = () => {
|
|
// ============================================================
|
|
// Every hour — Local automatic backup (runs when hour matches setting)
|
|
// ============================================================
|
|
cron.schedule("0 * * * *", async () => {
|
|
const admin = await getAdminUser();
|
|
if (!admin) return;
|
|
if (!admin.autoBackupEnabled) return;
|
|
|
|
const currentHour = new Date().getHours();
|
|
const backupHour = admin.autoBackupHour ?? 20;
|
|
if (currentHour !== backupHour) return;
|
|
|
|
console.log(`🔄 [${backupHour}:00] Running local auto-backup...`);
|
|
ensureLocalBackupDir();
|
|
|
|
const startedAt = new Date();
|
|
const log = await cronJobLogStorage.createJobLog("local-backup", startedAt);
|
|
|
|
try {
|
|
const filename = `dental_backup_${Date.now()}.zip`;
|
|
await backupDatabaseToPath({ destinationPath: LOCAL_BACKUP_DIR, filename });
|
|
pruneOldBackups(LOCAL_BACKUP_DIR);
|
|
await storage.createBackup(admin.id);
|
|
await storage.deleteNotificationsByType(admin.id, "BACKUP");
|
|
await cronJobLogStorage.completeJobLog(log.id, "success", new Date());
|
|
console.log(`✅ Local backup done → ${filename}`);
|
|
} catch (err) {
|
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
console.error("Local backup failed:", err);
|
|
await cronJobLogStorage.completeJobLog(log.id, "failed", new Date(), errorMessage);
|
|
await storage.createNotification(
|
|
admin.id,
|
|
"BACKUP",
|
|
"❌ Automatic backup failed. Please check the server backup folder."
|
|
);
|
|
}
|
|
});
|
|
|
|
// ============================================================
|
|
// Every hour — USB backup (runs when hour matches setting)
|
|
// ============================================================
|
|
cron.schedule("0 * * * *", async () => {
|
|
const admin = await getAdminUser();
|
|
if (!admin) return;
|
|
if (!admin.usbBackupEnabled) return;
|
|
|
|
const currentHour = new Date().getHours();
|
|
const backupHour = admin.usbBackupHour ?? 21;
|
|
if (currentHour !== backupHour) return;
|
|
|
|
console.log(`🔄 [${backupHour}:00] Running USB backup...`);
|
|
|
|
const startedAt = new Date();
|
|
const log = await cronJobLogStorage.createJobLog("usb-backup", startedAt);
|
|
|
|
const destination = await storage.getActiveBackupDestination(admin.id);
|
|
if (!destination) {
|
|
const errorMessage = "No backup destination configured.";
|
|
await cronJobLogStorage.completeJobLog(log.id, "failed", new Date(), errorMessage);
|
|
await storage.createNotification(
|
|
admin.id,
|
|
"BACKUP",
|
|
"❌ USB backup failed: no backup destination configured."
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (!fs.existsSync(destination.path)) {
|
|
const errorMessage = "Backup destination drive not found or disconnected.";
|
|
await cronJobLogStorage.completeJobLog(log.id, "failed", new Date(), errorMessage);
|
|
await storage.createNotification(
|
|
admin.id,
|
|
"BACKUP",
|
|
"❌ USB backup failed: backup drive not found. Make sure the USB drive is connected."
|
|
);
|
|
return;
|
|
}
|
|
|
|
const usbBackupPath = path.join(destination.path, USB_BACKUP_FOLDER_NAME);
|
|
|
|
if (!fs.existsSync(usbBackupPath)) {
|
|
fs.mkdirSync(usbBackupPath, { recursive: true });
|
|
}
|
|
|
|
try {
|
|
const filename = `dental_backup_usb_${Date.now()}.zip`;
|
|
await backupDatabaseToPath({ destinationPath: usbBackupPath, filename });
|
|
pruneOldBackups(usbBackupPath);
|
|
await storage.createBackup(admin.id);
|
|
await storage.deleteNotificationsByType(admin.id, "BACKUP");
|
|
await cronJobLogStorage.completeJobLog(log.id, "success", new Date());
|
|
console.log(`✅ USB backup done → ${usbBackupPath}/${filename}`);
|
|
} catch (err) {
|
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
console.error("USB backup failed:", err);
|
|
await cronJobLogStorage.completeJobLog(log.id, "failed", new Date(), errorMessage);
|
|
await storage.createNotification(
|
|
admin.id,
|
|
"BACKUP",
|
|
"❌ USB backup failed. Please check the USB drive and try again."
|
|
);
|
|
}
|
|
});
|
|
|
|
// ============================================================
|
|
// Every hour — Rclone backup (runs only when hour matches config)
|
|
// ============================================================
|
|
cron.schedule("0 * * * *", async () => {
|
|
const rcloneConfig = readRcloneConfig();
|
|
if (!rcloneConfig.receiverEnabled || !rcloneConfig.sourceIp) return;
|
|
|
|
const currentHour = new Date().getHours();
|
|
if (currentHour !== rcloneConfig.receiverSyncHour) return;
|
|
|
|
console.log(`[${rcloneConfig.receiverSyncHour}:00] Running rclone pull from ${rcloneConfig.sourceIp}...`);
|
|
|
|
const admin = await getAdminUser();
|
|
const startedAt = new Date();
|
|
const log = await cronJobLogStorage.createJobLog("rclone-backup", startedAt);
|
|
|
|
try {
|
|
await runRclonePull();
|
|
writeRcloneConfig({ lastSyncAt: new Date().toISOString(), lastSyncStatus: "success", lastSyncError: null });
|
|
await cronJobLogStorage.completeJobLog(log.id, "success", new Date());
|
|
console.log(`Rclone backup complete.`);
|
|
} catch (err) {
|
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
console.error("Rclone backup failed:", err);
|
|
writeRcloneConfig({ 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",
|
|
`Rclone backup failed: ${errorMessage}`
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
// ============================================================
|
|
// 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)
|
|
// ============================================================
|
|
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);
|
|
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 (database + uploads).`);
|
|
} 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}`
|
|
);
|
|
}
|
|
}
|
|
});
|
|
};
|