From 1e581c193c3fc8f087c4c40b25ece1ad9ac19475 Mon Sep 17 00:00:00 2001 From: Gitead Date: Mon, 25 May 2026 00:29:04 -0400 Subject: [PATCH] feat: United/DentalHub claim submission automation and patient list sync - Add full Selenium automation for United/DentalHub claim submission (steps 1-8: login, OTP, patient search, practitioner page, code entry, other coverage No, attachments, submit, Status & History PDF) - Consolidate UnitedDH siteKey to UNITED_SCO throughout app - Fix procedure date overwrite with Ctrl+A+Delete before typing service date - Fix OTP popup reliability: emit every poll (no throttle) - Fix Chrome session persistence: only clear cookies on startup - Add touchPatient() to storage: claim submission now pushes patient to top of list across eligibility, claims, and documents pages Co-Authored-By: Claude Sonnet 4.6 --- apps/Backend/src/queue/jobRunner.ts | 24 + .../queue/processors/ddmaClaimProcessor.ts | 7 + .../processors/unitedDHClaimProcessor.ts | 164 +++ apps/Backend/src/routes/index.ts | 4 + apps/Backend/src/storage/patients-storage.ts | 10 + .../src/components/claims/claim-form.tsx | 170 ++- .../components/settings/InsuranceCredForm.tsx | 2 +- apps/Frontend/src/pages/appointments-page.tsx | 4 + apps/Frontend/src/pages/claims-page.tsx | 216 ++++ .../src/utils/procedureCombosMapping.ts | 28 +- apps/SeleniumService/agent.py | 94 ++ .../SeleniumService/helpers_uniteddh_claim.py | 344 ++++++ .../selenium_UnitedDH_claimSubmitWorker.py | 1017 +++++++++++++++++ .../unitedsco_browser_manager.py | 111 +- 14 files changed, 2100 insertions(+), 95 deletions(-) create mode 100644 apps/Backend/src/queue/processors/unitedDHClaimProcessor.ts create mode 100644 apps/SeleniumService/helpers_uniteddh_claim.py create mode 100644 apps/SeleniumService/selenium_UnitedDH_claimSubmitWorker.py diff --git a/apps/Backend/src/queue/jobRunner.ts b/apps/Backend/src/queue/jobRunner.ts index c4029079..823d97d0 100644 --- a/apps/Backend/src/queue/jobRunner.ts +++ b/apps/Backend/src/queue/jobRunner.ts @@ -19,6 +19,8 @@ import { runCCAEligibilityProcessor } from "./processors/ccaEligibilityProcessor import { runCCAClaimProcessor } from "./processors/ccaClaimProcessor"; import { runCCAPreAuthProcessor } from "./processors/ccaPreAuthProcessor"; import { runDDMAClaimProcessor } from "./processors/ddmaClaimProcessor"; +import { runUnitedDHClaimProcessor } from "./processors/unitedDHClaimProcessor"; +import { runTuftsSCOClaimProcessor } from "./processors/tuftsSCOClaimProcessor"; import { runEligibilityHistoryProcessor } from "./processors/eligibilityHistoryProcessor"; import { runCmspEligibilityHistoryRemainingProcessor } from "./processors/cmspEligibilityHistoryRemainingProcessor"; import type { SeleniumJobData, OcrJobData } from "./queues"; @@ -168,6 +170,28 @@ export function enqueueSeleniumJob(data: SeleniumJobData): string { job.id ); } + if (jobType === "uniteddh-claim-submit") { + return runUnitedDHClaimProcessor( + { + enrichedPayload: data.enrichedPayload, + userId: data.userId, + claimId: data.claimId, + socketId: data.socketId, + }, + job.id + ); + } + if (jobType === "tuftssco-claim-submit") { + return runTuftsSCOClaimProcessor( + { + enrichedPayload: data.enrichedPayload, + userId: data.userId, + claimId: data.claimId, + socketId: data.socketId, + }, + job.id + ); + } if (jobType === "cca-eligibility-check") { return runCCAEligibilityProcessor( { diff --git a/apps/Backend/src/queue/processors/ddmaClaimProcessor.ts b/apps/Backend/src/queue/processors/ddmaClaimProcessor.ts index cecce827..88c4f1c5 100644 --- a/apps/Backend/src/queue/processors/ddmaClaimProcessor.ts +++ b/apps/Backend/src/queue/processors/ddmaClaimProcessor.ts @@ -127,6 +127,13 @@ export async function runDDMAClaimProcessor( if (claimNumber) updates.claimNumber = claimNumber; await storage.updateClaim(claimId, updates); log("ddma-claim-processor", "claim record updated", { claimId, claimNumber }); + + // Touch patient so they rise to top of the list across all pages + const claim = await storage.getClaim(claimId); + if (claim?.patientId) { + await storage.touchPatient(claim.patientId); + log("ddma-claim-processor", "patient touched", { patientId: claim.patientId }); + } } catch (e) { log("ddma-claim-processor", "failed to update claim record (non-fatal)", { error: e }); } diff --git a/apps/Backend/src/queue/processors/unitedDHClaimProcessor.ts b/apps/Backend/src/queue/processors/unitedDHClaimProcessor.ts new file mode 100644 index 00000000..2682f79a --- /dev/null +++ b/apps/Backend/src/queue/processors/unitedDHClaimProcessor.ts @@ -0,0 +1,164 @@ +/** + * Processor for "uniteddh-claim-submit" jobs. + * Opens a claim on the United/DentalHub provider portal via Selenium. + * + * Flow: + * 1. POST /uniteddh-claim to Python agent → get session_id + * 2. Emit selenium:uniteddh_claim_started to frontend + * 3. Poll until completed/error + * 4. Emit result + */ +import { + forwardToSeleniumUnitedDHClaimAgent, + getSeleniumUnitedDHClaimSessionStatus, +} from "../../services/seleniumUnitedDHClaimClient"; +import { io } from "../../socket"; +import { storage } from "../../storage"; + +function log(tag: string, msg: string, ctx?: any) { + console.log(`${new Date().toISOString()} [${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); + } catch (_) {} +} + +async function pollUntilDone( + sessionId: string, + socketId: string | undefined, + jobId: string, + pollTimeoutMs = 5 * 60 * 1000 +): Promise { + const maxAttempts = 600; + const pollIntervalMs = 500; + const maxTransientErrors = 12; + const noProgressLimit = 240; // 120s of waiting_for_otp before giving up + 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(`UnitedDH claim polling timeout for session ${sessionId}`); + } + try { + const st = await getSeleniumUnitedDHClaimSessionStatus(sessionId); + const status: string = st?.status ?? "unknown"; + log("uniteddh-claim-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") { + // Emit every poll (same as eligibility) so the popup appears immediately + emitToSocket(socketId, "selenium:otp_required", { + session_id: sessionId, + jobId, + message: "OTP required. Please enter the OTP shown by the DentalHub portal.", + }); + await new Promise((r) => setTimeout(r, pollIntervalMs)); + continue; + } + + if (status === "completed") return st.result; + if (status === "error" || status === "not_found") { + throw new Error(st?.message || `UnitedDH claim 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"))); + if (isTerminal) throw err; + transientErrors++; + if (transientErrors > maxTransientErrors) { + throw new Error(`Too many transient errors polling UnitedDH claim session ${sessionId}`); + } + const backoff = Math.min(30_000, 500 * Math.pow(2, transientErrors - 1)); + await new Promise((r) => setTimeout(r, backoff)); + } + } + throw new Error(`UnitedDH claim polling exhausted all attempts for session ${sessionId}`); +} + +export interface UnitedDHClaimProcessorInput { + enrichedPayload: any; + userId: number; + claimId?: number; + socketId?: string; +} + +export async function runUnitedDHClaimProcessor( + input: UnitedDHClaimProcessorInput, + jobId: string +): Promise<{ status: string; pdf_url?: string; claimNumber?: string }> { + const { enrichedPayload, userId, claimId, socketId } = input; + + log("uniteddh-claim-processor", "starting Python agent session", { claimId }); + const agentResp = await forwardToSeleniumUnitedDHClaimAgent(enrichedPayload); + + if (!agentResp?.session_id) { + throw new Error("Python agent did not return a session_id for UnitedDH claim"); + } + + const sessionId = agentResp.session_id as string; + log("uniteddh-claim-processor", "got session_id", { sessionId }); + + emitToSocket(socketId, "selenium:uniteddh_claim_started", { session_id: sessionId, jobId }); + + const seleniumResult = await pollUntilDone(sessionId, socketId, jobId); + + if (!seleniumResult || seleniumResult.status === "error") { + throw new Error(seleniumResult?.message ?? "UnitedDH claim session returned an error"); + } + + const claimNumber: string | undefined = seleniumResult.claimNumber ?? undefined; + const pdf_url: string | undefined = seleniumResult.pdf_url ?? undefined; + + if (claimId) { + try { + const updates: Record = { status: "REVIEW" }; + if (claimNumber) updates.claimNumber = claimNumber; + await storage.updateClaim(claimId, updates); + log("uniteddh-claim-processor", "claim record updated", { claimId, claimNumber }); + + // Touch patient so they rise to top of the list across all pages + const claim = await storage.getClaim(claimId); + if (claim?.patientId) { + await storage.touchPatient(claim.patientId); + log("uniteddh-claim-processor", "patient touched", { patientId: claim.patientId }); + } + } catch (e) { + log("uniteddh-claim-processor", "failed to update claim record (non-fatal)", { error: e }); + } + } + + emitToSocket(socketId, "selenium:uniteddh_claim_completed", { + jobId, + claimId, + claimNumber, + pdf_url, + message: claimNumber + ? `United/DentalHub claim submitted — Claim #: ${claimNumber}` + : (seleniumResult?.message ?? "United/DentalHub claim submitted successfully"), + }); + + log("uniteddh-claim-processor", "done", { claimId, claimNumber }); + return { status: "success", pdf_url, claimNumber }; +} diff --git a/apps/Backend/src/routes/index.ts b/apps/Backend/src/routes/index.ts index 0dec9ef1..82fc8fc1 100755 --- a/apps/Backend/src/routes/index.ts +++ b/apps/Backend/src/routes/index.ts @@ -19,6 +19,8 @@ import insuranceStatusCCARoutes from "./insuranceStatusCCA"; import insuranceStatusCCAClaimRoutes from "./insuranceStatusCCAClaim"; import insuranceStatusCCAPreAuthRoutes from "./insuranceStatusCCAPreAuth"; import insuranceStatusDDMAClaimRoutes from "./insuranceStatusDDMAClaim"; +import insuranceStatusUnitedDHClaimRoutes from "./insuranceStatusUnitedDHClaim"; +import insuranceStatusTuftsSCOClaimRoutes from "./insuranceStatusTuftsSCOClaim"; import paymentsRoutes from "./payments"; import databaseManagementRoutes from "./database-management"; import notificationsRoutes from "./notifications"; @@ -60,6 +62,8 @@ router.use("/insurance-status-cca", insuranceStatusCCARoutes); router.use("/claims", insuranceStatusCCAClaimRoutes); router.use("/claims", insuranceStatusCCAPreAuthRoutes); router.use("/claims", insuranceStatusDDMAClaimRoutes); +router.use("/claims", insuranceStatusUnitedDHClaimRoutes); +router.use("/claims", insuranceStatusTuftsSCOClaimRoutes); router.use("/payments", paymentsRoutes); router.use("/database-management", databaseManagementRoutes); router.use("/notifications", notificationsRoutes); diff --git a/apps/Backend/src/storage/patients-storage.ts b/apps/Backend/src/storage/patients-storage.ts index 70e89f0e..afc973e5 100755 --- a/apps/Backend/src/storage/patients-storage.ts +++ b/apps/Backend/src/storage/patients-storage.ts @@ -15,6 +15,7 @@ export interface IStorage { getPatientsByIds(ids: number[]): Promise; createPatient(patient: InsertPatient): Promise; updatePatient(id: number, patient: UpdatePatient): Promise; + touchPatient(id: number): Promise; deletePatient(id: number): Promise; searchPatients(args: { filters: any; @@ -105,6 +106,15 @@ export const patientsStorage: IStorage = { } }, + async touchPatient(id: number): Promise { + try { + await db.patient.update({ + where: { id }, + data: { updatedAt: new Date() }, + }); + } catch (_) {} + }, + async deletePatient(id: number): Promise { try { await db.patient.delete({ where: { id } }); diff --git a/apps/Frontend/src/components/claims/claim-form.tsx b/apps/Frontend/src/components/claims/claim-form.tsx index 77384e65..6ca23857 100755 --- a/apps/Frontend/src/components/claims/claim-form.tsx +++ b/apps/Frontend/src/components/claims/claim-form.tsx @@ -90,6 +90,8 @@ interface ClaimFormProps { onHandleForCCASeleniumClaim: (data: ClaimFormData) => void; onHandleForCCASeleniumPreAuth: (data: ClaimPreAuthData) => void; onHandleForDDMASeleniumClaim: (data: ClaimFormData) => void; + onHandleForUnitedDHSeleniumClaim: (data: ClaimFormData) => void; + onHandleForTuftsSCOSeleniumClaim: (data: ClaimFormData) => void; onClose: () => void; } @@ -105,6 +107,8 @@ export function ClaimForm({ onHandleForCCASeleniumClaim, onHandleForCCASeleniumPreAuth, onHandleForDDMASeleniumClaim, + onHandleForUnitedDHSeleniumClaim, + onHandleForTuftsSCOSeleniumClaim, onSubmit, onClose, }: ClaimFormProps) { @@ -618,7 +622,7 @@ export function ClaimForm({ if (p.includes("ddma") || p.includes("delta dental ma")) return "DDMA"; if (p.includes("delta ins") || p === "deltains") return "DeltaIns"; if (p.includes("tufts") || p.includes("dentaquest") || p === "tuftssco") return "TuftsSCO"; - if (p.includes("united sco") || p === "unitedsco") return "UnitedSCO"; + if ((p.includes("united") && p.includes("sco")) || p === "unitedsco") return "UnitedSCO"; if (p.includes("cmsp")) return "CMSP"; if (p.includes("bcbs") || p.includes("blue cross")) return "BCBS"; if (p.includes("united aapr") || p === "unitedaapr") return "UnitedAAPR"; @@ -1070,6 +1074,160 @@ export function ClaimForm({ onClose(); }; + // United/DentalHub Claim: saves to DB then submits via Selenium + const handleUnitedDHClaim = async () => { + const missingFields: string[] = []; + if (!form.memberId?.trim()) missingFields.push("Member ID"); + if (!form.dateOfBirth?.trim()) missingFields.push("Date of Birth"); + if (!patient?.firstName?.trim()) missingFields.push("First Name"); + if (missingFields.length > 0) { + toast({ + title: "Missing Required Fields", + description: `Please fill out the following field(s): ${missingFields.join(", ")}`, + variant: "destructive", + }); + return; + } + + const filteredServiceLines = (form.serviceLines || []).filter( + (line) => (line.procedureCode ?? "").trim() !== "", + ); + if (filteredServiceLines.length === 0) { + toast({ + title: "No procedure codes", + description: "Please add at least one procedure code before submitting the claim.", + variant: "destructive", + }); + return; + } + + let appointmentIdToUse = appointmentId; + if (appointmentIdToUse == null) { + const created = await onHandleAppointmentSubmit({ + patientId, + date: serviceDate, + staffId: appointmentStaffId ?? staff?.id, + }); + if (typeof created === "number" && created > 0) { + appointmentIdToUse = created; + } else if (created && typeof (created as any).id === "number") { + appointmentIdToUse = (created as any).id; + } + } + + const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = form; + + const claimFilesMeta: ClaimFileMeta[] = uploadedFiles?.length + ? await uploadAttachmentsToLocalFolder(uploadedFiles) + : []; + + const selectedNpiProviderId = npiProvider?.npiNumber + ? npiProviders.find((p) => p.npiNumber === npiProvider.npiNumber)?.id ?? null + : null; + + const createdClaim = await onSubmit({ + ...formToCreateClaim, + serviceLines: filteredServiceLines, + staffId: appointmentStaffId ?? Number(staff?.id), + patientId, + insuranceProvider: "United/DentalHub", + appointmentId: appointmentIdToUse!, + claimFiles: claimFilesMeta, + ...(selectedNpiProviderId ? { npiProviderId: selectedNpiProviderId } : {}), + }); + + onHandleForUnitedDHSeleniumClaim({ + ...form, + serviceLines: filteredServiceLines, + staffId: appointmentStaffId ?? Number(staff?.id), + patientId, + insuranceProvider: "United/DentalHub", + appointmentId: appointmentIdToUse!, + insuranceSiteKey: "UNITED_SCO", + claimId: createdClaim.id, + claimFiles: claimFilesMeta, + }); + + onClose(); + }; + + // Tufts SCO Claim: saves to DB then submits via Selenium + const handleTuftsSCOClaim = async () => { + const missingFields: string[] = []; + if (!form.memberId?.trim()) missingFields.push("Member ID"); + if (!form.dateOfBirth?.trim()) missingFields.push("Date of Birth"); + if (!patient?.firstName?.trim()) missingFields.push("First Name"); + if (missingFields.length > 0) { + toast({ + title: "Missing Required Fields", + description: `Please fill out the following field(s): ${missingFields.join(", ")}`, + variant: "destructive", + }); + return; + } + + const filteredServiceLines = (form.serviceLines || []).filter( + (line) => (line.procedureCode ?? "").trim() !== "", + ); + if (filteredServiceLines.length === 0) { + toast({ + title: "No procedure codes", + description: "Please add at least one procedure code before submitting the claim.", + variant: "destructive", + }); + return; + } + + let appointmentIdToUse = appointmentId; + if (appointmentIdToUse == null) { + const created = await onHandleAppointmentSubmit({ + patientId, + date: serviceDate, + staffId: appointmentStaffId ?? staff?.id, + }); + if (typeof created === "number" && created > 0) { + appointmentIdToUse = created; + } else if (created && typeof (created as any).id === "number") { + appointmentIdToUse = (created as any).id; + } + } + + const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = form; + + const claimFilesMeta: ClaimFileMeta[] = uploadedFiles?.length + ? await uploadAttachmentsToLocalFolder(uploadedFiles) + : []; + + const selectedNpiProviderId = npiProvider?.npiNumber + ? npiProviders.find((p) => p.npiNumber === npiProvider.npiNumber)?.id ?? null + : null; + + const createdClaim = await onSubmit({ + ...formToCreateClaim, + serviceLines: filteredServiceLines, + staffId: appointmentStaffId ?? Number(staff?.id), + patientId, + insuranceProvider: "Tufts SCO", + appointmentId: appointmentIdToUse!, + claimFiles: claimFilesMeta, + ...(selectedNpiProviderId ? { npiProviderId: selectedNpiProviderId } : {}), + }); + + onHandleForTuftsSCOSeleniumClaim({ + ...form, + serviceLines: filteredServiceLines, + staffId: appointmentStaffId ?? Number(staff?.id), + patientId, + insuranceProvider: "Tufts SCO", + appointmentId: appointmentIdToUse!, + insuranceSiteKey: "TuftsSCO", + claimId: createdClaim.id, + claimFiles: claimFilesMeta, + }); + + onClose(); + }; + const handleCCAPreAuth = async () => { const missingFields: string[] = []; if (!form.memberId?.trim()) missingFields.push("Member ID"); @@ -2007,10 +2165,16 @@ export function ClaimForm({ > Delta MA Claim - - + +

+ The Tufts SCO (DentaQuest) portal requires a one-time password (OTP) to continue claim submission. +

+
{ + e.preventDefault(); + const input = (e.currentTarget.elements.namedItem("otp") as HTMLInputElement); + if (input?.value.trim()) handleTuftsSCOClaimOtpSubmit(input.value.trim()); + }} className="space-y-4"> +
+ + +
+
+ + +
+
+ + + )} + + {/* United/DentalHub Claim OTP Modal */} + {unitedDHClaimOtpOpen && ( +
+
+
+

Enter OTP — United/DentalHub Claim

+ +
+

+ The United/DentalHub portal requires a one-time password (OTP) to continue claim submission. +

+
{ + e.preventDefault(); + const input = (e.currentTarget.elements.namedItem("otp") as HTMLInputElement); + if (input?.value.trim()) handleUnitedDHClaimOtpSubmit(input.value.trim()); + }} className="space-y-4"> +
+ + +
+
+ + +
+
+
+
+ )} ); } diff --git a/apps/Frontend/src/utils/procedureCombosMapping.ts b/apps/Frontend/src/utils/procedureCombosMapping.ts index 0a17787b..eaf49f5f 100755 --- a/apps/Frontend/src/utils/procedureCombosMapping.ts +++ b/apps/Frontend/src/utils/procedureCombosMapping.ts @@ -3,6 +3,8 @@ import Decimal from "decimal.js"; import rawCodeTable from "@/assets/data/procedureCodesMH.json"; import rawCCACodeTable from "@/assets/data/procedureCodesCCA.json"; import rawDDMACodeTable from "@/assets/data/procedureCodesDDMA.json"; +import rawUnitedDHCodeTable from "@/assets/data/procedureCodesUnitedDH.json"; +import rawTuftsSCOCodeTable from "@/assets/data/procedureCodesTuftsSCO.json"; import { PROCEDURE_COMBOS } from "./procedureCombos"; /* ----------------------------- Types ----------------------------- */ @@ -17,6 +19,8 @@ export type CodeRow = { const CODE_TABLE = rawCodeTable as CodeRow[]; const CCA_CODE_TABLE = rawCCACodeTable as CodeRow[]; const DDMA_CODE_TABLE = rawDDMACodeTable as CodeRow[]; +const UNITEDDH_CODE_TABLE = rawUnitedDHCodeTable as CodeRow[]; +const TUFTSSCO_CODE_TABLE = rawTuftsSCOCodeTable as CodeRow[]; export type ClaimFormLike = { serviceDate: string; // form-level service date @@ -67,10 +71,30 @@ const DDMA_CODE_MAP: Map = (() => { return m; })(); +const UNITEDDH_CODE_MAP: Map = (() => { + const m = new Map(); + for (const r of UNITEDDH_CODE_TABLE) { + const k = normalizeCode(String(r["Procedure Code"] || "")); + if (k && !m.has(k)) m.set(k, r); + } + return m; +})(); + +const TUFTSSCO_CODE_MAP: Map = (() => { + const m = new Map(); + for (const r of TUFTSSCO_CODE_TABLE) { + const k = normalizeCode(String(r["Procedure Code"] || "")); + if (k && !m.has(k)) m.set(k, r); + } + return m; +})(); + /** Return the correct fee-schedule map for the given insurance type. */ function getCodeMap(insuranceSiteKey?: string): Map { if (insuranceSiteKey === "CCA") return CCA_CODE_MAP; if (insuranceSiteKey === "DDMA") return DDMA_CODE_MAP; + if (insuranceSiteKey === "UNITED_SCO") return UNITEDDH_CODE_MAP; + if (insuranceSiteKey === "TuftsSCO") return TUFTSSCO_CODE_MAP; return CODE_MAP; // default: MassHealth } @@ -345,7 +369,7 @@ export function applyComboToForm( } -export { CODE_MAP, CCA_CODE_MAP, DDMA_CODE_MAP, getCodeMap, getPriceForCodeWithAgeFromMap }; +export { CODE_MAP, CCA_CODE_MAP, DDMA_CODE_MAP, UNITEDDH_CODE_MAP, TUFTSSCO_CODE_MAP, getCodeMap, getPriceForCodeWithAgeFromMap }; export type PriceMismatch = { procedureCode: string; @@ -362,7 +386,7 @@ export function findPriceMismatches( patientDOB: string, serviceDate: string, ): PriceMismatch[] { - const supported = ["MH", "MASSHEALTH", "CCA", "DDMA"]; + const supported = ["MH", "MASSHEALTH", "CCA", "DDMA", "UNITEDDH", "TUFTSSCO"]; if (!insuranceSiteKey || !supported.includes(insuranceSiteKey.toUpperCase())) return []; const map = getCodeMap(insuranceSiteKey); diff --git a/apps/SeleniumService/agent.py b/apps/SeleniumService/agent.py index 0e55c8b4..35489e90 100755 --- a/apps/SeleniumService/agent.py +++ b/apps/SeleniumService/agent.py @@ -21,6 +21,8 @@ import helpers_cca_eligibility as hcca import helpers_cca_claim as hcca_claim import helpers_cca_preauth as hcca_preauth import helpers_ddma_claim as hddma_claim +import helpers_uniteddh_claim as huniteddh_claim +import helpers_tuftssco_claim as htuftssco_claim # Import startup session-clear functions from ddma_browser_manager import clear_ddma_session_on_startup @@ -628,6 +630,90 @@ async def ddma_claim(request: Request): return {"status": "started", "session_id": sid} +async def _uniteddh_claim_worker_wrapper(sid: str, data: dict, url: str): + """Background worker for United/DentalHub claim submission.""" + global active_jobs, waiting_jobs + async with semaphore: + async with lock: + waiting_jobs -= 1 + active_jobs += 1 + try: + await huniteddh_claim.start_uniteddh_claim_run(sid, data, url) + finally: + async with lock: + active_jobs -= 1 + + +@app.post("/uniteddh-claim") +async def uniteddh_claim(request: Request): + """ + Starts a United/DentalHub claim submission session in the background. + Logs in, searches patient, opens Member Information page, clicks Create claim, + fills service date and procedure codes. + Body: { "claim": { "uniteddhUsername": "...", "uniteddhPassword": "...", ... } } + Returns: { status: "started", session_id: "" } + """ + global waiting_jobs + + body = await request.json() + + sid = huniteddh_claim.make_session_entry() + huniteddh_claim.sessions[sid]["type"] = "uniteddh_claim" + huniteddh_claim.sessions[sid]["last_activity"] = time.time() + + async with lock: + waiting_jobs += 1 + + asyncio.create_task(_uniteddh_claim_worker_wrapper( + sid, body, + url="https://app.dentalhub.com/app/login" + )) + + return {"status": "started", "session_id": sid} + + +async def _tuftssco_claim_worker_wrapper(sid: str, data: dict, url: str): + """Background worker for Tufts SCO (DentaQuest) claim submission.""" + global active_jobs, waiting_jobs + async with semaphore: + async with lock: + waiting_jobs -= 1 + active_jobs += 1 + try: + await htuftssco_claim.start_tuftssco_claim_run(sid, data, url) + finally: + async with lock: + active_jobs -= 1 + + +@app.post("/tuftssco-claim") +async def tuftssco_claim(request: Request): + """ + Starts a Tufts SCO (DentaQuest) claim submission session in the background. + Logs in, searches patient, opens Member Information page, clicks Create claim, + fills service date and procedure codes. + Body: { "claim": { "dentaquestUsername": "...", "dentaquestPassword": "...", ... } } + Returns: { status: "started", session_id: "" } + """ + global waiting_jobs + + body = await request.json() + + sid = htuftssco_claim.make_session_entry() + htuftssco_claim.sessions[sid]["type"] = "tuftssco_claim" + htuftssco_claim.sessions[sid]["last_activity"] = time.time() + + async with lock: + waiting_jobs += 1 + + asyncio.create_task(_tuftssco_claim_worker_wrapper( + sid, body, + url="https://providers.dentaquest.com/" + )) + + return {"status": "started", "session_id": sid} + + async def _cca_preauth_worker_wrapper(sid: str, data: dict, url: str): """Background worker for CCA pre-authorization submission.""" global active_jobs, waiting_jobs @@ -692,6 +778,10 @@ async def submit_otp(request: Request): res = hdentaquest.submit_otp(sid, otp) elif sid in hddma_claim.sessions: res = hddma_claim.submit_otp(sid, otp) + elif sid in huniteddh_claim.sessions: + res = huniteddh_claim.submit_otp(sid, otp) + elif sid in htuftssco_claim.sessions: + res = htuftssco_claim.submit_otp(sid, otp) else: raise HTTPException(status_code=404, detail="session not found") @@ -719,6 +809,10 @@ async def session_status(sid: str): s = hcca_preauth.get_session_status(sid) elif sid in hddma_claim.sessions: s = hddma_claim.get_session_status(sid) + elif sid in huniteddh_claim.sessions: + s = huniteddh_claim.get_session_status(sid) + elif sid in htuftssco_claim.sessions: + s = htuftssco_claim.get_session_status(sid) else: s = {"status": "not_found"} if s.get("status") == "not_found": diff --git a/apps/SeleniumService/helpers_uniteddh_claim.py b/apps/SeleniumService/helpers_uniteddh_claim.py new file mode 100644 index 00000000..2a723fe2 --- /dev/null +++ b/apps/SeleniumService/helpers_uniteddh_claim.py @@ -0,0 +1,344 @@ +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_UnitedDH_claimSubmitWorker import AutomationUnitedDHClaimSubmit + +sessions: Dict[str, Dict[str, Any]] = {} + +SESSION_OTP_TIMEOUT = int(os.getenv("SESSION_OTP_TIMEOUT", "120")) + + +def make_session_entry() -> str: + import uuid + sid = str(uuid.uuid4()) + sessions[sid] = { + "status": "created", + "created_at": time.time(), + "last_activity": time.time(), + "bot": None, + "driver": None, + "otp_event": asyncio.Event(), + "otp_value": None, + "result": None, + "message": None, + } + return sid + + +async def cleanup_session(sid: str, message: str | None = None): + s = sessions.get(sid) + if not s: + return + try: + if s.get("status") not in ("completed", "error", "not_found"): + s["status"] = "error" + if message: + s["message"] = message + ev = s.get("otp_event") + if ev and not ev.is_set(): + ev.set() + finally: + sessions.pop(sid, None) + print(f"[helpers_uniteddh_claim] cleaned session {sid}") + + +async def _remove_session_later(sid: str, delay: int = 20): + await asyncio.sleep(delay) + await cleanup_session(sid) + + +def _minimize_browser(bot): + try: + if bot and bot.driver: + try: + bot.driver.get("about:blank") + except Exception: + pass + try: + bot.driver.minimize_window() + print("[UnitedDH Claim] Browser minimized after error") + return + except Exception: + pass + try: + bot.driver.set_window_position(-10000, -10000) + print("[UnitedDH Claim] Browser moved off-screen after error") + except Exception: + pass + except Exception as e: + print(f"[UnitedDH Claim] Could not hide browser: {e}") + + +async def start_uniteddh_claim_run(sid: str, data: dict, url: str): + """ + Run the United/DentalHub claim workflow. + Login/OTP handling mirrors helpers_ddma_claim.py exactly. + Claim steps call selenium_UnitedDH_claimSubmitWorker. + """ + s = sessions.get(sid) + if not s: + return {"status": "error", "message": "session not found"} + + s["status"] = "running" + s["last_activity"] = time.time() + + bot = None + try: + bot = AutomationUnitedDHClaimSubmit(data) + bot.config_driver() + + s["bot"] = bot + s["driver"] = bot.driver + s["last_activity"] = time.time() + + try: + 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 ──────────────────────────────────────────────── + if isinstance(login_result, str) and login_result == "ALREADY_LOGGED_IN": + print("[UnitedDH Claim] Session persisted — skipping OTP") + s["status"] = "running" + s["message"] = "Session persisted" + + # ── OTP required ───────────────────────────────────────────────────── + 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"[UnitedDH Claim 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"[UnitedDH Claim OTP] OTP received from app: {otp_value}") + try: + otp_input = driver.find_element(By.XPATH, + "//input[contains(@name,'otp') or contains(@name,'code') or @type='tel' or contains(@aria-label,'Verification') or contains(@placeholder,'code')]" + ) + otp_input.clear() + otp_input.send_keys(otp_value) + try: + verify_btn = driver.find_element(By.XPATH, "//button[@type='button' and @aria-label='Verify']") + verify_btn.click() + print("[UnitedDH Claim OTP] Clicked verify button (aria-label)") + except: + try: + verify_btn = driver.find_element(By.XPATH, "//button[contains(text(),'Verify') or contains(text(),'Submit') or @type='submit']") + verify_btn.click() + print("[UnitedDH Claim OTP] Clicked verify button (text/type)") + except: + otp_input.send_keys("\n") + print("[UnitedDH Claim OTP] Pressed Enter as fallback") + print("[UnitedDH Claim OTP] OTP typed and submitted via app") + s["otp_value"] = None + await asyncio.sleep(3) + except Exception as type_err: + print(f"[UnitedDH Claim OTP] Failed to type OTP from app: {type_err}") + + # Check current URL - if we're on dashboard/member page, login succeeded + current_url = driver.current_url.lower() + print(f"[UnitedDH Claim OTP Poll {poll+1}/{max_polls}] URL: {current_url[:60]}...") + + if "member" in current_url or "dashboard" in current_url or "eligibility" in current_url or "home" in current_url: + try: + WebDriverWait(driver, 5).until( + EC.presence_of_element_located((By.XPATH, + '//input[@placeholder="Search by member ID"] | //input[contains(@placeholder,"Search")] | //*[contains(@class,"dashboard")]' + )) + ) + print("[UnitedDH Claim OTP] Dashboard/search element found - login successful!") + login_success = True + break + except TimeoutException: + print("[UnitedDH Claim OTP] On member page but search input not found, continuing to poll...") + + # Also check if OTP input is still visible + try: + driver.find_element(By.XPATH, + "//input[contains(@name,'otp') or contains(@name,'code') or @type='tel' or contains(@aria-label,'Verification') or contains(@placeholder,'code') or contains(@placeholder,'Code')]" + ) + print(f"[UnitedDH Claim OTP Poll {poll+1}] OTP input still visible - waiting...") + except: + if "login" in current_url or "app/login" in current_url: + print("[UnitedDH Claim OTP] OTP input gone, trying to navigate to dashboard...") + try: + driver.get("https://app.dentalhub.com/app/dashboard") + await asyncio.sleep(2) + except: + pass + + except Exception as poll_err: + print(f"[UnitedDH Claim OTP Poll {poll+1}] Error: {poll_err}") + + if not login_success: + try: + print("[UnitedDH Claim OTP] Final attempt - navigating to dashboard...") + driver.get("https://app.dentalhub.com/app/dashboard") + await asyncio.sleep(3) + WebDriverWait(driver, 10).until( + EC.presence_of_element_located((By.XPATH, + '//input[@placeholder="Search by member ID"] | //input[contains(@placeholder,"Search")] | //*[contains(@class,"dashboard")]' + )) + ) + print("[UnitedDH Claim OTP] Dashboard element found - login successful!") + login_success = True + except TimeoutException: + s["status"] = "error" + s["message"] = "OTP timeout - login not completed" + await cleanup_session(sid) + return {"status": "error", "message": "OTP not completed in time"} + except Exception as final_err: + s["status"] = "error" + s["message"] = f"OTP verification failed: {final_err}" + await cleanup_session(sid) + return {"status": "error", "message": s["message"]} + + if login_success: + s["status"] = "running" + s["message"] = "Login successful after OTP" + print("[UnitedDH Claim OTP] Proceeding to claim steps...") + + # ── Login succeeded without OTP ─────────────────────────────────────── + elif isinstance(login_result, str) and login_result == "SUCCESS": + print("[UnitedDH Claim] Login succeeded without OTP") + s["status"] = "running" + s["message"] = "Login succeeded" + + # ── Login error ─────────────────────────────────────────────────────── + 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} + + # --- Claim steps --- + for step_name, step_fn in [ + ("step1_search_patient", bot.step1_search_patient), + ("step2_open_member_page", bot.step2_open_member_page), + ("step3_click_create_claim", bot.step3_click_create_claim), + ("step4_fill_claim_form", bot.step4_fill_claim_form), + ("step5_attach_files", bot.step5_attach_files), + ("step6_click_next", bot.step6_click_next), + ("step7_submit_claim", bot.step7_submit_claim), + ]: + result = step_fn() + print(f"[UnitedDH Claim] {step_name} result: {result}") + if isinstance(result, str) and result.startswith("ERROR"): + s["status"] = "error" + s["message"] = result + _minimize_browser(bot) + asyncio.create_task(_remove_session_later(sid, 30)) + return {"status": "error", "message": result} + + # --- Step 8: PDF + claim number --- + step8_result = bot.step8_save_confirmation_pdf() + print(f"[UnitedDH Claim] step8 result: {step8_result}") + if isinstance(step8_result, str) and step8_result.startswith("ERROR"): + print(f"[UnitedDH Claim] step8 warning (non-fatal): {step8_result}") + step8_result = {} + + pdf_path = step8_result.get("pdf_path") if isinstance(step8_result, dict) else None + pdf_url = None + if pdf_path: + import os as _os + filename = _os.path.basename(pdf_path) + port = _os.getenv("PORT", "5002") + url_host = _os.getenv("HOST", "localhost") + pdf_url = f"http://{url_host}:{port}/downloads/{filename}" + print(f"[UnitedDH Claim] pdf_url: {pdf_url}") + + claim_number = step8_result.get("claimNumber") if isinstance(step8_result, dict) else None + + result = { + "status": "success", + "message": "United/DentalHub claim submitted successfully", + "claimNumber": claim_number, + "pdf_url": pdf_url, + } + s["status"] = "completed" + s["result"] = result + s["message"] = "completed" + + # Close browser window (session preserved in profile via UnitedSCO browser manager) + try: + from unitedsco_browser_manager import get_browser_manager as _gbm + _gbm().quit_driver() + print("[UnitedDH Claim] Browser closed - session preserved in profile") + except Exception as close_err: + print(f"[UnitedDH Claim] Could not close browser (non-fatal): {close_err}") + + asyncio.create_task(_remove_session_later(sid, 60)) + return result + + except Exception as e: + if s: + s["status"] = "error" + s["message"] = f"worker exception: {e}" + await cleanup_session(sid) + return {"status": "error", "message": f"worker exception: {e}"} + + +def submit_otp(sid: str, otp: str) -> Dict[str, Any]: + s = sessions.get(sid) + if not s: + return {"status": "error", "message": "session not found"} + if s.get("status") != "waiting_for_otp": + return {"status": "error", "message": f"session not waiting for otp (state={s.get('status')})"} + s["otp_value"] = otp + s["last_activity"] = time.time() + try: + s["otp_event"].set() + except Exception: + pass + return {"status": "ok", "message": "otp accepted"} + + +def get_session_status(sid: str) -> Dict[str, Any]: + s = sessions.get(sid) + if not s: + return {"status": "not_found"} + return { + "session_id": sid, + "status": s.get("status"), + "message": s.get("message"), + "created_at": s.get("created_at"), + "last_activity": s.get("last_activity"), + "result": s.get("result") if s.get("status") in ("completed", "error") else None, + } diff --git a/apps/SeleniumService/selenium_UnitedDH_claimSubmitWorker.py b/apps/SeleniumService/selenium_UnitedDH_claimSubmitWorker.py new file mode 100644 index 00000000..15f81fc0 --- /dev/null +++ b/apps/SeleniumService/selenium_UnitedDH_claimSubmitWorker.py @@ -0,0 +1,1017 @@ +""" +United/DentalHub Claim Submission Worker. +Based on the UnitedSCO eligibility check worker (same portal: app.dentalhub.com). +""" +from selenium import webdriver +from selenium.common.exceptions import WebDriverException, TimeoutException +from selenium.webdriver.chrome.service import Service +from selenium.webdriver.common.by import By +from selenium.webdriver.common.keys import Keys +from selenium.webdriver.common.action_chains import ActionChains +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC +from webdriver_manager.chrome import ChromeDriverManager +import time +import os +import base64 +import json + +from unitedsco_browser_manager import get_browser_manager + +_SERVICE_DIR = os.path.dirname(os.path.abspath(__file__)) +_BACKEND_CWD = os.path.normpath(os.path.join(_SERVICE_DIR, "..", "Backend")) + + +class AutomationUnitedDHClaimSubmit: + def __init__(self, data): + self.headless = False + self.driver = None + + claim = data.get("claim", {}) if isinstance(data, dict) else {} + + self.memberId = claim.get("memberId", "") + self.dateOfBirth = claim.get("dateOfBirth", "") + self.firstName = claim.get("firstName", "") + self.lastName = claim.get("lastName", "") + self.serviceDate = claim.get("serviceDate", "") + self.serviceLines = claim.get("serviceLines", []) + self.claimFiles = claim.get("claimFiles", []) + self.patientName = claim.get("patientName", "") + self.remarks = claim.get("remarks", "") + + # Credentials (injected by backend) + self.uniteddh_username = claim.get("uniteddhUsername", "") + self.uniteddh_password = claim.get("uniteddhPassword", "") + + # Re-use the UnitedSCO browser manager (same portal) + self.download_dir = get_browser_manager().download_dir + os.makedirs(self.download_dir, exist_ok=True) + + def config_driver(self): + self.driver = get_browser_manager().get_driver(self.headless) + + def _force_logout(self): + """Force logout by clearing cookies for the DentalHub domain.""" + try: + print("[UnitedDH Claim login] Forcing logout due to credential change...") + browser_manager = get_browser_manager() + + try: + self.driver.get("https://app.dentalhub.com/app/dashboard") + time.sleep(2) + for selector in [ + "//button[contains(text(),'Log out') or contains(text(),'Logout') or contains(text(),'Sign out')]", + "//a[contains(text(),'Log out') or contains(text(),'Logout')]", + "//button[@aria-label='Log out' or @aria-label='Logout']", + ]: + try: + btn = WebDriverWait(self.driver, 3).until(EC.element_to_be_clickable((By.XPATH, selector))) + btn.click() + print("[UnitedDH Claim login] Clicked logout button") + time.sleep(2) + break + except TimeoutException: + continue + except Exception as e: + print(f"[UnitedDH Claim login] Could not click logout button: {e}") + + try: + self.driver.delete_all_cookies() + print("[UnitedDH Claim login] Cleared all cookies") + except Exception as e: + print(f"[UnitedDH Claim login] Error clearing cookies: {e}") + + browser_manager.clear_credentials_hash() + return True + except Exception as e: + print(f"[UnitedDH Claim login] Error during forced logout: {e}") + return False + + def login(self, url): + wait = WebDriverWait(self.driver, 30) + browser_manager = get_browser_manager() + + try: + if self.uniteddh_username and browser_manager.credentials_changed(self.uniteddh_username): + self._force_logout() + self.driver.get(url) + time.sleep(2) + + # Check if already logged in + try: + current_url = self.driver.current_url + print(f"[UnitedDH Claim login] Current URL: {current_url}") + if "app.dentalhub.com" in current_url and "login" not in current_url.lower(): + try: + WebDriverWait(self.driver, 3).until( + EC.presence_of_element_located((By.XPATH, + '//input[contains(@placeholder,"Search")] | //*[contains(@class,"dashboard")] | //nav')) + ) + print("[UnitedDH Claim login] Already logged in") + return "ALREADY_LOGGED_IN" + except TimeoutException: + pass + except Exception as e: + print(f"[UnitedDH Claim login] Error checking current state: {e}") + + self.driver.get(url) + time.sleep(3) + + current_url = self.driver.current_url + print(f"[UnitedDH Claim login] After navigation URL: {current_url}") + + if "app.dentalhub.com" in current_url and "login" not in current_url.lower(): + print("[UnitedDH Claim login] Already on dashboard") + return "ALREADY_LOGGED_IN" + + # Check for OTP input first + try: + WebDriverWait(self.driver, 3).until( + EC.presence_of_element_located((By.XPATH, + "//input[@type='tel' or contains(@placeholder,'code') or contains(@aria-label,'Verification')]")) + ) + print("[UnitedDH Claim login] OTP input found") + return "OTP_REQUIRED" + except TimeoutException: + pass + + # Click LOGIN button on dentalhub landing page + if "app.dentalhub.com" in current_url: + try: + login_btn = WebDriverWait(self.driver, 5).until( + EC.element_to_be_clickable((By.XPATH, + "//button[contains(text(),'LOGIN') or contains(text(),'Log In') or contains(text(),'Login')]")) + ) + login_btn.click() + print("[UnitedDH Claim login] Clicked LOGIN button") + time.sleep(5) + except TimeoutException: + print("[UnitedDH Claim login] No LOGIN button found, proceeding...") + + current_url = self.driver.current_url + print(f"[UnitedDH Claim login] After LOGIN click URL: {current_url}") + + # Fill Azure B2C credentials + if "b2clogin.com" in current_url or "login" in current_url.lower(): + print("[UnitedDH Claim login] On B2C login page - filling credentials") + + # Check if already on phone verification page + try: + send_code_btn = self.driver.find_element(By.XPATH, + "//button[@id='sendCode'] | //input[@id='sendCode'] | " + "//button[contains(text(),'Text Me') or contains(text(),'Send Code')]" + ) + if send_code_btn.is_displayed(): + print("[UnitedDH Claim login] Already on phone verification page - clicking 'Text Me'") + self.driver.execute_script("arguments[0].click();", send_code_btn) + time.sleep(3) + return "OTP_REQUIRED" + except Exception: + pass + + try: + email_field = WebDriverWait(self.driver, 10).until( + EC.element_to_be_clickable((By.XPATH, + "//input[@id='signInName' or @name='signInName' or @name='Email address' or @type='email']")) + ) + email_field.clear() + email_field.send_keys(self.uniteddh_username) + print(f"[UnitedDH Claim login] Entered username: {self.uniteddh_username}") + + password_field = WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located((By.XPATH, + "//input[@id='password' or @type='password']")) + ) + password_field.clear() + password_field.send_keys(self.uniteddh_password) + print("[UnitedDH Claim login] Entered password") + + signin_button = WebDriverWait(self.driver, 10).until( + EC.element_to_be_clickable((By.XPATH, + "//button[@id='next'] | //button[@type='submit' and contains(text(),'Sign')]")) + ) + signin_button.click() + print("[UnitedDH Claim login] Clicked Sign in button") + + if self.uniteddh_username: + browser_manager.save_credentials_hash(self.uniteddh_username) + + time.sleep(5) + + # Handle MFA method selection page + try: + continue_btn = self.driver.find_element(By.XPATH, "//button[contains(text(),'Continue')]") + phone_elements = self.driver.find_elements(By.XPATH, "//*[contains(text(),'Phone')]") + if continue_btn and phone_elements: + print("[UnitedDH Claim login] MFA method selection page detected") + try: + phone_radio = self.driver.find_element(By.XPATH, + "//input[@type='radio' and (contains(@value,'phone') or contains(@value,'Phone'))] | " + "//label[contains(text(),'Phone')]/preceding-sibling::input[@type='radio'] | " + "//input[@type='radio']" + ) + if phone_radio and not phone_radio.is_selected(): + phone_radio.click() + print("[UnitedDH Claim login] Selected 'Phone' radio button") + except Exception as radio_err: + print(f"[UnitedDH Claim login] Could not click Phone radio: {radio_err}") + time.sleep(1) + continue_btn.click() + print("[UnitedDH Claim login] Clicked 'Continue' on MFA selection page") + time.sleep(3) + except Exception: + pass + + # Check for "Text Me" / Send Code button (phone OTP) + try: + send_code_btn = WebDriverWait(self.driver, 5).until( + EC.element_to_be_clickable((By.XPATH, + "//button[@id='sendCode'] | //input[@id='sendCode'] | " + "//button[contains(text(),'Text Me') or contains(text(),'Send Code')]")) + ) + print("[UnitedDH Claim login] Found 'Text Me' / Send Code button") + self.driver.execute_script("arguments[0].click();", send_code_btn) + time.sleep(3) + return "OTP_REQUIRED" + except TimeoutException: + pass + + # Check if OTP input appeared + try: + WebDriverWait(self.driver, 5).until( + EC.presence_of_element_located((By.XPATH, + "//input[@type='tel' or contains(@placeholder,'code') or contains(@aria-label,'Verification')]")) + ) + print("[UnitedDH Claim login] OTP input appeared after sign-in") + return "OTP_REQUIRED" + except TimeoutException: + pass + + # Check if login succeeded + current_url = self.driver.current_url + if "app.dentalhub.com" in current_url and "login" not in current_url.lower(): + print("[UnitedDH Claim login] Login succeeded without OTP") + return "SUCCESS" + + print(f"[UnitedDH Claim login] Unexpected state - URL: {current_url}") + return "SUCCESS" + + except Exception as e: + return f"ERROR: Login failed - {e}" + + # If still on dentalhub, may already be logged in + if "app.dentalhub.com" in current_url: + return "ALREADY_LOGGED_IN" + + return "SUCCESS" + + except Exception as e: + return f"ERROR: Login exception - {e}" + + # ── Helpers ──────────────────────────────────────────────────────────────── + + def _check_for_error_dialog(self): + """Check for and dismiss common error dialogs. Returns error message string or None.""" + error_patterns = [ + ("Patient Not Found", "Patient Not Found - please check the Subscriber ID, DOB, and Payer selection"), + ("Insufficient Information", "Insufficient Information - need Subscriber ID + DOB, or First Name + Last Name + DOB"), + ("No Eligibility", "No eligibility information found for this patient"), + ("Error", None), + ] + + for pattern, default_msg in error_patterns: + try: + dialog_elem = self.driver.find_element(By.XPATH, + f"//modal-container//*[contains(text(),'{pattern}')] | " + f"//div[contains(@class,'modal')]//*[contains(text(),'{pattern}')]" + ) + if dialog_elem.is_displayed(): + try: + modal = self.driver.find_element(By.XPATH, "//modal-container | //div[contains(@class,'modal-dialog')]") + dialog_text = modal.text.strip()[:200] + except Exception: + dialog_text = dialog_elem.text.strip()[:200] + + print(f"[UnitedDH Claim step1] Error dialog detected: {dialog_text}") + + try: + dismiss_btn = self.driver.find_element(By.XPATH, + "//modal-container//button[contains(text(),'Ok') or contains(text(),'OK') or contains(text(),'Close')] | " + "//div[contains(@class,'modal')]//button[contains(text(),'Ok') or contains(text(),'OK') or contains(text(),'Close')]" + ) + dismiss_btn.click() + print("[UnitedDH Claim step1] Dismissed error dialog") + time.sleep(1) + except Exception: + try: + close_btn = self.driver.find_element(By.XPATH, "//modal-container//button[@class='close']") + close_btn.click() + except Exception: + pass + + error_msg = default_msg if default_msg else f"ERROR: {dialog_text}" + return f"ERROR: {error_msg}" + except Exception: + continue + + return None + + def _format_dob(self, dob_str): + """Convert DOB from YYYY-MM-DD to MM/DD/YYYY format.""" + if dob_str and "-" in dob_str: + dob_parts = dob_str.split("-") + if len(dob_parts) == 3: + return f"{dob_parts[1]}/{dob_parts[2]}/{dob_parts[0]}" + return dob_str + + def _get_existing_downloads(self): + """Get set of existing PDF files in download dir before clicking.""" + import glob + return set(glob.glob(os.path.join(self.download_dir, "*.pdf"))) + + def _wait_for_new_download(self, existing_files, timeout=15): + """Wait for a new PDF file to appear in the download dir.""" + import glob + for _ in range(timeout * 2): + time.sleep(0.5) + current = set(glob.glob(os.path.join(self.download_dir, "*.pdf"))) + new_files = current - existing_files + if new_files: + crdownloads = glob.glob(os.path.join(self.download_dir, "*.crdownload")) + if not crdownloads: + return list(new_files)[0] + return None + + def _hide_browser(self): + """Hide the browser window after task completion.""" + try: + try: + self.driver.get("about:blank") + time.sleep(0.5) + except Exception: + pass + try: + self.driver.minimize_window() + print("[UnitedDH Claim] Browser window minimized") + return + except Exception: + pass + try: + self.driver.set_window_position(-10000, -10000) + print("[UnitedDH Claim] Browser window moved off-screen") + return + except Exception: + pass + try: + import subprocess + subprocess.run(["xdotool", "getactivewindow", "windowminimize"], + timeout=3, capture_output=True) + print("[UnitedDH Claim] Browser minimized via xdotool") + except Exception: + pass + except Exception as e: + print(f"[UnitedDH Claim] Could not hide browser: {e}") + + def _capture_pdf(self, identifier): + """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_id = identifier if identifier 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"uniteddh_claim_{file_id}_{int(time.time())}.pdf") + with open(pdf_path, "wb") as f: + f.write(pdf_data) + return pdf_path + except Exception as e: + print(f"[UnitedDH Claim _capture_pdf] Error: {e}") + return None + + # ── Claim steps ──────────────────────────────────────────────────────────── + + def step1_search_patient(self): + """ + 1. Navigate directly to the Submit Claim page. + 2. Fill Subscriber ID, Date of Birth, Procedure Date, select Payer + (first UnitedHealthcare Massachusetts result). + 3. Click Continue. + """ + try: + print(f"[UnitedDH Claim] step1: memberId={self.memberId}, dob={self.dateOfBirth}, serviceDate={self.serviceDate}") + + # Navigate directly to the claim submission page + self.driver.get("https://app.dentalhub.com/app/claims-auths/claim") + time.sleep(3) + print(f"[UnitedDH Claim] step1 URL: {self.driver.current_url}") + + # --- Wait for claim form to load (Subscriber ID field) --- + try: + WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located((By.ID, "subscriberId_Front")) + ) + print("[UnitedDH Claim] step1: Claim form loaded (subscriberId_Front found)") + except TimeoutException: + # Try alternate field names + try: + WebDriverWait(self.driver, 5).until( + EC.presence_of_element_located((By.XPATH, + "//input[contains(@id,'subscriberId') or contains(@id,'memberId')]" + )) + ) + print("[UnitedDH Claim] step1: Claim form loaded (alternate subscriber field)") + except TimeoutException: + return "ERROR: step1 - Claim form not found after clicking Submit Claim" + + # --- Fill Subscriber ID --- + if self.memberId: + subscriber_id_selectors = [ + "//input[@id='subscriberId_Front']", + "//input[@id='subscriberId_Back' or @id='subscriberID_Back']", + "//input[@id='memberId_Back' or @id='memberid_Back']", + "//input[contains(@placeholder,'Subscriber') or contains(@placeholder,'subscriber')]", + "//input[contains(@placeholder,'Member') or contains(@placeholder,'member')]", + ] + subscriber_filled = False + for sel in subscriber_id_selectors: + try: + sid_input = self.driver.find_element(By.XPATH, sel) + if sid_input.is_displayed(): + sid_input.clear() + sid_input.send_keys(self.memberId) + print(f"[UnitedDH Claim] step1: Subscriber ID entered: {self.memberId} (field='{sid_input.get_attribute('id')}')") + subscriber_filled = True + break + except Exception: + continue + if not subscriber_filled: + print(f"[UnitedDH Claim] step1: WARNING - Could not find Subscriber ID field") + + # --- Fill Date of Birth --- + dob_formatted = self._format_dob(self.dateOfBirth) + try: + dob_input = WebDriverWait(self.driver, 5).until( + EC.presence_of_element_located((By.XPATH, + "//input[@id='dateOfBirth_Back' or @id='dateOfBirth_Front']" + " | //input[contains(@placeholder,'Date of Birth') or contains(@placeholder,'DOB')]" + )) + ) + dob_input.clear() + dob_input.send_keys(dob_formatted) + print(f"[UnitedDH Claim] step1: DOB entered: {dob_formatted}") + except Exception as e: + print(f"[UnitedDH Claim] step1: Error entering DOB: {e}") + return "ERROR: step1 - Could not enter Date of Birth" + + # --- Fill Procedure Date (portal pre-fills today's date — must overwrite) --- + procedure_date_formatted = self._format_dob(self.serviceDate) # YYYY-MM-DD -> MM/DD/YYYY + try: + proc_date_input = WebDriverWait(self.driver, 5).until( + EC.element_to_be_clickable((By.XPATH, + "//input[@id='procedureDate_Back' or @id='procedureDate_Front']" + " | //input[contains(@placeholder,'Procedure Date') or contains(@placeholder,'Service Date')]" + )) + ) + proc_date_input.click() + proc_date_input.send_keys(Keys.CONTROL + "a") + proc_date_input.send_keys(Keys.DELETE) + proc_date_input.send_keys(procedure_date_formatted) + print(f"[UnitedDH Claim] step1: Procedure Date entered: {procedure_date_formatted}") + except Exception as e: + print(f"[UnitedDH Claim] step1: Error entering Procedure Date: {e}") + + time.sleep(1) + + # --- Select Payer: first UnitedHealthcare Massachusetts result --- + print("[UnitedDH Claim] step1: Selecting Payer...") + payer_selected = False + try: + payer_selectors = [ + "//label[contains(text(),'Payer')]/following-sibling::ng-select", + "//label[contains(text(),'Payer')]/..//ng-select", + "//ng-select[contains(@placeholder,'Payer') or contains(@placeholder,'payer')]", + "//ng-select[.//input[contains(@placeholder,'Search by Payers') or contains(@placeholder,'Payer')]]", + ] + payer_ng_select = None + for sel in payer_selectors: + try: + elem = self.driver.find_element(By.XPATH, sel) + if elem.is_displayed(): + payer_ng_select = elem + break + except Exception: + continue + + if payer_ng_select: + self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", payer_ng_select) + time.sleep(0.5) + payer_ng_select.click() + time.sleep(1) + + try: + search_input = payer_ng_select.find_element(By.XPATH, + ".//input[@type='text' or @role='combobox']") + search_input.clear() + search_input.send_keys("UnitedHealthcare Massachusetts") + print("[UnitedDH Claim] step1: Typed payer search text") + time.sleep(2) + except Exception: + try: + from selenium.webdriver.common.action_chains import ActionChains + ActionChains(self.driver).send_keys("UnitedHealthcare Mass").perform() + time.sleep(2) + except Exception: + pass + + # Pick the first visible matching option + payer_options = self.driver.find_elements(By.XPATH, + "//ng-dropdown-panel//div[contains(@class,'ng-option')]") + for opt in payer_options: + if opt.is_displayed(): + opt_text = opt.text.strip() + if "UnitedHealthcare" in opt_text and "Massachusetts" in opt_text: + opt.click() + print(f"[UnitedDH Claim] step1: Selected Payer: {opt_text}") + payer_selected = True + break + if not payer_selected: + # Fallback: first visible option at all + for opt in payer_options: + if opt.is_displayed(): + opt.click() + print(f"[UnitedDH Claim] step1: Selected first visible Payer: {opt.text.strip()}") + payer_selected = True + break + + try: + from selenium.webdriver.common.action_chains import ActionChains + ActionChains(self.driver).send_keys(Keys.ESCAPE).perform() + except Exception: + pass + time.sleep(0.5) + else: + print("[UnitedDH Claim] step1: Could not find Payer ng-select element") + except Exception as e: + print(f"[UnitedDH Claim] step1: Payer selection error: {e}") + + if not payer_selected: + print("[UnitedDH Claim] step1: WARNING - Could not select Payer") + + time.sleep(1) + + # --- Click Continue --- + try: + continue_btn = WebDriverWait(self.driver, 10).until( + EC.element_to_be_clickable((By.XPATH, "//button[contains(text(),'Continue')]")) + ) + continue_btn.click() + print("[UnitedDH Claim] step1: Clicked Continue") + time.sleep(4) + + error_result = self._check_for_error_dialog() + if error_result: + return error_result + except Exception as e: + return f"ERROR: step1 - Could not click Continue: {e}" + + print("[UnitedDH Claim] step1: Patient search completed") + return "OK" + + except Exception as e: + return f"ERROR: step1_search_patient - {e}" + + def step2_open_member_page(self): + """ + After step1 clicks Continue, the portal shows a "Select Insurance" popup. + Click Ok on it, then wait for the patient info / office location page. + """ + try: + print("[UnitedDH Claim] step2: waiting for 'Select Insurance' popup") + time.sleep(2) + + # Click the Ok button on the "Select Insurance" modal + try: + ok_btn = WebDriverWait(self.driver, 10).until( + EC.element_to_be_clickable((By.XPATH, + "//button[@type='button' and contains(@class,'btn-primary') and " + "(normalize-space(text())='Ok' or normalize-space(text())='OK')]" + )) + ) + ok_btn.click() + print("[UnitedDH Claim] step2: Clicked Ok on Select Insurance popup") + time.sleep(2) + except TimeoutException: + print("[UnitedDH Claim] step2: Select Insurance popup not found — proceeding") + + # Wait for patient info / office location page (Continue button visible) + try: + WebDriverWait(self.driver, 15).until( + EC.element_to_be_clickable((By.XPATH, + "//button[contains(@class,'btn-primary') and contains(normalize-space(text()),'Continue')]" + )) + ) + print("[UnitedDH Claim] step2: Patient info page loaded (Continue button found)") + except TimeoutException: + print("[UnitedDH Claim] step2: Continue button not found — proceeding anyway") + + print(f"[UnitedDH Claim] step2 URL: {self.driver.current_url}") + return "OK" + + except Exception as e: + return f"ERROR: step2_open_member_page - {e}" + + def step3_click_create_claim(self): + """ + Click Continue on the Practitioner & Location page. + The location is auto-filled by Angular asynchronously — wait for it to be + populated before clicking Continue, otherwise the field may submit blank. + """ + try: + print("[UnitedDH Claim] step3: waiting for Practitioner & Location page") + + # Wait for the Continue button to appear (page has loaded) + continue_btn = WebDriverWait(self.driver, 15).until( + EC.element_to_be_clickable((By.XPATH, + "//button[contains(@class,'btn-primary') and contains(normalize-space(text()),'Continue')]" + )) + ) + + # Explicit wait: hold until Angular has auto-filled the location dropdown + try: + WebDriverWait(self.driver, 10).until( + lambda d: d.find_elements(By.XPATH, + "//ng-select//span[contains(@class,'ng-value-label') and normalize-space(text())!=''] | " + "//ng-select//div[contains(@class,'ng-value') and normalize-space(.)!='']" + ) + ) + print("[UnitedDH Claim] step3: Location auto-filled") + except TimeoutException: + print("[UnitedDH Claim] step3: Location field did not populate in time — proceeding anyway") + + self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", continue_btn) + continue_btn.click() + print("[UnitedDH Claim] step3: Clicked Continue — waiting for Code Entry page") + time.sleep(3) + + # Confirm we're on the Code Entry page + try: + WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located((By.ID, "procedureCode")) + ) + print("[UnitedDH Claim] step3: Code Entry page loaded (procedureCode found)") + except TimeoutException: + print("[UnitedDH Claim] step3: procedureCode input not found — proceeding anyway") + + print(f"[UnitedDH Claim] step3 URL: {self.driver.current_url}") + return "OK" + + except Exception as e: + return f"ERROR: step3_click_create_claim - {e}" + + def step4_fill_claim_form(self): + """ + For each service line with a procedure code: + 1. (For lines after the first: click btnAddItem to start a new row) + 2. Type CDT code into id="procedureCode" + 3. Click id="btnAddItem" — billed amount input appears + 4. Clear id="billedAmount" and enter the billed amount + 5. Click the Add button to confirm the row + """ + try: + active_lines = [ + ln for ln in self.serviceLines + if str(ln.get("procedureCode") or "").strip() + ] + print(f"[UnitedDH Claim] step4: {len(active_lines)} service line(s)") + + if not active_lines: + print("[UnitedDH Claim] step4: No service lines — skipping") + return "OK" + + for idx, line in enumerate(active_lines): + code = str(line.get("procedureCode") or "").strip().upper() + billed = str( + line.get("totalBilled") or + line.get("billedAmount") or + line.get("fee") or "" + ).strip() + print(f"[UnitedDH Claim] step4: line {idx}: code={code}, billed={billed}") + + # For lines after the first, click btnAddItem to open a new procedure row + if idx > 0: + try: + add_btn = WebDriverWait(self.driver, 8).until( + EC.element_to_be_clickable((By.ID, "btnAddItem")) + ) + self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", add_btn) + add_btn.click() + print(f"[UnitedDH Claim] step4: clicked btnAddItem to start row {idx}") + time.sleep(1) + except Exception as e: + print(f"[UnitedDH Claim] step4: could not click btnAddItem for row {idx}: {e}") + + # Type CDT code in procedureCode input + try: + proc_input = WebDriverWait(self.driver, 8).until( + EC.element_to_be_clickable((By.ID, "procedureCode")) + ) + self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", proc_input) + proc_input.click() + proc_input.send_keys(Keys.CONTROL + "a") + proc_input.send_keys(Keys.DELETE) + proc_input.send_keys(code) + print(f"[UnitedDH Claim] step4: typed procedure code: {code}") + time.sleep(0.5) + except Exception as e: + print(f"[UnitedDH Claim] step4: could not type procedure code for row {idx}: {e}") + continue + + # Click btnAddItem to confirm code and reveal billed amount input + try: + add_btn = WebDriverWait(self.driver, 8).until( + EC.element_to_be_clickable((By.ID, "btnAddItem")) + ) + self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", add_btn) + add_btn.click() + print(f"[UnitedDH Claim] step4: clicked btnAddItem to reveal billedAmount for row {idx}") + time.sleep(1.5) + except Exception as e: + print(f"[UnitedDH Claim] step4: could not click btnAddItem for billed amount row {idx}: {e}") + continue + + # Fill billed amount + if billed: + try: + billed_input = WebDriverWait(self.driver, 8).until( + EC.element_to_be_clickable((By.ID, "billedAmount")) + ) + self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", billed_input) + billed_input.click() + billed_input.send_keys(Keys.CONTROL + "a") + billed_input.send_keys(Keys.DELETE) + billed_input.send_keys(billed) + print(f"[UnitedDH Claim] step4: entered billed amount: {billed}") + time.sleep(0.5) + except Exception as e: + print(f"[UnitedDH Claim] step4: could not fill billed amount for row {idx}: {e}") + + # Click the span "Add" button to confirm the row + try: + span_add = WebDriverWait(self.driver, 8).until( + EC.element_to_be_clickable((By.XPATH, + "//span[contains(@class,'ng-star-inserted') and normalize-space(text())='Add'] | " + "//button[normalize-space(text())='Add' and not(@id='btnAddItem')] | " + "//span[normalize-space(text())='Add']" + )) + ) + self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", span_add) + span_add.click() + print(f"[UnitedDH Claim] step4: clicked span Add — row {idx} confirmed") + time.sleep(1) + except Exception as e: + print(f"[UnitedDH Claim] step4: could not click span Add for row {idx}: {e}") + + # --- Other coverage section: click "No" (second radio button) --- + try: + print("[UnitedDH Claim] step4: selecting 'No' for Other coverage") + # The "No" option is the second radio button on the page + radio_buttons = WebDriverWait(self.driver, 8).until( + lambda d: d.find_elements(By.XPATH, "//input[@type='radio']") + ) + if len(radio_buttons) >= 2: + no_radio = radio_buttons[1] + self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", no_radio) + no_radio.click() + print("[UnitedDH Claim] step4: Clicked 'No' (2nd radio) for Other coverage") + else: + print(f"[UnitedDH Claim] step4: Only {len(radio_buttons)} radio button(s) found — skipping") + time.sleep(0.5) + except Exception as e: + print(f"[UnitedDH Claim] step4: Could not click 'No' for Other coverage (non-fatal): {e}") + + print("[UnitedDH Claim] step4: Done filling claim form") + return "OK" + + except Exception as e: + return f"ERROR: step4_fill_claim_form - {e}" + + def step5_attach_files(self): + """ + If there are claim files: + 1. Click the fa-caret-up dropdown icon to reveal the Add Document button + 2. Click id="upload-document" + 3. Send the absolute file path to the file input + """ + try: + if not self.claimFiles: + print("[UnitedDH Claim] step5: No files to attach") + return "OK" + + # Open the Attached Documents section by clicking the caret-up icon + try: + caret = WebDriverWait(self.driver, 8).until( + EC.element_to_be_clickable((By.XPATH, + "//em[contains(@class,'fa-caret-up')] | " + "//i[contains(@class,'fa-caret-up')] | " + "//*[contains(@class,'fa') and contains(@class,'fa-caret-up')]" + )) + ) + self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", caret) + caret.click() + print("[UnitedDH Claim] step5: Clicked caret-up to expand Attached Documents") + time.sleep(1) + except Exception as e: + print(f"[UnitedDH Claim] step5: Could not click caret (section may already be open): {e}") + + attached = 0 + for cf in self.claimFiles: + relative_path = cf.get("filePath") or "" + if not relative_path: + print(f"[UnitedDH Claim] step5: Skipping file with no filePath: {cf}") + continue + + abs_path = os.path.normpath(os.path.join(_BACKEND_CWD, relative_path.lstrip("/"))) + if not os.path.isfile(abs_path): + print(f"[UnitedDH Claim] step5: File not found on disk: {abs_path}") + continue + + print(f"[UnitedDH Claim] step5: Attaching: {abs_path}") + try: + # Click the Add Document button + upload_btn = WebDriverWait(self.driver, 10).until( + EC.element_to_be_clickable((By.ID, "upload-document")) + ) + self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", upload_btn) + upload_btn.click() + time.sleep(1) + + # Find the file input (may be hidden) and send the path + file_input = WebDriverWait(self.driver, 8).until( + EC.presence_of_element_located((By.XPATH, "//input[@type='file']")) + ) + self.driver.execute_script("arguments[0].style.display='block';", file_input) + file_input.send_keys(abs_path) + time.sleep(1.5) + print(f"[UnitedDH Claim] step5: Attached: {os.path.basename(abs_path)}") + attached += 1 + except Exception as e: + print(f"[UnitedDH Claim] step5: Could not attach {abs_path}: {e}") + + print(f"[UnitedDH Claim] step5: Attached {attached}/{len(self.claimFiles)} file(s)") + return "OK" + + except Exception as e: + return f"ERROR: step5_attach_files - {e}" + + def step6_click_next(self): + """No separate Next step on DentalHub claim form — submission goes directly from Code Entry.""" + print("[UnitedDH Claim] step6: no-op (DentalHub has no Next step before Submit)") + return "OK" + + def step7_submit_claim(self): + """ + Click Submit Claim, then handle the post-submit popup: + - Top button: "Submit another claim" + - Bottom button: "View Status and History" ← click this one + """ + try: + print(f"[UnitedDH Claim] step7: submitting claim — URL: {self.driver.current_url}") + + submit_btn = WebDriverWait(self.driver, 15).until( + EC.element_to_be_clickable((By.XPATH, + "//button[contains(@class,'btn-primary') and contains(normalize-space(text()),'Submit Claim')] | " + "//button[normalize-space(text())='Submit Claim'] | " + "//button[contains(normalize-space(.),'Submit Claim')]" + )) + ) + self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", submit_btn) + time.sleep(0.5) + submit_btn.click() + print("[UnitedDH Claim] step7: Clicked Submit Claim — waiting for post-submit popup") + time.sleep(3) + + # Click "View Status and History" (the bottom button in the popup) + try: + view_btn = WebDriverWait(self.driver, 15).until( + EC.element_to_be_clickable((By.XPATH, + "//button[contains(normalize-space(.),'View Status and History')] | " + "//a[contains(normalize-space(.),'View Status and History')]" + )) + ) + view_btn.click() + print("[UnitedDH Claim] step7: Clicked 'View Status and History'") + time.sleep(3) + except TimeoutException: + print("[UnitedDH Claim] step7: Post-submit popup not found — proceeding to step8") + + print(f"[UnitedDH Claim] step7: URL after popup: {self.driver.current_url}") + return "OK" + + except Exception as e: + return f"ERROR: step7_submit_claim - {e}" + + def step8_save_confirmation_pdf(self): + """ + On the Status & History page, read the claim number from the first row + (Reference Number column), then save the page as PDF. + """ + import re + try: + print("[UnitedDH Claim] step8: waiting for Status & History page") + + # Wait for Status & History page to load + WebDriverWait(self.driver, 20).until( + lambda d: "status" in d.current_url.lower() or "history" in d.current_url.lower() + or d.find_elements(By.XPATH, "//td | //th[contains(text(),'Reference')]") + ) + time.sleep(2) + print(f"[UnitedDH Claim] step8: Status & History URL: {self.driver.current_url}") + + # Refresh so the just-submitted claim appears at the top + self.driver.refresh() + print("[UnitedDH Claim] step8: Page refreshed — waiting for table to reload") + WebDriverWait(self.driver, 15).until( + EC.presence_of_element_located((By.XPATH, "//table//tr[td]")) + ) + time.sleep(2) + + # Extract claim number from the first data row, Reference Number column + claim_number = None + try: + # The first in the first data row that looks like a 14-digit reference number + first_ref = WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located((By.XPATH, + "(//table//tr[not(th)]/td[2] | " + "//table//tr[td]/td[contains(normalize-space(.),'2026') or " + " contains(normalize-space(.),'2025')])[1]" + )) + ) + ref_text = first_ref.text.strip() + # Reference numbers are 14-digit integers like 20260524181895 + match = re.search(r'\b(\d{14})\b', ref_text) + if match: + claim_number = match.group(1) + else: + # Fallback: any 10+ digit number in the cell + match = re.search(r'\b(\d{10,})\b', ref_text) + if match: + claim_number = match.group(1) + print(f"[UnitedDH Claim] step8: Claim number from first row: {claim_number!r} (cell text: {ref_text!r})") + except Exception as e: + print(f"[UnitedDH Claim] step8: Could not read first-row reference number: {e}") + # Last-resort: scan all visible text for a 14-digit number + try: + body_text = self.driver.find_element(By.TAG_NAME, "body").text + match = re.search(r'\b(\d{14})\b', body_text) + if match: + claim_number = match.group(1) + print(f"[UnitedDH Claim] step8: Claim number (body scan): {claim_number}") + except Exception: + pass + + # Save page as PDF + shared_downloads = os.path.join(_SERVICE_DIR, "downloads") + os.makedirs(shared_downloads, exist_ok=True) + safe_member = "".join(c for c in str(self.memberId) if c.isalnum() or c in "-_.") + safe_claim = ("_" + claim_number[:20]) if claim_number else "" + timestamp = time.strftime("%Y%m%d_%H%M%S") + pdf_filename = f"uniteddh_claim_confirmation_{safe_member}{safe_claim}_{timestamp}.pdf" + pdf_path = os.path.join(shared_downloads, pdf_filename) + + try: + pdf_data = self.driver.execute_cdp_cmd("Page.printToPDF", { + "printBackground": True, + "paperWidth": 8.5, + "paperHeight": 11, + "marginTop": 0.4, + "marginBottom": 0.4, + "marginLeft": 0.4, + "marginRight": 0.4, + }) + pdf_bytes = base64.b64decode(pdf_data["data"]) + with open(pdf_path, "wb") as f: + f.write(pdf_bytes) + print(f"[UnitedDH Claim] step8: PDF saved: {pdf_path}") + except Exception as e: + print(f"[UnitedDH Claim] step8: PDF capture failed: {e}") + return f"ERROR: step8 PDF failed: {e}" + + return { + "status": "success", + "pdf_path": pdf_path, + "claimNumber": claim_number, + } + + except Exception as e: + return f"ERROR: step8_save_confirmation_pdf - {e}" diff --git a/apps/SeleniumService/unitedsco_browser_manager.py b/apps/SeleniumService/unitedsco_browser_manager.py index 692e3149..1bfcccaa 100644 --- a/apps/SeleniumService/unitedsco_browser_manager.py +++ b/apps/SeleniumService/unitedsco_browser_manager.py @@ -43,100 +43,33 @@ class UnitedSCOBrowserManager: def clear_session_on_startup(self): """ - Clear session cookies from Chrome profile on startup. - This forces a fresh login after PC restart. - Preserves device trust tokens (LocalStorage, IndexedDB) to avoid OTPs. + On startup, only clear the Cookies file so the login session is reset + but device trust tokens (Local Storage, IndexedDB) are preserved. + Preserving those lets Azure B2C recognise the device and skip OTP. """ - print("[UnitedSCO BrowserManager] Clearing session on startup...") - + print("[UnitedSCO BrowserManager] Clearing cookies on startup (preserving device trust)...") + try: - # Clear the credentials tracking file + # Clear credentials tracking so the next login re-saves the hash if os.path.exists(self._credentials_file): os.remove(self._credentials_file) print("[UnitedSCO BrowserManager] Cleared credentials tracking file") - - # Clear session-related files from Chrome profile - # These are the files that store login session cookies - session_files = [ - "Cookies", - "Cookies-journal", - "Login Data", - "Login Data-journal", - "Web Data", - "Web Data-journal", - ] - - for filename in session_files: - filepath = os.path.join(self.profile_dir, "Default", filename) - if os.path.exists(filepath): - try: - os.remove(filepath) - print(f"[UnitedSCO BrowserManager] Removed {filename}") - except Exception as e: - print(f"[UnitedSCO BrowserManager] Could not remove {filename}: {e}") - - # Also try root level (some Chrome versions) - for filename in session_files: - filepath = os.path.join(self.profile_dir, filename) - if os.path.exists(filepath): - try: - os.remove(filepath) - print(f"[UnitedSCO BrowserManager] Removed root {filename}") - except Exception as e: - print(f"[UnitedSCO BrowserManager] Could not remove root {filename}: {e}") - - # Clear Session Storage (contains login state) - session_storage_dir = os.path.join(self.profile_dir, "Default", "Session Storage") - if os.path.exists(session_storage_dir): - try: - shutil.rmtree(session_storage_dir) - print("[UnitedSCO BrowserManager] Cleared Session Storage") - except Exception as e: - print(f"[UnitedSCO BrowserManager] Could not clear Session Storage: {e}") - - # Clear Local Storage (may contain auth tokens) - local_storage_dir = os.path.join(self.profile_dir, "Default", "Local Storage") - if os.path.exists(local_storage_dir): - try: - shutil.rmtree(local_storage_dir) - print("[UnitedSCO BrowserManager] Cleared Local Storage") - except Exception as e: - print(f"[UnitedSCO BrowserManager] Could not clear Local Storage: {e}") - - # Clear IndexedDB (may contain auth tokens) - indexeddb_dir = os.path.join(self.profile_dir, "Default", "IndexedDB") - if os.path.exists(indexeddb_dir): - try: - shutil.rmtree(indexeddb_dir) - print("[UnitedSCO BrowserManager] Cleared IndexedDB") - except Exception as e: - print(f"[UnitedSCO BrowserManager] Could not clear IndexedDB: {e}") - - # Clear browser cache (prevents corrupted cached responses) - cache_dirs = [ - os.path.join(self.profile_dir, "Default", "Cache"), - os.path.join(self.profile_dir, "Default", "Code Cache"), - os.path.join(self.profile_dir, "Default", "GPUCache"), - os.path.join(self.profile_dir, "Default", "Service Worker"), - os.path.join(self.profile_dir, "Cache"), - os.path.join(self.profile_dir, "Code Cache"), - os.path.join(self.profile_dir, "GPUCache"), - os.path.join(self.profile_dir, "Service Worker"), - os.path.join(self.profile_dir, "ShaderCache"), - ] - for cache_dir in cache_dirs: - if os.path.exists(cache_dir): - try: - shutil.rmtree(cache_dir) - print(f"[UnitedSCO BrowserManager] Cleared {os.path.basename(cache_dir)}") - except Exception as e: - print(f"[UnitedSCO BrowserManager] Could not clear {os.path.basename(cache_dir)}: {e}") - - # Set flag to clear session via JavaScript after browser opens - self._needs_session_clear = True - - print("[UnitedSCO BrowserManager] Session cleared - will require fresh login") - + + # 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"[UnitedSCO BrowserManager] Removed {filepath}") + except Exception as e: + print(f"[UnitedSCO BrowserManager] Could not remove {filepath}: {e}") + + self._needs_session_clear = False + print("[UnitedSCO BrowserManager] Cookies cleared — device trust tokens preserved") + except Exception as e: print(f"[UnitedSCO BrowserManager] Error clearing session: {e}")