diff --git a/apps/Backend/package.json b/apps/Backend/package.json index efc2ca2b..70bf1408 100755 --- a/apps/Backend/package.json +++ b/apps/Backend/package.json @@ -13,6 +13,9 @@ "license": "ISC", "type": "commonjs", "dependencies": { + "@google/generative-ai": "^0.24.1", + "@langchain/google-genai": "^2.1.30", + "@langchain/langgraph": "^1.2.9", "archiver": "^7.0.1", "axios": "^1.9.0", "bcrypt": "^5.1.1", diff --git a/apps/Backend/src/cron/backupCheck.ts b/apps/Backend/src/cron/backupCheck.ts index c79e3b01..34211254 100755 --- a/apps/Backend/src/cron/backupCheck.ts +++ b/apps/Backend/src/cron/backupCheck.ts @@ -11,12 +11,31 @@ 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 = 30; + 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; @@ -49,6 +68,7 @@ export const startBackupCron = () => { const startedAt = new Date(); const log = await cronJobLogStorage.createJobLog("local-backup", startedAt); await cronJobLogStorage.completeJobLog(log.id, "skipped", new Date()); + await storage.deleteNotificationsByType(admin.id, "BACKUP"); return; } @@ -58,6 +78,7 @@ export const startBackupCron = () => { try { const filename = `dental_backup_${Date.now()}.sql`; 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()); @@ -93,6 +114,7 @@ export const startBackupCron = () => { const startedAt = new Date(); const log = await cronJobLogStorage.createJobLog("usb-backup", startedAt); await cronJobLogStorage.completeJobLog(log.id, "skipped", new Date()); + await storage.deleteNotificationsByType(admin.id, "BACKUP"); return; } @@ -131,6 +153,7 @@ export const startBackupCron = () => { try { const filename = `dental_backup_usb_${Date.now()}.sql`; 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()); diff --git a/apps/Backend/src/routes/database-management.ts b/apps/Backend/src/routes/database-management.ts index abff7d94..809699a6 100755 --- a/apps/Backend/src/routes/database-management.ts +++ b/apps/Backend/src/routes/database-management.ts @@ -12,8 +12,9 @@ const restoreUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 500 * 1024 * 1024 }, // 500 MB fileFilter: (_req, file, cb) => { - if (file.originalname.toLowerCase().endsWith(".sql")) cb(null, true); - else cb(new Error("Only .sql files are allowed")); + const name = file.originalname.toLowerCase(); + if (name.endsWith(".sql") || name.endsWith(".zip")) cb(null, true); + else cb(new Error("Only .sql or .zip files are allowed")); }, }); @@ -339,13 +340,21 @@ router.post("/restore", restoreUpload.single("file"), async (req: Request, res: if (!req.file) return res.status(400).json({ error: "No file provided" }); + const isZip = req.file.originalname.toLowerCase().endsWith(".zip"); + + // For zip files, write to a temp file so unzip can read it + let tmpZipPath: string | null = null; + if (isZip) { + tmpZipPath = path.join(os.tmpdir(), `restore_${Date.now()}.zip`); + fs.writeFileSync(tmpZipPath, req.file.buffer); + } + // Drop and recreate the public schema so existing tables don't block the restore. - // pg_dump without --clean produces no DROP statements, so restoring into an - // existing schema would silently skip CREATE TABLE / fail on duplicate inserts. try { await prisma.$executeRawUnsafe(`DROP SCHEMA public CASCADE`); await prisma.$executeRawUnsafe(`CREATE SCHEMA public`); } catch (err: any) { + if (tmpZipPath) try { fs.unlinkSync(tmpZipPath); } catch {} console.error("Failed to reset schema before restore:", err); return res.status(500).json({ error: "Failed to reset database schema", details: err.message }); } @@ -366,30 +375,41 @@ router.post("/restore", restoreUpload.single("file"), async (req: Request, res: psql.stderr.on("data", (d) => (stderr += d.toString())); psql.on("error", (err) => { + if (tmpZipPath) try { fs.unlinkSync(tmpZipPath); } catch {} console.error("Failed to start psql:", err); if (!res.headersSent) res.status(500).json({ error: "Failed to run psql", details: err.message }); }); psql.on("close", async (code) => { + if (tmpZipPath) try { fs.unlinkSync(tmpZipPath); } catch {} if (code !== 0) { console.error("psql restore failed:", stderr); return res.status(500).json({ error: "Restore failed", details: stderr }); } - // Disconnect so Prisma drops its pooled connections and reconnects fresh - // on the next request, avoiding stale prepared statements against the old schema. try { await prisma.$disconnect(); - } catch (_) { - // non-fatal — the restore succeeded - } + } catch (_) {} res.json({ success: true }); }); - psql.stdin.write(req.file.buffer); - psql.stdin.end(); + if (isZip && tmpZipPath) { + // Pipe the first .sql entry from the zip directly into psql stdin + const unzip = spawn("unzip", ["-p", tmpZipPath, "*.sql"]); + let unzipErr = ""; + unzip.stderr.on("data", (d) => (unzipErr += d.toString())); + unzip.on("error", (err) => { + if (tmpZipPath) try { fs.unlinkSync(tmpZipPath); } catch {} + if (!res.headersSent) + res.status(500).json({ error: "Failed to extract zip", details: err.message }); + }); + unzip.stdout.pipe(psql.stdin); + } else { + psql.stdin.write(req.file.buffer); + psql.stdin.end(); + } }); export default router; diff --git a/apps/Backend/src/routes/index.ts b/apps/Backend/src/routes/index.ts index ed6b3672..674a8d27 100755 --- a/apps/Backend/src/routes/index.ts +++ b/apps/Backend/src/routes/index.ts @@ -25,6 +25,7 @@ import paymentsReportsRoutes from "./payments-reports"; import exportPaymentsReportsRoutes from "./export-payments-reports"; import jobMonitorRoutes from "./job-monitor"; import twilioRoutes from "./twilio"; +import aiSettingsRoutes from "./ai-settings"; const router = Router(); @@ -54,5 +55,6 @@ router.use("/payments-reports", paymentsReportsRoutes); router.use("/export-payments-reports", exportPaymentsReportsRoutes); router.use("/job-monitor", jobMonitorRoutes); router.use("/twilio", twilioRoutes); +router.use("/ai", aiSettingsRoutes); export default router; diff --git a/apps/Backend/src/routes/twilio-webhooks.ts b/apps/Backend/src/routes/twilio-webhooks.ts index 6fe0c8e8..4ea87c0f 100644 --- a/apps/Backend/src/routes/twilio-webhooks.ts +++ b/apps/Backend/src/routes/twilio-webhooks.ts @@ -1,6 +1,7 @@ import express, { Request, Response } from "express"; import { storage } from "../storage"; import { prisma as db } from "@repo/db/client"; +import { runReminderGraph } from "../ai/reminder-graph"; const router = express.Router(); @@ -10,12 +11,13 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise => const { From, Body, MessageSid } = req.body; const normalizedFrom = (From || "").replace(/\D/g, ""); - const allPatients = await db.patient.findMany({ select: { id: true, phone: true } }); + const allPatients = await db.patient.findMany({ select: { id: true, phone: true, userId: true } }); const patient = allPatients.find( - (p: { id: number; phone: string | null }) => p.phone && p.phone.replace(/\D/g, "") === normalizedFrom + (p: { id: number; phone: string | null; userId: number }) => p.phone && p.phone.replace(/\D/g, "") === normalizedFrom ); if (patient) { + // Save the inbound message await storage.createCommunication({ patientId: patient.id, channel: "sms", @@ -24,6 +26,28 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise => body: Body, twilioSid: MessageSid, }); + + // Run AI graph if API key is configured + const aiSettings = await storage.getAiSettings(patient.userId); + if (aiSettings?.apiKey) { + const { reply, intent } = await runReminderGraph(Body, aiSettings.apiKey); + + if (reply) { + // Save the AI outbound reply + await storage.createCommunication({ + patientId: patient.id, + channel: "sms", + direction: "outbound", + status: "sent", + body: reply, + }); + + res.set("Content-Type", "text/xml"); + return res.send( + `${escapeXml(reply)}` + ); + } + } } res.set("Content-Type", "text/xml"); @@ -104,4 +128,13 @@ router.post("/webhook/voice-recording", async (req: Request, res: Response): Pro } }); +function escapeXml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + export default router; diff --git a/apps/Backend/src/services/databaseBackupService.ts b/apps/Backend/src/services/databaseBackupService.ts index d6a94960..daa9eaaa 100755 --- a/apps/Backend/src/services/databaseBackupService.ts +++ b/apps/Backend/src/services/databaseBackupService.ts @@ -1,54 +1,73 @@ import { spawn } from "child_process"; +import fs from "fs"; +import path from "path"; +import archiver from "archiver"; interface BackupToPathParams { destinationPath: string; - filename: string; + filename: string; // should end in .zip } export async function backupDatabaseToPath({ destinationPath, filename, }: BackupToPathParams): Promise { - const path = await import("path"); - const fs = await import("fs"); + // Verify write access before spawning pg_dump + try { + fs.accessSync(destinationPath, fs.constants.W_OK); + } catch { + throw new Error( + `No write permission to "${destinationPath}". Try running: sudo chmod a+w "${destinationPath}"` + ); + } - const outputFile = path.join(destinationPath, filename); + const zipFile = path.join(destinationPath, filename); + const sqlName = filename.replace(/\.zip$/, ".sql"); return new Promise((resolve, reject) => { + const output = fs.createWriteStream(zipFile); + const archive = archiver("zip", { zlib: { level: 6 } }); + + output.on("close", () => resolve()); + archive.on("error", (err) => { + try { if (fs.existsSync(zipFile)) fs.unlinkSync(zipFile); } catch {} + reject(err); + }); + + archive.pipe(output); + const pgDump = spawn( "pg_dump", [ "--no-acl", "--no-owner", - "-h", - process.env.DB_HOST || "localhost", - "-U", - process.env.DB_USER || "postgres", - "-f", - outputFile, + "-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, - }, + env: { ...process.env, PGPASSWORD: process.env.DB_PASSWORD }, } ); let pgError = ""; - pgDump.stderr.on("data", (d) => (pgError += d.toString())); + pgDump.on("error", (err) => { + try { if (fs.existsSync(zipFile)) fs.unlinkSync(zipFile); } catch {} + reject(new Error(`Failed to start pg_dump: ${err.message}. Make sure postgresql-client is installed.`)); + }); + pgDump.on("close", (code) => { if (code !== 0) { - // clean up partial file if it was created - try { - if (fs.existsSync(outputFile)) fs.unlinkSync(outputFile); - } catch {} - return reject(new Error(pgError || "pg_dump failed")); + try { if (fs.existsSync(zipFile)) fs.unlinkSync(zipFile); } catch {} + reject(new Error(pgError.trim() || `pg_dump exited with code ${code}`)); + return; } - resolve(); + archive.finalize(); }); + + // Stream pg_dump stdout directly into the zip as a .sql entry + archive.append(pgDump.stdout, { name: sqlName }); }); } diff --git a/apps/Backend/src/storage/index.ts b/apps/Backend/src/storage/index.ts index 7a2eeef5..3b10ba3b 100755 --- a/apps/Backend/src/storage/index.ts +++ b/apps/Backend/src/storage/index.ts @@ -18,6 +18,7 @@ import { patientDocumentsStorage } from './patientDocuments-storage'; import * as exportPaymentsReportsStorage from "./export-payments-reports-storage"; import { cronJobLogStorage } from "./cron-job-log-storage"; import { twilioStorage } from "./twilio-storage"; +import { aiSettingsStorage } from "./ai-settings-storage"; export const storage = { @@ -39,6 +40,7 @@ export const storage = { ...exportPaymentsReportsStorage, ...cronJobLogStorage, ...twilioStorage, + ...aiSettingsStorage, }; diff --git a/apps/Frontend/src/App.tsx b/apps/Frontend/src/App.tsx index f0aeb062..4be756a4 100755 --- a/apps/Frontend/src/App.tsx +++ b/apps/Frontend/src/App.tsx @@ -45,6 +45,7 @@ function Router() { } /> } /> } /> + } adminOnly /> } adminOnly /> } /> { const res = await apiRequest("POST", "/api/database-management/backup-path"); - if (!res.ok) throw new Error((await res.json()).error || "Backup failed"); + if (!res.ok) { + const body = await res.json(); + throw new Error(body.details || body.error || "Backup failed"); + } return res.json(); }, onSuccess: (data) => { diff --git a/apps/Frontend/src/components/database-management/folder-browser-modal.tsx b/apps/Frontend/src/components/database-management/folder-browser-modal.tsx index e09516dd..4ce0c318 100644 --- a/apps/Frontend/src/components/database-management/folder-browser-modal.tsx +++ b/apps/Frontend/src/components/database-management/folder-browser-modal.tsx @@ -25,7 +25,7 @@ interface FolderBrowserModalProps { export function FolderBrowserModal({ open, onClose, onSelect }: FolderBrowserModalProps) { const [browsePath, setBrowsePath] = useState("/"); - const [selected, setSelected] = useState(null); + const [selected, setSelected] = useState("/"); const { data, isLoading, isError } = useQuery({ queryKey: ["/db/browse", browsePath], @@ -41,15 +41,13 @@ export function FolderBrowserModal({ open, onClose, onSelect }: FolderBrowserMod }); const handleNavigate = (path: string) => { - setSelected(null); + setSelected(path); setBrowsePath(path); }; const handleConfirm = () => { - if (selected) { - onSelect(selected); - onClose(); - } + onSelect(selected); + onClose(); }; return ( @@ -120,7 +118,7 @@ export function FolderBrowserModal({ open, onClose, onSelect }: FolderBrowserMod - diff --git a/apps/Frontend/src/components/database-management/import-database-section.tsx b/apps/Frontend/src/components/database-management/import-database-section.tsx index 8f7345a4..a3f07e09 100644 --- a/apps/Frontend/src/components/database-management/import-database-section.tsx +++ b/apps/Frontend/src/components/database-management/import-database-section.tsx @@ -82,7 +82,7 @@ export function ImportDatabaseSection() {

- Restore the database from a .sql backup file. + Restore the database from a .sql or .zip backup file. This will overwrite all existing data.

@@ -90,7 +90,7 @@ export function ImportDatabaseSection() { diff --git a/apps/Frontend/src/components/layout/sidebar.tsx b/apps/Frontend/src/components/layout/sidebar.tsx index e5962baf..a273682c 100755 --- a/apps/Frontend/src/components/layout/sidebar.tsx +++ b/apps/Frontend/src/components/layout/sidebar.tsx @@ -20,6 +20,12 @@ import { Microscope, ChevronDown, ChevronRight, + UserCog, + User, + ShieldCheck, + Stethoscope, + Workflow, + Bot, } from "lucide-react"; import { cn } from "@/lib/utils"; import { useMemo, useState, useEffect } from "react"; @@ -30,6 +36,8 @@ type NavChild = { name: string; path: string; icon: React.ReactNode; + adminOnly?: boolean; + groupLabel?: string; // renders a group heading before this item }; type NavItem = { @@ -42,13 +50,14 @@ type NavItem = { export function Sidebar() { const [location] = useLocation(); - const { state, openMobile, setOpenMobile } = useSidebar(); // "expanded" | "collapsed" + const { state, openMobile, setOpenMobile } = useSidebar(); const { user } = useAuth(); const isAdmin = user?.username === "admin"; const [expandedPaths, setExpandedPaths] = useState>(() => { const s = new Set(); if (location.startsWith("/chart")) s.add("/chart"); + if (location.startsWith("/settings")) s.add("/settings"); return s; }); @@ -56,6 +65,9 @@ export function Sidebar() { if (location.startsWith("/chart")) { setExpandedPaths((prev) => new Set([...prev, "/chart"])); } + if (location.startsWith("/settings")) { + setExpandedPaths((prev) => new Set([...prev, "/settings"])); + } }, [location]); const togglePath = (path: string) => { @@ -163,6 +175,53 @@ export function Sidebar() { path: "/settings", icon: , adminOnly: true, + children: [ + // ── General ────────────────────────────────────────── + { + groupLabel: "General", + name: "Staff Management", + path: "/settings/staff", + icon: , + }, + { + name: "Manage Users", + path: "/settings/users", + icon: , + adminOnly: true, + }, + { + name: "Account Settings", + path: "/settings/account", + icon: , + }, + { + name: "Insurance Credentials", + path: "/settings/credentials", + icon: , + }, + { + name: "NPI Providers", + path: "/settings/npi", + icon: , + }, + { + name: "Program Bridge", + path: "/settings/programs", + icon: , + }, + // ── Advanced ───────────────────────────────────────── + { + groupLabel: "Advanced", + name: "Twilio Settings", + path: "/settings/twilio", + icon: , + }, + { + name: "Google AI Settings", + path: "/settings/ai", + icon: , + }, + ], }, ], [] @@ -172,16 +231,16 @@ export function Sidebar() {
-
+