Compare commits

..

12 Commits

Author SHA1 Message Date
854de90011 Delete packages/db/prisma.config.ts 2026-03-01 00:40:31 +00:00
d0c3d9dfdd Upload files to "packages/db/prisma" 2026-03-01 00:39:56 +00:00
cc66660ccd feat(db) - replace dotenv with custom environment variable loader in prisma.config.ts; remove old configuration file 2026-02-27 22:17:30 -05:00
131bd6db41 feat(dependencies) - add dotenv package to manage environment variables in db and package-lock.json 2026-02-27 22:08:26 -05:00
b89084e7cb feat(db) - update user seeding logic to use bcrypt for password hashing; add peer dependencies in package.json; enhance appointment creation with staff association and skipDuplicates option 2026-02-27 21:44:01 -05:00
4cb7ec7e2e feat(eligibility-check) - add CCA eligibility workflow with new routes and frontend components; enhance patient data processing and eligibility status updates; update insurance provider handling across various workflows 2026-02-25 22:38:33 -05:00
27e6e6a4a0 Update insurance status components and ignore selenium downloads 2026-02-22 23:20:18 -05:00
35896c264c Initial commit 2026-02-17 21:28:19 -05:00
cf53065a26 feat(eligibility-check) - implement Delta Dental Ins eligibility workflow with OTP handling; added routes, services, and frontend components for patient data processing and eligibility status retrieval; enhanced browser session management and logging 2026-02-17 20:53:24 -05:00
03172f0710 feat(eligibility-check) - enhance United SCO workflows with improved patient creation and update logic; added eligibility status handling and detailed logging; implemented browser cache clearing and anti-detection measures in Selenium service 2026-02-11 20:14:29 -05:00
445691cdd0 feat(eligibility-check) - enhance DDMA and DentaQuest workflows with flexible input handling; added detailed logging for patient data processing and eligibility status updates; improved browser cache management in Selenium service 2026-02-10 20:55:26 -05:00
e425a829b2 feat(eligibility-check) - enhance DentaQuest and United SCO workflows with flexible input handling; added Selenium session clearing on credential updates and deletions; improved patient name extraction and eligibility checks across services 2026-02-06 08:57:29 -05:00
62 changed files with 7537 additions and 819 deletions

8
.gitignore vendored
View File

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

View File

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

View File

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

View File

@@ -8,22 +8,19 @@ import { z } from "zod";
type SelectUser = z.infer<typeof UserUncheckedCreateInputObjectSchema>; type SelectUser = z.infer<typeof UserUncheckedCreateInputObjectSchema>;
const JWT_SECRET = process.env.JWT_SECRET || "your-jwt-secret"; const JWT_SECRET = process.env.JWT_SECRET || "your-jwt-secret";
const JWT_EXPIRATION = "24h"; // JWT expiration time (1 day) const JWT_EXPIRATION = "24h";
// Function to hash password using bcrypt
async function hashPassword(password: string) { async function hashPassword(password: string) {
const saltRounds = 10; // Salt rounds for bcrypt const saltRounds = 10;
const hashedPassword = await bcrypt.hash(password, saltRounds); const hashedPassword = await bcrypt.hash(password, saltRounds);
return hashedPassword; return hashedPassword;
} }
// Function to compare passwords using bcrypt
async function comparePasswords(supplied: string, stored: string) { async function comparePasswords(supplied: string, stored: string) {
const isMatch = await bcrypt.compare(supplied, stored); const isMatch = await bcrypt.compare(supplied, stored);
return isMatch; return isMatch;
} }
// Function to generate JWT
function generateToken(user: SelectUser) { function generateToken(user: SelectUser) {
return jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { return jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, {
expiresIn: JWT_EXPIRATION, expiresIn: JWT_EXPIRATION,
@@ -32,35 +29,13 @@ function generateToken(user: SelectUser) {
const router = express.Router(); const router = express.Router();
// User registration route
router.post( router.post(
"/register", "/register",
async (req: Request, res: Response, next: NextFunction): Promise<any> => { async (req: Request, res: Response, next: NextFunction): Promise<any> => {
try { return res.status(403).json({ error: "Public registration is disabled. Please contact your administrator." });
const existingUser = await storage.getUserByUsername(req.body.username);
if (existingUser) {
return res.status(400).send("Username already exists");
}
const hashedPassword = await hashPassword(req.body.password);
const user = await storage.createUser({
...req.body,
password: hashedPassword,
});
// Generate a JWT token for the user after successful registration
const token = generateToken(user);
const { password, ...safeUser } = user;
return res.status(201).json({ user: safeUser, token });
} catch (error) {
console.error("Registration error:", error);
return res.status(500).json({ error: "Internal server error" });
}
} }
); );
// User login route
router.post( router.post(
"/login", "/login",
async (req: Request, res: Response, next: NextFunction): Promise<any> => { async (req: Request, res: Response, next: NextFunction): Promise<any> => {
@@ -77,12 +52,12 @@ router.post(
); );
if (!isPasswordMatch) { if (!isPasswordMatch) {
return res.status(401).json({ error: "Invalid password or password" }); return res.status(401).json({ error: "Invalid username or password" });
} }
// Generate a JWT token for the user after successful login
const token = generateToken(user); const token = generateToken(user);
const { password, ...safeUser } = user; const { password, ...rest } = user;
const safeUser = { ...rest, role: rest.role ?? "USER" };
return res.status(200).json({ user: safeUser, token }); return res.status(200).json({ user: safeUser, token });
} catch (error) { } catch (error) {
return res.status(500).json({ error: "Internal server error" }); return res.status(500).json({ error: "Internal server error" });
@@ -90,9 +65,7 @@ router.post(
} }
); );
// Logout route (client-side action to remove the token)
router.post("/logout", (req: Request, res: Response) => { router.post("/logout", (req: Request, res: Response) => {
// For JWT-based auth, logout is handled on the client (by removing token)
res.status(200).send("Logged out successfully"); res.status(200).send("Logged out successfully");
}); });

View File

@@ -11,6 +11,8 @@ import insuranceStatusRoutes from "./insuranceStatus";
import insuranceStatusDdmaRoutes from "./insuranceStatusDDMA"; import insuranceStatusDdmaRoutes from "./insuranceStatusDDMA";
import insuranceStatusDentaQuestRoutes from "./insuranceStatusDentaQuest"; import insuranceStatusDentaQuestRoutes from "./insuranceStatusDentaQuest";
import insuranceStatusUnitedSCORoutes from "./insuranceStatusUnitedSCO"; import insuranceStatusUnitedSCORoutes from "./insuranceStatusUnitedSCO";
import insuranceStatusDeltaInsRoutes from "./insuranceStatusDeltaIns";
import insuranceStatusCCARoutes from "./insuranceStatusCCA";
import paymentsRoutes from "./payments"; import paymentsRoutes from "./payments";
import databaseManagementRoutes from "./database-management"; import databaseManagementRoutes from "./database-management";
import notificationsRoutes from "./notifications"; import notificationsRoutes from "./notifications";
@@ -33,6 +35,8 @@ router.use("/insurance-status", insuranceStatusRoutes);
router.use("/insurance-status-ddma", insuranceStatusDdmaRoutes); router.use("/insurance-status-ddma", insuranceStatusDdmaRoutes);
router.use("/insurance-status-dentaquest", insuranceStatusDentaQuestRoutes); router.use("/insurance-status-dentaquest", insuranceStatusDentaQuestRoutes);
router.use("/insurance-status-unitedsco", insuranceStatusUnitedSCORoutes); router.use("/insurance-status-unitedsco", insuranceStatusUnitedSCORoutes);
router.use("/insurance-status-deltains", insuranceStatusDeltaInsRoutes);
router.use("/insurance-status-cca", insuranceStatusCCARoutes);
router.use("/payments", paymentsRoutes); router.use("/payments", paymentsRoutes);
router.use("/database-management", databaseManagementRoutes); router.use("/database-management", databaseManagementRoutes);
router.use("/notifications", notificationsRoutes); router.use("/notifications", notificationsRoutes);

View File

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

View File

@@ -69,15 +69,15 @@ async function createOrUpdatePatientByInsuranceId(options: {
} }
return; return;
} else { } else {
// inside createOrUpdatePatientByInsuranceId, when creating:
const createPayload: any = { const createPayload: any = {
firstName: incomingFirst, firstName: incomingFirst,
lastName: incomingLast, lastName: incomingLast,
dateOfBirth: dob, // raw from caller (string | Date | null) dateOfBirth: dob,
gender: "", gender: "",
phone: "", phone: "",
userId, userId,
insuranceId, insuranceId,
insuranceProvider: "MassHealth",
}; };
let patientData: InsertPatient; let patientData: InsertPatient;
@@ -219,8 +219,8 @@ router.post(
if (patient && patient.id !== undefined) { if (patient && patient.id !== undefined) {
const newStatus = const newStatus =
seleniumResult.eligibility === "Y" ? "ACTIVE" : "INACTIVE"; seleniumResult.eligibility === "Y" ? "ACTIVE" : "INACTIVE";
await storage.updatePatient(patient.id, { status: newStatus }); await storage.updatePatient(patient.id, { status: newStatus, insuranceProvider: "MassHealth" });
outputResult.patientUpdateStatus = `Patient status updated to ${newStatus}`; outputResult.patientUpdateStatus = `Patient status updated to ${newStatus}, insuranceProvider=MassHealth`;
// ✅ Step 5: Handle PDF Upload // ✅ Step 5: Handle PDF Upload
if ( if (
@@ -649,8 +649,8 @@ router.post(
seleniumResult?.eligibility === "Y" ? "ACTIVE" : "INACTIVE"; seleniumResult?.eligibility === "Y" ? "ACTIVE" : "INACTIVE";
// 1. updating patient // 1. updating patient
await storage.updatePatient(updatedPatient.id, { status: newStatus }); await storage.updatePatient(updatedPatient.id, { status: newStatus, insuranceProvider: "MassHealth" });
resultItem.patientUpdateStatus = `Patient status updated to ${newStatus}`; resultItem.patientUpdateStatus = `Patient status updated to ${newStatus}, insuranceProvider=MassHealth`;
// 2. updating appointment status - for aptmnt page // 2. updating appointment status - for aptmnt page
try { try {

View File

@@ -0,0 +1,752 @@
import { Router, Request, Response } from "express";
import { storage } from "../storage";
import {
forwardToSeleniumCCAEligibilityAgent,
getSeleniumCCASessionStatus,
} from "../services/seleniumCCAInsuranceEligibilityClient";
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();
interface CCAJobContext {
userId: number;
insuranceEligibilityData: any;
socketId?: string;
}
const ccaJobs: Record<string, CCAJobContext> = {};
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);
}
});
}
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(
`[cca-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: "CCA",
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(
`[cca-eligibility] Created new patient: ${newPatient.id} with status: ${eligibilityStatus || "UNKNOWN"}`
);
}
}
async function handleCCACompletedJob(
sessionId: string,
job: CCAJobContext,
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(
"[cca-eligibility] No Member ID found - will use name for patient lookup"
);
} else {
console.log(`[cca-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(`[cca-eligibility] Eligibility status: ${eligibilityStatus}`);
// Extract extra patient data from selenium result
const extractedAddress = String(seleniumResult?.address ?? "").trim();
const extractedCity = String(seleniumResult?.city ?? "").trim();
const extractedZip = String(seleniumResult?.zipCode ?? "").trim();
const extractedInsurer = String(seleniumResult?.insurerName ?? "").trim() || "CCA";
if (extractedAddress || extractedCity || extractedZip) {
console.log(`[cca-eligibility] Extra data: address=${extractedAddress}, city=${extractedCity}, zip=${extractedZip}, insurer=${extractedInsurer}`);
}
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(
`[cca-eligibility] Found patient by name: ${patient.id}`
);
}
}
if (!patient && firstName && lastName) {
console.log(
`[cca-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: extractedInsurer,
gender: "Unknown",
phone: "",
userId: job.userId,
status: eligibilityStatus,
address: extractedAddress || undefined,
city: extractedCity || undefined,
zipCode: extractedZip || undefined,
};
const validation = insertPatientSchema.safeParse(newPatientData);
if (validation.success) {
patient = await storage.createPatient(validation.data);
console.log(
`[cca-eligibility] Created new patient: ${patient.id}`
);
} else {
console.log(
`[cca-eligibility] Patient validation failed: ${validation.error.message}`
);
}
} catch (createErr: any) {
console.log(
`[cca-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,
insuranceProvider: extractedInsurer,
};
if (firstName && (!patient.firstName || patient.firstName.trim() === "")) {
updatePayload.firstName = firstName;
}
if (lastName && (!patient.lastName || patient.lastName.trim() === "")) {
updatePayload.lastName = lastName;
}
if (extractedAddress && (!patient.address || patient.address.trim() === "")) {
updatePayload.address = extractedAddress;
}
if (extractedCity && (!patient.city || patient.city.trim() === "")) {
updatePayload.city = extractedCity;
}
if (extractedZip && (!patient.zipCode || patient.zipCode.trim() === "")) {
updatePayload.zipCode = extractedZip;
}
await storage.updatePatient(patient.id, updatePayload);
outputResult.patientUpdateStatus = `Patient ${patient.id} updated: status=${eligibilityStatus}, insuranceProvider=${extractedInsurer}, name=${firstName} ${lastName}, address=${extractedAddress}, city=${extractedCity}, zip=${extractedZip}`;
console.log(`[cca-eligibility] ${outputResult.patientUpdateStatus}`);
// Handle PDF
let pdfBuffer: Buffer | null = null;
if (
seleniumResult?.pdfBase64 &&
typeof seleniumResult.pdfBase64 === "string" &&
seleniumResult.pdfBase64.length > 100
) {
try {
pdfBuffer = Buffer.from(seleniumResult.pdfBase64, "base64");
const pdfFileName = `cca_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(
`[cca-eligibility] PDF saved from base64: ${generatedPdfPath}`
);
} catch (pdfErr: any) {
console.error(
`[cca-eligibility] Failed to save base64 PDF: ${pdfErr.message}`
);
pdfBuffer = null;
}
}
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 = `cca_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(
"[cca-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 CCA 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(`[cca-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 = 4 * 60 * 1000
) {
const maxAttempts = 300;
const baseDelayMs = 1000;
const maxTransientErrors = 12;
const noProgressLimit = 200;
const job = ccaJobs[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 ccaJobs[sessionId];
return;
}
log(
"poller-cca",
`attempt=${attempt} session=${sessionId} transientErrCount=${transientErrorCount}`
);
try {
const st = await getSeleniumCCASessionStatus(sessionId);
const status = st?.status ?? null;
log("poller-cca", "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 ccaJobs[sessionId];
return;
}
emitSafe(socketId, "selenium:debug", {
session_id: sessionId,
attempt,
status,
serverTime: new Date().toISOString(),
});
if (status === "completed") {
log("poller-cca", "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 handleCCACompletedJob(
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-cca", "handleCCACompletedJob 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 ccaJobs[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 ccaJobs[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-cca] 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 ccaJobs[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 ccaJobs[sessionId];
return;
}
const backoffMs = Math.min(
30_000,
baseDelayMs * Math.pow(2, transientErrorCount - 1)
);
console.warn(
`${new Date().toISOString()} [poller-cca] 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 ccaJobs[sessionId];
}
router.post(
"/cca-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,
"CCA"
);
if (!credentials) {
return res.status(404).json({
error:
"No insurance credentials found for this provider, Kindly Update this at Settings Page.",
});
}
const enrichedData = {
...rawData,
cca_username: credentials.username,
cca_password: credentials.password,
};
const socketId: string | undefined = req.body.socketId;
const agentResp =
await forwardToSeleniumCCAEligibilityAgent(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;
ccaJobs[sessionId] = {
userId: req.user.id,
insuranceEligibilityData: enrichedData,
socketId,
};
pollAgentSessionAndProcess(sessionId, socketId).catch((e) =>
console.warn("pollAgentSessionAndProcess (cca) 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 CCA selenium agent",
});
}
}
);
router.get(
"/selenium/session/:sid/final",
async (req: Request, res: Response) => {
const sid = req.params.sid;
if (!sid) return res.status(400).json({ error: "session id required" });
if (currentFinalSessionId !== sid || !currentFinalResult) {
return res.status(404).json({ error: "final result not found" });
}
return res.json(currentFinalResult);
}
);
export default router;

View File

@@ -109,6 +109,7 @@ async function createOrUpdatePatientByInsuranceId(options: {
phone: "", phone: "",
userId, userId,
insuranceId, insuranceId,
insuranceProvider: "Delta MA",
}; };
let patientData: InsertPatient; let patientData: InsertPatient;
try { try {
@@ -136,36 +137,135 @@ async function handleDdmaCompletedJob(
// We'll wrap the processing in try/catch/finally so cleanup always runs // We'll wrap the processing in try/catch/finally so cleanup always runs
try { try {
// 1) ensuring memberid.
const insuranceEligibilityData = job.insuranceEligibilityData; const insuranceEligibilityData = job.insuranceEligibilityData;
const insuranceId = String(insuranceEligibilityData.memberId ?? "").trim();
// 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) { 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 = const patientNameFromResult =
typeof seleniumResult?.patientName === "string" typeof seleniumResult?.patientName === "string"
? seleniumResult.patientName.trim() ? seleniumResult.patientName.trim()
: null; : 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({ // 3) Find or create patient
insuranceId, let patient: any = null;
firstName,
lastName, // First, try to find by insuranceId if available
dob: insuranceEligibilityData.dateOfBirth, if (insuranceId) {
userId: job.userId, patient = await storage.getPatientByInsuranceId(insuranceId);
}); if (patient) {
console.log(`[ddma-eligibility] Found patient by insuranceId: ${patient.id}`);
// 3) Update patient status + PDF upload
const patient = await storage.getPatientByInsuranceId( // Update name if we have better data
insuranceEligibilityData.memberId 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) { if (!patient?.id) {
outputResult.patientUpdateStatus = outputResult.patientUpdateStatus =
"Patient not found; no update performed"; "Patient not found and could not be created; no update performed";
return { return {
patientUpdateStatus: outputResult.patientUpdateStatus, patientUpdateStatus: outputResult.patientUpdateStatus,
pdfUploadStatus: "none", pdfUploadStatus: "none",
@@ -173,11 +273,10 @@ async function handleDdmaCompletedJob(
}; };
} }
// update patient status. // Update patient status from Delta MA eligibility result
const newStatus = await storage.updatePatient(patient.id, { status: eligibilityStatus, insuranceProvider: "Delta MA" });
seleniumResult.eligibility === "active" ? "ACTIVE" : "INACTIVE"; outputResult.patientUpdateStatus = `Patient ${patient.id} status set to ${eligibilityStatus}, insuranceProvider=Delta MA (Delta MA eligibility: ${seleniumResult.eligibility})`;
await storage.updatePatient(patient.id, { status: newStatus }); console.log(`[ddma-eligibility] ${outputResult.patientUpdateStatus}`);
outputResult.patientUpdateStatus = `Patient status updated to ${newStatus}`;
// Handle PDF or convert screenshot -> pdf if available // Handle PDF or convert screenshot -> pdf if available
let pdfBuffer: Buffer | null = null; let pdfBuffer: Buffer | null = null;

View File

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

View File

@@ -109,6 +109,7 @@ async function createOrUpdatePatientByInsuranceId(options: {
phone: "", phone: "",
userId, userId,
insuranceId, insuranceId,
insuranceProvider: "Tufts / DentaQuest",
}; };
let patientData: InsertPatient; let patientData: InsertPatient;
try { try {
@@ -136,12 +137,17 @@ async function handleDentaQuestCompletedJob(
// We'll wrap the processing in try/catch/finally so cleanup always runs // We'll wrap the processing in try/catch/finally so cleanup always runs
try { try {
// 1) ensuring memberid.
const insuranceEligibilityData = job.insuranceEligibilityData; const insuranceEligibilityData = job.insuranceEligibilityData;
const insuranceId = String(insuranceEligibilityData.memberId ?? "").trim();
// 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) { if (!insuranceId) {
throw new Error("Missing memberId for DentaQuest job"); // 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) // 2) Create or update patient (with name from selenium result if available)
const patientNameFromResult = const patientNameFromResult =
@@ -149,23 +155,99 @@ async function handleDentaQuestCompletedJob(
? seleniumResult.patientName.trim() ? seleniumResult.patientName.trim()
: null; : null;
const { firstName, lastName } = splitName(patientNameFromResult); // 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;
}
await createOrUpdatePatientByInsuranceId({ // Create or update patient if we have an insurance ID
insuranceId, if (insuranceId) {
firstName, await createOrUpdatePatientByInsuranceId({
lastName, insuranceId,
dob: insuranceEligibilityData.dateOfBirth, firstName,
userId: job.userId, 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 // 3) Update patient status + PDF upload
const patient = await storage.getPatientByInsuranceId( // First try to find by insurance ID, then by name + DOB
insuranceEligibilityData.memberId 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: "Tufts / DentaQuest",
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) { if (!patient?.id) {
outputResult.patientUpdateStatus = outputResult.patientUpdateStatus =
"Patient not found; no update performed"; "Patient not found and could not be created";
return { return {
patientUpdateStatus: outputResult.patientUpdateStatus, patientUpdateStatus: outputResult.patientUpdateStatus,
pdfUploadStatus: "none", pdfUploadStatus: "none",
@@ -173,11 +255,10 @@ async function handleDentaQuestCompletedJob(
}; };
} }
// update patient status. // Update patient status from DentaQuest eligibility result
const newStatus = await storage.updatePatient(patient.id, { status: eligibilityStatus, insuranceProvider: "Tufts / DentaQuest" });
seleniumResult.eligibility === "active" ? "ACTIVE" : "INACTIVE"; outputResult.patientUpdateStatus = `Patient ${patient.id} status set to ${eligibilityStatus}, insuranceProvider=Tufts / DentaQuest (DentaQuest eligibility: ${seleniumResult.eligibility})`;
await storage.updatePatient(patient.id, { status: newStatus }); console.log(`[dentaquest-eligibility] ${outputResult.patientUpdateStatus}`);
outputResult.patientUpdateStatus = `Patient status updated to ${newStatus}`;
// Handle PDF or convert screenshot -> pdf if available // Handle PDF or convert screenshot -> pdf if available
let pdfBuffer: Buffer | null = null; let pdfBuffer: Buffer | null = null;

View File

@@ -73,8 +73,9 @@ async function createOrUpdatePatientByInsuranceId(options: {
lastName?: string | null; lastName?: string | null;
dob?: string | Date | null; dob?: string | Date | null;
userId: number; userId: number;
eligibilityStatus?: string; // "ACTIVE" or "INACTIVE"
}) { }) {
const { insuranceId, firstName, lastName, dob, userId } = options; const { insuranceId, firstName, lastName, dob, userId, eligibilityStatus } = options;
if (!insuranceId) throw new Error("Missing insuranceId"); if (!insuranceId) throw new Error("Missing insuranceId");
const incomingFirst = (firstName || "").trim(); const incomingFirst = (firstName || "").trim();
@@ -101,14 +102,17 @@ async function createOrUpdatePatientByInsuranceId(options: {
} }
return; return;
} else { } else {
console.log(`[unitedsco-eligibility] Creating new patient: ${incomingFirst} ${incomingLast} with status: ${eligibilityStatus || "UNKNOWN"}`);
const createPayload: any = { const createPayload: any = {
firstName: incomingFirst, firstName: incomingFirst,
lastName: incomingLast, lastName: incomingLast,
dateOfBirth: dob, dateOfBirth: dob,
gender: "", gender: "Unknown",
phone: "", phone: "",
userId, userId,
insuranceId, insuranceId,
insuranceProvider: "United SCO",
status: eligibilityStatus || "UNKNOWN",
}; };
let patientData: InsertPatient; let patientData: InsertPatient;
try { try {
@@ -118,7 +122,8 @@ async function createOrUpdatePatientByInsuranceId(options: {
delete (safePayload as any).dateOfBirth; delete (safePayload as any).dateOfBirth;
patientData = insertPatientSchema.parse(safePayload); patientData = insertPatientSchema.parse(safePayload);
} }
await storage.createPatient(patientData); const newPatient = await storage.createPatient(patientData);
console.log(`[unitedsco-eligibility] Created new patient: ${newPatient.id} with status: ${eligibilityStatus || "UNKNOWN"}`);
} }
} }
@@ -135,6 +140,7 @@ async function handleUnitedSCOCompletedJob(
seleniumResult: any seleniumResult: any
) { ) {
let createdPdfFileId: number | null = null; let createdPdfFileId: number | null = null;
let generatedPdfPath: string | null = null;
const outputResult: any = {}; const outputResult: any = {};
// We'll wrap the processing in try/catch/finally so cleanup always runs // We'll wrap the processing in try/catch/finally so cleanup always runs
@@ -170,6 +176,10 @@ async function handleUnitedSCOCompletedJob(
lastName = parsedName.lastName || lastName; 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 // 3) Create or update patient
if (insuranceId) { if (insuranceId) {
await createOrUpdatePatientByInsuranceId({ await createOrUpdatePatientByInsuranceId({
@@ -178,6 +188,7 @@ async function handleUnitedSCOCompletedJob(
lastName, lastName,
dob: insuranceEligibilityData.dateOfBirth, dob: insuranceEligibilityData.dateOfBirth,
userId: job.userId, userId: job.userId,
eligibilityStatus,
}); });
} }
@@ -186,9 +197,61 @@ async function handleUnitedSCOCompletedJob(
? await storage.getPatientByInsuranceId(insuranceId) ? await storage.getPatientByInsuranceId(insuranceId)
: null; : 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) { if (!patient?.id) {
outputResult.patientUpdateStatus = outputResult.patientUpdateStatus =
"Patient not found; no update performed"; "Patient not found and could not be created; no update performed";
return { return {
patientUpdateStatus: outputResult.patientUpdateStatus, patientUpdateStatus: outputResult.patientUpdateStatus,
pdfUploadStatus: "none", pdfUploadStatus: "none",
@@ -196,15 +259,22 @@ async function handleUnitedSCOCompletedJob(
}; };
} }
// Update patient status // Update patient status and name from United SCO eligibility result
const newStatus = const updatePayload: Record<string, any> = { status: eligibilityStatus, insuranceProvider: "United SCO" };
seleniumResult.eligibility === "active" ? "ACTIVE" : "INACTIVE";
await storage.updatePatient(patient.id, { status: newStatus }); if (firstName && (!patient.firstName || patient.firstName.trim() === "")) {
outputResult.patientUpdateStatus = `Patient status updated to ${newStatus}`; 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}, insuranceProvider=United SCO, name=${firstName} ${lastName} (United SCO eligibility: ${seleniumResult.eligibility})`;
console.log(`[unitedsco-eligibility] ${outputResult.patientUpdateStatus}`);
// Handle PDF or convert screenshot -> pdf if available // Handle PDF or convert screenshot -> pdf if available
let pdfBuffer: Buffer | null = null; let pdfBuffer: Buffer | null = null;
let generatedPdfPath: string | null = null;
if ( if (
seleniumResult && seleniumResult &&
@@ -233,7 +303,8 @@ async function handleUnitedSCOCompletedJob(
// Convert image to PDF // Convert image to PDF
pdfBuffer = await imageToPdfBuffer(seleniumResult.ss_path); pdfBuffer = await imageToPdfBuffer(seleniumResult.ss_path);
const pdfFileName = `unitedsco_eligibility_${insuranceEligibilityData.memberId}_${Date.now()}.pdf`; // Use insuranceId (which may come from Selenium result) for filename
const pdfFileName = `unitedsco_eligibility_${insuranceId || "unknown"}_${Date.now()}.pdf`;
generatedPdfPath = path.join( generatedPdfPath = path.join(
path.dirname(seleniumResult.ss_path), path.dirname(seleniumResult.ss_path),
pdfFileName pdfFileName
@@ -287,18 +358,25 @@ async function handleUnitedSCOCompletedJob(
"No valid PDF path provided by Selenium, Couldn't upload pdf to server."; "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 { return {
patientUpdateStatus: outputResult.patientUpdateStatus, patientUpdateStatus: outputResult.patientUpdateStatus,
pdfUploadStatus: outputResult.pdfUploadStatus, pdfUploadStatus: outputResult.pdfUploadStatus,
pdfFileId: createdPdfFileId, pdfFileId: createdPdfFileId,
pdfFilename,
}; };
} catch (err: any) { } catch (err: any) {
// Get filename for frontend preview if available
const pdfFilename = generatedPdfPath ? path.basename(generatedPdfPath) : null;
return { return {
patientUpdateStatus: outputResult.patientUpdateStatus, patientUpdateStatus: outputResult.patientUpdateStatus,
pdfUploadStatus: pdfUploadStatus:
outputResult.pdfUploadStatus ?? outputResult.pdfUploadStatus ??
`Failed to process United SCO job: ${err?.message ?? String(err)}`, `Failed to process United SCO job: ${err?.message ?? String(err)}`,
pdfFileId: createdPdfFileId, pdfFileId: createdPdfFileId,
pdfFilename,
error: err?.message ?? String(err), error: err?.message ?? String(err),
}; };
} finally { } finally {

View File

@@ -3,16 +3,13 @@ import type { Request, Response } from "express";
import { storage } from "../storage"; import { storage } from "../storage";
import { z } from "zod"; import { z } from "zod";
import { UserUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas"; import { UserUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas";
import jwt from 'jsonwebtoken'; import bcrypt from "bcrypt";
import bcrypt from 'bcrypt';
const router = Router(); const router = Router();
// Type based on shared schema
type SelectUser = z.infer<typeof UserUncheckedCreateInputObjectSchema>; type SelectUser = z.infer<typeof UserUncheckedCreateInputObjectSchema>;
// Zod validation
const userCreateSchema = UserUncheckedCreateInputObjectSchema; const userCreateSchema = UserUncheckedCreateInputObjectSchema;
const userUpdateSchema = (UserUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>).partial(); const userUpdateSchema = (UserUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>).partial();
@@ -25,16 +22,32 @@ router.get("/", async (req: Request, res: Response): Promise<any> => {
const user = await storage.getUser(userId); const user = await storage.getUser(userId);
if (!user) return res.status(404).send("User not found"); if (!user) return res.status(404).send("User not found");
const { password, ...rest } = user;
const { password, ...safeUser } = user; res.json({ ...rest, role: rest.role ?? "USER" });
res.json(safeUser);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
res.status(500).send("Failed to fetch user"); res.status(500).send("Failed to fetch user");
} }
}); });
// GET: User by ID router.get("/list", async (req: Request, res: Response): Promise<any> => {
try {
if (!req.user?.id) return res.status(401).send("Unauthorized");
const limit = Math.min(Number(req.query.limit) || 100, 500);
const offset = Number(req.query.offset) || 0;
const users = await storage.getUsers(limit, offset);
const safe = users.map((u) => {
const { password: _p, ...rest } = u;
return { ...rest, role: rest.role ?? "USER" };
});
res.json(safe);
} catch (error) {
console.error(error);
res.status(500).send("Failed to fetch users");
}
});
router.get("/:id", async (req: Request, res: Response): Promise<any> => { router.get("/:id", async (req: Request, res: Response): Promise<any> => {
try { try {
const idParam = req.params.id; const idParam = req.params.id;
@@ -46,35 +59,36 @@ router.get("/:id", async (req: Request, res: Response): Promise<any> => {
const user = await storage.getUser(id); const user = await storage.getUser(id);
if (!user) return res.status(404).send("User not found"); if (!user) return res.status(404).send("User not found");
const { password, ...safeUser } = user; const { password, ...rest } = user;
res.json(safeUser); res.json({ ...rest, role: rest.role ?? "USER" });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
res.status(500).send("Failed to fetch user"); res.status(500).send("Failed to fetch user");
} }
}); });
// POST: Create new user
router.post("/", async (req: Request, res: Response) => { router.post("/", async (req: Request, res: Response) => {
try { try {
const input = userCreateSchema.parse(req.body); const input = userCreateSchema.parse(req.body);
const newUser = await storage.createUser(input); const existing = await storage.getUserByUsername(input.username);
const { password, ...safeUser } = newUser; if (existing) {
res.status(201).json(safeUser); return res.status(400).json({ error: "Username already exists" });
}
const hashed = await hashPassword(input.password);
const newUser = await storage.createUser({ ...input, password: hashed });
const { password: _p, ...rest } = newUser;
res.status(201).json({ ...rest, role: rest.role ?? "USER" });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
res.status(400).json({ error: "Invalid user data", details: err }); res.status(400).json({ error: "Invalid user data", details: err });
} }
}); });
// Function to hash password using bcrypt
async function hashPassword(password: string) { async function hashPassword(password: string) {
const saltRounds = 10; // Salt rounds for bcrypt const saltRounds = 10;
const hashedPassword = await bcrypt.hash(password, saltRounds); return bcrypt.hash(password, saltRounds);
return hashedPassword;
} }
// PUT: Update user
router.put("/:id", async (req: Request, res: Response):Promise<any> => { router.put("/:id", async (req: Request, res: Response):Promise<any> => {
try { try {
const idParam = req.params.id; const idParam = req.params.id;
@@ -86,27 +100,24 @@ router.put("/:id", async (req: Request, res: Response):Promise<any> => {
const updates = userUpdateSchema.parse(req.body); const updates = userUpdateSchema.parse(req.body);
// If password is provided and non-empty, hash it
if (updates.password && updates.password.trim() !== "") { if (updates.password && updates.password.trim() !== "") {
updates.password = await hashPassword(updates.password); updates.password = await hashPassword(updates.password);
} else { } else {
// Remove password field if empty, so it won't overwrite existing password with blank
delete updates.password; delete updates.password;
} }
const updatedUser = await storage.updateUser(id, updates); const updatedUser = await storage.updateUser(id, updates);
if (!updatedUser) return res.status(404).send("User not found"); if (!updatedUser) return res.status(404).send("User not found");
const { password, ...safeUser } = updatedUser; const { password, ...rest } = updatedUser;
res.json(safeUser); res.json({ ...rest, role: rest.role ?? "USER" });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
res.status(400).json({ error: "Invalid update data", details: err }); res.status(400).json({ error: "Invalid update data", details: err });
} }
}); });
// DELETE: Delete user router.delete("/:id", async (req: Request, res: Response): Promise<any> => {
router.delete("/:id", async (req: Request, res: Response):Promise<any> => {
try { try {
const idParam = req.params.id; const idParam = req.params.id;
if (!idParam) return res.status(400).send("User ID is required"); if (!idParam) return res.status(400).send("User ID is required");
@@ -114,6 +125,10 @@ router.delete("/:id", async (req: Request, res: Response):Promise<any> => {
const id = parseInt(idParam); const id = parseInt(idParam);
if (isNaN(id)) return res.status(400).send("Invalid user ID"); if (isNaN(id)) return res.status(400).send("Invalid user ID");
if (req.user?.id === id) {
return res.status(403).json({ error: "Cannot delete your own account" });
}
const success = await storage.deleteUser(id); const success = await storage.deleteUser(id);
if (!success) return res.status(404).send("User not found"); if (!success) return res.status(404).send("User not found");
@@ -124,4 +139,4 @@ router.delete("/:id", async (req: Request, res: Response):Promise<any> => {
} }
}); });
export default router; export default router;

View File

@@ -0,0 +1,99 @@
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-cca-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-cca-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 forwardToSeleniumCCAEligibilityAgent(
insuranceEligibilityData: any
): Promise<any> {
const payload = { data: insuranceEligibilityData };
const url = `/cca-eligibility`;
log("selenium-cca-client", "POST cca-eligibility", {
url: SELENIUM_AGENT_BASE + url,
keys: Object.keys(payload),
});
const r = await requestWithRetries({ url, method: "POST", data: payload }, 4);
log("selenium-cca-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 getSeleniumCCASessionStatus(
sessionId: string
): Promise<any> {
const url = `/cca-session/${sessionId}/status`;
log("selenium-cca-client", "GET session status", {
url: SELENIUM_AGENT_BASE + url,
sessionId,
});
const r = await requestWithRetries({ url, method: "GET" }, 4);
log("selenium-cca-client", "session status response", {
status: r.status,
dataKeys: r.data ? Object.keys(r.data) : null,
});
if (r.status === 404) {
const e: any = new Error("not_found");
e.response = { status: 404, data: r.data };
throw e;
}
return r.data;
}

View File

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

View File

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

View File

@@ -41,7 +41,7 @@ function Router() {
component={() => <AppointmentsPage />} component={() => <AppointmentsPage />}
/> />
<ProtectedRoute path="/patients" component={() => <PatientsPage />} /> <ProtectedRoute path="/patients" component={() => <PatientsPage />} />
<ProtectedRoute path="/settings" component={() => <SettingsPage />} /> <ProtectedRoute path="/settings" component={() => <SettingsPage />} adminOnly />
<ProtectedRoute path="/claims" component={() => <ClaimsPage />} /> <ProtectedRoute path="/claims" component={() => <ClaimsPage />} />
<ProtectedRoute <ProtectedRoute
path="/insurance-status" path="/insurance-status"

View File

@@ -0,0 +1,357 @@
import { useEffect, useRef, useState } from "react";
import { io as ioClient, Socket } from "socket.io-client";
import { Button } from "@/components/ui/button";
import { CheckCircle, LoaderCircleIcon } 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 : "");
interface CCAEligibilityButtonProps {
memberId: string;
dateOfBirth: Date | null;
firstName?: string;
lastName?: string;
isFormIncomplete: boolean;
onPdfReady: (pdfId: number, fallbackFilename: string | null) => void;
}
export function CCAEligibilityButton({
memberId,
dateOfBirth,
firstName,
lastName,
isFormIncomplete,
onPdfReady,
}: CCAEligibilityButtonProps) {
const { toast } = useToast();
const dispatch = useAppDispatch();
const isCCAFormIncomplete =
!dateOfBirth || (!memberId && !firstName && !lastName);
const socketRef = useRef<Socket | null>(null);
const connectingRef = useRef<Promise<void> | null>(null);
const [isStarting, setIsStarting] = 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", () => {
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_failed", () => {
dispatch(
setTaskStatus({
status: "error",
message: "Reconnect failed",
})
);
closeSocket();
reject(new Error("Realtime reconnect failed"));
});
socket.on("disconnect", () => {
dispatch(
setTaskStatus({
status: "error",
message: "Connection disconnected",
})
);
});
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:
"CCA eligibility updated and PDF attached to patient documents.",
})
);
toast({
title: "CCA 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_cca_${memberId}.pdf`;
onPdfReady(Number(pdfId), filename);
}
} else if (status === "error") {
const msg =
payload?.message ||
final?.error ||
"CCA eligibility session failed.";
dispatch(
setTaskStatus({
status: "error",
message: msg,
})
);
toast({
title: "CCA selenium error",
description: msg,
variant: "destructive",
});
try {
closeSocket();
} catch (e) {}
}
queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
});
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) {}
});
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 startCCAEligibility = async () => {
if (!dateOfBirth) {
toast({
title: "Missing fields",
description: "Date of Birth is required for CCA eligibility.",
variant: "destructive",
});
return;
}
if (!memberId && !firstName && !lastName) {
toast({
title: "Missing fields",
description:
"Member ID, First Name, or Last Name is required for CCA eligibility.",
variant: "destructive",
});
return;
}
const formattedDob = dateOfBirth ? formatLocalDate(dateOfBirth) : "";
const payload = {
memberId: memberId || "",
dateOfBirth: formattedDob,
firstName: firstName || "",
lastName: lastName || "",
insuranceSiteKey: "CCA",
};
try {
setIsStarting(true);
dispatch(
setTaskStatus({
status: "pending",
message: "Opening realtime channel for CCA 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 CCA eligibility check via selenium...",
})
);
const response = await apiRequest(
"POST",
"/api/insurance-status-cca/cca-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 ||
`CCA selenium start failed (status ${response.status})`
);
}
if (result?.error) {
throw new Error(result.error);
}
if (result.status === "started" && result.session_id) {
dispatch(
setTaskStatus({
status: "pending",
message:
"CCA eligibility job started. Waiting for result...",
})
);
} else {
dispatch(
setTaskStatus({
status: "success",
message: "CCA eligibility completed.",
})
);
}
} catch (err: any) {
console.error("startCCAEligibility error:", err);
dispatch(
setTaskStatus({
status: "error",
message: err?.message || "Failed to start CCA eligibility",
})
);
toast({
title: "CCA selenium error",
description: err?.message || "Failed to start CCA eligibility",
variant: "destructive",
});
} finally {
setIsStarting(false);
}
};
return (
<Button
className="w-full"
disabled={isCCAFormIncomplete || isStarting}
onClick={startCCAEligibility}
>
{isStarting ? (
<>
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin" />
Processing...
</>
) : (
<>
<CheckCircle className="h-4 w-4 mr-2" />
CCA
</>
)}
</Button>
);
}

View File

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

View File

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

View File

@@ -127,6 +127,11 @@ export function DentaQuestEligibilityButton({
const [isStarting, setIsStarting] = useState(false); const [isStarting, setIsStarting] = useState(false);
const [isSubmittingOtp, setIsSubmittingOtp] = 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 // Clean up socket on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -370,10 +375,13 @@ export function DentaQuestEligibilityButton({
}; };
const startDentaQuestEligibility = async () => { const startDentaQuestEligibility = async () => {
if (!memberId || !dateOfBirth) { // Flexible search - DOB required plus at least one identifier
const hasAnyIdentifier = memberId || firstName || lastName;
if (!dateOfBirth || !hasAnyIdentifier) {
toast({ toast({
title: "Missing fields", title: "Missing fields",
description: "Member ID and Date of Birth are required.", description: "Please provide Date of Birth and at least one of: Member ID, First Name, or Last Name.",
variant: "destructive", variant: "destructive",
}); });
return; return;
@@ -537,8 +545,8 @@ export function DentaQuestEligibilityButton({
<> <>
<Button <Button
className="w-full" className="w-full"
variant="outline"
disabled={isFormIncomplete || isStarting} disabled={isDentaQuestFormIncomplete || isStarting}
onClick={startDentaQuestEligibility} onClick={startDentaQuestEligibility}
> >
{isStarting ? ( {isStarting ? (

View File

@@ -118,6 +118,10 @@ export function UnitedSCOEligibilityButton({
}: UnitedSCOEligibilityButtonProps) { }: UnitedSCOEligibilityButtonProps) {
const { toast } = useToast(); const { toast } = useToast();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
// Flexible validation: require DOB + at least one identifier (memberId OR firstName OR lastName)
const isUnitedSCOFormIncomplete =
!dateOfBirth || (!memberId && !firstName && !lastName);
const socketRef = useRef<Socket | null>(null); const socketRef = useRef<Socket | null>(null);
const connectingRef = useRef<Promise<void> | null>(null); const connectingRef = useRef<Promise<void> | null>(null);
@@ -370,10 +374,20 @@ export function UnitedSCOEligibilityButton({
}; };
const startUnitedSCOEligibility = async () => { const startUnitedSCOEligibility = async () => {
if (!memberId || !dateOfBirth) { // Flexible: require DOB + at least one identifier (memberId OR firstName OR lastName)
if (!dateOfBirth) {
toast({ toast({
title: "Missing fields", title: "Missing fields",
description: "Member ID and Date of Birth are required.", 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", variant: "destructive",
}); });
return; return;
@@ -382,11 +396,11 @@ export function UnitedSCOEligibilityButton({
const formattedDob = dateOfBirth ? formatLocalDate(dateOfBirth) : ""; const formattedDob = dateOfBirth ? formatLocalDate(dateOfBirth) : "";
const payload = { const payload = {
memberId, memberId: memberId || "",
dateOfBirth: formattedDob, dateOfBirth: formattedDob,
firstName, firstName: firstName || "",
lastName, lastName: lastName || "",
insuranceSiteKey: "UNITEDSCO", // for backend credential lookup (uses DENTAQUEST) insuranceSiteKey: "UNITEDSCO",
}; };
try { try {
@@ -537,8 +551,8 @@ export function UnitedSCOEligibilityButton({
<> <>
<Button <Button
className="w-full" className="w-full"
variant="outline"
disabled={isFormIncomplete || isStarting} disabled={isUnitedSCOFormIncomplete || isStarting}
onClick={startUnitedSCOEligibility} onClick={startUnitedSCOEligibility}
> >
{isStarting ? ( {isStarting ? (

View File

@@ -16,10 +16,16 @@ import {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useMemo } from "react"; import { useMemo } from "react";
import { useSidebar } from "@/components/ui/sidebar"; import { useSidebar } from "@/components/ui/sidebar";
import { useAuth } from "@/hooks/use-auth";
export function Sidebar() { export function Sidebar() {
const [location] = useLocation(); const [location] = useLocation();
const { state, openMobile, setOpenMobile } = useSidebar(); // "expanded" | "collapsed" const { state, openMobile, setOpenMobile } = useSidebar();
const { user } = useAuth();
const isAdmin =
user?.role?.toUpperCase() === "ADMIN" ||
user?.username?.toLowerCase() === "admin";
const navItems = useMemo( const navItems = useMemo(
() => [ () => [
@@ -82,6 +88,7 @@ export function Sidebar() {
name: "Settings", name: "Settings",
path: "/settings", path: "/settings",
icon: <Settings className="h-5 w-5" />, icon: <Settings className="h-5 w-5" />,
adminOnly: true,
}, },
], ],
[] []
@@ -90,43 +97,39 @@ export function Sidebar() {
return ( return (
<div <div
className={cn( className={cn(
// original look
"bg-white border-r border-gray-200 shadow-sm z-20", "bg-white border-r border-gray-200 shadow-sm z-20",
// clip during width animation to avoid text peeking
"overflow-hidden will-change-[width]", "overflow-hidden will-change-[width]",
// animate width only
"transition-[width] duration-200 ease-in-out", "transition-[width] duration-200 ease-in-out",
// MOBILE: overlay below topbar (h = 100vh - 4rem)
openMobile openMobile
? "fixed top-16 left-0 h-[calc(100vh-4rem)] w-64 block md:hidden" ? "fixed top-16 left-0 h-[calc(100vh-4rem)] w-64 block md:hidden"
: "hidden md:block", : "hidden md:block",
// DESKTOP: participates in row layout
"md:static md:top-auto md:h-auto md:flex-shrink-0", "md:static md:top-auto md:h-auto md:flex-shrink-0",
state === "collapsed" ? "md:w-0 overflow-hidden" : "md:w-64" state === "collapsed" ? "md:w-0 overflow-hidden" : "md:w-64"
)} )}
> >
<div className="p-2"> <div className="p-2">
<nav role="navigation" aria-label="Main"> <nav role="navigation" aria-label="Main">
{navItems.map((item) => ( {navItems
<div key={item.path}> .filter((item) => !item.adminOnly || isAdmin)
<Link to={item.path} onClick={() => setOpenMobile(false)}> .map((item) => (
<div <div key={item.path}>
className={cn( <Link to={item.path} onClick={() => setOpenMobile(false)}>
"flex items-center space-x-3 p-2 rounded-md pl-3 mb-1 transition-colors cursor-pointer", <div
location === item.path className={cn(
? "text-primary font-medium border-l-2 border-primary" "flex items-center space-x-3 p-2 rounded-md pl-3 mb-1 transition-colors cursor-pointer",
: "text-gray-600 hover:bg-gray-100" location === item.path
)} ? "text-primary font-medium border-l-2 border-primary"
> : "text-gray-600 hover:bg-gray-100"
{item.icon} )}
{/* show label only after expand animation completes */} >
<span className="whitespace-nowrap select-none"> {item.icon}
{item.name} <span className="whitespace-nowrap select-none">
</span> {item.name}
</div> </span>
</Link> </div>
</div> </Link>
))} </div>
))}
</nav> </nav>
</div> </div>
</div> </div>

View File

@@ -18,7 +18,10 @@ type CredentialFormProps = {
const SITE_KEY_OPTIONS = [ const SITE_KEY_OPTIONS = [
{ value: "MH", label: "MassHealth" }, { value: "MH", label: "MassHealth" },
{ value: "DDMA", label: "Delta Dental MA" }, { value: "DDMA", label: "Delta Dental MA" },
{ value: "DELTAINS", label: "Delta Dental Ins" },
{ value: "DENTAQUEST", label: "Tufts SCO / DentaQuest" }, { value: "DENTAQUEST", label: "Tufts SCO / DentaQuest" },
{ value: "UNITEDSCO", label: "United SCO" },
{ value: "CCA", label: "CCA" },
]; ];
export function CredentialForm({ onClose, userId, defaultValues }: CredentialFormProps) { export function CredentialForm({ onClose, userId, defaultValues }: CredentialFormProps) {

View File

@@ -17,7 +17,10 @@ type Credential = {
const SITE_KEY_LABELS: Record<string, string> = { const SITE_KEY_LABELS: Record<string, string> = {
MH: "MassHealth", MH: "MassHealth",
DDMA: "Delta Dental MA", DDMA: "Delta Dental MA",
DELTAINS: "Delta Dental Ins",
DENTAQUEST: "Tufts SCO / DentaQuest", DENTAQUEST: "Tufts SCO / DentaQuest",
UNITEDSCO: "United SCO",
CCA: "CCA",
}; };
function getSiteKeyLabel(siteKey: string): string { function getSiteKeyLabel(siteKey: string): string {

View File

@@ -4,28 +4,32 @@ import { useAuth } from "@/hooks/use-auth";
import { Suspense } from "react"; import { Suspense } from "react";
import { Redirect, Route } from "wouter"; import { Redirect, Route } from "wouter";
type ComponentLike = React.ComponentType; // works for both lazy() and regular components type ComponentLike = React.ComponentType;
export function ProtectedRoute({ export function ProtectedRoute({
path, path,
component: Component, component: Component,
adminOnly,
}: { }: {
path: string; path: string;
component: ComponentLike; component: ComponentLike;
adminOnly?: boolean;
}) { }) {
const { user, isLoading } = useAuth(); const { user, isLoading } = useAuth();
return ( return (
<Route path={path}> <Route path={path}>
{/* While auth is resolving: keep layout visible and show a small spinner in the content area */}
{isLoading ? ( {isLoading ? (
<AppLayout> <AppLayout>
<LoadingScreen /> <LoadingScreen />
</AppLayout> </AppLayout>
) : !user ? ( ) : !user ? (
<Redirect to="/auth" /> <Redirect to="/auth" />
) : adminOnly &&
user.role?.toUpperCase() !== "ADMIN" &&
user.username?.toLowerCase() !== "admin" ? (
<Redirect to="/dashboard" />
) : ( ) : (
// Authenticated: render page inside layout. Lazy pages load with an in-layout spinner.
<AppLayout> <AppLayout>
<Suspense fallback={<LoadingScreen />}> <Suspense fallback={<LoadingScreen />}>
<Component /> <Component />

View File

@@ -1,6 +1,6 @@
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react"; import { useEffect } from "react";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -12,7 +12,6 @@ import {
FormMessage, FormMessage,
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { CheckCircle, Torus } from "lucide-react"; import { CheckCircle, Torus } from "lucide-react";
@@ -22,13 +21,10 @@ import { useLocation } from "wouter";
import { import {
LoginFormValues, LoginFormValues,
loginSchema, loginSchema,
RegisterFormValues,
registerSchema,
} from "@repo/db/types"; } from "@repo/db/types";
export default function AuthPage() { export default function AuthPage() {
const [activeTab, setActiveTab] = useState<string>("login"); const { isLoading, user, loginMutation } = useAuth();
const { isLoading, user, loginMutation, registerMutation } = useAuth();
const [, navigate] = useLocation(); const [, navigate] = useLocation();
const loginForm = useForm<LoginFormValues>({ const loginForm = useForm<LoginFormValues>({
@@ -40,37 +36,20 @@ export default function AuthPage() {
}, },
}); });
const registerForm = useForm<RegisterFormValues>({
resolver: zodResolver(registerSchema),
defaultValues: {
username: "",
password: "",
confirmPassword: "",
agreeTerms: false,
},
});
const onLoginSubmit = (data: LoginFormValues) => { const onLoginSubmit = (data: LoginFormValues) => {
loginMutation.mutate({ username: data.username, password: data.password }); loginMutation.mutate({ username: data.username, password: data.password });
}; };
const onRegisterSubmit = (data: RegisterFormValues) => {
registerMutation.mutate({
username: data.username,
password: data.password,
});
};
if (isLoading) {
return <LoadingScreen />;
}
useEffect(() => { useEffect(() => {
if (user) { if (user) {
navigate("/insurance-status"); navigate("/insurance-status");
} }
}, [user, navigate]); }, [user, navigate]);
if (isLoading) {
return <LoadingScreen />;
}
return ( return (
<div className="min-h-screen bg-gray-100 flex items-center justify-center"> <div className="min-h-screen bg-gray-100 flex items-center justify-center">
<div className="w-full max-w-4xl grid grid-cols-1 md:grid-cols-2 shadow-lg rounded-lg overflow-hidden"> <div className="w-full max-w-4xl grid grid-cols-1 md:grid-cols-2 shadow-lg rounded-lg overflow-hidden">
@@ -81,198 +60,78 @@ export default function AuthPage() {
My Dental Office Management My Dental Office Management
</h1> </h1>
<p className="text-gray-600"> <p className="text-gray-600">
{" "}
Comprehensive Practice Management System Comprehensive Practice Management System
</p> </p>
</div> </div>
<Tabs <Form {...loginForm}>
defaultValue="login" <form
value={activeTab} onSubmit={loginForm.handleSubmit(onLoginSubmit)}
onValueChange={setActiveTab} className="space-y-4"
> >
<TabsList className="grid w-full grid-cols-2 mb-6"> <FormField
<TabsTrigger value="login">Login</TabsTrigger> control={loginForm.control}
<TabsTrigger value="register">Register</TabsTrigger> name="username"
</TabsList> render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="Enter your username" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<TabsContent value="login"> <FormField
<Form {...loginForm}> control={loginForm.control}
<form name="password"
onSubmit={loginForm.handleSubmit(onLoginSubmit)} render={({ field }) => (
className="space-y-4" <FormItem>
> <FormLabel>Password</FormLabel>
<FormField <FormControl>
control={loginForm.control} <Input
name="username" placeholder="••••••••"
render={({ field }) => ( type="password"
<FormItem> {...field}
<FormLabel>Username</FormLabel> />
<FormControl> </FormControl>
<Input placeholder="Enter your username" {...field} /> <FormMessage />
</FormControl> </FormItem>
<FormMessage /> )}
</FormItem> />
)}
/>
<FormField <div className="flex items-center justify-between">
control={loginForm.control} <FormField
name="password" control={loginForm.control}
render={({ field }) => ( name="rememberMe"
<FormItem> render={({ field }) => (
<FormLabel>Password</FormLabel> <div className="flex items-center space-x-2">
<FormControl> <Checkbox
<Input id="remember-me"
placeholder="••••••••" checked={field.value as CheckedState}
type="password" onCheckedChange={field.onChange}
{...field} />
/> <label
</FormControl> htmlFor="remember-me"
<FormMessage /> className="text-sm font-medium text-gray-700"
</FormItem> >
)} Remember me
/> </label>
</div>
)}
/>
</div>
<div className="flex items-center justify-between"> <Button
<FormField type="submit"
control={loginForm.control} className="w-full"
name="rememberMe" disabled={loginMutation.isPending}
render={({ field }) => ( >
<div className="flex items-center space-x-2"> {loginMutation.isPending ? "Signing in..." : "Sign in"}
<Checkbox </Button>
id="remember-me" </form>
checked={field.value as CheckedState} </Form>
onCheckedChange={field.onChange}
/>
<label
htmlFor="remember-me"
className="text-sm font-medium text-gray-700"
>
Remember me
</label>
</div>
)}
/>
<button
type="button"
onClick={() => {
// do something if needed
}}
className="text-sm font-medium text-primary hover:text-primary/80"
>
Forgot password?
</button>
</div>
<Button
type="submit"
className="w-full"
disabled={loginMutation.isPending}
>
{loginMutation.isPending ? "Signing in..." : "Sign in"}
</Button>
</form>
</Form>
</TabsContent>
<TabsContent value="register">
<Form {...registerForm}>
<form
onSubmit={registerForm.handleSubmit(onRegisterSubmit)}
className="space-y-4"
>
<FormField
control={registerForm.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="Choose a username" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={registerForm.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
placeholder="••••••••"
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={registerForm.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<Input
placeholder="••••••••"
type="password"
{...field}
value={
typeof field.value === "string" ? field.value : ""
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={registerForm.control}
name="agreeTerms"
render={({ field }) => (
<FormItem className="flex space-x-2 items-center">
<FormControl>
<Checkbox
checked={field.value as CheckedState}
onCheckedChange={field.onChange}
className="mt-2.5"
/>
</FormControl>
<div className="">
<FormLabel className="text-sm font-bold leading-tight">
I agree to the{" "}
<a href="#" className="text-primary underline">
Terms and Conditions
</a>
</FormLabel>
<FormMessage />
</div>
</FormItem>
)}
/>
<Button
type="submit"
className="w-full"
disabled={registerMutation.isPending}
>
{registerMutation.isPending
? "Creating Account..."
: "Create Account"}
</Button>
</form>
</Form>
</TabsContent>
</Tabs>
</Card> </Card>
{/* Hero Section */} {/* Hero Section */}

View File

@@ -30,6 +30,8 @@ import { useLocation } from "wouter";
import { DdmaEligibilityButton } from "@/components/insurance-status/ddma-buton-modal"; import { DdmaEligibilityButton } from "@/components/insurance-status/ddma-buton-modal";
import { DentaQuestEligibilityButton } from "@/components/insurance-status/dentaquest-button-modal"; import { DentaQuestEligibilityButton } from "@/components/insurance-status/dentaquest-button-modal";
import { UnitedSCOEligibilityButton } from "@/components/insurance-status/unitedsco-button-modal"; import { UnitedSCOEligibilityButton } from "@/components/insurance-status/unitedsco-button-modal";
import { DeltaInsEligibilityButton } from "@/components/insurance-status/deltains-button-modal";
import { CCAEligibilityButton } from "@/components/insurance-status/cca-button-modal";
export default function InsuranceStatusPage() { export default function InsuranceStatusPage() {
const { user } = useAuth(); const { user } = useAuth();
@@ -577,7 +579,7 @@ export default function InsuranceStatusPage() {
{/* TEMP PROVIDER BUTTONS */} {/* TEMP PROVIDER BUTTONS */}
<div className="space-y-4 mt-6"> <div className="space-y-4 mt-6">
<h3 className="text-sm font-medium text-muted-foreground"> <h3 className="text-sm font-medium text-muted-foreground">
Other provider checks Other Insurances
</h3> </h3>
{/* Row 1 */} {/* Row 1 */}
@@ -597,14 +599,20 @@ export default function InsuranceStatusPage() {
}} }}
/> />
<Button <DeltaInsEligibilityButton
className="w-full" memberId={memberId}
variant="outline" dateOfBirth={dateOfBirth}
disabled={isFormIncomplete} firstName={firstName}
> lastName={lastName}
<CheckCircle className="h-4 w-4 mr-2" /> isFormIncomplete={isFormIncomplete}
Metlife Dental onPdfReady={(pdfId, fallbackFilename) => {
</Button> setPreviewPdfId(pdfId);
setPreviewFallbackFilename(
fallbackFilename ?? `eligibility_deltains_${memberId}.pdf`
);
setPreviewOpen(true);
}}
/>
<Button <Button
className="w-full" className="w-full"
@@ -612,7 +620,7 @@ export default function InsuranceStatusPage() {
disabled={isFormIncomplete} disabled={isFormIncomplete}
> >
<CheckCircle className="h-4 w-4 mr-2" /> <CheckCircle className="h-4 w-4 mr-2" />
CCA BCBS
</Button> </Button>
</div> </div>
@@ -648,6 +656,24 @@ export default function InsuranceStatusPage() {
}} }}
/> />
<CCAEligibilityButton
memberId={memberId}
dateOfBirth={dateOfBirth}
firstName={firstName}
lastName={lastName}
isFormIncomplete={isFormIncomplete}
onPdfReady={(pdfId, fallbackFilename) => {
setPreviewPdfId(pdfId);
setPreviewFallbackFilename(
fallbackFilename ?? `eligibility_cca_${memberId}.pdf`
);
setPreviewOpen(true);
}}
/>
</div>
{/* Row 3 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Button <Button
className="w-full" className="w-full"
variant="outline" variant="outline"
@@ -656,9 +682,25 @@ export default function InsuranceStatusPage() {
<CheckCircle className="h-4 w-4 mr-2" /> <CheckCircle className="h-4 w-4 mr-2" />
United AAPR United AAPR
</Button> </Button>
<Button
className="w-full"
variant="outline"
disabled={isFormIncomplete}
>
<CheckCircle className="h-4 w-4 mr-2" />
Metlife
</Button>
<Button
className="w-full"
variant="outline"
disabled={isFormIncomplete}
>
<CheckCircle className="h-4 w-4 mr-2" />
Cigna
</Button>
</div> </div>
{/* Row 3 */} {/* Row 4 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Button <Button
className="w-full" className="w-full"
@@ -676,7 +718,28 @@ export default function InsuranceStatusPage() {
<CheckCircle className="h-4 w-4 mr-2" /> <CheckCircle className="h-4 w-4 mr-2" />
Altus Altus
</Button> </Button>
<div /> {/* filler cell to keep grid shape */} <Button
className="w-full"
variant="outline"
disabled={isFormIncomplete}
>
<CheckCircle className="h-4 w-4 mr-2" />
Delta WA
</Button>
</div>
{/* Row 5 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Button
className="w-full"
variant="outline"
disabled={isFormIncomplete}
>
<CheckCircle className="h-4 w-4 mr-2" />
Delta IL
</Button>
<div />
<div />
</div> </div>
</div> </div>
</CardContent> </CardContent>

View File

@@ -10,19 +10,18 @@ import { CredentialTable } from "@/components/settings/insuranceCredTable";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { Staff } from "@repo/db/types"; import { Staff } from "@repo/db/types";
type SafeUser = { id: number; username: string; role: "ADMIN" | "USER" };
export default function SettingsPage() { export default function SettingsPage() {
const { toast } = useToast(); const { toast } = useToast();
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
// Modal and editing staff state
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [credentialModalOpen, setCredentialModalOpen] = useState(false); const [credentialModalOpen, setCredentialModalOpen] = useState(false);
const [editingStaff, setEditingStaff] = useState<Staff | null>(null); const [editingStaff, setEditingStaff] = useState<Staff | null>(null);
const toggleMobileMenu = () => setIsMobileMenuOpen((prev) => !prev); const toggleMobileMenu = () => setIsMobileMenuOpen((prev) => !prev);
// Fetch staff data
const { const {
data: staff = [], data: staff = [],
isLoading, isLoading,
@@ -37,14 +36,13 @@ export default function SettingsPage() {
} }
return res.json(); return res.json();
}, },
staleTime: 1000 * 60 * 5, // 5 minutes cache staleTime: 1000 * 60 * 5,
}); });
// Add Staff mutation
const addStaffMutate = useMutation< const addStaffMutate = useMutation<
Staff, // Return type Staff,
Error, // Error type Error,
Omit<Staff, "id" | "createdAt"> // Variables Omit<Staff, "id" | "createdAt">
>({ >({
mutationFn: async (newStaff: Omit<Staff, "id" | "createdAt">) => { mutationFn: async (newStaff: Omit<Staff, "id" | "createdAt">) => {
const res = await apiRequest("POST", "/api/staffs/", newStaff); const res = await apiRequest("POST", "/api/staffs/", newStaff);
@@ -71,7 +69,6 @@ export default function SettingsPage() {
}, },
}); });
// Update Staff mutation
const updateStaffMutate = useMutation< const updateStaffMutate = useMutation<
Staff, Staff,
Error, Error,
@@ -108,7 +105,6 @@ export default function SettingsPage() {
}, },
}); });
// Delete Staff mutation
const deleteStaffMutation = useMutation<number, Error, number>({ const deleteStaffMutation = useMutation<number, Error, number>({
mutationFn: async (id: number) => { mutationFn: async (id: number) => {
const res = await apiRequest("DELETE", `/api/staffs/${id}`); const res = await apiRequest("DELETE", `/api/staffs/${id}`);
@@ -136,30 +132,24 @@ export default function SettingsPage() {
}, },
}); });
// Extract mutation states for modal control and loading
const isAdding = addStaffMutate.status === "pending"; const isAdding = addStaffMutate.status === "pending";
const isAddSuccess = addStaffMutate.status === "success"; const isAddSuccess = addStaffMutate.status === "success";
const isUpdating = updateStaffMutate.status === "pending"; const isUpdating = updateStaffMutate.status === "pending";
const isUpdateSuccess = updateStaffMutate.status === "success"; const isUpdateSuccess = updateStaffMutate.status === "success";
// Open Add modal
const openAddStaffModal = () => { const openAddStaffModal = () => {
setEditingStaff(null); setEditingStaff(null);
setModalOpen(true); setModalOpen(true);
}; };
// Open Edit modal
const openEditStaffModal = (staff: Staff) => { const openEditStaffModal = (staff: Staff) => {
setEditingStaff(staff); setEditingStaff(staff);
setModalOpen(true); setModalOpen(true);
}; };
// Handle form submit for Add or Edit
const handleFormSubmit = (formData: Omit<Staff, "id" | "createdAt">) => { const handleFormSubmit = (formData: Omit<Staff, "id" | "createdAt">) => {
if (editingStaff) { if (editingStaff) {
// Editing existing staff
if (editingStaff.id === undefined) { if (editingStaff.id === undefined) {
toast({ toast({
title: "Error", title: "Error",
@@ -181,7 +171,6 @@ export default function SettingsPage() {
setModalOpen(false); setModalOpen(false);
}; };
// Close modal on successful add/update
useEffect(() => { useEffect(() => {
if (isAddSuccess || isUpdateSuccess) { if (isAddSuccess || isUpdateSuccess) {
setModalOpen(false); setModalOpen(false);
@@ -215,10 +204,86 @@ export default function SettingsPage() {
`Viewing staff member:\n${staff.name} (${staff.email || "No email"})` `Viewing staff member:\n${staff.name} (${staff.email || "No email"})`
); );
// MANAGE USER // --- Users control (list, add, edit password, delete) ---
const {
data: usersList = [],
isLoading: usersLoading,
isError: usersError,
error: usersErrorObj,
} = useQuery<SafeUser[]>({
queryKey: ["/api/users/list"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/users/list");
if (!res.ok) throw new Error("Failed to fetch users");
return res.json();
},
staleTime: 1000 * 60 * 2,
});
const addUserMutate = useMutation<SafeUser, Error, { username: string; password: string; role?: "ADMIN" | "USER" }>({
mutationFn: async (data) => {
const res = await apiRequest("POST", "/api/users/", data);
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.error || "Failed to add user");
}
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/users/list"] });
setAddUserModalOpen(false);
toast({ title: "User Added", description: "User created successfully.", variant: "default" });
},
onError: (e: any) => {
toast({ title: "Error", description: e?.message || "Failed to add user", variant: "destructive" });
},
});
const updateUserPasswordMutate = useMutation<SafeUser, Error, { id: number; password: string }>({
mutationFn: async ({ id, password }) => {
const res = await apiRequest("PUT", `/api/users/${id}`, { password });
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.error || "Failed to update password");
}
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/users/list"] });
setEditPasswordUser(null);
toast({ title: "Password Updated", description: "Password changed successfully.", variant: "default" });
},
onError: (e: any) => {
toast({ title: "Error", description: e?.message || "Failed to update password", variant: "destructive" });
},
});
const deleteUserMutate = useMutation<number, Error, number>({
mutationFn: async (id) => {
const res = await apiRequest("DELETE", `/api/users/${id}`);
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.error || "Failed to delete user");
}
return id;
},
onSuccess: () => {
setUserToDelete(null);
queryClient.invalidateQueries({ queryKey: ["/api/users/list"] });
toast({ title: "User Removed", description: "User deleted.", variant: "default" });
},
onError: (e: any) => {
toast({ title: "Error", description: e?.message || "Failed to delete user", variant: "destructive" });
},
});
const [addUserModalOpen, setAddUserModalOpen] = useState(false);
const [editPasswordUser, setEditPasswordUser] = useState<SafeUser | null>(null);
const [userToDelete, setUserToDelete] = useState<SafeUser | null>(null);
// MANAGE USER (current user profile)
const [usernameUser, setUsernameUser] = useState(""); const [usernameUser, setUsernameUser] = useState("");
//fetch user
const { user } = useAuth(); const { user } = useAuth();
useEffect(() => { useEffect(() => {
if (user?.username) { if (user?.username) {
@@ -226,7 +291,6 @@ export default function SettingsPage() {
} }
}, [user]); }, [user]);
//update user mutation
const updateUserMutate = useMutation({ const updateUserMutate = useMutation({
mutationFn: async ( mutationFn: async (
updates: Partial<{ username: string; password: string }> updates: Partial<{ username: string; password: string }>
@@ -303,10 +367,73 @@ export default function SettingsPage() {
</div> </div>
)} )}
{/* User Setting section */} {/* Users control section */}
<Card className="mt-6">
<CardContent className="py-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold">User Accounts</h3>
<button
type="button"
onClick={() => setAddUserModalOpen(true)}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Add User
</button>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Username</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Role</th>
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{usersLoading && (
<tr><td colSpan={3} className="px-4 py-4 text-gray-500">Loading users...</td></tr>
)}
{usersError && (
<tr><td colSpan={3} className="px-4 py-4 text-red-600">{(usersErrorObj as Error)?.message}</td></tr>
)}
{!usersLoading && !usersError && usersList.filter((u) => u.id !== user?.id).length === 0 && (
<tr><td colSpan={3} className="px-4 py-4 text-gray-500">No other users.</td></tr>
)}
{!usersLoading && usersList.filter((u) => u.id !== user?.id).map((u) => (
<tr key={u.id}>
<td className="px-4 py-2">
<span>{u.username}</span>
</td>
<td className="px-4 py-2">{u.role}</td>
<td className="px-4 py-2 text-right space-x-2">
<button
type="button"
onClick={() => setEditPasswordUser(u)}
className="text-blue-600 hover:underline"
>
Edit password
</button>
<button
type="button"
onClick={() => setUserToDelete(u)}
className="text-red-600 hover:underline"
title="Delete user"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
{/* User Setting section (current user profile) */}
<Card className="mt-6"> <Card className="mt-6">
<CardContent className="space-y-4 py-6"> <CardContent className="space-y-4 py-6">
<h3 className="text-lg font-semibold">User Settings</h3> <h3 className="text-lg font-semibold">Admin Setting</h3>
<form <form
className="space-y-4" className="space-y-4"
onSubmit={(e) => { onSubmit={(e) => {
@@ -358,6 +485,96 @@ export default function SettingsPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Add User modal */}
{addUserModalOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md shadow-lg">
<h2 className="text-lg font-bold mb-4">Add User</h2>
<form
onSubmit={(e) => {
e.preventDefault();
const form = e.currentTarget;
const username = (form.querySelector('[name="new-username"]') as HTMLInputElement)?.value?.trim();
const password = (form.querySelector('[name="new-password"]') as HTMLInputElement)?.value;
const role = (form.querySelector('[name="new-role"]') as HTMLSelectElement)?.value as "ADMIN" | "USER";
if (!username || !password) {
toast({ title: "Error", description: "Username and password are required.", variant: "destructive" });
return;
}
addUserMutate.mutate({ username, password, role: role || "USER" });
}}
className="space-y-4"
>
<div>
<label className="block text-sm font-medium">Username</label>
<input name="new-username" type="text" required className="mt-1 p-2 border rounded w-full" />
</div>
<div>
<label className="block text-sm font-medium">Password</label>
<input name="new-password" type="password" required className="mt-1 p-2 border rounded w-full" placeholder="••••••••" />
</div>
<div>
<label className="block text-sm font-medium">Role</label>
<select name="new-role" className="mt-1 p-2 border rounded w-full" defaultValue="USER">
<option value="USER">User</option>
<option value="ADMIN">Admin</option>
</select>
</div>
<div className="flex gap-2 justify-end">
<button type="button" onClick={() => setAddUserModalOpen(false)} className="px-4 py-2 border rounded hover:bg-gray-100">
Cancel
</button>
<button type="submit" disabled={addUserMutate.isPending} className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50">
{addUserMutate.isPending ? "Adding..." : "Add User"}
</button>
</div>
</form>
</div>
</div>
)}
{/* Edit password modal */}
{editPasswordUser && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md shadow-lg">
<h2 className="text-lg font-bold mb-4">Change password for {editPasswordUser.username}</h2>
<form
onSubmit={(e) => {
e.preventDefault();
const form = e.currentTarget;
const password = (form.querySelector('[name="edit-password"]') as HTMLInputElement)?.value;
if (!password?.trim()) {
toast({ title: "Error", description: "Password is required.", variant: "destructive" });
return;
}
updateUserPasswordMutate.mutate({ id: editPasswordUser.id, password });
}}
className="space-y-4"
>
<div>
<label className="block text-sm font-medium">New password</label>
<input name="edit-password" type="password" required className="mt-1 p-2 border rounded w-full" placeholder="••••••••" />
</div>
<div className="flex gap-2 justify-end">
<button type="button" onClick={() => setEditPasswordUser(null)} className="px-4 py-2 border rounded hover:bg-gray-100">
Cancel
</button>
<button type="submit" disabled={updateUserPasswordMutate.isPending} className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50">
{updateUserPasswordMutate.isPending ? "Saving..." : "Save"}
</button>
</div>
</form>
</div>
</div>
)}
<DeleteConfirmationDialog
isOpen={!!userToDelete}
onConfirm={() => userToDelete && deleteUserMutate.mutate(userToDelete.id)}
onCancel={() => setUserToDelete(null)}
entityName={userToDelete?.username}
/>
{/* Credential Section */} {/* Credential Section */}
<div className="mt-6"> <div className="mt-6">
<CredentialTable /> <CredentialTable />

View File

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

View File

@@ -11,11 +11,15 @@ import time
import helpers_ddma_eligibility as hddma import helpers_ddma_eligibility as hddma
import helpers_dentaquest_eligibility as hdentaquest import helpers_dentaquest_eligibility as hdentaquest
import helpers_unitedsco_eligibility as hunitedsco import helpers_unitedsco_eligibility as hunitedsco
import helpers_deltains_eligibility as hdeltains
import helpers_cca_eligibility as hcca
# Import session clear functions for startup # Import session clear functions for startup
from ddma_browser_manager import clear_ddma_session_on_startup from ddma_browser_manager import clear_ddma_session_on_startup
from dentaquest_browser_manager import clear_dentaquest_session_on_startup from dentaquest_browser_manager import clear_dentaquest_session_on_startup
from unitedsco_browser_manager import clear_unitedsco_session_on_startup from unitedsco_browser_manager import clear_unitedsco_session_on_startup
from deltains_browser_manager import clear_deltains_session_on_startup
from cca_browser_manager import clear_cca_session_on_startup
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
@@ -28,6 +32,8 @@ print("=" * 50)
clear_ddma_session_on_startup() clear_ddma_session_on_startup()
clear_dentaquest_session_on_startup() clear_dentaquest_session_on_startup()
clear_unitedsco_session_on_startup() clear_unitedsco_session_on_startup()
clear_deltains_session_on_startup()
clear_cca_session_on_startup()
print("=" * 50) print("=" * 50)
print("SESSION CLEAR COMPLETE - FRESH LOGINS REQUIRED") print("SESSION CLEAR COMPLETE - FRESH LOGINS REQUIRED")
print("=" * 50) print("=" * 50)
@@ -351,6 +357,119 @@ async def unitedsco_session_status(sid: str):
return s 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
# Endpoint:9 - CCA eligibility (background, no OTP)
async def _cca_worker_wrapper(sid: str, data: dict, url: str):
global active_jobs, waiting_jobs
async with semaphore:
async with lock:
waiting_jobs -= 1
active_jobs += 1
try:
await hcca.start_cca_run(sid, data, url)
finally:
async with lock:
active_jobs -= 1
@app.post("/cca-eligibility")
async def cca_eligibility(request: Request):
global waiting_jobs
body = await request.json()
data = body.get("data", {})
sid = hcca.make_session_entry()
hcca.sessions[sid]["type"] = "cca_eligibility"
hcca.sessions[sid]["last_activity"] = time.time()
async with lock:
waiting_jobs += 1
asyncio.create_task(_cca_worker_wrapper(sid, data, url="https://pwp.sciondental.com/PWP/Landing"))
return {"status": "started", "session_id": sid}
@app.get("/cca-session/{sid}/status")
async def cca_session_status(sid: str):
s = hcca.get_session_status(sid)
if s.get("status") == "not_found":
raise HTTPException(status_code=404, detail="session not found")
return s
@app.post("/submit-otp") @app.post("/submit-otp")
async def submit_otp(request: Request): async def submit_otp(request: Request):
""" """
@@ -425,6 +544,27 @@ async def clear_unitedsco_session():
return {"status": "error", "message": str(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)}
@app.post("/clear-cca-session")
async def clear_cca_session_endpoint():
try:
clear_cca_session_on_startup()
return {"status": "success", "message": "CCA session cleared"}
except Exception as e:
return {"status": "error", "message": str(e)}
if __name__ == "__main__": if __name__ == "__main__":
host = os.getenv("HOST") host = os.getenv("HOST")
port = int(os.getenv("PORT")) port = int(os.getenv("PORT"))

View File

@@ -0,0 +1,292 @@
"""
Browser manager for CCA (Commonwealth Care Alliance) via ScionDental portal.
Handles persistent Chrome profile, cookie save/restore, and credential tracking.
No OTP required for this provider.
"""
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"
class CCABrowserManager:
_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_cca")
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
def save_cookies(self):
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"[CCA BrowserManager] Saved {len(cookies)} cookies to disk")
except Exception as e:
print(f"[CCA BrowserManager] Failed to save cookies: {e}")
def restore_cookies(self):
if not os.path.exists(self._cookies_file):
print("[CCA 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("[CCA BrowserManager] Saved cookies file is empty")
return False
try:
self._driver.get("https://pwp.sciondental.com/favicon.ico")
time.sleep(2)
except Exception:
self._driver.get("https://pwp.sciondental.com")
time.sleep(3)
restored = 0
for cookie in cookies:
try:
for key in ["sameSite", "storeId", "hostOnly", "session"]:
cookie.pop(key, None)
cookie["sameSite"] = "None"
self._driver.add_cookie(cookie)
restored += 1
except Exception:
pass
print(f"[CCA BrowserManager] Restored {restored}/{len(cookies)} cookies")
return restored > 0
except Exception as e:
print(f"[CCA BrowserManager] Failed to restore cookies: {e}")
return False
def clear_saved_cookies(self):
try:
if os.path.exists(self._cookies_file):
os.remove(self._cookies_file)
print("[CCA BrowserManager] Cleared saved cookies file")
except Exception as e:
print(f"[CCA BrowserManager] Failed to clear saved cookies: {e}")
def clear_session_on_startup(self):
print("[CCA BrowserManager] Clearing session on startup...")
try:
if os.path.exists(self._credentials_file):
os.remove(self._credentials_file)
self.clear_saved_cookies()
session_files = [
"Cookies", "Cookies-journal",
"Login Data", "Login Data-journal",
"Web Data", "Web Data-journal",
]
for filename in session_files:
for base in [os.path.join(self.profile_dir, "Default"), self.profile_dir]:
filepath = os.path.join(base, filename)
if os.path.exists(filepath):
try:
os.remove(filepath)
except Exception:
pass
for dirname in ["Session Storage", "Local Storage", "IndexedDB"]:
dirpath = os.path.join(self.profile_dir, "Default", dirname)
if os.path.exists(dirpath):
try:
shutil.rmtree(dirpath)
except Exception:
pass
for cache_name in ["Cache", "Code Cache", "GPUCache", "Service Worker", "ShaderCache"]:
for base in [os.path.join(self.profile_dir, "Default"), self.profile_dir]:
cache_dir = os.path.join(base, cache_name)
if os.path.exists(cache_dir):
try:
shutil.rmtree(cache_dir)
except Exception:
pass
self._needs_session_clear = True
print("[CCA BrowserManager] Session cleared - will require fresh login")
except Exception as e:
print(f"[CCA BrowserManager] Error clearing session: {e}")
def _hash_credentials(self, username: str) -> str:
return hashlib.sha256(username.encode()).hexdigest()[:16]
def get_last_credentials_hash(self):
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"[CCA 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("[CCA 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:
pass
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():
for pid in result.stdout.strip().split('\n'):
try:
subprocess.run(["kill", "-9", pid], check=False)
except Exception:
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 Exception:
pass
def get_driver(self, headless=False):
with self._lock:
need_cookie_restore = False
if self._driver is None:
print("[CCA 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("[CCA BrowserManager] Driver not alive, recreating")
self._kill_existing_chrome_for_profile()
self._create_driver(headless)
need_cookie_restore = True
else:
print("[CCA BrowserManager] Reusing existing driver")
if need_cookie_restore and os.path.exists(self._cookies_file):
print("[CCA 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 Exception:
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 Exception:
pass
self._driver = None
self._kill_existing_chrome_for_profile()
_manager = None
def get_browser_manager():
global _manager
if _manager is None:
_manager = CCABrowserManager()
return _manager
def clear_cca_session_on_startup():
manager = get_browser_manager()
manager.clear_session_on_startup()

View File

@@ -111,6 +111,26 @@ class DDMABrowserManager:
except Exception as e: except Exception as e:
print(f"[DDMA BrowserManager] Could not clear IndexedDB: {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 # Set flag to clear session via JavaScript after browser opens
self._needs_session_clear = True self._needs_session_clear = True
@@ -235,6 +255,12 @@ class DDMABrowserManager:
options.add_argument("--no-sandbox") options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage") options.add_argument("--disable-dev-shm-usage")
# Anti-detection options (prevent bot detection)
options.add_argument("--disable-blink-features=AutomationControlled")
options.add_experimental_option("excludeSwitches", ["enable-automation"])
options.add_experimental_option("useAutomationExtension", False)
options.add_argument("--disable-infobars")
prefs = { prefs = {
"download.default_directory": self.download_dir, "download.default_directory": self.download_dir,
"plugins.always_open_pdf_externally": True, "plugins.always_open_pdf_externally": True,
@@ -247,6 +273,12 @@ class DDMABrowserManager:
self._driver = webdriver.Chrome(service=service, options=options) self._driver = webdriver.Chrome(service=service, options=options)
self._driver.maximize_window() self._driver.maximize_window()
# Remove webdriver property to avoid detection
try:
self._driver.execute_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})")
except Exception:
pass
# Reset the session clear flag (file-based clearing is done on startup) # Reset the session clear flag (file-based clearing is done on startup)
self._needs_session_clear = False self._needs_session_clear = False

View File

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

View File

@@ -0,0 +1,180 @@
import os
import time
import asyncio
from typing import Dict, Any
from selenium.common.exceptions import WebDriverException
from selenium_CCA_eligibilityCheckWorker import AutomationCCAEligibilityCheck
from cca_browser_manager import get_browser_manager
sessions: Dict[str, Dict[str, Any]] = {}
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,
"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:
if s.get("status") not in ("completed", "error", "not_found"):
s["status"] = "error"
if message:
s["message"] = message
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):
try:
bm = get_browser_manager()
try:
bm.save_cookies()
except Exception:
pass
try:
bm.quit_driver()
print("[CCA] Browser closed")
except Exception:
pass
except Exception as e:
print(f"[CCA] Could not close browser: {e}")
async def start_cca_run(sid: str, data: dict, url: str):
"""
Run the CCA eligibility check workflow (no OTP):
1. Login
2. Search patient by Subscriber 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 = AutomationCCAEligibilityCheck({"data": data})
bot.config_driver()
s["bot"] = bot
s["driver"] = bot.driver
s["last_activity"] = time.time()
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"]}
if isinstance(login_result, str) and login_result == "ALREADY_LOGGED_IN":
s["status"] = "running"
s["message"] = "Session persisted"
print("[CCA] Session persisted - skipping login")
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("[CCA] Login succeeded")
s["status"] = "running"
s["message"] = "Login succeeded"
get_browser_manager().save_cookies()
# Step 1 - search patient and verify eligibility
step1_result = bot.step1()
print(f"[CCA] 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"[CCA] 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 get_session_status(sid: str) -> Dict[str, Any]:
s = sessions.get(sid)
if not s:
return {"status": "not_found"}
return {
"session_id": sid,
"status": s.get("status"),
"message": s.get("message"),
"created_at": s.get("created_at"),
"last_activity": s.get("last_activity"),
"result": s.get("result") if s.get("status") in ("completed", "error") else None,
}

View File

@@ -147,6 +147,28 @@ async def start_ddma_run(sid: str, data: dict, url: str):
s["last_activity"] = time.time() s["last_activity"] = time.time()
try: 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 # Check current URL - if we're on member search page, login succeeded
current_url = driver.current_url.lower() current_url = driver.current_url.lower()
print(f"[OTP Poll {poll+1}/{max_polls}] URL: {current_url[:60]}...") print(f"[OTP Poll {poll+1}/{max_polls}] URL: {current_url[:60]}...")

View File

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

View File

@@ -146,6 +146,36 @@ async def start_dentaquest_run(sid: str, data: dict, url: str):
s["last_activity"] = time.time() s["last_activity"] = time.time()
try: 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 # Check current URL - if we're on dashboard/member page, login succeeded
current_url = driver.current_url.lower() current_url = driver.current_url.lower()
print(f"[DentaQuest OTP Poll {poll+1}/{max_polls}] URL: {current_url[:60]}...") print(f"[DentaQuest OTP Poll {poll+1}/{max_polls}] URL: {current_url[:60]}...")

View File

@@ -73,6 +73,32 @@ async def _remove_session_later(sid: str, delay: int = 20):
await cleanup_session(sid) 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): async def start_unitedsco_run(sid: str, data: dict, url: str):
""" """
Run the United SCO workflow for a session (WITHOUT managing semaphore/counters). Run the United SCO workflow for a session (WITHOUT managing semaphore/counters).
@@ -266,7 +292,11 @@ async def start_unitedsco_run(sid: str, data: dict, url: str):
if isinstance(step1_result, str) and step1_result.startswith("ERROR"): if isinstance(step1_result, str) and step1_result.startswith("ERROR"):
s["status"] = "error" s["status"] = "error"
s["message"] = step1_result s["message"] = step1_result
await cleanup_session(sid) 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} return {"status": "error", "message": step1_result}
# Step 2 (PDF) # Step 2 (PDF)
@@ -283,13 +313,24 @@ async def start_unitedsco_run(sid: str, data: dict, url: str):
s["message"] = step2_result.get("message", "unknown error") s["message"] = step2_result.get("message", "unknown error")
else: else:
s["message"] = str(step2_result) s["message"] = str(step2_result)
await cleanup_session(sid) 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"]} return {"status": "error", "message": s["message"]}
except Exception as e: except Exception as e:
s["status"] = "error" s["status"] = "error"
s["message"] = f"worker exception: {e}" s["message"] = f"worker exception: {e}"
await cleanup_session(sid) # 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"]} return {"status": "error", "message": s["message"]}
@@ -319,5 +360,5 @@ def get_session_status(sid: str) -> Dict[str, Any]:
"message": s.get("message"), "message": s.get("message"),
"created_at": s.get("created_at"), "created_at": s.get("created_at"),
"last_activity": s.get("last_activity"), "last_activity": s.get("last_activity"),
"result": s.get("result") if s.get("status") == "completed" else None, "result": s.get("result") if s.get("status") in ("completed", "error") else None,
} }

View File

@@ -0,0 +1,723 @@
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 datetime import datetime
from cca_browser_manager import get_browser_manager
LOGIN_URL = "https://pwp.sciondental.com/PWP/Landing"
LANDING_URL = "https://pwp.sciondental.com/PWP/Landing"
class AutomationCCAEligibilityCheck:
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.cca_username = self.data.get("cca_username", "")
self.cca_password = self.data.get("cca_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 _close_browser(self):
browser_manager = get_browser_manager()
try:
browser_manager.save_cookies()
except Exception as e:
print(f"[CCA] Failed to save cookies before close: {e}")
try:
browser_manager.quit_driver()
print("[CCA] Browser closed")
except Exception as e:
print(f"[CCA] Could not close browser: {e}")
def _force_logout(self):
try:
print("[CCA login] Forcing logout due to credential change...")
browser_manager = get_browser_manager()
try:
self.driver.delete_all_cookies()
except Exception:
pass
browser_manager.clear_credentials_hash()
print("[CCA login] Logout complete")
return True
except Exception as e:
print(f"[CCA login] Error during forced logout: {e}")
return False
def _page_has_logged_in_content(self):
"""Quick check if the current page shows logged-in portal content."""
try:
body_text = self.driver.find_element(By.TAG_NAME, "body").text
return ("Verify Patient Eligibility" in body_text
or "Patient Management" in body_text
or "Submit a Claim" in body_text
or "Claim Inquiry" in body_text)
except Exception:
return False
def login(self, url):
"""
Login to ScionDental portal for CCA.
No OTP required - simple username/password login.
Returns: ALREADY_LOGGED_IN, SUCCESS, or ERROR:...
"""
browser_manager = get_browser_manager()
try:
if self.cca_username and browser_manager.credentials_changed(self.cca_username):
self._force_logout()
self.driver.get(url)
time.sleep(2)
# Check current page state first (no navigation needed)
try:
current_url = self.driver.current_url
print(f"[CCA login] Current URL: {current_url}")
if ("sciondental.com" in current_url
and "login" not in current_url.lower()
and self._page_has_logged_in_content()):
print("[CCA login] Already logged in")
return "ALREADY_LOGGED_IN"
except Exception as e:
print(f"[CCA login] Error checking current state: {e}")
# Navigate to landing page to check session
print("[CCA login] Checking session at landing page...")
self.driver.get(LANDING_URL)
try:
WebDriverWait(self.driver, 10).until(
lambda d: "sciondental.com" in d.current_url
)
except TimeoutException:
pass
time.sleep(2)
current_url = self.driver.current_url
print(f"[CCA login] After landing nav URL: {current_url}")
if self._page_has_logged_in_content():
print("[CCA login] Session still valid")
return "ALREADY_LOGGED_IN"
# Session expired — navigate to login URL
print("[CCA login] Session not valid, navigating to login page...")
self.driver.get(url)
time.sleep(2)
current_url = self.driver.current_url
print(f"[CCA login] After login nav URL: {current_url}")
# Enter username
print("[CCA login] Looking for username field...")
username_entered = False
for sel in [
(By.ID, "Username"),
(By.NAME, "Username"),
(By.XPATH, "//input[@type='text']"),
]:
try:
field = WebDriverWait(self.driver, 6).until(
EC.presence_of_element_located(sel))
if field.is_displayed():
field.clear()
field.send_keys(self.cca_username)
username_entered = True
print(f"[CCA login] Username entered via {sel}")
break
except Exception:
continue
if not username_entered:
if self._page_has_logged_in_content():
return "ALREADY_LOGGED_IN"
return "ERROR: Could not find username field"
# Enter password
print("[CCA login] Looking for password field...")
pw_entered = False
for sel in [
(By.ID, "Password"),
(By.NAME, "Password"),
(By.XPATH, "//input[@type='password']"),
]:
try:
field = self.driver.find_element(*sel)
if field.is_displayed():
field.clear()
field.send_keys(self.cca_password)
pw_entered = True
print(f"[CCA login] Password entered via {sel}")
break
except Exception:
continue
if not pw_entered:
return "ERROR: Password field not found"
# Click login button
for sel in [
(By.XPATH, "//button[@type='submit']"),
(By.XPATH, "//input[@type='submit']"),
(By.XPATH, "//button[contains(text(),'Sign In') or contains(text(),'Log In') or contains(text(),'Login')]"),
(By.XPATH, "//input[@value='Sign In' or @value='Log In' or @value='Login']"),
]:
try:
btn = self.driver.find_element(*sel)
if btn.is_displayed():
btn.click()
print(f"[CCA login] Clicked login button via {sel}")
break
except Exception:
continue
if self.cca_username:
browser_manager.save_credentials_hash(self.cca_username)
# Wait for page to load after login
try:
WebDriverWait(self.driver, 15).until(
lambda d: "Landing" in d.current_url
or "Dental" in d.current_url
or "Home" in d.current_url
)
print("[CCA login] Redirected to portal page")
except TimeoutException:
time.sleep(3)
current_url = self.driver.current_url
print(f"[CCA login] After login submit URL: {current_url}")
# Check for login errors
try:
body_text = self.driver.find_element(By.TAG_NAME, "body").text
if "invalid" in body_text.lower() and ("password" in body_text.lower() or "username" in body_text.lower()):
return "ERROR: Invalid username or password"
except Exception:
pass
if self._page_has_logged_in_content():
print("[CCA login] Login successful")
return "SUCCESS"
if "Landing" in current_url or "Home" in current_url or "Dental" in current_url:
return "SUCCESS"
# Check for errors
try:
errors = self.driver.find_elements(By.XPATH,
"//*[contains(@class,'error') or contains(@class,'alert-danger') or contains(@class,'validation-summary')]")
for err in errors:
if err.is_displayed() and err.text.strip():
return f"ERROR: {err.text.strip()[:200]}"
except Exception:
pass
print("[CCA login] Login completed (assuming success)")
return "SUCCESS"
except Exception as e:
print(f"[CCA login] Exception: {e}")
return f"ERROR:LOGIN FAILED: {e}"
def _format_dob(self, dob_str):
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 step1(self):
"""
Enter patient info and click Verify Eligibility.
"""
try:
formatted_dob = self._format_dob(self.dateOfBirth)
today_str = datetime.now().strftime("%m/%d/%Y")
print(f"[CCA step1] Starting — memberId={self.memberId}, DOB={formatted_dob}, DateOfService={today_str}")
# Always navigate fresh to Landing to reset page state
print("[CCA step1] Navigating to eligibility page...")
self.driver.get(LANDING_URL)
# Wait for the page to fully load with the eligibility form
try:
WebDriverWait(self.driver, 15).until(
lambda d: "Verify Patient Eligibility" in d.find_element(By.TAG_NAME, "body").text
)
print("[CCA step1] Eligibility form loaded")
except TimeoutException:
print("[CCA step1] Eligibility form not found after 15s, checking page...")
body_text = self.driver.find_element(By.TAG_NAME, "body").text
print(f"[CCA step1] Page text (first 300): {body_text[:300]}")
time.sleep(1)
# Select "Subscriber ID and date of birth" radio
print("[CCA step1] Selecting 'Subscriber ID and date of birth' option...")
for sel in [
(By.XPATH, "//input[@type='radio' and contains(@id,'SubscriberId')]"),
(By.XPATH, "//input[@type='radio'][following-sibling::*[contains(text(),'Subscriber ID')]]"),
(By.XPATH, "//label[contains(text(),'Subscriber ID')]//input[@type='radio']"),
(By.XPATH, "(//input[@type='radio'])[1]"),
]:
try:
radio = self.driver.find_element(*sel)
if radio.is_displayed():
if not radio.is_selected():
radio.click()
print(f"[CCA step1] Selected radio via {sel}")
else:
print("[CCA step1] Radio already selected")
break
except Exception:
continue
# Enter Subscriber ID
print(f"[CCA step1] Entering Subscriber ID: {self.memberId}")
sub_id_entered = False
for sel in [
(By.ID, "SubscriberId"),
(By.NAME, "SubscriberId"),
(By.XPATH, "//input[contains(@id,'SubscriberId')]"),
(By.XPATH, "//label[contains(text(),'Subscriber ID')]/following::input[1]"),
]:
try:
field = self.driver.find_element(*sel)
if field.is_displayed():
field.click()
field.send_keys(Keys.CONTROL + "a")
field.send_keys(Keys.DELETE)
field.send_keys(self.memberId)
time.sleep(0.3)
print(f"[CCA step1] Subscriber ID entered: '{field.get_attribute('value')}'")
sub_id_entered = True
break
except Exception:
continue
if not sub_id_entered:
return "ERROR: Subscriber ID field not found"
# Enter Date of Birth
print(f"[CCA step1] Entering DOB: {formatted_dob}")
dob_entered = False
for sel in [
(By.ID, "DateOfBirth"),
(By.NAME, "DateOfBirth"),
(By.XPATH, "//input[contains(@id,'DateOfBirth') or contains(@id,'dob')]"),
(By.XPATH, "//label[contains(text(),'Date of Birth')]/following::input[1]"),
]:
try:
field = self.driver.find_element(*sel)
if field.is_displayed():
field.click()
field.send_keys(Keys.CONTROL + "a")
field.send_keys(Keys.DELETE)
field.send_keys(formatted_dob)
time.sleep(0.3)
print(f"[CCA step1] DOB entered: '{field.get_attribute('value')}'")
dob_entered = True
break
except Exception:
continue
if not dob_entered:
return "ERROR: Date of Birth field not found"
# Set Date of Service to today
print(f"[CCA step1] Setting Date of Service: {today_str}")
for sel in [
(By.ID, "DateOfService"),
(By.NAME, "DateOfService"),
(By.XPATH, "//input[contains(@id,'DateOfService')]"),
(By.XPATH, "//label[contains(text(),'Date of Service')]/following::input[1]"),
]:
try:
field = self.driver.find_element(*sel)
if field.is_displayed():
field.click()
field.send_keys(Keys.CONTROL + "a")
field.send_keys(Keys.DELETE)
field.send_keys(today_str)
time.sleep(0.3)
print(f"[CCA step1] Date of Service set: '{field.get_attribute('value')}'")
break
except Exception:
continue
# Click "Verify Eligibility"
print("[CCA step1] Clicking 'Verify Eligibility'...")
clicked = False
for sel in [
(By.XPATH, "//button[contains(text(),'Verify Eligibility')]"),
(By.XPATH, "//input[@value='Verify Eligibility']"),
(By.XPATH, "//a[contains(text(),'Verify Eligibility')]"),
(By.XPATH, "//*[@id='btnVerifyEligibility']"),
]:
try:
btn = self.driver.find_element(*sel)
if btn.is_displayed():
btn.click()
clicked = True
print(f"[CCA step1] Clicked Verify Eligibility via {sel}")
break
except Exception:
continue
if not clicked:
return "ERROR: Could not find 'Verify Eligibility' button"
# Wait for result using WebDriverWait instead of fixed sleep
print("[CCA step1] Waiting for eligibility result...")
try:
WebDriverWait(self.driver, 30).until(
lambda d: "Patient Selected" in d.find_element(By.TAG_NAME, "body").text
or "Patient Information" in d.find_element(By.TAG_NAME, "body").text
or "patient is eligible" in d.find_element(By.TAG_NAME, "body").text.lower()
or "not eligible" in d.find_element(By.TAG_NAME, "body").text.lower()
or "no results" in d.find_element(By.TAG_NAME, "body").text.lower()
or "not found" in d.find_element(By.TAG_NAME, "body").text.lower()
)
print("[CCA step1] Eligibility result appeared")
except TimeoutException:
print("[CCA step1] Timed out waiting for result, checking page...")
time.sleep(1)
# Check for errors
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 Subscriber ID and DOB"
# Check for error alerts
try:
alerts = self.driver.find_elements(By.XPATH,
"//*[@role='alert'] | //*[contains(@class,'alert-danger')]")
for alert in alerts:
if alert.is_displayed() and alert.text.strip():
return f"ERROR: {alert.text.strip()[:200]}"
except Exception:
pass
return "SUCCESS"
except Exception as e:
print(f"[CCA step1] Exception: {e}")
return f"ERROR: step1 failed: {e}"
def step2(self):
"""
Extract all patient information from the result popup,
capture the eligibility report PDF, and return everything.
"""
try:
print("[CCA step2] Extracting eligibility data...")
time.sleep(1)
patientName = ""
extractedDob = ""
foundMemberId = ""
eligibility = "Unknown"
address = ""
city = ""
zipCode = ""
insurerName = ""
body_text = self.driver.find_element(By.TAG_NAME, "body").text
print(f"[CCA step2] Page text (first 800): {body_text[:800]}")
# --- Eligibility status ---
if "patient is eligible" in body_text.lower():
eligibility = "Eligible"
elif "not eligible" in body_text.lower() or "ineligible" in body_text.lower():
eligibility = "Not Eligible"
# --- Patient name ---
for sel in [
(By.XPATH, "//*[contains(@class,'patient-name') or contains(@class,'PatientName')]"),
(By.XPATH, "//div[contains(@class,'modal')]//strong"),
(By.XPATH, "//div[contains(@class,'modal')]//b"),
(By.XPATH, "//*[contains(text(),'Patient Information')]/following::*[1]"),
]:
try:
el = self.driver.find_element(*sel)
name = el.text.strip()
if name and 2 < len(name) < 100:
patientName = name
print(f"[CCA step2] Patient name via DOM: {patientName}")
break
except Exception:
continue
if not patientName:
name_match = re.search(r'Patient Information\s*\n+\s*([A-Z][A-Za-z\s\-\']+)', body_text)
if name_match:
raw = name_match.group(1).strip().split('\n')[0].strip()
for stop in ['Subscriber', 'Address', 'Date', 'DOB', 'Member']:
if stop in raw:
raw = raw[:raw.index(stop)].strip()
patientName = raw
print(f"[CCA step2] Patient name via regex: {patientName}")
# --- Subscriber ID ---
sub_match = re.search(r'Subscriber\s*ID:?\s*(\d+)', body_text)
if sub_match:
foundMemberId = sub_match.group(1).strip()
print(f"[CCA step2] Subscriber ID: {foundMemberId}")
else:
foundMemberId = self.memberId
# --- Date of Birth ---
dob_match = re.search(r'Date\s*of\s*Birth:?\s*([\d/]+)', body_text)
if dob_match:
extractedDob = dob_match.group(1).strip()
print(f"[CCA step2] DOB: {extractedDob}")
else:
extractedDob = self._format_dob(self.dateOfBirth)
# --- Address, City, State, Zip ---
# The search results table shows: "YVONNE KADLIK\n107 HARTFORD AVE W\nMENDON, MA 01756"
# Try extracting from the result table row (name followed by address lines)
if patientName:
addr_block_match = re.search(
re.escape(patientName) + r'\s*\n\s*(.+?)\s*\n\s*([A-Z][A-Za-z\s]+),\s*([A-Z]{2})\s+(\d{5}(?:-?\d{4})?)',
body_text
)
if addr_block_match:
address = addr_block_match.group(1).strip()
city = addr_block_match.group(2).strip()
state = addr_block_match.group(3).strip()
zipCode = addr_block_match.group(4).strip()
address = f"{address}, {city}, {state} {zipCode}"
print(f"[CCA step2] Address: {address}, City: {city}, State: {state}, Zip: {zipCode}")
# Fallback: look for "Address: ..." in Patient Information section
if not address:
addr_match = re.search(
r'Patient Information.*?Address:?\s+(\d+.+?)(?:Date of Birth|DOB|\n\s*\n)',
body_text, re.DOTALL
)
if addr_match:
raw_addr = addr_match.group(1).strip().replace('\n', ', ')
address = raw_addr
print(f"[CCA step2] Address (from Patient Info): {address}")
if not city:
city_match = re.search(
r'([A-Z][A-Za-z]+),\s*([A-Z]{2})\s+(\d{5}(?:-?\d{4})?)',
address or body_text
)
if city_match:
city = city_match.group(1).strip()
zipCode = city_match.group(3).strip()
print(f"[CCA step2] City: {city}, Zip: {zipCode}")
# --- Insurance provider name ---
# Look for insurer name like "Commonwealth Care Alliance"
insurer_match = re.search(
r'(?:Commonwealth\s+Care\s+Alliance|'
r'Delta\s+Dental|'
r'Tufts\s+Health|'
r'MassHealth|'
r'United\s+Healthcare)',
body_text,
re.IGNORECASE
)
if insurer_match:
insurerName = insurer_match.group(0).strip()
print(f"[CCA step2] Insurer: {insurerName}")
# Also try generic pattern after "View Benefits" section
if not insurerName:
ins_match = re.search(
r'View Eligibility Report\s*\n+\s*(.+?)(?:\n|View Benefits)',
body_text
)
if ins_match:
candidate = ins_match.group(1).strip()
if 3 < len(candidate) < 80 and not candidate.startswith("Start"):
insurerName = candidate
print(f"[CCA step2] Insurer via context: {insurerName}")
# --- PDF capture ---
print("[CCA step2] Clicking 'View Eligibility Report'...")
pdfBase64 = ""
try:
existing_files = set(glob.glob(os.path.join(self.download_dir, "*")))
original_window = self.driver.current_window_handle
original_handles = set(self.driver.window_handles)
view_report_clicked = False
for sel in [
(By.XPATH, "//button[contains(text(),'View Eligibility Report')]"),
(By.XPATH, "//input[@value='View Eligibility Report']"),
(By.XPATH, "//a[contains(text(),'View Eligibility Report')]"),
(By.XPATH, "//*[contains(text(),'View Eligibility Report')]"),
]:
try:
btn = self.driver.find_element(*sel)
if btn.is_displayed():
btn.click()
view_report_clicked = True
print(f"[CCA step2] Clicked 'View Eligibility Report' via {sel}")
break
except Exception:
continue
if not view_report_clicked:
print("[CCA step2] 'View Eligibility Report' button not found")
raise Exception("View Eligibility Report button not found")
# Wait for download to start
time.sleep(3)
# Check for downloaded file (this site downloads rather than opens in-tab)
pdf_path = None
for i in range(15):
time.sleep(1)
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]
print(f"[CCA step2] PDF downloaded: {pdf_path}")
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"[CCA step2] PDF from download: {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:
# Fallback: check for new window
new_handles = set(self.driver.window_handles) - original_handles
if new_handles:
new_window = new_handles.pop()
self.driver.switch_to.window(new_window)
time.sleep(3)
print(f"[CCA step2] Switched to new window: {self.driver.current_url}")
try:
cdp_result = self.driver.execute_cdp_cmd("Page.printToPDF", {
"printBackground": True,
"preferCSSPageSize": True,
"scale": 0.8,
"paperWidth": 8.5,
"paperHeight": 11,
})
pdf_data = cdp_result.get("data", "")
if len(pdf_data) > 2000:
pdfBase64 = pdf_data
print(f"[CCA step2] PDF from new window, b64 len={len(pdfBase64)}")
except Exception as e:
print(f"[CCA step2] CDP in new window failed: {e}")
try:
self.driver.close()
self.driver.switch_to.window(original_window)
except Exception:
pass
# Final fallback: CDP on main page
if not pdfBase64 or len(pdfBase64) < 2000:
print("[CCA step2] Falling back to CDP PDF from main page...")
try:
try:
self.driver.switch_to.window(original_window)
except Exception:
pass
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"[CCA step2] Main page CDP PDF, b64 len={len(pdfBase64)}")
except Exception as e2:
print(f"[CCA step2] Main page CDP failed: {e2}")
except Exception as e:
print(f"[CCA 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"[CCA step2] CDP fallback PDF, b64 len={len(pdfBase64)}")
except Exception as e2:
print(f"[CCA step2] CDP fallback also failed: {e2}")
self._close_browser()
result = {
"status": "success",
"patientName": patientName,
"eligibility": eligibility,
"pdfBase64": pdfBase64,
"extractedDob": extractedDob,
"memberId": foundMemberId,
"address": address,
"city": city,
"zipCode": zipCode,
"insurerName": insurerName,
}
print(f"[CCA step2] Result: name={result['patientName']}, "
f"eligibility={result['eligibility']}, "
f"memberId={result['memberId']}, "
f"address={result['address']}, "
f"city={result['city']}, zip={result['zipCode']}, "
f"insurer={result['insurerName']}")
return result
except Exception as e:
print(f"[CCA step2] Exception: {e}")
self._close_browser()
return {
"status": "error",
"patientName": f"{self.firstName} {self.lastName}".strip(),
"eligibility": "Unknown",
"pdfBase64": "",
"extractedDob": self._format_dob(self.dateOfBirth),
"memberId": self.memberId,
"address": "",
"city": "",
"zipCode": "",
"insurerName": "",
"error": str(e),
}

View File

@@ -23,6 +23,8 @@ class AutomationDeltaDentalMAEligibilityCheck:
# Flatten values for convenience # Flatten values for convenience
self.memberId = self.data.get("memberId", "") self.memberId = self.data.get("memberId", "")
self.dateOfBirth = self.data.get("dateOfBirth", "") self.dateOfBirth = self.data.get("dateOfBirth", "")
self.firstName = self.data.get("firstName", "")
self.lastName = self.data.get("lastName", "")
self.massddma_username = self.data.get("massddmaUsername", "") self.massddma_username = self.data.get("massddmaUsername", "")
self.massddma_password = self.data.get("massddmaPassword", "") self.massddma_password = self.data.get("massddmaPassword", "")
@@ -284,58 +286,105 @@ class AutomationDeltaDentalMAEligibilityCheck:
return f"ERROR:LOGIN FAILED: {e}" return f"ERROR:LOGIN FAILED: {e}"
def step1(self): def step1(self):
"""Fill search form with all available fields (flexible search)"""
wait = WebDriverWait(self.driver, 30) wait = WebDriverWait(self.driver, 30)
try: try:
# Fill Member ID # Log what fields are available
member_id_input = wait.until(EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]'))) fields = []
member_id_input.clear() if self.memberId:
member_id_input.send_keys(self.memberId) fields.append(f"ID: {self.memberId}")
if self.firstName:
fields.append(f"FirstName: {self.firstName}")
if self.lastName:
fields.append(f"LastName: {self.lastName}")
if self.dateOfBirth:
fields.append(f"DOB: {self.dateOfBirth}")
print(f"[DDMA step1] Starting search with: {', '.join(fields)}")
# Fill DOB parts # Helper to click, select-all and type
try:
dob_parts = self.dateOfBirth.split("-")
year = dob_parts[0] # "1964"
month = dob_parts[1].zfill(2) # "04"
day = dob_parts[2].zfill(2) # "17"
except Exception as e:
print(f"Error parsing DOB: {e}")
return "ERROR: PARSING DOB"
# 1) locate the specific member DOB container
dob_container = wait.until(
EC.presence_of_element_located(
(By.XPATH, "//div[@data-testid='member-search_date-of-birth']")
)
)
# 2) find the editable spans *inside that container* using relative XPaths
month_elem = dob_container.find_element(By.XPATH, ".//span[@data-type='month' and @contenteditable='true']")
day_elem = dob_container.find_element(By.XPATH, ".//span[@data-type='day' and @contenteditable='true']")
year_elem = dob_container.find_element(By.XPATH, ".//span[@data-type='year' and @contenteditable='true']")
# Helper to click, select-all and type (pure send_keys approach)
def replace_with_sendkeys(el, value): def replace_with_sendkeys(el, value):
# focus (same as click)
el.click() el.click()
# select all (Ctrl+A) and delete (some apps pick up BACKSPACE better — we use BACKSPACE after select)
el.send_keys(Keys.CONTROL, "a") el.send_keys(Keys.CONTROL, "a")
el.send_keys(Keys.BACKSPACE) el.send_keys(Keys.BACKSPACE)
# type the value
el.send_keys(value) el.send_keys(value)
# optionally blur or tab out if app expects it
# el.send_keys(Keys.TAB)
replace_with_sendkeys(month_elem, month) # 1. Fill Member ID if provided
time.sleep(0.05) if self.memberId:
replace_with_sendkeys(day_elem, day) try:
time.sleep(0.05) member_id_input = wait.until(EC.presence_of_element_located(
replace_with_sendkeys(year_elem, year) (By.XPATH, '//input[@placeholder="Search by member ID"]')
))
member_id_input.clear()
member_id_input.send_keys(self.memberId)
print(f"[DDMA step1] Entered Member ID: {self.memberId}")
time.sleep(0.2)
except Exception as e:
print(f"[DDMA step1] Warning: Could not fill Member ID: {e}")
# 2. Fill DOB if provided
if self.dateOfBirth:
try:
dob_parts = self.dateOfBirth.split("-")
year = dob_parts[0]
month = dob_parts[1].zfill(2)
day = dob_parts[2].zfill(2)
# Click Continue button dob_container = wait.until(
continue_btn = wait.until(EC.element_to_be_clickable((By.XPATH, '//button[@data-testid="member-search_search-button"]'))) EC.presence_of_element_located(
(By.XPATH, "//div[@data-testid='member-search_date-of-birth']")
)
)
month_elem = dob_container.find_element(By.XPATH, ".//span[@data-type='month' and @contenteditable='true']")
day_elem = dob_container.find_element(By.XPATH, ".//span[@data-type='day' and @contenteditable='true']")
year_elem = dob_container.find_element(By.XPATH, ".//span[@data-type='year' and @contenteditable='true']")
replace_with_sendkeys(month_elem, month)
time.sleep(0.05)
replace_with_sendkeys(day_elem, day)
time.sleep(0.05)
replace_with_sendkeys(year_elem, year)
print(f"[DDMA step1] Filled DOB: {month}/{day}/{year}")
except Exception as e:
print(f"[DDMA step1] Warning: Could not fill DOB: {e}")
# 3. Fill First Name if provided
if self.firstName:
try:
first_name_input = wait.until(EC.presence_of_element_located(
(By.XPATH, '//input[@placeholder="First name - 1 char minimum" or contains(@placeholder,"first name") or contains(@name,"firstName")]')
))
first_name_input.clear()
first_name_input.send_keys(self.firstName)
print(f"[DDMA step1] Entered First Name: {self.firstName}")
time.sleep(0.2)
except Exception as e:
print(f"[DDMA step1] Warning: Could not fill First Name: {e}")
# 4. Fill Last Name if provided
if self.lastName:
try:
last_name_input = wait.until(EC.presence_of_element_located(
(By.XPATH, '//input[@placeholder="Last name - 2 char minimum" or contains(@placeholder,"last name") or contains(@name,"lastName")]')
))
last_name_input.clear()
last_name_input.send_keys(self.lastName)
print(f"[DDMA step1] Entered Last Name: {self.lastName}")
time.sleep(0.2)
except Exception as e:
print(f"[DDMA step1] Warning: Could not fill Last Name: {e}")
time.sleep(0.3)
# Click Search button
continue_btn = wait.until(EC.element_to_be_clickable(
(By.XPATH, '//button[@data-testid="member-search_search-button"]')
))
continue_btn.click() continue_btn.click()
print("[DDMA step1] Clicked Search button")
time.sleep(5)
# Check for error message # Check for error message
try: try:
@@ -343,23 +392,24 @@ class AutomationDeltaDentalMAEligibilityCheck:
(By.XPATH, '//div[@data-testid="member-search-result-no-results"]') (By.XPATH, '//div[@data-testid="member-search-result-no-results"]')
)) ))
if error_msg: if error_msg:
print("Error: Invalid Member ID or Date of Birth.") print("[DDMA step1] Error: No results found")
return "ERROR: INVALID MEMBERID OR DOB" return "ERROR: INVALID SEARCH CRITERIA"
except TimeoutException: except TimeoutException:
pass pass
print("[DDMA step1] Search completed successfully")
return "Success" return "Success"
except Exception as e: except Exception as e:
print(f"Error while step1 i.e Cheking the MemberId and DOB in: {e}") print(f"[DDMA step1] Exception: {e}")
return "ERROR:STEP1" return f"ERROR:STEP1 - {e}"
def step2(self): def step2(self):
wait = WebDriverWait(self.driver, 90) wait = WebDriverWait(self.driver, 90)
try: try:
# Wait for results table to load (use explicit wait instead of fixed sleep) # Wait for results table to load
try: try:
WebDriverWait(self.driver, 10).until( WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located((By.XPATH, "//tbody//tr")) EC.presence_of_element_located((By.XPATH, "//tbody//tr"))
@@ -367,10 +417,50 @@ class AutomationDeltaDentalMAEligibilityCheck:
except TimeoutException: except TimeoutException:
print("[DDMA step2] Warning: Results table not found within timeout") print("[DDMA step2] Warning: Results table not found within timeout")
# 1) Find and extract eligibility status from search results (use short timeout - not critical) # 1) Extract eligibility status and Member ID from search results
eligibilityText = "unknown" 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: try:
# Use short timeout (3s) since this is just for status extraction
short_wait = WebDriverWait(self.driver, 3) short_wait = WebDriverWait(self.driver, 3)
status_link = short_wait.until(EC.presence_of_element_located(( status_link = short_wait.until(EC.presence_of_element_located((
By.XPATH, By.XPATH,
@@ -391,38 +481,15 @@ class AutomationDeltaDentalMAEligibilityCheck:
except: except:
pass pass
# 2) Extract patient name and click to navigate to detailed patient page # 2) Click on patient name to navigate to detailed patient page
print("[DDMA step2] Extracting patient name and finding detail link...") print("[DDMA step2] Clicking on patient name to open detailed page...")
patient_name_clicked = False patient_name_clicked = False
patientName = "" # 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 # First, let's print what we see on the page for debugging
current_url_before = self.driver.current_url current_url_before = self.driver.current_url
print(f"[DDMA step2] Current URL before click: {current_url_before}") print(f"[DDMA step2] Current URL before click: {current_url_before}")
# Try to extract patient name from the first row of search results
# This is more reliable than extracting from link text
name_extraction_selectors = [
"(//tbody//tr)[1]//td[1]", # First column of first row (usually name)
"(//table//tbody//tr)[1]//td[1]", # Alternative table structure
"//table//tr[2]//td[1]", # Skip header row
"(//tbody//tr)[1]//td[contains(@class,'name')]", # Name column by class
"(//tbody//tr)[1]//a", # Link in first row (might contain name)
]
for selector in name_extraction_selectors:
try:
elem = self.driver.find_element(By.XPATH, selector)
text = elem.text.strip()
# Filter out non-name text
if text and len(text) > 1 and len(text) < 100:
if not any(x in text.lower() for x in ['active', 'inactive', 'eligible', 'search', 'date', 'print', 'view', 'details', 'status']):
patientName = text
print(f"[DDMA step2] Extracted patient name from search results: '{patientName}'")
break
except Exception:
continue
# Try to find all links in the first row and print them for debugging # Try to find all links in the first row and print them for debugging
try: try:
all_links = self.driver.find_elements(By.XPATH, "(//tbody//tr)[1]//a") all_links = self.driver.find_elements(By.XPATH, "(//tbody//tr)[1]//a")
@@ -431,11 +498,6 @@ class AutomationDeltaDentalMAEligibilityCheck:
href = link.get_attribute("href") or "no-href" href = link.get_attribute("href") or "no-href"
text = link.text.strip() or "(empty text)" text = link.text.strip() or "(empty text)"
print(f" Link {i}: href={href[:80]}..., text={text}") print(f" Link {i}: href={href[:80]}..., text={text}")
# Also try to get name from link if we haven't found it yet
if not patientName and text and len(text) > 1:
if not any(x in text.lower() for x in ['active', 'inactive', 'eligible', 'search', 'view', 'details']):
patientName = text
print(f"[DDMA step2] Got patient name from link text: '{patientName}'")
except Exception as e: except Exception as e:
print(f"[DDMA step2] Error listing links: {e}") print(f"[DDMA step2] Error listing links: {e}")
@@ -456,10 +518,9 @@ class AutomationDeltaDentalMAEligibilityCheck:
href = patient_link.get_attribute("href") href = patient_link.get_attribute("href")
print(f"[DDMA step2] Found patient link: text='{link_text}', href={href}") print(f"[DDMA step2] Found patient link: text='{link_text}', href={href}")
# Use link text as name if we don't have one yet # Only update patientName if link has text (preserve previously extracted name)
if not patientName and link_text and len(link_text) > 1: if link_text and not patientName:
if not any(x in link_text.lower() for x in ['active', 'inactive', 'view', 'details']): patientName = link_text
patientName = link_text
if href and "member-details" in href: if href and "member-details" in href:
detail_url = href detail_url = href
@@ -540,70 +601,31 @@ class AutomationDeltaDentalMAEligibilityCheck:
# Try to extract patient name from detailed page if not already found # Try to extract patient name from detailed page if not already found
if not patientName: if not patientName:
detail_name_selectors = [ detail_name_selectors = [
"//*[contains(@class,'member-name')]", "//h1",
"//*[contains(@class,'patient-name')]", "//h2",
"//h1[not(contains(@class,'page-title'))]", "//*[contains(@class,'patient-name') or contains(@class,'member-name')]",
"//h2[not(contains(@class,'section-title'))]", "//div[contains(@class,'header')]//span",
"//div[contains(@class,'header')]//span[string-length(text()) > 2]",
"//div[contains(@class,'member-info')]//span",
"//div[contains(@class,'patient-info')]//span",
"//span[contains(@class,'name')]",
] ]
for selector in detail_name_selectors: for selector in detail_name_selectors:
try: try:
name_elem = self.driver.find_element(By.XPATH, selector) name_elem = self.driver.find_element(By.XPATH, selector)
name_text = name_elem.text.strip() name_text = name_elem.text.strip()
if name_text and len(name_text) > 2 and len(name_text) < 100: if name_text and len(name_text) > 1:
# Filter out common non-name text if not any(x in name_text.lower() for x in ['active', 'inactive', 'eligible', 'search', 'date', 'print']):
skip_words = ['active', 'inactive', 'eligible', 'search', 'date', 'print',
'view', 'details', 'member', 'patient', 'status', 'eligibility',
'welcome', 'home', 'logout', 'menu', 'close', 'expand']
if not any(x in name_text.lower() for x in skip_words):
patientName = name_text patientName = name_text
print(f"[DDMA step2] Found patient name on detail page: {patientName}") print(f"[DDMA step2] Found patient name on detail page: {patientName}")
break break
except: except:
continue continue
# As a last resort, try to find name in page text using patterns
if not patientName:
try:
# Look for text that looks like a name (First Last format)
import re
page_text = self.driver.find_element(By.TAG_NAME, "body").text
# Look for "Member Name:" or "Patient Name:" followed by text
name_patterns = [
r'Member Name[:\s]+([A-Z][a-z]+\s+[A-Z][a-z]+)',
r'Patient Name[:\s]+([A-Z][a-z]+\s+[A-Z][a-z]+)',
r'Name[:\s]+([A-Z][a-z]+\s+[A-Z][a-z]+)',
]
for pattern in name_patterns:
match = re.search(pattern, page_text, re.IGNORECASE)
if match:
patientName = match.group(1).strip()
print(f"[DDMA step2] Found patient name via pattern match: {patientName}")
break
except:
pass
else: else:
print("[DDMA step2] Warning: Could not click on patient, capturing search results page") print("[DDMA step2] Warning: Could not click on patient, capturing search results page")
# Still try to get patient name from search results # Still try to get patient name from search results if not already found
if not patientName: if not patientName:
name_selectors = [ try:
"(//tbody//tr)[1]//td[1]", # First column of first row name_elem = self.driver.find_element(By.XPATH, "(//tbody//tr)[1]//td[1]")
"(//table//tbody//tr)[1]//td[1]", patientName = name_elem.text.strip()
"(//tbody//tr)[1]//a", # Link in first row except:
] pass
for selector in name_selectors:
try:
name_elem = self.driver.find_element(By.XPATH, selector)
text = name_elem.text.strip()
if text and len(text) > 1 and not any(x in text.lower() for x in ['active', 'inactive', 'view', 'details']):
patientName = text
print(f"[DDMA step2] Got patient name from search results: {patientName}")
break
except:
continue
if not patientName: if not patientName:
print("[DDMA step2] Could not extract patient name") print("[DDMA step2] Could not extract patient name")
@@ -639,7 +661,9 @@ class AutomationDeltaDentalMAEligibilityCheck:
result = self.driver.execute_cdp_cmd("Page.printToPDF", pdf_options) result = self.driver.execute_cdp_cmd("Page.printToPDF", pdf_options)
pdf_data = base64.b64decode(result.get('data', '')) pdf_data = base64.b64decode(result.get('data', ''))
pdf_path = os.path.join(self.download_dir, f"eligibility_{self.memberId}.pdf") # 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: with open(pdf_path, "wb") as f:
f.write(pdf_data) f.write(pdf_data)
@@ -653,12 +677,23 @@ class AutomationDeltaDentalMAEligibilityCheck:
except Exception as e: except Exception as e:
print(f"[step2] Error closing browser: {e}") print(f"[step2] Error closing browser: {e}")
# Clean patient name - remove DOB if it was included (already cleaned above but double check)
if patientName:
# Remove "DOB: MM/DD/YYYY" or similar patterns from the name
cleaned_name = re.sub(r'\s*DOB[:\s]*\d{1,2}/\d{1,2}/\d{2,4}\s*', '', patientName, flags=re.IGNORECASE).strip()
if cleaned_name:
patientName = cleaned_name
print(f"[DDMA step2] Cleaned patient name: {patientName}")
print(f"[DDMA step2] Final data - PatientName: '{patientName}', MemberID: '{foundMemberId}'")
output = { output = {
"status": "success", "status": "success",
"eligibility": eligibilityText, "eligibility": eligibilityText,
"ss_path": pdf_path, # Keep key as ss_path for backward compatibility "ss_path": pdf_path, # Keep key as ss_path for backward compatibility
"pdf_path": pdf_path, # Also add explicit pdf_path "pdf_path": pdf_path, # Also add explicit pdf_path
"patientName": patientName "patientName": patientName,
"memberId": foundMemberId # Include extracted Member ID
} }
return output return output
except Exception as e: except Exception as e:

View File

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

View File

@@ -3,6 +3,7 @@ from selenium.common.exceptions import WebDriverException, TimeoutException
from selenium.webdriver.chrome.service import Service from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys from selenium.webdriver.common.keys import Keys
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support import expected_conditions as EC
from webdriver_manager.chrome import ChromeDriverManager from webdriver_manager.chrome import ChromeDriverManager
@@ -22,6 +23,8 @@ class AutomationDentaQuestEligibilityCheck:
# Flatten values for convenience # Flatten values for convenience
self.memberId = self.data.get("memberId", "") self.memberId = self.data.get("memberId", "")
self.dateOfBirth = self.data.get("dateOfBirth", "") self.dateOfBirth = self.data.get("dateOfBirth", "")
self.firstName = self.data.get("firstName", "")
self.lastName = self.data.get("lastName", "")
self.dentaquest_username = self.data.get("dentaquestUsername", "") self.dentaquest_username = self.data.get("dentaquestUsername", "")
self.dentaquest_password = self.data.get("dentaquestPassword", "") self.dentaquest_password = self.data.get("dentaquestPassword", "")
@@ -247,11 +250,20 @@ class AutomationDentaQuestEligibilityCheck:
return f"ERROR:LOGIN FAILED: {e}" return f"ERROR:LOGIN FAILED: {e}"
def step1(self): def step1(self):
"""Navigate to member search and enter member ID + DOB""" """Navigate to member search - fills all available fields (Member ID, First Name, Last Name, DOB)"""
wait = WebDriverWait(self.driver, 30) wait = WebDriverWait(self.driver, 30)
try: try:
print(f"[DentaQuest step1] Starting member search for ID: {self.memberId}, DOB: {self.dateOfBirth}") # 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 # Wait for page to be ready
time.sleep(2) time.sleep(2)
@@ -267,14 +279,6 @@ class AutomationDentaQuestEligibilityCheck:
print(f"[DentaQuest step1] Error parsing DOB: {e}") print(f"[DentaQuest step1] Error parsing DOB: {e}")
return "ERROR: PARSING DOB" return "ERROR: PARSING DOB"
# Get today's date for Date of Service
from datetime import datetime
today = datetime.now()
service_month = str(today.month).zfill(2)
service_day = str(today.day).zfill(2)
service_year = str(today.year)
print(f"[DentaQuest step1] Service date: {service_month}/{service_day}/{service_year}")
# Helper function to fill contenteditable date spans within a specific container # 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): def fill_date_by_testid(testid, month_val, day_val, year_val, field_name):
try: try:
@@ -285,55 +289,127 @@ class AutomationDentaQuestEligibilityCheck:
def replace_with_sendkeys(el, value): def replace_with_sendkeys(el, value):
el.click() el.click()
time.sleep(0.1) time.sleep(0.05)
# Clear existing content
el.send_keys(Keys.CONTROL, "a") el.send_keys(Keys.CONTROL, "a")
time.sleep(0.05)
el.send_keys(Keys.BACKSPACE) el.send_keys(Keys.BACKSPACE)
time.sleep(0.05)
# Type new value
el.send_keys(value) el.send_keys(value)
time.sleep(0.1)
# Fill month
replace_with_sendkeys(month_elem, month_val) replace_with_sendkeys(month_elem, month_val)
# Tab to day field
month_elem.send_keys(Keys.TAB)
time.sleep(0.1) time.sleep(0.1)
# Fill day
replace_with_sendkeys(day_elem, day_val) replace_with_sendkeys(day_elem, day_val)
# Tab to year field
day_elem.send_keys(Keys.TAB)
time.sleep(0.1) time.sleep(0.1)
# Fill year
replace_with_sendkeys(year_elem, year_val) replace_with_sendkeys(year_elem, year_val)
# Tab out of the field to trigger validation
year_elem.send_keys(Keys.TAB)
time.sleep(0.2)
print(f"[DentaQuest step1] Filled {field_name}: {month_val}/{day_val}/{year_val}") print(f"[DentaQuest step1] Filled {field_name}: {month_val}/{day_val}/{year_val}")
return True return True
except Exception as e: except Exception as e:
print(f"[DentaQuest step1] Error filling {field_name}: {e}") print(f"[DentaQuest step1] Error filling {field_name}: {e}")
return False return False
# 1. Fill Date of Service with TODAY's date using specific data-testid # 1. Select Provider from dropdown (required field)
fill_date_by_testid("member-search_date-of-service", service_month, service_day, service_year, "Date of Service") try:
time.sleep(0.5) 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 # 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") fill_date_by_testid("member-search_date-of-birth", dob_month, dob_day, dob_year, "Date of Birth")
time.sleep(0.5) time.sleep(0.3)
# 3. Fill Member ID # 3. Fill ALL available search fields (flexible search)
member_id_input = wait.until(EC.presence_of_element_located( # Fill Member ID if provided
(By.XPATH, '//input[@placeholder="Search by member ID"]') if self.memberId:
)) try:
member_id_input.clear() member_id_input = wait.until(EC.presence_of_element_located(
member_id_input.send_keys(self.memberId) (By.XPATH, '//input[@placeholder="Search by member ID"]')
print(f"[DentaQuest step1] Entered member ID: {self.memberId}") ))
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) time.sleep(0.3)
@@ -351,7 +427,8 @@ class AutomationDentaQuestEligibilityCheck:
search_btn.click() search_btn.click()
print("[DentaQuest step1] Clicked search button (fallback)") print("[DentaQuest step1] Clicked search button (fallback)")
except: except:
member_id_input.send_keys(Keys.RETURN) # Press Enter on the last input field
ActionChains(self.driver).send_keys(Keys.RETURN).perform()
print("[DentaQuest step1] Pressed Enter to search") print("[DentaQuest step1] Pressed Enter to search")
time.sleep(5) time.sleep(5)
@@ -363,7 +440,7 @@ class AutomationDentaQuestEligibilityCheck:
)) ))
if error_msg and error_msg.is_displayed(): if error_msg and error_msg.is_displayed():
print("[DentaQuest step1] No results found") print("[DentaQuest step1] No results found")
return "ERROR: INVALID MEMBERID OR DOB" return "ERROR: INVALID SEARCH CRITERIA"
except TimeoutException: except TimeoutException:
pass pass
@@ -390,8 +467,41 @@ class AutomationDentaQuestEligibilityCheck:
except TimeoutException: except TimeoutException:
print("[DentaQuest step2] Warning: Results table not found within timeout") print("[DentaQuest step2] Warning: Results table not found within timeout")
# 1) Find and extract eligibility status from search results # 1) Find and extract eligibility status and Member ID from search results
eligibilityText = "unknown" 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 = [ status_selectors = [
"(//tbody//tr)[1]//a[contains(@href, 'eligibility')]", "(//tbody//tr)[1]//a[contains(@href, 'eligibility')]",
"//a[contains(@href,'eligibility')]", "//a[contains(@href,'eligibility')]",
@@ -424,25 +534,6 @@ class AutomationDentaQuestEligibilityCheck:
current_url_before = self.driver.current_url current_url_before = self.driver.current_url
print(f"[DentaQuest step2] Current URL before: {current_url_before}") print(f"[DentaQuest step2] Current URL before: {current_url_before}")
# Try to extract patient name from search results first
name_extraction_selectors = [
"(//tbody//tr)[1]//td[1]", # First column of first row
"(//table//tbody//tr)[1]//td[1]",
"//table//tr[2]//td[1]", # Skip header row
"(//tbody//tr)[1]//a", # Link in first row
]
for selector in name_extraction_selectors:
try:
elem = self.driver.find_element(By.XPATH, selector)
text = elem.text.strip()
if text and len(text) > 1 and len(text) < 100:
if not any(x in text.lower() for x in ['active', 'inactive', 'eligible', 'search', 'view', 'details', 'status']):
patientName = text
print(f"[DentaQuest step2] Extracted patient name from search results: '{patientName}'")
break
except:
continue
# Find all links in first row and log them # Find all links in first row and log them
try: try:
all_links = self.driver.find_elements(By.XPATH, "(//tbody//tr)[1]//a") all_links = self.driver.find_elements(By.XPATH, "(//tbody//tr)[1]//a")
@@ -454,21 +545,45 @@ class AutomationDentaQuestEligibilityCheck:
except Exception as e: except Exception as e:
print(f"[DentaQuest step2] Error listing links: {e}") print(f"[DentaQuest step2] Error listing links: {e}")
# Find the patient detail link # Find the patient detail link and extract patient name from row
patient_link_selectors = [ patient_link_selectors = [
"(//table//tbody//tr)[1]//td[1]//a", # First column link "(//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-details')]", # member-details link
"(//tbody//tr)[1]//a[contains(@href, 'member')]", # Any member 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: for selector in patient_link_selectors:
try: try:
patient_link = WebDriverWait(self.driver, 5).until( patient_link = WebDriverWait(self.driver, 5).until(
EC.presence_of_element_located((By.XPATH, selector)) EC.presence_of_element_located((By.XPATH, selector))
) )
patientName = patient_link.text.strip() link_text = patient_link.text.strip()
href = patient_link.get_attribute("href") href = patient_link.get_attribute("href")
print(f"[DentaQuest step2] Found patient link: text='{patientName}', href={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): if href and ("member-details" in href or "member" in href):
detail_url = href detail_url = href
@@ -619,7 +734,8 @@ class AutomationDentaQuestEligibilityCheck:
"eligibility": eligibilityText, "eligibility": eligibilityText,
"ss_path": pdf_path, # Keep key as ss_path for backward compatibility "ss_path": pdf_path, # Keep key as ss_path for backward compatibility
"pdf_path": pdf_path, # Also add explicit pdf_path "pdf_path": pdf_path, # Also add explicit pdf_path
"patientName": patientName "patientName": patientName,
"memberId": foundMemberId # Member ID extracted from the page
} }
print(f"[DentaQuest step2] Success: {output}") print(f"[DentaQuest step2] Success: {output}")
return output return output

View File

@@ -191,7 +191,59 @@ class AutomationUnitedSCOEligibilityCheck:
time.sleep(5) # Wait for login to process time.sleep(5) # Wait for login to process
# Check for OTP input after login # Check for MFA method selection page
# DentalHub shows: "Phone" / "Authenticator App" radio buttons + "Continue" button
try:
continue_btn = self.driver.find_element(By.XPATH,
"//button[contains(text(),'Continue')]"
)
# Check if "Phone" radio is present (MFA selection page)
phone_elements = self.driver.find_elements(By.XPATH,
"//*[contains(text(),'Phone')]"
)
if continue_btn and phone_elements:
print("[UnitedSCO login] MFA method selection page detected")
# Select "Phone" radio button if not already selected
try:
phone_radio = self.driver.find_element(By.XPATH,
"//input[@type='radio' and (contains(@value,'phone') or contains(@value,'Phone'))] | "
"//label[contains(text(),'Phone')]/preceding-sibling::input[@type='radio'] | "
"//label[contains(text(),'Phone')]//input[@type='radio'] | "
"//input[@type='radio'][following-sibling::*[contains(text(),'Phone')]] | "
"//input[@type='radio']"
)
if phone_radio and not phone_radio.is_selected():
phone_radio.click()
print("[UnitedSCO login] Selected 'Phone' radio button")
else:
print("[UnitedSCO login] 'Phone' already selected")
except Exception as radio_err:
print(f"[UnitedSCO login] Could not click Phone radio (may already be selected): {radio_err}")
# Try clicking the label text instead
try:
phone_label = self.driver.find_element(By.XPATH, "//*[contains(text(),'Phone') and not(contains(text(),'Authenticator'))]")
phone_label.click()
print("[UnitedSCO login] Clicked 'Phone' label")
except Exception:
pass
time.sleep(1)
# Click Continue
continue_btn.click()
print("[UnitedSCO login] Clicked 'Continue' on MFA selection page")
time.sleep(5) # Wait for OTP to be sent
except Exception:
pass # No MFA selection page - proceed normally
# Check if login succeeded (redirected back to dentalhub dashboard)
current_url_after_login = self.driver.current_url.lower()
print(f"[UnitedSCO login] After login URL: {current_url_after_login}")
if "app.dentalhub.com" in current_url_after_login and "login" not in current_url_after_login:
print("[UnitedSCO login] Login successful - redirected to dashboard")
return "SUCCESS"
# Check for OTP input after login / after MFA selection
try: try:
otp_input = WebDriverWait(self.driver, 15).until( otp_input = WebDriverWait(self.driver, 15).until(
EC.presence_of_element_located((By.XPATH, EC.presence_of_element_located((By.XPATH,
@@ -207,10 +259,8 @@ class AutomationUnitedSCOEligibilityCheck:
except TimeoutException: except TimeoutException:
print("[UnitedSCO login] No OTP input detected") print("[UnitedSCO login] No OTP input detected")
# Check if login succeeded (redirected back to dentalhub dashboard) # Re-check dashboard after waiting for OTP check
current_url_after_login = self.driver.current_url.lower() current_url_after_login = self.driver.current_url.lower()
print(f"[UnitedSCO login] After login URL: {current_url_after_login}")
if "app.dentalhub.com" in current_url_after_login and "login" not in current_url_after_login: if "app.dentalhub.com" in current_url_after_login and "login" not in current_url_after_login:
print("[UnitedSCO login] Login successful - redirected to dashboard") print("[UnitedSCO login] Login successful - redirected to dashboard")
return "SUCCESS" return "SUCCESS"
@@ -254,6 +304,55 @@ class AutomationUnitedSCOEligibilityCheck:
print(f"[UnitedSCO login] Exception: {e}") print(f"[UnitedSCO login] Exception: {e}")
return f"ERROR:LOGIN FAILED: {e}" return f"ERROR:LOGIN FAILED: {e}"
def _check_for_error_dialog(self):
"""Check for and dismiss common error dialogs. Returns error message string or None."""
error_patterns = [
("Patient Not Found", "Patient Not Found - please check the Subscriber ID, DOB, and Payer selection"),
("Insufficient Information", "Insufficient Information - need Subscriber ID + DOB, or First Name + Last Name + DOB"),
("No Eligibility", "No eligibility information found for this patient"),
("Error", None), # Generic error - will use the dialog text
]
for pattern, default_msg in error_patterns:
try:
dialog_elem = self.driver.find_element(By.XPATH,
f"//modal-container//*[contains(text(),'{pattern}')] | "
f"//div[contains(@class,'modal')]//*[contains(text(),'{pattern}')]"
)
if dialog_elem.is_displayed():
# Get the full dialog text for logging
try:
modal = self.driver.find_element(By.XPATH, "//modal-container | //div[contains(@class,'modal-dialog')]")
dialog_text = modal.text.strip()[:200]
except Exception:
dialog_text = dialog_elem.text.strip()[:200]
print(f"[UnitedSCO step1] Error dialog detected: {dialog_text}")
# Click OK/Close to dismiss
try:
dismiss_btn = self.driver.find_element(By.XPATH,
"//modal-container//button[contains(text(),'Ok') or contains(text(),'OK') or contains(text(),'Close')] | "
"//div[contains(@class,'modal')]//button[contains(text(),'Ok') or contains(text(),'OK') or contains(text(),'Close')]"
)
dismiss_btn.click()
print("[UnitedSCO step1] Dismissed error dialog")
time.sleep(1)
except Exception:
# Try clicking the X button
try:
close_btn = self.driver.find_element(By.XPATH, "//modal-container//button[@class='close']")
close_btn.click()
except Exception:
pass
error_msg = default_msg if default_msg else f"ERROR: {dialog_text}"
return f"ERROR: {error_msg}"
except Exception:
continue
return None
def _format_dob(self, dob_str): def _format_dob(self, dob_str):
"""Convert DOB from YYYY-MM-DD to MM/DD/YYYY format""" """Convert DOB from YYYY-MM-DD to MM/DD/YYYY format"""
if dob_str and "-" in dob_str: if dob_str and "-" in dob_str:
@@ -267,13 +366,9 @@ class AutomationUnitedSCOEligibilityCheck:
""" """
Navigate to Eligibility page and fill the Patient Information form. Navigate to Eligibility page and fill the Patient Information form.
FLEXIBLE INPUT SUPPORT: Workflow based on actual DOM testing:
- If Member ID is provided: Fill Subscriber ID + DOB (+ optional First/Last Name)
- If no Member ID but First/Last Name provided: Fill First Name + Last Name + DOB
Workflow:
1. Navigate directly to eligibility page 1. Navigate directly to eligibility page
2. Fill available fields based on input 2. Fill First Name (id='firstName_Back'), Last Name (id='lastName_Back'), DOB (id='dateOfBirth_Back')
3. Select Payer: "UnitedHealthcare Massachusetts" from ng-select dropdown 3. Select Payer: "UnitedHealthcare Massachusetts" from ng-select dropdown
4. Click Continue 4. Click Continue
5. Handle Practitioner & Location page - click paymentGroupId dropdown, select Summit Dental Care 5. Handle Practitioner & Location page - click paymentGroupId dropdown, select Summit Dental Care
@@ -282,17 +377,7 @@ class AutomationUnitedSCOEligibilityCheck:
from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.action_chains import ActionChains
try: try:
# Determine which input mode to use print(f"[UnitedSCO step1] Starting eligibility search for: {self.firstName} {self.lastName}, DOB: {self.dateOfBirth}")
has_member_id = bool(self.memberId and self.memberId.strip())
has_name = bool(self.firstName and self.firstName.strip() and self.lastName and self.lastName.strip())
if has_member_id:
print(f"[UnitedSCO step1] Using Member ID mode: ID={self.memberId}, DOB={self.dateOfBirth}")
elif has_name:
print(f"[UnitedSCO step1] Using Name mode: {self.firstName} {self.lastName}, DOB={self.dateOfBirth}")
else:
print("[UnitedSCO step1] ERROR: Need either Member ID or First Name + Last Name")
return "ERROR: Missing required input (Member ID or Name)"
# Navigate directly to eligibility page # Navigate directly to eligibility page
print("[UnitedSCO step1] Navigating to eligibility page...") print("[UnitedSCO step1] Navigating to eligibility page...")
@@ -305,28 +390,70 @@ class AutomationUnitedSCOEligibilityCheck:
# Step 1.1: Fill the Patient Information form # Step 1.1: Fill the Patient Information form
print("[UnitedSCO step1] Filling Patient Information form...") print("[UnitedSCO step1] Filling Patient Information form...")
# Wait for form to load # Wait for form to load - look for First Name field (id='firstName_Back')
try: try:
WebDriverWait(self.driver, 10).until( WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located((By.ID, "subscriberId_Front")) EC.presence_of_element_located((By.ID, "firstName_Back"))
) )
print("[UnitedSCO step1] Patient Information form loaded") print("[UnitedSCO step1] Patient Information form loaded")
except TimeoutException: except TimeoutException:
print("[UnitedSCO step1] Patient Information form not found") print("[UnitedSCO step1] Patient Information form not found")
return "ERROR: Patient Information form not found" return "ERROR: Patient Information form not found"
# Fill Subscriber ID if provided (id='subscriberId_Front') # Fill Subscriber ID / Medicaid ID if memberId is provided
if has_member_id: # The field is labeled "Subscriber ID or Medicaid ID" on the DentalHub form
# Actual DOM field id is 'subscriberId_Front' (not 'subscriberId_Back')
if self.memberId:
try: try:
subscriber_id_input = self.driver.find_element(By.ID, "subscriberId_Front") subscriber_id_selectors = [
subscriber_id_input.clear() "//input[@id='subscriberId_Front']",
subscriber_id_input.send_keys(self.memberId) "//input[@id='subscriberId_Back' or @id='subscriberID_Back']",
print(f"[UnitedSCO step1] Entered Subscriber ID: {self.memberId}") "//input[@id='memberId_Back' or @id='memberid_Back']",
"//input[@id='medicaidId_Back']",
"//label[contains(text(),'Subscriber ID')]/..//input[not(@id='firstName_Back') and not(@id='lastName_Back') and not(@id='dateOfBirth_Back')]",
"//input[contains(@placeholder,'Subscriber') or contains(@placeholder,'subscriber')]",
"//input[contains(@placeholder,'Medicaid') or contains(@placeholder,'medicaid')]",
"//input[contains(@placeholder,'Member') or contains(@placeholder,'member')]",
]
subscriber_filled = False
for sel in subscriber_id_selectors:
try:
sid_input = self.driver.find_element(By.XPATH, sel)
if sid_input.is_displayed():
sid_input.clear()
sid_input.send_keys(self.memberId)
field_id = sid_input.get_attribute("id") or "unknown"
print(f"[UnitedSCO step1] Entered Subscriber ID: {self.memberId} (field id='{field_id}')")
subscriber_filled = True
break
except Exception:
continue
if not subscriber_filled:
# Fallback: find visible input that is NOT a known field
try:
all_inputs = self.driver.find_elements(By.XPATH,
"//form//input[@type='text' or not(@type)]"
)
known_ids = {'firstName_Back', 'lastName_Back', 'dateOfBirth_Back', 'procedureDate_Back', 'insurerId'}
for inp in all_inputs:
inp_id = inp.get_attribute("id") or ""
if inp_id not in known_ids and inp.is_displayed():
inp.clear()
inp.send_keys(self.memberId)
print(f"[UnitedSCO step1] Entered Subscriber ID in field id='{inp_id}': {self.memberId}")
subscriber_filled = True
break
except Exception as e2:
print(f"[UnitedSCO step1] Fallback subscriber field search error: {e2}")
if not subscriber_filled:
print(f"[UnitedSCO step1] WARNING: Could not find Subscriber ID field (ID: {self.memberId})")
except Exception as e: except Exception as e:
print(f"[UnitedSCO step1] Error entering Subscriber ID: {e}") print(f"[UnitedSCO step1] Error entering Subscriber ID: {e}")
# Fill First Name if provided (id='firstName_Back') # Fill First Name (id='firstName_Back') - only if provided
if self.firstName and self.firstName.strip(): if self.firstName:
try: try:
first_name_input = self.driver.find_element(By.ID, "firstName_Back") first_name_input = self.driver.find_element(By.ID, "firstName_Back")
first_name_input.clear() first_name_input.clear()
@@ -334,11 +461,11 @@ class AutomationUnitedSCOEligibilityCheck:
print(f"[UnitedSCO step1] Entered First Name: {self.firstName}") print(f"[UnitedSCO step1] Entered First Name: {self.firstName}")
except Exception as e: except Exception as e:
print(f"[UnitedSCO step1] Error entering First Name: {e}") print(f"[UnitedSCO step1] Error entering First Name: {e}")
if not has_member_id: # Only fail if we're relying on name else:
return "ERROR: Could not enter First Name" print("[UnitedSCO step1] No First Name provided, skipping")
# Fill Last Name if provided (id='lastName_Back') # Fill Last Name (id='lastName_Back') - only if provided
if self.lastName and self.lastName.strip(): if self.lastName:
try: try:
last_name_input = self.driver.find_element(By.ID, "lastName_Back") last_name_input = self.driver.find_element(By.ID, "lastName_Back")
last_name_input.clear() last_name_input.clear()
@@ -346,10 +473,10 @@ class AutomationUnitedSCOEligibilityCheck:
print(f"[UnitedSCO step1] Entered Last Name: {self.lastName}") print(f"[UnitedSCO step1] Entered Last Name: {self.lastName}")
except Exception as e: except Exception as e:
print(f"[UnitedSCO step1] Error entering Last Name: {e}") print(f"[UnitedSCO step1] Error entering Last Name: {e}")
if not has_member_id: # Only fail if we're relying on name else:
return "ERROR: Could not enter Last Name" print("[UnitedSCO step1] No Last Name provided, skipping")
# Fill Date of Birth (id='dateOfBirth_Back', format: MM/DD/YYYY) - always required # Fill Date of Birth (id='dateOfBirth_Back', format: MM/DD/YYYY)
try: try:
dob_input = self.driver.find_element(By.ID, "dateOfBirth_Back") dob_input = self.driver.find_element(By.ID, "dateOfBirth_Back")
dob_input.clear() dob_input.clear()
@@ -364,32 +491,135 @@ class AutomationUnitedSCOEligibilityCheck:
# Step 1.2: Select Payer - UnitedHealthcare Massachusetts # Step 1.2: Select Payer - UnitedHealthcare Massachusetts
print("[UnitedSCO step1] Selecting Payer...") print("[UnitedSCO step1] Selecting Payer...")
# First dismiss any blocking dialogs (e.g. Chrome password save)
try: try:
# Click the Payer ng-select dropdown self.driver.execute_script("""
payer_ng_select = self.driver.find_element(By.XPATH, // Dismiss Chrome password manager popup if present
"//label[contains(text(),'Payer')]/following-sibling::ng-select" var dialogs = document.querySelectorAll('[role="dialog"], .cdk-overlay-container');
) dialogs.forEach(function(d) { d.style.display = 'none'; });
payer_ng_select.click() """)
time.sleep(1) except Exception:
pass
# Find and click "UnitedHealthcare Massachusetts" option
payer_options = self.driver.find_elements(By.XPATH, payer_selected = False
"//ng-dropdown-panel//div[contains(@class,'ng-option')]"
) # Strategy 1: Click the ng-select, type to search, and select the option
for opt in payer_options: try:
if "UnitedHealthcare Massachusetts" in opt.text: # Find the Payer ng-select by multiple selectors
opt.click() payer_selectors = [
print("[UnitedSCO step1] Selected Payer: UnitedHealthcare Massachusetts") "//label[contains(text(),'Payer')]/following-sibling::ng-select",
break "//label[contains(text(),'Payer')]/..//ng-select",
"//ng-select[contains(@placeholder,'Payer') or contains(@placeholder,'payer')]",
# Press Escape to close any dropdown "//ng-select[.//input[contains(@placeholder,'Search by Payers')]]",
ActionChains(self.driver).send_keys(Keys.ESCAPE).perform() ]
time.sleep(1) payer_ng_select = None
for sel in payer_selectors:
try:
payer_ng_select = self.driver.find_element(By.XPATH, sel)
if payer_ng_select.is_displayed():
break
except Exception:
continue
if payer_ng_select:
# Scroll to it and click to open
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", payer_ng_select)
time.sleep(0.5)
payer_ng_select.click()
time.sleep(1)
# Type into the search input inside ng-select to filter options
try:
search_input = payer_ng_select.find_element(By.XPATH, ".//input[contains(@type,'text') or contains(@role,'combobox')]")
search_input.clear()
search_input.send_keys("UnitedHealthcare Massachusetts")
print("[UnitedSCO step1] Typed payer search text")
time.sleep(2)
except Exception:
# If no search input, try sending keys directly to ng-select
try:
ActionChains(self.driver).send_keys("UnitedHealthcare Mass").perform()
print("[UnitedSCO step1] Typed payer search via ActionChains")
time.sleep(2)
except Exception:
pass
# Find and click the matching option
payer_options = self.driver.find_elements(By.XPATH,
"//ng-dropdown-panel//div[contains(@class,'ng-option')]"
)
for opt in payer_options:
opt_text = opt.text.strip()
if "UnitedHealthcare Massachusetts" in opt_text:
opt.click()
print(f"[UnitedSCO step1] Selected Payer: {opt_text}")
payer_selected = True
break
if not payer_selected and payer_options:
# Select first visible option if it contains "United"
for opt in payer_options:
opt_text = opt.text.strip()
if "United" in opt_text and opt.is_displayed():
opt.click()
print(f"[UnitedSCO step1] Selected first matching Payer: {opt_text}")
payer_selected = True
break
# Close dropdown
ActionChains(self.driver).send_keys(Keys.ESCAPE).perform()
time.sleep(0.5)
else:
print("[UnitedSCO step1] Could not find Payer ng-select element")
except Exception as e: except Exception as e:
print(f"[UnitedSCO step1] Error selecting Payer: {e}") print(f"[UnitedSCO step1] Payer selection strategy 1 error: {e}")
# Try to continue anyway - payer might be pre-selected try:
ActionChains(self.driver).send_keys(Keys.ESCAPE).perform() ActionChains(self.driver).send_keys(Keys.ESCAPE).perform()
except Exception:
pass
# Strategy 2: JavaScript direct selection if strategy 1 failed
if not payer_selected:
try:
# Try clicking via JavaScript
clicked = self.driver.execute_script("""
// Find ng-select near the Payer label
var labels = document.querySelectorAll('label');
for (var i = 0; i < labels.length; i++) {
if (labels[i].textContent.includes('Payer')) {
var parent = labels[i].parentElement;
var ngSelect = parent.querySelector('ng-select') || labels[i].nextElementSibling;
if (ngSelect) {
ngSelect.click();
return true;
}
}
}
return false;
""")
if clicked:
time.sleep(1)
ActionChains(self.driver).send_keys("UnitedHealthcare Mass").perform()
time.sleep(2)
payer_options = self.driver.find_elements(By.XPATH,
"//ng-dropdown-panel//div[contains(@class,'ng-option')]"
)
for opt in payer_options:
if "UnitedHealthcare" in opt.text and "Massachusetts" in opt.text:
opt.click()
print(f"[UnitedSCO step1] Selected Payer via JS: {opt.text.strip()}")
payer_selected = True
break
ActionChains(self.driver).send_keys(Keys.ESCAPE).perform()
except Exception as e:
print(f"[UnitedSCO step1] Payer selection strategy 2 error: {e}")
if not payer_selected:
print("[UnitedSCO step1] WARNING: Could not select Payer - form may fail")
time.sleep(1)
# Step 1.3: Click Continue button (Step 1 - Patient Info) # Step 1.3: Click Continue button (Step 1 - Patient Info)
try: try:
@@ -399,38 +629,84 @@ class AutomationUnitedSCOEligibilityCheck:
continue_btn.click() continue_btn.click()
print("[UnitedSCO step1] Clicked Continue button (Patient Info)") print("[UnitedSCO step1] Clicked Continue button (Patient Info)")
time.sleep(4) time.sleep(4)
# Check for error dialogs (modal) after clicking Continue
error_result = self._check_for_error_dialog()
if error_result:
return error_result
except Exception as e: except Exception as e:
print(f"[UnitedSCO step1] Error clicking Continue: {e}") print(f"[UnitedSCO step1] Error clicking Continue: {e}")
return "ERROR: Could not click Continue button" return "ERROR: Could not click Continue button"
# Step 1.4: Handle Practitioner & Location page # Step 1.4: Handle Practitioner & Location page
# First check if we actually moved to the Practitioner page
# by looking for Practitioner-specific elements
print("[UnitedSCO step1] Handling Practitioner & Location page...") print("[UnitedSCO step1] Handling Practitioner & Location page...")
on_practitioner_page = False
try: try:
# Click Practitioner Taxonomy dropdown (id='paymentGroupId') # Check for Practitioner page elements (paymentGroupId or treatment location)
taxonomy_input = WebDriverWait(self.driver, 10).until( WebDriverWait(self.driver, 8).until(
EC.element_to_be_clickable((By.ID, "paymentGroupId")) lambda d: d.find_element(By.ID, "paymentGroupId").is_displayed() or
d.find_element(By.ID, "treatmentLocation").is_displayed()
) )
taxonomy_input.click() on_practitioner_page = True
print("[UnitedSCO step1] Clicked Practitioner Taxonomy dropdown") print("[UnitedSCO step1] Practitioner & Location page loaded")
time.sleep(1) except Exception:
# Check if we're already on results page (3rd step)
try:
results_elem = self.driver.find_element(By.XPATH,
"//*[contains(text(),'Selected Patient') or contains(@id,'patient-name') or contains(@id,'eligibility')]"
)
if results_elem.is_displayed():
print("[UnitedSCO step1] Already on Eligibility Results page (skipped Practitioner)")
return "Success"
except Exception:
pass
# Select "Summit Dental Care" option # Check for error dialog again
summit_option = WebDriverWait(self.driver, 10).until( error_result = self._check_for_error_dialog()
EC.element_to_be_clickable((By.XPATH, if error_result:
"//ng-dropdown-panel//div[contains(@class,'ng-option') and contains(.,'Summit Dental Care')]" return error_result
))
)
summit_option.click()
print("[UnitedSCO step1] Selected: Summit Dental Care")
# Press Escape to close dropdown print("[UnitedSCO step1] Practitioner page not detected, attempting to continue...")
ActionChains(self.driver).send_keys(Keys.ESCAPE).perform()
time.sleep(1) if on_practitioner_page:
try:
except TimeoutException: # Click Practitioner Taxonomy dropdown (id='paymentGroupId')
print("[UnitedSCO step1] Practitioner Taxonomy not found or already selected") taxonomy_input = self.driver.find_element(By.ID, "paymentGroupId")
except Exception as e: if taxonomy_input.is_displayed():
print(f"[UnitedSCO step1] Practitioner Taxonomy handling: {e}") taxonomy_input.click()
print("[UnitedSCO step1] Clicked Practitioner Taxonomy dropdown")
time.sleep(1)
# Select "Summit Dental Care" option
try:
summit_option = WebDriverWait(self.driver, 5).until(
EC.element_to_be_clickable((By.XPATH,
"//ng-dropdown-panel//div[contains(@class,'ng-option') and contains(.,'Summit Dental Care')]"
))
)
summit_option.click()
print("[UnitedSCO step1] Selected: Summit Dental Care")
except TimeoutException:
# Select first available option
try:
first_option = self.driver.find_element(By.XPATH,
"//ng-dropdown-panel//div[contains(@class,'ng-option')]"
)
option_text = first_option.text.strip()
first_option.click()
print(f"[UnitedSCO step1] Selected first available: {option_text}")
except Exception:
print("[UnitedSCO step1] No options available in Practitioner dropdown")
# Press Escape to close dropdown
ActionChains(self.driver).send_keys(Keys.ESCAPE).perform()
time.sleep(1)
except Exception as e:
print(f"[UnitedSCO step1] Practitioner Taxonomy handling: {e}")
# Step 1.5: Click Continue button (Step 2 - Practitioner) # Step 1.5: Click Continue button (Step 2 - Practitioner)
try: try:
@@ -442,25 +718,15 @@ class AutomationUnitedSCOEligibilityCheck:
time.sleep(5) time.sleep(5)
except Exception as e: except Exception as e:
print(f"[UnitedSCO step1] Error clicking Continue on Practitioner page: {e}") print(f"[UnitedSCO step1] Error clicking Continue on Practitioner page: {e}")
# Check for error dialog intercepting the click
error_result = self._check_for_error_dialog()
if error_result:
return error_result
# Check for errors # Final check for error dialogs after the search
try: error_result = self._check_for_error_dialog()
error_selectors = [ if error_result:
"//*[contains(text(),'No results')]", return error_result
"//*[contains(text(),'not found')]",
"//*[contains(text(),'Invalid')]",
]
for sel in error_selectors:
try:
error_elem = self.driver.find_element(By.XPATH, sel)
if error_elem and error_elem.is_displayed():
error_text = error_elem.text
print(f"[UnitedSCO step1] Error found: {error_text}")
return f"ERROR: {error_text}"
except:
continue
except:
pass
print("[UnitedSCO step1] Patient search completed successfully") print("[UnitedSCO step1] Patient search completed successfully")
return "Success" return "Success"
@@ -470,16 +736,40 @@ class AutomationUnitedSCOEligibilityCheck:
return f"ERROR:STEP1 - {e}" return f"ERROR:STEP1 - {e}"
def _get_existing_downloads(self):
"""Get set of existing PDF files in download dir before clicking."""
import glob
return set(glob.glob(os.path.join(self.download_dir, "*.pdf")))
def _wait_for_new_download(self, existing_files, timeout=15):
"""Wait for a new PDF file to appear in the download dir."""
import glob
for _ in range(timeout * 2): # check every 0.5s
time.sleep(0.5)
current = set(glob.glob(os.path.join(self.download_dir, "*.pdf")))
new_files = current - existing_files
if new_files:
# Also wait for download to finish (no .crdownload files)
crdownloads = glob.glob(os.path.join(self.download_dir, "*.crdownload"))
if not crdownloads:
return list(new_files)[0]
return None
def step2(self): def step2(self):
""" """
Navigate to eligibility detail page and capture PDF. Extract data from Selected Patient page, click the "Eligibility" tab
to navigate to the eligibility details page, then capture PDF.
At this point we should be on the "Selected Patient" page after step1. The "Eligibility" tab at the bottom (next to "Benefit Summary" and
Workflow based on actual DOM testing: "Service History") may:
1. Extract eligibility status and Member ID from the page a) Open a new browser tab with eligibility details
2. Click the "Eligibility" button (id='eligibility-link') b) Download a PDF file
3. Generate PDF using Chrome DevTools Protocol (same as other insurances) c) Load content dynamically on the same page
We handle all three cases.
""" """
import glob
import re
try: try:
print("[UnitedSCO step2] Starting eligibility capture") print("[UnitedSCO step2] Starting eligibility capture")
@@ -498,7 +788,7 @@ class AutomationUnitedSCOEligibilityCheck:
try: try:
status_elem = WebDriverWait(self.driver, 10).until( status_elem = WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located((By.XPATH, EC.presence_of_element_located((By.XPATH,
"//*[contains(text(),'Member Eligible')]" "//*[contains(text(),'Member Eligible') or contains(text(),'member eligible')]"
)) ))
) )
status_text = status_elem.text.strip().lower() status_text = status_elem.text.strip().lower()
@@ -516,12 +806,93 @@ class AutomationUnitedSCOEligibilityCheck:
print(f"[UnitedSCO step2] Eligibility status: {eligibilityText}") print(f"[UnitedSCO step2] Eligibility status: {eligibilityText}")
# Extract patient name from the page
page_text = ""
try:
page_text = self.driver.find_element(By.TAG_NAME, "body").text
except Exception:
pass
# Log a snippet of page text around "Selected Patient" for debugging
try:
sp_idx = page_text.find("Selected Patient")
if sp_idx >= 0:
snippet = page_text[sp_idx:sp_idx+300]
print(f"[UnitedSCO step2] Page text near 'Selected Patient': {repr(snippet[:200])}")
except Exception:
pass
# Strategy 1: Try DOM element id="patient-name"
name_extracted = False
try:
name_elem = self.driver.find_element(By.ID, "patient-name")
extracted_name = name_elem.text.strip()
if extracted_name:
patientName = extracted_name
name_extracted = True
print(f"[UnitedSCO step2] Extracted patient name from DOM (id=patient-name): {patientName}")
except Exception:
pass
# Strategy 2: Try various DOM patterns for patient name
if not name_extracted:
name_selectors = [
"//*[contains(@class,'patient-name') or contains(@class,'patientName')]",
"//*[contains(@class,'selected-patient')]//h3 | //*[contains(@class,'selected-patient')]//h4 | //*[contains(@class,'selected-patient')]//strong",
"//div[contains(@class,'patient')]//h3 | //div[contains(@class,'patient')]//h4",
"//*[contains(@class,'eligibility__banner')]//h3 | //*[contains(@class,'eligibility__banner')]//h4",
"//*[contains(@class,'banner__patient')]",
]
for sel in name_selectors:
try:
elems = self.driver.find_elements(By.XPATH, sel)
for elem in elems:
txt = elem.text.strip()
# Filter: must look like a name (2+ words, starts with uppercase)
if txt and len(txt.split()) >= 2 and txt[0].isupper() and len(txt) < 60:
patientName = txt
name_extracted = True
print(f"[UnitedSCO step2] Extracted patient name from DOM: {patientName}")
break
if name_extracted:
break
except Exception:
continue
# Strategy 3: Regex from page text - multiple patterns
# IMPORTANT: Use [^\n] to avoid matching across newlines (e.g. picking up "Member Eligible")
if not name_extracted:
name_patterns = [
# Name on the line right after "Selected Patient"
r'Selected Patient\s*\n\s*([A-Z][A-Za-z\-\']+(?: [A-Z][A-Za-z\-\']+)+)',
r'Patient Name\s*[\n:]\s*([A-Z][A-Za-z\-\']+(?: [A-Z][A-Za-z\-\']+)+)',
# "LASTNAME, FIRSTNAME" format
r'Selected Patient\s*\n\s*([A-Z][A-Za-z\-\']+,\s*[A-Z][A-Za-z\-\']+)',
# Name on the line right before "Member Eligible" or "Member ID"
r'\n([A-Z][A-Za-z\-\']+(?: [A-Z]\.?)? [A-Z][A-Za-z\-\']+)\n(?:Member|Date Of Birth|DOB)',
]
for pattern in name_patterns:
try:
name_match = re.search(pattern, page_text)
if name_match:
candidate = name_match.group(1).strip()
# Validate: not too long, not a header/label, and doesn't contain "Eligible"/"Member"/"Patient"
skip_words = ("Selected Patient", "Patient Name", "Patient Information",
"Member Eligible", "Member ID", "Date Of Birth")
if (len(candidate) < 50 and candidate not in skip_words
and "Eligible" not in candidate and "Member" not in candidate):
patientName = candidate
name_extracted = True
print(f"[UnitedSCO step2] Extracted patient name from text: {patientName}")
break
except Exception:
continue
if not name_extracted:
print(f"[UnitedSCO step2] WARNING: Could not extract patient name from page")
# Extract Member ID from the page (for database storage) # Extract Member ID from the page (for database storage)
try: try:
# Look for Member ID on the page
page_text = self.driver.find_element(By.TAG_NAME, "body").text
import re
# Look for "Member ID" followed by a number
member_id_match = re.search(r'Member ID\s*[\n:]\s*(\d+)', page_text) member_id_match = re.search(r'Member ID\s*[\n:]\s*(\d+)', page_text)
if member_id_match: if member_id_match:
foundMemberId = member_id_match.group(1) foundMemberId = member_id_match.group(1)
@@ -529,44 +900,213 @@ class AutomationUnitedSCOEligibilityCheck:
except Exception as e: except Exception as e:
print(f"[UnitedSCO step2] Could not extract Member ID: {e}") print(f"[UnitedSCO step2] Could not extract Member ID: {e}")
# 2) Click the "Eligibility" button (id='eligibility-link') # Extract Date of Birth from page if available (for patient creation)
print("[UnitedSCO step2] Looking for 'Eligibility' button...") extractedDob = ""
try: try:
eligibility_btn = WebDriverWait(self.driver, 10).until( dob_match = re.search(r'Date Of Birth\s*[\n:]\s*(\d{2}/\d{2}/\d{4})', page_text)
EC.element_to_be_clickable((By.ID, "eligibility-link")) if dob_match:
) extractedDob = dob_match.group(1)
eligibility_btn.click() print(f"[UnitedSCO step2] Extracted DOB from page: {extractedDob}")
print("[UnitedSCO step2] Clicked 'Eligibility' button")
time.sleep(5)
except TimeoutException:
print("[UnitedSCO step2] Eligibility button not found, trying alternative selectors...")
try:
# Alternative: find button with text "Eligibility"
eligibility_btn = self.driver.find_element(By.XPATH,
"//button[normalize-space(text())='Eligibility']"
)
eligibility_btn.click()
print("[UnitedSCO step2] Clicked 'Eligibility' button (alternative)")
time.sleep(5)
except Exception as e:
print(f"[UnitedSCO step2] Could not click Eligibility button: {e}")
# Wait for page to fully load
try:
WebDriverWait(self.driver, 30).until(
lambda d: d.execute_script("return document.readyState") == "complete"
)
except Exception: except Exception:
pass pass
time.sleep(2) # 2) Click the "Eligibility" button to navigate to eligibility details
# The DOM has: <button id="eligibility-link" class="btn btn-link">Eligibility</button>
# This is near "Benefit Summary" and "Service History" buttons.
print("[UnitedSCO step2] Looking for 'Eligibility' button (id='eligibility-link')...")
print(f"[UnitedSCO step2] Final URL: {self.driver.current_url}") # Record existing downloads BEFORE clicking (to detect new downloads)
existing_downloads = self._get_existing_downloads()
# Record current window handles BEFORE clicking (to detect new tabs)
original_window = self.driver.current_window_handle
original_windows = set(self.driver.window_handles)
eligibility_clicked = False
# Strategy 1 (PRIMARY): Use the known button id="eligibility-link"
try:
# First check if the button exists and is visible
elig_btn = WebDriverWait(self.driver, 15).until(
EC.presence_of_element_located((By.ID, "eligibility-link"))
)
# Wait for it to become visible (it's hidden when no results)
WebDriverWait(self.driver, 10).until(
EC.visibility_of(elig_btn)
)
self.driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", elig_btn)
time.sleep(0.5)
elig_btn.click()
eligibility_clicked = True
print("[UnitedSCO step2] Clicked 'Eligibility' button (id='eligibility-link')")
time.sleep(5)
except Exception as e:
print(f"[UnitedSCO step2] Could not click by ID: {e}")
# Strategy 2: Find the button with exact "Eligibility" text (not "Eligibility Check Results" etc.)
if not eligibility_clicked:
try:
buttons = self.driver.find_elements(By.XPATH, "//button")
for btn in buttons:
try:
text = btn.text.strip()
if re.match(r'^Eligibility\s*$', text, re.IGNORECASE) and btn.is_displayed():
self.driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", btn)
time.sleep(0.5)
btn.click()
eligibility_clicked = True
print(f"[UnitedSCO step2] Clicked button with text 'Eligibility'")
time.sleep(5)
break
except Exception:
continue
except Exception as e:
print(f"[UnitedSCO step2] Button text search error: {e}")
# Strategy 3: JavaScript click on #eligibility-link
if not eligibility_clicked:
try:
clicked = self.driver.execute_script("""
var btn = document.getElementById('eligibility-link');
if (btn) { btn.scrollIntoView({block: 'center'}); btn.click(); return true; }
// Fallback: find any button/a with exact "Eligibility" text
var all = document.querySelectorAll('button, a');
for (var i = 0; i < all.length; i++) {
if (/^\\s*Eligibility\\s*$/i.test(all[i].textContent)) {
all[i].scrollIntoView({block: 'center'});
all[i].click();
return true;
}
}
return false;
""")
if clicked:
eligibility_clicked = True
print("[UnitedSCO step2] Clicked via JavaScript")
time.sleep(5)
except Exception as e:
print(f"[UnitedSCO step2] JS click error: {e}")
if not eligibility_clicked:
print("[UnitedSCO step2] WARNING: Could not click Eligibility button")
# 3) Handle the result of clicking: new tab, download, or same-page content
pdf_path = None
# Check for new browser tab/window
new_windows = set(self.driver.window_handles) - original_windows
if new_windows:
new_tab = list(new_windows)[0]
print(f"[UnitedSCO step2] New tab opened! Switching to it...")
self.driver.switch_to.window(new_tab)
time.sleep(5)
# Wait for the new page to load
try:
WebDriverWait(self.driver, 30).until(
lambda d: d.execute_script("return document.readyState") == "complete"
)
except Exception:
pass
time.sleep(2)
print(f"[UnitedSCO step2] New tab URL: {self.driver.current_url}")
# Capture PDF from the new tab
pdf_path = self._capture_pdf(foundMemberId)
# Close the new tab and switch back to original
self.driver.close()
self.driver.switch_to.window(original_window)
print("[UnitedSCO step2] Closed new tab, switched back to original")
# Check for downloaded file
if not pdf_path:
downloaded_file = self._wait_for_new_download(existing_downloads, timeout=10)
if downloaded_file:
print(f"[UnitedSCO step2] File downloaded: {downloaded_file}")
pdf_path = downloaded_file
# Fallback: capture current page as PDF
if not pdf_path:
print("[UnitedSCO step2] No new tab or download detected - capturing current page as PDF")
# Wait for any dynamic content
try:
WebDriverWait(self.driver, 15).until(
lambda d: d.execute_script("return document.readyState") == "complete"
)
except Exception:
pass
time.sleep(3)
print(f"[UnitedSCO step2] Capturing PDF from URL: {self.driver.current_url}")
pdf_path = self._capture_pdf(foundMemberId)
# 3) Generate PDF using Chrome DevTools Protocol (same as other insurances) if not pdf_path:
print("[UnitedSCO step2] Generating PDF...") return {"status": "error", "message": "STEP2 FAILED: Could not generate PDF"}
print(f"[UnitedSCO step2] PDF saved: {pdf_path}")
# Hide browser window after completion
self._hide_browser()
print("[UnitedSCO step2] Eligibility capture complete")
return {
"status": "success",
"eligibility": eligibilityText,
"ss_path": pdf_path,
"pdf_path": pdf_path,
"patientName": patientName,
"memberId": foundMemberId
}
except Exception as e:
print(f"[UnitedSCO step2] Exception: {e}")
return {"status": "error", "message": f"STEP2 FAILED: {str(e)}"}
def _hide_browser(self):
"""Hide the browser window after task completion using multiple strategies."""
try:
# Strategy 1: Navigate to blank page first (clears sensitive data from view)
try:
self.driver.get("about:blank")
time.sleep(0.5)
except Exception:
pass
# Strategy 2: Minimize window
try:
self.driver.minimize_window()
print("[UnitedSCO step2] Browser window minimized")
return
except Exception:
pass
# Strategy 3: Move window off-screen
try:
self.driver.set_window_position(-10000, -10000)
print("[UnitedSCO step2] Browser window moved off-screen")
return
except Exception:
pass
# Strategy 4: Use xdotool to minimize (Linux)
try:
import subprocess
subprocess.run(["xdotool", "getactivewindow", "windowminimize"],
timeout=3, capture_output=True)
print("[UnitedSCO step2] Browser minimized via xdotool")
except Exception:
pass
except Exception as e:
print(f"[UnitedSCO step2] Could not hide browser: {e}")
def _capture_pdf(self, member_id):
"""Capture the current page as PDF using Chrome DevTools Protocol."""
try:
pdf_options = { pdf_options = {
"landscape": False, "landscape": False,
"displayHeaderFooter": False, "displayHeaderFooter": False,
@@ -581,31 +1121,17 @@ class AutomationUnitedSCOEligibilityCheck:
"scale": 0.9, "scale": 0.9,
} }
# Use foundMemberId for filename file_identifier = member_id if member_id else f"{self.firstName}_{self.lastName}"
file_identifier = foundMemberId if foundMemberId else f"{self.firstName}_{self.lastName}"
result = self.driver.execute_cdp_cmd("Page.printToPDF", pdf_options) result = self.driver.execute_cdp_cmd("Page.printToPDF", pdf_options)
pdf_data = base64.b64decode(result.get('data', '')) pdf_data = base64.b64decode(result.get('data', ''))
pdf_path = os.path.join(self.download_dir, f"unitedsco_eligibility_{file_identifier}_{int(time.time())}.pdf") pdf_path = os.path.join(self.download_dir, f"unitedsco_eligibility_{file_identifier}_{int(time.time())}.pdf")
with open(pdf_path, "wb") as f: with open(pdf_path, "wb") as f:
f.write(pdf_data) f.write(pdf_data)
print(f"[UnitedSCO step2] PDF saved: {pdf_path}") return pdf_path
# Keep browser alive for next patient
print("[UnitedSCO step2] Eligibility capture complete - session preserved")
return {
"status": "success",
"eligibility": eligibilityText,
"ss_path": pdf_path,
"pdf_path": pdf_path,
"patientName": patientName,
"memberId": foundMemberId # Return the Member ID found on the page
}
except Exception as e: except Exception as e:
print(f"[UnitedSCO step2] Exception: {e}") print(f"[UnitedSCO _capture_pdf] Error: {e}")
return {"status": "error", "message": f"STEP2 FAILED: {str(e)}"} return None
def main_workflow(self, url): def main_workflow(self, url):

View File

@@ -112,6 +112,26 @@ class UnitedSCOBrowserManager:
except Exception as e: except Exception as e:
print(f"[UnitedSCO BrowserManager] Could not clear IndexedDB: {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 # Set flag to clear session via JavaScript after browser opens
self._needs_session_clear = True self._needs_session_clear = True
@@ -233,11 +253,21 @@ class UnitedSCOBrowserManager:
options.add_argument("--no-sandbox") options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage") options.add_argument("--disable-dev-shm-usage")
# Anti-detection options (prevent bot detection)
options.add_argument("--disable-blink-features=AutomationControlled")
options.add_experimental_option("excludeSwitches", ["enable-automation"])
options.add_experimental_option("useAutomationExtension", False)
options.add_argument("--disable-infobars")
prefs = { prefs = {
"download.default_directory": self.download_dir, "download.default_directory": self.download_dir,
"plugins.always_open_pdf_externally": True, "plugins.always_open_pdf_externally": True,
"download.prompt_for_download": False, "download.prompt_for_download": False,
"download.directory_upgrade": True "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) options.add_experimental_option("prefs", prefs)
@@ -245,6 +275,12 @@ class UnitedSCOBrowserManager:
self._driver = webdriver.Chrome(service=service, options=options) self._driver = webdriver.Chrome(service=service, options=options)
self._driver.maximize_window() self._driver.maximize_window()
# Remove webdriver property to avoid detection
try:
self._driver.execute_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})")
except Exception:
pass
# Reset the session clear flag (file-based clearing is done on startup) # Reset the session clear flag (file-based clearing is done on startup)
self._needs_session_clear = False self._needs_session_clear = False

3
package-lock.json generated
View File

@@ -13942,10 +13942,13 @@
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "^7.0.1", "@prisma/adapter-pg": "^7.0.1",
"@prisma/client": "^7.0.0", "@prisma/client": "^7.0.0",
"dotenv": "^16.6.1",
"prisma-zod-generator": "^2.1.2" "prisma-zod-generator": "^2.1.2"
}, },
"devDependencies": { "devDependencies": {
"@types/bcrypt": "^5.0.2",
"@types/node": "^22.15.17", "@types/node": "^22.15.17",
"bcrypt": "^5.1.1",
"prisma": "^7.0.0", "prisma": "^7.0.0",
"tsx": "^4.19.4", "tsx": "^4.19.4",
"typescript": "^5.8.2" "typescript": "^5.8.2"

View File

@@ -19,10 +19,13 @@
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "^7.0.1", "@prisma/adapter-pg": "^7.0.1",
"@prisma/client": "^7.0.0", "@prisma/client": "^7.0.0",
"dotenv": "^16.6.1",
"prisma-zod-generator": "^2.1.2" "prisma-zod-generator": "^2.1.2"
}, },
"devDependencies": { "devDependencies": {
"@types/bcrypt": "^5.0.2",
"@types/node": "^22.15.17", "@types/node": "^22.15.17",
"bcrypt": "^5.1.1",
"prisma": "^7.0.0", "prisma": "^7.0.0",
"tsx": "^4.19.4", "tsx": "^4.19.4",
"typescript": "^5.8.2" "typescript": "^5.8.2"

View File

@@ -1,12 +1,38 @@
import dotenv from "dotenv";
import path from "path"; import path from "path";
import { defineConfig, env } from "prisma/config"; import fs from "fs";
import { defineConfig } from "prisma/config";
dotenv.config({ path: path.resolve(__dirname, ".env") }); function loadEnvFile(): string | undefined {
const candidates = [
path.resolve(__dirname, "prisma", ".env"),
path.resolve(process.cwd(), "prisma", ".env"),
path.resolve(__dirname, ".env"),
path.resolve(process.cwd(), ".env"),
];
for (const p of candidates) {
try {
if (!fs.existsSync(p)) continue;
const content = fs.readFileSync(p, "utf-8");
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eqIdx = trimmed.indexOf("=");
if (eqIdx < 0) continue;
const key = trimmed.slice(0, eqIdx).trim();
const val = trimmed.slice(eqIdx + 1).trim();
if (key === "DATABASE_URL") return val;
}
} catch {}
}
return process.env.DATABASE_URL;
}
const url = loadEnvFile();
export default defineConfig({ export default defineConfig({
schema: "schema.prisma", schema: "prisma/schema.prisma",
datasource: { datasource: {
url: env("DATABASE_URL"), url: url!,
}, },
}); });

View File

@@ -18,10 +18,16 @@ datasource db {
provider = "postgresql" provider = "postgresql"
} }
enum UserRole {
ADMIN
USER
}
model User { model User {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
username String @unique username String @unique
password String password String
role UserRole @default(USER)
patients Patient[] patients Patient[]
appointments Appointment[] appointments Appointment[]
staff Staff[] staff Staff[]

View File

@@ -1,32 +1,43 @@
import dotenv from "dotenv";
import path from "path";
dotenv.config({ path: path.resolve(__dirname, ".env") });
import { PrismaClient } from "../generated/prisma"; import { PrismaClient } from "../generated/prisma";
const prisma = new PrismaClient(); import { PrismaPg } from "@prisma/adapter-pg";
import bcrypt from "bcrypt";
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const prisma = new PrismaClient({ adapter });
function formatTime(date: Date): string { function formatTime(date: Date): string {
return date.toTimeString().slice(0, 5); // "HH:MM" return date.toTimeString().slice(0, 5); // "HH:MM"
} }
async function main() { async function main() {
// Create multiple users const hash = (pw: string) => bcrypt.hash(pw, 10);
const users = await prisma.user.createMany({
data: [ const adminUser = await prisma.user.upsert({
{ username: "admin2", password: "123456" }, where: { username: "admin" },
{ username: "bob", password: "123456" }, update: {},
], create: { username: "admin", password: await hash("123456"), role: "ADMIN" },
});
const aaaUser = await prisma.user.upsert({
where: { username: "aaa" },
update: {},
create: { username: "aaa", password: await hash("aaa"), role: "USER" },
}); });
const createdUsers = await prisma.user.findMany(); const createdUsers = await prisma.user.findMany();
// Creatin staff
await prisma.staff.createMany({ await prisma.staff.createMany({
data: [ data: [
{ name: "Dr. Kai Gao", role: "Doctor" }, { name: "Dr. Kai Gao", role: "Doctor" },
{ name: "Dr. Jane Smith", role: "Doctor" }, { name: "Dr. Jane Smith", role: "Doctor" },
], ],
skipDuplicates: true,
}); });
const staffMembers = await prisma.staff.findMany();
// Create multiple patients
const patients = await prisma.patient.createMany({ const patients = await prisma.patient.createMany({
data: [ data: [
{ {
@@ -54,33 +65,39 @@ async function main() {
userId: createdUsers[1].id, userId: createdUsers[1].id,
}, },
], ],
skipDuplicates: true,
}); });
const createdPatients = await prisma.patient.findMany(); const createdPatients = await prisma.patient.findMany();
// Create multiple appointments const staffMembers = await prisma.staff.findMany();
await prisma.appointment.createMany({ if (createdPatients.length >= 2 && createdUsers.length >= 2 && staffMembers.length >= 1) {
data: [ await prisma.appointment.createMany({
{ data: [
patientId: createdPatients[0].id, {
userId: createdUsers[0].id, patientId: createdPatients[0].id,
title: "Initial Consultation", userId: createdUsers[0].id,
date: new Date("2025-06-01"), staffId: staffMembers[0].id,
startTime: formatTime(new Date("2025-06-01T10:00:00")), title: "Initial Consultation",
endTime: formatTime(new Date("2025-06-01T10:30:00")), date: new Date("2025-06-01"),
type: "consultation", startTime: formatTime(new Date("2025-06-01T10:00:00")),
}, endTime: formatTime(new Date("2025-06-01T10:30:00")),
{ type: "consultation",
patientId: createdPatients[1].id, },
userId: createdUsers[1].id, {
title: "Follow-up", patientId: createdPatients[1].id,
date: new Date("2025-06-02"), userId: createdUsers[1].id,
startTime: formatTime(new Date("2025-06-01T10:00:00")), staffId: staffMembers[0].id,
endTime: formatTime(new Date("2025-06-01T10:30:00")), title: "Follow-up",
type: "checkup", date: new Date("2025-06-02"),
}, startTime: formatTime(new Date("2025-06-01T10:00:00")),
], endTime: formatTime(new Date("2025-06-01T10:30:00")),
}); type: "checkup",
},
],
skipDuplicates: true,
});
}
} }
main() main()