diff --git a/apps/Backend/src/cron/backupCheck.ts b/apps/Backend/src/cron/backupCheck.ts index b6f13f8..b12354d 100644 --- a/apps/Backend/src/cron/backupCheck.ts +++ b/apps/Backend/src/cron/backupCheck.ts @@ -1,14 +1,19 @@ import cron from "node-cron"; +import fs from "fs"; import { storage } from "../storage"; import { NotificationTypes } from "@repo/db/types"; +import { backupDatabaseToPath } from "../services/databaseBackupService"; /** * Daily cron job to check if users haven't backed up in 7 days * Creates a backup notification if overdue */ export const startBackupCron = () => { - cron.schedule("0 9 * * *", async () => { - console.log("🔄 Running daily backup check..."); + cron.schedule("0 2 */3 * *", async () => { + // Every 3 calendar days, at 2 AM + // cron.schedule("*/10 * * * * *", async () => { // Every 10 seconds (for Test) + + console.log("🔄 Running backup check..."); const userBatchSize = 100; let userOffset = 0; @@ -23,7 +28,52 @@ export const startBackupCron = () => { if (user.id == null) { continue; } + + const destination = await storage.getActiveBackupDestination(user.id); const lastBackup = await storage.getLastBackup(user.id); + + // ============================== + // CASE 1: Destination exists → auto backup + // ============================== + if (destination) { + if (!fs.existsSync(destination.path)) { + await storage.createNotification( + user.id, + "BACKUP", + "❌ Automatic backup failed: external drive not connected." + ); + continue; + } + + try { + const filename = `dental_backup_${Date.now()}.zip`; + + await backupDatabaseToPath({ + destinationPath: destination.path, + filename, + }); + + await storage.createBackup(user.id); + await storage.deleteNotificationsByType(user.id, "BACKUP"); + + console.log(`✅ Auto backup successful for user ${user.id}`); + continue; + } catch (err) { + console.error(`Auto backup failed for user ${user.id}`, err); + + await storage.createNotification( + user.id, + "BACKUP", + "❌ Automatic backup failed. Please check your backup destination." + ); + continue; + } + } + + // ============================== + // CASE 2: No destination → fallback to reminder + // ============================== + const daysSince = lastBackup?.createdAt ? (Date.now() - new Date(lastBackup.createdAt).getTime()) / (1000 * 60 * 60 * 24) diff --git a/apps/Backend/src/routes/database-management.ts b/apps/Backend/src/routes/database-management.ts index 871b1db..895077d 100644 --- a/apps/Backend/src/routes/database-management.ts +++ b/apps/Backend/src/routes/database-management.ts @@ -6,6 +6,7 @@ import fs from "fs"; import { prisma } from "@repo/db/client"; import { storage } from "../storage"; import archiver from "archiver"; +import { backupDatabaseToPath } from "../services/databaseBackupService"; const router = Router(); @@ -33,6 +34,8 @@ router.post("/backup", async (req: Request, res: Response): Promise => { return res.status(401).json({ error: "Unauthorized" }); } + const destination = await storage.getActiveBackupDestination(userId); + // create a unique tmp directory for directory-format dump const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dental_backup_")); // MUST @@ -240,4 +243,118 @@ router.get("/status", async (req: Request, res: Response): Promise => { } }); +// ============================== +// Backup Destination CRUD +// ============================== + +// CREATE / UPDATE destination +router.post("/destination", async (req, res) => { + const userId = req.user?.id; + const { path: destinationPath } = req.body; + + if (!userId) return res.status(401).json({ error: "Unauthorized" }); + if (!destinationPath) + return res.status(400).json({ error: "Path is required" }); + + // validate path exists + if (!fs.existsSync(destinationPath)) { + return res.status(400).json({ + error: "Backup path does not exist or drive not connected", + }); + } + + try { + const destination = await storage.createBackupDestination( + userId, + destinationPath + ); + res.json(destination); + } catch (err) { + console.error(err); + res.status(500).json({ error: "Failed to save backup destination" }); + } +}); + +// GET all destinations +router.get("/destination", async (req, res) => { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ error: "Unauthorized" }); + + const destinations = await storage.getAllBackupDestination(userId); + res.json(destinations); +}); + +// UPDATE destination +router.put("/destination/:id", async (req, res) => { + const userId = req.user?.id; + const id = Number(req.params.id); + const { path: destinationPath } = req.body; + + if (!userId) return res.status(401).json({ error: "Unauthorized" }); + if (!destinationPath) + return res.status(400).json({ error: "Path is required" }); + + if (!fs.existsSync(destinationPath)) { + return res.status(400).json({ error: "Path does not exist" }); + } + + const updated = await storage.updateBackupDestination( + id, + userId, + destinationPath + ); + + res.json(updated); +}); + +// DELETE destination +router.delete("/destination/:id", async (req, res) => { + const userId = req.user?.id; + const id = Number(req.params.id); + + if (!userId) return res.status(401).json({ error: "Unauthorized" }); + + await storage.deleteBackupDestination(id, userId); + res.json({ success: true }); +}); + +router.post("/backup-path", async (req, res) => { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ error: "Unauthorized" }); + + const destination = await storage.getActiveBackupDestination(userId); + if (!destination) { + return res.status(400).json({ + error: "No backup destination configured", + }); + } + + if (!fs.existsSync(destination.path)) { + return res.status(400).json({ + error: + "Backup destination not found. External drive may be disconnected.", + }); + } + + const filename = `dental_backup_${Date.now()}.zip`; + + try { + await backupDatabaseToPath({ + destinationPath: destination.path, + filename, + }); + + await storage.createBackup(userId); + await storage.deleteNotificationsByType(userId, "BACKUP"); + + res.json({ success: true, filename }); + } catch (err: any) { + console.error(err); + res.status(500).json({ + error: "Backup to destination failed", + details: err.message, + }); + } +}); + export default router; diff --git a/apps/Backend/src/services/databaseBackupService.ts b/apps/Backend/src/services/databaseBackupService.ts new file mode 100644 index 0000000..c8ae6db --- /dev/null +++ b/apps/Backend/src/services/databaseBackupService.ts @@ -0,0 +1,85 @@ +import { spawn } from "child_process"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import archiver from "archiver"; + +function safeRmDir(dir: string) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch {} +} + +interface BackupToPathParams { + destinationPath: string; + filename: string; +} + +export async function backupDatabaseToPath({ + destinationPath, + filename, +}: BackupToPathParams): Promise { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dental_backup_")); + + return new Promise((resolve, reject) => { + const pgDump = spawn( + "pg_dump", + [ + "-Fd", + "-j", + "4", + "--no-acl", + "--no-owner", + "-h", + process.env.DB_HOST || "localhost", + "-U", + process.env.DB_USER || "postgres", + process.env.DB_NAME || "dental_db", + "-f", + tmpDir, + ], + { + env: { + ...process.env, + PGPASSWORD: process.env.DB_PASSWORD, + }, + } + ); + + let pgError = ""; + + pgDump.stderr.on("data", (d) => (pgError += d.toString())); + + pgDump.on("close", async (code) => { + if (code !== 0) { + safeRmDir(tmpDir); + return reject(new Error(pgError || "pg_dump failed")); + } + + const outputFile = path.join(destinationPath, filename); + const outputStream = fs.createWriteStream(outputFile); + + const archive = archiver("zip"); + + outputStream.on("error", (err) => { + safeRmDir(tmpDir); + reject(err); + }); + + archive.on("error", (err) => { + safeRmDir(tmpDir); + reject(err); + }); + + archive.pipe(outputStream); + archive.directory(tmpDir + path.sep, false); + + archive.finalize(); + + archive.on("end", () => { + safeRmDir(tmpDir); + resolve(); + }); + }); + }); +} diff --git a/apps/Backend/src/storage/database-backup-storage.ts b/apps/Backend/src/storage/database-backup-storage.ts index 0a878c2..cf79046 100644 --- a/apps/Backend/src/storage/database-backup-storage.ts +++ b/apps/Backend/src/storage/database-backup-storage.ts @@ -1,4 +1,4 @@ -import { DatabaseBackup } from "@repo/db/types"; +import { DatabaseBackup, BackupDestination } from "@repo/db/types"; import { prisma as db } from "@repo/db/client"; export interface IStorage { @@ -7,6 +7,33 @@ export interface IStorage { getLastBackup(userId: number): Promise; getBackups(userId: number, limit?: number): Promise; deleteBackups(userId: number): Promise; // clears all for user + + // ============================== + // Backup Destination methods + // ============================== + createBackupDestination( + userId: number, + path: string + ): Promise; + + getActiveBackupDestination( + userId: number + ): Promise; + + getAllBackupDestination( + userId: number + ): Promise; + + updateBackupDestination( + id: number, + userId: number, + path: string + ): Promise; + + deleteBackupDestination( + id: number, + userId: number + ): Promise; } export const databaseBackupStorage: IStorage = { @@ -36,4 +63,51 @@ export const databaseBackupStorage: IStorage = { const result = await db.databaseBackup.deleteMany({ where: { userId } }); return result.count; }, -}; + + // ============================== + // Backup Destination methods + // ============================== + async createBackupDestination(userId, path) { + // deactivate existing destination + await db.backupDestination.updateMany({ + where: { userId }, + data: { isActive: false }, + }); + + return db.backupDestination.create({ + data: { userId, path }, + }); + }, + + async getActiveBackupDestination(userId) { + return db.backupDestination.findFirst({ + where: { userId, isActive: true }, + }); + }, + + async getAllBackupDestination(userId) { + return db.backupDestination.findMany({ + where: { userId }, + orderBy: { createdAt: "desc" }, + }); + }, + + async updateBackupDestination(id, userId, path) { + // optional: make this one active + await db.backupDestination.updateMany({ + where: { userId }, + data: { isActive: false }, + }); + + return db.backupDestination.update({ + where: { id, userId }, + data: { path, isActive: true }, + }); + }, + + async deleteBackupDestination(id, userId) { + return db.backupDestination.delete({ + where: { id, userId }, + }); + }, +}; \ No newline at end of file diff --git a/apps/Frontend/src/components/database-management/backup-destination-manager.tsx b/apps/Frontend/src/components/database-management/backup-destination-manager.tsx new file mode 100644 index 0000000..6b520ec --- /dev/null +++ b/apps/Frontend/src/components/database-management/backup-destination-manager.tsx @@ -0,0 +1,165 @@ +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 { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { FolderOpen, Trash2 } from "lucide-react"; +import { apiRequest, queryClient } from "@/lib/queryClient"; +import { useToast } from "@/hooks/use-toast"; + +export function BackupDestinationManager() { + const { toast } = useToast(); + const [path, setPath] = useState(""); + const [deleteId, setDeleteId] = useState(null); + + // ============================== + // Queries + // ============================== + const { data: destinations = [] } = useQuery({ + queryKey: ["/db/destination"], + queryFn: async () => { + const res = await apiRequest( + "GET", + "/api/database-management/destination" + ); + return res.json(); + }, + }); + + // ============================== + // Mutations + // ============================== + const saveMutation = useMutation({ + mutationFn: async () => { + const res = await apiRequest( + "POST", + "/api/database-management/destination", + { path } + ); + if (!res.ok) throw new Error((await res.json()).error); + }, + onSuccess: () => { + toast({ title: "Backup destination saved" }); + setPath(""); + queryClient.invalidateQueries({ queryKey: ["/db/destination"] }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: async (id: number) => { + await apiRequest("DELETE", `/api/database-management/destination/${id}`); + }, + onSuccess: () => { + toast({ title: "Backup destination deleted" }); + queryClient.invalidateQueries({ queryKey: ["/db/destination"] }); + setDeleteId(null); + }, + }); + + // ============================== + // Folder picker (browser limitation) + // ============================== + const openFolderPicker = async () => { + // @ts-ignore + if (!window.showDirectoryPicker) { + toast({ + title: "Not supported", + description: "Your browser does not support folder picking", + variant: "destructive", + }); + return; + } + + try { + // @ts-ignore + const dirHandle = await window.showDirectoryPicker(); + + toast({ + title: "Folder selected", + description: `Selected folder: ${dirHandle.name}. Please enter the full path manually.`, + }); + } catch { + // user cancelled + } + }; + + // ============================== + // UI + // ============================== + return ( + + + External Backup Destination + + +
+ setPath(e.target.value)} + /> + +
+ + + +
+ {destinations.map((d: any) => ( +
+ {d.path} + +
+ ))} +
+ + {/* Confirm delete dialog */} + + + + Delete backup destination? + + This will remove the destination and stop automatic backups. + + + + setDeleteId(null)}> + Cancel + + deleteId && deleteMutation.mutate(deleteId)} + > + Delete + + + + +
+
+ ); +} diff --git a/apps/Frontend/src/pages/database-management-page.tsx b/apps/Frontend/src/pages/database-management-page.tsx index ed87774..eb70ab5 100644 --- a/apps/Frontend/src/pages/database-management-page.tsx +++ b/apps/Frontend/src/pages/database-management-page.tsx @@ -12,6 +12,7 @@ import { 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"; export default function DatabaseManagementPage() { const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); @@ -205,6 +206,9 @@ export default function DatabaseManagementPage() { + {/* Externa Drive automatic backup manager */} + + {/* Database Status Section */} diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 422c21f..c32303a 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -29,6 +29,7 @@ model User { insuranceCredentials InsuranceCredential[] updatedPayments Payment[] @relation("PaymentUpdatedBy") backups DatabaseBackup[] + backupDestinations BackupDestination[] notifications Notification[] cloudFolders CloudFolder[] cloudFiles CloudFile[] @@ -301,6 +302,7 @@ enum PaymentMethod { OTHER } +// Database management page model DatabaseBackup { id Int @id @default(autoincrement()) userId Int @@ -312,6 +314,16 @@ model DatabaseBackup { @@index([createdAt]) } +model BackupDestination { + id Int @id @default(autoincrement()) + userId Int + path String + isActive Boolean @default(true) + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id]) +} + model Notification { id Int @id @default(autoincrement()) userId Int diff --git a/packages/db/types/databaseBackup-types.ts b/packages/db/types/databaseBackup-types.ts index cff1444..66be21c 100644 --- a/packages/db/types/databaseBackup-types.ts +++ b/packages/db/types/databaseBackup-types.ts @@ -1,6 +1,10 @@ -import { DatabaseBackupUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas"; +import { DatabaseBackupUncheckedCreateInputObjectSchema, BackupDestinationUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas"; import { z } from "zod"; export type DatabaseBackup = z.infer< typeof DatabaseBackupUncheckedCreateInputObjectSchema >; + +export type BackupDestination = z.infer< + typeof BackupDestinationUncheckedCreateInputObjectSchema +>; diff --git a/packages/db/usedSchemas/index.ts b/packages/db/usedSchemas/index.ts index db21c36..573567c 100644 --- a/packages/db/usedSchemas/index.ts +++ b/packages/db/usedSchemas/index.ts @@ -16,6 +16,7 @@ export * from '../shared/schemas/enums/PaymentStatus.schema' export * from '../shared/schemas/enums/NotificationTypes.schema' export * from '../shared/schemas/objects/NotificationUncheckedCreateInput.schema' export * from '../shared/schemas/objects/DatabaseBackupUncheckedCreateInput.schema' +export * from '../shared/schemas/objects/BackupDestinationUncheckedCreateInput.schema' export * from '../shared/schemas/objects/CloudFolderUncheckedCreateInput.schema' export * from '../shared/schemas/objects/CloudFileUncheckedCreateInput.schema' export * from '../shared/schemas/objects/CommunicationUncheckedCreateInput.schema' \ No newline at end of file