From 3f8a5253b78d2febccb109fba75e4d5630d56c98 Mon Sep 17 00:00:00 2001 From: Potenz Date: Wed, 10 Dec 2025 23:32:15 +0530 Subject: [PATCH] feat(ddma eligbility) - v4 scripts --- .../Backend/src/routes/insuranceStatusDDMA.ts | 511 ++++++++++++------ .../seleniumDdmaInsuranceEligibilityClient.ts | 144 +++-- .../insurance-status/ddma-buton-modal.tsx | 138 ++++- .../helpers_ddma_eligibility.py | 76 ++- 4 files changed, 644 insertions(+), 225 deletions(-) diff --git a/apps/Backend/src/routes/insuranceStatusDDMA.ts b/apps/Backend/src/routes/insuranceStatusDDMA.ts index 5a5267e..2623a3f 100644 --- a/apps/Backend/src/routes/insuranceStatusDDMA.ts +++ b/apps/Backend/src/routes/insuranceStatusDDMA.ts @@ -6,16 +6,16 @@ import { 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 forwardToPatientDataExtractorService from "../services/patientDataExtractorService"; import { InsertPatient, insertPatientSchema, } from "../../../../packages/db/types/patient-types"; import { io } from "../socket"; - const router = Router(); /** Job context stored in memory by sessionId */ @@ -35,6 +35,34 @@ function splitName(fullName?: string | null) { 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. */ @@ -85,10 +113,6 @@ async function createOrUpdatePatientByInsuranceId(options: { try { patientData = insertPatientSchema.parse(createPayload); } catch (err) { - console.warn( - "Failed to validate patient payload in ddma insurance flow:", - err - ); const safePayload = { ...createPayload }; delete (safePayload as any).dateOfBirth; patientData = insertPatientSchema.parse(safePayload); @@ -108,125 +132,175 @@ async function handleDdmaCompletedJob( ) { let createdPdfFileId: number | null = null; const outputResult: any = {}; - const extracted: any = {}; const insuranceEligibilityData = job.insuranceEligibilityData; - // 1) Extract name from PDF if available - if ( - seleniumResult?.pdf_path && - typeof seleniumResult.pdf_path === "string" && - seleniumResult.pdf_path.endsWith(".pdf") - ) { - try { - const pdfPath = seleniumResult.pdf_path; - const pdfBuffer = await fs.readFile(pdfPath); - - const extraction = await forwardToPatientDataExtractorService({ - buffer: pdfBuffer, - originalname: path.basename(pdfPath), - mimetype: "application/pdf", - } as any); - - if (extraction.name) { - const parts = splitName(extraction.name); - extracted.firstName = parts.firstName; - extracted.lastName = parts.lastName; - } - } catch (err: any) { - outputResult.extractionError = - err?.message ?? "Patient data extraction failed"; + // We'll wrap the processing in try/catch/finally so cleanup always runs + try { + // 1) ensuring memberid. + const insuranceId = String(insuranceEligibilityData.memberId ?? "").trim(); + if (!insuranceId) { + throw new Error("Missing memberId for ddma job"); } - } - // 2) Create or update patient - const insuranceId = String(insuranceEligibilityData.memberId ?? "").trim(); - if (!insuranceId) { - throw new Error("Missing memberId for ddma job"); - } + // 2) Create or update patient + await createOrUpdatePatientByInsuranceId({ + insuranceId, + dob: insuranceEligibilityData.dateOfBirth, + userId: job.userId, + }); - const preferFirst = extracted.firstName; - const preferLast = extracted.lastName; + // 3) Update patient status + PDF upload + const patient = await storage.getPatientByInsuranceId( + insuranceEligibilityData.memberId + ); - await createOrUpdatePatientByInsuranceId({ - insuranceId, - firstName: preferFirst, - lastName: preferLast, - dob: insuranceEligibilityData.dateOfBirth, - userId: job.userId, - }); + if (patient && patient.id !== undefined) { + const newStatus = + seleniumResult.eligibility === "active" ? "ACTIVE" : "INACTIVE"; + await storage.updatePatient(patient.id, { status: newStatus }); + outputResult.patientUpdateStatus = `Patient status updated to ${newStatus}`; - // 3) Update patient status + PDF upload - const patient = await storage.getPatientByInsuranceId( - insuranceEligibilityData.memberId - ); + // Expect only ss_path (screenshot) + let pdfBuffer: Buffer | null = null; + let generatedPdfPath: string | null = null; - if (patient && patient.id !== undefined) { - const newStatus = - seleniumResult.eligibility === "Y" ? "ACTIVE" : "INACTIVE"; - await storage.updatePatient(patient.id, { status: newStatus }); - outputResult.patientUpdateStatus = `Patient status updated to ${newStatus}`; + 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}` + ); + } - if ( - seleniumResult.pdf_path && - typeof seleniumResult.pdf_path === "string" && - seleniumResult.pdf_path.endsWith(".pdf") - ) { - const pdfBuffer = await fs.readFile(seleniumResult.pdf_path); + pdfBuffer = await imageToPdfBuffer(seleniumResult.ss_path); - const groupTitle = "Eligibility Status"; - const groupTitleKey = "ELIGIBILITY_STATUS"; + const pdfFileName = `ddma_eligibility_${insuranceEligibilityData.memberId}_${Date.now()}.pdf`; + generatedPdfPath = path.join( + path.dirname(seleniumResult.ss_path), + pdfFileName + ); + await fs.writeFile(generatedPdfPath, pdfBuffer); - let group = await storage.findPdfGroupByPatientTitleKey( - patient.id, - groupTitleKey - ); - if (!group) { - group = await storage.createPdfGroup( + // 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, - groupTitle, groupTitleKey ); - } - if (!group?.id) { - throw new Error("PDF group creation failed: missing group ID"); - } + 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(seleniumResult.pdf_path), - pdfBuffer - ); - if (created && typeof created === "object" && "id" in created) { - createdPdfFileId = Number(created.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."; } - outputResult.pdfUploadStatus = `PDF saved to group: ${group.title}`; } else { - outputResult.pdfUploadStatus = - "No valid PDF path provided by Selenium, Couldn't upload pdf to server."; + outputResult.patientUpdateStatus = + "Patient not found or missing ID; no update performed"; } - } else { - outputResult.patientUpdateStatus = - "Patient not found or missing ID; no update performed"; - } - // 4) Cleanup PDF temp folder + 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 --- +const finalResults: Record = {}; + +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 { - if (seleniumResult && seleniumResult.pdf_path) { - await emptyFolderContainingFile(seleniumResult.pdf_path); + const socket = io?.sockets.sockets.get(socketId); + if (!socket) { + log("socket", "socket not found (maybe disconnected)", { + socketId, + event, + }); + return; } - } catch (cleanupErr) { - console.error( - `[ddma-eligibility cleanup failed for ${seleniumResult?.pdf_path}]`, - cleanupErr - ); + socket.emit(event, payload); + log("socket", "emitted", { socketId, event }); + } catch (err: any) { + log("socket", "emit failed", { socketId, event, err: err?.message }); } - - return { - patientUpdateStatus: outputResult.patientUpdateStatus, - pdfUploadStatus: outputResult.pdfUploadStatus, - pdfFileId: createdPdfFileId, - }; } /** @@ -238,29 +312,66 @@ async function pollAgentSessionAndProcess( sessionId: string, socketId?: string ) { - const maxAttempts = 300; // ~5 minutes @ 1s - const delayMs = 1000; + const maxAttempts = 300; // ~5 minutes @ 1s base (adjust if needed) + const baseDelayMs = 1000; + const maxTransientErrors = 12; // tolerate more transient errors const job = ddmaJobs[sessionId]; + let transientErrorCount = 0; for (let attempt = 0; attempt < maxAttempts; attempt++) { + const attemptTs = new Date().toISOString(); + log( + "poller", + `attempt=${attempt} session=${sessionId} transientErrCount=${transientErrorCount}` + ); + try { const st = await getSeleniumDdmaSessionStatus(sessionId); const status = st?.status; + log("poller", "got status", { + sessionId, + status, + message: st?.message, + resultKeys: st?.result ? Object.keys(st.result) : null, + }); + // reset transient errors on success + transientErrorCount = 0; + + // 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") { - if (socketId && io && io.sockets.sockets.get(socketId)) { - io.to(socketId).emit("selenium:otp_required", { - session_id: sessionId, - message: "OTP required. Please enter the OTP.", - }); - } - // once waiting_for_otp, we stop polling here; OTP flow continues separately - return; + 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") { - // run DB + PDF pipeline + 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 + finalResults[sessionId] = { + rawSelenium: st.result, + processedAt: null, + final: null, + }; + let finalResult: any = null; if (job && st.result) { try { @@ -269,53 +380,120 @@ async function pollAgentSessionAndProcess( job, st.result ); + finalResults[sessionId].final = finalResult; + finalResults[sessionId].processedAt = Date.now(); } catch (err: any) { - finalResult = { - error: "Failed to process ddma completed job", + finalResults[sessionId].final = { + error: "processing_failed", detail: err?.message ?? String(err), }; + finalResults[sessionId].processedAt = Date.now(); + log("poller", "handleDdmaCompletedJob failed", { + sessionId, + err: err?.message ?? err, + }); } + } else { + finalResults[sessionId].final = { error: "no_job_or_no_result" }; + finalResults[sessionId].processedAt = Date.now(); } - if (socketId && io && io.sockets.sockets.get(socketId)) { - io.to(socketId).emit("selenium:session_update", { - session_id: sessionId, - status: "completed", - rawSelenium: st.result, - final: finalResult, - }); - } + // Emit final update (if socket present) + emitSafe(socketId, "selenium:session_update", { + session_id: sessionId, + status: "completed", + rawSelenium: st.result, + final: finalResults[sessionId].final, + }); + + // cleanup job context delete ddmaJobs[sessionId]; return; } + // Terminal error / not_found if (status === "error" || status === "not_found") { - if (socketId && io && io.sockets.sockets.get(socketId)) { - io.to(socketId).emit("selenium:session_update", { - session_id: sessionId, - status, - message: st?.message || "Selenium session error", - }); - } + 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) { - // swallow transient errors and keep polling - console.warn("pollAgentSessionAndProcess error", err); + } 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++; + 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` + ); + + 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; + } + await new Promise((r) => setTimeout(r, backoffMs)); + continue; } - await new Promise((r) => setTimeout(r, delayMs)); + // normal poll interval + await new Promise((r) => setTimeout(r, baseDelayMs)); } - // fallback: timeout - if (socketId && io && io.sockets.sockets.get(socketId)) { - io.to(socketId).emit("selenium:session_update", { - session_id: sessionId, - status: "error", - message: "Polling timeout while waiting for selenium session", - }); - } + // overall timeout fallback + emitSafe(socketId, "selenium:session_update", { + session_id: sessionId, + status: "error", + message: "Polling timeout while waiting for selenium session", + }); + delete ddmaJobs[sessionId]; } /** @@ -363,11 +541,14 @@ router.post( const socketId: string | undefined = req.body.socketId; - const agentResp = await forwardToSeleniumDdmaEligibilityAgent( - enrichedData, - ); + const agentResp = + await forwardToSeleniumDdmaEligibilityAgent(enrichedData); - if (!agentResp || agentResp.status !== "started" || !agentResp.session_id) { + if ( + !agentResp || + agentResp.status !== "started" || + !agentResp.session_id + ) { return res.status(502).json({ error: "Selenium agent did not return a started session", detail: agentResp, @@ -408,30 +589,24 @@ router.post( 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" }); + return res.status(400).json({ error: "session_id and otp are required" }); } try { const r = await forwardOtpToSeleniumDdmaAgent(sessionId, otp); - // notify socket that OTP was accepted (if socketId present) - try { - const { io } = require("../socket"); - if (socketId && io && io.sockets.sockets.get(socketId)) { - io.to(socketId).emit("selenium:otp_submitted", { - session_id: sessionId, - result: r, - }); - } - } catch (emitErr) { - console.warn("Failed to emit selenium:otp_submitted", emitErr); - } + // 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); + 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, @@ -440,4 +615,16 @@ router.post( } ); +// 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" }); + const f = finalResults[sid]; + if (!f) return res.status(404).json({ error: "final result not found" }); + return res.json(f); + } +); + export default router; diff --git a/apps/Backend/src/services/seleniumDdmaInsuranceEligibilityClient.ts b/apps/Backend/src/services/seleniumDdmaInsuranceEligibilityClient.ts index 1fecadd..5211237 100644 --- a/apps/Backend/src/services/seleniumDdmaInsuranceEligibilityClient.ts +++ b/apps/Backend/src/services/seleniumDdmaInsuranceEligibilityClient.ts @@ -1,4 +1,6 @@ import axios from "axios"; +import http from "http"; +import https from "https"; import dotenv from "dotenv"; dotenv.config(); @@ -7,66 +9,114 @@ export interface SeleniumPayload { url?: string; } -const SELENIUM_AGENT_BASE = - process.env.SELENIUM_AGENT_BASE_URL; +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, + insuranceEligibilityData: any ): Promise { - const payload: SeleniumPayload = { - data: insuranceEligibilityData, - }; - - const url = `${SELENIUM_AGENT_BASE}/ddma-eligibility`; - console.log(url) - const result = await axios.post( - `${SELENIUM_AGENT_BASE}/ddma-eligibility`, - payload, - { timeout: 5 * 60 * 1000 } - ); - - if (!result || !result.data) { - throw new Error("Empty response from selenium agent"); - } - - if (result.data.status === "error") { - const errorMsg = - typeof result.data.message === "string" - ? result.data.message - : result.data.message?.msg || "Selenium agent error"; - throw new Error(errorMsg); - } - - return result.data; // { status: "started", session_id } + 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 result = await axios.post(`${SELENIUM_AGENT_BASE}/submit-otp`, { - session_id: sessionId, - otp, + const url = `/submit-otp`; + log("selenium-client", "POST submit-otp", { + url: SELENIUM_AGENT_BASE + url, + sessionId, }); - - if (!result || !result.data) throw new Error("Empty OTP response"); - if (result.data.status === "error") { - const message = - typeof result.data.message === "string" - ? result.data.message - : JSON.stringify(result.data); - throw new Error(message); - } - - return result.data; + 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 result = await axios.get( - `${SELENIUM_AGENT_BASE}/session/${sessionId}/status` - ); - if (!result || !result.data) throw new Error("Empty session status"); - return result.data; + 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/Frontend/src/components/insurance-status/ddma-buton-modal.tsx b/apps/Frontend/src/components/insurance-status/ddma-buton-modal.tsx index 9ffb688..f02b6cf 100644 --- a/apps/Frontend/src/components/insurance-status/ddma-buton-modal.tsx +++ b/apps/Frontend/src/components/insurance-status/ddma-buton-modal.tsx @@ -11,7 +11,6 @@ import { setTaskStatus } from "@/redux/slices/seleniumEligibilityCheckTaskSlice" import { formatLocalDate } from "@/utils/dateUtils"; import { QK_PATIENTS_BASE } from "@/components/patients/patient-table"; -// Use Vite env (set VITE_BACKEND_URL in your frontend .env) const SOCKET_URL = import.meta.env.VITE_API_BASE_URL_BACKEND || (typeof window !== "undefined" ? window.location.origin : ""); @@ -24,7 +23,12 @@ interface DdmaOtpModalProps { isSubmitting: boolean; } -function DdmaOtpModal({ open, onClose, onSubmit, isSubmitting }: DdmaOtpModalProps) { +function DdmaOtpModal({ + open, + onClose, + onSubmit, + isSubmitting, +}: DdmaOtpModalProps) { const [otp, setOtp] = useState(""); useEffect(() => { @@ -135,6 +139,17 @@ export function DdmaEligibilityButton({ }; }, []); + 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 @@ -159,13 +174,68 @@ export function DdmaEligibilityButton({ resolve(); }); - socket.on("connect_error", (err) => { - console.error("DDMA socket connect_error:", err); - reject(err); + // 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.on("disconnect", () => { - console.log("DDMA socket disconnected"); + // 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 @@ -176,15 +246,14 @@ export function DdmaEligibilityButton({ dispatch( setTaskStatus({ status: "pending", - message: - "OTP required for DDMA eligibility. Please enter the OTP.", + 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 || payload.session_id !== sessionId) return; + if (!payload?.session_id) return; dispatch( setTaskStatus({ status: "pending", @@ -196,7 +265,7 @@ export function DdmaEligibilityButton({ // Session update socket.on("selenium:session_update", (payload: any) => { const { session_id, status, final } = payload || {}; - if (!session_id || session_id !== sessionId) return; + if (!session_id) return; if (status === "completed") { dispatch( @@ -238,20 +307,65 @@ export function DdmaEligibilityButton({ 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 { - // Once resolved or rejected, allow future attempts if needed connectingRef.current = null; } }; diff --git a/apps/SeleniumService/helpers_ddma_eligibility.py b/apps/SeleniumService/helpers_ddma_eligibility.py index d4227cd..9c6327f 100644 --- a/apps/SeleniumService/helpers_ddma_eligibility.py +++ b/apps/SeleniumService/helpers_ddma_eligibility.py @@ -5,6 +5,8 @@ from typing import Dict, Any from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC +from selenium.common.exceptions import WebDriverException +import pickle from selenium_DDMA_eligibilityCheckWorker import AutomationDeltaDentalMAEligibilityCheck @@ -33,23 +35,46 @@ def make_session_entry() -> str: return sid -async def cleanup_session(sid: str): - """Close driver (if any) and remove session entry.""" +async def cleanup_session(sid: str, message: str | None = None): + """ + Close driver (if any), wake OTP waiter, set final state, and remove session entry. + Idempotent: safe to call multiple times. + """ s = sessions.get(sid) if not s: return try: + # Ensure final state + try: + if s.get("status") not in ("completed", "error", "not_found"): + s["status"] = "error" + if message: + s["message"] = message + except Exception: + pass + + # Wake any OTP waiter (so awaiting coroutines don't hang) + try: + ev = s.get("otp_event") + if ev and not ev.is_set(): + ev.set() + except Exception: + pass + + # Attempt to quit driver (may already be dead) driver = s.get("driver") if driver: try: driver.quit() except Exception: + # ignore errors from quit (session already gone) pass + finally: + # Remove session entry from map sessions.pop(sid, None) print(f"[helpers] cleaned session {sid}") - async def _remove_session_later(sid: str, delay: int = 20): await asyncio.sleep(delay) await cleanup_session(sid) @@ -89,7 +114,18 @@ async def start_ddma_run(sid: str, data: dict, url: str): return {"status": "error", "message": s["message"]} # Login - login_result = bot.login() + try: + login_result = bot.login(url) + except WebDriverException as wde: + s["status"] = "error" + s["message"] = f"Selenium driver error during login: {wde}" + await cleanup_session(sid, s["message"]) + return {"status": "error", "message": s["message"]} + except Exception as e: + s["status"] = "error" + s["message"] = f"Unexpected error during login: {e}" + await cleanup_session(sid, s["message"]) + return {"status": "error", "message": s["message"]} # OTP required path if isinstance(login_result, str) and login_result == "OTP_REQUIRED": @@ -138,6 +174,38 @@ async def start_ddma_run(sid: str, data: dict, url: str): s["status"] = "otp_submitted" s["last_activity"] = time.time() await asyncio.sleep(0.5) + + # Wait for post-OTP login to complete and then save cookies + try: + driver = s["driver"] + wait = WebDriverWait(driver, 30) + # Wait for dashboard element or URL change indicating success + logged_in_el = wait.until( + EC.presence_of_element_located( + (By.XPATH, "//a[text()='Member Eligibility' or contains(., 'Member Eligibility')]") + ) + ) + # If found, save cookies + if logged_in_el: + try: + # Prefer direct save to avoid subtle create_if_missing behavior + cookies = driver.get_cookies() + pickle.dump(cookies, open(bot.cookies_path, "wb")) + print(f"[start_ddma_run] Saved {len(cookies)} cookies after OTP to {bot.cookies_path}") + except Exception as e: + print("[start_ddma_run] Warning saving cookies after OTP:", e) + except Exception as e: + # If waiting times out, still attempt a heuristic check by URL + cur = s["driver"].current_url if s.get("driver") else "" + print("[start_ddma_run] Post-OTP dashboard detection timed out. Current URL:", cur) + if "dashboard" in cur or "providers" in cur: + try: + cookies = s["driver"].get_cookies() + pickle.dump(cookies, open(bot.cookies_path, "wb")) + print(f"[start_ddma_run] Saved {len(cookies)} cookies after OTP (URL heuristic).") + except Exception as e2: + print("[start_ddma_run] Warning saving cookies after OTP (heuristic):", e2) + except Exception as e: s["status"] = "error" s["message"] = f"Failed to submit OTP into page: {e}"