Compare commits
10 Commits
0961c01660
...
27e6e6a4a0
| Author | SHA1 | Date | |
|---|---|---|---|
| 27e6e6a4a0 | |||
| 35896c264c | |||
| cf53065a26 | |||
| 03172f0710 | |||
| 445691cdd0 | |||
| e425a829b2 | |||
| e43329e95f | |||
| 5370a0e445 | |||
| 279a6b8dbc | |||
|
|
3907672185 |
8
.gitignore
vendored
8
.gitignore
vendored
@@ -37,4 +37,10 @@ dist/
|
||||
|
||||
# env
|
||||
*.env
|
||||
*chrome_profile_ddma*
|
||||
*chrome_profile_ddma*
|
||||
*chrome_profile_dentaquest*
|
||||
*chrome_profile_unitedsco*
|
||||
*chrome_profile_deltains*
|
||||
|
||||
# selenium downloads (generated PDFs)
|
||||
apps/**/seleniumDownloads/
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
NODE_ENV="development"
|
||||
HOST=0.0.0.0
|
||||
PORT=5000
|
||||
FRONTEND_URLS=http://localhost:3000,http://192.168.1.8:3000
|
||||
# FRONTEND_URLS=http://localhost:3000,http://192.168.1.8:3000
|
||||
FRONTEND_URLS=http://192.168.0.238:3000
|
||||
SELENIUM_AGENT_BASE_URL=http://localhost:5002
|
||||
JWT_SECRET = 'dentalsecret'
|
||||
DB_HOST=localhost
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=mypassword
|
||||
DB_NAME=dentalapp
|
||||
DATABASE_URL=postgresql://postgres:mypassword@localhost:5432/dentalapp
|
||||
DATABASE_URL=postgresql://postgres:mypassword@localhost:5432/dentalapp
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -41,7 +41,8 @@ function isOriginAllowed(origin?: string | null) {
|
||||
// Dev mode: allow localhost origins automatically
|
||||
if (
|
||||
origin.startsWith("http://localhost") ||
|
||||
origin.startsWith("http://127.0.0.1")
|
||||
origin.startsWith("http://127.0.0.1") ||
|
||||
origin.startsWith("http://192.168.0.238")
|
||||
)
|
||||
return true;
|
||||
// allow explicit FRONTEND_URLS if provided
|
||||
|
||||
@@ -9,6 +9,9 @@ import insuranceCredsRoutes from "./insuranceCreds";
|
||||
import documentsRoutes from "./documents";
|
||||
import insuranceStatusRoutes from "./insuranceStatus";
|
||||
import insuranceStatusDdmaRoutes from "./insuranceStatusDDMA";
|
||||
import insuranceStatusDentaQuestRoutes from "./insuranceStatusDentaQuest";
|
||||
import insuranceStatusUnitedSCORoutes from "./insuranceStatusUnitedSCO";
|
||||
import insuranceStatusDeltaInsRoutes from "./insuranceStatusDeltaIns";
|
||||
import paymentsRoutes from "./payments";
|
||||
import databaseManagementRoutes from "./database-management";
|
||||
import notificationsRoutes from "./notifications";
|
||||
@@ -29,6 +32,9 @@ router.use("/insuranceCreds", insuranceCredsRoutes);
|
||||
router.use("/documents", documentsRoutes);
|
||||
router.use("/insurance-status", insuranceStatusRoutes);
|
||||
router.use("/insurance-status-ddma", insuranceStatusDdmaRoutes);
|
||||
router.use("/insurance-status-dentaquest", insuranceStatusDentaQuestRoutes);
|
||||
router.use("/insurance-status-unitedsco", insuranceStatusUnitedSCORoutes);
|
||||
router.use("/insurance-status-deltains", insuranceStatusDeltaInsRoutes);
|
||||
router.use("/payments", paymentsRoutes);
|
||||
router.use("/database-management", databaseManagementRoutes);
|
||||
router.use("/notifications", notificationsRoutes);
|
||||
|
||||
@@ -76,8 +76,36 @@ router.put("/:id", async (req: Request, res: Response): Promise<any> => {
|
||||
const id = Number(req.params.id);
|
||||
if (isNaN(id)) return res.status(400).send("Invalid credential ID");
|
||||
|
||||
// Get existing credential to know its siteKey
|
||||
const existing = await storage.getInsuranceCredential(id);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ message: "Credential not found" });
|
||||
}
|
||||
|
||||
const updates = req.body as Partial<InsuranceCredential>;
|
||||
const credential = await storage.updateInsuranceCredential(id, updates);
|
||||
|
||||
// Clear Selenium browser session when credentials are changed
|
||||
const seleniumAgentUrl = process.env.SELENIUM_AGENT_URL || "http://localhost:5002";
|
||||
try {
|
||||
if (existing.siteKey === "DDMA") {
|
||||
await fetch(`${seleniumAgentUrl}/clear-ddma-session`, { method: "POST" });
|
||||
console.log("[insuranceCreds] Cleared DDMA browser session after credential update");
|
||||
} else if (existing.siteKey === "DENTAQUEST") {
|
||||
await fetch(`${seleniumAgentUrl}/clear-dentaquest-session`, { method: "POST" });
|
||||
console.log("[insuranceCreds] Cleared DentaQuest browser session after credential update");
|
||||
} else if (existing.siteKey === "UNITEDSCO") {
|
||||
await fetch(`${seleniumAgentUrl}/clear-unitedsco-session`, { method: "POST" });
|
||||
console.log("[insuranceCreds] Cleared United SCO browser session after credential update");
|
||||
} else if (existing.siteKey === "DELTAINS") {
|
||||
await fetch(`${seleniumAgentUrl}/clear-deltains-session`, { method: "POST" });
|
||||
console.log("[insuranceCreds] Cleared Delta Dental Ins browser session after credential update");
|
||||
}
|
||||
} catch (seleniumErr) {
|
||||
// Don't fail the update if Selenium session clear fails
|
||||
console.error("[insuranceCreds] Failed to clear Selenium session:", seleniumErr);
|
||||
}
|
||||
|
||||
return res.status(200).json(credential);
|
||||
} catch (err) {
|
||||
return res
|
||||
@@ -115,6 +143,28 @@ router.delete("/:id", async (req: Request, res: Response): Promise<any> => {
|
||||
.status(404)
|
||||
.json({ message: "Credential not found or already deleted" });
|
||||
}
|
||||
|
||||
// 4) Clear Selenium browser session for this provider
|
||||
const seleniumAgentUrl = process.env.SELENIUM_AGENT_URL || "http://localhost:5002";
|
||||
try {
|
||||
if (existing.siteKey === "DDMA") {
|
||||
await fetch(`${seleniumAgentUrl}/clear-ddma-session`, { method: "POST" });
|
||||
console.log("[insuranceCreds] Cleared DDMA browser session after credential deletion");
|
||||
} else if (existing.siteKey === "DENTAQUEST") {
|
||||
await fetch(`${seleniumAgentUrl}/clear-dentaquest-session`, { method: "POST" });
|
||||
console.log("[insuranceCreds] Cleared DentaQuest browser session after credential deletion");
|
||||
} else if (existing.siteKey === "UNITEDSCO") {
|
||||
await fetch(`${seleniumAgentUrl}/clear-unitedsco-session`, { method: "POST" });
|
||||
console.log("[insuranceCreds] Cleared United SCO browser session after credential deletion");
|
||||
} else if (existing.siteKey === "DELTAINS") {
|
||||
await fetch(`${seleniumAgentUrl}/clear-deltains-session`, { method: "POST" });
|
||||
console.log("[insuranceCreds] Cleared Delta Dental Ins browser session after credential deletion");
|
||||
}
|
||||
} catch (seleniumErr) {
|
||||
// Don't fail the delete if Selenium session clear fails
|
||||
console.error("[insuranceCreds] Failed to clear Selenium session:", seleniumErr);
|
||||
}
|
||||
|
||||
return res.status(204).send();
|
||||
} catch (err) {
|
||||
return res
|
||||
|
||||
@@ -136,36 +136,135 @@ async function handleDdmaCompletedJob(
|
||||
|
||||
// We'll wrap the processing in try/catch/finally so cleanup always runs
|
||||
try {
|
||||
// 1) ensuring memberid.
|
||||
const insuranceEligibilityData = job.insuranceEligibilityData;
|
||||
const insuranceId = String(insuranceEligibilityData.memberId ?? "").trim();
|
||||
|
||||
// DEBUG: Log the raw selenium result
|
||||
console.log(`[ddma-eligibility] === DEBUG: Raw seleniumResult ===`);
|
||||
console.log(`[ddma-eligibility] seleniumResult.patientName: '${seleniumResult?.patientName}'`);
|
||||
console.log(`[ddma-eligibility] seleniumResult.memberId: '${seleniumResult?.memberId}'`);
|
||||
console.log(`[ddma-eligibility] seleniumResult.status: '${seleniumResult?.status}'`);
|
||||
|
||||
// 1) Get insuranceId - prefer from Selenium result (flexible search support)
|
||||
let insuranceId = String(seleniumResult?.memberId || "").trim();
|
||||
if (!insuranceId) {
|
||||
throw new Error("Missing memberId for ddma job");
|
||||
insuranceId = String(insuranceEligibilityData.memberId ?? "").trim();
|
||||
}
|
||||
console.log(`[ddma-eligibility] Resolved insuranceId: ${insuranceId || "(none)"}`);
|
||||
|
||||
// 2) Create or update patient (with name from selenium result if available)
|
||||
// 2) Get patient name - prefer from Selenium result
|
||||
const patientNameFromResult =
|
||||
typeof seleniumResult?.patientName === "string"
|
||||
? seleniumResult.patientName.trim()
|
||||
: null;
|
||||
|
||||
console.log(`[ddma-eligibility] patientNameFromResult: '${patientNameFromResult}'`);
|
||||
|
||||
const { firstName, lastName } = splitName(patientNameFromResult);
|
||||
// Get name from input data as fallback
|
||||
let firstName = String(insuranceEligibilityData.firstName || "").trim();
|
||||
let lastName = String(insuranceEligibilityData.lastName || "").trim();
|
||||
|
||||
// Override with name from Selenium result if available
|
||||
if (patientNameFromResult) {
|
||||
const parsedName = splitName(patientNameFromResult);
|
||||
console.log(`[ddma-eligibility] splitName result: firstName='${parsedName.firstName}', lastName='${parsedName.lastName}'`);
|
||||
if (parsedName.firstName) firstName = parsedName.firstName;
|
||||
if (parsedName.lastName) lastName = parsedName.lastName;
|
||||
}
|
||||
console.log(`[ddma-eligibility] Resolved name: firstName='${firstName}', lastName='${lastName}'`);
|
||||
|
||||
await createOrUpdatePatientByInsuranceId({
|
||||
insuranceId,
|
||||
firstName,
|
||||
lastName,
|
||||
dob: insuranceEligibilityData.dateOfBirth,
|
||||
userId: job.userId,
|
||||
});
|
||||
|
||||
// 3) Update patient status + PDF upload
|
||||
const patient = await storage.getPatientByInsuranceId(
|
||||
insuranceEligibilityData.memberId
|
||||
);
|
||||
// 3) Find or create patient
|
||||
let patient: any = null;
|
||||
|
||||
// First, try to find by insuranceId if available
|
||||
if (insuranceId) {
|
||||
patient = await storage.getPatientByInsuranceId(insuranceId);
|
||||
if (patient) {
|
||||
console.log(`[ddma-eligibility] Found patient by insuranceId: ${patient.id}`);
|
||||
|
||||
// Update name if we have better data
|
||||
const updates: any = {};
|
||||
if (firstName && String(patient.firstName ?? "").trim() !== firstName) {
|
||||
updates.firstName = firstName;
|
||||
}
|
||||
if (lastName && String(patient.lastName ?? "").trim() !== lastName) {
|
||||
updates.lastName = lastName;
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await storage.updatePatient(patient.id, updates);
|
||||
console.log(`[ddma-eligibility] Updated patient name to: ${firstName} ${lastName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If not found by ID, try to find by name
|
||||
if (!patient && firstName && lastName) {
|
||||
try {
|
||||
console.log(`[ddma-eligibility] Looking up patient by name: ${firstName} ${lastName}`);
|
||||
const patients = await storage.getPatientsByUserId(job.userId);
|
||||
patient = patients.find(
|
||||
(p: any) =>
|
||||
String(p.firstName ?? "").toLowerCase() === firstName.toLowerCase() &&
|
||||
String(p.lastName ?? "").toLowerCase() === lastName.toLowerCase()
|
||||
) || null;
|
||||
if (patient) {
|
||||
console.log(`[ddma-eligibility] Found patient by name: ${patient.id}`);
|
||||
// Update insuranceId if we have it
|
||||
if (insuranceId && String(patient.insuranceId ?? "").trim() !== insuranceId) {
|
||||
await storage.updatePatient(patient.id, { insuranceId });
|
||||
console.log(`[ddma-eligibility] Updated patient insuranceId to: ${insuranceId}`);
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.log(`[ddma-eligibility] Error finding patient by name: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine eligibility status from Selenium result
|
||||
const eligibilityStatus = seleniumResult.eligibility === "active" ? "ACTIVE" : "INACTIVE";
|
||||
console.log(`[ddma-eligibility] Eligibility status from Delta MA: ${eligibilityStatus}`);
|
||||
|
||||
// If still not found, create new patient
|
||||
console.log(`[ddma-eligibility] Patient creation check: patient=${patient?.id || 'null'}, firstName='${firstName}', lastName='${lastName}'`);
|
||||
if (!patient && firstName && lastName) {
|
||||
console.log(`[ddma-eligibility] Creating new patient: ${firstName} ${lastName} with status: ${eligibilityStatus}`);
|
||||
try {
|
||||
let parsedDob: Date | undefined = undefined;
|
||||
if (insuranceEligibilityData.dateOfBirth) {
|
||||
try {
|
||||
parsedDob = new Date(insuranceEligibilityData.dateOfBirth);
|
||||
if (isNaN(parsedDob.getTime())) parsedDob = undefined;
|
||||
} catch {
|
||||
parsedDob = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const newPatientData: InsertPatient = {
|
||||
firstName,
|
||||
lastName,
|
||||
dateOfBirth: parsedDob || new Date(), // Required field
|
||||
insuranceId: insuranceId || undefined,
|
||||
insuranceProvider: "Delta MA", // Set insurance provider
|
||||
gender: "Unknown", // Required field - default value
|
||||
phone: "", // Required field - default empty
|
||||
userId: job.userId, // Required field
|
||||
status: eligibilityStatus, // Set status from eligibility check
|
||||
};
|
||||
|
||||
const validation = insertPatientSchema.safeParse(newPatientData);
|
||||
if (validation.success) {
|
||||
patient = await storage.createPatient(validation.data);
|
||||
console.log(`[ddma-eligibility] Created new patient: ${patient.id} with status: ${eligibilityStatus}`);
|
||||
} else {
|
||||
console.log(`[ddma-eligibility] Patient validation failed: ${validation.error.message}`);
|
||||
}
|
||||
} catch (createErr: any) {
|
||||
console.log(`[ddma-eligibility] Failed to create patient: ${createErr.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!patient?.id) {
|
||||
outputResult.patientUpdateStatus =
|
||||
"Patient not found; no update performed";
|
||||
"Patient not found and could not be created; no update performed";
|
||||
return {
|
||||
patientUpdateStatus: outputResult.patientUpdateStatus,
|
||||
pdfUploadStatus: "none",
|
||||
@@ -173,49 +272,61 @@ async function handleDdmaCompletedJob(
|
||||
};
|
||||
}
|
||||
|
||||
// update patient status.
|
||||
const newStatus =
|
||||
seleniumResult.eligibility === "active" ? "ACTIVE" : "INACTIVE";
|
||||
await storage.updatePatient(patient.id, { status: newStatus });
|
||||
outputResult.patientUpdateStatus = `Patient status updated to ${newStatus}`;
|
||||
// Update patient status from Delta MA eligibility result
|
||||
await storage.updatePatient(patient.id, { status: eligibilityStatus });
|
||||
outputResult.patientUpdateStatus = `Patient ${patient.id} status set to ${eligibilityStatus} (Delta MA eligibility: ${seleniumResult.eligibility})`;
|
||||
console.log(`[ddma-eligibility] ${outputResult.patientUpdateStatus}`);
|
||||
|
||||
// convert screenshot -> pdf if available
|
||||
// Handle PDF or convert screenshot -> pdf if available
|
||||
let pdfBuffer: Buffer | null = null;
|
||||
let generatedPdfPath: string | null = null;
|
||||
|
||||
if (
|
||||
seleniumResult &&
|
||||
seleniumResult.ss_path &&
|
||||
typeof seleniumResult.ss_path === "string" &&
|
||||
(seleniumResult.ss_path.endsWith(".png") ||
|
||||
seleniumResult.ss_path.endsWith(".jpg") ||
|
||||
seleniumResult.ss_path.endsWith(".jpeg"))
|
||||
typeof seleniumResult.ss_path === "string"
|
||||
) {
|
||||
try {
|
||||
if (!fsSync.existsSync(seleniumResult.ss_path)) {
|
||||
throw new Error(
|
||||
`Screenshot file not found: ${seleniumResult.ss_path}`
|
||||
`File not found: ${seleniumResult.ss_path}`
|
||||
);
|
||||
}
|
||||
|
||||
pdfBuffer = await imageToPdfBuffer(seleniumResult.ss_path);
|
||||
// Check if the file is already a PDF (from Page.printToPDF)
|
||||
if (seleniumResult.ss_path.endsWith(".pdf")) {
|
||||
// Read PDF directly
|
||||
pdfBuffer = await fs.readFile(seleniumResult.ss_path);
|
||||
generatedPdfPath = seleniumResult.ss_path;
|
||||
seleniumResult.pdf_path = generatedPdfPath;
|
||||
console.log(`[ddma-eligibility] Using PDF directly from Selenium: ${generatedPdfPath}`);
|
||||
} else if (
|
||||
seleniumResult.ss_path.endsWith(".png") ||
|
||||
seleniumResult.ss_path.endsWith(".jpg") ||
|
||||
seleniumResult.ss_path.endsWith(".jpeg")
|
||||
) {
|
||||
// Convert image to PDF
|
||||
pdfBuffer = await imageToPdfBuffer(seleniumResult.ss_path);
|
||||
|
||||
const pdfFileName = `ddma_eligibility_${insuranceEligibilityData.memberId}_${Date.now()}.pdf`;
|
||||
generatedPdfPath = path.join(
|
||||
path.dirname(seleniumResult.ss_path),
|
||||
pdfFileName
|
||||
);
|
||||
await fs.writeFile(generatedPdfPath, pdfBuffer);
|
||||
|
||||
// ensure cleanup uses this
|
||||
seleniumResult.pdf_path = generatedPdfPath;
|
||||
const pdfFileName = `ddma_eligibility_${insuranceEligibilityData.memberId}_${Date.now()}.pdf`;
|
||||
generatedPdfPath = path.join(
|
||||
path.dirname(seleniumResult.ss_path),
|
||||
pdfFileName
|
||||
);
|
||||
await fs.writeFile(generatedPdfPath, pdfBuffer);
|
||||
seleniumResult.pdf_path = generatedPdfPath;
|
||||
console.log(`[ddma-eligibility] Converted screenshot to PDF: ${generatedPdfPath}`);
|
||||
} else {
|
||||
outputResult.pdfUploadStatus =
|
||||
`Unsupported file format: ${seleniumResult.ss_path}`;
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("Failed to convert screenshot to PDF:", err);
|
||||
outputResult.pdfUploadStatus = `Failed to convert screenshot to PDF: ${String(err)}`;
|
||||
console.error("Failed to process PDF/screenshot:", err);
|
||||
outputResult.pdfUploadStatus = `Failed to process file: ${String(err)}`;
|
||||
}
|
||||
} else {
|
||||
outputResult.pdfUploadStatus =
|
||||
"No valid screenshot (ss_path) provided by Selenium; nothing to upload.";
|
||||
"No valid file path (ss_path) provided by Selenium; nothing to upload.";
|
||||
}
|
||||
|
||||
if (pdfBuffer && generatedPdfPath) {
|
||||
|
||||
749
apps/Backend/src/routes/insuranceStatusDeltaIns.ts
Normal file
749
apps/Backend/src/routes/insuranceStatusDeltaIns.ts
Normal 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;
|
||||
793
apps/Backend/src/routes/insuranceStatusDentaQuest.ts
Normal file
793
apps/Backend/src/routes/insuranceStatusDentaQuest.ts
Normal 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;
|
||||
|
||||
815
apps/Backend/src/routes/insuranceStatusUnitedSCO.ts
Normal file
815
apps/Backend/src/routes/insuranceStatusUnitedSCO.ts
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
NODE_ENV=development
|
||||
HOST=0.0.0.0
|
||||
PORT=3000
|
||||
VITE_API_BASE_URL_BACKEND=http://localhost:5000
|
||||
# VITE_API_BASE_URL_BACKEND=http://localhost:5000
|
||||
VITE_API_BASE_URL_BACKEND=
|
||||
|
||||
@@ -119,6 +119,10 @@ export function DdmaEligibilityButton({
|
||||
const { toast } = useToast();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// Flexible validation: require DOB + at least one identifier (memberId OR firstName OR lastName)
|
||||
const isDdmaFormIncomplete =
|
||||
!dateOfBirth || (!memberId && !firstName && !lastName);
|
||||
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const connectingRef = useRef<Promise<void> | null>(null);
|
||||
|
||||
@@ -371,10 +375,11 @@ export function DdmaEligibilityButton({
|
||||
};
|
||||
|
||||
const startDdmaEligibility = async () => {
|
||||
if (!memberId || !dateOfBirth) {
|
||||
// Flexible validation: require DOB + at least one identifier
|
||||
if (!dateOfBirth || (!memberId && !firstName && !lastName)) {
|
||||
toast({
|
||||
title: "Missing fields",
|
||||
description: "Member ID and Date of Birth are required.",
|
||||
description: "Date of Birth and at least one identifier (Member ID, First Name, or Last Name) are required.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
@@ -539,7 +544,7 @@ export function DdmaEligibilityButton({
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="default"
|
||||
disabled={isFormIncomplete || isStarting}
|
||||
disabled={isDdmaFormIncomplete || isStarting}
|
||||
onClick={startDdmaEligibility}
|
||||
>
|
||||
{isStarting ? (
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,15 @@ type CredentialFormProps = {
|
||||
};
|
||||
};
|
||||
|
||||
// Available site keys - must match exactly what the automation buttons expect
|
||||
const SITE_KEY_OPTIONS = [
|
||||
{ value: "MH", label: "MassHealth" },
|
||||
{ value: "DDMA", label: "Delta Dental MA" },
|
||||
{ value: "DELTAINS", label: "Delta Dental Ins" },
|
||||
{ value: "DENTAQUEST", label: "Tufts SCO / DentaQuest" },
|
||||
{ value: "UNITEDSCO", label: "United SCO" },
|
||||
];
|
||||
|
||||
export function CredentialForm({ onClose, userId, defaultValues }: CredentialFormProps) {
|
||||
const [siteKey, setSiteKey] = useState(defaultValues?.siteKey || "");
|
||||
const [username, setUsername] = useState(defaultValues?.username || "");
|
||||
@@ -91,14 +100,19 @@ export function CredentialForm({ onClose, userId, defaultValues }: CredentialFor
|
||||
</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium">Site Key</label>
|
||||
<input
|
||||
type="text"
|
||||
<label className="block text-sm font-medium">Insurance Provider</label>
|
||||
<select
|
||||
value={siteKey}
|
||||
onChange={(e) => setSiteKey(e.target.value)}
|
||||
className="mt-1 p-2 border rounded w-full"
|
||||
placeholder="e.g., MH, Delta MA, (keep the site key exact same)"
|
||||
/>
|
||||
className="mt-1 p-2 border rounded w-full bg-white"
|
||||
>
|
||||
<option value="">Select a provider...</option>
|
||||
{SITE_KEY_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium">Username</label>
|
||||
|
||||
@@ -13,6 +13,19 @@ type Credential = {
|
||||
password: string;
|
||||
};
|
||||
|
||||
// Map site keys to friendly labels
|
||||
const SITE_KEY_LABELS: Record<string, string> = {
|
||||
MH: "MassHealth",
|
||||
DDMA: "Delta Dental MA",
|
||||
DELTAINS: "Delta Dental Ins",
|
||||
DENTAQUEST: "Tufts SCO / DentaQuest",
|
||||
UNITEDSCO: "United SCO",
|
||||
};
|
||||
|
||||
function getSiteKeyLabel(siteKey: string): string {
|
||||
return SITE_KEY_LABELS[siteKey] || siteKey;
|
||||
}
|
||||
|
||||
export function CredentialTable() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -108,7 +121,7 @@ export function CredentialTable() {
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Site Key
|
||||
Provider
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Username
|
||||
@@ -141,7 +154,7 @@ export function CredentialTable() {
|
||||
) : (
|
||||
currentCredentials.map((cred) => (
|
||||
<tr key={cred.id}>
|
||||
<td className="px-4 py-2">{cred.siteKey}</td>
|
||||
<td className="px-4 py-2">{getSiteKeyLabel(cred.siteKey)}</td>
|
||||
<td className="px-4 py-2">{cred.username}</td>
|
||||
<td className="px-4 py-2">••••••••</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
@@ -227,7 +240,7 @@ export function CredentialTable() {
|
||||
isOpen={isDeleteDialogOpen}
|
||||
onConfirm={handleConfirmDelete}
|
||||
onCancel={handleCancelDelete}
|
||||
entityName={credentialToDelete?.siteKey}
|
||||
entityName={credentialToDelete ? getSiteKeyLabel(credentialToDelete.siteKey) : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -28,6 +28,9 @@ import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
|
||||
import { PdfPreviewModal } from "@/components/insurance-status/pdf-preview-modal";
|
||||
import { useLocation } from "wouter";
|
||||
import { DdmaEligibilityButton } from "@/components/insurance-status/ddma-buton-modal";
|
||||
import { DentaQuestEligibilityButton } from "@/components/insurance-status/dentaquest-button-modal";
|
||||
import { UnitedSCOEligibilityButton } from "@/components/insurance-status/unitedsco-button-modal";
|
||||
import { DeltaInsEligibilityButton } from "@/components/insurance-status/deltains-button-modal";
|
||||
|
||||
export default function InsuranceStatusPage() {
|
||||
const { user } = useAuth();
|
||||
@@ -575,7 +578,7 @@ export default function InsuranceStatusPage() {
|
||||
{/* TEMP PROVIDER BUTTONS */}
|
||||
<div className="space-y-4 mt-6">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">
|
||||
Other provider checks
|
||||
Other Insurances
|
||||
</h3>
|
||||
|
||||
{/* Row 1 */}
|
||||
@@ -595,14 +598,62 @@ export default function InsuranceStatusPage() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeltaInsEligibilityButton
|
||||
memberId={memberId}
|
||||
dateOfBirth={dateOfBirth}
|
||||
firstName={firstName}
|
||||
lastName={lastName}
|
||||
isFormIncomplete={isFormIncomplete}
|
||||
onPdfReady={(pdfId, fallbackFilename) => {
|
||||
setPreviewPdfId(pdfId);
|
||||
setPreviewFallbackFilename(
|
||||
fallbackFilename ?? `eligibility_deltains_${memberId}.pdf`
|
||||
);
|
||||
setPreviewOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
disabled={isFormIncomplete}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Metlife Dental
|
||||
BCBS
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Row 2 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<DentaQuestEligibilityButton
|
||||
memberId={memberId}
|
||||
dateOfBirth={dateOfBirth}
|
||||
firstName={firstName}
|
||||
lastName={lastName}
|
||||
isFormIncomplete={isFormIncomplete}
|
||||
onPdfReady={(pdfId, fallbackFilename) => {
|
||||
setPreviewPdfId(pdfId);
|
||||
setPreviewFallbackFilename(
|
||||
fallbackFilename ?? `eligibility_dentaquest_${memberId}.pdf`
|
||||
);
|
||||
setPreviewOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<UnitedSCOEligibilityButton
|
||||
memberId={memberId}
|
||||
dateOfBirth={dateOfBirth}
|
||||
firstName={firstName}
|
||||
lastName={lastName}
|
||||
isFormIncomplete={isFormIncomplete}
|
||||
onPdfReady={(pdfId, fallbackFilename) => {
|
||||
setPreviewPdfId(pdfId);
|
||||
setPreviewFallbackFilename(
|
||||
fallbackFilename ?? `eligibility_unitedsco_${memberId}.pdf`
|
||||
);
|
||||
setPreviewOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
@@ -614,26 +665,8 @@ export default function InsuranceStatusPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Row 2 */}
|
||||
{/* Row 3 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
disabled={isFormIncomplete}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Tufts SCO/SWH/Navi/Mass Gen
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
disabled={isFormIncomplete}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
United SCO
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
@@ -642,9 +675,25 @@ export default function InsuranceStatusPage() {
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
United AAPR
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
disabled={isFormIncomplete}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Metlife
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
disabled={isFormIncomplete}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Cigna
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Row 3 */}
|
||||
{/* Row 4 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Button
|
||||
className="w-full"
|
||||
@@ -662,7 +711,28 @@ export default function InsuranceStatusPage() {
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Altus
|
||||
</Button>
|
||||
<div /> {/* filler cell to keep grid shape */}
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
disabled={isFormIncomplete}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Delta WA
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Row 5 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
disabled={isFormIncomplete}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Delta IL
|
||||
</Button>
|
||||
<div />
|
||||
<div />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -10,6 +10,17 @@ export default defineConfig(({ mode }) => {
|
||||
server: {
|
||||
host: env.HOST,
|
||||
port: Number(env.PORT),
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: env.VITE_API_BASE_URL_BACKEND || "http://localhost:5000",
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/socket.io": {
|
||||
target: env.VITE_API_BASE_URL_BACKEND || "http://localhost:5000",
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"postinstall": "pip install -r requirements.txt",
|
||||
"dev": "python main.py"
|
||||
"dev": "python3 main.py"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"postinstall": "pip install -r requirements.txt",
|
||||
"dev": "python main.py"
|
||||
"dev": "python3 main.py"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,32 @@ from selenium_preAuthWorker import AutomationMassHealthPreAuth
|
||||
import os
|
||||
import time
|
||||
import helpers_ddma_eligibility as hddma
|
||||
import helpers_dentaquest_eligibility as hdentaquest
|
||||
import helpers_unitedsco_eligibility as hunitedsco
|
||||
import helpers_deltains_eligibility as hdeltains
|
||||
|
||||
# Import session clear functions for startup
|
||||
from ddma_browser_manager import clear_ddma_session_on_startup
|
||||
from dentaquest_browser_manager import clear_dentaquest_session_on_startup
|
||||
from unitedsco_browser_manager import clear_unitedsco_session_on_startup
|
||||
from deltains_browser_manager import clear_deltains_session_on_startup
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
# Clear all sessions on startup (after PC restart)
|
||||
# This ensures users must login again after PC restart
|
||||
print("=" * 50)
|
||||
print("SELENIUM AGENT STARTING - CLEARING ALL SESSIONS")
|
||||
print("=" * 50)
|
||||
clear_ddma_session_on_startup()
|
||||
clear_dentaquest_session_on_startup()
|
||||
clear_unitedsco_session_on_startup()
|
||||
clear_deltains_session_on_startup()
|
||||
print("=" * 50)
|
||||
print("SESSION CLEAR COMPLETE - FRESH LOGINS REQUIRED")
|
||||
print("=" * 50)
|
||||
|
||||
app = FastAPI()
|
||||
# Allow 1 selenium session at a time
|
||||
semaphore = asyncio.Semaphore(1)
|
||||
@@ -186,6 +208,223 @@ async def ddma_eligibility(request: Request):
|
||||
return {"status": "started", "session_id": sid}
|
||||
|
||||
|
||||
# Endpoint:6 - DentaQuest eligibility (background, OTP)
|
||||
|
||||
async def _dentaquest_worker_wrapper(sid: str, data: dict, url: str):
|
||||
"""
|
||||
Background worker that:
|
||||
- acquires semaphore (to keep 1 selenium at a time),
|
||||
- updates active/queued counters,
|
||||
- runs the DentaQuest flow via helpers.start_dentaquest_run.
|
||||
"""
|
||||
global active_jobs, waiting_jobs
|
||||
async with semaphore:
|
||||
async with lock:
|
||||
waiting_jobs -= 1
|
||||
active_jobs += 1
|
||||
try:
|
||||
await hdentaquest.start_dentaquest_run(sid, data, url)
|
||||
finally:
|
||||
async with lock:
|
||||
active_jobs -= 1
|
||||
|
||||
|
||||
@app.post("/dentaquest-eligibility")
|
||||
async def dentaquest_eligibility(request: Request):
|
||||
"""
|
||||
Starts a DentaQuest eligibility session in the background.
|
||||
Body: { "data": { ... }, "url"?: string }
|
||||
Returns: { status: "started", session_id: "<uuid>" }
|
||||
"""
|
||||
global waiting_jobs
|
||||
|
||||
body = await request.json()
|
||||
data = body.get("data", {})
|
||||
|
||||
# create session
|
||||
sid = hdentaquest.make_session_entry()
|
||||
hdentaquest.sessions[sid]["type"] = "dentaquest_eligibility"
|
||||
hdentaquest.sessions[sid]["last_activity"] = time.time()
|
||||
|
||||
async with lock:
|
||||
waiting_jobs += 1
|
||||
|
||||
# run in background (queued under semaphore)
|
||||
asyncio.create_task(_dentaquest_worker_wrapper(sid, data, url="https://providers.dentaquest.com/onboarding/start/"))
|
||||
|
||||
return {"status": "started", "session_id": sid}
|
||||
|
||||
|
||||
@app.post("/dentaquest-submit-otp")
|
||||
async def dentaquest_submit_otp(request: Request):
|
||||
"""
|
||||
Body: { "session_id": "<sid>", "otp": "123456" }
|
||||
Node / frontend call this when user provides OTP for DentaQuest.
|
||||
"""
|
||||
body = await request.json()
|
||||
sid = body.get("session_id")
|
||||
otp = body.get("otp")
|
||||
if not sid or not otp:
|
||||
raise HTTPException(status_code=400, detail="session_id and otp required")
|
||||
|
||||
res = hdentaquest.submit_otp(sid, otp)
|
||||
if res.get("status") == "error":
|
||||
raise HTTPException(status_code=400, detail=res.get("message"))
|
||||
return res
|
||||
|
||||
|
||||
@app.get("/dentaquest-session/{sid}/status")
|
||||
async def dentaquest_session_status(sid: str):
|
||||
s = hdentaquest.get_session_status(sid)
|
||||
if s.get("status") == "not_found":
|
||||
raise HTTPException(status_code=404, detail="session not found")
|
||||
return s
|
||||
|
||||
|
||||
# Endpoint:7 - United SCO eligibility (background, OTP)
|
||||
|
||||
async def _unitedsco_worker_wrapper(sid: str, data: dict, url: str):
|
||||
"""
|
||||
Background worker that:
|
||||
- acquires semaphore (to keep 1 selenium at a time),
|
||||
- updates active/queued counters,
|
||||
- runs the United SCO flow via helpers.start_unitedsco_run.
|
||||
"""
|
||||
global active_jobs, waiting_jobs
|
||||
async with semaphore:
|
||||
async with lock:
|
||||
waiting_jobs -= 1
|
||||
active_jobs += 1
|
||||
try:
|
||||
await hunitedsco.start_unitedsco_run(sid, data, url)
|
||||
finally:
|
||||
async with lock:
|
||||
active_jobs -= 1
|
||||
|
||||
|
||||
@app.post("/unitedsco-eligibility")
|
||||
async def unitedsco_eligibility(request: Request):
|
||||
"""
|
||||
Starts a United SCO eligibility session in the background.
|
||||
Body: { "data": { ... }, "url"?: string }
|
||||
Returns: { status: "started", session_id: "<uuid>" }
|
||||
"""
|
||||
global waiting_jobs
|
||||
|
||||
body = await request.json()
|
||||
data = body.get("data", {})
|
||||
|
||||
# create session
|
||||
sid = hunitedsco.make_session_entry()
|
||||
hunitedsco.sessions[sid]["type"] = "unitedsco_eligibility"
|
||||
hunitedsco.sessions[sid]["last_activity"] = time.time()
|
||||
|
||||
async with lock:
|
||||
waiting_jobs += 1
|
||||
|
||||
# run in background (queued under semaphore)
|
||||
asyncio.create_task(_unitedsco_worker_wrapper(sid, data, url="https://app.dentalhub.com/app/login"))
|
||||
|
||||
return {"status": "started", "session_id": sid}
|
||||
|
||||
|
||||
@app.post("/unitedsco-submit-otp")
|
||||
async def unitedsco_submit_otp(request: Request):
|
||||
"""
|
||||
Body: { "session_id": "<sid>", "otp": "123456" }
|
||||
Node / frontend call this when user provides OTP for United SCO.
|
||||
"""
|
||||
body = await request.json()
|
||||
sid = body.get("session_id")
|
||||
otp = body.get("otp")
|
||||
if not sid or not otp:
|
||||
raise HTTPException(status_code=400, detail="session_id and otp required")
|
||||
|
||||
res = hunitedsco.submit_otp(sid, otp)
|
||||
if res.get("status") == "error":
|
||||
raise HTTPException(status_code=400, detail=res.get("message"))
|
||||
return res
|
||||
|
||||
|
||||
@app.get("/unitedsco-session/{sid}/status")
|
||||
async def unitedsco_session_status(sid: str):
|
||||
s = hunitedsco.get_session_status(sid)
|
||||
if s.get("status") == "not_found":
|
||||
raise HTTPException(status_code=404, detail="session not found")
|
||||
return s
|
||||
|
||||
|
||||
# Endpoint:8 - DeltaIns eligibility (background, OTP)
|
||||
|
||||
async def _deltains_worker_wrapper(sid: str, data: dict, url: str):
|
||||
"""
|
||||
Background worker that:
|
||||
- acquires semaphore (to keep 1 selenium at a time),
|
||||
- updates active/queued counters,
|
||||
- runs the DeltaIns flow via helpers.start_deltains_run.
|
||||
"""
|
||||
global active_jobs, waiting_jobs
|
||||
async with semaphore:
|
||||
async with lock:
|
||||
waiting_jobs -= 1
|
||||
active_jobs += 1
|
||||
try:
|
||||
await hdeltains.start_deltains_run(sid, data, url)
|
||||
finally:
|
||||
async with lock:
|
||||
active_jobs -= 1
|
||||
|
||||
|
||||
@app.post("/deltains-eligibility")
|
||||
async def deltains_eligibility(request: Request):
|
||||
"""
|
||||
Starts a DeltaIns eligibility session in the background.
|
||||
Body: { "data": { ... }, "url"?: string }
|
||||
Returns: { status: "started", session_id: "<uuid>" }
|
||||
"""
|
||||
global waiting_jobs
|
||||
|
||||
body = await request.json()
|
||||
data = body.get("data", {})
|
||||
|
||||
sid = hdeltains.make_session_entry()
|
||||
hdeltains.sessions[sid]["type"] = "deltains_eligibility"
|
||||
hdeltains.sessions[sid]["last_activity"] = time.time()
|
||||
|
||||
async with lock:
|
||||
waiting_jobs += 1
|
||||
|
||||
asyncio.create_task(_deltains_worker_wrapper(sid, data, url="https://www.deltadentalins.com/ciam/login?TARGET=%2Fprovider-tools%2Fv2"))
|
||||
|
||||
return {"status": "started", "session_id": sid}
|
||||
|
||||
|
||||
@app.post("/deltains-submit-otp")
|
||||
async def deltains_submit_otp(request: Request):
|
||||
"""
|
||||
Body: { "session_id": "<sid>", "otp": "123456" }
|
||||
Node / frontend call this when user provides OTP for DeltaIns.
|
||||
"""
|
||||
body = await request.json()
|
||||
sid = body.get("session_id")
|
||||
otp = body.get("otp")
|
||||
if not sid or not otp:
|
||||
raise HTTPException(status_code=400, detail="session_id and otp required")
|
||||
|
||||
res = hdeltains.submit_otp(sid, otp)
|
||||
if res.get("status") == "error":
|
||||
raise HTTPException(status_code=400, detail=res.get("message"))
|
||||
return res
|
||||
|
||||
|
||||
@app.get("/deltains-session/{sid}/status")
|
||||
async def deltains_session_status(sid: str):
|
||||
s = hdeltains.get_session_status(sid)
|
||||
if s.get("status") == "not_found":
|
||||
raise HTTPException(status_code=404, detail="session not found")
|
||||
return s
|
||||
|
||||
|
||||
@app.post("/submit-otp")
|
||||
async def submit_otp(request: Request):
|
||||
"""
|
||||
@@ -222,6 +461,56 @@ async def get_status():
|
||||
"status": "busy" if active_jobs > 0 or waiting_jobs > 0 else "idle"
|
||||
}
|
||||
|
||||
|
||||
# ✅ Clear session endpoints - called when credentials are deleted
|
||||
@app.post("/clear-ddma-session")
|
||||
async def clear_ddma_session():
|
||||
"""
|
||||
Clears the DDMA browser session. Called when DDMA credentials are deleted.
|
||||
"""
|
||||
try:
|
||||
clear_ddma_session_on_startup()
|
||||
return {"status": "success", "message": "DDMA session cleared"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
@app.post("/clear-dentaquest-session")
|
||||
async def clear_dentaquest_session():
|
||||
"""
|
||||
Clears the DentaQuest browser session. Called when DentaQuest credentials are deleted.
|
||||
"""
|
||||
try:
|
||||
clear_dentaquest_session_on_startup()
|
||||
return {"status": "success", "message": "DentaQuest session cleared"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
@app.post("/clear-unitedsco-session")
|
||||
async def clear_unitedsco_session():
|
||||
"""
|
||||
Clears the United SCO browser session. Called when United SCO credentials are deleted.
|
||||
"""
|
||||
try:
|
||||
clear_unitedsco_session_on_startup()
|
||||
return {"status": "success", "message": "United SCO session cleared"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
@app.post("/clear-deltains-session")
|
||||
async def clear_deltains_session():
|
||||
"""
|
||||
Clears the Delta Dental Ins browser session. Called when DeltaIns credentials are deleted.
|
||||
"""
|
||||
try:
|
||||
clear_deltains_session_on_startup()
|
||||
return {"status": "success", "message": "DeltaIns session cleared"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
host = os.getenv("HOST")
|
||||
port = int(os.getenv("PORT"))
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
"""
|
||||
Minimal browser manager for DDMA - only handles persistent profile and keeping browser alive.
|
||||
Does NOT modify any login/OTP logic.
|
||||
Clears session cookies on startup (after PC restart) to force fresh login.
|
||||
Tracks credentials to detect changes mid-session.
|
||||
"""
|
||||
import os
|
||||
import glob
|
||||
import shutil
|
||||
import hashlib
|
||||
import threading
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.service import Service
|
||||
from webdriver_manager.chrome import ChromeDriverManager
|
||||
|
||||
# Ensure DISPLAY is set for Chrome to work (needed when running from SSH/background)
|
||||
if not os.environ.get("DISPLAY"):
|
||||
os.environ["DISPLAY"] = ":0"
|
||||
|
||||
|
||||
class DDMABrowserManager:
|
||||
"""
|
||||
Singleton that manages a persistent Chrome browser instance.
|
||||
- Uses --user-data-dir for persistent profile (device trust tokens, cookies)
|
||||
- Keeps browser alive between patient runs
|
||||
- Uses --user-data-dir for persistent profile (device trust tokens)
|
||||
- Clears session cookies on startup (after PC restart)
|
||||
- Tracks credentials to detect changes mid-session
|
||||
"""
|
||||
_instance = None
|
||||
_lock = threading.Lock()
|
||||
@@ -25,31 +34,208 @@ class DDMABrowserManager:
|
||||
cls._instance._driver = None
|
||||
cls._instance.profile_dir = os.path.abspath("chrome_profile_ddma")
|
||||
cls._instance.download_dir = os.path.abspath("seleniumDownloads")
|
||||
cls._instance._credentials_file = os.path.join(cls._instance.profile_dir, ".last_credentials")
|
||||
cls._instance._needs_session_clear = False # Flag to clear session on next driver creation
|
||||
os.makedirs(cls._instance.profile_dir, exist_ok=True)
|
||||
os.makedirs(cls._instance.download_dir, exist_ok=True)
|
||||
return cls._instance
|
||||
|
||||
def clear_session_on_startup(self):
|
||||
"""
|
||||
Clear session cookies from Chrome profile on startup.
|
||||
This forces a fresh login after PC restart.
|
||||
Preserves device trust tokens (LocalStorage, IndexedDB) to avoid OTPs.
|
||||
"""
|
||||
print("[DDMA BrowserManager] Clearing session on startup...")
|
||||
|
||||
try:
|
||||
# Clear the credentials tracking file
|
||||
if os.path.exists(self._credentials_file):
|
||||
os.remove(self._credentials_file)
|
||||
print("[DDMA BrowserManager] Cleared credentials tracking file")
|
||||
|
||||
# Clear session-related files from Chrome profile
|
||||
# These are the files that store login session cookies
|
||||
session_files = [
|
||||
"Cookies",
|
||||
"Cookies-journal",
|
||||
"Login Data",
|
||||
"Login Data-journal",
|
||||
"Web Data",
|
||||
"Web Data-journal",
|
||||
]
|
||||
|
||||
for filename in session_files:
|
||||
filepath = os.path.join(self.profile_dir, "Default", filename)
|
||||
if os.path.exists(filepath):
|
||||
try:
|
||||
os.remove(filepath)
|
||||
print(f"[DDMA BrowserManager] Removed {filename}")
|
||||
except Exception as e:
|
||||
print(f"[DDMA BrowserManager] Could not remove {filename}: {e}")
|
||||
|
||||
# Also try root level (some Chrome versions)
|
||||
for filename in session_files:
|
||||
filepath = os.path.join(self.profile_dir, filename)
|
||||
if os.path.exists(filepath):
|
||||
try:
|
||||
os.remove(filepath)
|
||||
print(f"[DDMA BrowserManager] Removed root {filename}")
|
||||
except Exception as e:
|
||||
print(f"[DDMA BrowserManager] Could not remove root {filename}: {e}")
|
||||
|
||||
# Clear Session Storage (contains login state)
|
||||
session_storage_dir = os.path.join(self.profile_dir, "Default", "Session Storage")
|
||||
if os.path.exists(session_storage_dir):
|
||||
try:
|
||||
shutil.rmtree(session_storage_dir)
|
||||
print("[DDMA BrowserManager] Cleared Session Storage")
|
||||
except Exception as e:
|
||||
print(f"[DDMA BrowserManager] Could not clear Session Storage: {e}")
|
||||
|
||||
# Clear Local Storage (may contain auth tokens)
|
||||
local_storage_dir = os.path.join(self.profile_dir, "Default", "Local Storage")
|
||||
if os.path.exists(local_storage_dir):
|
||||
try:
|
||||
shutil.rmtree(local_storage_dir)
|
||||
print("[DDMA BrowserManager] Cleared Local Storage")
|
||||
except Exception as e:
|
||||
print(f"[DDMA BrowserManager] Could not clear Local Storage: {e}")
|
||||
|
||||
# Clear IndexedDB (may contain auth tokens)
|
||||
indexeddb_dir = os.path.join(self.profile_dir, "Default", "IndexedDB")
|
||||
if os.path.exists(indexeddb_dir):
|
||||
try:
|
||||
shutil.rmtree(indexeddb_dir)
|
||||
print("[DDMA BrowserManager] Cleared IndexedDB")
|
||||
except Exception as e:
|
||||
print(f"[DDMA BrowserManager] Could not clear IndexedDB: {e}")
|
||||
|
||||
# Clear browser cache (prevents corrupted cached responses)
|
||||
cache_dirs = [
|
||||
os.path.join(self.profile_dir, "Default", "Cache"),
|
||||
os.path.join(self.profile_dir, "Default", "Code Cache"),
|
||||
os.path.join(self.profile_dir, "Default", "GPUCache"),
|
||||
os.path.join(self.profile_dir, "Default", "Service Worker"),
|
||||
os.path.join(self.profile_dir, "Cache"),
|
||||
os.path.join(self.profile_dir, "Code Cache"),
|
||||
os.path.join(self.profile_dir, "GPUCache"),
|
||||
os.path.join(self.profile_dir, "Service Worker"),
|
||||
os.path.join(self.profile_dir, "ShaderCache"),
|
||||
]
|
||||
for cache_dir in cache_dirs:
|
||||
if os.path.exists(cache_dir):
|
||||
try:
|
||||
shutil.rmtree(cache_dir)
|
||||
print(f"[DDMA BrowserManager] Cleared {os.path.basename(cache_dir)}")
|
||||
except Exception as e:
|
||||
print(f"[DDMA BrowserManager] Could not clear {os.path.basename(cache_dir)}: {e}")
|
||||
|
||||
# Set flag to clear session via JavaScript after browser opens
|
||||
self._needs_session_clear = True
|
||||
|
||||
print("[DDMA BrowserManager] Session cleared - will require fresh login")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[DDMA BrowserManager] Error clearing session: {e}")
|
||||
|
||||
def _hash_credentials(self, username: str) -> str:
|
||||
"""Create a hash of the username to track credential changes."""
|
||||
return hashlib.sha256(username.encode()).hexdigest()[:16]
|
||||
|
||||
def get_last_credentials_hash(self) -> str | None:
|
||||
"""Get the hash of the last-used credentials."""
|
||||
try:
|
||||
if os.path.exists(self._credentials_file):
|
||||
with open(self._credentials_file, 'r') as f:
|
||||
return f.read().strip()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def save_credentials_hash(self, username: str):
|
||||
"""Save the hash of the current credentials."""
|
||||
try:
|
||||
cred_hash = self._hash_credentials(username)
|
||||
with open(self._credentials_file, 'w') as f:
|
||||
f.write(cred_hash)
|
||||
except Exception as e:
|
||||
print(f"[DDMA BrowserManager] Failed to save credentials hash: {e}")
|
||||
|
||||
def credentials_changed(self, username: str) -> bool:
|
||||
"""Check if the credentials have changed since last login."""
|
||||
last_hash = self.get_last_credentials_hash()
|
||||
if last_hash is None:
|
||||
return False # No previous credentials, not a change
|
||||
current_hash = self._hash_credentials(username)
|
||||
changed = last_hash != current_hash
|
||||
if changed:
|
||||
print(f"[DDMA BrowserManager] Credentials changed - logout required")
|
||||
return changed
|
||||
|
||||
def clear_credentials_hash(self):
|
||||
"""Clear the saved credentials hash (used after logout)."""
|
||||
try:
|
||||
if os.path.exists(self._credentials_file):
|
||||
os.remove(self._credentials_file)
|
||||
except Exception as e:
|
||||
print(f"[DDMA BrowserManager] Failed to clear credentials hash: {e}")
|
||||
|
||||
def _kill_existing_chrome_for_profile(self):
|
||||
"""Kill any existing Chrome processes using this profile and clean up locks."""
|
||||
import subprocess
|
||||
import time as time_module
|
||||
try:
|
||||
# Find and kill Chrome processes using this profile
|
||||
result = subprocess.run(
|
||||
["pgrep", "-f", f"user-data-dir={self.profile_dir}"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if result.stdout.strip():
|
||||
pids = result.stdout.strip().split('\n')
|
||||
for pid in pids:
|
||||
try:
|
||||
subprocess.run(["kill", "-9", pid], check=False)
|
||||
except:
|
||||
pass
|
||||
time_module.sleep(1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Remove lock files if they exist
|
||||
for lock_file in ["SingletonLock", "SingletonSocket", "SingletonCookie"]:
|
||||
lock_path = os.path.join(self.profile_dir, lock_file)
|
||||
try:
|
||||
if os.path.islink(lock_path) or os.path.exists(lock_path):
|
||||
os.remove(lock_path)
|
||||
except:
|
||||
pass
|
||||
|
||||
def get_driver(self, headless=False):
|
||||
"""Get or create the persistent browser instance."""
|
||||
with self._lock:
|
||||
if self._driver is None:
|
||||
print("[BrowserManager] Driver is None, creating new driver")
|
||||
print("[DDMA BrowserManager] Driver is None, creating new driver")
|
||||
self._kill_existing_chrome_for_profile()
|
||||
self._create_driver(headless)
|
||||
elif not self._is_alive():
|
||||
print("[BrowserManager] Driver not alive, recreating")
|
||||
print("[DDMA BrowserManager] Driver not alive, recreating")
|
||||
self._kill_existing_chrome_for_profile()
|
||||
self._create_driver(headless)
|
||||
else:
|
||||
print("[BrowserManager] Reusing existing driver")
|
||||
print("[DDMA BrowserManager] Reusing existing driver")
|
||||
return self._driver
|
||||
|
||||
def _is_alive(self):
|
||||
"""Check if browser is still responsive."""
|
||||
try:
|
||||
if self._driver is None:
|
||||
return False
|
||||
url = self._driver.current_url
|
||||
print(f"[BrowserManager] Driver alive, current URL: {url[:50]}...")
|
||||
print(f"[DDMA BrowserManager] Driver alive, current URL: {url[:50]}...")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[BrowserManager] Driver not alive: {e}")
|
||||
print(f"[DDMA BrowserManager] Driver not alive: {e}")
|
||||
return False
|
||||
|
||||
def _create_driver(self, headless=False):
|
||||
@@ -69,6 +255,12 @@ class DDMABrowserManager:
|
||||
options.add_argument("--no-sandbox")
|
||||
options.add_argument("--disable-dev-shm-usage")
|
||||
|
||||
# Anti-detection options (prevent bot detection)
|
||||
options.add_argument("--disable-blink-features=AutomationControlled")
|
||||
options.add_experimental_option("excludeSwitches", ["enable-automation"])
|
||||
options.add_experimental_option("useAutomationExtension", False)
|
||||
options.add_argument("--disable-infobars")
|
||||
|
||||
prefs = {
|
||||
"download.default_directory": self.download_dir,
|
||||
"plugins.always_open_pdf_externally": True,
|
||||
@@ -80,6 +272,15 @@ class DDMABrowserManager:
|
||||
service = Service(ChromeDriverManager().install())
|
||||
self._driver = webdriver.Chrome(service=service, options=options)
|
||||
self._driver.maximize_window()
|
||||
|
||||
# Remove webdriver property to avoid detection
|
||||
try:
|
||||
self._driver.execute_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Reset the session clear flag (file-based clearing is done on startup)
|
||||
self._needs_session_clear = False
|
||||
|
||||
def quit_driver(self):
|
||||
"""Quit browser (only call on shutdown)."""
|
||||
@@ -100,3 +301,9 @@ def get_browser_manager():
|
||||
if _manager is None:
|
||||
_manager = DDMABrowserManager()
|
||||
return _manager
|
||||
|
||||
|
||||
def clear_ddma_session_on_startup():
|
||||
"""Called by agent.py on startup to clear session."""
|
||||
manager = get_browser_manager()
|
||||
manager.clear_session_on_startup()
|
||||
|
||||
376
apps/SeleniumService/deltains_browser_manager.py
Normal file
376
apps/SeleniumService/deltains_browser_manager.py
Normal 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()
|
||||
277
apps/SeleniumService/dentaquest_browser_manager.py
Normal file
277
apps/SeleniumService/dentaquest_browser_manager.py
Normal 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()
|
||||
@@ -5,7 +5,7 @@ from typing import Dict, Any
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
from selenium.common.exceptions import WebDriverException
|
||||
from selenium.common.exceptions import WebDriverException, TimeoutException
|
||||
|
||||
from selenium_DDMA_eligibilityCheckWorker import AutomationDeltaDentalMAEligibilityCheck
|
||||
|
||||
@@ -127,74 +127,113 @@ async def start_ddma_run(sid: str, data: dict, url: str):
|
||||
s["message"] = "Session persisted"
|
||||
# Continue to step1 below
|
||||
|
||||
# OTP required path
|
||||
# OTP required path - POLL THE BROWSER to detect when user enters OTP
|
||||
elif isinstance(login_result, str) and login_result == "OTP_REQUIRED":
|
||||
s["status"] = "waiting_for_otp"
|
||||
s["message"] = "OTP required for login"
|
||||
s["message"] = "OTP required for login - please enter OTP in browser"
|
||||
s["last_activity"] = time.time()
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(s["otp_event"].wait(), timeout=SESSION_OTP_TIMEOUT)
|
||||
except asyncio.TimeoutError:
|
||||
s["status"] = "error"
|
||||
s["message"] = "OTP timeout"
|
||||
await cleanup_session(sid)
|
||||
return {"status": "error", "message": "OTP not provided in time"}
|
||||
|
||||
otp_value = s.get("otp_value")
|
||||
if not otp_value:
|
||||
s["status"] = "error"
|
||||
s["message"] = "OTP missing after event"
|
||||
await cleanup_session(sid)
|
||||
return {"status": "error", "message": "OTP missing after event"}
|
||||
|
||||
# Submit OTP - check if it's in a popup window
|
||||
try:
|
||||
driver = s["driver"]
|
||||
wait = WebDriverWait(driver, 30)
|
||||
|
||||
# Check if there's a popup window and switch to it
|
||||
original_window = driver.current_window_handle
|
||||
all_windows = driver.window_handles
|
||||
if len(all_windows) > 1:
|
||||
for window in all_windows:
|
||||
if window != original_window:
|
||||
driver.switch_to.window(window)
|
||||
print(f"[OTP] Switched to popup window for OTP entry")
|
||||
break
|
||||
|
||||
otp_input = wait.until(
|
||||
EC.presence_of_element_located(
|
||||
(By.XPATH, "//input[contains(@aria-lable,'Verification code') or contains(@placeholder,'Enter your verification code')]")
|
||||
)
|
||||
)
|
||||
otp_input.clear()
|
||||
otp_input.send_keys(otp_value)
|
||||
|
||||
try:
|
||||
submit_btn = wait.until(
|
||||
EC.element_to_be_clickable(
|
||||
(By.XPATH, "//button[@type='button' and @aria-label='Verify']")
|
||||
)
|
||||
)
|
||||
submit_btn.click()
|
||||
except Exception:
|
||||
otp_input.send_keys("\n")
|
||||
|
||||
# Wait for verification and switch back to main window if needed
|
||||
await asyncio.sleep(2)
|
||||
if len(driver.window_handles) > 0:
|
||||
driver.switch_to.window(driver.window_handles[0])
|
||||
|
||||
s["status"] = "otp_submitted"
|
||||
|
||||
driver = s["driver"]
|
||||
|
||||
# Poll the browser to detect when OTP is completed (user enters it directly)
|
||||
# We check every 1 second for up to SESSION_OTP_TIMEOUT seconds (faster response)
|
||||
max_polls = SESSION_OTP_TIMEOUT
|
||||
login_success = False
|
||||
|
||||
print(f"[OTP] Waiting for user to enter OTP (polling browser for {SESSION_OTP_TIMEOUT}s)...")
|
||||
|
||||
for poll in range(max_polls):
|
||||
await asyncio.sleep(1)
|
||||
s["last_activity"] = time.time()
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
except Exception as e:
|
||||
s["status"] = "error"
|
||||
s["message"] = f"Failed to submit OTP into page: {e}"
|
||||
await cleanup_session(sid)
|
||||
return {"status": "error", "message": s["message"]}
|
||||
|
||||
try:
|
||||
# Check if OTP was submitted via API (from app)
|
||||
otp_value = s.get("otp_value")
|
||||
if otp_value:
|
||||
print(f"[OTP] OTP received from app: {otp_value}")
|
||||
try:
|
||||
otp_input = driver.find_element(By.XPATH,
|
||||
"//input[contains(@aria-label,'Verification') or contains(@placeholder,'verification') or @type='tel']"
|
||||
)
|
||||
otp_input.clear()
|
||||
otp_input.send_keys(otp_value)
|
||||
# Click verify button
|
||||
try:
|
||||
verify_btn = driver.find_element(By.XPATH, "//button[@type='button' and @aria-label='Verify']")
|
||||
verify_btn.click()
|
||||
except:
|
||||
otp_input.send_keys("\n") # Press Enter as fallback
|
||||
print("[OTP] OTP typed and submitted via app")
|
||||
s["otp_value"] = None # Clear so we don't submit again
|
||||
await asyncio.sleep(3) # Wait for verification
|
||||
except Exception as type_err:
|
||||
print(f"[OTP] Failed to type OTP from app: {type_err}")
|
||||
|
||||
# Check current URL - if we're on member search page, login succeeded
|
||||
current_url = driver.current_url.lower()
|
||||
print(f"[OTP Poll {poll+1}/{max_polls}] URL: {current_url[:60]}...")
|
||||
|
||||
# Check if we've navigated away from login/OTP pages
|
||||
if "member" in current_url or "dashboard" in current_url or "eligibility" in current_url:
|
||||
# Verify by checking for member search input
|
||||
try:
|
||||
member_search = WebDriverWait(driver, 5).until(
|
||||
EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]'))
|
||||
)
|
||||
print("[OTP] Member search input found - login successful!")
|
||||
login_success = True
|
||||
break
|
||||
except TimeoutException:
|
||||
print("[OTP] On member page but search input not found, continuing to poll...")
|
||||
|
||||
# Also check if OTP input is still visible
|
||||
try:
|
||||
otp_input = driver.find_element(By.XPATH,
|
||||
"//input[contains(@aria-label,'Verification') or contains(@placeholder,'verification') or @type='tel']"
|
||||
)
|
||||
# OTP input still visible - user hasn't entered OTP yet
|
||||
print(f"[OTP Poll {poll+1}] OTP input still visible - waiting...")
|
||||
except:
|
||||
# OTP input not found - might mean login is in progress or succeeded
|
||||
# Try navigating to members page
|
||||
if "onboarding" in current_url or "start" in current_url:
|
||||
print("[OTP] OTP input gone, trying to navigate to members page...")
|
||||
try:
|
||||
driver.get("https://providers.deltadentalma.com/members")
|
||||
await asyncio.sleep(2)
|
||||
except:
|
||||
pass
|
||||
|
||||
except Exception as poll_err:
|
||||
print(f"[OTP Poll {poll+1}] Error: {poll_err}")
|
||||
|
||||
if not login_success:
|
||||
# Final attempt - navigate to members page and check
|
||||
try:
|
||||
print("[OTP] Final attempt - navigating to members page...")
|
||||
driver.get("https://providers.deltadentalma.com/members")
|
||||
await asyncio.sleep(3)
|
||||
|
||||
member_search = WebDriverWait(driver, 10).until(
|
||||
EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]'))
|
||||
)
|
||||
print("[OTP] Member search input found - login successful!")
|
||||
login_success = True
|
||||
except TimeoutException:
|
||||
s["status"] = "error"
|
||||
s["message"] = "OTP timeout - login not completed"
|
||||
await cleanup_session(sid)
|
||||
return {"status": "error", "message": "OTP not completed in time"}
|
||||
except Exception as final_err:
|
||||
s["status"] = "error"
|
||||
s["message"] = f"OTP verification failed: {final_err}"
|
||||
await cleanup_session(sid)
|
||||
return {"status": "error", "message": s["message"]}
|
||||
|
||||
if login_success:
|
||||
s["status"] = "running"
|
||||
s["message"] = "Login successful after OTP"
|
||||
print("[OTP] Proceeding to step1...")
|
||||
|
||||
elif isinstance(login_result, str) and login_result.startswith("ERROR"):
|
||||
s["status"] = "error"
|
||||
@@ -202,6 +241,13 @@ async def start_ddma_run(sid: str, data: dict, url: str):
|
||||
await cleanup_session(sid)
|
||||
return {"status": "error", "message": login_result}
|
||||
|
||||
# Login succeeded without OTP (SUCCESS)
|
||||
elif isinstance(login_result, str) and login_result == "SUCCESS":
|
||||
print("[start_ddma_run] Login succeeded without OTP")
|
||||
s["status"] = "running"
|
||||
s["message"] = "Login succeeded"
|
||||
# Continue to step1 below
|
||||
|
||||
# Step 1
|
||||
step1_result = bot.step1()
|
||||
if isinstance(step1_result, str) and step1_result.startswith("ERROR"):
|
||||
|
||||
300
apps/SeleniumService/helpers_deltains_eligibility.py
Normal file
300
apps/SeleniumService/helpers_deltains_eligibility.py
Normal 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,
|
||||
}
|
||||
318
apps/SeleniumService/helpers_dentaquest_eligibility.py
Normal file
318
apps/SeleniumService/helpers_dentaquest_eligibility.py
Normal 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,
|
||||
}
|
||||
|
||||
364
apps/SeleniumService/helpers_unitedsco_eligibility.py
Normal file
364
apps/SeleniumService/helpers_unitedsco_eligibility.py
Normal 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,
|
||||
}
|
||||
@@ -23,6 +23,8 @@ class AutomationDeltaDentalMAEligibilityCheck:
|
||||
# Flatten values for convenience
|
||||
self.memberId = self.data.get("memberId", "")
|
||||
self.dateOfBirth = self.data.get("dateOfBirth", "")
|
||||
self.firstName = self.data.get("firstName", "")
|
||||
self.lastName = self.data.get("lastName", "")
|
||||
self.massddma_username = self.data.get("massddmaUsername", "")
|
||||
self.massddma_password = self.data.get("massddmaPassword", "")
|
||||
|
||||
@@ -34,9 +36,63 @@ class AutomationDeltaDentalMAEligibilityCheck:
|
||||
# Use persistent browser from manager (keeps device trust tokens)
|
||||
self.driver = get_browser_manager().get_driver(self.headless)
|
||||
|
||||
def _force_logout(self):
|
||||
"""Force logout by clearing cookies for Delta Dental domain."""
|
||||
try:
|
||||
print("[DDMA login] Forcing logout due to credential change...")
|
||||
browser_manager = get_browser_manager()
|
||||
|
||||
# First try to click logout button if visible
|
||||
try:
|
||||
self.driver.get("https://providers.deltadentalma.com/")
|
||||
time.sleep(2)
|
||||
|
||||
logout_selectors = [
|
||||
"//button[contains(text(), 'Log out') or contains(text(), 'Logout') or contains(text(), 'Sign out')]",
|
||||
"//a[contains(text(), 'Log out') or contains(text(), 'Logout') or contains(text(), 'Sign out')]",
|
||||
"//button[@aria-label='Log out' or @aria-label='Logout' or @aria-label='Sign out']",
|
||||
"//*[contains(@class, 'logout') or contains(@class, 'signout')]"
|
||||
]
|
||||
|
||||
for selector in logout_selectors:
|
||||
try:
|
||||
logout_btn = WebDriverWait(self.driver, 3).until(
|
||||
EC.element_to_be_clickable((By.XPATH, selector))
|
||||
)
|
||||
logout_btn.click()
|
||||
print("[DDMA login] Clicked logout button")
|
||||
time.sleep(2)
|
||||
break
|
||||
except TimeoutException:
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"[DDMA login] Could not click logout button: {e}")
|
||||
|
||||
# Clear cookies as backup
|
||||
try:
|
||||
self.driver.delete_all_cookies()
|
||||
print("[DDMA login] Cleared all cookies")
|
||||
except Exception as e:
|
||||
print(f"[DDMA login] Error clearing cookies: {e}")
|
||||
|
||||
browser_manager.clear_credentials_hash()
|
||||
print("[DDMA login] Logout complete")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[DDMA login] Error during forced logout: {e}")
|
||||
return False
|
||||
|
||||
def login(self, url):
|
||||
wait = WebDriverWait(self.driver, 30)
|
||||
browser_manager = get_browser_manager()
|
||||
|
||||
try:
|
||||
# Check if credentials have changed - if so, force logout first
|
||||
if self.massddma_username and browser_manager.credentials_changed(self.massddma_username):
|
||||
self._force_logout()
|
||||
self.driver.get(url)
|
||||
time.sleep(2)
|
||||
|
||||
# First check if we're already on a logged-in page (from previous run)
|
||||
try:
|
||||
current_url = self.driver.current_url
|
||||
@@ -178,8 +234,12 @@ class AutomationDeltaDentalMAEligibilityCheck:
|
||||
|
||||
login_button = wait.until(EC.element_to_be_clickable((By.XPATH, "//button[@type='submit' and @aria-label='Sign in']")))
|
||||
login_button.click()
|
||||
|
||||
# Save credentials hash after login attempt
|
||||
if self.massddma_username:
|
||||
browser_manager.save_credentials_hash(self.massddma_username)
|
||||
|
||||
# OTP detection
|
||||
# OTP detection - wait up to 30 seconds for OTP input to appear
|
||||
try:
|
||||
otp_candidate = WebDriverWait(self.driver, 30).until(
|
||||
EC.presence_of_element_located(
|
||||
@@ -191,63 +251,140 @@ class AutomationDeltaDentalMAEligibilityCheck:
|
||||
return "OTP_REQUIRED"
|
||||
except TimeoutException:
|
||||
print("[login] No OTP input detected in allowed time.")
|
||||
# Check if we're now on the member search page (login succeeded without OTP)
|
||||
try:
|
||||
current_url = self.driver.current_url.lower()
|
||||
if "member" in current_url or "dashboard" in current_url:
|
||||
member_search = WebDriverWait(self.driver, 5).until(
|
||||
EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]'))
|
||||
)
|
||||
print("[login] Login successful - now on member search page")
|
||||
return "SUCCESS"
|
||||
except TimeoutException:
|
||||
pass
|
||||
|
||||
# Check for error messages on page
|
||||
try:
|
||||
error_elem = WebDriverWait(self.driver, 3).until(
|
||||
EC.presence_of_element_located((By.XPATH, "//*[contains(@class,'error') or contains(text(),'invalid') or contains(text(),'failed')]"))
|
||||
)
|
||||
print(f"[login] Login failed - error detected: {error_elem.text}")
|
||||
return f"ERROR:LOGIN FAILED: {error_elem.text}"
|
||||
except TimeoutException:
|
||||
pass
|
||||
|
||||
# If still on login page, login failed
|
||||
if "onboarding" in self.driver.current_url.lower() or "login" in self.driver.current_url.lower():
|
||||
print("[login] Login failed - still on login page")
|
||||
return "ERROR:LOGIN FAILED: Still on login page"
|
||||
|
||||
# Otherwise assume success (might be on an intermediate page)
|
||||
print("[login] Assuming login succeeded (no errors detected)")
|
||||
return "SUCCESS"
|
||||
except Exception as e:
|
||||
print("[login] Exception during login:", e)
|
||||
return f"ERROR:LOGIN FAILED: {e}"
|
||||
|
||||
def step1(self):
|
||||
"""Fill search form with all available fields (flexible search)"""
|
||||
wait = WebDriverWait(self.driver, 30)
|
||||
|
||||
try:
|
||||
# Fill Member ID
|
||||
member_id_input = wait.until(EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]')))
|
||||
member_id_input.clear()
|
||||
member_id_input.send_keys(self.memberId)
|
||||
# Log what fields are available
|
||||
fields = []
|
||||
if self.memberId:
|
||||
fields.append(f"ID: {self.memberId}")
|
||||
if self.firstName:
|
||||
fields.append(f"FirstName: {self.firstName}")
|
||||
if self.lastName:
|
||||
fields.append(f"LastName: {self.lastName}")
|
||||
if self.dateOfBirth:
|
||||
fields.append(f"DOB: {self.dateOfBirth}")
|
||||
print(f"[DDMA step1] Starting search with: {', '.join(fields)}")
|
||||
|
||||
# Fill DOB parts
|
||||
try:
|
||||
dob_parts = self.dateOfBirth.split("-")
|
||||
year = dob_parts[0] # "1964"
|
||||
month = dob_parts[1].zfill(2) # "04"
|
||||
day = dob_parts[2].zfill(2) # "17"
|
||||
except Exception as e:
|
||||
print(f"Error parsing DOB: {e}")
|
||||
return "ERROR: PARSING DOB"
|
||||
|
||||
# 1) locate the specific member DOB container
|
||||
dob_container = wait.until(
|
||||
EC.presence_of_element_located(
|
||||
(By.XPATH, "//div[@data-testid='member-search_date-of-birth']")
|
||||
)
|
||||
)
|
||||
|
||||
# 2) find the editable spans *inside that container* using relative XPaths
|
||||
month_elem = dob_container.find_element(By.XPATH, ".//span[@data-type='month' and @contenteditable='true']")
|
||||
day_elem = dob_container.find_element(By.XPATH, ".//span[@data-type='day' and @contenteditable='true']")
|
||||
year_elem = dob_container.find_element(By.XPATH, ".//span[@data-type='year' and @contenteditable='true']")
|
||||
|
||||
# Helper to click, select-all and type (pure send_keys approach)
|
||||
# Helper to click, select-all and type
|
||||
def replace_with_sendkeys(el, value):
|
||||
# focus (same as click)
|
||||
el.click()
|
||||
# select all (Ctrl+A) and delete (some apps pick up BACKSPACE better — we use BACKSPACE after select)
|
||||
el.send_keys(Keys.CONTROL, "a")
|
||||
el.send_keys(Keys.BACKSPACE)
|
||||
# type the value
|
||||
el.send_keys(value)
|
||||
# optionally blur or tab out if app expects it
|
||||
# el.send_keys(Keys.TAB)
|
||||
|
||||
replace_with_sendkeys(month_elem, month)
|
||||
time.sleep(0.05)
|
||||
replace_with_sendkeys(day_elem, day)
|
||||
time.sleep(0.05)
|
||||
replace_with_sendkeys(year_elem, year)
|
||||
# 1. Fill Member ID if provided
|
||||
if self.memberId:
|
||||
try:
|
||||
member_id_input = wait.until(EC.presence_of_element_located(
|
||||
(By.XPATH, '//input[@placeholder="Search by member ID"]')
|
||||
))
|
||||
member_id_input.clear()
|
||||
member_id_input.send_keys(self.memberId)
|
||||
print(f"[DDMA step1] Entered Member ID: {self.memberId}")
|
||||
time.sleep(0.2)
|
||||
except Exception as e:
|
||||
print(f"[DDMA step1] Warning: Could not fill Member ID: {e}")
|
||||
|
||||
# 2. Fill DOB if provided
|
||||
if self.dateOfBirth:
|
||||
try:
|
||||
dob_parts = self.dateOfBirth.split("-")
|
||||
year = dob_parts[0]
|
||||
month = dob_parts[1].zfill(2)
|
||||
day = dob_parts[2].zfill(2)
|
||||
|
||||
# Click Continue button
|
||||
continue_btn = wait.until(EC.element_to_be_clickable((By.XPATH, '//button[@data-testid="member-search_search-button"]')))
|
||||
dob_container = wait.until(
|
||||
EC.presence_of_element_located(
|
||||
(By.XPATH, "//div[@data-testid='member-search_date-of-birth']")
|
||||
)
|
||||
)
|
||||
|
||||
month_elem = dob_container.find_element(By.XPATH, ".//span[@data-type='month' and @contenteditable='true']")
|
||||
day_elem = dob_container.find_element(By.XPATH, ".//span[@data-type='day' and @contenteditable='true']")
|
||||
year_elem = dob_container.find_element(By.XPATH, ".//span[@data-type='year' and @contenteditable='true']")
|
||||
|
||||
replace_with_sendkeys(month_elem, month)
|
||||
time.sleep(0.05)
|
||||
replace_with_sendkeys(day_elem, day)
|
||||
time.sleep(0.05)
|
||||
replace_with_sendkeys(year_elem, year)
|
||||
print(f"[DDMA step1] Filled DOB: {month}/{day}/{year}")
|
||||
except Exception as e:
|
||||
print(f"[DDMA step1] Warning: Could not fill DOB: {e}")
|
||||
|
||||
# 3. Fill First Name if provided
|
||||
if self.firstName:
|
||||
try:
|
||||
first_name_input = wait.until(EC.presence_of_element_located(
|
||||
(By.XPATH, '//input[@placeholder="First name - 1 char minimum" or contains(@placeholder,"first name") or contains(@name,"firstName")]')
|
||||
))
|
||||
first_name_input.clear()
|
||||
first_name_input.send_keys(self.firstName)
|
||||
print(f"[DDMA step1] Entered First Name: {self.firstName}")
|
||||
time.sleep(0.2)
|
||||
except Exception as e:
|
||||
print(f"[DDMA step1] Warning: Could not fill First Name: {e}")
|
||||
|
||||
# 4. Fill Last Name if provided
|
||||
if self.lastName:
|
||||
try:
|
||||
last_name_input = wait.until(EC.presence_of_element_located(
|
||||
(By.XPATH, '//input[@placeholder="Last name - 2 char minimum" or contains(@placeholder,"last name") or contains(@name,"lastName")]')
|
||||
))
|
||||
last_name_input.clear()
|
||||
last_name_input.send_keys(self.lastName)
|
||||
print(f"[DDMA step1] Entered Last Name: {self.lastName}")
|
||||
time.sleep(0.2)
|
||||
except Exception as e:
|
||||
print(f"[DDMA step1] Warning: Could not fill Last Name: {e}")
|
||||
|
||||
time.sleep(0.3)
|
||||
|
||||
# Click Search button
|
||||
continue_btn = wait.until(EC.element_to_be_clickable(
|
||||
(By.XPATH, '//button[@data-testid="member-search_search-button"]')
|
||||
))
|
||||
continue_btn.click()
|
||||
print("[DDMA step1] Clicked Search button")
|
||||
|
||||
time.sleep(5)
|
||||
|
||||
# Check for error message
|
||||
try:
|
||||
@@ -255,89 +392,284 @@ class AutomationDeltaDentalMAEligibilityCheck:
|
||||
(By.XPATH, '//div[@data-testid="member-search-result-no-results"]')
|
||||
))
|
||||
if error_msg:
|
||||
print("Error: Invalid Member ID or Date of Birth.")
|
||||
return "ERROR: INVALID MEMBERID OR DOB"
|
||||
print("[DDMA step1] Error: No results found")
|
||||
return "ERROR: INVALID SEARCH CRITERIA"
|
||||
except TimeoutException:
|
||||
pass
|
||||
|
||||
print("[DDMA step1] Search completed successfully")
|
||||
return "Success"
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error while step1 i.e Cheking the MemberId and DOB in: {e}")
|
||||
return "ERROR:STEP1"
|
||||
print(f"[DDMA step1] Exception: {e}")
|
||||
return f"ERROR:STEP1 - {e}"
|
||||
|
||||
|
||||
def step2(self):
|
||||
wait = WebDriverWait(self.driver, 90)
|
||||
|
||||
try:
|
||||
# 1) find the eligibility <a> inside the correct cell
|
||||
status_link = wait.until(EC.presence_of_element_located((
|
||||
By.XPATH,
|
||||
"(//tbody//tr)[1]//a[contains(@href, 'member-eligibility-search')]"
|
||||
)))
|
||||
|
||||
eligibilityText = status_link.text.strip().lower()
|
||||
|
||||
# 2) finding patient name.
|
||||
patient_name_div = wait.until(EC.presence_of_element_located((
|
||||
By.XPATH,
|
||||
'//div[@class="flex flex-row w-full items-center"]'
|
||||
)))
|
||||
|
||||
patientName = patient_name_div.text.strip().lower()
|
||||
# Wait for results table to load
|
||||
try:
|
||||
WebDriverWait(self.driver, 10).until(
|
||||
EC.presence_of_element_located((By.XPATH, "//tbody//tr"))
|
||||
)
|
||||
except TimeoutException:
|
||||
print("[DDMA step2] Warning: Results table not found within timeout")
|
||||
|
||||
# 1) Extract eligibility status and Member ID from search results
|
||||
eligibilityText = "unknown"
|
||||
foundMemberId = ""
|
||||
patientName = ""
|
||||
|
||||
# Extract data from first row
|
||||
import re
|
||||
try:
|
||||
first_row = self.driver.find_element(By.XPATH, "(//tbody//tr)[1]")
|
||||
row_text = first_row.text.strip()
|
||||
print(f"[DDMA step2] First row text: {row_text[:150]}...")
|
||||
|
||||
if row_text:
|
||||
lines = row_text.split('\n')
|
||||
|
||||
# Extract patient name (first line, before "DOB:")
|
||||
if lines:
|
||||
potential_name = lines[0].strip()
|
||||
# Remove DOB if included in the name
|
||||
potential_name = re.sub(r'\s*DOB[:\s]*\d{1,2}/\d{1,2}/\d{2,4}\s*', '', potential_name, flags=re.IGNORECASE).strip()
|
||||
if potential_name and not potential_name.startswith('DOB') and not potential_name.isdigit():
|
||||
patientName = potential_name
|
||||
print(f"[DDMA step2] Extracted patient name from row: '{patientName}'")
|
||||
|
||||
# Extract Member ID (usually a numeric/alphanumeric ID on its own line)
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if line and re.match(r'^[A-Z0-9]{5,}$', line) and not line.startswith('DOB'):
|
||||
foundMemberId = line
|
||||
print(f"[DDMA step2] Extracted Member ID from row: {foundMemberId}")
|
||||
break
|
||||
|
||||
# Fallback: use input memberId if not found
|
||||
if not foundMemberId and self.memberId:
|
||||
foundMemberId = self.memberId
|
||||
print(f"[DDMA step2] Using input Member ID: {foundMemberId}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[DDMA step2] Error extracting data from row: {e}")
|
||||
if self.memberId:
|
||||
foundMemberId = self.memberId
|
||||
|
||||
# Extract eligibility status
|
||||
try:
|
||||
short_wait = WebDriverWait(self.driver, 3)
|
||||
status_link = short_wait.until(EC.presence_of_element_located((
|
||||
By.XPATH,
|
||||
"(//tbody//tr)[1]//a[contains(@href, 'member-eligibility-search')]"
|
||||
)))
|
||||
eligibilityText = status_link.text.strip().lower()
|
||||
print(f"[DDMA step2] Found eligibility status: {eligibilityText}")
|
||||
except Exception as e:
|
||||
print(f"[DDMA step2] Eligibility link not found, trying alternative...")
|
||||
try:
|
||||
alt_status = self.driver.find_element(By.XPATH, "//*[contains(text(),'Active') or contains(text(),'Inactive') or contains(text(),'Eligible')]")
|
||||
eligibilityText = alt_status.text.strip().lower()
|
||||
if "active" in eligibilityText or "eligible" in eligibilityText:
|
||||
eligibilityText = "active"
|
||||
elif "inactive" in eligibilityText:
|
||||
eligibilityText = "inactive"
|
||||
print(f"[DDMA step2] Found eligibility via alternative: {eligibilityText}")
|
||||
except:
|
||||
pass
|
||||
|
||||
# 2) Click on patient name to navigate to detailed patient page
|
||||
print("[DDMA step2] Clicking on patient name to open detailed page...")
|
||||
patient_name_clicked = False
|
||||
# Note: Don't reset patientName here - preserve the name extracted from row above
|
||||
|
||||
# First, let's print what we see on the page for debugging
|
||||
current_url_before = self.driver.current_url
|
||||
print(f"[DDMA step2] Current URL before click: {current_url_before}")
|
||||
|
||||
# Try to find all links in the first row and print them for debugging
|
||||
try:
|
||||
all_links = self.driver.find_elements(By.XPATH, "(//tbody//tr)[1]//a")
|
||||
print(f"[DDMA step2] Found {len(all_links)} links in first row:")
|
||||
for i, link in enumerate(all_links):
|
||||
href = link.get_attribute("href") or "no-href"
|
||||
text = link.text.strip() or "(empty text)"
|
||||
print(f" Link {i}: href={href[:80]}..., text={text}")
|
||||
except Exception as e:
|
||||
print(f"[DDMA step2] Error listing links: {e}")
|
||||
|
||||
# Find the patient detail link and navigate DIRECTLY to it
|
||||
detail_url = None
|
||||
patient_link_selectors = [
|
||||
"(//table//tbody//tr)[1]//td[1]//a", # First column link
|
||||
"(//tbody//tr)[1]//a[contains(@href, 'member-details')]", # member-details link
|
||||
"(//tbody//tr)[1]//a[contains(@href, 'member')]", # Any member link
|
||||
]
|
||||
|
||||
for selector in patient_link_selectors:
|
||||
try:
|
||||
patient_link = WebDriverWait(self.driver, 5).until(
|
||||
EC.presence_of_element_located((By.XPATH, selector))
|
||||
)
|
||||
link_text = patient_link.text.strip()
|
||||
href = patient_link.get_attribute("href")
|
||||
print(f"[DDMA step2] Found patient link: text='{link_text}', href={href}")
|
||||
|
||||
# Only update patientName if link has text (preserve previously extracted name)
|
||||
if link_text and not patientName:
|
||||
patientName = link_text
|
||||
|
||||
if href and "member-details" in href:
|
||||
detail_url = href
|
||||
patient_name_clicked = True
|
||||
print(f"[DDMA step2] Will navigate directly to: {detail_url}")
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"[DDMA step2] Selector '{selector}' failed: {e}")
|
||||
continue
|
||||
|
||||
if not detail_url:
|
||||
# Fallback: Try to find ANY link to member-details
|
||||
try:
|
||||
all_links = self.driver.find_elements(By.XPATH, "//a[contains(@href, 'member-details')]")
|
||||
if all_links:
|
||||
detail_url = all_links[0].get_attribute("href")
|
||||
patient_name_clicked = True
|
||||
print(f"[DDMA step2] Found member-details link: {detail_url}")
|
||||
except Exception as e:
|
||||
print(f"[DDMA step2] Could not find member-details link: {e}")
|
||||
|
||||
# Navigate to detail page DIRECTLY instead of clicking (which may open new tab/fail)
|
||||
if patient_name_clicked and detail_url:
|
||||
print(f"[DDMA step2] Navigating directly to detail page: {detail_url}")
|
||||
self.driver.get(detail_url)
|
||||
time.sleep(3) # Wait for page to load
|
||||
|
||||
current_url_after = self.driver.current_url
|
||||
print(f"[DDMA step2] Current URL after navigation: {current_url_after}")
|
||||
|
||||
if "member-details" in current_url_after:
|
||||
print("[DDMA step2] Successfully navigated to member details page!")
|
||||
else:
|
||||
print(f"[DDMA step2] WARNING: Navigation might have redirected. Current URL: {current_url_after}")
|
||||
|
||||
# Wait for page to be ready
|
||||
try:
|
||||
WebDriverWait(self.driver, 30).until(
|
||||
lambda d: d.execute_script("return document.readyState") == "complete"
|
||||
)
|
||||
except Exception:
|
||||
print("[DDMA step2] Warning: document.readyState did not become 'complete'")
|
||||
|
||||
# Wait for member details content to load (wait for specific elements)
|
||||
print("[DDMA step2] Waiting for member details content to fully load...")
|
||||
content_loaded = False
|
||||
content_selectors = [
|
||||
"//div[contains(@class,'member') or contains(@class,'detail') or contains(@class,'patient')]",
|
||||
"//h1",
|
||||
"//h2",
|
||||
"//table",
|
||||
"//*[contains(text(),'Member ID') or contains(text(),'Name') or contains(text(),'Date of Birth')]",
|
||||
]
|
||||
for selector in content_selectors:
|
||||
try:
|
||||
WebDriverWait(self.driver, 10).until(
|
||||
EC.presence_of_element_located((By.XPATH, selector))
|
||||
)
|
||||
content_loaded = True
|
||||
print(f"[DDMA step2] Content element found: {selector}")
|
||||
break
|
||||
except:
|
||||
continue
|
||||
|
||||
if not content_loaded:
|
||||
print("[DDMA step2] Warning: Could not verify content loaded, waiting extra time...")
|
||||
|
||||
# Additional wait for dynamic content and animations
|
||||
time.sleep(5) # Increased from 2 to 5 seconds
|
||||
|
||||
# Print page title for debugging
|
||||
try:
|
||||
page_title = self.driver.title
|
||||
print(f"[DDMA step2] Page title: {page_title}")
|
||||
except:
|
||||
pass
|
||||
|
||||
# Try to extract patient name from detailed page if not already found
|
||||
if not patientName:
|
||||
detail_name_selectors = [
|
||||
"//h1",
|
||||
"//h2",
|
||||
"//*[contains(@class,'patient-name') or contains(@class,'member-name')]",
|
||||
"//div[contains(@class,'header')]//span",
|
||||
]
|
||||
for selector in detail_name_selectors:
|
||||
try:
|
||||
name_elem = self.driver.find_element(By.XPATH, selector)
|
||||
name_text = name_elem.text.strip()
|
||||
if name_text and len(name_text) > 1:
|
||||
if not any(x in name_text.lower() for x in ['active', 'inactive', 'eligible', 'search', 'date', 'print']):
|
||||
patientName = name_text
|
||||
print(f"[DDMA step2] Found patient name on detail page: {patientName}")
|
||||
break
|
||||
except:
|
||||
continue
|
||||
else:
|
||||
print("[DDMA step2] Warning: Could not click on patient, capturing search results page")
|
||||
# Still try to get patient name from search results if not already found
|
||||
if not patientName:
|
||||
try:
|
||||
name_elem = self.driver.find_element(By.XPATH, "(//tbody//tr)[1]//td[1]")
|
||||
patientName = name_elem.text.strip()
|
||||
except:
|
||||
pass
|
||||
|
||||
if not patientName:
|
||||
print("[DDMA step2] Could not extract patient name")
|
||||
else:
|
||||
print(f"[DDMA step2] Patient name: {patientName}")
|
||||
|
||||
# Wait for page to fully load before generating PDF
|
||||
try:
|
||||
WebDriverWait(self.driver, 30).until(
|
||||
lambda d: d.execute_script("return document.readyState") == "complete"
|
||||
)
|
||||
except Exception:
|
||||
print("Warning: document.readyState did not become 'complete' within timeout")
|
||||
|
||||
# Give some time for lazy content to finish rendering (adjust if needed)
|
||||
time.sleep(0.6)
|
||||
|
||||
# Get total page size and DPR
|
||||
total_width = int(self.driver.execute_script(
|
||||
"return Math.max(document.body.scrollWidth, document.documentElement.scrollWidth, document.documentElement.clientWidth);"
|
||||
))
|
||||
total_height = int(self.driver.execute_script(
|
||||
"return Math.max(document.body.scrollHeight, document.documentElement.scrollHeight, document.documentElement.clientHeight);"
|
||||
))
|
||||
dpr = float(self.driver.execute_script("return window.devicePixelRatio || 1;"))
|
||||
|
||||
# Set device metrics to the full page size so Page.captureScreenshot captures everything
|
||||
# Note: Some pages are extremely tall; if you hit memory limits, you can capture in chunks.
|
||||
self.driver.execute_cdp_cmd('Emulation.setDeviceMetricsOverride', {
|
||||
"mobile": False,
|
||||
"width": total_width,
|
||||
"height": total_height,
|
||||
"deviceScaleFactor": dpr,
|
||||
"screenOrientation": {"angle": 0, "type": "portraitPrimary"}
|
||||
})
|
||||
|
||||
# Small pause for layout to settle after emulation change
|
||||
time.sleep(0.15)
|
||||
|
||||
# Capture screenshot (base64 PNG)
|
||||
result = self.driver.execute_cdp_cmd("Page.captureScreenshot", {"format": "png", "fromSurface": True})
|
||||
image_data = base64.b64decode(result.get('data', ''))
|
||||
screenshot_path = os.path.join(self.download_dir, f"ss_{self.memberId}.png")
|
||||
with open(screenshot_path, "wb") as f:
|
||||
f.write(image_data)
|
||||
|
||||
# Restore original metrics to avoid affecting further interactions
|
||||
try:
|
||||
self.driver.execute_cdp_cmd('Emulation.clearDeviceMetricsOverride', {})
|
||||
except Exception:
|
||||
# non-fatal: continue
|
||||
pass
|
||||
|
||||
print("Screenshot saved at:", screenshot_path)
|
||||
|
||||
# Close the browser window after screenshot (session preserved in profile)
|
||||
time.sleep(1)
|
||||
|
||||
# Generate PDF of the detailed patient page using Chrome DevTools Protocol
|
||||
print("[DDMA step2] Generating PDF of patient detail page...")
|
||||
|
||||
pdf_options = {
|
||||
"landscape": False,
|
||||
"displayHeaderFooter": False,
|
||||
"printBackground": True,
|
||||
"preferCSSPageSize": True,
|
||||
"paperWidth": 8.5, # Letter size in inches
|
||||
"paperHeight": 11,
|
||||
"marginTop": 0.4,
|
||||
"marginBottom": 0.4,
|
||||
"marginLeft": 0.4,
|
||||
"marginRight": 0.4,
|
||||
"scale": 0.9, # Slightly scale down to fit content
|
||||
}
|
||||
|
||||
result = self.driver.execute_cdp_cmd("Page.printToPDF", pdf_options)
|
||||
pdf_data = base64.b64decode(result.get('data', ''))
|
||||
# Use foundMemberId for filename if available, otherwise fall back to input memberId
|
||||
pdf_id = foundMemberId or self.memberId or "unknown"
|
||||
pdf_path = os.path.join(self.download_dir, f"eligibility_{pdf_id}.pdf")
|
||||
with open(pdf_path, "wb") as f:
|
||||
f.write(pdf_data)
|
||||
|
||||
print(f"[DDMA step2] PDF saved at: {pdf_path}")
|
||||
|
||||
# Close the browser window after PDF generation (session preserved in profile)
|
||||
try:
|
||||
from ddma_browser_manager import get_browser_manager
|
||||
get_browser_manager().quit_driver()
|
||||
@@ -345,11 +677,23 @@ class AutomationDeltaDentalMAEligibilityCheck:
|
||||
except Exception as e:
|
||||
print(f"[step2] Error closing browser: {e}")
|
||||
|
||||
# Clean patient name - remove DOB if it was included (already cleaned above but double check)
|
||||
if patientName:
|
||||
# Remove "DOB: MM/DD/YYYY" or similar patterns from the name
|
||||
cleaned_name = re.sub(r'\s*DOB[:\s]*\d{1,2}/\d{1,2}/\d{2,4}\s*', '', patientName, flags=re.IGNORECASE).strip()
|
||||
if cleaned_name:
|
||||
patientName = cleaned_name
|
||||
print(f"[DDMA step2] Cleaned patient name: {patientName}")
|
||||
|
||||
print(f"[DDMA step2] Final data - PatientName: '{patientName}', MemberID: '{foundMemberId}'")
|
||||
|
||||
output = {
|
||||
"status": "success",
|
||||
"eligibility": eligibilityText,
|
||||
"ss_path": screenshot_path,
|
||||
"patientName":patientName
|
||||
"ss_path": pdf_path, # Keep key as ss_path for backward compatibility
|
||||
"pdf_path": pdf_path, # Also add explicit pdf_path
|
||||
"patientName": patientName,
|
||||
"memberId": foundMemberId # Include extracted Member ID
|
||||
}
|
||||
return output
|
||||
except Exception as e:
|
||||
|
||||
686
apps/SeleniumService/selenium_DeltaIns_eligibilityCheckWorker.py
Normal file
686
apps/SeleniumService/selenium_DeltaIns_eligibilityCheckWorker.py
Normal 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),
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
1163
apps/SeleniumService/selenium_UnitedSCO_eligibilityCheckWorker.py
Normal file
1163
apps/SeleniumService/selenium_UnitedSCO_eligibilityCheckWorker.py
Normal file
File diff suppressed because it is too large
Load Diff
313
apps/SeleniumService/unitedsco_browser_manager.py
Normal file
313
apps/SeleniumService/unitedsco_browser_manager.py
Normal 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()
|
||||
@@ -22,10 +22,10 @@ export const insuranceIdSchema = z.preprocess(
|
||||
}
|
||||
return val;
|
||||
},
|
||||
// After preprocess, require digits-only string (or optional nullable)
|
||||
// After preprocess, allow alphanumeric insurance IDs (some providers like DentaQuest use letter prefixes)
|
||||
z
|
||||
.string()
|
||||
.regex(/^\d+$/, { message: "Insurance ID must contain only digits" })
|
||||
.regex(/^[A-Za-z0-9]+$/, { message: "Insurance ID must contain only letters and digits" })
|
||||
.min(1)
|
||||
.max(32)
|
||||
.optional()
|
||||
|
||||
Reference in New Issue
Block a user