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:
2026-09-13 22:44:43 -04:00
co-authored by Claude Sonnet 5
parent e8889c0ff8
commit f30016e674
14 changed files with 2226 additions and 17 deletions
+15
View File
@@ -14,6 +14,7 @@ import { runOcrProcessor } from "./processors/ocrProcessor";
import { runDdmaEligibilityProcessor } from "./processors/ddmaEligibilityProcessor";
import { runDeltaInsEligibilityProcessor } from "./processors/deltaInsEligibilityProcessor";
import { runUnitedSCOEligibilityProcessor } from "./processors/unitedSCOEligibilityProcessor";
import { runUnitedUcardEligibilityProcessor } from "./processors/unitedUcardEligibilityProcessor";
import { runDentaQuestEligibilityProcessor } from "./processors/dentaQuestEligibilityProcessor";
import { runCCAEligibilityProcessor } from "./processors/ccaEligibilityProcessor";
import { runCCAClaimProcessor } from "./processors/ccaClaimProcessor";
@@ -128,6 +129,20 @@ export function enqueueSeleniumJob(data: SeleniumJobData): string {
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") {
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;
}
+1
View File
@@ -10,6 +10,7 @@ export type SeleniumJobType =
| "ddma-eligibility-check"
| "deltains-eligibility-check"
| "unitedsco-eligibility-check"
| "uniteducard-eligibility-check"
| "cca-eligibility-check"
| "cca-claim-submit"
| "cca-preauth-submit"
+2
View File
@@ -14,6 +14,7 @@ import insuranceStatusRoutes from "./insuranceStatus";
import insuranceStatusDdmaRoutes from "./insuranceStatusDDMA";
import insuranceStatusDeltaInsRoutes from "./insuranceStatusDeltaIns";
import insuranceStatusUnitedSCORoutes from "./insuranceStatusUnitedSCO";
import insuranceStatusUnitedUcardRoutes from "./insuranceStatusUnitedUcard";
import insuranceStatusTuftsSCORoutes from "./insuranceStatusTuftsSCO";
import insuranceStatusCCARoutes from "./insuranceStatusCCA";
import insuranceStatusCCAClaimRoutes from "./insuranceStatusCCAClaim";
@@ -66,6 +67,7 @@ router.use("/insurance-status", insuranceStatusRoutes);
router.use("/insurance-status-ddma", insuranceStatusDdmaRoutes);
router.use("/insurance-status-deltains", insuranceStatusDeltaInsRoutes);
router.use("/insurance-status-unitedsco", insuranceStatusUnitedSCORoutes);
router.use("/insurance-status-uniteducard", insuranceStatusUnitedUcardRoutes);
router.use("/insurance-status-tuftssco", insuranceStatusTuftsSCORoutes);
router.use("/insurance-status-cca", insuranceStatusCCARoutes);
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: "TUFTS_SCO", label: "Tufts SCO (TUFTS_SCO)" },
{ value: "UNITED_SCO", label: "United SCO / DentalHub (UNITED_SCO)" },
{ value: "UNITED_UCARD", label: "United Ucard (UNITED_UCARD)" },
{ value: "CCA", label: "CCA (CCA)" },
{ 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 { TuftsSCOEligibilityButton } from "@/components/insurance-status/tufts-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 { BcbsMaEligibilityButton } from "@/components/insurance-status/bcbs-ma-button-modal";
import { useLicense } from "@/hooks/use-license";
@@ -171,7 +172,7 @@ export default function InsuranceStatusPage() {
const [cmspAccumulatorPdfId, setCmspAccumulatorPdfId] = useState<number | 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 pendingScrollTo = useRef<string | null>(null);
const [prefillTick, setPrefillTick] = useState(0);
@@ -909,7 +910,7 @@ export default function InsuranceStatusPage() {
const autoCheck = params.get("autoCheck") as typeof pendingAutoCheck.current;
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)) {
pendingAutoCheck.current = autoCheck;
} else if (scrollTo) {
@@ -1196,14 +1197,27 @@ export default function InsuranceStatusPage() {
{/* Row 3 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Button
className="w-full"
variant="outline"
disabled={isFormIncomplete}
>
<CheckCircle className="h-4 w-4 mr-2" />
United AAPR
</Button>
<div title={!isLicensed ? "License required" : undefined} className={!isLicensed ? "opacity-40 pointer-events-none" : ""}>
<UnitedUcardEligibilityButton
memberId={memberId}
dateOfBirth={dateOfBirth}
firstName={firstName}
lastName={lastName}
isFormIncomplete={isFormIncomplete}
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
className="w-full"
+58
View File
@@ -17,6 +17,7 @@ import time
import helpers_ddma_eligibility as hddma
import helpers_deltains_eligibility as hdeltains
import helpers_unitedsco_eligibility as hunitedsco
import helpers_uniteducard_eligibility as huniteducard
import helpers_dentaquest_eligibility as hdentaquest
import helpers_cca_eligibility as hcca
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 deltains_browser_manager import clear_deltains_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 cca_browser_manager import clear_cca_session_on_startup
@@ -45,6 +47,7 @@ print("=" * 50)
clear_ddma_session_on_startup()
clear_deltains_session_on_startup()
clear_unitedsco_session_on_startup()
clear_uniteducard_session_on_startup()
clear_dentaquest_session_on_startup()
clear_cca_session_on_startup()
print("=" * 50)
@@ -497,6 +500,47 @@ async def unitedsco_eligibility(request: Request):
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):
"""Background worker for DentaQuest (Tufts SCO) — acquires semaphore, updates counters."""
global active_jobs, waiting_jobs
@@ -932,6 +976,8 @@ async def submit_otp(request: Request):
res = hdeltains.submit_otp(sid, otp)
elif sid in hunitedsco.sessions:
res = hunitedsco.submit_otp(sid, otp)
elif sid in huniteducard.sessions:
res = huniteducard.submit_otp(sid, otp)
elif sid in hdentaquest.sessions:
res = hdentaquest.submit_otp(sid, otp)
elif sid in hddma_claim.sessions:
@@ -963,6 +1009,8 @@ async def session_status(sid: str):
s = hdeltains.get_session_status(sid)
elif sid in hunitedsco.sessions:
s = hunitedsco.get_session_status(sid)
elif sid in huniteducard.sessions:
s = huniteducard.get_session_status(sid)
elif sid in hdentaquest.sessions:
s = hdentaquest.get_session_status(sid)
elif sid in hcca.sessions:
@@ -1022,6 +1070,16 @@ async def clear_unitedsco_session_endpoint():
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")
async def clear_cca_session_endpoint():
"""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()
+7 -7
View File
@@ -85,13 +85,13 @@ export const PatientUncheckedCreateInputObjectSchema = z.object({
firstName: z.string(),
lastName: z.string(),
dateOfBirth: z.string(),
gender: z.string().optional(),
phone: z.string().optional(),
email: z.string().optional(),
address: z.string().optional(),
city: z.string().optional(),
state: z.string().optional(),
zipCode: z.string().optional(),
gender: z.string().optional().nullable(),
phone: z.string().optional().nullable(),
email: z.string().optional().nullable(),
address: z.string().optional().nullable(),
city: z.string().optional().nullable(),
state: z.string().optional().nullable(),
zipCode: z.string().optional().nullable(),
insuranceProvider: z.string().optional().nullable(),
insuranceId: z.string().optional().nullable(),
groupNumber: z.string().optional().nullable(),