From d357edd4a6cab67dda7c05c884c80a417e608e5a Mon Sep 17 00:00:00 2001 From: Gitead Date: Fri, 2 Oct 2026 15:37:55 -0400 Subject: [PATCH] feat: add Delta Dental Individual (DeltaIndiv) eligibility check flow; fix CCA eligibility false-positive error CCA step1 was treating the harmless "password set to expire" banner as a fatal error, closing the browser before the eligibility popup/report could be captured. Now it short-circuits to success once the patient popup renders and ignores password-expiration notices. Co-Authored-By: Claude Sonnet 5 --- apps/Backend/src/queue/jobRunner.ts | 15 + .../deltaIndivEligibilityProcessor.ts | 393 ++++++++++ apps/Backend/src/queue/queues.ts | 1 + apps/Backend/src/routes/index.ts | 2 + .../src/routes/insuranceStatusDeltaIndiv.ts | 147 ++++ .../seleniumDeltaIndivEligibilityClient.ts | 122 +++ .../deltaindiv-button-modal.tsx | 435 +++++++++++ .../components/settings/InsuranceCredForm.tsx | 1 + .../src/pages/insurance-status-page.tsx | 28 +- apps/SeleniumService/.gitignore | 1 + apps/SeleniumService/agent.py | 56 ++ .../deltaindiv_browser_manager.py | 253 +++++++ .../helpers_deltaindiv_eligibility.py | 297 ++++++++ .../selenium_CCA_eligibilityCheckWorker.py | 13 +- ...enium_DeltaIndiv_eligibilityCheckWorker.py | 704 ++++++++++++++++++ 15 files changed, 2464 insertions(+), 4 deletions(-) create mode 100644 apps/Backend/src/queue/processors/deltaIndivEligibilityProcessor.ts create mode 100644 apps/Backend/src/routes/insuranceStatusDeltaIndiv.ts create mode 100644 apps/Backend/src/services/seleniumDeltaIndivEligibilityClient.ts create mode 100644 apps/Frontend/src/components/insurance-status/deltaindiv-button-modal.tsx mode change 100644 => 100755 apps/Frontend/src/pages/insurance-status-page.tsx create mode 100644 apps/SeleniumService/deltaindiv_browser_manager.py create mode 100644 apps/SeleniumService/helpers_deltaindiv_eligibility.py create mode 100644 apps/SeleniumService/selenium_DeltaIndiv_eligibilityCheckWorker.py diff --git a/apps/Backend/src/queue/jobRunner.ts b/apps/Backend/src/queue/jobRunner.ts index 8d38e3a9..ea62247e 100644 --- a/apps/Backend/src/queue/jobRunner.ts +++ b/apps/Backend/src/queue/jobRunner.ts @@ -12,6 +12,7 @@ import { runClaimStatusProcessor } from "./processors/claimStatusProcessor"; import { runClaimSubmitProcessor } from "./processors/claimSubmitProcessor"; import { runOcrProcessor } from "./processors/ocrProcessor"; import { runDdmaEligibilityProcessor } from "./processors/ddmaEligibilityProcessor"; +import { runDeltaIndivEligibilityProcessor } from "./processors/deltaIndivEligibilityProcessor"; import { runDeltaInsEligibilityProcessor } from "./processors/deltaInsEligibilityProcessor"; import { runUnitedSCOEligibilityProcessor } from "./processors/unitedSCOEligibilityProcessor"; import { runUnitedUcardEligibilityProcessor } from "./processors/unitedUcardEligibilityProcessor"; @@ -117,6 +118,20 @@ export function enqueueSeleniumJob(data: SeleniumJobData): string { job.id ); } + if (jobType === "deltaindiv-eligibility-check") { + return runDeltaIndivEligibilityProcessor( + { + 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 === "unitedsco-eligibility-check") { return runUnitedSCOEligibilityProcessor( { diff --git a/apps/Backend/src/queue/processors/deltaIndivEligibilityProcessor.ts b/apps/Backend/src/queue/processors/deltaIndivEligibilityProcessor.ts new file mode 100644 index 00000000..0fc70417 --- /dev/null +++ b/apps/Backend/src/queue/processors/deltaIndivEligibilityProcessor.ts @@ -0,0 +1,393 @@ +/** + * Processor for "deltaindiv-eligibility-check" jobs. + * + * Integrates the full Delta Indiv persistent-session flow into the InProcessQueue: + * 1. Start a session on the Python agent (POST /deltaindiv-eligibility) + * 2. Emit selenium:deltaindiv_session_started → frontend stores session_id for OTP + * 3. Poll agent status, emitting selenium:otp_required when OTP is needed + * 4. On completion: save PDF, create/update patient, update eligibility status + * 5. Return { pdfFileId, pdfFilename, patientUpdateStatus, pdfUploadStatus } + * + * The OTP submission endpoint (/api/insurance-status-deltaindiv/selenium/submit-otp) + * continues to forward OTPs directly to the Python agent — it does NOT go + * through the queue. + */ +import fs from "fs/promises"; +import fsSync from "fs"; +import path from "path"; +import { storage } from "../../storage"; +import { emptyFolderContainingFile } from "../../utils/emptyTempFolder"; +import { + forwardToSeleniumDeltaIndivEligibilityAgent, + getSeleniumDeltaIndivSessionStatus, +} from "../../services/seleniumDeltaIndivEligibilityClient"; +import { + splitName, + createOrUpdatePatientByInsuranceId, + imageToPdfBuffer, +} from "./_shared"; +import { io } from "../../socket"; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +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("deltaindiv-processor", `emitted ${event}`, { socketId }); + } + } catch (err: any) { + log("deltaindiv-processor", `emit failed for ${event}`, { err: err?.message }); + } +} + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface DeltaIndivEligibilityProcessorInput { + enrichedPayload: any; + userId: number; + insuranceId: string; + formFirstName?: string; + formLastName?: string; + formDob?: string; + socketId?: string; +} + +export interface DeltaIndivEligibilityProcessorResult { + patientUpdateStatus?: string; + pdfUploadStatus?: string; + pdfFileId?: number | string | null; + pdfFilename?: string | null; +} + +// ─── Core DB processing (mirrors handleDeltaIndivCompletedJob in the route) ───────── + +async function processDeltaIndivResult( + userId: number, + insuranceId: string, + formFirstName: string | undefined, + formLastName: string | undefined, + formDob: string | undefined, + seleniumResult: any +): Promise { + const output: DeltaIndivEligibilityProcessorResult = {}; + let createdPdfFileId: number | string | null = null; + + try { + // 1) Resolve patient name (prefer selenium extraction → form data) + const rawName = + typeof seleniumResult?.patientName === "string" + ? seleniumResult.patientName.trim() + : null; + + let firstName: string; + let lastName: string; + + if (rawName) { + // Strip trailing bare dates Delta Indiv appends to names e.g. "Christian Rodriguez 04/27/2026" + const cleanName = rawName.replace(/\s+\d{1,2}\/\d{1,2}\/\d{2,4}$/, "").trim(); + + if (cleanName.includes(",")) { + // "LAST, FIRST" format common on insurance portals + const [last, ...firstParts] = cleanName.split(",").map((s: string) => s.trim()); + lastName = last || formLastName || ""; + firstName = firstParts.join(" ").trim() || formFirstName || ""; + } else { + const parsed = splitName(cleanName); + if (!parsed.lastName) { + // Single word — treat as last name, pull first name from form + lastName = parsed.firstName || formLastName || ""; + firstName = formFirstName || ""; + } else { + firstName = parsed.firstName || formFirstName || ""; + lastName = parsed.lastName || formLastName || ""; + } + } + } else { + firstName = formFirstName ?? ""; + lastName = formLastName ?? ""; + } + + // 2) Create / update patient + const upsertedPatient = await createOrUpdatePatientByInsuranceId({ + insuranceId, + firstName, + lastName, + dob: formDob, + userId, + }); + + // 3) Fetch patient (needed for ID) + const normalizedInsuranceId = insuranceId.replace(/\s+/g, ""); + // Use the patient just created/updated (family members share an insuranceId, so an + // id-only lookup could return a different member). + const dobForLookup = formDob ? new Date(formDob) : null; + const patient = + upsertedPatient ?? + (dobForLookup && !isNaN(dobForLookup.getTime()) + ? await storage.getPatientByInsuranceIdAndDob(normalizedInsuranceId, dobForLookup) + : await storage.getPatientByInsuranceId(normalizedInsuranceId)); + log("deltaindiv-processor", `patient lookup after create: ${patient ? `id=${patient.id}` : "NOT FOUND"} for insuranceId="${normalizedInsuranceId}"`); + if (!patient?.id) { + output.patientUpdateStatus = "Patient not found; no update performed"; + return output; + } + + // 4) Determine and update eligibility status + insurance provider name + const eligStatus = (seleniumResult?.eligibility ?? "").toLowerCase(); + let newStatus = "UNKNOWN"; + if (eligStatus === "active" || eligStatus === "y") newStatus = "ACTIVE"; + else if (eligStatus === "inactive" || eligStatus === "n" || eligStatus === "termed") newStatus = "INACTIVE"; + await storage.updatePatient(patient.id, { + status: newStatus, + insuranceProvider: "Delta Dental Indiv", + }); + output.patientUpdateStatus = `Patient status updated to ${newStatus}`; + + // 5) Resolve PDF buffer + // New Delta Indiv worker returns a real PDF via pdf_path / ss_path (.pdf). + // Old worker returned a screenshot (.png) via ss_path. + 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")) { + // Already a PDF — read directly + try { + pdfBuffer = await fs.readFile(pdfPath); + pdfFilename = path.basename(pdfPath); + log("deltaindiv-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") + ) { + // Legacy screenshot → convert to PDF + try { + pdfBuffer = await imageToPdfBuffer(pdfPath); + pdfFilename = `deltaindiv_eligibility_${insuranceId}_${Date.now()}.pdf`; + log("deltaindiv-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."; + } + + // 6) Save PDF to patient document group + if (pdfBuffer && pdfFilename) { + const patientName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim() || `Patient ${patient.id}`; + const cloudFile = await storage.savePdfToCloudStorage( + userId, + patient.id, + patientName, + "Eligibility", + pdfFilename, + pdfBuffer + ); + createdPdfFileId = `cloud:${cloudFile.id}`; + output.pdfUploadStatus = "PDF saved to Eligibility folder"; + output.pdfFilename = pdfFilename; + } + + output.pdfFileId = createdPdfFileId; + return output; + } catch (err: any) { + log("deltaindiv-processor", `processDeltaIndivResult ERROR: ${err?.message ?? String(err)}`, err); + return { + ...output, + pdfUploadStatus: + output.pdfUploadStatus ?? `Processing failed: ${err?.message ?? String(err)}`, + pdfFileId: createdPdfFileId, + }; + } finally { + // Always clean up temp files + const cleanupPath = + seleniumResult?.pdf_path ?? seleniumResult?.ss_path ?? null; + if (cleanupPath) { + try { + await emptyFolderContainingFile(cleanupPath); + } catch (e) { + log("deltaindiv-processor", "cleanup failed", { cleanupPath }); + } + } + } +} + +// ─── Polling loop ──────────────────────────────────────────────────────────── + +async function pollUntilDone( + sessionId: string, + socketId: string | undefined, + jobId: string, + pollTimeoutMs = 5 * 60 * 1000 // 5 min total (includes OTP wait) +): Promise { + const maxAttempts = 600; // 600 × 500ms = 5 min + const pollIntervalMs = 500; + const maxTransientErrors = 12; + const noProgressLimit = 120; // 60 s of same status → abort + + 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( + `Delta Indiv polling timeout (${Math.round(pollTimeoutMs / 1000)}s) for session ${sessionId}` + ); + } + + try { + const st = await getSeleniumDeltaIndivSessionStatus(sessionId); + const status: string = st?.status ?? "unknown"; + + log("deltaindiv-processor", `poll attempt=${attempt}`, { sessionId, status }); + + transientErrors = 0; // reset on success + + // Track no-progress + 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` + ); + } + + // OTP required — notify frontend and keep polling + if (status === "waiting_for_otp") { + emitToSocket(socketId, "selenium:otp_required", { + session_id: sessionId, + jobId, + message: "OTP required. Please enter the OTP shown by the Delta Indiv portal.", + }); + await new Promise((r) => setTimeout(r, pollIntervalMs)); + continue; + } + + if (status === "completed") { + log("deltaindiv-processor", "session completed", { sessionId }); + return st.result; + } + + if (status === "error" || status === "not_found") { + throw new Error( + st?.message || `Delta Indiv session ended with status: ${status}` + ); + } + + // Still running / otp_submitted / created — keep polling + await new Promise((r) => setTimeout(r, pollIntervalMs)); + } catch (err: any) { + // Propagate terminal errors immediately + 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; + + // Transient network errors — back off + transientErrors++; + if (transientErrors > maxTransientErrors) { + throw new Error( + `Too many transient network errors polling Delta Indiv session ${sessionId}` + ); + } + const backoff = Math.min(30_000, 500 * Math.pow(2, transientErrors - 1)); + log("deltaindiv-processor", `transient error #${transientErrors}, backoff ${backoff}ms`, { + err: err?.message, + }); + await new Promise((r) => setTimeout(r, backoff)); + } + } + + throw new Error(`Delta Indiv polling exhausted all attempts for session ${sessionId}`); +} + +// ─── Main processor entry point ─────────────────────────────────────────────── + +export async function runDeltaIndivEligibilityProcessor( + input: DeltaIndivEligibilityProcessorInput, + jobId: string +): Promise { + const { + enrichedPayload, + userId, + insuranceId, + formFirstName, + formLastName, + formDob, + socketId, + } = input; + + // 1) Tell Python agent to start a Delta Indiv session + log("deltaindiv-processor", "starting Python agent session", { insuranceId }); + const agentResp = await forwardToSeleniumDeltaIndivEligibilityAgent(enrichedPayload); + + if (!agentResp?.session_id) { + throw new Error( + "Python agent did not return a session_id for Delta Indiv eligibility" + ); + } + + const sessionId = agentResp.session_id as string; + log("deltaindiv-processor", "got session_id", { sessionId }); + + // 2) Emit session started so frontend can store session_id for OTP submission + emitToSocket(socketId, "selenium:deltaindiv_session_started", { + session_id: sessionId, + jobId, + }); + + // 3) Poll until done (handles OTP events internally) + const seleniumResult = await pollUntilDone(sessionId, socketId, jobId); + + if (!seleniumResult || seleniumResult.status === "error") { + throw new Error( + seleniumResult?.message ?? "Delta Indiv session returned an error result" + ); + } + + // 4) Process DB writes and PDF upload + log("deltaindiv-processor", "processing DB result", { insuranceId }); + const result = await processDeltaIndivResult( + userId, + insuranceId, + formFirstName, + formLastName, + formDob, + seleniumResult + ); + + log("deltaindiv-processor", "done", { result }); + return result; +} diff --git a/apps/Backend/src/queue/queues.ts b/apps/Backend/src/queue/queues.ts index 5ce8b1d9..d84bd1aa 100644 --- a/apps/Backend/src/queue/queues.ts +++ b/apps/Backend/src/queue/queues.ts @@ -9,6 +9,7 @@ export type SeleniumJobType = | "claim-pre-auth" | "ddma-eligibility-check" | "deltains-eligibility-check" + | "deltaindiv-eligibility-check" | "unitedsco-eligibility-check" | "uniteducard-eligibility-check" | "cca-eligibility-check" diff --git a/apps/Backend/src/routes/index.ts b/apps/Backend/src/routes/index.ts index cd34e62e..c716bfaa 100755 --- a/apps/Backend/src/routes/index.ts +++ b/apps/Backend/src/routes/index.ts @@ -12,6 +12,7 @@ import documentsRoutes from "./documents"; import patientDocumentsRoutes from "./patient-documents"; import insuranceStatusRoutes from "./insuranceStatus"; import insuranceStatusDdmaRoutes from "./insuranceStatusDDMA"; +import insuranceStatusDeltaIndivRoutes from "./insuranceStatusDeltaIndiv"; import insuranceStatusDeltaInsRoutes from "./insuranceStatusDeltaIns"; import insuranceStatusUnitedSCORoutes from "./insuranceStatusUnitedSCO"; import insuranceStatusUnitedUcardRoutes from "./insuranceStatusUnitedUcard"; @@ -68,6 +69,7 @@ router.use("/patient-documents", patientDocumentsRoutes); router.use("/insurance-status", insuranceStatusRoutes); router.use("/insurance-status-ddma", insuranceStatusDdmaRoutes); router.use("/insurance-status-deltains", insuranceStatusDeltaInsRoutes); +router.use("/insurance-status-deltaindiv", insuranceStatusDeltaIndivRoutes); router.use("/insurance-status-unitedsco", insuranceStatusUnitedSCORoutes); router.use("/insurance-status-uniteducard", insuranceStatusUnitedUcardRoutes); router.use("/insurance-status-tuftssco", insuranceStatusTuftsSCORoutes); diff --git a/apps/Backend/src/routes/insuranceStatusDeltaIndiv.ts b/apps/Backend/src/routes/insuranceStatusDeltaIndiv.ts new file mode 100644 index 00000000..b721f02a --- /dev/null +++ b/apps/Backend/src/routes/insuranceStatusDeltaIndiv.ts @@ -0,0 +1,147 @@ +import { Router, Request, Response } from "express"; +import { storage } from "../storage"; +import { forwardOtpToSeleniumDeltaIndivAgent } from "../services/seleniumDeltaIndivEligibilityClient"; +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 }); + } +} + +/** + * POST /deltaindiv-eligibility + * + * Enqueues a Delta Indiv eligibility check in the shared InProcessQueue + * (concurrency=1, mirrors the Python semaphore). + * + * Body: + * data — patient + search fields (memberId, dateOfBirth, …) + * socketId — socket.io client id for real-time updates + * + * Response: { status: "queued", jobId: "…" } + * + * Real-time events emitted to socketId during job execution: + * job:update { jobId, jobType, status: "active"|"completed"|"failed", … } + * selenium:deltaindiv_session_started { session_id, jobId } + * selenium:otp_required { session_id, jobId, message } + */ +router.post( + "/deltaindiv-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?.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; + + // Fetch credentials from DB + 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. Please update them at the Settings page.", + }); + } + + // Fetch NPI providers to pick the target provider on the Delta Indiv portal + const npiProviders = await storage.getNpiProvidersByUser(req.user.id); + const primaryProvider = npiProviders[0]; // sorted by sortOrder asc, then id asc + + const enrichedData = { + ...rawData, + deltaindivUsername: credentials.username, + deltaindivPassword: credentials.password, + providerName: primaryProvider?.providerName ?? "", + }; + + const socketId: string | undefined = req.body.socketId; + + // Enqueue — this enforces the same concurrency=1 as all other selenium jobs + const jobId = enqueueSeleniumJob({ + jobType: "deltaindiv-eligibility-check", + userId: req.user.id, + socketId, + enrichedPayload: enrichedData, + insuranceId: String(rawData.memberId ?? "").trim(), + formFirstName: rawData.firstName, + formLastName: rawData.lastName, + formDob: rawData.dateOfBirth, + }); + + log("deltaindiv-route", "job enqueued", { jobId, insuranceId: rawData.memberId }); + + return res.json({ status: "queued", jobId }); + } catch (err: any) { + console.error("[deltaindiv-route] enqueue failed:", err); + return res.status(500).json({ + error: err.message || "Failed to enqueue Delta Indiv selenium job", + }); + } + } +); + +/** + * POST /selenium/submit-otp + * + * Forwards the OTP entered by the user directly to the Python agent. + * This is a side-channel — it does NOT go through the queue. + * The polling loop inside deltaindivEligibilityProcessor picks up the completed + * state after OTP is submitted. + * + * Body: { session_id, otp, socketId? } + */ +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 forwardOtpToSeleniumDeltaIndivAgent(sessionId, otp); + + emitSafe(socketId, "selenium:otp_submitted", { + session_id: sessionId, + result: r, + }); + + return res.json(r); + } catch (err: any) { + console.error( + "[deltaindiv-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/seleniumDeltaIndivEligibilityClient.ts b/apps/Backend/src/services/seleniumDeltaIndivEligibilityClient.ts new file mode 100644 index 00000000..6bbe958c --- /dev/null +++ b/apps/Backend/src/services/seleniumDeltaIndivEligibilityClient.ts @@ -0,0 +1,122 @@ +import axios from "axios"; +import http from "http"; +import https from "https"; +import dotenv from "dotenv"; +dotenv.config(); + +export interface SeleniumPayload { + data: any; + url?: string; +} + +const SELENIUM_AGENT_BASE = process.env.SELENIUM_AGENT_BASE_URL; + +const httpAgent = new http.Agent({ keepAlive: true, keepAliveMsecs: 60_000 }); +const httpsAgent = new https.Agent({ keepAlive: true, keepAliveMsecs: 60_000 }); + +const client = axios.create({ + baseURL: SELENIUM_AGENT_BASE, + timeout: 5 * 60 * 1000, + httpAgent, + httpsAgent, + validateStatus: (s) => s >= 200 && s < 600, +}); + +async function requestWithRetries( + config: any, + retries = 4, + baseBackoffMs = 300 +) { + for (let attempt = 1; attempt <= retries; attempt++) { + try { + const r = await client.request(config); + if (![502, 503, 504].includes(r.status)) return r; + console.warn( + `[selenium-client] retryable HTTP status ${r.status} (attempt ${attempt})` + ); + } catch (err: any) { + const code = err?.code; + const isTransient = + code === "ECONNRESET" || + code === "ECONNREFUSED" || + code === "EPIPE" || + code === "ETIMEDOUT"; + if (!isTransient) throw err; + console.warn( + `[selenium-client] transient network error ${code} (attempt ${attempt})` + ); + } + await new Promise((r) => setTimeout(r, baseBackoffMs * attempt)); + } + // final attempt (let exception bubble if it fails) + return client.request(config); +} + +function now() { + return new Date().toISOString(); +} +function log(tag: string, msg: string, ctx?: any) { + console.log(`${now()} [${tag}] ${msg}`, ctx ?? ""); +} + +export async function forwardToSeleniumDeltaIndivEligibilityAgent( + insuranceEligibilityData: any +): Promise { + const payload = { data: insuranceEligibilityData }; + const url = `/deltaindiv-eligibility`; + log("selenium-client", "POST deltaindiv-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 forwardOtpToSeleniumDeltaIndivAgent( + sessionId: string, + otp: string +): Promise { + const url = `/submit-otp`; + log("selenium-client", "POST submit-otp", { + url: SELENIUM_AGENT_BASE + url, + sessionId, + }); + const r = await requestWithRetries( + { url, method: "POST", data: { session_id: sessionId, otp } }, + 4 + ); + log("selenium-client", "submit-otp response", { + status: r.status, + data: r.data, + }); + if (r.status >= 500) + throw new Error(`Selenium agent server error on submit-otp: ${r.status}`); + return r.data; +} + +export async function getSeleniumDeltaIndivSessionStatus( + sessionId: string +): Promise { + const url = `/session/${sessionId}/status`; + log("selenium-client", "GET session status", { + url: SELENIUM_AGENT_BASE + url, + sessionId, + }); + const r = await requestWithRetries({ url, method: "GET" }, 4); + log("selenium-client", "session status response", { + status: r.status, + dataKeys: r.data ? Object.keys(r.data) : null, + }); + if (r.status === 404) { + const e: any = new Error("not_found"); + e.response = { status: 404, data: r.data }; + throw e; + } + return r.data; +} diff --git a/apps/Frontend/src/components/insurance-status/deltaindiv-button-modal.tsx b/apps/Frontend/src/components/insurance-status/deltaindiv-button-modal.tsx new file mode 100644 index 00000000..13f4bf95 --- /dev/null +++ b/apps/Frontend/src/components/insurance-status/deltaindiv-button-modal.tsx @@ -0,0 +1,435 @@ +import { useEffect, useRef, useState } from "react"; +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/seleniumTaskSlice"; +import { formatLocalDate } from "@/utils/dateUtils"; +import { socket } from "@/lib/socket"; +import { QK_PATIENTS_BASE } from "@/components/patients/patient-table"; + +// ─── OTP Modal ──────────────────────────────────────────────────────────────── + +interface DeltaIndivOtpModalProps { + open: boolean; + onClose: () => void; + onSubmit: (otp: string) => Promise | void; + isSubmitting: boolean; +} + +function DeltaIndivOtpModal({ open, onClose, onSubmit, isSubmitting }: DeltaIndivOtpModalProps) { + const [otp, setOtp] = useState(""); + + useEffect(() => { + if (!open) setOtp(""); + }, [open]); + + if (!open) return null; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!otp.trim()) return; + await onSubmit(otp.trim()); + }; + + return ( +
+
+
+

Enter OTP

+ +
+

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

+
+
+ + setOtp(e.target.value)} + autoFocus + /> +
+
+ + +
+
+
+
+ ); +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +interface DeltaIndivEligibilityButtonProps { + memberId: string; + dateOfBirth: Date | null; + firstName?: string; + lastName?: string; + isFormIncomplete: boolean; + autoTrigger?: boolean; + onAutoTriggered?: () => void; + onPdfReady: (pdfId: number | string, fallbackFilename: string | null) => void; +} + +export function DeltaIndivEligibilityButton({ + memberId, + dateOfBirth, + firstName, + lastName, + isFormIncomplete, + autoTrigger, + onAutoTriggered, + onPdfReady, +}: DeltaIndivEligibilityButtonProps) { + const { toast } = useToast(); + const dispatch = useAppDispatch(); + + const sessionIdRef = useRef(null); + const autoTriggeredRef = useRef(false); + + const [otpModalOpen, setOtpModalOpen] = useState(false); + const [isStarting, setIsStarting] = useState(false); + const [isSubmittingOtp, setIsSubmittingOtp] = useState(false); + + // ── Socket event handlers ───────────────────────────────────────────────── + + const handleDeltaIndivStart = async () => { + if (!memberId || !dateOfBirth) { + toast({ + title: "Missing fields", + description: "Member ID and Date of Birth are required.", + variant: "destructive", + }); + return; + } + + const formattedDob = formatLocalDate(dateOfBirth); + const payload = { + memberId, + dateOfBirth: formattedDob, + firstName, + lastName, + insuranceSiteKey: "DELTAINDIV", + }; + + setIsStarting(true); + + try { + dispatch( + setTaskStatus({ + key: "eligibilityCheck", + status: "pending", + message: "Starting Delta Indiv eligibility check…", + }) + ); + + // 1) POST to backend — returns { status: "queued", jobId } + const response = await apiRequest( + "POST", + "/api/insurance-status-deltaindiv/deltaindiv-eligibility", + { data: JSON.stringify(payload), socketId: socket.id } + ); + + const result = await response.json(); + if (!response.ok || result.error) { + throw new Error(result.error || `Server error (${response.status})`); + } + + const jobId: string = result.jobId; + if (!jobId) throw new Error("No jobId returned from server"); + + dispatch( + setTaskStatus({ + key: "eligibilityCheck", + status: "pending", + message: "Delta Indiv job queued. Waiting for browser session to start…", + }) + ); + + // 2) Listen for job-lifecycle and Delta Indiv-specific socket events. + // All events come through the shared app socket. + + // Handler: Python agent started a browser session → we now have session_id + const onSessionStarted = (data: any) => { + if (String(data?.jobId) !== String(jobId)) return; + sessionIdRef.current = data.session_id ?? null; + dispatch( + setTaskStatus({ + key: "eligibilityCheck", + status: "pending", + message: "Browser session started. Waiting for OTP or result…", + }) + ); + }; + + // Handler: OTP is required by the Delta Indiv portal + const onOtpRequired = (data: any) => { + if (String(data?.jobId) !== String(jobId)) return; + // Update sessionId in case it arrives here first + if (data.session_id) sessionIdRef.current = data.session_id; + setOtpModalOpen(true); + dispatch( + setTaskStatus({ + key: "eligibilityCheck", + status: "pending", + message: "OTP required for Delta Dental Indiv. Please enter the code.", + }) + ); + }; + + // Handler: OTP accepted by Python agent (optional UX feedback) + const onOtpSubmitted = (data: any) => { + if (data?.session_id && data.session_id !== sessionIdRef.current) return; + dispatch( + setTaskStatus({ + key: "eligibilityCheck", + status: "pending", + message: "OTP submitted. Finishing Delta Indiv eligibility check…", + }) + ); + }; + + // Handler: job completed or failed (from InProcessQueue) + const onJobUpdate = (data: any) => { + if (String(data?.jobId) !== String(jobId)) return; + + if (data.status === "active") { + dispatch( + setTaskStatus({ + key: "eligibilityCheck", + status: "pending", + message: data.message ?? "Selenium browser starting…", + }) + ); + return; + } + + // Terminal states + cleanup(); + + if (data.status === "completed") { + dispatch( + setTaskStatus({ + key: "eligibilityCheck", + status: "success", + message: "Delta Indiv eligibility updated and PDF attached to patient documents.", + }) + ); + toast({ + title: "Delta Indiv eligibility complete", + description: "Patient status was updated and the eligibility PDF was saved.", + }); + + queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE }); + + const pdfId = data.result?.pdfFileId; + if (pdfId) { + const filename = + data.result?.pdfFilename ?? `eligibility_deltaindiv_${memberId}.pdf`; + onPdfReady(pdfId, filename); + } + } else if (data.status === "failed") { + const msg = data.error ?? "Delta Indiv eligibility job failed."; + dispatch( + setTaskStatus({ key: "eligibilityCheck", status: "error", message: msg }) + ); + toast({ title: "Delta Indiv selenium error", description: msg, variant: "destructive" }); + } + + setIsStarting(false); + setOtpModalOpen(false); + }; + + // Attach listeners + socket.on("selenium:deltaindiv_session_started", onSessionStarted); + socket.on("selenium:otp_required", onOtpRequired); + socket.on("selenium:otp_submitted", onOtpSubmitted); + socket.on("job:update", onJobUpdate); + + // Cleanup helper removes all listeners for this job + function cleanup() { + socket.off("selenium:deltaindiv_session_started", onSessionStarted); + socket.off("selenium:otp_required", onOtpRequired); + socket.off("selenium:otp_submitted", onOtpSubmitted); + socket.off("job:update", onJobUpdate); + } + + // Safety timeout — clean up listeners if no terminal event in 6 min + const safetyTimer = setTimeout(() => { + cleanup(); + setIsStarting(false); + setOtpModalOpen(false); + dispatch( + setTaskStatus({ + key: "eligibilityCheck", + status: "error", + message: "Delta Indiv job timed out waiting for completion.", + }) + ); + }, 6 * 60 * 1000); + + // Patch cleanup to also clear the timer + const originalCleanup = cleanup; + function cleanupWithTimer() { + clearTimeout(safetyTimer); + originalCleanup(); + } + // Override the onJobUpdate cleanup reference + socket.off("job:update", onJobUpdate); + socket.on("job:update", (data: any) => { + if (String(data?.jobId) !== String(jobId)) return; + if (data.status === "active") { + dispatch( + setTaskStatus({ + key: "eligibilityCheck", + status: "pending", + message: data.message ?? "Selenium browser starting…", + }) + ); + return; + } + cleanupWithTimer(); + if (data.status === "completed") { + dispatch( + setTaskStatus({ + key: "eligibilityCheck", + status: "success", + message: "Delta Indiv eligibility updated and PDF attached to patient documents.", + }) + ); + toast({ + title: "Delta Indiv eligibility complete", + description: "Patient status was updated and the eligibility PDF was saved.", + }); + queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE }); + const pdfId = data.result?.pdfFileId; + if (pdfId) { + onPdfReady(pdfId, data.result?.pdfFilename ?? `eligibility_deltaindiv_${memberId}.pdf`); + } + } else if (data.status === "failed") { + const msg = data.error ?? "Delta Indiv eligibility job failed."; + dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: msg })); + toast({ title: "Delta Indiv selenium error", description: msg, variant: "destructive" }); + } + setIsStarting(false); + setOtpModalOpen(false); + }); + + } catch (err: any) { + console.error("DeltaIndivEligibilityButton error:", err); + dispatch( + setTaskStatus({ + key: "eligibilityCheck", + status: "error", + message: err?.message || "Failed to start Delta Indiv eligibility", + }) + ); + toast({ + title: "Delta Indiv selenium error", + description: err?.message || "Failed to start Delta Indiv eligibility", + variant: "destructive", + }); + setIsStarting(false); + } + }; + + // ── OTP submission ──────────────────────────────────────────────────────── + + const handleSubmitOtp = async (otp: string) => { + const sessionId = sessionIdRef.current; + if (!sessionId) { + toast({ + title: "Session not ready", + description: "Cannot submit OTP — Delta Indiv session ID is not available yet.", + variant: "destructive", + }); + return; + } + + try { + setIsSubmittingOtp(true); + const resp = await apiRequest("POST", "/api/insurance-status-deltaindiv/selenium/submit-otp", { + session_id: sessionId, + otp, + socketId: socket.id, + }); + const data = await resp.json(); + if (!resp.ok || data.error) { + throw new Error(data.error || "Failed to submit OTP"); + } + 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); + } + }; + + useEffect(() => { + if (!autoTrigger) { autoTriggeredRef.current = false; return; } + if (autoTriggeredRef.current || isFormIncomplete) return; + autoTriggeredRef.current = true; + onAutoTriggered?.(); + handleDeltaIndivStart(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autoTrigger, isFormIncomplete]); + + // ── Render ──────────────────────────────────────────────────────────────── + + return ( + <> + + + setOtpModalOpen(false)} + onSubmit={handleSubmitOtp} + isSubmitting={isSubmittingOtp} + /> + + ); +} diff --git a/apps/Frontend/src/components/settings/InsuranceCredForm.tsx b/apps/Frontend/src/components/settings/InsuranceCredForm.tsx index 77581b60..cb0fa0f6 100755 --- a/apps/Frontend/src/components/settings/InsuranceCredForm.tsx +++ b/apps/Frontend/src/components/settings/InsuranceCredForm.tsx @@ -19,6 +19,7 @@ const SITE_KEY_OPTIONS = [ { value: "MH", label: "MassHealth (MH)" }, { value: "DDMA", label: "Delta Dental MA (DDMA)" }, { value: "DELTAINS", label: "Delta Dental Ins (DELTAINS)" }, + { value: "DELTAINDIV", label: "Delta Dental Indiv (DELTAINDIV)" }, { value: "TUFTS_SCO", label: "Tufts SCO (TUFTS_SCO)" }, { value: "UNITED_SCO", label: "United SCO / DentalHub (UNITED_SCO)" }, { value: "UNITED_UCARD", label: "United Ucard (UNITED_UCARD)" }, diff --git a/apps/Frontend/src/pages/insurance-status-page.tsx b/apps/Frontend/src/pages/insurance-status-page.tsx old mode 100644 new mode 100755 index 6642133e..2cf6d514 --- a/apps/Frontend/src/pages/insurance-status-page.tsx +++ b/apps/Frontend/src/pages/insurance-status-page.tsx @@ -38,6 +38,7 @@ import { PdfPreviewModal } from "@/components/insurance-status/pdf-preview-modal import { DualPdfPreviewModal } from "@/components/insurance-status/dual-pdf-preview-modal"; import { useLocation } from "wouter"; import { DdmaEligibilityButton } from "@/components/insurance-status/ddma-buton-modal"; +import { DeltaIndivEligibilityButton } from "@/components/insurance-status/deltaindiv-button-modal"; import { DeltaInsEligibilityButton } from "@/components/insurance-status/deltains-button-modal"; import { TuftsSCOEligibilityButton } from "@/components/insurance-status/tufts-sco-button-modal"; import { UnitedSCOEligibilityButton } from "@/components/insurance-status/united-sco-button-modal"; @@ -174,7 +175,7 @@ export default function InsuranceStatusPage() { const [cmspAccumulatorPdfId, setCmspAccumulatorPdfId] = useState(null); const [cmspAccumulatorFilename, setCmspAccumulatorFilename] = useState(null); - const pendingAutoCheck = useRef<"mh" | "mh-history" | "cmsp" | "ddma" | "delta-ins" | "united-sco" | "united-ucard" | "tufts-sco" | "cca" | "bcbs-ma" | null>(null); + const pendingAutoCheck = useRef<"mh" | "mh-history" | "cmsp" | "ddma" | "delta-ins" | "delta-indiv" | "united-sco" | "united-ucard" | "tufts-sco" | "cca" | "bcbs-ma" | null>(null); const [triggerTarget, setTriggerTarget] = useState(null); const pendingScrollTo = useRef(null); const [prefillTick, setPrefillTick] = useState(0); @@ -915,7 +916,7 @@ export default function InsuranceStatusPage() { const autoCheck = params.get("autoCheck") as typeof pendingAutoCheck.current; const scrollTo = params.get("scrollTo"); - const knownAutoChecks = ["mh", "mh-history", "cmsp", "ddma", "delta-ins", "united-sco", "united-ucard", "tufts-sco", "cca", "bcbs-ma"] as const; + const knownAutoChecks = ["mh", "mh-history", "cmsp", "ddma", "delta-ins", "delta-indiv", "united-sco", "united-ucard", "tufts-sco", "cca", "bcbs-ma"] as const; if (autoCheck && (knownAutoChecks as readonly string[]).includes(autoCheck)) { pendingAutoCheck.current = autoCheck; } else if (scrollTo) { @@ -1113,6 +1114,29 @@ export default function InsuranceStatusPage() { /> +
+ setTriggerTarget(null)} + onPdfReady={async (pdfId, fallbackFilename) => { + await queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE }); + const claimed = await tryClaimFromChatbot(selectedPatient?.id); + void tryAppointmentFromChatbot(); + if (!claimed) { + setPreviewPdfId(pdfId); + setPreviewFallbackFilename(fallbackFilename ?? `eligibility_deltaindiv_${memberId}.pdf`); + setPreviewOpen(true); + } + processNextInQueue(); + }} + /> +
+
" } + """ + global waiting_jobs + + body = await request.json() + data = body.get("data", {}) + + sid = hdeltaindiv.make_session_entry() + hdeltaindiv.sessions[sid]["type"] = "deltaindiv_eligibility" + hdeltaindiv.sessions[sid]["last_activity"] = time.time() + + async with lock: + waiting_jobs += 1 + + # TODO: replace with the real Delta Indiv sign-in URL + asyncio.create_task(_deltaindiv_worker_wrapper(sid, data, url="https://www.deltadentalma.com/sign-in")) + + return {"status": "started", "session_id": sid} + + async def _deltains_worker_wrapper(sid: str, data: dict, url: str): """Background worker for DeltaIns — acquires semaphore, updates counters.""" global active_jobs, waiting_jobs @@ -1058,6 +1100,8 @@ async def submit_otp(request: Request): # Try each session store in order if sid in hddma.sessions: res = hddma.submit_otp(sid, otp) + elif sid in hdeltaindiv.sessions: + res = hdeltaindiv.submit_otp(sid, otp) elif sid in hdeltains.sessions: res = hdeltains.submit_otp(sid, otp) elif sid in hunitedsco.sessions: @@ -1095,6 +1139,8 @@ async def session_status(sid: str): # Try each session store in order if sid in hddma.sessions: s = hddma.get_session_status(sid) + elif sid in hdeltaindiv.sessions: + s = hdeltaindiv.get_session_status(sid) elif sid in hdeltains.sessions: s = hdeltains.get_session_status(sid) elif sid in hunitedsco.sessions: @@ -1144,6 +1190,16 @@ async def clear_ddma_session_endpoint(): return {"status": "error", "message": str(e)} +@app.post("/clear-deltaindiv-session") +async def clear_deltaindiv_session_endpoint(): + """Clears the Delta Indiv browser session. Call when credentials are deleted or changed.""" + try: + clear_deltaindiv_session_on_startup() + return {"status": "success", "message": "Delta Indiv session cleared"} + except Exception as e: + return {"status": "error", "message": str(e)} + + @app.post("/clear-deltains-session") async def clear_deltains_session_endpoint(): """Clears the DeltaIns browser session. Call when credentials are deleted or changed.""" diff --git a/apps/SeleniumService/deltaindiv_browser_manager.py b/apps/SeleniumService/deltaindiv_browser_manager.py new file mode 100644 index 00000000..d1298850 --- /dev/null +++ b/apps/SeleniumService/deltaindiv_browser_manager.py @@ -0,0 +1,253 @@ +""" +Browser manager for Delta Indiv (Delta Dental Indiv) - persistent profile with session management. +- Uses --user-data-dir for persistent profile (device trust tokens) +- Clears session cookies on startup (after PC restart) to force fresh login +- Tracks credentials to detect changes mid-session (triggers logout) +- Anti-detection options to avoid bot detection +""" +import os +import glob +import shutil +import hashlib +import threading +from selenium import webdriver +from selenium.webdriver.chrome.service import Service +from webdriver_manager.chrome import ChromeDriverManager + +# Ensure DISPLAY is set for Chrome to work (needed when running from SSH/background) +if not os.environ.get("DISPLAY"): + os.environ["DISPLAY"] = ":0" + + +class DeltaIndivBrowserManager: + """ + Singleton that manages a persistent Chrome browser instance. + - Uses --user-data-dir for persistent profile (device trust tokens) + - Clears session cookies on startup (after PC restart) + - Tracks credentials to detect changes mid-session + """ + _instance = None + _lock = threading.Lock() + + def __new__(cls): + with cls._lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._driver = None + cls._instance.profile_dir = os.path.abspath("chrome_profile_deltaindiv") + cls._instance.download_dir = os.path.abspath("seleniumDownloads") + cls._instance._credentials_file = os.path.join(cls._instance.profile_dir, ".last_credentials") + cls._instance._needs_session_clear = False + os.makedirs(cls._instance.profile_dir, exist_ok=True) + os.makedirs(cls._instance.download_dir, exist_ok=True) + return cls._instance + + def clear_session_on_startup(self): + """ + Clear only login cookies on startup to force credential re-entry after restart. + NEVER clears Local Storage or IndexedDB — those hold the Delta Indiv device trust token + that allows the portal to skip OTP for recognised devices. + """ + print("[Delta Indiv BrowserManager] Clearing login cookies on startup (preserving device trust)...") + + try: + # Reset credentials tracking so the next login re-saves the hash + if os.path.exists(self._credentials_file): + os.remove(self._credentials_file) + print("[Delta Indiv BrowserManager] Cleared credentials tracking file") + + # Only remove cookie / login-data files — these expire the session so the + # user must re-enter credentials, but the device trust token is untouched. + session_files = [ + "Cookies", + "Cookies-journal", + "Login Data", + "Login Data-journal", + ] + for filename in session_files: + for base in [os.path.join(self.profile_dir, "Default"), self.profile_dir]: + filepath = os.path.join(base, filename) + if os.path.exists(filepath): + try: + os.remove(filepath) + print(f"[Delta Indiv BrowserManager] Removed {filename}") + except Exception as e: + print(f"[Delta Indiv BrowserManager] Could not remove {filename}: {e}") + + # Local Storage, IndexedDB, and Session Storage are intentionally + # NOT cleared — they contain the Delta Indiv device trust token that prevents + # OTP from being required on every login. + + self._needs_session_clear = True + print("[Delta Indiv BrowserManager] Startup clear done — device trust preserved, OTP not required") + + except Exception as e: + print(f"[Delta Indiv BrowserManager] Error clearing session: {e}") + + # ── Credential hash tracking ────────────────────────────────────────────── + + def _hash_credentials(self, username: str) -> str: + return hashlib.sha256(username.encode()).hexdigest()[:16] + + def get_last_credentials_hash(self) -> str | None: + try: + if os.path.exists(self._credentials_file): + with open(self._credentials_file, 'r') as f: + return f.read().strip() + except Exception: + pass + return None + + def save_credentials_hash(self, username: str): + try: + cred_hash = self._hash_credentials(username) + with open(self._credentials_file, 'w') as f: + f.write(cred_hash) + except Exception as e: + print(f"[Delta Indiv BrowserManager] Failed to save credentials hash: {e}") + + def credentials_changed(self, username: str) -> bool: + last_hash = self.get_last_credentials_hash() + if last_hash is None: + return False # No previous credentials stored — not a change + current_hash = self._hash_credentials(username) + changed = last_hash != current_hash + if changed: + print("[Delta Indiv BrowserManager] Credentials changed — logout required") + return changed + + def clear_credentials_hash(self): + try: + if os.path.exists(self._credentials_file): + os.remove(self._credentials_file) + except Exception as e: + print(f"[Delta Indiv BrowserManager] Failed to clear credentials hash: {e}") + + # ── Chrome process management ───────────────────────────────────────────── + + def _kill_existing_chrome_for_profile(self): + """Kill any existing Chrome processes using this profile and remove lock files.""" + import subprocess + import time as time_module + try: + result = subprocess.run( + ["pgrep", "-f", f"user-data-dir={self.profile_dir}"], + capture_output=True, text=True + ) + if result.stdout.strip(): + for pid in result.stdout.strip().split('\n'): + try: + subprocess.run(["kill", "-9", pid], check=False) + except Exception: + pass + time_module.sleep(1) + except Exception: + pass + + for lock_file in ["SingletonLock", "SingletonSocket", "SingletonCookie"]: + lock_path = os.path.join(self.profile_dir, lock_file) + try: + if os.path.islink(lock_path) or os.path.exists(lock_path): + os.remove(lock_path) + except Exception: + pass + + # ── Driver lifecycle ────────────────────────────────────────────────────── + + def get_driver(self, headless=False): + """Get or create the persistent browser instance.""" + with self._lock: + if self._driver is None: + print("[Delta Indiv BrowserManager] Driver is None, creating new driver") + self._kill_existing_chrome_for_profile() + self._create_driver(headless) + elif not self._is_alive(): + print("[Delta Indiv BrowserManager] Driver not alive, recreating") + self._kill_existing_chrome_for_profile() + self._create_driver(headless) + else: + print("[Delta Indiv BrowserManager] Reusing existing driver") + return self._driver + + def _is_alive(self): + try: + if self._driver is None: + return False + url = self._driver.current_url + print(f"[Delta Indiv BrowserManager] Driver alive, current URL: {url[:50]}...") + return True + except Exception as e: + print(f"[Delta Indiv BrowserManager] Driver not alive: {e}") + return False + + def _create_driver(self, headless=False): + """Create browser with persistent profile and anti-detection options.""" + if self._driver: + try: + self._driver.quit() + except Exception: + pass + + options = webdriver.ChromeOptions() + if headless: + options.add_argument("--headless") + + # Persistent profile — keeps device trust tokens between runs + options.add_argument(f"--user-data-dir={self.profile_dir}") + options.add_argument("--no-sandbox") + options.add_argument("--disable-dev-shm-usage") + + # Anti-detection + options.add_argument("--disable-blink-features=AutomationControlled") + options.add_experimental_option("excludeSwitches", ["enable-automation"]) + options.add_experimental_option("useAutomationExtension", False) + options.add_argument("--disable-infobars") + + prefs = { + "download.default_directory": self.download_dir, + "plugins.always_open_pdf_externally": True, + "download.prompt_for_download": False, + "download.directory_upgrade": True, + } + options.add_experimental_option("prefs", prefs) + + service = Service(ChromeDriverManager().install()) + self._driver = webdriver.Chrome(service=service, options=options) + self._driver.maximize_window() + + # Remove webdriver property to avoid detection + try: + self._driver.execute_script( + "Object.defineProperty(navigator, 'webdriver', {get: () => undefined})" + ) + except Exception: + pass + + self._needs_session_clear = False + + def quit_driver(self): + """Quit browser (only call on shutdown — NOT between patients).""" + with self._lock: + if self._driver: + try: + self._driver.quit() + except Exception: + pass + self._driver = None + + +# ── Singleton accessor ──────────────────────────────────────────────────────── + +_manager = None + +def get_browser_manager() -> DeltaIndivBrowserManager: + global _manager + if _manager is None: + _manager = DeltaIndivBrowserManager() + return _manager + + +def clear_deltaindiv_session_on_startup(): + """Called by agent.py on startup to clear Delta Indiv session (after PC restart).""" + manager = get_browser_manager() + manager.clear_session_on_startup() diff --git a/apps/SeleniumService/helpers_deltaindiv_eligibility.py b/apps/SeleniumService/helpers_deltaindiv_eligibility.py new file mode 100644 index 00000000..222750f9 --- /dev/null +++ b/apps/SeleniumService/helpers_deltaindiv_eligibility.py @@ -0,0 +1,297 @@ +import os +import time +import asyncio +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, TimeoutException + +from selenium_DeltaIndiv_eligibilityCheckWorker import AutomationDeltaIndivEligibilityCheck + +# In-memory session store +sessions: Dict[str, Dict[str, Any]] = {} + +SESSION_OTP_TIMEOUT = int(os.getenv("SESSION_OTP_TIMEOUT", "120")) # seconds + + +def make_session_entry() -> str: + """Create a new session entry and return its ID.""" + import uuid + sid = str(uuid.uuid4()) + sessions[sid] = { + "status": "created", # created → running → waiting_for_otp → completed / error + "created_at": time.time(), + "last_activity": time.time(), + "bot": None, # AutomationDeltaIndivEligibilityCheck instance + "driver": None, # selenium webdriver + "otp_event": asyncio.Event(), + "otp_value": None, # OTP submitted from the app + "result": None, + "message": None, + "type": None, + } + return sid + + +async def cleanup_session(sid: str, message: str | None = None): + """ + Wake any OTP waiter, set final state, and remove the session entry. + Safe to call multiple times (idempotent). + NOTE: Does NOT quit the browser driver — the persistent browser stays alive. + """ + s = sessions.get(sid) + if not s: + return + try: + if s.get("status") not in ("completed", "error", "not_found"): + s["status"] = "error" + if message: + s["message"] = message + + ev = s.get("otp_event") + if ev and not ev.is_set(): + ev.set() + finally: + sessions.pop(sid, None) + print(f"[helpers_deltaindiv] cleaned session {sid}") + + +async def _remove_session_later(sid: str, delay: int = 20): + await asyncio.sleep(delay) + await cleanup_session(sid) + + +async def start_deltaindiv_run(sid: str, data: dict, url: str): + """ + Run the full Delta Indiv eligibility workflow for one session. + Called by agent.py inside a wrapper that manages the semaphore/counters. + + OTP handling uses two complementary strategies: + 1. Accept OTP submitted from the app (via /submit-otp endpoint → otp_value field) + 2. Poll the browser URL/DOM directly to detect when the user enters OTP themselves + """ + s = sessions.get(sid) + if not s: + return {"status": "error", "message": "session not found"} + + s["status"] = "running" + s["last_activity"] = time.time() + + try: + bot = AutomationDeltaIndivEligibilityCheck({"data": data}) + bot.config_driver() + + s["bot"] = bot + s["driver"] = bot.driver + s["last_activity"] = time.time() + + # Navigate to login page + try: + if not url: + raise ValueError("URL not provided for Delta Indiv run") + bot.driver.maximize_window() + bot.driver.get(url) + await asyncio.sleep(1) + except Exception as e: + s["status"] = "error" + s["message"] = f"Navigation failed: {e}" + await cleanup_session(sid) + return {"status": "error", "message": s["message"]} + + # 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"]} + + # ── Path: already logged in (persistent session) ────────────────────── + if isinstance(login_result, str) and login_result == "ALREADY_LOGGED_IN": + print("[start_deltaindiv_run] Session persisted - skipping OTP") + s["status"] = "running" + s["message"] = "Session persisted" + + # ── Path: OTP required ──────────────────────────────────────────────── + elif isinstance(login_result, str) and login_result == "OTP_REQUIRED": + s["status"] = "waiting_for_otp" + s["message"] = "OTP required for login - please enter OTP" + s["last_activity"] = time.time() + + driver = s["driver"] + + # Poll every second for up to SESSION_OTP_TIMEOUT seconds. + # Accept OTP from two sources: + # a) app API (otp_value set by submit_otp()) + # b) user entering OTP directly in the browser window + max_polls = SESSION_OTP_TIMEOUT + login_success = False + + print(f"[OTP] Polling for OTP completion (up to {SESSION_OTP_TIMEOUT}s)...") + + for poll in range(max_polls): + await asyncio.sleep(1) + s["last_activity"] = time.time() + + try: + # a) App submitted OTP via /submit-otp endpoint + otp_value = s.get("otp_value") + if otp_value: + print(f"[OTP poll {poll+1}] OTP received from app, typing it in...") + try: + otp_input = driver.find_element(By.XPATH, "//input[@data-test-id='MfaCode']") + otp_input.clear() + otp_input.send_keys(otp_value) + try: + verify_btn = driver.find_element(By.XPATH, "//button[@data-test-id='ConfirmSubmitButton']") + verify_btn.click() + except Exception: + otp_input.send_keys("\n") + print("[OTP] OTP typed and submitted via app") + s["otp_value"] = None # Clear so we don't re-submit + await asyncio.sleep(3) + except Exception as type_err: + print(f"[OTP] Failed to type OTP from app: {type_err}") + + # b) Check URL — if we're past OTP page, login succeeded + current_url = driver.current_url.lower() + print(f"[OTP poll {poll+1}/{max_polls}] URL: {current_url[:70]}...") + + if "member" in current_url or "dashboard" in current_url or "eligibility" in current_url: + try: + member_search = WebDriverWait(driver, 5).until( + EC.presence_of_element_located((By.XPATH, "//a[contains(@href, 'check-patient-benefits-search')]")) + ) + print("[OTP] Member search found — login successful!") + login_success = True + break + except TimeoutException: + print("[OTP] On member page but search input not found, continuing...") + + # Check if OTP input is still visible (user hasn't finished) + try: + otp_input_elem = driver.find_element(By.XPATH, "//input[@data-test-id='MfaCode']") + print(f"[OTP poll {poll+1}] OTP input still visible - waiting...") + except Exception: + # OTP input gone — may mean login is completing; try members page + if "sign-in" in current_url: + print("[OTP] OTP input gone, trying to navigate to members page...") + try: + driver.get("https://www.deltadentalma.com/private/provider/dashboard") + await asyncio.sleep(2) + except Exception: + pass + + except Exception as poll_err: + print(f"[OTP poll {poll+1}] Error: {poll_err}") + + if not login_success: + # Final check — navigate directly to members page + try: + print("[OTP] Final attempt - navigating to members page...") + driver.get("https://www.deltadentalma.com/private/provider/dashboard") + await asyncio.sleep(3) + member_search = WebDriverWait(driver, 10).until( + EC.presence_of_element_located((By.XPATH, "//a[contains(@href, 'check-patient-benefits-search')]")) + ) + print("[OTP] Member search found — login successful!") + login_success = True + except TimeoutException: + s["status"] = "error" + s["message"] = "OTP timeout - login not completed" + await cleanup_session(sid) + return {"status": "error", "message": "OTP not completed in time"} + except Exception as final_err: + s["status"] = "error" + s["message"] = f"OTP verification failed: {final_err}" + await cleanup_session(sid) + return {"status": "error", "message": s["message"]} + + if login_success: + s["status"] = "running" + s["message"] = "Login successful after OTP" + print("[OTP] Proceeding to step1...") + + # ── Path: login succeeded without OTP ───────────────────────────────── + elif isinstance(login_result, str) and login_result == "SUCCESS": + print("[start_deltaindiv_run] Login succeeded without OTP") + s["status"] = "running" + s["message"] = "Login succeeded" + + # ── Path: login error ────────────────────────────────────────────────── + elif isinstance(login_result, str) and login_result.startswith("ERROR"): + s["status"] = "error" + s["message"] = login_result + await cleanup_session(sid) + return {"status": "error", "message": login_result} + + # ── Step 1: search ──────────────────────────────────────────────────── + step1_result = bot.step1() + if isinstance(step1_result, str) and step1_result.startswith("ERROR"): + s["status"] = "error" + s["message"] = step1_result + await cleanup_session(sid) + return {"status": "error", "message": step1_result} + + # ── Step 2: PDF generation ──────────────────────────────────────────── + step2_result = bot.step2() + if isinstance(step2_result, dict) and step2_result.get("status") == "success": + s["status"] = "completed" + s["result"] = step2_result + s["message"] = "completed" + asyncio.create_task(_remove_session_later(sid, 30)) + return step2_result + else: + s["status"] = "error" + if isinstance(step2_result, dict): + s["message"] = step2_result.get("message", "unknown error") + else: + s["message"] = str(step2_result) + await cleanup_session(sid) + return {"status": "error", "message": s["message"]} + + except Exception as e: + s["status"] = "error" + s["message"] = f"worker exception: {e}" + await cleanup_session(sid) + return {"status": "error", "message": s["message"]} + + +def submit_otp(sid: str, otp: str) -> Dict[str, Any]: + """ + Called when the app sends an OTP via POST /submit-otp. + Sets otp_value on the session so the polling loop picks it up. + """ + s = sessions.get(sid) + if not s: + return {"status": "error", "message": "session not found"} + if s.get("status") != "waiting_for_otp": + return {"status": "error", "message": f"session not waiting for OTP (state={s.get('status')})"} + s["otp_value"] = otp + s["last_activity"] = time.time() + try: + s["otp_event"].set() + except Exception: + pass + return {"status": "ok", "message": "otp accepted"} + + +def get_session_status(sid: str) -> Dict[str, Any]: + s = sessions.get(sid) + if not s: + return {"status": "not_found"} + return { + "session_id": sid, + "status": s.get("status"), + "message": s.get("message"), + "created_at": s.get("created_at"), + "last_activity": s.get("last_activity"), + "result": s.get("result") if s.get("status") == "completed" else None, + } diff --git a/apps/SeleniumService/selenium_CCA_eligibilityCheckWorker.py b/apps/SeleniumService/selenium_CCA_eligibilityCheckWorker.py index 6c525283..309cf1dd 100644 --- a/apps/SeleniumService/selenium_CCA_eligibilityCheckWorker.py +++ b/apps/SeleniumService/selenium_CCA_eligibilityCheckWorker.py @@ -466,13 +466,22 @@ class AutomationCCAEligibilityCheck: if "no results" in body_text.lower() or "not found" in body_text.lower() or "no patient" in body_text.lower(): return "ERROR: No patient found with the provided Subscriber ID and DOB" + # Patient popup already rendered — this is a success regardless of any + # unrelated banner (e.g. a password-expiration notice) also on the page. + if "patient selected" in body_text.lower() or "patient information" in body_text.lower(): + return "SUCCESS" + # Check for error alerts try: alerts = self.driver.find_elements(By.XPATH, "//*[@role='alert'] | //*[contains(@class,'alert-danger')]") for alert in alerts: - if alert.is_displayed() and alert.text.strip(): - return f"ERROR: {alert.text.strip()[:200]}" + alert_text = alert.text.strip() + if not alert.is_displayed() or not alert_text: + continue + if "password" in alert_text.lower() and "expire" in alert_text.lower(): + continue + return f"ERROR: {alert_text[:200]}" except Exception: pass diff --git a/apps/SeleniumService/selenium_DeltaIndiv_eligibilityCheckWorker.py b/apps/SeleniumService/selenium_DeltaIndiv_eligibilityCheckWorker.py new file mode 100644 index 00000000..0e9eb3f6 --- /dev/null +++ b/apps/SeleniumService/selenium_DeltaIndiv_eligibilityCheckWorker.py @@ -0,0 +1,704 @@ +from selenium import webdriver +from selenium.common.exceptions import WebDriverException, TimeoutException +from selenium.webdriver.chrome.service import Service +from selenium.webdriver.common.by import By +from selenium.webdriver.common.keys import Keys +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC +from webdriver_manager.chrome import ChromeDriverManager +import time +import os +import re +import base64 +import calendar + +from deltaindiv_browser_manager import get_browser_manager + +class AutomationDeltaIndivEligibilityCheck: + def __init__(self, data): + self.headless = False + self.driver = None + + self.data = data.get("data", {}) if isinstance(data, dict) else {} + + # Flatten values for convenience + self.memberId = self.data.get("memberId", "") + self.dateOfBirth = self.data.get("dateOfBirth", "") + self.firstName = self.data.get("firstName", "") + self.lastName = self.data.get("lastName", "") + self.deltaindiv_username = self.data.get("deltaindivUsername", "") + self.deltaindiv_password = self.data.get("deltaindivPassword", "") + self.providerName = self.data.get("providerName", "") + + # Use browser manager's download dir + self.download_dir = get_browser_manager().download_dir + os.makedirs(self.download_dir, exist_ok=True) + + def config_driver(self): + # Use persistent browser from manager (keeps device trust tokens) + self.driver = get_browser_manager().get_driver(self.headless) + + def _force_logout(self): + """Force logout by clearing cookies when credentials change.""" + try: + print("[Delta Indiv login] Forcing logout due to credential change...") + browser_manager = get_browser_manager() + + # Try to click logout button if visible + try: + self.driver.get("https://www.deltadentalma.com/") + time.sleep(2) + + logout_selectors = [ + "//button[contains(text(), 'Log out') or contains(text(), 'Logout') or contains(text(), 'Sign out')]", + "//a[contains(text(), 'Log out') or contains(text(), 'Logout') or contains(text(), 'Sign out')]", + "//button[@aria-label='Log out' or @aria-label='Logout' or @aria-label='Sign out']", + "//*[contains(@class, 'logout') or contains(@class, 'signout')]" + ] + + for selector in logout_selectors: + try: + logout_btn = WebDriverWait(self.driver, 3).until( + EC.element_to_be_clickable((By.XPATH, selector)) + ) + logout_btn.click() + print("[Delta Indiv login] Clicked logout button") + time.sleep(2) + break + except TimeoutException: + continue + except Exception as e: + print(f"[Delta Indiv login] Could not click logout button: {e}") + + # Clear cookies as backup + try: + self.driver.delete_all_cookies() + print("[Delta Indiv login] Cleared all cookies") + except Exception as e: + print(f"[Delta Indiv login] Error clearing cookies: {e}") + + browser_manager.clear_credentials_hash() + print("[Delta Indiv login] Logout complete") + return True + except Exception as e: + print(f"[Delta Indiv login] Error during forced logout: {e}") + return False + + def login(self, url): + wait = WebDriverWait(self.driver, 30) + browser_manager = get_browser_manager() + + try: + # Check if credentials changed — force logout first + if self.deltaindiv_username and browser_manager.credentials_changed(self.deltaindiv_username): + self._force_logout() + self.driver.get(url) + time.sleep(2) + + # Check if already on a logged-in page (persistent session from profile) + try: + current_url = self.driver.current_url + print(f"[login] Current URL: {current_url}") + + logged_in_patterns = ["member", "dashboard", "eligibility", "search", "patients", "private/provider"] + is_logged_in_url = any(pattern in current_url.lower() for pattern in logged_in_patterns) + + if is_logged_in_url and "sign-in" not in current_url.lower(): + print("[login] Already on logged-in page - skipping login entirely") + if "member" not in current_url.lower(): + try: + member_search = WebDriverWait(self.driver, 5).until( + EC.presence_of_element_located((By.XPATH, "//a[contains(@href, 'check-patient-benefits-search')]")) + ) + print("[login] Found member search input - returning ALREADY_LOGGED_IN") + return "ALREADY_LOGGED_IN" + except TimeoutException: + members_url = "https://www.deltadentalma.com/private/provider/dashboard" + print(f"[login] Navigating to members page: {members_url}") + self.driver.get(members_url) + time.sleep(2) + + try: + member_search = WebDriverWait(self.driver, 5).until( + EC.presence_of_element_located((By.XPATH, "//a[contains(@href, 'check-patient-benefits-search')]")) + ) + print("[login] Member search found - ALREADY_LOGGED_IN") + return "ALREADY_LOGGED_IN" + except TimeoutException: + print("[login] Could not find member search, will try login") + except Exception as e: + print(f"[login] Error checking current state: {e}") + + # Navigate to login URL + self.driver.get(url) + time.sleep(2) + + # Check if session redirected us straight to member search + try: + current_url = self.driver.current_url + print(f"[login] URL after navigation: {current_url}") + + if "sign-in" not in current_url.lower(): + member_search = WebDriverWait(self.driver, 3).until( + EC.presence_of_element_located((By.XPATH, "//a[contains(@href, 'check-patient-benefits-search')]")) + ) + if member_search: + print("[login] Session valid - skipping login") + return "ALREADY_LOGGED_IN" + except TimeoutException: + print("[login] Proceeding with login") + + # Dismiss any "Authentication flow continued in another tab" modal + modal_dismissed = False + try: + ok_button = WebDriverWait(self.driver, 3).until( + EC.element_to_be_clickable((By.XPATH, "//button[normalize-space(text())='Ok' or normalize-space(text())='OK']")) + ) + ok_button.click() + print("[login] Dismissed authentication modal") + modal_dismissed = True + time.sleep(2) + + all_windows = self.driver.window_handles + print(f"[login] Windows after modal dismiss: {len(all_windows)}") + + if len(all_windows) > 1: + original_window = self.driver.current_window_handle + for window in all_windows: + if window != original_window: + self.driver.switch_to.window(window) + print("[login] Switched to auth popup window") + break + + try: + otp_candidate = WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located( + (By.XPATH, "//input[@data-test-id='MfaCode']") + ) + ) + if otp_candidate: + print("[login] OTP input found in popup -> OTP_REQUIRED") + return "OTP_REQUIRED" + except TimeoutException: + print("[login] No OTP in popup, checking main window") + self.driver.switch_to.window(original_window) + + except TimeoutException: + pass # No modal present + + if modal_dismissed: + time.sleep(2) + try: + member_search = WebDriverWait(self.driver, 5).until( + EC.presence_of_element_located((By.XPATH, "//a[contains(@href, 'check-patient-benefits-search')]")) + ) + if member_search: + print("[login] Already authenticated after modal dismiss") + return "ALREADY_LOGGED_IN" + except TimeoutException: + pass + + # Fill login form + try: + email_field = WebDriverWait(self.driver, 10).until( + EC.element_to_be_clickable((By.XPATH, "//input[@data-test-id='Username']")) + ) + except TimeoutException: + print("[login] Could not find login form - page may have changed") + return "ERROR: Login form not found" + + email_field = wait.until(EC.presence_of_element_located((By.XPATH, "//input[@data-test-id='Username']"))) + email_field.clear() + email_field.send_keys(self.deltaindiv_username) + + password_field = wait.until(EC.presence_of_element_located((By.XPATH, "//input[@data-test-id='Password']"))) + password_field.clear() + password_field.send_keys(self.deltaindiv_password) + + login_button = wait.until(EC.element_to_be_clickable((By.XPATH, "//button[@data-test-id='SigninSubmitButton']"))) + login_button.click() + + # Save credentials hash after login attempt + if self.deltaindiv_username: + browser_manager.save_credentials_hash(self.deltaindiv_username) + + # OTP detection — wait up to 30 seconds for OTP input + try: + otp_candidate = WebDriverWait(self.driver, 30).until( + EC.presence_of_element_located( + (By.XPATH, "//input[@data-test-id='MfaCode']") + ) + ) + if otp_candidate: + print("[login] OTP input detected -> OTP_REQUIRED") + return "OTP_REQUIRED" + except TimeoutException: + print("[login] No OTP input detected in allowed time.") + # Check if we're now on the member search page (login succeeded without OTP) + try: + current_url = self.driver.current_url.lower() + if "member" in current_url or "dashboard" in current_url: + member_search = WebDriverWait(self.driver, 5).until( + EC.presence_of_element_located((By.XPATH, "//a[contains(@href, 'check-patient-benefits-search')]")) + ) + print("[login] Login successful - now on member search page") + return "SUCCESS" + except TimeoutException: + pass + + # Check for error messages + try: + error_elem = WebDriverWait(self.driver, 3).until( + EC.presence_of_element_located((By.XPATH, "//*[contains(@class,'error') or contains(text(),'invalid') or contains(text(),'failed')]")) + ) + print(f"[login] Login failed - error detected: {error_elem.text}") + return f"ERROR:LOGIN FAILED: {error_elem.text}" + except TimeoutException: + pass + + if "sign-in" in self.driver.current_url.lower(): + print("[login] Login failed - still on login page") + return "ERROR:LOGIN FAILED: Still on login page" + + print("[login] Assuming login succeeded (no errors detected)") + return "SUCCESS" + + except Exception as e: + print("[login] Exception during login:", e) + return f"ERROR:LOGIN FAILED: {e}" + + def step1(self): + """Open the Group/Health Connector search page and search by Subscriber ID + Birthdate.""" + wait = WebDriverWait(self.driver, 30) + + try: + fields = [] + if self.memberId: + fields.append(f"SubscriberID: {self.memberId}") + if self.dateOfBirth: + fields.append(f"DOB: {self.dateOfBirth}") + print(f"[Delta Indiv step1] Starting search with: {', '.join(fields)}") + + search_page_url = "https://www.deltadentalma.com/private/provider/check-patient-benefits-search" + + # Try navigating straight to the search page first — avoids any click/visibility issues + print(f"[Delta Indiv step1] Navigating directly to search page: {search_page_url}") + self.driver.get(search_page_url) + time.sleep(2) + + if "check-patient-benefits-search" not in self.driver.current_url.lower(): + # Direct deep-link didn't stick (e.g. bounced back to sign-in/dashboard) — + # fall back to clicking the Group/Health Connector link from the dashboard. + print(f"[Delta Indiv step1] Direct navigation redirected to: {self.driver.current_url} — falling back to clicking the link") + + if "private/provider/dashboard" not in self.driver.current_url.lower(): + dashboard_url = "https://www.deltadentalma.com/private/provider/dashboard" + print(f"[Delta Indiv step1] Navigating to dashboard: {dashboard_url}") + self.driver.get(dashboard_url) + time.sleep(2) + + link_xpath = "//a[contains(@href, 'check-patient-benefits-search')]" + try: + connector_link = WebDriverWait(self.driver, 20).until( + EC.element_to_be_clickable((By.XPATH, link_xpath)) + ) + connector_link.click() + except TimeoutException: + # Element may exist but be hidden (e.g. a responsive duplicate) — try any match via JS click + candidates = self.driver.find_elements(By.XPATH, link_xpath) + print(f"[Delta Indiv step1] element_to_be_clickable timed out; found {len(candidates)} matching link(s) in DOM") + if candidates: + try: + self.driver.execute_script("arguments[0].click();", candidates[0]) + except Exception as js_err: + print(f"[Delta Indiv step1] JS click failed: {js_err}") + print(f"[Delta Indiv step1] Current URL: {self.driver.current_url}") + return "ERROR: Could not open Group/Health Connector search page" + else: + print(f"[Delta Indiv step1] No Group/Health Connector link found in DOM. Current URL: {self.driver.current_url}") + return "ERROR: Could not open Group/Health Connector search page" + + try: + wait.until(lambda d: "check-patient-benefits-search" in d.current_url) + except TimeoutException: + print(f"[Delta Indiv step1] Click did not navigate to search page. Current URL: {self.driver.current_url}") + return "ERROR: Could not open Group/Health Connector search page" + + print(f"[Delta Indiv step1] On Group/Health Connector search page: {self.driver.current_url}") + + # Fill Birthdate (masked mm/dd/yyyy input — send digits only, mask inserts slashes) + if self.dateOfBirth: + try: + dob_parts = self.dateOfBirth.split("-") + year = dob_parts[0] + month = dob_parts[1].zfill(2) + day = dob_parts[2].zfill(2) + + birthdate_input = wait.until(EC.element_to_be_clickable( + (By.XPATH, "//input[@data-test-id='InStateSearchBirthdate']") + )) + birthdate_input.click() + birthdate_input.send_keys(Keys.CONTROL, "a") + birthdate_input.send_keys(Keys.BACKSPACE) + birthdate_input.send_keys(f"{month}{day}{year}") + print(f"[Delta Indiv step1] Filled Birthdate: {month}/{day}/{year}") + except Exception as e: + print(f"[Delta Indiv step1] Warning: Could not fill Birthdate: {e}") + + # Fill Subscriber ID (== member ID) + if self.memberId: + try: + subscriber_input = wait.until(EC.element_to_be_clickable( + (By.XPATH, "//input[@data-test-id='InStateSearchSubscriberId']") + )) + subscriber_input.clear() + subscriber_input.send_keys(self.memberId) + print(f"[Delta Indiv step1] Filled Subscriber ID: {self.memberId}") + except Exception as e: + print(f"[Delta Indiv step1] Warning: Could not fill Subscriber ID: {e}") + + time.sleep(0.3) + + # Click Search button + search_btn = wait.until(EC.element_to_be_clickable( + (By.XPATH, "//button[@data-test-id='InStateSearchButton']") + )) + search_btn.click() + print("[Delta Indiv step1] Clicked Search button") + + # Wait for either the Members results table to render, or (when the + # Subscriber ID + DOB match exactly one family member) Delta Indiv to skip + # the results list and navigate straight to the patient detail page. + # Note: the detail page URL is nested UNDER the search page path + # (".../check-patient-benefits-search/check-patient-benefit-details"), + # so "check-patient-benefits-search" alone can't distinguish them — + # only the detail page's own "-benefit-details" segment can. + try: + WebDriverWait(self.driver, 15).until( + lambda d: d.find_elements(By.XPATH, "//table//tbody//tr") + or "check-patient-benefit-details" in d.current_url.lower() + ) + time.sleep(2) + except TimeoutException: + print("[Delta Indiv step1] Warning: No results table appeared within timeout") + return "ERROR: INVALID SEARCH CRITERIA" + + if "check-patient-benefit-details" in self.driver.current_url.lower(): + print(f"[Delta Indiv step1] Search navigated directly to detail page: {self.driver.current_url}") + + print("[Delta Indiv step1] Search completed successfully") + return "Success" + + except Exception as e: + print(f"[Delta Indiv step1] Exception: {e}") + return f"ERROR:STEP1 - {e}" + + def step2(self): + """Navigate to patient detail page and generate PDF.""" + wait = WebDriverWait(self.driver, 90) + + try: + import re + + eligibilityText = "unknown" + foundMemberId = self.memberId or "" + # If the caller already knows this patient (e.g. an existing patient record + # being rechecked), use that name directly — no need to scrape/parse it off + # the Delta Indiv page, only the eligibility status is actually needed. + patientName = f"{self.firstName} {self.lastName}".strip() if self.firstName and self.lastName else "" + known_patient_name = bool(patientName) + + # Delta Indiv now sometimes skips the family-members results table entirely and + # navigates straight to the single-patient detail page (when Subscriber ID + + # DOB match exactly one person). In that case there's no members table to + # parse here — skip straight to the detail-page fallback extraction below. + already_on_detail_page = "check-patient-benefit-details" in self.driver.current_url.lower() + + rows = [] + target_row = None + + if not already_on_detail_page: + # Wait for results table, then pause for full render + try: + WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located((By.XPATH, "//tbody//tr")) + ) + time.sleep(2) # Let the row content fully render after table appears + except TimeoutException: + print("[Delta Indiv step2] Warning: Results table not found within timeout") + + # The Members table lists the whole family sharing this subscriber ID — + # find the row matching this patient's DOB (falls back to last name, then first row). + rows = self.driver.find_elements(By.XPATH, "//table//tbody//tr") + print(f"[Delta Indiv step2] Found {len(rows)} family member row(s)") + else: + print(f"[Delta Indiv step2] Already on detail page: {self.driver.current_url}") + + if self.dateOfBirth and rows: + try: + dob_parts = self.dateOfBirth.split("-") + dob_year, dob_month, dob_day = dob_parts[0], int(dob_parts[1]), int(dob_parts[2]) + expected_dob = f"{calendar.month_abbr[dob_month]} {dob_day}, {dob_year}".lower() + for row in rows: + cells = row.find_elements(By.XPATH, "./td") + if len(cells) >= 3 and cells[2].text.strip().lower() == expected_dob: + target_row = row + break + except Exception as e: + print(f"[Delta Indiv step2] DOB matching error (non-fatal): {e}") + + if target_row is None and self.lastName and rows: + for row in rows: + if self.lastName.strip().lower() in row.text.lower(): + target_row = row + break + + if target_row is None and rows: + target_row = rows[0] + print("[Delta Indiv step2] No DOB/name match — defaulting to first family member row") + + if target_row is not None: + try: + cells = target_row.find_elements(By.XPATH, "./td") + if cells and not known_patient_name: + patientName = cells[0].text.strip() + print(f"[Delta Indiv step2] Extracted patient name from row: '{patientName}'") + if len(cells) >= 2 and cells[1].text.strip(): + foundMemberId = cells[1].text.strip() + if len(cells) >= 7: + status_text = cells[6].text.strip().lower() + if "inactive" in status_text or "termed" in status_text: + eligibilityText = "inactive" + elif "active" in status_text: + eligibilityText = "active" + else: + eligibilityText = status_text + print(f"[Delta Indiv step2] Found eligibility status: {eligibilityText}") + except Exception as e: + print(f"[Delta Indiv step2] Error reading matched row cells: {e}") + + # Click into the member's detail page + try: + name_link = target_row.find_element(By.XPATH, ".//a") + name_link.click() + print(f"[Delta Indiv step2] Clicked into detail page for: {patientName}") + except Exception as e: + print(f"[Delta Indiv step2] Warning: Could not click patient name link: {e}") + else: + print("[Delta Indiv step2] Warning: No family member rows found in search results") + + # Wait for the detail page to load (URL gains the "-benefit-details" segment) + try: + WebDriverWait(self.driver, 15).until( + lambda d: "check-patient-benefit-details" in d.current_url.lower() + ) + print(f"[Delta Indiv step2] Navigated to detail page: {self.driver.current_url}") + except Exception: + print(f"[Delta Indiv step2] Warning: Still on search page: {self.driver.current_url}") + + # Wait for meaningful content on the detail page + for selector in [ + "//*[contains(text(),'Date of Birth') or contains(text(),'Address') or contains(text(),'Member ID')]", + "//table", "//h1", "//h2", + ]: + try: + WebDriverWait(self.driver, 15).until( + EC.presence_of_element_located((By.XPATH, selector)) + ) + print(f"[Delta Indiv step2] Detail page content loaded: {selector}") + break + except Exception: + continue + + time.sleep(3) # Let JavaScript finish rendering all sections + + # Fallback: refine patient name from the detail page text if row extraction failed + if not patientName: + try: + page_lines = self.driver.execute_script( + "return document.body.innerText;" + ).split('\n') + page_lines = [l.strip() for l in page_lines if l.strip()] + except Exception as e: + print(f"[Delta Indiv step2] Could not get page text: {e}") + page_lines = [] + + # UI noise words that appear in accessibility/sort-button text or field labels + ui_noise = ['click', 'sort', 'activate', 'direction', 'enter', 'space', + 'current', 'use ', 'table', 'column', 'filter', 'search', + 'date', 'birth', 'relationship', 'subscriber', 'coverage', + 'status', 'period', 'network', 'plan', 'deductible'] + + def looks_like_name(text): + """Return True if text is a plausible patient name.""" + t = text.strip() + # Must be 2–60 chars, letters/spaces/hyphens/apostrophes only (no periods) + if not t or not (2 <= len(t) <= 60): + return False + if not re.match(r"^[A-Za-z\s\-']+$", t): + return False + # Must not be UI noise + if any(w in t.lower() for w in ui_noise): + return False + return True + + # Preferred: the "Benefits for JOSHUA ZHANG" heading names the actual + # patient. "Subscriber name:" is the policy holder (e.g. a parent), so + # it is only used below if this heading is missing. + for line in page_lines: + heading = re.match(r'^benefits\s+for\s+(.+)$', line, re.IGNORECASE) + if heading and looks_like_name(heading.group(1)): + patientName = heading.group(1).strip() + print(f"[Delta Indiv step2] Extracted name from 'Benefits for' heading: '{patientName}'") + break + + # Scan lines for the "Name" label. When "Name" appears alone we scan + # forward past accessibility text to the first plausible name value. + for i, line in enumerate(page_lines): + if patientName: + break + # Case 1: "Name : Value" or "Name: Value" on the same line + # (Delta Indiv's detail page labels this "Subscriber name:", so that + # prefix must be accepted rather than excluded) + same_line = re.match( + r'^(?:member\s+|subscriber\s+)?name\s*[:\-]\s*(.+)$', line, re.IGNORECASE + ) + if same_line and not re.search( + r'(provider|group|plan)\s+name', line, re.IGNORECASE + ): + candidate = same_line.group(1).strip() + if looks_like_name(candidate): + patientName = candidate + print(f"[Delta Indiv step2] Extracted name from 'Name:' label: '{patientName}'") + break + + # Case 2: "Name" or "Name:" alone on a line — scan forward for value + elif re.match(r'^(?:member\s+|subscriber\s+)?name\s*:?\s*$', line, re.IGNORECASE): + for j in range(i + 1, min(i + 6, len(page_lines))): + candidate = page_lines[j].strip() + if looks_like_name(candidate): + patientName = candidate + print(f"[Delta Indiv step2] Extracted name from 'Name' label (line +{j-i}): '{patientName}'") + break + if patientName: + break + + # Clean patient name — strip any remaining date artifacts + if patientName: + cleaned = re.sub(r'\s*DOB[:\s]*\d{1,2}/\d{1,2}/\d{2,4}\s*', '', patientName, flags=re.IGNORECASE).strip() + cleaned = re.sub(r'\s+\d{1,2}/\d{1,2}/\d{2,4}$', '', cleaned).strip() + patientName = cleaned if cleaned else patientName + + if not patientName: + print("[Delta Indiv step2] Could not extract patient name from detail page") + + # Fallback: refine eligibility status from the detail page's "Coverage is:" + # label if the family-table row extraction didn't set it (e.g. Delta Indiv + # navigated straight to the detail page, skipping the members table). + if eligibilityText == "unknown": + try: + page_text = self.driver.execute_script("return document.body.innerText;") + coverage_match = re.search( + r'coverage\s+is\s*[:\-]?\s*([A-Za-z]+)', page_text, re.IGNORECASE + ) + if coverage_match: + status_text = coverage_match.group(1).strip().lower() + if "inactive" in status_text or "termed" in status_text: + eligibilityText = "inactive" + elif "active" in status_text: + eligibilityText = "active" + else: + eligibilityText = status_text + print(f"[Delta Indiv step2] Extracted eligibility from 'Coverage is:' label: '{eligibilityText}'") + except Exception as e: + print(f"[Delta Indiv step2] Could not extract eligibility from 'Coverage is:' label: {e}") + + # Wait for the page to be fully ready before capturing PDF + try: + WebDriverWait(self.driver, 30).until( + lambda d: d.execute_script("return document.readyState") == "complete" + ) + except Exception: + pass + + # Extra wait for lazy-loaded sections (benefits summary, member history, etc.) + time.sleep(4) + + # Generate PDF via Chrome DevTools Protocol + print("[Delta Indiv step2] Generating PDF of patient detail page...") + pdf_options = { + "landscape": False, + "displayHeaderFooter": False, + "printBackground": True, + "preferCSSPageSize": True, + "paperWidth": 8.5, + "paperHeight": 11, + "marginTop": 0.4, + "marginBottom": 0.4, + "marginLeft": 0.4, + "marginRight": 0.4, + "scale": 0.9, + } + + result = self.driver.execute_cdp_cmd("Page.printToPDF", pdf_options) + pdf_data = base64.b64decode(result.get('data', '')) + pdf_id = foundMemberId or self.memberId or "unknown" + pdf_path = os.path.join(self.download_dir, f"eligibility_{pdf_id}.pdf") + with open(pdf_path, "wb") as f: + f.write(pdf_data) + + print(f"[Delta Indiv step2] PDF saved at: {pdf_path}") + + print(f"[Delta Indiv step2] Final — PatientName: '{patientName}', MemberID: '{foundMemberId}'") + + return { + "status": "success", + "eligibility": eligibilityText, + "ss_path": pdf_path, # kept for backward compatibility + "pdf_path": pdf_path, # explicit pdf_path + "patientName": patientName, + "memberId": foundMemberId, + "dateOfBirth": self.dateOfBirth, + } + + except Exception as e: + print("ERROR in step2:", e) + # Cleanup download dir on error + try: + dl = os.path.abspath(self.download_dir) + if os.path.isdir(dl): + for name in os.listdir(dl): + item = os.path.join(dl, name) + try: + if os.path.isfile(item) or os.path.islink(item): + os.remove(item) + except Exception as rm_err: + print(f"[cleanup] failed to remove {item}: {rm_err}") + except Exception as cleanup_exc: + print(f"[cleanup] unexpected error: {cleanup_exc}") + return {"status": "error", "message": str(e)} + + def main_workflow(self, url): + try: + self.config_driver() + self.driver.maximize_window() + time.sleep(3) + + login_result = self.login(url) + if login_result.startswith("ERROR"): + return {"status": "error", "message": login_result} + if login_result == "OTP_REQUIRED": + return {"status": "otp_required", "message": "OTP required after login"} + + step1_result = self.step1() + if step1_result.startswith("ERROR"): + return {"status": "error", "message": step1_result} + + step2_result = self.step2() + if step2_result.get("status") == "error": + return {"status": "error", "message": step2_result.get("message")} + + return step2_result + except Exception as e: + return {"status": "error", "message": str(e)} + # NOTE: Do NOT quit driver — keep browser alive for next patient