import { BackupRestoreMode, BackupRestoreStatus, BackupSchedule, BackupSource, BackupStatus, BackupType, Prisma, SnapshotFrequency } from "@prisma/client"; import { prisma } from "../lib/prisma"; import { HttpError } from "../lib/http-error"; type PolicyShape = { id?: string; max_files: number; max_total_size_mb: number; max_protected_files: number; allow_file_restore: boolean; allow_cross_vm_restore: boolean; allow_pbs_restore: boolean; }; const DEFAULT_POLICY: PolicyShape = { max_files: 20, max_total_size_mb: 102400, max_protected_files: 5, allow_file_restore: true, allow_cross_vm_restore: true, allow_pbs_restore: true }; function toPositiveInt(value: number, fallback: number) { if (!Number.isFinite(value)) return fallback; const rounded = Math.floor(value); return rounded > 0 ? rounded : fallback; } function toScheduleForSnapshot(frequency: SnapshotFrequency): BackupSchedule { if (frequency === SnapshotFrequency.HOURLY) return BackupSchedule.DAILY; if (frequency === SnapshotFrequency.DAILY) return BackupSchedule.DAILY; return BackupSchedule.WEEKLY; } function nextBackupRunDate(schedule: BackupSchedule, fromDate = new Date()) { const now = new Date(fromDate); if (schedule === BackupSchedule.DAILY) return new Date(now.getTime() + 24 * 60 * 60 * 1000); if (schedule === BackupSchedule.WEEKLY) return new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); if (schedule === BackupSchedule.MONTHLY) return new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); return null; } function nextSnapshotRunAt(job: { frequency: SnapshotFrequency; interval: number; day_of_week: number | null; hour_utc: number; minute_utc: number; }, fromDate = new Date()) { const now = new Date(fromDate); const interval = Math.max(1, job.interval); if (job.frequency === SnapshotFrequency.HOURLY) { const candidate = new Date(now); candidate.setUTCSeconds(0, 0); candidate.setUTCMinutes(job.minute_utc); if (candidate <= now) { candidate.setUTCHours(candidate.getUTCHours() + interval); } return candidate; } if (job.frequency === SnapshotFrequency.DAILY) { const candidate = new Date(now); candidate.setUTCSeconds(0, 0); candidate.setUTCHours(job.hour_utc, job.minute_utc, 0, 0); if (candidate <= now) { candidate.setUTCDate(candidate.getUTCDate() + interval); } return candidate; } const dayOfWeek = job.day_of_week ?? 0; const candidate = new Date(now); candidate.setUTCSeconds(0, 0); candidate.setUTCHours(job.hour_utc, job.minute_utc, 0, 0); const currentDow = candidate.getUTCDay(); const deltaDays = (dayOfWeek - currentDow + 7) % 7; candidate.setUTCDate(candidate.getUTCDate() + deltaDays); if (candidate <= now) { candidate.setUTCDate(candidate.getUTCDate() + interval * 7); } return candidate; } function estimateBackupSizeMb(input: { requestedSizeMb?: number; diskGb: number; type: BackupType }) { if (typeof input.requestedSizeMb === "number" && Number.isFinite(input.requestedSizeMb) && input.requestedSizeMb > 0) { return input.requestedSizeMb; } const base = Math.max(256, input.diskGb * 1024 * 0.35); if (input.type === BackupType.SNAPSHOT) { return Math.max(128, base * 0.4); } if (input.type === BackupType.INCREMENTAL) { return Math.max(128, base * 0.6); } return base; } async function effectiveBackupPolicy(tenantId: string, billingPlanId?: string | null): Promise { const policies = await prisma.backupPolicy.findMany({ where: { OR: [ { tenant_id: tenantId, billing_plan_id: billingPlanId ?? null }, { tenant_id: tenantId, billing_plan_id: null }, { tenant_id: null, billing_plan_id: billingPlanId ?? null }, { tenant_id: null, billing_plan_id: null } ] } }); const score = (item: (typeof policies)[number]) => { let value = 0; if (item.tenant_id === tenantId) value += 8; if (item.billing_plan_id && billingPlanId && item.billing_plan_id === billingPlanId) value += 4; if (!item.tenant_id) value += 1; if (!item.billing_plan_id) value += 1; return value; }; const selected = policies.sort((a, b) => score(b) - score(a))[0]; if (!selected) return DEFAULT_POLICY; return { id: selected.id, max_files: selected.max_files, max_total_size_mb: selected.max_total_size_mb, max_protected_files: selected.max_protected_files, allow_file_restore: selected.allow_file_restore, allow_cross_vm_restore: selected.allow_cross_vm_restore, allow_pbs_restore: selected.allow_pbs_restore }; } async function tenantBackupUsage(tenantId: string) { const backups = await prisma.backup.findMany({ where: { OR: [{ tenant_id: tenantId }, { vm: { tenant_id: tenantId } }], status: { in: [BackupStatus.PENDING, BackupStatus.RUNNING, BackupStatus.COMPLETED] } }, select: { id: true, size_mb: true, is_protected: true } }); const totalSize = backups.reduce((sum, item) => sum + (item.size_mb ?? 0), 0); const protectedCount = backups.filter((item) => item.is_protected).length; return { count: backups.length, totalSizeMb: totalSize, protectedCount }; } async function enforceBackupPolicyLimits(input: { tenantId: string; billingPlanId?: string | null; predictedSizeMb: number; protected: boolean; }) { const [policy, usage] = await Promise.all([ effectiveBackupPolicy(input.tenantId, input.billingPlanId), tenantBackupUsage(input.tenantId) ]); if (usage.count >= policy.max_files) { throw new HttpError( 400, `Backup limit reached (${policy.max_files} files). Remove old backups or raise policy limits.`, "BACKUP_LIMIT_FILES" ); } if (usage.totalSizeMb + input.predictedSizeMb > policy.max_total_size_mb) { throw new HttpError( 400, `Backup storage limit exceeded (${policy.max_total_size_mb} MB).`, "BACKUP_LIMIT_SIZE" ); } if (input.protected && usage.protectedCount >= policy.max_protected_files) { throw new HttpError( 400, `Protected backup limit reached (${policy.max_protected_files}).`, "BACKUP_LIMIT_PROTECTED" ); } return policy; } export async function listBackups(input: { tenantId?: string; vmId?: string; status?: BackupStatus; limit?: number; offset?: number; }) { const where: Prisma.BackupWhereInput = {}; if (input.vmId) where.vm_id = input.vmId; if (input.status) where.status = input.status; if (input.tenantId) { where.OR = [{ tenant_id: input.tenantId }, { vm: { tenant_id: input.tenantId } }]; } const limit = Math.min(Math.max(input.limit ?? 50, 1), 200); const offset = Math.max(input.offset ?? 0, 0); const [data, total] = await Promise.all([ prisma.backup.findMany({ where, include: { vm: { select: { id: true, name: true, node: true, tenant_id: true } }, snapshot_job: { select: { id: true, name: true } } }, orderBy: [{ is_protected: "desc" }, { created_at: "desc" }], take: limit, skip: offset }), prisma.backup.count({ where }) ]); return { data, meta: { total, limit, offset } }; } export async function createBackup(input: { vmId: string; type?: BackupType; source?: BackupSource; schedule?: BackupSchedule; retentionDays?: number; storage?: string; routeKey?: string; isProtected?: boolean; notes?: string; requestedSizeMb?: number; createdBy?: string; }) { const vm = await prisma.virtualMachine.findUnique({ where: { id: input.vmId }, include: { billing_plan: { select: { id: true } } } }); if (!vm) { throw new HttpError(404, "VM not found", "VM_NOT_FOUND"); } const type = input.type ?? BackupType.FULL; const source = input.source ?? BackupSource.LOCAL; const schedule = input.schedule ?? BackupSchedule.MANUAL; const retentionDays = toPositiveInt(input.retentionDays ?? 7, 7); const sizeMb = estimateBackupSizeMb({ requestedSizeMb: input.requestedSizeMb, diskGb: vm.disk_gb, type }); const keepProtected = Boolean(input.isProtected); await enforceBackupPolicyLimits({ tenantId: vm.tenant_id, billingPlanId: vm.billing_plan_id, predictedSizeMb: sizeMb, protected: keepProtected }); const now = new Date(); const expiresAt = new Date(now.getTime() + retentionDays * 24 * 60 * 60 * 1000); const nextRunAt = schedule === BackupSchedule.MANUAL ? null : nextBackupRunDate(schedule, now); const backup = await prisma.backup.create({ data: { vm_id: vm.id, vm_name: vm.name, tenant_id: vm.tenant_id, node: vm.node, status: schedule === BackupSchedule.MANUAL ? BackupStatus.COMPLETED : BackupStatus.PENDING, type, source, size_mb: sizeMb, storage: input.storage ?? (source === BackupSource.PBS ? "pbs-datastore" : "local-lvm"), backup_path: source === BackupSource.PBS ? null : `backup/${vm.node}/${vm.vmid}/${Date.now()}`, pbs_snapshot_id: source === BackupSource.PBS ? `pbs/${vm.node}/${vm.vmid}/${Date.now()}` : null, route_key: input.routeKey, is_protected: keepProtected, restore_enabled: true, total_files: Math.max(1, Math.floor(sizeMb / 64)), schedule, retention_days: retentionDays, started_at: schedule === BackupSchedule.MANUAL ? now : null, completed_at: schedule === BackupSchedule.MANUAL ? now : null, next_run_at: nextRunAt, expires_at: expiresAt, notes: input.notes } }); return backup; } export async function toggleBackupProtection(backupId: string, protect: boolean) { const backup = await prisma.backup.findUnique({ where: { id: backupId }, include: { vm: { select: { tenant_id: true, billing_plan_id: true } } } }); if (!backup) { throw new HttpError(404, "Backup not found", "BACKUP_NOT_FOUND"); } if (protect) { await enforceBackupPolicyLimits({ tenantId: backup.vm.tenant_id, billingPlanId: backup.vm.billing_plan_id, predictedSizeMb: 0, protected: true }); } return prisma.backup.update({ where: { id: backup.id }, data: { is_protected: protect } }); } export async function deleteBackup(backupId: string, force = false) { const backup = await prisma.backup.findUnique({ where: { id: backupId } }); if (!backup) { throw new HttpError(404, "Backup not found", "BACKUP_NOT_FOUND"); } if (backup.is_protected && !force) { throw new HttpError(400, "Protected backup cannot be deleted without force", "BACKUP_PROTECTED"); } return prisma.backup.delete({ where: { id: backup.id } }); } async function executeRestoreTask(taskId: string) { const task = await prisma.backupRestoreTask.findUnique({ where: { id: taskId }, include: { backup: true, source_vm: true, target_vm: true } }); if (!task) { throw new HttpError(404, "Restore task not found", "RESTORE_TASK_NOT_FOUND"); } await prisma.backupRestoreTask.update({ where: { id: task.id }, data: { status: BackupRestoreStatus.RUNNING, started_at: new Date() } }); let resultMessage = "Restore completed"; if (task.mode === BackupRestoreMode.FILES || task.mode === BackupRestoreMode.SINGLE_FILE) { const files = Array.isArray(task.requested_files) ? task.requested_files.length : 0; resultMessage = `File restore completed (${files} item${files === 1 ? "" : "s"})`; } if (task.pbs_enabled) { resultMessage = `${resultMessage} via PBS`; } return prisma.backupRestoreTask.update({ where: { id: task.id }, data: { status: BackupRestoreStatus.COMPLETED, completed_at: new Date(), result_message: resultMessage }, include: { backup: true, source_vm: true, target_vm: true } }); } export async function createRestoreTask(input: { backupId: string; targetVmId?: string; mode: BackupRestoreMode; requestedFiles?: string[]; pbsEnabled?: boolean; createdBy?: string; runImmediately?: boolean; }) { const backup = await prisma.backup.findUnique({ where: { id: input.backupId }, include: { vm: { select: { id: true, tenant_id: true, billing_plan_id: true } } } }); if (!backup) { throw new HttpError(404, "Backup not found", "BACKUP_NOT_FOUND"); } if (!backup.restore_enabled) { throw new HttpError(400, "Restore is disabled for this backup", "RESTORE_DISABLED"); } const targetVmId = input.targetVmId ?? backup.vm_id; const targetVm = await prisma.virtualMachine.findUnique({ where: { id: targetVmId }, select: { id: true, tenant_id: true } }); if (!targetVm) { throw new HttpError(404, "Target VM not found", "TARGET_VM_NOT_FOUND"); } if (targetVm.tenant_id !== backup.vm.tenant_id) { throw new HttpError(403, "Cross-tenant restore is not allowed", "CROSS_TENANT_RESTORE_DENIED"); } const policy = await effectiveBackupPolicy(backup.vm.tenant_id, backup.vm.billing_plan_id); const isCrossVm = targetVmId !== backup.vm_id; if (isCrossVm && !policy.allow_cross_vm_restore) { throw new HttpError(400, "Cross-VM restore is disabled by policy", "CROSS_VM_RESTORE_DISABLED"); } if (input.mode !== BackupRestoreMode.FULL_VM && !policy.allow_file_restore) { throw new HttpError(400, "File-level restore is disabled by policy", "FILE_RESTORE_DISABLED"); } const pbsEnabled = Boolean(input.pbsEnabled); if (pbsEnabled && !policy.allow_pbs_restore) { throw new HttpError(400, "PBS restore is disabled by policy", "PBS_RESTORE_DISABLED"); } if (pbsEnabled && backup.source !== BackupSource.PBS) { throw new HttpError(400, "PBS restore requires a PBS backup source", "PBS_SOURCE_REQUIRED"); } const requestedFiles = input.requestedFiles ?? []; if (input.mode === BackupRestoreMode.SINGLE_FILE && requestedFiles.length !== 1) { throw new HttpError(400, "SINGLE_FILE restore requires exactly one file path", "INVALID_RESTORE_FILES"); } if ((input.mode === BackupRestoreMode.FILES || input.mode === BackupRestoreMode.SINGLE_FILE) && requestedFiles.length === 0) { throw new HttpError(400, "Requested files are required for file-level restore", "INVALID_RESTORE_FILES"); } const task = await prisma.backupRestoreTask.create({ data: { backup_id: backup.id, source_vm_id: backup.vm_id, target_vm_id: targetVmId, mode: input.mode, requested_files: requestedFiles, pbs_enabled: pbsEnabled, created_by: input.createdBy } }); if (input.runImmediately === false) { return prisma.backupRestoreTask.findUnique({ where: { id: task.id }, include: { backup: true, source_vm: true, target_vm: true } }); } return executeRestoreTask(task.id); } export async function runRestoreTaskNow(taskId: string) { const task = await prisma.backupRestoreTask.findUnique({ where: { id: taskId } }); if (!task) { throw new HttpError(404, "Restore task not found", "RESTORE_TASK_NOT_FOUND"); } if (task.status === BackupRestoreStatus.COMPLETED) { return prisma.backupRestoreTask.findUnique({ where: { id: task.id }, include: { backup: true, source_vm: true, target_vm: true } }); } return executeRestoreTask(task.id); } export async function listRestoreTasks(input: { tenantId?: string; status?: BackupRestoreStatus; limit?: number; offset?: number; }) { const where: Prisma.BackupRestoreTaskWhereInput = {}; if (input.status) where.status = input.status; if (input.tenantId) { where.source_vm = { tenant_id: input.tenantId }; } const limit = Math.min(Math.max(input.limit ?? 50, 1), 200); const offset = Math.max(input.offset ?? 0, 0); const [data, total] = await Promise.all([ prisma.backupRestoreTask.findMany({ where, include: { backup: true, source_vm: { select: { id: true, name: true, tenant_id: true } }, target_vm: { select: { id: true, name: true, tenant_id: true } } }, orderBy: { created_at: "desc" }, take: limit, skip: offset }), prisma.backupRestoreTask.count({ where }) ]); return { data, meta: { total, limit, offset } }; } async function enforceSnapshotRetention(jobId: string, retention: number) { const backups = await prisma.backup.findMany({ where: { snapshot_job_id: jobId, type: BackupType.SNAPSHOT }, orderBy: { created_at: "desc" } }); const removable = backups.filter((item, index) => index >= retention && !item.is_protected); if (removable.length === 0) { return 0; } await prisma.backup.deleteMany({ where: { id: { in: removable.map((item) => item.id) } } }); return removable.length; } async function createSnapshotBackupFromJob(job: { id: string; vm_id: string; vm: { id: string; name: string; node: string; tenant_id: string; billing_plan_id: string | null; disk_gb: number; vmid: number; }; retention: number; frequency: SnapshotFrequency; }) { const predictedSizeMb = estimateBackupSizeMb({ diskGb: job.vm.disk_gb, type: BackupType.SNAPSHOT }); await enforceBackupPolicyLimits({ tenantId: job.vm.tenant_id, billingPlanId: job.vm.billing_plan_id, predictedSizeMb, protected: false }); const now = new Date(); return prisma.backup.create({ data: { vm_id: job.vm_id, vm_name: job.vm.name, tenant_id: job.vm.tenant_id, node: job.vm.node, status: BackupStatus.COMPLETED, type: BackupType.SNAPSHOT, source: BackupSource.LOCAL, size_mb: predictedSizeMb, storage: "local-lvm", backup_path: `snapshot/${job.vm.node}/${job.vm.vmid}/${Date.now()}`, schedule: toScheduleForSnapshot(job.frequency), retention_days: Math.max(1, job.retention), snapshot_job_id: job.id, started_at: now, completed_at: now, expires_at: new Date(now.getTime() + Math.max(1, job.retention) * 24 * 60 * 60 * 1000), notes: "Created by snapshot policy scheduler" } }); } export async function listSnapshotJobs(input: { tenantId?: string }) { const where: Prisma.SnapshotJobWhereInput = {}; if (input.tenantId) { where.vm = { tenant_id: input.tenantId }; } return prisma.snapshotJob.findMany({ where, include: { vm: { select: { id: true, name: true, tenant_id: true, node: true } } }, orderBy: [{ enabled: "desc" }, { next_run_at: "asc" }, { created_at: "desc" }] }); } export async function createSnapshotJob(input: { vmId: string; name: string; frequency: SnapshotFrequency; interval?: number; dayOfWeek?: number; hourUtc?: number; minuteUtc?: number; retention?: number; enabled?: boolean; createdBy?: string; }) { const vm = await prisma.virtualMachine.findUnique({ where: { id: input.vmId }, select: { id: true } }); if (!vm) { throw new HttpError(404, "VM not found", "VM_NOT_FOUND"); } const interval = toPositiveInt(input.interval ?? 1, 1); const retention = toPositiveInt(input.retention ?? 7, 7); const hourUtc = Math.max(0, Math.min(23, Math.floor(input.hourUtc ?? 2))); const minuteUtc = Math.max(0, Math.min(59, Math.floor(input.minuteUtc ?? 0))); if (input.frequency === SnapshotFrequency.WEEKLY) { const day = input.dayOfWeek ?? 0; if (day < 0 || day > 6) { throw new HttpError(400, "dayOfWeek must be between 0 and 6", "INVALID_DAY_OF_WEEK"); } } const nextRun = nextSnapshotRunAt({ frequency: input.frequency, interval, day_of_week: input.dayOfWeek ?? null, hour_utc: hourUtc, minute_utc: minuteUtc }); return prisma.snapshotJob.create({ data: { vm_id: input.vmId, name: input.name, frequency: input.frequency, interval, day_of_week: input.frequency === SnapshotFrequency.WEEKLY ? input.dayOfWeek ?? 0 : null, hour_utc: hourUtc, minute_utc: minuteUtc, retention, enabled: input.enabled ?? true, next_run_at: input.enabled === false ? null : nextRun, created_by: input.createdBy }, include: { vm: { select: { id: true, name: true, tenant_id: true, node: true } } } }); } export async function updateSnapshotJob( jobId: string, input: Partial<{ name: string; frequency: SnapshotFrequency; interval: number; dayOfWeek: number | null; hourUtc: number; minuteUtc: number; retention: number; enabled: boolean; }> ) { const existing = await prisma.snapshotJob.findUnique({ where: { id: jobId } }); if (!existing) { throw new HttpError(404, "Snapshot job not found", "SNAPSHOT_JOB_NOT_FOUND"); } const frequency = input.frequency ?? existing.frequency; const interval = toPositiveInt(input.interval ?? existing.interval, existing.interval); const hourUtc = Math.max(0, Math.min(23, Math.floor(input.hourUtc ?? existing.hour_utc))); const minuteUtc = Math.max(0, Math.min(59, Math.floor(input.minuteUtc ?? existing.minute_utc))); const retention = toPositiveInt(input.retention ?? existing.retention, existing.retention); const dayOfWeek = frequency === SnapshotFrequency.WEEKLY ? (input.dayOfWeek ?? existing.day_of_week ?? 0) : null; if (frequency === SnapshotFrequency.WEEKLY && (dayOfWeek === null || dayOfWeek < 0 || dayOfWeek > 6)) { throw new HttpError(400, "dayOfWeek must be between 0 and 6", "INVALID_DAY_OF_WEEK"); } const enabled = input.enabled ?? existing.enabled; const nextRun = enabled ? nextSnapshotRunAt({ frequency, interval, day_of_week: dayOfWeek, hour_utc: hourUtc, minute_utc: minuteUtc }) : null; return prisma.snapshotJob.update({ where: { id: existing.id }, data: { name: input.name, frequency, interval, day_of_week: dayOfWeek, hour_utc: hourUtc, minute_utc: minuteUtc, retention, enabled, next_run_at: nextRun }, include: { vm: { select: { id: true, name: true, tenant_id: true, node: true } } } }); } export async function deleteSnapshotJob(jobId: string) { return prisma.snapshotJob.delete({ where: { id: jobId } }); } export async function runSnapshotJobNow(jobId: string) { const job = await prisma.snapshotJob.findUnique({ where: { id: jobId }, include: { vm: { select: { id: true, name: true, node: true, vmid: true, disk_gb: true, tenant_id: true, billing_plan_id: true } } } }); if (!job) { throw new HttpError(404, "Snapshot job not found", "SNAPSHOT_JOB_NOT_FOUND"); } const backup = await createSnapshotBackupFromJob({ id: job.id, vm_id: job.vm_id, vm: job.vm, retention: job.retention, frequency: job.frequency }); const pruned = await enforceSnapshotRetention(job.id, Math.max(1, job.retention)); const now = new Date(); const nextRun = job.enabled ? nextSnapshotRunAt({ frequency: job.frequency, interval: job.interval, day_of_week: job.day_of_week, hour_utc: job.hour_utc, minute_utc: job.minute_utc }, now) : null; await prisma.snapshotJob.update({ where: { id: job.id }, data: { last_run_at: now, next_run_at: nextRun } }); return { backup, pruned }; } export async function processDueSnapshotJobs() { const now = new Date(); const dueJobs = await prisma.snapshotJob.findMany({ where: { enabled: true, next_run_at: { lte: now } }, include: { vm: { select: { id: true, name: true, node: true, vmid: true, disk_gb: true, tenant_id: true, billing_plan_id: true } } }, orderBy: { next_run_at: "asc" }, take: 100 }); let executed = 0; let failed = 0; let pruned = 0; let skipped = 0; for (const job of dueJobs) { const nextRun = nextSnapshotRunAt({ frequency: job.frequency, interval: job.interval, day_of_week: job.day_of_week, hour_utc: job.hour_utc, minute_utc: job.minute_utc }, now); const claim = await prisma.snapshotJob.updateMany({ where: { id: job.id, enabled: true, next_run_at: { lte: now } }, data: { last_run_at: now, next_run_at: nextRun } }); if (claim.count === 0) { skipped += 1; continue; } try { await createSnapshotBackupFromJob({ id: job.id, vm_id: job.vm_id, vm: job.vm, retention: job.retention, frequency: job.frequency }); executed += 1; pruned += await enforceSnapshotRetention(job.id, Math.max(1, job.retention)); } catch { failed += 1; } } return { scanned: dueJobs.length, executed, failed, pruned, skipped }; } export async function processPendingBackups() { const now = new Date(); const pending = await prisma.backup.findMany({ where: { status: BackupStatus.PENDING }, orderBy: { created_at: "asc" }, take: 200 }); let completed = 0; let skipped = 0; for (const backup of pending) { const claim = await prisma.backup.updateMany({ where: { id: backup.id, status: BackupStatus.PENDING }, data: { status: BackupStatus.RUNNING, started_at: backup.started_at ?? now } }); if (claim.count === 0) { skipped += 1; continue; } const retentionDays = Math.max(1, backup.retention_days); await prisma.backup.update({ where: { id: backup.id }, data: { status: BackupStatus.COMPLETED, completed_at: now, expires_at: new Date(now.getTime() + retentionDays * 24 * 60 * 60 * 1000), next_run_at: backup.schedule === BackupSchedule.MANUAL ? null : nextBackupRunDate(backup.schedule, now), pbs_snapshot_id: backup.source === BackupSource.PBS ? backup.pbs_snapshot_id ?? `pbs/${backup.node ?? "node"}/${backup.vm_id}/${Date.now()}` : backup.pbs_snapshot_id } }); completed += 1; } return { scanned: pending.length, completed, skipped }; } export async function listBackupPolicies() { return prisma.backupPolicy.findMany({ include: { tenant: { select: { id: true, name: true, slug: true } }, billing_plan: { select: { id: true, name: true, slug: true } } }, orderBy: [{ tenant_id: "asc" }, { billing_plan_id: "asc" }, { created_at: "desc" }] }); } export async function upsertBackupPolicy(input: { policyId?: string; tenantId?: string; billingPlanId?: string; maxFiles?: number; maxTotalSizeMb?: number; maxProtectedFiles?: number; allowFileRestore?: boolean; allowCrossVmRestore?: boolean; allowPbsRestore?: boolean; }) { const basePayload = { tenant_id: input.tenantId, billing_plan_id: input.billingPlanId, max_files: toPositiveInt(input.maxFiles ?? DEFAULT_POLICY.max_files, DEFAULT_POLICY.max_files), max_total_size_mb: typeof input.maxTotalSizeMb === "number" && Number.isFinite(input.maxTotalSizeMb) && input.maxTotalSizeMb > 0 ? input.maxTotalSizeMb : DEFAULT_POLICY.max_total_size_mb, max_protected_files: toPositiveInt( input.maxProtectedFiles ?? DEFAULT_POLICY.max_protected_files, DEFAULT_POLICY.max_protected_files ), allow_file_restore: input.allowFileRestore ?? DEFAULT_POLICY.allow_file_restore, allow_cross_vm_restore: input.allowCrossVmRestore ?? DEFAULT_POLICY.allow_cross_vm_restore, allow_pbs_restore: input.allowPbsRestore ?? DEFAULT_POLICY.allow_pbs_restore }; if (input.policyId) { return prisma.backupPolicy.update({ where: { id: input.policyId }, data: basePayload }); } return prisma.backupPolicy.create({ data: basePayload }); }