feat: add United Ucard eligibility check with OTP + persistent session
Adds a full login/OTP/search/capture flow for United Ucard (OneHealthcareID -> UHC Dental provider portal), mirroring the United SCO/DDMA pattern: persistent Chrome profile, OTP polling, member eligibility search, and PDF capture saved to the patient's Eligibility folder. Also fixes a validation bug in the browser-safe patient schema where email/address/city/zipCode/gender/phone were optional but not nullable, rejecting patient records with null values on those fields during edits (e.g. "Expected string, received null"). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ import { runOcrProcessor } from "./processors/ocrProcessor";
|
|||||||
import { runDdmaEligibilityProcessor } from "./processors/ddmaEligibilityProcessor";
|
import { runDdmaEligibilityProcessor } from "./processors/ddmaEligibilityProcessor";
|
||||||
import { runDeltaInsEligibilityProcessor } from "./processors/deltaInsEligibilityProcessor";
|
import { runDeltaInsEligibilityProcessor } from "./processors/deltaInsEligibilityProcessor";
|
||||||
import { runUnitedSCOEligibilityProcessor } from "./processors/unitedSCOEligibilityProcessor";
|
import { runUnitedSCOEligibilityProcessor } from "./processors/unitedSCOEligibilityProcessor";
|
||||||
|
import { runUnitedUcardEligibilityProcessor } from "./processors/unitedUcardEligibilityProcessor";
|
||||||
import { runDentaQuestEligibilityProcessor } from "./processors/dentaQuestEligibilityProcessor";
|
import { runDentaQuestEligibilityProcessor } from "./processors/dentaQuestEligibilityProcessor";
|
||||||
import { runCCAEligibilityProcessor } from "./processors/ccaEligibilityProcessor";
|
import { runCCAEligibilityProcessor } from "./processors/ccaEligibilityProcessor";
|
||||||
import { runCCAClaimProcessor } from "./processors/ccaClaimProcessor";
|
import { runCCAClaimProcessor } from "./processors/ccaClaimProcessor";
|
||||||
@@ -128,6 +129,20 @@ export function enqueueSeleniumJob(data: SeleniumJobData): string {
|
|||||||
job.id
|
job.id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (jobType === "uniteducard-eligibility-check") {
|
||||||
|
return runUnitedUcardEligibilityProcessor(
|
||||||
|
{
|
||||||
|
enrichedPayload: data.enrichedPayload,
|
||||||
|
userId: data.userId,
|
||||||
|
insuranceId: data.insuranceId!,
|
||||||
|
formFirstName: data.formFirstName,
|
||||||
|
formLastName: data.formLastName,
|
||||||
|
formDob: data.formDob,
|
||||||
|
socketId: data.socketId,
|
||||||
|
},
|
||||||
|
job.id
|
||||||
|
);
|
||||||
|
}
|
||||||
if (jobType === "tuftssco-eligibility-check") {
|
if (jobType === "tuftssco-eligibility-check") {
|
||||||
return runDentaQuestEligibilityProcessor(
|
return runDentaQuestEligibilityProcessor(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,349 @@
|
|||||||
|
/**
|
||||||
|
* Processor for "uniteducard-eligibility-check" jobs (United Ucard / OneHealthcareID).
|
||||||
|
*
|
||||||
|
* Same persistent-session flow as UnitedSCO/DDMA:
|
||||||
|
* 1. Start a session on the Python agent (POST /uniteducard-eligibility)
|
||||||
|
* 2. Emit selenium:uniteducard_session_started → frontend stores session_id for OTP
|
||||||
|
* 3. Poll agent status, emitting selenium:otp_required when OTP is needed
|
||||||
|
* 4. On completion: save PDF (file path), create/update patient, update status
|
||||||
|
* 5. Return { pdfFileId, pdfFilename, patientUpdateStatus, pdfUploadStatus }
|
||||||
|
*/
|
||||||
|
import fs from "fs/promises";
|
||||||
|
import fsSync from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import { storage } from "../../storage";
|
||||||
|
import { emptyFolderContainingFile } from "../../utils/emptyTempFolder";
|
||||||
|
import {
|
||||||
|
forwardToSeleniumUnitedUcardEligibilityAgent,
|
||||||
|
getSeleniumUnitedUcardSessionStatus,
|
||||||
|
} from "../../services/seleniumUnitedUcardEligibilityClient";
|
||||||
|
import { splitName, createOrUpdatePatientByInsuranceId, imageToPdfBuffer } from "./_shared";
|
||||||
|
import { io } from "../../socket";
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function now() {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function log(tag: string, msg: string, ctx?: any) {
|
||||||
|
console.log(`${now()} [${tag}] ${msg}`, ctx ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitToSocket(socketId: string | undefined, event: string, payload: any) {
|
||||||
|
if (!socketId || !io) return;
|
||||||
|
try {
|
||||||
|
const socket = io.sockets.sockets.get(socketId);
|
||||||
|
if (socket) {
|
||||||
|
socket.emit(event, payload);
|
||||||
|
log("uniteducard-processor", `emitted ${event}`, { socketId });
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
log("uniteducard-processor", `emit failed for ${event}`, { err: err?.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface UnitedUcardEligibilityProcessorInput {
|
||||||
|
enrichedPayload: any;
|
||||||
|
userId: number;
|
||||||
|
insuranceId: string;
|
||||||
|
formFirstName?: string;
|
||||||
|
formLastName?: string;
|
||||||
|
formDob?: string;
|
||||||
|
socketId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnitedUcardEligibilityProcessorResult {
|
||||||
|
patientUpdateStatus?: string;
|
||||||
|
pdfUploadStatus?: string;
|
||||||
|
pdfFileId?: number | string | null;
|
||||||
|
pdfFilename?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Core DB processing ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function processUnitedUcardResult(
|
||||||
|
userId: number,
|
||||||
|
insuranceId: string,
|
||||||
|
formFirstName: string | undefined,
|
||||||
|
formLastName: string | undefined,
|
||||||
|
formDob: string | undefined,
|
||||||
|
seleniumResult: any
|
||||||
|
): Promise<UnitedUcardEligibilityProcessorResult> {
|
||||||
|
const output: UnitedUcardEligibilityProcessorResult = {};
|
||||||
|
let createdPdfFileId: number | string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1) Resolve patient name
|
||||||
|
const rawName =
|
||||||
|
typeof seleniumResult?.patientName === "string"
|
||||||
|
? seleniumResult.patientName.trim()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const { firstName, lastName } = rawName
|
||||||
|
? splitName(rawName)
|
||||||
|
: { firstName: formFirstName ?? "", lastName: formLastName ?? "" };
|
||||||
|
|
||||||
|
// 2) Create / update patient
|
||||||
|
await createOrUpdatePatientByInsuranceId({
|
||||||
|
insuranceId,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
dob: formDob,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3) Fetch patient
|
||||||
|
const patient = await storage.getPatientByInsuranceId(insuranceId);
|
||||||
|
if (!patient?.id) {
|
||||||
|
output.patientUpdateStatus = "Patient not found; no update performed";
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Determine eligibility status
|
||||||
|
const eligStatus = (seleniumResult?.eligibility ?? "").toLowerCase();
|
||||||
|
const newStatus =
|
||||||
|
eligStatus === "active" || eligStatus === "y" ? "ACTIVE" :
|
||||||
|
eligStatus === "inactive" ? "INACTIVE" : null;
|
||||||
|
|
||||||
|
const statusUpdates: any = { insuranceProvider: "United Ucard" };
|
||||||
|
if (newStatus) statusUpdates.status = newStatus;
|
||||||
|
|
||||||
|
const scrapedMemberId =
|
||||||
|
typeof seleniumResult?.memberId === "string"
|
||||||
|
? seleniumResult.memberId.replace(/\s+/g, "")
|
||||||
|
: null;
|
||||||
|
if (scrapedMemberId && scrapedMemberId !== insuranceId) {
|
||||||
|
statusUpdates.insuranceId = scrapedMemberId;
|
||||||
|
log("uniteducard-processor", "member ID changed, overwriting on patient record", {
|
||||||
|
patientId: patient.id,
|
||||||
|
oldInsuranceId: insuranceId,
|
||||||
|
newInsuranceId: scrapedMemberId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.updatePatient(patient.id, statusUpdates);
|
||||||
|
output.patientUpdateStatus = newStatus
|
||||||
|
? `Patient status updated to ${newStatus}`
|
||||||
|
: `Eligibility unknown — status not changed`;
|
||||||
|
if (statusUpdates.insuranceId) {
|
||||||
|
output.patientUpdateStatus += ` (Member ID updated from ${insuranceId} to ${statusUpdates.insuranceId})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) Resolve PDF buffer from file path
|
||||||
|
let pdfBuffer: Buffer | null = null;
|
||||||
|
let pdfFilename: string | null = null;
|
||||||
|
|
||||||
|
const pdfPath: string | null =
|
||||||
|
seleniumResult?.pdf_path ?? seleniumResult?.ss_path ?? null;
|
||||||
|
|
||||||
|
if (pdfPath && fsSync.existsSync(pdfPath)) {
|
||||||
|
if (pdfPath.endsWith(".pdf")) {
|
||||||
|
try {
|
||||||
|
pdfBuffer = await fs.readFile(pdfPath);
|
||||||
|
pdfFilename = path.basename(pdfPath);
|
||||||
|
log("uniteducard-processor", "read PDF directly", { pdfPath });
|
||||||
|
} catch (e: any) {
|
||||||
|
output.pdfUploadStatus = `Failed to read PDF: ${e.message}`;
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
pdfPath.endsWith(".png") ||
|
||||||
|
pdfPath.endsWith(".jpg") ||
|
||||||
|
pdfPath.endsWith(".jpeg")
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
pdfBuffer = await imageToPdfBuffer(pdfPath);
|
||||||
|
pdfFilename = `uniteducard_eligibility_${insuranceId}_${Date.now()}.pdf`;
|
||||||
|
log("uniteducard-processor", "converted screenshot to PDF", { pdfPath });
|
||||||
|
} catch (e: any) {
|
||||||
|
output.pdfUploadStatus = `Failed to convert screenshot to PDF: ${e.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
output.pdfUploadStatus = "No valid file path from Selenium; nothing uploaded.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6) Save PDF to patient document group
|
||||||
|
if (pdfBuffer && pdfFilename) {
|
||||||
|
const patientName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim() || `Patient ${patient.id}`;
|
||||||
|
const cloudFile = await storage.savePdfToCloudStorage(
|
||||||
|
userId,
|
||||||
|
patient.id,
|
||||||
|
patientName,
|
||||||
|
"Eligibility",
|
||||||
|
pdfFilename,
|
||||||
|
pdfBuffer
|
||||||
|
);
|
||||||
|
createdPdfFileId = `cloud:${cloudFile.id}`;
|
||||||
|
output.pdfUploadStatus = "PDF saved to Eligibility folder";
|
||||||
|
output.pdfFilename = pdfFilename;
|
||||||
|
}
|
||||||
|
|
||||||
|
output.pdfFileId = createdPdfFileId;
|
||||||
|
return output;
|
||||||
|
} catch (err: any) {
|
||||||
|
return {
|
||||||
|
...output,
|
||||||
|
pdfUploadStatus:
|
||||||
|
output.pdfUploadStatus ?? `Processing failed: ${err?.message ?? String(err)}`,
|
||||||
|
pdfFileId: createdPdfFileId,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
const cleanupPath = seleniumResult?.pdf_path ?? seleniumResult?.ss_path ?? null;
|
||||||
|
if (cleanupPath) {
|
||||||
|
try {
|
||||||
|
await emptyFolderContainingFile(cleanupPath);
|
||||||
|
} catch (e) {
|
||||||
|
log("uniteducard-processor", "cleanup failed", { cleanupPath });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Polling loop ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function pollUntilDone(
|
||||||
|
sessionId: string,
|
||||||
|
socketId: string | undefined,
|
||||||
|
jobId: string,
|
||||||
|
pollTimeoutMs = 5 * 60 * 1000
|
||||||
|
): Promise<any> {
|
||||||
|
const maxAttempts = 600;
|
||||||
|
const pollIntervalMs = 500;
|
||||||
|
const maxTransientErrors = 12;
|
||||||
|
const noProgressLimit = 120;
|
||||||
|
|
||||||
|
let transientErrors = 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) {
|
||||||
|
throw new Error(
|
||||||
|
`UnitedUcard polling timeout (${Math.round(pollTimeoutMs / 1000)}s) for session ${sessionId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const st = await getSeleniumUnitedUcardSessionStatus(sessionId);
|
||||||
|
const status: string = st?.status ?? "unknown";
|
||||||
|
|
||||||
|
log("uniteducard-processor", `poll attempt=${attempt}`, { sessionId, status });
|
||||||
|
|
||||||
|
transientErrors = 0;
|
||||||
|
|
||||||
|
const isTerminal =
|
||||||
|
status === "completed" || status === "error" || status === "not_found";
|
||||||
|
if (status === lastStatus && !isTerminal) {
|
||||||
|
consecutiveNoProgress++;
|
||||||
|
} else {
|
||||||
|
consecutiveNoProgress = 0;
|
||||||
|
}
|
||||||
|
lastStatus = status;
|
||||||
|
|
||||||
|
if (consecutiveNoProgress >= noProgressLimit) {
|
||||||
|
throw new Error(
|
||||||
|
`No progress from Python agent (status="${status}") after ${consecutiveNoProgress} polls`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "waiting_for_otp") {
|
||||||
|
emitToSocket(socketId, "selenium:otp_required", {
|
||||||
|
session_id: sessionId,
|
||||||
|
jobId,
|
||||||
|
message: "OTP required. Please enter the verification code.",
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "completed") {
|
||||||
|
log("uniteducard-processor", "session completed", { sessionId });
|
||||||
|
return st.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "error" || status === "not_found") {
|
||||||
|
throw new Error(st?.message || `UnitedUcard session ended with status: ${status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
||||||
|
} catch (err: any) {
|
||||||
|
const isTerminal =
|
||||||
|
err?.response?.status === 404 ||
|
||||||
|
(typeof err?.message === "string" &&
|
||||||
|
(err.message.includes("not_found") ||
|
||||||
|
err.message.includes("polling timeout") ||
|
||||||
|
err.message.includes("No progress")));
|
||||||
|
|
||||||
|
if (isTerminal) throw err;
|
||||||
|
|
||||||
|
transientErrors++;
|
||||||
|
if (transientErrors > maxTransientErrors) {
|
||||||
|
throw new Error(
|
||||||
|
`Too many transient network errors polling UnitedUcard session ${sessionId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const backoff = Math.min(30_000, 500 * Math.pow(2, transientErrors - 1));
|
||||||
|
log("uniteducard-processor", `transient error #${transientErrors}, backoff ${backoff}ms`, {
|
||||||
|
err: err?.message,
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, backoff));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`UnitedUcard polling exhausted all attempts for session ${sessionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main processor entry point ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function runUnitedUcardEligibilityProcessor(
|
||||||
|
input: UnitedUcardEligibilityProcessorInput,
|
||||||
|
jobId: string
|
||||||
|
): Promise<UnitedUcardEligibilityProcessorResult> {
|
||||||
|
const {
|
||||||
|
enrichedPayload,
|
||||||
|
userId,
|
||||||
|
insuranceId,
|
||||||
|
formFirstName,
|
||||||
|
formLastName,
|
||||||
|
formDob,
|
||||||
|
socketId,
|
||||||
|
} = input;
|
||||||
|
|
||||||
|
log("uniteducard-processor", "starting Python agent session", { insuranceId });
|
||||||
|
const agentResp = await forwardToSeleniumUnitedUcardEligibilityAgent(enrichedPayload);
|
||||||
|
|
||||||
|
if (!agentResp?.session_id) {
|
||||||
|
throw new Error("Python agent did not return a session_id for United Ucard eligibility");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = agentResp.session_id as string;
|
||||||
|
log("uniteducard-processor", "got session_id", { sessionId });
|
||||||
|
|
||||||
|
emitToSocket(socketId, "selenium:uniteducard_session_started", {
|
||||||
|
session_id: sessionId,
|
||||||
|
jobId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const seleniumResult = await pollUntilDone(sessionId, socketId, jobId);
|
||||||
|
|
||||||
|
if (!seleniumResult || seleniumResult.status === "error") {
|
||||||
|
throw new Error(seleniumResult?.message ?? "United Ucard session returned an error result");
|
||||||
|
}
|
||||||
|
|
||||||
|
log("uniteducard-processor", "processing DB result", { insuranceId });
|
||||||
|
const result = await processUnitedUcardResult(
|
||||||
|
userId,
|
||||||
|
insuranceId,
|
||||||
|
formFirstName,
|
||||||
|
formLastName,
|
||||||
|
formDob,
|
||||||
|
seleniumResult
|
||||||
|
);
|
||||||
|
|
||||||
|
log("uniteducard-processor", "done", { result });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ export type SeleniumJobType =
|
|||||||
| "ddma-eligibility-check"
|
| "ddma-eligibility-check"
|
||||||
| "deltains-eligibility-check"
|
| "deltains-eligibility-check"
|
||||||
| "unitedsco-eligibility-check"
|
| "unitedsco-eligibility-check"
|
||||||
|
| "uniteducard-eligibility-check"
|
||||||
| "cca-eligibility-check"
|
| "cca-eligibility-check"
|
||||||
| "cca-claim-submit"
|
| "cca-claim-submit"
|
||||||
| "cca-preauth-submit"
|
| "cca-preauth-submit"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import insuranceStatusRoutes from "./insuranceStatus";
|
|||||||
import insuranceStatusDdmaRoutes from "./insuranceStatusDDMA";
|
import insuranceStatusDdmaRoutes from "./insuranceStatusDDMA";
|
||||||
import insuranceStatusDeltaInsRoutes from "./insuranceStatusDeltaIns";
|
import insuranceStatusDeltaInsRoutes from "./insuranceStatusDeltaIns";
|
||||||
import insuranceStatusUnitedSCORoutes from "./insuranceStatusUnitedSCO";
|
import insuranceStatusUnitedSCORoutes from "./insuranceStatusUnitedSCO";
|
||||||
|
import insuranceStatusUnitedUcardRoutes from "./insuranceStatusUnitedUcard";
|
||||||
import insuranceStatusTuftsSCORoutes from "./insuranceStatusTuftsSCO";
|
import insuranceStatusTuftsSCORoutes from "./insuranceStatusTuftsSCO";
|
||||||
import insuranceStatusCCARoutes from "./insuranceStatusCCA";
|
import insuranceStatusCCARoutes from "./insuranceStatusCCA";
|
||||||
import insuranceStatusCCAClaimRoutes from "./insuranceStatusCCAClaim";
|
import insuranceStatusCCAClaimRoutes from "./insuranceStatusCCAClaim";
|
||||||
@@ -66,6 +67,7 @@ router.use("/insurance-status", insuranceStatusRoutes);
|
|||||||
router.use("/insurance-status-ddma", insuranceStatusDdmaRoutes);
|
router.use("/insurance-status-ddma", insuranceStatusDdmaRoutes);
|
||||||
router.use("/insurance-status-deltains", insuranceStatusDeltaInsRoutes);
|
router.use("/insurance-status-deltains", insuranceStatusDeltaInsRoutes);
|
||||||
router.use("/insurance-status-unitedsco", insuranceStatusUnitedSCORoutes);
|
router.use("/insurance-status-unitedsco", insuranceStatusUnitedSCORoutes);
|
||||||
|
router.use("/insurance-status-uniteducard", insuranceStatusUnitedUcardRoutes);
|
||||||
router.use("/insurance-status-tuftssco", insuranceStatusTuftsSCORoutes);
|
router.use("/insurance-status-tuftssco", insuranceStatusTuftsSCORoutes);
|
||||||
router.use("/insurance-status-cca", insuranceStatusCCARoutes);
|
router.use("/insurance-status-cca", insuranceStatusCCARoutes);
|
||||||
router.use("/insurance-status-bcbs-ma", insuranceStatusBcbsMaRoutes);
|
router.use("/insurance-status-bcbs-ma", insuranceStatusBcbsMaRoutes);
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { Router, Request, Response } from "express";
|
||||||
|
import { storage } from "../storage";
|
||||||
|
import { forwardOtpToSeleniumUnitedUcardAgent } from "../services/seleniumUnitedUcardEligibilityClient";
|
||||||
|
import { io } from "../socket";
|
||||||
|
import { enqueueSeleniumJob } from "../queue/jobRunner";
|
||||||
|
import { prisma as db } from "@repo/db/client";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
function log(tag: string, msg: string, ctx?: any) {
|
||||||
|
console.log(`${new Date().toISOString()} [${tag}] ${msg}`, ctx ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitSafe(socketId: string | undefined, event: string, payload: any) {
|
||||||
|
if (!socketId || !io) return;
|
||||||
|
try {
|
||||||
|
const socket = io.sockets.sockets.get(socketId);
|
||||||
|
if (socket) socket.emit(event, payload);
|
||||||
|
} catch (err: any) {
|
||||||
|
log("socket", "emit failed", { socketId, event, err: err?.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /uniteducard-eligibility
|
||||||
|
*
|
||||||
|
* Enqueues a United Ucard (OneHealthcareID) eligibility check (concurrency=1).
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* data — patient + search fields (memberId, dateOfBirth, firstName, lastName, …)
|
||||||
|
* socketId — socket.io client id for real-time updates
|
||||||
|
*
|
||||||
|
* Response: { status: "queued", jobId: "…" }
|
||||||
|
*
|
||||||
|
* Real-time socket events:
|
||||||
|
* job:update { jobId, jobType, status: "active"|"completed"|"failed", … }
|
||||||
|
* selenium:uniteducard_session_started { session_id, jobId }
|
||||||
|
* selenium:otp_required { session_id, jobId, message }
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/uniteducard-eligibility",
|
||||||
|
async (req: Request, res: Response): Promise<any> => {
|
||||||
|
if (!req.body.data) {
|
||||||
|
return res.status(400).json({ error: "Missing eligibility data for selenium" });
|
||||||
|
}
|
||||||
|
if (!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;
|
||||||
|
|
||||||
|
// Fetch United Ucard credentials from DB
|
||||||
|
const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(
|
||||||
|
req.user.id,
|
||||||
|
rawData.insuranceSiteKey
|
||||||
|
);
|
||||||
|
if (!credentials) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error:
|
||||||
|
"No credentials found for United Ucard. Please add them on the Settings page.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const seleniumSettings = await db.seleniumSettings.findUnique({ where: { userId: req.user.id } });
|
||||||
|
|
||||||
|
const enrichedData = {
|
||||||
|
...rawData,
|
||||||
|
uniteducardUsername: credentials.username,
|
||||||
|
uniteducardPassword: credentials.password,
|
||||||
|
paymentGroupId: seleniumSettings?.paymentGroupId ?? "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const socketId: string | undefined = req.body.socketId;
|
||||||
|
|
||||||
|
const jobId = enqueueSeleniumJob({
|
||||||
|
jobType: "uniteducard-eligibility-check",
|
||||||
|
userId: req.user.id,
|
||||||
|
socketId,
|
||||||
|
enrichedPayload: enrichedData,
|
||||||
|
insuranceId: String(rawData.memberId ?? "").trim(),
|
||||||
|
formFirstName: rawData.firstName,
|
||||||
|
formLastName: rawData.lastName,
|
||||||
|
formDob: rawData.dateOfBirth,
|
||||||
|
});
|
||||||
|
|
||||||
|
log("uniteducard-route", "job enqueued", { jobId, insuranceId: rawData.memberId });
|
||||||
|
|
||||||
|
return res.json({ status: "queued", jobId });
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[uniteducard-route] enqueue failed:", err);
|
||||||
|
return res.status(500).json({
|
||||||
|
error: err.message || "Failed to enqueue United Ucard selenium job",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /selenium/submit-otp
|
||||||
|
* Side-channel OTP forwarding — does NOT go through the queue.
|
||||||
|
* Body: { session_id, otp, socketId? }
|
||||||
|
*/
|
||||||
|
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 forwardOtpToSeleniumUnitedUcardAgent(sessionId, otp);
|
||||||
|
|
||||||
|
emitSafe(socketId, "selenium:otp_submitted", {
|
||||||
|
session_id: sessionId,
|
||||||
|
result: r,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json(r);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(
|
||||||
|
"[uniteducard-route] submit-otp failed:",
|
||||||
|
err?.response?.data || err?.message || err
|
||||||
|
);
|
||||||
|
return res.status(500).json({
|
||||||
|
error: "Failed to forward OTP to selenium agent",
|
||||||
|
detail: err?.message || err,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import http from "http";
|
||||||
|
import https from "https";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
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(`[uniteducard-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(`[uniteducard-client] transient network error ${code} (attempt ${attempt})`);
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, baseBackoffMs * attempt));
|
||||||
|
}
|
||||||
|
return client.request(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
function log(tag: string, msg: string, ctx?: any) {
|
||||||
|
console.log(`${new Date().toISOString()} [${tag}] ${msg}`, ctx ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function forwardToSeleniumUnitedUcardEligibilityAgent(data: any): Promise<any> {
|
||||||
|
const payload = { data };
|
||||||
|
const url = `/uniteducard-eligibility`;
|
||||||
|
log("uniteducard-client", "POST uniteducard-eligibility", { url: SELENIUM_AGENT_BASE + url });
|
||||||
|
const r = await requestWithRetries({ url, method: "POST", data: payload }, 4);
|
||||||
|
log("uniteducard-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 forwardOtpToSeleniumUnitedUcardAgent(sessionId: string, otp: string): Promise<any> {
|
||||||
|
const url = `/submit-otp`;
|
||||||
|
log("uniteducard-client", "POST submit-otp", { url: SELENIUM_AGENT_BASE + url, sessionId });
|
||||||
|
const r = await requestWithRetries({ url, method: "POST", data: { session_id: sessionId, otp } }, 4);
|
||||||
|
log("uniteducard-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 getSeleniumUnitedUcardSessionStatus(sessionId: string): Promise<any> {
|
||||||
|
const url = `/session/${sessionId}/status`;
|
||||||
|
log("uniteducard-client", "GET session status", { url: SELENIUM_AGENT_BASE + url, sessionId });
|
||||||
|
const r = await requestWithRetries({ url, method: "GET" }, 4);
|
||||||
|
log("uniteducard-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,364 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
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/seleniumTaskSlice";
|
||||||
|
import { formatLocalDate } from "@/utils/dateUtils";
|
||||||
|
import { socket } from "@/lib/socket";
|
||||||
|
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
|
||||||
|
|
||||||
|
// ─── OTP Modal ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface UnitedUcardOtpModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (otp: string) => Promise<void> | void;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function UnitedUcardOtpModal({ open, onClose, onSubmit, isSubmitting }: UnitedUcardOtpModalProps) {
|
||||||
|
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 verification code sent to your phone or email to complete this check.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="united-ucard-otp">Verification Code</Label>
|
||||||
|
<Input
|
||||||
|
id="united-ucard-otp"
|
||||||
|
placeholder="Enter verification 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 Code"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface UnitedUcardEligibilityButtonProps {
|
||||||
|
memberId: string;
|
||||||
|
dateOfBirth: Date | null;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
isFormIncomplete: boolean;
|
||||||
|
autoTrigger?: boolean;
|
||||||
|
onAutoTriggered?: () => void;
|
||||||
|
onPdfReady: (pdfId: number | string, fallbackFilename: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UnitedUcardEligibilityButton({
|
||||||
|
memberId,
|
||||||
|
dateOfBirth,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
isFormIncomplete,
|
||||||
|
autoTrigger,
|
||||||
|
onAutoTriggered,
|
||||||
|
onPdfReady,
|
||||||
|
}: UnitedUcardEligibilityButtonProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const sessionIdRef = useRef<string | null>(null);
|
||||||
|
const autoTriggeredRef = useRef(false);
|
||||||
|
|
||||||
|
const [otpModalOpen, setOtpModalOpen] = useState(false);
|
||||||
|
const [isStarting, setIsStarting] = useState(false);
|
||||||
|
const [isSubmittingOtp, setIsSubmittingOtp] = useState(false);
|
||||||
|
|
||||||
|
const handleStart = async () => {
|
||||||
|
if (!memberId || !dateOfBirth) {
|
||||||
|
toast({
|
||||||
|
title: "Missing fields",
|
||||||
|
description: "Member ID and Date of Birth are required.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedDob = formatLocalDate(dateOfBirth);
|
||||||
|
const payload = {
|
||||||
|
memberId,
|
||||||
|
dateOfBirth: formattedDob,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
insuranceSiteKey: "UNITED_UCARD",
|
||||||
|
};
|
||||||
|
|
||||||
|
setIsStarting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
dispatch(
|
||||||
|
setTaskStatus({
|
||||||
|
key: "eligibilityCheck",
|
||||||
|
status: "pending",
|
||||||
|
message: "Starting United Ucard eligibility check…",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await apiRequest(
|
||||||
|
"POST",
|
||||||
|
"/api/insurance-status-uniteducard/uniteducard-eligibility",
|
||||||
|
{ data: JSON.stringify(payload), socketId: socket.id }
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok || result.error) {
|
||||||
|
throw new Error(result.error || `Server error (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobId: string = result.jobId;
|
||||||
|
if (!jobId) throw new Error("No jobId returned from server");
|
||||||
|
|
||||||
|
dispatch(
|
||||||
|
setTaskStatus({
|
||||||
|
key: "eligibilityCheck",
|
||||||
|
status: "pending",
|
||||||
|
message: "United Ucard job queued. Waiting for browser session…",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const onSessionStarted = (data: any) => {
|
||||||
|
if (String(data?.jobId) !== String(jobId)) return;
|
||||||
|
sessionIdRef.current = data.session_id ?? null;
|
||||||
|
dispatch(
|
||||||
|
setTaskStatus({
|
||||||
|
key: "eligibilityCheck",
|
||||||
|
status: "pending",
|
||||||
|
message: "Browser session started. Waiting for verification code or result…",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onOtpRequired = (data: any) => {
|
||||||
|
if (String(data?.jobId) !== String(jobId)) return;
|
||||||
|
if (data.session_id) sessionIdRef.current = data.session_id;
|
||||||
|
setOtpModalOpen(true);
|
||||||
|
dispatch(
|
||||||
|
setTaskStatus({
|
||||||
|
key: "eligibilityCheck",
|
||||||
|
status: "pending",
|
||||||
|
message: "Verification code required. Please enter the code.",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onOtpSubmitted = (data: any) => {
|
||||||
|
if (data?.session_id && data.session_id !== sessionIdRef.current) return;
|
||||||
|
dispatch(
|
||||||
|
setTaskStatus({
|
||||||
|
key: "eligibilityCheck",
|
||||||
|
status: "pending",
|
||||||
|
message: "Code submitted. Finishing eligibility check…",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on("selenium:uniteducard_session_started", onSessionStarted);
|
||||||
|
socket.on("selenium:otp_required", onOtpRequired);
|
||||||
|
socket.on("selenium:otp_submitted", onOtpSubmitted);
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
clearTimeout(safetyTimer);
|
||||||
|
socket.off("selenium:uniteducard_session_started", onSessionStarted);
|
||||||
|
socket.off("selenium:otp_required", onOtpRequired);
|
||||||
|
socket.off("selenium:otp_submitted", onOtpSubmitted);
|
||||||
|
socket.off("job:update", onJobUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onJobUpdate = (data: any) => {
|
||||||
|
if (String(data?.jobId) !== String(jobId)) return;
|
||||||
|
|
||||||
|
if (data.status === "active") {
|
||||||
|
dispatch(
|
||||||
|
setTaskStatus({
|
||||||
|
key: "eligibilityCheck",
|
||||||
|
status: "pending",
|
||||||
|
message: data.message ?? "Selenium browser starting…",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
if (data.status === "completed") {
|
||||||
|
dispatch(
|
||||||
|
setTaskStatus({
|
||||||
|
key: "eligibilityCheck",
|
||||||
|
status: "success",
|
||||||
|
message: "United Ucard eligibility updated and PDF attached to patient documents.",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
toast({
|
||||||
|
title: "United Ucard eligibility complete",
|
||||||
|
description: "Patient status was updated and the eligibility PDF was saved.",
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
|
||||||
|
const pdfId = data.result?.pdfFileId;
|
||||||
|
if (pdfId) {
|
||||||
|
onPdfReady(pdfId, data.result?.pdfFilename ?? `eligibility_uniteducard_${memberId}.pdf`);
|
||||||
|
}
|
||||||
|
} else if (data.status === "failed") {
|
||||||
|
const msg = data.error ?? "United Ucard eligibility job failed.";
|
||||||
|
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: msg }));
|
||||||
|
toast({ title: "United Ucard selenium error", description: msg, variant: "destructive" });
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsStarting(false);
|
||||||
|
setOtpModalOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on("job:update", onJobUpdate);
|
||||||
|
|
||||||
|
const safetyTimer = setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
setIsStarting(false);
|
||||||
|
setOtpModalOpen(false);
|
||||||
|
dispatch(
|
||||||
|
setTaskStatus({
|
||||||
|
key: "eligibilityCheck",
|
||||||
|
status: "error",
|
||||||
|
message: "United Ucard job timed out waiting for completion.",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, 6 * 60 * 1000);
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("UnitedUcardEligibilityButton error:", err);
|
||||||
|
dispatch(
|
||||||
|
setTaskStatus({
|
||||||
|
key: "eligibilityCheck",
|
||||||
|
status: "error",
|
||||||
|
message: err?.message || "Failed to start United Ucard eligibility",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
toast({
|
||||||
|
title: "United Ucard selenium error",
|
||||||
|
description: err?.message || "Failed to start United Ucard eligibility",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
setIsStarting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmitOtp = async (otp: string) => {
|
||||||
|
const sessionId = sessionIdRef.current;
|
||||||
|
if (!sessionId) {
|
||||||
|
toast({
|
||||||
|
title: "Session not ready",
|
||||||
|
description: "Cannot submit code — session ID is not available yet.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSubmittingOtp(true);
|
||||||
|
const resp = await apiRequest("POST", "/api/insurance-status-uniteducard/selenium/submit-otp", {
|
||||||
|
session_id: sessionId,
|
||||||
|
otp,
|
||||||
|
socketId: socket.id,
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok || data.error) {
|
||||||
|
throw new Error(data.error || "Failed to submit code");
|
||||||
|
}
|
||||||
|
setOtpModalOpen(false);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("handleSubmitOtp error:", err);
|
||||||
|
toast({
|
||||||
|
title: "Failed to submit code",
|
||||||
|
description: err?.message || "Error forwarding code to selenium agent",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSubmittingOtp(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoTrigger) { autoTriggeredRef.current = false; return; }
|
||||||
|
if (autoTriggeredRef.current || isFormIncomplete) return;
|
||||||
|
autoTriggeredRef.current = true;
|
||||||
|
onAutoTriggered?.();
|
||||||
|
handleStart();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [autoTrigger, isFormIncomplete]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
variant="default"
|
||||||
|
disabled={isFormIncomplete || isStarting}
|
||||||
|
onClick={handleStart}
|
||||||
|
>
|
||||||
|
{isStarting ? (
|
||||||
|
<>
|
||||||
|
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Processing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CheckCircle className="h-4 w-4 mr-2" />
|
||||||
|
United Ucard
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<UnitedUcardOtpModal
|
||||||
|
open={otpModalOpen}
|
||||||
|
onClose={() => setOtpModalOpen(false)}
|
||||||
|
onSubmit={handleSubmitOtp}
|
||||||
|
isSubmitting={isSubmittingOtp}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ const SITE_KEY_OPTIONS = [
|
|||||||
{ value: "DELTAINS", label: "Delta Dental Ins (DELTAINS)" },
|
{ value: "DELTAINS", label: "Delta Dental Ins (DELTAINS)" },
|
||||||
{ value: "TUFTS_SCO", label: "Tufts SCO (TUFTS_SCO)" },
|
{ value: "TUFTS_SCO", label: "Tufts SCO (TUFTS_SCO)" },
|
||||||
{ value: "UNITED_SCO", label: "United SCO / DentalHub (UNITED_SCO)" },
|
{ value: "UNITED_SCO", label: "United SCO / DentalHub (UNITED_SCO)" },
|
||||||
|
{ value: "UNITED_UCARD", label: "United Ucard (UNITED_UCARD)" },
|
||||||
{ value: "CCA", label: "CCA (CCA)" },
|
{ value: "CCA", label: "CCA (CCA)" },
|
||||||
{ value: "BCBS_MA", label: "BCBS MA (BCBS_MA)" },
|
{ value: "BCBS_MA", label: "BCBS MA (BCBS_MA)" },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import { DdmaEligibilityButton } from "@/components/insurance-status/ddma-buton-
|
|||||||
import { DeltaInsEligibilityButton } from "@/components/insurance-status/deltains-button-modal";
|
import { DeltaInsEligibilityButton } from "@/components/insurance-status/deltains-button-modal";
|
||||||
import { TuftsSCOEligibilityButton } from "@/components/insurance-status/tufts-sco-button-modal";
|
import { TuftsSCOEligibilityButton } from "@/components/insurance-status/tufts-sco-button-modal";
|
||||||
import { UnitedSCOEligibilityButton } from "@/components/insurance-status/united-sco-button-modal";
|
import { UnitedSCOEligibilityButton } from "@/components/insurance-status/united-sco-button-modal";
|
||||||
|
import { UnitedUcardEligibilityButton } from "@/components/insurance-status/united-ucard-button-modal";
|
||||||
import { CCAEligibilityButton } from "@/components/insurance-status/cca-button-modal";
|
import { CCAEligibilityButton } from "@/components/insurance-status/cca-button-modal";
|
||||||
import { BcbsMaEligibilityButton } from "@/components/insurance-status/bcbs-ma-button-modal";
|
import { BcbsMaEligibilityButton } from "@/components/insurance-status/bcbs-ma-button-modal";
|
||||||
import { useLicense } from "@/hooks/use-license";
|
import { useLicense } from "@/hooks/use-license";
|
||||||
@@ -171,7 +172,7 @@ export default function InsuranceStatusPage() {
|
|||||||
const [cmspAccumulatorPdfId, setCmspAccumulatorPdfId] = useState<number | string | null>(null);
|
const [cmspAccumulatorPdfId, setCmspAccumulatorPdfId] = useState<number | string | null>(null);
|
||||||
const [cmspAccumulatorFilename, setCmspAccumulatorFilename] = useState<string | null>(null);
|
const [cmspAccumulatorFilename, setCmspAccumulatorFilename] = useState<string | null>(null);
|
||||||
|
|
||||||
const pendingAutoCheck = useRef<"mh" | "mh-history" | "cmsp" | "ddma" | "delta-ins" | "united-sco" | "tufts-sco" | "cca" | null>(null);
|
const pendingAutoCheck = useRef<"mh" | "mh-history" | "cmsp" | "ddma" | "delta-ins" | "united-sco" | "united-ucard" | "tufts-sco" | "cca" | null>(null);
|
||||||
const [triggerTarget, setTriggerTarget] = useState<string | null>(null);
|
const [triggerTarget, setTriggerTarget] = useState<string | null>(null);
|
||||||
const pendingScrollTo = useRef<string | null>(null);
|
const pendingScrollTo = useRef<string | null>(null);
|
||||||
const [prefillTick, setPrefillTick] = useState(0);
|
const [prefillTick, setPrefillTick] = useState(0);
|
||||||
@@ -909,7 +910,7 @@ export default function InsuranceStatusPage() {
|
|||||||
const autoCheck = params.get("autoCheck") as typeof pendingAutoCheck.current;
|
const autoCheck = params.get("autoCheck") as typeof pendingAutoCheck.current;
|
||||||
const scrollTo = params.get("scrollTo");
|
const scrollTo = params.get("scrollTo");
|
||||||
|
|
||||||
const knownAutoChecks = ["mh", "mh-history", "cmsp", "ddma", "delta-ins", "united-sco", "tufts-sco", "cca"] as const;
|
const knownAutoChecks = ["mh", "mh-history", "cmsp", "ddma", "delta-ins", "united-sco", "united-ucard", "tufts-sco", "cca"] as const;
|
||||||
if (autoCheck && (knownAutoChecks as readonly string[]).includes(autoCheck)) {
|
if (autoCheck && (knownAutoChecks as readonly string[]).includes(autoCheck)) {
|
||||||
pendingAutoCheck.current = autoCheck;
|
pendingAutoCheck.current = autoCheck;
|
||||||
} else if (scrollTo) {
|
} else if (scrollTo) {
|
||||||
@@ -1196,14 +1197,27 @@ export default function InsuranceStatusPage() {
|
|||||||
|
|
||||||
{/* Row 3 */}
|
{/* Row 3 */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<Button
|
<div title={!isLicensed ? "License required" : undefined} className={!isLicensed ? "opacity-40 pointer-events-none" : ""}>
|
||||||
className="w-full"
|
<UnitedUcardEligibilityButton
|
||||||
variant="outline"
|
memberId={memberId}
|
||||||
disabled={isFormIncomplete}
|
dateOfBirth={dateOfBirth}
|
||||||
>
|
firstName={firstName}
|
||||||
<CheckCircle className="h-4 w-4 mr-2" />
|
lastName={lastName}
|
||||||
United AAPR
|
isFormIncomplete={isFormIncomplete}
|
||||||
</Button>
|
autoTrigger={triggerTarget === "united-ucard"}
|
||||||
|
onAutoTriggered={() => setTriggerTarget(null)}
|
||||||
|
onPdfReady={async (pdfId, fallbackFilename) => {
|
||||||
|
const claimed = await tryClaimFromChatbot(selectedPatient?.id);
|
||||||
|
void tryAppointmentFromChatbot();
|
||||||
|
if (!claimed) {
|
||||||
|
setPreviewPdfId(pdfId);
|
||||||
|
setPreviewFallbackFilename(fallbackFilename ?? `eligibility_uniteducard_${memberId}.pdf`);
|
||||||
|
setPreviewOpen(true);
|
||||||
|
}
|
||||||
|
processNextInQueue();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import time
|
|||||||
import helpers_ddma_eligibility as hddma
|
import helpers_ddma_eligibility as hddma
|
||||||
import helpers_deltains_eligibility as hdeltains
|
import helpers_deltains_eligibility as hdeltains
|
||||||
import helpers_unitedsco_eligibility as hunitedsco
|
import helpers_unitedsco_eligibility as hunitedsco
|
||||||
|
import helpers_uniteducard_eligibility as huniteducard
|
||||||
import helpers_dentaquest_eligibility as hdentaquest
|
import helpers_dentaquest_eligibility as hdentaquest
|
||||||
import helpers_cca_eligibility as hcca
|
import helpers_cca_eligibility as hcca
|
||||||
import helpers_cca_claim as hcca_claim
|
import helpers_cca_claim as hcca_claim
|
||||||
@@ -32,6 +33,7 @@ import helpers_bcbs_ma_eligibility as hbcbs_ma
|
|||||||
from ddma_browser_manager import clear_ddma_session_on_startup
|
from ddma_browser_manager import clear_ddma_session_on_startup
|
||||||
from deltains_browser_manager import clear_deltains_session_on_startup
|
from deltains_browser_manager import clear_deltains_session_on_startup
|
||||||
from unitedsco_browser_manager import clear_unitedsco_session_on_startup
|
from unitedsco_browser_manager import clear_unitedsco_session_on_startup
|
||||||
|
from uniteducard_browser_manager import clear_uniteducard_session_on_startup
|
||||||
from dentaquest_browser_manager import clear_dentaquest_session_on_startup
|
from dentaquest_browser_manager import clear_dentaquest_session_on_startup
|
||||||
from cca_browser_manager import clear_cca_session_on_startup
|
from cca_browser_manager import clear_cca_session_on_startup
|
||||||
|
|
||||||
@@ -45,6 +47,7 @@ print("=" * 50)
|
|||||||
clear_ddma_session_on_startup()
|
clear_ddma_session_on_startup()
|
||||||
clear_deltains_session_on_startup()
|
clear_deltains_session_on_startup()
|
||||||
clear_unitedsco_session_on_startup()
|
clear_unitedsco_session_on_startup()
|
||||||
|
clear_uniteducard_session_on_startup()
|
||||||
clear_dentaquest_session_on_startup()
|
clear_dentaquest_session_on_startup()
|
||||||
clear_cca_session_on_startup()
|
clear_cca_session_on_startup()
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
@@ -497,6 +500,47 @@ async def unitedsco_eligibility(request: Request):
|
|||||||
return {"status": "started", "session_id": sid}
|
return {"status": "started", "session_id": sid}
|
||||||
|
|
||||||
|
|
||||||
|
async def _uniteducard_worker_wrapper(sid: str, data: dict, url: str):
|
||||||
|
"""Background worker for United Ucard — acquires semaphore, updates counters."""
|
||||||
|
global active_jobs, waiting_jobs
|
||||||
|
async with semaphore:
|
||||||
|
async with lock:
|
||||||
|
waiting_jobs -= 1
|
||||||
|
active_jobs += 1
|
||||||
|
try:
|
||||||
|
await huniteducard.start_uniteducard_run(sid, data, url)
|
||||||
|
finally:
|
||||||
|
async with lock:
|
||||||
|
active_jobs -= 1
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/uniteducard-eligibility")
|
||||||
|
async def uniteducard_eligibility(request: Request):
|
||||||
|
"""
|
||||||
|
Starts a United Ucard (OneHealthcareID) eligibility session in the background.
|
||||||
|
Body: { "data": { ... } }
|
||||||
|
Returns: { status: "started", session_id: "<uuid>" }
|
||||||
|
"""
|
||||||
|
global waiting_jobs
|
||||||
|
|
||||||
|
body = await request.json()
|
||||||
|
data = body.get("data", {})
|
||||||
|
|
||||||
|
sid = huniteducard.make_session_entry()
|
||||||
|
huniteducard.sessions[sid]["type"] = "uniteducard_eligibility"
|
||||||
|
huniteducard.sessions[sid]["last_activity"] = time.time()
|
||||||
|
|
||||||
|
async with lock:
|
||||||
|
waiting_jobs += 1
|
||||||
|
|
||||||
|
asyncio.create_task(_uniteducard_worker_wrapper(
|
||||||
|
sid, data,
|
||||||
|
url="https://www.uhcdental.com/content/provider/dental.html?state=abc"
|
||||||
|
))
|
||||||
|
|
||||||
|
return {"status": "started", "session_id": sid}
|
||||||
|
|
||||||
|
|
||||||
async def _dentaquest_worker_wrapper(sid: str, data: dict, url: str):
|
async def _dentaquest_worker_wrapper(sid: str, data: dict, url: str):
|
||||||
"""Background worker for DentaQuest (Tufts SCO) — acquires semaphore, updates counters."""
|
"""Background worker for DentaQuest (Tufts SCO) — acquires semaphore, updates counters."""
|
||||||
global active_jobs, waiting_jobs
|
global active_jobs, waiting_jobs
|
||||||
@@ -932,6 +976,8 @@ async def submit_otp(request: Request):
|
|||||||
res = hdeltains.submit_otp(sid, otp)
|
res = hdeltains.submit_otp(sid, otp)
|
||||||
elif sid in hunitedsco.sessions:
|
elif sid in hunitedsco.sessions:
|
||||||
res = hunitedsco.submit_otp(sid, otp)
|
res = hunitedsco.submit_otp(sid, otp)
|
||||||
|
elif sid in huniteducard.sessions:
|
||||||
|
res = huniteducard.submit_otp(sid, otp)
|
||||||
elif sid in hdentaquest.sessions:
|
elif sid in hdentaquest.sessions:
|
||||||
res = hdentaquest.submit_otp(sid, otp)
|
res = hdentaquest.submit_otp(sid, otp)
|
||||||
elif sid in hddma_claim.sessions:
|
elif sid in hddma_claim.sessions:
|
||||||
@@ -963,6 +1009,8 @@ async def session_status(sid: str):
|
|||||||
s = hdeltains.get_session_status(sid)
|
s = hdeltains.get_session_status(sid)
|
||||||
elif sid in hunitedsco.sessions:
|
elif sid in hunitedsco.sessions:
|
||||||
s = hunitedsco.get_session_status(sid)
|
s = hunitedsco.get_session_status(sid)
|
||||||
|
elif sid in huniteducard.sessions:
|
||||||
|
s = huniteducard.get_session_status(sid)
|
||||||
elif sid in hdentaquest.sessions:
|
elif sid in hdentaquest.sessions:
|
||||||
s = hdentaquest.get_session_status(sid)
|
s = hdentaquest.get_session_status(sid)
|
||||||
elif sid in hcca.sessions:
|
elif sid in hcca.sessions:
|
||||||
@@ -1022,6 +1070,16 @@ async def clear_unitedsco_session_endpoint():
|
|||||||
return {"status": "error", "message": str(e)}
|
return {"status": "error", "message": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/clear-uniteducard-session")
|
||||||
|
async def clear_uniteducard_session_endpoint():
|
||||||
|
"""Clears the United Ucard browser session. Call when credentials are deleted or changed."""
|
||||||
|
try:
|
||||||
|
clear_uniteducard_session_on_startup()
|
||||||
|
return {"status": "success", "message": "United Ucard session cleared"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "error", "message": str(e)}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/clear-cca-session")
|
@app.post("/clear-cca-session")
|
||||||
async def clear_cca_session_endpoint():
|
async def clear_cca_session_endpoint():
|
||||||
"""Clears the CCA browser session. Call when credentials are deleted or changed."""
|
"""Clears the CCA browser session. Call when credentials are deleted or changed."""
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import os
|
||||||
|
import time
|
||||||
|
import asyncio
|
||||||
|
from typing import Dict, Any
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
from selenium.webdriver.support.ui import WebDriverWait
|
||||||
|
from selenium.webdriver.support import expected_conditions as EC
|
||||||
|
from selenium.common.exceptions import WebDriverException, TimeoutException
|
||||||
|
|
||||||
|
from selenium_UnitedUcard_eligibilityCheckWorker import AutomationUnitedUcardEligibilityCheck
|
||||||
|
|
||||||
|
# In-memory session store
|
||||||
|
sessions: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
|
SESSION_OTP_TIMEOUT = int(os.getenv("SESSION_OTP_TIMEOUT", "120")) # seconds
|
||||||
|
|
||||||
|
|
||||||
|
def make_session_entry() -> str:
|
||||||
|
"""Create a new session entry and return its ID."""
|
||||||
|
import uuid
|
||||||
|
sid = str(uuid.uuid4())
|
||||||
|
sessions[sid] = {
|
||||||
|
"status": "created", # created -> running -> waiting_for_otp -> otp_submitted -> completed / error
|
||||||
|
"created_at": time.time(),
|
||||||
|
"last_activity": time.time(),
|
||||||
|
"bot": None, # worker instance
|
||||||
|
"driver": None, # selenium webdriver
|
||||||
|
"otp_event": asyncio.Event(),
|
||||||
|
"otp_value": None,
|
||||||
|
"result": None,
|
||||||
|
"message": None,
|
||||||
|
"type": None,
|
||||||
|
}
|
||||||
|
return sid
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_session(sid: str, message: str | None = None):
|
||||||
|
"""
|
||||||
|
Close driver (if any), wake OTP waiter, set final state, and remove session entry.
|
||||||
|
Idempotent: safe to call multiple times.
|
||||||
|
"""
|
||||||
|
s = sessions.get(sid)
|
||||||
|
if not s:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
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
|
||||||
|
|
||||||
|
# NOTE: Do NOT quit driver - keep browser alive for next patient
|
||||||
|
# Browser manager handles the persistent browser instance
|
||||||
|
|
||||||
|
finally:
|
||||||
|
sessions.pop(sid, None)
|
||||||
|
|
||||||
|
|
||||||
|
async def _remove_session_later(sid: str, delay: int = 20):
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
await cleanup_session(sid)
|
||||||
|
|
||||||
|
|
||||||
|
def _minimize_browser(bot):
|
||||||
|
"""Hide the browser window so it doesn't stay in the user's way."""
|
||||||
|
try:
|
||||||
|
if bot and bot.driver:
|
||||||
|
try:
|
||||||
|
bot.driver.get("about:blank")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
bot.driver.minimize_window()
|
||||||
|
print("[UnitedUcard] Browser minimized after error")
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
bot.driver.set_window_position(-10000, -10000)
|
||||||
|
print("[UnitedUcard] Browser moved off-screen after error")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[UnitedUcard] Could not hide browser: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def start_uniteducard_run(sid: str, data: dict, url: str):
|
||||||
|
"""
|
||||||
|
Run the United Ucard workflow for a session (WITHOUT managing semaphore/counters).
|
||||||
|
Called by agent.py inside a wrapper that handles queue/counters.
|
||||||
|
"""
|
||||||
|
s = sessions.get(sid)
|
||||||
|
if not s:
|
||||||
|
return {"status": "error", "message": "session not found"}
|
||||||
|
|
||||||
|
s["status"] = "running"
|
||||||
|
s["last_activity"] = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
bot = AutomationUnitedUcardEligibilityCheck({"data": data})
|
||||||
|
bot.config_driver()
|
||||||
|
|
||||||
|
s["bot"] = bot
|
||||||
|
s["driver"] = bot.driver
|
||||||
|
s["last_activity"] = time.time()
|
||||||
|
|
||||||
|
# Navigate to login URL
|
||||||
|
try:
|
||||||
|
if not url:
|
||||||
|
raise ValueError("URL not provided for United Ucard run")
|
||||||
|
bot.driver.maximize_window()
|
||||||
|
bot.driver.get(url)
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
except Exception as e:
|
||||||
|
s["status"] = "error"
|
||||||
|
s["message"] = f"Navigation failed: {e}"
|
||||||
|
await cleanup_session(sid)
|
||||||
|
return {"status": "error", "message": s["message"]}
|
||||||
|
|
||||||
|
# Login
|
||||||
|
try:
|
||||||
|
login_result = bot.login(url)
|
||||||
|
except WebDriverException as wde:
|
||||||
|
s["status"] = "error"
|
||||||
|
s["message"] = f"Selenium driver error during login: {wde}"
|
||||||
|
await cleanup_session(sid, s["message"])
|
||||||
|
return {"status": "error", "message": s["message"]}
|
||||||
|
except Exception as e:
|
||||||
|
s["status"] = "error"
|
||||||
|
s["message"] = f"Unexpected error during login: {e}"
|
||||||
|
await cleanup_session(sid, s["message"])
|
||||||
|
return {"status": "error", "message": s["message"]}
|
||||||
|
|
||||||
|
# Already logged in - session persisted from profile, skip to step1
|
||||||
|
if isinstance(login_result, str) and login_result == "ALREADY_LOGGED_IN":
|
||||||
|
s["status"] = "running"
|
||||||
|
s["message"] = "Session persisted"
|
||||||
|
print("[start_uniteducard_run] Session persisted - skipping OTP")
|
||||||
|
|
||||||
|
# OTP required path - POLL THE BROWSER to detect when user enters OTP
|
||||||
|
elif isinstance(login_result, str) and login_result == "OTP_REQUIRED":
|
||||||
|
s["status"] = "waiting_for_otp"
|
||||||
|
s["message"] = "OTP required for login - please enter OTP in browser"
|
||||||
|
s["last_activity"] = time.time()
|
||||||
|
|
||||||
|
driver = s["driver"]
|
||||||
|
|
||||||
|
max_polls = SESSION_OTP_TIMEOUT
|
||||||
|
login_success = False
|
||||||
|
|
||||||
|
print(f"[UnitedUcard OTP] Waiting for user to enter OTP (polling browser for {SESSION_OTP_TIMEOUT}s)...")
|
||||||
|
|
||||||
|
for poll in range(max_polls):
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
s["last_activity"] = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if OTP was submitted via API (from app)
|
||||||
|
otp_value = s.get("otp_value")
|
||||||
|
if otp_value:
|
||||||
|
print(f"[UnitedUcard OTP] OTP received from app: {otp_value}")
|
||||||
|
try:
|
||||||
|
# Confirmed against live site (2026-09-13): #/rba/options/text page
|
||||||
|
# <input id="otpBox" data-testid="otpBox" maxlength="10" type="text">
|
||||||
|
# <button id="continuebtn" data-cy="data-continuebtn-field">Continue</button>
|
||||||
|
otp_input = driver.find_element(By.XPATH,
|
||||||
|
"//input[@id='otpBox' or contains(@id,'otp') or 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)
|
||||||
|
try:
|
||||||
|
# Try the confirmed id first — the broader text/type
|
||||||
|
# fallback below can match the WRONG button if more
|
||||||
|
# than one element on the page qualifies (find_element
|
||||||
|
# returns the first DOM match, not necessarily the
|
||||||
|
# right one), which was landing on a blank page.
|
||||||
|
try:
|
||||||
|
verify_btn = driver.find_element(By.ID, "continuebtn")
|
||||||
|
except Exception:
|
||||||
|
verify_btn = driver.find_element(By.XPATH,
|
||||||
|
"//button[@type='submit' or contains(text(),'Verify') or "
|
||||||
|
"contains(text(),'Submit') or contains(text(),'Continue')]"
|
||||||
|
)
|
||||||
|
driver.execute_script("arguments[0].scrollIntoView({block:'center'});", verify_btn)
|
||||||
|
verify_btn.click()
|
||||||
|
print("[UnitedUcard OTP] Clicked verify button")
|
||||||
|
except Exception:
|
||||||
|
otp_input.send_keys("\n") # Press Enter as fallback
|
||||||
|
print("[UnitedUcard OTP] Pressed Enter as fallback")
|
||||||
|
print("[UnitedUcard 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"[UnitedUcard OTP] Failed to type OTP from app: {type_err}")
|
||||||
|
|
||||||
|
# Check current URL - success lands on the UHC Dental provider dashboard
|
||||||
|
# (secure.uhcdental.com/.../postloginhomescreen.html)
|
||||||
|
current_url = driver.current_url.lower()
|
||||||
|
print(f"[UnitedUcard OTP Poll {poll+1}/{max_polls}] URL: {current_url[:60]}...")
|
||||||
|
|
||||||
|
if "secure.uhcdental.com" in current_url:
|
||||||
|
print("[UnitedUcard OTP] Reached UHC Dental dashboard - login successful!")
|
||||||
|
login_success = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# Also check if OTP input is still visible
|
||||||
|
try:
|
||||||
|
driver.find_element(By.XPATH,
|
||||||
|
"//input[@id='otpBox' or contains(@id,'otp') or contains(@name,'otp') or "
|
||||||
|
"contains(@name,'code') or @type='tel' or contains(@aria-label,'Verification') or "
|
||||||
|
"contains(@placeholder,'code') or contains(@placeholder,'Code')]"
|
||||||
|
)
|
||||||
|
print(f"[UnitedUcard OTP Poll {poll+1}] OTP input still visible - waiting...")
|
||||||
|
except Exception:
|
||||||
|
print(f"[UnitedUcard OTP Poll {poll+1}] OTP input not found, continuing to poll...")
|
||||||
|
|
||||||
|
except Exception as poll_err:
|
||||||
|
print(f"[UnitedUcard OTP Poll {poll+1}] Error: {poll_err}")
|
||||||
|
|
||||||
|
if not login_success:
|
||||||
|
s["status"] = "error"
|
||||||
|
s["message"] = "OTP timeout - login not completed"
|
||||||
|
await cleanup_session(sid)
|
||||||
|
return {"status": "error", "message": "OTP not completed in time"}
|
||||||
|
|
||||||
|
if login_success:
|
||||||
|
s["status"] = "running"
|
||||||
|
s["message"] = "Login successful after OTP"
|
||||||
|
print("[UnitedUcard OTP] Proceeding to step1...")
|
||||||
|
|
||||||
|
elif isinstance(login_result, str) and login_result.startswith("ERROR"):
|
||||||
|
s["status"] = "error"
|
||||||
|
s["message"] = login_result
|
||||||
|
await cleanup_session(sid)
|
||||||
|
return {"status": "error", "message": login_result}
|
||||||
|
|
||||||
|
# Login succeeded without OTP (SUCCESS)
|
||||||
|
elif isinstance(login_result, str) and login_result == "SUCCESS":
|
||||||
|
print("[start_uniteducard_run] Login succeeded without OTP")
|
||||||
|
s["status"] = "running"
|
||||||
|
s["message"] = "Login succeeded"
|
||||||
|
|
||||||
|
# Step 1
|
||||||
|
step1_result = bot.step1()
|
||||||
|
if isinstance(step1_result, str) and step1_result.startswith("ERROR"):
|
||||||
|
s["status"] = "error"
|
||||||
|
s["message"] = step1_result
|
||||||
|
s["result"] = {"status": "error", "message": step1_result}
|
||||||
|
_minimize_browser(bot)
|
||||||
|
asyncio.create_task(_remove_session_later(sid, 30))
|
||||||
|
return {"status": "error", "message": step1_result}
|
||||||
|
|
||||||
|
# Step 2 (PDF)
|
||||||
|
step2_result = bot.step2()
|
||||||
|
if isinstance(step2_result, dict) and step2_result.get("status") == "success":
|
||||||
|
s["status"] = "completed"
|
||||||
|
s["result"] = step2_result
|
||||||
|
s["message"] = "completed"
|
||||||
|
asyncio.create_task(_remove_session_later(sid, 30))
|
||||||
|
return step2_result
|
||||||
|
else:
|
||||||
|
s["status"] = "error"
|
||||||
|
if isinstance(step2_result, dict):
|
||||||
|
s["message"] = step2_result.get("message", "unknown error")
|
||||||
|
else:
|
||||||
|
s["message"] = str(step2_result)
|
||||||
|
s["result"] = {"status": "error", "message": s["message"]}
|
||||||
|
_minimize_browser(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"worker exception: {e}"
|
||||||
|
try:
|
||||||
|
if bot and bot.driver:
|
||||||
|
bot.driver.minimize_window()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
s["result"] = {"status": "error", "message": s["message"]}
|
||||||
|
asyncio.create_task(_remove_session_later(sid, 30))
|
||||||
|
return {"status": "error", "message": s["message"]}
|
||||||
|
|
||||||
|
|
||||||
|
def submit_otp(sid: str, otp: str) -> Dict[str, Any]:
|
||||||
|
"""Set OTP for a session and wake waiting runner."""
|
||||||
|
s = sessions.get(sid)
|
||||||
|
if not s:
|
||||||
|
return {"status": "error", "message": "session not found"}
|
||||||
|
if s.get("status") != "waiting_for_otp":
|
||||||
|
return {"status": "error", "message": f"session not waiting for otp (state={s.get('status')})"}
|
||||||
|
s["otp_value"] = otp
|
||||||
|
s["last_activity"] = time.time()
|
||||||
|
try:
|
||||||
|
s["otp_event"].set()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {"status": "ok", "message": "otp accepted"}
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_status(sid: str) -> Dict[str, Any]:
|
||||||
|
s = sessions.get(sid)
|
||||||
|
if not s:
|
||||||
|
return {"status": "not_found"}
|
||||||
|
return {
|
||||||
|
"session_id": sid,
|
||||||
|
"status": s.get("status"),
|
||||||
|
"message": s.get("message"),
|
||||||
|
"created_at": s.get("created_at"),
|
||||||
|
"last_activity": s.get("last_activity"),
|
||||||
|
"result": s.get("result") if s.get("status") in ("completed", "error") else None,
|
||||||
|
}
|
||||||
@@ -0,0 +1,632 @@
|
|||||||
|
"""
|
||||||
|
United Ucard (OneHealthcareID) eligibility check worker.
|
||||||
|
|
||||||
|
Scaffolded from selenium_UnitedSCO_eligibilityCheckWorker.py — same shape
|
||||||
|
(config_driver / login / step1 / step2 / main_workflow) so it plugs into the
|
||||||
|
same helpers_*_eligibility.py + agent.py session/OTP pattern.
|
||||||
|
|
||||||
|
Login flow (confirmed against the live site, 2026-09-13):
|
||||||
|
0. https://www.uhcdental.com/content/provider/dental.html?state=abc (public site)
|
||||||
|
span.header-signin-btn ("Sign In") -> a.sign-in-button-link ("Provider Login")
|
||||||
|
which OIDC-redirects to identity.onehealthcareid.com (or straight back to the
|
||||||
|
dashboard if there's already a valid SSO session)
|
||||||
|
1. https://identity.onehealthcareid.com/oneapp/index.html#/login
|
||||||
|
input#username -> button#btnLogin ("Continue")
|
||||||
|
2. #/signin/password
|
||||||
|
input#login-pwd -> button#btnLogin ("Continue")
|
||||||
|
3. #/rba/options ("Verify Your Identity")
|
||||||
|
button#textMsg ("Via Text Message")
|
||||||
|
4. #/rba/options/text ("Access Code")
|
||||||
|
input#otpBox -> button#continuebtn ("Continue")
|
||||||
|
5. Lands on the UHC Dental provider dashboard:
|
||||||
|
https://secure.uhcdental.com/.../postloginhomescreen.html?...
|
||||||
|
|
||||||
|
step1()/step2() (the actual eligibility search + PDF capture, once logged in)
|
||||||
|
are left as TODO stubs — fill these in once we know the exact DOM for the
|
||||||
|
member search + eligibility result pages on the dashboard.
|
||||||
|
"""
|
||||||
|
from selenium.common.exceptions import WebDriverException, TimeoutException
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
from selenium.webdriver.support.ui import WebDriverWait
|
||||||
|
from selenium.webdriver.support import expected_conditions as EC
|
||||||
|
import time
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import base64
|
||||||
|
|
||||||
|
from uniteducard_browser_manager import get_browser_manager
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationUnitedUcardEligibilityCheck:
|
||||||
|
def __init__(self, data):
|
||||||
|
self.headless = False
|
||||||
|
self.driver = None
|
||||||
|
|
||||||
|
self.data = data.get("data", {}) if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
# Flatten values for convenience
|
||||||
|
self.memberId = self.data.get("memberId", "")
|
||||||
|
self.dateOfBirth = self.data.get("dateOfBirth", "")
|
||||||
|
self.firstName = self.data.get("firstName", "")
|
||||||
|
self.lastName = self.data.get("lastName", "")
|
||||||
|
self.uniteducard_username = self.data.get("uniteducardUsername", "")
|
||||||
|
self.uniteducard_password = self.data.get("uniteducardPassword", "")
|
||||||
|
self.payment_group_id = self.data.get("paymentGroupId", "")
|
||||||
|
|
||||||
|
# Use browser manager's download dir
|
||||||
|
self.download_dir = get_browser_manager().download_dir
|
||||||
|
os.makedirs(self.download_dir, exist_ok=True)
|
||||||
|
|
||||||
|
def config_driver(self):
|
||||||
|
# Use persistent browser from manager (keeps device trust tokens)
|
||||||
|
self.driver = get_browser_manager().get_driver(self.headless)
|
||||||
|
|
||||||
|
def _force_logout(self):
|
||||||
|
"""Force logout by clearing cookies for the One Healthcare ID domain."""
|
||||||
|
try:
|
||||||
|
print("[UnitedUcard login] Forcing logout due to credential change...")
|
||||||
|
browser_manager = get_browser_manager()
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.driver.delete_all_cookies()
|
||||||
|
print("[UnitedUcard login] Cleared all cookies")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[UnitedUcard login] Error clearing cookies: {e}")
|
||||||
|
|
||||||
|
browser_manager.clear_credentials_hash()
|
||||||
|
print("[UnitedUcard login] Logout complete")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[UnitedUcard login] Error during forced logout: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def login(self, url):
|
||||||
|
"""
|
||||||
|
Log in via https://www.uhcdental.com/content/provider/dental.html (Sign In ->
|
||||||
|
Provider Login), which OIDC-redirects through identity.onehealthcareid.com.
|
||||||
|
|
||||||
|
Returns one of:
|
||||||
|
"ALREADY_LOGGED_IN" / "SUCCESS" - logged in, caller should proceed to step1
|
||||||
|
"OTP_REQUIRED" - caller should poll for OTP entry
|
||||||
|
"ERROR: <msg>" - login failed
|
||||||
|
"""
|
||||||
|
wait = WebDriverWait(self.driver, 30)
|
||||||
|
browser_manager = get_browser_manager()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if credentials have changed - if so, force logout first
|
||||||
|
if self.uniteducard_username and browser_manager.credentials_changed(self.uniteducard_username):
|
||||||
|
self._force_logout()
|
||||||
|
self.driver.get(url)
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
# Navigate to the public UHC Dental provider portal (NOT directly to
|
||||||
|
# identity.onehealthcareid.com) — Sign In -> Provider Login triggers
|
||||||
|
# the OIDC redirect to the identity login page.
|
||||||
|
self.driver.get(url)
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
current_url = self.driver.current_url
|
||||||
|
print(f"[UnitedUcard login] Current URL: {current_url}")
|
||||||
|
|
||||||
|
# If we've already landed on the UHC Dental provider dashboard
|
||||||
|
# (secure.uhcdental.com/.../postloginhomescreen.html), a prior
|
||||||
|
# session is still valid.
|
||||||
|
if "secure.uhcdental.com" in current_url.lower():
|
||||||
|
print("[UnitedUcard login] Already logged in - on UHC Dental dashboard")
|
||||||
|
return "ALREADY_LOGGED_IN"
|
||||||
|
|
||||||
|
# ── Step 0: public site -> Sign In -> Provider Login ────────────
|
||||||
|
# Confirmed against live site (2026-09-13):
|
||||||
|
# <span class="header-signin-btn">Sign In</span> (opens dropdown)
|
||||||
|
# <a class="sign-in-button-link" href="https://identity.onehealthcareid.com/
|
||||||
|
# oidc/authorize?client_id=DBP92444&response_type=code&scope=openid+profile+email&
|
||||||
|
# redirect_uri=https://secure.uhcdental.com/content/dental-benefits-provider/
|
||||||
|
# en/secure/postloginhomescreen.html">Provider Login</a>
|
||||||
|
if "onehealthcareid.com" not in current_url.lower():
|
||||||
|
try:
|
||||||
|
signin_btn = WebDriverWait(self.driver, 10).until(
|
||||||
|
EC.element_to_be_clickable((By.XPATH,
|
||||||
|
"//span[contains(@class,'header-signin-btn') or contains(text(),'Sign In')]"))
|
||||||
|
)
|
||||||
|
signin_btn.click()
|
||||||
|
print("[UnitedUcard login] Clicked 'Sign In' to open dropdown")
|
||||||
|
time.sleep(1)
|
||||||
|
except TimeoutException:
|
||||||
|
return "ERROR: 'Sign In' button not found on UHC Dental provider site"
|
||||||
|
|
||||||
|
# Match by exact visible text "Provider Login" — the dropdown
|
||||||
|
# also has an "Administrator Login" link that shares the same
|
||||||
|
# class AND the same oidc/authorize href pattern (just a
|
||||||
|
# different client_id), so text is the only reliable
|
||||||
|
# differentiator. It's also always the first item in the
|
||||||
|
# dropdown (Provider Login above Administrator Login).
|
||||||
|
clicked_provider_login = False
|
||||||
|
try:
|
||||||
|
provider_login_link = WebDriverWait(self.driver, 10).until(
|
||||||
|
EC.presence_of_element_located((By.XPATH,
|
||||||
|
"//a[normalize-space(text())='Provider Login']"))
|
||||||
|
)
|
||||||
|
self.driver.execute_script(
|
||||||
|
"arguments[0].scrollIntoView({block:'center'});", provider_login_link
|
||||||
|
)
|
||||||
|
time.sleep(0.3)
|
||||||
|
try:
|
||||||
|
provider_login_link.click()
|
||||||
|
print("[UnitedUcard login] Clicked 'Provider Login' (direct)")
|
||||||
|
clicked_provider_login = True
|
||||||
|
except Exception as direct_click_err:
|
||||||
|
print(f"[UnitedUcard login] Direct click failed ({direct_click_err}); trying JS click")
|
||||||
|
self.driver.execute_script("arguments[0].click();", provider_login_link)
|
||||||
|
print("[UnitedUcard login] Clicked 'Provider Login' (JS)")
|
||||||
|
clicked_provider_login = True
|
||||||
|
time.sleep(3)
|
||||||
|
except TimeoutException:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not clicked_provider_login:
|
||||||
|
# Fallback: the sign-in dropdown's first link is always
|
||||||
|
# "Provider Login" (Administrator Login is second) —
|
||||||
|
# click by position instead of matching text/href.
|
||||||
|
try:
|
||||||
|
dropdown_link = WebDriverWait(self.driver, 5).until(
|
||||||
|
EC.presence_of_element_located((By.XPATH,
|
||||||
|
"(//a[contains(@class,'sign-in-button-link')])[1]"))
|
||||||
|
)
|
||||||
|
self.driver.execute_script("arguments[0].click();", dropdown_link)
|
||||||
|
print("[UnitedUcard login] Clicked first dropdown link (Provider Login, by position)")
|
||||||
|
clicked_provider_login = True
|
||||||
|
time.sleep(3)
|
||||||
|
except Exception as e:
|
||||||
|
return f"ERROR: 'Provider Login' link not found in Sign In dropdown: {e}"
|
||||||
|
|
||||||
|
current_url = self.driver.current_url
|
||||||
|
print(f"[UnitedUcard login] After Provider Login click URL: {current_url}")
|
||||||
|
|
||||||
|
# SSO may skip straight back to the dashboard if already authenticated
|
||||||
|
if "secure.uhcdental.com" in current_url.lower():
|
||||||
|
print("[UnitedUcard login] Already logged in - redirected straight to dashboard")
|
||||||
|
return "ALREADY_LOGGED_IN"
|
||||||
|
|
||||||
|
# Check for OTP input first (in case device left mid-OTP)
|
||||||
|
try:
|
||||||
|
WebDriverWait(self.driver, 3).until(
|
||||||
|
EC.presence_of_element_located((By.XPATH,
|
||||||
|
"//input[@type='tel' or contains(@placeholder,'code') or contains(@placeholder,'Code') or "
|
||||||
|
"contains(@aria-label,'Verification') or contains(@id,'otp') or contains(@name,'otp')]"))
|
||||||
|
)
|
||||||
|
print("[UnitedUcard login] OTP input found on landing")
|
||||||
|
return "OTP_REQUIRED"
|
||||||
|
except TimeoutException:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ── Step 1: "One Healthcare ID or Email Address" ────────────────
|
||||||
|
# Confirmed against live site (2026-09-13):
|
||||||
|
# <input id="username" data-testid="username" ...>
|
||||||
|
# <button id="btnLogin" data-cy="data-btnLogin-field">Continue</button>
|
||||||
|
try:
|
||||||
|
username_field = wait.until(
|
||||||
|
EC.element_to_be_clickable((By.ID, "username"))
|
||||||
|
)
|
||||||
|
username_field.clear()
|
||||||
|
username_field.send_keys(self.uniteducard_username)
|
||||||
|
print(f"[UnitedUcard login] Entered username: {self.uniteducard_username}")
|
||||||
|
except TimeoutException:
|
||||||
|
return "ERROR: Username field not found on login page"
|
||||||
|
|
||||||
|
try:
|
||||||
|
continue_btn = wait.until(
|
||||||
|
EC.element_to_be_clickable((By.ID, "btnLogin"))
|
||||||
|
)
|
||||||
|
continue_btn.click()
|
||||||
|
print("[UnitedUcard login] Clicked Continue (btnLogin) after username")
|
||||||
|
time.sleep(2)
|
||||||
|
except TimeoutException:
|
||||||
|
return "ERROR: Continue button (btnLogin) not found after entering username"
|
||||||
|
|
||||||
|
# ── Step 2: "Enter Your Password" page (#/signin/password) ─────
|
||||||
|
# Confirmed against live site (2026-09-13):
|
||||||
|
# <input id="login-pwd" data-testid="login-pwd" type="password" ...>
|
||||||
|
# <button id="btnLogin" data-cy="data-btnLogin-field">Continue</button>
|
||||||
|
# The email field on this page is pre-filled/read-only from step 1.
|
||||||
|
try:
|
||||||
|
password_field = wait.until(
|
||||||
|
EC.presence_of_element_located((By.ID, "login-pwd"))
|
||||||
|
)
|
||||||
|
password_field.clear()
|
||||||
|
password_field.send_keys(self.uniteducard_password)
|
||||||
|
print("[UnitedUcard login] Entered password")
|
||||||
|
except TimeoutException:
|
||||||
|
return "ERROR: Password field not found on login page"
|
||||||
|
|
||||||
|
try:
|
||||||
|
signin_button = wait.until(
|
||||||
|
EC.element_to_be_clickable((By.ID, "btnLogin"))
|
||||||
|
)
|
||||||
|
signin_button.click()
|
||||||
|
print("[UnitedUcard login] Clicked Continue (btnLogin) on password page")
|
||||||
|
except TimeoutException:
|
||||||
|
return "ERROR: Continue button (btnLogin) not found on password page"
|
||||||
|
|
||||||
|
if self.uniteducard_username:
|
||||||
|
browser_manager.save_credentials_hash(self.uniteducard_username)
|
||||||
|
|
||||||
|
time.sleep(5) # Wait for login to process
|
||||||
|
|
||||||
|
current_url_after_login = self.driver.current_url.lower()
|
||||||
|
print(f"[UnitedUcard login] After login URL: {current_url_after_login}")
|
||||||
|
|
||||||
|
if "secure.uhcdental.com" in current_url_after_login:
|
||||||
|
print("[UnitedUcard login] Login successful - redirected to UHC Dental dashboard")
|
||||||
|
return "SUCCESS"
|
||||||
|
|
||||||
|
# ── Step 3: "Verify Your Identity" MFA method page (#/rba/options) ──
|
||||||
|
# Confirmed against live site (2026-09-13):
|
||||||
|
# <button id="textMsg" data-cy="data-textMsg-field">Via Text Message</button>
|
||||||
|
if "rba/options" in current_url_after_login:
|
||||||
|
try:
|
||||||
|
text_msg_btn = WebDriverWait(self.driver, 10).until(
|
||||||
|
EC.element_to_be_clickable((By.ID, "textMsg"))
|
||||||
|
)
|
||||||
|
text_msg_btn.click()
|
||||||
|
print("[UnitedUcard login] Clicked 'Via Text Message' on MFA options page")
|
||||||
|
time.sleep(3)
|
||||||
|
except TimeoutException:
|
||||||
|
print("[UnitedUcard login] MFA options page detected but 'Via Text Message' button not found")
|
||||||
|
|
||||||
|
current_url_after_login = self.driver.current_url.lower()
|
||||||
|
print(f"[UnitedUcard login] After MFA method selection URL: {current_url_after_login}")
|
||||||
|
|
||||||
|
# Check for OTP input after submitting credentials
|
||||||
|
try:
|
||||||
|
WebDriverWait(self.driver, 15).until(
|
||||||
|
EC.presence_of_element_located((By.XPATH,
|
||||||
|
"//input[@type='tel' or contains(@placeholder,'code') or contains(@placeholder,'Code') or "
|
||||||
|
"contains(@aria-label,'Verification') or contains(@aria-label,'verification') or "
|
||||||
|
"contains(@name,'otp') or contains(@name,'code') or contains(@id,'otp') or contains(@id,'code')]"
|
||||||
|
))
|
||||||
|
)
|
||||||
|
print("[UnitedUcard login] OTP input detected -> OTP_REQUIRED")
|
||||||
|
return "OTP_REQUIRED"
|
||||||
|
except TimeoutException:
|
||||||
|
print("[UnitedUcard login] No OTP input detected")
|
||||||
|
|
||||||
|
# Re-check after waiting for OTP check
|
||||||
|
current_url_after_login = self.driver.current_url.lower()
|
||||||
|
if "secure.uhcdental.com" in current_url_after_login:
|
||||||
|
print("[UnitedUcard login] Login successful - redirected to UHC Dental dashboard")
|
||||||
|
return "SUCCESS"
|
||||||
|
|
||||||
|
# Check for error messages on the page
|
||||||
|
try:
|
||||||
|
error_elem = self.driver.find_element(By.XPATH,
|
||||||
|
"//*[contains(@class,'error') or contains(@class,'alert')]")
|
||||||
|
error_text = error_elem.text
|
||||||
|
if error_text:
|
||||||
|
print(f"[UnitedUcard login] Error on page: {error_text}")
|
||||||
|
return f"ERROR: {error_text}"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return "ERROR: Login did not complete - still on identity domain"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[UnitedUcard 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:
|
||||||
|
# YYYY-MM-DD -> MM/DD/YYYY
|
||||||
|
return f"{dob_parts[1]}/{dob_parts[2]}/{dob_parts[0]}"
|
||||||
|
return dob_str
|
||||||
|
|
||||||
|
def step1(self):
|
||||||
|
"""
|
||||||
|
Fill in the "Eligibility Search" form — it's the default tab already
|
||||||
|
showing on the dashboard (postloginhomescreen.html) right after login.
|
||||||
|
|
||||||
|
Confirmed against live site (2026-09-13):
|
||||||
|
<input id="serviceDate"> - defaults blank; fill with today's date, MM/DD/YYYY
|
||||||
|
<input id="eligMemDob"> - Member Date of Birth, MM/DD/YYYY
|
||||||
|
<input id="eligSubsId"> - Subscriber ID
|
||||||
|
<input type="button" value="search" ng-click="submit()">
|
||||||
|
"Search By: Subscriber ID" and "Search For: Individual" are already
|
||||||
|
the default radio selections, so no extra clicks needed there.
|
||||||
|
"""
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
def _fill_field(field_id, value, label):
|
||||||
|
"""Wait for field to be clickable, scroll into view, then fill it.
|
||||||
|
Falls back to a JS value-set (+dispatch input/change events, which
|
||||||
|
Angular's ng-model listens for) if send_keys hits an
|
||||||
|
ElementNotInteractable error — the dashboard's Angular app can
|
||||||
|
take a beat to finish rendering/enabling these fields."""
|
||||||
|
field = WebDriverWait(self.driver, 20).until(
|
||||||
|
EC.presence_of_element_located((By.ID, field_id))
|
||||||
|
)
|
||||||
|
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", field)
|
||||||
|
time.sleep(0.3)
|
||||||
|
try:
|
||||||
|
WebDriverWait(self.driver, 10).until(EC.element_to_be_clickable((By.ID, field_id)))
|
||||||
|
field.clear()
|
||||||
|
field.send_keys(value)
|
||||||
|
print(f"[UnitedUcard step1] Entered {label}: {value}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[UnitedUcard step1] send_keys failed for {label} ({e}); falling back to JS set")
|
||||||
|
self.driver.execute_script("""
|
||||||
|
var el = arguments[0];
|
||||||
|
var val = arguments[1];
|
||||||
|
var nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
|
||||||
|
nativeSetter.call(el, val);
|
||||||
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
el.dispatchEvent(new Event('keyup', { bubbles: true }));
|
||||||
|
""", field, value)
|
||||||
|
print(f"[UnitedUcard step1] Entered {label} via JS: {value}")
|
||||||
|
return field
|
||||||
|
|
||||||
|
try:
|
||||||
|
print(f"[UnitedUcard step1] Starting eligibility search for memberId={self.memberId}, DOB={self.dateOfBirth}")
|
||||||
|
|
||||||
|
# Give the dashboard's Angular app a moment to finish rendering
|
||||||
|
# after the redirect from login — filling fields too early was
|
||||||
|
# throwing "element not interactable".
|
||||||
|
WebDriverWait(self.driver, 20).until(
|
||||||
|
lambda d: d.execute_script("return document.readyState") == "complete"
|
||||||
|
)
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
# Service Date - default to today.
|
||||||
|
# NOTE: serviceDate/eligMemDob both use onkeydown="dateFunction(...)",
|
||||||
|
# a JS input mask that auto-inserts "/" as you type raw digits
|
||||||
|
# (same as manually typing it). Sending an already-formatted
|
||||||
|
# "MM/DD/YYYY" string double-processes the slashes through the
|
||||||
|
# mask and corrupts the stored value — send digits only instead,
|
||||||
|
# matching what a human typing the field actually does.
|
||||||
|
try:
|
||||||
|
today_digits = date.today().strftime("%m%d%Y")
|
||||||
|
_fill_field("serviceDate", today_digits, "Service Date")
|
||||||
|
except TimeoutException:
|
||||||
|
return "ERROR: Service Date field (serviceDate) not found on Eligibility Search page"
|
||||||
|
except Exception as e:
|
||||||
|
return f"ERROR: Could not enter Service Date: {e}"
|
||||||
|
|
||||||
|
# Member Date of Birth
|
||||||
|
try:
|
||||||
|
dob_digits = re.sub(r"\D", "", self._format_dob(self.dateOfBirth))
|
||||||
|
_fill_field("eligMemDob", dob_digits, "Member DOB")
|
||||||
|
except Exception as e:
|
||||||
|
return f"ERROR: Could not enter Member Date of Birth: {e}"
|
||||||
|
|
||||||
|
# Subscriber ID
|
||||||
|
try:
|
||||||
|
_fill_field("eligSubsId", self.memberId, "Subscriber ID")
|
||||||
|
except Exception as e:
|
||||||
|
return f"ERROR: Could not enter Subscriber ID: {e}"
|
||||||
|
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
# Click SEARCH button
|
||||||
|
try:
|
||||||
|
search_btn = WebDriverWait(self.driver, 10).until(
|
||||||
|
EC.presence_of_element_located((By.XPATH,
|
||||||
|
"//input[@type='button' and @value='search']"))
|
||||||
|
)
|
||||||
|
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", search_btn)
|
||||||
|
time.sleep(0.3)
|
||||||
|
try:
|
||||||
|
search_btn.click()
|
||||||
|
except Exception:
|
||||||
|
self.driver.execute_script("arguments[0].click();", search_btn)
|
||||||
|
print("[UnitedUcard step1] Clicked SEARCH button")
|
||||||
|
except Exception as e:
|
||||||
|
return f"ERROR: Could not click SEARCH button: {e}"
|
||||||
|
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
# TODO: confirm what the results look like (same page update? new
|
||||||
|
# tab? modal?) once we see it, then extend step2() accordingly.
|
||||||
|
print("[UnitedUcard step1] Eligibility search submitted")
|
||||||
|
return "Success"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[UnitedUcard step1] Exception: {e}")
|
||||||
|
return f"ERROR:STEP1 - {e}"
|
||||||
|
|
||||||
|
def step2(self):
|
||||||
|
"""
|
||||||
|
Extract patient/eligibility info from the "Eligibility Summary" page
|
||||||
|
(secure.uhcdental.com/.../eligibility-summary.html) that step1()'s
|
||||||
|
SEARCH click lands on, then capture it as a PDF.
|
||||||
|
|
||||||
|
Confirmed against live site (2026-09-13) via screenshot/PDF export:
|
||||||
|
"Patient" panel -> patient full name (e.g. "WINGHUN KWONG")
|
||||||
|
"Insurance Information" -> status badges: "In Network",
|
||||||
|
"Eligible" (or "Not Eligible"/"Ineligible"), "No Essential
|
||||||
|
Health Benefits", and a MASKED Subscriber ID (e.g. "******444")
|
||||||
|
— we deliberately do NOT use that masked ID to overwrite the
|
||||||
|
patient record.
|
||||||
|
Exact DOM ids/classes for the badges haven't been inspected yet, so
|
||||||
|
this uses text-based extraction (same approach as UnitedSCO/DDMA)
|
||||||
|
as a robust starting point.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
print("[UnitedUcard step2] Starting eligibility capture")
|
||||||
|
|
||||||
|
try:
|
||||||
|
WebDriverWait(self.driver, 20).until(
|
||||||
|
lambda d: d.execute_script("return document.readyState") == "complete"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
current_url = self.driver.current_url
|
||||||
|
print(f"[UnitedUcard step2] Current URL: {current_url}")
|
||||||
|
|
||||||
|
page_text = ""
|
||||||
|
try:
|
||||||
|
page_text = self.driver.find_element(By.TAG_NAME, "body").text
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ── Eligibility status ───────────────────────────────────────
|
||||||
|
eligibilityText = "unknown"
|
||||||
|
lower_text = page_text.lower()
|
||||||
|
if "not eligible" in lower_text or "ineligible" in lower_text:
|
||||||
|
eligibilityText = "inactive"
|
||||||
|
elif "eligible" in lower_text:
|
||||||
|
eligibilityText = "active"
|
||||||
|
print(f"[UnitedUcard step2] Eligibility status: {eligibilityText}")
|
||||||
|
|
||||||
|
# ── Patient name ─────────────────────────────────────────────
|
||||||
|
patientName = f"{self.firstName} {self.lastName}".strip()
|
||||||
|
name_extracted = False
|
||||||
|
|
||||||
|
# Strategy 1: text right after the "Patient" heading, e.g.
|
||||||
|
# "Patient\nWINGHUN KWONG\nSUBSCRIBER/INSURED"
|
||||||
|
try:
|
||||||
|
name_match = re.search(
|
||||||
|
r'Patient\s*\n\s*([A-Z][A-Za-z\-\']+(?:\s+[A-Z][A-Za-z\-\']+)+)\s*\n',
|
||||||
|
page_text
|
||||||
|
)
|
||||||
|
if name_match:
|
||||||
|
candidate = name_match.group(1).strip()
|
||||||
|
if candidate and len(candidate) < 60 and "eligible" not in candidate.lower():
|
||||||
|
patientName = candidate.title()
|
||||||
|
name_extracted = True
|
||||||
|
print(f"[UnitedUcard step2] Extracted patient name from text: {patientName}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[UnitedUcard step2] Name regex failed: {e}")
|
||||||
|
|
||||||
|
# Strategy 2: DOM heuristic fallback — heading/strong element
|
||||||
|
# following a "Patient" label
|
||||||
|
if not name_extracted:
|
||||||
|
try:
|
||||||
|
elems = self.driver.find_elements(By.XPATH,
|
||||||
|
"//*[contains(text(),'Patient')]/following::*[self::h1 or self::h2 or self::h3 or self::strong][1]"
|
||||||
|
)
|
||||||
|
for elem in elems:
|
||||||
|
txt = elem.text.strip()
|
||||||
|
if txt and len(txt.split()) >= 2 and txt[0].isupper() and len(txt) < 60:
|
||||||
|
patientName = txt.title()
|
||||||
|
name_extracted = True
|
||||||
|
print(f"[UnitedUcard step2] Extracted patient name from DOM: {patientName}")
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not name_extracted:
|
||||||
|
print("[UnitedUcard step2] WARNING: Could not extract patient name from page; using form values")
|
||||||
|
|
||||||
|
# ── Capture PDF ──────────────────────────────────────────────
|
||||||
|
pdf_path = self._capture_pdf(self.memberId)
|
||||||
|
if not pdf_path:
|
||||||
|
return {"status": "error", "message": "STEP2 FAILED: Could not generate PDF"}
|
||||||
|
|
||||||
|
print(f"[UnitedUcard step2] PDF saved: {pdf_path}")
|
||||||
|
|
||||||
|
self._hide_browser()
|
||||||
|
|
||||||
|
print("[UnitedUcard step2] Eligibility capture complete")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"eligibility": eligibilityText,
|
||||||
|
"ss_path": pdf_path,
|
||||||
|
"pdf_path": pdf_path,
|
||||||
|
"patientName": patientName,
|
||||||
|
# NOTE: Subscriber ID shown on this page is masked (e.g.
|
||||||
|
# "******444"), so we return the ID we searched with rather
|
||||||
|
# than a scraped-and-truncated value.
|
||||||
|
"memberId": self.memberId,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[UnitedUcard step2] Exception: {e}")
|
||||||
|
return {"status": "error", "message": f"STEP2 FAILED: {str(e)}"}
|
||||||
|
|
||||||
|
def _capture_pdf(self, member_id):
|
||||||
|
"""Capture the current page as PDF using Chrome DevTools Protocol."""
|
||||||
|
try:
|
||||||
|
pdf_options = {
|
||||||
|
"landscape": False,
|
||||||
|
"displayHeaderFooter": False,
|
||||||
|
"printBackground": True,
|
||||||
|
"preferCSSPageSize": True,
|
||||||
|
"paperWidth": 8.5,
|
||||||
|
"paperHeight": 11,
|
||||||
|
"marginTop": 0.4,
|
||||||
|
"marginBottom": 0.4,
|
||||||
|
"marginLeft": 0.4,
|
||||||
|
"marginRight": 0.4,
|
||||||
|
"scale": 0.9,
|
||||||
|
}
|
||||||
|
|
||||||
|
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"uniteducard_eligibility_{file_identifier}_{int(time.time())}.pdf")
|
||||||
|
with open(pdf_path, "wb") as f:
|
||||||
|
f.write(pdf_data)
|
||||||
|
return pdf_path
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[UnitedUcard _capture_pdf] Error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _hide_browser(self):
|
||||||
|
"""Hide the browser window after task completion using multiple strategies."""
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
self.driver.get("about:blank")
|
||||||
|
time.sleep(0.5)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.driver.minimize_window()
|
||||||
|
print("[UnitedUcard step2] Browser window minimized")
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.driver.set_window_position(-10000, -10000)
|
||||||
|
print("[UnitedUcard step2] Browser window moved off-screen")
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[UnitedUcard step2] Could not hide browser: {e}")
|
||||||
|
|
||||||
|
def main_workflow(self, url):
|
||||||
|
"""Main workflow that runs all steps."""
|
||||||
|
try:
|
||||||
|
self.config_driver()
|
||||||
|
|
||||||
|
login_result = self.login(url)
|
||||||
|
print(f"[main_workflow] Login result: {login_result}")
|
||||||
|
|
||||||
|
if login_result == "OTP_REQUIRED":
|
||||||
|
return {"status": "otp_required", "message": "OTP required after login"}
|
||||||
|
|
||||||
|
if isinstance(login_result, str) and login_result.startswith("ERROR"):
|
||||||
|
return {"status": "error", "message": login_result}
|
||||||
|
|
||||||
|
step1_result = self.step1()
|
||||||
|
print(f"[main_workflow] Step1 result: {step1_result}")
|
||||||
|
|
||||||
|
if isinstance(step1_result, str) and step1_result.startswith("ERROR"):
|
||||||
|
return {"status": "error", "message": step1_result}
|
||||||
|
|
||||||
|
step2_result = self.step2()
|
||||||
|
print(f"[main_workflow] Step2 result: {step2_result}")
|
||||||
|
|
||||||
|
return step2_result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "error", "message": str(e)}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
"""
|
||||||
|
Minimal browser manager for United Ucard (OneHealthcareID) - only handles persistent
|
||||||
|
profile and keeping browser alive. Clears session cookies on startup (after PC restart)
|
||||||
|
to force fresh login. Tracks credentials to detect changes mid-session.
|
||||||
|
|
||||||
|
Mirrors unitedsco_browser_manager.py — see that file for the pattern this is based on.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import hashlib
|
||||||
|
import threading
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from selenium import webdriver
|
||||||
|
from selenium.webdriver.chrome.service import Service
|
||||||
|
from webdriver_manager.chrome import ChromeDriverManager
|
||||||
|
|
||||||
|
# Ensure DISPLAY is set for Chrome to work (needed when running from SSH/background)
|
||||||
|
if not os.environ.get("DISPLAY"):
|
||||||
|
os.environ["DISPLAY"] = ":0"
|
||||||
|
|
||||||
|
|
||||||
|
class UnitedUcardBrowserManager:
|
||||||
|
"""
|
||||||
|
Singleton that manages a persistent Chrome browser instance for United Ucard.
|
||||||
|
- Uses --user-data-dir for persistent profile (device trust tokens)
|
||||||
|
- Clears session cookies on startup (after PC restart)
|
||||||
|
- Tracks credentials to detect changes mid-session
|
||||||
|
"""
|
||||||
|
_instance = None
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
def __new__(cls):
|
||||||
|
with cls._lock:
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super().__new__(cls)
|
||||||
|
cls._instance._driver = None
|
||||||
|
cls._instance.profile_dir = os.path.abspath("chrome_profile_uniteducard")
|
||||||
|
cls._instance.download_dir = os.path.abspath("seleniumDownloads")
|
||||||
|
cls._instance._credentials_file = os.path.join(cls._instance.profile_dir, ".last_credentials")
|
||||||
|
cls._instance._needs_session_clear = False # Flag to clear session on next driver creation
|
||||||
|
os.makedirs(cls._instance.profile_dir, exist_ok=True)
|
||||||
|
os.makedirs(cls._instance.download_dir, exist_ok=True)
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def clear_session_on_startup(self):
|
||||||
|
"""
|
||||||
|
On startup, only clear the Cookies file so the login session is reset
|
||||||
|
but device trust tokens (Local Storage, IndexedDB) are preserved.
|
||||||
|
"""
|
||||||
|
print("[UnitedUcard BrowserManager] Clearing cookies on startup (preserving device trust)...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Clear credentials tracking so the next login re-saves the hash
|
||||||
|
if os.path.exists(self._credentials_file):
|
||||||
|
os.remove(self._credentials_file)
|
||||||
|
print("[UnitedUcard BrowserManager] Cleared credentials tracking file")
|
||||||
|
|
||||||
|
# Only remove cookie files — leave everything else intact
|
||||||
|
cookie_files = ["Cookies", "Cookies-journal"]
|
||||||
|
for filename in cookie_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)
|
||||||
|
print(f"[UnitedUcard BrowserManager] Removed {filepath}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[UnitedUcard BrowserManager] Could not remove {filepath}: {e}")
|
||||||
|
|
||||||
|
self._needs_session_clear = False
|
||||||
|
print("[UnitedUcard BrowserManager] Cookies cleared — device trust tokens preserved")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[UnitedUcard BrowserManager] Error clearing session: {e}")
|
||||||
|
|
||||||
|
def _hash_credentials(self, username: str) -> str:
|
||||||
|
"""Create a hash of the username to track credential changes."""
|
||||||
|
return hashlib.sha256(username.encode()).hexdigest()[:16]
|
||||||
|
|
||||||
|
def get_last_credentials_hash(self) -> str | None:
|
||||||
|
"""Get the hash of the last-used credentials."""
|
||||||
|
try:
|
||||||
|
if os.path.exists(self._credentials_file):
|
||||||
|
with open(self._credentials_file, 'r') as f:
|
||||||
|
return f.read().strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def save_credentials_hash(self, username: str):
|
||||||
|
"""Save the hash of the current credentials."""
|
||||||
|
try:
|
||||||
|
cred_hash = self._hash_credentials(username)
|
||||||
|
with open(self._credentials_file, 'w') as f:
|
||||||
|
f.write(cred_hash)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[UnitedUcard BrowserManager] Failed to save credentials hash: {e}")
|
||||||
|
|
||||||
|
def credentials_changed(self, username: str) -> bool:
|
||||||
|
"""Check if the credentials have changed since last login."""
|
||||||
|
last_hash = self.get_last_credentials_hash()
|
||||||
|
if last_hash is None:
|
||||||
|
return False # No previous credentials, not a change
|
||||||
|
current_hash = self._hash_credentials(username)
|
||||||
|
changed = last_hash != current_hash
|
||||||
|
if changed:
|
||||||
|
print(f"[UnitedUcard BrowserManager] Credentials changed - logout required")
|
||||||
|
return changed
|
||||||
|
|
||||||
|
def clear_credentials_hash(self):
|
||||||
|
"""Clear the saved credentials hash (used after logout)."""
|
||||||
|
try:
|
||||||
|
if os.path.exists(self._credentials_file):
|
||||||
|
os.remove(self._credentials_file)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[UnitedUcard BrowserManager] Failed to clear credentials hash: {e}")
|
||||||
|
|
||||||
|
def _kill_existing_chrome_for_profile(self):
|
||||||
|
"""Kill any existing Chrome processes using this profile."""
|
||||||
|
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 Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Remove SingletonLock if exists
|
||||||
|
lock_file = os.path.join(self.profile_dir, "SingletonLock")
|
||||||
|
try:
|
||||||
|
if os.path.islink(lock_file) or os.path.exists(lock_file):
|
||||||
|
os.remove(lock_file)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_driver(self, headless=False):
|
||||||
|
"""Get or create the persistent browser instance."""
|
||||||
|
with self._lock:
|
||||||
|
if self._driver is None:
|
||||||
|
print("[UnitedUcard BrowserManager] Driver is None, creating new driver")
|
||||||
|
self._kill_existing_chrome_for_profile()
|
||||||
|
self._create_driver(headless)
|
||||||
|
elif not self._is_alive():
|
||||||
|
print("[UnitedUcard BrowserManager] Driver not alive, recreating")
|
||||||
|
self._kill_existing_chrome_for_profile()
|
||||||
|
self._create_driver(headless)
|
||||||
|
else:
|
||||||
|
print("[UnitedUcard BrowserManager] Reusing existing driver")
|
||||||
|
return self._driver
|
||||||
|
|
||||||
|
def _is_alive(self):
|
||||||
|
"""Check if browser is still responsive."""
|
||||||
|
try:
|
||||||
|
if self._driver is None:
|
||||||
|
return False
|
||||||
|
_ = self._driver.current_url
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _create_driver(self, headless=False):
|
||||||
|
"""Create browser with persistent profile."""
|
||||||
|
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")
|
||||||
|
|
||||||
|
# Persistent profile - THIS IS THE KEY for device trust
|
||||||
|
options.add_argument(f"--user-data-dir={self.profile_dir}")
|
||||||
|
options.add_argument("--no-sandbox")
|
||||||
|
options.add_argument("--disable-dev-shm-usage")
|
||||||
|
|
||||||
|
# Anti-detection options (prevent bot detection)
|
||||||
|
options.add_argument("--disable-blink-features=AutomationControlled")
|
||||||
|
options.add_experimental_option("excludeSwitches", ["enable-automation"])
|
||||||
|
options.add_experimental_option("useAutomationExtension", False)
|
||||||
|
options.add_argument("--disable-infobars")
|
||||||
|
|
||||||
|
prefs = {
|
||||||
|
"download.default_directory": self.download_dir,
|
||||||
|
"plugins.always_open_pdf_externally": True,
|
||||||
|
"download.prompt_for_download": False,
|
||||||
|
"download.directory_upgrade": True,
|
||||||
|
# Disable password save dialog that blocks page interactions
|
||||||
|
"credentials_enable_service": False,
|
||||||
|
"profile.password_manager_enabled": False,
|
||||||
|
"profile.password_manager_leak_detection": False,
|
||||||
|
}
|
||||||
|
options.add_experimental_option("prefs", prefs)
|
||||||
|
|
||||||
|
service = Service(ChromeDriverManager().install())
|
||||||
|
self._driver = webdriver.Chrome(service=service, options=options)
|
||||||
|
self._driver.maximize_window()
|
||||||
|
|
||||||
|
# Remove webdriver property to avoid detection
|
||||||
|
try:
|
||||||
|
self._driver.execute_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self._needs_session_clear = False
|
||||||
|
|
||||||
|
def quit_driver(self):
|
||||||
|
"""Quit browser (only call on shutdown)."""
|
||||||
|
with self._lock:
|
||||||
|
if self._driver:
|
||||||
|
try:
|
||||||
|
self._driver.quit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._driver = None
|
||||||
|
self._kill_existing_chrome_for_profile()
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton accessor
|
||||||
|
_manager = None
|
||||||
|
|
||||||
|
def get_browser_manager():
|
||||||
|
global _manager
|
||||||
|
if _manager is None:
|
||||||
|
_manager = UnitedUcardBrowserManager()
|
||||||
|
return _manager
|
||||||
|
|
||||||
|
|
||||||
|
def clear_uniteducard_session_on_startup():
|
||||||
|
"""Called by agent.py on startup to clear session."""
|
||||||
|
manager = get_browser_manager()
|
||||||
|
manager.clear_session_on_startup()
|
||||||
@@ -85,13 +85,13 @@ export const PatientUncheckedCreateInputObjectSchema = z.object({
|
|||||||
firstName: z.string(),
|
firstName: z.string(),
|
||||||
lastName: z.string(),
|
lastName: z.string(),
|
||||||
dateOfBirth: z.string(),
|
dateOfBirth: z.string(),
|
||||||
gender: z.string().optional(),
|
gender: z.string().optional().nullable(),
|
||||||
phone: z.string().optional(),
|
phone: z.string().optional().nullable(),
|
||||||
email: z.string().optional(),
|
email: z.string().optional().nullable(),
|
||||||
address: z.string().optional(),
|
address: z.string().optional().nullable(),
|
||||||
city: z.string().optional(),
|
city: z.string().optional().nullable(),
|
||||||
state: z.string().optional(),
|
state: z.string().optional().nullable(),
|
||||||
zipCode: z.string().optional(),
|
zipCode: z.string().optional().nullable(),
|
||||||
insuranceProvider: z.string().optional().nullable(),
|
insuranceProvider: z.string().optional().nullable(),
|
||||||
insuranceId: z.string().optional().nullable(),
|
insuranceId: z.string().optional().nullable(),
|
||||||
groupNumber: z.string().optional().nullable(),
|
groupNumber: z.string().optional().nullable(),
|
||||||
|
|||||||
Reference in New Issue
Block a user