feat: add BCBS MA eligibility check with OTP flow
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,7 @@ import { runUnitedDHClaimProcessor } from "./processors/unitedDHClaimProcessor";
|
|||||||
import { runTuftsSCOClaimProcessor } from "./processors/tuftsSCOClaimProcessor";
|
import { runTuftsSCOClaimProcessor } from "./processors/tuftsSCOClaimProcessor";
|
||||||
import { runEligibilityHistoryProcessor } from "./processors/eligibilityHistoryProcessor";
|
import { runEligibilityHistoryProcessor } from "./processors/eligibilityHistoryProcessor";
|
||||||
import { runCmspEligibilityHistoryRemainingProcessor } from "./processors/cmspEligibilityHistoryRemainingProcessor";
|
import { runCmspEligibilityHistoryRemainingProcessor } from "./processors/cmspEligibilityHistoryRemainingProcessor";
|
||||||
|
import { runBcbsMaEligibilityProcessor } from "./processors/bcbsMaEligibilityProcessor";
|
||||||
import type { SeleniumJobData, OcrJobData } from "./queues";
|
import type { SeleniumJobData, OcrJobData } from "./queues";
|
||||||
|
|
||||||
// ── Queue instances ──────────────────────────────────────────────────────────
|
// ── Queue instances ──────────────────────────────────────────────────────────
|
||||||
@@ -226,6 +227,20 @@ export function enqueueSeleniumJob(data: SeleniumJobData): string {
|
|||||||
formDob: data.formDob,
|
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}`);
|
throw new Error(`Unknown selenium jobType: ${jobType}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
255
apps/Backend/src/queue/processors/bcbsMaEligibilityProcessor.ts
Normal file
255
apps/Backend/src/queue/processors/bcbsMaEligibilityProcessor.ts
Normal file
@@ -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<BcbsMaEligibilityProcessorResult> {
|
||||||
|
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<any> {
|
||||||
|
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<BcbsMaEligibilityProcessorResult> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -18,7 +18,8 @@ export type SeleniumJobType =
|
|||||||
| "uniteddh-claim-submit"
|
| "uniteddh-claim-submit"
|
||||||
| "tuftssco-eligibility-check"
|
| "tuftssco-eligibility-check"
|
||||||
| "mh-eligibility-history-check"
|
| "mh-eligibility-history-check"
|
||||||
| "cmsp-eligibility-history-remaining-check";
|
| "cmsp-eligibility-history-remaining-check"
|
||||||
|
| "bcbs-ma-eligibility-check";
|
||||||
|
|
||||||
export interface SeleniumJobData {
|
export interface SeleniumJobData {
|
||||||
jobType: SeleniumJobType;
|
jobType: SeleniumJobType;
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import commissionsRoutes from "./commissions";
|
|||||||
import shoppingVendorsRoutes from "./shopping-vendors";
|
import shoppingVendorsRoutes from "./shopping-vendors";
|
||||||
import feeScheduleRoutes from "./feeSchedule";
|
import feeScheduleRoutes from "./feeSchedule";
|
||||||
import licenseRoutes from "./license";
|
import licenseRoutes from "./license";
|
||||||
|
import insuranceStatusBcbsMaRoutes from "./insuranceStatusBcbsMa";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -60,6 +61,7 @@ router.use("/insurance-status-deltains", insuranceStatusDeltaInsRoutes);
|
|||||||
router.use("/insurance-status-unitedsco", insuranceStatusUnitedSCORoutes);
|
router.use("/insurance-status-unitedsco", insuranceStatusUnitedSCORoutes);
|
||||||
router.use("/insurance-status-tuftssco", insuranceStatusTuftsSCORoutes);
|
router.use("/insurance-status-tuftssco", insuranceStatusTuftsSCORoutes);
|
||||||
router.use("/insurance-status-cca", insuranceStatusCCARoutes);
|
router.use("/insurance-status-cca", insuranceStatusCCARoutes);
|
||||||
|
router.use("/insurance-status-bcbs-ma", insuranceStatusBcbsMaRoutes);
|
||||||
router.use("/claims", insuranceStatusCCAClaimRoutes);
|
router.use("/claims", insuranceStatusCCAClaimRoutes);
|
||||||
router.use("/claims", insuranceStatusCCAPreAuthRoutes);
|
router.use("/claims", insuranceStatusCCAPreAuthRoutes);
|
||||||
router.use("/claims", insuranceStatusDDMAClaimRoutes);
|
router.use("/claims", insuranceStatusDDMAClaimRoutes);
|
||||||
|
|||||||
108
apps/Backend/src/routes/insuranceStatusBcbsMa.ts
Normal file
108
apps/Backend/src/routes/insuranceStatusBcbsMa.ts
Normal file
@@ -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<any> => {
|
||||||
|
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<any> => {
|
||||||
|
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;
|
||||||
@@ -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<any> {
|
||||||
|
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<any> {
|
||||||
|
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<any> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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> | 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 (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
|
<div className="bg-white dark:bg-slate-900 rounded-xl shadow-lg w-full max-w-md p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold">Enter OTP — BCBS MA</h2>
|
||||||
|
<button type="button" onClick={onClose} className="text-slate-500 hover:text-slate-800">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-500 mb-4">
|
||||||
|
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{" "}
|
||||||
|
<span className="font-mono font-medium">XXXX-XXXXXX</span> — enter only the last 6 digits.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="bcbs-ma-otp">Last 6 digits of OTP</Label>
|
||||||
|
<Input
|
||||||
|
id="bcbs-ma-otp"
|
||||||
|
placeholder="e.g. 482913"
|
||||||
|
value={otp}
|
||||||
|
onChange={(e) => setOtp(e.target.value)}
|
||||||
|
maxLength={6}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button type="button" variant="outline" onClick={onClose} disabled={isSubmitting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting || !otp.trim()}>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<LoaderCircleIcon className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Submitting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Submit OTP"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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<string | null>(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 (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isFormIncomplete || isStarting}
|
||||||
|
onClick={handleStart}
|
||||||
|
>
|
||||||
|
{isStarting ? (
|
||||||
|
<>
|
||||||
|
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Processing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CheckCircle className="h-4 w-4 mr-2" />
|
||||||
|
BCBS MA
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<BcbsMaOtpModal
|
||||||
|
open={otpModalOpen}
|
||||||
|
onClose={() => setOtpModalOpen(false)}
|
||||||
|
onSubmit={handleSubmitOtp}
|
||||||
|
isSubmitting={isSubmittingOtp}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ const SITE_KEY_OPTIONS = [
|
|||||||
{ value: "TUFTS_SCO", label: "Tufts SCO (TUFTS_SCO)" },
|
{ value: "TUFTS_SCO", label: "Tufts SCO (TUFTS_SCO)" },
|
||||||
{ value: "UNITED_SCO", label: "United SCO / DentalHub (UNITED_SCO)" },
|
{ value: "UNITED_SCO", label: "United SCO / DentalHub (UNITED_SCO)" },
|
||||||
{ value: "CCA", label: "CCA (CCA)" },
|
{ value: "CCA", label: "CCA (CCA)" },
|
||||||
|
{ value: "BCBS_MA", label: "BCBS MA (BCBS_MA)" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function CredentialForm({ onClose, userId, defaultValues }: CredentialFormProps) {
|
export function CredentialForm({ onClose, userId, defaultValues }: CredentialFormProps) {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import { DeltaInsEligibilityButton } from "@/components/insurance-status/deltain
|
|||||||
import { TuftsSCOEligibilityButton } from "@/components/insurance-status/tufts-sco-button-modal";
|
import { TuftsSCOEligibilityButton } from "@/components/insurance-status/tufts-sco-button-modal";
|
||||||
import { UnitedSCOEligibilityButton } from "@/components/insurance-status/united-sco-button-modal";
|
import { UnitedSCOEligibilityButton } from "@/components/insurance-status/united-sco-button-modal";
|
||||||
import { CCAEligibilityButton } from "@/components/insurance-status/cca-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";
|
import { useLicense } from "@/hooks/use-license";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -908,14 +909,22 @@ export default function InsuranceStatusPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<div title={!isLicensed ? "License required" : undefined} className={!isLicensed ? "opacity-40 pointer-events-none" : ""}>
|
||||||
className="w-full"
|
<BcbsMaEligibilityButton
|
||||||
variant="outline"
|
memberId={memberId}
|
||||||
disabled={isFormIncomplete}
|
dateOfBirth={dateOfBirth}
|
||||||
>
|
firstName={firstName}
|
||||||
<CheckCircle className="h-4 w-4 mr-2" />
|
lastName={lastName}
|
||||||
BCBS
|
isFormIncomplete={isFormIncomplete}
|
||||||
</Button>
|
onPdfReady={(pdfId, fallbackFilename) => {
|
||||||
|
setPreviewPdfId(pdfId);
|
||||||
|
setPreviewFallbackFilename(
|
||||||
|
fallbackFilename ?? `eligibility_bcbs_ma_${memberId}.pdf`,
|
||||||
|
);
|
||||||
|
setPreviewOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 2 */}
|
{/* Row 2 */}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import helpers_cca_preauth as hcca_preauth
|
|||||||
import helpers_ddma_claim as hddma_claim
|
import helpers_ddma_claim as hddma_claim
|
||||||
import helpers_uniteddh_claim as huniteddh_claim
|
import helpers_uniteddh_claim as huniteddh_claim
|
||||||
import helpers_tuftssco_claim as htuftssco_claim
|
import helpers_tuftssco_claim as htuftssco_claim
|
||||||
|
import helpers_bcbs_ma_eligibility as hbcbs_ma
|
||||||
|
|
||||||
# Import startup session-clear functions
|
# Import startup session-clear functions
|
||||||
from ddma_browser_manager import clear_ddma_session_on_startup
|
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}
|
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: "<uuid>" }
|
||||||
|
"""
|
||||||
|
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):
|
async def _cca_claim_worker_wrapper(sid: str, data: dict, url: str):
|
||||||
"""Background worker for CCA claim submission."""
|
"""Background worker for CCA claim submission."""
|
||||||
global active_jobs, waiting_jobs
|
global active_jobs, waiting_jobs
|
||||||
@@ -782,6 +825,8 @@ async def submit_otp(request: Request):
|
|||||||
res = huniteddh_claim.submit_otp(sid, otp)
|
res = huniteddh_claim.submit_otp(sid, otp)
|
||||||
elif sid in htuftssco_claim.sessions:
|
elif sid in htuftssco_claim.sessions:
|
||||||
res = htuftssco_claim.submit_otp(sid, otp)
|
res = htuftssco_claim.submit_otp(sid, otp)
|
||||||
|
elif sid in hbcbs_ma.sessions:
|
||||||
|
res = hbcbs_ma.submit_otp(sid, otp)
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=404, detail="session not found")
|
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)
|
s = huniteddh_claim.get_session_status(sid)
|
||||||
elif sid in htuftssco_claim.sessions:
|
elif sid in htuftssco_claim.sessions:
|
||||||
s = htuftssco_claim.get_session_status(sid)
|
s = htuftssco_claim.get_session_status(sid)
|
||||||
|
elif sid in hbcbs_ma.sessions:
|
||||||
|
s = hbcbs_ma.get_session_status(sid)
|
||||||
else:
|
else:
|
||||||
s = {"status": "not_found"}
|
s = {"status": "not_found"}
|
||||||
if s.get("status") == "not_found":
|
if s.get("status") == "not_found":
|
||||||
|
|||||||
199
apps/SeleniumService/helpers_bcbs_ma_eligibility.py
Normal file
199
apps/SeleniumService/helpers_bcbs_ma_eligibility.py
Normal file
@@ -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,
|
||||||
|
}
|
||||||
462
apps/SeleniumService/selenium_BCBS_MA_eligibilityCheckWorker.py
Normal file
462
apps/SeleniumService/selenium_BCBS_MA_eligibilityCheckWorker.py
Normal file
@@ -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}"}
|
||||||
Reference in New Issue
Block a user