From e644d21cee37d2710a9676375a269725f1711d35 Mon Sep 17 00:00:00 2001 From: ff Date: Mon, 1 Jun 2026 00:36:11 -0400 Subject: [PATCH] feat: add BCBS MA eligibility check with OTP flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New Selenium worker (fresh Chrome per run, no persistent session) login → OTP modal → eTools → ConnectCenter → Verification → New Eligibility Request → fill form (NPI, member ID, DOB) → Expand All → CDP PDF back to app - Backend route fetches BCBS_MA credentials + provider NPI from settings - Frontend OTP modal with 6-digit code entry - BCBS MA added to insurance credentials dropdown in settings Co-Authored-By: Claude Sonnet 4.6 --- apps/Backend/src/queue/jobRunner.ts | 15 + .../processors/bcbsMaEligibilityProcessor.ts | 255 ++++++++++ apps/Backend/src/queue/queues.ts | 3 +- apps/Backend/src/routes/index.ts | 2 + .../src/routes/insuranceStatusBcbsMa.ts | 108 ++++ ...eleniumBcbsMaInsuranceEligibilityClient.ts | 67 +++ .../insurance-status/bcbs-ma-button-modal.tsx | 293 +++++++++++ .../components/settings/InsuranceCredForm.tsx | 1 + .../src/pages/insurance-status-page.tsx | 25 +- apps/SeleniumService/agent.py | 47 ++ .../helpers_bcbs_ma_eligibility.py | 199 ++++++++ ...selenium_BCBS_MA_eligibilityCheckWorker.py | 462 ++++++++++++++++++ 12 files changed, 1468 insertions(+), 9 deletions(-) create mode 100644 apps/Backend/src/queue/processors/bcbsMaEligibilityProcessor.ts create mode 100644 apps/Backend/src/routes/insuranceStatusBcbsMa.ts create mode 100644 apps/Backend/src/services/seleniumBcbsMaInsuranceEligibilityClient.ts create mode 100644 apps/Frontend/src/components/insurance-status/bcbs-ma-button-modal.tsx create mode 100644 apps/SeleniumService/helpers_bcbs_ma_eligibility.py create mode 100644 apps/SeleniumService/selenium_BCBS_MA_eligibilityCheckWorker.py diff --git a/apps/Backend/src/queue/jobRunner.ts b/apps/Backend/src/queue/jobRunner.ts index 823d97d0..94a502b9 100644 --- a/apps/Backend/src/queue/jobRunner.ts +++ b/apps/Backend/src/queue/jobRunner.ts @@ -23,6 +23,7 @@ import { runUnitedDHClaimProcessor } from "./processors/unitedDHClaimProcessor"; import { runTuftsSCOClaimProcessor } from "./processors/tuftsSCOClaimProcessor"; import { runEligibilityHistoryProcessor } from "./processors/eligibilityHistoryProcessor"; import { runCmspEligibilityHistoryRemainingProcessor } from "./processors/cmspEligibilityHistoryRemainingProcessor"; +import { runBcbsMaEligibilityProcessor } from "./processors/bcbsMaEligibilityProcessor"; import type { SeleniumJobData, OcrJobData } from "./queues"; // ── Queue instances ────────────────────────────────────────────────────────── @@ -226,6 +227,20 @@ export function enqueueSeleniumJob(data: SeleniumJobData): string { formDob: data.formDob, }); } + if (jobType === "bcbs-ma-eligibility-check") { + return runBcbsMaEligibilityProcessor( + { + enrichedPayload: data.enrichedPayload, + userId: data.userId, + insuranceId: data.insuranceId!, + formFirstName: data.formFirstName, + formLastName: data.formLastName, + formDob: data.formDob, + socketId: data.socketId, + }, + job.id + ); + } throw new Error(`Unknown selenium jobType: ${jobType}`); }); diff --git a/apps/Backend/src/queue/processors/bcbsMaEligibilityProcessor.ts b/apps/Backend/src/queue/processors/bcbsMaEligibilityProcessor.ts new file mode 100644 index 00000000..812657eb --- /dev/null +++ b/apps/Backend/src/queue/processors/bcbsMaEligibilityProcessor.ts @@ -0,0 +1,255 @@ +import fs from "fs/promises"; +import fsSync from "fs"; +import path from "path"; +import { storage } from "../../storage"; +import { emptyFolderContainingFile } from "../../utils/emptyTempFolder"; +import { + forwardToSeleniumBcbsMaEligibilityAgent, + getSeleniumBcbsMaSessionStatus, +} from "../../services/seleniumBcbsMaInsuranceEligibilityClient"; +import { splitName, createOrUpdatePatientByInsuranceId } from "./_shared"; +import { io } from "../../socket"; + +function log(tag: string, msg: string, ctx?: any) { + console.log(`${new Date().toISOString()} [${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); + } catch (err: any) { + log("bcbs-ma-processor", `emit failed for ${event}`, { err: err?.message }); + } +} + +export interface BcbsMaEligibilityProcessorInput { + enrichedPayload: any; + userId: number; + insuranceId: string; + formFirstName?: string; + formLastName?: string; + formDob?: string; + socketId?: string; +} + +export interface BcbsMaEligibilityProcessorResult { + patientUpdateStatus?: string; + pdfUploadStatus?: string; + pdfFileId?: number | null; + pdfFilename?: string | null; +} + +async function processBcbsMaResult( + userId: number, + insuranceId: string, + formFirstName: string | undefined, + formLastName: string | undefined, + formDob: string | undefined, + seleniumResult: any +): Promise { + const output: BcbsMaEligibilityProcessorResult = {}; + let createdPdfFileId: number | null = null; + + try { + // Resolve patient name + const rawName = + typeof seleniumResult?.patientName === "string" ? seleniumResult.patientName.trim() : null; + + let firstName: string; + let lastName: string; + + if (rawName) { + if (rawName.includes(",")) { + const [last, ...firstParts] = rawName.split(",").map((s: string) => s.trim()); + lastName = last || formLastName || ""; + firstName = firstParts.join(" ").trim() || formFirstName || ""; + } else { + const parsed = splitName(rawName); + if (!parsed.lastName) { + lastName = parsed.firstName || formLastName || ""; + firstName = formFirstName || ""; + } else { + firstName = parsed.firstName || formFirstName || ""; + lastName = parsed.lastName || formLastName || ""; + } + } + } else { + firstName = formFirstName ?? ""; + lastName = formLastName ?? ""; + } + + await createOrUpdatePatientByInsuranceId({ insuranceId, firstName, lastName, dob: formDob, userId }); + + const normalizedInsuranceId = insuranceId.replace(/\s+/g, ""); + const patient = await storage.getPatientByInsuranceId(normalizedInsuranceId); + if (!patient?.id) { + output.patientUpdateStatus = "Patient not found; no update performed"; + return output; + } + + const eligStatus = (seleniumResult?.eligibility ?? "").toLowerCase(); + const newStatus = + eligStatus === "eligible" || eligStatus === "active" ? "ACTIVE" : "INACTIVE"; + await storage.updatePatient(patient.id, { + status: newStatus, + insuranceProvider: "BCBS MA", + }); + output.patientUpdateStatus = `Patient status updated to ${newStatus}`; + + // Save PDF + let pdfBuffer: Buffer | null = null; + let pdfFilename: string | null = null; + const pdfPath: string | null = seleniumResult?.pdf_path ?? null; + + if (pdfPath && fsSync.existsSync(pdfPath)) { + try { + pdfBuffer = await fs.readFile(pdfPath); + pdfFilename = path.basename(pdfPath); + } catch (e: any) { + output.pdfUploadStatus = `Failed to read PDF: ${e.message}`; + } + } else { + output.pdfUploadStatus = "No valid PDF path from Selenium."; + } + + 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) { + log("bcbs-ma-processor", `processBcbsMaResult ERROR: ${err?.message}`, err); + return { + ...output, + pdfUploadStatus: output.pdfUploadStatus ?? `Processing failed: ${err?.message}`, + pdfFileId: createdPdfFileId, + }; + } finally { + const cleanupPath = seleniumResult?.pdf_path ?? null; + if (cleanupPath) { + try { + await emptyFolderContainingFile(cleanupPath); + } catch (_) {} + } + } +} + +async function pollUntilDone( + sessionId: string, + socketId: string | undefined, + jobId: string, + pollTimeoutMs = 8 * 60 * 1000 // 8 min — accounts for fresh login + OTP wait +): Promise { + const maxAttempts = 960; // 960 × 500ms = 8 min + 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(`BCBS MA polling timeout (${Math.round(pollTimeoutMs / 1000)}s) for session ${sessionId}`); + } + + try { + const st = await getSeleniumBcbsMaSessionStatus(sessionId); + const status: string = st?.status ?? "unknown"; + 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 6-digit code from the BCBS MA email.", + }); + await new Promise((r) => setTimeout(r, pollIntervalMs)); + continue; + } + + if (status === "completed") return st.result; + + if (status === "error" || status === "not_found") { + throw new Error(st?.message || `BCBS MA 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 BCBS MA session ${sessionId}`); + } + const backoff = Math.min(30_000, 500 * Math.pow(2, transientErrors - 1)); + await new Promise((r) => setTimeout(r, backoff)); + } + } + + throw new Error(`BCBS MA polling exhausted all attempts for session ${sessionId}`); +} + +export async function runBcbsMaEligibilityProcessor( + input: BcbsMaEligibilityProcessorInput, + jobId: string +): Promise { + const { enrichedPayload, userId, insuranceId, formFirstName, formLastName, formDob, socketId } = input; + + log("bcbs-ma-processor", "starting Python agent session", { insuranceId }); + const agentResp = await forwardToSeleniumBcbsMaEligibilityAgent(enrichedPayload); + + if (!agentResp?.session_id) { + throw new Error("Python agent did not return a session_id for BCBS MA eligibility"); + } + + const sessionId = agentResp.session_id as string; + log("bcbs-ma-processor", "got session_id", { sessionId }); + + emitToSocket(socketId, "selenium:bcbs_ma_session_started", { session_id: sessionId, jobId }); + + const seleniumResult = await pollUntilDone(sessionId, socketId, jobId); + + if (!seleniumResult || seleniumResult.status === "error") { + throw new Error(seleniumResult?.message ?? "BCBS MA session returned an error result"); + } + + log("bcbs-ma-processor", "processing DB result", { insuranceId }); + const result = await processBcbsMaResult( + userId, insuranceId, formFirstName, formLastName, formDob, seleniumResult + ); + + log("bcbs-ma-processor", "done", { result }); + return result; +} diff --git a/apps/Backend/src/queue/queues.ts b/apps/Backend/src/queue/queues.ts index 96a0a914..26aa8e8d 100644 --- a/apps/Backend/src/queue/queues.ts +++ b/apps/Backend/src/queue/queues.ts @@ -18,7 +18,8 @@ export type SeleniumJobType = | "uniteddh-claim-submit" | "tuftssco-eligibility-check" | "mh-eligibility-history-check" - | "cmsp-eligibility-history-remaining-check"; + | "cmsp-eligibility-history-remaining-check" + | "bcbs-ma-eligibility-check"; export interface SeleniumJobData { jobType: SeleniumJobType; diff --git a/apps/Backend/src/routes/index.ts b/apps/Backend/src/routes/index.ts index 2b7b20f9..1e0549b6 100755 --- a/apps/Backend/src/routes/index.ts +++ b/apps/Backend/src/routes/index.ts @@ -40,6 +40,7 @@ import commissionsRoutes from "./commissions"; import shoppingVendorsRoutes from "./shopping-vendors"; import feeScheduleRoutes from "./feeSchedule"; import licenseRoutes from "./license"; +import insuranceStatusBcbsMaRoutes from "./insuranceStatusBcbsMa"; const router = Router(); @@ -60,6 +61,7 @@ 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("/insurance-status-bcbs-ma", insuranceStatusBcbsMaRoutes); router.use("/claims", insuranceStatusCCAClaimRoutes); router.use("/claims", insuranceStatusCCAPreAuthRoutes); router.use("/claims", insuranceStatusDDMAClaimRoutes); diff --git a/apps/Backend/src/routes/insuranceStatusBcbsMa.ts b/apps/Backend/src/routes/insuranceStatusBcbsMa.ts new file mode 100644 index 00000000..fe911591 --- /dev/null +++ b/apps/Backend/src/routes/insuranceStatusBcbsMa.ts @@ -0,0 +1,108 @@ +import { Router, Request, Response } from "express"; +import { storage } from "../storage"; +import { forwardOtpToSeleniumBcbsMaAgent } from "../services/seleniumBcbsMaInsuranceEligibilityClient"; +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("bcbs-ma-route", "emit failed", { socketId, event, err: err?.message }); + } +} + +/** + * POST /bcbs-ma-eligibility + * Enqueues a BCBS MA eligibility check. + * Body: { data: { memberId, dateOfBirth, firstName, lastName, insuranceSiteKey }, socketId } + */ +router.post("/bcbs-ma-eligibility", async (req: Request, res: Response): Promise => { + if (!req.body.data) { + return res.status(400).json({ error: "Missing data for BCBS MA eligibility check" }); + } + 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, "BCBS_MA"); + if (!credentials) { + return res.status(404).json({ + error: "No BCBS MA credentials found. Please add them in Settings → Insurance Credentials.", + }); + } + + // Fetch provider NPI — use the first one on file for this user + const npiProviders = await storage.getNpiProvidersByUser(req.user.id); + const npiNumber = npiProviders?.[0]?.npiNumber ?? ""; + if (!npiNumber) { + return res.status(404).json({ + error: "No NPI provider found. Please add one in Settings → NPI Providers.", + }); + } + + const enrichedData = { + ...rawData, + bcbsMaUsername: credentials.username, + bcbsMaPassword: credentials.password, + providerNpi: npiNumber, + }; + + const socketId: string | undefined = req.body.socketId; + + const jobId = enqueueSeleniumJob({ + jobType: "bcbs-ma-eligibility-check", + userId: req.user.id, + socketId, + enrichedPayload: enrichedData, + insuranceId: String(rawData.memberId ?? "").trim(), + formFirstName: rawData.firstName, + formLastName: rawData.lastName, + formDob: rawData.dateOfBirth, + }); + + log("bcbs-ma-route", "job enqueued", { jobId, insuranceId: rawData.memberId }); + return res.json({ status: "queued", jobId }); + } catch (err: any) { + console.error("[bcbs-ma-route] enqueue failed:", err); + return res.status(500).json({ error: err.message || "Failed to enqueue BCBS MA selenium job" }); + } +}); + +/** + * POST /selenium/submit-otp + * Forwards the OTP to the Python agent (side-channel, bypasses queue). + * 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 forwardOtpToSeleniumBcbsMaAgent(sessionId, otp); + emitSafe(socketId, "selenium:otp_submitted", { session_id: sessionId, result: r }); + return res.json(r); + } catch (err: any) { + console.error("[bcbs-ma-route] submit-otp failed:", 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/seleniumBcbsMaInsuranceEligibilityClient.ts b/apps/Backend/src/services/seleniumBcbsMaInsuranceEligibilityClient.ts new file mode 100644 index 00000000..31b695aa --- /dev/null +++ b/apps/Backend/src/services/seleniumBcbsMaInsuranceEligibilityClient.ts @@ -0,0 +1,67 @@ +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; + } catch (err: any) { + const code = err?.code; + const isTransient = + code === "ECONNRESET" || code === "ECONNREFUSED" || code === "EPIPE" || code === "ETIMEDOUT"; + if (!isTransient) throw err; + } + 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 forwardToSeleniumBcbsMaEligibilityAgent(data: any): Promise { + const payload = { data }; + log("bcbs-ma-client", "POST bcbs-ma-eligibility", { keys: Object.keys(payload) }); + const r = await requestWithRetries({ url: "/bcbs-ma-eligibility", method: "POST", data: payload }, 4); + log("bcbs-ma-client", "agent response", { status: r.status }); + if (r.status >= 500) throw new Error(`Selenium agent server error: ${r.status}`); + return r.data; +} + +export async function forwardOtpToSeleniumBcbsMaAgent(sessionId: string, otp: string): Promise { + log("bcbs-ma-client", "POST submit-otp", { sessionId }); + const r = await requestWithRetries( + { url: "/submit-otp", method: "POST", data: { session_id: sessionId, otp } }, + 4 + ); + if (r.status >= 500) throw new Error(`Selenium agent server error on submit-otp: ${r.status}`); + return r.data; +} + +export async function getSeleniumBcbsMaSessionStatus(sessionId: string): Promise { + const r = await requestWithRetries({ url: `/session/${sessionId}/status`, method: "GET" }, 4); + 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/bcbs-ma-button-modal.tsx b/apps/Frontend/src/components/insurance-status/bcbs-ma-button-modal.tsx new file mode 100644 index 00000000..eac35c5b --- /dev/null +++ b/apps/Frontend/src/components/insurance-status/bcbs-ma-button-modal.tsx @@ -0,0 +1,293 @@ +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 BcbsMaOtpModalProps { + open: boolean; + onClose: () => void; + onSubmit: (otp: string) => Promise | void; + isSubmitting: boolean; +} + +function BcbsMaOtpModal({ open, onClose, onSubmit, isSubmitting }: BcbsMaOtpModalProps) { + 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 — BCBS MA

+ +
+

+ Enter the last 6 digits of the one-time verification code sent by the BCBS MA Provider + Central portal to your registered email. The email shows a code like{" "} + XXXX-XXXXXX — enter only the last 6 digits. +

+
+
+ + setOtp(e.target.value)} + maxLength={6} + autoFocus + /> +
+
+ + +
+
+
+
+ ); +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +interface BcbsMaEligibilityButtonProps { + memberId: string; + dateOfBirth: Date | null; + firstName?: string; + lastName?: string; + isFormIncomplete: boolean; + autoTrigger?: boolean; + onAutoTriggered?: () => void; + onPdfReady: (pdfId: number, fallbackFilename: string | null) => void; +} + +export function BcbsMaEligibilityButton({ + memberId, + dateOfBirth, + firstName, + lastName, + isFormIncomplete, + autoTrigger, + onAutoTriggered, + onPdfReady, +}: BcbsMaEligibilityButtonProps) { + 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); + + const handleStart = 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: "BCBS_MA", + }; + + setIsStarting(true); + + try { + dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: "Starting BCBS MA eligibility check…" })); + + const response = await apiRequest( + "POST", + "/api/insurance-status-bcbs-ma/bcbs-ma-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: "BCBS MA job queued. Opening browser…" })); + + 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 started. Waiting for OTP…" })); + }; + + const onOtpRequired = (data: any) => { + if (String(data?.jobId) !== String(jobId)) return; + if (data.session_id) sessionIdRef.current = data.session_id; + setOtpModalOpen(true); + dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: "OTP required for BCBS MA. Please enter the code." })); + }; + + const onOtpSubmitted = (data: any) => { + if (data?.session_id && data.session_id !== sessionIdRef.current) return; + dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: "OTP submitted. Finishing BCBS MA eligibility check…" })); + }; + + function cleanup() { + socket.off("selenium:bcbs_ma_session_started", onSessionStarted); + socket.off("selenium:otp_required", onOtpRequired); + socket.off("selenium:otp_submitted", onOtpSubmitted); + socket.off("job:update", onJobUpdate); + } + + const safetyTimer = setTimeout(() => { + cleanup(); + setIsStarting(false); + setOtpModalOpen(false); + dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: "BCBS MA job timed out." })); + }, 10 * 60 * 1000); + + const onJobUpdate = (data: any) => { + if (String(data?.jobId) !== String(jobId)) return; + + if (data.status === "active") { + dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: data.message ?? "Browser starting…" })); + return; + } + + clearTimeout(safetyTimer); + cleanup(); + + if (data.status === "completed") { + dispatch(setTaskStatus({ key: "eligibilityCheck", status: "success", message: "BCBS MA eligibility updated and PDF saved." })); + toast({ title: "BCBS MA 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(Number(pdfId), data.result?.pdfFilename ?? `eligibility_bcbs_ma_${memberId}.pdf`); + } + } else if (data.status === "failed") { + const msg = data.error ?? "BCBS MA eligibility job failed."; + dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: msg })); + toast({ title: "BCBS MA selenium error", description: msg, variant: "destructive" }); + } + + setIsStarting(false); + setOtpModalOpen(false); + }; + + socket.on("selenium:bcbs_ma_session_started", onSessionStarted); + socket.on("selenium:otp_required", onOtpRequired); + socket.on("selenium:otp_submitted", onOtpSubmitted); + socket.on("job:update", onJobUpdate); + + } catch (err: any) { + console.error("BcbsMaEligibilityButton error:", err); + dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: err?.message || "Failed to start BCBS MA eligibility" })); + toast({ title: "BCBS MA selenium error", description: err?.message || "Failed to start BCBS MA eligibility", variant: "destructive" }); + setIsStarting(false); + } + }; + + const handleSubmitOtp = async (otp: string) => { + const sessionId = sessionIdRef.current; + if (!sessionId) { + toast({ title: "Session not ready", description: "Cannot submit OTP — session ID not yet available.", variant: "destructive" }); + return; + } + + try { + setIsSubmittingOtp(true); + const resp = await apiRequest("POST", "/api/insurance-status-bcbs-ma/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) { + 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 || isFormIncomplete) return; + autoTriggeredRef.current = true; + onAutoTriggered?.(); + handleStart(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autoTrigger, isFormIncomplete]); + + 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 75904367..9e683391 100755 --- a/apps/Frontend/src/components/settings/InsuranceCredForm.tsx +++ b/apps/Frontend/src/components/settings/InsuranceCredForm.tsx @@ -22,6 +22,7 @@ const SITE_KEY_OPTIONS = [ { value: "TUFTS_SCO", label: "Tufts SCO (TUFTS_SCO)" }, { value: "UNITED_SCO", label: "United SCO / DentalHub (UNITED_SCO)" }, { value: "CCA", label: "CCA (CCA)" }, + { value: "BCBS_MA", label: "BCBS MA (BCBS_MA)" }, ]; export function CredentialForm({ onClose, userId, defaultValues }: CredentialFormProps) { diff --git a/apps/Frontend/src/pages/insurance-status-page.tsx b/apps/Frontend/src/pages/insurance-status-page.tsx index 402ddbb6..9188d5c2 100755 --- a/apps/Frontend/src/pages/insurance-status-page.tsx +++ b/apps/Frontend/src/pages/insurance-status-page.tsx @@ -41,6 +41,7 @@ import { DeltaInsEligibilityButton } from "@/components/insurance-status/deltain import { TuftsSCOEligibilityButton } from "@/components/insurance-status/tufts-sco-button-modal"; import { UnitedSCOEligibilityButton } from "@/components/insurance-status/united-sco-button-modal"; import { CCAEligibilityButton } from "@/components/insurance-status/cca-button-modal"; +import { BcbsMaEligibilityButton } from "@/components/insurance-status/bcbs-ma-button-modal"; import { useLicense } from "@/hooks/use-license"; /** @@ -908,14 +909,22 @@ export default function InsuranceStatusPage() { /> - +
+ { + setPreviewPdfId(pdfId); + setPreviewFallbackFilename( + fallbackFilename ?? `eligibility_bcbs_ma_${memberId}.pdf`, + ); + setPreviewOpen(true); + }} + /> +
{/* Row 2 */} diff --git a/apps/SeleniumService/agent.py b/apps/SeleniumService/agent.py index 35489e90..889c4f65 100755 --- a/apps/SeleniumService/agent.py +++ b/apps/SeleniumService/agent.py @@ -23,6 +23,7 @@ import helpers_cca_preauth as hcca_preauth import helpers_ddma_claim as hddma_claim import helpers_uniteddh_claim as huniteddh_claim import helpers_tuftssco_claim as htuftssco_claim +import helpers_bcbs_ma_eligibility as hbcbs_ma # Import startup session-clear functions from ddma_browser_manager import clear_ddma_session_on_startup @@ -547,6 +548,48 @@ async def cca_eligibility(request: Request): return {"status": "started", "session_id": sid} +async def _bcbs_ma_worker_wrapper(sid: str, data: dict, url: str): + """Background worker for BCBS MA eligibility — fresh browser, always OTP.""" + global active_jobs, waiting_jobs + async with semaphore: + async with lock: + waiting_jobs -= 1 + active_jobs += 1 + try: + await hbcbs_ma.start_bcbs_ma_run(sid, data, url) + finally: + async with lock: + active_jobs -= 1 + + +@app.post("/bcbs-ma-eligibility") +async def bcbs_ma_eligibility(request: Request): + """ + Starts a BCBS MA eligibility session in the background. + Fresh Chrome each time — no persistent session (OTP always required). + Body: { "data": { memberId, dateOfBirth, firstName, lastName, bcbsMaUsername, bcbsMaPassword } } + Returns: { status: "started", session_id: "" } + """ + global waiting_jobs + + body = await request.json() + data = body.get("data", {}) + + sid = hbcbs_ma.make_session_entry() + hbcbs_ma.sessions[sid]["type"] = "bcbs_ma_eligibility" + hbcbs_ma.sessions[sid]["last_activity"] = time.time() + + async with lock: + waiting_jobs += 1 + + asyncio.create_task(_bcbs_ma_worker_wrapper( + sid, data, + url="https://provider.bluecrossma.com/ProviderHome/portal/" + )) + + return {"status": "started", "session_id": sid} + + async def _cca_claim_worker_wrapper(sid: str, data: dict, url: str): """Background worker for CCA claim submission.""" global active_jobs, waiting_jobs @@ -782,6 +825,8 @@ async def submit_otp(request: Request): res = huniteddh_claim.submit_otp(sid, otp) elif sid in htuftssco_claim.sessions: res = htuftssco_claim.submit_otp(sid, otp) + elif sid in hbcbs_ma.sessions: + res = hbcbs_ma.submit_otp(sid, otp) else: raise HTTPException(status_code=404, detail="session not found") @@ -813,6 +858,8 @@ async def session_status(sid: str): s = huniteddh_claim.get_session_status(sid) elif sid in htuftssco_claim.sessions: s = htuftssco_claim.get_session_status(sid) + elif sid in hbcbs_ma.sessions: + s = hbcbs_ma.get_session_status(sid) else: s = {"status": "not_found"} if s.get("status") == "not_found": diff --git a/apps/SeleniumService/helpers_bcbs_ma_eligibility.py b/apps/SeleniumService/helpers_bcbs_ma_eligibility.py new file mode 100644 index 00000000..29b417e9 --- /dev/null +++ b/apps/SeleniumService/helpers_bcbs_ma_eligibility.py @@ -0,0 +1,199 @@ +import os +import time +import asyncio +import uuid +from typing import Dict, Any +from selenium.common.exceptions import WebDriverException + +from selenium_BCBS_MA_eligibilityCheckWorker import AutomationBCBSMAEligibilityCheck + +# 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: + 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, + "driver": None, + "otp_event": asyncio.Event(), + "otp_value": None, + "result": None, + "message": None, + "type": None, + } + return sid + + +async def _remove_session_later(sid: str, delay: int = 30): + await asyncio.sleep(delay) + sessions.pop(sid, None) + print(f"[helpers_bcbs_ma] cleaned session {sid}") + + +async def start_bcbs_ma_run(sid: str, data: dict, url: str): + """ + Full BCBS MA eligibility workflow for one session. + Always fresh Chrome — no persistent session because BCBS MA always + requires OTP (the prefix changes each login). + + OTP handling: + a) Accept OTP submitted from the app via /submit-otp (sets otp_value) + b) Poll the browser directly to detect user entry in the open window + """ + s = sessions.get(sid) + if not s: + return {"status": "error", "message": "session not found"} + + s["status"] = "running" + s["last_activity"] = time.time() + bot = None + + try: + bot = AutomationBCBSMAEligibilityCheck({"data": data}) + bot.config_driver() + s["bot"] = bot + s["driver"] = bot.driver + + # ── Login ────────────────────────────────────────────────────────────── + try: + login_result = bot.login(url) + except WebDriverException as e: + s["status"] = "error" + s["message"] = f"WebDriver error during login: {e}" + return {"status": "error", "message": s["message"]} + except Exception as e: + s["status"] = "error" + s["message"] = f"Login failed: {e}" + return {"status": "error", "message": s["message"]} + + if isinstance(login_result, str) and login_result.startswith("ERROR"): + s["status"] = "error" + s["message"] = login_result + return {"status": "error", "message": login_result} + + # ── OTP required (always for BCBS MA) ───────────────────────────────── + if isinstance(login_result, str) and login_result == "OTP_REQUIRED": + s["status"] = "waiting_for_otp" + s["message"] = "OTP required — enter the 6-digit code from the BCBS MA email" + s["last_activity"] = time.time() + + driver = bot.driver + login_success = False + + print(f"[BCBS MA] Waiting for OTP (up to {SESSION_OTP_TIMEOUT}s)...") + for poll in range(SESSION_OTP_TIMEOUT): + await asyncio.sleep(1) + s["last_activity"] = time.time() + + try: + # a) App submitted OTP + otp_value = s.get("otp_value") + if otp_value: + print(f"[BCBS MA OTP poll {poll+1}] Submitting OTP from app...") + otp_result = bot.submit_otp_step(otp_value) + s["otp_value"] = None + if isinstance(otp_result, str) and otp_result == "SUCCESS": + login_success = True + break + elif isinstance(otp_result, str) and otp_result.startswith("ERROR"): + print(f"[BCBS MA OTP] submit_otp_step returned: {otp_result}") + # Don't abort yet — let the poll loop check the browser state + + # b) Detect success by browser URL + current_url = driver.current_url.lower() + print(f"[BCBS MA OTP poll {poll+1}/{SESSION_OTP_TIMEOUT}] URL: {current_url[:70]}") + + if "authsvc" not in current_url and "/mga/sps/" not in current_url: + # Left the OTP page — login completed (user entered OTP in browser) + print("[BCBS MA OTP] Browser left OTP page — login successful") + login_success = True + break + + except Exception as poll_err: + print(f"[BCBS MA OTP poll {poll+1}] Error: {poll_err}") + + if not login_success: + s["status"] = "error" + s["message"] = "OTP timeout — login not completed in time" + return {"status": "error", "message": s["message"]} + + s["status"] = "running" + s["message"] = "Login successful after OTP" + print("[BCBS MA] OTP accepted, proceeding to eligibility search...") + await asyncio.sleep(2) + + # ── Already logged in (no OTP path) ────────────────────────────────── + elif isinstance(login_result, str) and login_result == "SUCCESS": + print("[BCBS MA] Login succeeded without OTP") + s["status"] = "running" + + # ── Step 1: search member ────────────────────────────────────────────── + step1_result = bot.step1() + if isinstance(step1_result, str) and step1_result.startswith("ERROR"): + s["status"] = "error" + s["message"] = step1_result + return {"status": "error", "message": step1_result} + + # ── Step 2: extract + PDF ────────────────────────────────────────────── + 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" + s["message"] = step2_result.get("message", "unknown error") if isinstance(step2_result, dict) else str(step2_result) + return {"status": "error", "message": s["message"]} + + except Exception as e: + s["status"] = "error" + s["message"] = f"worker exception: {e}" + print(f"[helpers_bcbs_ma] Unexpected error: {e}") + return {"status": "error", "message": s["message"]} + + finally: + # Always close the disposable browser + try: + if bot: + bot.close_driver() + except Exception: + pass + s["driver"] = None + + +def submit_otp(sid: str, otp: str) -> Dict[str, Any]: + """Called by /submit-otp to hand OTP to the polling loop.""" + 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_BCBS_MA_eligibilityCheckWorker.py b/apps/SeleniumService/selenium_BCBS_MA_eligibilityCheckWorker.py new file mode 100644 index 00000000..fa8126a2 --- /dev/null +++ b/apps/SeleniumService/selenium_BCBS_MA_eligibilityCheckWorker.py @@ -0,0 +1,462 @@ +import os +import time +import base64 +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 TimeoutException, WebDriverException +from selenium import webdriver +from selenium.webdriver.chrome.options import Options +from selenium.webdriver.chrome.service import Service + + +DOWNLOAD_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "downloads", "bcbs_ma")) + + +def _fresh_driver() -> webdriver.Chrome: + """Create a disposable Chrome instance for a single BCBS MA session.""" + os.makedirs(DOWNLOAD_DIR, exist_ok=True) + options = Options() + options.add_argument("--no-sandbox") + options.add_argument("--disable-dev-shm-usage") + options.add_argument("--disable-blink-features=AutomationControlled") + options.add_experimental_option("excludeSwitches", ["enable-automation"]) + options.add_experimental_option("useAutomationExtension", False) + options.add_experimental_option("prefs", { + "download.default_directory": DOWNLOAD_DIR, + "download.prompt_for_download": False, + "plugins.always_open_pdf_externally": True, + }) + headless = os.getenv("SELENIUM_HEADLESS", "false").lower() == "true" + if headless: + options.add_argument("--headless=new") + + try: + from webdriver_manager.chrome import ChromeDriverManager + service = Service(ChromeDriverManager().install()) + except Exception: + service = Service() + + driver = webdriver.Chrome(service=service, options=options) + driver.maximize_window() + return driver + + +class AutomationBCBSMAEligibilityCheck: + """ + BCBS MA Provider Central eligibility check. + + No persistent session — fresh Chrome every run because BCBS MA + always requires OTP on new login (the OTP prefix changes each time). + + Flow: login(url) → OTP_REQUIRED → [caller polls for OTP] → submit_otp_step(otp) + → step1() [search member] → step2() [extract + PDF] + """ + + LOGIN_URL = "https://provider.bluecrossma.com/ProviderHome/portal/" + + def __init__(self, data: dict): + raw = data.get("data", data) + self.member_id = raw.get("memberId", "") + self.dob = raw.get("dateOfBirth", "") # YYYY-MM-DD from frontend + self.first_name = raw.get("firstName", "") + self.last_name = raw.get("lastName", "") + self.username = raw.get("bcbsMaUsername", "") + self.password = raw.get("bcbsMaPassword", "") + self.provider_npi = raw.get("providerNpi", "") + self.driver: webdriver.Chrome | None = None + + # ── Driver lifecycle ────────────────────────────────────────────────────── + + def config_driver(self): + self.driver = _fresh_driver() + + def close_driver(self): + try: + if self.driver: + self.driver.quit() + except Exception: + pass + self.driver = None + + # ── Helpers ─────────────────────────────────────────────────────────────── + + def _wait(self, timeout=15): + return WebDriverWait(self.driver, timeout) + + def _dob_mmddyyyy(self) -> str: + """Convert YYYY-MM-DD → MM/DD/YYYY.""" + try: + parts = self.dob.split("-") + return f"{parts[1]}/{parts[2]}/{parts[0]}" + except Exception: + return self.dob + + # ── Step: Login ─────────────────────────────────────────────────────────── + + def login(self, url: str = "") -> str: + """ + Navigate to BCBS MA Provider Central, fill credentials, click Log in. + Returns: + "OTP_REQUIRED" – redirected to MFA challenge page (/mga/sps/authsvc) + "SUCCESS" – landed on dashboard without OTP + "ERROR:..." – something went wrong + """ + target = url or self.LOGIN_URL + try: + print(f"[BCBS MA login] Navigating to {target}") + self.driver.get(target) + + # Wait for username field: id="txtUsername0" + username_input = self._wait(20).until( + EC.presence_of_element_located((By.ID, "txtUsername0")) + ) + username_input.clear() + username_input.send_keys(self.username) + print("[BCBS MA login] Username filled") + + # Password field: id="txtPassword" + password_input = self._wait(10).until( + EC.presence_of_element_located((By.ID, "txtPassword")) + ) + password_input.clear() + password_input.send_keys(self.password) + print("[BCBS MA login] Password filled") + + # Log in button: id="ns_Z7_09ME1282N8N3B0QGV9ND6N20G2_loginSubmit" + login_btn = self._wait(10).until( + EC.element_to_be_clickable((By.ID, "ns_Z7_09ME1282N8N3B0QGV9ND6N20G2_loginSubmit")) + ) + login_btn.click() + print("[BCBS MA login] Log in clicked, waiting for response...") + time.sleep(3) + + return self._detect_post_login_state() + + except Exception as e: + print(f"[BCBS MA login] Error: {e}") + return f"ERROR: login failed: {e}" + + def _detect_post_login_state(self) -> str: + """Check current URL/DOM to decide what happened after login.""" + for _ in range(6): + time.sleep(1) + url = self.driver.current_url.lower() + print(f"[BCBS MA] post-login URL: {url[:80]}") + + if "authsvc" in url or "/mga/sps/" in url: + print("[BCBS MA] OTP page detected") + return "OTP_REQUIRED" + + if "providerhome" in url or "portal" in url: + # Check if we're on a real portal page (not login form) + try: + self.driver.find_element(By.XPATH, + "//*[contains(@href,'eTools') or contains(text(),'eTools') or " + "contains(@href,'eligibility') or contains(text(),'Eligibility')]" + ) + print("[BCBS MA] Dashboard detected — logged in without OTP") + return "SUCCESS" + except Exception: + pass + + print("[BCBS MA] Could not determine post-login state") + return "OTP_REQUIRED" # default assumption for BCBS MA + + # ── Step: Submit OTP ────────────────────────────────────────────────────── + + def submit_otp_step(self, otp: str) -> str: + """ + Enter the 6-digit OTP into the BCBS MA verification page and click Submit. + OTP input: id="otppswd", name="otp.user.otp" + Submit btn: id="submitButton" (starts disabled, enables after OTP typed) + Returns "SUCCESS" or "ERROR:..." + """ + try: + print(f"[BCBS MA OTP] Submitting OTP: {otp}") + + # OTP input field: id="otppswd" + otp_input = self._wait(15).until( + EC.presence_of_element_located((By.ID, "otppswd")) + ) + otp_input.clear() + otp_input.send_keys(otp) + print("[BCBS MA OTP] OTP entered") + + # Submit button starts disabled — wait for it to become clickable + submit_btn = self._wait(10).until( + EC.element_to_be_clickable((By.ID, "submitButton")) + ) + submit_btn.click() + print("[BCBS MA OTP] Submit clicked, waiting for dashboard...") + time.sleep(4) + + # Wait for dashboard + for _ in range(15): + time.sleep(1) + url = self.driver.current_url.lower() + print(f"[BCBS MA OTP] URL: {url[:80]}") + if "authsvc" not in url and "/mga/sps/" not in url: + print("[BCBS MA OTP] Left OTP page — login successful") + return "SUCCESS" + + return "ERROR: OTP page still visible after submission" + + except Exception as e: + print(f"[BCBS MA OTP] Error: {e}") + return f"ERROR: OTP submission failed: {e}" + + # ── Step 1: Navigate to ConnectCenter → New Eligibility Request ────────── + + def step1(self) -> str: + """ + After OTP login: + 1. Click eTools menu + 2. Click ConnectCenter in the dropdown + 3. Click Go Now on the ConnectCenter launch page + 4. Click Continue in the popup (opens ConnectCenter in new tab) + 5. Switch to new tab, click Verification + 6. Click New Eligibility Request in dropdown + Returns "SUCCESS" (on Eligibility Identifier page) or "ERROR:..." + """ + try: + from selenium.webdriver.common.keys import Keys + + # 1. Click eTools + print("[BCBS MA step1] Clicking eTools...") + etools = self._wait(15).until( + EC.element_to_be_clickable((By.XPATH, + "//span[text()='eTools'] | //a[.//span[text()='eTools']]" + )) + ) + etools.click() + print("[BCBS MA step1] eTools clicked, waiting for dropdown...") + time.sleep(2) + + # 2. Click ConnectCenter link in the dropdown + print("[BCBS MA step1] Clicking ConnectCenter...") + connect_center = self._wait(15).until( + EC.element_to_be_clickable((By.XPATH, + "//a[contains(@href,'connectcenter')]" + )) + ) + connect_center.click() + print("[BCBS MA step1] ConnectCenter clicked, waiting for page to load...") + time.sleep(3) + + # 3. Click Go Now on the ConnectCenter page + print("[BCBS MA step1] Clicking Go Now...") + go_now = self._wait(20).until( + EC.element_to_be_clickable((By.ID, "GoNow-2c338de9-6a2f-4d71-b5fb-a6e4ae14be80")) + ) + go_now.click() + time.sleep(2) + print("[BCBS MA step1] Go Now clicked — popup opened, pressing Enter to continue...") + + # 4. Press Enter to auto-confirm the popup + from selenium.webdriver.common.action_chains import ActionChains + ActionChains(self.driver).send_keys(Keys.ENTER).perform() + time.sleep(4) + print("[BCBS MA step1] Enter pressed — continuing to ConnectCenter") + + # 5. Switch to the new tab that ConnectCenter opened + self._wait(10).until(lambda d: len(d.window_handles) > 1) + self.driver.switch_to.window(self.driver.window_handles[-1]) + print(f"[BCBS MA step1] Switched to new tab: {self.driver.current_url[:60]}") + time.sleep(3) + + # 6. Click Verification menu + print("[BCBS MA step1] Clicking Verification...") + verification = self._wait(15).until( + EC.element_to_be_clickable((By.XPATH, + "//a[text()='Verification' or normalize-space(text())='Verification']" + )) + ) + verification.click() + time.sleep(2) + print("[BCBS MA step1] Verification clicked") + + # 7. Click New Eligibility Request in dropdown + print("[BCBS MA step1] Clicking New Eligibility Request...") + new_elig = self._wait(10).until( + EC.element_to_be_clickable((By.XPATH, + "//a[@ng-click='newEligibility();']" + )) + ) + new_elig.click() + time.sleep(3) + print("[BCBS MA step1] New Eligibility Request clicked — on Eligibility Identifier page") + + return "SUCCESS" + + except Exception as e: + print(f"[BCBS MA step1] Error: {e}") + return f"ERROR: step1 failed: {e}" + + # ── Step 2: Fill Eligibility Identifier form and get results ───────────── + + def step2(self) -> dict: + """ + On the Eligibility Identifier page: + 1. Enter provider NPI → click Find Provider + 2. Select payer search option: Member ID, Subscriber Date Of Birth (value=2) + 3. Select service type: Dental Care [35] (value=33) + 4. Select place of service: OFFICE [11] (value=30) + 5. Enter member ID + 6. Enter date of birth (MM/DD/YYYY) + 7. Click Submit → wait for results page → PDF + """ + from selenium.webdriver.support.ui import Select + + try: + print("[BCBS MA step2] Filling Eligibility Identifier form...") + + # 1. Enter provider NPI + provider_id_input = self._wait(15).until( + EC.presence_of_element_located((By.ID, "providerID")) + ) + provider_id_input.clear() + provider_id_input.send_keys(self.provider_npi) + print(f"[BCBS MA step2] Provider NPI entered: {self.provider_npi}") + + # Click Find Provider + find_provider = self._wait(10).until( + EC.element_to_be_clickable((By.XPATH, + "//button[@ng-click='searchProviders()']" + )) + ) + find_provider.click() + print("[BCBS MA step2] Find Provider clicked, waiting...") + time.sleep(3) + + # 2. Payer search options → value="2": Member ID, Subscriber Date Of Birth + payer_select = self._wait(10).until( + EC.presence_of_element_located((By.ID, "payerSearchOptions")) + ) + Select(payer_select).select_by_value("2") + print("[BCBS MA step2] Payer search option selected: Member ID + DOB") + time.sleep(1) + + # 3. Service type → value="33": Dental Care [35] + service_select = self._wait(10).until( + EC.presence_of_element_located((By.ID, "serviceType")) + ) + Select(service_select).select_by_value("33") + print("[BCBS MA step2] Service type selected: Dental Care [35]") + + # 4. Place of service → value="30": OFFICE [11] + pos_select = self._wait(10).until( + EC.presence_of_element_located((By.ID, "placeOfService")) + ) + Select(pos_select).select_by_value("30") + print("[BCBS MA step2] Place of service selected: OFFICE [11]") + + # 5. Member ID — field name is subscriberMedicaidID + member_input = self._wait(10).until( + EC.presence_of_element_located((By.XPATH, + "//input[@name='subscriberMedicaidID' or @id='subscriberMedicaidID']" + )) + ) + member_input.clear() + member_input.send_keys(self.member_id) + print(f"[BCBS MA step2] Member ID entered: {self.member_id}") + + # 6. Date of birth — id="subscriberDateOfBirth", placeholder="mm/dd/yyyy" + from selenium.webdriver.common.action_chains import ActionChains + from selenium.webdriver.common.keys import Keys + + dob_formatted = self._dob_mmddyyyy() + dob_input = self._wait(10).until( + EC.presence_of_element_located((By.ID, "subscriberDateOfBirth")) + ) + # Double-click to focus, then type directly via ActionChains + ActionChains(self.driver).double_click(dob_input).perform() + time.sleep(0.3) + ActionChains(self.driver).send_keys(dob_formatted).perform() + print(f"[BCBS MA step2] DOB typed: {dob_formatted}") + time.sleep(1) + + # 7. Click Submit + submit_btn = self._wait(10).until( + EC.element_to_be_clickable((By.XPATH, + "//button[@ng-click='submit()' and @type='submit']" + )) + ) + submit_btn.click() + print("[BCBS MA step2] Submit clicked, waiting for results...") + time.sleep(5) + + # Wait for results page to load + self._wait(20).until( + EC.presence_of_element_located((By.XPATH, + "//*[contains(text(),'Eligible') or contains(text(),'Not Eligible') or " + "contains(text(),'Active') or contains(text(),'Inactive') or " + "contains(text(),'Coverage') or contains(text(),'Benefit')]" + )) + ) + print("[BCBS MA step2] Results page loaded") + + # Click Expand All to expand all eligibility sections before PDFing + try: + expand_all = self._wait(10).until( + EC.element_to_be_clickable((By.XPATH, + "//h6[normalize-space(text())='Expand All']" + )) + ) + expand_all.click() + print("[BCBS MA step2] Expand All clicked, waiting for sections to expand...") + time.sleep(3) + except Exception as e: + print(f"[BCBS MA step2] Expand All not found or failed: {e}") + + # Extract eligibility status + page_text = self.driver.find_element(By.TAG_NAME, "body").text + text_lower = page_text.lower() + if "not eligible" in text_lower or "inactive" in text_lower or "terminated" in text_lower: + eligibility = "Not Eligible" + elif "eligible" in text_lower or "active" in text_lower or "covered" in text_lower: + eligibility = "Eligible" + else: + eligibility = "Unknown" + + patient_name = f"{self.first_name} {self.last_name}".strip() + print(f"[BCBS MA step2] Eligibility: {eligibility}") + + # PDF the results page via CDP + pdf_base64 = "" + pdf_path = None + try: + result = self.driver.execute_cdp_cmd("Page.printToPDF", { + "printBackground": True, + "paperWidth": 8.5, + "paperHeight": 11, + "marginTop": 0.5, + "marginBottom": 0.5, + "marginLeft": 0.5, + "marginRight": 0.5, + }) + pdf_data = result.get("data", "") + if pdf_data: + os.makedirs(DOWNLOAD_DIR, exist_ok=True) + filename = f"bcbs_ma_eligibility_{self.member_id}_{int(time.time())}.pdf" + pdf_path = os.path.join(DOWNLOAD_DIR, filename) + with open(pdf_path, "wb") as f: + f.write(base64.b64decode(pdf_data)) + pdf_base64 = pdf_data + print(f"[BCBS MA step2] PDF saved: {pdf_path}") + except Exception as e: + print(f"[BCBS MA step2] PDF generation failed: {e}") + + return { + "status": "success", + "eligibility": eligibility, + "patientName": patient_name, + "memberId": self.member_id, + "insurerName": "BCBS MA", + "pdfBase64": pdf_base64, + "pdf_path": pdf_path, + } + + except Exception as e: + print(f"[BCBS MA step2] Error: {e}") + return {"status": "error", "message": f"step2 failed: {e}"}