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

6
.gitignore vendored
View File

@@ -38,3 +38,9 @@ dist/
# env # env
*.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,7 +1,8 @@
NODE_ENV="development" NODE_ENV="development"
HOST=0.0.0.0 HOST=0.0.0.0
PORT=5000 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 SELENIUM_AGENT_BASE_URL=http://localhost:5002
JWT_SECRET = 'dentalsecret' JWT_SECRET = 'dentalsecret'
DB_HOST=localhost DB_HOST=localhost

View File

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

View File

@@ -9,6 +9,9 @@ import insuranceCredsRoutes from "./insuranceCreds";
import documentsRoutes from "./documents"; import documentsRoutes from "./documents";
import insuranceStatusRoutes from "./insuranceStatus"; import insuranceStatusRoutes from "./insuranceStatus";
import insuranceStatusDdmaRoutes from "./insuranceStatusDDMA"; import insuranceStatusDdmaRoutes from "./insuranceStatusDDMA";
import insuranceStatusDentaQuestRoutes from "./insuranceStatusDentaQuest";
import insuranceStatusUnitedSCORoutes from "./insuranceStatusUnitedSCO";
import insuranceStatusDeltaInsRoutes from "./insuranceStatusDeltaIns";
import paymentsRoutes from "./payments"; import paymentsRoutes from "./payments";
import databaseManagementRoutes from "./database-management"; import databaseManagementRoutes from "./database-management";
import notificationsRoutes from "./notifications"; import notificationsRoutes from "./notifications";
@@ -29,6 +32,9 @@ router.use("/insuranceCreds", insuranceCredsRoutes);
router.use("/documents", documentsRoutes); router.use("/documents", documentsRoutes);
router.use("/insurance-status", insuranceStatusRoutes); router.use("/insurance-status", insuranceStatusRoutes);
router.use("/insurance-status-ddma", insuranceStatusDdmaRoutes); 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("/payments", paymentsRoutes);
router.use("/database-management", databaseManagementRoutes); router.use("/database-management", databaseManagementRoutes);
router.use("/notifications", notificationsRoutes); 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); const id = Number(req.params.id);
if (isNaN(id)) return res.status(400).send("Invalid credential 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 updates = req.body as Partial<InsuranceCredential>;
const credential = await storage.updateInsuranceCredential(id, updates); 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); return res.status(200).json(credential);
} catch (err) { } catch (err) {
return res return res
@@ -115,6 +143,28 @@ router.delete("/:id", async (req: Request, res: Response): Promise<any> => {
.status(404) .status(404)
.json({ message: "Credential not found or already deleted" }); .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(); return res.status(204).send();
} catch (err) { } catch (err) {
return res return res

View File

@@ -136,36 +136,135 @@ async function handleDdmaCompletedJob(
// We'll wrap the processing in try/catch/finally so cleanup always runs // We'll wrap the processing in try/catch/finally so cleanup always runs
try { try {
// 1) ensuring memberid.
const insuranceEligibilityData = job.insuranceEligibilityData; const insuranceEligibilityData = job.insuranceEligibilityData;
const insuranceId = String(insuranceEligibilityData.memberId ?? "").trim();
if (!insuranceId) {
throw new Error("Missing memberId for ddma job");
}
// 2) Create or update patient (with name from selenium result if available) // 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) {
insuranceId = String(insuranceEligibilityData.memberId ?? "").trim();
}
console.log(`[ddma-eligibility] Resolved insuranceId: ${insuranceId || "(none)"}`);
// 2) Get patient name - prefer from Selenium result
const patientNameFromResult = const patientNameFromResult =
typeof seleniumResult?.patientName === "string" typeof seleniumResult?.patientName === "string"
? seleniumResult.patientName.trim() ? seleniumResult.patientName.trim()
: null; : null;
const { firstName, lastName } = splitName(patientNameFromResult); console.log(`[ddma-eligibility] patientNameFromResult: '${patientNameFromResult}'`);
await createOrUpdatePatientByInsuranceId({ // Get name from input data as fallback
insuranceId, let firstName = String(insuranceEligibilityData.firstName || "").trim();
firstName, let lastName = String(insuranceEligibilityData.lastName || "").trim();
lastName,
dob: insuranceEligibilityData.dateOfBirth, // Override with name from Selenium result if available
userId: job.userId, 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}'`);
// 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}`);
}
}
// 3) Update patient status + PDF upload
const patient = await storage.getPatientByInsuranceId(
insuranceEligibilityData.memberId
);
if (!patient?.id) { if (!patient?.id) {
outputResult.patientUpdateStatus = outputResult.patientUpdateStatus =
"Patient not found; no update performed"; "Patient not found and could not be created; no update performed";
return { return {
patientUpdateStatus: outputResult.patientUpdateStatus, patientUpdateStatus: outputResult.patientUpdateStatus,
pdfUploadStatus: "none", pdfUploadStatus: "none",
@@ -173,49 +272,61 @@ async function handleDdmaCompletedJob(
}; };
} }
// update patient status. // Update patient status from Delta MA eligibility result
const newStatus = await storage.updatePatient(patient.id, { status: eligibilityStatus });
seleniumResult.eligibility === "active" ? "ACTIVE" : "INACTIVE"; outputResult.patientUpdateStatus = `Patient ${patient.id} status set to ${eligibilityStatus} (Delta MA eligibility: ${seleniumResult.eligibility})`;
await storage.updatePatient(patient.id, { status: newStatus }); console.log(`[ddma-eligibility] ${outputResult.patientUpdateStatus}`);
outputResult.patientUpdateStatus = `Patient status updated to ${newStatus}`;
// convert screenshot -> pdf if available // Handle PDF or convert screenshot -> pdf if available
let pdfBuffer: Buffer | null = null; let pdfBuffer: Buffer | null = null;
let generatedPdfPath: string | null = null; let generatedPdfPath: string | null = null;
if ( if (
seleniumResult && seleniumResult &&
seleniumResult.ss_path && seleniumResult.ss_path &&
typeof seleniumResult.ss_path === "string" && typeof seleniumResult.ss_path === "string"
(seleniumResult.ss_path.endsWith(".png") ||
seleniumResult.ss_path.endsWith(".jpg") ||
seleniumResult.ss_path.endsWith(".jpeg"))
) { ) {
try { try {
if (!fsSync.existsSync(seleniumResult.ss_path)) { if (!fsSync.existsSync(seleniumResult.ss_path)) {
throw new Error( 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`; const pdfFileName = `ddma_eligibility_${insuranceEligibilityData.memberId}_${Date.now()}.pdf`;
generatedPdfPath = path.join( generatedPdfPath = path.join(
path.dirname(seleniumResult.ss_path), path.dirname(seleniumResult.ss_path),
pdfFileName pdfFileName
); );
await fs.writeFile(generatedPdfPath, pdfBuffer); await fs.writeFile(generatedPdfPath, pdfBuffer);
seleniumResult.pdf_path = generatedPdfPath;
// ensure cleanup uses this console.log(`[ddma-eligibility] Converted screenshot to PDF: ${generatedPdfPath}`);
seleniumResult.pdf_path = generatedPdfPath; } else {
outputResult.pdfUploadStatus =
`Unsupported file format: ${seleniumResult.ss_path}`;
}
} catch (err: any) { } catch (err: any) {
console.error("Failed to convert screenshot to PDF:", err); console.error("Failed to process PDF/screenshot:", err);
outputResult.pdfUploadStatus = `Failed to convert screenshot to PDF: ${String(err)}`; outputResult.pdfUploadStatus = `Failed to process file: ${String(err)}`;
} }
} else { } else {
outputResult.pdfUploadStatus = 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) { 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 NODE_ENV=development
HOST=0.0.0.0 HOST=0.0.0.0
PORT=3000 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 { toast } = useToast();
const dispatch = useAppDispatch(); 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 socketRef = useRef<Socket | null>(null);
const connectingRef = useRef<Promise<void> | null>(null); const connectingRef = useRef<Promise<void> | null>(null);
@@ -371,10 +375,11 @@ export function DdmaEligibilityButton({
}; };
const startDdmaEligibility = async () => { const startDdmaEligibility = async () => {
if (!memberId || !dateOfBirth) { // Flexible validation: require DOB + at least one identifier
if (!dateOfBirth || (!memberId && !firstName && !lastName)) {
toast({ toast({
title: "Missing fields", 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", variant: "destructive",
}); });
return; return;
@@ -539,7 +544,7 @@ export function DdmaEligibilityButton({
<Button <Button
className="w-full" className="w-full"
variant="default" variant="default"
disabled={isFormIncomplete || isStarting} disabled={isDdmaFormIncomplete || isStarting}
onClick={startDdmaEligibility} onClick={startDdmaEligibility}
> >
{isStarting ? ( {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) { export function CredentialForm({ onClose, userId, defaultValues }: CredentialFormProps) {
const [siteKey, setSiteKey] = useState(defaultValues?.siteKey || ""); const [siteKey, setSiteKey] = useState(defaultValues?.siteKey || "");
const [username, setUsername] = useState(defaultValues?.username || ""); const [username, setUsername] = useState(defaultValues?.username || "");
@@ -91,14 +100,19 @@ export function CredentialForm({ onClose, userId, defaultValues }: CredentialFor
</h2> </h2>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium">Site Key</label> <label className="block text-sm font-medium">Insurance Provider</label>
<input <select
type="text"
value={siteKey} value={siteKey}
onChange={(e) => setSiteKey(e.target.value)} onChange={(e) => setSiteKey(e.target.value)}
className="mt-1 p-2 border rounded w-full" className="mt-1 p-2 border rounded w-full bg-white"
placeholder="e.g., MH, Delta MA, (keep the site key exact same)" >
/> <option value="">Select a provider...</option>
{SITE_KEY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium">Username</label> <label className="block text-sm font-medium">Username</label>

View File

@@ -13,6 +13,19 @@ type Credential = {
password: string; 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() { export function CredentialTable() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -108,7 +121,7 @@ export function CredentialTable() {
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<tr> <tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Site Key Provider
</th> </th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Username Username
@@ -141,7 +154,7 @@ export function CredentialTable() {
) : ( ) : (
currentCredentials.map((cred) => ( currentCredentials.map((cred) => (
<tr key={cred.id}> <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">{cred.username}</td>
<td className="px-4 py-2"></td> <td className="px-4 py-2"></td>
<td className="px-4 py-2 text-right"> <td className="px-4 py-2 text-right">
@@ -227,7 +240,7 @@ export function CredentialTable() {
isOpen={isDeleteDialogOpen} isOpen={isDeleteDialogOpen}
onConfirm={handleConfirmDelete} onConfirm={handleConfirmDelete}
onCancel={handleCancelDelete} onCancel={handleCancelDelete}
entityName={credentialToDelete?.siteKey} entityName={credentialToDelete ? getSiteKeyLabel(credentialToDelete.siteKey) : undefined}
/> />
</div> </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 { PdfPreviewModal } from "@/components/insurance-status/pdf-preview-modal";
import { useLocation } from "wouter"; import { useLocation } from "wouter";
import { DdmaEligibilityButton } from "@/components/insurance-status/ddma-buton-modal"; 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() { export default function InsuranceStatusPage() {
const { user } = useAuth(); const { user } = useAuth();
@@ -575,7 +578,7 @@ export default function InsuranceStatusPage() {
{/* TEMP PROVIDER BUTTONS */} {/* TEMP PROVIDER BUTTONS */}
<div className="space-y-4 mt-6"> <div className="space-y-4 mt-6">
<h3 className="text-sm font-medium text-muted-foreground"> <h3 className="text-sm font-medium text-muted-foreground">
Other provider checks Other Insurances
</h3> </h3>
{/* Row 1 */} {/* 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 <Button
className="w-full" className="w-full"
variant="outline" variant="outline"
disabled={isFormIncomplete} disabled={isFormIncomplete}
> >
<CheckCircle className="h-4 w-4 mr-2" /> <CheckCircle className="h-4 w-4 mr-2" />
Metlife Dental BCBS
</Button> </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 <Button
className="w-full" className="w-full"
@@ -614,26 +665,8 @@ export default function InsuranceStatusPage() {
</Button> </Button>
</div> </div>
{/* Row 2 */} {/* Row 3 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <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 <Button
className="w-full" className="w-full"
variant="outline" variant="outline"
@@ -642,9 +675,25 @@ export default function InsuranceStatusPage() {
<CheckCircle className="h-4 w-4 mr-2" /> <CheckCircle className="h-4 w-4 mr-2" />
United AAPR United AAPR
</Button> </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> </div>
{/* Row 3 */} {/* Row 4 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Button <Button
className="w-full" className="w-full"
@@ -662,7 +711,28 @@ export default function InsuranceStatusPage() {
<CheckCircle className="h-4 w-4 mr-2" /> <CheckCircle className="h-4 w-4 mr-2" />
Altus Altus
</Button> </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>
</div> </div>
</CardContent> </CardContent>

View File

@@ -10,6 +10,17 @@ export default defineConfig(({ mode }) => {
server: { server: {
host: env.HOST, host: env.HOST,
port: Number(env.PORT), 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: { resolve: {
alias: { alias: {

View File

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

View File

@@ -3,6 +3,6 @@
"private": true, "private": true,
"scripts": { "scripts": {
"postinstall": "pip install -r requirements.txt", "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 os
import time import time
import helpers_ddma_eligibility as hddma 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 from dotenv import load_dotenv
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() app = FastAPI()
# Allow 1 selenium session at a time # Allow 1 selenium session at a time
semaphore = asyncio.Semaphore(1) semaphore = asyncio.Semaphore(1)
@@ -186,6 +208,223 @@ async def ddma_eligibility(request: Request):
return {"status": "started", "session_id": sid} 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") @app.post("/submit-otp")
async def submit_otp(request: Request): 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" "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__": if __name__ == "__main__":
host = os.getenv("HOST") host = os.getenv("HOST")
port = int(os.getenv("PORT")) port = int(os.getenv("PORT"))

View File

@@ -1,19 +1,28 @@
""" """
Minimal browser manager for DDMA - only handles persistent profile and keeping browser alive. 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 os
import glob
import shutil
import hashlib
import threading import threading
from selenium import webdriver from selenium import webdriver
from selenium.webdriver.chrome.service import Service from selenium.webdriver.chrome.service import Service
from webdriver_manager.chrome import ChromeDriverManager from webdriver_manager.chrome import ChromeDriverManager
# Ensure DISPLAY is set for Chrome to work (needed when running from SSH/background)
if not os.environ.get("DISPLAY"):
os.environ["DISPLAY"] = ":0"
class DDMABrowserManager: class DDMABrowserManager:
""" """
Singleton that manages a persistent Chrome browser instance. Singleton that manages a persistent Chrome browser instance.
- Uses --user-data-dir for persistent profile (device trust tokens, cookies) - Uses --user-data-dir for persistent profile (device trust tokens)
- Keeps browser alive between patient runs - Clears session cookies on startup (after PC restart)
- Tracks credentials to detect changes mid-session
""" """
_instance = None _instance = None
_lock = threading.Lock() _lock = threading.Lock()
@@ -25,31 +34,208 @@ class DDMABrowserManager:
cls._instance._driver = None cls._instance._driver = None
cls._instance.profile_dir = os.path.abspath("chrome_profile_ddma") cls._instance.profile_dir = os.path.abspath("chrome_profile_ddma")
cls._instance.download_dir = os.path.abspath("seleniumDownloads") cls._instance.download_dir = os.path.abspath("seleniumDownloads")
cls._instance._credentials_file = os.path.join(cls._instance.profile_dir, ".last_credentials")
cls._instance._needs_session_clear = False # Flag to clear session on next driver creation
os.makedirs(cls._instance.profile_dir, exist_ok=True) os.makedirs(cls._instance.profile_dir, exist_ok=True)
os.makedirs(cls._instance.download_dir, exist_ok=True) os.makedirs(cls._instance.download_dir, exist_ok=True)
return cls._instance return cls._instance
def clear_session_on_startup(self):
"""
Clear session cookies from Chrome profile on startup.
This forces a fresh login after PC restart.
Preserves device trust tokens (LocalStorage, IndexedDB) to avoid OTPs.
"""
print("[DDMA BrowserManager] Clearing session on startup...")
try:
# Clear 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): def get_driver(self, headless=False):
"""Get or create the persistent browser instance.""" """Get or create the persistent browser instance."""
with self._lock: with self._lock:
if self._driver is None: if self._driver is None:
print("[BrowserManager] Driver is None, creating new driver") print("[DDMA BrowserManager] Driver is None, creating new driver")
self._kill_existing_chrome_for_profile()
self._create_driver(headless) self._create_driver(headless)
elif not self._is_alive(): elif not self._is_alive():
print("[BrowserManager] Driver not alive, recreating") print("[DDMA BrowserManager] Driver not alive, recreating")
self._kill_existing_chrome_for_profile()
self._create_driver(headless) self._create_driver(headless)
else: else:
print("[BrowserManager] Reusing existing driver") print("[DDMA BrowserManager] Reusing existing driver")
return self._driver return self._driver
def _is_alive(self): def _is_alive(self):
"""Check if browser is still responsive.""" """Check if browser is still responsive."""
try: try:
if self._driver is None:
return False
url = self._driver.current_url url = self._driver.current_url
print(f"[BrowserManager] Driver alive, current URL: {url[:50]}...") print(f"[DDMA BrowserManager] Driver alive, current URL: {url[:50]}...")
return True return True
except Exception as e: except Exception as e:
print(f"[BrowserManager] Driver not alive: {e}") print(f"[DDMA BrowserManager] Driver not alive: {e}")
return False return False
def _create_driver(self, headless=False): def _create_driver(self, headless=False):
@@ -69,6 +255,12 @@ class DDMABrowserManager:
options.add_argument("--no-sandbox") options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage") options.add_argument("--disable-dev-shm-usage")
# Anti-detection options (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 = { prefs = {
"download.default_directory": self.download_dir, "download.default_directory": self.download_dir,
"plugins.always_open_pdf_externally": True, "plugins.always_open_pdf_externally": True,
@@ -81,6 +273,15 @@ class DDMABrowserManager:
self._driver = webdriver.Chrome(service=service, options=options) self._driver = webdriver.Chrome(service=service, options=options)
self._driver.maximize_window() self._driver.maximize_window()
# Remove webdriver property to avoid detection
try:
self._driver.execute_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})")
except Exception:
pass
# Reset the session clear flag (file-based clearing is done on startup)
self._needs_session_clear = False
def quit_driver(self): def quit_driver(self):
"""Quit browser (only call on shutdown).""" """Quit browser (only call on shutdown)."""
with self._lock: with self._lock:
@@ -100,3 +301,9 @@ def get_browser_manager():
if _manager is None: if _manager is None:
_manager = DDMABrowserManager() _manager = DDMABrowserManager()
return _manager return _manager
def clear_ddma_session_on_startup():
"""Called by agent.py on startup to clear 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.common.by import By
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import WebDriverException from selenium.common.exceptions import WebDriverException, TimeoutException
from selenium_DDMA_eligibilityCheckWorker import AutomationDeltaDentalMAEligibilityCheck from selenium_DDMA_eligibilityCheckWorker import AutomationDeltaDentalMAEligibilityCheck
@@ -127,74 +127,113 @@ async def start_ddma_run(sid: str, data: dict, url: str):
s["message"] = "Session persisted" s["message"] = "Session persisted"
# Continue to step1 below # 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": elif isinstance(login_result, str) and login_result == "OTP_REQUIRED":
s["status"] = "waiting_for_otp" s["status"] = "waiting_for_otp"
s["message"] = "OTP required for login" s["message"] = "OTP required for login - please enter OTP in browser"
s["last_activity"] = time.time() s["last_activity"] = time.time()
try: driver = s["driver"]
await asyncio.wait_for(s["otp_event"].wait(), timeout=SESSION_OTP_TIMEOUT)
except asyncio.TimeoutError:
s["status"] = "error"
s["message"] = "OTP timeout"
await cleanup_session(sid)
return {"status": "error", "message": "OTP not provided in time"}
otp_value = s.get("otp_value") # Poll the browser to detect when OTP is completed (user enters it directly)
if not otp_value: # We check every 1 second for up to SESSION_OTP_TIMEOUT seconds (faster response)
s["status"] = "error" max_polls = SESSION_OTP_TIMEOUT
s["message"] = "OTP missing after event" login_success = False
await cleanup_session(sid)
return {"status": "error", "message": "OTP missing after event"}
# Submit OTP - check if it's in a popup window print(f"[OTP] Waiting for user to enter OTP (polling browser for {SESSION_OTP_TIMEOUT}s)...")
try:
driver = s["driver"]
wait = WebDriverWait(driver, 30)
# Check if there's a popup window and switch to it for poll in range(max_polls):
original_window = driver.current_window_handle await asyncio.sleep(1)
all_windows = driver.window_handles s["last_activity"] = time.time()
if len(all_windows) > 1:
for window in all_windows:
if window != original_window:
driver.switch_to.window(window)
print(f"[OTP] Switched to popup window for OTP entry")
break
otp_input = wait.until(
EC.presence_of_element_located(
(By.XPATH, "//input[contains(@aria-lable,'Verification code') or contains(@placeholder,'Enter your verification code')]")
)
)
otp_input.clear()
otp_input.send_keys(otp_value)
try: try:
submit_btn = wait.until( # Check if OTP was submitted via API (from app)
EC.element_to_be_clickable( otp_value = s.get("otp_value")
(By.XPATH, "//button[@type='button' and @aria-label='Verify']") if otp_value:
print(f"[OTP] 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"]'))
) )
submit_btn.click() print("[OTP] Member search input found - login successful!")
except Exception: login_success = True
otp_input.send_keys("\n") except TimeoutException:
s["status"] = "error"
s["message"] = "OTP timeout - login not completed"
await cleanup_session(sid)
return {"status": "error", "message": "OTP not completed in time"}
except Exception as final_err:
s["status"] = "error"
s["message"] = f"OTP verification failed: {final_err}"
await cleanup_session(sid)
return {"status": "error", "message": s["message"]}
# Wait for verification and switch back to main window if needed if login_success:
await asyncio.sleep(2) s["status"] = "running"
if len(driver.window_handles) > 0: s["message"] = "Login successful after OTP"
driver.switch_to.window(driver.window_handles[0]) print("[OTP] Proceeding to step1...")
s["status"] = "otp_submitted"
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"]}
elif isinstance(login_result, str) and login_result.startswith("ERROR"): elif isinstance(login_result, str) and login_result.startswith("ERROR"):
s["status"] = "error" s["status"] = "error"
@@ -202,6 +241,13 @@ async def start_ddma_run(sid: str, data: dict, url: str):
await cleanup_session(sid) await cleanup_session(sid)
return {"status": "error", "message": login_result} 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 # Step 1
step1_result = bot.step1() step1_result = bot.step1()
if isinstance(step1_result, str) and step1_result.startswith("ERROR"): if isinstance(step1_result, str) and step1_result.startswith("ERROR"):

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 # Flatten values for convenience
self.memberId = self.data.get("memberId", "") self.memberId = self.data.get("memberId", "")
self.dateOfBirth = self.data.get("dateOfBirth", "") self.dateOfBirth = self.data.get("dateOfBirth", "")
self.firstName = self.data.get("firstName", "")
self.lastName = self.data.get("lastName", "")
self.massddma_username = self.data.get("massddmaUsername", "") self.massddma_username = self.data.get("massddmaUsername", "")
self.massddma_password = self.data.get("massddmaPassword", "") self.massddma_password = self.data.get("massddmaPassword", "")
@@ -34,9 +36,63 @@ class AutomationDeltaDentalMAEligibilityCheck:
# Use persistent browser from manager (keeps device trust tokens) # Use persistent browser from manager (keeps device trust tokens)
self.driver = get_browser_manager().get_driver(self.headless) self.driver = get_browser_manager().get_driver(self.headless)
def _force_logout(self):
"""Force logout by clearing cookies 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): def login(self, url):
wait = WebDriverWait(self.driver, 30) wait = WebDriverWait(self.driver, 30)
browser_manager = get_browser_manager()
try: 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) # First check if we're already on a logged-in page (from previous run)
try: try:
current_url = self.driver.current_url current_url = self.driver.current_url
@@ -179,7 +235,11 @@ class AutomationDeltaDentalMAEligibilityCheck:
login_button = wait.until(EC.element_to_be_clickable((By.XPATH, "//button[@type='submit' and @aria-label='Sign in']"))) login_button = wait.until(EC.element_to_be_clickable((By.XPATH, "//button[@type='submit' and @aria-label='Sign in']")))
login_button.click() login_button.click()
# OTP detection # Save credentials hash after login attempt
if self.massddma_username:
browser_manager.save_credentials_hash(self.massddma_username)
# OTP detection - wait up to 30 seconds for OTP input to appear
try: try:
otp_candidate = WebDriverWait(self.driver, 30).until( otp_candidate = WebDriverWait(self.driver, 30).until(
EC.presence_of_element_located( EC.presence_of_element_located(
@@ -191,63 +251,140 @@ class AutomationDeltaDentalMAEligibilityCheck:
return "OTP_REQUIRED" return "OTP_REQUIRED"
except TimeoutException: except TimeoutException:
print("[login] No OTP input detected in allowed time.") print("[login] No OTP input detected in allowed time.")
# Check if we're now on the member search page (login succeeded without OTP)
try:
current_url = self.driver.current_url.lower()
if "member" in current_url or "dashboard" in current_url:
member_search = WebDriverWait(self.driver, 5).until(
EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]'))
)
print("[login] Login successful - now on member search page")
return "SUCCESS"
except TimeoutException:
pass
# Check for error messages 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: except Exception as e:
print("[login] Exception during login:", e) print("[login] Exception during login:", e)
return f"ERROR:LOGIN FAILED: {e}" return f"ERROR:LOGIN FAILED: {e}"
def step1(self): def step1(self):
"""Fill search form with all available fields (flexible search)"""
wait = WebDriverWait(self.driver, 30) wait = WebDriverWait(self.driver, 30)
try: try:
# Fill Member ID # Log what fields are available
member_id_input = wait.until(EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]'))) fields = []
member_id_input.clear() if self.memberId:
member_id_input.send_keys(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 # Helper to click, select-all and type
try:
dob_parts = self.dateOfBirth.split("-")
year = dob_parts[0] # "1964"
month = dob_parts[1].zfill(2) # "04"
day = dob_parts[2].zfill(2) # "17"
except Exception as e:
print(f"Error parsing DOB: {e}")
return "ERROR: PARSING DOB"
# 1) locate the specific member DOB container
dob_container = wait.until(
EC.presence_of_element_located(
(By.XPATH, "//div[@data-testid='member-search_date-of-birth']")
)
)
# 2) find the editable spans *inside that container* using relative XPaths
month_elem = dob_container.find_element(By.XPATH, ".//span[@data-type='month' and @contenteditable='true']")
day_elem = dob_container.find_element(By.XPATH, ".//span[@data-type='day' and @contenteditable='true']")
year_elem = dob_container.find_element(By.XPATH, ".//span[@data-type='year' and @contenteditable='true']")
# Helper to click, select-all and type (pure send_keys approach)
def replace_with_sendkeys(el, value): def replace_with_sendkeys(el, value):
# focus (same as click)
el.click() el.click()
# select all (Ctrl+A) and delete (some apps pick up BACKSPACE better — we use BACKSPACE after select)
el.send_keys(Keys.CONTROL, "a") el.send_keys(Keys.CONTROL, "a")
el.send_keys(Keys.BACKSPACE) el.send_keys(Keys.BACKSPACE)
# type the value
el.send_keys(value) el.send_keys(value)
# optionally blur or tab out if app expects it
# el.send_keys(Keys.TAB)
replace_with_sendkeys(month_elem, month) # 1. Fill Member ID if provided
time.sleep(0.05) if self.memberId:
replace_with_sendkeys(day_elem, day) try:
time.sleep(0.05) member_id_input = wait.until(EC.presence_of_element_located(
replace_with_sendkeys(year_elem, year) (By.XPATH, '//input[@placeholder="Search by member ID"]')
))
member_id_input.clear()
member_id_input.send_keys(self.memberId)
print(f"[DDMA step1] Entered Member ID: {self.memberId}")
time.sleep(0.2)
except Exception as e:
print(f"[DDMA step1] Warning: Could not fill Member ID: {e}")
# 2. Fill DOB if provided
if self.dateOfBirth:
try:
dob_parts = self.dateOfBirth.split("-")
year = dob_parts[0]
month = dob_parts[1].zfill(2)
day = dob_parts[2].zfill(2)
# Click Continue button dob_container = wait.until(
continue_btn = wait.until(EC.element_to_be_clickable((By.XPATH, '//button[@data-testid="member-search_search-button"]'))) EC.presence_of_element_located(
(By.XPATH, "//div[@data-testid='member-search_date-of-birth']")
)
)
month_elem = dob_container.find_element(By.XPATH, ".//span[@data-type='month' and @contenteditable='true']")
day_elem = dob_container.find_element(By.XPATH, ".//span[@data-type='day' and @contenteditable='true']")
year_elem = dob_container.find_element(By.XPATH, ".//span[@data-type='year' and @contenteditable='true']")
replace_with_sendkeys(month_elem, month)
time.sleep(0.05)
replace_with_sendkeys(day_elem, day)
time.sleep(0.05)
replace_with_sendkeys(year_elem, year)
print(f"[DDMA step1] Filled DOB: {month}/{day}/{year}")
except Exception as e:
print(f"[DDMA step1] Warning: Could not fill DOB: {e}")
# 3. Fill First Name if provided
if self.firstName:
try:
first_name_input = wait.until(EC.presence_of_element_located(
(By.XPATH, '//input[@placeholder="First name - 1 char minimum" or contains(@placeholder,"first name") or contains(@name,"firstName")]')
))
first_name_input.clear()
first_name_input.send_keys(self.firstName)
print(f"[DDMA step1] Entered First Name: {self.firstName}")
time.sleep(0.2)
except Exception as e:
print(f"[DDMA step1] Warning: Could not fill First Name: {e}")
# 4. Fill Last Name if provided
if self.lastName:
try:
last_name_input = wait.until(EC.presence_of_element_located(
(By.XPATH, '//input[@placeholder="Last name - 2 char minimum" or contains(@placeholder,"last name") or contains(@name,"lastName")]')
))
last_name_input.clear()
last_name_input.send_keys(self.lastName)
print(f"[DDMA step1] Entered Last Name: {self.lastName}")
time.sleep(0.2)
except Exception as e:
print(f"[DDMA step1] Warning: Could not fill Last Name: {e}")
time.sleep(0.3)
# Click Search button
continue_btn = wait.until(EC.element_to_be_clickable(
(By.XPATH, '//button[@data-testid="member-search_search-button"]')
))
continue_btn.click() continue_btn.click()
print("[DDMA step1] Clicked Search button")
time.sleep(5)
# Check for error message # Check for error message
try: try:
@@ -255,89 +392,284 @@ class AutomationDeltaDentalMAEligibilityCheck:
(By.XPATH, '//div[@data-testid="member-search-result-no-results"]') (By.XPATH, '//div[@data-testid="member-search-result-no-results"]')
)) ))
if error_msg: if error_msg:
print("Error: Invalid Member ID or Date of Birth.") print("[DDMA step1] Error: No results found")
return "ERROR: INVALID MEMBERID OR DOB" return "ERROR: INVALID SEARCH CRITERIA"
except TimeoutException: except TimeoutException:
pass pass
print("[DDMA step1] Search completed successfully")
return "Success" return "Success"
except Exception as e: except Exception as e:
print(f"Error while step1 i.e Cheking the MemberId and DOB in: {e}") print(f"[DDMA step1] Exception: {e}")
return "ERROR:STEP1" return f"ERROR:STEP1 - {e}"
def step2(self): def step2(self):
wait = WebDriverWait(self.driver, 90) wait = WebDriverWait(self.driver, 90)
try: try:
# 1) find the eligibility <a> inside the correct cell # Wait for results table to load
status_link = wait.until(EC.presence_of_element_located(( try:
By.XPATH, WebDriverWait(self.driver, 10).until(
"(//tbody//tr)[1]//a[contains(@href, 'member-eligibility-search')]" EC.presence_of_element_located((By.XPATH, "//tbody//tr"))
))) )
except TimeoutException:
print("[DDMA step2] Warning: Results table not found within timeout")
eligibilityText = status_link.text.strip().lower() # 1) Extract eligibility status and Member ID from search results
eligibilityText = "unknown"
foundMemberId = ""
patientName = ""
# 2) finding patient name. # Extract data from first row
patient_name_div = wait.until(EC.presence_of_element_located(( import re
By.XPATH, try:
'//div[@class="flex flex-row w-full items-center"]' 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]}...")
patientName = patient_name_div.text.strip().lower() 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: try:
WebDriverWait(self.driver, 30).until( WebDriverWait(self.driver, 30).until(
lambda d: d.execute_script("return document.readyState") == "complete" lambda d: d.execute_script("return document.readyState") == "complete"
) )
except Exception: except Exception:
print("Warning: document.readyState did not become 'complete' within timeout")
# Give some time for lazy content to finish rendering (adjust if needed)
time.sleep(0.6)
# Get total page size and DPR
total_width = int(self.driver.execute_script(
"return Math.max(document.body.scrollWidth, document.documentElement.scrollWidth, document.documentElement.clientWidth);"
))
total_height = int(self.driver.execute_script(
"return Math.max(document.body.scrollHeight, document.documentElement.scrollHeight, document.documentElement.clientHeight);"
))
dpr = float(self.driver.execute_script("return window.devicePixelRatio || 1;"))
# Set device metrics to the full page size so Page.captureScreenshot captures everything
# Note: Some pages are extremely tall; if you hit memory limits, you can capture in chunks.
self.driver.execute_cdp_cmd('Emulation.setDeviceMetricsOverride', {
"mobile": False,
"width": total_width,
"height": total_height,
"deviceScaleFactor": dpr,
"screenOrientation": {"angle": 0, "type": "portraitPrimary"}
})
# Small pause for layout to settle after emulation change
time.sleep(0.15)
# Capture screenshot (base64 PNG)
result = self.driver.execute_cdp_cmd("Page.captureScreenshot", {"format": "png", "fromSurface": True})
image_data = base64.b64decode(result.get('data', ''))
screenshot_path = os.path.join(self.download_dir, f"ss_{self.memberId}.png")
with open(screenshot_path, "wb") as f:
f.write(image_data)
# Restore original metrics to avoid affecting further interactions
try:
self.driver.execute_cdp_cmd('Emulation.clearDeviceMetricsOverride', {})
except Exception:
# non-fatal: continue
pass pass
print("Screenshot saved at:", screenshot_path) time.sleep(1)
# Close the browser window after screenshot (session preserved in profile) # 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: try:
from ddma_browser_manager import get_browser_manager from ddma_browser_manager import get_browser_manager
get_browser_manager().quit_driver() get_browser_manager().quit_driver()
@@ -345,11 +677,23 @@ class AutomationDeltaDentalMAEligibilityCheck:
except Exception as e: except Exception as e:
print(f"[step2] Error closing browser: {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 = { output = {
"status": "success", "status": "success",
"eligibility": eligibilityText, "eligibility": eligibilityText,
"ss_path": screenshot_path, "ss_path": pdf_path, # Keep key as ss_path for backward compatibility
"patientName":patientName "pdf_path": pdf_path, # Also add explicit pdf_path
"patientName": patientName,
"memberId": foundMemberId # Include extracted Member ID
} }
return output return output
except Exception as e: 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; 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 z
.string() .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) .min(1)
.max(32) .max(32)
.optional() .optional()