diff --git a/README.md b/README.md index 46b6c71..a6f41c2 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ i.e apps/PatientDataExtractorService/package.json. ## πŸ“– Developer Documentation +- [Setting up server environment](docs/server-setup.md) β€” the first step, to run this app in environment. - [Development Hosts & Ports](docs/ports.md) β€” which app runs on which host/port diff --git a/apps/Backend/.env.example b/apps/Backend/.env.example index bf8de98..7f2e8e2 100644 --- a/apps/Backend/.env.example +++ b/apps/Backend/.env.example @@ -2,6 +2,7 @@ NODE_ENV="development" HOST=0.0.0.0 PORT=5000 FRONTEND_URLS=http://localhost:3000,http://192.168.1.8:3000 +SELENIUM_AGENT_BASE_URL=http://localhost:5002 JWT_SECRET = 'dentalsecret' DB_HOST=localhost DB_USER=postgres diff --git a/apps/Backend/package.json b/apps/Backend/package.json index f2640c2..d2530f6 100644 --- a/apps/Backend/package.json +++ b/apps/Backend/package.json @@ -27,6 +27,7 @@ "passport": "^0.7.0", "passport-local": "^1.0.0", "pdfkit": "^0.17.2", + "socket.io": "^4.8.1", "ws": "^8.18.0", "zod": "^3.24.2", "zod-validation-error": "^3.4.0" diff --git a/apps/Backend/src/cron/backupCheck.ts b/apps/Backend/src/cron/backupCheck.ts index b6f13f8..a1b544b 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 * * *", async () => { + // Every 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/index.ts b/apps/Backend/src/index.ts index c2903c6..70e4375 100644 --- a/apps/Backend/src/index.ts +++ b/apps/Backend/src/index.ts @@ -1,5 +1,7 @@ import app from "./app"; import dotenv from "dotenv"; +import http from "http"; +import { initSocket } from "./socket"; dotenv.config(); @@ -11,7 +13,13 @@ const NODE_ENV = ( const HOST = process.env.HOST || "0.0.0.0"; const PORT = Number(process.env.PORT) || 5000; -const server = app.listen(PORT, HOST, () => { +// HTTP server from express app +const server = http.createServer(app); + +// Initialize socket.io on this server +initSocket(server); + +server.listen(PORT, HOST, () => { console.log( `βœ… Server running in ${NODE_ENV} mode at http://${HOST}:${PORT}` ); diff --git a/apps/Backend/src/routes/claims.ts b/apps/Backend/src/routes/claims.ts index 1f22ac7..8e07f70 100644 --- a/apps/Backend/src/routes/claims.ts +++ b/apps/Backend/src/routes/claims.ts @@ -126,17 +126,28 @@ router.post( responseType: "arraybuffer", }); - const groupTitle = "Claims"; + // Allowed keys as a literal tuple to derive a union type + const allowedKeys = [ + "INSURANCE_CLAIM", + "INSURANCE_CLAIM_PREAUTH", + ] as const; + type GroupKey = (typeof allowedKeys)[number]; + const isGroupKey = (v: any): v is GroupKey => + (allowedKeys as readonly string[]).includes(v); - // allowed keys - const allowedKeys = ["INSURANCE_CLAIM", "INSURANCE_CLAIM_PREAUTH"]; - if (!allowedKeys.includes(groupTitleKey)) { + if (!isGroupKey(groupTitleKey)) { return sendError( res, `Invalid groupTitleKey. Must be one of: ${allowedKeys.join(", ")}` ); } + const GROUP_TITLES: Record = { + INSURANCE_CLAIM: "Claims", + INSURANCE_CLAIM_PREAUTH: "Claims Preauth", + }; + const groupTitle = GROUP_TITLES[groupTitleKey]; + // βœ… Find or create PDF group for this claim let group = await storage.findPdfGroupByPatientTitleKey( parsedPatientId, diff --git a/apps/Backend/src/routes/database-management.ts b/apps/Backend/src/routes/database-management.ts index 789c02f..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 @@ -132,12 +135,10 @@ router.post("/backup", async (req: Request, res: Response): Promise => { // attempt to respond with error if possible try { if (!res.headersSent) { - res - .status(500) - .json({ - error: "Failed to create archive", - details: err.message, - }); + res.status(500).json({ + error: "Failed to create archive", + details: err.message, + }); } else { // if streaming already started, destroy the connection res.destroy(err); @@ -187,12 +188,10 @@ router.post("/backup", async (req: Request, res: Response): Promise => { // if headers not sent, send 500; otherwise destroy try { if (!res.headersSent) { - res - .status(500) - .json({ - error: "Failed to finalize archive", - details: String(err), - }); + res.status(500).json({ + error: "Failed to finalize archive", + details: String(err), + }); } else { res.destroy(err); } @@ -222,9 +221,9 @@ router.get("/status", async (req: Request, res: Response): Promise => { return res.status(401).json({ error: "Unauthorized" }); } - const size = await prisma.$queryRawUnsafe<{ size: string }[]>( - "SELECT pg_size_pretty(pg_database_size(current_database())) as size" - ); + const size = await prisma.$queryRaw<{ size: string }[]>` + SELECT pg_size_pretty(pg_database_size(current_database())) as size + `; const patientsCount = await storage.getTotalPatientCount(); const lastBackup = await storage.getLastBackup(userId); @@ -244,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/routes/index.ts b/apps/Backend/src/routes/index.ts index 3b0ecbf..96a45e9 100644 --- a/apps/Backend/src/routes/index.ts +++ b/apps/Backend/src/routes/index.ts @@ -8,6 +8,7 @@ import patientDataExtractionRoutes from "./patientDataExtraction"; import insuranceCredsRoutes from "./insuranceCreds"; import documentsRoutes from "./documents"; import insuranceStatusRoutes from "./insuranceStatus"; +import insuranceStatusDdmaRoutes from "./insuranceStatusDDMA"; import paymentsRoutes from "./payments"; import databaseManagementRoutes from "./database-management"; import notificationsRoutes from "./notifications"; @@ -27,6 +28,7 @@ router.use("/claims", claimsRoutes); router.use("/insuranceCreds", insuranceCredsRoutes); router.use("/documents", documentsRoutes); router.use("/insurance-status", insuranceStatusRoutes); +router.use("/insurance-status-ddma", insuranceStatusDdmaRoutes); router.use("/payments", paymentsRoutes); router.use("/database-management", databaseManagementRoutes); router.use("/notifications", notificationsRoutes); diff --git a/apps/Backend/src/routes/insuranceCreds.ts b/apps/Backend/src/routes/insuranceCreds.ts index 589b91a..fcf813e 100644 --- a/apps/Backend/src/routes/insuranceCreds.ts +++ b/apps/Backend/src/routes/insuranceCreds.ts @@ -96,7 +96,7 @@ router.delete("/:id", async (req: Request, res: Response): Promise => { if (isNaN(id)) return res.status(400).send("Invalid ID"); // 1) Check existence - const existing = await storage.getInsuranceCredential(userId); + const existing = await storage.getInsuranceCredential(id); if (!existing) return res.status(404).json({ message: "Credential not found" }); diff --git a/apps/Backend/src/routes/insuranceStatusDDMA.ts b/apps/Backend/src/routes/insuranceStatusDDMA.ts new file mode 100644 index 0000000..a5571ea --- /dev/null +++ b/apps/Backend/src/routes/insuranceStatusDDMA.ts @@ -0,0 +1,699 @@ +import { Router, Request, Response } from "express"; +import { storage } from "../storage"; +import { + forwardToSeleniumDdmaEligibilityAgent, + forwardOtpToSeleniumDdmaAgent, + getSeleniumDdmaSessionStatus, +} from "../services/seleniumDdmaInsuranceEligibilityClient"; +import fs from "fs/promises"; +import fsSync from "fs"; +import path from "path"; +import PDFDocument from "pdfkit"; +import { emptyFolderContainingFile } from "../utils/emptyTempFolder"; +import { + InsertPatient, + insertPatientSchema, +} from "../../../../packages/db/types/patient-types"; +import { io } from "../socket"; + +const router = Router(); + +/** Job context stored in memory by sessionId */ +interface DdmaJobContext { + userId: number; + insuranceEligibilityData: any; // parsed, enriched (includes username/password) + socketId?: string; +} + +const ddmaJobs: Record = {}; + +/** Utility: naive name splitter */ +function splitName(fullName?: string | null) { + if (!fullName) return { firstName: "", lastName: "" }; + const parts = fullName.trim().split(/\s+/).filter(Boolean); + const firstName = parts.shift() ?? ""; + const lastName = parts.join(" ") ?? ""; + return { firstName, lastName }; +} + +async function imageToPdfBuffer(imagePath: string): Promise { + return new Promise((resolve, reject) => { + try { + const doc = new PDFDocument({ autoFirstPage: false }); + const chunks: Uint8Array[] = []; + + doc.on("data", (chunk: any) => chunks.push(chunk)); + doc.on("end", () => resolve(Buffer.concat(chunks))); + doc.on("error", (err: any) => reject(err)); + + const A4_WIDTH = 595.28; // points + const A4_HEIGHT = 841.89; // points + + doc.addPage({ size: [A4_WIDTH, A4_HEIGHT] }); + + doc.image(imagePath, 0, 0, { + fit: [A4_WIDTH, A4_HEIGHT], + align: "center", + valign: "center", + }); + + doc.end(); + } catch (err) { + reject(err); + } + }); +} + +/** + * Ensure patient exists for given insuranceId. + */ +async function createOrUpdatePatientByInsuranceId(options: { + insuranceId: string; + firstName?: string | null; + lastName?: string | null; + dob?: string | Date | null; + userId: number; +}) { + const { insuranceId, firstName, lastName, dob, userId } = options; + if (!insuranceId) throw new Error("Missing insuranceId"); + + const incomingFirst = (firstName || "").trim(); + const incomingLast = (lastName || "").trim(); + + let patient = await storage.getPatientByInsuranceId(insuranceId); + + if (patient && patient.id) { + const updates: any = {}; + if ( + incomingFirst && + String(patient.firstName ?? "").trim() !== incomingFirst + ) { + updates.firstName = incomingFirst; + } + if ( + incomingLast && + String(patient.lastName ?? "").trim() !== incomingLast + ) { + updates.lastName = incomingLast; + } + if (Object.keys(updates).length > 0) { + await storage.updatePatient(patient.id, updates); + } + return; + } else { + const createPayload: any = { + firstName: incomingFirst, + lastName: incomingLast, + dateOfBirth: dob, + gender: "", + phone: "", + userId, + insuranceId, + }; + let patientData: InsertPatient; + try { + patientData = insertPatientSchema.parse(createPayload); + } catch (err) { + const safePayload = { ...createPayload }; + delete (safePayload as any).dateOfBirth; + patientData = insertPatientSchema.parse(safePayload); + } + await storage.createPatient(patientData); + } +} + +/** + * When Selenium finishes for a given sessionId, run your patient + PDF pipeline, + * and return the final API response shape. + */ +async function handleDdmaCompletedJob( + sessionId: string, + job: DdmaJobContext, + seleniumResult: any +) { + let createdPdfFileId: number | null = null; + const outputResult: any = {}; + + // We'll wrap the processing in try/catch/finally so cleanup always runs + try { + // 1) ensuring memberid. + const insuranceEligibilityData = job.insuranceEligibilityData; + const insuranceId = String(insuranceEligibilityData.memberId ?? "").trim(); + if (!insuranceId) { + throw new Error("Missing memberId for ddma job"); + } + + // 2) Create or update patient (with name from selenium result if available) + const patientNameFromResult = + typeof seleniumResult?.patientName === "string" + ? seleniumResult.patientName.trim() + : null; + + const { firstName, lastName } = splitName(patientNameFromResult); + + await createOrUpdatePatientByInsuranceId({ + insuranceId, + firstName, + lastName, + dob: insuranceEligibilityData.dateOfBirth, + userId: job.userId, + }); + + // 3) Update patient status + PDF upload + const patient = await storage.getPatientByInsuranceId( + insuranceEligibilityData.memberId + ); + if (!patient?.id) { + outputResult.patientUpdateStatus = + "Patient not found; no update performed"; + return { + patientUpdateStatus: outputResult.patientUpdateStatus, + pdfUploadStatus: "none", + pdfFileId: null, + }; + } + + // update patient status. + const newStatus = + seleniumResult.eligibility === "active" ? "ACTIVE" : "INACTIVE"; + await storage.updatePatient(patient.id, { status: newStatus }); + outputResult.patientUpdateStatus = `Patient status updated to ${newStatus}`; + + // convert screenshot -> pdf if available + let pdfBuffer: Buffer | null = null; + let generatedPdfPath: string | null = null; + + if ( + seleniumResult && + seleniumResult.ss_path && + typeof seleniumResult.ss_path === "string" && + (seleniumResult.ss_path.endsWith(".png") || + seleniumResult.ss_path.endsWith(".jpg") || + seleniumResult.ss_path.endsWith(".jpeg")) + ) { + try { + if (!fsSync.existsSync(seleniumResult.ss_path)) { + throw new Error( + `Screenshot file not found: ${seleniumResult.ss_path}` + ); + } + + pdfBuffer = await imageToPdfBuffer(seleniumResult.ss_path); + + const pdfFileName = `ddma_eligibility_${insuranceEligibilityData.memberId}_${Date.now()}.pdf`; + generatedPdfPath = path.join( + path.dirname(seleniumResult.ss_path), + pdfFileName + ); + await fs.writeFile(generatedPdfPath, pdfBuffer); + + // ensure cleanup uses this + seleniumResult.pdf_path = generatedPdfPath; + } catch (err: any) { + console.error("Failed to convert screenshot to PDF:", err); + outputResult.pdfUploadStatus = `Failed to convert screenshot to PDF: ${String(err)}`; + } + } else { + outputResult.pdfUploadStatus = + "No valid screenshot (ss_path) provided by Selenium; nothing to upload."; + } + + if (pdfBuffer && generatedPdfPath) { + const groupTitle = "Eligibility Status"; + const groupTitleKey = "ELIGIBILITY_STATUS"; + + let group = await storage.findPdfGroupByPatientTitleKey( + patient.id, + groupTitleKey + ); + if (!group) { + group = await storage.createPdfGroup( + patient.id, + groupTitle, + groupTitleKey + ); + } + if (!group?.id) { + throw new Error("PDF group creation failed: missing group ID"); + } + + const created = await storage.createPdfFile( + group.id, + path.basename(generatedPdfPath), + pdfBuffer + ); + if (created && typeof created === "object" && "id" in created) { + createdPdfFileId = Number(created.id); + } + outputResult.pdfUploadStatus = `PDF saved to group: ${group.title}`; + } else { + outputResult.pdfUploadStatus = + "No valid PDF path provided by Selenium, Couldn't upload pdf to server."; + } + + return { + patientUpdateStatus: outputResult.patientUpdateStatus, + pdfUploadStatus: outputResult.pdfUploadStatus, + pdfFileId: createdPdfFileId, + }; + } catch (err: any) { + return { + patientUpdateStatus: outputResult.patientUpdateStatus, + pdfUploadStatus: + outputResult.pdfUploadStatus ?? + `Failed to process DDMA job: ${err?.message ?? String(err)}`, + pdfFileId: createdPdfFileId, + error: err?.message ?? String(err), + }; + } finally { + // ALWAYS attempt cleanup of temp files + try { + if (seleniumResult && seleniumResult.pdf_path) { + await emptyFolderContainingFile(seleniumResult.pdf_path); + } else if (seleniumResult && seleniumResult.ss_path) { + await emptyFolderContainingFile(seleniumResult.ss_path); + } else { + console.log( + `[ddma-eligibility] no pdf_path or ss_path available to cleanup` + ); + } + } catch (cleanupErr) { + console.error( + `[ddma-eligibility cleanup failed for ${seleniumResult?.pdf_path ?? seleniumResult?.ss_path}]`, + cleanupErr + ); + } + } +} + +// --- top of file, alongside ddmaJobs --- +let currentFinalSessionId: string | null = null; +let currentFinalResult: any = null; + +function now() { + return new Date().toISOString(); +} +function log(tag: string, msg: string, ctx?: any) { + console.log(`${now()} [${tag}] ${msg}`, ctx ?? ""); +} + +function emitSafe(socketId: string | undefined, event: string, payload: any) { + if (!socketId) { + log("socket", "no socketId for emit", { event }); + return; + } + try { + const socket = io?.sockets.sockets.get(socketId); + if (!socket) { + log("socket", "socket not found (maybe disconnected)", { + socketId, + event, + }); + return; + } + socket.emit(event, payload); + log("socket", "emitted", { socketId, event }); + } catch (err: any) { + log("socket", "emit failed", { socketId, event, err: err?.message }); + } +} + +/** + * Polls Python agent for session status and emits socket events: + * - 'selenium:otp_required' when waiting_for_otp + * - 'selenium:session_update' when completed/error + * - rabsolute timeout + transient error handling. + * - pollTimeoutMs default = 2 minutes (adjust where invoked) + */ +async function pollAgentSessionAndProcess( + sessionId: string, + socketId?: string, + pollTimeoutMs = 2 * 60 * 1000 +) { + const maxAttempts = 300; + const baseDelayMs = 1000; + const maxTransientErrors = 12; + + // NEW: give up if same non-terminal status repeats this many times + const noProgressLimit = 100; + + const job = ddmaJobs[sessionId]; + let transientErrorCount = 0; + let consecutiveNoProgress = 0; + let lastStatus: string | null = null; + const deadline = Date.now() + pollTimeoutMs; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + // absolute deadline check + if (Date.now() > deadline) { + emitSafe(socketId, "selenium:session_update", { + session_id: sessionId, + status: "error", + message: `Polling timeout reached (${Math.round(pollTimeoutMs / 1000)}s).`, + }); + delete ddmaJobs[sessionId]; + return; + } + + log( + "poller", + `attempt=${attempt} session=${sessionId} transientErrCount=${transientErrorCount}` + ); + + try { + const st = await getSeleniumDdmaSessionStatus(sessionId); + const status = st?.status ?? null; + log("poller", "got status", { + sessionId, + status, + message: st?.message, + resultKeys: st?.result ? Object.keys(st.result) : null, + }); + + // reset transient errors on success + transientErrorCount = 0; + + // if status unchanged and non-terminal, increment no-progress counter + const isTerminalLike = + status === "completed" || status === "error" || status === "not_found"; + if (status === lastStatus && !isTerminalLike) { + consecutiveNoProgress++; + } else { + consecutiveNoProgress = 0; + } + lastStatus = status; + + // if no progress for too many consecutive polls -> abort + if (consecutiveNoProgress >= noProgressLimit) { + emitSafe(socketId, "selenium:session_update", { + session_id: sessionId, + status: "error", + message: `No progress from selenium agent (status="${status}") after ${consecutiveNoProgress} polls; aborting.`, + }); + emitSafe(socketId, "selenium:session_error", { + session_id: sessionId, + status: "error", + message: "No progress from selenium agent", + }); + delete ddmaJobs[sessionId]; + return; + } + + // always emit debug to client if socket exists + emitSafe(socketId, "selenium:debug", { + session_id: sessionId, + attempt, + status, + serverTime: new Date().toISOString(), + }); + + // If agent is waiting for OTP, inform client but keep polling (do not return) + if (status === "waiting_for_otp") { + emitSafe(socketId, "selenium:otp_required", { + session_id: sessionId, + message: "OTP required. Please enter the OTP.", + }); + // do not return β€” keep polling (allows same poller to pick up completion) + await new Promise((r) => setTimeout(r, baseDelayMs)); + continue; + } + + // Completed path + if (status === "completed") { + log("poller", "agent completed; processing result", { + sessionId, + resultKeys: st.result ? Object.keys(st.result) : null, + }); + + // Persist raw result so frontend can fetch if socket disconnects + currentFinalSessionId = sessionId; + currentFinalResult = { + rawSelenium: st.result, + processedAt: null, + final: null, + }; + + let finalResult: any = null; + if (job && st.result) { + try { + finalResult = await handleDdmaCompletedJob( + sessionId, + job, + st.result + ); + currentFinalResult.final = finalResult; + currentFinalResult.processedAt = Date.now(); + } catch (err: any) { + currentFinalResult.final = { + error: "processing_failed", + detail: err?.message ?? String(err), + }; + currentFinalResult.processedAt = Date.now(); + log("poller", "handleDdmaCompletedJob failed", { + sessionId, + err: err?.message ?? err, + }); + } + } else { + currentFinalResult[sessionId].final = { + error: "no_job_or_no_result", + }; + currentFinalResult[sessionId].processedAt = Date.now(); + } + + // Emit final update (if socket present) + emitSafe(socketId, "selenium:session_update", { + session_id: sessionId, + status: "completed", + rawSelenium: st.result, + final: currentFinalResult.final, + }); + + // cleanup job context + delete ddmaJobs[sessionId]; + return; + } + + // Terminal error / not_found + if (status === "error" || status === "not_found") { + const emitPayload = { + session_id: sessionId, + status, + message: st?.message || "Selenium session error", + }; + emitSafe(socketId, "selenium:session_update", emitPayload); + emitSafe(socketId, "selenium:session_error", emitPayload); + delete ddmaJobs[sessionId]; + return; + } + } catch (err: any) { + const axiosStatus = + err?.response?.status ?? (err?.status ? Number(err.status) : undefined); + const errCode = err?.code ?? err?.errno; + const errMsg = err?.message ?? String(err); + const errData = err?.response?.data ?? null; + + // If agent explicitly returned 404 -> terminal (session gone) + if ( + axiosStatus === 404 || + (typeof errMsg === "string" && errMsg.includes("not_found")) + ) { + console.warn( + `${new Date().toISOString()} [poller] terminal 404/not_found for ${sessionId}: data=${JSON.stringify(errData)}` + ); + + // Emit not_found to client + const emitPayload = { + session_id: sessionId, + status: "not_found", + message: + errData?.detail || "Selenium session not found (agent cleaned up).", + }; + emitSafe(socketId, "selenium:session_update", emitPayload); + emitSafe(socketId, "selenium:session_error", emitPayload); + + // Remove job context and stop polling + delete ddmaJobs[sessionId]; + return; + } + + // Detailed transient error logging + transientErrorCount++; + if (transientErrorCount > maxTransientErrors) { + const emitPayload = { + session_id: sessionId, + status: "error", + message: + "Repeated network errors while polling selenium agent; giving up.", + }; + emitSafe(socketId, "selenium:session_update", emitPayload); + emitSafe(socketId, "selenium:session_error", emitPayload); + delete ddmaJobs[sessionId]; + return; + } + + const backoffMs = Math.min( + 30_000, + baseDelayMs * Math.pow(2, transientErrorCount - 1) + ); + console.warn( + `${new Date().toISOString()} [poller] transient error (#${transientErrorCount}) for ${sessionId}: code=${errCode} status=${axiosStatus} msg=${errMsg} data=${JSON.stringify(errData)}` + ); + console.warn( + `${new Date().toISOString()} [poller] backing off ${backoffMs}ms before next attempt` + ); + + await new Promise((r) => setTimeout(r, backoffMs)); + continue; + } + + // normal poll interval + await new Promise((r) => setTimeout(r, baseDelayMs)); + } + + // overall timeout fallback + emitSafe(socketId, "selenium:session_update", { + session_id: sessionId, + status: "error", + message: "Polling timeout while waiting for selenium session", + }); + delete ddmaJobs[sessionId]; +} + +/** + * POST /ddma-eligibility + * Starts DDMA eligibility Selenium job. + * Expects: + * - req.body.data: stringified JSON like your existing /eligibility-check + * - req.body.socketId: socket.io client id + */ +router.post( + "/ddma-eligibility", + async (req: Request, res: Response): Promise => { + if (!req.body.data) { + return res + .status(400) + .json({ error: "Missing Insurance Eligibility data for selenium" }); + } + + if (!req.user || !req.user.id) { + return res.status(401).json({ error: "Unauthorized: user info missing" }); + } + + try { + const rawData = + typeof req.body.data === "string" + ? JSON.parse(req.body.data) + : req.body.data; + + const credentials = await storage.getInsuranceCredentialByUserAndSiteKey( + req.user.id, + rawData.insuranceSiteKey + ); + if (!credentials) { + return res.status(404).json({ + error: + "No insurance credentials found for this provider, Kindly Update this at Settings Page.", + }); + } + + const enrichedData = { + ...rawData, + massddmaUsername: credentials.username, + massddmaPassword: credentials.password, + }; + + const socketId: string | undefined = req.body.socketId; + + const agentResp = + await forwardToSeleniumDdmaEligibilityAgent(enrichedData); + + if ( + !agentResp || + agentResp.status !== "started" || + !agentResp.session_id + ) { + return res.status(502).json({ + error: "Selenium agent did not return a started session", + detail: agentResp, + }); + } + + const sessionId = agentResp.session_id as string; + + // Save job context + ddmaJobs[sessionId] = { + userId: req.user.id, + insuranceEligibilityData: enrichedData, + socketId, + }; + + // start polling in background to notify client via socket and process job + pollAgentSessionAndProcess(sessionId, socketId).catch((e) => + console.warn("pollAgentSessionAndProcess failed", e) + ); + + // reply immediately with started status + return res.json({ status: "started", session_id: sessionId }); + } catch (err: any) { + console.error(err); + return res.status(500).json({ + error: err.message || "Failed to start ddma selenium agent", + }); + } + } +); + +/** + * POST /selenium/submit-otp + * Body: { session_id, otp, socketId? } + * Forwards OTP to Python agent and optionally notifies client socket. + */ +router.post( + "/selenium/submit-otp", + async (req: Request, res: Response): Promise => { + const { session_id: sessionId, otp, socketId } = req.body; + if (!sessionId || !otp) { + return res.status(400).json({ error: "session_id and otp are required" }); + } + + try { + const r = await forwardOtpToSeleniumDdmaAgent(sessionId, otp); + + // emit OTP accepted (if socket present) + emitSafe(socketId, "selenium:otp_submitted", { + session_id: sessionId, + result: r, + }); + + return res.json(r); + } catch (err: any) { + console.error( + "Failed to forward OTP:", + err?.response?.data || err?.message || err + ); + return res.status(500).json({ + error: "Failed to forward otp to selenium agent", + detail: err?.message || err, + }); + } + } +); + +// GET /selenium/session/:sid/final +router.get( + "/selenium/session/:sid/final", + async (req: Request, res: Response) => { + const sid = req.params.sid; + if (!sid) return res.status(400).json({ error: "session id required" }); + + // Only the current in-memory result is available + if (currentFinalSessionId !== sid || !currentFinalResult) { + return res.status(404).json({ error: "final result not found" }); + } + + return res.json(currentFinalResult); + } +); + +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/services/seleniumDdmaInsuranceEligibilityClient.ts b/apps/Backend/src/services/seleniumDdmaInsuranceEligibilityClient.ts new file mode 100644 index 0000000..5211237 --- /dev/null +++ b/apps/Backend/src/services/seleniumDdmaInsuranceEligibilityClient.ts @@ -0,0 +1,122 @@ +import axios from "axios"; +import http from "http"; +import https from "https"; +import dotenv from "dotenv"; +dotenv.config(); + +export interface SeleniumPayload { + data: any; + url?: string; +} + +const SELENIUM_AGENT_BASE = process.env.SELENIUM_AGENT_BASE_URL; + +const httpAgent = new http.Agent({ keepAlive: true, keepAliveMsecs: 60_000 }); +const httpsAgent = new https.Agent({ keepAlive: true, keepAliveMsecs: 60_000 }); + +const client = axios.create({ + baseURL: SELENIUM_AGENT_BASE, + timeout: 5 * 60 * 1000, + httpAgent, + httpsAgent, + validateStatus: (s) => s >= 200 && s < 600, +}); + +async function requestWithRetries( + config: any, + retries = 4, + baseBackoffMs = 300 +) { + for (let attempt = 1; attempt <= retries; attempt++) { + try { + const r = await client.request(config); + if (![502, 503, 504].includes(r.status)) return r; + console.warn( + `[selenium-client] retryable HTTP status ${r.status} (attempt ${attempt})` + ); + } catch (err: any) { + const code = err?.code; + const isTransient = + code === "ECONNRESET" || + code === "ECONNREFUSED" || + code === "EPIPE" || + code === "ETIMEDOUT"; + if (!isTransient) throw err; + console.warn( + `[selenium-client] transient network error ${code} (attempt ${attempt})` + ); + } + await new Promise((r) => setTimeout(r, baseBackoffMs * attempt)); + } + // final attempt (let exception bubble if it fails) + return client.request(config); +} + +function now() { + return new Date().toISOString(); +} +function log(tag: string, msg: string, ctx?: any) { + console.log(`${now()} [${tag}] ${msg}`, ctx ?? ""); +} + +export async function forwardToSeleniumDdmaEligibilityAgent( + insuranceEligibilityData: any +): Promise { + const payload = { data: insuranceEligibilityData }; + const url = `/ddma-eligibility`; + log("selenium-client", "POST ddma-eligibility", { + url: SELENIUM_AGENT_BASE + url, + keys: Object.keys(payload), + }); + const r = await requestWithRetries({ url, method: "POST", data: payload }, 4); + log("selenium-client", "agent response", { + status: r.status, + dataKeys: r.data ? Object.keys(r.data) : null, + }); + if (r.status >= 500) + throw new Error(`Selenium agent server error: ${r.status}`); + return r.data; +} + +export async function forwardOtpToSeleniumDdmaAgent( + sessionId: string, + otp: string +): Promise { + const url = `/submit-otp`; + log("selenium-client", "POST submit-otp", { + url: SELENIUM_AGENT_BASE + url, + sessionId, + }); + const r = await requestWithRetries( + { url, method: "POST", data: { session_id: sessionId, otp } }, + 4 + ); + log("selenium-client", "submit-otp response", { + status: r.status, + data: r.data, + }); + if (r.status >= 500) + throw new Error(`Selenium agent server error on submit-otp: ${r.status}`); + return r.data; +} + +export async function getSeleniumDdmaSessionStatus( + sessionId: string +): Promise { + const url = `/session/${sessionId}/status`; + log("selenium-client", "GET session status", { + url: SELENIUM_AGENT_BASE + url, + sessionId, + }); + const r = await requestWithRetries({ url, method: "GET" }, 4); + log("selenium-client", "session status response", { + status: r.status, + dataKeys: r.data ? Object.keys(r.data) : null, + }); + if (r.status === 404) { + const e: any = new Error("not_found"); + e.response = { status: 404, data: r.data }; + throw e; + } + return r.data; +} diff --git a/apps/Backend/src/socket.ts b/apps/Backend/src/socket.ts new file mode 100644 index 0000000..b5e764f --- /dev/null +++ b/apps/Backend/src/socket.ts @@ -0,0 +1,53 @@ +import { Server as HttpServer } from "http"; +import { Server, Socket } from "socket.io"; + +let io: Server | null = null; + +export function initSocket(server: HttpServer) { + const NODE_ENV = ( + process.env.NODE_ENV || + process.env.ENV || + "development" + ).toLowerCase(); + + const rawFrontendUrls = + process.env.FRONTEND_URLS || process.env.FRONTEND_URL || ""; + const FRONTEND_URLS = rawFrontendUrls + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + + // In dev: allow all origins + // In prod: restrict to FRONTEND_URLS if provided + const corsOrigin = + NODE_ENV !== "production" + ? true + : FRONTEND_URLS.length > 0 + ? FRONTEND_URLS + : false; // no origins allowed if none configured in prod + + io = new Server(server, { + cors: { + origin: corsOrigin, + methods: ["GET", "POST"], + credentials: true, + }, + }); + + io.on("connection", (socket: Socket) => { + console.log("πŸ”Œ Socket connected:", socket.id); + + socket.on("disconnect", () => { + console.log("πŸ”Œ Socket disconnected:", socket.id); + }); + }); + + // Optional: log low-level engine errors for debugging + io.engine.on("connection_error", (err) => { + console.error("Socket engine connection_error:", err); + }); + + return io; +} + +export { io }; 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/package.json b/apps/Frontend/package.json index 7baca06..026861a 100644 --- a/apps/Frontend/package.json +++ b/apps/Frontend/package.json @@ -76,6 +76,7 @@ "react-icons": "^5.4.0", "react-resizable-panels": "^2.1.7", "recharts": "^2.15.2", + "socket.io-client": "^4.8.1", "tailwind-merge": "^3.2.0", "tailwindcss": "^3.4.17", "tailwindcss-animate": "^1.0.7", diff --git a/apps/Frontend/src/App.tsx b/apps/Frontend/src/App.tsx index 1d1a49f..bea2914 100644 --- a/apps/Frontend/src/App.tsx +++ b/apps/Frontend/src/App.tsx @@ -12,6 +12,7 @@ import Dashboard from "./pages/dashboard"; import LoadingScreen from "./components/ui/LoadingScreen"; const AuthPage = lazy(() => import("./pages/auth-page")); +const PatientConnectionPage = lazy(() => import("./pages/patient-connection-page")); const AppointmentsPage = lazy(() => import("./pages/appointments-page")); const PatientsPage = lazy(() => import("./pages/patients-page")); const SettingsPage = lazy(() => import("./pages/settings-page")); @@ -34,6 +35,7 @@ function Router() { } /> } /> + } /> } diff --git a/apps/Frontend/src/components/claims/claim-form.tsx b/apps/Frontend/src/components/claims/claim-form.tsx index 917b5e6..9c05de3 100644 --- a/apps/Frontend/src/components/claims/claim-form.tsx +++ b/apps/Frontend/src/components/claims/claim-form.tsx @@ -964,6 +964,9 @@ export function ClaimForm({ "childRecallDirect4BW", "childRecallDirect2PA2BW", "childRecallDirect2PA4BW", + "childRecallDirect3PA2BW", + "childRecallDirect3PA", + "childRecallDirect4PA", "childRecallDirectPANO", ].map((comboId) => { const b = PROCEDURE_COMBOS[comboId]; @@ -979,6 +982,9 @@ export function ClaimForm({ childRecallDirect4BW: "Direct 4BW", childRecallDirect2PA2BW: "Direct 2PA 2BW", childRecallDirect2PA4BW: "Direct 2PA 4BW", + childRecallDirect3PA2BW: "Direct 3PA 2BW", + childRecallDirect3PA: "Direct 3PA", + childRecallDirect4PA: "Direct 4PA", childRecallDirectPANO: "Direct Pano", }; return ( @@ -1015,6 +1021,7 @@ export function ClaimForm({ "adultRecallDirect4BW", "adultRecallDirect2PA2BW", "adultRecallDirect2PA4BW", + "adultRecallDirect4PA", "adultRecallDirectPano", ].map((comboId) => { const b = PROCEDURE_COMBOS[comboId]; @@ -1030,6 +1037,7 @@ export function ClaimForm({ adultRecallDirect4BW: "Direct 4BW", adultRecallDirect2PA2BW: "Direct 2PA 2BW", adultRecallDirect2PA4BW: "Direct 2PA 4BW", + adultRecallDirect4PA: "Direct 4PA", adultRecallDirectPano: "Direct Pano", }; return ( @@ -1053,6 +1061,45 @@ export function ClaimForm({ })} + + {/* ORTH GROUP */} +
+
Orth
+ +
+ {[ + "orthPreExamDirect", + "orthRecordDirect", + "orthPerioVisitDirect", + "orthRetentionDirect", + ].map((comboId) => { + const b = PROCEDURE_COMBOS[comboId]; + if (!b) return null; + + const tooltipText = b.codes.join(", "); + + return ( + + + + + + +
+ {tooltipText} +
+
+
+ ); + })} +
+
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/components/insurance-status/ddma-buton-modal.tsx b/apps/Frontend/src/components/insurance-status/ddma-buton-modal.tsx new file mode 100644 index 0000000..061f67d --- /dev/null +++ b/apps/Frontend/src/components/insurance-status/ddma-buton-modal.tsx @@ -0,0 +1,566 @@ +import { useEffect, useRef, useState } from "react"; +import { io as ioClient, Socket } from "socket.io-client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { CheckCircle, LoaderCircleIcon, X } from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; +import { apiRequest, queryClient } from "@/lib/queryClient"; +import { useAppDispatch } from "@/redux/hooks"; +import { setTaskStatus } from "@/redux/slices/seleniumEligibilityCheckTaskSlice"; +import { formatLocalDate } from "@/utils/dateUtils"; +import { QK_PATIENTS_BASE } from "@/components/patients/patient-table"; + +const SOCKET_URL = + import.meta.env.VITE_API_BASE_URL_BACKEND || + (typeof window !== "undefined" ? window.location.origin : ""); + +// ---------- OTP Modal component ---------- +interface DdmaOtpModalProps { + open: boolean; + onClose: () => void; + onSubmit: (otp: string) => Promise | void; + isSubmitting: boolean; +} + +function DdmaOtpModal({ + open, + onClose, + onSubmit, + isSubmitting, +}: DdmaOtpModalProps) { + const [otp, setOtp] = useState(""); + + useEffect(() => { + if (!open) setOtp(""); + }, [open]); + + if (!open) return null; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!otp.trim()) return; + await onSubmit(otp.trim()); + }; + + return ( +
+
+
+

Enter OTP

+ +
+

+ We need the one-time password (OTP) sent by the Delta Dental MA portal + to complete this eligibility check. +

+
+
+ + setOtp(e.target.value)} + autoFocus + /> +
+
+ + +
+
+
+
+ ); +} + +// ---------- Main DDMA Eligibility button component ---------- +interface DdmaEligibilityButtonProps { + memberId: string; + dateOfBirth: Date | null; + firstName?: string; + lastName?: string; + isFormIncomplete: boolean; + /** Called when backend has finished and PDF is ready */ + onPdfReady: (pdfId: number, fallbackFilename: string | null) => void; +} + +export function DdmaEligibilityButton({ + memberId, + dateOfBirth, + firstName, + lastName, + isFormIncomplete, + onPdfReady, +}: DdmaEligibilityButtonProps) { + const { toast } = useToast(); + const dispatch = useAppDispatch(); + + const socketRef = useRef(null); + const connectingRef = useRef | null>(null); + + const [sessionId, setSessionId] = useState(null); + const [otpModalOpen, setOtpModalOpen] = useState(false); + const [isStarting, setIsStarting] = useState(false); + const [isSubmittingOtp, setIsSubmittingOtp] = useState(false); + + // Clean up socket on unmount + useEffect(() => { + return () => { + if (socketRef.current) { + socketRef.current.removeAllListeners(); + socketRef.current.disconnect(); + socketRef.current = null; + } + connectingRef.current = null; + }; + }, []); + + const closeSocket = () => { + try { + socketRef.current?.removeAllListeners(); + socketRef.current?.disconnect(); + } catch (e) { + // ignore + } finally { + socketRef.current = null; + } + }; + + // Lazy socket setup: called only when we actually need it (first click) + const ensureSocketConnected = async () => { + // If already connected, nothing to do + if (socketRef.current && socketRef.current.connected) { + return; + } + + // If a connection is in progress, reuse that promise + if (connectingRef.current) { + return connectingRef.current; + } + + const promise = new Promise((resolve, reject) => { + const socket = ioClient(SOCKET_URL, { + withCredentials: true, + }); + + socketRef.current = socket; + + socket.on("connect", () => { + console.log("DDMA socket connected:", socket.id); + resolve(); + }); + + // connection error when first connecting (or later) + socket.on("connect_error", (err: any) => { + dispatch( + setTaskStatus({ + status: "error", + message: "Connection failed", + }) + ); + toast({ + title: "Realtime connection failed", + description: + "Could not connect to realtime server. Retrying automatically...", + variant: "destructive", + }); + // do not reject here because socket.io will attempt reconnection + }); + + // socket.io will emit 'reconnect_attempt' for retries + socket.on("reconnect_attempt", (attempt: number) => { + dispatch( + setTaskStatus({ + status: "pending", + message: `Realtime reconnect attempt #${attempt}`, + }) + ); + }); + + // when reconnection failed after configured attempts + socket.on("reconnect_failed", () => { + dispatch( + setTaskStatus({ + status: "error", + message: "Reconnect failed", + }) + ); + toast({ + title: "Realtime reconnect failed", + description: + "Connection to realtime server could not be re-established. Please try again later.", + variant: "destructive", + }); + // terminal failure β€” cleanup and reject so caller can stop start flow + closeSocket(); + reject(new Error("Realtime reconnect failed")); + }); + + socket.on("disconnect", (reason: any) => { + dispatch( + setTaskStatus({ + status: "error", + message: "Connection disconnected", + }) + ); + toast({ + title: "Connection Disconnected", + description: + "Connection to the server was lost. If a DDMA job was running it may have failed.", + variant: "destructive", + }); + // clear sessionId/OTP modal + setSessionId(null); + setOtpModalOpen(false); + }); + + // OTP required + socket.on("selenium:otp_required", (payload: any) => { + if (!payload?.session_id) return; + setSessionId(payload.session_id); + setOtpModalOpen(true); + dispatch( + setTaskStatus({ + status: "pending", + message: "OTP required for DDMA eligibility. Please enter the OTP.", + }) + ); + }); + + // OTP submitted (optional UX) + socket.on("selenium:otp_submitted", (payload: any) => { + if (!payload?.session_id) return; + dispatch( + setTaskStatus({ + status: "pending", + message: "OTP submitted. Finishing DDMA eligibility check...", + }) + ); + }); + + // Session update + socket.on("selenium:session_update", (payload: any) => { + const { session_id, status, final } = payload || {}; + if (!session_id) return; + + if (status === "completed") { + dispatch( + setTaskStatus({ + status: "success", + message: + "DDMA eligibility updated and PDF attached to patient documents.", + }) + ); + toast({ + title: "DDMA eligibility complete", + description: + "Patient status was updated and the eligibility PDF was saved.", + variant: "default", + }); + + const pdfId = final?.pdfFileId; + if (pdfId) { + const filename = + final?.pdfFilename ?? `eligibility_ddma_${memberId}.pdf`; + onPdfReady(Number(pdfId), filename); + } + + setSessionId(null); + setOtpModalOpen(false); + } else if (status === "error") { + const msg = + payload?.message || + final?.error || + "DDMA eligibility session failed."; + dispatch( + setTaskStatus({ + status: "error", + message: msg, + }) + ); + toast({ + title: "DDMA selenium error", + description: msg, + variant: "destructive", + }); + + // Ensure socket is torn down for this session (stop receiving stale events) + try { + closeSocket(); + } catch (e) {} + setSessionId(null); + setOtpModalOpen(false); + } + + queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE }); + }); + + // explicit session error event (helpful) + socket.on("selenium:session_error", (payload: any) => { + const msg = payload?.message || "Selenium session error"; + + dispatch( + setTaskStatus({ + status: "error", + message: msg, + }) + ); + + toast({ + title: "Selenium session error", + description: msg, + variant: "destructive", + }); + + // tear down socket to avoid stale updates + try { + closeSocket(); + } catch (e) {} + setSessionId(null); + setOtpModalOpen(false); + }); + + // If socket.io initial connection fails permanently (very rare: client-level) + // set a longer timeout to reject the first attempt to connect. + const initialConnectTimeout = setTimeout(() => { + if (!socket.connected) { + // if still not connected after 8s, treat as failure and reject so caller can handle it + closeSocket(); + reject(new Error("Realtime initial connection timeout")); + } + }, 8000); + + // When the connect resolves we should clear this timer + socket.once("connect", () => { + clearTimeout(initialConnectTimeout); + }); + }); + + // store promise to prevent multiple concurrent connections + connectingRef.current = promise; + + try { + await promise; + } finally { + connectingRef.current = null; + } + }; + + const startDdmaEligibility = async () => { + if (!memberId || !dateOfBirth) { + toast({ + title: "Missing fields", + description: "Member ID and Date of Birth are required.", + variant: "destructive", + }); + return; + } + + const formattedDob = dateOfBirth ? formatLocalDate(dateOfBirth) : ""; + + const payload = { + memberId, + dateOfBirth: formattedDob, + firstName, + lastName, + insuranceSiteKey: "DDMA", // make sure this matches backend credential key + }; + + try { + setIsStarting(true); + + // 1) Ensure socket is connected (lazy) + dispatch( + setTaskStatus({ + status: "pending", + message: "Opening realtime channel for DDMA eligibility...", + }) + ); + await ensureSocketConnected(); + + const socket = socketRef.current; + if (!socket || !socket.connected) { + throw new Error("Socket connection failed"); + } + + const socketId = socket.id; + + // 2) Start the selenium job via backend + dispatch( + setTaskStatus({ + status: "pending", + message: "Starting DDMA eligibility check via selenium...", + }) + ); + + const response = await apiRequest( + "POST", + "/api/insurance-status-ddma/ddma-eligibility", + { + data: JSON.stringify(payload), + socketId, + } + ); + + // If apiRequest threw, we would have caught above; but just in case it returns. + let result: any = null; + let backendError: string | null = null; + + try { + // attempt JSON first + result = await response.clone().json(); + backendError = + result?.error || result?.message || result?.detail || null; + } catch { + // fallback to text response + try { + const text = await response.clone().text(); + backendError = text?.trim() || null; + } catch { + backendError = null; + } + } + + if (!response.ok) { + throw new Error( + backendError || + `DDMA selenium start failed (status ${response.status})` + ); + } + + // Normal success path: optional: if backend returns non-error shape still check for result.error + if (result?.error) { + throw new Error(result.error); + } + + if (result.status === "started" && result.session_id) { + setSessionId(result.session_id as string); + dispatch( + setTaskStatus({ + status: "pending", + message: + "DDMA eligibility job started. Waiting for OTP or final result...", + }) + ); + } else { + // fallback if backend returns immediate result + dispatch( + setTaskStatus({ + status: "success", + message: "DDMA eligibility completed.", + }) + ); + } + } catch (err: any) { + console.error("startDdmaEligibility error:", err); + dispatch( + setTaskStatus({ + status: "error", + message: err?.message || "Failed to start DDMA eligibility", + }) + ); + toast({ + title: "DDMA selenium error", + description: err?.message || "Failed to start DDMA eligibility", + variant: "destructive", + }); + } finally { + setIsStarting(false); + } + }; + + const handleSubmitOtp = async (otp: string) => { + if (!sessionId || !socketRef.current || !socketRef.current.connected) { + toast({ + title: "Session not ready", + description: + "Could not submit OTP because the DDMA session or socket is not ready.", + variant: "destructive", + }); + return; + } + + try { + setIsSubmittingOtp(true); + const resp = await apiRequest( + "POST", + "/api/insurance-status-ddma/selenium/submit-otp", + { + session_id: sessionId, + otp, + socketId: socketRef.current.id, + } + ); + const data = await resp.json(); + if (!resp.ok || data.error) { + throw new Error(data.error || "Failed to submit OTP"); + } + + // from here we rely on websocket events (otp_submitted + session_update) + setOtpModalOpen(false); + } catch (err: any) { + console.error("handleSubmitOtp error:", err); + toast({ + title: "Failed to submit OTP", + description: err?.message || "Error forwarding OTP to selenium agent", + variant: "destructive", + }); + } finally { + setIsSubmittingOtp(false); + } + }; + + return ( + <> + + + setOtpModalOpen(false)} + onSubmit={handleSubmitOtp} + isSubmitting={isSubmittingOtp} + /> + + ); +} diff --git a/apps/Frontend/src/components/layout/sidebar.tsx b/apps/Frontend/src/components/layout/sidebar.tsx index c526526..9f5b673 100644 --- a/apps/Frontend/src/components/layout/sidebar.tsx +++ b/apps/Frontend/src/components/layout/sidebar.tsx @@ -11,6 +11,7 @@ import { Database, FileText, Cloud, + Phone, } from "lucide-react"; import { cn } from "@/lib/utils"; import { useMemo } from "react"; @@ -27,6 +28,11 @@ export function Sidebar() { path: "/dashboard", icon: , }, + { + name: "Patient Connection", + path: "/patient-connection", + icon: , + }, { name: "Appointments", path: "/appointments", diff --git a/apps/Frontend/src/components/patient-connection/message-thread.tsx b/apps/Frontend/src/components/patient-connection/message-thread.tsx new file mode 100644 index 0000000..3d48ba4 --- /dev/null +++ b/apps/Frontend/src/components/patient-connection/message-thread.tsx @@ -0,0 +1,251 @@ +import { useState, useEffect, useRef } from "react"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useToast } from "@/hooks/use-toast"; +import { apiRequest, queryClient } from "@/lib/queryClient"; +import { Send, ArrowLeft } from "lucide-react"; +import type { Patient, Communication } from "@repo/db/types"; +import { format, isToday, isYesterday, parseISO } from "date-fns"; + +interface MessageThreadProps { + patient: Patient; + onBack?: () => void; +} + +export function MessageThread({ patient, onBack }: MessageThreadProps) { + const { toast } = useToast(); + const [messageText, setMessageText] = useState(""); + const messagesEndRef = useRef(null); + + const { data: communications = [], isLoading } = useQuery({ + queryKey: ["/api/patients", patient.id, "communications"], + queryFn: async () => { + const res = await fetch(`/api/patients/${patient.id}/communications`, { + credentials: "include", + }); + if (!res.ok) throw new Error("Failed to fetch communications"); + return res.json(); + }, + refetchInterval: 5000, // Refresh every 5 seconds to get new messages + }); + + const sendMessageMutation = useMutation({ + mutationFn: async (message: string) => { + return apiRequest("POST", "/api/twilio/send-sms", { + to: patient.phone, + message: message, + patientId: patient.id, + }); + }, + onSuccess: () => { + setMessageText(""); + queryClient.invalidateQueries({ + queryKey: ["/api/patients", patient.id, "communications"], + }); + toast({ + title: "Message sent", + description: "Your message has been sent successfully.", + }); + }, + onError: (error: any) => { + toast({ + title: "Failed to send message", + description: + error.message || "Unable to send message. Please try again.", + variant: "destructive", + }); + }, + }); + + const handleSendMessage = () => { + if (!messageText.trim()) return; + sendMessageMutation.mutate(messageText); + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSendMessage(); + } + }; + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [communications]); + + const formatMessageDate = (dateValue: string | Date) => { + const date = + typeof dateValue === "string" ? parseISO(dateValue) : dateValue; + if (isToday(date)) { + return format(date, "h:mm a"); + } else if (isYesterday(date)) { + return `Yesterday ${format(date, "h:mm a")}`; + } else { + return format(date, "MMM d, h:mm a"); + } + }; + + const getDateDivider = (dateValue: string | Date) => { + const messageDate = + typeof dateValue === "string" ? parseISO(dateValue) : dateValue; + if (isToday(messageDate)) { + return "Today"; + } else if (isYesterday(messageDate)) { + return "Yesterday"; + } else { + return format(messageDate, "MMMM d, yyyy"); + } + }; + + const groupedMessages: { date: string; messages: Communication[] }[] = []; + communications.forEach((comm) => { + if (!comm.createdAt) return; + const messageDate = + typeof comm.createdAt === "string" + ? parseISO(comm.createdAt) + : comm.createdAt; + const dateKey = format(messageDate, "yyyy-MM-dd"); + const existingGroup = groupedMessages.find((g) => g.date === dateKey); + if (existingGroup) { + existingGroup.messages.push(comm); + } else { + groupedMessages.push({ date: dateKey, messages: [comm] }); + } + }); + + return ( +
+ {/* Header */} +
+
+ {onBack && ( + + )} +
+
+ {patient.firstName[0]} + {patient.lastName[0]} +
+
+

+ {patient.firstName} {patient.lastName} +

+

{patient.phone}

+
+
+
+
+ + {/* Messages */} +
+ {isLoading ? ( +
+

Loading messages...

+
+ ) : communications.length === 0 ? ( +
+

+ No messages yet. Start the conversation! +

+
+ ) : ( + <> + {groupedMessages.map((group) => ( +
+ {/* Date Divider */} +
+
+ {getDateDivider(group.messages[0]?.createdAt!)} +
+
+ + {/* Messages for this date */} + {group.messages.map((comm) => ( +
+
+ {comm.direction === "inbound" && ( +
+
+ {patient.firstName[0]} + {patient.lastName[0]} +
+
+
+

+ {comm.body} +

+
+

+ {comm.createdAt && + formatMessageDate(comm.createdAt)} +

+
+
+ )} + + {comm.direction === "outbound" && ( +
+
+

+ {comm.body} +

+
+

+ {comm.createdAt && + formatMessageDate(comm.createdAt)} +

+
+ )} +
+
+ ))} +
+ ))} +
+ + )} +
+ + {/* Input Area */} +
+
+ setMessageText(e.target.value)} + onKeyPress={handleKeyPress} + placeholder="Type your message..." + className="flex-1 rounded-full" + disabled={sendMessageMutation.isPending} + data-testid="input-message" + /> + +
+
+
+ ); +} diff --git a/apps/Frontend/src/components/patient-connection/sms-template-diaog.tsx b/apps/Frontend/src/components/patient-connection/sms-template-diaog.tsx new file mode 100644 index 0000000..3f74254 --- /dev/null +++ b/apps/Frontend/src/components/patient-connection/sms-template-diaog.tsx @@ -0,0 +1,226 @@ +import { useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { MessageSquare, Send, Loader2 } from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; +import { apiRequest } from "@/lib/queryClient"; +import type { Patient } from "@repo/db/types"; + +interface SmsTemplateDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + patient: Patient | null; +} + +const MESSAGE_TEMPLATES = { + appointment_reminder: { + name: "Appointment Reminder", + template: (firstName: string) => + `Hi ${firstName}, this is your dental office. Reminder: You have an appointment scheduled. Please confirm or call us if you need to reschedule.`, + }, + appointment_confirmation: { + name: "Appointment Confirmation", + template: (firstName: string) => + `Hi ${firstName}, your appointment has been confirmed. We look forward to seeing you! If you have any questions, please call our office.`, + }, + follow_up: { + name: "Follow-Up", + template: (firstName: string) => + `Hi ${firstName}, thank you for visiting our dental office. How are you feeling after your treatment? Please let us know if you have any concerns.`, + }, + payment_reminder: { + name: "Payment Reminder", + template: (firstName: string) => + `Hi ${firstName}, this is a friendly reminder about your outstanding balance. Please contact our office to discuss payment options.`, + }, + general: { + name: "General Message", + template: (firstName: string) => + `Hi ${firstName}, this is your dental office. `, + }, + custom: { + name: "Custom Message", + template: () => "", + }, +}; + +export function SmsTemplateDialog({ + open, + onOpenChange, + patient, +}: SmsTemplateDialogProps) { + const [selectedTemplate, setSelectedTemplate] = useState< + keyof typeof MESSAGE_TEMPLATES + >("appointment_reminder"); + const [customMessage, setCustomMessage] = useState(""); + const { toast } = useToast(); + + const sendSmsMutation = useMutation({ + mutationFn: async ({ + to, + message, + patientId, + }: { + to: string; + message: string; + patientId: number; + }) => { + return apiRequest("POST", "/api/twilio/send-sms", { + to, + message, + patientId, + }); + }, + onSuccess: () => { + toast({ + title: "SMS Sent Successfully", + description: `Message sent to ${patient?.firstName} ${patient?.lastName}`, + }); + onOpenChange(false); + // Reset state + setSelectedTemplate("appointment_reminder"); + setCustomMessage(""); + }, + onError: (error: any) => { + toast({ + title: "Failed to Send SMS", + description: + error.message || + "Please check your Twilio configuration and try again.", + variant: "destructive", + }); + }, + }); + + const getMessage = () => { + if (!patient) return ""; + + if (selectedTemplate === "custom") { + return customMessage; + } + + return MESSAGE_TEMPLATES[selectedTemplate].template(patient.firstName); + }; + + const handleSend = () => { + if (!patient || !patient.phone) return; + + const message = getMessage(); + if (!message.trim()) return; + + sendSmsMutation.mutate({ + to: patient.phone, + message: message, + patientId: Number(patient.id), + }); + }; + + const handleTemplateChange = (value: string) => { + const templateKey = value as keyof typeof MESSAGE_TEMPLATES; + setSelectedTemplate(templateKey); + + // Pre-fill custom message if not custom template + if (templateKey !== "custom" && patient) { + setCustomMessage( + MESSAGE_TEMPLATES[templateKey].template(patient.firstName) + ); + } + }; + + return ( + + + + + + Send SMS to {patient?.firstName} {patient?.lastName} + + + Choose a message template or write a custom message + + + +
+
+ + +
+ +
+ +