Compare commits
12 Commits
e43329e95f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 854de90011 | |||
| d0c3d9dfdd | |||
| cc66660ccd | |||
| 131bd6db41 | |||
| b89084e7cb | |||
| 4cb7ec7e2e | |||
| 27e6e6a4a0 | |||
| 35896c264c | |||
| cf53065a26 | |||
| 03172f0710 | |||
| 445691cdd0 | |||
| e425a829b2 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -38,4 +38,10 @@ dist/
|
||||
# env
|
||||
*.env
|
||||
*chrome_profile_ddma*
|
||||
*chrome_profile_cca*
|
||||
*chrome_profile_dentaquest*
|
||||
*chrome_profile_unitedsco*
|
||||
*chrome_profile_deltains*
|
||||
|
||||
# selenium downloads (generated PDFs)
|
||||
apps/**/seleniumDownloads/
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
NODE_ENV="development"
|
||||
HOST=0.0.0.0
|
||||
PORT=5000
|
||||
FRONTEND_URLS=http://localhost:3000,http://192.168.1.8:3000
|
||||
# FRONTEND_URLS=http://localhost:3000,http://192.168.1.8:3000
|
||||
FRONTEND_URLS=http://192.168.0.238:3000
|
||||
SELENIUM_AGENT_BASE_URL=http://localhost:5002
|
||||
JWT_SECRET = 'dentalsecret'
|
||||
DB_HOST=localhost
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -41,7 +41,8 @@ function isOriginAllowed(origin?: string | null) {
|
||||
// Dev mode: allow localhost origins automatically
|
||||
if (
|
||||
origin.startsWith("http://localhost") ||
|
||||
origin.startsWith("http://127.0.0.1")
|
||||
origin.startsWith("http://127.0.0.1") ||
|
||||
origin.startsWith("http://192.168.0.238")
|
||||
)
|
||||
return true;
|
||||
// allow explicit FRONTEND_URLS if provided
|
||||
|
||||
@@ -8,22 +8,19 @@ import { z } from "zod";
|
||||
type SelectUser = z.infer<typeof UserUncheckedCreateInputObjectSchema>;
|
||||
|
||||
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) {
|
||||
const saltRounds = 10; // Salt rounds for bcrypt
|
||||
const saltRounds = 10;
|
||||
const hashedPassword = await bcrypt.hash(password, saltRounds);
|
||||
return hashedPassword;
|
||||
}
|
||||
|
||||
// Function to compare passwords using bcrypt
|
||||
async function comparePasswords(supplied: string, stored: string) {
|
||||
const isMatch = await bcrypt.compare(supplied, stored);
|
||||
return isMatch;
|
||||
}
|
||||
|
||||
// Function to generate JWT
|
||||
function generateToken(user: SelectUser) {
|
||||
return jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, {
|
||||
expiresIn: JWT_EXPIRATION,
|
||||
@@ -32,35 +29,13 @@ function generateToken(user: SelectUser) {
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// User registration route
|
||||
router.post(
|
||||
"/register",
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<any> => {
|
||||
try {
|
||||
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" });
|
||||
}
|
||||
return res.status(403).json({ error: "Public registration is disabled. Please contact your administrator." });
|
||||
}
|
||||
);
|
||||
|
||||
// User login route
|
||||
router.post(
|
||||
"/login",
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<any> => {
|
||||
@@ -77,12 +52,12 @@ router.post(
|
||||
);
|
||||
|
||||
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 { password, ...safeUser } = user;
|
||||
const { password, ...rest } = user;
|
||||
const safeUser = { ...rest, role: rest.role ?? "USER" };
|
||||
return res.status(200).json({ user: safeUser, token });
|
||||
} catch (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) => {
|
||||
// For JWT-based auth, logout is handled on the client (by removing token)
|
||||
res.status(200).send("Logged out successfully");
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import insuranceStatusRoutes from "./insuranceStatus";
|
||||
import insuranceStatusDdmaRoutes from "./insuranceStatusDDMA";
|
||||
import insuranceStatusDentaQuestRoutes from "./insuranceStatusDentaQuest";
|
||||
import insuranceStatusUnitedSCORoutes from "./insuranceStatusUnitedSCO";
|
||||
import insuranceStatusDeltaInsRoutes from "./insuranceStatusDeltaIns";
|
||||
import insuranceStatusCCARoutes from "./insuranceStatusCCA";
|
||||
import paymentsRoutes from "./payments";
|
||||
import databaseManagementRoutes from "./database-management";
|
||||
import notificationsRoutes from "./notifications";
|
||||
@@ -33,6 +35,8 @@ router.use("/insurance-status", insuranceStatusRoutes);
|
||||
router.use("/insurance-status-ddma", insuranceStatusDdmaRoutes);
|
||||
router.use("/insurance-status-dentaquest", insuranceStatusDentaQuestRoutes);
|
||||
router.use("/insurance-status-unitedsco", insuranceStatusUnitedSCORoutes);
|
||||
router.use("/insurance-status-deltains", insuranceStatusDeltaInsRoutes);
|
||||
router.use("/insurance-status-cca", insuranceStatusCCARoutes);
|
||||
router.use("/payments", paymentsRoutes);
|
||||
router.use("/database-management", databaseManagementRoutes);
|
||||
router.use("/notifications", notificationsRoutes);
|
||||
|
||||
@@ -76,8 +76,36 @@ router.put("/:id", async (req: Request, res: Response): Promise<any> => {
|
||||
const id = Number(req.params.id);
|
||||
if (isNaN(id)) return res.status(400).send("Invalid credential ID");
|
||||
|
||||
// Get existing credential to know its siteKey
|
||||
const existing = await storage.getInsuranceCredential(id);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ message: "Credential not found" });
|
||||
}
|
||||
|
||||
const updates = req.body as Partial<InsuranceCredential>;
|
||||
const credential = await storage.updateInsuranceCredential(id, updates);
|
||||
|
||||
// Clear Selenium browser session when credentials are changed
|
||||
const seleniumAgentUrl = process.env.SELENIUM_AGENT_URL || "http://localhost:5002";
|
||||
try {
|
||||
if (existing.siteKey === "DDMA") {
|
||||
await fetch(`${seleniumAgentUrl}/clear-ddma-session`, { method: "POST" });
|
||||
console.log("[insuranceCreds] Cleared DDMA browser session after credential update");
|
||||
} else if (existing.siteKey === "DENTAQUEST") {
|
||||
await fetch(`${seleniumAgentUrl}/clear-dentaquest-session`, { method: "POST" });
|
||||
console.log("[insuranceCreds] Cleared DentaQuest browser session after credential update");
|
||||
} else if (existing.siteKey === "UNITEDSCO") {
|
||||
await fetch(`${seleniumAgentUrl}/clear-unitedsco-session`, { method: "POST" });
|
||||
console.log("[insuranceCreds] Cleared United SCO browser session after credential update");
|
||||
} else if (existing.siteKey === "DELTAINS") {
|
||||
await fetch(`${seleniumAgentUrl}/clear-deltains-session`, { method: "POST" });
|
||||
console.log("[insuranceCreds] Cleared Delta Dental Ins browser session after credential update");
|
||||
}
|
||||
} catch (seleniumErr) {
|
||||
// Don't fail the update if Selenium session clear fails
|
||||
console.error("[insuranceCreds] Failed to clear Selenium session:", seleniumErr);
|
||||
}
|
||||
|
||||
return res.status(200).json(credential);
|
||||
} catch (err) {
|
||||
return res
|
||||
@@ -115,6 +143,28 @@ router.delete("/:id", async (req: Request, res: Response): Promise<any> => {
|
||||
.status(404)
|
||||
.json({ message: "Credential not found or already deleted" });
|
||||
}
|
||||
|
||||
// 4) Clear Selenium browser session for this provider
|
||||
const seleniumAgentUrl = process.env.SELENIUM_AGENT_URL || "http://localhost:5002";
|
||||
try {
|
||||
if (existing.siteKey === "DDMA") {
|
||||
await fetch(`${seleniumAgentUrl}/clear-ddma-session`, { method: "POST" });
|
||||
console.log("[insuranceCreds] Cleared DDMA browser session after credential deletion");
|
||||
} else if (existing.siteKey === "DENTAQUEST") {
|
||||
await fetch(`${seleniumAgentUrl}/clear-dentaquest-session`, { method: "POST" });
|
||||
console.log("[insuranceCreds] Cleared DentaQuest browser session after credential deletion");
|
||||
} else if (existing.siteKey === "UNITEDSCO") {
|
||||
await fetch(`${seleniumAgentUrl}/clear-unitedsco-session`, { method: "POST" });
|
||||
console.log("[insuranceCreds] Cleared United SCO browser session after credential deletion");
|
||||
} else if (existing.siteKey === "DELTAINS") {
|
||||
await fetch(`${seleniumAgentUrl}/clear-deltains-session`, { method: "POST" });
|
||||
console.log("[insuranceCreds] Cleared Delta Dental Ins browser session after credential deletion");
|
||||
}
|
||||
} catch (seleniumErr) {
|
||||
// Don't fail the delete if Selenium session clear fails
|
||||
console.error("[insuranceCreds] Failed to clear Selenium session:", seleniumErr);
|
||||
}
|
||||
|
||||
return res.status(204).send();
|
||||
} catch (err) {
|
||||
return res
|
||||
|
||||
@@ -69,15 +69,15 @@ async function createOrUpdatePatientByInsuranceId(options: {
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
// inside createOrUpdatePatientByInsuranceId, when creating:
|
||||
const createPayload: any = {
|
||||
firstName: incomingFirst,
|
||||
lastName: incomingLast,
|
||||
dateOfBirth: dob, // raw from caller (string | Date | null)
|
||||
dateOfBirth: dob,
|
||||
gender: "",
|
||||
phone: "",
|
||||
userId,
|
||||
insuranceId,
|
||||
insuranceProvider: "MassHealth",
|
||||
};
|
||||
|
||||
let patientData: InsertPatient;
|
||||
@@ -219,8 +219,8 @@ router.post(
|
||||
if (patient && patient.id !== undefined) {
|
||||
const newStatus =
|
||||
seleniumResult.eligibility === "Y" ? "ACTIVE" : "INACTIVE";
|
||||
await storage.updatePatient(patient.id, { status: newStatus });
|
||||
outputResult.patientUpdateStatus = `Patient status updated to ${newStatus}`;
|
||||
await storage.updatePatient(patient.id, { status: newStatus, insuranceProvider: "MassHealth" });
|
||||
outputResult.patientUpdateStatus = `Patient status updated to ${newStatus}, insuranceProvider=MassHealth`;
|
||||
|
||||
// ✅ Step 5: Handle PDF Upload
|
||||
if (
|
||||
@@ -649,8 +649,8 @@ router.post(
|
||||
seleniumResult?.eligibility === "Y" ? "ACTIVE" : "INACTIVE";
|
||||
|
||||
// 1. updating patient
|
||||
await storage.updatePatient(updatedPatient.id, { status: newStatus });
|
||||
resultItem.patientUpdateStatus = `Patient status updated to ${newStatus}`;
|
||||
await storage.updatePatient(updatedPatient.id, { status: newStatus, insuranceProvider: "MassHealth" });
|
||||
resultItem.patientUpdateStatus = `Patient status updated to ${newStatus}, insuranceProvider=MassHealth`;
|
||||
|
||||
// 2. updating appointment status - for aptmnt page
|
||||
try {
|
||||
|
||||
752
apps/Backend/src/routes/insuranceStatusCCA.ts
Normal file
752
apps/Backend/src/routes/insuranceStatusCCA.ts
Normal 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;
|
||||
@@ -109,6 +109,7 @@ async function createOrUpdatePatientByInsuranceId(options: {
|
||||
phone: "",
|
||||
userId,
|
||||
insuranceId,
|
||||
insuranceProvider: "Delta MA",
|
||||
};
|
||||
let patientData: InsertPatient;
|
||||
try {
|
||||
@@ -136,36 +137,135 @@ async function handleDdmaCompletedJob(
|
||||
|
||||
// We'll wrap the processing in try/catch/finally so cleanup always runs
|
||||
try {
|
||||
// 1) ensuring memberid.
|
||||
const insuranceEligibilityData = job.insuranceEligibilityData;
|
||||
const insuranceId = String(insuranceEligibilityData.memberId ?? "").trim();
|
||||
if (!insuranceId) {
|
||||
throw new Error("Missing memberId for ddma job");
|
||||
}
|
||||
|
||||
// 2) Create or update patient (with name from selenium result if available)
|
||||
// DEBUG: Log the raw selenium result
|
||||
console.log(`[ddma-eligibility] === DEBUG: Raw seleniumResult ===`);
|
||||
console.log(`[ddma-eligibility] seleniumResult.patientName: '${seleniumResult?.patientName}'`);
|
||||
console.log(`[ddma-eligibility] seleniumResult.memberId: '${seleniumResult?.memberId}'`);
|
||||
console.log(`[ddma-eligibility] seleniumResult.status: '${seleniumResult?.status}'`);
|
||||
|
||||
// 1) Get insuranceId - prefer from Selenium result (flexible search support)
|
||||
let insuranceId = String(seleniumResult?.memberId || "").trim();
|
||||
if (!insuranceId) {
|
||||
insuranceId = String(insuranceEligibilityData.memberId ?? "").trim();
|
||||
}
|
||||
console.log(`[ddma-eligibility] Resolved insuranceId: ${insuranceId || "(none)"}`);
|
||||
|
||||
// 2) Get patient name - prefer from Selenium result
|
||||
const patientNameFromResult =
|
||||
typeof seleniumResult?.patientName === "string"
|
||||
? seleniumResult.patientName.trim()
|
||||
: null;
|
||||
|
||||
const { firstName, lastName } = splitName(patientNameFromResult);
|
||||
console.log(`[ddma-eligibility] patientNameFromResult: '${patientNameFromResult}'`);
|
||||
|
||||
await createOrUpdatePatientByInsuranceId({
|
||||
insuranceId,
|
||||
firstName,
|
||||
lastName,
|
||||
dob: insuranceEligibilityData.dateOfBirth,
|
||||
userId: job.userId,
|
||||
});
|
||||
// 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}'`);
|
||||
|
||||
// 3) Find or create patient
|
||||
let patient: any = null;
|
||||
|
||||
// First, try to find by insuranceId if available
|
||||
if (insuranceId) {
|
||||
patient = await storage.getPatientByInsuranceId(insuranceId);
|
||||
if (patient) {
|
||||
console.log(`[ddma-eligibility] Found patient by insuranceId: ${patient.id}`);
|
||||
|
||||
// Update name if we have better data
|
||||
const updates: any = {};
|
||||
if (firstName && String(patient.firstName ?? "").trim() !== firstName) {
|
||||
updates.firstName = firstName;
|
||||
}
|
||||
if (lastName && String(patient.lastName ?? "").trim() !== lastName) {
|
||||
updates.lastName = lastName;
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await storage.updatePatient(patient.id, updates);
|
||||
console.log(`[ddma-eligibility] Updated patient name to: ${firstName} ${lastName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If not found by ID, try to find by name
|
||||
if (!patient && firstName && lastName) {
|
||||
try {
|
||||
console.log(`[ddma-eligibility] Looking up patient by name: ${firstName} ${lastName}`);
|
||||
const patients = await storage.getPatientsByUserId(job.userId);
|
||||
patient = patients.find(
|
||||
(p: any) =>
|
||||
String(p.firstName ?? "").toLowerCase() === firstName.toLowerCase() &&
|
||||
String(p.lastName ?? "").toLowerCase() === lastName.toLowerCase()
|
||||
) || null;
|
||||
if (patient) {
|
||||
console.log(`[ddma-eligibility] Found patient by name: ${patient.id}`);
|
||||
// Update insuranceId if we have it
|
||||
if (insuranceId && String(patient.insuranceId ?? "").trim() !== insuranceId) {
|
||||
await storage.updatePatient(patient.id, { insuranceId });
|
||||
console.log(`[ddma-eligibility] Updated patient insuranceId to: ${insuranceId}`);
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.log(`[ddma-eligibility] Error finding patient by name: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine eligibility status from Selenium result
|
||||
const eligibilityStatus = seleniumResult.eligibility === "active" ? "ACTIVE" : "INACTIVE";
|
||||
console.log(`[ddma-eligibility] Eligibility status from Delta MA: ${eligibilityStatus}`);
|
||||
|
||||
// If still not found, create new patient
|
||||
console.log(`[ddma-eligibility] Patient creation check: patient=${patient?.id || 'null'}, firstName='${firstName}', lastName='${lastName}'`);
|
||||
if (!patient && firstName && lastName) {
|
||||
console.log(`[ddma-eligibility] Creating new patient: ${firstName} ${lastName} with status: ${eligibilityStatus}`);
|
||||
try {
|
||||
let parsedDob: Date | undefined = undefined;
|
||||
if (insuranceEligibilityData.dateOfBirth) {
|
||||
try {
|
||||
parsedDob = new Date(insuranceEligibilityData.dateOfBirth);
|
||||
if (isNaN(parsedDob.getTime())) parsedDob = undefined;
|
||||
} catch {
|
||||
parsedDob = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const newPatientData: InsertPatient = {
|
||||
firstName,
|
||||
lastName,
|
||||
dateOfBirth: parsedDob || new Date(), // Required field
|
||||
insuranceId: insuranceId || undefined,
|
||||
insuranceProvider: "Delta MA", // Set insurance provider
|
||||
gender: "Unknown", // Required field - default value
|
||||
phone: "", // Required field - default empty
|
||||
userId: job.userId, // Required field
|
||||
status: eligibilityStatus, // Set status from eligibility check
|
||||
};
|
||||
|
||||
const validation = insertPatientSchema.safeParse(newPatientData);
|
||||
if (validation.success) {
|
||||
patient = await storage.createPatient(validation.data);
|
||||
console.log(`[ddma-eligibility] Created new patient: ${patient.id} with status: ${eligibilityStatus}`);
|
||||
} else {
|
||||
console.log(`[ddma-eligibility] Patient validation failed: ${validation.error.message}`);
|
||||
}
|
||||
} catch (createErr: any) {
|
||||
console.log(`[ddma-eligibility] Failed to create patient: ${createErr.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Update patient status + PDF upload
|
||||
const patient = await storage.getPatientByInsuranceId(
|
||||
insuranceEligibilityData.memberId
|
||||
);
|
||||
if (!patient?.id) {
|
||||
outputResult.patientUpdateStatus =
|
||||
"Patient not found; no update performed";
|
||||
"Patient not found and could not be created; no update performed";
|
||||
return {
|
||||
patientUpdateStatus: outputResult.patientUpdateStatus,
|
||||
pdfUploadStatus: "none",
|
||||
@@ -173,11 +273,10 @@ async function handleDdmaCompletedJob(
|
||||
};
|
||||
}
|
||||
|
||||
// update patient status.
|
||||
const newStatus =
|
||||
seleniumResult.eligibility === "active" ? "ACTIVE" : "INACTIVE";
|
||||
await storage.updatePatient(patient.id, { status: newStatus });
|
||||
outputResult.patientUpdateStatus = `Patient status updated to ${newStatus}`;
|
||||
// Update patient status from Delta MA eligibility result
|
||||
await storage.updatePatient(patient.id, { status: eligibilityStatus, insuranceProvider: "Delta MA" });
|
||||
outputResult.patientUpdateStatus = `Patient ${patient.id} status set to ${eligibilityStatus}, insuranceProvider=Delta MA (Delta MA eligibility: ${seleniumResult.eligibility})`;
|
||||
console.log(`[ddma-eligibility] ${outputResult.patientUpdateStatus}`);
|
||||
|
||||
// Handle PDF or convert screenshot -> pdf if available
|
||||
let pdfBuffer: Buffer | null = null;
|
||||
|
||||
749
apps/Backend/src/routes/insuranceStatusDeltaIns.ts
Normal file
749
apps/Backend/src/routes/insuranceStatusDeltaIns.ts
Normal file
@@ -0,0 +1,749 @@
|
||||
import { Router, Request, Response } from "express";
|
||||
import { storage } from "../storage";
|
||||
import {
|
||||
forwardToSeleniumDeltaInsEligibilityAgent,
|
||||
forwardOtpToSeleniumDeltaInsAgent,
|
||||
getSeleniumDeltaInsSessionStatus,
|
||||
} from "../services/seleniumDeltainsInsuranceEligibilityClient";
|
||||
import fs from "fs/promises";
|
||||
import fsSync from "fs";
|
||||
import path from "path";
|
||||
import PDFDocument from "pdfkit";
|
||||
import { emptyFolderContainingFile } from "../utils/emptyTempFolder";
|
||||
import {
|
||||
InsertPatient,
|
||||
insertPatientSchema,
|
||||
} from "../../../../packages/db/types/patient-types";
|
||||
import { io } from "../socket";
|
||||
|
||||
const router = Router();
|
||||
|
||||
/** Job context stored in memory by sessionId */
|
||||
interface DeltaInsJobContext {
|
||||
userId: number;
|
||||
insuranceEligibilityData: any;
|
||||
socketId?: string;
|
||||
}
|
||||
|
||||
const deltainsJobs: Record<string, DeltaInsJobContext> = {};
|
||||
|
||||
/** Utility: naive name splitter */
|
||||
function splitName(fullName?: string | null) {
|
||||
if (!fullName) return { firstName: "", lastName: "" };
|
||||
const parts = fullName.trim().split(/\s+/).filter(Boolean);
|
||||
const firstName = parts.shift() ?? "";
|
||||
const lastName = parts.join(" ") ?? "";
|
||||
return { firstName, lastName };
|
||||
}
|
||||
|
||||
async function imageToPdfBuffer(imagePath: string): Promise<Buffer> {
|
||||
return new Promise<Buffer>((resolve, reject) => {
|
||||
try {
|
||||
const doc = new PDFDocument({ autoFirstPage: false });
|
||||
const chunks: Uint8Array[] = [];
|
||||
|
||||
doc.on("data", (chunk: any) => chunks.push(chunk));
|
||||
doc.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
doc.on("error", (err: any) => reject(err));
|
||||
|
||||
const A4_WIDTH = 595.28;
|
||||
const A4_HEIGHT = 841.89;
|
||||
|
||||
doc.addPage({ size: [A4_WIDTH, A4_HEIGHT] });
|
||||
|
||||
doc.image(imagePath, 0, 0, {
|
||||
fit: [A4_WIDTH, A4_HEIGHT],
|
||||
align: "center",
|
||||
valign: "center",
|
||||
});
|
||||
|
||||
doc.end();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure patient exists for given insuranceId.
|
||||
*/
|
||||
async function createOrUpdatePatientByInsuranceId(options: {
|
||||
insuranceId: string;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
dob?: string | Date | null;
|
||||
userId: number;
|
||||
eligibilityStatus?: string;
|
||||
}) {
|
||||
const { insuranceId, firstName, lastName, dob, userId, eligibilityStatus } = options;
|
||||
if (!insuranceId) throw new Error("Missing insuranceId");
|
||||
|
||||
const incomingFirst = (firstName || "").trim();
|
||||
const incomingLast = (lastName || "").trim();
|
||||
|
||||
let patient = await storage.getPatientByInsuranceId(insuranceId);
|
||||
|
||||
if (patient && patient.id) {
|
||||
const updates: any = {};
|
||||
if (
|
||||
incomingFirst &&
|
||||
String(patient.firstName ?? "").trim() !== incomingFirst
|
||||
) {
|
||||
updates.firstName = incomingFirst;
|
||||
}
|
||||
if (
|
||||
incomingLast &&
|
||||
String(patient.lastName ?? "").trim() !== incomingLast
|
||||
) {
|
||||
updates.lastName = incomingLast;
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await storage.updatePatient(patient.id, updates);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
console.log(`[deltains-eligibility] Creating new patient: ${incomingFirst} ${incomingLast} with status: ${eligibilityStatus || "UNKNOWN"}`);
|
||||
const createPayload: any = {
|
||||
firstName: incomingFirst,
|
||||
lastName: incomingLast,
|
||||
dateOfBirth: dob,
|
||||
gender: "Unknown",
|
||||
phone: "",
|
||||
userId,
|
||||
insuranceId,
|
||||
insuranceProvider: "Delta Dental Ins",
|
||||
status: eligibilityStatus || "UNKNOWN",
|
||||
};
|
||||
let patientData: InsertPatient;
|
||||
try {
|
||||
patientData = insertPatientSchema.parse(createPayload);
|
||||
} catch (err) {
|
||||
const safePayload = { ...createPayload };
|
||||
delete (safePayload as any).dateOfBirth;
|
||||
patientData = insertPatientSchema.parse(safePayload);
|
||||
}
|
||||
const newPatient = await storage.createPatient(patientData);
|
||||
console.log(`[deltains-eligibility] Created new patient: ${newPatient.id} with status: ${eligibilityStatus || "UNKNOWN"}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When Selenium finishes for a given sessionId, run patient + PDF pipeline.
|
||||
*/
|
||||
async function handleDeltaInsCompletedJob(
|
||||
sessionId: string,
|
||||
job: DeltaInsJobContext,
|
||||
seleniumResult: any
|
||||
) {
|
||||
let createdPdfFileId: number | null = null;
|
||||
let generatedPdfPath: string | null = null;
|
||||
const outputResult: any = {};
|
||||
|
||||
try {
|
||||
const insuranceEligibilityData = job.insuranceEligibilityData;
|
||||
|
||||
let insuranceId = String(seleniumResult?.memberId ?? "").trim();
|
||||
if (!insuranceId) {
|
||||
insuranceId = String(insuranceEligibilityData.memberId ?? "").trim();
|
||||
}
|
||||
|
||||
if (!insuranceId) {
|
||||
console.log("[deltains-eligibility] No Member ID found - will use name for patient lookup");
|
||||
} else {
|
||||
console.log(`[deltains-eligibility] Using Member ID: ${insuranceId}`);
|
||||
}
|
||||
|
||||
const patientNameFromResult =
|
||||
typeof seleniumResult?.patientName === "string"
|
||||
? seleniumResult.patientName.trim()
|
||||
: null;
|
||||
|
||||
let firstName = insuranceEligibilityData.firstName || "";
|
||||
let lastName = insuranceEligibilityData.lastName || "";
|
||||
|
||||
if (patientNameFromResult) {
|
||||
const parsedName = splitName(patientNameFromResult);
|
||||
firstName = parsedName.firstName || firstName;
|
||||
lastName = parsedName.lastName || lastName;
|
||||
}
|
||||
|
||||
const rawEligibility = String(seleniumResult?.eligibility ?? "").toLowerCase();
|
||||
const eligibilityStatus = rawEligibility.includes("active") || rawEligibility.includes("eligible")
|
||||
? "ACTIVE" : "INACTIVE";
|
||||
console.log(`[deltains-eligibility] Eligibility status: ${eligibilityStatus}`);
|
||||
|
||||
if (insuranceId) {
|
||||
await createOrUpdatePatientByInsuranceId({
|
||||
insuranceId,
|
||||
firstName,
|
||||
lastName,
|
||||
dob: insuranceEligibilityData.dateOfBirth,
|
||||
userId: job.userId,
|
||||
eligibilityStatus,
|
||||
});
|
||||
}
|
||||
|
||||
let patient = insuranceId
|
||||
? await storage.getPatientByInsuranceId(insuranceId)
|
||||
: null;
|
||||
|
||||
if (!patient?.id && firstName && lastName) {
|
||||
const patients = await storage.getAllPatients(job.userId);
|
||||
patient = patients.find(
|
||||
(p) =>
|
||||
p.firstName?.toLowerCase() === firstName.toLowerCase() &&
|
||||
p.lastName?.toLowerCase() === lastName.toLowerCase()
|
||||
) ?? null;
|
||||
if (patient) {
|
||||
console.log(`[deltains-eligibility] Found patient by name: ${patient.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!patient && firstName && lastName) {
|
||||
console.log(`[deltains-eligibility] Creating new patient: ${firstName} ${lastName}`);
|
||||
try {
|
||||
let parsedDob: Date | undefined = undefined;
|
||||
if (insuranceEligibilityData.dateOfBirth) {
|
||||
try {
|
||||
parsedDob = new Date(insuranceEligibilityData.dateOfBirth);
|
||||
if (isNaN(parsedDob.getTime())) parsedDob = undefined;
|
||||
} catch {
|
||||
parsedDob = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const newPatientData: InsertPatient = {
|
||||
firstName,
|
||||
lastName,
|
||||
dateOfBirth: parsedDob || new Date(),
|
||||
insuranceId: insuranceId || undefined,
|
||||
insuranceProvider: "Delta Dental Ins",
|
||||
gender: "Unknown",
|
||||
phone: "",
|
||||
userId: job.userId,
|
||||
status: eligibilityStatus,
|
||||
};
|
||||
|
||||
const validation = insertPatientSchema.safeParse(newPatientData);
|
||||
if (validation.success) {
|
||||
patient = await storage.createPatient(validation.data);
|
||||
console.log(`[deltains-eligibility] Created new patient: ${patient.id}`);
|
||||
} else {
|
||||
console.log(`[deltains-eligibility] Patient validation failed: ${validation.error.message}`);
|
||||
}
|
||||
} catch (createErr: any) {
|
||||
console.log(`[deltains-eligibility] Failed to create patient: ${createErr.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!patient?.id) {
|
||||
outputResult.patientUpdateStatus =
|
||||
"Patient not found and could not be created; no update performed";
|
||||
return {
|
||||
patientUpdateStatus: outputResult.patientUpdateStatus,
|
||||
pdfUploadStatus: "none",
|
||||
pdfFileId: null,
|
||||
};
|
||||
}
|
||||
|
||||
const updatePayload: Record<string, any> = { status: eligibilityStatus, 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;
|
||||
@@ -109,6 +109,7 @@ async function createOrUpdatePatientByInsuranceId(options: {
|
||||
phone: "",
|
||||
userId,
|
||||
insuranceId,
|
||||
insuranceProvider: "Tufts / DentaQuest",
|
||||
};
|
||||
let patientData: InsertPatient;
|
||||
try {
|
||||
@@ -136,36 +137,117 @@ async function handleDentaQuestCompletedJob(
|
||||
|
||||
// We'll wrap the processing in try/catch/finally so cleanup always runs
|
||||
try {
|
||||
// 1) ensuring memberid.
|
||||
const insuranceEligibilityData = job.insuranceEligibilityData;
|
||||
const insuranceId = String(insuranceEligibilityData.memberId ?? "").trim();
|
||||
|
||||
// 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) {
|
||||
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)
|
||||
const patientNameFromResult =
|
||||
typeof seleniumResult?.patientName === "string"
|
||||
? seleniumResult.patientName.trim()
|
||||
: null;
|
||||
|
||||
const { firstName, lastName } = splitName(patientNameFromResult);
|
||||
// Get name from request data as fallback
|
||||
let firstName = insuranceEligibilityData.firstName || "";
|
||||
let lastName = insuranceEligibilityData.lastName || "";
|
||||
|
||||
await createOrUpdatePatientByInsuranceId({
|
||||
insuranceId,
|
||||
firstName,
|
||||
lastName,
|
||||
dob: insuranceEligibilityData.dateOfBirth,
|
||||
userId: job.userId,
|
||||
});
|
||||
// Override with name from Selenium result if available
|
||||
if (patientNameFromResult) {
|
||||
const parsedName = splitName(patientNameFromResult);
|
||||
firstName = parsedName.firstName || firstName;
|
||||
lastName = parsedName.lastName || lastName;
|
||||
}
|
||||
|
||||
// Create or update patient if we have an insurance ID
|
||||
if (insuranceId) {
|
||||
await createOrUpdatePatientByInsuranceId({
|
||||
insuranceId,
|
||||
firstName,
|
||||
lastName,
|
||||
dob: insuranceEligibilityData.dateOfBirth,
|
||||
userId: job.userId,
|
||||
});
|
||||
} else {
|
||||
console.log("[dentaquest-eligibility] No Member ID available - will try to find patient by name/DOB");
|
||||
}
|
||||
|
||||
// 3) Update patient status + PDF upload
|
||||
const patient = await storage.getPatientByInsuranceId(
|
||||
insuranceEligibilityData.memberId
|
||||
);
|
||||
// First try to find by insurance ID, then by name + DOB
|
||||
let patient = insuranceId
|
||||
? await storage.getPatientByInsuranceId(insuranceId)
|
||||
: null;
|
||||
|
||||
// If not found by ID and we have name + DOB, try to find by those
|
||||
if (!patient && firstName && lastName) {
|
||||
console.log(`[dentaquest-eligibility] Looking up patient by name: ${firstName} ${lastName}`);
|
||||
const patients = await storage.getPatientsByUserId(job.userId);
|
||||
patient = patients.find(p =>
|
||||
p.firstName?.toLowerCase() === firstName.toLowerCase() &&
|
||||
p.lastName?.toLowerCase() === lastName.toLowerCase()
|
||||
) || null;
|
||||
|
||||
// If found and we now have the insurance ID, update the patient record
|
||||
if (patient && insuranceId) {
|
||||
await storage.updatePatient(patient.id, { insuranceId });
|
||||
console.log(`[dentaquest-eligibility] Updated patient ${patient.id} with insuranceId: ${insuranceId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine eligibility status from Selenium result
|
||||
const eligibilityStatus = seleniumResult.eligibility === "active" ? "ACTIVE" : "INACTIVE";
|
||||
console.log(`[dentaquest-eligibility] Eligibility status from DentaQuest: ${eligibilityStatus}`);
|
||||
|
||||
// If still no patient found, CREATE a new one with the data we have
|
||||
if (!patient?.id && firstName && lastName) {
|
||||
console.log(`[dentaquest-eligibility] Creating new patient: ${firstName} ${lastName} with status: ${eligibilityStatus}`);
|
||||
|
||||
const createPayload: any = {
|
||||
firstName,
|
||||
lastName,
|
||||
dateOfBirth: insuranceEligibilityData.dateOfBirth || null,
|
||||
gender: "",
|
||||
phone: "",
|
||||
userId: job.userId,
|
||||
insuranceId: insuranceId || null,
|
||||
insuranceProvider: "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) {
|
||||
outputResult.patientUpdateStatus =
|
||||
"Patient not found; no update performed";
|
||||
"Patient not found and could not be created";
|
||||
return {
|
||||
patientUpdateStatus: outputResult.patientUpdateStatus,
|
||||
pdfUploadStatus: "none",
|
||||
@@ -173,11 +255,10 @@ async function handleDentaQuestCompletedJob(
|
||||
};
|
||||
}
|
||||
|
||||
// update patient status.
|
||||
const newStatus =
|
||||
seleniumResult.eligibility === "active" ? "ACTIVE" : "INACTIVE";
|
||||
await storage.updatePatient(patient.id, { status: newStatus });
|
||||
outputResult.patientUpdateStatus = `Patient status updated to ${newStatus}`;
|
||||
// Update patient status from DentaQuest eligibility result
|
||||
await storage.updatePatient(patient.id, { status: eligibilityStatus, insuranceProvider: "Tufts / DentaQuest" });
|
||||
outputResult.patientUpdateStatus = `Patient ${patient.id} status set to ${eligibilityStatus}, insuranceProvider=Tufts / DentaQuest (DentaQuest eligibility: ${seleniumResult.eligibility})`;
|
||||
console.log(`[dentaquest-eligibility] ${outputResult.patientUpdateStatus}`);
|
||||
|
||||
// Handle PDF or convert screenshot -> pdf if available
|
||||
let pdfBuffer: Buffer | null = null;
|
||||
|
||||
@@ -73,8 +73,9 @@ async function createOrUpdatePatientByInsuranceId(options: {
|
||||
lastName?: string | null;
|
||||
dob?: string | Date | null;
|
||||
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");
|
||||
|
||||
const incomingFirst = (firstName || "").trim();
|
||||
@@ -101,14 +102,17 @@ async function createOrUpdatePatientByInsuranceId(options: {
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
console.log(`[unitedsco-eligibility] Creating new patient: ${incomingFirst} ${incomingLast} with status: ${eligibilityStatus || "UNKNOWN"}`);
|
||||
const createPayload: any = {
|
||||
firstName: incomingFirst,
|
||||
lastName: incomingLast,
|
||||
dateOfBirth: dob,
|
||||
gender: "",
|
||||
gender: "Unknown",
|
||||
phone: "",
|
||||
userId,
|
||||
insuranceId,
|
||||
insuranceProvider: "United SCO",
|
||||
status: eligibilityStatus || "UNKNOWN",
|
||||
};
|
||||
let patientData: InsertPatient;
|
||||
try {
|
||||
@@ -118,7 +122,8 @@ async function createOrUpdatePatientByInsuranceId(options: {
|
||||
delete (safePayload as any).dateOfBirth;
|
||||
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
|
||||
) {
|
||||
let createdPdfFileId: number | null = null;
|
||||
let generatedPdfPath: string | null = null;
|
||||
const outputResult: any = {};
|
||||
|
||||
// We'll wrap the processing in try/catch/finally so cleanup always runs
|
||||
@@ -170,6 +176,10 @@ async function handleUnitedSCOCompletedJob(
|
||||
lastName = parsedName.lastName || lastName;
|
||||
}
|
||||
|
||||
// Determine eligibility status from Selenium result
|
||||
const eligibilityStatus = seleniumResult.eligibility === "active" ? "ACTIVE" : "INACTIVE";
|
||||
console.log(`[unitedsco-eligibility] Eligibility status from United SCO: ${eligibilityStatus}`);
|
||||
|
||||
// 3) Create or update patient
|
||||
if (insuranceId) {
|
||||
await createOrUpdatePatientByInsuranceId({
|
||||
@@ -178,6 +188,7 @@ async function handleUnitedSCOCompletedJob(
|
||||
lastName,
|
||||
dob: insuranceEligibilityData.dateOfBirth,
|
||||
userId: job.userId,
|
||||
eligibilityStatus,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -186,9 +197,61 @@ async function handleUnitedSCOCompletedJob(
|
||||
? await storage.getPatientByInsuranceId(insuranceId)
|
||||
: null;
|
||||
|
||||
// If no patient found by insuranceId, try to find by firstName + lastName
|
||||
if (!patient?.id && firstName && lastName) {
|
||||
const patients = await storage.getAllPatients(job.userId);
|
||||
patient = patients.find(
|
||||
(p) =>
|
||||
p.firstName?.toLowerCase() === firstName.toLowerCase() &&
|
||||
p.lastName?.toLowerCase() === lastName.toLowerCase()
|
||||
) ?? null;
|
||||
if (patient) {
|
||||
console.log(`[unitedsco-eligibility] Found patient by name: ${patient.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// If still not found, create new patient
|
||||
console.log(`[unitedsco-eligibility] Patient creation check: patient=${patient?.id || 'null'}, firstName='${firstName}', lastName='${lastName}'`);
|
||||
if (!patient && firstName && lastName) {
|
||||
console.log(`[unitedsco-eligibility] Creating new patient: ${firstName} ${lastName} with status: ${eligibilityStatus}`);
|
||||
try {
|
||||
let parsedDob: Date | undefined = undefined;
|
||||
if (insuranceEligibilityData.dateOfBirth) {
|
||||
try {
|
||||
parsedDob = new Date(insuranceEligibilityData.dateOfBirth);
|
||||
if (isNaN(parsedDob.getTime())) parsedDob = undefined;
|
||||
} catch {
|
||||
parsedDob = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const newPatientData: InsertPatient = {
|
||||
firstName,
|
||||
lastName,
|
||||
dateOfBirth: parsedDob || new Date(), // Required field
|
||||
insuranceId: insuranceId || undefined,
|
||||
insuranceProvider: "United SCO",
|
||||
gender: "Unknown",
|
||||
phone: "",
|
||||
userId: job.userId,
|
||||
status: eligibilityStatus,
|
||||
};
|
||||
|
||||
const validation = insertPatientSchema.safeParse(newPatientData);
|
||||
if (validation.success) {
|
||||
patient = await storage.createPatient(validation.data);
|
||||
console.log(`[unitedsco-eligibility] Created new patient: ${patient.id} with status: ${eligibilityStatus}`);
|
||||
} else {
|
||||
console.log(`[unitedsco-eligibility] Patient validation failed: ${validation.error.message}`);
|
||||
}
|
||||
} catch (createErr: any) {
|
||||
console.log(`[unitedsco-eligibility] Failed to create patient: ${createErr.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!patient?.id) {
|
||||
outputResult.patientUpdateStatus =
|
||||
"Patient not found; no update performed";
|
||||
"Patient not found and could not be created; no update performed";
|
||||
return {
|
||||
patientUpdateStatus: outputResult.patientUpdateStatus,
|
||||
pdfUploadStatus: "none",
|
||||
@@ -196,15 +259,22 @@ async function handleUnitedSCOCompletedJob(
|
||||
};
|
||||
}
|
||||
|
||||
// Update patient status
|
||||
const newStatus =
|
||||
seleniumResult.eligibility === "active" ? "ACTIVE" : "INACTIVE";
|
||||
await storage.updatePatient(patient.id, { status: newStatus });
|
||||
outputResult.patientUpdateStatus = `Patient status updated to ${newStatus}`;
|
||||
// Update patient status and name from United SCO eligibility result
|
||||
const updatePayload: Record<string, any> = { status: eligibilityStatus, insuranceProvider: "United SCO" };
|
||||
|
||||
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=United SCO, name=${firstName} ${lastName} (United SCO eligibility: ${seleniumResult.eligibility})`;
|
||||
console.log(`[unitedsco-eligibility] ${outputResult.patientUpdateStatus}`);
|
||||
|
||||
// Handle PDF or convert screenshot -> pdf if available
|
||||
let pdfBuffer: Buffer | null = null;
|
||||
let generatedPdfPath: string | null = null;
|
||||
|
||||
if (
|
||||
seleniumResult &&
|
||||
@@ -233,7 +303,8 @@ async function handleUnitedSCOCompletedJob(
|
||||
// Convert image to PDF
|
||||
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(
|
||||
path.dirname(seleniumResult.ss_path),
|
||||
pdfFileName
|
||||
@@ -287,18 +358,25 @@ async function handleUnitedSCOCompletedJob(
|
||||
"No valid PDF path provided by Selenium, Couldn't upload pdf to server.";
|
||||
}
|
||||
|
||||
// Get filename for frontend preview
|
||||
const pdfFilename = generatedPdfPath ? path.basename(generatedPdfPath) : null;
|
||||
|
||||
return {
|
||||
patientUpdateStatus: outputResult.patientUpdateStatus,
|
||||
pdfUploadStatus: outputResult.pdfUploadStatus,
|
||||
pdfFileId: createdPdfFileId,
|
||||
pdfFilename,
|
||||
};
|
||||
} catch (err: any) {
|
||||
// Get filename for frontend preview if available
|
||||
const pdfFilename = generatedPdfPath ? path.basename(generatedPdfPath) : null;
|
||||
return {
|
||||
patientUpdateStatus: outputResult.patientUpdateStatus,
|
||||
pdfUploadStatus:
|
||||
outputResult.pdfUploadStatus ??
|
||||
`Failed to process United SCO job: ${err?.message ?? String(err)}`,
|
||||
pdfFileId: createdPdfFileId,
|
||||
pdfFilename,
|
||||
error: err?.message ?? String(err),
|
||||
};
|
||||
} finally {
|
||||
|
||||
@@ -3,16 +3,13 @@ import type { Request, Response } from "express";
|
||||
import { storage } from "../storage";
|
||||
import { z } from "zod";
|
||||
import { UserUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas";
|
||||
import jwt from 'jsonwebtoken';
|
||||
import bcrypt from 'bcrypt';
|
||||
import bcrypt from "bcrypt";
|
||||
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Type based on shared schema
|
||||
type SelectUser = z.infer<typeof UserUncheckedCreateInputObjectSchema>;
|
||||
|
||||
// Zod validation
|
||||
const userCreateSchema = UserUncheckedCreateInputObjectSchema;
|
||||
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);
|
||||
if (!user) return res.status(404).send("User not found");
|
||||
|
||||
|
||||
const { password, ...safeUser } = user;
|
||||
res.json(safeUser);
|
||||
const { password, ...rest } = user;
|
||||
res.json({ ...rest, role: rest.role ?? "USER" });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
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> => {
|
||||
try {
|
||||
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);
|
||||
if (!user) return res.status(404).send("User not found");
|
||||
|
||||
const { password, ...safeUser } = user;
|
||||
res.json(safeUser);
|
||||
const { password, ...rest } = user;
|
||||
res.json({ ...rest, role: rest.role ?? "USER" });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).send("Failed to fetch user");
|
||||
}
|
||||
});
|
||||
|
||||
// POST: Create new user
|
||||
router.post("/", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const input = userCreateSchema.parse(req.body);
|
||||
const newUser = await storage.createUser(input);
|
||||
const { password, ...safeUser } = newUser;
|
||||
res.status(201).json(safeUser);
|
||||
const existing = await storage.getUserByUsername(input.username);
|
||||
if (existing) {
|
||||
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) {
|
||||
console.error(err);
|
||||
res.status(400).json({ error: "Invalid user data", details: err });
|
||||
}
|
||||
});
|
||||
|
||||
// Function to hash password using bcrypt
|
||||
async function hashPassword(password: string) {
|
||||
const saltRounds = 10; // Salt rounds for bcrypt
|
||||
const hashedPassword = await bcrypt.hash(password, saltRounds);
|
||||
return hashedPassword;
|
||||
const saltRounds = 10;
|
||||
return bcrypt.hash(password, saltRounds);
|
||||
}
|
||||
|
||||
// PUT: Update user
|
||||
router.put("/:id", async (req: Request, res: Response):Promise<any> => {
|
||||
try {
|
||||
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);
|
||||
|
||||
// If password is provided and non-empty, hash it
|
||||
if (updates.password && updates.password.trim() !== "") {
|
||||
updates.password = await hashPassword(updates.password);
|
||||
} else {
|
||||
// Remove password field if empty, so it won't overwrite existing password with blank
|
||||
delete updates.password;
|
||||
}
|
||||
|
||||
const updatedUser = await storage.updateUser(id, updates);
|
||||
if (!updatedUser) return res.status(404).send("User not found");
|
||||
|
||||
const { password, ...safeUser } = updatedUser;
|
||||
res.json(safeUser);
|
||||
const { password, ...rest } = updatedUser;
|
||||
res.json({ ...rest, role: rest.role ?? "USER" });
|
||||
} catch (err) {
|
||||
console.error(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 {
|
||||
const idParam = req.params.id;
|
||||
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);
|
||||
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);
|
||||
if (!success) return res.status(404).send("User not found");
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
NODE_ENV=development
|
||||
HOST=0.0.0.0
|
||||
PORT=3000
|
||||
VITE_API_BASE_URL_BACKEND=http://localhost:5000
|
||||
# VITE_API_BASE_URL_BACKEND=http://localhost:5000
|
||||
VITE_API_BASE_URL_BACKEND=
|
||||
|
||||
@@ -41,7 +41,7 @@ function Router() {
|
||||
component={() => <AppointmentsPage />}
|
||||
/>
|
||||
<ProtectedRoute path="/patients" component={() => <PatientsPage />} />
|
||||
<ProtectedRoute path="/settings" component={() => <SettingsPage />} />
|
||||
<ProtectedRoute path="/settings" component={() => <SettingsPage />} adminOnly />
|
||||
<ProtectedRoute path="/claims" component={() => <ClaimsPage />} />
|
||||
<ProtectedRoute
|
||||
path="/insurance-status"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -119,6 +119,10 @@ export function DdmaEligibilityButton({
|
||||
const { toast } = useToast();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// Flexible validation: require DOB + at least one identifier (memberId OR firstName OR lastName)
|
||||
const isDdmaFormIncomplete =
|
||||
!dateOfBirth || (!memberId && !firstName && !lastName);
|
||||
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const connectingRef = useRef<Promise<void> | null>(null);
|
||||
|
||||
@@ -371,10 +375,11 @@ export function DdmaEligibilityButton({
|
||||
};
|
||||
|
||||
const startDdmaEligibility = async () => {
|
||||
if (!memberId || !dateOfBirth) {
|
||||
// Flexible validation: require DOB + at least one identifier
|
||||
if (!dateOfBirth || (!memberId && !firstName && !lastName)) {
|
||||
toast({
|
||||
title: "Missing fields",
|
||||
description: "Member ID and Date of Birth are required.",
|
||||
description: "Date of Birth and at least one identifier (Member ID, First Name, or Last Name) are required.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
@@ -539,7 +544,7 @@ export function DdmaEligibilityButton({
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="default"
|
||||
disabled={isFormIncomplete || isStarting}
|
||||
disabled={isDdmaFormIncomplete || isStarting}
|
||||
onClick={startDdmaEligibility}
|
||||
>
|
||||
{isStarting ? (
|
||||
|
||||
@@ -0,0 +1,551 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { io as ioClient, Socket } from "socket.io-client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { CheckCircle, LoaderCircleIcon, X } from "lucide-react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { useAppDispatch } from "@/redux/hooks";
|
||||
import { setTaskStatus } from "@/redux/slices/seleniumEligibilityCheckTaskSlice";
|
||||
import { formatLocalDate } from "@/utils/dateUtils";
|
||||
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
|
||||
|
||||
const SOCKET_URL =
|
||||
import.meta.env.VITE_API_BASE_URL_BACKEND ||
|
||||
(typeof window !== "undefined" ? window.location.origin : "");
|
||||
|
||||
// ---------- OTP Modal component ----------
|
||||
interface DeltaInsOtpModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (otp: string) => Promise<void> | void;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
function DeltaInsOtpModal({
|
||||
open,
|
||||
onClose,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
}: DeltaInsOtpModalProps) {
|
||||
const [otp, setOtp] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setOtp("");
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!otp.trim()) return;
|
||||
await onSubmit(otp.trim());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white dark:bg-slate-900 rounded-xl shadow-lg w-full max-w-md p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Enter OTP</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-slate-500 hover:text-slate-800"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mb-4">
|
||||
We need the one-time password (OTP) sent to your email by Delta Dental
|
||||
Ins to complete this eligibility check.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="deltains-otp">OTP</Label>
|
||||
<Input
|
||||
id="deltains-otp"
|
||||
placeholder="Enter OTP code"
|
||||
value={otp}
|
||||
onChange={(e) => setOtp(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting || !otp.trim()}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<LoaderCircleIcon className="w-4 h-4 mr-2 animate-spin" />
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
"Submit OTP"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Main DeltaIns Eligibility button component ----------
|
||||
interface DeltaInsEligibilityButtonProps {
|
||||
memberId: string;
|
||||
dateOfBirth: Date | null;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
isFormIncomplete: boolean;
|
||||
onPdfReady: (pdfId: number, fallbackFilename: string | null) => void;
|
||||
}
|
||||
|
||||
export function DeltaInsEligibilityButton({
|
||||
memberId,
|
||||
dateOfBirth,
|
||||
firstName,
|
||||
lastName,
|
||||
isFormIncomplete,
|
||||
onPdfReady,
|
||||
}: DeltaInsEligibilityButtonProps) {
|
||||
const { toast } = useToast();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const isDeltaInsFormIncomplete =
|
||||
!dateOfBirth || (!memberId && !firstName && !lastName);
|
||||
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const connectingRef = useRef<Promise<void> | null>(null);
|
||||
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [otpModalOpen, setOtpModalOpen] = useState(false);
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
const [isSubmittingOtp, setIsSubmittingOtp] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (socketRef.current) {
|
||||
socketRef.current.removeAllListeners();
|
||||
socketRef.current.disconnect();
|
||||
socketRef.current = null;
|
||||
}
|
||||
connectingRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const closeSocket = () => {
|
||||
try {
|
||||
socketRef.current?.removeAllListeners();
|
||||
socketRef.current?.disconnect();
|
||||
} catch (e) {
|
||||
// ignore
|
||||
} finally {
|
||||
socketRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const ensureSocketConnected = async () => {
|
||||
if (socketRef.current && socketRef.current.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (connectingRef.current) {
|
||||
return connectingRef.current;
|
||||
}
|
||||
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
const socket = ioClient(SOCKET_URL, {
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.on("connect", () => {
|
||||
resolve();
|
||||
});
|
||||
|
||||
socket.on("connect_error", (err: any) => {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "error",
|
||||
message: "Connection failed",
|
||||
})
|
||||
);
|
||||
toast({
|
||||
title: "Realtime connection failed",
|
||||
description:
|
||||
"Could not connect to realtime server. Retrying automatically...",
|
||||
variant: "destructive",
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("reconnect_attempt", (attempt: number) => {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "pending",
|
||||
message: `Realtime reconnect attempt #${attempt}`,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
socket.on("reconnect_failed", () => {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "error",
|
||||
message: "Reconnect failed",
|
||||
})
|
||||
);
|
||||
toast({
|
||||
title: "Realtime reconnect failed",
|
||||
description:
|
||||
"Connection to realtime server could not be re-established. Please try again later.",
|
||||
variant: "destructive",
|
||||
});
|
||||
closeSocket();
|
||||
reject(new Error("Realtime reconnect failed"));
|
||||
});
|
||||
|
||||
socket.on("disconnect", (reason: any) => {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "error",
|
||||
message: "Connection disconnected",
|
||||
})
|
||||
);
|
||||
toast({
|
||||
title: "Connection Disconnected",
|
||||
description:
|
||||
"Connection to the server was lost. If a DeltaIns job was running it may have failed.",
|
||||
variant: "destructive",
|
||||
});
|
||||
setSessionId(null);
|
||||
setOtpModalOpen(false);
|
||||
});
|
||||
|
||||
// OTP required
|
||||
socket.on("selenium:otp_required", (payload: any) => {
|
||||
if (!payload?.session_id) return;
|
||||
setSessionId(payload.session_id);
|
||||
setOtpModalOpen(true);
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "pending",
|
||||
message: "OTP required for Delta Dental Ins eligibility. Please enter the code sent to your email.",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// OTP submitted
|
||||
socket.on("selenium:otp_submitted", (payload: any) => {
|
||||
if (!payload?.session_id) return;
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "pending",
|
||||
message: "OTP submitted. Finishing Delta Dental Ins eligibility check...",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Session update
|
||||
socket.on("selenium:session_update", (payload: any) => {
|
||||
const { session_id, status, final } = payload || {};
|
||||
if (!session_id) return;
|
||||
|
||||
if (status === "completed") {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "success",
|
||||
message:
|
||||
"Delta Dental Ins eligibility updated and PDF attached to patient documents.",
|
||||
})
|
||||
);
|
||||
toast({
|
||||
title: "Delta Dental Ins eligibility complete",
|
||||
description:
|
||||
"Patient status was updated and the eligibility PDF was saved.",
|
||||
variant: "default",
|
||||
});
|
||||
|
||||
const pdfId = final?.pdfFileId;
|
||||
if (pdfId) {
|
||||
const filename =
|
||||
final?.pdfFilename ?? `eligibility_deltains_${memberId}.pdf`;
|
||||
onPdfReady(Number(pdfId), filename);
|
||||
}
|
||||
|
||||
setSessionId(null);
|
||||
setOtpModalOpen(false);
|
||||
} else if (status === "error") {
|
||||
const msg =
|
||||
payload?.message ||
|
||||
final?.error ||
|
||||
"Delta Dental Ins eligibility session failed.";
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "error",
|
||||
message: msg,
|
||||
})
|
||||
);
|
||||
toast({
|
||||
title: "Delta Dental Ins selenium error",
|
||||
description: msg,
|
||||
variant: "destructive",
|
||||
});
|
||||
|
||||
try {
|
||||
closeSocket();
|
||||
} catch (e) {}
|
||||
setSessionId(null);
|
||||
setOtpModalOpen(false);
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
|
||||
});
|
||||
|
||||
// explicit session error event
|
||||
socket.on("selenium:session_error", (payload: any) => {
|
||||
const msg = payload?.message || "Selenium session error";
|
||||
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "error",
|
||||
message: msg,
|
||||
})
|
||||
);
|
||||
|
||||
toast({
|
||||
title: "Selenium session error",
|
||||
description: msg,
|
||||
variant: "destructive",
|
||||
});
|
||||
|
||||
try {
|
||||
closeSocket();
|
||||
} catch (e) {}
|
||||
setSessionId(null);
|
||||
setOtpModalOpen(false);
|
||||
});
|
||||
|
||||
const initialConnectTimeout = setTimeout(() => {
|
||||
if (!socket.connected) {
|
||||
closeSocket();
|
||||
reject(new Error("Realtime initial connection timeout"));
|
||||
}
|
||||
}, 8000);
|
||||
|
||||
socket.once("connect", () => {
|
||||
clearTimeout(initialConnectTimeout);
|
||||
});
|
||||
});
|
||||
|
||||
connectingRef.current = promise;
|
||||
|
||||
try {
|
||||
await promise;
|
||||
} finally {
|
||||
connectingRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const startDeltaInsEligibility = async () => {
|
||||
if (!dateOfBirth) {
|
||||
toast({
|
||||
title: "Missing fields",
|
||||
description: "Date of Birth is required for Delta Dental Ins eligibility.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!memberId && !firstName && !lastName) {
|
||||
toast({
|
||||
title: "Missing fields",
|
||||
description: "Member ID, First Name, or Last Name is required for Delta Dental Ins eligibility.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const formattedDob = dateOfBirth ? formatLocalDate(dateOfBirth) : "";
|
||||
|
||||
const payload = {
|
||||
memberId: memberId || "",
|
||||
dateOfBirth: formattedDob,
|
||||
firstName: firstName || "",
|
||||
lastName: lastName || "",
|
||||
insuranceSiteKey: "DELTAINS",
|
||||
};
|
||||
|
||||
try {
|
||||
setIsStarting(true);
|
||||
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "pending",
|
||||
message: "Opening realtime channel for Delta Dental Ins eligibility...",
|
||||
})
|
||||
);
|
||||
await ensureSocketConnected();
|
||||
|
||||
const socket = socketRef.current;
|
||||
if (!socket || !socket.connected) {
|
||||
throw new Error("Socket connection failed");
|
||||
}
|
||||
|
||||
const socketId = socket.id;
|
||||
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "pending",
|
||||
message: "Starting Delta Dental Ins eligibility check via selenium...",
|
||||
})
|
||||
);
|
||||
|
||||
const response = await apiRequest(
|
||||
"POST",
|
||||
"/api/insurance-status-deltains/deltains-eligibility",
|
||||
{
|
||||
data: JSON.stringify(payload),
|
||||
socketId,
|
||||
}
|
||||
);
|
||||
|
||||
let result: any = null;
|
||||
let backendError: string | null = null;
|
||||
|
||||
try {
|
||||
result = await response.clone().json();
|
||||
backendError =
|
||||
result?.error || result?.message || result?.detail || null;
|
||||
} catch {
|
||||
try {
|
||||
const text = await response.clone().text();
|
||||
backendError = text?.trim() || null;
|
||||
} catch {
|
||||
backendError = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
backendError ||
|
||||
`Delta Dental Ins selenium start failed (status ${response.status})`
|
||||
);
|
||||
}
|
||||
|
||||
if (result?.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
if (result.status === "started" && result.session_id) {
|
||||
setSessionId(result.session_id as string);
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "pending",
|
||||
message:
|
||||
"Delta Dental Ins eligibility job started. Waiting for OTP or final result...",
|
||||
})
|
||||
);
|
||||
} else {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "success",
|
||||
message: "Delta Dental Ins eligibility completed.",
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("startDeltaInsEligibility error:", err);
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "error",
|
||||
message: err?.message || "Failed to start Delta Dental Ins eligibility",
|
||||
})
|
||||
);
|
||||
toast({
|
||||
title: "Delta Dental Ins selenium error",
|
||||
description: err?.message || "Failed to start Delta Dental Ins eligibility",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitOtp = async (otp: string) => {
|
||||
if (!sessionId || !socketRef.current || !socketRef.current.connected) {
|
||||
toast({
|
||||
title: "Session not ready",
|
||||
description:
|
||||
"Could not submit OTP because the DeltaIns session or socket is not ready.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmittingOtp(true);
|
||||
const resp = await apiRequest(
|
||||
"POST",
|
||||
"/api/insurance-status-deltains/selenium/submit-otp",
|
||||
{
|
||||
session_id: sessionId,
|
||||
otp,
|
||||
socketId: socketRef.current.id,
|
||||
}
|
||||
);
|
||||
const data = await resp.json();
|
||||
if (!resp.ok || data.error) {
|
||||
throw new Error(data.error || "Failed to submit OTP");
|
||||
}
|
||||
|
||||
setOtpModalOpen(false);
|
||||
} catch (err: any) {
|
||||
console.error("handleSubmitOtp error:", err);
|
||||
toast({
|
||||
title: "Failed to submit OTP",
|
||||
description: err?.message || "Error forwarding OTP to selenium agent",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmittingOtp(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
className="w-full"
|
||||
|
||||
disabled={isDeltaInsFormIncomplete || isStarting}
|
||||
onClick={startDeltaInsEligibility}
|
||||
>
|
||||
{isStarting ? (
|
||||
<>
|
||||
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Delta Dental Ins
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<DeltaInsOtpModal
|
||||
open={otpModalOpen}
|
||||
onClose={() => setOtpModalOpen(false)}
|
||||
onSubmit={handleSubmitOtp}
|
||||
isSubmitting={isSubmittingOtp}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -127,6 +127,11 @@ export function DentaQuestEligibilityButton({
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
const [isSubmittingOtp, setIsSubmittingOtp] = useState(false);
|
||||
|
||||
// DentaQuest allows flexible search - only DOB is required, plus at least one identifier
|
||||
// Can use: memberId, firstName, lastName, or any combination
|
||||
const hasAnyIdentifier = memberId || firstName || lastName;
|
||||
const isDentaQuestFormIncomplete = !dateOfBirth || !hasAnyIdentifier;
|
||||
|
||||
// Clean up socket on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -370,10 +375,13 @@ export function DentaQuestEligibilityButton({
|
||||
};
|
||||
|
||||
const startDentaQuestEligibility = async () => {
|
||||
if (!memberId || !dateOfBirth) {
|
||||
// Flexible search - DOB required plus at least one identifier
|
||||
const hasAnyIdentifier = memberId || firstName || lastName;
|
||||
|
||||
if (!dateOfBirth || !hasAnyIdentifier) {
|
||||
toast({
|
||||
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",
|
||||
});
|
||||
return;
|
||||
@@ -537,8 +545,8 @@ export function DentaQuestEligibilityButton({
|
||||
<>
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
disabled={isFormIncomplete || isStarting}
|
||||
|
||||
disabled={isDentaQuestFormIncomplete || isStarting}
|
||||
onClick={startDentaQuestEligibility}
|
||||
>
|
||||
{isStarting ? (
|
||||
|
||||
@@ -119,6 +119,10 @@ export function UnitedSCOEligibilityButton({
|
||||
const { toast } = useToast();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// Flexible validation: require DOB + at least one identifier (memberId OR firstName OR lastName)
|
||||
const isUnitedSCOFormIncomplete =
|
||||
!dateOfBirth || (!memberId && !firstName && !lastName);
|
||||
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const connectingRef = useRef<Promise<void> | null>(null);
|
||||
|
||||
@@ -370,10 +374,20 @@ export function UnitedSCOEligibilityButton({
|
||||
};
|
||||
|
||||
const startUnitedSCOEligibility = async () => {
|
||||
if (!memberId || !dateOfBirth) {
|
||||
// Flexible: require DOB + at least one identifier (memberId OR firstName OR lastName)
|
||||
if (!dateOfBirth) {
|
||||
toast({
|
||||
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",
|
||||
});
|
||||
return;
|
||||
@@ -382,11 +396,11 @@ export function UnitedSCOEligibilityButton({
|
||||
const formattedDob = dateOfBirth ? formatLocalDate(dateOfBirth) : "";
|
||||
|
||||
const payload = {
|
||||
memberId,
|
||||
memberId: memberId || "",
|
||||
dateOfBirth: formattedDob,
|
||||
firstName,
|
||||
lastName,
|
||||
insuranceSiteKey: "UNITEDSCO", // for backend credential lookup (uses DENTAQUEST)
|
||||
firstName: firstName || "",
|
||||
lastName: lastName || "",
|
||||
insuranceSiteKey: "UNITEDSCO",
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -537,8 +551,8 @@ export function UnitedSCOEligibilityButton({
|
||||
<>
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
disabled={isFormIncomplete || isStarting}
|
||||
|
||||
disabled={isUnitedSCOFormIncomplete || isStarting}
|
||||
onClick={startUnitedSCOEligibility}
|
||||
>
|
||||
{isStarting ? (
|
||||
|
||||
@@ -16,10 +16,16 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useMemo } from "react";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
|
||||
export function Sidebar() {
|
||||
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(
|
||||
() => [
|
||||
@@ -82,6 +88,7 @@ export function Sidebar() {
|
||||
name: "Settings",
|
||||
path: "/settings",
|
||||
icon: <Settings className="h-5 w-5" />,
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
[]
|
||||
@@ -90,43 +97,39 @@ export function Sidebar() {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// original look
|
||||
"bg-white border-r border-gray-200 shadow-sm z-20",
|
||||
// clip during width animation to avoid text peeking
|
||||
"overflow-hidden will-change-[width]",
|
||||
// animate width only
|
||||
"transition-[width] duration-200 ease-in-out",
|
||||
// MOBILE: overlay below topbar (h = 100vh - 4rem)
|
||||
openMobile
|
||||
? "fixed top-16 left-0 h-[calc(100vh-4rem)] w-64 block md:hidden"
|
||||
: "hidden md:block",
|
||||
// DESKTOP: participates in row layout
|
||||
"md:static md:top-auto md:h-auto md:flex-shrink-0",
|
||||
state === "collapsed" ? "md:w-0 overflow-hidden" : "md:w-64"
|
||||
)}
|
||||
>
|
||||
<div className="p-2">
|
||||
<nav role="navigation" aria-label="Main">
|
||||
{navItems.map((item) => (
|
||||
<div key={item.path}>
|
||||
<Link to={item.path} onClick={() => setOpenMobile(false)}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center space-x-3 p-2 rounded-md pl-3 mb-1 transition-colors cursor-pointer",
|
||||
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.name}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
{navItems
|
||||
.filter((item) => !item.adminOnly || isAdmin)
|
||||
.map((item) => (
|
||||
<div key={item.path}>
|
||||
<Link to={item.path} onClick={() => setOpenMobile(false)}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center space-x-3 p-2 rounded-md pl-3 mb-1 transition-colors cursor-pointer",
|
||||
location === item.path
|
||||
? "text-primary font-medium border-l-2 border-primary"
|
||||
: "text-gray-600 hover:bg-gray-100"
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="whitespace-nowrap select-none">
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,10 @@ type CredentialFormProps = {
|
||||
const SITE_KEY_OPTIONS = [
|
||||
{ value: "MH", label: "MassHealth" },
|
||||
{ value: "DDMA", label: "Delta Dental MA" },
|
||||
{ value: "DELTAINS", label: "Delta Dental Ins" },
|
||||
{ value: "DENTAQUEST", label: "Tufts SCO / DentaQuest" },
|
||||
{ value: "UNITEDSCO", label: "United SCO" },
|
||||
{ value: "CCA", label: "CCA" },
|
||||
];
|
||||
|
||||
export function CredentialForm({ onClose, userId, defaultValues }: CredentialFormProps) {
|
||||
|
||||
@@ -17,7 +17,10 @@ type Credential = {
|
||||
const SITE_KEY_LABELS: Record<string, string> = {
|
||||
MH: "MassHealth",
|
||||
DDMA: "Delta Dental MA",
|
||||
DELTAINS: "Delta Dental Ins",
|
||||
DENTAQUEST: "Tufts SCO / DentaQuest",
|
||||
UNITEDSCO: "United SCO",
|
||||
CCA: "CCA",
|
||||
};
|
||||
|
||||
function getSiteKeyLabel(siteKey: string): string {
|
||||
|
||||
@@ -4,28 +4,32 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { Suspense } from "react";
|
||||
import { Redirect, Route } from "wouter";
|
||||
|
||||
type ComponentLike = React.ComponentType; // works for both lazy() and regular components
|
||||
type ComponentLike = React.ComponentType;
|
||||
|
||||
export function ProtectedRoute({
|
||||
path,
|
||||
component: Component,
|
||||
adminOnly,
|
||||
}: {
|
||||
path: string;
|
||||
component: ComponentLike;
|
||||
adminOnly?: boolean;
|
||||
}) {
|
||||
const { user, isLoading } = useAuth();
|
||||
|
||||
return (
|
||||
<Route path={path}>
|
||||
{/* While auth is resolving: keep layout visible and show a small spinner in the content area */}
|
||||
{isLoading ? (
|
||||
<AppLayout>
|
||||
<LoadingScreen />
|
||||
</AppLayout>
|
||||
) : !user ? (
|
||||
<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>
|
||||
<Suspense fallback={<LoadingScreen />}>
|
||||
<Component />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { CheckCircle, Torus } from "lucide-react";
|
||||
@@ -22,13 +21,10 @@ import { useLocation } from "wouter";
|
||||
import {
|
||||
LoginFormValues,
|
||||
loginSchema,
|
||||
RegisterFormValues,
|
||||
registerSchema,
|
||||
} from "@repo/db/types";
|
||||
|
||||
export default function AuthPage() {
|
||||
const [activeTab, setActiveTab] = useState<string>("login");
|
||||
const { isLoading, user, loginMutation, registerMutation } = useAuth();
|
||||
const { isLoading, user, loginMutation } = useAuth();
|
||||
const [, navigate] = useLocation();
|
||||
|
||||
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) => {
|
||||
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(() => {
|
||||
if (user) {
|
||||
navigate("/insurance-status");
|
||||
}
|
||||
}, [user, navigate]);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
@@ -81,198 +60,78 @@ export default function AuthPage() {
|
||||
My Dental Office Management
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
{" "}
|
||||
Comprehensive Practice Management System
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
defaultValue="login"
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="login">Login</TabsTrigger>
|
||||
<TabsTrigger value="register">Register</TabsTrigger>
|
||||
</TabsList>
|
||||
<Form {...loginForm}>
|
||||
<form
|
||||
onSubmit={loginForm.handleSubmit(onLoginSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={loginForm.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter your username" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<TabsContent value="login">
|
||||
<Form {...loginForm}>
|
||||
<form
|
||||
onSubmit={loginForm.handleSubmit(onLoginSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={loginForm.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter your username" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={loginForm.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="••••••••"
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={loginForm.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="••••••••"
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormField
|
||||
control={loginForm.control}
|
||||
name="rememberMe"
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="remember-me"
|
||||
checked={field.value as CheckedState}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
<label
|
||||
htmlFor="remember-me"
|
||||
className="text-sm font-medium text-gray-700"
|
||||
>
|
||||
Remember me
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<FormField
|
||||
control={loginForm.control}
|
||||
name="rememberMe"
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="remember-me"
|
||||
checked={field.value as CheckedState}
|
||||
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>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={loginMutation.isPending}
|
||||
>
|
||||
{loginMutation.isPending ? "Signing in..." : "Sign in"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
{/* Hero Section */}
|
||||
|
||||
@@ -30,6 +30,8 @@ import { useLocation } from "wouter";
|
||||
import { DdmaEligibilityButton } from "@/components/insurance-status/ddma-buton-modal";
|
||||
import { DentaQuestEligibilityButton } from "@/components/insurance-status/dentaquest-button-modal";
|
||||
import { UnitedSCOEligibilityButton } from "@/components/insurance-status/unitedsco-button-modal";
|
||||
import { DeltaInsEligibilityButton } from "@/components/insurance-status/deltains-button-modal";
|
||||
import { CCAEligibilityButton } from "@/components/insurance-status/cca-button-modal";
|
||||
|
||||
export default function InsuranceStatusPage() {
|
||||
const { user } = useAuth();
|
||||
@@ -577,7 +579,7 @@ export default function InsuranceStatusPage() {
|
||||
{/* TEMP PROVIDER BUTTONS */}
|
||||
<div className="space-y-4 mt-6">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">
|
||||
Other provider checks
|
||||
Other Insurances
|
||||
</h3>
|
||||
|
||||
{/* Row 1 */}
|
||||
@@ -597,14 +599,20 @@ export default function InsuranceStatusPage() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
disabled={isFormIncomplete}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Metlife Dental
|
||||
</Button>
|
||||
<DeltaInsEligibilityButton
|
||||
memberId={memberId}
|
||||
dateOfBirth={dateOfBirth}
|
||||
firstName={firstName}
|
||||
lastName={lastName}
|
||||
isFormIncomplete={isFormIncomplete}
|
||||
onPdfReady={(pdfId, fallbackFilename) => {
|
||||
setPreviewPdfId(pdfId);
|
||||
setPreviewFallbackFilename(
|
||||
fallbackFilename ?? `eligibility_deltains_${memberId}.pdf`
|
||||
);
|
||||
setPreviewOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
@@ -612,7 +620,7 @@ export default function InsuranceStatusPage() {
|
||||
disabled={isFormIncomplete}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
CCA
|
||||
BCBS
|
||||
</Button>
|
||||
</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
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
@@ -656,9 +682,25 @@ export default function InsuranceStatusPage() {
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
United AAPR
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
disabled={isFormIncomplete}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Metlife
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
disabled={isFormIncomplete}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Cigna
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Row 3 */}
|
||||
{/* Row 4 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Button
|
||||
className="w-full"
|
||||
@@ -676,7 +718,28 @@ export default function InsuranceStatusPage() {
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Altus
|
||||
</Button>
|
||||
<div /> {/* filler cell to keep grid shape */}
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
disabled={isFormIncomplete}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Delta WA
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Row 5 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
disabled={isFormIncomplete}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Delta IL
|
||||
</Button>
|
||||
<div />
|
||||
<div />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -10,19 +10,18 @@ import { CredentialTable } from "@/components/settings/insuranceCredTable";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { Staff } from "@repo/db/types";
|
||||
|
||||
type SafeUser = { id: number; username: string; role: "ADMIN" | "USER" };
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { toast } = useToast();
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
// Modal and editing staff state
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [credentialModalOpen, setCredentialModalOpen] = useState(false);
|
||||
const [editingStaff, setEditingStaff] = useState<Staff | null>(null);
|
||||
|
||||
const toggleMobileMenu = () => setIsMobileMenuOpen((prev) => !prev);
|
||||
|
||||
// Fetch staff data
|
||||
const {
|
||||
data: staff = [],
|
||||
isLoading,
|
||||
@@ -37,14 +36,13 @@ export default function SettingsPage() {
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes cache
|
||||
staleTime: 1000 * 60 * 5,
|
||||
});
|
||||
|
||||
// Add Staff mutation
|
||||
const addStaffMutate = useMutation<
|
||||
Staff, // Return type
|
||||
Error, // Error type
|
||||
Omit<Staff, "id" | "createdAt"> // Variables
|
||||
Staff,
|
||||
Error,
|
||||
Omit<Staff, "id" | "createdAt">
|
||||
>({
|
||||
mutationFn: async (newStaff: Omit<Staff, "id" | "createdAt">) => {
|
||||
const res = await apiRequest("POST", "/api/staffs/", newStaff);
|
||||
@@ -71,7 +69,6 @@ export default function SettingsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
// Update Staff mutation
|
||||
const updateStaffMutate = useMutation<
|
||||
Staff,
|
||||
Error,
|
||||
@@ -108,7 +105,6 @@ export default function SettingsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
// Delete Staff mutation
|
||||
const deleteStaffMutation = useMutation<number, Error, number>({
|
||||
mutationFn: async (id: number) => {
|
||||
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 isAddSuccess = addStaffMutate.status === "success";
|
||||
|
||||
const isUpdating = updateStaffMutate.status === "pending";
|
||||
const isUpdateSuccess = updateStaffMutate.status === "success";
|
||||
|
||||
// Open Add modal
|
||||
const openAddStaffModal = () => {
|
||||
setEditingStaff(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
// Open Edit modal
|
||||
const openEditStaffModal = (staff: Staff) => {
|
||||
setEditingStaff(staff);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
// Handle form submit for Add or Edit
|
||||
const handleFormSubmit = (formData: Omit<Staff, "id" | "createdAt">) => {
|
||||
if (editingStaff) {
|
||||
// Editing existing staff
|
||||
if (editingStaff.id === undefined) {
|
||||
toast({
|
||||
title: "Error",
|
||||
@@ -181,7 +171,6 @@ export default function SettingsPage() {
|
||||
setModalOpen(false);
|
||||
};
|
||||
|
||||
// Close modal on successful add/update
|
||||
useEffect(() => {
|
||||
if (isAddSuccess || isUpdateSuccess) {
|
||||
setModalOpen(false);
|
||||
@@ -215,10 +204,86 @@ export default function SettingsPage() {
|
||||
`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("");
|
||||
|
||||
//fetch user
|
||||
const { user } = useAuth();
|
||||
useEffect(() => {
|
||||
if (user?.username) {
|
||||
@@ -226,7 +291,6 @@ export default function SettingsPage() {
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
//update user mutation
|
||||
const updateUserMutate = useMutation({
|
||||
mutationFn: async (
|
||||
updates: Partial<{ username: string; password: string }>
|
||||
@@ -303,10 +367,73 @@ export default function SettingsPage() {
|
||||
</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">
|
||||
<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
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
@@ -358,6 +485,96 @@ export default function SettingsPage() {
|
||||
</CardContent>
|
||||
</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 */}
|
||||
<div className="mt-6">
|
||||
<CredentialTable />
|
||||
|
||||
@@ -10,6 +10,17 @@ export default defineConfig(({ mode }) => {
|
||||
server: {
|
||||
host: env.HOST,
|
||||
port: Number(env.PORT),
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: env.VITE_API_BASE_URL_BACKEND || "http://localhost:5000",
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/socket.io": {
|
||||
target: env.VITE_API_BASE_URL_BACKEND || "http://localhost:5000",
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
@@ -11,11 +11,15 @@ import time
|
||||
import helpers_ddma_eligibility as hddma
|
||||
import helpers_dentaquest_eligibility as hdentaquest
|
||||
import helpers_unitedsco_eligibility as hunitedsco
|
||||
import helpers_deltains_eligibility as hdeltains
|
||||
import helpers_cca_eligibility as hcca
|
||||
|
||||
# Import session clear functions for startup
|
||||
from ddma_browser_manager import clear_ddma_session_on_startup
|
||||
from dentaquest_browser_manager import clear_dentaquest_session_on_startup
|
||||
from unitedsco_browser_manager import clear_unitedsco_session_on_startup
|
||||
from deltains_browser_manager import clear_deltains_session_on_startup
|
||||
from cca_browser_manager import clear_cca_session_on_startup
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
@@ -28,6 +32,8 @@ print("=" * 50)
|
||||
clear_ddma_session_on_startup()
|
||||
clear_dentaquest_session_on_startup()
|
||||
clear_unitedsco_session_on_startup()
|
||||
clear_deltains_session_on_startup()
|
||||
clear_cca_session_on_startup()
|
||||
print("=" * 50)
|
||||
print("SESSION CLEAR COMPLETE - FRESH LOGINS REQUIRED")
|
||||
print("=" * 50)
|
||||
@@ -351,6 +357,119 @@ async def unitedsco_session_status(sid: str):
|
||||
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")
|
||||
async def submit_otp(request: Request):
|
||||
"""
|
||||
@@ -425,6 +544,27 @@ async def clear_unitedsco_session():
|
||||
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__":
|
||||
host = os.getenv("HOST")
|
||||
port = int(os.getenv("PORT"))
|
||||
|
||||
292
apps/SeleniumService/cca_browser_manager.py
Normal file
292
apps/SeleniumService/cca_browser_manager.py
Normal 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()
|
||||
@@ -111,6 +111,26 @@ class DDMABrowserManager:
|
||||
except Exception as e:
|
||||
print(f"[DDMA BrowserManager] Could not clear IndexedDB: {e}")
|
||||
|
||||
# Clear browser cache (prevents corrupted cached responses)
|
||||
cache_dirs = [
|
||||
os.path.join(self.profile_dir, "Default", "Cache"),
|
||||
os.path.join(self.profile_dir, "Default", "Code Cache"),
|
||||
os.path.join(self.profile_dir, "Default", "GPUCache"),
|
||||
os.path.join(self.profile_dir, "Default", "Service Worker"),
|
||||
os.path.join(self.profile_dir, "Cache"),
|
||||
os.path.join(self.profile_dir, "Code Cache"),
|
||||
os.path.join(self.profile_dir, "GPUCache"),
|
||||
os.path.join(self.profile_dir, "Service Worker"),
|
||||
os.path.join(self.profile_dir, "ShaderCache"),
|
||||
]
|
||||
for cache_dir in cache_dirs:
|
||||
if os.path.exists(cache_dir):
|
||||
try:
|
||||
shutil.rmtree(cache_dir)
|
||||
print(f"[DDMA BrowserManager] Cleared {os.path.basename(cache_dir)}")
|
||||
except Exception as e:
|
||||
print(f"[DDMA BrowserManager] Could not clear {os.path.basename(cache_dir)}: {e}")
|
||||
|
||||
# Set flag to clear session via JavaScript after browser opens
|
||||
self._needs_session_clear = True
|
||||
|
||||
@@ -235,6 +255,12 @@ class DDMABrowserManager:
|
||||
options.add_argument("--no-sandbox")
|
||||
options.add_argument("--disable-dev-shm-usage")
|
||||
|
||||
# Anti-detection options (prevent bot detection)
|
||||
options.add_argument("--disable-blink-features=AutomationControlled")
|
||||
options.add_experimental_option("excludeSwitches", ["enable-automation"])
|
||||
options.add_experimental_option("useAutomationExtension", False)
|
||||
options.add_argument("--disable-infobars")
|
||||
|
||||
prefs = {
|
||||
"download.default_directory": self.download_dir,
|
||||
"plugins.always_open_pdf_externally": True,
|
||||
@@ -247,6 +273,12 @@ class DDMABrowserManager:
|
||||
self._driver = webdriver.Chrome(service=service, options=options)
|
||||
self._driver.maximize_window()
|
||||
|
||||
# Remove webdriver property to avoid detection
|
||||
try:
|
||||
self._driver.execute_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Reset the session clear flag (file-based clearing is done on startup)
|
||||
self._needs_session_clear = False
|
||||
|
||||
|
||||
376
apps/SeleniumService/deltains_browser_manager.py
Normal file
376
apps/SeleniumService/deltains_browser_manager.py
Normal file
@@ -0,0 +1,376 @@
|
||||
"""
|
||||
Browser manager for Delta Dental Ins - handles persistent profile, cookie
|
||||
save/restore (for Okta session-only cookies), and keeping browser alive.
|
||||
Tracks credentials to detect changes mid-session.
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import shutil
|
||||
import hashlib
|
||||
import threading
|
||||
import subprocess
|
||||
import time
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.service import Service
|
||||
from webdriver_manager.chrome import ChromeDriverManager
|
||||
|
||||
if not os.environ.get("DISPLAY"):
|
||||
os.environ["DISPLAY"] = ":0"
|
||||
|
||||
DELTAINS_DOMAIN = ".deltadentalins.com"
|
||||
OKTA_DOMAINS = [".okta.com", ".oktacdn.com"]
|
||||
|
||||
|
||||
class DeltaInsBrowserManager:
|
||||
"""
|
||||
Singleton that manages a persistent Chrome browser instance for Delta Dental Ins.
|
||||
- Uses --user-data-dir for persistent profile
|
||||
- Saves/restores Okta session cookies to survive browser restarts
|
||||
- Tracks credentials to detect changes mid-session
|
||||
"""
|
||||
_instance = None
|
||||
_lock = threading.Lock()
|
||||
|
||||
def __new__(cls):
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._driver = None
|
||||
cls._instance.profile_dir = os.path.abspath("chrome_profile_deltains")
|
||||
cls._instance.download_dir = os.path.abspath("seleniumDownloads")
|
||||
cls._instance._credentials_file = os.path.join(cls._instance.profile_dir, ".last_credentials")
|
||||
cls._instance._cookies_file = os.path.join(cls._instance.profile_dir, ".saved_cookies.json")
|
||||
cls._instance._needs_session_clear = False
|
||||
os.makedirs(cls._instance.profile_dir, exist_ok=True)
|
||||
os.makedirs(cls._instance.download_dir, exist_ok=True)
|
||||
return cls._instance
|
||||
|
||||
# ── Cookie save / restore ──────────────────────────────────────────
|
||||
|
||||
def save_cookies(self):
|
||||
"""Save all browser cookies to a JSON file so they survive browser restart."""
|
||||
try:
|
||||
if not self._driver:
|
||||
return
|
||||
cookies = self._driver.get_cookies()
|
||||
if not cookies:
|
||||
return
|
||||
with open(self._cookies_file, "w") as f:
|
||||
json.dump(cookies, f)
|
||||
print(f"[DeltaIns BrowserManager] Saved {len(cookies)} cookies to disk")
|
||||
except Exception as e:
|
||||
print(f"[DeltaIns BrowserManager] Failed to save cookies: {e}")
|
||||
|
||||
def restore_cookies(self):
|
||||
"""Restore saved cookies into the current browser session."""
|
||||
if not os.path.exists(self._cookies_file):
|
||||
print("[DeltaIns BrowserManager] No saved cookies file found")
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(self._cookies_file, "r") as f:
|
||||
cookies = json.load(f)
|
||||
|
||||
if not cookies:
|
||||
print("[DeltaIns BrowserManager] Saved cookies file is empty")
|
||||
return False
|
||||
|
||||
# Navigate to the DeltaIns domain first so we can set cookies for it
|
||||
try:
|
||||
self._driver.get("https://www.deltadentalins.com/favicon.ico")
|
||||
time.sleep(2)
|
||||
except Exception:
|
||||
self._driver.get("https://www.deltadentalins.com")
|
||||
time.sleep(3)
|
||||
|
||||
restored = 0
|
||||
for cookie in cookies:
|
||||
try:
|
||||
# Remove problematic fields that Selenium doesn't accept
|
||||
for key in ["sameSite", "storeId", "hostOnly", "session"]:
|
||||
cookie.pop(key, None)
|
||||
# sameSite must be one of: Strict, Lax, None
|
||||
cookie["sameSite"] = "None"
|
||||
self._driver.add_cookie(cookie)
|
||||
restored += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print(f"[DeltaIns BrowserManager] Restored {restored}/{len(cookies)} cookies")
|
||||
return restored > 0
|
||||
|
||||
except Exception as e:
|
||||
print(f"[DeltaIns BrowserManager] Failed to restore cookies: {e}")
|
||||
return False
|
||||
|
||||
def clear_saved_cookies(self):
|
||||
"""Delete the saved cookies file."""
|
||||
try:
|
||||
if os.path.exists(self._cookies_file):
|
||||
os.remove(self._cookies_file)
|
||||
print("[DeltaIns BrowserManager] Cleared saved cookies file")
|
||||
except Exception as e:
|
||||
print(f"[DeltaIns BrowserManager] Failed to clear saved cookies: {e}")
|
||||
|
||||
# ── Session clear ──────────────────────────────────────────────────
|
||||
|
||||
def clear_session_on_startup(self):
|
||||
"""
|
||||
Clear session cookies from Chrome profile on startup.
|
||||
This forces a fresh login after PC restart.
|
||||
"""
|
||||
print("[DeltaIns BrowserManager] Clearing session on startup...")
|
||||
|
||||
try:
|
||||
if os.path.exists(self._credentials_file):
|
||||
os.remove(self._credentials_file)
|
||||
print("[DeltaIns BrowserManager] Cleared credentials tracking file")
|
||||
|
||||
# Also clear saved cookies
|
||||
self.clear_saved_cookies()
|
||||
|
||||
session_files = [
|
||||
"Cookies",
|
||||
"Cookies-journal",
|
||||
"Login Data",
|
||||
"Login Data-journal",
|
||||
"Web Data",
|
||||
"Web Data-journal",
|
||||
]
|
||||
|
||||
for filename in session_files:
|
||||
filepath = os.path.join(self.profile_dir, "Default", filename)
|
||||
if os.path.exists(filepath):
|
||||
try:
|
||||
os.remove(filepath)
|
||||
print(f"[DeltaIns BrowserManager] Removed {filename}")
|
||||
except Exception as e:
|
||||
print(f"[DeltaIns BrowserManager] Could not remove {filename}: {e}")
|
||||
|
||||
for filename in session_files:
|
||||
filepath = os.path.join(self.profile_dir, filename)
|
||||
if os.path.exists(filepath):
|
||||
try:
|
||||
os.remove(filepath)
|
||||
print(f"[DeltaIns BrowserManager] Removed root {filename}")
|
||||
except Exception as e:
|
||||
print(f"[DeltaIns BrowserManager] Could not remove root {filename}: {e}")
|
||||
|
||||
session_storage_dir = os.path.join(self.profile_dir, "Default", "Session Storage")
|
||||
if os.path.exists(session_storage_dir):
|
||||
try:
|
||||
shutil.rmtree(session_storage_dir)
|
||||
print("[DeltaIns BrowserManager] Cleared Session Storage")
|
||||
except Exception as e:
|
||||
print(f"[DeltaIns BrowserManager] Could not clear Session Storage: {e}")
|
||||
|
||||
local_storage_dir = os.path.join(self.profile_dir, "Default", "Local Storage")
|
||||
if os.path.exists(local_storage_dir):
|
||||
try:
|
||||
shutil.rmtree(local_storage_dir)
|
||||
print("[DeltaIns BrowserManager] Cleared Local Storage")
|
||||
except Exception as e:
|
||||
print(f"[DeltaIns BrowserManager] Could not clear Local Storage: {e}")
|
||||
|
||||
indexeddb_dir = os.path.join(self.profile_dir, "Default", "IndexedDB")
|
||||
if os.path.exists(indexeddb_dir):
|
||||
try:
|
||||
shutil.rmtree(indexeddb_dir)
|
||||
print("[DeltaIns BrowserManager] Cleared IndexedDB")
|
||||
except Exception as e:
|
||||
print(f"[DeltaIns BrowserManager] Could not clear IndexedDB: {e}")
|
||||
|
||||
cache_dirs = [
|
||||
os.path.join(self.profile_dir, "Default", "Cache"),
|
||||
os.path.join(self.profile_dir, "Default", "Code Cache"),
|
||||
os.path.join(self.profile_dir, "Default", "GPUCache"),
|
||||
os.path.join(self.profile_dir, "Default", "Service Worker"),
|
||||
os.path.join(self.profile_dir, "Cache"),
|
||||
os.path.join(self.profile_dir, "Code Cache"),
|
||||
os.path.join(self.profile_dir, "GPUCache"),
|
||||
os.path.join(self.profile_dir, "Service Worker"),
|
||||
os.path.join(self.profile_dir, "ShaderCache"),
|
||||
]
|
||||
for cache_dir in cache_dirs:
|
||||
if os.path.exists(cache_dir):
|
||||
try:
|
||||
shutil.rmtree(cache_dir)
|
||||
print(f"[DeltaIns BrowserManager] Cleared {os.path.basename(cache_dir)}")
|
||||
except Exception as e:
|
||||
print(f"[DeltaIns BrowserManager] Could not clear {os.path.basename(cache_dir)}: {e}")
|
||||
|
||||
self._needs_session_clear = True
|
||||
print("[DeltaIns BrowserManager] Session cleared - will require fresh login")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[DeltaIns BrowserManager] Error clearing session: {e}")
|
||||
|
||||
# ── Credential tracking ────────────────────────────────────────────
|
||||
|
||||
def _hash_credentials(self, username: str) -> str:
|
||||
return hashlib.sha256(username.encode()).hexdigest()[:16]
|
||||
|
||||
def get_last_credentials_hash(self) -> str | None:
|
||||
try:
|
||||
if os.path.exists(self._credentials_file):
|
||||
with open(self._credentials_file, 'r') as f:
|
||||
return f.read().strip()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def save_credentials_hash(self, username: str):
|
||||
try:
|
||||
cred_hash = self._hash_credentials(username)
|
||||
with open(self._credentials_file, 'w') as f:
|
||||
f.write(cred_hash)
|
||||
except Exception as e:
|
||||
print(f"[DeltaIns BrowserManager] Failed to save credentials hash: {e}")
|
||||
|
||||
def credentials_changed(self, username: str) -> bool:
|
||||
last_hash = self.get_last_credentials_hash()
|
||||
if last_hash is None:
|
||||
return False
|
||||
current_hash = self._hash_credentials(username)
|
||||
changed = last_hash != current_hash
|
||||
if changed:
|
||||
print("[DeltaIns BrowserManager] Credentials changed - logout required")
|
||||
return changed
|
||||
|
||||
def clear_credentials_hash(self):
|
||||
try:
|
||||
if os.path.exists(self._credentials_file):
|
||||
os.remove(self._credentials_file)
|
||||
except Exception as e:
|
||||
print(f"[DeltaIns BrowserManager] Failed to clear credentials hash: {e}")
|
||||
|
||||
# ── Chrome process management ──────────────────────────────────────
|
||||
|
||||
def _kill_existing_chrome_for_profile(self):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["pgrep", "-f", f"user-data-dir={self.profile_dir}"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if result.stdout.strip():
|
||||
pids = result.stdout.strip().split('\n')
|
||||
for pid in pids:
|
||||
try:
|
||||
subprocess.run(["kill", "-9", pid], check=False)
|
||||
except:
|
||||
pass
|
||||
time.sleep(1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for lock_file in ["SingletonLock", "SingletonSocket", "SingletonCookie"]:
|
||||
lock_path = os.path.join(self.profile_dir, lock_file)
|
||||
try:
|
||||
if os.path.islink(lock_path) or os.path.exists(lock_path):
|
||||
os.remove(lock_path)
|
||||
except:
|
||||
pass
|
||||
|
||||
# ── Driver lifecycle ───────────────────────────────────────────────
|
||||
|
||||
def get_driver(self, headless=False):
|
||||
with self._lock:
|
||||
need_cookie_restore = False
|
||||
|
||||
if self._driver is None:
|
||||
print("[DeltaIns BrowserManager] Driver is None, creating new driver")
|
||||
self._kill_existing_chrome_for_profile()
|
||||
self._create_driver(headless)
|
||||
need_cookie_restore = True
|
||||
elif not self._is_alive():
|
||||
print("[DeltaIns BrowserManager] Driver not alive, recreating")
|
||||
# Save cookies from the dead session if possible (usually can't)
|
||||
self._kill_existing_chrome_for_profile()
|
||||
self._create_driver(headless)
|
||||
need_cookie_restore = True
|
||||
else:
|
||||
print("[DeltaIns BrowserManager] Reusing existing driver")
|
||||
|
||||
if need_cookie_restore and os.path.exists(self._cookies_file):
|
||||
print("[DeltaIns BrowserManager] Restoring saved cookies into new browser...")
|
||||
self.restore_cookies()
|
||||
|
||||
return self._driver
|
||||
|
||||
def _is_alive(self):
|
||||
try:
|
||||
if self._driver is None:
|
||||
return False
|
||||
_ = self._driver.current_url
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _create_driver(self, headless=False):
|
||||
if self._driver:
|
||||
try:
|
||||
self._driver.quit()
|
||||
except:
|
||||
pass
|
||||
self._driver = None
|
||||
time.sleep(1)
|
||||
|
||||
options = webdriver.ChromeOptions()
|
||||
if headless:
|
||||
options.add_argument("--headless")
|
||||
|
||||
options.add_argument(f"--user-data-dir={self.profile_dir}")
|
||||
options.add_argument("--no-sandbox")
|
||||
options.add_argument("--disable-dev-shm-usage")
|
||||
|
||||
options.add_argument("--disable-blink-features=AutomationControlled")
|
||||
options.add_experimental_option("excludeSwitches", ["enable-automation"])
|
||||
options.add_experimental_option("useAutomationExtension", False)
|
||||
options.add_argument("--disable-infobars")
|
||||
|
||||
prefs = {
|
||||
"download.default_directory": self.download_dir,
|
||||
"plugins.always_open_pdf_externally": True,
|
||||
"download.prompt_for_download": False,
|
||||
"download.directory_upgrade": True,
|
||||
"credentials_enable_service": False,
|
||||
"profile.password_manager_enabled": False,
|
||||
"profile.password_manager_leak_detection": False,
|
||||
}
|
||||
options.add_experimental_option("prefs", prefs)
|
||||
|
||||
service = Service(ChromeDriverManager().install())
|
||||
self._driver = webdriver.Chrome(service=service, options=options)
|
||||
self._driver.maximize_window()
|
||||
|
||||
try:
|
||||
self._driver.execute_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._needs_session_clear = False
|
||||
|
||||
def quit_driver(self):
|
||||
with self._lock:
|
||||
if self._driver:
|
||||
try:
|
||||
self._driver.quit()
|
||||
except:
|
||||
pass
|
||||
self._driver = None
|
||||
self._kill_existing_chrome_for_profile()
|
||||
|
||||
|
||||
_manager = None
|
||||
|
||||
def get_browser_manager():
|
||||
global _manager
|
||||
if _manager is None:
|
||||
_manager = DeltaInsBrowserManager()
|
||||
return _manager
|
||||
|
||||
|
||||
def clear_deltains_session_on_startup():
|
||||
"""Called by agent.py on startup to clear session."""
|
||||
manager = get_browser_manager()
|
||||
manager.clear_session_on_startup()
|
||||
180
apps/SeleniumService/helpers_cca_eligibility.py
Normal file
180
apps/SeleniumService/helpers_cca_eligibility.py
Normal 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,
|
||||
}
|
||||
@@ -147,6 +147,28 @@ async def start_ddma_run(sid: str, data: dict, url: str):
|
||||
s["last_activity"] = time.time()
|
||||
|
||||
try:
|
||||
# Check if OTP was submitted via API (from app)
|
||||
otp_value = s.get("otp_value")
|
||||
if otp_value:
|
||||
print(f"[OTP] OTP received from app: {otp_value}")
|
||||
try:
|
||||
otp_input = driver.find_element(By.XPATH,
|
||||
"//input[contains(@aria-label,'Verification') or contains(@placeholder,'verification') or @type='tel']"
|
||||
)
|
||||
otp_input.clear()
|
||||
otp_input.send_keys(otp_value)
|
||||
# Click verify button
|
||||
try:
|
||||
verify_btn = driver.find_element(By.XPATH, "//button[@type='button' and @aria-label='Verify']")
|
||||
verify_btn.click()
|
||||
except:
|
||||
otp_input.send_keys("\n") # Press Enter as fallback
|
||||
print("[OTP] OTP typed and submitted via app")
|
||||
s["otp_value"] = None # Clear so we don't submit again
|
||||
await asyncio.sleep(3) # Wait for verification
|
||||
except Exception as type_err:
|
||||
print(f"[OTP] Failed to type OTP from app: {type_err}")
|
||||
|
||||
# Check current URL - if we're on member search page, login succeeded
|
||||
current_url = driver.current_url.lower()
|
||||
print(f"[OTP Poll {poll+1}/{max_polls}] URL: {current_url[:60]}...")
|
||||
|
||||
300
apps/SeleniumService/helpers_deltains_eligibility.py
Normal file
300
apps/SeleniumService/helpers_deltains_eligibility.py
Normal file
@@ -0,0 +1,300 @@
|
||||
import os
|
||||
import time
|
||||
import asyncio
|
||||
from typing import Dict, Any
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
from selenium.common.exceptions import WebDriverException, TimeoutException
|
||||
|
||||
from selenium_DeltaIns_eligibilityCheckWorker import AutomationDeltaInsEligibilityCheck
|
||||
from deltains_browser_manager import get_browser_manager
|
||||
|
||||
# In-memory session store
|
||||
sessions: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
SESSION_OTP_TIMEOUT = int(os.getenv("SESSION_OTP_TIMEOUT", "240"))
|
||||
|
||||
|
||||
def make_session_entry() -> str:
|
||||
import uuid
|
||||
sid = str(uuid.uuid4())
|
||||
sessions[sid] = {
|
||||
"status": "created",
|
||||
"created_at": time.time(),
|
||||
"last_activity": time.time(),
|
||||
"bot": None,
|
||||
"driver": None,
|
||||
"otp_event": asyncio.Event(),
|
||||
"otp_value": None,
|
||||
"result": None,
|
||||
"message": None,
|
||||
"type": None,
|
||||
}
|
||||
return sid
|
||||
|
||||
|
||||
async def cleanup_session(sid: str, message: str | None = None):
|
||||
s = sessions.get(sid)
|
||||
if not s:
|
||||
return
|
||||
try:
|
||||
try:
|
||||
if s.get("status") not in ("completed", "error", "not_found"):
|
||||
s["status"] = "error"
|
||||
if message:
|
||||
s["message"] = message
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
ev = s.get("otp_event")
|
||||
if ev and not ev.is_set():
|
||||
ev.set()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
sessions.pop(sid, None)
|
||||
|
||||
|
||||
async def _remove_session_later(sid: str, delay: int = 30):
|
||||
await asyncio.sleep(delay)
|
||||
await cleanup_session(sid)
|
||||
|
||||
|
||||
def _close_browser(bot):
|
||||
"""Save cookies and close the browser after task completion."""
|
||||
try:
|
||||
bm = get_browser_manager()
|
||||
try:
|
||||
bm.save_cookies()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
bm.quit_driver()
|
||||
print("[DeltaIns] Browser closed")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"[DeltaIns] Could not close browser: {e}")
|
||||
|
||||
|
||||
async def start_deltains_run(sid: str, data: dict, url: str):
|
||||
"""
|
||||
Run the DeltaIns eligibility check workflow:
|
||||
1. Login (with OTP if needed)
|
||||
2. Search patient by Member ID + DOB
|
||||
3. Extract eligibility info + PDF
|
||||
"""
|
||||
s = sessions.get(sid)
|
||||
if not s:
|
||||
return {"status": "error", "message": "session not found"}
|
||||
|
||||
s["status"] = "running"
|
||||
s["last_activity"] = time.time()
|
||||
bot = None
|
||||
|
||||
try:
|
||||
bot = AutomationDeltaInsEligibilityCheck({"data": data})
|
||||
bot.config_driver()
|
||||
|
||||
s["bot"] = bot
|
||||
s["driver"] = bot.driver
|
||||
s["last_activity"] = time.time()
|
||||
|
||||
# Maximize window and login (bot.login handles navigation itself,
|
||||
# checking provider-tools URL first to preserve existing sessions)
|
||||
try:
|
||||
bot.driver.maximize_window()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
login_result = bot.login(url)
|
||||
except WebDriverException as wde:
|
||||
s["status"] = "error"
|
||||
s["message"] = f"Selenium driver error during login: {wde}"
|
||||
s["result"] = {"status": "error", "message": s["message"]}
|
||||
_close_browser(bot)
|
||||
asyncio.create_task(_remove_session_later(sid, 30))
|
||||
return {"status": "error", "message": s["message"]}
|
||||
except Exception as e:
|
||||
s["status"] = "error"
|
||||
s["message"] = f"Unexpected error during login: {e}"
|
||||
s["result"] = {"status": "error", "message": s["message"]}
|
||||
_close_browser(bot)
|
||||
asyncio.create_task(_remove_session_later(sid, 30))
|
||||
return {"status": "error", "message": s["message"]}
|
||||
|
||||
# Handle login result
|
||||
if isinstance(login_result, str) and login_result == "ALREADY_LOGGED_IN":
|
||||
s["status"] = "running"
|
||||
s["message"] = "Session persisted"
|
||||
print("[DeltaIns] Session persisted - skipping OTP")
|
||||
# Re-save cookies to keep them fresh on disk
|
||||
get_browser_manager().save_cookies()
|
||||
|
||||
elif isinstance(login_result, str) and login_result == "OTP_REQUIRED":
|
||||
s["status"] = "waiting_for_otp"
|
||||
s["message"] = "OTP required - please enter the code sent to your email"
|
||||
s["last_activity"] = time.time()
|
||||
|
||||
driver = s["driver"]
|
||||
max_polls = SESSION_OTP_TIMEOUT
|
||||
login_success = False
|
||||
|
||||
print(f"[DeltaIns OTP] Waiting for OTP (polling for {SESSION_OTP_TIMEOUT}s)...")
|
||||
|
||||
for poll in range(max_polls):
|
||||
await asyncio.sleep(1)
|
||||
s["last_activity"] = time.time()
|
||||
|
||||
try:
|
||||
otp_value = s.get("otp_value")
|
||||
if otp_value:
|
||||
print(f"[DeltaIns OTP] OTP received from app: {otp_value}")
|
||||
try:
|
||||
otp_input = driver.find_element(By.XPATH,
|
||||
"//input[@name='credentials.passcode' and @type='text'] | "
|
||||
"//input[contains(@name,'passcode')]")
|
||||
otp_input.clear()
|
||||
otp_input.send_keys(otp_value)
|
||||
|
||||
try:
|
||||
verify_btn = driver.find_element(By.XPATH,
|
||||
"//input[@type='submit'] | "
|
||||
"//button[@type='submit']")
|
||||
verify_btn.click()
|
||||
print("[DeltaIns OTP] Clicked verify button")
|
||||
except Exception:
|
||||
otp_input.send_keys(Keys.RETURN)
|
||||
print("[DeltaIns OTP] Pressed Enter as fallback")
|
||||
|
||||
s["otp_value"] = None
|
||||
await asyncio.sleep(8)
|
||||
except Exception as type_err:
|
||||
print(f"[DeltaIns OTP] Failed to type OTP: {type_err}")
|
||||
|
||||
current_url = driver.current_url.lower()
|
||||
if poll % 10 == 0:
|
||||
print(f"[DeltaIns OTP Poll {poll+1}/{max_polls}] URL: {current_url[:80]}")
|
||||
|
||||
if "provider-tools" in current_url and "login" not in current_url and "ciam" not in current_url:
|
||||
print("[DeltaIns OTP] Login successful!")
|
||||
login_success = True
|
||||
break
|
||||
|
||||
except Exception as poll_err:
|
||||
if poll % 10 == 0:
|
||||
print(f"[DeltaIns OTP Poll {poll+1}] Error: {poll_err}")
|
||||
|
||||
if not login_success:
|
||||
try:
|
||||
current_url = driver.current_url.lower()
|
||||
if "provider-tools" in current_url and "login" not in current_url and "ciam" not in current_url:
|
||||
login_success = True
|
||||
else:
|
||||
s["status"] = "error"
|
||||
s["message"] = "OTP timeout - login not completed"
|
||||
s["result"] = {"status": "error", "message": "OTP not completed in time"}
|
||||
_close_browser(bot)
|
||||
asyncio.create_task(_remove_session_later(sid, 30))
|
||||
return {"status": "error", "message": "OTP not completed in time"}
|
||||
except Exception as final_err:
|
||||
s["status"] = "error"
|
||||
s["message"] = f"OTP verification failed: {final_err}"
|
||||
s["result"] = {"status": "error", "message": s["message"]}
|
||||
_close_browser(bot)
|
||||
asyncio.create_task(_remove_session_later(sid, 30))
|
||||
return {"status": "error", "message": s["message"]}
|
||||
|
||||
if login_success:
|
||||
s["status"] = "running"
|
||||
s["message"] = "Login successful after OTP"
|
||||
print("[DeltaIns OTP] Proceeding to step1...")
|
||||
# Save cookies to disk so session survives browser restart
|
||||
get_browser_manager().save_cookies()
|
||||
|
||||
elif isinstance(login_result, str) and login_result.startswith("ERROR"):
|
||||
s["status"] = "error"
|
||||
s["message"] = login_result
|
||||
s["result"] = {"status": "error", "message": login_result}
|
||||
_close_browser(bot)
|
||||
asyncio.create_task(_remove_session_later(sid, 30))
|
||||
return {"status": "error", "message": login_result}
|
||||
|
||||
elif isinstance(login_result, str) and login_result == "SUCCESS":
|
||||
print("[DeltaIns] Login succeeded without OTP")
|
||||
s["status"] = "running"
|
||||
s["message"] = "Login succeeded"
|
||||
# Save cookies to disk so session survives browser restart
|
||||
get_browser_manager().save_cookies()
|
||||
|
||||
# Step 1 - search patient
|
||||
step1_result = bot.step1()
|
||||
print(f"[DeltaIns] step1 result: {step1_result}")
|
||||
|
||||
if isinstance(step1_result, str) and step1_result.startswith("ERROR"):
|
||||
s["status"] = "error"
|
||||
s["message"] = step1_result
|
||||
s["result"] = {"status": "error", "message": step1_result}
|
||||
_close_browser(bot)
|
||||
asyncio.create_task(_remove_session_later(sid, 30))
|
||||
return {"status": "error", "message": step1_result}
|
||||
|
||||
# Step 2 - extract eligibility info + PDF
|
||||
step2_result = bot.step2()
|
||||
print(f"[DeltaIns] step2 result: {step2_result.get('status') if isinstance(step2_result, dict) else step2_result}")
|
||||
|
||||
if isinstance(step2_result, dict):
|
||||
s["status"] = "completed"
|
||||
s["result"] = step2_result
|
||||
s["message"] = "completed"
|
||||
asyncio.create_task(_remove_session_later(sid, 60))
|
||||
return step2_result
|
||||
else:
|
||||
s["status"] = "error"
|
||||
s["message"] = f"step2 returned unexpected result: {step2_result}"
|
||||
s["result"] = {"status": "error", "message": s["message"]}
|
||||
_close_browser(bot)
|
||||
asyncio.create_task(_remove_session_later(sid, 30))
|
||||
return {"status": "error", "message": s["message"]}
|
||||
|
||||
except Exception as e:
|
||||
if s:
|
||||
s["status"] = "error"
|
||||
s["message"] = f"worker exception: {e}"
|
||||
s["result"] = {"status": "error", "message": s["message"]}
|
||||
if bot:
|
||||
_close_browser(bot)
|
||||
asyncio.create_task(_remove_session_later(sid, 30))
|
||||
return {"status": "error", "message": f"worker exception: {e}"}
|
||||
|
||||
|
||||
def submit_otp(sid: str, otp: str) -> Dict[str, Any]:
|
||||
s = sessions.get(sid)
|
||||
if not s:
|
||||
return {"status": "error", "message": "session not found"}
|
||||
if s.get("status") != "waiting_for_otp":
|
||||
return {"status": "error", "message": f"session not waiting for otp (state={s.get('status')})"}
|
||||
s["otp_value"] = otp
|
||||
s["last_activity"] = time.time()
|
||||
try:
|
||||
s["otp_event"].set()
|
||||
except Exception:
|
||||
pass
|
||||
return {"status": "ok", "message": "otp accepted"}
|
||||
|
||||
|
||||
def get_session_status(sid: str) -> Dict[str, Any]:
|
||||
s = sessions.get(sid)
|
||||
if not s:
|
||||
return {"status": "not_found"}
|
||||
return {
|
||||
"session_id": sid,
|
||||
"status": s.get("status"),
|
||||
"message": s.get("message"),
|
||||
"created_at": s.get("created_at"),
|
||||
"last_activity": s.get("last_activity"),
|
||||
"result": s.get("result") if s.get("status") in ("completed", "error") else None,
|
||||
}
|
||||
@@ -146,6 +146,36 @@ async def start_dentaquest_run(sid: str, data: dict, url: str):
|
||||
s["last_activity"] = time.time()
|
||||
|
||||
try:
|
||||
# Check if OTP was submitted via API (from app)
|
||||
otp_value = s.get("otp_value")
|
||||
if otp_value:
|
||||
print(f"[DentaQuest OTP] OTP received from app: {otp_value}")
|
||||
try:
|
||||
otp_input = driver.find_element(By.XPATH,
|
||||
"//input[contains(@name,'otp') or contains(@name,'code') or @type='tel' or contains(@aria-label,'Verification') or contains(@placeholder,'code')]"
|
||||
)
|
||||
otp_input.clear()
|
||||
otp_input.send_keys(otp_value)
|
||||
# Click verify button - use same pattern as Delta MA
|
||||
try:
|
||||
verify_btn = driver.find_element(By.XPATH, "//button[@type='button' and @aria-label='Verify']")
|
||||
verify_btn.click()
|
||||
print("[DentaQuest OTP] Clicked verify button (aria-label)")
|
||||
except:
|
||||
try:
|
||||
# Fallback: try other button patterns
|
||||
verify_btn = driver.find_element(By.XPATH, "//button[contains(text(),'Verify') or contains(text(),'Submit') or @type='submit']")
|
||||
verify_btn.click()
|
||||
print("[DentaQuest OTP] Clicked verify button (text/type)")
|
||||
except:
|
||||
otp_input.send_keys("\n") # Press Enter as fallback
|
||||
print("[DentaQuest OTP] Pressed Enter as fallback")
|
||||
print("[DentaQuest OTP] OTP typed and submitted via app")
|
||||
s["otp_value"] = None # Clear so we don't submit again
|
||||
await asyncio.sleep(3) # Wait for verification
|
||||
except Exception as type_err:
|
||||
print(f"[DentaQuest OTP] Failed to type OTP from app: {type_err}")
|
||||
|
||||
# Check current URL - if we're on dashboard/member page, login succeeded
|
||||
current_url = driver.current_url.lower()
|
||||
print(f"[DentaQuest OTP Poll {poll+1}/{max_polls}] URL: {current_url[:60]}...")
|
||||
|
||||
@@ -73,6 +73,32 @@ async def _remove_session_later(sid: str, delay: int = 20):
|
||||
await cleanup_session(sid)
|
||||
|
||||
|
||||
def _minimize_browser(bot):
|
||||
"""Hide the browser window so it doesn't stay in the user's way."""
|
||||
try:
|
||||
if bot and bot.driver:
|
||||
# Navigate to blank page first
|
||||
try:
|
||||
bot.driver.get("about:blank")
|
||||
except Exception:
|
||||
pass
|
||||
# Try minimize
|
||||
try:
|
||||
bot.driver.minimize_window()
|
||||
print("[UnitedSCO] Browser minimized after error")
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
# Fallback: move off-screen
|
||||
try:
|
||||
bot.driver.set_window_position(-10000, -10000)
|
||||
print("[UnitedSCO] Browser moved off-screen after error")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"[UnitedSCO] Could not hide browser: {e}")
|
||||
|
||||
|
||||
async def start_unitedsco_run(sid: str, data: dict, url: str):
|
||||
"""
|
||||
Run the United SCO workflow for a session (WITHOUT managing semaphore/counters).
|
||||
@@ -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"):
|
||||
s["status"] = "error"
|
||||
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}
|
||||
|
||||
# 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")
|
||||
else:
|
||||
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"]}
|
||||
|
||||
except Exception as e:
|
||||
s["status"] = "error"
|
||||
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"]}
|
||||
|
||||
|
||||
@@ -319,5 +360,5 @@ def get_session_status(sid: str) -> Dict[str, Any]:
|
||||
"message": s.get("message"),
|
||||
"created_at": s.get("created_at"),
|
||||
"last_activity": s.get("last_activity"),
|
||||
"result": s.get("result") if s.get("status") == "completed" else None,
|
||||
"result": s.get("result") if s.get("status") in ("completed", "error") else None,
|
||||
}
|
||||
|
||||
723
apps/SeleniumService/selenium_CCA_eligibilityCheckWorker.py
Normal file
723
apps/SeleniumService/selenium_CCA_eligibilityCheckWorker.py
Normal 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),
|
||||
}
|
||||
@@ -23,6 +23,8 @@ class AutomationDeltaDentalMAEligibilityCheck:
|
||||
# Flatten values for convenience
|
||||
self.memberId = self.data.get("memberId", "")
|
||||
self.dateOfBirth = self.data.get("dateOfBirth", "")
|
||||
self.firstName = self.data.get("firstName", "")
|
||||
self.lastName = self.data.get("lastName", "")
|
||||
self.massddma_username = self.data.get("massddmaUsername", "")
|
||||
self.massddma_password = self.data.get("massddmaPassword", "")
|
||||
|
||||
@@ -284,58 +286,105 @@ class AutomationDeltaDentalMAEligibilityCheck:
|
||||
return f"ERROR:LOGIN FAILED: {e}"
|
||||
|
||||
def step1(self):
|
||||
"""Fill search form with all available fields (flexible search)"""
|
||||
wait = WebDriverWait(self.driver, 30)
|
||||
|
||||
try:
|
||||
# Fill Member ID
|
||||
member_id_input = wait.until(EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]')))
|
||||
member_id_input.clear()
|
||||
member_id_input.send_keys(self.memberId)
|
||||
# Log what fields are available
|
||||
fields = []
|
||||
if self.memberId:
|
||||
fields.append(f"ID: {self.memberId}")
|
||||
if self.firstName:
|
||||
fields.append(f"FirstName: {self.firstName}")
|
||||
if self.lastName:
|
||||
fields.append(f"LastName: {self.lastName}")
|
||||
if self.dateOfBirth:
|
||||
fields.append(f"DOB: {self.dateOfBirth}")
|
||||
print(f"[DDMA step1] Starting search with: {', '.join(fields)}")
|
||||
|
||||
# Fill DOB parts
|
||||
try:
|
||||
dob_parts = self.dateOfBirth.split("-")
|
||||
year = dob_parts[0] # "1964"
|
||||
month = dob_parts[1].zfill(2) # "04"
|
||||
day = dob_parts[2].zfill(2) # "17"
|
||||
except Exception as e:
|
||||
print(f"Error parsing DOB: {e}")
|
||||
return "ERROR: PARSING DOB"
|
||||
|
||||
# 1) locate the specific member DOB container
|
||||
dob_container = wait.until(
|
||||
EC.presence_of_element_located(
|
||||
(By.XPATH, "//div[@data-testid='member-search_date-of-birth']")
|
||||
)
|
||||
)
|
||||
|
||||
# 2) find the editable spans *inside that container* using relative XPaths
|
||||
month_elem = dob_container.find_element(By.XPATH, ".//span[@data-type='month' and @contenteditable='true']")
|
||||
day_elem = dob_container.find_element(By.XPATH, ".//span[@data-type='day' and @contenteditable='true']")
|
||||
year_elem = dob_container.find_element(By.XPATH, ".//span[@data-type='year' and @contenteditable='true']")
|
||||
|
||||
# Helper to click, select-all and type (pure send_keys approach)
|
||||
# Helper to click, select-all and type
|
||||
def replace_with_sendkeys(el, value):
|
||||
# focus (same as click)
|
||||
el.click()
|
||||
# select all (Ctrl+A) and delete (some apps pick up BACKSPACE better — we use BACKSPACE after select)
|
||||
el.send_keys(Keys.CONTROL, "a")
|
||||
el.send_keys(Keys.BACKSPACE)
|
||||
# type the value
|
||||
el.send_keys(value)
|
||||
# optionally blur or tab out if app expects it
|
||||
# el.send_keys(Keys.TAB)
|
||||
|
||||
replace_with_sendkeys(month_elem, month)
|
||||
time.sleep(0.05)
|
||||
replace_with_sendkeys(day_elem, day)
|
||||
time.sleep(0.05)
|
||||
replace_with_sendkeys(year_elem, year)
|
||||
# 1. Fill Member ID if provided
|
||||
if self.memberId:
|
||||
try:
|
||||
member_id_input = wait.until(EC.presence_of_element_located(
|
||||
(By.XPATH, '//input[@placeholder="Search by member ID"]')
|
||||
))
|
||||
member_id_input.clear()
|
||||
member_id_input.send_keys(self.memberId)
|
||||
print(f"[DDMA step1] Entered Member ID: {self.memberId}")
|
||||
time.sleep(0.2)
|
||||
except Exception as e:
|
||||
print(f"[DDMA step1] Warning: Could not fill Member ID: {e}")
|
||||
|
||||
# 2. Fill DOB if provided
|
||||
if self.dateOfBirth:
|
||||
try:
|
||||
dob_parts = self.dateOfBirth.split("-")
|
||||
year = dob_parts[0]
|
||||
month = dob_parts[1].zfill(2)
|
||||
day = dob_parts[2].zfill(2)
|
||||
|
||||
# Click Continue button
|
||||
continue_btn = wait.until(EC.element_to_be_clickable((By.XPATH, '//button[@data-testid="member-search_search-button"]')))
|
||||
dob_container = wait.until(
|
||||
EC.presence_of_element_located(
|
||||
(By.XPATH, "//div[@data-testid='member-search_date-of-birth']")
|
||||
)
|
||||
)
|
||||
|
||||
month_elem = dob_container.find_element(By.XPATH, ".//span[@data-type='month' and @contenteditable='true']")
|
||||
day_elem = dob_container.find_element(By.XPATH, ".//span[@data-type='day' and @contenteditable='true']")
|
||||
year_elem = dob_container.find_element(By.XPATH, ".//span[@data-type='year' and @contenteditable='true']")
|
||||
|
||||
replace_with_sendkeys(month_elem, month)
|
||||
time.sleep(0.05)
|
||||
replace_with_sendkeys(day_elem, day)
|
||||
time.sleep(0.05)
|
||||
replace_with_sendkeys(year_elem, year)
|
||||
print(f"[DDMA step1] Filled DOB: {month}/{day}/{year}")
|
||||
except Exception as e:
|
||||
print(f"[DDMA step1] Warning: Could not fill DOB: {e}")
|
||||
|
||||
# 3. Fill First Name if provided
|
||||
if self.firstName:
|
||||
try:
|
||||
first_name_input = wait.until(EC.presence_of_element_located(
|
||||
(By.XPATH, '//input[@placeholder="First name - 1 char minimum" or contains(@placeholder,"first name") or contains(@name,"firstName")]')
|
||||
))
|
||||
first_name_input.clear()
|
||||
first_name_input.send_keys(self.firstName)
|
||||
print(f"[DDMA step1] Entered First Name: {self.firstName}")
|
||||
time.sleep(0.2)
|
||||
except Exception as e:
|
||||
print(f"[DDMA step1] Warning: Could not fill First Name: {e}")
|
||||
|
||||
# 4. Fill Last Name if provided
|
||||
if self.lastName:
|
||||
try:
|
||||
last_name_input = wait.until(EC.presence_of_element_located(
|
||||
(By.XPATH, '//input[@placeholder="Last name - 2 char minimum" or contains(@placeholder,"last name") or contains(@name,"lastName")]')
|
||||
))
|
||||
last_name_input.clear()
|
||||
last_name_input.send_keys(self.lastName)
|
||||
print(f"[DDMA step1] Entered Last Name: {self.lastName}")
|
||||
time.sleep(0.2)
|
||||
except Exception as e:
|
||||
print(f"[DDMA step1] Warning: Could not fill Last Name: {e}")
|
||||
|
||||
time.sleep(0.3)
|
||||
|
||||
# Click Search button
|
||||
continue_btn = wait.until(EC.element_to_be_clickable(
|
||||
(By.XPATH, '//button[@data-testid="member-search_search-button"]')
|
||||
))
|
||||
continue_btn.click()
|
||||
print("[DDMA step1] Clicked Search button")
|
||||
|
||||
time.sleep(5)
|
||||
|
||||
# Check for error message
|
||||
try:
|
||||
@@ -343,23 +392,24 @@ class AutomationDeltaDentalMAEligibilityCheck:
|
||||
(By.XPATH, '//div[@data-testid="member-search-result-no-results"]')
|
||||
))
|
||||
if error_msg:
|
||||
print("Error: Invalid Member ID or Date of Birth.")
|
||||
return "ERROR: INVALID MEMBERID OR DOB"
|
||||
print("[DDMA step1] Error: No results found")
|
||||
return "ERROR: INVALID SEARCH CRITERIA"
|
||||
except TimeoutException:
|
||||
pass
|
||||
|
||||
print("[DDMA step1] Search completed successfully")
|
||||
return "Success"
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error while step1 i.e Cheking the MemberId and DOB in: {e}")
|
||||
return "ERROR:STEP1"
|
||||
print(f"[DDMA step1] Exception: {e}")
|
||||
return f"ERROR:STEP1 - {e}"
|
||||
|
||||
|
||||
def step2(self):
|
||||
wait = WebDriverWait(self.driver, 90)
|
||||
|
||||
try:
|
||||
# Wait for results table to load (use explicit wait instead of fixed sleep)
|
||||
# Wait for results table to load
|
||||
try:
|
||||
WebDriverWait(self.driver, 10).until(
|
||||
EC.presence_of_element_located((By.XPATH, "//tbody//tr"))
|
||||
@@ -367,10 +417,50 @@ class AutomationDeltaDentalMAEligibilityCheck:
|
||||
except TimeoutException:
|
||||
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"
|
||||
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:
|
||||
# Use short timeout (3s) since this is just for status extraction
|
||||
short_wait = WebDriverWait(self.driver, 3)
|
||||
status_link = short_wait.until(EC.presence_of_element_located((
|
||||
By.XPATH,
|
||||
@@ -391,38 +481,15 @@ class AutomationDeltaDentalMAEligibilityCheck:
|
||||
except:
|
||||
pass
|
||||
|
||||
# 2) Extract patient name and click to navigate to detailed patient page
|
||||
print("[DDMA step2] Extracting patient name and finding detail link...")
|
||||
# 2) Click on patient name to navigate to detailed patient page
|
||||
print("[DDMA step2] Clicking on patient name to open detailed page...")
|
||||
patient_name_clicked = False
|
||||
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
|
||||
current_url_before = self.driver.current_url
|
||||
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:
|
||||
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"
|
||||
text = link.text.strip() or "(empty 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:
|
||||
print(f"[DDMA step2] Error listing links: {e}")
|
||||
|
||||
@@ -456,10 +518,9 @@ class AutomationDeltaDentalMAEligibilityCheck:
|
||||
href = patient_link.get_attribute("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
|
||||
if not patientName and link_text and len(link_text) > 1:
|
||||
if not any(x in link_text.lower() for x in ['active', 'inactive', 'view', 'details']):
|
||||
patientName = link_text
|
||||
# Only update patientName if link has text (preserve previously extracted name)
|
||||
if link_text and not patientName:
|
||||
patientName = link_text
|
||||
|
||||
if href and "member-details" in href:
|
||||
detail_url = href
|
||||
@@ -540,70 +601,31 @@ class AutomationDeltaDentalMAEligibilityCheck:
|
||||
# Try to extract patient name from detailed page if not already found
|
||||
if not patientName:
|
||||
detail_name_selectors = [
|
||||
"//*[contains(@class,'member-name')]",
|
||||
"//*[contains(@class,'patient-name')]",
|
||||
"//h1[not(contains(@class,'page-title'))]",
|
||||
"//h2[not(contains(@class,'section-title'))]",
|
||||
"//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')]",
|
||||
"//h1",
|
||||
"//h2",
|
||||
"//*[contains(@class,'patient-name') or contains(@class,'member-name')]",
|
||||
"//div[contains(@class,'header')]//span",
|
||||
]
|
||||
for selector in detail_name_selectors:
|
||||
try:
|
||||
name_elem = self.driver.find_element(By.XPATH, selector)
|
||||
name_text = name_elem.text.strip()
|
||||
if name_text and len(name_text) > 2 and len(name_text) < 100:
|
||||
# Filter out common non-name text
|
||||
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):
|
||||
if name_text and len(name_text) > 1:
|
||||
if not any(x in name_text.lower() for x in ['active', 'inactive', 'eligible', 'search', 'date', 'print']):
|
||||
patientName = name_text
|
||||
print(f"[DDMA step2] Found patient name on detail page: {patientName}")
|
||||
break
|
||||
except:
|
||||
continue
|
||||
|
||||
# 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:
|
||||
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:
|
||||
name_selectors = [
|
||||
"(//tbody//tr)[1]//td[1]", # First column of first row
|
||||
"(//table//tbody//tr)[1]//td[1]",
|
||||
"(//tbody//tr)[1]//a", # Link in first row
|
||||
]
|
||||
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
|
||||
try:
|
||||
name_elem = self.driver.find_element(By.XPATH, "(//tbody//tr)[1]//td[1]")
|
||||
patientName = name_elem.text.strip()
|
||||
except:
|
||||
pass
|
||||
|
||||
if not patientName:
|
||||
print("[DDMA step2] Could not extract patient name")
|
||||
@@ -639,7 +661,9 @@ class AutomationDeltaDentalMAEligibilityCheck:
|
||||
|
||||
result = self.driver.execute_cdp_cmd("Page.printToPDF", pdf_options)
|
||||
pdf_data = base64.b64decode(result.get('data', ''))
|
||||
pdf_path = os.path.join(self.download_dir, f"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:
|
||||
f.write(pdf_data)
|
||||
|
||||
@@ -653,12 +677,23 @@ class AutomationDeltaDentalMAEligibilityCheck:
|
||||
except Exception as e:
|
||||
print(f"[step2] Error closing browser: {e}")
|
||||
|
||||
# Clean patient name - remove DOB if it was included (already cleaned above but double check)
|
||||
if patientName:
|
||||
# Remove "DOB: MM/DD/YYYY" or similar patterns from the name
|
||||
cleaned_name = re.sub(r'\s*DOB[:\s]*\d{1,2}/\d{1,2}/\d{2,4}\s*', '', patientName, flags=re.IGNORECASE).strip()
|
||||
if cleaned_name:
|
||||
patientName = cleaned_name
|
||||
print(f"[DDMA step2] Cleaned patient name: {patientName}")
|
||||
|
||||
print(f"[DDMA step2] Final data - PatientName: '{patientName}', MemberID: '{foundMemberId}'")
|
||||
|
||||
output = {
|
||||
"status": "success",
|
||||
"eligibility": eligibilityText,
|
||||
"ss_path": pdf_path, # Keep key as ss_path for backward compatibility
|
||||
"pdf_path": pdf_path, # Also add explicit pdf_path
|
||||
"patientName": patientName
|
||||
"patientName": patientName,
|
||||
"memberId": foundMemberId # Include extracted Member ID
|
||||
}
|
||||
return output
|
||||
except Exception as e:
|
||||
|
||||
686
apps/SeleniumService/selenium_DeltaIns_eligibilityCheckWorker.py
Normal file
686
apps/SeleniumService/selenium_DeltaIns_eligibilityCheckWorker.py
Normal file
@@ -0,0 +1,686 @@
|
||||
from selenium import webdriver
|
||||
from selenium.common.exceptions import WebDriverException, TimeoutException
|
||||
from selenium.webdriver.chrome.service import Service
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
from webdriver_manager.chrome import ChromeDriverManager
|
||||
import time
|
||||
import os
|
||||
import base64
|
||||
import re
|
||||
import glob
|
||||
|
||||
from deltains_browser_manager import get_browser_manager
|
||||
|
||||
LOGIN_URL = "https://www.deltadentalins.com/ciam/login?TARGET=%2Fprovider-tools%2Fv2"
|
||||
PROVIDER_TOOLS_URL = "https://www.deltadentalins.com/provider-tools/v2"
|
||||
|
||||
|
||||
class AutomationDeltaInsEligibilityCheck:
|
||||
def __init__(self, data):
|
||||
self.headless = False
|
||||
self.driver = None
|
||||
|
||||
self.data = data.get("data", {}) if isinstance(data, dict) else {}
|
||||
|
||||
self.memberId = self.data.get("memberId", "")
|
||||
self.dateOfBirth = self.data.get("dateOfBirth", "")
|
||||
self.firstName = self.data.get("firstName", "")
|
||||
self.lastName = self.data.get("lastName", "")
|
||||
self.deltains_username = self.data.get("deltains_username", "")
|
||||
self.deltains_password = self.data.get("deltains_password", "")
|
||||
|
||||
self.download_dir = get_browser_manager().download_dir
|
||||
os.makedirs(self.download_dir, exist_ok=True)
|
||||
|
||||
def config_driver(self):
|
||||
self.driver = get_browser_manager().get_driver(self.headless)
|
||||
|
||||
def _dismiss_cookie_banner(self):
|
||||
try:
|
||||
accept_btn = WebDriverWait(self.driver, 5).until(
|
||||
EC.element_to_be_clickable((By.ID, "onetrust-accept-btn-handler"))
|
||||
)
|
||||
accept_btn.click()
|
||||
print("[DeltaIns login] Dismissed cookie consent banner")
|
||||
time.sleep(1)
|
||||
except TimeoutException:
|
||||
print("[DeltaIns login] No cookie consent banner found")
|
||||
except Exception as e:
|
||||
print(f"[DeltaIns login] Error dismissing cookie banner: {e}")
|
||||
|
||||
def _force_logout(self):
|
||||
try:
|
||||
print("[DeltaIns login] Forcing logout due to credential change...")
|
||||
browser_manager = get_browser_manager()
|
||||
try:
|
||||
self.driver.delete_all_cookies()
|
||||
print("[DeltaIns login] Cleared all cookies")
|
||||
except Exception as e:
|
||||
print(f"[DeltaIns login] Error clearing cookies: {e}")
|
||||
browser_manager.clear_credentials_hash()
|
||||
print("[DeltaIns login] Logout complete")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[DeltaIns login] Error during forced logout: {e}")
|
||||
return False
|
||||
|
||||
def login(self, url):
|
||||
"""
|
||||
Multi-step login flow for DeltaIns (Okta-based):
|
||||
1. Enter username (name='identifier') -> click Next
|
||||
2. Enter password (type='password') -> click Submit
|
||||
3. Handle MFA: click 'Send me an email' -> wait for OTP
|
||||
Returns: ALREADY_LOGGED_IN, SUCCESS, OTP_REQUIRED, or ERROR:...
|
||||
"""
|
||||
wait = WebDriverWait(self.driver, 30)
|
||||
browser_manager = get_browser_manager()
|
||||
|
||||
try:
|
||||
if self.deltains_username and browser_manager.credentials_changed(self.deltains_username):
|
||||
self._force_logout()
|
||||
self.driver.get(url)
|
||||
time.sleep(3)
|
||||
|
||||
# First, try navigating to provider-tools directly (not login URL)
|
||||
# This avoids triggering Okta password re-verification when session is valid
|
||||
try:
|
||||
current_url = self.driver.current_url
|
||||
print(f"[DeltaIns login] Current URL: {current_url}")
|
||||
if "provider-tools" in current_url and "login" not in current_url.lower() and "ciam" not in current_url.lower():
|
||||
print("[DeltaIns login] Already on provider tools page - logged in")
|
||||
return "ALREADY_LOGGED_IN"
|
||||
except Exception as e:
|
||||
print(f"[DeltaIns login] Error checking current state: {e}")
|
||||
|
||||
# Navigate to provider-tools URL first to check if session is still valid
|
||||
print("[DeltaIns login] Trying provider-tools URL to check session...")
|
||||
self.driver.get(PROVIDER_TOOLS_URL)
|
||||
time.sleep(5)
|
||||
|
||||
current_url = self.driver.current_url
|
||||
print(f"[DeltaIns login] After provider-tools nav URL: {current_url}")
|
||||
|
||||
if "provider-tools" in current_url and "login" not in current_url.lower() and "ciam" not in current_url.lower():
|
||||
print("[DeltaIns login] Session still valid - already logged in")
|
||||
return "ALREADY_LOGGED_IN"
|
||||
|
||||
# Session expired or not logged in - navigate to login URL
|
||||
print("[DeltaIns login] Session not valid, navigating to login page...")
|
||||
self.driver.get(url)
|
||||
time.sleep(3)
|
||||
|
||||
current_url = self.driver.current_url
|
||||
print(f"[DeltaIns login] After login nav URL: {current_url}")
|
||||
|
||||
if "provider-tools" in current_url and "login" not in current_url.lower() and "ciam" not in current_url.lower():
|
||||
print("[DeltaIns login] Already logged in - on provider tools")
|
||||
return "ALREADY_LOGGED_IN"
|
||||
|
||||
self._dismiss_cookie_banner()
|
||||
|
||||
# Step 1: Username entry (name='identifier')
|
||||
print("[DeltaIns login] Looking for username field...")
|
||||
username_entered = False
|
||||
for sel in [
|
||||
(By.NAME, "identifier"),
|
||||
(By.ID, "okta-signin-username"),
|
||||
(By.XPATH, "//input[@type='text' and @autocomplete='username']"),
|
||||
(By.XPATH, "//input[@type='text']"),
|
||||
]:
|
||||
try:
|
||||
field = WebDriverWait(self.driver, 8).until(EC.presence_of_element_located(sel))
|
||||
if field.is_displayed():
|
||||
field.clear()
|
||||
field.send_keys(self.deltains_username)
|
||||
username_entered = True
|
||||
print(f"[DeltaIns login] Username entered via {sel}")
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not username_entered:
|
||||
return "ERROR: Could not find username field"
|
||||
|
||||
# Click Next/Submit
|
||||
time.sleep(1)
|
||||
for sel in [
|
||||
(By.XPATH, "//input[@type='submit' and @value='Next']"),
|
||||
(By.XPATH, "//input[@type='submit']"),
|
||||
(By.XPATH, "//button[@type='submit']"),
|
||||
]:
|
||||
try:
|
||||
btn = self.driver.find_element(*sel)
|
||||
if btn.is_displayed():
|
||||
btn.click()
|
||||
print(f"[DeltaIns login] Clicked Next via {sel}")
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
time.sleep(4)
|
||||
|
||||
current_url = self.driver.current_url
|
||||
if "provider-tools" in current_url and "login" not in current_url.lower() and "ciam" not in current_url.lower():
|
||||
return "ALREADY_LOGGED_IN"
|
||||
|
||||
# Step 2: Password entry
|
||||
print("[DeltaIns login] Looking for password field...")
|
||||
pw_entered = False
|
||||
for sel in [
|
||||
(By.XPATH, "//input[@type='password']"),
|
||||
(By.ID, "okta-signin-password"),
|
||||
(By.NAME, "password"),
|
||||
]:
|
||||
try:
|
||||
field = WebDriverWait(self.driver, 10).until(EC.presence_of_element_located(sel))
|
||||
if field.is_displayed():
|
||||
field.clear()
|
||||
field.send_keys(self.deltains_password)
|
||||
pw_entered = True
|
||||
print(f"[DeltaIns login] Password entered via {sel}")
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not pw_entered:
|
||||
current_url = self.driver.current_url
|
||||
if "provider-tools" in current_url and "login" not in current_url.lower():
|
||||
return "ALREADY_LOGGED_IN"
|
||||
return "ERROR: Password field not found"
|
||||
|
||||
# Click Sign In
|
||||
time.sleep(1)
|
||||
for sel in [
|
||||
(By.ID, "okta-signin-submit"),
|
||||
(By.XPATH, "//input[@type='submit']"),
|
||||
(By.XPATH, "//button[@type='submit']"),
|
||||
]:
|
||||
try:
|
||||
btn = self.driver.find_element(*sel)
|
||||
if btn.is_displayed():
|
||||
btn.click()
|
||||
print(f"[DeltaIns login] Clicked Sign In via {sel}")
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if self.deltains_username:
|
||||
browser_manager.save_credentials_hash(self.deltains_username)
|
||||
|
||||
time.sleep(6)
|
||||
|
||||
current_url = self.driver.current_url
|
||||
print(f"[DeltaIns login] After password submit URL: {current_url}")
|
||||
|
||||
if "provider-tools" in current_url and "login" not in current_url.lower() and "ciam" not in current_url.lower():
|
||||
print("[DeltaIns login] Login successful - on provider tools")
|
||||
return "SUCCESS"
|
||||
|
||||
# Step 3: MFA handling
|
||||
# There are two possible MFA pages:
|
||||
# A) Method selection: "Verify it's you with a security method" with Email/Phone Select buttons
|
||||
# B) Direct: "Send me an email" button
|
||||
print("[DeltaIns login] Handling MFA...")
|
||||
|
||||
# Check for method selection page first (Email "Select" link)
|
||||
# The Okta MFA page uses <a> tags (not buttons/inputs) with class "select-factor"
|
||||
# inside <div data-se="okta_email"> for Email selection
|
||||
try:
|
||||
body_text = self.driver.find_element(By.TAG_NAME, "body").text
|
||||
if "security method" in body_text.lower() or "select from the following" in body_text.lower():
|
||||
print("[DeltaIns login] MFA method selection page detected")
|
||||
email_select = None
|
||||
for sel in [
|
||||
(By.CSS_SELECTOR, "div[data-se='okta_email'] a.select-factor"),
|
||||
(By.XPATH, "//div[@data-se='okta_email']//a[contains(@class,'select-factor')]"),
|
||||
(By.XPATH, "//a[contains(@aria-label,'Select Email')]"),
|
||||
(By.XPATH, "//div[@data-se='okta_email']//a[@data-se='button']"),
|
||||
(By.CSS_SELECTOR, "a.select-factor.link-button"),
|
||||
]:
|
||||
try:
|
||||
btn = self.driver.find_element(*sel)
|
||||
if btn.is_displayed():
|
||||
email_select = btn
|
||||
print(f"[DeltaIns login] Found Email Select via {sel}")
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if email_select:
|
||||
email_select.click()
|
||||
print("[DeltaIns login] Clicked 'Select' for Email MFA")
|
||||
time.sleep(5)
|
||||
else:
|
||||
print("[DeltaIns login] Could not find Email Select button")
|
||||
except Exception as e:
|
||||
print(f"[DeltaIns login] Error checking MFA method selection: {e}")
|
||||
|
||||
# Now look for "Send me an email" button (may appear after method selection or directly)
|
||||
try:
|
||||
send_btn = WebDriverWait(self.driver, 8).until(
|
||||
EC.element_to_be_clickable((By.XPATH,
|
||||
"//input[@type='submit' and @value='Send me an email'] | "
|
||||
"//input[@value='Send me an email'] | "
|
||||
"//button[contains(text(),'Send me an email')]"))
|
||||
)
|
||||
send_btn.click()
|
||||
print("[DeltaIns login] Clicked 'Send me an email'")
|
||||
time.sleep(5)
|
||||
except TimeoutException:
|
||||
print("[DeltaIns login] No 'Send me an email' button, checking for OTP input...")
|
||||
|
||||
# Step 4: OTP entry page
|
||||
try:
|
||||
otp_input = WebDriverWait(self.driver, 10).until(
|
||||
EC.presence_of_element_located((By.XPATH,
|
||||
"//input[@name='credentials.passcode' and @type='text'] | "
|
||||
"//input[contains(@name,'passcode')]"))
|
||||
)
|
||||
print("[DeltaIns login] OTP input found -> OTP_REQUIRED")
|
||||
return "OTP_REQUIRED"
|
||||
except TimeoutException:
|
||||
pass
|
||||
|
||||
current_url = self.driver.current_url
|
||||
if "provider-tools" in current_url and "login" not in current_url.lower() and "ciam" not in current_url.lower():
|
||||
return "SUCCESS"
|
||||
|
||||
try:
|
||||
error_elem = self.driver.find_element(By.XPATH,
|
||||
"//*[contains(@class,'error') or contains(@class,'alert-error')]")
|
||||
error_text = error_elem.text.strip()[:200]
|
||||
if error_text:
|
||||
return f"ERROR: {error_text}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print("[DeltaIns login] Could not determine login state - returning OTP_REQUIRED as fallback")
|
||||
return "OTP_REQUIRED"
|
||||
|
||||
except Exception as e:
|
||||
print(f"[DeltaIns login] Exception: {e}")
|
||||
return f"ERROR:LOGIN FAILED: {e}"
|
||||
|
||||
def _format_dob(self, dob_str):
|
||||
"""Convert DOB from YYYY-MM-DD to MM/DD/YYYY format."""
|
||||
if dob_str and "-" in dob_str:
|
||||
dob_parts = dob_str.split("-")
|
||||
if len(dob_parts) == 3:
|
||||
return f"{dob_parts[1]}/{dob_parts[2]}/{dob_parts[0]}"
|
||||
return dob_str
|
||||
|
||||
def _close_browser(self):
|
||||
"""Save cookies and close the browser after task completion."""
|
||||
browser_manager = get_browser_manager()
|
||||
try:
|
||||
browser_manager.save_cookies()
|
||||
except Exception as e:
|
||||
print(f"[DeltaIns] Failed to save cookies before close: {e}")
|
||||
try:
|
||||
browser_manager.quit_driver()
|
||||
print("[DeltaIns] Browser closed")
|
||||
except Exception as e:
|
||||
print(f"[DeltaIns] Could not close browser: {e}")
|
||||
|
||||
def step1(self):
|
||||
"""
|
||||
Navigate to Eligibility search, enter patient info, search, and
|
||||
click 'Check eligibility and benefits' on the result card.
|
||||
|
||||
Search flow:
|
||||
1. Click 'Eligibility and benefits' link
|
||||
2. Click 'Search for a new patient' button
|
||||
3. Click 'Search by member ID' tab
|
||||
4. Enter Member ID in #memberId
|
||||
5. Enter DOB in #dob (MM/DD/YYYY)
|
||||
6. Click Search
|
||||
7. Extract patient info from result card
|
||||
8. Click 'Check eligibility and benefits'
|
||||
"""
|
||||
try:
|
||||
formatted_dob = self._format_dob(self.dateOfBirth)
|
||||
print(f"[DeltaIns step1] Starting — memberId={self.memberId}, DOB={formatted_dob}")
|
||||
|
||||
# 1. Click "Eligibility and benefits" link
|
||||
print("[DeltaIns step1] Clicking 'Eligibility and benefits'...")
|
||||
try:
|
||||
elig_link = WebDriverWait(self.driver, 15).until(
|
||||
EC.element_to_be_clickable((By.XPATH,
|
||||
"//a[contains(text(),'Eligibility and benefits')] | "
|
||||
"//a[contains(text(),'Eligibility')]"))
|
||||
)
|
||||
elig_link.click()
|
||||
time.sleep(5)
|
||||
print("[DeltaIns step1] Clicked Eligibility link")
|
||||
except TimeoutException:
|
||||
print("[DeltaIns step1] No Eligibility link found, checking if already on page...")
|
||||
if "patient-search" not in self.driver.current_url and "eligibility" not in self.driver.current_url:
|
||||
self.driver.get("https://www.deltadentalins.com/provider-tools/v2/patient-search")
|
||||
time.sleep(5)
|
||||
|
||||
# 2. Click "Search for a new patient" button
|
||||
print("[DeltaIns step1] Clicking 'Search for a new patient'...")
|
||||
try:
|
||||
new_patient_btn = WebDriverWait(self.driver, 10).until(
|
||||
EC.element_to_be_clickable((By.XPATH,
|
||||
"//button[contains(text(),'Search for a new patient')]"))
|
||||
)
|
||||
new_patient_btn.click()
|
||||
time.sleep(3)
|
||||
print("[DeltaIns step1] Clicked 'Search for a new patient'")
|
||||
except TimeoutException:
|
||||
print("[DeltaIns step1] 'Search for a new patient' button not found - may already be on search page")
|
||||
|
||||
# 3. Click "Search by member ID" tab
|
||||
print("[DeltaIns step1] Clicking 'Search by member ID' tab...")
|
||||
try:
|
||||
member_id_tab = WebDriverWait(self.driver, 10).until(
|
||||
EC.element_to_be_clickable((By.XPATH,
|
||||
"//button[contains(text(),'Search by member ID')]"))
|
||||
)
|
||||
member_id_tab.click()
|
||||
time.sleep(2)
|
||||
print("[DeltaIns step1] Clicked 'Search by member ID' tab")
|
||||
except TimeoutException:
|
||||
print("[DeltaIns step1] 'Search by member ID' tab not found")
|
||||
return "ERROR: Could not find 'Search by member ID' tab"
|
||||
|
||||
# 4. Enter Member ID
|
||||
print(f"[DeltaIns step1] Entering Member ID: {self.memberId}")
|
||||
try:
|
||||
mid_field = WebDriverWait(self.driver, 10).until(
|
||||
EC.presence_of_element_located((By.ID, "memberId"))
|
||||
)
|
||||
mid_field.click()
|
||||
mid_field.send_keys(Keys.CONTROL + "a")
|
||||
mid_field.send_keys(Keys.DELETE)
|
||||
time.sleep(0.3)
|
||||
mid_field.send_keys(self.memberId)
|
||||
time.sleep(0.5)
|
||||
print(f"[DeltaIns step1] Member ID entered: '{mid_field.get_attribute('value')}'")
|
||||
except TimeoutException:
|
||||
return "ERROR: Member ID field not found"
|
||||
|
||||
# 5. Enter DOB
|
||||
print(f"[DeltaIns step1] Entering DOB: {formatted_dob}")
|
||||
try:
|
||||
dob_field = self.driver.find_element(By.ID, "dob")
|
||||
dob_field.click()
|
||||
dob_field.send_keys(Keys.CONTROL + "a")
|
||||
dob_field.send_keys(Keys.DELETE)
|
||||
time.sleep(0.3)
|
||||
dob_field.send_keys(formatted_dob)
|
||||
time.sleep(0.5)
|
||||
print(f"[DeltaIns step1] DOB entered: '{dob_field.get_attribute('value')}'")
|
||||
except Exception as e:
|
||||
return f"ERROR: DOB field not found: {e}"
|
||||
|
||||
# 6. Click Search
|
||||
print("[DeltaIns step1] Clicking Search...")
|
||||
try:
|
||||
search_btn = self.driver.find_element(By.XPATH,
|
||||
"//button[@type='submit'][contains(text(),'Search')] | "
|
||||
"//button[@data-testid='searchButton']")
|
||||
search_btn.click()
|
||||
time.sleep(10)
|
||||
print("[DeltaIns step1] Search clicked")
|
||||
except Exception as e:
|
||||
return f"ERROR: Search button not found: {e}"
|
||||
|
||||
# 7. Check for results - look for patient card
|
||||
print("[DeltaIns step1] Checking for results...")
|
||||
try:
|
||||
patient_card = WebDriverWait(self.driver, 15).until(
|
||||
EC.presence_of_element_located((By.XPATH,
|
||||
"//div[contains(@class,'patient-card-root')] | "
|
||||
"//div[@data-testid='patientCard'] | "
|
||||
"//div[starts-with(@data-testid,'patientCard')]"))
|
||||
)
|
||||
print("[DeltaIns step1] Patient card found!")
|
||||
|
||||
# Extract patient name
|
||||
try:
|
||||
name_el = patient_card.find_element(By.XPATH, ".//h3")
|
||||
patient_name = name_el.text.strip()
|
||||
print(f"[DeltaIns step1] Patient name: {patient_name}")
|
||||
except Exception:
|
||||
patient_name = ""
|
||||
|
||||
# Extract eligibility dates
|
||||
try:
|
||||
elig_el = patient_card.find_element(By.XPATH,
|
||||
".//*[@data-testid='patientCardMemberEligibility']//*[contains(@class,'pt-staticfield-text')]")
|
||||
elig_text = elig_el.text.strip()
|
||||
print(f"[DeltaIns step1] Eligibility: {elig_text}")
|
||||
except Exception:
|
||||
elig_text = ""
|
||||
|
||||
# Store for step2
|
||||
self._patient_name = patient_name
|
||||
self._eligibility_text = elig_text
|
||||
|
||||
except TimeoutException:
|
||||
# Check for error messages
|
||||
try:
|
||||
body_text = self.driver.find_element(By.TAG_NAME, "body").text
|
||||
if "no results" in body_text.lower() or "not found" in body_text.lower() or "no patient" in body_text.lower():
|
||||
return "ERROR: No patient found with the provided Member ID and DOB"
|
||||
# Check for specific error alerts
|
||||
alerts = self.driver.find_elements(By.XPATH, "//*[@role='alert']")
|
||||
for alert in alerts:
|
||||
if alert.is_displayed():
|
||||
return f"ERROR: {alert.text.strip()[:200]}"
|
||||
except Exception:
|
||||
pass
|
||||
return "ERROR: No patient results found within timeout"
|
||||
|
||||
# 8. Click "Check eligibility and benefits"
|
||||
print("[DeltaIns step1] Clicking 'Check eligibility and benefits'...")
|
||||
try:
|
||||
check_btn = WebDriverWait(self.driver, 10).until(
|
||||
EC.element_to_be_clickable((By.XPATH,
|
||||
"//button[contains(text(),'Check eligibility and benefits')] | "
|
||||
"//button[@data-testid='eligibilityBenefitsButton']"))
|
||||
)
|
||||
check_btn.click()
|
||||
time.sleep(10)
|
||||
print(f"[DeltaIns step1] Navigated to: {self.driver.current_url}")
|
||||
except TimeoutException:
|
||||
return "ERROR: 'Check eligibility and benefits' button not found"
|
||||
|
||||
return "SUCCESS"
|
||||
|
||||
except Exception as e:
|
||||
print(f"[DeltaIns step1] Exception: {e}")
|
||||
return f"ERROR: step1 failed: {e}"
|
||||
|
||||
def step2(self):
|
||||
"""
|
||||
Extract eligibility information and capture PDF from the
|
||||
Eligibility & Benefits detail page.
|
||||
|
||||
URL: .../provider-tools/v2/eligibility-benefits
|
||||
|
||||
Extracts:
|
||||
- Patient name from h3 in patient-card-header
|
||||
- DOB, Member ID, eligibility from data-testid fields
|
||||
- PDF via Page.printToPDF
|
||||
"""
|
||||
try:
|
||||
print("[DeltaIns step2] Extracting eligibility data...")
|
||||
time.sleep(3)
|
||||
|
||||
current_url = self.driver.current_url
|
||||
print(f"[DeltaIns step2] URL: {current_url}")
|
||||
|
||||
if "eligibility-benefits" not in current_url:
|
||||
print("[DeltaIns step2] Not on eligibility page, checking body text...")
|
||||
|
||||
# Extract patient name
|
||||
patientName = ""
|
||||
try:
|
||||
name_el = self.driver.find_element(By.XPATH,
|
||||
"//div[contains(@class,'patient-card-header')]//h3 | "
|
||||
"//div[starts-with(@data-testid,'patientCard')]//h3")
|
||||
patientName = name_el.text.strip()
|
||||
print(f"[DeltaIns step2] Patient name: {patientName}")
|
||||
except Exception:
|
||||
patientName = getattr(self, '_patient_name', '') or f"{self.firstName} {self.lastName}".strip()
|
||||
print(f"[DeltaIns step2] Using stored/fallback name: {patientName}")
|
||||
|
||||
# Extract DOB from card
|
||||
extractedDob = ""
|
||||
try:
|
||||
dob_el = self.driver.find_element(By.XPATH,
|
||||
"//*[@data-testid='patientCardDateOfBirth']//*[contains(@class,'pt-staticfield-text')]")
|
||||
extractedDob = dob_el.text.strip()
|
||||
print(f"[DeltaIns step2] DOB: {extractedDob}")
|
||||
except Exception:
|
||||
extractedDob = self._format_dob(self.dateOfBirth)
|
||||
|
||||
# Extract Member ID from card
|
||||
foundMemberId = ""
|
||||
try:
|
||||
mid_el = self.driver.find_element(By.XPATH,
|
||||
"//*[@data-testid='patientCardMemberId']//*[contains(@class,'pt-staticfield-text')]")
|
||||
foundMemberId = mid_el.text.strip()
|
||||
print(f"[DeltaIns step2] Member ID: {foundMemberId}")
|
||||
except Exception:
|
||||
foundMemberId = self.memberId
|
||||
|
||||
# Extract eligibility status
|
||||
eligibility = "Unknown"
|
||||
try:
|
||||
elig_el = self.driver.find_element(By.XPATH,
|
||||
"//*[@data-testid='patientCardMemberEligibility']//*[contains(@class,'pt-staticfield-text')]")
|
||||
elig_text = elig_el.text.strip()
|
||||
print(f"[DeltaIns step2] Eligibility text: {elig_text}")
|
||||
|
||||
if "present" in elig_text.lower():
|
||||
eligibility = "Eligible"
|
||||
elif elig_text:
|
||||
eligibility = elig_text
|
||||
except Exception:
|
||||
elig_text = getattr(self, '_eligibility_text', '')
|
||||
if elig_text and "present" in elig_text.lower():
|
||||
eligibility = "Eligible"
|
||||
elif elig_text:
|
||||
eligibility = elig_text
|
||||
|
||||
# Check page body for additional eligibility info
|
||||
try:
|
||||
body_text = self.driver.find_element(By.TAG_NAME, "body").text
|
||||
if "not eligible" in body_text.lower():
|
||||
eligibility = "Not Eligible"
|
||||
elif "terminated" in body_text.lower():
|
||||
eligibility = "Terminated"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Capture PDF via "Download summary" -> "Download PDF" button
|
||||
pdfBase64 = ""
|
||||
try:
|
||||
existing_files = set(glob.glob(os.path.join(self.download_dir, "*")))
|
||||
|
||||
dl_link = WebDriverWait(self.driver, 10).until(
|
||||
EC.element_to_be_clickable((By.XPATH,
|
||||
"//a[@data-testid='downloadBenefitSummaryLink']"))
|
||||
)
|
||||
dl_link.click()
|
||||
print("[DeltaIns step2] Clicked 'Download summary'")
|
||||
time.sleep(3)
|
||||
|
||||
dl_btn = WebDriverWait(self.driver, 10).until(
|
||||
EC.element_to_be_clickable((By.XPATH,
|
||||
"//button[@data-testid='downloadPdfButton']"))
|
||||
)
|
||||
dl_btn.click()
|
||||
print("[DeltaIns step2] Clicked 'Download PDF'")
|
||||
|
||||
pdf_path = None
|
||||
for i in range(30):
|
||||
time.sleep(2)
|
||||
current_files = set(glob.glob(os.path.join(self.download_dir, "*")))
|
||||
new_files = current_files - existing_files
|
||||
completed = [f for f in new_files
|
||||
if not f.endswith(".crdownload") and not f.endswith(".tmp")]
|
||||
if completed:
|
||||
pdf_path = completed[0]
|
||||
break
|
||||
|
||||
if pdf_path and os.path.exists(pdf_path):
|
||||
with open(pdf_path, "rb") as f:
|
||||
pdfBase64 = base64.b64encode(f.read()).decode()
|
||||
print(f"[DeltaIns step2] PDF downloaded: {os.path.basename(pdf_path)} "
|
||||
f"({os.path.getsize(pdf_path)} bytes), b64 len={len(pdfBase64)}")
|
||||
try:
|
||||
os.remove(pdf_path)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
print("[DeltaIns step2] Download PDF timed out, falling back to CDP")
|
||||
cdp_result = self.driver.execute_cdp_cmd("Page.printToPDF", {
|
||||
"printBackground": True,
|
||||
"preferCSSPageSize": True,
|
||||
"scale": 0.7,
|
||||
"paperWidth": 11,
|
||||
"paperHeight": 17,
|
||||
})
|
||||
pdfBase64 = cdp_result.get("data", "")
|
||||
print(f"[DeltaIns step2] CDP fallback PDF, b64 len={len(pdfBase64)}")
|
||||
|
||||
# Dismiss the download modal
|
||||
try:
|
||||
self.driver.find_element(By.TAG_NAME, "body").send_keys(Keys.ESCAPE)
|
||||
time.sleep(1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
print(f"[DeltaIns step2] PDF capture failed: {e}")
|
||||
try:
|
||||
cdp_result = self.driver.execute_cdp_cmd("Page.printToPDF", {
|
||||
"printBackground": True,
|
||||
"preferCSSPageSize": True,
|
||||
"scale": 0.7,
|
||||
"paperWidth": 11,
|
||||
"paperHeight": 17,
|
||||
})
|
||||
pdfBase64 = cdp_result.get("data", "")
|
||||
print(f"[DeltaIns step2] CDP fallback PDF, b64 len={len(pdfBase64)}")
|
||||
except Exception as e2:
|
||||
print(f"[DeltaIns step2] CDP fallback also failed: {e2}")
|
||||
|
||||
# Hide browser after completion
|
||||
self._close_browser()
|
||||
|
||||
result = {
|
||||
"status": "success",
|
||||
"patientName": patientName,
|
||||
"eligibility": eligibility,
|
||||
"pdfBase64": pdfBase64,
|
||||
"extractedDob": extractedDob,
|
||||
"memberId": foundMemberId,
|
||||
}
|
||||
|
||||
print(f"[DeltaIns step2] Result: name={result['patientName']}, "
|
||||
f"eligibility={result['eligibility']}, "
|
||||
f"memberId={result['memberId']}")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
print(f"[DeltaIns step2] Exception: {e}")
|
||||
self._close_browser()
|
||||
return {
|
||||
"status": "error",
|
||||
"patientName": getattr(self, '_patient_name', '') or f"{self.firstName} {self.lastName}".strip(),
|
||||
"eligibility": "Unknown",
|
||||
"pdfBase64": "",
|
||||
"extractedDob": self._format_dob(self.dateOfBirth),
|
||||
"memberId": self.memberId,
|
||||
"error": str(e),
|
||||
}
|
||||
@@ -3,6 +3,7 @@ from selenium.common.exceptions import WebDriverException, TimeoutException
|
||||
from selenium.webdriver.chrome.service import Service
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
from selenium.webdriver.common.action_chains import ActionChains
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
from webdriver_manager.chrome import ChromeDriverManager
|
||||
@@ -22,6 +23,8 @@ class AutomationDentaQuestEligibilityCheck:
|
||||
# Flatten values for convenience
|
||||
self.memberId = self.data.get("memberId", "")
|
||||
self.dateOfBirth = self.data.get("dateOfBirth", "")
|
||||
self.firstName = self.data.get("firstName", "")
|
||||
self.lastName = self.data.get("lastName", "")
|
||||
self.dentaquest_username = self.data.get("dentaquestUsername", "")
|
||||
self.dentaquest_password = self.data.get("dentaquestPassword", "")
|
||||
|
||||
@@ -247,11 +250,20 @@ class AutomationDentaQuestEligibilityCheck:
|
||||
return f"ERROR:LOGIN FAILED: {e}"
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
time.sleep(2)
|
||||
@@ -267,14 +279,6 @@ class AutomationDentaQuestEligibilityCheck:
|
||||
print(f"[DentaQuest step1] Error parsing DOB: {e}")
|
||||
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
|
||||
def fill_date_by_testid(testid, month_val, day_val, year_val, field_name):
|
||||
try:
|
||||
@@ -285,55 +289,127 @@ class AutomationDentaQuestEligibilityCheck:
|
||||
|
||||
def replace_with_sendkeys(el, value):
|
||||
el.click()
|
||||
time.sleep(0.1)
|
||||
# Clear existing content
|
||||
time.sleep(0.05)
|
||||
el.send_keys(Keys.CONTROL, "a")
|
||||
time.sleep(0.05)
|
||||
el.send_keys(Keys.BACKSPACE)
|
||||
time.sleep(0.05)
|
||||
# Type new value
|
||||
el.send_keys(value)
|
||||
time.sleep(0.1)
|
||||
|
||||
# Fill month
|
||||
replace_with_sendkeys(month_elem, month_val)
|
||||
# Tab to day field
|
||||
month_elem.send_keys(Keys.TAB)
|
||||
time.sleep(0.1)
|
||||
|
||||
# Fill day
|
||||
replace_with_sendkeys(day_elem, day_val)
|
||||
# Tab to year field
|
||||
day_elem.send_keys(Keys.TAB)
|
||||
time.sleep(0.1)
|
||||
|
||||
# Fill year
|
||||
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}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[DentaQuest step1] Error filling {field_name}: {e}")
|
||||
return False
|
||||
|
||||
# 1. Fill Date of Service with TODAY's date using specific data-testid
|
||||
fill_date_by_testid("member-search_date-of-service", service_month, service_day, service_year, "Date of Service")
|
||||
time.sleep(0.5)
|
||||
# 1. Select Provider from dropdown (required field)
|
||||
try:
|
||||
print("[DentaQuest step1] Selecting Provider...")
|
||||
# Try to find and click Provider dropdown
|
||||
provider_selectors = [
|
||||
"//label[contains(text(),'Provider')]/following-sibling::*//div[contains(@class,'select')]",
|
||||
"//div[contains(@data-testid,'provider')]//div[contains(@class,'select')]",
|
||||
"//*[@aria-label='Provider']",
|
||||
"//select[contains(@name,'provider') or contains(@id,'provider')]",
|
||||
"//div[contains(@class,'provider')]//input",
|
||||
"//label[contains(text(),'Provider')]/..//div[contains(@class,'control')]"
|
||||
]
|
||||
|
||||
provider_clicked = False
|
||||
for selector in provider_selectors:
|
||||
try:
|
||||
provider_dropdown = WebDriverWait(self.driver, 3).until(
|
||||
EC.element_to_be_clickable((By.XPATH, selector))
|
||||
)
|
||||
provider_dropdown.click()
|
||||
print(f"[DentaQuest step1] Clicked provider dropdown with selector: {selector}")
|
||||
time.sleep(0.5)
|
||||
provider_clicked = True
|
||||
break
|
||||
except TimeoutException:
|
||||
continue
|
||||
|
||||
if provider_clicked:
|
||||
# Select first available provider option
|
||||
option_selectors = [
|
||||
"//div[contains(@class,'option') and not(contains(@class,'disabled'))]",
|
||||
"//li[contains(@class,'option')]",
|
||||
"//option[not(@disabled)]",
|
||||
"//*[@role='option']"
|
||||
]
|
||||
|
||||
for opt_selector in option_selectors:
|
||||
try:
|
||||
options = self.driver.find_elements(By.XPATH, opt_selector)
|
||||
if options:
|
||||
# Select first non-placeholder option
|
||||
for opt in options:
|
||||
opt_text = opt.text.strip()
|
||||
if opt_text and "select" not in opt_text.lower():
|
||||
opt.click()
|
||||
print(f"[DentaQuest step1] Selected provider: {opt_text}")
|
||||
break
|
||||
break
|
||||
except:
|
||||
continue
|
||||
|
||||
# Close dropdown if still open
|
||||
ActionChains(self.driver).send_keys(Keys.ESCAPE).perform()
|
||||
time.sleep(0.3)
|
||||
else:
|
||||
print("[DentaQuest step1] Warning: Could not find Provider dropdown")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[DentaQuest step1] Error selecting provider: {e}")
|
||||
|
||||
time.sleep(0.3)
|
||||
|
||||
# 2. Fill Date of Birth with patient's DOB using specific data-testid
|
||||
fill_date_by_testid("member-search_date-of-birth", dob_month, dob_day, dob_year, "Date of Birth")
|
||||
time.sleep(0.5)
|
||||
time.sleep(0.3)
|
||||
|
||||
# 3. Fill Member ID
|
||||
member_id_input = wait.until(EC.presence_of_element_located(
|
||||
(By.XPATH, '//input[@placeholder="Search by member ID"]')
|
||||
))
|
||||
member_id_input.clear()
|
||||
member_id_input.send_keys(self.memberId)
|
||||
print(f"[DentaQuest step1] Entered member ID: {self.memberId}")
|
||||
# 3. Fill ALL available search fields (flexible search)
|
||||
# Fill Member ID if provided
|
||||
if self.memberId:
|
||||
try:
|
||||
member_id_input = wait.until(EC.presence_of_element_located(
|
||||
(By.XPATH, '//input[@placeholder="Search by member ID"]')
|
||||
))
|
||||
member_id_input.clear()
|
||||
member_id_input.send_keys(self.memberId)
|
||||
print(f"[DentaQuest step1] Entered member ID: {self.memberId}")
|
||||
time.sleep(0.2)
|
||||
except Exception as e:
|
||||
print(f"[DentaQuest step1] Warning: Could not fill member ID: {e}")
|
||||
|
||||
# Fill First Name if provided
|
||||
if self.firstName:
|
||||
try:
|
||||
first_name_input = wait.until(EC.presence_of_element_located(
|
||||
(By.XPATH, '//input[@placeholder="First name - 1 char minimum" or contains(@placeholder,"first name") or contains(@name,"firstName") or contains(@id,"firstName")]')
|
||||
))
|
||||
first_name_input.clear()
|
||||
first_name_input.send_keys(self.firstName)
|
||||
print(f"[DentaQuest step1] Entered first name: {self.firstName}")
|
||||
time.sleep(0.2)
|
||||
except Exception as e:
|
||||
print(f"[DentaQuest step1] Warning: Could not fill first name: {e}")
|
||||
|
||||
# Fill Last Name if provided
|
||||
if self.lastName:
|
||||
try:
|
||||
last_name_input = wait.until(EC.presence_of_element_located(
|
||||
(By.XPATH, '//input[@placeholder="Last name - 2 char minimum" or contains(@placeholder,"last name") or contains(@name,"lastName") or contains(@id,"lastName")]')
|
||||
))
|
||||
last_name_input.clear()
|
||||
last_name_input.send_keys(self.lastName)
|
||||
print(f"[DentaQuest step1] Entered last name: {self.lastName}")
|
||||
time.sleep(0.2)
|
||||
except Exception as e:
|
||||
print(f"[DentaQuest step1] Warning: Could not fill last name: {e}")
|
||||
|
||||
time.sleep(0.3)
|
||||
|
||||
@@ -351,7 +427,8 @@ class AutomationDentaQuestEligibilityCheck:
|
||||
search_btn.click()
|
||||
print("[DentaQuest step1] Clicked search button (fallback)")
|
||||
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")
|
||||
|
||||
time.sleep(5)
|
||||
@@ -363,7 +440,7 @@ class AutomationDentaQuestEligibilityCheck:
|
||||
))
|
||||
if error_msg and error_msg.is_displayed():
|
||||
print("[DentaQuest step1] No results found")
|
||||
return "ERROR: INVALID MEMBERID OR DOB"
|
||||
return "ERROR: INVALID SEARCH CRITERIA"
|
||||
except TimeoutException:
|
||||
pass
|
||||
|
||||
@@ -390,8 +467,41 @@ class AutomationDentaQuestEligibilityCheck:
|
||||
except TimeoutException:
|
||||
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"
|
||||
foundMemberId = ""
|
||||
|
||||
# Try to extract Member ID from the first row of search results
|
||||
# Row format: "NAME\nDOB: MM/DD/YYYY\nMEMBER_ID\n..."
|
||||
import re
|
||||
try:
|
||||
first_row = self.driver.find_element(By.XPATH, "(//tbody//tr)[1]")
|
||||
row_text = first_row.text.strip()
|
||||
|
||||
if row_text:
|
||||
lines = row_text.split('\n')
|
||||
# Member ID is typically the 3rd line (index 2) - a pure number
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
# Member ID is usually a number, could be alphanumeric
|
||||
# It should be after DOB line and be mostly digits
|
||||
if line and re.match(r'^[A-Z0-9]{5,}$', line) and not line.startswith('DOB'):
|
||||
foundMemberId = line
|
||||
print(f"[DentaQuest step2] Extracted Member ID from row: {foundMemberId}")
|
||||
break
|
||||
|
||||
# Fallback: if we have self.memberId from input, use that
|
||||
if not foundMemberId and self.memberId:
|
||||
foundMemberId = self.memberId
|
||||
print(f"[DentaQuest step2] Using input Member ID: {foundMemberId}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[DentaQuest step2] Error extracting Member ID: {e}")
|
||||
# Fallback to input memberId
|
||||
if self.memberId:
|
||||
foundMemberId = self.memberId
|
||||
|
||||
# Extract eligibility status
|
||||
status_selectors = [
|
||||
"(//tbody//tr)[1]//a[contains(@href, 'eligibility')]",
|
||||
"//a[contains(@href,'eligibility')]",
|
||||
@@ -424,25 +534,6 @@ class AutomationDentaQuestEligibilityCheck:
|
||||
current_url_before = self.driver.current_url
|
||||
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
|
||||
try:
|
||||
all_links = self.driver.find_elements(By.XPATH, "(//tbody//tr)[1]//a")
|
||||
@@ -454,21 +545,45 @@ class AutomationDentaQuestEligibilityCheck:
|
||||
except Exception as 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 = [
|
||||
"(//table//tbody//tr)[1]//td[1]//a", # First column link
|
||||
"(//tbody//tr)[1]//a[contains(@href, 'member-details')]", # member-details link
|
||||
"(//tbody//tr)[1]//a[contains(@href, 'member')]", # Any member link
|
||||
]
|
||||
|
||||
# First, try to extract patient name from the row text (not the link)
|
||||
try:
|
||||
first_row = self.driver.find_element(By.XPATH, "(//tbody//tr)[1]")
|
||||
row_text = first_row.text.strip()
|
||||
print(f"[DentaQuest step2] First row text: {row_text[:100]}...")
|
||||
|
||||
# The name is typically the first line, before "DOB:"
|
||||
if row_text:
|
||||
lines = row_text.split('\n')
|
||||
if lines:
|
||||
# First line is usually the patient name
|
||||
potential_name = lines[0].strip()
|
||||
# Make sure it's not a date or ID
|
||||
if potential_name and not potential_name.startswith('DOB') and not potential_name.isdigit():
|
||||
patientName = potential_name
|
||||
print(f"[DentaQuest step2] Extracted patient name from row: '{patientName}'")
|
||||
except Exception as e:
|
||||
print(f"[DentaQuest step2] Error extracting name from row: {e}")
|
||||
|
||||
# Now find the detail link
|
||||
for selector in patient_link_selectors:
|
||||
try:
|
||||
patient_link = WebDriverWait(self.driver, 5).until(
|
||||
EC.presence_of_element_located((By.XPATH, selector))
|
||||
)
|
||||
patientName = patient_link.text.strip()
|
||||
link_text = patient_link.text.strip()
|
||||
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):
|
||||
detail_url = href
|
||||
@@ -619,7 +734,8 @@ class AutomationDentaQuestEligibilityCheck:
|
||||
"eligibility": eligibilityText,
|
||||
"ss_path": pdf_path, # Keep key as ss_path for backward compatibility
|
||||
"pdf_path": pdf_path, # Also add explicit pdf_path
|
||||
"patientName": patientName
|
||||
"patientName": patientName,
|
||||
"memberId": foundMemberId # Member ID extracted from the page
|
||||
}
|
||||
print(f"[DentaQuest step2] Success: {output}")
|
||||
return output
|
||||
|
||||
@@ -191,7 +191,59 @@ class AutomationUnitedSCOEligibilityCheck:
|
||||
|
||||
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:
|
||||
otp_input = WebDriverWait(self.driver, 15).until(
|
||||
EC.presence_of_element_located((By.XPATH,
|
||||
@@ -207,10 +259,8 @@ class AutomationUnitedSCOEligibilityCheck:
|
||||
except TimeoutException:
|
||||
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()
|
||||
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"
|
||||
@@ -254,6 +304,55 @@ class AutomationUnitedSCOEligibilityCheck:
|
||||
print(f"[UnitedSCO login] Exception: {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):
|
||||
"""Convert DOB from YYYY-MM-DD to MM/DD/YYYY format"""
|
||||
if dob_str and "-" in dob_str:
|
||||
@@ -267,13 +366,9 @@ class AutomationUnitedSCOEligibilityCheck:
|
||||
"""
|
||||
Navigate to Eligibility page and fill the Patient Information form.
|
||||
|
||||
FLEXIBLE INPUT SUPPORT:
|
||||
- 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:
|
||||
Workflow based on actual DOM testing:
|
||||
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
|
||||
4. Click Continue
|
||||
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
|
||||
|
||||
try:
|
||||
# Determine which input mode to use
|
||||
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)"
|
||||
print(f"[UnitedSCO step1] Starting eligibility search for: {self.firstName} {self.lastName}, DOB: {self.dateOfBirth}")
|
||||
|
||||
# Navigate directly to eligibility page
|
||||
print("[UnitedSCO step1] Navigating to eligibility page...")
|
||||
@@ -305,28 +390,70 @@ class AutomationUnitedSCOEligibilityCheck:
|
||||
# Step 1.1: Fill the 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:
|
||||
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")
|
||||
except TimeoutException:
|
||||
print("[UnitedSCO step1] Patient Information form not found")
|
||||
return "ERROR: Patient Information form not found"
|
||||
|
||||
# Fill Subscriber ID if provided (id='subscriberId_Front')
|
||||
if has_member_id:
|
||||
# Fill Subscriber ID / Medicaid ID if memberId is provided
|
||||
# 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:
|
||||
subscriber_id_input = self.driver.find_element(By.ID, "subscriberId_Front")
|
||||
subscriber_id_input.clear()
|
||||
subscriber_id_input.send_keys(self.memberId)
|
||||
print(f"[UnitedSCO step1] Entered Subscriber ID: {self.memberId}")
|
||||
subscriber_id_selectors = [
|
||||
"//input[@id='subscriberId_Front']",
|
||||
"//input[@id='subscriberId_Back' or @id='subscriberID_Back']",
|
||||
"//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:
|
||||
print(f"[UnitedSCO step1] Error entering Subscriber ID: {e}")
|
||||
|
||||
# Fill First Name if provided (id='firstName_Back')
|
||||
if self.firstName and self.firstName.strip():
|
||||
# Fill First Name (id='firstName_Back') - only if provided
|
||||
if self.firstName:
|
||||
try:
|
||||
first_name_input = self.driver.find_element(By.ID, "firstName_Back")
|
||||
first_name_input.clear()
|
||||
@@ -334,11 +461,11 @@ class AutomationUnitedSCOEligibilityCheck:
|
||||
print(f"[UnitedSCO step1] Entered First Name: {self.firstName}")
|
||||
except Exception as e:
|
||||
print(f"[UnitedSCO step1] Error entering First Name: {e}")
|
||||
if not has_member_id: # Only fail if we're relying on name
|
||||
return "ERROR: Could not enter First Name"
|
||||
else:
|
||||
print("[UnitedSCO step1] No First Name provided, skipping")
|
||||
|
||||
# Fill Last Name if provided (id='lastName_Back')
|
||||
if self.lastName and self.lastName.strip():
|
||||
# Fill Last Name (id='lastName_Back') - only if provided
|
||||
if self.lastName:
|
||||
try:
|
||||
last_name_input = self.driver.find_element(By.ID, "lastName_Back")
|
||||
last_name_input.clear()
|
||||
@@ -346,10 +473,10 @@ class AutomationUnitedSCOEligibilityCheck:
|
||||
print(f"[UnitedSCO step1] Entered Last Name: {self.lastName}")
|
||||
except Exception as e:
|
||||
print(f"[UnitedSCO step1] Error entering Last Name: {e}")
|
||||
if not has_member_id: # Only fail if we're relying on name
|
||||
return "ERROR: Could not enter Last Name"
|
||||
else:
|
||||
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:
|
||||
dob_input = self.driver.find_element(By.ID, "dateOfBirth_Back")
|
||||
dob_input.clear()
|
||||
@@ -364,32 +491,135 @@ class AutomationUnitedSCOEligibilityCheck:
|
||||
|
||||
# Step 1.2: Select Payer - UnitedHealthcare Massachusetts
|
||||
print("[UnitedSCO step1] Selecting Payer...")
|
||||
|
||||
# First dismiss any blocking dialogs (e.g. Chrome password save)
|
||||
try:
|
||||
# Click the Payer ng-select dropdown
|
||||
payer_ng_select = self.driver.find_element(By.XPATH,
|
||||
"//label[contains(text(),'Payer')]/following-sibling::ng-select"
|
||||
)
|
||||
payer_ng_select.click()
|
||||
time.sleep(1)
|
||||
self.driver.execute_script("""
|
||||
// Dismiss Chrome password manager popup if present
|
||||
var dialogs = document.querySelectorAll('[role="dialog"], .cdk-overlay-container');
|
||||
dialogs.forEach(function(d) { d.style.display = 'none'; });
|
||||
""")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Find and click "UnitedHealthcare Massachusetts" option
|
||||
payer_options = self.driver.find_elements(By.XPATH,
|
||||
"//ng-dropdown-panel//div[contains(@class,'ng-option')]"
|
||||
)
|
||||
for opt in payer_options:
|
||||
if "UnitedHealthcare Massachusetts" in opt.text:
|
||||
opt.click()
|
||||
print("[UnitedSCO step1] Selected Payer: UnitedHealthcare Massachusetts")
|
||||
break
|
||||
payer_selected = False
|
||||
|
||||
# Press Escape to close any dropdown
|
||||
ActionChains(self.driver).send_keys(Keys.ESCAPE).perform()
|
||||
time.sleep(1)
|
||||
# Strategy 1: Click the ng-select, type to search, and select the option
|
||||
try:
|
||||
# Find the Payer ng-select by multiple selectors
|
||||
payer_selectors = [
|
||||
"//label[contains(text(),'Payer')]/following-sibling::ng-select",
|
||||
"//label[contains(text(),'Payer')]/..//ng-select",
|
||||
"//ng-select[contains(@placeholder,'Payer') or contains(@placeholder,'payer')]",
|
||||
"//ng-select[.//input[contains(@placeholder,'Search by Payers')]]",
|
||||
]
|
||||
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:
|
||||
print(f"[UnitedSCO step1] Error selecting Payer: {e}")
|
||||
# Try to continue anyway - payer might be pre-selected
|
||||
ActionChains(self.driver).send_keys(Keys.ESCAPE).perform()
|
||||
print(f"[UnitedSCO step1] Payer selection strategy 1 error: {e}")
|
||||
try:
|
||||
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)
|
||||
try:
|
||||
@@ -399,38 +629,84 @@ class AutomationUnitedSCOEligibilityCheck:
|
||||
continue_btn.click()
|
||||
print("[UnitedSCO step1] Clicked Continue button (Patient Info)")
|
||||
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:
|
||||
print(f"[UnitedSCO step1] Error clicking Continue: {e}")
|
||||
return "ERROR: Could not click Continue button"
|
||||
|
||||
# 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...")
|
||||
|
||||
on_practitioner_page = False
|
||||
try:
|
||||
# Click Practitioner Taxonomy dropdown (id='paymentGroupId')
|
||||
taxonomy_input = WebDriverWait(self.driver, 10).until(
|
||||
EC.element_to_be_clickable((By.ID, "paymentGroupId"))
|
||||
# Check for Practitioner page elements (paymentGroupId or treatment location)
|
||||
WebDriverWait(self.driver, 8).until(
|
||||
lambda d: d.find_element(By.ID, "paymentGroupId").is_displayed() or
|
||||
d.find_element(By.ID, "treatmentLocation").is_displayed()
|
||||
)
|
||||
taxonomy_input.click()
|
||||
print("[UnitedSCO step1] Clicked Practitioner Taxonomy dropdown")
|
||||
time.sleep(1)
|
||||
on_practitioner_page = True
|
||||
print("[UnitedSCO step1] Practitioner & Location page loaded")
|
||||
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
|
||||
summit_option = WebDriverWait(self.driver, 10).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")
|
||||
# Check for error dialog again
|
||||
error_result = self._check_for_error_dialog()
|
||||
if error_result:
|
||||
return error_result
|
||||
|
||||
# Press Escape to close dropdown
|
||||
ActionChains(self.driver).send_keys(Keys.ESCAPE).perform()
|
||||
time.sleep(1)
|
||||
print("[UnitedSCO step1] Practitioner page not detected, attempting to continue...")
|
||||
|
||||
except TimeoutException:
|
||||
print("[UnitedSCO step1] Practitioner Taxonomy not found or already selected")
|
||||
except Exception as e:
|
||||
print(f"[UnitedSCO step1] Practitioner Taxonomy handling: {e}")
|
||||
if on_practitioner_page:
|
||||
try:
|
||||
# Click Practitioner Taxonomy dropdown (id='paymentGroupId')
|
||||
taxonomy_input = self.driver.find_element(By.ID, "paymentGroupId")
|
||||
if taxonomy_input.is_displayed():
|
||||
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)
|
||||
try:
|
||||
@@ -442,25 +718,15 @@ class AutomationUnitedSCOEligibilityCheck:
|
||||
time.sleep(5)
|
||||
except Exception as 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
|
||||
try:
|
||||
error_selectors = [
|
||||
"//*[contains(text(),'No results')]",
|
||||
"//*[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
|
||||
# Final check for error dialogs after the search
|
||||
error_result = self._check_for_error_dialog()
|
||||
if error_result:
|
||||
return error_result
|
||||
|
||||
print("[UnitedSCO step1] Patient search completed successfully")
|
||||
return "Success"
|
||||
@@ -470,16 +736,40 @@ class AutomationUnitedSCOEligibilityCheck:
|
||||
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):
|
||||
"""
|
||||
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.
|
||||
Workflow based on actual DOM testing:
|
||||
1. Extract eligibility status and Member ID from the page
|
||||
2. Click the "Eligibility" button (id='eligibility-link')
|
||||
3. Generate PDF using Chrome DevTools Protocol (same as other insurances)
|
||||
The "Eligibility" tab at the bottom (next to "Benefit Summary" and
|
||||
"Service History") may:
|
||||
a) Open a new browser tab with eligibility details
|
||||
b) Download a PDF file
|
||||
c) Load content dynamically on the same page
|
||||
We handle all three cases.
|
||||
"""
|
||||
import glob
|
||||
import re
|
||||
|
||||
try:
|
||||
print("[UnitedSCO step2] Starting eligibility capture")
|
||||
|
||||
@@ -498,7 +788,7 @@ class AutomationUnitedSCOEligibilityCheck:
|
||||
try:
|
||||
status_elem = WebDriverWait(self.driver, 10).until(
|
||||
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()
|
||||
@@ -516,12 +806,93 @@ class AutomationUnitedSCOEligibilityCheck:
|
||||
|
||||
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)
|
||||
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)
|
||||
if member_id_match:
|
||||
foundMemberId = member_id_match.group(1)
|
||||
@@ -529,44 +900,213 @@ class AutomationUnitedSCOEligibilityCheck:
|
||||
except Exception as e:
|
||||
print(f"[UnitedSCO step2] Could not extract Member ID: {e}")
|
||||
|
||||
# 2) Click the "Eligibility" button (id='eligibility-link')
|
||||
print("[UnitedSCO step2] Looking for 'Eligibility' button...")
|
||||
|
||||
# Extract Date of Birth from page if available (for patient creation)
|
||||
extractedDob = ""
|
||||
try:
|
||||
eligibility_btn = WebDriverWait(self.driver, 10).until(
|
||||
EC.element_to_be_clickable((By.ID, "eligibility-link"))
|
||||
)
|
||||
eligibility_btn.click()
|
||||
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"
|
||||
)
|
||||
dob_match = re.search(r'Date Of Birth\s*[\n:]\s*(\d{2}/\d{2}/\d{4})', page_text)
|
||||
if dob_match:
|
||||
extractedDob = dob_match.group(1)
|
||||
print(f"[UnitedSCO step2] Extracted DOB from page: {extractedDob}")
|
||||
except Exception:
|
||||
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()
|
||||
|
||||
# 3) Generate PDF using Chrome DevTools Protocol (same as other insurances)
|
||||
print("[UnitedSCO step2] Generating PDF...")
|
||||
# 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)
|
||||
|
||||
if not pdf_path:
|
||||
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 = {
|
||||
"landscape": False,
|
||||
"displayHeaderFooter": False,
|
||||
@@ -581,31 +1121,17 @@ class AutomationUnitedSCOEligibilityCheck:
|
||||
"scale": 0.9,
|
||||
}
|
||||
|
||||
# Use foundMemberId for filename
|
||||
file_identifier = foundMemberId if foundMemberId else f"{self.firstName}_{self.lastName}"
|
||||
file_identifier = member_id if member_id else f"{self.firstName}_{self.lastName}"
|
||||
|
||||
result = self.driver.execute_cdp_cmd("Page.printToPDF", pdf_options)
|
||||
pdf_data = base64.b64decode(result.get('data', ''))
|
||||
pdf_path = os.path.join(self.download_dir, f"unitedsco_eligibility_{file_identifier}_{int(time.time())}.pdf")
|
||||
with open(pdf_path, "wb") as f:
|
||||
f.write(pdf_data)
|
||||
print(f"[UnitedSCO step2] PDF saved: {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
|
||||
}
|
||||
|
||||
return pdf_path
|
||||
except Exception as e:
|
||||
print(f"[UnitedSCO step2] Exception: {e}")
|
||||
return {"status": "error", "message": f"STEP2 FAILED: {str(e)}"}
|
||||
print(f"[UnitedSCO _capture_pdf] Error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def main_workflow(self, url):
|
||||
|
||||
@@ -112,6 +112,26 @@ class UnitedSCOBrowserManager:
|
||||
except Exception as e:
|
||||
print(f"[UnitedSCO BrowserManager] Could not clear IndexedDB: {e}")
|
||||
|
||||
# Clear browser cache (prevents corrupted cached responses)
|
||||
cache_dirs = [
|
||||
os.path.join(self.profile_dir, "Default", "Cache"),
|
||||
os.path.join(self.profile_dir, "Default", "Code Cache"),
|
||||
os.path.join(self.profile_dir, "Default", "GPUCache"),
|
||||
os.path.join(self.profile_dir, "Default", "Service Worker"),
|
||||
os.path.join(self.profile_dir, "Cache"),
|
||||
os.path.join(self.profile_dir, "Code Cache"),
|
||||
os.path.join(self.profile_dir, "GPUCache"),
|
||||
os.path.join(self.profile_dir, "Service Worker"),
|
||||
os.path.join(self.profile_dir, "ShaderCache"),
|
||||
]
|
||||
for cache_dir in cache_dirs:
|
||||
if os.path.exists(cache_dir):
|
||||
try:
|
||||
shutil.rmtree(cache_dir)
|
||||
print(f"[UnitedSCO BrowserManager] Cleared {os.path.basename(cache_dir)}")
|
||||
except Exception as e:
|
||||
print(f"[UnitedSCO BrowserManager] Could not clear {os.path.basename(cache_dir)}: {e}")
|
||||
|
||||
# Set flag to clear session via JavaScript after browser opens
|
||||
self._needs_session_clear = True
|
||||
|
||||
@@ -233,11 +253,21 @@ class UnitedSCOBrowserManager:
|
||||
options.add_argument("--no-sandbox")
|
||||
options.add_argument("--disable-dev-shm-usage")
|
||||
|
||||
# Anti-detection options (prevent bot detection)
|
||||
options.add_argument("--disable-blink-features=AutomationControlled")
|
||||
options.add_experimental_option("excludeSwitches", ["enable-automation"])
|
||||
options.add_experimental_option("useAutomationExtension", False)
|
||||
options.add_argument("--disable-infobars")
|
||||
|
||||
prefs = {
|
||||
"download.default_directory": self.download_dir,
|
||||
"plugins.always_open_pdf_externally": True,
|
||||
"download.prompt_for_download": False,
|
||||
"download.directory_upgrade": True
|
||||
"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)
|
||||
|
||||
@@ -245,6 +275,12 @@ class UnitedSCOBrowserManager:
|
||||
self._driver = webdriver.Chrome(service=service, options=options)
|
||||
self._driver.maximize_window()
|
||||
|
||||
# Remove webdriver property to avoid detection
|
||||
try:
|
||||
self._driver.execute_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Reset the session clear flag (file-based clearing is done on startup)
|
||||
self._needs_session_clear = False
|
||||
|
||||
|
||||
3
package-lock.json
generated
3
package-lock.json
generated
@@ -13942,10 +13942,13 @@
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.0.1",
|
||||
"@prisma/client": "^7.0.0",
|
||||
"dotenv": "^16.6.1",
|
||||
"prisma-zod-generator": "^2.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/node": "^22.15.17",
|
||||
"bcrypt": "^5.1.1",
|
||||
"prisma": "^7.0.0",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.8.2"
|
||||
|
||||
@@ -19,10 +19,13 @@
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.0.1",
|
||||
"@prisma/client": "^7.0.0",
|
||||
"dotenv": "^16.6.1",
|
||||
"prisma-zod-generator": "^2.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/node": "^22.15.17",
|
||||
"bcrypt": "^5.1.1",
|
||||
"prisma": "^7.0.0",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.8.2"
|
||||
|
||||
@@ -1,12 +1,38 @@
|
||||
import dotenv from "dotenv";
|
||||
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({
|
||||
schema: "schema.prisma",
|
||||
schema: "prisma/schema.prisma",
|
||||
datasource: {
|
||||
url: env("DATABASE_URL"),
|
||||
url: url!,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -18,10 +18,16 @@ datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
|
||||
enum UserRole {
|
||||
ADMIN
|
||||
USER
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique
|
||||
password String
|
||||
role UserRole @default(USER)
|
||||
patients Patient[]
|
||||
appointments Appointment[]
|
||||
staff Staff[]
|
||||
|
||||
@@ -1,32 +1,43 @@
|
||||
import dotenv from "dotenv";
|
||||
import path from "path";
|
||||
dotenv.config({ path: path.resolve(__dirname, ".env") });
|
||||
|
||||
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 {
|
||||
return date.toTimeString().slice(0, 5); // "HH:MM"
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Create multiple users
|
||||
const users = await prisma.user.createMany({
|
||||
data: [
|
||||
{ username: "admin2", password: "123456" },
|
||||
{ username: "bob", password: "123456" },
|
||||
],
|
||||
const hash = (pw: string) => bcrypt.hash(pw, 10);
|
||||
|
||||
const adminUser = await prisma.user.upsert({
|
||||
where: { username: "admin" },
|
||||
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();
|
||||
|
||||
// Creatin staff
|
||||
await prisma.staff.createMany({
|
||||
data: [
|
||||
{ name: "Dr. Kai Gao", 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({
|
||||
data: [
|
||||
{
|
||||
@@ -54,33 +65,39 @@ async function main() {
|
||||
userId: createdUsers[1].id,
|
||||
},
|
||||
],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
const createdPatients = await prisma.patient.findMany();
|
||||
|
||||
// Create multiple appointments
|
||||
await prisma.appointment.createMany({
|
||||
data: [
|
||||
{
|
||||
patientId: createdPatients[0].id,
|
||||
userId: createdUsers[0].id,
|
||||
title: "Initial Consultation",
|
||||
date: new Date("2025-06-01"),
|
||||
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",
|
||||
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",
|
||||
},
|
||||
],
|
||||
});
|
||||
const staffMembers = await prisma.staff.findMany();
|
||||
if (createdPatients.length >= 2 && createdUsers.length >= 2 && staffMembers.length >= 1) {
|
||||
await prisma.appointment.createMany({
|
||||
data: [
|
||||
{
|
||||
patientId: createdPatients[0].id,
|
||||
userId: createdUsers[0].id,
|
||||
staffId: staffMembers[0].id,
|
||||
title: "Initial Consultation",
|
||||
date: new Date("2025-06-01"),
|
||||
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,
|
||||
staffId: staffMembers[0].id,
|
||||
title: "Follow-up",
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user