diff --git a/.gitignore b/.gitignore index 311c765..8cb82bf 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,5 @@ dist/ # env *.env -*chrome_profile_ddma* \ No newline at end of file +*chrome_profile_ddma* +*chrome_profile_dentaquest* \ No newline at end of file diff --git a/apps/Backend/src/routes/index.ts b/apps/Backend/src/routes/index.ts index 96a45e9..4eb1ad1 100644 --- a/apps/Backend/src/routes/index.ts +++ b/apps/Backend/src/routes/index.ts @@ -9,6 +9,7 @@ import insuranceCredsRoutes from "./insuranceCreds"; import documentsRoutes from "./documents"; import insuranceStatusRoutes from "./insuranceStatus"; import insuranceStatusDdmaRoutes from "./insuranceStatusDDMA"; +import insuranceStatusDentaQuestRoutes from "./insuranceStatusDentaQuest"; import paymentsRoutes from "./payments"; import databaseManagementRoutes from "./database-management"; import notificationsRoutes from "./notifications"; @@ -29,6 +30,7 @@ router.use("/insuranceCreds", insuranceCredsRoutes); router.use("/documents", documentsRoutes); router.use("/insurance-status", insuranceStatusRoutes); router.use("/insurance-status-ddma", insuranceStatusDdmaRoutes); +router.use("/insurance-status-dentaquest", insuranceStatusDentaQuestRoutes); router.use("/payments", paymentsRoutes); router.use("/database-management", databaseManagementRoutes); router.use("/notifications", notificationsRoutes); diff --git a/apps/Backend/src/routes/insuranceStatusDentaQuest.ts b/apps/Backend/src/routes/insuranceStatusDentaQuest.ts new file mode 100644 index 0000000..544565d --- /dev/null +++ b/apps/Backend/src/routes/insuranceStatusDentaQuest.ts @@ -0,0 +1,700 @@ +import { Router, Request, Response } from "express"; +import { storage } from "../storage"; +import { + forwardToSeleniumDentaQuestEligibilityAgent, + forwardOtpToSeleniumDentaQuestAgent, + getSeleniumDentaQuestSessionStatus, +} from "../services/seleniumDentaQuestInsuranceEligibilityClient"; +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 DentaQuestJobContext { + userId: number; + insuranceEligibilityData: any; // parsed, enriched (includes username/password) + socketId?: string; +} + +const dentaquestJobs: 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 handleDentaQuestCompletedJob( + sessionId: string, + job: DentaQuestJobContext, + 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 DentaQuest 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 = `dentaquest_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 DentaQuest 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( + `[dentaquest-eligibility] no pdf_path or ss_path available to cleanup` + ); + } + } catch (cleanupErr) { + console.error( + `[dentaquest-eligibility cleanup failed for ${seleniumResult?.pdf_path ?? seleniumResult?.ss_path}]`, + cleanupErr + ); + } + } +} + +// --- top of file, alongside dentaquestJobs --- +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 + * - absolute 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 = dentaquestJobs[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 dentaquestJobs[sessionId]; + return; + } + + log( + "poller", + `attempt=${attempt} session=${sessionId} transientErrCount=${transientErrorCount}` + ); + + try { + const st = await getSeleniumDentaQuestSessionStatus(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 dentaquestJobs[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 handleDentaQuestCompletedJob( + 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", "handleDentaQuestCompletedJob failed", { + sessionId, + err: err?.message ?? err, + }); + } + } else { + currentFinalResult.final = { + error: "no_job_or_no_result", + }; + currentFinalResult.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 dentaquestJobs[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 dentaquestJobs[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 dentaquestJobs[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 dentaquestJobs[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 dentaquestJobs[sessionId]; +} + +/** + * POST /dentaquest-eligibility + * Starts DentaQuest eligibility Selenium job. + * Expects: + * - req.body.data: stringified JSON like your existing /eligibility-check + * - req.body.socketId: socket.io client id + */ +router.post( + "/dentaquest-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, + dentaquestUsername: credentials.username, + dentaquestPassword: credentials.password, + }; + + const socketId: string | undefined = req.body.socketId; + + const agentResp = + await forwardToSeleniumDentaQuestEligibilityAgent(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 + dentaquestJobs[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 DentaQuest 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 forwardOtpToSeleniumDentaQuestAgent(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/seleniumDentaQuestInsuranceEligibilityClient.ts b/apps/Backend/src/services/seleniumDentaQuestInsuranceEligibilityClient.ts new file mode 100644 index 0000000..89c5fcf --- /dev/null +++ b/apps/Backend/src/services/seleniumDentaQuestInsuranceEligibilityClient.ts @@ -0,0 +1,123 @@ +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-dentaquest-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-dentaquest-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 forwardToSeleniumDentaQuestEligibilityAgent( + insuranceEligibilityData: any +): Promise { + const payload = { data: insuranceEligibilityData }; + const url = `/dentaquest-eligibility`; + log("selenium-dentaquest-client", "POST dentaquest-eligibility", { + url: SELENIUM_AGENT_BASE + url, + keys: Object.keys(payload), + }); + const r = await requestWithRetries({ url, method: "POST", data: payload }, 4); + log("selenium-dentaquest-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 forwardOtpToSeleniumDentaQuestAgent( + sessionId: string, + otp: string +): Promise { + const url = `/dentaquest-submit-otp`; + log("selenium-dentaquest-client", "POST dentaquest-submit-otp", { + url: SELENIUM_AGENT_BASE + url, + sessionId, + }); + const r = await requestWithRetries( + { url, method: "POST", data: { session_id: sessionId, otp } }, + 4 + ); + log("selenium-dentaquest-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 getSeleniumDentaQuestSessionStatus( + sessionId: string +): Promise { + const url = `/dentaquest-session/${sessionId}/status`; + log("selenium-dentaquest-client", "GET session status", { + url: SELENIUM_AGENT_BASE + url, + sessionId, + }); + const r = await requestWithRetries({ url, method: "GET" }, 4); + log("selenium-dentaquest-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/Frontend/src/components/insurance-status/dentaquest-button-modal.tsx b/apps/Frontend/src/components/insurance-status/dentaquest-button-modal.tsx new file mode 100644 index 0000000..a183767 --- /dev/null +++ b/apps/Frontend/src/components/insurance-status/dentaquest-button-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 DentaQuestOtpModalProps { + open: boolean; + onClose: () => void; + onSubmit: (otp: string) => Promise | void; + isSubmitting: boolean; +} + +function DentaQuestOtpModal({ + open, + onClose, + onSubmit, + isSubmitting, +}: DentaQuestOtpModalProps) { + 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 DentaQuest portal + to complete this eligibility check. +

+
+
+ + setOtp(e.target.value)} + autoFocus + /> +
+
+ + +
+
+
+
+ ); +} + +// ---------- Main DentaQuest Eligibility button component ---------- +interface DentaQuestEligibilityButtonProps { + 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 DentaQuestEligibilityButton({ + memberId, + dateOfBirth, + firstName, + lastName, + isFormIncomplete, + onPdfReady, +}: DentaQuestEligibilityButtonProps) { + 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", () => { + 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 DentaQuest 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 DentaQuest 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 DentaQuest 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: + "DentaQuest eligibility updated and PDF attached to patient documents.", + }) + ); + toast({ + title: "DentaQuest 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_dentaquest_${memberId}.pdf`; + onPdfReady(Number(pdfId), filename); + } + + setSessionId(null); + setOtpModalOpen(false); + } else if (status === "error") { + const msg = + payload?.message || + final?.error || + "DentaQuest eligibility session failed."; + dispatch( + setTaskStatus({ + status: "error", + message: msg, + }) + ); + toast({ + title: "DentaQuest 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 startDentaQuestEligibility = 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: "DENTAQUEST", // 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 DentaQuest 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 DentaQuest eligibility check via selenium...", + }) + ); + + const response = await apiRequest( + "POST", + "/api/insurance-status-dentaquest/dentaquest-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 || + `DentaQuest 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: + "DentaQuest eligibility job started. Waiting for OTP or final result...", + }) + ); + } else { + // fallback if backend returns immediate result + dispatch( + setTaskStatus({ + status: "success", + message: "DentaQuest eligibility completed.", + }) + ); + } + } catch (err: any) { + console.error("startDentaQuestEligibility error:", err); + dispatch( + setTaskStatus({ + status: "error", + message: err?.message || "Failed to start DentaQuest eligibility", + }) + ); + toast({ + title: "DentaQuest selenium error", + description: err?.message || "Failed to start DentaQuest 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 DentaQuest session or socket is not ready.", + variant: "destructive", + }); + return; + } + + try { + setIsSubmittingOtp(true); + const resp = await apiRequest( + "POST", + "/api/insurance-status-dentaquest/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/pages/insurance-status-page.tsx b/apps/Frontend/src/pages/insurance-status-page.tsx index 92951ec..549f636 100644 --- a/apps/Frontend/src/pages/insurance-status-page.tsx +++ b/apps/Frontend/src/pages/insurance-status-page.tsx @@ -28,6 +28,7 @@ import { QK_PATIENTS_BASE } from "@/components/patients/patient-table"; import { PdfPreviewModal } from "@/components/insurance-status/pdf-preview-modal"; import { useLocation } from "wouter"; import { DdmaEligibilityButton } from "@/components/insurance-status/ddma-buton-modal"; +import { DentaQuestEligibilityButton } from "@/components/insurance-status/dentaquest-button-modal"; export default function InsuranceStatusPage() { const { user } = useAuth(); @@ -616,14 +617,20 @@ export default function InsuranceStatusPage() { {/* Row 2 */}
- + { + setPreviewPdfId(pdfId); + setPreviewFallbackFilename( + fallbackFilename ?? `eligibility_dentaquest_${memberId}.pdf` + ); + setPreviewOpen(true); + }} + />