Compare commits

...

10 Commits

Author SHA1 Message Date
27e6e6a4a0 Update insurance status components and ignore selenium downloads 2026-02-22 23:20:18 -05:00
35896c264c Initial commit 2026-02-17 21:28:19 -05:00
cf53065a26 feat(eligibility-check) - implement Delta Dental Ins eligibility workflow with OTP handling; added routes, services, and frontend components for patient data processing and eligibility status retrieval; enhanced browser session management and logging 2026-02-17 20:53:24 -05:00
03172f0710 feat(eligibility-check) - enhance United SCO workflows with improved patient creation and update logic; added eligibility status handling and detailed logging; implemented browser cache clearing and anti-detection measures in Selenium service 2026-02-11 20:14:29 -05:00
445691cdd0 feat(eligibility-check) - enhance DDMA and DentaQuest workflows with flexible input handling; added detailed logging for patient data processing and eligibility status updates; improved browser cache management in Selenium service 2026-02-10 20:55:26 -05:00
e425a829b2 feat(eligibility-check) - enhance DentaQuest and United SCO workflows with flexible input handling; added Selenium session clearing on credential updates and deletions; improved patient name extraction and eligibility checks across services 2026-02-06 08:57:29 -05:00
e43329e95f Add United SCO eligibility feature and fix issues
- United SCO: Add complete eligibility check workflow with flexible input
  (supports either Member ID + DOB or First Name + Last Name + DOB)
- Tufts SCO (DentaQuest): Fix Date of Service validation by adding proper
  Tab key navigation between date fields
- Delta MA: Improve patient name extraction with more robust selectors
  and pattern matching for saving names to database
2026-02-03 18:33:26 -05:00
5370a0e445 feat(eligibility-check) - enhance OTP handling and eligibility status retrieval for DDMA and DentaQuest; improved file processing logic for screenshots and PDFs, and updated frontend components for better user experience 2026-01-29 21:25:18 -05:00
279a6b8dbc feat(dentaquest) - implement DentaQuest eligibility check with Selenium integration; added routes, services, and frontend components for OTP handling and eligibility status retrieval 2026-01-20 22:08:06 -05:00
Emile
3907672185 Update service dependencies 2026-01-06 09:35:57 -05:00
49 changed files with 10446 additions and 262 deletions

8
.gitignore vendored
View File

@@ -37,4 +37,10 @@ dist/
# env
*.env
*chrome_profile_ddma*
*chrome_profile_ddma*
*chrome_profile_dentaquest*
*chrome_profile_unitedsco*
*chrome_profile_deltains*
# selenium downloads (generated PDFs)
apps/**/seleniumDownloads/

View File

@@ -1,11 +1,12 @@
NODE_ENV="development"
HOST=0.0.0.0
PORT=5000
FRONTEND_URLS=http://localhost:3000,http://192.168.1.8:3000
# FRONTEND_URLS=http://localhost:3000,http://192.168.1.8:3000
FRONTEND_URLS=http://192.168.0.238:3000
SELENIUM_AGENT_BASE_URL=http://localhost:5002
JWT_SECRET = 'dentalsecret'
DB_HOST=localhost
DB_USER=postgres
DB_PASSWORD=mypassword
DB_NAME=dentalapp
DATABASE_URL=postgresql://postgres:mypassword@localhost:5432/dentalapp
DATABASE_URL=postgresql://postgres:mypassword@localhost:5432/dentalapp

View File

@@ -41,7 +41,8 @@ function isOriginAllowed(origin?: string | null) {
// Dev mode: allow localhost origins automatically
if (
origin.startsWith("http://localhost") ||
origin.startsWith("http://127.0.0.1")
origin.startsWith("http://127.0.0.1") ||
origin.startsWith("http://192.168.0.238")
)
return true;
// allow explicit FRONTEND_URLS if provided

View File

@@ -9,6 +9,9 @@ import insuranceCredsRoutes from "./insuranceCreds";
import documentsRoutes from "./documents";
import insuranceStatusRoutes from "./insuranceStatus";
import insuranceStatusDdmaRoutes from "./insuranceStatusDDMA";
import insuranceStatusDentaQuestRoutes from "./insuranceStatusDentaQuest";
import insuranceStatusUnitedSCORoutes from "./insuranceStatusUnitedSCO";
import insuranceStatusDeltaInsRoutes from "./insuranceStatusDeltaIns";
import paymentsRoutes from "./payments";
import databaseManagementRoutes from "./database-management";
import notificationsRoutes from "./notifications";
@@ -29,6 +32,9 @@ router.use("/insuranceCreds", insuranceCredsRoutes);
router.use("/documents", documentsRoutes);
router.use("/insurance-status", insuranceStatusRoutes);
router.use("/insurance-status-ddma", insuranceStatusDdmaRoutes);
router.use("/insurance-status-dentaquest", insuranceStatusDentaQuestRoutes);
router.use("/insurance-status-unitedsco", insuranceStatusUnitedSCORoutes);
router.use("/insurance-status-deltains", insuranceStatusDeltaInsRoutes);
router.use("/payments", paymentsRoutes);
router.use("/database-management", databaseManagementRoutes);
router.use("/notifications", notificationsRoutes);

View File

@@ -76,8 +76,36 @@ router.put("/:id", async (req: Request, res: Response): Promise<any> => {
const id = Number(req.params.id);
if (isNaN(id)) return res.status(400).send("Invalid credential ID");
// Get existing credential to know its siteKey
const existing = await storage.getInsuranceCredential(id);
if (!existing) {
return res.status(404).json({ message: "Credential not found" });
}
const updates = req.body as Partial<InsuranceCredential>;
const credential = await storage.updateInsuranceCredential(id, updates);
// Clear Selenium browser session when credentials are changed
const seleniumAgentUrl = process.env.SELENIUM_AGENT_URL || "http://localhost:5002";
try {
if (existing.siteKey === "DDMA") {
await fetch(`${seleniumAgentUrl}/clear-ddma-session`, { method: "POST" });
console.log("[insuranceCreds] Cleared DDMA browser session after credential update");
} else if (existing.siteKey === "DENTAQUEST") {
await fetch(`${seleniumAgentUrl}/clear-dentaquest-session`, { method: "POST" });
console.log("[insuranceCreds] Cleared DentaQuest browser session after credential update");
} else if (existing.siteKey === "UNITEDSCO") {
await fetch(`${seleniumAgentUrl}/clear-unitedsco-session`, { method: "POST" });
console.log("[insuranceCreds] Cleared United SCO browser session after credential update");
} else if (existing.siteKey === "DELTAINS") {
await fetch(`${seleniumAgentUrl}/clear-deltains-session`, { method: "POST" });
console.log("[insuranceCreds] Cleared Delta Dental Ins browser session after credential update");
}
} catch (seleniumErr) {
// Don't fail the update if Selenium session clear fails
console.error("[insuranceCreds] Failed to clear Selenium session:", seleniumErr);
}
return res.status(200).json(credential);
} catch (err) {
return res
@@ -115,6 +143,28 @@ router.delete("/:id", async (req: Request, res: Response): Promise<any> => {
.status(404)
.json({ message: "Credential not found or already deleted" });
}
// 4) Clear Selenium browser session for this provider
const seleniumAgentUrl = process.env.SELENIUM_AGENT_URL || "http://localhost:5002";
try {
if (existing.siteKey === "DDMA") {
await fetch(`${seleniumAgentUrl}/clear-ddma-session`, { method: "POST" });
console.log("[insuranceCreds] Cleared DDMA browser session after credential deletion");
} else if (existing.siteKey === "DENTAQUEST") {
await fetch(`${seleniumAgentUrl}/clear-dentaquest-session`, { method: "POST" });
console.log("[insuranceCreds] Cleared DentaQuest browser session after credential deletion");
} else if (existing.siteKey === "UNITEDSCO") {
await fetch(`${seleniumAgentUrl}/clear-unitedsco-session`, { method: "POST" });
console.log("[insuranceCreds] Cleared United SCO browser session after credential deletion");
} else if (existing.siteKey === "DELTAINS") {
await fetch(`${seleniumAgentUrl}/clear-deltains-session`, { method: "POST" });
console.log("[insuranceCreds] Cleared Delta Dental Ins browser session after credential deletion");
}
} catch (seleniumErr) {
// Don't fail the delete if Selenium session clear fails
console.error("[insuranceCreds] Failed to clear Selenium session:", seleniumErr);
}
return res.status(204).send();
} catch (err) {
return res

View File

@@ -136,36 +136,135 @@ async function handleDdmaCompletedJob(
// 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();
// DEBUG: Log the raw selenium result
console.log(`[ddma-eligibility] === DEBUG: Raw seleniumResult ===`);
console.log(`[ddma-eligibility] seleniumResult.patientName: '${seleniumResult?.patientName}'`);
console.log(`[ddma-eligibility] seleniumResult.memberId: '${seleniumResult?.memberId}'`);
console.log(`[ddma-eligibility] seleniumResult.status: '${seleniumResult?.status}'`);
// 1) Get insuranceId - prefer from Selenium result (flexible search support)
let insuranceId = String(seleniumResult?.memberId || "").trim();
if (!insuranceId) {
throw new Error("Missing memberId for ddma job");
insuranceId = String(insuranceEligibilityData.memberId ?? "").trim();
}
console.log(`[ddma-eligibility] Resolved insuranceId: ${insuranceId || "(none)"}`);
// 2) Create or update patient (with name from selenium result if available)
// 2) Get patient name - prefer from Selenium result
const patientNameFromResult =
typeof seleniumResult?.patientName === "string"
? seleniumResult.patientName.trim()
: null;
console.log(`[ddma-eligibility] patientNameFromResult: '${patientNameFromResult}'`);
const { firstName, lastName } = splitName(patientNameFromResult);
// Get name from input data as fallback
let firstName = String(insuranceEligibilityData.firstName || "").trim();
let lastName = String(insuranceEligibilityData.lastName || "").trim();
// Override with name from Selenium result if available
if (patientNameFromResult) {
const parsedName = splitName(patientNameFromResult);
console.log(`[ddma-eligibility] splitName result: firstName='${parsedName.firstName}', lastName='${parsedName.lastName}'`);
if (parsedName.firstName) firstName = parsedName.firstName;
if (parsedName.lastName) lastName = parsedName.lastName;
}
console.log(`[ddma-eligibility] Resolved name: firstName='${firstName}', lastName='${lastName}'`);
await createOrUpdatePatientByInsuranceId({
insuranceId,
firstName,
lastName,
dob: insuranceEligibilityData.dateOfBirth,
userId: job.userId,
});
// 3) Update patient status + PDF upload
const patient = await storage.getPatientByInsuranceId(
insuranceEligibilityData.memberId
);
// 3) Find or create patient
let patient: any = null;
// First, try to find by insuranceId if available
if (insuranceId) {
patient = await storage.getPatientByInsuranceId(insuranceId);
if (patient) {
console.log(`[ddma-eligibility] Found patient by insuranceId: ${patient.id}`);
// Update name if we have better data
const updates: any = {};
if (firstName && String(patient.firstName ?? "").trim() !== firstName) {
updates.firstName = firstName;
}
if (lastName && String(patient.lastName ?? "").trim() !== lastName) {
updates.lastName = lastName;
}
if (Object.keys(updates).length > 0) {
await storage.updatePatient(patient.id, updates);
console.log(`[ddma-eligibility] Updated patient name to: ${firstName} ${lastName}`);
}
}
}
// If not found by ID, try to find by name
if (!patient && firstName && lastName) {
try {
console.log(`[ddma-eligibility] Looking up patient by name: ${firstName} ${lastName}`);
const patients = await storage.getPatientsByUserId(job.userId);
patient = patients.find(
(p: any) =>
String(p.firstName ?? "").toLowerCase() === firstName.toLowerCase() &&
String(p.lastName ?? "").toLowerCase() === lastName.toLowerCase()
) || null;
if (patient) {
console.log(`[ddma-eligibility] Found patient by name: ${patient.id}`);
// Update insuranceId if we have it
if (insuranceId && String(patient.insuranceId ?? "").trim() !== insuranceId) {
await storage.updatePatient(patient.id, { insuranceId });
console.log(`[ddma-eligibility] Updated patient insuranceId to: ${insuranceId}`);
}
}
} catch (err: any) {
console.log(`[ddma-eligibility] Error finding patient by name: ${err.message}`);
}
}
// Determine eligibility status from Selenium result
const eligibilityStatus = seleniumResult.eligibility === "active" ? "ACTIVE" : "INACTIVE";
console.log(`[ddma-eligibility] Eligibility status from Delta MA: ${eligibilityStatus}`);
// If still not found, create new patient
console.log(`[ddma-eligibility] Patient creation check: patient=${patient?.id || 'null'}, firstName='${firstName}', lastName='${lastName}'`);
if (!patient && firstName && lastName) {
console.log(`[ddma-eligibility] Creating new patient: ${firstName} ${lastName} with status: ${eligibilityStatus}`);
try {
let parsedDob: Date | undefined = undefined;
if (insuranceEligibilityData.dateOfBirth) {
try {
parsedDob = new Date(insuranceEligibilityData.dateOfBirth);
if (isNaN(parsedDob.getTime())) parsedDob = undefined;
} catch {
parsedDob = undefined;
}
}
const newPatientData: InsertPatient = {
firstName,
lastName,
dateOfBirth: parsedDob || new Date(), // Required field
insuranceId: insuranceId || undefined,
insuranceProvider: "Delta MA", // Set insurance provider
gender: "Unknown", // Required field - default value
phone: "", // Required field - default empty
userId: job.userId, // Required field
status: eligibilityStatus, // Set status from eligibility check
};
const validation = insertPatientSchema.safeParse(newPatientData);
if (validation.success) {
patient = await storage.createPatient(validation.data);
console.log(`[ddma-eligibility] Created new patient: ${patient.id} with status: ${eligibilityStatus}`);
} else {
console.log(`[ddma-eligibility] Patient validation failed: ${validation.error.message}`);
}
} catch (createErr: any) {
console.log(`[ddma-eligibility] Failed to create patient: ${createErr.message}`);
}
}
if (!patient?.id) {
outputResult.patientUpdateStatus =
"Patient not found; no update performed";
"Patient not found and could not be created; no update performed";
return {
patientUpdateStatus: outputResult.patientUpdateStatus,
pdfUploadStatus: "none",
@@ -173,49 +272,61 @@ async function handleDdmaCompletedJob(
};
}
// update patient status.
const newStatus =
seleniumResult.eligibility === "active" ? "ACTIVE" : "INACTIVE";
await storage.updatePatient(patient.id, { status: newStatus });
outputResult.patientUpdateStatus = `Patient status updated to ${newStatus}`;
// Update patient status from Delta MA eligibility result
await storage.updatePatient(patient.id, { status: eligibilityStatus });
outputResult.patientUpdateStatus = `Patient ${patient.id} status set to ${eligibilityStatus} (Delta MA eligibility: ${seleniumResult.eligibility})`;
console.log(`[ddma-eligibility] ${outputResult.patientUpdateStatus}`);
// convert screenshot -> pdf if available
// Handle PDF or 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"))
typeof seleniumResult.ss_path === "string"
) {
try {
if (!fsSync.existsSync(seleniumResult.ss_path)) {
throw new Error(
`Screenshot file not found: ${seleniumResult.ss_path}`
`File not found: ${seleniumResult.ss_path}`
);
}
pdfBuffer = await imageToPdfBuffer(seleniumResult.ss_path);
// Check if the file is already a PDF (from Page.printToPDF)
if (seleniumResult.ss_path.endsWith(".pdf")) {
// Read PDF directly
pdfBuffer = await fs.readFile(seleniumResult.ss_path);
generatedPdfPath = seleniumResult.ss_path;
seleniumResult.pdf_path = generatedPdfPath;
console.log(`[ddma-eligibility] Using PDF directly from Selenium: ${generatedPdfPath}`);
} else if (
seleniumResult.ss_path.endsWith(".png") ||
seleniumResult.ss_path.endsWith(".jpg") ||
seleniumResult.ss_path.endsWith(".jpeg")
) {
// Convert image to PDF
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;
const pdfFileName = `ddma_eligibility_${insuranceEligibilityData.memberId}_${Date.now()}.pdf`;
generatedPdfPath = path.join(
path.dirname(seleniumResult.ss_path),
pdfFileName
);
await fs.writeFile(generatedPdfPath, pdfBuffer);
seleniumResult.pdf_path = generatedPdfPath;
console.log(`[ddma-eligibility] Converted screenshot to PDF: ${generatedPdfPath}`);
} else {
outputResult.pdfUploadStatus =
`Unsupported file format: ${seleniumResult.ss_path}`;
}
} catch (err: any) {
console.error("Failed to convert screenshot to PDF:", err);
outputResult.pdfUploadStatus = `Failed to convert screenshot to PDF: ${String(err)}`;
console.error("Failed to process PDF/screenshot:", err);
outputResult.pdfUploadStatus = `Failed to process file: ${String(err)}`;
}
} else {
outputResult.pdfUploadStatus =
"No valid screenshot (ss_path) provided by Selenium; nothing to upload.";
"No valid file path (ss_path) provided by Selenium; nothing to upload.";
}
if (pdfBuffer && generatedPdfPath) {

View File

@@ -0,0 +1,749 @@
import { Router, Request, Response } from "express";
import { storage } from "../storage";
import {
forwardToSeleniumDeltaInsEligibilityAgent,
forwardOtpToSeleniumDeltaInsAgent,
getSeleniumDeltaInsSessionStatus,
} from "../services/seleniumDeltainsInsuranceEligibilityClient";
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";
const router = Router();
/** Job context stored in memory by sessionId */
interface DeltaInsJobContext {
userId: number;
insuranceEligibilityData: any;
socketId?: string;
}
const deltainsJobs: Record<string, DeltaInsJobContext> = {};
/** 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;
const A4_HEIGHT = 841.89;
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;
eligibilityStatus?: string;
}) {
const { insuranceId, firstName, lastName, dob, userId, eligibilityStatus } = 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 {
console.log(`[deltains-eligibility] Creating new patient: ${incomingFirst} ${incomingLast} with status: ${eligibilityStatus || "UNKNOWN"}`);
const createPayload: any = {
firstName: incomingFirst,
lastName: incomingLast,
dateOfBirth: dob,
gender: "Unknown",
phone: "",
userId,
insuranceId,
insuranceProvider: "Delta Dental Ins",
status: eligibilityStatus || "UNKNOWN",
};
let patientData: InsertPatient;
try {
patientData = insertPatientSchema.parse(createPayload);
} catch (err) {
const safePayload = { ...createPayload };
delete (safePayload as any).dateOfBirth;
patientData = insertPatientSchema.parse(safePayload);
}
const newPatient = await storage.createPatient(patientData);
console.log(`[deltains-eligibility] Created new patient: ${newPatient.id} with status: ${eligibilityStatus || "UNKNOWN"}`);
}
}
/**
* When Selenium finishes for a given sessionId, run patient + PDF pipeline.
*/
async function handleDeltaInsCompletedJob(
sessionId: string,
job: DeltaInsJobContext,
seleniumResult: any
) {
let createdPdfFileId: number | null = null;
let generatedPdfPath: string | null = null;
const outputResult: any = {};
try {
const insuranceEligibilityData = job.insuranceEligibilityData;
let insuranceId = String(seleniumResult?.memberId ?? "").trim();
if (!insuranceId) {
insuranceId = String(insuranceEligibilityData.memberId ?? "").trim();
}
if (!insuranceId) {
console.log("[deltains-eligibility] No Member ID found - will use name for patient lookup");
} else {
console.log(`[deltains-eligibility] Using Member ID: ${insuranceId}`);
}
const patientNameFromResult =
typeof seleniumResult?.patientName === "string"
? seleniumResult.patientName.trim()
: null;
let firstName = insuranceEligibilityData.firstName || "";
let lastName = insuranceEligibilityData.lastName || "";
if (patientNameFromResult) {
const parsedName = splitName(patientNameFromResult);
firstName = parsedName.firstName || firstName;
lastName = parsedName.lastName || lastName;
}
const rawEligibility = String(seleniumResult?.eligibility ?? "").toLowerCase();
const eligibilityStatus = rawEligibility.includes("active") || rawEligibility.includes("eligible")
? "ACTIVE" : "INACTIVE";
console.log(`[deltains-eligibility] Eligibility status: ${eligibilityStatus}`);
if (insuranceId) {
await createOrUpdatePatientByInsuranceId({
insuranceId,
firstName,
lastName,
dob: insuranceEligibilityData.dateOfBirth,
userId: job.userId,
eligibilityStatus,
});
}
let patient = insuranceId
? await storage.getPatientByInsuranceId(insuranceId)
: null;
if (!patient?.id && firstName && lastName) {
const patients = await storage.getAllPatients(job.userId);
patient = patients.find(
(p) =>
p.firstName?.toLowerCase() === firstName.toLowerCase() &&
p.lastName?.toLowerCase() === lastName.toLowerCase()
) ?? null;
if (patient) {
console.log(`[deltains-eligibility] Found patient by name: ${patient.id}`);
}
}
if (!patient && firstName && lastName) {
console.log(`[deltains-eligibility] Creating new patient: ${firstName} ${lastName}`);
try {
let parsedDob: Date | undefined = undefined;
if (insuranceEligibilityData.dateOfBirth) {
try {
parsedDob = new Date(insuranceEligibilityData.dateOfBirth);
if (isNaN(parsedDob.getTime())) parsedDob = undefined;
} catch {
parsedDob = undefined;
}
}
const newPatientData: InsertPatient = {
firstName,
lastName,
dateOfBirth: parsedDob || new Date(),
insuranceId: insuranceId || undefined,
insuranceProvider: "Delta Dental Ins",
gender: "Unknown",
phone: "",
userId: job.userId,
status: eligibilityStatus,
};
const validation = insertPatientSchema.safeParse(newPatientData);
if (validation.success) {
patient = await storage.createPatient(validation.data);
console.log(`[deltains-eligibility] Created new patient: ${patient.id}`);
} else {
console.log(`[deltains-eligibility] Patient validation failed: ${validation.error.message}`);
}
} catch (createErr: any) {
console.log(`[deltains-eligibility] Failed to create patient: ${createErr.message}`);
}
}
if (!patient?.id) {
outputResult.patientUpdateStatus =
"Patient not found and could not be created; no update performed";
return {
patientUpdateStatus: outputResult.patientUpdateStatus,
pdfUploadStatus: "none",
pdfFileId: null,
};
}
const updatePayload: Record<string, any> = { status: eligibilityStatus };
if (firstName && (!patient.firstName || patient.firstName.trim() === "")) {
updatePayload.firstName = firstName;
}
if (lastName && (!patient.lastName || patient.lastName.trim() === "")) {
updatePayload.lastName = lastName;
}
await storage.updatePatient(patient.id, updatePayload);
outputResult.patientUpdateStatus = `Patient ${patient.id} updated: status=${eligibilityStatus}, name=${firstName} ${lastName}`;
console.log(`[deltains-eligibility] ${outputResult.patientUpdateStatus}`);
// Handle PDF
let pdfBuffer: Buffer | null = null;
// Check for base64 PDF from CDP command
if (seleniumResult?.pdfBase64 && typeof seleniumResult.pdfBase64 === "string" && seleniumResult.pdfBase64.length > 100) {
try {
pdfBuffer = Buffer.from(seleniumResult.pdfBase64, "base64");
const pdfFileName = `deltains_eligibility_${insuranceId || "unknown"}_${Date.now()}.pdf`;
const downloadDir = path.join(process.cwd(), "seleniumDownloads");
if (!fsSync.existsSync(downloadDir)) {
fsSync.mkdirSync(downloadDir, { recursive: true });
}
generatedPdfPath = path.join(downloadDir, pdfFileName);
await fs.writeFile(generatedPdfPath, pdfBuffer);
console.log(`[deltains-eligibility] PDF saved from base64: ${generatedPdfPath}`);
} catch (pdfErr: any) {
console.error(`[deltains-eligibility] Failed to save base64 PDF: ${pdfErr.message}`);
pdfBuffer = null;
}
}
// Fallback: check for file path from selenium
if (!pdfBuffer && seleniumResult?.ss_path && typeof seleniumResult.ss_path === "string") {
try {
if (!fsSync.existsSync(seleniumResult.ss_path)) {
throw new Error(`File not found: ${seleniumResult.ss_path}`);
}
if (seleniumResult.ss_path.endsWith(".pdf")) {
pdfBuffer = await fs.readFile(seleniumResult.ss_path);
generatedPdfPath = seleniumResult.ss_path;
seleniumResult.pdf_path = generatedPdfPath;
} else if (
seleniumResult.ss_path.endsWith(".png") ||
seleniumResult.ss_path.endsWith(".jpg") ||
seleniumResult.ss_path.endsWith(".jpeg")
) {
pdfBuffer = await imageToPdfBuffer(seleniumResult.ss_path);
const pdfFileName = `deltains_eligibility_${insuranceId || "unknown"}_${Date.now()}.pdf`;
generatedPdfPath = path.join(
path.dirname(seleniumResult.ss_path),
pdfFileName
);
await fs.writeFile(generatedPdfPath, pdfBuffer);
seleniumResult.pdf_path = generatedPdfPath;
}
} catch (err: any) {
console.error("[deltains-eligibility] Failed to process PDF/screenshot:", err);
outputResult.pdfUploadStatus = `Failed to process file: ${String(err)}`;
}
}
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 if (!outputResult.pdfUploadStatus) {
outputResult.pdfUploadStatus = "No PDF available from Selenium";
}
const pdfFilename = generatedPdfPath ? path.basename(generatedPdfPath) : null;
return {
patientUpdateStatus: outputResult.patientUpdateStatus,
pdfUploadStatus: outputResult.pdfUploadStatus,
pdfFileId: createdPdfFileId,
pdfFilename,
};
} catch (err: any) {
const pdfFilename = generatedPdfPath ? path.basename(generatedPdfPath) : null;
return {
patientUpdateStatus: outputResult.patientUpdateStatus,
pdfUploadStatus:
outputResult.pdfUploadStatus ??
`Failed to process DeltaIns job: ${err?.message ?? String(err)}`,
pdfFileId: createdPdfFileId,
pdfFilename,
error: err?.message ?? String(err),
};
} finally {
try {
if (seleniumResult && seleniumResult.pdf_path) {
await emptyFolderContainingFile(seleniumResult.pdf_path);
} else if (seleniumResult && seleniumResult.ss_path) {
await emptyFolderContainingFile(seleniumResult.ss_path);
}
} catch (cleanupErr) {
console.error(
`[deltains-eligibility cleanup failed]`,
cleanupErr
);
}
}
}
let currentFinalSessionId: string | null = null;
let currentFinalResult: any = null;
function now() {
return new Date().toISOString();
}
function log(tag: string, msg: string, ctx?: any) {
console.log(`${now()} [${tag}] ${msg}`, ctx ?? "");
}
function emitSafe(socketId: string | undefined, event: string, payload: any) {
if (!socketId) {
log("socket", "no socketId for emit", { event });
return;
}
try {
const socket = io?.sockets.sockets.get(socketId);
if (!socket) {
log("socket", "socket not found (maybe disconnected)", {
socketId,
event,
});
return;
}
socket.emit(event, payload);
log("socket", "emitted", { socketId, event });
} catch (err: any) {
log("socket", "emit failed", { socketId, event, err: err?.message });
}
}
async function pollAgentSessionAndProcess(
sessionId: string,
socketId?: string,
pollTimeoutMs = 8 * 60 * 1000
) {
const maxAttempts = 500;
const baseDelayMs = 1000;
const maxTransientErrors = 12;
const noProgressLimit = 200;
const job = deltainsJobs[sessionId];
let transientErrorCount = 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) {
emitSafe(socketId, "selenium:session_update", {
session_id: sessionId,
status: "error",
message: `Polling timeout reached (${Math.round(pollTimeoutMs / 1000)}s).`,
});
delete deltainsJobs[sessionId];
return;
}
log(
"poller-deltains",
`attempt=${attempt} session=${sessionId} transientErrCount=${transientErrorCount}`
);
try {
const st = await getSeleniumDeltaInsSessionStatus(sessionId);
const status = st?.status ?? null;
log("poller-deltains", "got status", {
sessionId,
status,
message: st?.message,
resultKeys: st?.result ? Object.keys(st.result) : null,
});
transientErrorCount = 0;
const isTerminalLike =
status === "completed" || status === "error" || status === "not_found";
if (status === lastStatus && !isTerminalLike) {
consecutiveNoProgress++;
} else {
consecutiveNoProgress = 0;
}
lastStatus = status;
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 deltainsJobs[sessionId];
return;
}
emitSafe(socketId, "selenium:debug", {
session_id: sessionId,
attempt,
status,
serverTime: new Date().toISOString(),
});
if (status === "waiting_for_otp") {
emitSafe(socketId, "selenium:otp_required", {
session_id: sessionId,
message: "OTP required. Please enter the code sent to your email.",
});
await new Promise((r) => setTimeout(r, baseDelayMs));
continue;
}
if (status === "completed") {
log("poller-deltains", "agent completed; processing result", {
sessionId,
resultKeys: st.result ? Object.keys(st.result) : null,
});
currentFinalSessionId = sessionId;
currentFinalResult = {
rawSelenium: st.result,
processedAt: null,
final: null,
};
let finalResult: any = null;
if (job && st.result) {
try {
finalResult = await handleDeltaInsCompletedJob(
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-deltains", "handleDeltaInsCompletedJob failed", {
sessionId,
err: err?.message ?? err,
});
}
} else {
currentFinalResult.final = {
error: "no_job_or_no_result",
};
currentFinalResult.processedAt = Date.now();
}
emitSafe(socketId, "selenium:session_update", {
session_id: sessionId,
status: "completed",
rawSelenium: st.result,
final: currentFinalResult.final,
});
delete deltainsJobs[sessionId];
return;
}
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 deltainsJobs[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 (
axiosStatus === 404 ||
(typeof errMsg === "string" && errMsg.includes("not_found"))
) {
console.warn(
`${new Date().toISOString()} [poller-deltains] terminal 404/not_found for ${sessionId}`
);
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);
delete deltainsJobs[sessionId];
return;
}
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 deltainsJobs[sessionId];
return;
}
const backoffMs = Math.min(
30_000,
baseDelayMs * Math.pow(2, transientErrorCount - 1)
);
console.warn(
`${new Date().toISOString()} [poller-deltains] transient error (#${transientErrorCount}) for ${sessionId}: code=${errCode} status=${axiosStatus} msg=${errMsg}`
);
await new Promise((r) => setTimeout(r, backoffMs));
continue;
}
await new Promise((r) => setTimeout(r, baseDelayMs));
}
emitSafe(socketId, "selenium:session_update", {
session_id: sessionId,
status: "error",
message: "Polling timeout while waiting for selenium session",
});
delete deltainsJobs[sessionId];
}
/**
* POST /deltains-eligibility
* Starts DeltaIns eligibility Selenium job.
*/
router.post(
"/deltains-eligibility",
async (req: Request, res: Response): Promise<any> => {
if (!req.body.data) {
return res
.status(400)
.json({ error: "Missing Insurance Eligibility data for selenium" });
}
if (!req.user || !req.user.id) {
return res.status(401).json({ error: "Unauthorized: user info missing" });
}
try {
const rawData =
typeof req.body.data === "string"
? JSON.parse(req.body.data)
: req.body.data;
const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(
req.user.id,
"DELTAINS"
);
if (!credentials) {
return res.status(404).json({
error:
"No insurance credentials found for this provider, Kindly Update this at Settings Page.",
});
}
const enrichedData = {
...rawData,
deltains_username: credentials.username,
deltains_password: credentials.password,
};
const socketId: string | undefined = req.body.socketId;
const agentResp =
await forwardToSeleniumDeltaInsEligibilityAgent(enrichedData);
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;
deltainsJobs[sessionId] = {
userId: req.user.id,
insuranceEligibilityData: enrichedData,
socketId,
};
pollAgentSessionAndProcess(sessionId, socketId).catch((e) =>
console.warn("pollAgentSessionAndProcess (deltains) failed", e)
);
return res.json({ status: "started", session_id: sessionId });
} catch (err: any) {
console.error(err);
return res.status(500).json({
error: err.message || "Failed to start DeltaIns selenium agent",
});
}
}
);
/**
* POST /selenium/submit-otp
*/
router.post(
"/selenium/submit-otp",
async (req: Request, res: Response): Promise<any> => {
const { session_id: sessionId, otp, socketId } = req.body;
if (!sessionId || !otp) {
return res.status(400).json({ error: "session_id and otp are required" });
}
try {
const r = await forwardOtpToSeleniumDeltaInsAgent(sessionId, otp);
emitSafe(socketId, "selenium:otp_submitted", {
session_id: sessionId,
result: r,
});
return res.json(r);
} catch (err: any) {
console.error(
"Failed to forward OTP:",
err?.response?.data || err?.message || err
);
return res.status(500).json({
error: "Failed to forward otp to selenium agent",
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" });
if (currentFinalSessionId !== sid || !currentFinalResult) {
return res.status(404).json({ error: "final result not found" });
}
return res.json(currentFinalResult);
}
);
export default router;

View File

@@ -0,0 +1,793 @@
import { Router, Request, Response } from "express";
import { storage } from "../storage";
import {
forwardToSeleniumDentaQuestEligibilityAgent,
forwardOtpToSeleniumDentaQuestAgent,
getSeleniumDentaQuestSessionStatus,
} from "../services/seleniumDentaQuestInsuranceEligibilityClient";
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";
const router = Router();
/** Job context stored in memory by sessionId */
interface DentaQuestJobContext {
userId: number;
insuranceEligibilityData: any; // parsed, enriched (includes username/password)
socketId?: string;
}
const dentaquestJobs: Record<string, DentaQuestJobContext> = {};
/** 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 handleDentaQuestCompletedJob(
sessionId: string,
job: DentaQuestJobContext,
seleniumResult: any
) {
let createdPdfFileId: number | null = null;
const outputResult: any = {};
// We'll wrap the processing in try/catch/finally so cleanup always runs
try {
const insuranceEligibilityData = job.insuranceEligibilityData;
// 1) Get Member ID - prefer the one extracted from the page by Selenium,
// since we now allow searching by name only
let insuranceId = String(seleniumResult?.memberId ?? "").trim();
if (!insuranceId) {
// Fallback to the one provided in the request
insuranceId = String(insuranceEligibilityData.memberId ?? "").trim();
}
console.log(`[dentaquest-eligibility] Insurance ID: ${insuranceId || "(none)"}`);
// 2) Create or update patient (with name from selenium result if available)
const patientNameFromResult =
typeof seleniumResult?.patientName === "string"
? seleniumResult.patientName.trim()
: null;
// Get name from request data as fallback
let firstName = insuranceEligibilityData.firstName || "";
let lastName = insuranceEligibilityData.lastName || "";
// Override with name from Selenium result if available
if (patientNameFromResult) {
const parsedName = splitName(patientNameFromResult);
firstName = parsedName.firstName || firstName;
lastName = parsedName.lastName || lastName;
}
// Create or update patient if we have an insurance ID
if (insuranceId) {
await createOrUpdatePatientByInsuranceId({
insuranceId,
firstName,
lastName,
dob: insuranceEligibilityData.dateOfBirth,
userId: job.userId,
});
} else {
console.log("[dentaquest-eligibility] No Member ID available - will try to find patient by name/DOB");
}
// 3) Update patient status + PDF upload
// First try to find by insurance ID, then by name + DOB
let patient = insuranceId
? await storage.getPatientByInsuranceId(insuranceId)
: null;
// If not found by ID and we have name + DOB, try to find by those
if (!patient && firstName && lastName) {
console.log(`[dentaquest-eligibility] Looking up patient by name: ${firstName} ${lastName}`);
const patients = await storage.getPatientsByUserId(job.userId);
patient = patients.find(p =>
p.firstName?.toLowerCase() === firstName.toLowerCase() &&
p.lastName?.toLowerCase() === lastName.toLowerCase()
) || null;
// If found and we now have the insurance ID, update the patient record
if (patient && insuranceId) {
await storage.updatePatient(patient.id, { insuranceId });
console.log(`[dentaquest-eligibility] Updated patient ${patient.id} with insuranceId: ${insuranceId}`);
}
}
// Determine eligibility status from Selenium result
const eligibilityStatus = seleniumResult.eligibility === "active" ? "ACTIVE" : "INACTIVE";
console.log(`[dentaquest-eligibility] Eligibility status from DentaQuest: ${eligibilityStatus}`);
// If still no patient found, CREATE a new one with the data we have
if (!patient?.id && firstName && lastName) {
console.log(`[dentaquest-eligibility] Creating new patient: ${firstName} ${lastName} with status: ${eligibilityStatus}`);
const createPayload: any = {
firstName,
lastName,
dateOfBirth: insuranceEligibilityData.dateOfBirth || null,
gender: "",
phone: "",
userId: job.userId,
insuranceId: insuranceId || null,
insuranceProvider: "DentaQuest", // Set insurance provider
status: eligibilityStatus, // Set status from eligibility check
};
try {
const patientData = insertPatientSchema.parse(createPayload);
const newPatient = await storage.createPatient(patientData);
if (newPatient) {
patient = newPatient;
console.log(`[dentaquest-eligibility] Created new patient with ID: ${patient.id}, status: ${eligibilityStatus}`);
}
} catch (err: any) {
// Try without dateOfBirth if it fails
try {
const safePayload = { ...createPayload };
delete safePayload.dateOfBirth;
const patientData = insertPatientSchema.parse(safePayload);
const newPatient = await storage.createPatient(patientData);
if (newPatient) {
patient = newPatient;
console.log(`[dentaquest-eligibility] Created new patient (no DOB) with ID: ${patient.id}, status: ${eligibilityStatus}`);
}
} catch (err2: any) {
console.error(`[dentaquest-eligibility] Failed to create patient: ${err2?.message}`);
}
}
}
if (!patient?.id) {
outputResult.patientUpdateStatus =
"Patient not found and could not be created";
return {
patientUpdateStatus: outputResult.patientUpdateStatus,
pdfUploadStatus: "none",
pdfFileId: null,
};
}
// Update patient status from DentaQuest eligibility result
await storage.updatePatient(patient.id, { status: eligibilityStatus });
outputResult.patientUpdateStatus = `Patient ${patient.id} status set to ${eligibilityStatus} (DentaQuest eligibility: ${seleniumResult.eligibility})`;
console.log(`[dentaquest-eligibility] ${outputResult.patientUpdateStatus}`);
// Handle PDF or 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"
) {
try {
if (!fsSync.existsSync(seleniumResult.ss_path)) {
throw new Error(
`File not found: ${seleniumResult.ss_path}`
);
}
// Check if the file is already a PDF (from Page.printToPDF)
if (seleniumResult.ss_path.endsWith(".pdf")) {
// Read PDF directly
pdfBuffer = await fs.readFile(seleniumResult.ss_path);
generatedPdfPath = seleniumResult.ss_path;
seleniumResult.pdf_path = generatedPdfPath;
console.log(`[dentaquest-eligibility] Using PDF directly from Selenium: ${generatedPdfPath}`);
} else if (
seleniumResult.ss_path.endsWith(".png") ||
seleniumResult.ss_path.endsWith(".jpg") ||
seleniumResult.ss_path.endsWith(".jpeg")
) {
// Convert image to PDF
pdfBuffer = await imageToPdfBuffer(seleniumResult.ss_path);
const pdfFileName = `dentaquest_eligibility_${insuranceEligibilityData.memberId}_${Date.now()}.pdf`;
generatedPdfPath = path.join(
path.dirname(seleniumResult.ss_path),
pdfFileName
);
await fs.writeFile(generatedPdfPath, pdfBuffer);
seleniumResult.pdf_path = generatedPdfPath;
console.log(`[dentaquest-eligibility] Converted screenshot to PDF: ${generatedPdfPath}`);
} else {
outputResult.pdfUploadStatus =
`Unsupported file format: ${seleniumResult.ss_path}`;
}
} catch (err: any) {
console.error("Failed to process PDF/screenshot:", err);
outputResult.pdfUploadStatus = `Failed to process file: ${String(err)}`;
}
} else {
outputResult.pdfUploadStatus =
"No valid file path (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 DentaQuest 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(
`[dentaquest-eligibility] no pdf_path or ss_path available to cleanup`
);
}
} catch (cleanupErr) {
console.error(
`[dentaquest-eligibility cleanup failed for ${seleniumResult?.pdf_path ?? seleniumResult?.ss_path}]`,
cleanupErr
);
}
}
}
// --- top of file, alongside dentaquestJobs ---
let currentFinalSessionId: string | null = null;
let currentFinalResult: any = null;
function now() {
return new Date().toISOString();
}
function log(tag: string, msg: string, ctx?: any) {
console.log(`${now()} [${tag}] ${msg}`, ctx ?? "");
}
function emitSafe(socketId: string | undefined, event: string, payload: any) {
if (!socketId) {
log("socket", "no socketId for emit", { event });
return;
}
try {
const socket = io?.sockets.sockets.get(socketId);
if (!socket) {
log("socket", "socket not found (maybe disconnected)", {
socketId,
event,
});
return;
}
socket.emit(event, payload);
log("socket", "emitted", { socketId, event });
} catch (err: any) {
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
* - absolute 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 = dentaquestJobs[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 dentaquestJobs[sessionId];
return;
}
log(
"poller",
`attempt=${attempt} session=${sessionId} transientErrCount=${transientErrorCount}`
);
try {
const st = await getSeleniumDentaQuestSessionStatus(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 dentaquestJobs[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 handleDentaQuestCompletedJob(
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", "handleDentaQuestCompletedJob failed", {
sessionId,
err: err?.message ?? err,
});
}
} else {
currentFinalResult.final = {
error: "no_job_or_no_result",
};
currentFinalResult.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 dentaquestJobs[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 dentaquestJobs[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 dentaquestJobs[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 dentaquestJobs[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 dentaquestJobs[sessionId];
}
/**
* POST /dentaquest-eligibility
* Starts DentaQuest eligibility Selenium job.
* Expects:
* - req.body.data: stringified JSON like your existing /eligibility-check
* - req.body.socketId: socket.io client id
*/
router.post(
"/dentaquest-eligibility",
async (req: Request, res: Response): Promise<any> => {
if (!req.body.data) {
return res
.status(400)
.json({ error: "Missing Insurance Eligibility data for selenium" });
}
if (!req.user || !req.user.id) {
return res.status(401).json({ error: "Unauthorized: user info missing" });
}
try {
const rawData =
typeof req.body.data === "string"
? JSON.parse(req.body.data)
: req.body.data;
const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(
req.user.id,
rawData.insuranceSiteKey
);
if (!credentials) {
return res.status(404).json({
error:
"No insurance credentials found for this provider, Kindly Update this at Settings Page.",
});
}
const enrichedData = {
...rawData,
dentaquestUsername: credentials.username,
dentaquestPassword: credentials.password,
};
const socketId: string | undefined = req.body.socketId;
const agentResp =
await forwardToSeleniumDentaQuestEligibilityAgent(enrichedData);
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
dentaquestJobs[sessionId] = {
userId: req.user.id,
insuranceEligibilityData: enrichedData,
socketId,
};
// start polling in background to notify client via socket and process job
pollAgentSessionAndProcess(sessionId, socketId).catch((e) =>
console.warn("pollAgentSessionAndProcess failed", e)
);
// reply immediately with started status
return res.json({ status: "started", session_id: sessionId });
} catch (err: any) {
console.error(err);
return res.status(500).json({
error: err.message || "Failed to start DentaQuest selenium agent",
});
}
}
);
/**
* POST /selenium/submit-otp
* Body: { session_id, otp, socketId? }
* Forwards OTP to Python agent and optionally notifies client socket.
*/
router.post(
"/selenium/submit-otp",
async (req: Request, res: Response): Promise<any> => {
const { session_id: sessionId, otp, socketId } = req.body;
if (!sessionId || !otp) {
return res.status(400).json({ error: "session_id and otp are required" });
}
try {
const r = await forwardOtpToSeleniumDentaQuestAgent(sessionId, otp);
// emit OTP accepted (if socket present)
emitSafe(socketId, "selenium:otp_submitted", {
session_id: sessionId,
result: r,
});
return res.json(r);
} catch (err: any) {
console.error(
"Failed to forward OTP:",
err?.response?.data || err?.message || err
);
return res.status(500).json({
error: "Failed to forward otp to selenium agent",
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;

View File

@@ -0,0 +1,815 @@
import { Router, Request, Response } from "express";
import { storage } from "../storage";
import {
forwardToSeleniumUnitedSCOEligibilityAgent,
forwardOtpToSeleniumUnitedSCOAgent,
getSeleniumUnitedSCOSessionStatus,
} from "../services/seleniumUnitedSCOInsuranceEligibilityClient";
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";
const router = Router();
/** Job context stored in memory by sessionId */
interface UnitedSCOJobContext {
userId: number;
insuranceEligibilityData: any; // parsed, enriched (includes username/password)
socketId?: string;
}
const unitedscoJobs: Record<string, UnitedSCOJobContext> = {};
/** 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;
eligibilityStatus?: string; // "ACTIVE" or "INACTIVE"
}) {
const { insuranceId, firstName, lastName, dob, userId, eligibilityStatus } = 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 {
console.log(`[unitedsco-eligibility] Creating new patient: ${incomingFirst} ${incomingLast} with status: ${eligibilityStatus || "UNKNOWN"}`);
const createPayload: any = {
firstName: incomingFirst,
lastName: incomingLast,
dateOfBirth: dob,
gender: "Unknown",
phone: "",
userId,
insuranceId,
insuranceProvider: "United SCO",
status: eligibilityStatus || "UNKNOWN",
};
let patientData: InsertPatient;
try {
patientData = insertPatientSchema.parse(createPayload);
} catch (err) {
const safePayload = { ...createPayload };
delete (safePayload as any).dateOfBirth;
patientData = insertPatientSchema.parse(safePayload);
}
const newPatient = await storage.createPatient(patientData);
console.log(`[unitedsco-eligibility] Created new patient: ${newPatient.id} with status: ${eligibilityStatus || "UNKNOWN"}`);
}
}
/**
* When Selenium finishes for a given sessionId, run your patient + PDF pipeline,
* and return the final API response shape.
*
* Note: For United SCO, we search by First Name + Last Name + DOB (not by Member ID).
* The Member ID is extracted from the page after search and returned in seleniumResult.memberId.
*/
async function handleUnitedSCOCompletedJob(
sessionId: string,
job: UnitedSCOJobContext,
seleniumResult: any
) {
let createdPdfFileId: number | null = null;
let generatedPdfPath: string | null = null;
const outputResult: any = {};
// We'll wrap the processing in try/catch/finally so cleanup always runs
try {
const insuranceEligibilityData = job.insuranceEligibilityData;
// 1) Get Member ID - prefer the one extracted from the page by Selenium,
// since United SCO searches by name and the Member ID is found after search
let insuranceId = String(seleniumResult?.memberId ?? "").trim();
if (!insuranceId) {
// Fallback to the one provided in the request (if any)
insuranceId = String(insuranceEligibilityData.memberId ?? "").trim();
}
if (!insuranceId) {
console.log("[unitedsco-eligibility] No Member ID found - will use name for patient lookup");
} else {
console.log(`[unitedsco-eligibility] Using Member ID: ${insuranceId}`);
}
// 2) Get patient name - prefer from selenium result, fallback to request data
const patientNameFromResult =
typeof seleniumResult?.patientName === "string"
? seleniumResult.patientName.trim()
: null;
let firstName = insuranceEligibilityData.firstName || "";
let lastName = insuranceEligibilityData.lastName || "";
if (patientNameFromResult) {
const parsedName = splitName(patientNameFromResult);
firstName = parsedName.firstName || firstName;
lastName = parsedName.lastName || lastName;
}
// Determine eligibility status from Selenium result
const eligibilityStatus = seleniumResult.eligibility === "active" ? "ACTIVE" : "INACTIVE";
console.log(`[unitedsco-eligibility] Eligibility status from United SCO: ${eligibilityStatus}`);
// 3) Create or update patient
if (insuranceId) {
await createOrUpdatePatientByInsuranceId({
insuranceId,
firstName,
lastName,
dob: insuranceEligibilityData.dateOfBirth,
userId: job.userId,
eligibilityStatus,
});
}
// 4) Get patient for status update and PDF upload
let patient = insuranceId
? await storage.getPatientByInsuranceId(insuranceId)
: null;
// If no patient found by insuranceId, try to find by firstName + lastName
if (!patient?.id && firstName && lastName) {
const patients = await storage.getAllPatients(job.userId);
patient = patients.find(
(p) =>
p.firstName?.toLowerCase() === firstName.toLowerCase() &&
p.lastName?.toLowerCase() === lastName.toLowerCase()
) ?? null;
if (patient) {
console.log(`[unitedsco-eligibility] Found patient by name: ${patient.id}`);
}
}
// If still not found, create new patient
console.log(`[unitedsco-eligibility] Patient creation check: patient=${patient?.id || 'null'}, firstName='${firstName}', lastName='${lastName}'`);
if (!patient && firstName && lastName) {
console.log(`[unitedsco-eligibility] Creating new patient: ${firstName} ${lastName} with status: ${eligibilityStatus}`);
try {
let parsedDob: Date | undefined = undefined;
if (insuranceEligibilityData.dateOfBirth) {
try {
parsedDob = new Date(insuranceEligibilityData.dateOfBirth);
if (isNaN(parsedDob.getTime())) parsedDob = undefined;
} catch {
parsedDob = undefined;
}
}
const newPatientData: InsertPatient = {
firstName,
lastName,
dateOfBirth: parsedDob || new Date(), // Required field
insuranceId: insuranceId || undefined,
insuranceProvider: "United SCO",
gender: "Unknown",
phone: "",
userId: job.userId,
status: eligibilityStatus,
};
const validation = insertPatientSchema.safeParse(newPatientData);
if (validation.success) {
patient = await storage.createPatient(validation.data);
console.log(`[unitedsco-eligibility] Created new patient: ${patient.id} with status: ${eligibilityStatus}`);
} else {
console.log(`[unitedsco-eligibility] Patient validation failed: ${validation.error.message}`);
}
} catch (createErr: any) {
console.log(`[unitedsco-eligibility] Failed to create patient: ${createErr.message}`);
}
}
if (!patient?.id) {
outputResult.patientUpdateStatus =
"Patient not found and could not be created; no update performed";
return {
patientUpdateStatus: outputResult.patientUpdateStatus,
pdfUploadStatus: "none",
pdfFileId: null,
};
}
// Update patient status and name from United SCO eligibility result
const updatePayload: Record<string, any> = { status: eligibilityStatus };
// Also update first/last name if we extracted them and patient has empty names
if (firstName && (!patient.firstName || patient.firstName.trim() === "")) {
updatePayload.firstName = firstName;
}
if (lastName && (!patient.lastName || patient.lastName.trim() === "")) {
updatePayload.lastName = lastName;
}
await storage.updatePatient(patient.id, updatePayload);
outputResult.patientUpdateStatus = `Patient ${patient.id} updated: status=${eligibilityStatus}, name=${firstName} ${lastName} (United SCO eligibility: ${seleniumResult.eligibility})`;
console.log(`[unitedsco-eligibility] ${outputResult.patientUpdateStatus}`);
// Handle PDF or convert screenshot -> pdf if available
let pdfBuffer: Buffer | null = null;
if (
seleniumResult &&
seleniumResult.ss_path &&
typeof seleniumResult.ss_path === "string"
) {
try {
if (!fsSync.existsSync(seleniumResult.ss_path)) {
throw new Error(
`File not found: ${seleniumResult.ss_path}`
);
}
// Check if the file is already a PDF (from Page.printToPDF)
if (seleniumResult.ss_path.endsWith(".pdf")) {
// Read PDF directly
pdfBuffer = await fs.readFile(seleniumResult.ss_path);
generatedPdfPath = seleniumResult.ss_path;
seleniumResult.pdf_path = generatedPdfPath;
console.log(`[unitedsco-eligibility] Using PDF directly from Selenium: ${generatedPdfPath}`);
} else if (
seleniumResult.ss_path.endsWith(".png") ||
seleniumResult.ss_path.endsWith(".jpg") ||
seleniumResult.ss_path.endsWith(".jpeg")
) {
// Convert image to PDF
pdfBuffer = await imageToPdfBuffer(seleniumResult.ss_path);
// Use insuranceId (which may come from Selenium result) for filename
const pdfFileName = `unitedsco_eligibility_${insuranceId || "unknown"}_${Date.now()}.pdf`;
generatedPdfPath = path.join(
path.dirname(seleniumResult.ss_path),
pdfFileName
);
await fs.writeFile(generatedPdfPath, pdfBuffer);
seleniumResult.pdf_path = generatedPdfPath;
console.log(`[unitedsco-eligibility] Converted screenshot to PDF: ${generatedPdfPath}`);
} else {
outputResult.pdfUploadStatus =
`Unsupported file format: ${seleniumResult.ss_path}`;
}
} catch (err: any) {
console.error("Failed to process PDF/screenshot:", err);
outputResult.pdfUploadStatus = `Failed to process file: ${String(err)}`;
}
} else {
outputResult.pdfUploadStatus =
"No valid file path (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.";
}
// Get filename for frontend preview
const pdfFilename = generatedPdfPath ? path.basename(generatedPdfPath) : null;
return {
patientUpdateStatus: outputResult.patientUpdateStatus,
pdfUploadStatus: outputResult.pdfUploadStatus,
pdfFileId: createdPdfFileId,
pdfFilename,
};
} catch (err: any) {
// Get filename for frontend preview if available
const pdfFilename = generatedPdfPath ? path.basename(generatedPdfPath) : null;
return {
patientUpdateStatus: outputResult.patientUpdateStatus,
pdfUploadStatus:
outputResult.pdfUploadStatus ??
`Failed to process United SCO job: ${err?.message ?? String(err)}`,
pdfFileId: createdPdfFileId,
pdfFilename,
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(
`[unitedsco-eligibility] no pdf_path or ss_path available to cleanup`
);
}
} catch (cleanupErr) {
console.error(
`[unitedsco-eligibility cleanup failed for ${seleniumResult?.pdf_path ?? seleniumResult?.ss_path}]`,
cleanupErr
);
}
}
}
// --- top of file, alongside unitedscoJobs ---
let currentFinalSessionId: string | null = null;
let currentFinalResult: any = null;
function now() {
return new Date().toISOString();
}
function log(tag: string, msg: string, ctx?: any) {
console.log(`${now()} [${tag}] ${msg}`, ctx ?? "");
}
function emitSafe(socketId: string | undefined, event: string, payload: any) {
if (!socketId) {
log("socket", "no socketId for emit", { event });
return;
}
try {
const socket = io?.sockets.sockets.get(socketId);
if (!socket) {
log("socket", "socket not found (maybe disconnected)", {
socketId,
event,
});
return;
}
socket.emit(event, payload);
log("socket", "emitted", { socketId, event });
} catch (err: any) {
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
* - absolute 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 = unitedscoJobs[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 unitedscoJobs[sessionId];
return;
}
log(
"poller",
`attempt=${attempt} session=${sessionId} transientErrCount=${transientErrorCount}`
);
try {
const st = await getSeleniumUnitedSCOSessionStatus(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 unitedscoJobs[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 handleUnitedSCOCompletedJob(
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", "handleUnitedSCOCompletedJob failed", {
sessionId,
err: err?.message ?? err,
});
}
} else {
currentFinalResult.final = {
error: "no_job_or_no_result",
};
currentFinalResult.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 unitedscoJobs[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 unitedscoJobs[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 unitedscoJobs[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 unitedscoJobs[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 unitedscoJobs[sessionId];
}
/**
* POST /unitedsco-eligibility
* Starts United SCO eligibility Selenium job.
* Expects:
* - req.body.data: stringified JSON like your existing /eligibility-check
* - req.body.socketId: socket.io client id
*/
router.post(
"/unitedsco-eligibility",
async (req: Request, res: Response): Promise<any> => {
if (!req.body.data) {
return res
.status(400)
.json({ error: "Missing Insurance Eligibility data for selenium" });
}
if (!req.user || !req.user.id) {
return res.status(401).json({ error: "Unauthorized: user info missing" });
}
try {
const rawData =
typeof req.body.data === "string"
? JSON.parse(req.body.data)
: req.body.data;
// United SCO uses its own credentials
const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(
req.user.id,
"UNITEDSCO"
);
if (!credentials) {
return res.status(404).json({
error:
"No insurance credentials found for this provider, Kindly Update this at Settings Page.",
});
}
const enrichedData = {
...rawData,
unitedscoUsername: credentials.username,
unitedscoPassword: credentials.password,
};
const socketId: string | undefined = req.body.socketId;
const agentResp =
await forwardToSeleniumUnitedSCOEligibilityAgent(enrichedData);
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
unitedscoJobs[sessionId] = {
userId: req.user.id,
insuranceEligibilityData: enrichedData,
socketId,
};
// start polling in background to notify client via socket and process job
pollAgentSessionAndProcess(sessionId, socketId).catch((e) =>
console.warn("pollAgentSessionAndProcess failed", e)
);
// reply immediately with started status
return res.json({ status: "started", session_id: sessionId });
} catch (err: any) {
console.error(err);
return res.status(500).json({
error: err.message || "Failed to start United SCO selenium agent",
});
}
}
);
/**
* POST /selenium/submit-otp
* Body: { session_id, otp, socketId? }
* Forwards OTP to Python agent and optionally notifies client socket.
*/
router.post(
"/selenium/submit-otp",
async (req: Request, res: Response): Promise<any> => {
const { session_id: sessionId, otp, socketId } = req.body;
if (!sessionId || !otp) {
return res.status(400).json({ error: "session_id and otp are required" });
}
try {
const r = await forwardOtpToSeleniumUnitedSCOAgent(sessionId, otp);
// emit OTP accepted (if socket present)
emitSafe(socketId, "selenium:otp_submitted", {
session_id: sessionId,
result: r,
});
return res.json(r);
} catch (err: any) {
console.error(
"Failed to forward OTP:",
err?.response?.data || err?.message || err
);
return res.status(500).json({
error: "Failed to forward otp to selenium agent",
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;

View File

@@ -0,0 +1,121 @@
import axios from "axios";
import http from "http";
import https from "https";
import dotenv from "dotenv";
dotenv.config();
export interface SeleniumPayload {
data: any;
url?: string;
}
const SELENIUM_AGENT_BASE = process.env.SELENIUM_AGENT_BASE_URL;
const httpAgent = new http.Agent({ keepAlive: true, keepAliveMsecs: 60_000 });
const httpsAgent = new https.Agent({ keepAlive: true, keepAliveMsecs: 60_000 });
const client = axios.create({
baseURL: SELENIUM_AGENT_BASE,
timeout: 5 * 60 * 1000,
httpAgent,
httpsAgent,
validateStatus: (s) => s >= 200 && s < 600,
});
async function requestWithRetries(
config: any,
retries = 4,
baseBackoffMs = 300
) {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const r = await client.request(config);
if (![502, 503, 504].includes(r.status)) return r;
console.warn(
`[selenium-deltains-client] retryable HTTP status ${r.status} (attempt ${attempt})`
);
} catch (err: any) {
const code = err?.code;
const isTransient =
code === "ECONNRESET" ||
code === "ECONNREFUSED" ||
code === "EPIPE" ||
code === "ETIMEDOUT";
if (!isTransient) throw err;
console.warn(
`[selenium-deltains-client] transient network error ${code} (attempt ${attempt})`
);
}
await new Promise((r) => setTimeout(r, baseBackoffMs * attempt));
}
return client.request(config);
}
function now() {
return new Date().toISOString();
}
function log(tag: string, msg: string, ctx?: any) {
console.log(`${now()} [${tag}] ${msg}`, ctx ?? "");
}
export async function forwardToSeleniumDeltaInsEligibilityAgent(
insuranceEligibilityData: any
): Promise<any> {
const payload = { data: insuranceEligibilityData };
const url = `/deltains-eligibility`;
log("selenium-deltains-client", "POST deltains-eligibility", {
url: SELENIUM_AGENT_BASE + url,
keys: Object.keys(payload),
});
const r = await requestWithRetries({ url, method: "POST", data: payload }, 4);
log("selenium-deltains-client", "agent response", {
status: r.status,
dataKeys: r.data ? Object.keys(r.data) : null,
});
if (r.status >= 500)
throw new Error(`Selenium agent server error: ${r.status}`);
return r.data;
}
export async function forwardOtpToSeleniumDeltaInsAgent(
sessionId: string,
otp: string
): Promise<any> {
const url = `/deltains-submit-otp`;
log("selenium-deltains-client", "POST deltains-submit-otp", {
url: SELENIUM_AGENT_BASE + url,
sessionId,
});
const r = await requestWithRetries(
{ url, method: "POST", data: { session_id: sessionId, otp } },
4
);
log("selenium-deltains-client", "submit-otp response", {
status: r.status,
data: r.data,
});
if (r.status >= 500)
throw new Error(`Selenium agent server error on submit-otp: ${r.status}`);
return r.data;
}
export async function getSeleniumDeltaInsSessionStatus(
sessionId: string
): Promise<any> {
const url = `/deltains-session/${sessionId}/status`;
log("selenium-deltains-client", "GET session status", {
url: SELENIUM_AGENT_BASE + url,
sessionId,
});
const r = await requestWithRetries({ url, method: "GET" }, 4);
log("selenium-deltains-client", "session status response", {
status: r.status,
dataKeys: r.data ? Object.keys(r.data) : null,
});
if (r.status === 404) {
const e: any = new Error("not_found");
e.response = { status: 404, data: r.data };
throw e;
}
return r.data;
}

View File

@@ -0,0 +1,123 @@
import axios from "axios";
import http from "http";
import https from "https";
import dotenv from "dotenv";
dotenv.config();
export interface SeleniumPayload {
data: any;
url?: string;
}
const SELENIUM_AGENT_BASE = process.env.SELENIUM_AGENT_BASE_URL;
const httpAgent = new http.Agent({ keepAlive: true, keepAliveMsecs: 60_000 });
const httpsAgent = new https.Agent({ keepAlive: true, keepAliveMsecs: 60_000 });
const client = axios.create({
baseURL: SELENIUM_AGENT_BASE,
timeout: 5 * 60 * 1000,
httpAgent,
httpsAgent,
validateStatus: (s) => s >= 200 && s < 600,
});
async function requestWithRetries(
config: any,
retries = 4,
baseBackoffMs = 300
) {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const r = await client.request(config);
if (![502, 503, 504].includes(r.status)) return r;
console.warn(
`[selenium-dentaquest-client] retryable HTTP status ${r.status} (attempt ${attempt})`
);
} catch (err: any) {
const code = err?.code;
const isTransient =
code === "ECONNRESET" ||
code === "ECONNREFUSED" ||
code === "EPIPE" ||
code === "ETIMEDOUT";
if (!isTransient) throw err;
console.warn(
`[selenium-dentaquest-client] transient network error ${code} (attempt ${attempt})`
);
}
await new Promise((r) => setTimeout(r, baseBackoffMs * attempt));
}
// final attempt (let exception bubble if it fails)
return client.request(config);
}
function now() {
return new Date().toISOString();
}
function log(tag: string, msg: string, ctx?: any) {
console.log(`${now()} [${tag}] ${msg}`, ctx ?? "");
}
export async function forwardToSeleniumDentaQuestEligibilityAgent(
insuranceEligibilityData: any
): Promise<any> {
const payload = { data: insuranceEligibilityData };
const url = `/dentaquest-eligibility`;
log("selenium-dentaquest-client", "POST dentaquest-eligibility", {
url: SELENIUM_AGENT_BASE + url,
keys: Object.keys(payload),
});
const r = await requestWithRetries({ url, method: "POST", data: payload }, 4);
log("selenium-dentaquest-client", "agent response", {
status: r.status,
dataKeys: r.data ? Object.keys(r.data) : null,
});
if (r.status >= 500)
throw new Error(`Selenium agent server error: ${r.status}`);
return r.data;
}
export async function forwardOtpToSeleniumDentaQuestAgent(
sessionId: string,
otp: string
): Promise<any> {
const url = `/dentaquest-submit-otp`;
log("selenium-dentaquest-client", "POST dentaquest-submit-otp", {
url: SELENIUM_AGENT_BASE + url,
sessionId,
});
const r = await requestWithRetries(
{ url, method: "POST", data: { session_id: sessionId, otp } },
4
);
log("selenium-dentaquest-client", "submit-otp response", {
status: r.status,
data: r.data,
});
if (r.status >= 500)
throw new Error(`Selenium agent server error on submit-otp: ${r.status}`);
return r.data;
}
export async function getSeleniumDentaQuestSessionStatus(
sessionId: string
): Promise<any> {
const url = `/dentaquest-session/${sessionId}/status`;
log("selenium-dentaquest-client", "GET session status", {
url: SELENIUM_AGENT_BASE + url,
sessionId,
});
const r = await requestWithRetries({ url, method: "GET" }, 4);
log("selenium-dentaquest-client", "session status response", {
status: r.status,
dataKeys: r.data ? Object.keys(r.data) : null,
});
if (r.status === 404) {
const e: any = new Error("not_found");
e.response = { status: 404, data: r.data };
throw e;
}
return r.data;
}

View File

@@ -0,0 +1,122 @@
import axios from "axios";
import http from "http";
import https from "https";
import dotenv from "dotenv";
dotenv.config();
export interface SeleniumPayload {
data: any;
url?: string;
}
const SELENIUM_AGENT_BASE = process.env.SELENIUM_AGENT_BASE_URL;
const httpAgent = new http.Agent({ keepAlive: true, keepAliveMsecs: 60_000 });
const httpsAgent = new https.Agent({ keepAlive: true, keepAliveMsecs: 60_000 });
const client = axios.create({
baseURL: SELENIUM_AGENT_BASE,
timeout: 5 * 60 * 1000,
httpAgent,
httpsAgent,
validateStatus: (s) => s >= 200 && s < 600,
});
async function requestWithRetries(
config: any,
retries = 4,
baseBackoffMs = 300
) {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const r = await client.request(config);
if (![502, 503, 504].includes(r.status)) return r;
console.warn(
`[selenium-unitedsco-client] retryable HTTP status ${r.status} (attempt ${attempt})`
);
} catch (err: any) {
const code = err?.code;
const isTransient =
code === "ECONNRESET" ||
code === "ECONNREFUSED" ||
code === "EPIPE" ||
code === "ETIMEDOUT";
if (!isTransient) throw err;
console.warn(
`[selenium-unitedsco-client] transient network error ${code} (attempt ${attempt})`
);
}
await new Promise((r) => setTimeout(r, baseBackoffMs * attempt));
}
// final attempt (let exception bubble if it fails)
return client.request(config);
}
function now() {
return new Date().toISOString();
}
function log(tag: string, msg: string, ctx?: any) {
console.log(`${now()} [${tag}] ${msg}`, ctx ?? "");
}
export async function forwardToSeleniumUnitedSCOEligibilityAgent(
insuranceEligibilityData: any
): Promise<any> {
const payload = { data: insuranceEligibilityData };
const url = `/unitedsco-eligibility`;
log("selenium-unitedsco-client", "POST unitedsco-eligibility", {
url: SELENIUM_AGENT_BASE + url,
keys: Object.keys(payload),
});
const r = await requestWithRetries({ url, method: "POST", data: payload }, 4);
log("selenium-unitedsco-client", "agent response", {
status: r.status,
dataKeys: r.data ? Object.keys(r.data) : null,
});
if (r.status >= 500)
throw new Error(`Selenium agent server error: ${r.status}`);
return r.data;
}
export async function forwardOtpToSeleniumUnitedSCOAgent(
sessionId: string,
otp: string
): Promise<any> {
const url = `/unitedsco-submit-otp`;
log("selenium-unitedsco-client", "POST unitedsco-submit-otp", {
url: SELENIUM_AGENT_BASE + url,
sessionId,
});
const r = await requestWithRetries(
{ url, method: "POST", data: { session_id: sessionId, otp } },
4
);
log("selenium-unitedsco-client", "submit-otp response", {
status: r.status,
data: r.data,
});
if (r.status >= 500)
throw new Error(`Selenium agent server error on submit-otp: ${r.status}`);
return r.data;
}
export async function getSeleniumUnitedSCOSessionStatus(
sessionId: string
): Promise<any> {
const url = `/unitedsco-session/${sessionId}/status`;
log("selenium-unitedsco-client", "GET session status", {
url: SELENIUM_AGENT_BASE + url,
sessionId,
});
const r = await requestWithRetries({ url, method: "GET" }, 4);
log("selenium-unitedsco-client", "session status response", {
status: r.status,
dataKeys: r.data ? Object.keys(r.data) : null,
});
if (r.status === 404) {
const e: any = new Error("not_found");
e.response = { status: 404, data: r.data };
throw e;
}
return r.data;
}

View File

@@ -1,4 +1,5 @@
NODE_ENV=development
HOST=0.0.0.0
PORT=3000
VITE_API_BASE_URL_BACKEND=http://localhost:5000
# VITE_API_BASE_URL_BACKEND=http://localhost:5000
VITE_API_BASE_URL_BACKEND=

View File

@@ -119,6 +119,10 @@ export function DdmaEligibilityButton({
const { toast } = useToast();
const dispatch = useAppDispatch();
// Flexible validation: require DOB + at least one identifier (memberId OR firstName OR lastName)
const isDdmaFormIncomplete =
!dateOfBirth || (!memberId && !firstName && !lastName);
const socketRef = useRef<Socket | null>(null);
const connectingRef = useRef<Promise<void> | null>(null);
@@ -371,10 +375,11 @@ export function DdmaEligibilityButton({
};
const startDdmaEligibility = async () => {
if (!memberId || !dateOfBirth) {
// Flexible validation: require DOB + at least one identifier
if (!dateOfBirth || (!memberId && !firstName && !lastName)) {
toast({
title: "Missing fields",
description: "Member ID and Date of Birth are required.",
description: "Date of Birth and at least one identifier (Member ID, First Name, or Last Name) are required.",
variant: "destructive",
});
return;
@@ -539,7 +544,7 @@ export function DdmaEligibilityButton({
<Button
className="w-full"
variant="default"
disabled={isFormIncomplete || isStarting}
disabled={isDdmaFormIncomplete || isStarting}
onClick={startDdmaEligibility}
>
{isStarting ? (

View File

@@ -0,0 +1,551 @@
import { useEffect, useRef, useState } from "react";
import { io as ioClient, Socket } from "socket.io-client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { CheckCircle, LoaderCircleIcon, X } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useAppDispatch } from "@/redux/hooks";
import { setTaskStatus } from "@/redux/slices/seleniumEligibilityCheckTaskSlice";
import { formatLocalDate } from "@/utils/dateUtils";
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
const SOCKET_URL =
import.meta.env.VITE_API_BASE_URL_BACKEND ||
(typeof window !== "undefined" ? window.location.origin : "");
// ---------- OTP Modal component ----------
interface DeltaInsOtpModalProps {
open: boolean;
onClose: () => void;
onSubmit: (otp: string) => Promise<void> | void;
isSubmitting: boolean;
}
function DeltaInsOtpModal({
open,
onClose,
onSubmit,
isSubmitting,
}: DeltaInsOtpModalProps) {
const [otp, setOtp] = useState("");
useEffect(() => {
if (!open) setOtp("");
}, [open]);
if (!open) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!otp.trim()) return;
await onSubmit(otp.trim());
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white dark:bg-slate-900 rounded-xl shadow-lg w-full max-w-md p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Enter OTP</h2>
<button
type="button"
onClick={onClose}
className="text-slate-500 hover:text-slate-800"
>
<X className="w-4 h-4" />
</button>
</div>
<p className="text-sm text-slate-500 mb-4">
We need the one-time password (OTP) sent to your email by Delta Dental
Ins to complete this eligibility check.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="deltains-otp">OTP</Label>
<Input
id="deltains-otp"
placeholder="Enter OTP code"
value={otp}
onChange={(e) => setOtp(e.target.value)}
autoFocus
/>
</div>
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || !otp.trim()}>
{isSubmitting ? (
<>
<LoaderCircleIcon className="w-4 h-4 mr-2 animate-spin" />
Submitting...
</>
) : (
"Submit OTP"
)}
</Button>
</div>
</form>
</div>
</div>
);
}
// ---------- Main DeltaIns Eligibility button component ----------
interface DeltaInsEligibilityButtonProps {
memberId: string;
dateOfBirth: Date | null;
firstName?: string;
lastName?: string;
isFormIncomplete: boolean;
onPdfReady: (pdfId: number, fallbackFilename: string | null) => void;
}
export function DeltaInsEligibilityButton({
memberId,
dateOfBirth,
firstName,
lastName,
isFormIncomplete,
onPdfReady,
}: DeltaInsEligibilityButtonProps) {
const { toast } = useToast();
const dispatch = useAppDispatch();
const isDeltaInsFormIncomplete =
!dateOfBirth || (!memberId && !firstName && !lastName);
const socketRef = useRef<Socket | null>(null);
const connectingRef = useRef<Promise<void> | null>(null);
const [sessionId, setSessionId] = useState<string | null>(null);
const [otpModalOpen, setOtpModalOpen] = useState(false);
const [isStarting, setIsStarting] = useState(false);
const [isSubmittingOtp, setIsSubmittingOtp] = useState(false);
useEffect(() => {
return () => {
if (socketRef.current) {
socketRef.current.removeAllListeners();
socketRef.current.disconnect();
socketRef.current = null;
}
connectingRef.current = null;
};
}, []);
const closeSocket = () => {
try {
socketRef.current?.removeAllListeners();
socketRef.current?.disconnect();
} catch (e) {
// ignore
} finally {
socketRef.current = null;
}
};
const ensureSocketConnected = async () => {
if (socketRef.current && socketRef.current.connected) {
return;
}
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", () => {
resolve();
});
socket.on("connect_error", (err: any) => {
dispatch(
setTaskStatus({
status: "error",
message: "Connection failed",
})
);
toast({
title: "Realtime connection failed",
description:
"Could not connect to realtime server. Retrying automatically...",
variant: "destructive",
});
});
socket.on("reconnect_attempt", (attempt: number) => {
dispatch(
setTaskStatus({
status: "pending",
message: `Realtime reconnect attempt #${attempt}`,
})
);
});
socket.on("reconnect_failed", () => {
dispatch(
setTaskStatus({
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",
});
closeSocket();
reject(new Error("Realtime reconnect failed"));
});
socket.on("disconnect", (reason: any) => {
dispatch(
setTaskStatus({
status: "error",
message: "Connection disconnected",
})
);
toast({
title: "Connection Disconnected",
description:
"Connection to the server was lost. If a DeltaIns job was running it may have failed.",
variant: "destructive",
});
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({
status: "pending",
message: "OTP required for Delta Dental Ins eligibility. Please enter the code sent to your email.",
})
);
});
// OTP submitted
socket.on("selenium:otp_submitted", (payload: any) => {
if (!payload?.session_id) return;
dispatch(
setTaskStatus({
status: "pending",
message: "OTP submitted. Finishing Delta Dental Ins 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({
status: "success",
message:
"Delta Dental Ins eligibility updated and PDF attached to patient documents.",
})
);
toast({
title: "Delta Dental Ins 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_deltains_${memberId}.pdf`;
onPdfReady(Number(pdfId), filename);
}
setSessionId(null);
setOtpModalOpen(false);
} else if (status === "error") {
const msg =
payload?.message ||
final?.error ||
"Delta Dental Ins eligibility session failed.";
dispatch(
setTaskStatus({
status: "error",
message: msg,
})
);
toast({
title: "Delta Dental Ins selenium error",
description: msg,
variant: "destructive",
});
try {
closeSocket();
} catch (e) {}
setSessionId(null);
setOtpModalOpen(false);
}
queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
});
// explicit session error event
socket.on("selenium:session_error", (payload: any) => {
const msg = payload?.message || "Selenium session error";
dispatch(
setTaskStatus({
status: "error",
message: msg,
})
);
toast({
title: "Selenium session error",
description: msg,
variant: "destructive",
});
try {
closeSocket();
} catch (e) {}
setSessionId(null);
setOtpModalOpen(false);
});
const initialConnectTimeout = setTimeout(() => {
if (!socket.connected) {
closeSocket();
reject(new Error("Realtime initial connection timeout"));
}
}, 8000);
socket.once("connect", () => {
clearTimeout(initialConnectTimeout);
});
});
connectingRef.current = promise;
try {
await promise;
} finally {
connectingRef.current = null;
}
};
const startDeltaInsEligibility = async () => {
if (!dateOfBirth) {
toast({
title: "Missing fields",
description: "Date of Birth is required for Delta Dental Ins eligibility.",
variant: "destructive",
});
return;
}
if (!memberId && !firstName && !lastName) {
toast({
title: "Missing fields",
description: "Member ID, First Name, or Last Name is required for Delta Dental Ins eligibility.",
variant: "destructive",
});
return;
}
const formattedDob = dateOfBirth ? formatLocalDate(dateOfBirth) : "";
const payload = {
memberId: memberId || "",
dateOfBirth: formattedDob,
firstName: firstName || "",
lastName: lastName || "",
insuranceSiteKey: "DELTAINS",
};
try {
setIsStarting(true);
dispatch(
setTaskStatus({
status: "pending",
message: "Opening realtime channel for Delta Dental Ins eligibility...",
})
);
await ensureSocketConnected();
const socket = socketRef.current;
if (!socket || !socket.connected) {
throw new Error("Socket connection failed");
}
const socketId = socket.id;
dispatch(
setTaskStatus({
status: "pending",
message: "Starting Delta Dental Ins eligibility check via selenium...",
})
);
const response = await apiRequest(
"POST",
"/api/insurance-status-deltains/deltains-eligibility",
{
data: JSON.stringify(payload),
socketId,
}
);
let result: any = null;
let backendError: string | null = null;
try {
result = await response.clone().json();
backendError =
result?.error || result?.message || result?.detail || null;
} catch {
try {
const text = await response.clone().text();
backendError = text?.trim() || null;
} catch {
backendError = null;
}
}
if (!response.ok) {
throw new Error(
backendError ||
`Delta Dental Ins selenium start failed (status ${response.status})`
);
}
if (result?.error) {
throw new Error(result.error);
}
if (result.status === "started" && result.session_id) {
setSessionId(result.session_id as string);
dispatch(
setTaskStatus({
status: "pending",
message:
"Delta Dental Ins eligibility job started. Waiting for OTP or final result...",
})
);
} else {
dispatch(
setTaskStatus({
status: "success",
message: "Delta Dental Ins eligibility completed.",
})
);
}
} catch (err: any) {
console.error("startDeltaInsEligibility error:", err);
dispatch(
setTaskStatus({
status: "error",
message: err?.message || "Failed to start Delta Dental Ins eligibility",
})
);
toast({
title: "Delta Dental Ins selenium error",
description: err?.message || "Failed to start Delta Dental Ins eligibility",
variant: "destructive",
});
} finally {
setIsStarting(false);
}
};
const handleSubmitOtp = async (otp: string) => {
if (!sessionId || !socketRef.current || !socketRef.current.connected) {
toast({
title: "Session not ready",
description:
"Could not submit OTP because the DeltaIns session or socket is not ready.",
variant: "destructive",
});
return;
}
try {
setIsSubmittingOtp(true);
const resp = await apiRequest(
"POST",
"/api/insurance-status-deltains/selenium/submit-otp",
{
session_id: sessionId,
otp,
socketId: socketRef.current.id,
}
);
const data = await resp.json();
if (!resp.ok || data.error) {
throw new Error(data.error || "Failed to submit OTP");
}
setOtpModalOpen(false);
} catch (err: any) {
console.error("handleSubmitOtp error:", err);
toast({
title: "Failed to submit OTP",
description: err?.message || "Error forwarding OTP to selenium agent",
variant: "destructive",
});
} finally {
setIsSubmittingOtp(false);
}
};
return (
<>
<Button
className="w-full"
disabled={isDeltaInsFormIncomplete || isStarting}
onClick={startDeltaInsEligibility}
>
{isStarting ? (
<>
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin" />
Processing...
</>
) : (
<>
<CheckCircle className="h-4 w-4 mr-2" />
Delta Dental Ins
</>
)}
</Button>
<DeltaInsOtpModal
open={otpModalOpen}
onClose={() => setOtpModalOpen(false)}
onSubmit={handleSubmitOtp}
isSubmitting={isSubmittingOtp}
/>
</>
);
}

View File

@@ -0,0 +1,574 @@
import { useEffect, useRef, useState } from "react";
import { io as ioClient, Socket } from "socket.io-client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { CheckCircle, LoaderCircleIcon, X } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useAppDispatch } from "@/redux/hooks";
import { setTaskStatus } from "@/redux/slices/seleniumEligibilityCheckTaskSlice";
import { formatLocalDate } from "@/utils/dateUtils";
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
const SOCKET_URL =
import.meta.env.VITE_API_BASE_URL_BACKEND ||
(typeof window !== "undefined" ? window.location.origin : "");
// ---------- OTP Modal component ----------
interface DentaQuestOtpModalProps {
open: boolean;
onClose: () => void;
onSubmit: (otp: string) => Promise<void> | void;
isSubmitting: boolean;
}
function DentaQuestOtpModal({
open,
onClose,
onSubmit,
isSubmitting,
}: DentaQuestOtpModalProps) {
const [otp, setOtp] = useState("");
useEffect(() => {
if (!open) setOtp("");
}, [open]);
if (!open) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!otp.trim()) return;
await onSubmit(otp.trim());
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white dark:bg-slate-900 rounded-xl shadow-lg w-full max-w-md p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Enter OTP</h2>
<button
type="button"
onClick={onClose}
className="text-slate-500 hover:text-slate-800"
>
<X className="w-4 h-4" />
</button>
</div>
<p className="text-sm text-slate-500 mb-4">
We need the one-time password (OTP) sent by the DentaQuest portal
to complete this eligibility check.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="dentaquest-otp">OTP</Label>
<Input
id="dentaquest-otp"
placeholder="Enter OTP code"
value={otp}
onChange={(e) => setOtp(e.target.value)}
autoFocus
/>
</div>
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || !otp.trim()}>
{isSubmitting ? (
<>
<LoaderCircleIcon className="w-4 h-4 mr-2 animate-spin" />
Submitting...
</>
) : (
"Submit OTP"
)}
</Button>
</div>
</form>
</div>
</div>
);
}
// ---------- Main DentaQuest Eligibility button component ----------
interface DentaQuestEligibilityButtonProps {
memberId: string;
dateOfBirth: Date | null;
firstName?: string;
lastName?: string;
isFormIncomplete: boolean;
/** Called when backend has finished and PDF is ready */
onPdfReady: (pdfId: number, fallbackFilename: string | null) => void;
}
export function DentaQuestEligibilityButton({
memberId,
dateOfBirth,
firstName,
lastName,
isFormIncomplete,
onPdfReady,
}: DentaQuestEligibilityButtonProps) {
const { toast } = useToast();
const dispatch = useAppDispatch();
const socketRef = useRef<Socket | null>(null);
const connectingRef = useRef<Promise<void> | null>(null);
const [sessionId, setSessionId] = useState<string | null>(null);
const [otpModalOpen, setOtpModalOpen] = useState(false);
const [isStarting, setIsStarting] = useState(false);
const [isSubmittingOtp, setIsSubmittingOtp] = useState(false);
// DentaQuest allows flexible search - only DOB is required, plus at least one identifier
// Can use: memberId, firstName, lastName, or any combination
const hasAnyIdentifier = memberId || firstName || lastName;
const isDentaQuestFormIncomplete = !dateOfBirth || !hasAnyIdentifier;
// Clean up socket on unmount
useEffect(() => {
return () => {
if (socketRef.current) {
socketRef.current.removeAllListeners();
socketRef.current.disconnect();
socketRef.current = null;
}
connectingRef.current = null;
};
}, []);
const closeSocket = () => {
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", () => {
resolve();
});
// connection error when first connecting (or later)
socket.on("connect_error", (err: any) => {
dispatch(
setTaskStatus({
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({
status: "pending",
message: `Realtime reconnect attempt #${attempt}`,
})
);
});
// when reconnection failed after configured attempts
socket.on("reconnect_failed", () => {
dispatch(
setTaskStatus({
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({
status: "error",
message: "Connection disconnected",
})
);
toast({
title: "Connection Disconnected",
description:
"Connection to the server was lost. If a DentaQuest 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({
status: "pending",
message: "OTP required for DentaQuest eligibility. Please enter the OTP.",
})
);
});
// OTP submitted (optional UX)
socket.on("selenium:otp_submitted", (payload: any) => {
if (!payload?.session_id) return;
dispatch(
setTaskStatus({
status: "pending",
message: "OTP submitted. Finishing DentaQuest 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({
status: "success",
message:
"DentaQuest eligibility updated and PDF attached to patient documents.",
})
);
toast({
title: "DentaQuest 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_dentaquest_${memberId}.pdf`;
onPdfReady(Number(pdfId), filename);
}
setSessionId(null);
setOtpModalOpen(false);
} else if (status === "error") {
const msg =
payload?.message ||
final?.error ||
"DentaQuest eligibility session failed.";
dispatch(
setTaskStatus({
status: "error",
message: msg,
})
);
toast({
title: "DentaQuest 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({
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 startDentaQuestEligibility = async () => {
// Flexible search - DOB required plus at least one identifier
const hasAnyIdentifier = memberId || firstName || lastName;
if (!dateOfBirth || !hasAnyIdentifier) {
toast({
title: "Missing fields",
description: "Please provide Date of Birth and at least one of: Member ID, First Name, or Last Name.",
variant: "destructive",
});
return;
}
const formattedDob = dateOfBirth ? formatLocalDate(dateOfBirth) : "";
const payload = {
memberId,
dateOfBirth: formattedDob,
firstName,
lastName,
insuranceSiteKey: "DENTAQUEST", // make sure this matches backend credential key
};
try {
setIsStarting(true);
// 1) Ensure socket is connected (lazy)
dispatch(
setTaskStatus({
status: "pending",
message: "Opening realtime channel for DentaQuest eligibility...",
})
);
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({
status: "pending",
message: "Starting DentaQuest eligibility check via selenium...",
})
);
const response = await apiRequest(
"POST",
"/api/insurance-status-dentaquest/dentaquest-eligibility",
{
data: JSON.stringify(payload),
socketId,
}
);
// If apiRequest threw, we would have caught above; but just in case it returns.
let result: any = null;
let backendError: string | null = null;
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) {
throw new Error(
backendError ||
`DentaQuest selenium start failed (status ${response.status})`
);
}
// Normal success path: optional: if backend returns non-error shape still check for result.error
if (result?.error) {
throw new Error(result.error);
}
if (result.status === "started" && result.session_id) {
setSessionId(result.session_id as string);
dispatch(
setTaskStatus({
status: "pending",
message:
"DentaQuest eligibility job started. Waiting for OTP or final result...",
})
);
} else {
// fallback if backend returns immediate result
dispatch(
setTaskStatus({
status: "success",
message: "DentaQuest eligibility completed.",
})
);
}
} catch (err: any) {
console.error("startDentaQuestEligibility error:", err);
dispatch(
setTaskStatus({
status: "error",
message: err?.message || "Failed to start DentaQuest eligibility",
})
);
toast({
title: "DentaQuest selenium error",
description: err?.message || "Failed to start DentaQuest eligibility",
variant: "destructive",
});
} finally {
setIsStarting(false);
}
};
const handleSubmitOtp = async (otp: string) => {
if (!sessionId || !socketRef.current || !socketRef.current.connected) {
toast({
title: "Session not ready",
description:
"Could not submit OTP because the DentaQuest session or socket is not ready.",
variant: "destructive",
});
return;
}
try {
setIsSubmittingOtp(true);
const resp = await apiRequest(
"POST",
"/api/insurance-status-dentaquest/selenium/submit-otp",
{
session_id: sessionId,
otp,
socketId: socketRef.current.id,
}
);
const data = await resp.json();
if (!resp.ok || data.error) {
throw new Error(data.error || "Failed to submit OTP");
}
// from here we rely on websocket events (otp_submitted + session_update)
setOtpModalOpen(false);
} catch (err: any) {
console.error("handleSubmitOtp error:", err);
toast({
title: "Failed to submit OTP",
description: err?.message || "Error forwarding OTP to selenium agent",
variant: "destructive",
});
} finally {
setIsSubmittingOtp(false);
}
};
return (
<>
<Button
className="w-full"
disabled={isDentaQuestFormIncomplete || isStarting}
onClick={startDentaQuestEligibility}
>
{isStarting ? (
<>
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin" />
Processing...
</>
) : (
<>
<CheckCircle className="h-4 w-4 mr-2" />
Tufts SCO/SWH/Navi/Mass Gen
</>
)}
</Button>
<DentaQuestOtpModal
open={otpModalOpen}
onClose={() => setOtpModalOpen(false)}
onSubmit={handleSubmitOtp}
isSubmitting={isSubmittingOtp}
/>
</>
);
}

View File

@@ -0,0 +1,579 @@
import { useEffect, useRef, useState } from "react";
import { io as ioClient, Socket } from "socket.io-client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { CheckCircle, LoaderCircleIcon, X } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useAppDispatch } from "@/redux/hooks";
import { setTaskStatus } from "@/redux/slices/seleniumEligibilityCheckTaskSlice";
import { formatLocalDate } from "@/utils/dateUtils";
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
const SOCKET_URL =
import.meta.env.VITE_API_BASE_URL_BACKEND ||
(typeof window !== "undefined" ? window.location.origin : "");
// ---------- OTP Modal component ----------
interface UnitedSCOOtpModalProps {
open: boolean;
onClose: () => void;
onSubmit: (otp: string) => Promise<void> | void;
isSubmitting: boolean;
}
function UnitedSCOOtpModal({
open,
onClose,
onSubmit,
isSubmitting,
}: UnitedSCOOtpModalProps) {
const [otp, setOtp] = useState("");
useEffect(() => {
if (!open) setOtp("");
}, [open]);
if (!open) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!otp.trim()) return;
await onSubmit(otp.trim());
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white dark:bg-slate-900 rounded-xl shadow-lg w-full max-w-md p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Enter OTP</h2>
<button
type="button"
onClick={onClose}
className="text-slate-500 hover:text-slate-800"
>
<X className="w-4 h-4" />
</button>
</div>
<p className="text-sm text-slate-500 mb-4">
We need the one-time password (OTP) sent by the United SCO portal
to complete this eligibility check.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="unitedsco-otp">OTP</Label>
<Input
id="unitedsco-otp"
placeholder="Enter OTP code"
value={otp}
onChange={(e) => setOtp(e.target.value)}
autoFocus
/>
</div>
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || !otp.trim()}>
{isSubmitting ? (
<>
<LoaderCircleIcon className="w-4 h-4 mr-2 animate-spin" />
Submitting...
</>
) : (
"Submit OTP"
)}
</Button>
</div>
</form>
</div>
</div>
);
}
// ---------- Main United SCO Eligibility button component ----------
interface UnitedSCOEligibilityButtonProps {
memberId: string;
dateOfBirth: Date | null;
firstName?: string;
lastName?: string;
isFormIncomplete: boolean;
/** Called when backend has finished and PDF is ready */
onPdfReady: (pdfId: number, fallbackFilename: string | null) => void;
}
export function UnitedSCOEligibilityButton({
memberId,
dateOfBirth,
firstName,
lastName,
isFormIncomplete,
onPdfReady,
}: UnitedSCOEligibilityButtonProps) {
const { toast } = useToast();
const dispatch = useAppDispatch();
// Flexible validation: require DOB + at least one identifier (memberId OR firstName OR lastName)
const isUnitedSCOFormIncomplete =
!dateOfBirth || (!memberId && !firstName && !lastName);
const socketRef = useRef<Socket | null>(null);
const connectingRef = useRef<Promise<void> | null>(null);
const [sessionId, setSessionId] = useState<string | null>(null);
const [otpModalOpen, setOtpModalOpen] = useState(false);
const [isStarting, setIsStarting] = useState(false);
const [isSubmittingOtp, setIsSubmittingOtp] = useState(false);
// Clean up socket on unmount
useEffect(() => {
return () => {
if (socketRef.current) {
socketRef.current.removeAllListeners();
socketRef.current.disconnect();
socketRef.current = null;
}
connectingRef.current = null;
};
}, []);
const closeSocket = () => {
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", () => {
resolve();
});
// connection error when first connecting (or later)
socket.on("connect_error", (err: any) => {
dispatch(
setTaskStatus({
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({
status: "pending",
message: `Realtime reconnect attempt #${attempt}`,
})
);
});
// when reconnection failed after configured attempts
socket.on("reconnect_failed", () => {
dispatch(
setTaskStatus({
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({
status: "error",
message: "Connection disconnected",
})
);
toast({
title: "Connection Disconnected",
description:
"Connection to the server was lost. If a United SCO 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({
status: "pending",
message: "OTP required for United SCO eligibility. Please enter the OTP.",
})
);
});
// OTP submitted (optional UX)
socket.on("selenium:otp_submitted", (payload: any) => {
if (!payload?.session_id) return;
dispatch(
setTaskStatus({
status: "pending",
message: "OTP submitted. Finishing United SCO 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({
status: "success",
message:
"United SCO eligibility updated and PDF attached to patient documents.",
})
);
toast({
title: "United SCO 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_unitedsco_${memberId}.pdf`;
onPdfReady(Number(pdfId), filename);
}
setSessionId(null);
setOtpModalOpen(false);
} else if (status === "error") {
const msg =
payload?.message ||
final?.error ||
"United SCO eligibility session failed.";
dispatch(
setTaskStatus({
status: "error",
message: msg,
})
);
toast({
title: "United SCO 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({
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 startUnitedSCOEligibility = async () => {
// Flexible: require DOB + at least one identifier (memberId OR firstName OR lastName)
if (!dateOfBirth) {
toast({
title: "Missing fields",
description: "Date of Birth is required for United SCO eligibility.",
variant: "destructive",
});
return;
}
if (!memberId && !firstName && !lastName) {
toast({
title: "Missing fields",
description: "Member ID, First Name, or Last Name is required for United SCO eligibility.",
variant: "destructive",
});
return;
}
const formattedDob = dateOfBirth ? formatLocalDate(dateOfBirth) : "";
const payload = {
memberId: memberId || "",
dateOfBirth: formattedDob,
firstName: firstName || "",
lastName: lastName || "",
insuranceSiteKey: "UNITEDSCO",
};
try {
setIsStarting(true);
// 1) Ensure socket is connected (lazy)
dispatch(
setTaskStatus({
status: "pending",
message: "Opening realtime channel for United SCO eligibility...",
})
);
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({
status: "pending",
message: "Starting United SCO eligibility check via selenium...",
})
);
const response = await apiRequest(
"POST",
"/api/insurance-status-unitedsco/unitedsco-eligibility",
{
data: JSON.stringify(payload),
socketId,
}
);
// If apiRequest threw, we would have caught above; but just in case it returns.
let result: any = null;
let backendError: string | null = null;
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) {
throw new Error(
backendError ||
`United SCO selenium start failed (status ${response.status})`
);
}
// Normal success path: optional: if backend returns non-error shape still check for result.error
if (result?.error) {
throw new Error(result.error);
}
if (result.status === "started" && result.session_id) {
setSessionId(result.session_id as string);
dispatch(
setTaskStatus({
status: "pending",
message:
"United SCO eligibility job started. Waiting for OTP or final result...",
})
);
} else {
// fallback if backend returns immediate result
dispatch(
setTaskStatus({
status: "success",
message: "United SCO eligibility completed.",
})
);
}
} catch (err: any) {
console.error("startUnitedSCOEligibility error:", err);
dispatch(
setTaskStatus({
status: "error",
message: err?.message || "Failed to start United SCO eligibility",
})
);
toast({
title: "United SCO selenium error",
description: err?.message || "Failed to start United SCO eligibility",
variant: "destructive",
});
} finally {
setIsStarting(false);
}
};
const handleSubmitOtp = async (otp: string) => {
if (!sessionId || !socketRef.current || !socketRef.current.connected) {
toast({
title: "Session not ready",
description:
"Could not submit OTP because the United SCO session or socket is not ready.",
variant: "destructive",
});
return;
}
try {
setIsSubmittingOtp(true);
const resp = await apiRequest(
"POST",
"/api/insurance-status-unitedsco/selenium/submit-otp",
{
session_id: sessionId,
otp,
socketId: socketRef.current.id,
}
);
const data = await resp.json();
if (!resp.ok || data.error) {
throw new Error(data.error || "Failed to submit OTP");
}
// from here we rely on websocket events (otp_submitted + session_update)
setOtpModalOpen(false);
} catch (err: any) {
console.error("handleSubmitOtp error:", err);
toast({
title: "Failed to submit OTP",
description: err?.message || "Error forwarding OTP to selenium agent",
variant: "destructive",
});
} finally {
setIsSubmittingOtp(false);
}
};
return (
<>
<Button
className="w-full"
disabled={isUnitedSCOFormIncomplete || isStarting}
onClick={startUnitedSCOEligibility}
>
{isStarting ? (
<>
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin" />
Processing...
</>
) : (
<>
<CheckCircle className="h-4 w-4 mr-2" />
United SCO
</>
)}
</Button>
<UnitedSCOOtpModal
open={otpModalOpen}
onClose={() => setOtpModalOpen(false)}
onSubmit={handleSubmitOtp}
isSubmitting={isSubmittingOtp}
/>
</>
);
}

View File

@@ -14,6 +14,15 @@ type CredentialFormProps = {
};
};
// Available site keys - must match exactly what the automation buttons expect
const SITE_KEY_OPTIONS = [
{ value: "MH", label: "MassHealth" },
{ value: "DDMA", label: "Delta Dental MA" },
{ value: "DELTAINS", label: "Delta Dental Ins" },
{ value: "DENTAQUEST", label: "Tufts SCO / DentaQuest" },
{ value: "UNITEDSCO", label: "United SCO" },
];
export function CredentialForm({ onClose, userId, defaultValues }: CredentialFormProps) {
const [siteKey, setSiteKey] = useState(defaultValues?.siteKey || "");
const [username, setUsername] = useState(defaultValues?.username || "");
@@ -91,14 +100,19 @@ export function CredentialForm({ onClose, userId, defaultValues }: CredentialFor
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium">Site Key</label>
<input
type="text"
<label className="block text-sm font-medium">Insurance Provider</label>
<select
value={siteKey}
onChange={(e) => setSiteKey(e.target.value)}
className="mt-1 p-2 border rounded w-full"
placeholder="e.g., MH, Delta MA, (keep the site key exact same)"
/>
className="mt-1 p-2 border rounded w-full bg-white"
>
<option value="">Select a provider...</option>
{SITE_KEY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium">Username</label>

View File

@@ -13,6 +13,19 @@ type Credential = {
password: string;
};
// Map site keys to friendly labels
const SITE_KEY_LABELS: Record<string, string> = {
MH: "MassHealth",
DDMA: "Delta Dental MA",
DELTAINS: "Delta Dental Ins",
DENTAQUEST: "Tufts SCO / DentaQuest",
UNITEDSCO: "United SCO",
};
function getSiteKeyLabel(siteKey: string): string {
return SITE_KEY_LABELS[siteKey] || siteKey;
}
export function CredentialTable() {
const queryClient = useQueryClient();
@@ -108,7 +121,7 @@ export function CredentialTable() {
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Site Key
Provider
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Username
@@ -141,7 +154,7 @@ export function CredentialTable() {
) : (
currentCredentials.map((cred) => (
<tr key={cred.id}>
<td className="px-4 py-2">{cred.siteKey}</td>
<td className="px-4 py-2">{getSiteKeyLabel(cred.siteKey)}</td>
<td className="px-4 py-2">{cred.username}</td>
<td className="px-4 py-2"></td>
<td className="px-4 py-2 text-right">
@@ -227,7 +240,7 @@ export function CredentialTable() {
isOpen={isDeleteDialogOpen}
onConfirm={handleConfirmDelete}
onCancel={handleCancelDelete}
entityName={credentialToDelete?.siteKey}
entityName={credentialToDelete ? getSiteKeyLabel(credentialToDelete.siteKey) : undefined}
/>
</div>
);

View File

@@ -28,6 +28,9 @@ import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
import { PdfPreviewModal } from "@/components/insurance-status/pdf-preview-modal";
import { useLocation } from "wouter";
import { DdmaEligibilityButton } from "@/components/insurance-status/ddma-buton-modal";
import { DentaQuestEligibilityButton } from "@/components/insurance-status/dentaquest-button-modal";
import { UnitedSCOEligibilityButton } from "@/components/insurance-status/unitedsco-button-modal";
import { DeltaInsEligibilityButton } from "@/components/insurance-status/deltains-button-modal";
export default function InsuranceStatusPage() {
const { user } = useAuth();
@@ -575,7 +578,7 @@ export default function InsuranceStatusPage() {
{/* TEMP PROVIDER BUTTONS */}
<div className="space-y-4 mt-6">
<h3 className="text-sm font-medium text-muted-foreground">
Other provider checks
Other Insurances
</h3>
{/* Row 1 */}
@@ -595,14 +598,62 @@ export default function InsuranceStatusPage() {
}}
/>
<DeltaInsEligibilityButton
memberId={memberId}
dateOfBirth={dateOfBirth}
firstName={firstName}
lastName={lastName}
isFormIncomplete={isFormIncomplete}
onPdfReady={(pdfId, fallbackFilename) => {
setPreviewPdfId(pdfId);
setPreviewFallbackFilename(
fallbackFilename ?? `eligibility_deltains_${memberId}.pdf`
);
setPreviewOpen(true);
}}
/>
<Button
className="w-full"
variant="outline"
disabled={isFormIncomplete}
>
<CheckCircle className="h-4 w-4 mr-2" />
Metlife Dental
BCBS
</Button>
</div>
{/* Row 2 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<DentaQuestEligibilityButton
memberId={memberId}
dateOfBirth={dateOfBirth}
firstName={firstName}
lastName={lastName}
isFormIncomplete={isFormIncomplete}
onPdfReady={(pdfId, fallbackFilename) => {
setPreviewPdfId(pdfId);
setPreviewFallbackFilename(
fallbackFilename ?? `eligibility_dentaquest_${memberId}.pdf`
);
setPreviewOpen(true);
}}
/>
<UnitedSCOEligibilityButton
memberId={memberId}
dateOfBirth={dateOfBirth}
firstName={firstName}
lastName={lastName}
isFormIncomplete={isFormIncomplete}
onPdfReady={(pdfId, fallbackFilename) => {
setPreviewPdfId(pdfId);
setPreviewFallbackFilename(
fallbackFilename ?? `eligibility_unitedsco_${memberId}.pdf`
);
setPreviewOpen(true);
}}
/>
<Button
className="w-full"
@@ -614,26 +665,8 @@ export default function InsuranceStatusPage() {
</Button>
</div>
{/* Row 2 */}
{/* Row 3 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Button
className="w-full"
variant="outline"
disabled={isFormIncomplete}
>
<CheckCircle className="h-4 w-4 mr-2" />
Tufts SCO/SWH/Navi/Mass Gen
</Button>
<Button
className="w-full"
variant="outline"
disabled={isFormIncomplete}
>
<CheckCircle className="h-4 w-4 mr-2" />
United SCO
</Button>
<Button
className="w-full"
variant="outline"
@@ -642,9 +675,25 @@ export default function InsuranceStatusPage() {
<CheckCircle className="h-4 w-4 mr-2" />
United AAPR
</Button>
<Button
className="w-full"
variant="outline"
disabled={isFormIncomplete}
>
<CheckCircle className="h-4 w-4 mr-2" />
Metlife
</Button>
<Button
className="w-full"
variant="outline"
disabled={isFormIncomplete}
>
<CheckCircle className="h-4 w-4 mr-2" />
Cigna
</Button>
</div>
{/* Row 3 */}
{/* Row 4 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Button
className="w-full"
@@ -662,7 +711,28 @@ export default function InsuranceStatusPage() {
<CheckCircle className="h-4 w-4 mr-2" />
Altus
</Button>
<div /> {/* filler cell to keep grid shape */}
<Button
className="w-full"
variant="outline"
disabled={isFormIncomplete}
>
<CheckCircle className="h-4 w-4 mr-2" />
Delta WA
</Button>
</div>
{/* Row 5 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Button
className="w-full"
variant="outline"
disabled={isFormIncomplete}
>
<CheckCircle className="h-4 w-4 mr-2" />
Delta IL
</Button>
<div />
<div />
</div>
</div>
</CardContent>

View File

@@ -10,6 +10,17 @@ export default defineConfig(({ mode }) => {
server: {
host: env.HOST,
port: Number(env.PORT),
proxy: {
"/api": {
target: env.VITE_API_BASE_URL_BACKEND || "http://localhost:5000",
changeOrigin: true,
},
"/socket.io": {
target: env.VITE_API_BASE_URL_BACKEND || "http://localhost:5000",
changeOrigin: true,
ws: true,
},
},
},
resolve: {
alias: {

View File

@@ -3,6 +3,6 @@
"private": true,
"scripts": {
"postinstall": "pip install -r requirements.txt",
"dev": "python main.py"
"dev": "python3 main.py"
}
}

View File

@@ -3,6 +3,6 @@
"private": true,
"scripts": {
"postinstall": "pip install -r requirements.txt",
"dev": "python main.py"
"dev": "python3 main.py"
}
}

View File

@@ -9,10 +9,32 @@ from selenium_preAuthWorker import AutomationMassHealthPreAuth
import os
import time
import helpers_ddma_eligibility as hddma
import helpers_dentaquest_eligibility as hdentaquest
import helpers_unitedsco_eligibility as hunitedsco
import helpers_deltains_eligibility as hdeltains
# Import session clear functions for startup
from ddma_browser_manager import clear_ddma_session_on_startup
from dentaquest_browser_manager import clear_dentaquest_session_on_startup
from unitedsco_browser_manager import clear_unitedsco_session_on_startup
from deltains_browser_manager import clear_deltains_session_on_startup
from dotenv import load_dotenv
load_dotenv()
# Clear all sessions on startup (after PC restart)
# This ensures users must login again after PC restart
print("=" * 50)
print("SELENIUM AGENT STARTING - CLEARING ALL SESSIONS")
print("=" * 50)
clear_ddma_session_on_startup()
clear_dentaquest_session_on_startup()
clear_unitedsco_session_on_startup()
clear_deltains_session_on_startup()
print("=" * 50)
print("SESSION CLEAR COMPLETE - FRESH LOGINS REQUIRED")
print("=" * 50)
app = FastAPI()
# Allow 1 selenium session at a time
semaphore = asyncio.Semaphore(1)
@@ -186,6 +208,223 @@ async def ddma_eligibility(request: Request):
return {"status": "started", "session_id": sid}
# Endpoint:6 - DentaQuest eligibility (background, OTP)
async def _dentaquest_worker_wrapper(sid: str, data: dict, url: str):
"""
Background worker that:
- acquires semaphore (to keep 1 selenium at a time),
- updates active/queued counters,
- runs the DentaQuest flow via helpers.start_dentaquest_run.
"""
global active_jobs, waiting_jobs
async with semaphore:
async with lock:
waiting_jobs -= 1
active_jobs += 1
try:
await hdentaquest.start_dentaquest_run(sid, data, url)
finally:
async with lock:
active_jobs -= 1
@app.post("/dentaquest-eligibility")
async def dentaquest_eligibility(request: Request):
"""
Starts a DentaQuest eligibility session in the background.
Body: { "data": { ... }, "url"?: string }
Returns: { status: "started", session_id: "<uuid>" }
"""
global waiting_jobs
body = await request.json()
data = body.get("data", {})
# create session
sid = hdentaquest.make_session_entry()
hdentaquest.sessions[sid]["type"] = "dentaquest_eligibility"
hdentaquest.sessions[sid]["last_activity"] = time.time()
async with lock:
waiting_jobs += 1
# run in background (queued under semaphore)
asyncio.create_task(_dentaquest_worker_wrapper(sid, data, url="https://providers.dentaquest.com/onboarding/start/"))
return {"status": "started", "session_id": sid}
@app.post("/dentaquest-submit-otp")
async def dentaquest_submit_otp(request: Request):
"""
Body: { "session_id": "<sid>", "otp": "123456" }
Node / frontend call this when user provides OTP for DentaQuest.
"""
body = await request.json()
sid = body.get("session_id")
otp = body.get("otp")
if not sid or not otp:
raise HTTPException(status_code=400, detail="session_id and otp required")
res = hdentaquest.submit_otp(sid, otp)
if res.get("status") == "error":
raise HTTPException(status_code=400, detail=res.get("message"))
return res
@app.get("/dentaquest-session/{sid}/status")
async def dentaquest_session_status(sid: str):
s = hdentaquest.get_session_status(sid)
if s.get("status") == "not_found":
raise HTTPException(status_code=404, detail="session not found")
return s
# Endpoint:7 - United SCO eligibility (background, OTP)
async def _unitedsco_worker_wrapper(sid: str, data: dict, url: str):
"""
Background worker that:
- acquires semaphore (to keep 1 selenium at a time),
- updates active/queued counters,
- runs the United SCO flow via helpers.start_unitedsco_run.
"""
global active_jobs, waiting_jobs
async with semaphore:
async with lock:
waiting_jobs -= 1
active_jobs += 1
try:
await hunitedsco.start_unitedsco_run(sid, data, url)
finally:
async with lock:
active_jobs -= 1
@app.post("/unitedsco-eligibility")
async def unitedsco_eligibility(request: Request):
"""
Starts a United SCO eligibility session in the background.
Body: { "data": { ... }, "url"?: string }
Returns: { status: "started", session_id: "<uuid>" }
"""
global waiting_jobs
body = await request.json()
data = body.get("data", {})
# create session
sid = hunitedsco.make_session_entry()
hunitedsco.sessions[sid]["type"] = "unitedsco_eligibility"
hunitedsco.sessions[sid]["last_activity"] = time.time()
async with lock:
waiting_jobs += 1
# run in background (queued under semaphore)
asyncio.create_task(_unitedsco_worker_wrapper(sid, data, url="https://app.dentalhub.com/app/login"))
return {"status": "started", "session_id": sid}
@app.post("/unitedsco-submit-otp")
async def unitedsco_submit_otp(request: Request):
"""
Body: { "session_id": "<sid>", "otp": "123456" }
Node / frontend call this when user provides OTP for United SCO.
"""
body = await request.json()
sid = body.get("session_id")
otp = body.get("otp")
if not sid or not otp:
raise HTTPException(status_code=400, detail="session_id and otp required")
res = hunitedsco.submit_otp(sid, otp)
if res.get("status") == "error":
raise HTTPException(status_code=400, detail=res.get("message"))
return res
@app.get("/unitedsco-session/{sid}/status")
async def unitedsco_session_status(sid: str):
s = hunitedsco.get_session_status(sid)
if s.get("status") == "not_found":
raise HTTPException(status_code=404, detail="session not found")
return s
# Endpoint:8 - DeltaIns eligibility (background, OTP)
async def _deltains_worker_wrapper(sid: str, data: dict, url: str):
"""
Background worker that:
- acquires semaphore (to keep 1 selenium at a time),
- updates active/queued counters,
- runs the DeltaIns flow via helpers.start_deltains_run.
"""
global active_jobs, waiting_jobs
async with semaphore:
async with lock:
waiting_jobs -= 1
active_jobs += 1
try:
await hdeltains.start_deltains_run(sid, data, url)
finally:
async with lock:
active_jobs -= 1
@app.post("/deltains-eligibility")
async def deltains_eligibility(request: Request):
"""
Starts a DeltaIns eligibility session in the background.
Body: { "data": { ... }, "url"?: string }
Returns: { status: "started", session_id: "<uuid>" }
"""
global waiting_jobs
body = await request.json()
data = body.get("data", {})
sid = hdeltains.make_session_entry()
hdeltains.sessions[sid]["type"] = "deltains_eligibility"
hdeltains.sessions[sid]["last_activity"] = time.time()
async with lock:
waiting_jobs += 1
asyncio.create_task(_deltains_worker_wrapper(sid, data, url="https://www.deltadentalins.com/ciam/login?TARGET=%2Fprovider-tools%2Fv2"))
return {"status": "started", "session_id": sid}
@app.post("/deltains-submit-otp")
async def deltains_submit_otp(request: Request):
"""
Body: { "session_id": "<sid>", "otp": "123456" }
Node / frontend call this when user provides OTP for DeltaIns.
"""
body = await request.json()
sid = body.get("session_id")
otp = body.get("otp")
if not sid or not otp:
raise HTTPException(status_code=400, detail="session_id and otp required")
res = hdeltains.submit_otp(sid, otp)
if res.get("status") == "error":
raise HTTPException(status_code=400, detail=res.get("message"))
return res
@app.get("/deltains-session/{sid}/status")
async def deltains_session_status(sid: str):
s = hdeltains.get_session_status(sid)
if s.get("status") == "not_found":
raise HTTPException(status_code=404, detail="session not found")
return s
@app.post("/submit-otp")
async def submit_otp(request: Request):
"""
@@ -222,6 +461,56 @@ async def get_status():
"status": "busy" if active_jobs > 0 or waiting_jobs > 0 else "idle"
}
# ✅ Clear session endpoints - called when credentials are deleted
@app.post("/clear-ddma-session")
async def clear_ddma_session():
"""
Clears the DDMA browser session. Called when DDMA credentials are deleted.
"""
try:
clear_ddma_session_on_startup()
return {"status": "success", "message": "DDMA session cleared"}
except Exception as e:
return {"status": "error", "message": str(e)}
@app.post("/clear-dentaquest-session")
async def clear_dentaquest_session():
"""
Clears the DentaQuest browser session. Called when DentaQuest credentials are deleted.
"""
try:
clear_dentaquest_session_on_startup()
return {"status": "success", "message": "DentaQuest session cleared"}
except Exception as e:
return {"status": "error", "message": str(e)}
@app.post("/clear-unitedsco-session")
async def clear_unitedsco_session():
"""
Clears the United SCO browser session. Called when United SCO credentials are deleted.
"""
try:
clear_unitedsco_session_on_startup()
return {"status": "success", "message": "United SCO session cleared"}
except Exception as e:
return {"status": "error", "message": str(e)}
@app.post("/clear-deltains-session")
async def clear_deltains_session():
"""
Clears the Delta Dental Ins browser session. Called when DeltaIns credentials are deleted.
"""
try:
clear_deltains_session_on_startup()
return {"status": "success", "message": "DeltaIns session cleared"}
except Exception as e:
return {"status": "error", "message": str(e)}
if __name__ == "__main__":
host = os.getenv("HOST")
port = int(os.getenv("PORT"))

View File

@@ -1,19 +1,28 @@
"""
Minimal browser manager for DDMA - only handles persistent profile and keeping browser alive.
Does NOT modify any login/OTP logic.
Clears session cookies on startup (after PC restart) to force fresh login.
Tracks credentials to detect changes mid-session.
"""
import os
import glob
import shutil
import hashlib
import threading
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from webdriver_manager.chrome import ChromeDriverManager
# Ensure DISPLAY is set for Chrome to work (needed when running from SSH/background)
if not os.environ.get("DISPLAY"):
os.environ["DISPLAY"] = ":0"
class DDMABrowserManager:
"""
Singleton that manages a persistent Chrome browser instance.
- Uses --user-data-dir for persistent profile (device trust tokens, cookies)
- Keeps browser alive between patient runs
- Uses --user-data-dir for persistent profile (device trust tokens)
- Clears session cookies on startup (after PC restart)
- Tracks credentials to detect changes mid-session
"""
_instance = None
_lock = threading.Lock()
@@ -25,31 +34,208 @@ class DDMABrowserManager:
cls._instance._driver = None
cls._instance.profile_dir = os.path.abspath("chrome_profile_ddma")
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 # Flag to clear session on next driver creation
os.makedirs(cls._instance.profile_dir, exist_ok=True)
os.makedirs(cls._instance.download_dir, exist_ok=True)
return cls._instance
def clear_session_on_startup(self):
"""
Clear 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 the 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 files from Chrome profile
# These are the files that store login session cookies
session_files = [
"Cookies",
"Cookies-journal",
"Login Data",
"Login Data-journal",
"Web Data",
"Web Data-journal",
]
for filename in session_files:
filepath = os.path.join(self.profile_dir, "Default", 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}")
# Also try root level (some Chrome versions)
for filename in session_files:
filepath = os.path.join(self.profile_dir, filename)
if os.path.exists(filepath):
try:
os.remove(filepath)
print(f"[DDMA BrowserManager] Removed root {filename}")
except Exception as e:
print(f"[DDMA BrowserManager] Could not remove root {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 cache (prevents corrupted cached responses)
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}")
# Set flag to clear session via JavaScript after browser opens
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}")
def _hash_credentials(self, username: str) -> str:
"""Create a hash of the username to track credential changes."""
return hashlib.sha256(username.encode()).hexdigest()[:16]
def get_last_credentials_hash(self) -> str | None:
"""Get the hash of the last-used credentials."""
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):
"""Save the hash of the current credentials."""
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:
"""Check if the credentials have changed since last login."""
last_hash = self.get_last_credentials_hash()
if last_hash is None:
return False # No previous credentials, not a change
current_hash = self._hash_credentials(username)
changed = last_hash != current_hash
if changed:
print(f"[DDMA BrowserManager] Credentials changed - logout required")
return changed
def clear_credentials_hash(self):
"""Clear the saved credentials hash (used after logout)."""
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}")
def _kill_existing_chrome_for_profile(self):
"""Kill any existing Chrome processes using this profile and clean up locks."""
import subprocess
import time as time_module
try:
# Find and kill Chrome processes using this profile
result = subprocess.run(
["pgrep", "-f", f"user-data-dir={self.profile_dir}"],
capture_output=True, text=True
)
if result.stdout.strip():
pids = result.stdout.strip().split('\n')
for pid in pids:
try:
subprocess.run(["kill", "-9", pid], check=False)
except:
pass
time_module.sleep(1)
except Exception:
pass
# Remove lock files if they exist
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:
pass
def get_driver(self, headless=False):
"""Get or create the persistent browser instance."""
with self._lock:
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)
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)
else:
print("[BrowserManager] Reusing existing driver")
print("[DDMA BrowserManager] Reusing existing driver")
return self._driver
def _is_alive(self):
"""Check if browser is still responsive."""
try:
if self._driver is None:
return False
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
except Exception as e:
print(f"[BrowserManager] Driver not alive: {e}")
print(f"[DDMA BrowserManager] Driver not alive: {e}")
return False
def _create_driver(self, headless=False):
@@ -69,6 +255,12 @@ class DDMABrowserManager:
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
# Anti-detection options (prevent bot detection)
options.add_argument("--disable-blink-features=AutomationControlled")
options.add_experimental_option("excludeSwitches", ["enable-automation"])
options.add_experimental_option("useAutomationExtension", False)
options.add_argument("--disable-infobars")
prefs = {
"download.default_directory": self.download_dir,
"plugins.always_open_pdf_externally": True,
@@ -80,6 +272,15 @@ class DDMABrowserManager:
service = Service(ChromeDriverManager().install())
self._driver = webdriver.Chrome(service=service, options=options)
self._driver.maximize_window()
# Remove webdriver property to avoid detection
try:
self._driver.execute_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})")
except Exception:
pass
# Reset the session clear flag (file-based clearing is done on startup)
self._needs_session_clear = False
def quit_driver(self):
"""Quit browser (only call on shutdown)."""
@@ -100,3 +301,9 @@ def get_browser_manager():
if _manager is None:
_manager = DDMABrowserManager()
return _manager
def clear_ddma_session_on_startup():
"""Called by agent.py on startup to clear session."""
manager = get_browser_manager()
manager.clear_session_on_startup()

View File

@@ -0,0 +1,376 @@
"""
Browser manager for Delta Dental Ins - handles persistent profile, cookie
save/restore (for Okta session-only cookies), and keeping browser alive.
Tracks credentials to detect changes mid-session.
"""
import os
import json
import shutil
import hashlib
import threading
import subprocess
import time
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from webdriver_manager.chrome import ChromeDriverManager
if not os.environ.get("DISPLAY"):
os.environ["DISPLAY"] = ":0"
DELTAINS_DOMAIN = ".deltadentalins.com"
OKTA_DOMAINS = [".okta.com", ".oktacdn.com"]
class DeltaInsBrowserManager:
"""
Singleton that manages a persistent Chrome browser instance for Delta Dental Ins.
- Uses --user-data-dir for persistent profile
- Saves/restores Okta session cookies to survive browser restarts
- Tracks credentials to detect changes mid-session
"""
_instance = None
_lock = threading.Lock()
def __new__(cls):
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._driver = None
cls._instance.profile_dir = os.path.abspath("chrome_profile_deltains")
cls._instance.download_dir = os.path.abspath("seleniumDownloads")
cls._instance._credentials_file = os.path.join(cls._instance.profile_dir, ".last_credentials")
cls._instance._cookies_file = os.path.join(cls._instance.profile_dir, ".saved_cookies.json")
cls._instance._needs_session_clear = False
os.makedirs(cls._instance.profile_dir, exist_ok=True)
os.makedirs(cls._instance.download_dir, exist_ok=True)
return cls._instance
# ── Cookie save / restore ──────────────────────────────────────────
def save_cookies(self):
"""Save all browser cookies to a JSON file so they survive browser restart."""
try:
if not self._driver:
return
cookies = self._driver.get_cookies()
if not cookies:
return
with open(self._cookies_file, "w") as f:
json.dump(cookies, f)
print(f"[DeltaIns BrowserManager] Saved {len(cookies)} cookies to disk")
except Exception as e:
print(f"[DeltaIns BrowserManager] Failed to save cookies: {e}")
def restore_cookies(self):
"""Restore saved cookies into the current browser session."""
if not os.path.exists(self._cookies_file):
print("[DeltaIns BrowserManager] No saved cookies file found")
return False
try:
with open(self._cookies_file, "r") as f:
cookies = json.load(f)
if not cookies:
print("[DeltaIns BrowserManager] Saved cookies file is empty")
return False
# Navigate to the DeltaIns domain first so we can set cookies for it
try:
self._driver.get("https://www.deltadentalins.com/favicon.ico")
time.sleep(2)
except Exception:
self._driver.get("https://www.deltadentalins.com")
time.sleep(3)
restored = 0
for cookie in cookies:
try:
# Remove problematic fields that Selenium doesn't accept
for key in ["sameSite", "storeId", "hostOnly", "session"]:
cookie.pop(key, None)
# sameSite must be one of: Strict, Lax, None
cookie["sameSite"] = "None"
self._driver.add_cookie(cookie)
restored += 1
except Exception:
pass
print(f"[DeltaIns BrowserManager] Restored {restored}/{len(cookies)} cookies")
return restored > 0
except Exception as e:
print(f"[DeltaIns BrowserManager] Failed to restore cookies: {e}")
return False
def clear_saved_cookies(self):
"""Delete the saved cookies file."""
try:
if os.path.exists(self._cookies_file):
os.remove(self._cookies_file)
print("[DeltaIns BrowserManager] Cleared saved cookies file")
except Exception as e:
print(f"[DeltaIns BrowserManager] Failed to clear saved cookies: {e}")
# ── Session clear ──────────────────────────────────────────────────
def clear_session_on_startup(self):
"""
Clear session cookies from Chrome profile on startup.
This forces a fresh login after PC restart.
"""
print("[DeltaIns BrowserManager] Clearing session on startup...")
try:
if os.path.exists(self._credentials_file):
os.remove(self._credentials_file)
print("[DeltaIns BrowserManager] Cleared credentials tracking file")
# Also clear saved cookies
self.clear_saved_cookies()
session_files = [
"Cookies",
"Cookies-journal",
"Login Data",
"Login Data-journal",
"Web Data",
"Web Data-journal",
]
for filename in session_files:
filepath = os.path.join(self.profile_dir, "Default", filename)
if os.path.exists(filepath):
try:
os.remove(filepath)
print(f"[DeltaIns BrowserManager] Removed {filename}")
except Exception as e:
print(f"[DeltaIns BrowserManager] Could not remove {filename}: {e}")
for filename in session_files:
filepath = os.path.join(self.profile_dir, filename)
if os.path.exists(filepath):
try:
os.remove(filepath)
print(f"[DeltaIns BrowserManager] Removed root {filename}")
except Exception as e:
print(f"[DeltaIns BrowserManager] Could not remove root {filename}: {e}")
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("[DeltaIns BrowserManager] Cleared Session Storage")
except Exception as e:
print(f"[DeltaIns BrowserManager] Could not clear Session Storage: {e}")
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("[DeltaIns BrowserManager] Cleared Local Storage")
except Exception as e:
print(f"[DeltaIns BrowserManager] Could not clear Local Storage: {e}")
indexeddb_dir = os.path.join(self.profile_dir, "Default", "IndexedDB")
if os.path.exists(indexeddb_dir):
try:
shutil.rmtree(indexeddb_dir)
print("[DeltaIns BrowserManager] Cleared IndexedDB")
except Exception as e:
print(f"[DeltaIns BrowserManager] Could not clear IndexedDB: {e}")
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"[DeltaIns BrowserManager] Cleared {os.path.basename(cache_dir)}")
except Exception as e:
print(f"[DeltaIns BrowserManager] Could not clear {os.path.basename(cache_dir)}: {e}")
self._needs_session_clear = True
print("[DeltaIns BrowserManager] Session cleared - will require fresh login")
except Exception as e:
print(f"[DeltaIns BrowserManager] Error clearing session: {e}")
# ── Credential 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"[DeltaIns 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
current_hash = self._hash_credentials(username)
changed = last_hash != current_hash
if changed:
print("[DeltaIns 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"[DeltaIns BrowserManager] Failed to clear credentials hash: {e}")
# ── Chrome process management ──────────────────────────────────────
def _kill_existing_chrome_for_profile(self):
try:
result = subprocess.run(
["pgrep", "-f", f"user-data-dir={self.profile_dir}"],
capture_output=True, text=True
)
if result.stdout.strip():
pids = result.stdout.strip().split('\n')
for pid in pids:
try:
subprocess.run(["kill", "-9", pid], check=False)
except:
pass
time.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:
pass
# ── Driver lifecycle ───────────────────────────────────────────────
def get_driver(self, headless=False):
with self._lock:
need_cookie_restore = False
if self._driver is None:
print("[DeltaIns BrowserManager] Driver is None, creating new driver")
self._kill_existing_chrome_for_profile()
self._create_driver(headless)
need_cookie_restore = True
elif not self._is_alive():
print("[DeltaIns BrowserManager] Driver not alive, recreating")
# Save cookies from the dead session if possible (usually can't)
self._kill_existing_chrome_for_profile()
self._create_driver(headless)
need_cookie_restore = True
else:
print("[DeltaIns BrowserManager] Reusing existing driver")
if need_cookie_restore and os.path.exists(self._cookies_file):
print("[DeltaIns BrowserManager] Restoring saved cookies into new browser...")
self.restore_cookies()
return self._driver
def _is_alive(self):
try:
if self._driver is None:
return False
_ = self._driver.current_url
return True
except Exception:
return False
def _create_driver(self, headless=False):
if self._driver:
try:
self._driver.quit()
except:
pass
self._driver = None
time.sleep(1)
options = webdriver.ChromeOptions()
if headless:
options.add_argument("--headless")
options.add_argument(f"--user-data-dir={self.profile_dir}")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
options.add_argument("--disable-blink-features=AutomationControlled")
options.add_experimental_option("excludeSwitches", ["enable-automation"])
options.add_experimental_option("useAutomationExtension", False)
options.add_argument("--disable-infobars")
prefs = {
"download.default_directory": self.download_dir,
"plugins.always_open_pdf_externally": True,
"download.prompt_for_download": False,
"download.directory_upgrade": True,
"credentials_enable_service": False,
"profile.password_manager_enabled": False,
"profile.password_manager_leak_detection": False,
}
options.add_experimental_option("prefs", prefs)
service = Service(ChromeDriverManager().install())
self._driver = webdriver.Chrome(service=service, options=options)
self._driver.maximize_window()
try:
self._driver.execute_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})")
except Exception:
pass
self._needs_session_clear = False
def quit_driver(self):
with self._lock:
if self._driver:
try:
self._driver.quit()
except:
pass
self._driver = None
self._kill_existing_chrome_for_profile()
_manager = None
def get_browser_manager():
global _manager
if _manager is None:
_manager = DeltaInsBrowserManager()
return _manager
def clear_deltains_session_on_startup():
"""Called by agent.py on startup to clear session."""
manager = get_browser_manager()
manager.clear_session_on_startup()

View File

@@ -0,0 +1,277 @@
"""
Minimal browser manager for DentaQuest - only handles persistent profile and keeping browser alive.
Clears session cookies on startup (after PC restart) to force fresh login.
Tracks credentials to detect changes mid-session.
"""
import os
import shutil
import hashlib
import threading
import subprocess
import time
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from webdriver_manager.chrome import ChromeDriverManager
# Ensure DISPLAY is set for Chrome to work (needed when running from SSH/background)
if not os.environ.get("DISPLAY"):
os.environ["DISPLAY"] = ":0"
class DentaQuestBrowserManager:
"""
Singleton that manages a persistent Chrome browser instance for DentaQuest.
- Uses --user-data-dir for persistent profile (device trust tokens)
- Clears session cookies on startup (after PC restart)
- Tracks credentials to detect changes mid-session
"""
_instance = None
_lock = threading.Lock()
def __new__(cls):
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._driver = None
cls._instance.profile_dir = os.path.abspath("chrome_profile_dentaquest")
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 # Flag to clear session on next driver creation
os.makedirs(cls._instance.profile_dir, exist_ok=True)
os.makedirs(cls._instance.download_dir, exist_ok=True)
return cls._instance
def clear_session_on_startup(self):
"""
Clear 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("[DentaQuest BrowserManager] Clearing session on startup...")
try:
# Clear the credentials tracking file
if os.path.exists(self._credentials_file):
os.remove(self._credentials_file)
print("[DentaQuest BrowserManager] Cleared credentials tracking file")
# Clear session-related files from Chrome profile
# These are the files that store login session cookies
session_files = [
"Cookies",
"Cookies-journal",
"Login Data",
"Login Data-journal",
"Web Data",
"Web Data-journal",
]
for filename in session_files:
filepath = os.path.join(self.profile_dir, "Default", filename)
if os.path.exists(filepath):
try:
os.remove(filepath)
print(f"[DentaQuest BrowserManager] Removed {filename}")
except Exception as e:
print(f"[DentaQuest BrowserManager] Could not remove {filename}: {e}")
# Also try root level (some Chrome versions)
for filename in session_files:
filepath = os.path.join(self.profile_dir, filename)
if os.path.exists(filepath):
try:
os.remove(filepath)
print(f"[DentaQuest BrowserManager] Removed root {filename}")
except Exception as e:
print(f"[DentaQuest BrowserManager] Could not remove root {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("[DentaQuest BrowserManager] Cleared Session Storage")
except Exception as e:
print(f"[DentaQuest 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("[DentaQuest BrowserManager] Cleared Local Storage")
except Exception as e:
print(f"[DentaQuest 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("[DentaQuest BrowserManager] Cleared IndexedDB")
except Exception as e:
print(f"[DentaQuest BrowserManager] Could not clear IndexedDB: {e}")
# Set flag to clear session via JavaScript after browser opens
self._needs_session_clear = True
print("[DentaQuest BrowserManager] Session cleared - will require fresh login")
except Exception as e:
print(f"[DentaQuest BrowserManager] Error clearing session: {e}")
def _hash_credentials(self, username: str) -> str:
"""Create a hash of the username to track credential changes."""
return hashlib.sha256(username.encode()).hexdigest()[:16]
def get_last_credentials_hash(self) -> str | None:
"""Get the hash of the last-used credentials."""
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):
"""Save the hash of the current credentials."""
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"[DentaQuest BrowserManager] Failed to save credentials hash: {e}")
def credentials_changed(self, username: str) -> bool:
"""Check if the credentials have changed since last login."""
last_hash = self.get_last_credentials_hash()
if last_hash is None:
return False # No previous credentials, not a change
current_hash = self._hash_credentials(username)
changed = last_hash != current_hash
if changed:
print(f"[DentaQuest BrowserManager] Credentials changed - logout required")
return changed
def clear_credentials_hash(self):
"""Clear the saved credentials hash (used after logout)."""
try:
if os.path.exists(self._credentials_file):
os.remove(self._credentials_file)
except Exception as e:
print(f"[DentaQuest BrowserManager] Failed to clear credentials hash: {e}")
def _kill_existing_chrome_for_profile(self):
"""Kill any existing Chrome processes using this profile."""
try:
# Find and kill Chrome processes using this profile
result = subprocess.run(
["pgrep", "-f", f"user-data-dir={self.profile_dir}"],
capture_output=True, text=True
)
if result.stdout.strip():
pids = result.stdout.strip().split('\n')
for pid in pids:
try:
subprocess.run(["kill", "-9", pid], check=False)
except:
pass
time.sleep(1)
except Exception as e:
pass
# Remove SingletonLock if exists
lock_file = os.path.join(self.profile_dir, "SingletonLock")
try:
if os.path.islink(lock_file) or os.path.exists(lock_file):
os.remove(lock_file)
except:
pass
def get_driver(self, headless=False):
"""Get or create the persistent browser instance."""
with self._lock:
if self._driver is None:
print("[DentaQuest BrowserManager] Driver is None, creating new driver")
self._kill_existing_chrome_for_profile()
self._create_driver(headless)
elif not self._is_alive():
print("[DentaQuest BrowserManager] Driver not alive, recreating")
self._kill_existing_chrome_for_profile()
self._create_driver(headless)
else:
print("[DentaQuest BrowserManager] Reusing existing driver")
return self._driver
def _is_alive(self):
"""Check if browser is still responsive."""
try:
if self._driver is None:
return False
url = self._driver.current_url
return True
except Exception as e:
return False
def _create_driver(self, headless=False):
"""Create browser with persistent profile."""
if self._driver:
try:
self._driver.quit()
except:
pass
self._driver = None
time.sleep(1)
options = webdriver.ChromeOptions()
if headless:
options.add_argument("--headless")
# Persistent profile - THIS IS THE KEY for device trust
options.add_argument(f"--user-data-dir={self.profile_dir}")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
prefs = {
"download.default_directory": self.download_dir,
"plugins.always_open_pdf_externally": True,
"download.prompt_for_download": False,
"download.directory_upgrade": True
}
options.add_experimental_option("prefs", prefs)
service = Service(ChromeDriverManager().install())
self._driver = webdriver.Chrome(service=service, options=options)
self._driver.maximize_window()
# Reset the session clear flag (file-based clearing is done on startup)
self._needs_session_clear = False
def quit_driver(self):
"""Quit browser (only call on shutdown)."""
with self._lock:
if self._driver:
try:
self._driver.quit()
except:
pass
self._driver = None
# Also clean up any orphaned processes
self._kill_existing_chrome_for_profile()
# Singleton accessor
_manager = None
def get_browser_manager():
global _manager
if _manager is None:
_manager = DentaQuestBrowserManager()
return _manager
def clear_dentaquest_session_on_startup():
"""Called by agent.py on startup to clear session."""
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.support.ui import WebDriverWait
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
@@ -127,74 +127,113 @@ async def start_ddma_run(sid: str, data: dict, url: str):
s["message"] = "Session persisted"
# Continue to step1 below
# OTP required path
# OTP required path - POLL THE BROWSER to detect when user enters OTP
elif isinstance(login_result, str) and login_result == "OTP_REQUIRED":
s["status"] = "waiting_for_otp"
s["message"] = "OTP required for login"
s["message"] = "OTP required for login - please enter OTP in browser"
s["last_activity"] = time.time()
try:
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")
if not otp_value:
s["status"] = "error"
s["message"] = "OTP missing after event"
await cleanup_session(sid)
return {"status": "error", "message": "OTP missing after event"}
# Submit OTP - check if it's in a popup window
try:
driver = s["driver"]
wait = WebDriverWait(driver, 30)
# Check if there's a popup window and switch to it
original_window = driver.current_window_handle
all_windows = driver.window_handles
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:
submit_btn = wait.until(
EC.element_to_be_clickable(
(By.XPATH, "//button[@type='button' and @aria-label='Verify']")
)
)
submit_btn.click()
except Exception:
otp_input.send_keys("\n")
# Wait for verification and switch back to main window if needed
await asyncio.sleep(2)
if len(driver.window_handles) > 0:
driver.switch_to.window(driver.window_handles[0])
s["status"] = "otp_submitted"
driver = s["driver"]
# Poll the browser to detect when OTP is completed (user enters it directly)
# We check every 1 second for up to SESSION_OTP_TIMEOUT seconds (faster response)
max_polls = SESSION_OTP_TIMEOUT
login_success = False
print(f"[OTP] Waiting for user to enter OTP (polling browser for {SESSION_OTP_TIMEOUT}s)...")
for poll in range(max_polls):
await asyncio.sleep(1)
s["last_activity"] = time.time()
await asyncio.sleep(0.5)
except Exception as e:
s["status"] = "error"
s["message"] = f"Failed to submit OTP into page: {e}"
await cleanup_session(sid)
return {"status": "error", "message": s["message"]}
try:
# Check if OTP was submitted via API (from app)
otp_value = s.get("otp_value")
if otp_value:
print(f"[OTP] OTP received from app: {otp_value}")
try:
otp_input = driver.find_element(By.XPATH,
"//input[contains(@aria-label,'Verification') or contains(@placeholder,'verification') or @type='tel']"
)
otp_input.clear()
otp_input.send_keys(otp_value)
# Click verify button
try:
verify_btn = driver.find_element(By.XPATH, "//button[@type='button' and @aria-label='Verify']")
verify_btn.click()
except:
otp_input.send_keys("\n") # Press Enter as fallback
print("[OTP] OTP typed and submitted via app")
s["otp_value"] = None # Clear so we don't submit again
await asyncio.sleep(3) # Wait for verification
except Exception as type_err:
print(f"[OTP] Failed to type OTP from app: {type_err}")
# Check current URL - if we're on member search page, login succeeded
current_url = driver.current_url.lower()
print(f"[OTP Poll {poll+1}/{max_polls}] URL: {current_url[:60]}...")
# Check if we've navigated away from login/OTP pages
if "member" in current_url or "dashboard" in current_url or "eligibility" in current_url:
# Verify by checking for member search input
try:
member_search = WebDriverWait(driver, 5).until(
EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]'))
)
print("[OTP] Member search input found - login successful!")
login_success = True
break
except TimeoutException:
print("[OTP] On member page but search input not found, continuing to poll...")
# Also check if OTP input is still visible
try:
otp_input = driver.find_element(By.XPATH,
"//input[contains(@aria-label,'Verification') or contains(@placeholder,'verification') or @type='tel']"
)
# OTP input still visible - user hasn't entered OTP yet
print(f"[OTP Poll {poll+1}] OTP input still visible - waiting...")
except:
# OTP input not found - might mean login is in progress or succeeded
# Try navigating to 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:
pass
except Exception as poll_err:
print(f"[OTP Poll {poll+1}] Error: {poll_err}")
if not login_success:
# Final attempt - navigate to members page and check
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"]'))
)
print("[OTP] Member search input found - login successful!")
login_success = True
except TimeoutException:
s["status"] = "error"
s["message"] = "OTP timeout - login not completed"
await cleanup_session(sid)
return {"status": "error", "message": "OTP not completed in time"}
except Exception as final_err:
s["status"] = "error"
s["message"] = f"OTP verification failed: {final_err}"
await cleanup_session(sid)
return {"status": "error", "message": s["message"]}
if login_success:
s["status"] = "running"
s["message"] = "Login successful after OTP"
print("[OTP] Proceeding to step1...")
elif isinstance(login_result, str) and login_result.startswith("ERROR"):
s["status"] = "error"
@@ -202,6 +241,13 @@ async def start_ddma_run(sid: str, data: dict, url: str):
await cleanup_session(sid)
return {"status": "error", "message": login_result}
# Login succeeded without OTP (SUCCESS)
elif isinstance(login_result, str) and login_result == "SUCCESS":
print("[start_ddma_run] Login succeeded without OTP")
s["status"] = "running"
s["message"] = "Login succeeded"
# Continue to step1 below
# Step 1
step1_result = bot.step1()
if isinstance(step1_result, str) and step1_result.startswith("ERROR"):

View File

@@ -0,0 +1,300 @@
import os
import time
import asyncio
from typing import Dict, Any
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import WebDriverException, TimeoutException
from selenium_DeltaIns_eligibilityCheckWorker import AutomationDeltaInsEligibilityCheck
from deltains_browser_manager import get_browser_manager
# In-memory session store
sessions: Dict[str, Dict[str, Any]] = {}
SESSION_OTP_TIMEOUT = int(os.getenv("SESSION_OTP_TIMEOUT", "240"))
def make_session_entry() -> str:
import uuid
sid = str(uuid.uuid4())
sessions[sid] = {
"status": "created",
"created_at": time.time(),
"last_activity": time.time(),
"bot": None,
"driver": None,
"otp_event": asyncio.Event(),
"otp_value": None,
"result": None,
"message": None,
"type": None,
}
return sid
async def cleanup_session(sid: str, message: str | None = None):
s = sessions.get(sid)
if not s:
return
try:
try:
if s.get("status") not in ("completed", "error", "not_found"):
s["status"] = "error"
if message:
s["message"] = message
except Exception:
pass
try:
ev = s.get("otp_event")
if ev and not ev.is_set():
ev.set()
except Exception:
pass
finally:
sessions.pop(sid, None)
async def _remove_session_later(sid: str, delay: int = 30):
await asyncio.sleep(delay)
await cleanup_session(sid)
def _close_browser(bot):
"""Save cookies and close the browser after task completion."""
try:
bm = get_browser_manager()
try:
bm.save_cookies()
except Exception:
pass
try:
bm.quit_driver()
print("[DeltaIns] Browser closed")
except Exception:
pass
except Exception as e:
print(f"[DeltaIns] Could not close browser: {e}")
async def start_deltains_run(sid: str, data: dict, url: str):
"""
Run the DeltaIns eligibility check workflow:
1. Login (with OTP if needed)
2. Search patient by Member ID + DOB
3. Extract eligibility info + PDF
"""
s = sessions.get(sid)
if not s:
return {"status": "error", "message": "session not found"}
s["status"] = "running"
s["last_activity"] = time.time()
bot = None
try:
bot = AutomationDeltaInsEligibilityCheck({"data": data})
bot.config_driver()
s["bot"] = bot
s["driver"] = bot.driver
s["last_activity"] = time.time()
# Maximize window and login (bot.login handles navigation itself,
# checking provider-tools URL first to preserve existing sessions)
try:
bot.driver.maximize_window()
except Exception:
pass
try:
login_result = bot.login(url)
except WebDriverException as wde:
s["status"] = "error"
s["message"] = f"Selenium driver error during login: {wde}"
s["result"] = {"status": "error", "message": s["message"]}
_close_browser(bot)
asyncio.create_task(_remove_session_later(sid, 30))
return {"status": "error", "message": s["message"]}
except Exception as e:
s["status"] = "error"
s["message"] = f"Unexpected error during login: {e}"
s["result"] = {"status": "error", "message": s["message"]}
_close_browser(bot)
asyncio.create_task(_remove_session_later(sid, 30))
return {"status": "error", "message": s["message"]}
# Handle login result
if isinstance(login_result, str) and login_result == "ALREADY_LOGGED_IN":
s["status"] = "running"
s["message"] = "Session persisted"
print("[DeltaIns] Session persisted - skipping OTP")
# Re-save cookies to keep them fresh on disk
get_browser_manager().save_cookies()
elif isinstance(login_result, str) and login_result == "OTP_REQUIRED":
s["status"] = "waiting_for_otp"
s["message"] = "OTP required - please enter the code sent to your email"
s["last_activity"] = time.time()
driver = s["driver"]
max_polls = SESSION_OTP_TIMEOUT
login_success = False
print(f"[DeltaIns OTP] Waiting for OTP (polling for {SESSION_OTP_TIMEOUT}s)...")
for poll in range(max_polls):
await asyncio.sleep(1)
s["last_activity"] = time.time()
try:
otp_value = s.get("otp_value")
if otp_value:
print(f"[DeltaIns OTP] OTP received from app: {otp_value}")
try:
otp_input = driver.find_element(By.XPATH,
"//input[@name='credentials.passcode' and @type='text'] | "
"//input[contains(@name,'passcode')]")
otp_input.clear()
otp_input.send_keys(otp_value)
try:
verify_btn = driver.find_element(By.XPATH,
"//input[@type='submit'] | "
"//button[@type='submit']")
verify_btn.click()
print("[DeltaIns OTP] Clicked verify button")
except Exception:
otp_input.send_keys(Keys.RETURN)
print("[DeltaIns OTP] Pressed Enter as fallback")
s["otp_value"] = None
await asyncio.sleep(8)
except Exception as type_err:
print(f"[DeltaIns OTP] Failed to type OTP: {type_err}")
current_url = driver.current_url.lower()
if poll % 10 == 0:
print(f"[DeltaIns OTP Poll {poll+1}/{max_polls}] URL: {current_url[:80]}")
if "provider-tools" in current_url and "login" not in current_url and "ciam" not in current_url:
print("[DeltaIns OTP] Login successful!")
login_success = True
break
except Exception as poll_err:
if poll % 10 == 0:
print(f"[DeltaIns OTP Poll {poll+1}] Error: {poll_err}")
if not login_success:
try:
current_url = driver.current_url.lower()
if "provider-tools" in current_url and "login" not in current_url and "ciam" not in current_url:
login_success = True
else:
s["status"] = "error"
s["message"] = "OTP timeout - login not completed"
s["result"] = {"status": "error", "message": "OTP not completed in time"}
_close_browser(bot)
asyncio.create_task(_remove_session_later(sid, 30))
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}"
s["result"] = {"status": "error", "message": s["message"]}
_close_browser(bot)
asyncio.create_task(_remove_session_later(sid, 30))
return {"status": "error", "message": s["message"]}
if login_success:
s["status"] = "running"
s["message"] = "Login successful after OTP"
print("[DeltaIns OTP] Proceeding to step1...")
# Save cookies to disk so session survives browser restart
get_browser_manager().save_cookies()
elif isinstance(login_result, str) and login_result.startswith("ERROR"):
s["status"] = "error"
s["message"] = login_result
s["result"] = {"status": "error", "message": login_result}
_close_browser(bot)
asyncio.create_task(_remove_session_later(sid, 30))
return {"status": "error", "message": login_result}
elif isinstance(login_result, str) and login_result == "SUCCESS":
print("[DeltaIns] Login succeeded without OTP")
s["status"] = "running"
s["message"] = "Login succeeded"
# Save cookies to disk so session survives browser restart
get_browser_manager().save_cookies()
# Step 1 - search patient
step1_result = bot.step1()
print(f"[DeltaIns] step1 result: {step1_result}")
if isinstance(step1_result, str) and step1_result.startswith("ERROR"):
s["status"] = "error"
s["message"] = step1_result
s["result"] = {"status": "error", "message": step1_result}
_close_browser(bot)
asyncio.create_task(_remove_session_later(sid, 30))
return {"status": "error", "message": step1_result}
# Step 2 - extract eligibility info + PDF
step2_result = bot.step2()
print(f"[DeltaIns] step2 result: {step2_result.get('status') if isinstance(step2_result, dict) else step2_result}")
if isinstance(step2_result, dict):
s["status"] = "completed"
s["result"] = step2_result
s["message"] = "completed"
asyncio.create_task(_remove_session_later(sid, 60))
return step2_result
else:
s["status"] = "error"
s["message"] = f"step2 returned unexpected result: {step2_result}"
s["result"] = {"status": "error", "message": s["message"]}
_close_browser(bot)
asyncio.create_task(_remove_session_later(sid, 30))
return {"status": "error", "message": s["message"]}
except Exception as e:
if s:
s["status"] = "error"
s["message"] = f"worker exception: {e}"
s["result"] = {"status": "error", "message": s["message"]}
if bot:
_close_browser(bot)
asyncio.create_task(_remove_session_later(sid, 30))
return {"status": "error", "message": f"worker exception: {e}"}
def submit_otp(sid: str, otp: str) -> Dict[str, Any]:
s = sessions.get(sid)
if not s:
return {"status": "error", "message": "session not found"}
if s.get("status") != "waiting_for_otp":
return {"status": "error", "message": f"session not waiting for otp (state={s.get('status')})"}
s["otp_value"] = otp
s["last_activity"] = time.time()
try:
s["otp_event"].set()
except Exception:
pass
return {"status": "ok", "message": "otp accepted"}
def get_session_status(sid: str) -> Dict[str, Any]:
s = sessions.get(sid)
if not s:
return {"status": "not_found"}
return {
"session_id": sid,
"status": s.get("status"),
"message": s.get("message"),
"created_at": s.get("created_at"),
"last_activity": s.get("last_activity"),
"result": s.get("result") if s.get("status") in ("completed", "error") else None,
}

View File

@@ -0,0 +1,318 @@
import os
import time
import asyncio
from typing import Dict, Any
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import WebDriverException, TimeoutException
from selenium_DentaQuest_eligibilityCheckWorker import AutomationDentaQuestEligibilityCheck
# In-memory session store
sessions: Dict[str, Dict[str, Any]] = {}
SESSION_OTP_TIMEOUT = int(os.getenv("SESSION_OTP_TIMEOUT", "120")) # seconds
def make_session_entry() -> str:
"""Create a new session entry and return its ID."""
import uuid
sid = str(uuid.uuid4())
sessions[sid] = {
"status": "created", # created -> running -> waiting_for_otp -> otp_submitted -> completed / error
"created_at": time.time(),
"last_activity": time.time(),
"bot": None, # worker instance
"driver": None, # selenium webdriver
"otp_event": asyncio.Event(),
"otp_value": None,
"result": None,
"message": None,
"type": None,
}
return sid
async def cleanup_session(sid: str, message: str | None = None):
"""
Close driver (if any), wake OTP waiter, set final state, and remove session entry.
Idempotent: safe to call multiple times.
"""
s = sessions.get(sid)
if not s:
return
try:
# Ensure final state
try:
if s.get("status") not in ("completed", "error", "not_found"):
s["status"] = "error"
if message:
s["message"] = message
except Exception:
pass
# 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
finally:
# Remove session entry from map
sessions.pop(sid, None)
async def _remove_session_later(sid: str, delay: int = 20):
await asyncio.sleep(delay)
await cleanup_session(sid)
async def start_dentaquest_run(sid: str, data: dict, url: str):
"""
Run the DentaQuest workflow for a session (WITHOUT managing semaphore/counters).
Called by agent.py inside a wrapper that handles queue/counters.
"""
s = sessions.get(sid)
if not s:
return {"status": "error", "message": "session not found"}
s["status"] = "running"
s["last_activity"] = time.time()
try:
bot = AutomationDentaQuestEligibilityCheck({"data": data})
bot.config_driver()
s["bot"] = bot
s["driver"] = bot.driver
s["last_activity"] = time.time()
# Navigate to login URL
try:
if not url:
raise ValueError("URL not provided for DentaQuest run")
bot.driver.maximize_window()
bot.driver.get(url)
await asyncio.sleep(1)
except Exception as e:
s["status"] = "error"
s["message"] = f"Navigation failed: {e}"
await cleanup_session(sid)
return {"status": "error", "message": s["message"]}
# Login
try:
login_result = bot.login(url)
except WebDriverException as wde:
s["status"] = "error"
s["message"] = f"Selenium driver error during login: {wde}"
await cleanup_session(sid, s["message"])
return {"status": "error", "message": s["message"]}
except Exception as e:
s["status"] = "error"
s["message"] = f"Unexpected error during login: {e}"
await cleanup_session(sid, s["message"])
return {"status": "error", "message": s["message"]}
# Already logged in - session persisted from profile, skip to step1
if isinstance(login_result, str) and login_result == "ALREADY_LOGGED_IN":
s["status"] = "running"
s["message"] = "Session persisted"
# Continue to step1 below
# OTP required path - POLL THE BROWSER to detect when user enters OTP
elif isinstance(login_result, str) and login_result == "OTP_REQUIRED":
s["status"] = "waiting_for_otp"
s["message"] = "OTP required for login - please enter OTP in browser"
s["last_activity"] = time.time()
driver = s["driver"]
# Poll the browser to detect when OTP is completed (user enters it directly)
# We check every 1 second for up to SESSION_OTP_TIMEOUT seconds (faster response)
max_polls = SESSION_OTP_TIMEOUT
login_success = False
print(f"[DentaQuest OTP] Waiting for user to enter OTP (polling browser for {SESSION_OTP_TIMEOUT}s)...")
for poll in range(max_polls):
await asyncio.sleep(1)
s["last_activity"] = time.time()
try:
# Check if OTP was submitted via API (from app)
otp_value = s.get("otp_value")
if otp_value:
print(f"[DentaQuest OTP] OTP received from app: {otp_value}")
try:
otp_input = driver.find_element(By.XPATH,
"//input[contains(@name,'otp') or contains(@name,'code') or @type='tel' or contains(@aria-label,'Verification') or contains(@placeholder,'code')]"
)
otp_input.clear()
otp_input.send_keys(otp_value)
# Click verify button - use same pattern as Delta MA
try:
verify_btn = driver.find_element(By.XPATH, "//button[@type='button' and @aria-label='Verify']")
verify_btn.click()
print("[DentaQuest OTP] Clicked verify button (aria-label)")
except:
try:
# Fallback: try other button patterns
verify_btn = driver.find_element(By.XPATH, "//button[contains(text(),'Verify') or contains(text(),'Submit') or @type='submit']")
verify_btn.click()
print("[DentaQuest OTP] Clicked verify button (text/type)")
except:
otp_input.send_keys("\n") # Press Enter as fallback
print("[DentaQuest OTP] Pressed Enter as fallback")
print("[DentaQuest OTP] OTP typed and submitted via app")
s["otp_value"] = None # Clear so we don't submit again
await asyncio.sleep(3) # Wait for verification
except Exception as type_err:
print(f"[DentaQuest OTP] Failed to type OTP from app: {type_err}")
# Check current URL - if we're on dashboard/member page, login succeeded
current_url = driver.current_url.lower()
print(f"[DentaQuest OTP Poll {poll+1}/{max_polls}] URL: {current_url[:60]}...")
# Check if we've navigated away from login/OTP pages
if "member" in current_url or "dashboard" in current_url or "eligibility" in current_url:
# Verify by checking for member search input
try:
member_search = WebDriverWait(driver, 5).until(
EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]'))
)
print("[DentaQuest OTP] Member search input found - login successful!")
login_success = True
break
except TimeoutException:
print("[DentaQuest OTP] On member page but search input not found, continuing to poll...")
# Also check if OTP input is still visible
try:
otp_input = driver.find_element(By.XPATH,
"//input[contains(@name,'otp') or contains(@name,'code') or @type='tel' or contains(@aria-label,'Verification') or contains(@placeholder,'code') or contains(@placeholder,'Code')]"
)
# OTP input still visible - user hasn't entered OTP yet
print(f"[DentaQuest OTP Poll {poll+1}] OTP input still visible - waiting...")
except:
# OTP input not found - might mean login is in progress or succeeded
# Try navigating to members page (like Delta MA)
if "onboarding" in current_url or "start" in current_url or "login" in current_url:
print("[DentaQuest OTP] OTP input gone, trying to navigate to members page...")
try:
driver.get("https://providers.dentaquest.com/members")
await asyncio.sleep(2)
except:
pass
except Exception as poll_err:
print(f"[DentaQuest OTP Poll {poll+1}] Error: {poll_err}")
if not login_success:
# Final attempt - navigate to members page and check (like Delta MA)
try:
print("[DentaQuest OTP] Final attempt - navigating to members page...")
driver.get("https://providers.dentaquest.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"]'))
)
print("[DentaQuest OTP] Member search input found - login successful!")
login_success = True
except TimeoutException:
s["status"] = "error"
s["message"] = "OTP timeout - login not completed"
await cleanup_session(sid)
return {"status": "error", "message": "OTP not completed in time"}
except Exception as final_err:
s["status"] = "error"
s["message"] = f"OTP verification failed: {final_err}"
await cleanup_session(sid)
return {"status": "error", "message": s["message"]}
if login_success:
s["status"] = "running"
s["message"] = "Login successful after OTP"
print("[DentaQuest OTP] Proceeding to step1...")
elif isinstance(login_result, str) and login_result.startswith("ERROR"):
s["status"] = "error"
s["message"] = login_result
await cleanup_session(sid)
return {"status": "error", "message": login_result}
# Login succeeded without OTP (SUCCESS)
elif isinstance(login_result, str) and login_result == "SUCCESS":
print("[start_dentaquest_run] Login succeeded without OTP")
s["status"] = "running"
s["message"] = "Login succeeded"
# Continue to step1 below
# Step 1
step1_result = bot.step1()
if isinstance(step1_result, str) and step1_result.startswith("ERROR"):
s["status"] = "error"
s["message"] = step1_result
await cleanup_session(sid)
return {"status": "error", "message": step1_result}
# Step 2 (PDF)
step2_result = bot.step2()
if isinstance(step2_result, dict) and step2_result.get("status") == "success":
s["status"] = "completed"
s["result"] = step2_result
s["message"] = "completed"
asyncio.create_task(_remove_session_later(sid, 30))
return step2_result
else:
s["status"] = "error"
if isinstance(step2_result, dict):
s["message"] = step2_result.get("message", "unknown error")
else:
s["message"] = str(step2_result)
await cleanup_session(sid)
return {"status": "error", "message": s["message"]}
except Exception as e:
s["status"] = "error"
s["message"] = f"worker exception: {e}"
await cleanup_session(sid)
return {"status": "error", "message": s["message"]}
def submit_otp(sid: str, otp: str) -> Dict[str, Any]:
"""Set OTP for a session and wake waiting runner."""
s = sessions.get(sid)
if not s:
return {"status": "error", "message": "session not found"}
if s.get("status") != "waiting_for_otp":
return {"status": "error", "message": f"session not waiting for otp (state={s.get('status')})"}
s["otp_value"] = otp
s["last_activity"] = time.time()
try:
s["otp_event"].set()
except Exception:
pass
return {"status": "ok", "message": "otp accepted"}
def get_session_status(sid: str) -> Dict[str, Any]:
s = sessions.get(sid)
if not s:
return {"status": "not_found"}
return {
"session_id": sid,
"status": s.get("status"),
"message": s.get("message"),
"created_at": s.get("created_at"),
"last_activity": s.get("last_activity"),
"result": s.get("result") if s.get("status") == "completed" else None,
}

View File

@@ -0,0 +1,364 @@
import os
import time
import asyncio
from typing import Dict, Any
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import WebDriverException, TimeoutException
from selenium_UnitedSCO_eligibilityCheckWorker import AutomationUnitedSCOEligibilityCheck
# In-memory session store
sessions: Dict[str, Dict[str, Any]] = {}
SESSION_OTP_TIMEOUT = int(os.getenv("SESSION_OTP_TIMEOUT", "120")) # seconds
def make_session_entry() -> str:
"""Create a new session entry and return its ID."""
import uuid
sid = str(uuid.uuid4())
sessions[sid] = {
"status": "created", # created -> running -> waiting_for_otp -> otp_submitted -> completed / error
"created_at": time.time(),
"last_activity": time.time(),
"bot": None, # worker instance
"driver": None, # selenium webdriver
"otp_event": asyncio.Event(),
"otp_value": None,
"result": None,
"message": None,
"type": None,
}
return sid
async def cleanup_session(sid: str, message: str | None = None):
"""
Close driver (if any), wake OTP waiter, set final state, and remove session entry.
Idempotent: safe to call multiple times.
"""
s = sessions.get(sid)
if not s:
return
try:
# Ensure final state
try:
if s.get("status") not in ("completed", "error", "not_found"):
s["status"] = "error"
if message:
s["message"] = message
except Exception:
pass
# 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
finally:
# Remove session entry from map
sessions.pop(sid, None)
async def _remove_session_later(sid: str, delay: int = 20):
await asyncio.sleep(delay)
await cleanup_session(sid)
def _minimize_browser(bot):
"""Hide the browser window so it doesn't stay in the user's way."""
try:
if bot and bot.driver:
# Navigate to blank page first
try:
bot.driver.get("about:blank")
except Exception:
pass
# Try minimize
try:
bot.driver.minimize_window()
print("[UnitedSCO] Browser minimized after error")
return
except Exception:
pass
# Fallback: move off-screen
try:
bot.driver.set_window_position(-10000, -10000)
print("[UnitedSCO] Browser moved off-screen after error")
except Exception:
pass
except Exception as e:
print(f"[UnitedSCO] Could not hide browser: {e}")
async def start_unitedsco_run(sid: str, data: dict, url: str):
"""
Run the United SCO workflow for a session (WITHOUT managing semaphore/counters).
Called by agent.py inside a wrapper that handles queue/counters.
"""
s = sessions.get(sid)
if not s:
return {"status": "error", "message": "session not found"}
s["status"] = "running"
s["last_activity"] = time.time()
try:
bot = AutomationUnitedSCOEligibilityCheck({"data": data})
bot.config_driver()
s["bot"] = bot
s["driver"] = bot.driver
s["last_activity"] = time.time()
# Navigate to login URL
try:
if not url:
raise ValueError("URL not provided for United SCO run")
bot.driver.maximize_window()
bot.driver.get(url)
await asyncio.sleep(1)
except Exception as e:
s["status"] = "error"
s["message"] = f"Navigation failed: {e}"
await cleanup_session(sid)
return {"status": "error", "message": s["message"]}
# Login
try:
login_result = bot.login(url)
except WebDriverException as wde:
s["status"] = "error"
s["message"] = f"Selenium driver error during login: {wde}"
await cleanup_session(sid, s["message"])
return {"status": "error", "message": s["message"]}
except Exception as e:
s["status"] = "error"
s["message"] = f"Unexpected error during login: {e}"
await cleanup_session(sid, s["message"])
return {"status": "error", "message": s["message"]}
# Already logged in - session persisted from profile, skip to step1
if isinstance(login_result, str) and login_result == "ALREADY_LOGGED_IN":
s["status"] = "running"
s["message"] = "Session persisted"
print("[start_unitedsco_run] Session persisted - skipping OTP")
# Continue to step1 below
# OTP required path - POLL THE BROWSER to detect when user enters OTP
elif isinstance(login_result, str) and login_result == "OTP_REQUIRED":
s["status"] = "waiting_for_otp"
s["message"] = "OTP required for login - please enter OTP in browser"
s["last_activity"] = time.time()
driver = s["driver"]
# Poll the browser to detect when OTP is completed (user enters it directly)
# We check every 1 second for up to SESSION_OTP_TIMEOUT seconds (faster response)
max_polls = SESSION_OTP_TIMEOUT
login_success = False
print(f"[UnitedSCO OTP] Waiting for user to enter OTP (polling browser for {SESSION_OTP_TIMEOUT}s)...")
for poll in range(max_polls):
await asyncio.sleep(1)
s["last_activity"] = time.time()
try:
# Check if OTP was submitted via API (from app)
otp_value = s.get("otp_value")
if otp_value:
print(f"[UnitedSCO OTP] OTP received from app: {otp_value}")
try:
otp_input = driver.find_element(By.XPATH,
"//input[contains(@name,'otp') or contains(@name,'code') or @type='tel' or contains(@aria-label,'Verification') or contains(@placeholder,'code')]"
)
otp_input.clear()
otp_input.send_keys(otp_value)
# Click verify button - use same pattern as Delta MA
try:
verify_btn = driver.find_element(By.XPATH, "//button[@type='button' and @aria-label='Verify']")
verify_btn.click()
print("[UnitedSCO OTP] Clicked verify button (aria-label)")
except:
try:
# Fallback: try other button patterns
verify_btn = driver.find_element(By.XPATH, "//button[contains(text(),'Verify') or contains(text(),'Submit') or @type='submit']")
verify_btn.click()
print("[UnitedSCO OTP] Clicked verify button (text/type)")
except:
otp_input.send_keys("\n") # Press Enter as fallback
print("[UnitedSCO OTP] Pressed Enter as fallback")
print("[UnitedSCO OTP] OTP typed and submitted via app")
s["otp_value"] = None # Clear so we don't submit again
await asyncio.sleep(3) # Wait for verification
except Exception as type_err:
print(f"[UnitedSCO OTP] Failed to type OTP from app: {type_err}")
# Check current URL - if we're on dashboard/member page, login succeeded
current_url = driver.current_url.lower()
print(f"[UnitedSCO OTP Poll {poll+1}/{max_polls}] URL: {current_url[:60]}...")
# Check if we've navigated away from login/OTP pages
if "member" in current_url or "dashboard" in current_url or "eligibility" in current_url or "home" in current_url:
# Verify by checking for member search input or dashboard element
try:
# Try multiple selectors for logged-in state
dashboard_elem = WebDriverWait(driver, 5).until(
EC.presence_of_element_located((By.XPATH,
'//input[@placeholder="Search by member ID"] | //input[contains(@placeholder,"Search")] | //*[contains(@class,"dashboard")]'
))
)
print("[UnitedSCO OTP] Dashboard/search element found - login successful!")
login_success = True
break
except TimeoutException:
print("[UnitedSCO OTP] On member page but search input not found, continuing to poll...")
# Also check if OTP input is still visible
try:
otp_input = driver.find_element(By.XPATH,
"//input[contains(@name,'otp') or contains(@name,'code') or @type='tel' or contains(@aria-label,'Verification') or contains(@placeholder,'code') or contains(@placeholder,'Code')]"
)
# OTP input still visible - user hasn't entered OTP yet
print(f"[UnitedSCO OTP Poll {poll+1}] OTP input still visible - waiting...")
except:
# OTP input not found - might mean login is in progress or succeeded
# Try navigating to dashboard
if "login" in current_url or "app/login" in current_url:
print("[UnitedSCO OTP] OTP input gone, trying to navigate to dashboard...")
try:
driver.get("https://app.dentalhub.com/app/dashboard")
await asyncio.sleep(2)
except:
pass
except Exception as poll_err:
print(f"[UnitedSCO OTP Poll {poll+1}] Error: {poll_err}")
if not login_success:
# Final attempt - navigate to dashboard and check
try:
print("[UnitedSCO OTP] Final attempt - navigating to dashboard...")
driver.get("https://app.dentalhub.com/app/dashboard")
await asyncio.sleep(3)
dashboard_elem = WebDriverWait(driver, 10).until(
EC.presence_of_element_located((By.XPATH,
'//input[@placeholder="Search by member ID"] | //input[contains(@placeholder,"Search")] | //*[contains(@class,"dashboard")]'
))
)
print("[UnitedSCO OTP] Dashboard element found - login successful!")
login_success = True
except TimeoutException:
s["status"] = "error"
s["message"] = "OTP timeout - login not completed"
await cleanup_session(sid)
return {"status": "error", "message": "OTP not completed in time"}
except Exception as final_err:
s["status"] = "error"
s["message"] = f"OTP verification failed: {final_err}"
await cleanup_session(sid)
return {"status": "error", "message": s["message"]}
if login_success:
s["status"] = "running"
s["message"] = "Login successful after OTP"
print("[UnitedSCO OTP] Proceeding to step1...")
elif isinstance(login_result, str) and login_result.startswith("ERROR"):
s["status"] = "error"
s["message"] = login_result
await cleanup_session(sid)
return {"status": "error", "message": login_result}
# Login succeeded without OTP (SUCCESS)
elif isinstance(login_result, str) and login_result == "SUCCESS":
print("[start_unitedsco_run] Login succeeded without OTP")
s["status"] = "running"
s["message"] = "Login succeeded"
# Continue to step1 below
# Step 1
step1_result = bot.step1()
if isinstance(step1_result, str) and step1_result.startswith("ERROR"):
s["status"] = "error"
s["message"] = step1_result
s["result"] = {"status": "error", "message": step1_result}
# Minimize browser on error
_minimize_browser(bot)
# Keep session alive for backend to poll, then clean up
asyncio.create_task(_remove_session_later(sid, 30))
return {"status": "error", "message": step1_result}
# Step 2 (PDF)
step2_result = bot.step2()
if isinstance(step2_result, dict) and step2_result.get("status") == "success":
s["status"] = "completed"
s["result"] = step2_result
s["message"] = "completed"
asyncio.create_task(_remove_session_later(sid, 30))
return step2_result
else:
s["status"] = "error"
if isinstance(step2_result, dict):
s["message"] = step2_result.get("message", "unknown error")
else:
s["message"] = str(step2_result)
s["result"] = {"status": "error", "message": s["message"]}
# Minimize browser on error
_minimize_browser(bot)
# Keep session alive for backend to poll, then clean up
asyncio.create_task(_remove_session_later(sid, 30))
return {"status": "error", "message": s["message"]}
except Exception as e:
s["status"] = "error"
s["message"] = f"worker exception: {e}"
# Minimize browser on exception
try:
if bot and bot.driver:
bot.driver.minimize_window()
except Exception:
pass
s["result"] = {"status": "error", "message": s["message"]}
asyncio.create_task(_remove_session_later(sid, 30))
return {"status": "error", "message": s["message"]}
def submit_otp(sid: str, otp: str) -> Dict[str, Any]:
"""Set OTP for a session and wake waiting runner."""
s = sessions.get(sid)
if not s:
return {"status": "error", "message": "session not found"}
if s.get("status") != "waiting_for_otp":
return {"status": "error", "message": f"session not waiting for otp (state={s.get('status')})"}
s["otp_value"] = otp
s["last_activity"] = time.time()
try:
s["otp_event"].set()
except Exception:
pass
return {"status": "ok", "message": "otp accepted"}
def get_session_status(sid: str) -> Dict[str, Any]:
s = sessions.get(sid)
if not s:
return {"status": "not_found"}
return {
"session_id": sid,
"status": s.get("status"),
"message": s.get("message"),
"created_at": s.get("created_at"),
"last_activity": s.get("last_activity"),
"result": s.get("result") if s.get("status") in ("completed", "error") else None,
}

View File

@@ -23,6 +23,8 @@ class AutomationDeltaDentalMAEligibilityCheck:
# Flatten values for convenience
self.memberId = self.data.get("memberId", "")
self.dateOfBirth = self.data.get("dateOfBirth", "")
self.firstName = self.data.get("firstName", "")
self.lastName = self.data.get("lastName", "")
self.massddma_username = self.data.get("massddmaUsername", "")
self.massddma_password = self.data.get("massddmaPassword", "")
@@ -34,9 +36,63 @@ class AutomationDeltaDentalMAEligibilityCheck:
# Use persistent browser from manager (keeps device trust tokens)
self.driver = get_browser_manager().get_driver(self.headless)
def _force_logout(self):
"""Force logout by clearing cookies for Delta Dental domain."""
try:
print("[DDMA login] Forcing logout due to credential change...")
browser_manager = get_browser_manager()
# First 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):
wait = WebDriverWait(self.driver, 30)
browser_manager = get_browser_manager()
try:
# Check if credentials have changed - if so, 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)
# First check if we're already on a logged-in page (from previous run)
try:
current_url = self.driver.current_url
@@ -178,8 +234,12 @@ class AutomationDeltaDentalMAEligibilityCheck:
login_button = wait.until(EC.element_to_be_clickable((By.XPATH, "//button[@type='submit' and @aria-label='Sign in']")))
login_button.click()
# Save credentials hash after login attempt
if self.massddma_username:
browser_manager.save_credentials_hash(self.massddma_username)
# OTP detection
# OTP detection - wait up to 30 seconds for OTP input to appear
try:
otp_candidate = WebDriverWait(self.driver, 30).until(
EC.presence_of_element_located(
@@ -191,63 +251,140 @@ class AutomationDeltaDentalMAEligibilityCheck:
return "OTP_REQUIRED"
except TimeoutException:
print("[login] No OTP input detected in allowed time.")
# Check if we're now on the member search page (login succeeded without OTP)
try:
current_url = self.driver.current_url.lower()
if "member" in current_url or "dashboard" in current_url:
member_search = WebDriverWait(self.driver, 5).until(
EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]'))
)
print("[login] Login successful - now on member search page")
return "SUCCESS"
except TimeoutException:
pass
# Check for error messages on page
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 still on login page, login failed
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"
# Otherwise assume success (might be on an intermediate page)
print("[login] Assuming login succeeded (no errors detected)")
return "SUCCESS"
except Exception as e:
print("[login] Exception during login:", e)
return f"ERROR:LOGIN FAILED: {e}"
def step1(self):
"""Fill search form with all available fields (flexible search)"""
wait = WebDriverWait(self.driver, 30)
try:
# Fill Member ID
member_id_input = wait.until(EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]')))
member_id_input.clear()
member_id_input.send_keys(self.memberId)
# Log what fields are available
fields = []
if self.memberId:
fields.append(f"ID: {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)
# Helper to click, select-all and type
def replace_with_sendkeys(el, value):
# focus (same as 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.BACKSPACE)
# type the 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)
time.sleep(0.05)
replace_with_sendkeys(day_elem, day)
time.sleep(0.05)
replace_with_sendkeys(year_elem, year)
# 1. Fill Member ID if provided
if self.memberId:
try:
member_id_input = wait.until(EC.presence_of_element_located(
(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
continue_btn = wait.until(EC.element_to_be_clickable((By.XPATH, '//button[@data-testid="member-search_search-button"]')))
dob_container = wait.until(
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()
print("[DDMA step1] Clicked Search button")
time.sleep(5)
# Check for error message
try:
@@ -255,89 +392,284 @@ class AutomationDeltaDentalMAEligibilityCheck:
(By.XPATH, '//div[@data-testid="member-search-result-no-results"]')
))
if error_msg:
print("Error: Invalid Member ID or Date of Birth.")
return "ERROR: INVALID MEMBERID OR DOB"
print("[DDMA step1] Error: No results found")
return "ERROR: INVALID SEARCH CRITERIA"
except TimeoutException:
pass
print("[DDMA step1] Search completed successfully")
return "Success"
except Exception as e:
print(f"Error while step1 i.e Cheking the MemberId and DOB in: {e}")
return "ERROR:STEP1"
print(f"[DDMA step1] Exception: {e}")
return f"ERROR:STEP1 - {e}"
def step2(self):
wait = WebDriverWait(self.driver, 90)
try:
# 1) find the eligibility <a> inside the correct cell
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()
# 2) finding patient name.
patient_name_div = wait.until(EC.presence_of_element_located((
By.XPATH,
'//div[@class="flex flex-row w-full items-center"]'
)))
patientName = patient_name_div.text.strip().lower()
# Wait for results table to load
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")
# 1) Extract eligibility status and Member ID from search results
eligibilityText = "unknown"
foundMemberId = ""
patientName = ""
# Extract data from first row
import re
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, before "DOB:")
if lines:
potential_name = lines[0].strip()
# Remove DOB if included in the name
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 from row: '{patientName}'")
# Extract Member ID (usually a numeric/alphanumeric ID on its own line)
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 from row: {foundMemberId}")
break
# Fallback: use input memberId if not found
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 as e:
print(f"[DDMA step2] Eligibility link not found, trying alternative...")
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:
pass
# 2) Click on patient name to navigate to detailed patient page
print("[DDMA step2] Clicking on patient name to open detailed page...")
patient_name_clicked = False
# Note: Don't reset patientName here - preserve the name extracted from row above
# First, let's print what we see on the page for debugging
current_url_before = self.driver.current_url
print(f"[DDMA step2] Current URL before click: {current_url_before}")
# Try to find all links in the first row and print them for debugging
try:
all_links = self.driver.find_elements(By.XPATH, "(//tbody//tr)[1]//a")
print(f"[DDMA step2] Found {len(all_links)} links in first row:")
for i, link in enumerate(all_links):
href = link.get_attribute("href") or "no-href"
text = link.text.strip() or "(empty text)"
print(f" Link {i}: href={href[:80]}..., text={text}")
except Exception as e:
print(f"[DDMA step2] Error listing links: {e}")
# Find the patient detail link and navigate DIRECTLY to it
detail_url = None
patient_link_selectors = [
"(//table//tbody//tr)[1]//td[1]//a", # First column link
"(//tbody//tr)[1]//a[contains(@href, 'member-details')]", # member-details link
"(//tbody//tr)[1]//a[contains(@href, 'member')]", # Any member link
]
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}")
# Only update patientName if link has text (preserve previously extracted name)
if link_text and not patientName:
patientName = link_text
if href and "member-details" in href:
detail_url = href
patient_name_clicked = True
print(f"[DDMA step2] Will navigate directly to: {detail_url}")
break
except Exception as e:
print(f"[DDMA step2] Selector '{selector}' failed: {e}")
continue
if not detail_url:
# Fallback: Try to find ANY link to member-details
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] Found member-details link: {detail_url}")
except Exception as e:
print(f"[DDMA step2] Could not find member-details link: {e}")
# Navigate to detail page DIRECTLY instead of clicking (which may open new tab/fail)
if patient_name_clicked and detail_url:
print(f"[DDMA step2] Navigating directly to detail page: {detail_url}")
self.driver.get(detail_url)
time.sleep(3) # Wait for page to load
current_url_after = self.driver.current_url
print(f"[DDMA step2] Current URL after navigation: {current_url_after}")
if "member-details" in current_url_after:
print("[DDMA step2] Successfully navigated to member details page!")
else:
print(f"[DDMA step2] WARNING: Navigation might have redirected. Current URL: {current_url_after}")
# 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 member details content to load (wait for specific elements)
print("[DDMA step2] Waiting for member details content to fully load...")
content_loaded = False
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))
)
content_loaded = True
print(f"[DDMA step2] Content element found: {selector}")
break
except:
continue
if not content_loaded:
print("[DDMA step2] Warning: Could not verify content loaded, waiting extra time...")
# Additional wait for dynamic content and animations
time.sleep(5) # Increased from 2 to 5 seconds
# Print page title for debugging
try:
page_title = self.driver.title
print(f"[DDMA step2] Page title: {page_title}")
except:
pass
# Try to extract patient name from detailed page if not already found
if not patientName:
detail_name_selectors = [
"//h1",
"//h2",
"//*[contains(@class,'patient-name') or contains(@class,'member-name')]",
"//div[contains(@class,'header')]//span",
]
for selector in detail_name_selectors:
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:
continue
else:
print("[DDMA step2] Warning: Could not click on patient, capturing search results page")
# Still try to get patient name from search results if not already found
if not patientName:
try:
name_elem = self.driver.find_element(By.XPATH, "(//tbody//tr)[1]//td[1]")
patientName = name_elem.text.strip()
except:
pass
if not patientName:
print("[DDMA step2] Could not extract patient name")
else:
print(f"[DDMA step2] Patient name: {patientName}")
# Wait for page to fully load before generating PDF
try:
WebDriverWait(self.driver, 30).until(
lambda d: d.execute_script("return document.readyState") == "complete"
)
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
print("Screenshot saved at:", screenshot_path)
# Close the browser window after screenshot (session preserved in profile)
time.sleep(1)
# Generate PDF of the detailed patient page using 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, # Letter size in inches
"paperHeight": 11,
"marginTop": 0.4,
"marginBottom": 0.4,
"marginLeft": 0.4,
"marginRight": 0.4,
"scale": 0.9, # Slightly scale down to fit content
}
result = self.driver.execute_cdp_cmd("Page.printToPDF", pdf_options)
pdf_data = base64.b64decode(result.get('data', ''))
# Use foundMemberId for filename if available, otherwise fall back to input memberId
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 generation (session preserved in profile)
try:
from ddma_browser_manager import get_browser_manager
get_browser_manager().quit_driver()
@@ -345,11 +677,23 @@ class AutomationDeltaDentalMAEligibilityCheck:
except Exception as e:
print(f"[step2] Error closing browser: {e}")
# Clean patient name - remove DOB if it was included (already cleaned above but double check)
if patientName:
# Remove "DOB: MM/DD/YYYY" or similar patterns from the name
cleaned_name = re.sub(r'\s*DOB[:\s]*\d{1,2}/\d{1,2}/\d{2,4}\s*', '', patientName, flags=re.IGNORECASE).strip()
if cleaned_name:
patientName = cleaned_name
print(f"[DDMA step2] Cleaned patient name: {patientName}")
print(f"[DDMA step2] Final data - PatientName: '{patientName}', MemberID: '{foundMemberId}'")
output = {
"status": "success",
"eligibility": eligibilityText,
"ss_path": screenshot_path,
"patientName":patientName
"ss_path": pdf_path, # Keep key as ss_path for backward compatibility
"pdf_path": pdf_path, # Also add explicit pdf_path
"patientName": patientName,
"memberId": foundMemberId # Include extracted Member ID
}
return output
except Exception as e:

View File

@@ -0,0 +1,686 @@
from selenium import webdriver
from selenium.common.exceptions import WebDriverException, TimeoutException
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from webdriver_manager.chrome import ChromeDriverManager
import time
import os
import base64
import re
import glob
from deltains_browser_manager import get_browser_manager
LOGIN_URL = "https://www.deltadentalins.com/ciam/login?TARGET=%2Fprovider-tools%2Fv2"
PROVIDER_TOOLS_URL = "https://www.deltadentalins.com/provider-tools/v2"
class AutomationDeltaInsEligibilityCheck:
def __init__(self, data):
self.headless = False
self.driver = None
self.data = data.get("data", {}) if isinstance(data, dict) else {}
self.memberId = self.data.get("memberId", "")
self.dateOfBirth = self.data.get("dateOfBirth", "")
self.firstName = self.data.get("firstName", "")
self.lastName = self.data.get("lastName", "")
self.deltains_username = self.data.get("deltains_username", "")
self.deltains_password = self.data.get("deltains_password", "")
self.download_dir = get_browser_manager().download_dir
os.makedirs(self.download_dir, exist_ok=True)
def config_driver(self):
self.driver = get_browser_manager().get_driver(self.headless)
def _dismiss_cookie_banner(self):
try:
accept_btn = WebDriverWait(self.driver, 5).until(
EC.element_to_be_clickable((By.ID, "onetrust-accept-btn-handler"))
)
accept_btn.click()
print("[DeltaIns login] Dismissed cookie consent banner")
time.sleep(1)
except TimeoutException:
print("[DeltaIns login] No cookie consent banner found")
except Exception as e:
print(f"[DeltaIns login] Error dismissing cookie banner: {e}")
def _force_logout(self):
try:
print("[DeltaIns login] Forcing logout due to credential change...")
browser_manager = get_browser_manager()
try:
self.driver.delete_all_cookies()
print("[DeltaIns login] Cleared all cookies")
except Exception as e:
print(f"[DeltaIns login] Error clearing cookies: {e}")
browser_manager.clear_credentials_hash()
print("[DeltaIns login] Logout complete")
return True
except Exception as e:
print(f"[DeltaIns login] Error during forced logout: {e}")
return False
def login(self, url):
"""
Multi-step login flow for DeltaIns (Okta-based):
1. Enter username (name='identifier') -> click Next
2. Enter password (type='password') -> click Submit
3. Handle MFA: click 'Send me an email' -> wait for OTP
Returns: ALREADY_LOGGED_IN, SUCCESS, OTP_REQUIRED, or ERROR:...
"""
wait = WebDriverWait(self.driver, 30)
browser_manager = get_browser_manager()
try:
if self.deltains_username and browser_manager.credentials_changed(self.deltains_username):
self._force_logout()
self.driver.get(url)
time.sleep(3)
# First, try navigating to provider-tools directly (not login URL)
# This avoids triggering Okta password re-verification when session is valid
try:
current_url = self.driver.current_url
print(f"[DeltaIns login] Current URL: {current_url}")
if "provider-tools" in current_url and "login" not in current_url.lower() and "ciam" not in current_url.lower():
print("[DeltaIns login] Already on provider tools page - logged in")
return "ALREADY_LOGGED_IN"
except Exception as e:
print(f"[DeltaIns login] Error checking current state: {e}")
# Navigate to provider-tools URL first to check if session is still valid
print("[DeltaIns login] Trying provider-tools URL to check session...")
self.driver.get(PROVIDER_TOOLS_URL)
time.sleep(5)
current_url = self.driver.current_url
print(f"[DeltaIns login] After provider-tools nav URL: {current_url}")
if "provider-tools" in current_url and "login" not in current_url.lower() and "ciam" not in current_url.lower():
print("[DeltaIns login] Session still valid - already logged in")
return "ALREADY_LOGGED_IN"
# Session expired or not logged in - navigate to login URL
print("[DeltaIns login] Session not valid, navigating to login page...")
self.driver.get(url)
time.sleep(3)
current_url = self.driver.current_url
print(f"[DeltaIns login] After login nav URL: {current_url}")
if "provider-tools" in current_url and "login" not in current_url.lower() and "ciam" not in current_url.lower():
print("[DeltaIns login] Already logged in - on provider tools")
return "ALREADY_LOGGED_IN"
self._dismiss_cookie_banner()
# Step 1: Username entry (name='identifier')
print("[DeltaIns login] Looking for username field...")
username_entered = False
for sel in [
(By.NAME, "identifier"),
(By.ID, "okta-signin-username"),
(By.XPATH, "//input[@type='text' and @autocomplete='username']"),
(By.XPATH, "//input[@type='text']"),
]:
try:
field = WebDriverWait(self.driver, 8).until(EC.presence_of_element_located(sel))
if field.is_displayed():
field.clear()
field.send_keys(self.deltains_username)
username_entered = True
print(f"[DeltaIns login] Username entered via {sel}")
break
except Exception:
continue
if not username_entered:
return "ERROR: Could not find username field"
# Click Next/Submit
time.sleep(1)
for sel in [
(By.XPATH, "//input[@type='submit' and @value='Next']"),
(By.XPATH, "//input[@type='submit']"),
(By.XPATH, "//button[@type='submit']"),
]:
try:
btn = self.driver.find_element(*sel)
if btn.is_displayed():
btn.click()
print(f"[DeltaIns login] Clicked Next via {sel}")
break
except Exception:
continue
time.sleep(4)
current_url = self.driver.current_url
if "provider-tools" in current_url and "login" not in current_url.lower() and "ciam" not in current_url.lower():
return "ALREADY_LOGGED_IN"
# Step 2: Password entry
print("[DeltaIns login] Looking for password field...")
pw_entered = False
for sel in [
(By.XPATH, "//input[@type='password']"),
(By.ID, "okta-signin-password"),
(By.NAME, "password"),
]:
try:
field = WebDriverWait(self.driver, 10).until(EC.presence_of_element_located(sel))
if field.is_displayed():
field.clear()
field.send_keys(self.deltains_password)
pw_entered = True
print(f"[DeltaIns login] Password entered via {sel}")
break
except Exception:
continue
if not pw_entered:
current_url = self.driver.current_url
if "provider-tools" in current_url and "login" not in current_url.lower():
return "ALREADY_LOGGED_IN"
return "ERROR: Password field not found"
# Click Sign In
time.sleep(1)
for sel in [
(By.ID, "okta-signin-submit"),
(By.XPATH, "//input[@type='submit']"),
(By.XPATH, "//button[@type='submit']"),
]:
try:
btn = self.driver.find_element(*sel)
if btn.is_displayed():
btn.click()
print(f"[DeltaIns login] Clicked Sign In via {sel}")
break
except Exception:
continue
if self.deltains_username:
browser_manager.save_credentials_hash(self.deltains_username)
time.sleep(6)
current_url = self.driver.current_url
print(f"[DeltaIns login] After password submit URL: {current_url}")
if "provider-tools" in current_url and "login" not in current_url.lower() and "ciam" not in current_url.lower():
print("[DeltaIns login] Login successful - on provider tools")
return "SUCCESS"
# Step 3: MFA handling
# There are two possible MFA pages:
# A) Method selection: "Verify it's you with a security method" with Email/Phone Select buttons
# B) Direct: "Send me an email" button
print("[DeltaIns login] Handling MFA...")
# Check for method selection page first (Email "Select" link)
# The Okta MFA page uses <a> tags (not buttons/inputs) with class "select-factor"
# inside <div data-se="okta_email"> for Email selection
try:
body_text = self.driver.find_element(By.TAG_NAME, "body").text
if "security method" in body_text.lower() or "select from the following" in body_text.lower():
print("[DeltaIns login] MFA method selection page detected")
email_select = None
for sel in [
(By.CSS_SELECTOR, "div[data-se='okta_email'] a.select-factor"),
(By.XPATH, "//div[@data-se='okta_email']//a[contains(@class,'select-factor')]"),
(By.XPATH, "//a[contains(@aria-label,'Select Email')]"),
(By.XPATH, "//div[@data-se='okta_email']//a[@data-se='button']"),
(By.CSS_SELECTOR, "a.select-factor.link-button"),
]:
try:
btn = self.driver.find_element(*sel)
if btn.is_displayed():
email_select = btn
print(f"[DeltaIns login] Found Email Select via {sel}")
break
except Exception:
continue
if email_select:
email_select.click()
print("[DeltaIns login] Clicked 'Select' for Email MFA")
time.sleep(5)
else:
print("[DeltaIns login] Could not find Email Select button")
except Exception as e:
print(f"[DeltaIns login] Error checking MFA method selection: {e}")
# Now look for "Send me an email" button (may appear after method selection or directly)
try:
send_btn = WebDriverWait(self.driver, 8).until(
EC.element_to_be_clickable((By.XPATH,
"//input[@type='submit' and @value='Send me an email'] | "
"//input[@value='Send me an email'] | "
"//button[contains(text(),'Send me an email')]"))
)
send_btn.click()
print("[DeltaIns login] Clicked 'Send me an email'")
time.sleep(5)
except TimeoutException:
print("[DeltaIns login] No 'Send me an email' button, checking for OTP input...")
# Step 4: OTP entry page
try:
otp_input = WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located((By.XPATH,
"//input[@name='credentials.passcode' and @type='text'] | "
"//input[contains(@name,'passcode')]"))
)
print("[DeltaIns login] OTP input found -> OTP_REQUIRED")
return "OTP_REQUIRED"
except TimeoutException:
pass
current_url = self.driver.current_url
if "provider-tools" in current_url and "login" not in current_url.lower() and "ciam" not in current_url.lower():
return "SUCCESS"
try:
error_elem = self.driver.find_element(By.XPATH,
"//*[contains(@class,'error') or contains(@class,'alert-error')]")
error_text = error_elem.text.strip()[:200]
if error_text:
return f"ERROR: {error_text}"
except Exception:
pass
print("[DeltaIns login] Could not determine login state - returning OTP_REQUIRED as fallback")
return "OTP_REQUIRED"
except Exception as e:
print(f"[DeltaIns login] Exception: {e}")
return f"ERROR:LOGIN FAILED: {e}"
def _format_dob(self, dob_str):
"""Convert DOB from YYYY-MM-DD to MM/DD/YYYY format."""
if dob_str and "-" in dob_str:
dob_parts = dob_str.split("-")
if len(dob_parts) == 3:
return f"{dob_parts[1]}/{dob_parts[2]}/{dob_parts[0]}"
return dob_str
def _close_browser(self):
"""Save cookies and close the browser after task completion."""
browser_manager = get_browser_manager()
try:
browser_manager.save_cookies()
except Exception as e:
print(f"[DeltaIns] Failed to save cookies before close: {e}")
try:
browser_manager.quit_driver()
print("[DeltaIns] Browser closed")
except Exception as e:
print(f"[DeltaIns] Could not close browser: {e}")
def step1(self):
"""
Navigate to Eligibility search, enter patient info, search, and
click 'Check eligibility and benefits' on the result card.
Search flow:
1. Click 'Eligibility and benefits' link
2. Click 'Search for a new patient' button
3. Click 'Search by member ID' tab
4. Enter Member ID in #memberId
5. Enter DOB in #dob (MM/DD/YYYY)
6. Click Search
7. Extract patient info from result card
8. Click 'Check eligibility and benefits'
"""
try:
formatted_dob = self._format_dob(self.dateOfBirth)
print(f"[DeltaIns step1] Starting — memberId={self.memberId}, DOB={formatted_dob}")
# 1. Click "Eligibility and benefits" link
print("[DeltaIns step1] Clicking 'Eligibility and benefits'...")
try:
elig_link = WebDriverWait(self.driver, 15).until(
EC.element_to_be_clickable((By.XPATH,
"//a[contains(text(),'Eligibility and benefits')] | "
"//a[contains(text(),'Eligibility')]"))
)
elig_link.click()
time.sleep(5)
print("[DeltaIns step1] Clicked Eligibility link")
except TimeoutException:
print("[DeltaIns step1] No Eligibility link found, checking if already on page...")
if "patient-search" not in self.driver.current_url and "eligibility" not in self.driver.current_url:
self.driver.get("https://www.deltadentalins.com/provider-tools/v2/patient-search")
time.sleep(5)
# 2. Click "Search for a new patient" button
print("[DeltaIns step1] Clicking 'Search for a new patient'...")
try:
new_patient_btn = WebDriverWait(self.driver, 10).until(
EC.element_to_be_clickable((By.XPATH,
"//button[contains(text(),'Search for a new patient')]"))
)
new_patient_btn.click()
time.sleep(3)
print("[DeltaIns step1] Clicked 'Search for a new patient'")
except TimeoutException:
print("[DeltaIns step1] 'Search for a new patient' button not found - may already be on search page")
# 3. Click "Search by member ID" tab
print("[DeltaIns step1] Clicking 'Search by member ID' tab...")
try:
member_id_tab = WebDriverWait(self.driver, 10).until(
EC.element_to_be_clickable((By.XPATH,
"//button[contains(text(),'Search by member ID')]"))
)
member_id_tab.click()
time.sleep(2)
print("[DeltaIns step1] Clicked 'Search by member ID' tab")
except TimeoutException:
print("[DeltaIns step1] 'Search by member ID' tab not found")
return "ERROR: Could not find 'Search by member ID' tab"
# 4. Enter Member ID
print(f"[DeltaIns step1] Entering Member ID: {self.memberId}")
try:
mid_field = WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located((By.ID, "memberId"))
)
mid_field.click()
mid_field.send_keys(Keys.CONTROL + "a")
mid_field.send_keys(Keys.DELETE)
time.sleep(0.3)
mid_field.send_keys(self.memberId)
time.sleep(0.5)
print(f"[DeltaIns step1] Member ID entered: '{mid_field.get_attribute('value')}'")
except TimeoutException:
return "ERROR: Member ID field not found"
# 5. Enter DOB
print(f"[DeltaIns step1] Entering DOB: {formatted_dob}")
try:
dob_field = self.driver.find_element(By.ID, "dob")
dob_field.click()
dob_field.send_keys(Keys.CONTROL + "a")
dob_field.send_keys(Keys.DELETE)
time.sleep(0.3)
dob_field.send_keys(formatted_dob)
time.sleep(0.5)
print(f"[DeltaIns step1] DOB entered: '{dob_field.get_attribute('value')}'")
except Exception as e:
return f"ERROR: DOB field not found: {e}"
# 6. Click Search
print("[DeltaIns step1] Clicking Search...")
try:
search_btn = self.driver.find_element(By.XPATH,
"//button[@type='submit'][contains(text(),'Search')] | "
"//button[@data-testid='searchButton']")
search_btn.click()
time.sleep(10)
print("[DeltaIns step1] Search clicked")
except Exception as e:
return f"ERROR: Search button not found: {e}"
# 7. Check for results - look for patient card
print("[DeltaIns step1] Checking for results...")
try:
patient_card = WebDriverWait(self.driver, 15).until(
EC.presence_of_element_located((By.XPATH,
"//div[contains(@class,'patient-card-root')] | "
"//div[@data-testid='patientCard'] | "
"//div[starts-with(@data-testid,'patientCard')]"))
)
print("[DeltaIns step1] Patient card found!")
# Extract patient name
try:
name_el = patient_card.find_element(By.XPATH, ".//h3")
patient_name = name_el.text.strip()
print(f"[DeltaIns step1] Patient name: {patient_name}")
except Exception:
patient_name = ""
# Extract eligibility dates
try:
elig_el = patient_card.find_element(By.XPATH,
".//*[@data-testid='patientCardMemberEligibility']//*[contains(@class,'pt-staticfield-text')]")
elig_text = elig_el.text.strip()
print(f"[DeltaIns step1] Eligibility: {elig_text}")
except Exception:
elig_text = ""
# Store for step2
self._patient_name = patient_name
self._eligibility_text = elig_text
except TimeoutException:
# Check for error messages
try:
body_text = self.driver.find_element(By.TAG_NAME, "body").text
if "no results" in body_text.lower() or "not found" in body_text.lower() or "no patient" in body_text.lower():
return "ERROR: No patient found with the provided Member ID and DOB"
# Check for specific error alerts
alerts = self.driver.find_elements(By.XPATH, "//*[@role='alert']")
for alert in alerts:
if alert.is_displayed():
return f"ERROR: {alert.text.strip()[:200]}"
except Exception:
pass
return "ERROR: No patient results found within timeout"
# 8. Click "Check eligibility and benefits"
print("[DeltaIns step1] Clicking 'Check eligibility and benefits'...")
try:
check_btn = WebDriverWait(self.driver, 10).until(
EC.element_to_be_clickable((By.XPATH,
"//button[contains(text(),'Check eligibility and benefits')] | "
"//button[@data-testid='eligibilityBenefitsButton']"))
)
check_btn.click()
time.sleep(10)
print(f"[DeltaIns step1] Navigated to: {self.driver.current_url}")
except TimeoutException:
return "ERROR: 'Check eligibility and benefits' button not found"
return "SUCCESS"
except Exception as e:
print(f"[DeltaIns step1] Exception: {e}")
return f"ERROR: step1 failed: {e}"
def step2(self):
"""
Extract eligibility information and capture PDF from the
Eligibility & Benefits detail page.
URL: .../provider-tools/v2/eligibility-benefits
Extracts:
- Patient name from h3 in patient-card-header
- DOB, Member ID, eligibility from data-testid fields
- PDF via Page.printToPDF
"""
try:
print("[DeltaIns step2] Extracting eligibility data...")
time.sleep(3)
current_url = self.driver.current_url
print(f"[DeltaIns step2] URL: {current_url}")
if "eligibility-benefits" not in current_url:
print("[DeltaIns step2] Not on eligibility page, checking body text...")
# Extract patient name
patientName = ""
try:
name_el = self.driver.find_element(By.XPATH,
"//div[contains(@class,'patient-card-header')]//h3 | "
"//div[starts-with(@data-testid,'patientCard')]//h3")
patientName = name_el.text.strip()
print(f"[DeltaIns step2] Patient name: {patientName}")
except Exception:
patientName = getattr(self, '_patient_name', '') or f"{self.firstName} {self.lastName}".strip()
print(f"[DeltaIns step2] Using stored/fallback name: {patientName}")
# Extract DOB from card
extractedDob = ""
try:
dob_el = self.driver.find_element(By.XPATH,
"//*[@data-testid='patientCardDateOfBirth']//*[contains(@class,'pt-staticfield-text')]")
extractedDob = dob_el.text.strip()
print(f"[DeltaIns step2] DOB: {extractedDob}")
except Exception:
extractedDob = self._format_dob(self.dateOfBirth)
# Extract Member ID from card
foundMemberId = ""
try:
mid_el = self.driver.find_element(By.XPATH,
"//*[@data-testid='patientCardMemberId']//*[contains(@class,'pt-staticfield-text')]")
foundMemberId = mid_el.text.strip()
print(f"[DeltaIns step2] Member ID: {foundMemberId}")
except Exception:
foundMemberId = self.memberId
# Extract eligibility status
eligibility = "Unknown"
try:
elig_el = self.driver.find_element(By.XPATH,
"//*[@data-testid='patientCardMemberEligibility']//*[contains(@class,'pt-staticfield-text')]")
elig_text = elig_el.text.strip()
print(f"[DeltaIns step2] Eligibility text: {elig_text}")
if "present" in elig_text.lower():
eligibility = "Eligible"
elif elig_text:
eligibility = elig_text
except Exception:
elig_text = getattr(self, '_eligibility_text', '')
if elig_text and "present" in elig_text.lower():
eligibility = "Eligible"
elif elig_text:
eligibility = elig_text
# Check page body for additional eligibility info
try:
body_text = self.driver.find_element(By.TAG_NAME, "body").text
if "not eligible" in body_text.lower():
eligibility = "Not Eligible"
elif "terminated" in body_text.lower():
eligibility = "Terminated"
except Exception:
pass
# Capture PDF via "Download summary" -> "Download PDF" button
pdfBase64 = ""
try:
existing_files = set(glob.glob(os.path.join(self.download_dir, "*")))
dl_link = WebDriverWait(self.driver, 10).until(
EC.element_to_be_clickable((By.XPATH,
"//a[@data-testid='downloadBenefitSummaryLink']"))
)
dl_link.click()
print("[DeltaIns step2] Clicked 'Download summary'")
time.sleep(3)
dl_btn = WebDriverWait(self.driver, 10).until(
EC.element_to_be_clickable((By.XPATH,
"//button[@data-testid='downloadPdfButton']"))
)
dl_btn.click()
print("[DeltaIns step2] Clicked 'Download PDF'")
pdf_path = None
for i in range(30):
time.sleep(2)
current_files = set(glob.glob(os.path.join(self.download_dir, "*")))
new_files = current_files - existing_files
completed = [f for f in new_files
if not f.endswith(".crdownload") and not f.endswith(".tmp")]
if completed:
pdf_path = completed[0]
break
if pdf_path and os.path.exists(pdf_path):
with open(pdf_path, "rb") as f:
pdfBase64 = base64.b64encode(f.read()).decode()
print(f"[DeltaIns step2] PDF downloaded: {os.path.basename(pdf_path)} "
f"({os.path.getsize(pdf_path)} bytes), b64 len={len(pdfBase64)}")
try:
os.remove(pdf_path)
except Exception:
pass
else:
print("[DeltaIns step2] Download PDF timed out, falling back to CDP")
cdp_result = self.driver.execute_cdp_cmd("Page.printToPDF", {
"printBackground": True,
"preferCSSPageSize": True,
"scale": 0.7,
"paperWidth": 11,
"paperHeight": 17,
})
pdfBase64 = cdp_result.get("data", "")
print(f"[DeltaIns step2] CDP fallback PDF, b64 len={len(pdfBase64)}")
# Dismiss the download modal
try:
self.driver.find_element(By.TAG_NAME, "body").send_keys(Keys.ESCAPE)
time.sleep(1)
except Exception:
pass
except Exception as e:
print(f"[DeltaIns step2] PDF capture failed: {e}")
try:
cdp_result = self.driver.execute_cdp_cmd("Page.printToPDF", {
"printBackground": True,
"preferCSSPageSize": True,
"scale": 0.7,
"paperWidth": 11,
"paperHeight": 17,
})
pdfBase64 = cdp_result.get("data", "")
print(f"[DeltaIns step2] CDP fallback PDF, b64 len={len(pdfBase64)}")
except Exception as e2:
print(f"[DeltaIns step2] CDP fallback also failed: {e2}")
# Hide browser after completion
self._close_browser()
result = {
"status": "success",
"patientName": patientName,
"eligibility": eligibility,
"pdfBase64": pdfBase64,
"extractedDob": extractedDob,
"memberId": foundMemberId,
}
print(f"[DeltaIns step2] Result: name={result['patientName']}, "
f"eligibility={result['eligibility']}, "
f"memberId={result['memberId']}")
return result
except Exception as e:
print(f"[DeltaIns step2] Exception: {e}")
self._close_browser()
return {
"status": "error",
"patientName": getattr(self, '_patient_name', '') or f"{self.firstName} {self.lastName}".strip(),
"eligibility": "Unknown",
"pdfBase64": "",
"extractedDob": self._format_dob(self.dateOfBirth),
"memberId": self.memberId,
"error": str(e),
}

View File

@@ -0,0 +1,785 @@
from selenium import webdriver
from selenium.common.exceptions import WebDriverException, TimeoutException
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from webdriver_manager.chrome import ChromeDriverManager
import time
import os
import base64
from dentaquest_browser_manager import get_browser_manager
class AutomationDentaQuestEligibilityCheck:
def __init__(self, data):
self.headless = False
self.driver = None
self.data = data.get("data", {}) if isinstance(data, dict) else {}
# Flatten values for convenience
self.memberId = self.data.get("memberId", "")
self.dateOfBirth = self.data.get("dateOfBirth", "")
self.firstName = self.data.get("firstName", "")
self.lastName = self.data.get("lastName", "")
self.dentaquest_username = self.data.get("dentaquestUsername", "")
self.dentaquest_password = self.data.get("dentaquestPassword", "")
# Use browser manager's download dir
self.download_dir = get_browser_manager().download_dir
os.makedirs(self.download_dir, exist_ok=True)
def config_driver(self):
# Use persistent browser from manager (keeps device trust tokens)
self.driver = get_browser_manager().get_driver(self.headless)
def _force_logout(self):
"""Force logout by clearing cookies for DentaQuest domain."""
try:
print("[DentaQuest login] Forcing logout due to credential change...")
browser_manager = get_browser_manager()
# First try to click logout button if visible
try:
self.driver.get("https://providers.dentaquest.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("[DentaQuest login] Clicked logout button")
time.sleep(2)
break
except TimeoutException:
continue
except Exception as e:
print(f"[DentaQuest login] Could not click logout button: {e}")
# Clear cookies as backup
try:
self.driver.delete_all_cookies()
print("[DentaQuest login] Cleared all cookies")
except Exception as e:
print(f"[DentaQuest login] Error clearing cookies: {e}")
browser_manager.clear_credentials_hash()
print("[DentaQuest login] Logout complete")
return True
except Exception as e:
print(f"[DentaQuest login] Error during forced logout: {e}")
return False
def login(self, url):
wait = WebDriverWait(self.driver, 30)
browser_manager = get_browser_manager()
try:
# Check if credentials have changed - if so, force logout first
if self.dentaquest_username and browser_manager.credentials_changed(self.dentaquest_username):
self._force_logout()
self.driver.get(url)
time.sleep(2)
# First check if we're already on a logged-in page (from previous run)
try:
current_url = self.driver.current_url
print(f"[DentaQuest login] Current URL: {current_url}")
# Check if we're already on dashboard with member search
if "dashboard" in current_url.lower() or "member" in current_url.lower():
try:
member_search = WebDriverWait(self.driver, 3).until(
EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]'))
)
print("[DentaQuest login] Already on dashboard with member search")
return "ALREADY_LOGGED_IN"
except TimeoutException:
pass
except Exception as e:
print(f"[DentaQuest login] Error checking current state: {e}")
# Navigate to login URL
self.driver.get(url)
time.sleep(3)
current_url = self.driver.current_url
print(f"[DentaQuest login] After navigation URL: {current_url}")
# If already on dashboard, we're logged in
if "dashboard" in current_url.lower():
print("[DentaQuest login] Already on dashboard")
return "ALREADY_LOGGED_IN"
# Try to dismiss the modal by clicking OK
try:
ok_button = WebDriverWait(self.driver, 5).until(
EC.element_to_be_clickable((By.XPATH, "//button[normalize-space(text())='Ok' or normalize-space(text())='OK' or normalize-space(text())='Continue']"))
)
ok_button.click()
print("[DentaQuest login] Clicked OK modal button")
time.sleep(3)
except TimeoutException:
print("[DentaQuest login] No OK modal button found")
# Check if we're now on dashboard (session was valid)
current_url = self.driver.current_url
print(f"[DentaQuest login] After modal click URL: {current_url}")
if "dashboard" in current_url.lower():
# Check for member search input to confirm logged in
try:
member_search = WebDriverWait(self.driver, 5).until(
EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]'))
)
print("[DentaQuest login] Session valid - on dashboard with member search")
return "ALREADY_LOGGED_IN"
except TimeoutException:
pass
# Check if OTP is required (popup window or OTP input)
if len(self.driver.window_handles) > 1:
original_window = self.driver.current_window_handle
for window in self.driver.window_handles:
if window != original_window:
self.driver.switch_to.window(window)
print("[DentaQuest login] Switched to popup window")
break
try:
otp_input = WebDriverWait(self.driver, 5).until(
EC.presence_of_element_located((By.XPATH, "//input[@type='tel' or contains(@placeholder,'code') or contains(@aria-label,'Verification')]"))
)
print("[DentaQuest login] OTP input found in popup")
return "OTP_REQUIRED"
except TimeoutException:
self.driver.switch_to.window(original_window)
# Check for OTP input on main page
try:
otp_input = WebDriverWait(self.driver, 3).until(
EC.presence_of_element_located((By.XPATH, "//input[@type='tel' or contains(@placeholder,'code') or contains(@aria-label,'Verification')]"))
)
print("[DentaQuest login] OTP input found")
return "OTP_REQUIRED"
except TimeoutException:
pass
# If still on login page, need to fill credentials
if "onboarding" in current_url.lower() or "login" in current_url.lower():
print("[DentaQuest login] Need to fill login credentials")
try:
email_field = WebDriverWait(self.driver, 10).until(
EC.element_to_be_clickable((By.XPATH, "//input[@name='username' or @type='text']"))
)
email_field.clear()
email_field.send_keys(self.dentaquest_username)
password_field = wait.until(EC.presence_of_element_located((By.XPATH, "//input[@type='password']")))
password_field.clear()
password_field.send_keys(self.dentaquest_password)
# Click login button
login_button = wait.until(EC.element_to_be_clickable((By.XPATH, "//button[@type='submit']")))
login_button.click()
print("[DentaQuest login] Submitted login form")
# Save credentials hash after login attempt
if self.dentaquest_username:
browser_manager.save_credentials_hash(self.dentaquest_username)
# OTP detection - wait up to 30 seconds for OTP input to appear (like Delta MA)
# Use comprehensive XPath to detect various OTP input patterns
try:
otp_input = WebDriverWait(self.driver, 30).until(
EC.presence_of_element_located((By.XPATH,
"//input[@type='tel' or contains(@placeholder,'code') or contains(@placeholder,'Code') or "
"contains(@aria-label,'Verification') or contains(@aria-label,'verification') or "
"contains(@aria-label,'Code') or contains(@aria-label,'code') or "
"contains(@placeholder,'verification') or contains(@placeholder,'Verification') or "
"contains(@name,'otp') or contains(@name,'code') or contains(@id,'otp') or contains(@id,'code')]"
))
)
print("[DentaQuest login] OTP input detected -> OTP_REQUIRED")
return "OTP_REQUIRED"
except TimeoutException:
print("[DentaQuest login] No OTP input detected in 30 seconds")
# Check if login succeeded (redirected to dashboard or member search)
current_url_after_login = self.driver.current_url.lower()
print(f"[DentaQuest login] After login URL: {current_url_after_login}")
if "dashboard" in current_url_after_login or "member" in current_url_after_login:
# Verify by checking for member search input
try:
member_search = WebDriverWait(self.driver, 5).until(
EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]'))
)
print("[DentaQuest login] Login successful - now on member search page")
return "SUCCESS"
except TimeoutException:
pass
# Still on login page - login failed
if "onboarding" in current_url_after_login or "login" in current_url_after_login:
print("[DentaQuest login] Login failed - still on login page")
return "ERROR: Login failed - check credentials"
except TimeoutException:
print("[DentaQuest login] Login form elements not found")
return "ERROR: Login form not found"
# If we got here without going through login, we're already logged in
return "SUCCESS"
except Exception as e:
print(f"[DentaQuest login] Exception: {e}")
return f"ERROR:LOGIN FAILED: {e}"
def step1(self):
"""Navigate to member search - fills all available fields (Member ID, First Name, Last Name, DOB)"""
wait = WebDriverWait(self.driver, 30)
try:
# Log what fields are available for search
fields = []
if self.memberId:
fields.append(f"ID: {self.memberId}")
if self.firstName:
fields.append(f"FirstName: {self.firstName}")
if self.lastName:
fields.append(f"LastName: {self.lastName}")
fields.append(f"DOB: {self.dateOfBirth}")
print(f"[DentaQuest step1] Starting member search with: {', '.join(fields)}")
# Wait for page to be ready
time.sleep(2)
# Parse DOB - format: YYYY-MM-DD
try:
dob_parts = self.dateOfBirth.split("-")
dob_year = dob_parts[0]
dob_month = dob_parts[1].zfill(2)
dob_day = dob_parts[2].zfill(2)
print(f"[DentaQuest step1] Parsed DOB: {dob_month}/{dob_day}/{dob_year}")
except Exception as e:
print(f"[DentaQuest step1] Error parsing DOB: {e}")
return "ERROR: PARSING DOB"
# Helper function to fill contenteditable date spans within a specific container
def fill_date_by_testid(testid, month_val, day_val, year_val, field_name):
try:
container = self.driver.find_element(By.XPATH, f"//div[@data-testid='{testid}']")
month_elem = container.find_element(By.XPATH, ".//span[@data-type='month' and @contenteditable='true']")
day_elem = container.find_element(By.XPATH, ".//span[@data-type='day' and @contenteditable='true']")
year_elem = container.find_element(By.XPATH, ".//span[@data-type='year' and @contenteditable='true']")
def replace_with_sendkeys(el, value):
el.click()
time.sleep(0.05)
el.send_keys(Keys.CONTROL, "a")
el.send_keys(Keys.BACKSPACE)
el.send_keys(value)
replace_with_sendkeys(month_elem, month_val)
time.sleep(0.1)
replace_with_sendkeys(day_elem, day_val)
time.sleep(0.1)
replace_with_sendkeys(year_elem, year_val)
print(f"[DentaQuest step1] Filled {field_name}: {month_val}/{day_val}/{year_val}")
return True
except Exception as e:
print(f"[DentaQuest step1] Error filling {field_name}: {e}")
return False
# 1. Select Provider from dropdown (required field)
try:
print("[DentaQuest step1] Selecting Provider...")
# Try to find and click Provider dropdown
provider_selectors = [
"//label[contains(text(),'Provider')]/following-sibling::*//div[contains(@class,'select')]",
"//div[contains(@data-testid,'provider')]//div[contains(@class,'select')]",
"//*[@aria-label='Provider']",
"//select[contains(@name,'provider') or contains(@id,'provider')]",
"//div[contains(@class,'provider')]//input",
"//label[contains(text(),'Provider')]/..//div[contains(@class,'control')]"
]
provider_clicked = False
for selector in provider_selectors:
try:
provider_dropdown = WebDriverWait(self.driver, 3).until(
EC.element_to_be_clickable((By.XPATH, selector))
)
provider_dropdown.click()
print(f"[DentaQuest step1] Clicked provider dropdown with selector: {selector}")
time.sleep(0.5)
provider_clicked = True
break
except TimeoutException:
continue
if provider_clicked:
# Select first available provider option
option_selectors = [
"//div[contains(@class,'option') and not(contains(@class,'disabled'))]",
"//li[contains(@class,'option')]",
"//option[not(@disabled)]",
"//*[@role='option']"
]
for opt_selector in option_selectors:
try:
options = self.driver.find_elements(By.XPATH, opt_selector)
if options:
# Select first non-placeholder option
for opt in options:
opt_text = opt.text.strip()
if opt_text and "select" not in opt_text.lower():
opt.click()
print(f"[DentaQuest step1] Selected provider: {opt_text}")
break
break
except:
continue
# Close dropdown if still open
ActionChains(self.driver).send_keys(Keys.ESCAPE).perform()
time.sleep(0.3)
else:
print("[DentaQuest step1] Warning: Could not find Provider dropdown")
except Exception as e:
print(f"[DentaQuest step1] Error selecting provider: {e}")
time.sleep(0.3)
# 2. Fill Date of Birth with patient's DOB using specific data-testid
fill_date_by_testid("member-search_date-of-birth", dob_month, dob_day, dob_year, "Date of Birth")
time.sleep(0.3)
# 3. Fill ALL available search fields (flexible search)
# Fill Member ID if provided
if self.memberId:
try:
member_id_input = wait.until(EC.presence_of_element_located(
(By.XPATH, '//input[@placeholder="Search by member ID"]')
))
member_id_input.clear()
member_id_input.send_keys(self.memberId)
print(f"[DentaQuest step1] Entered member ID: {self.memberId}")
time.sleep(0.2)
except Exception as e:
print(f"[DentaQuest step1] Warning: Could not fill member ID: {e}")
# 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") or contains(@id,"firstName")]')
))
first_name_input.clear()
first_name_input.send_keys(self.firstName)
print(f"[DentaQuest step1] Entered first name: {self.firstName}")
time.sleep(0.2)
except Exception as e:
print(f"[DentaQuest step1] Warning: Could not fill first name: {e}")
# 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") or contains(@id,"lastName")]')
))
last_name_input.clear()
last_name_input.send_keys(self.lastName)
print(f"[DentaQuest step1] Entered last name: {self.lastName}")
time.sleep(0.2)
except Exception as e:
print(f"[DentaQuest step1] Warning: Could not fill last name: {e}")
time.sleep(0.3)
# 4. Click Search button
try:
search_btn = wait.until(EC.element_to_be_clickable(
(By.XPATH, '//button[@data-testid="member-search_search-button"]')
))
search_btn.click()
print("[DentaQuest step1] Clicked search button")
except TimeoutException:
# Fallback
try:
search_btn = self.driver.find_element(By.XPATH, '//button[contains(text(),"Search")]')
search_btn.click()
print("[DentaQuest step1] Clicked search button (fallback)")
except:
# Press Enter on the last input field
ActionChains(self.driver).send_keys(Keys.RETURN).perform()
print("[DentaQuest step1] Pressed Enter to search")
time.sleep(5)
# Check for "no results" error
try:
error_msg = WebDriverWait(self.driver, 3).until(EC.presence_of_element_located(
(By.XPATH, '//*[contains(@data-testid,"no-results") or contains(@class,"no-results") or contains(text(),"No results") or contains(text(),"not found") or contains(text(),"No member found") or contains(text(),"Nothing was found")]')
))
if error_msg and error_msg.is_displayed():
print("[DentaQuest step1] No results found")
return "ERROR: INVALID SEARCH CRITERIA"
except TimeoutException:
pass
print("[DentaQuest step1] Search completed successfully")
return "Success"
except Exception as e:
print(f"[DentaQuest step1] Exception: {e}")
return f"ERROR:STEP1 - {e}"
def step2(self):
"""Get eligibility status, navigate to detail page, and capture PDF"""
wait = WebDriverWait(self.driver, 90)
try:
print("[DentaQuest step2] Starting eligibility capture")
# Wait for results table to load (use explicit wait instead of fixed sleep)
try:
WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located((By.XPATH, "//tbody//tr"))
)
except TimeoutException:
print("[DentaQuest step2] Warning: Results table not found within timeout")
# 1) Find and extract eligibility status and Member ID from search results
eligibilityText = "unknown"
foundMemberId = ""
# Try to extract Member ID from the first row of search results
# Row format: "NAME\nDOB: MM/DD/YYYY\nMEMBER_ID\n..."
import re
try:
first_row = self.driver.find_element(By.XPATH, "(//tbody//tr)[1]")
row_text = first_row.text.strip()
if row_text:
lines = row_text.split('\n')
# Member ID is typically the 3rd line (index 2) - a pure number
for line in lines:
line = line.strip()
# Member ID is usually a number, could be alphanumeric
# It should be after DOB line and be mostly digits
if line and re.match(r'^[A-Z0-9]{5,}$', line) and not line.startswith('DOB'):
foundMemberId = line
print(f"[DentaQuest step2] Extracted Member ID from row: {foundMemberId}")
break
# Fallback: if we have self.memberId from input, use that
if not foundMemberId and self.memberId:
foundMemberId = self.memberId
print(f"[DentaQuest step2] Using input Member ID: {foundMemberId}")
except Exception as e:
print(f"[DentaQuest step2] Error extracting Member ID: {e}")
# Fallback to input memberId
if self.memberId:
foundMemberId = self.memberId
# Extract eligibility status
status_selectors = [
"(//tbody//tr)[1]//a[contains(@href, 'eligibility')]",
"//a[contains(@href,'eligibility')]",
"//*[contains(@class,'status')]",
"//*[contains(text(),'Active') or contains(text(),'Inactive') or contains(text(),'Eligible')]"
]
for selector in status_selectors:
try:
status_elem = self.driver.find_element(By.XPATH, selector)
status_text = status_elem.text.strip().lower()
if status_text:
print(f"[DentaQuest step2] Found status with selector '{selector}': {status_text}")
if "active" in status_text or "eligible" in status_text:
eligibilityText = "active"
break
elif "inactive" in status_text or "ineligible" in status_text:
eligibilityText = "inactive"
break
except:
continue
print(f"[DentaQuest step2] Final eligibility status: {eligibilityText}")
# 2) Find the patient detail link and navigate DIRECTLY to it
print("[DentaQuest step2] Looking for patient detail link...")
patient_name_clicked = False
patientName = ""
detail_url = None
current_url_before = self.driver.current_url
print(f"[DentaQuest step2] Current URL before: {current_url_before}")
# Find all links in first row and log them
try:
all_links = self.driver.find_elements(By.XPATH, "(//tbody//tr)[1]//a")
print(f"[DentaQuest step2] Found {len(all_links)} links in first row:")
for i, link in enumerate(all_links):
href = link.get_attribute("href") or "no-href"
text = link.text.strip() or "(empty text)"
print(f" Link {i}: href={href[:80]}..., text={text}")
except Exception as e:
print(f"[DentaQuest step2] Error listing links: {e}")
# Find the patient detail link and extract patient name from row
patient_link_selectors = [
"(//table//tbody//tr)[1]//td[1]//a", # First column link
"(//tbody//tr)[1]//a[contains(@href, 'member-details')]", # member-details link
"(//tbody//tr)[1]//a[contains(@href, 'member')]", # Any member link
]
# First, try to extract patient name from the row text (not the link)
try:
first_row = self.driver.find_element(By.XPATH, "(//tbody//tr)[1]")
row_text = first_row.text.strip()
print(f"[DentaQuest step2] First row text: {row_text[:100]}...")
# The name is typically the first line, before "DOB:"
if row_text:
lines = row_text.split('\n')
if lines:
# First line is usually the patient name
potential_name = lines[0].strip()
# Make sure it's not a date or ID
if potential_name and not potential_name.startswith('DOB') and not potential_name.isdigit():
patientName = potential_name
print(f"[DentaQuest step2] Extracted patient name from row: '{patientName}'")
except Exception as e:
print(f"[DentaQuest step2] Error extracting name from row: {e}")
# Now find the detail link
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"[DentaQuest step2] Found patient link: text='{link_text}', href={href}")
# If link has text and we don't have patientName yet, use it
if link_text and not patientName:
patientName = link_text
if href and ("member-details" in href or "member" in href):
detail_url = href
patient_name_clicked = True
print(f"[DentaQuest step2] Will navigate directly to: {detail_url}")
break
except Exception as e:
print(f"[DentaQuest step2] Selector '{selector}' failed: {e}")
continue
if not detail_url:
# Fallback: Try to find ANY link to member-details
try:
all_links = self.driver.find_elements(By.XPATH, "//a[contains(@href, 'member')]")
if all_links:
detail_url = all_links[0].get_attribute("href")
patient_name_clicked = True
print(f"[DentaQuest step2] Found member link: {detail_url}")
except Exception as e:
print(f"[DentaQuest step2] Could not find member link: {e}")
# Navigate to detail page DIRECTLY
if patient_name_clicked and detail_url:
print(f"[DentaQuest step2] Navigating directly to detail page: {detail_url}")
self.driver.get(detail_url)
time.sleep(3) # Wait for page to load
current_url_after = self.driver.current_url
print(f"[DentaQuest step2] Current URL after navigation: {current_url_after}")
if "member-details" in current_url_after or "member" in current_url_after:
print("[DentaQuest step2] Successfully navigated to member details page!")
else:
print(f"[DentaQuest step2] WARNING: Navigation might have redirected. Current URL: {current_url_after}")
# Wait for page to be ready
try:
WebDriverWait(self.driver, 30).until(
lambda d: d.execute_script("return document.readyState") == "complete"
)
except Exception:
print("[DentaQuest step2] Warning: document.readyState did not become 'complete'")
# Wait for member details content to load
print("[DentaQuest step2] Waiting for member details content to fully load...")
content_loaded = False
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))
)
content_loaded = True
print(f"[DentaQuest step2] Content element found: {selector}")
break
except:
continue
if not content_loaded:
print("[DentaQuest step2] Warning: Could not verify content loaded, waiting extra time...")
# Additional wait for dynamic content
time.sleep(5)
# Try to extract patient name from detailed page if not already found
if not patientName:
detail_name_selectors = [
"//h1",
"//h2",
"//*[contains(@class,'patient-name') or contains(@class,'member-name')]",
"//div[contains(@class,'header')]//span",
]
for selector in detail_name_selectors:
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', 'member id']):
patientName = name_text
print(f"[DentaQuest step2] Found patient name on detail page: {patientName}")
break
except:
continue
else:
print("[DentaQuest step2] Warning: Could not find detail URL, capturing search results page")
# Still try to get patient name from search results
try:
name_elem = self.driver.find_element(By.XPATH, "(//tbody//tr)[1]//td[1]")
patientName = name_elem.text.strip()
except:
pass
if not patientName:
print("[DentaQuest step2] Could not extract patient name")
else:
print(f"[DentaQuest step2] Patient name: {patientName}")
# Wait for page to fully load before generating PDF
try:
WebDriverWait(self.driver, 30).until(
lambda d: d.execute_script("return document.readyState") == "complete"
)
except Exception:
pass
time.sleep(1)
# Generate PDF of the detailed patient page using Chrome DevTools Protocol
print("[DentaQuest step2] Generating PDF of patient detail page...")
pdf_options = {
"landscape": False,
"displayHeaderFooter": False,
"printBackground": True,
"preferCSSPageSize": True,
"paperWidth": 8.5, # Letter size in inches
"paperHeight": 11,
"marginTop": 0.4,
"marginBottom": 0.4,
"marginLeft": 0.4,
"marginRight": 0.4,
"scale": 0.9, # Slightly scale down to fit content
}
result = self.driver.execute_cdp_cmd("Page.printToPDF", pdf_options)
pdf_data = base64.b64decode(result.get('data', ''))
pdf_path = os.path.join(self.download_dir, f"dentaquest_eligibility_{self.memberId}_{int(time.time())}.pdf")
with open(pdf_path, "wb") as f:
f.write(pdf_data)
print(f"[DentaQuest step2] PDF saved: {pdf_path}")
# Close the browser window after PDF generation
try:
from dentaquest_browser_manager import get_browser_manager
get_browser_manager().quit_driver()
print("[DentaQuest step2] Browser closed")
except Exception as e:
print(f"[DentaQuest step2] Error closing browser: {e}")
output = {
"status": "success",
"eligibility": eligibilityText,
"ss_path": pdf_path, # Keep key as ss_path for backward compatibility
"pdf_path": pdf_path, # Also add explicit pdf_path
"patientName": patientName,
"memberId": foundMemberId # Member ID extracted from the page
}
print(f"[DentaQuest step2] Success: {output}")
return output
except Exception as e:
print(f"[DentaQuest step2] Exception: {e}")
# Cleanup download folder on error
try:
dl = os.path.abspath(self.download_dir)
if os.path.isdir(dl):
for name in os.listdir(dl):
item = os.path.join(dl, name)
try:
if os.path.isfile(item) or os.path.islink(item):
os.remove(item)
except Exception:
pass
except Exception:
pass
return {"status": "error", "message": str(e)}
def main_workflow(self, url):
try:
self.config_driver()
self.driver.maximize_window()
time.sleep(3)
login_result = self.login(url)
if login_result.startswith("ERROR"):
return {"status": "error", "message": login_result}
if login_result == "OTP_REQUIRED":
return {"status": "otp_required", "message": "OTP required after login"}
step1_result = self.step1()
if step1_result.startswith("ERROR"):
return {"status": "error", "message": step1_result}
step2_result = self.step2()
if step2_result.get("status") == "error":
return {"status": "error", "message": step2_result.get("message")}
return step2_result
except Exception as e:
return {
"status": "error",
"message": str(e)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,313 @@
"""
Minimal browser manager for United SCO - only handles persistent profile and keeping browser alive.
Clears session cookies on startup (after PC restart) to force fresh login.
Tracks credentials to detect changes mid-session.
"""
import os
import shutil
import hashlib
import threading
import subprocess
import time
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from webdriver_manager.chrome import ChromeDriverManager
# Ensure DISPLAY is set for Chrome to work (needed when running from SSH/background)
if not os.environ.get("DISPLAY"):
os.environ["DISPLAY"] = ":0"
class UnitedSCOBrowserManager:
"""
Singleton that manages a persistent Chrome browser instance for United SCO.
- Uses --user-data-dir for persistent profile (device trust tokens)
- Clears session cookies on startup (after PC restart)
- Tracks credentials to detect changes mid-session
"""
_instance = None
_lock = threading.Lock()
def __new__(cls):
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._driver = None
cls._instance.profile_dir = os.path.abspath("chrome_profile_unitedsco")
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 # Flag to clear session on next driver creation
os.makedirs(cls._instance.profile_dir, exist_ok=True)
os.makedirs(cls._instance.download_dir, exist_ok=True)
return cls._instance
def clear_session_on_startup(self):
"""
Clear 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("[UnitedSCO BrowserManager] Clearing session on startup...")
try:
# Clear the credentials tracking file
if os.path.exists(self._credentials_file):
os.remove(self._credentials_file)
print("[UnitedSCO BrowserManager] Cleared credentials tracking file")
# Clear session-related files from Chrome profile
# These are the files that store login session cookies
session_files = [
"Cookies",
"Cookies-journal",
"Login Data",
"Login Data-journal",
"Web Data",
"Web Data-journal",
]
for filename in session_files:
filepath = os.path.join(self.profile_dir, "Default", filename)
if os.path.exists(filepath):
try:
os.remove(filepath)
print(f"[UnitedSCO BrowserManager] Removed {filename}")
except Exception as e:
print(f"[UnitedSCO BrowserManager] Could not remove {filename}: {e}")
# Also try root level (some Chrome versions)
for filename in session_files:
filepath = os.path.join(self.profile_dir, filename)
if os.path.exists(filepath):
try:
os.remove(filepath)
print(f"[UnitedSCO BrowserManager] Removed root {filename}")
except Exception as e:
print(f"[UnitedSCO BrowserManager] Could not remove root {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("[UnitedSCO BrowserManager] Cleared Session Storage")
except Exception as e:
print(f"[UnitedSCO 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("[UnitedSCO BrowserManager] Cleared Local Storage")
except Exception as e:
print(f"[UnitedSCO 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("[UnitedSCO BrowserManager] Cleared IndexedDB")
except Exception as e:
print(f"[UnitedSCO BrowserManager] Could not clear IndexedDB: {e}")
# Clear browser cache (prevents corrupted cached responses)
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"[UnitedSCO BrowserManager] Cleared {os.path.basename(cache_dir)}")
except Exception as e:
print(f"[UnitedSCO BrowserManager] Could not clear {os.path.basename(cache_dir)}: {e}")
# Set flag to clear session via JavaScript after browser opens
self._needs_session_clear = True
print("[UnitedSCO BrowserManager] Session cleared - will require fresh login")
except Exception as e:
print(f"[UnitedSCO BrowserManager] Error clearing session: {e}")
def _hash_credentials(self, username: str) -> str:
"""Create a hash of the username to track credential changes."""
return hashlib.sha256(username.encode()).hexdigest()[:16]
def get_last_credentials_hash(self) -> str | None:
"""Get the hash of the last-used credentials."""
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):
"""Save the hash of the current credentials."""
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"[UnitedSCO BrowserManager] Failed to save credentials hash: {e}")
def credentials_changed(self, username: str) -> bool:
"""Check if the credentials have changed since last login."""
last_hash = self.get_last_credentials_hash()
if last_hash is None:
return False # No previous credentials, not a change
current_hash = self._hash_credentials(username)
changed = last_hash != current_hash
if changed:
print(f"[UnitedSCO BrowserManager] Credentials changed - logout required")
return changed
def clear_credentials_hash(self):
"""Clear the saved credentials hash (used after logout)."""
try:
if os.path.exists(self._credentials_file):
os.remove(self._credentials_file)
except Exception as e:
print(f"[UnitedSCO BrowserManager] Failed to clear credentials hash: {e}")
def _kill_existing_chrome_for_profile(self):
"""Kill any existing Chrome processes using this profile."""
try:
# Find and kill Chrome processes using this profile
result = subprocess.run(
["pgrep", "-f", f"user-data-dir={self.profile_dir}"],
capture_output=True, text=True
)
if result.stdout.strip():
pids = result.stdout.strip().split('\n')
for pid in pids:
try:
subprocess.run(["kill", "-9", pid], check=False)
except:
pass
time.sleep(1)
except Exception as e:
pass
# Remove SingletonLock if exists
lock_file = os.path.join(self.profile_dir, "SingletonLock")
try:
if os.path.islink(lock_file) or os.path.exists(lock_file):
os.remove(lock_file)
except:
pass
def get_driver(self, headless=False):
"""Get or create the persistent browser instance."""
with self._lock:
if self._driver is None:
print("[UnitedSCO BrowserManager] Driver is None, creating new driver")
self._kill_existing_chrome_for_profile()
self._create_driver(headless)
elif not self._is_alive():
print("[UnitedSCO BrowserManager] Driver not alive, recreating")
self._kill_existing_chrome_for_profile()
self._create_driver(headless)
else:
print("[UnitedSCO BrowserManager] Reusing existing driver")
return self._driver
def _is_alive(self):
"""Check if browser is still responsive."""
try:
if self._driver is None:
return False
url = self._driver.current_url
return True
except Exception as e:
return False
def _create_driver(self, headless=False):
"""Create browser with persistent profile."""
if self._driver:
try:
self._driver.quit()
except:
pass
self._driver = None
time.sleep(1)
options = webdriver.ChromeOptions()
if headless:
options.add_argument("--headless")
# Persistent profile - THIS IS THE KEY for device trust
options.add_argument(f"--user-data-dir={self.profile_dir}")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
# Anti-detection options (prevent bot detection)
options.add_argument("--disable-blink-features=AutomationControlled")
options.add_experimental_option("excludeSwitches", ["enable-automation"])
options.add_experimental_option("useAutomationExtension", False)
options.add_argument("--disable-infobars")
prefs = {
"download.default_directory": self.download_dir,
"plugins.always_open_pdf_externally": True,
"download.prompt_for_download": False,
"download.directory_upgrade": True,
# Disable password save dialog that blocks page interactions
"credentials_enable_service": False,
"profile.password_manager_enabled": False,
"profile.password_manager_leak_detection": False,
}
options.add_experimental_option("prefs", prefs)
service = Service(ChromeDriverManager().install())
self._driver = webdriver.Chrome(service=service, options=options)
self._driver.maximize_window()
# Remove webdriver property to avoid detection
try:
self._driver.execute_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})")
except Exception:
pass
# Reset the session clear flag (file-based clearing is done on startup)
self._needs_session_clear = False
def quit_driver(self):
"""Quit browser (only call on shutdown)."""
with self._lock:
if self._driver:
try:
self._driver.quit()
except:
pass
self._driver = None
# Also clean up any orphaned processes
self._kill_existing_chrome_for_profile()
# Singleton accessor
_manager = None
def get_browser_manager():
global _manager
if _manager is None:
_manager = UnitedSCOBrowserManager()
return _manager
def clear_unitedsco_session_on_startup():
"""Called by agent.py on startup to clear session."""
manager = get_browser_manager()
manager.clear_session_on_startup()

View File

@@ -22,10 +22,10 @@ export const insuranceIdSchema = z.preprocess(
}
return val;
},
// After preprocess, require digits-only string (or optional nullable)
// After preprocess, allow alphanumeric insurance IDs (some providers like DentaQuest use letter prefixes)
z
.string()
.regex(/^\d+$/, { message: "Insurance ID must contain only digits" })
.regex(/^[A-Za-z0-9]+$/, { message: "Insurance ID must contain only letters and digits" })
.min(1)
.max(32)
.optional()