diff --git a/apps/Backend/src/queue/jobRunner.ts b/apps/Backend/src/queue/jobRunner.ts index 42c6f26..68e4f7f 100644 --- a/apps/Backend/src/queue/jobRunner.ts +++ b/apps/Backend/src/queue/jobRunner.ts @@ -14,6 +14,7 @@ import { runOcrProcessor } from "./processors/ocrProcessor"; import { runDdmaEligibilityProcessor } from "./processors/ddmaEligibilityProcessor"; import { runDeltaInsEligibilityProcessor } from "./processors/deltaInsEligibilityProcessor"; import { runUnitedSCOEligibilityProcessor } from "./processors/unitedSCOEligibilityProcessor"; +import { runDentaQuestEligibilityProcessor } from "./processors/dentaQuestEligibilityProcessor"; import { runCCAEligibilityProcessor } from "./processors/ccaEligibilityProcessor"; import type { SeleniumJobData, OcrJobData } from "./queues"; @@ -114,6 +115,20 @@ export function enqueueSeleniumJob(data: SeleniumJobData): string { job.id ); } + if (jobType === "tuftssco-eligibility-check") { + return runDentaQuestEligibilityProcessor( + { + enrichedPayload: data.enrichedPayload, + userId: data.userId, + insuranceId: data.insuranceId!, + formFirstName: data.formFirstName, + formLastName: data.formLastName, + formDob: data.formDob, + socketId: data.socketId, + }, + job.id + ); + } if (jobType === "cca-eligibility-check") { return runCCAEligibilityProcessor( { diff --git a/apps/Backend/src/queue/processors/dentaQuestEligibilityProcessor.ts b/apps/Backend/src/queue/processors/dentaQuestEligibilityProcessor.ts new file mode 100644 index 0000000..8344271 --- /dev/null +++ b/apps/Backend/src/queue/processors/dentaQuestEligibilityProcessor.ts @@ -0,0 +1,304 @@ +import fs from "fs/promises"; +import fsSync from "fs"; +import path from "path"; +import { storage } from "../../storage"; +import { emptyFolderContainingFile } from "../../utils/emptyTempFolder"; +import { + forwardToSeleniumDentaQuestEligibilityAgent, + getSeleniumDentaQuestSessionStatus, +} from "../../services/seleniumDentaQuestEligibilityClient"; +import { splitName, createOrUpdatePatientByInsuranceId, imageToPdfBuffer } from "./_shared"; +import { io } from "../../socket"; + +function now() { + return new Date().toISOString(); +} + +function log(tag: string, msg: string, ctx?: any) { + console.log(`${now()} [${tag}] ${msg}`, ctx ?? ""); +} + +function emitToSocket(socketId: string | undefined, event: string, payload: any) { + if (!socketId || !io) return; + try { + const socket = io.sockets.sockets.get(socketId); + if (socket) { + socket.emit(event, payload); + log("dentaquest-processor", `emitted ${event}`, { socketId }); + } + } catch (err: any) { + log("dentaquest-processor", `emit failed for ${event}`, { err: err?.message }); + } +} + +export interface DentaQuestEligibilityProcessorInput { + enrichedPayload: any; + userId: number; + insuranceId: string; + formFirstName?: string; + formLastName?: string; + formDob?: string; + socketId?: string; +} + +export interface DentaQuestEligibilityProcessorResult { + patientUpdateStatus?: string; + pdfUploadStatus?: string; + pdfFileId?: number | null; + pdfFilename?: string | null; +} + +async function processDentaQuestResult( + userId: number, + insuranceId: string, + formFirstName: string | undefined, + formLastName: string | undefined, + formDob: string | undefined, + seleniumResult: any +): Promise { + const output: DentaQuestEligibilityProcessorResult = {}; + let createdPdfFileId: number | null = null; + + try { + const rawName = + typeof seleniumResult?.patientName === "string" + ? seleniumResult.patientName.trim() + : null; + + const { firstName, lastName } = rawName + ? splitName(rawName) + : { firstName: formFirstName ?? "", lastName: formLastName ?? "" }; + + await createOrUpdatePatientByInsuranceId({ + insuranceId, + firstName, + lastName, + dob: formDob, + userId, + }); + + const patient = await storage.getPatientByInsuranceId(insuranceId); + if (!patient?.id) { + output.patientUpdateStatus = "Patient not found; no update performed"; + return output; + } + + const eligStatus = (seleniumResult?.eligibility ?? "").toLowerCase(); + const newStatus = eligStatus === "active" || eligStatus === "y" ? "ACTIVE" : "INACTIVE"; + + await storage.updatePatient(patient.id, { + status: newStatus, + insuranceProvider: "Tufts SCO", + }); + output.patientUpdateStatus = `Patient status updated to ${newStatus}`; + + let pdfBuffer: Buffer | null = null; + let pdfFilename: string | null = null; + + const pdfPath: string | null = + seleniumResult?.pdf_path ?? seleniumResult?.ss_path ?? null; + + if (pdfPath && fsSync.existsSync(pdfPath)) { + if (pdfPath.endsWith(".pdf")) { + try { + pdfBuffer = await fs.readFile(pdfPath); + pdfFilename = path.basename(pdfPath); + log("dentaquest-processor", "read PDF directly", { pdfPath }); + } catch (e: any) { + output.pdfUploadStatus = `Failed to read PDF: ${e.message}`; + } + } else if ( + pdfPath.endsWith(".png") || + pdfPath.endsWith(".jpg") || + pdfPath.endsWith(".jpeg") + ) { + try { + pdfBuffer = await imageToPdfBuffer(pdfPath); + pdfFilename = `dentaquest_eligibility_${insuranceId}_${Date.now()}.pdf`; + log("dentaquest-processor", "converted screenshot to PDF", { pdfPath }); + } catch (e: any) { + output.pdfUploadStatus = `Failed to convert screenshot to PDF: ${e.message}`; + } + } + } else { + output.pdfUploadStatus = "No valid file path from Selenium; nothing uploaded."; + } + + if (pdfBuffer && pdfFilename) { + const groupTitleKey = "ELIGIBILITY_STATUS"; + const groupTitle = "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"); + + const created = await storage.createPdfFile(group.id, pdfFilename, pdfBuffer); + if (created && typeof created === "object" && "id" in created) { + createdPdfFileId = Number(created.id); + } + output.pdfUploadStatus = `PDF saved to group: ${group.title}`; + output.pdfFilename = pdfFilename; + } + + output.pdfFileId = createdPdfFileId; + return output; + } catch (err: any) { + return { + ...output, + pdfUploadStatus: + output.pdfUploadStatus ?? `Processing failed: ${err?.message ?? String(err)}`, + pdfFileId: createdPdfFileId, + }; + } finally { + const cleanupPath = seleniumResult?.pdf_path ?? seleniumResult?.ss_path ?? null; + if (cleanupPath) { + try { + await emptyFolderContainingFile(cleanupPath); + } catch (e) { + log("dentaquest-processor", "cleanup failed", { cleanupPath }); + } + } + } +} + +async function pollUntilDone( + sessionId: string, + socketId: string | undefined, + jobId: string, + pollTimeoutMs = 5 * 60 * 1000 +): Promise { + const maxAttempts = 600; + const pollIntervalMs = 500; + const maxTransientErrors = 12; + const noProgressLimit = 120; + + let transientErrors = 0; + let consecutiveNoProgress = 0; + let lastStatus: string | null = null; + const deadline = Date.now() + pollTimeoutMs; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + if (Date.now() > deadline) { + throw new Error( + `DentaQuest polling timeout (${Math.round(pollTimeoutMs / 1000)}s) for session ${sessionId}` + ); + } + + try { + const st = await getSeleniumDentaQuestSessionStatus(sessionId); + const status: string = st?.status ?? "unknown"; + + log("dentaquest-processor", `poll attempt=${attempt}`, { sessionId, status }); + + transientErrors = 0; + + const isTerminal = + status === "completed" || status === "error" || status === "not_found"; + if (status === lastStatus && !isTerminal) { + consecutiveNoProgress++; + } else { + consecutiveNoProgress = 0; + } + lastStatus = status; + + if (consecutiveNoProgress >= noProgressLimit) { + throw new Error( + `No progress from Python agent (status="${status}") after ${consecutiveNoProgress} polls` + ); + } + + if (status === "waiting_for_otp") { + emitToSocket(socketId, "selenium:otp_required", { + session_id: sessionId, + jobId, + message: "OTP required. Please enter the verification code.", + }); + await new Promise((r) => setTimeout(r, pollIntervalMs)); + continue; + } + + if (status === "completed") { + log("dentaquest-processor", "session completed", { sessionId }); + return st.result; + } + + if (status === "error" || status === "not_found") { + throw new Error(st?.message || `DentaQuest session ended with status: ${status}`); + } + + await new Promise((r) => setTimeout(r, pollIntervalMs)); + } catch (err: any) { + const isTerminal = + err?.response?.status === 404 || + (typeof err?.message === "string" && + (err.message.includes("not_found") || + err.message.includes("polling timeout") || + err.message.includes("No progress"))); + + if (isTerminal) throw err; + + transientErrors++; + if (transientErrors > maxTransientErrors) { + throw new Error( + `Too many transient network errors polling DentaQuest session ${sessionId}` + ); + } + const backoff = Math.min(30_000, 500 * Math.pow(2, transientErrors - 1)); + log("dentaquest-processor", `transient error #${transientErrors}, backoff ${backoff}ms`, { + err: err?.message, + }); + await new Promise((r) => setTimeout(r, backoff)); + } + } + + throw new Error(`DentaQuest polling exhausted all attempts for session ${sessionId}`); +} + +export async function runDentaQuestEligibilityProcessor( + input: DentaQuestEligibilityProcessorInput, + jobId: string +): Promise { + const { + enrichedPayload, + userId, + insuranceId, + formFirstName, + formLastName, + formDob, + socketId, + } = input; + + log("dentaquest-processor", "starting Python agent session", { insuranceId }); + const agentResp = await forwardToSeleniumDentaQuestEligibilityAgent(enrichedPayload); + + if (!agentResp?.session_id) { + throw new Error("Python agent did not return a session_id for DentaQuest eligibility"); + } + + const sessionId = agentResp.session_id as string; + log("dentaquest-processor", "got session_id", { sessionId }); + + emitToSocket(socketId, "selenium:dentaquest_session_started", { + session_id: sessionId, + jobId, + }); + + const seleniumResult = await pollUntilDone(sessionId, socketId, jobId); + + if (!seleniumResult || seleniumResult.status === "error") { + throw new Error(seleniumResult?.message ?? "DentaQuest session returned an error result"); + } + + log("dentaquest-processor", "processing DB result", { insuranceId }); + const result = await processDentaQuestResult( + userId, + insuranceId, + formFirstName, + formLastName, + formDob, + seleniumResult + ); + + log("dentaquest-processor", "done", { result }); + return result; +} diff --git a/apps/Backend/src/routes/index.ts b/apps/Backend/src/routes/index.ts index bd77e2d..9c1796d 100755 --- a/apps/Backend/src/routes/index.ts +++ b/apps/Backend/src/routes/index.ts @@ -14,6 +14,7 @@ import insuranceStatusRoutes from "./insuranceStatus"; import insuranceStatusDdmaRoutes from "./insuranceStatusDDMA"; import insuranceStatusDeltaInsRoutes from "./insuranceStatusDeltaIns"; import insuranceStatusUnitedSCORoutes from "./insuranceStatusUnitedSCO"; +import insuranceStatusTuftsSCORoutes from "./insuranceStatusTuftsSCO"; import insuranceStatusCCARoutes from "./insuranceStatusCCA"; import paymentsRoutes from "./payments"; import databaseManagementRoutes from "./database-management"; @@ -41,6 +42,7 @@ router.use("/insurance-status", insuranceStatusRoutes); router.use("/insurance-status-ddma", insuranceStatusDdmaRoutes); router.use("/insurance-status-deltains", insuranceStatusDeltaInsRoutes); router.use("/insurance-status-unitedsco", insuranceStatusUnitedSCORoutes); +router.use("/insurance-status-tuftssco", insuranceStatusTuftsSCORoutes); router.use("/insurance-status-cca", insuranceStatusCCARoutes); router.use("/payments", paymentsRoutes); router.use("/database-management", databaseManagementRoutes); diff --git a/apps/Backend/src/routes/insuranceStatusTuftsSCO.ts b/apps/Backend/src/routes/insuranceStatusTuftsSCO.ts new file mode 100644 index 0000000..f6a653d --- /dev/null +++ b/apps/Backend/src/routes/insuranceStatusTuftsSCO.ts @@ -0,0 +1,108 @@ +import { Router, Request, Response } from "express"; +import { storage } from "../storage"; +import { forwardOtpToSeleniumDentaQuestAgent } from "../services/seleniumDentaQuestEligibilityClient"; +import { io } from "../socket"; +import { enqueueSeleniumJob } from "../queue/jobRunner"; + +const router = Router(); + +function log(tag: string, msg: string, ctx?: any) { + console.log(`${new Date().toISOString()} [${tag}] ${msg}`, ctx ?? ""); +} + +function emitSafe(socketId: string | undefined, event: string, payload: any) { + if (!socketId || !io) return; + try { + const socket = io.sockets.sockets.get(socketId); + if (socket) socket.emit(event, payload); + } catch (err: any) { + log("socket", "emit failed", { socketId, event, err: err?.message }); + } +} + +router.post( + "/tuftssco-eligibility", + async (req: Request, res: Response): Promise => { + if (!req.body.data) { + return res.status(400).json({ error: "Missing eligibility data for selenium" }); + } + if (!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 credentials found for Tufts SCO. Please add them on the Settings page.", + }); + } + + const enrichedData = { + ...rawData, + dentaquestUsername: credentials.username, + dentaquestPassword: credentials.password, + }; + + const socketId: string | undefined = req.body.socketId; + + const jobId = enqueueSeleniumJob({ + jobType: "tuftssco-eligibility-check", + userId: req.user.id, + socketId, + enrichedPayload: enrichedData, + insuranceId: String(rawData.memberId ?? "").trim(), + formFirstName: rawData.firstName, + formLastName: rawData.lastName, + formDob: rawData.dateOfBirth, + }); + + log("tuftssco-route", "job enqueued", { jobId, insuranceId: rawData.memberId }); + + return res.json({ status: "queued", jobId }); + } catch (err: any) { + console.error("[tuftssco-route] enqueue failed:", err); + return res.status(500).json({ + error: err.message || "Failed to enqueue Tufts SCO selenium job", + }); + } + } +); + +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); + + emitSafe(socketId, "selenium:otp_submitted", { + session_id: sessionId, + result: r, + }); + + return res.json(r); + } catch (err: any) { + console.error( + "[tuftssco-route] submit-otp failed:", + err?.response?.data || err?.message || err + ); + return res.status(500).json({ + error: "Failed to forward OTP to selenium agent", + detail: err?.message || err, + }); + } + } +); + +export default router; diff --git a/apps/Backend/src/services/seleniumDentaQuestEligibilityClient.ts b/apps/Backend/src/services/seleniumDentaQuestEligibilityClient.ts new file mode 100644 index 0000000..dab4a84 --- /dev/null +++ b/apps/Backend/src/services/seleniumDentaQuestEligibilityClient.ts @@ -0,0 +1,72 @@ +import axios from "axios"; +import http from "http"; +import https from "https"; +import dotenv from "dotenv"; +dotenv.config(); + +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(`[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(`[dentaquest-client] transient network error ${code} (attempt ${attempt})`); + } + await new Promise((r) => setTimeout(r, baseBackoffMs * attempt)); + } + return client.request(config); +} + +function log(tag: string, msg: string, ctx?: any) { + console.log(`${new Date().toISOString()} [${tag}] ${msg}`, ctx ?? ""); +} + +export async function forwardToSeleniumDentaQuestEligibilityAgent(data: any): Promise { + const payload = { data }; + const url = `/dentaquest-eligibility`; + log("dentaquest-client", "POST dentaquest-eligibility", { url: SELENIUM_AGENT_BASE + url }); + const r = await requestWithRetries({ url, method: "POST", data: payload }, 4); + log("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 = `/submit-otp`; + log("dentaquest-client", "POST submit-otp", { url: SELENIUM_AGENT_BASE + url, sessionId }); + const r = await requestWithRetries({ url, method: "POST", data: { session_id: sessionId, otp } }, 4); + log("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 = `/session/${sessionId}/status`; + log("dentaquest-client", "GET session status", { url: SELENIUM_AGENT_BASE + url, sessionId }); + const r = await requestWithRetries({ url, method: "GET" }, 4); + log("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/tufts-sco-button-modal.tsx b/apps/Frontend/src/components/insurance-status/tufts-sco-button-modal.tsx index c50c26e..bc0a9de 100644 --- a/apps/Frontend/src/components/insurance-status/tufts-sco-button-modal.tsx +++ b/apps/Frontend/src/components/insurance-status/tufts-sco-button-modal.tsx @@ -139,7 +139,7 @@ export function TuftsSCOEligibilityButton({ const response = await apiRequest( "POST", - "/api/insurance-status-unitedsco/unitedsco-eligibility", + "/api/insurance-status-tuftssco/tuftssco-eligibility", { data: JSON.stringify(payload), socketId: socket.id } ); @@ -195,13 +195,13 @@ export function TuftsSCOEligibilityButton({ ); }; - socket.on("selenium:unitedsco_session_started", onSessionStarted); + socket.on("selenium:dentaquest_session_started", onSessionStarted); socket.on("selenium:otp_required", onOtpRequired); socket.on("selenium:otp_submitted", onOtpSubmitted); function cleanup() { clearTimeout(safetyTimer); - socket.off("selenium:unitedsco_session_started", onSessionStarted); + socket.off("selenium:dentaquest_session_started", onSessionStarted); socket.off("selenium:otp_required", onOtpRequired); socket.off("selenium:otp_submitted", onOtpSubmitted); socket.off("job:update", onJobUpdate); @@ -296,7 +296,7 @@ export function TuftsSCOEligibilityButton({ try { setIsSubmittingOtp(true); - const resp = await apiRequest("POST", "/api/insurance-status-unitedsco/selenium/submit-otp", { + const resp = await apiRequest("POST", "/api/insurance-status-tuftssco/selenium/submit-otp", { session_id: sessionId, otp, socketId: socket.id, diff --git a/apps/Frontend/src/components/settings/InsuranceCredForm.tsx b/apps/Frontend/src/components/settings/InsuranceCredForm.tsx index 57e16b4..26e8501 100755 --- a/apps/Frontend/src/components/settings/InsuranceCredForm.tsx +++ b/apps/Frontend/src/components/settings/InsuranceCredForm.tsx @@ -15,6 +15,15 @@ type CredentialFormProps = { }; }; +const SITE_KEY_OPTIONS = [ + { value: "MH", label: "MassHealth (MH)" }, + { value: "DDMA", label: "Delta Dental MA (DDMA)" }, + { value: "DELTAINS", label: "Delta Dental Ins (DELTAINS)" }, + { value: "TUFTS_SCO", label: "Tufts SCO (TUFTS_SCO)" }, + { value: "UNITED_SCO", label: "United SCO (UNITED_SCO)" }, + { value: "CCA", label: "CCA (CCA)" }, +]; + export function CredentialForm({ onClose, userId, defaultValues }: CredentialFormProps) { const [siteKey, setSiteKey] = useState(defaultValues?.siteKey || ""); const [username, setUsername] = useState(defaultValues?.username || ""); @@ -93,14 +102,19 @@ export function CredentialForm({ onClose, userId, defaultValues }: CredentialFor
- - Insurance +
diff --git a/apps/SeleniumService/agent.py b/apps/SeleniumService/agent.py index e0509e2..c18fa5b 100755 --- a/apps/SeleniumService/agent.py +++ b/apps/SeleniumService/agent.py @@ -13,12 +13,14 @@ import time import helpers_ddma_eligibility as hddma import helpers_deltains_eligibility as hdeltains import helpers_unitedsco_eligibility as hunitedsco +import helpers_dentaquest_eligibility as hdentaquest import helpers_cca_eligibility as hcca # Import startup session-clear functions from ddma_browser_manager import clear_ddma_session_on_startup from deltains_browser_manager import clear_deltains_session_on_startup from unitedsco_browser_manager import clear_unitedsco_session_on_startup +from dentaquest_browser_manager import clear_dentaquest_session_on_startup from cca_browser_manager import clear_cca_session_on_startup from dotenv import load_dotenv @@ -31,6 +33,7 @@ print("=" * 50) clear_ddma_session_on_startup() clear_deltains_session_on_startup() clear_unitedsco_session_on_startup() +clear_dentaquest_session_on_startup() clear_cca_session_on_startup() print("=" * 50) print("SESSION CLEAR COMPLETE") @@ -341,6 +344,47 @@ async def unitedsco_eligibility(request: Request): return {"status": "started", "session_id": sid} +async def _dentaquest_worker_wrapper(sid: str, data: dict, url: str): + """Background worker for DentaQuest (Tufts SCO) — acquires semaphore, updates counters.""" + global active_jobs, waiting_jobs + async with semaphore: + async with lock: + waiting_jobs -= 1 + active_jobs += 1 + try: + await hdentaquest.start_dentaquest_run(sid, data, url) + finally: + async with lock: + active_jobs -= 1 + + +@app.post("/dentaquest-eligibility") +async def dentaquest_eligibility(request: Request): + """ + Starts a DentaQuest (Tufts SCO) eligibility session in the background. + Body: { "data": { ... } } + Returns: { status: "started", session_id: "" } + """ + global waiting_jobs + + body = await request.json() + data = body.get("data", {}) + + sid = hdentaquest.make_session_entry() + hdentaquest.sessions[sid]["type"] = "dentaquest_eligibility" + hdentaquest.sessions[sid]["last_activity"] = time.time() + + async with lock: + waiting_jobs += 1 + + asyncio.create_task(_dentaquest_worker_wrapper( + sid, data, + url="https://providers.dentaquest.com/" + )) + + return {"status": "started", "session_id": sid} + + async def _cca_worker_wrapper(sid: str, data: dict, url: str): """Background worker for CCA — acquires semaphore, updates counters. No OTP.""" global active_jobs, waiting_jobs @@ -401,6 +445,8 @@ async def submit_otp(request: Request): res = hdeltains.submit_otp(sid, otp) elif sid in hunitedsco.sessions: res = hunitedsco.submit_otp(sid, otp) + elif sid in hdentaquest.sessions: + res = hdentaquest.submit_otp(sid, otp) else: raise HTTPException(status_code=404, detail="session not found") @@ -418,6 +464,8 @@ async def session_status(sid: str): s = hdeltains.get_session_status(sid) elif sid in hunitedsco.sessions: s = hunitedsco.get_session_status(sid) + elif sid in hdentaquest.sessions: + s = hdentaquest.get_session_status(sid) elif sid in hcca.sessions: s = hcca.get_session_status(sid) else: diff --git a/apps/SeleniumService/helpers_dentaquest_eligibility.py b/apps/SeleniumService/helpers_dentaquest_eligibility.py index c43a76b..76b1211 100644 --- a/apps/SeleniumService/helpers_dentaquest_eligibility.py +++ b/apps/SeleniumService/helpers_dentaquest_eligibility.py @@ -36,41 +36,34 @@ def make_session_entry() -> str: 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. + Set final error state and wake OTP waiter. Schedules session removal after a delay + so the backend can read the actual error message before the session disappears. """ 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 + 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 + try: + ev = s.get("otp_event") + if ev and not ev.is_set(): + ev.set() + except Exception: + pass - # NOTE: Do NOT quit driver - keep browser alive for next patient - # Browser manager handles the persistent browser instance - - finally: - # Remove session entry from map - sessions.pop(sid, None) + # Keep session for 30s so backend can read the error, then remove + asyncio.create_task(_remove_session_later(sid, 30)) -async def _remove_session_later(sid: str, delay: int = 20): +async def _remove_session_later(sid: str, delay: int = 30): await asyncio.sleep(delay) - await cleanup_session(sid) + sessions.pop(sid, None) async def start_dentaquest_run(sid: str, data: dict, url: str): diff --git a/apps/SeleniumService/selenium_DDMA_eligibilityCheckWorker.py b/apps/SeleniumService/selenium_DDMA_eligibilityCheckWorker.py index c80acf6..a529a75 100755 --- a/apps/SeleniumService/selenium_DDMA_eligibilityCheckWorker.py +++ b/apps/SeleniumService/selenium_DDMA_eligibilityCheckWorker.py @@ -335,32 +335,6 @@ class AutomationDeltaDentalMAEligibilityCheck: except Exception as e: print(f"[DDMA step1] Warning: Could not fill DOB: {e}") - # 3. Fill First Name if provided - if self.firstName: - try: - first_name_input = wait.until(EC.presence_of_element_located( - (By.XPATH, '//input[@placeholder="First name - 1 char minimum" or contains(@placeholder,"first name") or contains(@name,"firstName")]') - )) - first_name_input.clear() - first_name_input.send_keys(self.firstName) - print(f"[DDMA step1] Entered First Name: {self.firstName}") - time.sleep(0.2) - except Exception as e: - print(f"[DDMA step1] Warning: Could not fill First Name: {e}") - - # 4. Fill Last Name if provided - if self.lastName: - try: - last_name_input = wait.until(EC.presence_of_element_located( - (By.XPATH, '//input[@placeholder="Last name - 2 char minimum" or contains(@placeholder,"last name") or contains(@name,"lastName")]') - )) - last_name_input.clear() - last_name_input.send_keys(self.lastName) - print(f"[DDMA step1] Entered Last Name: {self.lastName}") - time.sleep(0.2) - except Exception as e: - print(f"[DDMA step1] Warning: Could not fill Last Name: {e}") - time.sleep(0.3) # Click Search button