feat: integrate DDMA eligibility into BullMQ queue with persistent session

- Route DDMA eligibility through InProcessQueue (concurrency=1) so it
  queues behind other selenium jobs instead of running concurrently
- New ddmaEligibilityProcessor: starts Python session, polls for OTP/
  completion via socket events, saves PDF and updates patient DB
- Frontend ddma-buton-modal now uses shared app socket + job:update
  pattern (drops private socket connection)
- SeleniumService: upgrade ddma_browser_manager with credential hash
  tracking, anti-detection options, and startup session clearing;
  upgrade DDMA worker with firstName/lastName support, PDF via
  printToPDF, force-logout on credential change; upgrade helpers with
  dual OTP strategy (app API + browser polling); add /clear-ddma-session
  endpoint; reduce fixed sleeps with smart WebDriverWait

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ff
2026-04-16 09:21:47 -04:00
parent a1cccc8716
commit 289ea426d3
9 changed files with 1429 additions and 1239 deletions

View File

@@ -11,6 +11,7 @@ import { runEligibilityProcessor } from "./processors/eligibilityProcessor";
import { runClaimStatusProcessor } from "./processors/claimStatusProcessor"; import { runClaimStatusProcessor } from "./processors/claimStatusProcessor";
import { runClaimSubmitProcessor } from "./processors/claimSubmitProcessor"; import { runClaimSubmitProcessor } from "./processors/claimSubmitProcessor";
import { runOcrProcessor } from "./processors/ocrProcessor"; import { runOcrProcessor } from "./processors/ocrProcessor";
import { runDdmaEligibilityProcessor } from "./processors/ddmaEligibilityProcessor";
import type { SeleniumJobData, OcrJobData } from "./queues"; import type { SeleniumJobData, OcrJobData } from "./queues";
// ── Queue instances ────────────────────────────────────────────────────────── // ── Queue instances ──────────────────────────────────────────────────────────
@@ -68,6 +69,20 @@ export function enqueueSeleniumJob(data: SeleniumJobData): string {
variant: jobType === "claim-pre-auth" ? "claim-pre-auth" : "claimsubmit", variant: jobType === "claim-pre-auth" ? "claim-pre-auth" : "claimsubmit",
}); });
} }
if (jobType === "ddma-eligibility-check") {
return runDdmaEligibilityProcessor(
{
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}`);
}); });

View File

@@ -0,0 +1,359 @@
/**
* Processor for "ddma-eligibility-check" jobs.
*
* Integrates the full DDMA persistent-session flow into the InProcessQueue:
* 1. Start a session on the Python agent (POST /ddma-eligibility)
* 2. Emit selenium:ddma_session_started → frontend stores session_id for OTP
* 3. Poll agent status, emitting selenium:otp_required when OTP is needed
* 4. On completion: save PDF, create/update patient, update eligibility status
* 5. Return { pdfFileId, pdfFilename, patientUpdateStatus, pdfUploadStatus }
*
* The OTP submission endpoint (/api/insurance-status-ddma/selenium/submit-otp)
* continues to forward OTPs directly to the Python agent — it does NOT go
* through the queue.
*/
import fs from "fs/promises";
import fsSync from "fs";
import path from "path";
import { storage } from "../../storage";
import { emptyFolderContainingFile } from "../../utils/emptyTempFolder";
import {
forwardToSeleniumDdmaEligibilityAgent,
getSeleniumDdmaSessionStatus,
} from "../../services/seleniumDdmaInsuranceEligibilityClient";
import {
splitName,
createOrUpdatePatientByInsuranceId,
imageToPdfBuffer,
} from "./_shared";
import { io } from "../../socket";
// ─── Helpers ────────────────────────────────────────────────────────────────
function now() {
return new Date().toISOString();
}
function log(tag: string, msg: string, ctx?: any) {
console.log(`${now()} [${tag}] ${msg}`, ctx ?? "");
}
function emitToSocket(socketId: string | undefined, event: string, payload: any) {
if (!socketId || !io) return;
try {
const socket = io.sockets.sockets.get(socketId);
if (socket) {
socket.emit(event, payload);
log("ddma-processor", `emitted ${event}`, { socketId });
}
} catch (err: any) {
log("ddma-processor", `emit failed for ${event}`, { err: err?.message });
}
}
// ─── Types ───────────────────────────────────────────────────────────────────
export interface DdmaEligibilityProcessorInput {
enrichedPayload: any;
userId: number;
insuranceId: string;
formFirstName?: string;
formLastName?: string;
formDob?: string;
socketId?: string;
}
export interface DdmaEligibilityProcessorResult {
patientUpdateStatus?: string;
pdfUploadStatus?: string;
pdfFileId?: number | null;
pdfFilename?: string | null;
}
// ─── Core DB processing (mirrors handleDdmaCompletedJob in the route) ─────────
async function processDdmaResult(
userId: number,
insuranceId: string,
formFirstName: string | undefined,
formLastName: string | undefined,
formDob: string | undefined,
seleniumResult: any
): Promise<DdmaEligibilityProcessorResult> {
const output: DdmaEligibilityProcessorResult = {};
let createdPdfFileId: number | null = null;
try {
// 1) Resolve patient name (prefer selenium extraction → form data)
const rawName =
typeof seleniumResult?.patientName === "string"
? seleniumResult.patientName.trim()
: null;
const { firstName, lastName } = rawName
? splitName(rawName)
: { firstName: formFirstName ?? "", lastName: formLastName ?? "" };
// 2) Create / update patient
await createOrUpdatePatientByInsuranceId({
insuranceId,
firstName,
lastName,
dob: formDob,
userId,
});
// 3) Fetch patient (needed for ID)
const patient = await storage.getPatientByInsuranceId(insuranceId);
if (!patient?.id) {
output.patientUpdateStatus = "Patient not found; no update performed";
return output;
}
// 4) Determine and update eligibility status + insurance provider name
const eligStatus = (seleniumResult?.eligibility ?? "").toLowerCase();
const newStatus =
eligStatus === "active" || eligStatus === "y" ? "ACTIVE" : "INACTIVE";
await storage.updatePatient(patient.id, {
status: newStatus,
insuranceProvider: "Delta Dental MA",
});
output.patientUpdateStatus = `Patient status updated to ${newStatus}`;
// 5) Resolve PDF buffer
// New DDMA worker returns a real PDF via pdf_path / ss_path (.pdf).
// Old worker returned a screenshot (.png) via ss_path.
let pdfBuffer: Buffer | null = null;
let pdfFilename: string | null = null;
const pdfPath: string | null =
seleniumResult?.pdf_path ?? seleniumResult?.ss_path ?? null;
if (pdfPath && fsSync.existsSync(pdfPath)) {
if (pdfPath.endsWith(".pdf")) {
// Already a PDF — read directly
try {
pdfBuffer = await fs.readFile(pdfPath);
pdfFilename = path.basename(pdfPath);
log("ddma-processor", "read PDF directly", { pdfPath });
} catch (e: any) {
output.pdfUploadStatus = `Failed to read PDF: ${e.message}`;
}
} else if (
pdfPath.endsWith(".png") ||
pdfPath.endsWith(".jpg") ||
pdfPath.endsWith(".jpeg")
) {
// Legacy screenshot → convert to PDF
try {
pdfBuffer = await imageToPdfBuffer(pdfPath);
pdfFilename = `ddma_eligibility_${insuranceId}_${Date.now()}.pdf`;
log("ddma-processor", "converted screenshot to PDF", { pdfPath });
} catch (e: any) {
output.pdfUploadStatus = `Failed to convert screenshot to PDF: ${e.message}`;
}
}
} else {
output.pdfUploadStatus = "No valid file path from Selenium; nothing uploaded.";
}
// 6) Save PDF to patient document group
if (pdfBuffer && pdfFilename) {
const groupTitleKey = "ELIGIBILITY_STATUS";
const groupTitle = "Eligibility Status";
let group = await storage.findPdfGroupByPatientTitleKey(patient.id, groupTitleKey);
if (!group) group = await storage.createPdfGroup(patient.id, groupTitle, groupTitleKey);
if (!group?.id) throw new Error("PDF group creation failed");
const created = await storage.createPdfFile(group.id, pdfFilename, pdfBuffer);
if (created && typeof created === "object" && "id" in created) {
createdPdfFileId = Number(created.id);
}
output.pdfUploadStatus = `PDF saved to group: ${group.title}`;
output.pdfFilename = pdfFilename;
}
output.pdfFileId = createdPdfFileId;
return output;
} catch (err: any) {
return {
...output,
pdfUploadStatus:
output.pdfUploadStatus ?? `Processing failed: ${err?.message ?? String(err)}`,
pdfFileId: createdPdfFileId,
};
} finally {
// Always clean up temp files
const cleanupPath =
seleniumResult?.pdf_path ?? seleniumResult?.ss_path ?? null;
if (cleanupPath) {
try {
await emptyFolderContainingFile(cleanupPath);
} catch (e) {
log("ddma-processor", "cleanup failed", { cleanupPath });
}
}
}
}
// ─── Polling loop ────────────────────────────────────────────────────────────
async function pollUntilDone(
sessionId: string,
socketId: string | undefined,
jobId: string,
pollTimeoutMs = 5 * 60 * 1000 // 5 min total (includes OTP wait)
): Promise<any> {
const maxAttempts = 600; // 600 × 500ms = 5 min
const pollIntervalMs = 500;
const maxTransientErrors = 12;
const noProgressLimit = 120; // 60 s of same status → abort
let transientErrors = 0;
let consecutiveNoProgress = 0;
let lastStatus: string | null = null;
const deadline = Date.now() + pollTimeoutMs;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
if (Date.now() > deadline) {
throw new Error(
`DDMA polling timeout (${Math.round(pollTimeoutMs / 1000)}s) for session ${sessionId}`
);
}
try {
const st = await getSeleniumDdmaSessionStatus(sessionId);
const status: string = st?.status ?? "unknown";
log("ddma-processor", `poll attempt=${attempt}`, { sessionId, status });
transientErrors = 0; // reset on success
// Track no-progress
const isTerminal =
status === "completed" || status === "error" || status === "not_found";
if (status === lastStatus && !isTerminal) {
consecutiveNoProgress++;
} else {
consecutiveNoProgress = 0;
}
lastStatus = status;
if (consecutiveNoProgress >= noProgressLimit) {
throw new Error(
`No progress from Python agent (status="${status}") after ${consecutiveNoProgress} polls`
);
}
// OTP required — notify frontend and keep polling
if (status === "waiting_for_otp") {
emitToSocket(socketId, "selenium:otp_required", {
session_id: sessionId,
jobId,
message: "OTP required. Please enter the OTP shown by the DDMA portal.",
});
await new Promise((r) => setTimeout(r, pollIntervalMs));
continue;
}
if (status === "completed") {
log("ddma-processor", "session completed", { sessionId });
return st.result;
}
if (status === "error" || status === "not_found") {
throw new Error(
st?.message || `DDMA session ended with status: ${status}`
);
}
// Still running / otp_submitted / created — keep polling
await new Promise((r) => setTimeout(r, pollIntervalMs));
} catch (err: any) {
// Propagate terminal errors immediately
const isTerminal =
err?.response?.status === 404 ||
(typeof err?.message === "string" &&
(err.message.includes("not_found") ||
err.message.includes("polling timeout") ||
err.message.includes("No progress")));
if (isTerminal) throw err;
// Transient network errors — back off
transientErrors++;
if (transientErrors > maxTransientErrors) {
throw new Error(
`Too many transient network errors polling DDMA session ${sessionId}`
);
}
const backoff = Math.min(30_000, 500 * Math.pow(2, transientErrors - 1));
log("ddma-processor", `transient error #${transientErrors}, backoff ${backoff}ms`, {
err: err?.message,
});
await new Promise((r) => setTimeout(r, backoff));
}
}
throw new Error(`DDMA polling exhausted all attempts for session ${sessionId}`);
}
// ─── Main processor entry point ───────────────────────────────────────────────
export async function runDdmaEligibilityProcessor(
input: DdmaEligibilityProcessorInput,
jobId: string
): Promise<DdmaEligibilityProcessorResult> {
const {
enrichedPayload,
userId,
insuranceId,
formFirstName,
formLastName,
formDob,
socketId,
} = input;
// 1) Tell Python agent to start a DDMA session
log("ddma-processor", "starting Python agent session", { insuranceId });
const agentResp = await forwardToSeleniumDdmaEligibilityAgent(enrichedPayload);
if (!agentResp?.session_id) {
throw new Error(
"Python agent did not return a session_id for DDMA eligibility"
);
}
const sessionId = agentResp.session_id as string;
log("ddma-processor", "got session_id", { sessionId });
// 2) Emit session started so frontend can store session_id for OTP submission
emitToSocket(socketId, "selenium:ddma_session_started", {
session_id: sessionId,
jobId,
});
// 3) Poll until done (handles OTP events internally)
const seleniumResult = await pollUntilDone(sessionId, socketId, jobId);
if (!seleniumResult || seleniumResult.status === "error") {
throw new Error(
seleniumResult?.message ?? "DDMA session returned an error result"
);
}
// 4) Process DB writes and PDF upload
log("ddma-processor", "processing DB result", { insuranceId });
const result = await processDdmaResult(
userId,
insuranceId,
formFirstName,
formLastName,
formDob,
seleniumResult
);
log("ddma-processor", "done", { result });
return result;
}

View File

@@ -6,7 +6,8 @@ export type SeleniumJobType =
| "eligibility-check" | "eligibility-check"
| "claim-status-check" | "claim-status-check"
| "claim-submit" | "claim-submit"
| "claim-pre-auth"; | "claim-pre-auth"
| "ddma-eligibility-check";
export interface SeleniumJobData { export interface SeleniumJobData {
jobType: SeleniumJobType; jobType: SeleniumJobType;

View File

@@ -1,571 +1,41 @@
import { Router, Request, Response } from "express"; import { Router, Request, Response } from "express";
import { storage } from "../storage"; import { storage } from "../storage";
import { import { forwardOtpToSeleniumDdmaAgent } from "../services/seleniumDdmaInsuranceEligibilityClient";
forwardToSeleniumDdmaEligibilityAgent,
forwardOtpToSeleniumDdmaAgent,
getSeleniumDdmaSessionStatus,
} from "../services/seleniumDdmaInsuranceEligibilityClient";
import fs from "fs/promises";
import fsSync from "fs";
import path from "path";
import PDFDocument from "pdfkit";
import { emptyFolderContainingFile } from "../utils/emptyTempFolder";
import {
InsertPatient,
insertPatientSchema,
} from "../../../../packages/db/types/patient-types";
import { io } from "../socket"; import { io } from "../socket";
import { enqueueSeleniumJob } from "../queue/jobRunner";
const router = Router(); const router = Router();
/** Job context stored in memory by sessionId */
interface DdmaJobContext {
userId: number;
insuranceEligibilityData: any; // parsed, enriched (includes username/password)
socketId?: string;
}
const ddmaJobs: Record<string, DdmaJobContext> = {};
/** Utility: naive name splitter */
function splitName(fullName?: string | null) {
if (!fullName) return { firstName: "", lastName: "" };
const parts = fullName.trim().split(/\s+/).filter(Boolean);
const firstName = parts.shift() ?? "";
const lastName = parts.join(" ") ?? "";
return { firstName, lastName };
}
async function imageToPdfBuffer(imagePath: string): Promise<Buffer> {
return new Promise<Buffer>((resolve, reject) => {
try {
const doc = new PDFDocument({ autoFirstPage: false });
const chunks: Uint8Array[] = [];
doc.on("data", (chunk: any) => chunks.push(chunk));
doc.on("end", () => resolve(Buffer.concat(chunks)));
doc.on("error", (err: any) => reject(err));
const A4_WIDTH = 595.28; // points
const A4_HEIGHT = 841.89; // points
doc.addPage({ size: [A4_WIDTH, A4_HEIGHT] });
doc.image(imagePath, 0, 0, {
fit: [A4_WIDTH, A4_HEIGHT],
align: "center",
valign: "center",
});
doc.end();
} catch (err) {
reject(err);
}
});
}
/**
* Ensure patient exists for given insuranceId.
*/
async function createOrUpdatePatientByInsuranceId(options: {
insuranceId: string;
firstName?: string | null;
lastName?: string | null;
dob?: string | Date | null;
userId: number;
}) {
const { insuranceId, firstName, lastName, dob, userId } = options;
if (!insuranceId) throw new Error("Missing insuranceId");
const incomingFirst = (firstName || "").trim();
const incomingLast = (lastName || "").trim();
let patient = await storage.getPatientByInsuranceId(insuranceId);
if (patient && patient.id) {
const updates: any = {};
if (
incomingFirst &&
String(patient.firstName ?? "").trim() !== incomingFirst
) {
updates.firstName = incomingFirst;
}
if (
incomingLast &&
String(patient.lastName ?? "").trim() !== incomingLast
) {
updates.lastName = incomingLast;
}
if (Object.keys(updates).length > 0) {
await storage.updatePatient(patient.id, updates);
}
return;
} else {
const createPayload: any = {
firstName: incomingFirst,
lastName: incomingLast,
dateOfBirth: dob,
gender: "",
phone: "",
userId,
insuranceId,
};
let patientData: InsertPatient;
try {
patientData = insertPatientSchema.parse(createPayload);
} catch (err) {
const safePayload = { ...createPayload };
delete (safePayload as any).dateOfBirth;
patientData = insertPatientSchema.parse(safePayload);
}
await storage.createPatient(patientData);
}
}
/**
* When Selenium finishes for a given sessionId, run your patient + PDF pipeline,
* and return the final API response shape.
*/
async function handleDdmaCompletedJob(
sessionId: string,
job: DdmaJobContext,
seleniumResult: any
) {
let createdPdfFileId: number | null = null;
const outputResult: any = {};
// We'll wrap the processing in try/catch/finally so cleanup always runs
try {
// 1) ensuring memberid.
const insuranceEligibilityData = job.insuranceEligibilityData;
const insuranceId = String(insuranceEligibilityData.memberId ?? "").trim();
if (!insuranceId) {
throw new Error("Missing memberId for ddma job");
}
// 2) Create or update patient (with name from selenium result if available)
const patientNameFromResult =
typeof seleniumResult?.patientName === "string"
? seleniumResult.patientName.trim()
: null;
const { firstName, lastName } = splitName(patientNameFromResult);
await createOrUpdatePatientByInsuranceId({
insuranceId,
firstName,
lastName,
dob: insuranceEligibilityData.dateOfBirth,
userId: job.userId,
});
// 3) Update patient status + PDF upload
const patient = await storage.getPatientByInsuranceId(
insuranceEligibilityData.memberId
);
if (!patient?.id) {
outputResult.patientUpdateStatus =
"Patient not found; no update performed";
return {
patientUpdateStatus: outputResult.patientUpdateStatus,
pdfUploadStatus: "none",
pdfFileId: null,
};
}
// update patient status.
const newStatus =
seleniumResult.eligibility === "active" ? "ACTIVE" : "INACTIVE";
await storage.updatePatient(patient.id, { status: newStatus });
outputResult.patientUpdateStatus = `Patient status updated to ${newStatus}`;
// convert screenshot -> pdf if available
let pdfBuffer: Buffer | null = null;
let generatedPdfPath: string | null = null;
if (
seleniumResult &&
seleniumResult.ss_path &&
typeof seleniumResult.ss_path === "string" &&
(seleniumResult.ss_path.endsWith(".png") ||
seleniumResult.ss_path.endsWith(".jpg") ||
seleniumResult.ss_path.endsWith(".jpeg"))
) {
try {
if (!fsSync.existsSync(seleniumResult.ss_path)) {
throw new Error(
`Screenshot file not found: ${seleniumResult.ss_path}`
);
}
pdfBuffer = await imageToPdfBuffer(seleniumResult.ss_path);
const pdfFileName = `ddma_eligibility_${insuranceEligibilityData.memberId}_${Date.now()}.pdf`;
generatedPdfPath = path.join(
path.dirname(seleniumResult.ss_path),
pdfFileName
);
await fs.writeFile(generatedPdfPath, pdfBuffer);
// ensure cleanup uses this
seleniumResult.pdf_path = generatedPdfPath;
} catch (err: any) {
console.error("Failed to convert screenshot to PDF:", err);
outputResult.pdfUploadStatus = `Failed to convert screenshot to PDF: ${String(err)}`;
}
} else {
outputResult.pdfUploadStatus =
"No valid screenshot (ss_path) provided by Selenium; nothing to upload.";
}
if (pdfBuffer && generatedPdfPath) {
const groupTitle = "Eligibility Status";
const groupTitleKey = "ELIGIBILITY_STATUS";
let group = await storage.findPdfGroupByPatientTitleKey(
patient.id,
groupTitleKey
);
if (!group) {
group = await storage.createPdfGroup(
patient.id,
groupTitle,
groupTitleKey
);
}
if (!group?.id) {
throw new Error("PDF group creation failed: missing group ID");
}
const created = await storage.createPdfFile(
group.id,
path.basename(generatedPdfPath),
pdfBuffer
);
if (created && typeof created === "object" && "id" in created) {
createdPdfFileId = Number(created.id);
}
outputResult.pdfUploadStatus = `PDF saved to group: ${group.title}`;
} else {
outputResult.pdfUploadStatus =
"No valid PDF path provided by Selenium, Couldn't upload pdf to server.";
}
return {
patientUpdateStatus: outputResult.patientUpdateStatus,
pdfUploadStatus: outputResult.pdfUploadStatus,
pdfFileId: createdPdfFileId,
};
} catch (err: any) {
return {
patientUpdateStatus: outputResult.patientUpdateStatus,
pdfUploadStatus:
outputResult.pdfUploadStatus ??
`Failed to process DDMA job: ${err?.message ?? String(err)}`,
pdfFileId: createdPdfFileId,
error: err?.message ?? String(err),
};
} finally {
// ALWAYS attempt cleanup of temp files
try {
if (seleniumResult && seleniumResult.pdf_path) {
await emptyFolderContainingFile(seleniumResult.pdf_path);
} else if (seleniumResult && seleniumResult.ss_path) {
await emptyFolderContainingFile(seleniumResult.ss_path);
} else {
console.log(
`[ddma-eligibility] no pdf_path or ss_path available to cleanup`
);
}
} catch (cleanupErr) {
console.error(
`[ddma-eligibility cleanup failed for ${seleniumResult?.pdf_path ?? seleniumResult?.ss_path}]`,
cleanupErr
);
}
}
}
// --- top of file, alongside ddmaJobs ---
let currentFinalSessionId: string | null = null;
let currentFinalResult: any = null;
function now() {
return new Date().toISOString();
}
function log(tag: string, msg: string, ctx?: any) { function log(tag: string, msg: string, ctx?: any) {
console.log(`${now()} [${tag}] ${msg}`, ctx ?? ""); console.log(`${new Date().toISOString()} [${tag}] ${msg}`, ctx ?? "");
} }
function emitSafe(socketId: string | undefined, event: string, payload: any) { function emitSafe(socketId: string | undefined, event: string, payload: any) {
if (!socketId) { if (!socketId || !io) return;
log("socket", "no socketId for emit", { event });
return;
}
try { try {
const socket = io?.sockets.sockets.get(socketId); const socket = io.sockets.sockets.get(socketId);
if (!socket) { if (socket) socket.emit(event, payload);
log("socket", "socket not found (maybe disconnected)", {
socketId,
event,
});
return;
}
socket.emit(event, payload);
log("socket", "emitted", { socketId, event });
} catch (err: any) { } catch (err: any) {
log("socket", "emit failed", { socketId, event, err: err?.message }); log("socket", "emit failed", { socketId, event, err: err?.message });
} }
} }
/**
* Polls Python agent for session status and emits socket events:
* - 'selenium:otp_required' when waiting_for_otp
* - 'selenium:session_update' when completed/error
* - rabsolute timeout + transient error handling.
* - pollTimeoutMs default = 2 minutes (adjust where invoked)
*/
async function pollAgentSessionAndProcess(
sessionId: string,
socketId?: string,
pollTimeoutMs = 2 * 60 * 1000
) {
const maxAttempts = 300;
const baseDelayMs = 1000;
const maxTransientErrors = 12;
// NEW: give up if same non-terminal status repeats this many times
const noProgressLimit = 100;
const job = ddmaJobs[sessionId];
let transientErrorCount = 0;
let consecutiveNoProgress = 0;
let lastStatus: string | null = null;
const deadline = Date.now() + pollTimeoutMs;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
// absolute deadline check
if (Date.now() > deadline) {
emitSafe(socketId, "selenium:session_update", {
session_id: sessionId,
status: "error",
message: `Polling timeout reached (${Math.round(pollTimeoutMs / 1000)}s).`,
});
delete ddmaJobs[sessionId];
return;
}
log(
"poller",
`attempt=${attempt} session=${sessionId} transientErrCount=${transientErrorCount}`
);
try {
const st = await getSeleniumDdmaSessionStatus(sessionId);
const status = st?.status ?? null;
log("poller", "got status", {
sessionId,
status,
message: st?.message,
resultKeys: st?.result ? Object.keys(st.result) : null,
});
// reset transient errors on success
transientErrorCount = 0;
// if status unchanged and non-terminal, increment no-progress counter
const isTerminalLike =
status === "completed" || status === "error" || status === "not_found";
if (status === lastStatus && !isTerminalLike) {
consecutiveNoProgress++;
} else {
consecutiveNoProgress = 0;
}
lastStatus = status;
// if no progress for too many consecutive polls -> abort
if (consecutiveNoProgress >= noProgressLimit) {
emitSafe(socketId, "selenium:session_update", {
session_id: sessionId,
status: "error",
message: `No progress from selenium agent (status="${status}") after ${consecutiveNoProgress} polls; aborting.`,
});
emitSafe(socketId, "selenium:session_error", {
session_id: sessionId,
status: "error",
message: "No progress from selenium agent",
});
delete ddmaJobs[sessionId];
return;
}
// always emit debug to client if socket exists
emitSafe(socketId, "selenium:debug", {
session_id: sessionId,
attempt,
status,
serverTime: new Date().toISOString(),
});
// If agent is waiting for OTP, inform client but keep polling (do not return)
if (status === "waiting_for_otp") {
emitSafe(socketId, "selenium:otp_required", {
session_id: sessionId,
message: "OTP required. Please enter the OTP.",
});
// do not return — keep polling (allows same poller to pick up completion)
await new Promise((r) => setTimeout(r, baseDelayMs));
continue;
}
// Completed path
if (status === "completed") {
log("poller", "agent completed; processing result", {
sessionId,
resultKeys: st.result ? Object.keys(st.result) : null,
});
// Persist raw result so frontend can fetch if socket disconnects
currentFinalSessionId = sessionId;
currentFinalResult = {
rawSelenium: st.result,
processedAt: null,
final: null,
};
let finalResult: any = null;
if (job && st.result) {
try {
finalResult = await handleDdmaCompletedJob(
sessionId,
job,
st.result
);
currentFinalResult.final = finalResult;
currentFinalResult.processedAt = Date.now();
} catch (err: any) {
currentFinalResult.final = {
error: "processing_failed",
detail: err?.message ?? String(err),
};
currentFinalResult.processedAt = Date.now();
log("poller", "handleDdmaCompletedJob failed", {
sessionId,
err: err?.message ?? err,
});
}
} else {
currentFinalResult[sessionId].final = {
error: "no_job_or_no_result",
};
currentFinalResult[sessionId].processedAt = Date.now();
}
// Emit final update (if socket present)
emitSafe(socketId, "selenium:session_update", {
session_id: sessionId,
status: "completed",
rawSelenium: st.result,
final: currentFinalResult.final,
});
// cleanup job context
delete ddmaJobs[sessionId];
return;
}
// Terminal error / not_found
if (status === "error" || status === "not_found") {
const emitPayload = {
session_id: sessionId,
status,
message: st?.message || "Selenium session error",
};
emitSafe(socketId, "selenium:session_update", emitPayload);
emitSafe(socketId, "selenium:session_error", emitPayload);
delete ddmaJobs[sessionId];
return;
}
} catch (err: any) {
const axiosStatus =
err?.response?.status ?? (err?.status ? Number(err.status) : undefined);
const errCode = err?.code ?? err?.errno;
const errMsg = err?.message ?? String(err);
const errData = err?.response?.data ?? null;
// If agent explicitly returned 404 -> terminal (session gone)
if (
axiosStatus === 404 ||
(typeof errMsg === "string" && errMsg.includes("not_found"))
) {
console.warn(
`${new Date().toISOString()} [poller] terminal 404/not_found for ${sessionId}: data=${JSON.stringify(errData)}`
);
// Emit not_found to client
const emitPayload = {
session_id: sessionId,
status: "not_found",
message:
errData?.detail || "Selenium session not found (agent cleaned up).",
};
emitSafe(socketId, "selenium:session_update", emitPayload);
emitSafe(socketId, "selenium:session_error", emitPayload);
// Remove job context and stop polling
delete ddmaJobs[sessionId];
return;
}
// Detailed transient error logging
transientErrorCount++;
if (transientErrorCount > maxTransientErrors) {
const emitPayload = {
session_id: sessionId,
status: "error",
message:
"Repeated network errors while polling selenium agent; giving up.",
};
emitSafe(socketId, "selenium:session_update", emitPayload);
emitSafe(socketId, "selenium:session_error", emitPayload);
delete ddmaJobs[sessionId];
return;
}
const backoffMs = Math.min(
30_000,
baseDelayMs * Math.pow(2, transientErrorCount - 1)
);
console.warn(
`${new Date().toISOString()} [poller] transient error (#${transientErrorCount}) for ${sessionId}: code=${errCode} status=${axiosStatus} msg=${errMsg} data=${JSON.stringify(errData)}`
);
console.warn(
`${new Date().toISOString()} [poller] backing off ${backoffMs}ms before next attempt`
);
await new Promise((r) => setTimeout(r, backoffMs));
continue;
}
// normal poll interval
await new Promise((r) => setTimeout(r, baseDelayMs));
}
// overall timeout fallback
emitSafe(socketId, "selenium:session_update", {
session_id: sessionId,
status: "error",
message: "Polling timeout while waiting for selenium session",
});
delete ddmaJobs[sessionId];
}
/** /**
* POST /ddma-eligibility * POST /ddma-eligibility
* Starts DDMA eligibility Selenium job. *
* Expects: * Enqueues a DDMA eligibility check in the shared InProcessQueue
* - req.body.data: stringified JSON like your existing /eligibility-check * (concurrency=1, mirrors the Python semaphore).
* - req.body.socketId: socket.io client id *
* Body:
* data — patient + search fields (memberId, dateOfBirth, …)
* socketId — socket.io client id for real-time updates
*
* Response: { status: "queued", jobId: "…" }
*
* Real-time events emitted to socketId during job execution:
* job:update { jobId, jobType, status: "active"|"completed"|"failed", … }
* selenium:ddma_session_started { session_id, jobId }
* selenium:otp_required { session_id, jobId, message }
*/ */
router.post( router.post(
"/ddma-eligibility", "/ddma-eligibility",
@@ -575,8 +45,7 @@ router.post(
.status(400) .status(400)
.json({ error: "Missing Insurance Eligibility data for selenium" }); .json({ error: "Missing Insurance Eligibility data for selenium" });
} }
if (!req.user?.id) {
if (!req.user || !req.user.id) {
return res.status(401).json({ error: "Unauthorized: user info missing" }); return res.status(401).json({ error: "Unauthorized: user info missing" });
} }
@@ -586,6 +55,7 @@ router.post(
? JSON.parse(req.body.data) ? JSON.parse(req.body.data)
: req.body.data; : req.body.data;
// Fetch credentials from DB
const credentials = await storage.getInsuranceCredentialByUserAndSiteKey( const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(
req.user.id, req.user.id,
rawData.insuranceSiteKey rawData.insuranceSiteKey
@@ -593,7 +63,7 @@ router.post(
if (!credentials) { if (!credentials) {
return res.status(404).json({ return res.status(404).json({
error: error:
"No insurance credentials found for this provider, Kindly Update this at Settings Page.", "No insurance credentials found for this provider. Please update them at the Settings page.",
}); });
} }
@@ -605,40 +75,25 @@ router.post(
const socketId: string | undefined = req.body.socketId; const socketId: string | undefined = req.body.socketId;
const agentResp = // Enqueue — this enforces the same concurrency=1 as all other selenium jobs
await forwardToSeleniumDdmaEligibilityAgent(enrichedData); const jobId = enqueueSeleniumJob({
jobType: "ddma-eligibility-check",
if (
!agentResp ||
agentResp.status !== "started" ||
!agentResp.session_id
) {
return res.status(502).json({
error: "Selenium agent did not return a started session",
detail: agentResp,
});
}
const sessionId = agentResp.session_id as string;
// Save job context
ddmaJobs[sessionId] = {
userId: req.user.id, userId: req.user.id,
insuranceEligibilityData: enrichedData,
socketId, socketId,
}; enrichedPayload: enrichedData,
insuranceId: String(rawData.memberId ?? "").trim(),
formFirstName: rawData.firstName,
formLastName: rawData.lastName,
formDob: rawData.dateOfBirth,
});
// start polling in background to notify client via socket and process job log("ddma-route", "job enqueued", { jobId, insuranceId: rawData.memberId });
pollAgentSessionAndProcess(sessionId, socketId).catch((e) =>
console.warn("pollAgentSessionAndProcess failed", e)
);
// reply immediately with started status return res.json({ status: "queued", jobId });
return res.json({ status: "started", session_id: sessionId });
} catch (err: any) { } catch (err: any) {
console.error(err); console.error("[ddma-route] enqueue failed:", err);
return res.status(500).json({ return res.status(500).json({
error: err.message || "Failed to start ddma selenium agent", error: err.message || "Failed to enqueue DDMA selenium job",
}); });
} }
} }
@@ -646,8 +101,13 @@ router.post(
/** /**
* POST /selenium/submit-otp * POST /selenium/submit-otp
*
* Forwards the OTP entered by the user directly to the Python agent.
* This is a side-channel — it does NOT go through the queue.
* The polling loop inside ddmaEligibilityProcessor picks up the completed
* state after OTP is submitted.
*
* Body: { session_id, otp, socketId? } * Body: { session_id, otp, socketId? }
* Forwards OTP to Python agent and optionally notifies client socket.
*/ */
router.post( router.post(
"/selenium/submit-otp", "/selenium/submit-otp",
@@ -660,7 +120,6 @@ router.post(
try { try {
const r = await forwardOtpToSeleniumDdmaAgent(sessionId, otp); const r = await forwardOtpToSeleniumDdmaAgent(sessionId, otp);
// emit OTP accepted (if socket present)
emitSafe(socketId, "selenium:otp_submitted", { emitSafe(socketId, "selenium:otp_submitted", {
session_id: sessionId, session_id: sessionId,
result: r, result: r,
@@ -669,31 +128,15 @@ router.post(
return res.json(r); return res.json(r);
} catch (err: any) { } catch (err: any) {
console.error( console.error(
"Failed to forward OTP:", "[ddma-route] submit-otp failed:",
err?.response?.data || err?.message || err err?.response?.data || err?.message || err
); );
return res.status(500).json({ return res.status(500).json({
error: "Failed to forward otp to selenium agent", error: "Failed to forward OTP to selenium agent",
detail: err?.message || err, detail: err?.message || err,
}); });
} }
} }
); );
// GET /selenium/session/:sid/final
router.get(
"/selenium/session/:sid/final",
async (req: Request, res: Response) => {
const sid = req.params.sid;
if (!sid) return res.status(400).json({ error: "session id required" });
// Only the current in-memory result is available
if (currentFinalSessionId !== sid || !currentFinalResult) {
return res.status(404).json({ error: "final result not found" });
}
return res.json(currentFinalResult);
}
);
export default router; export default router;

View File

@@ -1,5 +1,4 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { io as ioClient, Socket } from "socket.io-client";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -9,13 +8,11 @@ import { apiRequest, queryClient } from "@/lib/queryClient";
import { useAppDispatch } from "@/redux/hooks"; import { useAppDispatch } from "@/redux/hooks";
import { setTaskStatus } from "@/redux/slices/seleniumTaskSlice"; import { setTaskStatus } from "@/redux/slices/seleniumTaskSlice";
import { formatLocalDate } from "@/utils/dateUtils"; import { formatLocalDate } from "@/utils/dateUtils";
import { socket } from "@/lib/socket";
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table"; import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
const SOCKET_URL = // ─── OTP Modal ────────────────────────────────────────────────────────────────
import.meta.env.VITE_API_BASE_URL_BACKEND ||
(typeof window !== "undefined" ? window.location.origin : "");
// ---------- OTP Modal component ----------
interface DdmaOtpModalProps { interface DdmaOtpModalProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
@@ -23,12 +20,7 @@ interface DdmaOtpModalProps {
isSubmitting: boolean; isSubmitting: boolean;
} }
function DdmaOtpModal({ function DdmaOtpModal({ open, onClose, onSubmit, isSubmitting }: DdmaOtpModalProps) {
open,
onClose,
onSubmit,
isSubmitting,
}: DdmaOtpModalProps) {
const [otp, setOtp] = useState(""); const [otp, setOtp] = useState("");
useEffect(() => { useEffect(() => {
@@ -48,17 +40,13 @@ function DdmaOtpModal({
<div className="bg-white dark:bg-slate-900 rounded-xl shadow-lg w-full max-w-md p-6"> <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"> <div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Enter OTP</h2> <h2 className="text-lg font-semibold">Enter OTP</h2>
<button <button type="button" onClick={onClose} className="text-slate-500 hover:text-slate-800">
type="button"
onClick={onClose}
className="text-slate-500 hover:text-slate-800"
>
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
</div> </div>
<p className="text-sm text-slate-500 mb-4"> <p className="text-sm text-slate-500 mb-4">
We need the one-time password (OTP) sent by the Delta Dental MA portal We need the one-time password (OTP) sent by the Delta Dental MA portal to complete this
to complete this eligibility check. eligibility check.
</p> </p>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
@@ -72,12 +60,7 @@ function DdmaOtpModal({
/> />
</div> </div>
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<Button <Button type="button" variant="outline" onClick={onClose} disabled={isSubmitting}>
type="button"
variant="outline"
onClick={onClose}
disabled={isSubmitting}
>
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={isSubmitting || !otp.trim()}> <Button type="submit" disabled={isSubmitting || !otp.trim()}>
@@ -97,14 +80,14 @@ function DdmaOtpModal({
); );
} }
// ---------- Main DDMA Eligibility button component ---------- // ─── Main component ───────────────────────────────────────────────────────────
interface DdmaEligibilityButtonProps { interface DdmaEligibilityButtonProps {
memberId: string; memberId: string;
dateOfBirth: Date | null; dateOfBirth: Date | null;
firstName?: string; firstName?: string;
lastName?: string; lastName?: string;
isFormIncomplete: boolean; isFormIncomplete: boolean;
/** Called when backend has finished and PDF is ready */
onPdfReady: (pdfId: number, fallbackFilename: string | null) => void; onPdfReady: (pdfId: number, fallbackFilename: string | null) => void;
} }
@@ -119,267 +102,18 @@ export function DdmaEligibilityButton({
const { toast } = useToast(); const { toast } = useToast();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const socketRef = useRef<Socket | null>(null); // session_id is provided by the backend once the Python agent starts the
const connectingRef = useRef<Promise<void> | null>(null); // browser session. We receive it via the selenium:ddma_session_started event
// and need it to forward the OTP back.
const sessionIdRef = useRef<string | null>(null);
const [sessionId, setSessionId] = useState<string | null>(null);
const [otpModalOpen, setOtpModalOpen] = useState(false); const [otpModalOpen, setOtpModalOpen] = useState(false);
const [isStarting, setIsStarting] = useState(false); const [isStarting, setIsStarting] = useState(false);
const [isSubmittingOtp, setIsSubmittingOtp] = useState(false); const [isSubmittingOtp, setIsSubmittingOtp] = useState(false);
// Clean up socket on unmount // ── Socket event handlers ─────────────────────────────────────────────────
useEffect(() => {
return () => {
if (socketRef.current) {
socketRef.current.removeAllListeners();
socketRef.current.disconnect();
socketRef.current = null;
}
connectingRef.current = null;
};
}, []);
const closeSocket = () => { const handleDdmaStart = async () => {
try {
socketRef.current?.removeAllListeners();
socketRef.current?.disconnect();
} catch (e) {
// ignore
} finally {
socketRef.current = null;
}
};
// Lazy socket setup: called only when we actually need it (first click)
const ensureSocketConnected = async () => {
// If already connected, nothing to do
if (socketRef.current && socketRef.current.connected) {
return;
}
// If a connection is in progress, reuse that promise
if (connectingRef.current) {
return connectingRef.current;
}
const promise = new Promise<void>((resolve, reject) => {
const socket = ioClient(SOCKET_URL, {
withCredentials: true,
});
socketRef.current = socket;
socket.on("connect", () => {
console.log("DDMA socket connected:", socket.id);
resolve();
});
// connection error when first connecting (or later)
socket.on("connect_error", (err: any) => {
dispatch(
setTaskStatus({
key: "eligibilityCheck",
status: "error",
message: "Connection failed",
})
);
toast({
title: "Realtime connection failed",
description:
"Could not connect to realtime server. Retrying automatically...",
variant: "destructive",
});
// do not reject here because socket.io will attempt reconnection
});
// socket.io will emit 'reconnect_attempt' for retries
socket.on("reconnect_attempt", (attempt: number) => {
dispatch(
setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: `Realtime reconnect attempt #${attempt}`,
})
);
});
// when reconnection failed after configured attempts
socket.on("reconnect_failed", () => {
dispatch(
setTaskStatus({
key: "eligibilityCheck",
status: "error",
message: "Reconnect failed",
})
);
toast({
title: "Realtime reconnect failed",
description:
"Connection to realtime server could not be re-established. Please try again later.",
variant: "destructive",
});
// terminal failure — cleanup and reject so caller can stop start flow
closeSocket();
reject(new Error("Realtime reconnect failed"));
});
socket.on("disconnect", (reason: any) => {
dispatch(
setTaskStatus({
key: "eligibilityCheck",
status: "error",
message: "Connection disconnected",
})
);
toast({
title: "Connection Disconnected",
description:
"Connection to the server was lost. If a DDMA job was running it may have failed.",
variant: "destructive",
});
// clear sessionId/OTP modal
setSessionId(null);
setOtpModalOpen(false);
});
// OTP required
socket.on("selenium:otp_required", (payload: any) => {
if (!payload?.session_id) return;
setSessionId(payload.session_id);
setOtpModalOpen(true);
dispatch(
setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "OTP required for DDMA eligibility. Please enter the OTP.",
})
);
});
// OTP submitted (optional UX)
socket.on("selenium:otp_submitted", (payload: any) => {
if (!payload?.session_id) return;
dispatch(
setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "OTP submitted. Finishing DDMA eligibility check...",
})
);
});
// Session update
socket.on("selenium:session_update", (payload: any) => {
const { session_id, status, final } = payload || {};
if (!session_id) return;
if (status === "completed") {
dispatch(
setTaskStatus({
key: "eligibilityCheck",
status: "success",
message:
"DDMA eligibility updated and PDF attached to patient documents.",
})
);
toast({
title: "DDMA eligibility complete",
description:
"Patient status was updated and the eligibility PDF was saved.",
variant: "default",
});
const pdfId = final?.pdfFileId;
if (pdfId) {
const filename =
final?.pdfFilename ?? `eligibility_ddma_${memberId}.pdf`;
onPdfReady(Number(pdfId), filename);
}
setSessionId(null);
setOtpModalOpen(false);
} else if (status === "error") {
const msg =
payload?.message ||
final?.error ||
"DDMA eligibility session failed.";
dispatch(
setTaskStatus({
key: "eligibilityCheck",
status: "error",
message: msg,
})
);
toast({
title: "DDMA selenium error",
description: msg,
variant: "destructive",
});
// Ensure socket is torn down for this session (stop receiving stale events)
try {
closeSocket();
} catch (e) {}
setSessionId(null);
setOtpModalOpen(false);
}
queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
});
// explicit session error event (helpful)
socket.on("selenium:session_error", (payload: any) => {
const msg = payload?.message || "Selenium session error";
dispatch(
setTaskStatus({
key: "eligibilityCheck",
status: "error",
message: msg,
})
);
toast({
title: "Selenium session error",
description: msg,
variant: "destructive",
});
// tear down socket to avoid stale updates
try {
closeSocket();
} catch (e) {}
setSessionId(null);
setOtpModalOpen(false);
});
// If socket.io initial connection fails permanently (very rare: client-level)
// set a longer timeout to reject the first attempt to connect.
const initialConnectTimeout = setTimeout(() => {
if (!socket.connected) {
// if still not connected after 8s, treat as failure and reject so caller can handle it
closeSocket();
reject(new Error("Realtime initial connection timeout"));
}
}, 8000);
// When the connect resolves we should clear this timer
socket.once("connect", () => {
clearTimeout(initialConnectTimeout);
});
});
// store promise to prevent multiple concurrent connections
connectingRef.current = promise;
try {
await promise;
} finally {
connectingRef.current = null;
}
};
const startDdmaEligibility = async () => {
if (!memberId || !dateOfBirth) { if (!memberId || !dateOfBirth) {
toast({ toast({
title: "Missing fields", title: "Missing fields",
@@ -389,107 +123,220 @@ export function DdmaEligibilityButton({
return; return;
} }
const formattedDob = dateOfBirth ? formatLocalDate(dateOfBirth) : ""; const formattedDob = formatLocalDate(dateOfBirth);
const payload = { const payload = {
memberId, memberId,
dateOfBirth: formattedDob, dateOfBirth: formattedDob,
firstName, firstName,
lastName, lastName,
insuranceSiteKey: "DDMA", // make sure this matches backend credential key insuranceSiteKey: "DDMA",
}; };
setIsStarting(true);
try { try {
setIsStarting(true);
// 1) Ensure socket is connected (lazy)
dispatch( dispatch(
setTaskStatus({ setTaskStatus({
key: "eligibilityCheck", key: "eligibilityCheck",
status: "pending", status: "pending",
message: "Opening realtime channel for DDMA eligibility...", message: "Starting DDMA eligibility check…",
})
);
await ensureSocketConnected();
const socket = socketRef.current;
if (!socket || !socket.connected) {
throw new Error("Socket connection failed");
}
const socketId = socket.id;
// 2) Start the selenium job via backend
dispatch(
setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "Starting DDMA eligibility check via selenium...",
}) })
); );
// 1) POST to backend — returns { status: "queued", jobId }
const response = await apiRequest( const response = await apiRequest(
"POST", "POST",
"/api/insurance-status-ddma/ddma-eligibility", "/api/insurance-status-ddma/ddma-eligibility",
{ { data: JSON.stringify(payload), socketId: socket.id }
data: JSON.stringify(payload),
socketId,
}
); );
// If apiRequest threw, we would have caught above; but just in case it returns. const result = await response.json();
let result: any = null; if (!response.ok || result.error) {
let backendError: string | null = null; throw new Error(result.error || `Server error (${response.status})`);
try {
// attempt JSON first
result = await response.clone().json();
backendError =
result?.error || result?.message || result?.detail || null;
} catch {
// fallback to text response
try {
const text = await response.clone().text();
backendError = text?.trim() || null;
} catch {
backendError = null;
}
} }
if (!response.ok) { const jobId: string = result.jobId;
throw new Error( if (!jobId) throw new Error("No jobId returned from server");
backendError ||
`DDMA selenium start failed (status ${response.status})`
);
}
// Normal success path: optional: if backend returns non-error shape still check for result.error dispatch(
if (result?.error) { setTaskStatus({
throw new Error(result.error); key: "eligibilityCheck",
} status: "pending",
message: "DDMA job queued. Waiting for browser session to start…",
})
);
if (result.status === "started" && result.session_id) { // 2) Listen for job-lifecycle and DDMA-specific socket events.
setSessionId(result.session_id as string); // All events come through the shared app socket.
// Handler: Python agent started a browser session → we now have session_id
const onSessionStarted = (data: any) => {
if (String(data?.jobId) !== String(jobId)) return;
sessionIdRef.current = data.session_id ?? null;
dispatch( dispatch(
setTaskStatus({ setTaskStatus({
key: "eligibilityCheck", key: "eligibilityCheck",
status: "pending", status: "pending",
message: message: "Browser session started. Waiting for OTP or result…",
"DDMA eligibility job started. Waiting for OTP or final result...",
}) })
); );
} else { };
// fallback if backend returns immediate result
// Handler: OTP is required by the DDMA portal
const onOtpRequired = (data: any) => {
if (String(data?.jobId) !== String(jobId)) return;
// Update sessionId in case it arrives here first
if (data.session_id) sessionIdRef.current = data.session_id;
setOtpModalOpen(true);
dispatch( dispatch(
setTaskStatus({ setTaskStatus({
key: "eligibilityCheck", key: "eligibilityCheck",
status: "success", status: "pending",
message: "DDMA eligibility completed.", message: "OTP required for Delta Dental MA. Please enter the code.",
}) })
); );
};
// Handler: OTP accepted by Python agent (optional UX feedback)
const onOtpSubmitted = (data: any) => {
if (data?.session_id && data.session_id !== sessionIdRef.current) return;
dispatch(
setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "OTP submitted. Finishing DDMA eligibility check…",
})
);
};
// Handler: job completed or failed (from InProcessQueue)
const onJobUpdate = (data: any) => {
if (String(data?.jobId) !== String(jobId)) return;
if (data.status === "active") {
dispatch(
setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: data.message ?? "Selenium browser starting…",
})
);
return;
}
// Terminal states
cleanup();
if (data.status === "completed") {
dispatch(
setTaskStatus({
key: "eligibilityCheck",
status: "success",
message: "DDMA eligibility updated and PDF attached to patient documents.",
})
);
toast({
title: "DDMA eligibility complete",
description: "Patient status was updated and the eligibility PDF was saved.",
});
queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
const pdfId = data.result?.pdfFileId;
if (pdfId) {
const filename =
data.result?.pdfFilename ?? `eligibility_ddma_${memberId}.pdf`;
onPdfReady(Number(pdfId), filename);
}
} else if (data.status === "failed") {
const msg = data.error ?? "DDMA eligibility job failed.";
dispatch(
setTaskStatus({ key: "eligibilityCheck", status: "error", message: msg })
);
toast({ title: "DDMA selenium error", description: msg, variant: "destructive" });
}
setIsStarting(false);
setOtpModalOpen(false);
};
// Attach listeners
socket.on("selenium:ddma_session_started", onSessionStarted);
socket.on("selenium:otp_required", onOtpRequired);
socket.on("selenium:otp_submitted", onOtpSubmitted);
socket.on("job:update", onJobUpdate);
// Cleanup helper removes all listeners for this job
function cleanup() {
socket.off("selenium:ddma_session_started", onSessionStarted);
socket.off("selenium:otp_required", onOtpRequired);
socket.off("selenium:otp_submitted", onOtpSubmitted);
socket.off("job:update", onJobUpdate);
} }
// Safety timeout — clean up listeners if no terminal event in 6 min
const safetyTimer = setTimeout(() => {
cleanup();
setIsStarting(false);
setOtpModalOpen(false);
dispatch(
setTaskStatus({
key: "eligibilityCheck",
status: "error",
message: "DDMA job timed out waiting for completion.",
})
);
}, 6 * 60 * 1000);
// Patch cleanup to also clear the timer
const originalCleanup = cleanup;
function cleanupWithTimer() {
clearTimeout(safetyTimer);
originalCleanup();
}
// Override the onJobUpdate cleanup reference
socket.off("job:update", onJobUpdate);
socket.on("job:update", (data: any) => {
if (String(data?.jobId) !== String(jobId)) return;
if (data.status === "active") {
dispatch(
setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: data.message ?? "Selenium browser starting…",
})
);
return;
}
cleanupWithTimer();
if (data.status === "completed") {
dispatch(
setTaskStatus({
key: "eligibilityCheck",
status: "success",
message: "DDMA eligibility updated and PDF attached to patient documents.",
})
);
toast({
title: "DDMA 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_ddma_${memberId}.pdf`);
}
} else if (data.status === "failed") {
const msg = data.error ?? "DDMA eligibility job failed.";
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: msg }));
toast({ title: "DDMA selenium error", description: msg, variant: "destructive" });
}
setIsStarting(false);
setOtpModalOpen(false);
});
} catch (err: any) { } catch (err: any) {
console.error("startDdmaEligibility error:", err); console.error("DdmaEligibilityButton error:", err);
dispatch( dispatch(
setTaskStatus({ setTaskStatus({
key: "eligibilityCheck", key: "eligibilityCheck",
@@ -502,17 +349,18 @@ export function DdmaEligibilityButton({
description: err?.message || "Failed to start DDMA eligibility", description: err?.message || "Failed to start DDMA eligibility",
variant: "destructive", variant: "destructive",
}); });
} finally {
setIsStarting(false); setIsStarting(false);
} }
}; };
// ── OTP submission ────────────────────────────────────────────────────────
const handleSubmitOtp = async (otp: string) => { const handleSubmitOtp = async (otp: string) => {
if (!sessionId || !socketRef.current || !socketRef.current.connected) { const sessionId = sessionIdRef.current;
if (!sessionId) {
toast({ toast({
title: "Session not ready", title: "Session not ready",
description: description: "Cannot submit OTP — DDMA session ID is not available yet.",
"Could not submit OTP because the DDMA session or socket is not ready.",
variant: "destructive", variant: "destructive",
}); });
return; return;
@@ -520,21 +368,15 @@ export function DdmaEligibilityButton({
try { try {
setIsSubmittingOtp(true); setIsSubmittingOtp(true);
const resp = await apiRequest( const resp = await apiRequest("POST", "/api/insurance-status-ddma/selenium/submit-otp", {
"POST", session_id: sessionId,
"/api/insurance-status-ddma/selenium/submit-otp", otp,
{ socketId: socket.id,
session_id: sessionId, });
otp,
socketId: socketRef.current.id,
}
);
const data = await resp.json(); const data = await resp.json();
if (!resp.ok || data.error) { if (!resp.ok || data.error) {
throw new Error(data.error || "Failed to submit OTP"); throw new Error(data.error || "Failed to submit OTP");
} }
// from here we rely on websocket events (otp_submitted + session_update)
setOtpModalOpen(false); setOtpModalOpen(false);
} catch (err: any) { } catch (err: any) {
console.error("handleSubmitOtp error:", err); console.error("handleSubmitOtp error:", err);
@@ -548,13 +390,15 @@ export function DdmaEligibilityButton({
} }
}; };
// ── Render ────────────────────────────────────────────────────────────────
return ( return (
<> <>
<Button <Button
className="w-full" className="w-full"
variant="default" variant="default"
disabled={isFormIncomplete || isStarting} disabled={isFormIncomplete || isStarting}
onClick={startDdmaEligibility} onClick={handleDdmaStart}
> >
{isStarting ? ( {isStarting ? (
<> <>

View File

@@ -12,9 +12,22 @@ import os
import time import time
import helpers_ddma_eligibility as hddma import helpers_ddma_eligibility as hddma
# Import startup session-clear functions
from ddma_browser_manager import clear_ddma_session_on_startup
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
# Clear DDMA session on startup so fresh login is required after PC restart.
# Device trust tokens are preserved so OTP is still skipped after first login.
print("=" * 50)
print("SELENIUM AGENT STARTING - CLEARING DDMA SESSION")
print("=" * 50)
clear_ddma_session_on_startup()
print("=" * 50)
print("SESSION CLEAR COMPLETE")
print("=" * 50)
app = FastAPI() app = FastAPI()
# Allow 1 selenium session at a time # Allow 1 selenium session at a time
@@ -264,6 +277,21 @@ async def session_status(sid: str):
return s return s
# ── Session management endpoints ─────────────────────────────────────────────
@app.post("/clear-ddma-session")
async def clear_ddma_session_endpoint():
"""
Clears the DDMA browser session (cookies + cached credentials).
Call this when DDMA credentials are deleted or changed.
"""
try:
clear_ddma_session_on_startup()
return {"status": "success", "message": "DDMA session cleared"}
except Exception as e:
return {"status": "error", "message": str(e)}
# ✅ Health Check Endpoint # ✅ Health Check Endpoint
@app.get("/") @app.get("/")
async def health_check(): async def health_check():

View File

@@ -1,19 +1,30 @@
""" """
Minimal browser manager for DDMA - only handles persistent profile and keeping browser alive. Browser manager for DDMA (Delta Dental MA) - persistent profile with session management.
Does NOT modify any login/OTP logic. - Uses --user-data-dir for persistent profile (device trust tokens)
- Clears session cookies on startup (after PC restart) to force fresh login
- Tracks credentials to detect changes mid-session (triggers logout)
- Anti-detection options to avoid bot detection
""" """
import os import os
import glob
import shutil
import hashlib
import threading import threading
from selenium import webdriver from selenium import webdriver
from selenium.webdriver.chrome.service import Service from selenium.webdriver.chrome.service import Service
from webdriver_manager.chrome import ChromeDriverManager from webdriver_manager.chrome import ChromeDriverManager
# Ensure DISPLAY is set for Chrome to work (needed when running from SSH/background)
if not os.environ.get("DISPLAY"):
os.environ["DISPLAY"] = ":0"
class DDMABrowserManager: class DDMABrowserManager:
""" """
Singleton that manages a persistent Chrome browser instance. Singleton that manages a persistent Chrome browser instance.
- Uses --user-data-dir for persistent profile (device trust tokens, cookies) - Uses --user-data-dir for persistent profile (device trust tokens)
- Keeps browser alive between patient runs - Clears session cookies on startup (after PC restart)
- Tracks credentials to detect changes mid-session
""" """
_instance = None _instance = None
_lock = threading.Lock() _lock = threading.Lock()
@@ -25,55 +36,223 @@ class DDMABrowserManager:
cls._instance._driver = None cls._instance._driver = None
cls._instance.profile_dir = os.path.abspath("chrome_profile_ddma") cls._instance.profile_dir = os.path.abspath("chrome_profile_ddma")
cls._instance.download_dir = os.path.abspath("seleniumDownloads") cls._instance.download_dir = os.path.abspath("seleniumDownloads")
cls._instance._credentials_file = os.path.join(cls._instance.profile_dir, ".last_credentials")
cls._instance._needs_session_clear = False
os.makedirs(cls._instance.profile_dir, exist_ok=True) os.makedirs(cls._instance.profile_dir, exist_ok=True)
os.makedirs(cls._instance.download_dir, exist_ok=True) os.makedirs(cls._instance.download_dir, exist_ok=True)
return cls._instance return cls._instance
def clear_session_on_startup(self):
"""
Clear session cookies from Chrome profile on startup.
This forces a fresh login after PC restart.
Preserves device trust tokens (LocalStorage, IndexedDB) to avoid OTPs.
"""
print("[DDMA BrowserManager] Clearing session on startup...")
try:
# Clear credentials tracking file
if os.path.exists(self._credentials_file):
os.remove(self._credentials_file)
print("[DDMA BrowserManager] Cleared credentials tracking file")
# Clear session-related Chrome profile files
session_files = [
"Cookies",
"Cookies-journal",
"Login Data",
"Login Data-journal",
"Web Data",
"Web Data-journal",
]
for filename in session_files:
for base in [os.path.join(self.profile_dir, "Default"), self.profile_dir]:
filepath = os.path.join(base, filename)
if os.path.exists(filepath):
try:
os.remove(filepath)
print(f"[DDMA BrowserManager] Removed {filename}")
except Exception as e:
print(f"[DDMA BrowserManager] Could not remove {filename}: {e}")
# Clear Session Storage (contains login state)
session_storage_dir = os.path.join(self.profile_dir, "Default", "Session Storage")
if os.path.exists(session_storage_dir):
try:
shutil.rmtree(session_storage_dir)
print("[DDMA BrowserManager] Cleared Session Storage")
except Exception as e:
print(f"[DDMA BrowserManager] Could not clear Session Storage: {e}")
# Clear Local Storage (may contain auth tokens)
local_storage_dir = os.path.join(self.profile_dir, "Default", "Local Storage")
if os.path.exists(local_storage_dir):
try:
shutil.rmtree(local_storage_dir)
print("[DDMA BrowserManager] Cleared Local Storage")
except Exception as e:
print(f"[DDMA BrowserManager] Could not clear Local Storage: {e}")
# Clear IndexedDB (may contain auth tokens)
indexeddb_dir = os.path.join(self.profile_dir, "Default", "IndexedDB")
if os.path.exists(indexeddb_dir):
try:
shutil.rmtree(indexeddb_dir)
print("[DDMA BrowserManager] Cleared IndexedDB")
except Exception as e:
print(f"[DDMA BrowserManager] Could not clear IndexedDB: {e}")
# Clear browser caches
cache_dirs = [
os.path.join(self.profile_dir, "Default", "Cache"),
os.path.join(self.profile_dir, "Default", "Code Cache"),
os.path.join(self.profile_dir, "Default", "GPUCache"),
os.path.join(self.profile_dir, "Default", "Service Worker"),
os.path.join(self.profile_dir, "Cache"),
os.path.join(self.profile_dir, "Code Cache"),
os.path.join(self.profile_dir, "GPUCache"),
os.path.join(self.profile_dir, "Service Worker"),
os.path.join(self.profile_dir, "ShaderCache"),
]
for cache_dir in cache_dirs:
if os.path.exists(cache_dir):
try:
shutil.rmtree(cache_dir)
print(f"[DDMA BrowserManager] Cleared {os.path.basename(cache_dir)}")
except Exception as e:
print(f"[DDMA BrowserManager] Could not clear {os.path.basename(cache_dir)}: {e}")
self._needs_session_clear = True
print("[DDMA BrowserManager] Session cleared - will require fresh login")
except Exception as e:
print(f"[DDMA BrowserManager] Error clearing session: {e}")
# ── Credential hash tracking ──────────────────────────────────────────────
def _hash_credentials(self, username: str) -> str:
return hashlib.sha256(username.encode()).hexdigest()[:16]
def get_last_credentials_hash(self) -> str | None:
try:
if os.path.exists(self._credentials_file):
with open(self._credentials_file, 'r') as f:
return f.read().strip()
except Exception:
pass
return None
def save_credentials_hash(self, username: str):
try:
cred_hash = self._hash_credentials(username)
with open(self._credentials_file, 'w') as f:
f.write(cred_hash)
except Exception as e:
print(f"[DDMA BrowserManager] Failed to save credentials hash: {e}")
def credentials_changed(self, username: str) -> bool:
last_hash = self.get_last_credentials_hash()
if last_hash is None:
return False # No previous credentials stored — not a change
current_hash = self._hash_credentials(username)
changed = last_hash != current_hash
if changed:
print("[DDMA BrowserManager] Credentials changed — logout required")
return changed
def clear_credentials_hash(self):
try:
if os.path.exists(self._credentials_file):
os.remove(self._credentials_file)
except Exception as e:
print(f"[DDMA BrowserManager] Failed to clear credentials hash: {e}")
# ── Chrome process management ─────────────────────────────────────────────
def _kill_existing_chrome_for_profile(self):
"""Kill any existing Chrome processes using this profile and remove lock files."""
import subprocess
import time as time_module
try:
result = subprocess.run(
["pgrep", "-f", f"user-data-dir={self.profile_dir}"],
capture_output=True, text=True
)
if result.stdout.strip():
for pid in result.stdout.strip().split('\n'):
try:
subprocess.run(["kill", "-9", pid], check=False)
except Exception:
pass
time_module.sleep(1)
except Exception:
pass
for lock_file in ["SingletonLock", "SingletonSocket", "SingletonCookie"]:
lock_path = os.path.join(self.profile_dir, lock_file)
try:
if os.path.islink(lock_path) or os.path.exists(lock_path):
os.remove(lock_path)
except Exception:
pass
# ── Driver lifecycle ──────────────────────────────────────────────────────
def get_driver(self, headless=False): def get_driver(self, headless=False):
"""Get or create the persistent browser instance.""" """Get or create the persistent browser instance."""
with self._lock: with self._lock:
if self._driver is None: if self._driver is None:
print("[BrowserManager] Driver is None, creating new driver") print("[DDMA BrowserManager] Driver is None, creating new driver")
self._kill_existing_chrome_for_profile()
self._create_driver(headless) self._create_driver(headless)
elif not self._is_alive(): elif not self._is_alive():
print("[BrowserManager] Driver not alive, recreating") print("[DDMA BrowserManager] Driver not alive, recreating")
self._kill_existing_chrome_for_profile()
self._create_driver(headless) self._create_driver(headless)
else: else:
print("[BrowserManager] Reusing existing driver") print("[DDMA BrowserManager] Reusing existing driver")
return self._driver return self._driver
def _is_alive(self): def _is_alive(self):
"""Check if browser is still responsive."""
try: try:
if self._driver is None:
return False
url = self._driver.current_url url = self._driver.current_url
print(f"[BrowserManager] Driver alive, current URL: {url[:50]}...") print(f"[DDMA BrowserManager] Driver alive, current URL: {url[:50]}...")
return True return True
except Exception as e: except Exception as e:
print(f"[BrowserManager] Driver not alive: {e}") print(f"[DDMA BrowserManager] Driver not alive: {e}")
return False return False
def _create_driver(self, headless=False): def _create_driver(self, headless=False):
"""Create browser with persistent profile.""" """Create browser with persistent profile and anti-detection options."""
if self._driver: if self._driver:
try: try:
self._driver.quit() self._driver.quit()
except: except Exception:
pass pass
options = webdriver.ChromeOptions() options = webdriver.ChromeOptions()
if headless: if headless:
options.add_argument("--headless") options.add_argument("--headless")
# Persistent profile - THIS IS THE KEY for device trust # Persistent profile — keeps device trust tokens between runs
options.add_argument(f"--user-data-dir={self.profile_dir}") options.add_argument(f"--user-data-dir={self.profile_dir}")
options.add_argument("--no-sandbox") options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage") options.add_argument("--disable-dev-shm-usage")
# Anti-detection
options.add_argument("--disable-blink-features=AutomationControlled")
options.add_experimental_option("excludeSwitches", ["enable-automation"])
options.add_experimental_option("useAutomationExtension", False)
options.add_argument("--disable-infobars")
prefs = { prefs = {
"download.default_directory": self.download_dir, "download.default_directory": self.download_dir,
"plugins.always_open_pdf_externally": True, "plugins.always_open_pdf_externally": True,
"download.prompt_for_download": False, "download.prompt_for_download": False,
"download.directory_upgrade": True "download.directory_upgrade": True,
} }
options.add_experimental_option("prefs", prefs) options.add_experimental_option("prefs", prefs)
@@ -81,22 +260,39 @@ class DDMABrowserManager:
self._driver = webdriver.Chrome(service=service, options=options) self._driver = webdriver.Chrome(service=service, options=options)
self._driver.maximize_window() self._driver.maximize_window()
# Remove webdriver property to avoid detection
try:
self._driver.execute_script(
"Object.defineProperty(navigator, 'webdriver', {get: () => undefined})"
)
except Exception:
pass
self._needs_session_clear = False
def quit_driver(self): def quit_driver(self):
"""Quit browser (only call on shutdown).""" """Quit browser (only call on shutdown — NOT between patients)."""
with self._lock: with self._lock:
if self._driver: if self._driver:
try: try:
self._driver.quit() self._driver.quit()
except: except Exception:
pass pass
self._driver = None self._driver = None
# Singleton accessor # ── Singleton accessor ────────────────────────────────────────────────────────
_manager = None _manager = None
def get_browser_manager(): def get_browser_manager() -> DDMABrowserManager:
global _manager global _manager
if _manager is None: if _manager is None:
_manager = DDMABrowserManager() _manager = DDMABrowserManager()
return _manager return _manager
def clear_ddma_session_on_startup():
"""Called by agent.py on startup to clear DDMA session (after PC restart)."""
manager = get_browser_manager()
manager.clear_session_on_startup()

View File

@@ -5,7 +5,7 @@ from typing import Dict, Any
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import WebDriverException from selenium.common.exceptions import WebDriverException, TimeoutException
from selenium_DDMA_eligibilityCheckWorker import AutomationDeltaDentalMAEligibilityCheck from selenium_DDMA_eligibilityCheckWorker import AutomationDeltaDentalMAEligibilityCheck
@@ -20,13 +20,13 @@ def make_session_entry() -> str:
import uuid import uuid
sid = str(uuid.uuid4()) sid = str(uuid.uuid4())
sessions[sid] = { sessions[sid] = {
"status": "created", # created -> running -> waiting_for_otp -> otp_submitted -> completed / error "status": "created", # created running waiting_for_otp completed / error
"created_at": time.time(), "created_at": time.time(),
"last_activity": time.time(), "last_activity": time.time(),
"bot": None, # worker instance "bot": None, # AutomationDeltaDentalMAEligibilityCheck instance
"driver": None, # selenium webdriver "driver": None, # selenium webdriver
"otp_event": asyncio.Event(), "otp_event": asyncio.Event(),
"otp_value": None, "otp_value": None, # OTP submitted from the app
"result": None, "result": None,
"message": None, "message": None,
"type": None, "type": None,
@@ -36,37 +36,26 @@ def make_session_entry() -> str:
async def cleanup_session(sid: str, message: str | None = None): async def cleanup_session(sid: str, message: str | None = None):
""" """
Close driver (if any), wake OTP waiter, set final state, and remove session entry. Wake any OTP waiter, set final state, and remove the session entry.
Idempotent: safe to call multiple times. Safe to call multiple times (idempotent).
NOTE: Does NOT quit the browser driver — the persistent browser stays alive.
""" """
s = sessions.get(sid) s = sessions.get(sid)
if not s: if not s:
return return
try: try:
# Ensure final state if s.get("status") not in ("completed", "error", "not_found"):
try: s["status"] = "error"
if s.get("status") not in ("completed", "error", "not_found"): if message:
s["status"] = "error" s["message"] = message
if message:
s["message"] = message
except Exception:
pass
# Wake any OTP waiter (so awaiting coroutines don't hang)
try:
ev = s.get("otp_event")
if ev and not ev.is_set():
ev.set()
except Exception:
pass
# NOTE: Do NOT quit driver - keep browser alive for next patient
# Browser manager handles the persistent browser instance
ev = s.get("otp_event")
if ev and not ev.is_set():
ev.set()
finally: finally:
# Remove session entry from map
sessions.pop(sid, None) sessions.pop(sid, None)
print(f"[helpers] cleaned session {sid}") print(f"[helpers_ddma] cleaned session {sid}")
async def _remove_session_later(sid: str, delay: int = 20): async def _remove_session_later(sid: str, delay: int = 20):
await asyncio.sleep(delay) await asyncio.sleep(delay)
@@ -75,8 +64,12 @@ async def _remove_session_later(sid: str, delay: int = 20):
async def start_ddma_run(sid: str, data: dict, url: str): async def start_ddma_run(sid: str, data: dict, url: str):
""" """
Run the DDMA workflow for a session (WITHOUT managing semaphore/counters). Run the full DDMA eligibility workflow for one session.
Called by agent.py inside a wrapper that handles queue/counters. Called by agent.py inside a wrapper that manages the semaphore/counters.
OTP handling uses two complementary strategies:
1. Accept OTP submitted from the app (via /submit-otp endpoint → otp_value field)
2. Poll the browser URL/DOM directly to detect when the user enters OTP themselves
""" """
s = sessions.get(sid) s = sessions.get(sid)
if not s: if not s:
@@ -93,7 +86,7 @@ async def start_ddma_run(sid: str, data: dict, url: str):
s["driver"] = bot.driver s["driver"] = bot.driver
s["last_activity"] = time.time() s["last_activity"] = time.time()
# Navigate to login URL # Navigate to login page
try: try:
if not url: if not url:
raise ValueError("URL not provided for DDMA run") raise ValueError("URL not provided for DDMA run")
@@ -120,89 +113,130 @@ async def start_ddma_run(sid: str, data: dict, url: str):
await cleanup_session(sid, s["message"]) await cleanup_session(sid, s["message"])
return {"status": "error", "message": s["message"]} return {"status": "error", "message": s["message"]}
# Already logged in - session persisted from profile, skip to step1 # ── Path: already logged in (persistent session) ──────────────────────
if isinstance(login_result, str) and login_result == "ALREADY_LOGGED_IN": if isinstance(login_result, str) and login_result == "ALREADY_LOGGED_IN":
print("[start_ddma_run] Session persisted - skipping OTP") print("[start_ddma_run] Session persisted - skipping OTP")
s["status"] = "running" s["status"] = "running"
s["message"] = "Session persisted" s["message"] = "Session persisted"
# Continue to step1 below
# OTP required path # ── Path: OTP required ────────────────────────────────────────────────
elif isinstance(login_result, str) and login_result == "OTP_REQUIRED": elif isinstance(login_result, str) and login_result == "OTP_REQUIRED":
s["status"] = "waiting_for_otp" s["status"] = "waiting_for_otp"
s["message"] = "OTP required for login" s["message"] = "OTP required for login - please enter OTP"
s["last_activity"] = time.time() s["last_activity"] = time.time()
try: driver = s["driver"]
await asyncio.wait_for(s["otp_event"].wait(), timeout=SESSION_OTP_TIMEOUT)
except asyncio.TimeoutError:
s["status"] = "error"
s["message"] = "OTP timeout"
await cleanup_session(sid)
return {"status": "error", "message": "OTP not provided in time"}
otp_value = s.get("otp_value") # Poll every second for up to SESSION_OTP_TIMEOUT seconds.
if not otp_value: # Accept OTP from two sources:
s["status"] = "error" # a) app API (otp_value set by submit_otp())
s["message"] = "OTP missing after event" # b) user entering OTP directly in the browser window
await cleanup_session(sid) max_polls = SESSION_OTP_TIMEOUT
return {"status": "error", "message": "OTP missing after event"} login_success = False
# Submit OTP - check if it's in a popup window print(f"[OTP] Polling for OTP completion (up to {SESSION_OTP_TIMEOUT}s)...")
try:
driver = s["driver"]
wait = WebDriverWait(driver, 30)
# Check if there's a popup window and switch to it for poll in range(max_polls):
original_window = driver.current_window_handle await asyncio.sleep(1)
all_windows = driver.window_handles s["last_activity"] = time.time()
if len(all_windows) > 1:
for window in all_windows:
if window != original_window:
driver.switch_to.window(window)
print(f"[OTP] Switched to popup window for OTP entry")
break
otp_input = wait.until(
EC.presence_of_element_located(
(By.XPATH, "//input[contains(@aria-lable,'Verification code') or contains(@placeholder,'Enter your verification code')]")
)
)
otp_input.clear()
otp_input.send_keys(otp_value)
try: try:
submit_btn = wait.until( # a) App submitted OTP via /submit-otp endpoint
EC.element_to_be_clickable( otp_value = s.get("otp_value")
(By.XPATH, "//button[@type='button' and @aria-label='Verify']") if otp_value:
print(f"[OTP poll {poll+1}] OTP received from app, typing it in...")
try:
otp_input = driver.find_element(By.XPATH,
"//input[contains(@aria-label,'Verification') or contains(@placeholder,'verification') or @type='tel' or contains(@aria-lable,'Verification code') or contains(@placeholder,'Enter your verification code')]"
)
otp_input.clear()
otp_input.send_keys(otp_value)
try:
verify_btn = driver.find_element(By.XPATH, "//button[@type='button' and @aria-label='Verify']")
verify_btn.click()
except Exception:
otp_input.send_keys("\n")
print("[OTP] OTP typed and submitted via app")
s["otp_value"] = None # Clear so we don't re-submit
await asyncio.sleep(3)
except Exception as type_err:
print(f"[OTP] Failed to type OTP from app: {type_err}")
# b) Check URL — if we're past OTP page, login succeeded
current_url = driver.current_url.lower()
print(f"[OTP poll {poll+1}/{max_polls}] URL: {current_url[:70]}...")
if "member" in current_url or "dashboard" in current_url or "eligibility" in current_url:
try:
member_search = WebDriverWait(driver, 5).until(
EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]'))
)
print("[OTP] Member search found — login successful!")
login_success = True
break
except TimeoutException:
print("[OTP] On member page but search input not found, continuing...")
# Check if OTP input is still visible (user hasn't finished)
try:
otp_input_elem = driver.find_element(By.XPATH,
"//input[contains(@aria-label,'Verification') or contains(@placeholder,'verification') or @type='tel']"
) )
print(f"[OTP poll {poll+1}] OTP input still visible - waiting...")
except Exception:
# OTP input gone — may mean login is completing; try members page
if "onboarding" in current_url or "start" in current_url:
print("[OTP] OTP input gone, trying to navigate to members page...")
try:
driver.get("https://providers.deltadentalma.com/members")
await asyncio.sleep(2)
except Exception:
pass
except Exception as poll_err:
print(f"[OTP poll {poll+1}] Error: {poll_err}")
if not login_success:
# Final check — navigate directly to members page
try:
print("[OTP] Final attempt - navigating to members page...")
driver.get("https://providers.deltadentalma.com/members")
await asyncio.sleep(3)
member_search = WebDriverWait(driver, 10).until(
EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]'))
) )
submit_btn.click() print("[OTP] Member search found — login successful!")
except Exception: login_success = True
otp_input.send_keys("\n") except TimeoutException:
s["status"] = "error"
s["message"] = "OTP timeout - login not completed"
await cleanup_session(sid)
return {"status": "error", "message": "OTP not completed in time"}
except Exception as final_err:
s["status"] = "error"
s["message"] = f"OTP verification failed: {final_err}"
await cleanup_session(sid)
return {"status": "error", "message": s["message"]}
# Wait for verification and switch back to main window if needed if login_success:
await asyncio.sleep(2) s["status"] = "running"
if len(driver.window_handles) > 0: s["message"] = "Login successful after OTP"
driver.switch_to.window(driver.window_handles[0]) print("[OTP] Proceeding to step1...")
s["status"] = "otp_submitted" # ── Path: login succeeded without OTP ─────────────────────────────────
s["last_activity"] = time.time() elif isinstance(login_result, str) and login_result == "SUCCESS":
await asyncio.sleep(0.5) print("[start_ddma_run] Login succeeded without OTP")
s["status"] = "running"
except Exception as e: s["message"] = "Login succeeded"
s["status"] = "error"
s["message"] = f"Failed to submit OTP into page: {e}"
await cleanup_session(sid)
return {"status": "error", "message": s["message"]}
# ── Path: login error ──────────────────────────────────────────────────
elif isinstance(login_result, str) and login_result.startswith("ERROR"): elif isinstance(login_result, str) and login_result.startswith("ERROR"):
s["status"] = "error" s["status"] = "error"
s["message"] = login_result s["message"] = login_result
await cleanup_session(sid) await cleanup_session(sid)
return {"status": "error", "message": login_result} return {"status": "error", "message": login_result}
# Step 1 # ── Step 1: search ────────────────────────────────────────────────────
step1_result = bot.step1() step1_result = bot.step1()
if isinstance(step1_result, str) and step1_result.startswith("ERROR"): if isinstance(step1_result, str) and step1_result.startswith("ERROR"):
s["status"] = "error" s["status"] = "error"
@@ -210,7 +244,7 @@ async def start_ddma_run(sid: str, data: dict, url: str):
await cleanup_session(sid) await cleanup_session(sid)
return {"status": "error", "message": step1_result} return {"status": "error", "message": step1_result}
# Step 2 (PDF) # ── Step 2: PDF generation ────────────────────────────────────────────
step2_result = bot.step2() step2_result = bot.step2()
if isinstance(step2_result, dict) and step2_result.get("status") == "success": if isinstance(step2_result, dict) and step2_result.get("status") == "success":
s["status"] = "completed" s["status"] = "completed"
@@ -235,12 +269,15 @@ async def start_ddma_run(sid: str, data: dict, url: str):
def submit_otp(sid: str, otp: str) -> Dict[str, Any]: def submit_otp(sid: str, otp: str) -> Dict[str, Any]:
"""Set OTP for a session and wake waiting runner.""" """
Called when the app sends an OTP via POST /submit-otp.
Sets otp_value on the session so the polling loop picks it up.
"""
s = sessions.get(sid) s = sessions.get(sid)
if not s: if not s:
return {"status": "error", "message": "session not found"} return {"status": "error", "message": "session not found"}
if s.get("status") != "waiting_for_otp": if s.get("status") != "waiting_for_otp":
return {"status": "error", "message": f"session not waiting for otp (state={s.get('status')})"} return {"status": "error", "message": f"session not waiting for OTP (state={s.get('status')})"}
s["otp_value"] = otp s["otp_value"] = otp
s["last_activity"] = time.time() s["last_activity"] = time.time()
try: try:

View File

@@ -1,8 +1,11 @@
from selenium.common.exceptions import TimeoutException from selenium import webdriver
from selenium.common.exceptions import WebDriverException, TimeoutException
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support import expected_conditions as EC
from webdriver_manager.chrome import ChromeDriverManager
import time import time
import os import os
import base64 import base64
@@ -16,10 +19,11 @@ class AutomationDeltaDentalMAEligibilityCheck:
self.data = data.get("data", {}) if isinstance(data, dict) else {} self.data = data.get("data", {}) if isinstance(data, dict) else {}
# Flatten values for convenience # Flatten values for convenience
self.memberId = self.data.get("memberId", "") self.memberId = self.data.get("memberId", "")
self.dateOfBirth = self.data.get("dateOfBirth", "") self.dateOfBirth = self.data.get("dateOfBirth", "")
self.firstName = self.data.get("firstName", "")
self.lastName = self.data.get("lastName", "")
self.massddma_username = self.data.get("massddmaUsername", "") self.massddma_username = self.data.get("massddmaUsername", "")
self.massddma_password = self.data.get("massddmaPassword", "") self.massddma_password = self.data.get("massddmaPassword", "")
@@ -31,23 +35,74 @@ class AutomationDeltaDentalMAEligibilityCheck:
# Use persistent browser from manager (keeps device trust tokens) # Use persistent browser from manager (keeps device trust tokens)
self.driver = get_browser_manager().get_driver(self.headless) self.driver = get_browser_manager().get_driver(self.headless)
def _force_logout(self):
"""Force logout by clearing cookies when credentials change."""
try:
print("[DDMA login] Forcing logout due to credential change...")
browser_manager = get_browser_manager()
# Try to click logout button if visible
try:
self.driver.get("https://providers.deltadentalma.com/")
time.sleep(2)
logout_selectors = [
"//button[contains(text(), 'Log out') or contains(text(), 'Logout') or contains(text(), 'Sign out')]",
"//a[contains(text(), 'Log out') or contains(text(), 'Logout') or contains(text(), 'Sign out')]",
"//button[@aria-label='Log out' or @aria-label='Logout' or @aria-label='Sign out']",
"//*[contains(@class, 'logout') or contains(@class, 'signout')]"
]
for selector in logout_selectors:
try:
logout_btn = WebDriverWait(self.driver, 3).until(
EC.element_to_be_clickable((By.XPATH, selector))
)
logout_btn.click()
print("[DDMA login] Clicked logout button")
time.sleep(2)
break
except TimeoutException:
continue
except Exception as e:
print(f"[DDMA login] Could not click logout button: {e}")
# Clear cookies as backup
try:
self.driver.delete_all_cookies()
print("[DDMA login] Cleared all cookies")
except Exception as e:
print(f"[DDMA login] Error clearing cookies: {e}")
browser_manager.clear_credentials_hash()
print("[DDMA login] Logout complete")
return True
except Exception as e:
print(f"[DDMA login] Error during forced logout: {e}")
return False
def login(self, url): def login(self, url):
wait = WebDriverWait(self.driver, 30) wait = WebDriverWait(self.driver, 30)
browser_manager = get_browser_manager()
try: try:
# First check if we're already on a logged-in page (from previous run) # Check if credentials changed — force logout first
if self.massddma_username and browser_manager.credentials_changed(self.massddma_username):
self._force_logout()
self.driver.get(url)
time.sleep(2)
# Check if already on a logged-in page (persistent session from profile)
try: try:
current_url = self.driver.current_url current_url = self.driver.current_url
print(f"[login] Current URL: {current_url}") print(f"[login] Current URL: {current_url}")
# Check if we're on any logged-in page (dashboard, member pages, etc.)
logged_in_patterns = ["member", "dashboard", "eligibility", "search", "patients"] logged_in_patterns = ["member", "dashboard", "eligibility", "search", "patients"]
is_logged_in_url = any(pattern in current_url.lower() for pattern in logged_in_patterns) is_logged_in_url = any(pattern in current_url.lower() for pattern in logged_in_patterns)
if is_logged_in_url and "onboarding" not in current_url.lower(): if is_logged_in_url and "onboarding" not in current_url.lower():
print(f"[login] Already on logged-in page - skipping login entirely") print("[login] Already on logged-in page - skipping login entirely")
# Navigate directly to member search if not already there
if "member" not in current_url.lower(): if "member" not in current_url.lower():
# Try to find a link to member search or just check for search input
try: try:
member_search = WebDriverWait(self.driver, 5).until( member_search = WebDriverWait(self.driver, 5).until(
EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]')) EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]'))
@@ -55,13 +110,11 @@ class AutomationDeltaDentalMAEligibilityCheck:
print("[login] Found member search input - returning ALREADY_LOGGED_IN") print("[login] Found member search input - returning ALREADY_LOGGED_IN")
return "ALREADY_LOGGED_IN" return "ALREADY_LOGGED_IN"
except TimeoutException: except TimeoutException:
# Try navigating to members page
members_url = "https://providers.deltadentalma.com/members" members_url = "https://providers.deltadentalma.com/members"
print(f"[login] Navigating to members page: {members_url}") print(f"[login] Navigating to members page: {members_url}")
self.driver.get(members_url) self.driver.get(members_url)
time.sleep(2) time.sleep(2)
# Verify we have the member search input
try: try:
member_search = WebDriverWait(self.driver, 5).until( member_search = WebDriverWait(self.driver, 5).until(
EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]')) EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]'))
@@ -75,9 +128,9 @@ class AutomationDeltaDentalMAEligibilityCheck:
# Navigate to login URL # Navigate to login URL
self.driver.get(url) self.driver.get(url)
time.sleep(2) # Wait for page to load and any redirects time.sleep(2)
# Check if we got redirected to member search (session still valid) # Check if session redirected us straight to member search
try: try:
current_url = self.driver.current_url current_url = self.driver.current_url
print(f"[login] URL after navigation: {current_url}") print(f"[login] URL after navigation: {current_url}")
@@ -103,20 +156,17 @@ class AutomationDeltaDentalMAEligibilityCheck:
modal_dismissed = True modal_dismissed = True
time.sleep(2) time.sleep(2)
# Check if a popup window opened for authentication
all_windows = self.driver.window_handles all_windows = self.driver.window_handles
print(f"[login] Windows after modal dismiss: {len(all_windows)}") print(f"[login] Windows after modal dismiss: {len(all_windows)}")
if len(all_windows) > 1: if len(all_windows) > 1:
# Switch to the auth popup
original_window = self.driver.current_window_handle original_window = self.driver.current_window_handle
for window in all_windows: for window in all_windows:
if window != original_window: if window != original_window:
self.driver.switch_to.window(window) self.driver.switch_to.window(window)
print(f"[login] Switched to auth popup window") print("[login] Switched to auth popup window")
break break
# Look for OTP input in the popup
try: try:
otp_candidate = WebDriverWait(self.driver, 10).until( otp_candidate = WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located( EC.presence_of_element_located(
@@ -133,10 +183,8 @@ class AutomationDeltaDentalMAEligibilityCheck:
except TimeoutException: except TimeoutException:
pass # No modal present pass # No modal present
# If modal was dismissed but no popup, page might have changed - wait and check
if modal_dismissed: if modal_dismissed:
time.sleep(2) time.sleep(2)
# Check if we're now on member search page (already authenticated)
try: try:
member_search = WebDriverWait(self.driver, 5).until( member_search = WebDriverWait(self.driver, 5).until(
EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]')) EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]'))
@@ -147,7 +195,7 @@ class AutomationDeltaDentalMAEligibilityCheck:
except TimeoutException: except TimeoutException:
pass pass
# Try to fill login form # Fill login form
try: try:
email_field = WebDriverWait(self.driver, 10).until( email_field = WebDriverWait(self.driver, 10).until(
EC.element_to_be_clickable((By.XPATH, "//input[@name='username' and @type='text']")) EC.element_to_be_clickable((By.XPATH, "//input[@name='username' and @type='text']"))
@@ -164,19 +212,23 @@ class AutomationDeltaDentalMAEligibilityCheck:
password_field.clear() password_field.clear()
password_field.send_keys(self.massddma_password) password_field.send_keys(self.massddma_password)
# remember me # Remember me
try: try:
remember_me_checkbox = wait.until(EC.element_to_be_clickable( remember_me_checkbox = wait.until(EC.element_to_be_clickable(
(By.XPATH, "//label[.//span[contains(text(),'Remember me')]]") (By.XPATH, "//label[.//span[contains(text(),'Remember me')]]")
)) ))
remember_me_checkbox.click() remember_me_checkbox.click()
except: except Exception:
print("[login] Remember me checkbox not found (continuing).") print("[login] Remember me checkbox not found (continuing).")
login_button = wait.until(EC.element_to_be_clickable((By.XPATH, "//button[@type='submit' and @aria-label='Sign in']"))) login_button = wait.until(EC.element_to_be_clickable((By.XPATH, "//button[@type='submit' and @aria-label='Sign in']")))
login_button.click() login_button.click()
# OTP detection # Save credentials hash after login attempt
if self.massddma_username:
browser_manager.save_credentials_hash(self.massddma_username)
# OTP detection — wait up to 30 seconds for OTP input
try: try:
otp_candidate = WebDriverWait(self.driver, 30).until( otp_candidate = WebDriverWait(self.driver, 30).until(
EC.presence_of_element_located( EC.presence_of_element_located(
@@ -188,153 +240,373 @@ class AutomationDeltaDentalMAEligibilityCheck:
return "OTP_REQUIRED" return "OTP_REQUIRED"
except TimeoutException: except TimeoutException:
print("[login] No OTP input detected in allowed time.") print("[login] No OTP input detected in allowed time.")
# Check if we're now on the member search page (login succeeded without OTP)
try:
current_url = self.driver.current_url.lower()
if "member" in current_url or "dashboard" in current_url:
member_search = WebDriverWait(self.driver, 5).until(
EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]'))
)
print("[login] Login successful - now on member search page")
return "SUCCESS"
except TimeoutException:
pass
# Check for error messages
try:
error_elem = WebDriverWait(self.driver, 3).until(
EC.presence_of_element_located((By.XPATH, "//*[contains(@class,'error') or contains(text(),'invalid') or contains(text(),'failed')]"))
)
print(f"[login] Login failed - error detected: {error_elem.text}")
return f"ERROR:LOGIN FAILED: {error_elem.text}"
except TimeoutException:
pass
if "onboarding" in self.driver.current_url.lower() or "login" in self.driver.current_url.lower():
print("[login] Login failed - still on login page")
return "ERROR:LOGIN FAILED: Still on login page"
print("[login] Assuming login succeeded (no errors detected)")
return "SUCCESS"
except Exception as e: except Exception as e:
print("[login] Exception during login:", e) print("[login] Exception during login:", e)
return f"ERROR:LOGIN FAILED: {e}" return f"ERROR:LOGIN FAILED: {e}"
def step1(self): def step1(self):
"""Fill search form with all available fields (flexible search)."""
wait = WebDriverWait(self.driver, 30) wait = WebDriverWait(self.driver, 30)
try: try:
# Fill Member ID fields = []
member_id_input = wait.until(EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]'))) if self.memberId:
member_id_input.clear() fields.append(f"ID: {self.memberId}")
member_id_input.send_keys(self.memberId) if self.firstName:
fields.append(f"FirstName: {self.firstName}")
if self.lastName:
fields.append(f"LastName: {self.lastName}")
if self.dateOfBirth:
fields.append(f"DOB: {self.dateOfBirth}")
print(f"[DDMA step1] Starting search with: {', '.join(fields)}")
# Fill DOB parts
try:
dob_parts = self.dateOfBirth.split("-")
year = dob_parts[0] # "1964"
month = dob_parts[1].zfill(2) # "04"
day = dob_parts[2].zfill(2) # "17"
except Exception as e:
print(f"Error parsing DOB: {e}")
return "ERROR: PARSING DOB"
# 1) locate the specific member DOB container
dob_container = wait.until(
EC.presence_of_element_located(
(By.XPATH, "//div[@data-testid='member-search_date-of-birth']")
)
)
# 2) find the editable spans *inside that container* using relative XPaths
month_elem = dob_container.find_element(By.XPATH, ".//span[@data-type='month' and @contenteditable='true']")
day_elem = dob_container.find_element(By.XPATH, ".//span[@data-type='day' and @contenteditable='true']")
year_elem = dob_container.find_element(By.XPATH, ".//span[@data-type='year' and @contenteditable='true']")
# Helper to click, select-all and type (pure send_keys approach)
def replace_with_sendkeys(el, value): def replace_with_sendkeys(el, value):
# focus (same as click)
el.click() el.click()
# select all (Ctrl+A) and delete (some apps pick up BACKSPACE better — we use BACKSPACE after select)
el.send_keys(Keys.CONTROL, "a") el.send_keys(Keys.CONTROL, "a")
el.send_keys(Keys.BACKSPACE) el.send_keys(Keys.BACKSPACE)
# type the value
el.send_keys(value) el.send_keys(value)
# optionally blur or tab out if app expects it
# el.send_keys(Keys.TAB)
replace_with_sendkeys(month_elem, month) # 1. Fill Member ID if provided
time.sleep(0.05) if self.memberId:
replace_with_sendkeys(day_elem, day) try:
time.sleep(0.05) member_id_input = wait.until(EC.presence_of_element_located(
replace_with_sendkeys(year_elem, year) (By.XPATH, '//input[@placeholder="Search by member ID"]')
))
member_id_input.clear()
member_id_input.send_keys(self.memberId)
print(f"[DDMA step1] Entered Member ID: {self.memberId}")
time.sleep(0.2)
except Exception as e:
print(f"[DDMA step1] Warning: Could not fill Member ID: {e}")
# 2. Fill DOB if provided
if self.dateOfBirth:
try:
dob_parts = self.dateOfBirth.split("-")
year = dob_parts[0]
month = dob_parts[1].zfill(2)
day = dob_parts[2].zfill(2)
# Click Continue button dob_container = wait.until(
continue_btn = wait.until(EC.element_to_be_clickable((By.XPATH, '//button[@data-testid="member-search_search-button"]'))) EC.presence_of_element_located(
(By.XPATH, "//div[@data-testid='member-search_date-of-birth']")
)
)
month_elem = dob_container.find_element(By.XPATH, ".//span[@data-type='month' and @contenteditable='true']")
day_elem = dob_container.find_element(By.XPATH, ".//span[@data-type='day' and @contenteditable='true']")
year_elem = dob_container.find_element(By.XPATH, ".//span[@data-type='year' and @contenteditable='true']")
replace_with_sendkeys(month_elem, month)
time.sleep(0.05)
replace_with_sendkeys(day_elem, day)
time.sleep(0.05)
replace_with_sendkeys(year_elem, year)
print(f"[DDMA step1] Filled DOB: {month}/{day}/{year}")
except Exception as e:
print(f"[DDMA step1] Warning: Could not fill DOB: {e}")
# 3. Fill First Name if provided
if self.firstName:
try:
first_name_input = wait.until(EC.presence_of_element_located(
(By.XPATH, '//input[@placeholder="First name - 1 char minimum" or contains(@placeholder,"first name") or contains(@name,"firstName")]')
))
first_name_input.clear()
first_name_input.send_keys(self.firstName)
print(f"[DDMA step1] Entered First Name: {self.firstName}")
time.sleep(0.2)
except Exception as e:
print(f"[DDMA step1] Warning: Could not fill First Name: {e}")
# 4. Fill Last Name if provided
if self.lastName:
try:
last_name_input = wait.until(EC.presence_of_element_located(
(By.XPATH, '//input[@placeholder="Last name - 2 char minimum" or contains(@placeholder,"last name") or contains(@name,"lastName")]')
))
last_name_input.clear()
last_name_input.send_keys(self.lastName)
print(f"[DDMA step1] Entered Last Name: {self.lastName}")
time.sleep(0.2)
except Exception as e:
print(f"[DDMA step1] Warning: Could not fill Last Name: {e}")
time.sleep(0.3)
# Click Search button
continue_btn = wait.until(EC.element_to_be_clickable(
(By.XPATH, '//button[@data-testid="member-search_search-button"]')
))
continue_btn.click() continue_btn.click()
print("[DDMA step1] Clicked Search button")
# Check for error message # Wait for either results row or no-results message (up to 15s)
try: try:
error_msg = WebDriverWait(self.driver, 5).until(EC.presence_of_element_located( WebDriverWait(self.driver, 15).until(
(By.XPATH, '//div[@data-testid="member-search-result-no-results"]') EC.any_of(
)) EC.presence_of_element_located((By.XPATH, "//tbody//tr")),
if error_msg: EC.presence_of_element_located((By.XPATH, '//div[@data-testid="member-search-result-no-results"]')),
print("Error: Invalid Member ID or Date of Birth.") )
return "ERROR: INVALID MEMBERID OR DOB" )
except TimeoutException: except TimeoutException:
pass # proceed and let step2 handle missing results
# Check for no-results error
try:
error_msg = self.driver.find_element(By.XPATH, '//div[@data-testid="member-search-result-no-results"]')
if error_msg:
print("[DDMA step1] Error: No results found")
return "ERROR: INVALID SEARCH CRITERIA"
except Exception:
pass pass
print("[DDMA step1] Search completed successfully")
return "Success" return "Success"
except Exception as e: except Exception as e:
print(f"Error while step1 i.e Cheking the MemberId and DOB in: {e}") print(f"[DDMA step1] Exception: {e}")
return "ERROR:STEP1" return f"ERROR:STEP1 - {e}"
def step2(self): def step2(self):
"""Navigate to patient detail page and generate PDF."""
wait = WebDriverWait(self.driver, 90) wait = WebDriverWait(self.driver, 90)
try: try:
# 1) find the eligibility <a> inside the correct cell import re
status_link = wait.until(EC.presence_of_element_located((
By.XPATH,
"(//tbody//tr)[1]//a[contains(@href, 'member-eligibility-search')]"
)))
eligibilityText = status_link.text.strip().lower() # Wait for results table
try:
WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located((By.XPATH, "//tbody//tr"))
)
except TimeoutException:
print("[DDMA step2] Warning: Results table not found within timeout")
# 2) finding patient name. eligibilityText = "unknown"
patient_name_div = wait.until(EC.presence_of_element_located(( foundMemberId = ""
By.XPATH, patientName = ""
'//div[@class="flex flex-row w-full items-center"]'
)))
patientName = patient_name_div.text.strip().lower() # Extract data from first result row
try:
first_row = self.driver.find_element(By.XPATH, "(//tbody//tr)[1]")
row_text = first_row.text.strip()
print(f"[DDMA step2] First row text: {row_text[:150]}...")
if row_text:
lines = row_text.split('\n')
# Extract patient name (first line, strip DOB if present)
if lines:
potential_name = lines[0].strip()
potential_name = re.sub(r'\s*DOB[:\s]*\d{1,2}/\d{1,2}/\d{2,4}\s*', '', potential_name, flags=re.IGNORECASE).strip()
if potential_name and not potential_name.startswith('DOB') and not potential_name.isdigit():
patientName = potential_name
print(f"[DDMA step2] Extracted patient name: '{patientName}'")
# Extract Member ID
for line in lines:
line = line.strip()
if line and re.match(r'^[A-Z0-9]{5,}$', line) and not line.startswith('DOB'):
foundMemberId = line
print(f"[DDMA step2] Extracted Member ID: {foundMemberId}")
break
if not foundMemberId and self.memberId:
foundMemberId = self.memberId
print(f"[DDMA step2] Using input Member ID: {foundMemberId}")
except Exception as e:
print(f"[DDMA step2] Error extracting data from row: {e}")
if self.memberId:
foundMemberId = self.memberId
# Extract eligibility status
try:
short_wait = WebDriverWait(self.driver, 3)
status_link = short_wait.until(EC.presence_of_element_located((
By.XPATH,
"(//tbody//tr)[1]//a[contains(@href, 'member-eligibility-search')]"
)))
eligibilityText = status_link.text.strip().lower()
print(f"[DDMA step2] Found eligibility status: {eligibilityText}")
except Exception:
try:
alt_status = self.driver.find_element(By.XPATH, "//*[contains(text(),'Active') or contains(text(),'Inactive') or contains(text(),'Eligible')]")
eligibilityText = alt_status.text.strip().lower()
if "active" in eligibilityText or "eligible" in eligibilityText:
eligibilityText = "active"
elif "inactive" in eligibilityText:
eligibilityText = "inactive"
print(f"[DDMA step2] Found eligibility via alternative: {eligibilityText}")
except Exception:
pass
# Navigate to detailed patient page
print("[DDMA step2] Navigating to patient detail page...")
patient_name_clicked = False
detail_url = None
patient_link_selectors = [
"(//table//tbody//tr)[1]//td[1]//a",
"(//tbody//tr)[1]//a[contains(@href, 'member-details')]",
"(//tbody//tr)[1]//a[contains(@href, 'member')]",
]
for selector in patient_link_selectors:
try:
patient_link = WebDriverWait(self.driver, 5).until(
EC.presence_of_element_located((By.XPATH, selector))
)
link_text = patient_link.text.strip()
href = patient_link.get_attribute("href")
print(f"[DDMA step2] Found patient link: text='{link_text}', href={href}")
if link_text and not patientName:
patientName = link_text
if href and "member-details" in href:
detail_url = href
patient_name_clicked = True
break
except Exception as e:
print(f"[DDMA step2] Selector '{selector}' failed: {e}")
continue
if not detail_url:
try:
all_links = self.driver.find_elements(By.XPATH, "//a[contains(@href, 'member-details')]")
if all_links:
detail_url = all_links[0].get_attribute("href")
patient_name_clicked = True
print(f"[DDMA step2] Fallback member-details link: {detail_url}")
except Exception as e:
print(f"[DDMA step2] Could not find member-details link: {e}")
if patient_name_clicked and detail_url:
print(f"[DDMA step2] Navigating directly to: {detail_url}")
self.driver.get(detail_url)
# Wait for page to be ready
try:
WebDriverWait(self.driver, 30).until(
lambda d: d.execute_script("return document.readyState") == "complete"
)
except Exception:
print("[DDMA step2] Warning: document.readyState did not become 'complete'")
# Wait for meaningful content to appear
content_selectors = [
"//div[contains(@class,'member') or contains(@class,'detail') or contains(@class,'patient')]",
"//h1", "//h2", "//table",
"//*[contains(text(),'Member ID') or contains(text(),'Name') or contains(text(),'Date of Birth')]",
]
for selector in content_selectors:
try:
WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located((By.XPATH, selector))
)
print(f"[DDMA step2] Content loaded: {selector}")
break
except Exception:
continue
time.sleep(1) # Brief settle for any late-rendering elements
# Try to extract patient name from detail page if not already found
if not patientName:
for selector in ["//h1", "//h2", "//*[contains(@class,'patient-name') or contains(@class,'member-name')]"]:
try:
name_elem = self.driver.find_element(By.XPATH, selector)
name_text = name_elem.text.strip()
if name_text and len(name_text) > 1:
if not any(x in name_text.lower() for x in ['active', 'inactive', 'eligible', 'search', 'date', 'print']):
patientName = name_text
print(f"[DDMA step2] Found patient name on detail page: {patientName}")
break
except Exception:
continue
else:
print("[DDMA step2] Warning: Could not navigate to patient detail page")
if not patientName:
try:
name_elem = self.driver.find_element(By.XPATH, "(//tbody//tr)[1]//td[1]")
patientName = name_elem.text.strip()
except Exception:
pass
if not patientName:
print("[DDMA step2] Could not extract patient name")
# Clean patient name
if patientName:
cleaned = re.sub(r'\s*DOB[:\s]*\d{1,2}/\d{1,2}/\d{2,4}\s*', '', patientName, flags=re.IGNORECASE).strip()
if cleaned:
patientName = cleaned
# Wait for page ready before PDF
try: try:
WebDriverWait(self.driver, 30).until( WebDriverWait(self.driver, 30).until(
lambda d: d.execute_script("return document.readyState") == "complete" lambda d: d.execute_script("return document.readyState") == "complete"
) )
except Exception: except Exception:
print("Warning: document.readyState did not become 'complete' within timeout")
# Give some time for lazy content to finish rendering (adjust if needed)
time.sleep(0.6)
# Get total page size and DPR
total_width = int(self.driver.execute_script(
"return Math.max(document.body.scrollWidth, document.documentElement.scrollWidth, document.documentElement.clientWidth);"
))
total_height = int(self.driver.execute_script(
"return Math.max(document.body.scrollHeight, document.documentElement.scrollHeight, document.documentElement.clientHeight);"
))
dpr = float(self.driver.execute_script("return window.devicePixelRatio || 1;"))
# Set device metrics to the full page size so Page.captureScreenshot captures everything
# Note: Some pages are extremely tall; if you hit memory limits, you can capture in chunks.
self.driver.execute_cdp_cmd('Emulation.setDeviceMetricsOverride', {
"mobile": False,
"width": total_width,
"height": total_height,
"deviceScaleFactor": dpr,
"screenOrientation": {"angle": 0, "type": "portraitPrimary"}
})
# Small pause for layout to settle after emulation change
time.sleep(0.15)
# Capture screenshot (base64 PNG)
result = self.driver.execute_cdp_cmd("Page.captureScreenshot", {"format": "png", "fromSurface": True})
image_data = base64.b64decode(result.get('data', ''))
screenshot_path = os.path.join(self.download_dir, f"ss_{self.memberId}.png")
with open(screenshot_path, "wb") as f:
f.write(image_data)
# Restore original metrics to avoid affecting further interactions
try:
self.driver.execute_cdp_cmd('Emulation.clearDeviceMetricsOverride', {})
except Exception:
# non-fatal: continue
pass pass
print("Screenshot saved at:", screenshot_path) # Generate PDF via Chrome DevTools Protocol
print("[DDMA step2] Generating PDF of patient detail page...")
pdf_options = {
"landscape": False,
"displayHeaderFooter": False,
"printBackground": True,
"preferCSSPageSize": True,
"paperWidth": 8.5,
"paperHeight": 11,
"marginTop": 0.4,
"marginBottom": 0.4,
"marginLeft": 0.4,
"marginRight": 0.4,
"scale": 0.9,
}
# Close the browser window after screenshot (session preserved in profile) result = self.driver.execute_cdp_cmd("Page.printToPDF", pdf_options)
pdf_data = base64.b64decode(result.get('data', ''))
pdf_id = foundMemberId or self.memberId or "unknown"
pdf_path = os.path.join(self.download_dir, f"eligibility_{pdf_id}.pdf")
with open(pdf_path, "wb") as f:
f.write(pdf_data)
print(f"[DDMA step2] PDF saved at: {pdf_path}")
# Close the browser window after PDF (session preserved in profile)
try: try:
from ddma_browser_manager import get_browser_manager from ddma_browser_manager import get_browser_manager
get_browser_manager().quit_driver() get_browser_manager().quit_driver()
@@ -342,16 +614,20 @@ class AutomationDeltaDentalMAEligibilityCheck:
except Exception as e: except Exception as e:
print(f"[step2] Error closing browser: {e}") print(f"[step2] Error closing browser: {e}")
output = { print(f"[DDMA step2] Final — PatientName: '{patientName}', MemberID: '{foundMemberId}'")
"status": "success",
"eligibility": eligibilityText, return {
"ss_path": screenshot_path, "status": "success",
"patientName":patientName "eligibility": eligibilityText,
} "ss_path": pdf_path, # kept for backward compatibility
return output "pdf_path": pdf_path, # explicit pdf_path
"patientName": patientName,
"memberId": foundMemberId,
}
except Exception as e: except Exception as e:
print("ERROR in step2:", e) print("ERROR in step2:", e)
# Empty the download folder (remove files / symlinks only) # Cleanup download dir on error
try: try:
dl = os.path.abspath(self.download_dir) dl = os.path.abspath(self.download_dir)
if os.path.isdir(dl): if os.path.isdir(dl):
@@ -360,18 +636,12 @@ class AutomationDeltaDentalMAEligibilityCheck:
try: try:
if os.path.isfile(item) or os.path.islink(item): if os.path.isfile(item) or os.path.islink(item):
os.remove(item) os.remove(item)
print(f"[cleanup] removed: {item}")
except Exception as rm_err: except Exception as rm_err:
print(f"[cleanup] failed to remove {item}: {rm_err}") print(f"[cleanup] failed to remove {item}: {rm_err}")
print(f"[cleanup] emptied download dir: {dl}")
else:
print(f"[cleanup] download dir does not exist: {dl}")
except Exception as cleanup_exc: except Exception as cleanup_exc:
print(f"[cleanup] unexpected error while cleaning downloads dir: {cleanup_exc}") print(f"[cleanup] unexpected error: {cleanup_exc}")
return {"status": "error", "message": str(e)} return {"status": "error", "message": str(e)}
# NOTE: Do NOT quit driver here - keep browser alive for next patient
def main_workflow(self, url): def main_workflow(self, url):
try: try:
self.config_driver() self.config_driver()
@@ -394,8 +664,5 @@ class AutomationDeltaDentalMAEligibilityCheck:
return step2_result return step2_result
except Exception as e: except Exception as e:
return { return {"status": "error", "message": str(e)}
"status": "error", # NOTE: Do NOT quit driver — keep browser alive for next patient
"message": e
}
# NOTE: Do NOT quit driver - keep browser alive for next patient