feat(automatic-backup-to-usb) - done v1

This commit is contained in:
2025-12-19 01:30:27 +05:30
parent adb49a6683
commit 4c030713d7
9 changed files with 517 additions and 5 deletions

View File

@@ -1,14 +1,19 @@
import cron from "node-cron"; import cron from "node-cron";
import fs from "fs";
import { storage } from "../storage"; import { storage } from "../storage";
import { NotificationTypes } from "@repo/db/types"; 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 * Daily cron job to check if users haven't backed up in 7 days
* Creates a backup notification if overdue * Creates a backup notification if overdue
*/ */
export const startBackupCron = () => { export const startBackupCron = () => {
cron.schedule("0 9 * * *", async () => { cron.schedule("0 2 */3 * *", async () => {
console.log("🔄 Running daily backup check..."); // Every 3 calendar days, at 2 AM
// cron.schedule("*/10 * * * * *", async () => { // Every 10 seconds (for Test)
console.log("🔄 Running backup check...");
const userBatchSize = 100; const userBatchSize = 100;
let userOffset = 0; let userOffset = 0;
@@ -23,7 +28,52 @@ export const startBackupCron = () => {
if (user.id == null) { if (user.id == null) {
continue; continue;
} }
const destination = await storage.getActiveBackupDestination(user.id);
const lastBackup = await storage.getLastBackup(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 const daysSince = lastBackup?.createdAt
? (Date.now() - new Date(lastBackup.createdAt).getTime()) / ? (Date.now() - new Date(lastBackup.createdAt).getTime()) /
(1000 * 60 * 60 * 24) (1000 * 60 * 60 * 24)

View File

@@ -6,6 +6,7 @@ import fs from "fs";
import { prisma } from "@repo/db/client"; import { prisma } from "@repo/db/client";
import { storage } from "../storage"; import { storage } from "../storage";
import archiver from "archiver"; import archiver from "archiver";
import { backupDatabaseToPath } from "../services/databaseBackupService";
const router = Router(); const router = Router();
@@ -33,6 +34,8 @@ router.post("/backup", async (req: Request, res: Response): Promise<any> => {
return res.status(401).json({ error: "Unauthorized" }); return res.status(401).json({ error: "Unauthorized" });
} }
const destination = await storage.getActiveBackupDestination(userId);
// create a unique tmp directory for directory-format dump // create a unique tmp directory for directory-format dump
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dental_backup_")); // MUST const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dental_backup_")); // MUST
@@ -240,4 +243,118 @@ router.get("/status", async (req: Request, res: Response): Promise<any> => {
} }
}); });
// ==============================
// 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; export default router;

View File

@@ -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<void> {
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();
});
});
});
}

View File

@@ -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"; import { prisma as db } from "@repo/db/client";
export interface IStorage { export interface IStorage {
@@ -7,6 +7,33 @@ export interface IStorage {
getLastBackup(userId: number): Promise<DatabaseBackup | null>; getLastBackup(userId: number): Promise<DatabaseBackup | null>;
getBackups(userId: number, limit?: number): Promise<DatabaseBackup[]>; getBackups(userId: number, limit?: number): Promise<DatabaseBackup[]>;
deleteBackups(userId: number): Promise<number>; // clears all for user deleteBackups(userId: number): Promise<number>; // clears all for user
// ==============================
// Backup Destination methods
// ==============================
createBackupDestination(
userId: number,
path: string
): Promise<BackupDestination>;
getActiveBackupDestination(
userId: number
): Promise<BackupDestination | null>;
getAllBackupDestination(
userId: number
): Promise<BackupDestination[]>;
updateBackupDestination(
id: number,
userId: number,
path: string
): Promise<BackupDestination>;
deleteBackupDestination(
id: number,
userId: number
): Promise<BackupDestination>;
} }
export const databaseBackupStorage: IStorage = { export const databaseBackupStorage: IStorage = {
@@ -36,4 +63,51 @@ export const databaseBackupStorage: IStorage = {
const result = await db.databaseBackup.deleteMany({ where: { userId } }); const result = await db.databaseBackup.deleteMany({ where: { userId } });
return result.count; 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 },
});
},
};

View File

@@ -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<number | null>(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 (
<Card>
<CardHeader>
<CardTitle>External Backup Destination</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<Input
placeholder="/media/usb-drive or D:\\Backups"
value={path}
onChange={(e) => setPath(e.target.value)}
/>
<Button variant="outline" onClick={openFolderPicker}>
<FolderOpen className="h-4 w-4" />
</Button>
</div>
<Button
onClick={() => saveMutation.mutate()}
disabled={!path || saveMutation.isPending}
>
Save Destination
</Button>
<div className="space-y-2">
{destinations.map((d: any) => (
<div
key={d.id}
className="flex justify-between items-center border rounded p-2"
>
<span className="text-sm text-gray-700">{d.path}</span>
<Button
size="sm"
variant="destructive"
onClick={() => setDeleteId(d.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
{/* Confirm delete dialog */}
<AlertDialog open={deleteId !== null}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete backup destination?</AlertDialogTitle>
<AlertDialogDescription>
This will remove the destination and stop automatic backups.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setDeleteId(null)}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteId && deleteMutation.mutate(deleteId)}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
);
}

View File

@@ -12,6 +12,7 @@ import {
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { apiRequest, queryClient } from "@/lib/queryClient"; import { apiRequest, queryClient } from "@/lib/queryClient";
import { formatDateToHumanReadable } from "@/utils/dateUtils"; import { formatDateToHumanReadable } from "@/utils/dateUtils";
import { BackupDestinationManager } from "@/components/database-management/backup-destination-manager";
export default function DatabaseManagementPage() { export default function DatabaseManagementPage() {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
@@ -205,6 +206,9 @@ export default function DatabaseManagementPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Externa Drive automatic backup manager */}
<BackupDestinationManager />
{/* Database Status Section */} {/* Database Status Section */}
<Card> <Card>
<CardHeader> <CardHeader>

View File

@@ -29,6 +29,7 @@ model User {
insuranceCredentials InsuranceCredential[] insuranceCredentials InsuranceCredential[]
updatedPayments Payment[] @relation("PaymentUpdatedBy") updatedPayments Payment[] @relation("PaymentUpdatedBy")
backups DatabaseBackup[] backups DatabaseBackup[]
backupDestinations BackupDestination[]
notifications Notification[] notifications Notification[]
cloudFolders CloudFolder[] cloudFolders CloudFolder[]
cloudFiles CloudFile[] cloudFiles CloudFile[]
@@ -301,6 +302,7 @@ enum PaymentMethod {
OTHER OTHER
} }
// Database management page
model DatabaseBackup { model DatabaseBackup {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
userId Int userId Int
@@ -312,6 +314,16 @@ model DatabaseBackup {
@@index([createdAt]) @@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 { model Notification {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
userId Int userId Int

View File

@@ -1,6 +1,10 @@
import { DatabaseBackupUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas"; import { DatabaseBackupUncheckedCreateInputObjectSchema, BackupDestinationUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas";
import { z } from "zod"; import { z } from "zod";
export type DatabaseBackup = z.infer< export type DatabaseBackup = z.infer<
typeof DatabaseBackupUncheckedCreateInputObjectSchema typeof DatabaseBackupUncheckedCreateInputObjectSchema
>; >;
export type BackupDestination = z.infer<
typeof BackupDestinationUncheckedCreateInputObjectSchema
>;

View File

@@ -16,6 +16,7 @@ export * from '../shared/schemas/enums/PaymentStatus.schema'
export * from '../shared/schemas/enums/NotificationTypes.schema' export * from '../shared/schemas/enums/NotificationTypes.schema'
export * from '../shared/schemas/objects/NotificationUncheckedCreateInput.schema' export * from '../shared/schemas/objects/NotificationUncheckedCreateInput.schema'
export * from '../shared/schemas/objects/DatabaseBackupUncheckedCreateInput.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/CloudFolderUncheckedCreateInput.schema'
export * from '../shared/schemas/objects/CloudFileUncheckedCreateInput.schema' export * from '../shared/schemas/objects/CloudFileUncheckedCreateInput.schema'
export * from '../shared/schemas/objects/CommunicationUncheckedCreateInput.schema' export * from '../shared/schemas/objects/CommunicationUncheckedCreateInput.schema'