diff --git a/apps/Backend/src/queue/jobRunner.ts b/apps/Backend/src/queue/jobRunner.ts index 94a502b9..98016a69 100644 --- a/apps/Backend/src/queue/jobRunner.ts +++ b/apps/Backend/src/queue/jobRunner.ts @@ -20,6 +20,8 @@ import { runCCAClaimProcessor } from "./processors/ccaClaimProcessor"; import { runCCAPreAuthProcessor } from "./processors/ccaPreAuthProcessor"; import { runDDMAClaimProcessor } from "./processors/ddmaClaimProcessor"; import { runUnitedDHClaimProcessor } from "./processors/unitedDHClaimProcessor"; +import { runUnitedDHPreAuthProcessor } from "./processors/unitedDHPreAuthProcessor"; +import { runTuftsSCOPreAuthProcessor } from "./processors/tuftsSCOPreAuthProcessor"; import { runTuftsSCOClaimProcessor } from "./processors/tuftsSCOClaimProcessor"; import { runEligibilityHistoryProcessor } from "./processors/eligibilityHistoryProcessor"; import { runCmspEligibilityHistoryRemainingProcessor } from "./processors/cmspEligibilityHistoryRemainingProcessor"; @@ -182,6 +184,28 @@ export function enqueueSeleniumJob(data: SeleniumJobData): string { job.id ); } + if (jobType === "uniteddh-preauth-submit") { + return runUnitedDHPreAuthProcessor( + { + enrichedPayload: data.enrichedPayload, + userId: data.userId, + claimId: data.claimId, + socketId: data.socketId, + }, + job.id + ); + } + if (jobType === "tuftssco-preauth-submit") { + return runTuftsSCOPreAuthProcessor( + { + enrichedPayload: data.enrichedPayload, + userId: data.userId, + claimId: data.claimId, + socketId: data.socketId, + }, + job.id + ); + } if (jobType === "tuftssco-claim-submit") { return runTuftsSCOClaimProcessor( { diff --git a/apps/Backend/src/queue/processors/tuftsSCOPreAuthProcessor.ts b/apps/Backend/src/queue/processors/tuftsSCOPreAuthProcessor.ts new file mode 100644 index 00000000..08eae67b --- /dev/null +++ b/apps/Backend/src/queue/processors/tuftsSCOPreAuthProcessor.ts @@ -0,0 +1,178 @@ +import { + forwardToSeleniumTuftsSCOPreAuthAgent, + getSeleniumTuftsSCOPreAuthSessionStatus, +} from "../../services/seleniumTuftsSCOPreAuthClient"; +import { io } from "../../socket"; +import { storage } from "../../storage"; +import axios from "axios"; +import path from "path"; + +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; + 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(`Tufts SCO preauth polling timeout for session ${sessionId}`); + } + try { + const st = await getSeleniumTuftsSCOPreAuthSessionStatus(sessionId); + const status: string = st?.status ?? "unknown"; + log("tuftssco-preauth-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 OTP sent by the DentaQuest 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 || `Tufts SCO preauth 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 Tufts SCO preauth session ${sessionId}`); + } + const backoff = Math.min(30_000, 500 * Math.pow(2, transientErrors - 1)); + await new Promise((r) => setTimeout(r, backoff)); + } + } + throw new Error(`Tufts SCO preauth polling exhausted all attempts for session ${sessionId}`); +} + +async function savePdfFromSelenium(pdf_path: string, patientId: number) { + try { + const filename = path.basename(pdf_path); + const seleniumPort = process.env.SELENIUM_PORT || "5002"; + const localUrl = `http://localhost:${seleniumPort}/downloads/${filename}`; + const resp = await axios.get(localUrl, { responseType: "arraybuffer", timeout: 30000 }); + + let group = await storage.findPdfGroupByPatientTitleKey(patientId, "INSURANCE_CLAIM_PREAUTH"); + if (!group) { + group = await storage.createPdfGroup(patientId, "PreAuth", "INSURANCE_CLAIM_PREAUTH"); + } + await storage.createPdfFile(group.id!, filename, resp.data); + log("tuftssco-preauth-processor", "PDF saved", { patientId, filename }); + } catch (err: any) { + log("tuftssco-preauth-processor", "failed to save PDF (non-fatal)", { error: err?.message ?? err }); + } +} + +export interface TuftsSCOPreAuthProcessorInput { + enrichedPayload: any; + userId: number; + claimId?: number; + socketId?: string; +} + +export async function runTuftsSCOPreAuthProcessor( + input: TuftsSCOPreAuthProcessorInput, + jobId: string +): Promise<{ status: string; pdf_path?: string; preAuthNumber?: string }> { + const { enrichedPayload, userId, claimId, socketId } = input; + + log("tuftssco-preauth-processor", "starting Python agent session", { claimId }); + const agentResp = await forwardToSeleniumTuftsSCOPreAuthAgent(enrichedPayload); + + if (!agentResp?.session_id) { + throw new Error("Python agent did not return a session_id for Tufts SCO preauth"); + } + + const sessionId = agentResp.session_id as string; + log("tuftssco-preauth-processor", "got session_id", { sessionId }); + + emitToSocket(socketId, "selenium:tuftssco_preauth_started", { session_id: sessionId, jobId }); + + const seleniumResult = await pollUntilDone(sessionId, socketId, jobId); + + if (!seleniumResult || seleniumResult.status === "error") { + throw new Error(seleniumResult?.message ?? "Tufts SCO preauth session returned an error"); + } + + const preAuthNumber: string | undefined = seleniumResult.preAuthNumber ?? undefined; + const pdf_path: string | undefined = seleniumResult.pdf_path ?? undefined; + + if (claimId) { + try { + const updates: Record = { status: "PREAUTH" }; + if (preAuthNumber) updates.claimNumber = preAuthNumber; + await storage.updateClaim(claimId, updates); + log("tuftssco-preauth-processor", "claim record updated", { claimId, preAuthNumber }); + + const claim = await storage.getClaim(claimId); + if (claim?.patientId) { + await storage.touchPatient(claim.patientId); + log("tuftssco-preauth-processor", "patient touched", { patientId: claim.patientId }); + } + } catch (e) { + log("tuftssco-preauth-processor", "failed to update claim record (non-fatal)", { error: e }); + } + } + + if (pdf_path && !socketId) { + const claim = claimId ? await storage.getClaim(claimId).catch(() => null) : null; + const patientId = claim?.patientId ?? enrichedPayload?.claim?.patientId ?? enrichedPayload?.patientId; + if (patientId) await savePdfFromSelenium(pdf_path, Number(patientId)); + } + + emitToSocket(socketId, "selenium:tuftssco_preauth_completed", { + jobId, + claimId, + preAuthNumber, + pdf_path, + message: preAuthNumber + ? `Tufts SCO pre-authorization submitted — PreAuth #: ${preAuthNumber}` + : (seleniumResult?.message ?? "Tufts SCO pre-authorization submitted successfully"), + }); + + log("tuftssco-preauth-processor", "done", { claimId, preAuthNumber }); + return { status: "success", pdf_path, preAuthNumber }; +} diff --git a/apps/Backend/src/queue/processors/unitedDHPreAuthProcessor.ts b/apps/Backend/src/queue/processors/unitedDHPreAuthProcessor.ts new file mode 100644 index 00000000..d16efe2a --- /dev/null +++ b/apps/Backend/src/queue/processors/unitedDHPreAuthProcessor.ts @@ -0,0 +1,189 @@ +/** + * Processor for "uniteddh-preauth-submit" jobs. + * Submits a dental pre-authorization to the United/DentalHub portal via Selenium. + * + * Flow: + * 1. POST /uniteddh-preauth to Python agent → get session_id + * 2. Emit selenium:uniteddh_preauth_started to frontend + * 3. Poll until completed/error (emitting otp_required as needed) + * 4. Save PDF + preAuthNumber, emit result + */ +import { + forwardToSeleniumUnitedDHPreAuthAgent, + getSeleniumUnitedDHPreAuthSessionStatus, +} from "../../services/seleniumUnitedDHPreAuthClient"; +import { io } from "../../socket"; +import { storage } from "../../storage"; +import axios from "axios"; +import path from "path"; + +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; + 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 preauth polling timeout for session ${sessionId}`); + } + try { + const st = await getSeleniumUnitedDHPreAuthSessionStatus(sessionId); + const status: string = st?.status ?? "unknown"; + log("uniteddh-preauth-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 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 preauth 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 preauth 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 preauth polling exhausted all attempts for session ${sessionId}`); +} + +async function savePdfFromSelenium(pdf_url: string, patientId: number) { + try { + const filename = path.basename(new URL(pdf_url).pathname); + const seleniumPort = process.env.SELENIUM_PORT || "5002"; + const localUrl = `http://localhost:${seleniumPort}/downloads/${filename}`; + const resp = await axios.get(localUrl, { responseType: "arraybuffer", timeout: 30000 }); + + let group = await storage.findPdfGroupByPatientTitleKey(patientId, "INSURANCE_CLAIM_PREAUTH"); + if (!group) { + group = await storage.createPdfGroup(patientId, "PreAuth", "INSURANCE_CLAIM_PREAUTH"); + } + await storage.createPdfFile(group.id!, filename, resp.data); + log("uniteddh-preauth-processor", "PDF saved", { patientId, filename }); + } catch (err: any) { + log("uniteddh-preauth-processor", "failed to save PDF (non-fatal)", { error: err?.message ?? err }); + } +} + +export interface UnitedDHPreAuthProcessorInput { + enrichedPayload: any; + userId: number; + claimId?: number; + socketId?: string; +} + +export async function runUnitedDHPreAuthProcessor( + input: UnitedDHPreAuthProcessorInput, + jobId: string +): Promise<{ status: string; pdf_url?: string; preAuthNumber?: string }> { + const { enrichedPayload, userId, claimId, socketId } = input; + + log("uniteddh-preauth-processor", "starting Python agent session", { claimId }); + const agentResp = await forwardToSeleniumUnitedDHPreAuthAgent(enrichedPayload); + + if (!agentResp?.session_id) { + throw new Error("Python agent did not return a session_id for UnitedDH preauth"); + } + + const sessionId = agentResp.session_id as string; + log("uniteddh-preauth-processor", "got session_id", { sessionId }); + + emitToSocket(socketId, "selenium:uniteddh_preauth_started", { session_id: sessionId, jobId }); + + const seleniumResult = await pollUntilDone(sessionId, socketId, jobId); + + if (!seleniumResult || seleniumResult.status === "error") { + throw new Error(seleniumResult?.message ?? "UnitedDH preauth session returned an error"); + } + + const preAuthNumber: string | undefined = seleniumResult.preAuthNumber ?? undefined; + const pdf_url: string | undefined = seleniumResult.pdf_url ?? undefined; + + if (claimId) { + try { + const updates: Record = { status: "PREAUTH" }; + if (preAuthNumber) updates.claimNumber = preAuthNumber; + await storage.updateClaim(claimId, updates); + log("uniteddh-preauth-processor", "claim record updated", { claimId, preAuthNumber }); + + const claim = await storage.getClaim(claimId); + if (claim?.patientId) { + await storage.touchPatient(claim.patientId); + log("uniteddh-preauth-processor", "patient touched", { patientId: claim.patientId }); + } + } catch (e) { + log("uniteddh-preauth-processor", "failed to update claim record (non-fatal)", { error: e }); + } + } + + // Auto-save PDF when called without a frontend socket listener + if (pdf_url && !socketId) { + const claim = claimId ? await storage.getClaim(claimId).catch(() => null) : null; + const patientId = claim?.patientId ?? enrichedPayload?.claim?.patientId ?? enrichedPayload?.patientId; + if (patientId) await savePdfFromSelenium(pdf_url, Number(patientId)); + } + + emitToSocket(socketId, "selenium:uniteddh_preauth_completed", { + jobId, + claimId, + preAuthNumber, + pdf_url, + message: preAuthNumber + ? `United/DentalHub pre-authorization submitted — PreAuth #: ${preAuthNumber}` + : (seleniumResult?.message ?? "United/DentalHub pre-authorization submitted successfully"), + }); + + log("uniteddh-preauth-processor", "done", { claimId, preAuthNumber }); + return { status: "success", pdf_url, preAuthNumber }; +} diff --git a/apps/Backend/src/queue/queues.ts b/apps/Backend/src/queue/queues.ts index 26aa8e8d..9e1f64d8 100644 --- a/apps/Backend/src/queue/queues.ts +++ b/apps/Backend/src/queue/queues.ts @@ -16,6 +16,8 @@ export type SeleniumJobType = | "ddma-claim-submit" | "tuftssco-claim-submit" | "uniteddh-claim-submit" + | "uniteddh-preauth-submit" + | "tuftssco-preauth-submit" | "tuftssco-eligibility-check" | "mh-eligibility-history-check" | "cmsp-eligibility-history-remaining-check" diff --git a/apps/Backend/src/routes/index.ts b/apps/Backend/src/routes/index.ts index 1e0549b6..5eb94597 100755 --- a/apps/Backend/src/routes/index.ts +++ b/apps/Backend/src/routes/index.ts @@ -20,7 +20,9 @@ import insuranceStatusCCAClaimRoutes from "./insuranceStatusCCAClaim"; import insuranceStatusCCAPreAuthRoutes from "./insuranceStatusCCAPreAuth"; import insuranceStatusDDMAClaimRoutes from "./insuranceStatusDDMAClaim"; import insuranceStatusUnitedDHClaimRoutes from "./insuranceStatusUnitedDHClaim"; +import insuranceStatusUnitedDHPreAuthRoutes from "./insuranceStatusUnitedDHPreAuth"; import insuranceStatusTuftsSCOClaimRoutes from "./insuranceStatusTuftsSCOClaim"; +import insuranceStatusTuftsSCOPreAuthRoutes from "./insuranceStatusTuftsSCOPreAuth"; import paymentsRoutes from "./payments"; import databaseManagementRoutes from "./database-management"; import notificationsRoutes from "./notifications"; @@ -66,7 +68,9 @@ router.use("/claims", insuranceStatusCCAClaimRoutes); router.use("/claims", insuranceStatusCCAPreAuthRoutes); router.use("/claims", insuranceStatusDDMAClaimRoutes); router.use("/claims", insuranceStatusUnitedDHClaimRoutes); +router.use("/claims", insuranceStatusUnitedDHPreAuthRoutes); router.use("/claims", insuranceStatusTuftsSCOClaimRoutes); +router.use("/claims", insuranceStatusTuftsSCOPreAuthRoutes); router.use("/payments", paymentsRoutes); router.use("/database-management", databaseManagementRoutes); router.use("/notifications", notificationsRoutes); diff --git a/apps/Backend/src/routes/insuranceStatusTuftsSCOPreAuth.ts b/apps/Backend/src/routes/insuranceStatusTuftsSCOPreAuth.ts new file mode 100644 index 00000000..fc1d927a --- /dev/null +++ b/apps/Backend/src/routes/insuranceStatusTuftsSCOPreAuth.ts @@ -0,0 +1,122 @@ +import { Router, Request, Response } from "express"; +import { storage } from "../storage"; +import { enqueueSeleniumJob } from "../queue/jobRunner"; +import { forwardOtpToSeleniumTuftsSCOPreAuthAgent } from "../services/seleniumTuftsSCOPreAuthClient"; +import { io } from "../socket"; + +const router = Router(); + +/** + * POST /tuftssco-preauth + * + * Enqueues a Tufts SCO (DentaQuest) pre-authorization submission job. + * + * Body fields (JSON): + * data — preauth payload (memberId, dateOfBirth, serviceDate, serviceLines, patientName, etc.) + * socketId — socket.io client id + * claimId — existing claim DB id (optional) + * + * Response: { status: "queued", jobId: "…" } + */ +router.post("/tuftssco-preauth", async (req: Request, res: Response): Promise => { + if (!req.user?.id) { + return res.status(401).json({ error: "Unauthorized: user info missing" }); + } + + try { + const claimData = + typeof req.body.data === "string" + ? JSON.parse(req.body.data) + : req.body.data ?? req.body ?? {}; + + const credentials = await storage.getInsuranceCredentialByUserAndSiteKey( + req.user.id, + "TUFTS_SCO" + ); + if (!credentials) { + return res.status(404).json({ + error: "No Tufts SCO credentials found. Please add them on the Settings page.", + }); + } + + const enrichedPayload = { + claim: { + ...claimData, + dentaquestUsername: credentials.username, + dentaquestPassword: credentials.password, + }, + }; + + const socketId: string | undefined = req.body.socketId; + let claimId: number | undefined = claimData.claimId + ? Number(claimData.claimId) + : undefined; + + if (!claimId && claimData.patientId) { + try { + const serviceDate = claimData.serviceDate + ? new Date(claimData.serviceDate) + : new Date(); + const dob = claimData.dateOfBirth + ? new Date(claimData.dateOfBirth) + : new Date("2000-01-01"); + const record = await storage.createClaim({ + patientId: Number(claimData.patientId), + appointmentId: claimData.appointmentId ? Number(claimData.appointmentId) : null, + userId: req.user.id, + staffId: Number(claimData.staffId) || 1, + patientName: claimData.patientName || "", + memberId: claimData.memberId || "", + dateOfBirth: dob, + remarks: claimData.remarks || "", + missingTeethStatus: claimData.missingTeethStatus || "No_missing", + serviceDate, + insuranceProvider: "Tufts SCO", + status: "PREAUTH", + } as any); + claimId = record.id; + console.log(`[tuftssco-preauth route] created claim record id=${claimId}`); + } catch (e: any) { + console.error("[tuftssco-preauth route] failed to create claim record:", e?.message); + } + } + + const jobId = enqueueSeleniumJob({ + jobType: "tuftssco-preauth-submit", + userId: req.user.id, + socketId, + enrichedPayload, + claimId, + }); + + return res.json({ status: "queued", jobId }); + } catch (err: any) { + console.error("[tuftssco-preauth route] error:", err); + return res.status(500).json({ + error: err.message || "Failed to enqueue Tufts SCO preauth job", + }); + } +}); + +/** + * POST /claims/tuftssco-preauth/selenium/submit-otp + * Body: { session_id, otp, socketId? } + */ +router.post("/tuftssco-preauth/selenium/submit-otp", async (req: Request, res: Response): Promise => { + 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 forwardOtpToSeleniumTuftsSCOPreAuthAgent(sessionId, otp); + if (socketId && io) { + io.to(socketId).emit("selenium:otp_submitted", { session_id: sessionId }); + } + return res.json(r); + } catch (err: any) { + console.error("[tuftssco-preauth] submit-otp failed:", err?.message); + return res.status(500).json({ error: err?.message || "Failed to forward OTP" }); + } +}); + +export default router; diff --git a/apps/Backend/src/routes/insuranceStatusUnitedDHPreAuth.ts b/apps/Backend/src/routes/insuranceStatusUnitedDHPreAuth.ts new file mode 100644 index 00000000..06b9baa5 --- /dev/null +++ b/apps/Backend/src/routes/insuranceStatusUnitedDHPreAuth.ts @@ -0,0 +1,124 @@ +import { Router, Request, Response } from "express"; +import { storage } from "../storage"; +import { enqueueSeleniumJob } from "../queue/jobRunner"; +import { forwardOtpToSeleniumUnitedDHPreAuthAgent } from "../services/seleniumUnitedDHPreAuthClient"; +import { io } from "../socket"; + +const router = Router(); + +/** + * POST /uniteddh-preauth + * + * Enqueues a United/DentalHub pre-authorization submission job. + * + * Body fields (JSON): + * data — preauth payload (memberId, dateOfBirth, serviceDate, serviceLines, patientName, etc.) + * socketId — socket.io client id + * claimId — existing claim DB id (optional) + * + * Response: { status: "queued", jobId: "…" } + */ +router.post("/uniteddh-preauth", async (req: Request, res: Response): Promise => { + if (!req.user?.id) { + return res.status(401).json({ error: "Unauthorized: user info missing" }); + } + + try { + const claimData = + typeof req.body.data === "string" + ? JSON.parse(req.body.data) + : req.body.data ?? req.body ?? {}; + + // Fetch United/DentalHub credentials — same portal as UnitedSCO + const credentials = await storage.getInsuranceCredentialByUserAndSiteKey( + req.user.id, + "UNITED_SCO" + ); + if (!credentials) { + return res.status(404).json({ + error: "No United/DentalHub credentials found. Please add them on the Settings page.", + }); + } + + const enrichedPayload = { + claim: { + ...claimData, + uniteddhUsername: credentials.username, + uniteddhPassword: credentials.password, + }, + }; + + const socketId: string | undefined = req.body.socketId; + let claimId: number | undefined = claimData.claimId + ? Number(claimData.claimId) + : undefined; + + // Create a PREAUTH claim record so preAuthNumber can be stored + if (!claimId && claimData.patientId) { + try { + const serviceDate = claimData.serviceDate + ? new Date(claimData.serviceDate) + : new Date(); + const dob = claimData.dateOfBirth + ? new Date(claimData.dateOfBirth) + : new Date("2000-01-01"); + const record = await storage.createClaim({ + patientId: Number(claimData.patientId), + appointmentId: claimData.appointmentId ? Number(claimData.appointmentId) : null, + userId: req.user.id, + staffId: Number(claimData.staffId) || 1, + patientName: claimData.patientName || "", + memberId: claimData.memberId || "", + dateOfBirth: dob, + remarks: claimData.remarks || "", + missingTeethStatus: claimData.missingTeethStatus || "No_missing", + serviceDate, + insuranceProvider: "United/DentalHub", + status: "PREAUTH", + } as any); + claimId = record.id; + console.log(`[uniteddh-preauth route] created claim record id=${claimId}`); + } catch (e: any) { + console.error("[uniteddh-preauth route] failed to create claim record:", e?.message); + } + } + + const jobId = enqueueSeleniumJob({ + jobType: "uniteddh-preauth-submit", + userId: req.user.id, + socketId, + enrichedPayload, + claimId, + }); + + return res.json({ status: "queued", jobId }); + } catch (err: any) { + console.error("[uniteddh-preauth route] error:", err); + return res.status(500).json({ + error: err.message || "Failed to enqueue United/DentalHub preauth job", + }); + } +}); + +/** + * POST /claims/uniteddh-preauth/selenium/submit-otp + * Body: { session_id, otp, socketId? } + */ +router.post("/uniteddh-preauth/selenium/submit-otp", async (req: Request, res: Response): Promise => { + 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 forwardOtpToSeleniumUnitedDHPreAuthAgent(sessionId, otp); + if (socketId && io) { + io.to(socketId).emit("selenium:otp_submitted", { session_id: sessionId }); + } + return res.json(r); + } catch (err: any) { + console.error("[uniteddh-preauth] submit-otp failed:", err?.message); + return res.status(500).json({ error: err?.message || "Failed to forward OTP" }); + } +}); + +export default router; diff --git a/apps/Backend/src/services/seleniumTuftsSCOPreAuthClient.ts b/apps/Backend/src/services/seleniumTuftsSCOPreAuthClient.ts new file mode 100644 index 00000000..8375a7e1 --- /dev/null +++ b/apps/Backend/src/services/seleniumTuftsSCOPreAuthClient.ts @@ -0,0 +1,26 @@ +import axios from "axios"; + +const SELENIUM_BASE = process.env.SELENIUM_SERVICE_URL || "http://localhost:8000"; + +export async function forwardToSeleniumTuftsSCOPreAuthAgent(data: any) { + const response = await axios.post(`${SELENIUM_BASE}/tuftssco-preauth`, data, { + timeout: 30000, + }); + return response.data; +} + +export async function getSeleniumTuftsSCOPreAuthSessionStatus(sessionId: string) { + const response = await axios.get(`${SELENIUM_BASE}/session/${sessionId}/status`, { + timeout: 10000, + }); + return response.data; +} + +export async function forwardOtpToSeleniumTuftsSCOPreAuthAgent(sessionId: string, otp: string) { + const response = await axios.post( + `${SELENIUM_BASE}/submit-otp`, + { session_id: sessionId, otp }, + { timeout: 10000 } + ); + return response.data; +} diff --git a/apps/Backend/src/services/seleniumUnitedDHPreAuthClient.ts b/apps/Backend/src/services/seleniumUnitedDHPreAuthClient.ts new file mode 100644 index 00000000..331b205d --- /dev/null +++ b/apps/Backend/src/services/seleniumUnitedDHPreAuthClient.ts @@ -0,0 +1,35 @@ +import axios from "axios"; + +const SELENIUM_BASE = process.env.SELENIUM_SERVICE_URL ?? "http://localhost:5002"; + +/** + * POST /uniteddh-preauth + * Returns { status: "started", session_id: "" } + */ +export async function forwardToSeleniumUnitedDHPreAuthAgent( + data: Record +): Promise<{ status: string; session_id: string }> { + const resp = await axios.post(`${SELENIUM_BASE}/uniteddh-preauth`, data); + return resp.data; +} + +/** + * GET /session/{sid}/status + */ +export async function getSeleniumUnitedDHPreAuthSessionStatus( + sessionId: string +): Promise> { + const resp = await axios.get(`${SELENIUM_BASE}/session/${sessionId}/status`); + return resp.data; +} + +/** + * POST /submit-otp + */ +export async function forwardOtpToSeleniumUnitedDHPreAuthAgent( + sessionId: string, + otp: string +): Promise> { + const resp = await axios.post(`${SELENIUM_BASE}/submit-otp`, { session_id: sessionId, otp }); + return resp.data; +} diff --git a/apps/Frontend/src/components/claims/claim-form.tsx b/apps/Frontend/src/components/claims/claim-form.tsx index eef005e4..35f5e0b4 100755 --- a/apps/Frontend/src/components/claims/claim-form.tsx +++ b/apps/Frontend/src/components/claims/claim-form.tsx @@ -93,6 +93,8 @@ interface ClaimFormProps { onHandleForMHSeleniumClaimPreAuth: (data: ClaimPreAuthData) => void; onHandleForCCASeleniumClaim: (data: ClaimFormData) => void; onHandleForCCASeleniumPreAuth: (data: ClaimPreAuthData) => void; + onHandleForUnitedDHSeleniumPreAuth: (data: ClaimPreAuthData) => void; + onHandleForTuftsSCOSeleniumPreAuth: (data: ClaimPreAuthData) => void; onHandleForDDMASeleniumClaim: (data: ClaimFormData) => void; onHandleForUnitedDHSeleniumClaim: (data: ClaimFormData) => void; onHandleForTuftsSCOSeleniumClaim: (data: ClaimFormData) => void; @@ -112,6 +114,8 @@ export function ClaimForm({ onHandleForMHSeleniumClaimPreAuth, onHandleForCCASeleniumClaim, onHandleForCCASeleniumPreAuth, + onHandleForUnitedDHSeleniumPreAuth, + onHandleForTuftsSCOSeleniumPreAuth, onHandleForDDMASeleniumClaim, onHandleForUnitedDHSeleniumClaim, onHandleForTuftsSCOSeleniumClaim, @@ -1433,6 +1437,82 @@ export function ClaimForm({ onClose(); }; + const handleUnitedDHPreAuth = 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 pre-authorization.", + variant: "destructive", + }); + return; + } + + onHandleForUnitedDHSeleniumPreAuth({ + ...form, + serviceLines: filteredServiceLines, + staffId: appointmentStaffId ?? Number(staff?.id), + patientId, + insuranceProvider: "United/DentalHub", + insuranceSiteKey: "UNITED_SCO", + }); + + onClose(); + }; + + const handleTuftsSCOPreAuth = 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 pre-authorization.", + variant: "destructive", + }); + return; + } + + onHandleForTuftsSCOSeleniumPreAuth({ + ...form, + serviceLines: filteredServiceLines, + staffId: appointmentStaffId ?? Number(staff?.id), + patientId, + insuranceProvider: "Tufts SCO", + insuranceSiteKey: "TUFTS_SCO", + }); + + onClose(); + }; + /** Check prices against the fee schedule. If mismatches exist, show dialog and * store the action to run after the user responds. Otherwise run immediately. */ const runWithPriceCheck = (action: () => void) => { @@ -2931,16 +3011,16 @@ export function ClaimForm({ CCA PreAuth + +

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

+
{ + e.preventDefault(); + const input = (e.currentTarget.elements.namedItem("otp") as HTMLInputElement); + if (input?.value.trim()) handleUnitedDHPreAuthOtpSubmit(input.value.trim()); + }} className="space-y-4"> +
+ + +
+
+ + +
+
+ + + )} + + {/* Tufts SCO PreAuth OTP Modal */} + {tuftsSCOPreAuthOtpOpen && ( +
+
+
+

Enter OTP — Tufts SCO PreAuth

+ +
+

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

+
{ + e.preventDefault(); + const input = (e.currentTarget.elements.namedItem("otp") as HTMLInputElement); + if (input?.value.trim()) handleTuftsSCOPreAuthOtpSubmit(input.value.trim()); + }} className="space-y-4"> +
+ + +
+
+ + +
+
+
+
+ )} + {/* United/DentalHub Claim OTP Modal */} {unitedDHClaimOtpOpen && (
diff --git a/apps/Frontend/src/pages/cloud-storage-page.tsx b/apps/Frontend/src/pages/cloud-storage-page.tsx index 0d81e64b..3e43cc9e 100755 --- a/apps/Frontend/src/pages/cloud-storage-page.tsx +++ b/apps/Frontend/src/pages/cloud-storage-page.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useSearch } from "wouter"; import { useQueryClient } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; @@ -28,6 +28,7 @@ export default function CloudStoragePage() { const [panelInitialFolderId, setPanelInitialFolderId] = useState< number | null >(null); + const panelRef = useRef(null); // Deep-link: if navigated here with ?folderId=XXX, open that folder automatically useEffect(() => { @@ -53,6 +54,9 @@ export default function CloudStoragePage() { function handleOpenFolder(folderId: number | null) { setPanelInitialFolderId(folderId); setPanelOpen(true); + setTimeout(() => { + panelRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); + }, 50); } function handleSelectFile(fileId: number) { @@ -131,6 +135,7 @@ export default function CloudStoragePage() { /> {/* FolderPanel lives in page so it can be reused with other UI */} +
{panelOpen && ( " } + """ + global waiting_jobs + + body = await request.json() + + sid = huniteddh_preauth.make_session_entry() + huniteddh_preauth.sessions[sid]["type"] = "uniteddh_preauth" + huniteddh_preauth.sessions[sid]["last_activity"] = time.time() + + async with lock: + waiting_jobs += 1 + + asyncio.create_task(_uniteddh_preauth_worker_wrapper( + sid, body, + url="https://app.dentalhub.com/app/login" + )) + + return {"status": "started", "session_id": sid} + + +async def _tuftssco_preauth_worker_wrapper(sid: str, data: dict, url: str): + """Background worker for Tufts SCO (DentaQuest) pre-authorization submission.""" + global active_jobs, waiting_jobs + async with semaphore: + async with lock: + waiting_jobs -= 1 + active_jobs += 1 + try: + await htuftssco_preauth.start_tuftssco_preauth_run(sid, data, url) + finally: + async with lock: + active_jobs -= 1 + + +@app.post("/tuftssco-preauth") +async def tuftssco_preauth(request: Request): + """ + Starts a Tufts SCO (DentaQuest) pre-authorization session in the background. + Logs in (persistent session, OTP on first use), searches patient, clicks + Create Prior Authorization, fills the form and submits. + Body: { "claim": { "dentaquestUsername": "...", "dentaquestPassword": "...", ... } } + Returns: { status: "started", session_id: "" } + """ + global waiting_jobs + + body = await request.json() + + sid = htuftssco_preauth.make_session_entry() + htuftssco_preauth.sessions[sid]["type"] = "tuftssco_preauth" + htuftssco_preauth.sessions[sid]["last_activity"] = time.time() + + async with lock: + waiting_jobs += 1 + + asyncio.create_task(_tuftssco_preauth_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 @@ -823,8 +909,12 @@ async def submit_otp(request: Request): res = hddma_claim.submit_otp(sid, otp) elif sid in huniteddh_claim.sessions: res = huniteddh_claim.submit_otp(sid, otp) + elif sid in huniteddh_preauth.sessions: + res = huniteddh_preauth.submit_otp(sid, otp) elif sid in htuftssco_claim.sessions: res = htuftssco_claim.submit_otp(sid, otp) + elif sid in htuftssco_preauth.sessions: + res = htuftssco_preauth.submit_otp(sid, otp) elif sid in hbcbs_ma.sessions: res = hbcbs_ma.submit_otp(sid, otp) else: @@ -856,8 +946,12 @@ async def session_status(sid: str): s = hddma_claim.get_session_status(sid) elif sid in huniteddh_claim.sessions: s = huniteddh_claim.get_session_status(sid) + elif sid in huniteddh_preauth.sessions: + s = huniteddh_preauth.get_session_status(sid) elif sid in htuftssco_claim.sessions: s = htuftssco_claim.get_session_status(sid) + elif sid in htuftssco_preauth.sessions: + s = htuftssco_preauth.get_session_status(sid) elif sid in hbcbs_ma.sessions: s = hbcbs_ma.get_session_status(sid) else: diff --git a/apps/SeleniumService/helpers_tuftssco_preauth.py b/apps/SeleniumService/helpers_tuftssco_preauth.py new file mode 100644 index 00000000..721d4b6c --- /dev/null +++ b/apps/SeleniumService/helpers_tuftssco_preauth.py @@ -0,0 +1,345 @@ +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_TuftsSCO_preAuthWorker import AutomationTuftsSCOPreAuth + +sessions: Dict[str, Dict[str, Any]] = {} + +SESSION_OTP_TIMEOUT = int(os.getenv("SESSION_OTP_TIMEOUT", "120")) + +MEMBERS_URL = "https://providers.dentaquest.com/members" + + +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_tuftssco_preauth] 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("[TuftsSCO PreAuth] Browser minimized after error") + return + except Exception: + pass + try: + bot.driver.set_window_position(-10000, -10000) + print("[TuftsSCO PreAuth] Browser moved off-screen after error") + except Exception: + pass + except Exception as e: + print(f"[TuftsSCO PreAuth] Could not hide browser: {e}") + + +async def start_tuftssco_preauth_run(sid: str, data: dict, url: str): + """ + Run the Tufts SCO (DentaQuest) pre-authorization workflow. + Login/OTP handling mirrors helpers_tuftssco_claim.py exactly. + Pre-auth steps call selenium_TuftsSCO_preAuthWorker. + """ + 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 = AutomationTuftsSCOPreAuth(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("[TuftsSCO PreAuth] 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"[TuftsSCO PreAuth 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: + otp_value = s.get("otp_value") + if otp_value: + print(f"[TuftsSCO PreAuth 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(@aria-label,'verification') or " + "contains(@placeholder,'code') or contains(@placeholder,'Code')]" + ) + otp_input.clear() + otp_input.send_keys(otp_value) + try: + verify_btn = driver.find_element(By.XPATH, + "//button[@type='submit'] | " + "//button[contains(text(),'Verify') or contains(text(),'Submit') or contains(text(),'Confirm')]" + ) + verify_btn.click() + print("[TuftsSCO PreAuth OTP] Clicked verify button") + except: + otp_input.send_keys("\n") + print("[TuftsSCO PreAuth OTP] Pressed Enter as fallback") + print("[TuftsSCO PreAuth OTP] OTP typed and submitted via app") + s["otp_value"] = None + await asyncio.sleep(3) + except Exception as type_err: + print(f"[TuftsSCO PreAuth OTP] Failed to type OTP from app: {type_err}") + + current_url = driver.current_url.lower() + print(f"[TuftsSCO PreAuth 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("[TuftsSCO PreAuth OTP] Dashboard/search element found - login successful!") + login_success = True + break + except TimeoutException: + print("[TuftsSCO PreAuth OTP] On member page but search input not found, continuing to poll...") + + 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"[TuftsSCO PreAuth OTP Poll {poll+1}] OTP input still visible - waiting...") + except: + if "login" in current_url or "auth" in current_url: + print("[TuftsSCO PreAuth OTP] OTP input gone, trying to navigate to members page...") + try: + driver.get(MEMBERS_URL) + await asyncio.sleep(2) + except: + pass + + except Exception as poll_err: + print(f"[TuftsSCO PreAuth OTP Poll {poll+1}] Error: {poll_err}") + + if not login_success: + try: + print("[TuftsSCO PreAuth OTP] Final attempt - navigating to members page...") + driver.get(MEMBERS_URL) + 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("[TuftsSCO PreAuth OTP] Members page 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("[TuftsSCO PreAuth OTP] Proceeding to pre-auth steps...") + + # ── Login succeeded without OTP ─────────────────────────────────────── + elif isinstance(login_result, str) and login_result == "SUCCESS": + print("[TuftsSCO PreAuth] 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} + + # Ensure we're on the members search page before starting + try: + current_url = bot.driver.current_url + if "member" not in current_url.lower(): + bot.driver.get(MEMBERS_URL) + await asyncio.sleep(3) + print(f"[TuftsSCO PreAuth] Navigated to members: {bot.driver.current_url}") + except Exception as e: + print(f"[TuftsSCO PreAuth] Warning: could not ensure members URL: {e}") + + # --- Pre-auth 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_preauth", bot.step3_click_create_preauth), + ("step4_fill_preauth_form", bot.step4_fill_preauth_form), + ("step5_attach_files", bot.step5_attach_files), + ("step6_click_next", bot.step6_click_next), + ("step7_submit_preauth", bot.step7_submit_preauth), + ]: + result = step_fn() + print(f"[TuftsSCO PreAuth] {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 + pre-auth number --- + step8_result = bot.step8_save_confirmation_pdf() + print(f"[TuftsSCO PreAuth] step8 result: {step8_result}") + if isinstance(step8_result, str) and step8_result.startswith("ERROR"): + print(f"[TuftsSCO PreAuth] step8 warning (non-fatal): {step8_result}") + step8_result = {} + + pdf_path = step8_result.get("pdf_path") if isinstance(step8_result, dict) else None + preauth_number = step8_result.get("preAuthNumber") if isinstance(step8_result, dict) else None + + result = { + "status": "success", + "message": "Tufts SCO pre-authorization submitted successfully", + "preAuthNumber": preauth_number, + "pdf_path": pdf_path, + } + s["status"] = "completed" + s["result"] = result + s["message"] = "completed" + + # Preserve session: navigate away and minimize (do NOT quit the driver) + try: + if bot and bot.driver: + bot.driver.get("about:blank") + await asyncio.sleep(0.5) + bot.driver.minimize_window() + print("[TuftsSCO PreAuth] Session preserved (about:blank + minimize)") + except Exception as close_err: + print(f"[TuftsSCO PreAuth] Could not preserve session (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 { + "status": s.get("status"), + "message": s.get("message"), + "result": s.get("result"), + } diff --git a/apps/SeleniumService/helpers_uniteddh_preauth.py b/apps/SeleniumService/helpers_uniteddh_preauth.py new file mode 100644 index 00000000..d987c22e --- /dev/null +++ b/apps/SeleniumService/helpers_uniteddh_preauth.py @@ -0,0 +1,342 @@ +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_preAuthWorker import AutomationUnitedDHPreAuth + +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_preauth] 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 PreAuth] Browser minimized after error") + return + except Exception: + pass + try: + bot.driver.set_window_position(-10000, -10000) + print("[UnitedDH PreAuth] Browser moved off-screen after error") + except Exception: + pass + except Exception as e: + print(f"[UnitedDH PreAuth] Could not hide browser: {e}") + + +async def start_uniteddh_preauth_run(sid: str, data: dict, url: str): + """ + Run the United/DentalHub pre-authorization workflow. + Login/OTP handling mirrors helpers_uniteddh_claim.py exactly. + Pre-auth steps call selenium_UnitedDH_preAuthWorker. + """ + 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 = AutomationUnitedDHPreAuth(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 PreAuth] 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 PreAuth 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: + otp_value = s.get("otp_value") + if otp_value: + print(f"[UnitedDH PreAuth 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 PreAuth 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 PreAuth OTP] Clicked verify button (text/type)") + except: + otp_input.send_keys("\n") + print("[UnitedDH PreAuth OTP] Pressed Enter as fallback") + print("[UnitedDH PreAuth OTP] OTP typed and submitted via app") + s["otp_value"] = None + await asyncio.sleep(3) + except Exception as type_err: + print(f"[UnitedDH PreAuth OTP] Failed to type OTP from app: {type_err}") + + current_url = driver.current_url.lower() + print(f"[UnitedDH PreAuth 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 PreAuth OTP] Dashboard/search element found - login successful!") + login_success = True + break + except TimeoutException: + print("[UnitedDH PreAuth OTP] On member page but search input not found, continuing to poll...") + + 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 PreAuth OTP Poll {poll+1}] OTP input still visible - waiting...") + except: + if "login" in current_url or "app/login" in current_url: + print("[UnitedDH PreAuth 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 PreAuth OTP Poll {poll+1}] Error: {poll_err}") + + if not login_success: + try: + print("[UnitedDH PreAuth 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 PreAuth 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 PreAuth OTP] Proceeding to pre-auth steps...") + + # ── Login succeeded without OTP ─────────────────────────────────────── + elif isinstance(login_result, str) and login_result == "SUCCESS": + print("[UnitedDH PreAuth] 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} + + # --- Pre-auth steps --- + for step_name, step_fn in [ + ("step1_search_patient", bot.step1_search_patient), + ("step2_click_preauth_button", bot.step2_click_preauth_button), + ("step3_continue_prefilled", bot.step3_continue_prefilled), + ("step4_select_insurance_ok", bot.step4_select_insurance_ok), + ("step5_practitioner_continue", bot.step5_practitioner_continue), + ("step6_fill_preauth_form", bot.step6_fill_preauth_form), + ("step7_attach_files", bot.step7_attach_files), + ("step8_submit_preauth", bot.step8_submit_preauth), + ]: + result = step_fn() + print(f"[UnitedDH PreAuth] {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 9: PDF + pre-auth number --- + step9_result = bot.step9_save_confirmation_pdf() + print(f"[UnitedDH PreAuth] step9 result: {step9_result}") + if isinstance(step9_result, str) and step9_result.startswith("ERROR"): + print(f"[UnitedDH PreAuth] step9 warning (non-fatal): {step9_result}") + step9_result = {} + + pdf_path = step9_result.get("pdf_path") if isinstance(step9_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 PreAuth] pdf_url: {pdf_url}") + + preauth_number = step9_result.get("preAuthNumber") if isinstance(step9_result, dict) else None + + result = { + "status": "success", + "message": "United/DentalHub pre-authorization submitted successfully", + "preAuthNumber": preauth_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 PreAuth] Browser closed - session preserved in profile") + except Exception as close_err: + print(f"[UnitedDH PreAuth] 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_TuftsSCO_preAuthWorker.py b/apps/SeleniumService/selenium_TuftsSCO_preAuthWorker.py new file mode 100644 index 00000000..5fea455e --- /dev/null +++ b/apps/SeleniumService/selenium_TuftsSCO_preAuthWorker.py @@ -0,0 +1,954 @@ +""" +Tufts SCO (DentaQuest) Pre-Authorization Worker. +Portal: providers.dentaquest.com + +Steps mirror the claim worker exactly, except: + - step3 clicks "Create prior authorization" instead of "Create claim" + - step6/7 target pre-auth submit buttons/text + - step8 extracts a pre-auth number instead of a claim number +""" +from selenium.common.exceptions import TimeoutException +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 +import time +import os +import base64 +import re + +from dentaquest_browser_manager import get_browser_manager + +MEMBERS_URL = "https://providers.dentaquest.com/members" + +_SERVICE_DIR = os.path.dirname(os.path.abspath(__file__)) +_BACKEND_CWD = os.path.normpath(os.path.join(_SERVICE_DIR, "..", "Backend")) + + +class AutomationTuftsSCOPreAuth: + 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", "") + + self.dentaquest_username = claim.get("dentaquestUsername", "") + self.dentaquest_password = claim.get("dentaquestPassword", "") + + 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): + try: + print("[TuftsSCO PreAuth login] Forcing logout due to credential change...") + browser_manager = get_browser_manager() + try: + self.driver.get("https://providers.dentaquest.com/") + 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']", + "//*[contains(@class,'logout') or contains(@class,'signout')]", + ]: + try: + btn = WebDriverWait(self.driver, 3).until(EC.element_to_be_clickable((By.XPATH, selector))) + btn.click() + print("[TuftsSCO PreAuth login] Clicked logout button") + time.sleep(2) + break + except TimeoutException: + continue + except Exception as e: + print(f"[TuftsSCO PreAuth login] Could not click logout button: {e}") + try: + self.driver.delete_all_cookies() + print("[TuftsSCO PreAuth login] Cleared all cookies") + except Exception as e: + print(f"[TuftsSCO PreAuth login] Error clearing cookies: {e}") + browser_manager.clear_credentials_hash() + return True + except Exception as e: + print(f"[TuftsSCO PreAuth login] Error during forced logout: {e}") + return False + + def _is_maintenance_page(self) -> bool: + try: + body = self.driver.find_element(By.TAG_NAME, "body").text.lower() + markers = ["temporarily unable to service", "maintenance downtime", "capacity problems"] + return any(m in body for m in markers) + except Exception: + return False + + def login(self, url): + wait = WebDriverWait(self.driver, 30) + browser_manager = get_browser_manager() + + try: + if self.dentaquest_username and browser_manager.credentials_changed(self.dentaquest_username): + self._force_logout() + self.driver.get(url) + time.sleep(2) + + try: + current_url = self.driver.current_url + print(f"[TuftsSCO PreAuth login] Current URL: {current_url}") + if "dashboard" in current_url.lower() or "member" in current_url.lower(): + try: + WebDriverWait(self.driver, 3).until( + EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]')) + ) + print("[TuftsSCO PreAuth login] Already logged in") + return "ALREADY_LOGGED_IN" + except TimeoutException: + pass + except Exception as e: + print(f"[TuftsSCO PreAuth login] Error checking current state: {e}") + + self.driver.get(url) + time.sleep(3) + + if self._is_maintenance_page(): + return "ERROR: DentaQuest portal is in maintenance mode" + + current_url = self.driver.current_url.lower() + print(f"[TuftsSCO PreAuth login] After navigation URL: {current_url}") + + if "dashboard" in current_url or "member" in current_url: + try: + WebDriverWait(self.driver, 3).until( + EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]')) + ) + print("[TuftsSCO PreAuth login] Already on dashboard") + return "ALREADY_LOGGED_IN" + except TimeoutException: + pass + + # Dismiss "Authentication flow continued in another tab" modal if present + try: + ok_button = WebDriverWait(self.driver, 5).until( + EC.element_to_be_clickable((By.XPATH, + "//button[normalize-space(text())='Ok' or normalize-space(text())='OK' " + "or normalize-space(text())='Continue']" + )) + ) + ok_button.click() + print("[TuftsSCO PreAuth login] Dismissed modal") + time.sleep(3) + try: + WebDriverWait(self.driver, 5).until( + EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]')) + ) + print("[TuftsSCO PreAuth login] Already authenticated after modal dismiss") + return "ALREADY_LOGGED_IN" + except TimeoutException: + pass + except TimeoutException: + pass + + # Check for OTP input on page + 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') or contains(@name,'otp')]")) + ) + print("[TuftsSCO PreAuth login] OTP input found on arrival") + return "OTP_REQUIRED" + except TimeoutException: + pass + + # Fill login credentials + try: + username_field = WebDriverWait(self.driver, 10).until( + EC.element_to_be_clickable((By.XPATH, + "//input[@type='email' or @name='username' or @id='username' or " + "@name='Email' or @placeholder='Email' or @placeholder='Username' or @type='text']")) + ) + username_field.clear() + username_field.send_keys(self.dentaquest_username) + print(f"[TuftsSCO PreAuth login] Entered username") + + password_field = WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located((By.XPATH, "//input[@type='password']")) + ) + password_field.clear() + password_field.send_keys(self.dentaquest_password) + print("[TuftsSCO PreAuth login] Entered password") + + try: + remember_me = WebDriverWait(self.driver, 3).until( + EC.element_to_be_clickable((By.XPATH, + "//label[.//span[contains(text(),'Remember me')] or " + "contains(translate(text(),'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'remember me')] | " + "//input[@type='checkbox' and (" + "contains(translate(@name,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'remember') or " + "contains(translate(@id,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'remember'))]" + )) + ) + remember_me.click() + print("[TuftsSCO PreAuth login] Checked 'Remember me'") + except Exception: + print("[TuftsSCO PreAuth login] No 'Remember me' found (continuing)") + + signin_btn = WebDriverWait(self.driver, 10).until( + EC.element_to_be_clickable((By.XPATH, + "//button[@type='submit'] | //input[@type='submit'] | " + "//button[contains(text(),'Sign') or contains(text(),'Log')]")) + ) + signin_btn.click() + print("[TuftsSCO PreAuth login] Clicked Sign in") + + if self.dentaquest_username: + browser_manager.save_credentials_hash(self.dentaquest_username) + + try: + WebDriverWait(self.driver, 30).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')]" + )) + ) + print("[TuftsSCO PreAuth login] OTP required after sign-in") + return "OTP_REQUIRED" + except TimeoutException: + pass + + current_url = self.driver.current_url.lower() + if "dashboard" in current_url or "member" in current_url: + print("[TuftsSCO PreAuth login] Login succeeded without OTP") + return "SUCCESS" + + print(f"[TuftsSCO PreAuth login] Unexpected state — URL: {current_url}") + return "SUCCESS" + + except Exception as e: + return f"ERROR: Login failed - {e}" + + except Exception as e: + return f"ERROR: Login exception - {e}" + + # ── Step 1: Search patient ───────────────────────────────────────────────── + + def step1_search_patient(self): + """Navigate to member search and find the patient by Member ID + DOB.""" + wait = WebDriverWait(self.driver, 30) + + def replace_with_sendkeys(el, value): + el.click() + time.sleep(0.05) + el.send_keys(Keys.CONTROL, "a") + el.send_keys(Keys.BACKSPACE) + el.send_keys(value) + + try: + print(f"[TuftsSCO PreAuth step1] Current URL: {self.driver.current_url}") + print(f"[TuftsSCO PreAuth step1] Searching memberId={self.memberId} dob={self.dateOfBirth}") + + if self._is_maintenance_page(): + return "ERROR: DentaQuest portal is in maintenance mode" + + time.sleep(2) + + try: + dob_parts = self.dateOfBirth.split("-") + dob_year = dob_parts[0] + dob_month = dob_parts[1].zfill(2) + dob_day = dob_parts[2].zfill(2) + print(f"[TuftsSCO PreAuth step1] Parsed DOB: {dob_month}/{dob_day}/{dob_year}") + except Exception as e: + return f"ERROR: step1 DOB parse failed: {e}" + + # Select Location from dropdown + try: + trigger = WebDriverWait(self.driver, 5).until( + EC.element_to_be_clickable((By.XPATH, + '//button[@data-testid="member-search_location_select-btn"]')) + ) + trigger.click() + print("[TuftsSCO PreAuth step1] Clicked location dropdown") + time.sleep(0.5) + first_option = WebDriverWait(self.driver, 5).until( + EC.element_to_be_clickable((By.XPATH, "(//li[@role='option'])[1]")) + ) + opt_text = first_option.get_attribute("aria-label") or first_option.text.strip() + first_option.click() + print(f"[TuftsSCO PreAuth step1] Selected location: {opt_text[:60]}") + time.sleep(0.3) + except TimeoutException: + print("[TuftsSCO PreAuth step1] Warning: Location dropdown not found (continuing)") + except Exception as e: + print(f"[TuftsSCO PreAuth step1] Warning: Location select failed: {e}") + + # Fill DOB + try: + dob_container = wait.until(EC.presence_of_element_located( + (By.XPATH, "//div[@data-testid='member-search_date-of-birth']") + )) + month_elem = dob_container.find_element(By.XPATH, ".//span[@data-type='month' and @contenteditable='true']") + day_elem = dob_container.find_element(By.XPATH, ".//span[@data-type='day' and @contenteditable='true']") + year_elem = dob_container.find_element(By.XPATH, ".//span[@data-type='year' and @contenteditable='true']") + replace_with_sendkeys(month_elem, dob_month) + time.sleep(0.1) + replace_with_sendkeys(day_elem, dob_day) + time.sleep(0.1) + replace_with_sendkeys(year_elem, dob_year) + print(f"[TuftsSCO PreAuth step1] Filled DOB: {dob_month}/{dob_day}/{dob_year}") + except Exception as e: + print(f"[TuftsSCO PreAuth step1] Warning: Could not fill DOB: {e}") + + time.sleep(0.3) + + # Fill Member ID + if self.memberId: + try: + member_id_input = wait.until(EC.presence_of_element_located( + (By.XPATH, '//input[@placeholder="Search by member ID"]') + )) + member_id_input.clear() + member_id_input.send_keys(self.memberId) + print(f"[TuftsSCO PreAuth step1] Entered Member ID: {self.memberId}") + time.sleep(0.2) + except Exception as e: + print(f"[TuftsSCO PreAuth step1] Warning: Could not fill Member ID: {e}") + + time.sleep(0.3) + + # Click Search + try: + search_btn = wait.until(EC.element_to_be_clickable( + (By.XPATH, '//button[@data-testid="member-search_search-button"]') + )) + search_btn.click() + print("[TuftsSCO PreAuth step1] Clicked Search button") + except TimeoutException: + try: + search_btn = self.driver.find_element(By.XPATH, '//button[contains(text(),"Search")]') + search_btn.click() + print("[TuftsSCO PreAuth step1] Clicked Search button (fallback)") + except Exception: + ActionChains(self.driver).send_keys(Keys.RETURN).perform() + print("[TuftsSCO PreAuth step1] Pressed Enter to search") + + WebDriverWait(self.driver, 15).until( + EC.any_of( + EC.presence_of_element_located((By.XPATH, "//tbody//tr")), + EC.presence_of_element_located((By.XPATH, + '//*[contains(@data-testid,"no-results") or contains(text(),"No results") ' + 'or contains(text(),"No member found") or contains(text(),"Nothing was found")]' + )), + ) + ) + time.sleep(4) + + try: + no_results = self.driver.find_element(By.XPATH, + '//*[contains(@data-testid,"no-results") or contains(text(),"No results") ' + 'or contains(text(),"No member found")]' + ) + if no_results and no_results.is_displayed(): + return "ERROR: No patient found with given search criteria" + except Exception: + pass + + print("[TuftsSCO PreAuth step1] Search completed") + return "SUCCESS" + + except Exception as e: + print(f"[TuftsSCO PreAuth step1] Exception: {e}") + return f"ERROR: step1 failed: {e}" + + # ── Step 2: Open member information page ─────────────────────────────────── + + def step2_open_member_page(self): + """Click patient name link → Member Information page.""" + try: + try: + WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located((By.XPATH, "//tbody//tr")) + ) + time.sleep(2) + except TimeoutException: + print("[TuftsSCO PreAuth step2] Warning: Results table not found within timeout") + + detail_url = None + for selector in [ + "(//table//tbody//tr)[1]//td[1]//a", + "(//tbody//tr)[1]//a[contains(@href,'member-details')]", + "(//tbody//tr)[1]//a[contains(@href,'member')]", + "//a[contains(@href,'member-details')]", + ]: + try: + link_el = WebDriverWait(self.driver, 5).until( + EC.presence_of_element_located((By.XPATH, selector)) + ) + href = link_el.get_attribute("href") + if href and "member" in href: + detail_url = href + print(f"[TuftsSCO PreAuth step2] Found detail URL: {href}") + break + except Exception: + continue + + if not detail_url: + return "ERROR: step2 failed: could not find member link" + + self.driver.get(detail_url) + print(f"[TuftsSCO PreAuth step2] Navigating to: {detail_url}") + + try: + WebDriverWait(self.driver, 15).until( + lambda d: "member" in d.current_url + ) + print(f"[TuftsSCO PreAuth step2] Member Information page loaded: {self.driver.current_url}") + except TimeoutException: + print(f"[TuftsSCO PreAuth step2] Warning — URL: {self.driver.current_url}") + + # Wait for either Create claim or Create prior authorization button + try: + WebDriverWait(self.driver, 15).until( + EC.any_of( + EC.presence_of_element_located((By.XPATH, "//button[@aria-label='Create prior authorization']")), + EC.presence_of_element_located((By.XPATH, "//button[@aria-label='Create claim']")), + ) + ) + print("[TuftsSCO PreAuth step2] Action button found on member page") + except TimeoutException: + print("[TuftsSCO PreAuth step2] Warning: Action button not found within timeout") + + time.sleep(2) + return "SUCCESS" + + except Exception as e: + print(f"[TuftsSCO PreAuth step2] Exception: {e}") + return f"ERROR: step2 failed: {e}" + + # ── Step 3: Click "Create prior authorization" button ───────────────────── + + def step3_click_create_preauth(self): + """Click the 'Create prior authorization' button on the Member Information page.""" + try: + print(f"[TuftsSCO PreAuth step3] Current URL: {self.driver.current_url}") + handles_before = set(self.driver.window_handles) + + self.driver.execute_script("window.scrollTo(0, 0);") + time.sleep(0.5) + + all_btns = self.driver.find_elements(By.XPATH, "//button") + print(f"[TuftsSCO PreAuth step3] Buttons on page: {[b.get_attribute('aria-label') or b.text for b in all_btns]}") + + btn = None + try: + btn = WebDriverWait(self.driver, 10).until( + EC.element_to_be_clickable((By.XPATH, + "//button[.//span[normalize-space(text())='pre-authorization']]" + )) + ) + print("[TuftsSCO PreAuth step3] Found button via pre-authorization") + except TimeoutException: + pass + + if btn is None: + # Fallbacks + for xpath in [ + "//button[@aria-label='Create prior authorization' and @data-react-aria-pressable='true']", + "//button[contains(normalize-space(.),'prior authorization') or contains(normalize-space(.),'pre-authorization')]", + ]: + try: + btn = WebDriverWait(self.driver, 5).until( + EC.element_to_be_clickable((By.XPATH, xpath)) + ) + print(f"[TuftsSCO PreAuth step3] Found button via fallback") + break + except TimeoutException: + continue + + if btn is None: + return "ERROR: step3 failed: could not find pre-authorization button" + + print(f"[TuftsSCO PreAuth step3] Found button: displayed={btn.is_displayed()}, enabled={btn.is_enabled()}") + self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", btn) + time.sleep(0.5) + + self.driver.execute_script(""" + var el = arguments[0]; + el.dispatchEvent(new PointerEvent('pointerover', {bubbles:true, cancelable:true, composed:true})); + el.dispatchEvent(new PointerEvent('pointerdown', {bubbles:true, cancelable:true, composed:true})); + el.dispatchEvent(new PointerEvent('pointerup', {bubbles:true, cancelable:true, composed:true})); + el.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true, composed:true})); + """, btn) + print("[TuftsSCO PreAuth step3] Dispatched pointer+click events") + + time.sleep(2) + + handles_after = set(self.driver.window_handles) + new_handles = handles_after - handles_before + if new_handles: + self.driver.switch_to.window(new_handles.pop()) + print("[TuftsSCO PreAuth step3] Switched to new tab") + + print(f"[TuftsSCO PreAuth step3] Post-click URL: {self.driver.current_url}") + + try: + WebDriverWait(self.driver, 20).until( + EC.any_of( + EC.presence_of_element_located((By.XPATH, "//input[contains(@id,'procedureCode')]")), + EC.presence_of_element_located((By.XPATH, "//span[@data-type='month' and @contenteditable='true']")), + EC.presence_of_element_located((By.XPATH, + "//*[contains(text(),'date of service') or contains(text(),'Date of service') " + "or contains(text(),'Procedure code') or contains(text(),'prior auth')]" + )), + ) + ) + print("[TuftsSCO PreAuth step3] Pre-auth form loaded") + except TimeoutException: + page_text = self.driver.execute_script("return document.body.innerText;")[:400] + print(f"[TuftsSCO PreAuth step3] Pre-auth form not detected — page: {page_text}") + + time.sleep(1) + return "SUCCESS" + + except Exception as e: + print(f"[TuftsSCO PreAuth step3] Exception: {e}") + return f"ERROR: step3 failed: {e}" + + # ── Step 4: Fill service date and procedure lines ────────────────────────── + + def _parse_service_date(self): + s = str(self.serviceDate or "").strip() + if not s: + return None, None, None + if "-" in s: + parts = s.split("-") + if len(parts) == 3 and len(parts[0]) == 4: + return parts[1].zfill(2), parts[2].zfill(2), parts[0] + if len(parts) == 3 and len(parts[2]) == 4: + return parts[0].zfill(2), parts[1].zfill(2), parts[2] + if "/" in s: + parts = s.split("/") + if len(parts) == 3: + return parts[0].zfill(2), parts[1].zfill(2), parts[2] + return None, None, None + + def _fill_spinbutton(self, label_fragment, value): + for sel in [ + f"//span[@contenteditable='true' and contains(@aria-label,'{label_fragment}')]", + f"//span[@data-type='{label_fragment}' and @contenteditable='true']", + ]: + try: + elem = WebDriverWait(self.driver, 5).until( + EC.presence_of_element_located((By.XPATH, sel)) + ) + elem.click() + elem.send_keys(Keys.CONTROL, "a") + elem.send_keys(Keys.BACKSPACE) + elem.send_keys(value) + time.sleep(0.1) + print(f"[TuftsSCO PreAuth step4] Filled spinbutton '{label_fragment}' = {value!r}") + return True + except Exception: + continue + print(f"[TuftsSCO PreAuth step4] Warning: spinbutton '{label_fragment}' not found") + return False + + def _fill_combobox(self, inp, value, label="field"): + try: + inp.click() + inp.send_keys(Keys.CONTROL + "a") + inp.send_keys(Keys.DELETE) + inp.send_keys(str(value)) + time.sleep(0.5) + listbox_id = inp.get_attribute("aria-controls") or "" + try: + if listbox_id: + option = WebDriverWait(self.driver, 4).until( + EC.element_to_be_clickable((By.XPATH, + f"//*[@id='{listbox_id}']//*[@role='option'][1]" + )) + ) + else: + option = WebDriverWait(self.driver, 4).until( + EC.element_to_be_clickable((By.XPATH, + f"//*[@role='listbox']//*[@role='option' and contains(normalize-space(.),'{value}')]" + f" | //*[@role='listbox']//*[@role='option'][1]" + )) + ) + option.click() + print(f"[TuftsSCO PreAuth step4] {label}: selected '{value}'") + except TimeoutException: + inp.send_keys(Keys.TAB) + print(f"[TuftsSCO PreAuth step4] {label}: typed '{value}' (no dropdown)") + except Exception as e: + print(f"[TuftsSCO PreAuth step4] Warning: could not fill {label}: {e}") + + def _fill_text_input(self, inp, value, label="field"): + try: + inp.click() + inp.send_keys(Keys.CONTROL + "a") + inp.send_keys(Keys.DELETE) + inp.send_keys(str(value)) + time.sleep(0.1) + print(f"[TuftsSCO PreAuth step4] {label}: typed '{value}'") + except Exception as e: + print(f"[TuftsSCO PreAuth step4] Warning: could not fill {label}: {e}") + + def step4_fill_preauth_form(self): + """Fill service date then all procedure line fields.""" + try: + month, day, year = self._parse_service_date() + + if month and day and year: + print(f"[TuftsSCO PreAuth step4] Filling service date: {month}/{day}/{year}") + try: + dos_container = WebDriverWait(self.driver, 8).until( + EC.presence_of_element_located((By.XPATH, + "//*[@data-testid and contains(@data-testid,'date-of-service')] | " + "//*[contains(@aria-label,'Select date of service')]/ancestor::div[1]" + )) + ) + month_el = dos_container.find_element(By.XPATH, ".//span[@data-type='month' and @contenteditable='true']") + day_el = dos_container.find_element(By.XPATH, ".//span[@data-type='day' and @contenteditable='true']") + year_el = dos_container.find_element(By.XPATH, ".//span[@data-type='year' and @contenteditable='true']") + for elem, val in [(month_el, month), (day_el, day), (year_el, year)]: + elem.click() + elem.send_keys(Keys.CONTROL, "a") + elem.send_keys(Keys.BACKSPACE) + elem.send_keys(val) + time.sleep(0.05) + print("[TuftsSCO PreAuth step4] Service date filled") + except Exception: + self._fill_spinbutton("month", month) + self._fill_spinbutton("day", day) + self._fill_spinbutton("year", year) + else: + print(f"[TuftsSCO PreAuth step4] No valid service date: {self.serviceDate!r}") + + time.sleep(0.3) + + active_lines = [ln for ln in self.serviceLines if str(ln.get("procedureCode") or "").strip()] + print(f"[TuftsSCO PreAuth step4] {len(active_lines)} service line(s)") + + for idx, line in enumerate(active_lines): + code = str(line.get("procedureCode") or "").strip().upper() + tooth = str(line.get("toothNumber") or line.get("tooth") or "").strip() + arch = str(line.get("arch") or "").strip() + quad = str(line.get("quad") or line.get("quadrant") or "").strip() + surface = str(line.get("toothSurface") or line.get("surface") or "").strip().upper() + billed = str(line.get("totalBilled") or line.get("billedAmount") or line.get("fee") or "").strip() + billed = billed.replace("$", "").strip() + + print(f"[TuftsSCO PreAuth step4] Line {idx}: code={code} tooth={tooth!r} arch={arch!r} " + f"quad={quad!r} surface={surface!r} billed={billed!r}") + + if idx > 0: + try: + add_btn = WebDriverWait(self.driver, 10).until( + EC.element_to_be_clickable((By.XPATH, + "//button[.//span[contains(text(),'Add a procedure')]] | " + "//button[contains(normalize-space(text()),'Add a procedure')] | " + "//*[contains(text(),'Add a procedure') and @role='button']" + )) + ) + self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", add_btn) + add_btn.click() + print(f"[TuftsSCO PreAuth step4] Clicked 'Add a procedure' for line {idx}") + time.sleep(1) + except Exception as e: + print(f"[TuftsSCO PreAuth step4] Could not click 'Add a procedure': {e}") + + if code: + try: + proc_inputs = self.driver.find_elements(By.XPATH, + "//input[contains(@id,'procedureCode') and contains(@id,'-input')]" + ) + proc_inp = proc_inputs[idx] if idx < len(proc_inputs) else proc_inputs[-1] + self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", proc_inp) + self._fill_combobox(proc_inp, code, f"procedureCode[{idx}]") + time.sleep(0.5) + except Exception as e: + print(f"[TuftsSCO PreAuth step4] Could not fill procedure code: {e}") + + if tooth: + try: + tooth_inputs = self.driver.find_elements(By.XPATH, "//input[@aria-label='Tooth']") + if idx < len(tooth_inputs): + self._fill_combobox(tooth_inputs[idx], tooth, f"tooth[{idx}]") + time.sleep(0.3) + except Exception as e: + print(f"[TuftsSCO PreAuth step4] Could not fill tooth: {e}") + + if arch: + try: + arch_inputs = self.driver.find_elements(By.XPATH, "//input[@aria-label='Arch']") + if idx < len(arch_inputs): + self._fill_combobox(arch_inputs[idx], arch, f"arch[{idx}]") + time.sleep(0.3) + except Exception as e: + print(f"[TuftsSCO PreAuth step4] Could not fill arch: {e}") + + if quad: + try: + quad_inputs = self.driver.find_elements(By.XPATH, "//input[@aria-label='Quad']") + if idx < len(quad_inputs): + self._fill_combobox(quad_inputs[idx], quad, f"quad[{idx}]") + time.sleep(0.3) + except Exception as e: + print(f"[TuftsSCO PreAuth step4] Could not fill quad: {e}") + + if surface: + try: + surface_inputs = self.driver.find_elements(By.XPATH, "//input[@aria-label='Surface']") + if idx < len(surface_inputs): + surf_inp = surface_inputs[idx] + surf_inp.click() + surf_inp.send_keys(Keys.CONTROL + "a") + surf_inp.send_keys(Keys.DELETE) + surf_inp.send_keys(surface) + time.sleep(0.3) + surf_inp.send_keys(Keys.ESCAPE) + print(f"[TuftsSCO PreAuth step4] surface[{idx}]: typed '{surface}'") + time.sleep(0.2) + except Exception as e: + print(f"[TuftsSCO PreAuth step4] Could not fill surface: {e}") + + if billed: + try: + billed_inputs = self.driver.find_elements(By.XPATH, + "//input[@aria-label='Enter billed amount']" + ) + if idx < len(billed_inputs): + self._fill_text_input(billed_inputs[idx], billed, f"billedAmount[{idx}]") + time.sleep(0.2) + except Exception as e: + print(f"[TuftsSCO PreAuth step4] Could not fill billed amount: {e}") + + print("[TuftsSCO PreAuth step4] Done") + return "SUCCESS" + + except Exception as e: + print(f"[TuftsSCO PreAuth step4] Exception: {e}") + return f"ERROR: step4 failed: {e}" + + # ── Step 5: Attach files ──────────────────────────────────────────────────── + + def step5_attach_files(self): + """For each claimFile with a filePath, click 'Add a file' and upload it.""" + if not self.claimFiles: + print("[TuftsSCO PreAuth step5] No files to attach") + return "SUCCESS" + + attached = 0 + for cf in self.claimFiles: + relative_path = cf.get("filePath") or "" + if not relative_path: + print(f"[TuftsSCO PreAuth 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"[TuftsSCO PreAuth step5] File not found on disk: {abs_path}") + continue + + print(f"[TuftsSCO PreAuth step5] Attaching: {abs_path}") + try: + add_file_btn = WebDriverWait(self.driver, 10).until( + EC.element_to_be_clickable((By.XPATH, + "//button[.//span[contains(text(),'Add a file')]] | " + "//button[contains(normalize-space(text()),'Add a file')] | " + "//*[contains(text(),'Add a file') and (@role='button' or self::label)]" + )) + ) + self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", add_file_btn) + ActionChains(self.driver).move_to_element(add_file_btn).click().perform() + time.sleep(1) + + 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"[TuftsSCO PreAuth step5] Attached: {os.path.basename(abs_path)}") + attached += 1 + except Exception as e: + print(f"[TuftsSCO PreAuth step5] Could not attach {abs_path}: {e}") + + print(f"[TuftsSCO PreAuth step5] Attached {attached}/{len(self.claimFiles)} file(s)") + return "SUCCESS" + + # ── Step 6: Click "Next step" ────────────────────────────────────────────── + + def step6_click_next(self): + """Click the 'Next step' button.""" + try: + print(f"[TuftsSCO PreAuth step6] Current URL: {self.driver.current_url}") + btn = WebDriverWait(self.driver, 15).until( + EC.element_to_be_clickable((By.XPATH, + "//button[@data-testid='next-step-btn'] | " + "//button[@aria-label='Next step']" + )) + ) + self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", btn) + time.sleep(0.5) + self.driver.execute_script(""" + var el = arguments[0]; + el.dispatchEvent(new PointerEvent('pointerover', {bubbles:true, cancelable:true, composed:true})); + el.dispatchEvent(new PointerEvent('pointerdown', {bubbles:true, cancelable:true, composed:true})); + el.dispatchEvent(new PointerEvent('pointerup', {bubbles:true, cancelable:true, composed:true})); + el.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true, composed:true})); + """, btn) + print("[TuftsSCO PreAuth step6] Clicked 'Next step'") + time.sleep(2) + print(f"[TuftsSCO PreAuth step6] URL after Next: {self.driver.current_url}") + return "SUCCESS" + except Exception as e: + print(f"[TuftsSCO PreAuth step6] Exception: {e}") + return f"ERROR: step6 failed: {e}" + + # ── Step 7: Acknowledge + submit ──────────────────────────────────────────── + + def step7_submit_preauth(self): + """On the pre-auth summary page, tick the acknowledgement checkbox then submit.""" + try: + print(f"[TuftsSCO PreAuth step7] Current URL: {self.driver.current_url}") + + checkbox = WebDriverWait(self.driver, 15).until( + EC.presence_of_element_located((By.XPATH, + "//input[@type='checkbox'] | " + "//*[@role='checkbox'] | " + "//label[contains(.,'submitting this')]//input | " + "//*[contains(@aria-label,'submitting this')]" + )) + ) + self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", checkbox) + time.sleep(0.3) + self.driver.execute_script(""" + var el = arguments[0]; + el.dispatchEvent(new PointerEvent('pointerover', {bubbles:true, cancelable:true, composed:true})); + el.dispatchEvent(new PointerEvent('pointerdown', {bubbles:true, cancelable:true, composed:true})); + el.dispatchEvent(new PointerEvent('pointerup', {bubbles:true, cancelable:true, composed:true})); + el.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true, composed:true})); + """, checkbox) + print("[TuftsSCO PreAuth step7] Checked acknowledgement checkbox") + time.sleep(0.5) + + # Try pre-auth submit button first, fall back to generic submit + submit_btn = None + for xpath in [ + "//button[.//span[contains(text(),'Submit prior authorization')]] | //button[contains(normalize-space(text()),'Submit prior authorization')] | //button[@aria-label='Submit prior authorization']", + "//button[.//span[contains(text(),'Submit pre-authorization')]] | //button[contains(normalize-space(text()),'Submit pre-authorization')]", + "//button[.//span[contains(text(),'Submit claim')]] | //button[contains(normalize-space(text()),'Submit claim')] | //button[@aria-label='Submit claim']", + ]: + try: + submit_btn = WebDriverWait(self.driver, 5).until( + EC.element_to_be_clickable((By.XPATH, xpath)) + ) + print(f"[TuftsSCO PreAuth step7] Found submit button") + break + except TimeoutException: + continue + + if submit_btn is None: + return "ERROR: step7 failed: could not find submit button" + + self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", submit_btn) + time.sleep(0.3) + self.driver.execute_script(""" + var el = arguments[0]; + el.dispatchEvent(new PointerEvent('pointerover', {bubbles:true, cancelable:true, composed:true})); + el.dispatchEvent(new PointerEvent('pointerdown', {bubbles:true, cancelable:true, composed:true})); + el.dispatchEvent(new PointerEvent('pointerup', {bubbles:true, cancelable:true, composed:true})); + el.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true, composed:true})); + """, submit_btn) + print("[TuftsSCO PreAuth step7] Clicked submit button") + time.sleep(2) + print(f"[TuftsSCO PreAuth step7] URL after submit: {self.driver.current_url}") + return "SUCCESS" + + except Exception as e: + print(f"[TuftsSCO PreAuth step7] Exception: {e}") + return f"ERROR: step7 failed: {e}" + + # ── Step 8: Extract pre-auth number + save confirmation PDF ─────────────── + + def step8_save_confirmation_pdf(self): + """Wait for the confirmation page, extract the pre-auth number, save page as PDF.""" + try: + WebDriverWait(self.driver, 30).until( + lambda d: ( + "thank" in d.page_source.lower() + or "submitted" in d.page_source.lower() + or "prior auth" in d.page_source.lower() + or "pre-auth" in d.page_source.lower() + ) + ) + time.sleep(2) + print(f"[TuftsSCO PreAuth step8] Confirmation page URL: {self.driver.current_url}") + + preauth_number = None + try: + body_text = self.driver.find_element(By.TAG_NAME, "body").text + for pattern in [ + r'prior auth(?:orization)?\s+(?:number\s+)?(\d{8,})', + r'pre-auth(?:orization)?\s+(?:number\s+)?(\d{8,})', + r'submitted\s+(?:prior auth|pre-auth|authorization)\s+(\d{8,})', + r'\b(\d{12,})\b', + ]: + match = re.search(pattern, body_text, re.IGNORECASE) + if match: + preauth_number = match.group(1) + print(f"[TuftsSCO PreAuth step8] Extracted pre-auth number: {preauth_number}") + break + except Exception as e: + print(f"[TuftsSCO PreAuth step8] Could not extract pre-auth number: {e}") + + 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_preauth = ("_" + preauth_number[:20]) if preauth_number else "" + timestamp = time.strftime("%Y%m%d_%H%M%S") + pdf_filename = f"tuftssco_preauth_confirmation_{safe_member}{safe_preauth}_{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"[TuftsSCO PreAuth step8] PDF saved: {pdf_path}") + except Exception as e: + print(f"[TuftsSCO PreAuth step8] PDF capture failed: {e}") + return f"ERROR: step8 PDF failed: {e}" + + return { + "status": "success", + "pdf_path": pdf_path, + "preAuthNumber": preauth_number, + } + + except Exception as e: + print(f"[TuftsSCO PreAuth step8] Exception: {e}") + return f"ERROR: step8 failed: {e}" diff --git a/apps/SeleniumService/selenium_UnitedDH_preAuthWorker.py b/apps/SeleniumService/selenium_UnitedDH_preAuthWorker.py new file mode 100644 index 00000000..a5207eb4 --- /dev/null +++ b/apps/SeleniumService/selenium_UnitedDH_preAuthWorker.py @@ -0,0 +1,1237 @@ +""" +United/DentalHub Pre-Authorization Worker. +Portal: app.dentalhub.com (United SCO) + +Flow (mirrors claim worker): + 1. Navigate to eligibility page, fill member ID + DOB + payer, continue through + Select Insurance popup and Provider & Location page → land on Selected Patient page. + 2. Click Submit Pre-Authorization button on Selected Patient page. + 3. Pre-auth page is pre-filled — select payer and click Continue. + 4. Select Insurance popup — click Ok. + 5. Practitioner & Location page — click Continue only (no dropdowns). + 6. Date Entry / pre-auth form — fill CDT codes, tooth, billed amount, attach files, submit. + 7. Capture pre-auth number and PDF from confirmation page. +""" +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 +import re + +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 AutomationUnitedDHPreAuth: + 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", "") + + self.uniteddh_username = claim.get("uniteddhUsername", "") + self.uniteddh_password = claim.get("uniteddhPassword", "") + + 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): + try: + print("[UnitedDH PreAuth 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 PreAuth login] Clicked logout button") + time.sleep(2) + break + except TimeoutException: + continue + except Exception as e: + print(f"[UnitedDH PreAuth login] Could not click logout button: {e}") + + try: + self.driver.delete_all_cookies() + print("[UnitedDH PreAuth login] Cleared all cookies") + except Exception as e: + print(f"[UnitedDH PreAuth login] Error clearing cookies: {e}") + + browser_manager.clear_credentials_hash() + return True + except Exception as e: + print(f"[UnitedDH PreAuth 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) + + try: + current_url = self.driver.current_url + print(f"[UnitedDH PreAuth 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 PreAuth login] Already logged in") + return "ALREADY_LOGGED_IN" + except TimeoutException: + pass + except Exception as e: + print(f"[UnitedDH PreAuth login] Error checking current state: {e}") + + self.driver.get(url) + time.sleep(3) + + current_url = self.driver.current_url + print(f"[UnitedDH PreAuth login] After navigation URL: {current_url}") + + if "app.dentalhub.com" in current_url and "login" not in current_url.lower(): + print("[UnitedDH PreAuth login] Already on dashboard") + return "ALREADY_LOGGED_IN" + + 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 PreAuth login] OTP input found") + return "OTP_REQUIRED" + except TimeoutException: + pass + + 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 PreAuth login] Clicked LOGIN button") + time.sleep(5) + except TimeoutException: + print("[UnitedDH PreAuth login] No LOGIN button found, proceeding...") + + current_url = self.driver.current_url + print(f"[UnitedDH PreAuth login] After LOGIN click URL: {current_url}") + + if "b2clogin.com" in current_url or "login" in current_url.lower(): + print("[UnitedDH PreAuth login] On B2C login page - filling credentials") + + 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 PreAuth 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 PreAuth 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 PreAuth 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 PreAuth login] Clicked Sign in button") + + if self.uniteddh_username: + browser_manager.save_credentials_hash(self.uniteddh_username) + + time.sleep(5) + + 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 PreAuth 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 PreAuth login] Selected 'Phone' radio button") + except Exception as radio_err: + print(f"[UnitedDH PreAuth login] Could not click Phone radio: {radio_err}") + time.sleep(1) + continue_btn.click() + print("[UnitedDH PreAuth login] Clicked 'Continue' on MFA selection page") + time.sleep(3) + except Exception: + pass + + 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 PreAuth 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 + + 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 PreAuth login] OTP input appeared after sign-in") + return "OTP_REQUIRED" + except TimeoutException: + pass + + current_url = self.driver.current_url + if "app.dentalhub.com" in current_url and "login" not in current_url.lower(): + print("[UnitedDH PreAuth login] Login succeeded without OTP") + return "SUCCESS" + + print(f"[UnitedDH PreAuth login] Unexpected state - URL: {current_url}") + return "SUCCESS" + + except Exception as e: + return f"ERROR: Login failed - {e}" + + 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): + 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 PreAuth] 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 PreAuth] 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): + 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 _hide_browser(self): + try: + try: + self.driver.get("about:blank") + time.sleep(0.5) + except Exception: + pass + try: + self.driver.minimize_window() + print("[UnitedDH PreAuth] Browser window minimized") + return + except Exception: + pass + try: + self.driver.set_window_position(-10000, -10000) + print("[UnitedDH PreAuth] Browser window moved off-screen") + return + except Exception: + pass + try: + import subprocess + subprocess.run(["xdotool", "getactivewindow", "windowminimize"], + timeout=3, capture_output=True) + print("[UnitedDH PreAuth] Browser minimized via xdotool") + except Exception: + pass + except Exception as e: + print(f"[UnitedDH PreAuth] Could not hide browser: {e}") + + # ── Pre-auth steps ───────────────────────────────────────────────────────── + + def step1_search_patient(self): + """ + Navigate to the eligibility page, fill member ID + DOB + payer, continue through + the Select Insurance popup and Provider & Location dropdowns to land on the + Selected Patient results page. (Identical to the claim worker's step1.) + """ + from selenium.webdriver.common.action_chains import ActionChains + + try: + print(f"[UnitedDH PreAuth] step1: memberId={self.memberId}, dob={self.dateOfBirth}") + + self.driver.get("https://app.dentalhub.com/app/patient/eligibility") + time.sleep(3) + print(f"[UnitedDH PreAuth] step1 URL: {self.driver.current_url}") + + try: + WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located((By.ID, "firstName_Back")) + ) + print("[UnitedDH PreAuth] step1: Patient Information form loaded") + except TimeoutException: + print("[UnitedDH PreAuth] step1: Patient Information form not found") + return "ERROR: step1 - Patient Information form not found" + + # 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[@id='medicaidId_Back']", + "//label[contains(text(),'Subscriber ID')]/..//input[not(@id='firstName_Back') and not(@id='lastName_Back') and not(@id='dateOfBirth_Back')]", + "//input[contains(@placeholder,'Subscriber') or contains(@placeholder,'subscriber')]", + "//input[contains(@placeholder,'Medicaid') or contains(@placeholder,'medicaid')]", + "//input[contains(@placeholder,'Member') or contains(@placeholder,'member')]", + ] + subscriber_filled = False + for sel in subscriber_id_selectors: + try: + sid_input = self.driver.find_element(By.XPATH, sel) + if sid_input.is_displayed(): + sid_input.clear() + sid_input.send_keys(self.memberId) + field_id = sid_input.get_attribute("id") or "unknown" + print(f"[UnitedDH PreAuth] step1: Subscriber ID entered: {self.memberId} (field='{field_id}')") + subscriber_filled = True + break + except Exception: + continue + + if not subscriber_filled: + try: + all_inputs = self.driver.find_elements(By.XPATH, "//form//input[@type='text' or not(@type)]") + known_ids = {'firstName_Back', 'lastName_Back', 'dateOfBirth_Back', 'procedureDate_Back', 'insurerId'} + for inp in all_inputs: + inp_id = inp.get_attribute("id") or "" + if inp_id not in known_ids and inp.is_displayed(): + inp.clear() + inp.send_keys(self.memberId) + print(f"[UnitedDH PreAuth] step1: Subscriber ID in fallback field id='{inp_id}'") + subscriber_filled = True + break + except Exception as e2: + print(f"[UnitedDH PreAuth] step1: Fallback subscriber field error: {e2}") + + if not subscriber_filled: + print(f"[UnitedDH PreAuth] step1: WARNING - Could not find Subscriber ID field") + + # Fill Date of Birth + try: + dob_input = self.driver.find_element(By.ID, "dateOfBirth_Back") + dob_input.clear() + dob_formatted = self._format_dob(self.dateOfBirth) + dob_input.send_keys(dob_formatted) + print(f"[UnitedDH PreAuth] step1: DOB entered: {dob_formatted}") + except Exception as e: + print(f"[UnitedDH PreAuth] step1: Error entering DOB: {e}") + return "ERROR: step1 - Could not enter Date of Birth" + + time.sleep(1) + + # Dismiss any blocking overlays + try: + self.driver.execute_script(""" + var dialogs = document.querySelectorAll('[role="dialog"], .cdk-overlay-container'); + dialogs.forEach(function(d) { d.style.display = 'none'; }); + """) + except Exception: + pass + + # Select Payer: UnitedHealthcare Massachusetts + print("[UnitedDH PreAuth] 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')]]", + ] + 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) + search_input = payer_ng_select.find_element(By.XPATH, + ".//input[contains(@type,'text') or contains(@role,'combobox')]") + search_input.clear() + search_input.send_keys("UnitedHealthcare Massachusetts") + print("[UnitedDH PreAuth] step1: Typed payer search text") + time.sleep(2) + search_input.send_keys(Keys.ENTER) + print("[UnitedDH PreAuth] step1: Pressed Enter to select Payer") + time.sleep(0.5) + payer_selected = True + else: + print("[UnitedDH PreAuth] step1: Could not find Payer ng-select element") + except Exception as e: + print(f"[UnitedDH PreAuth] step1: Payer selection error: {e}") + + if not payer_selected: + print("[UnitedDH PreAuth] step1: WARNING - Could not select Payer") + + time.sleep(1) + + # Click Continue (Patient Info) + try: + continue_btn = WebDriverWait(self.driver, 10).until( + EC.element_to_be_clickable((By.XPATH, "//button[contains(text(),'Continue')]")) + ) + continue_btn.click() + print("[UnitedDH PreAuth] step1: Clicked Continue (Patient Info)") + time.sleep(3) + + 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}" + + # Click Ok on Select Insurance popup + print("[UnitedDH PreAuth] step1: Checking for Select Insurance popup...") + try: + ok_btn = WebDriverWait(self.driver, 10).until( + EC.element_to_be_clickable((By.XPATH, + "//button[contains(@class,'btn-primary') and " + "(normalize-space(.)='Ok' or normalize-space(.)='OK' or normalize-space(.)='Okay')] | " + "//modal-container//button[normalize-space(.)='Ok' or normalize-space(.)='OK' or normalize-space(.)='Okay'] | " + "//div[contains(@class,'modal')]//button[normalize-space(.)='Ok' or normalize-space(.)='OK' or normalize-space(.)='Okay']" + )) + ) + try: + self.driver.execute_script("arguments[0].click();", ok_btn) + print("[UnitedDH PreAuth] step1: Clicked OK on Select Insurance popup (JS)") + except Exception: + ok_btn.click() + print("[UnitedDH PreAuth] step1: Clicked OK on Select Insurance popup (direct)") + try: + WebDriverWait(self.driver, 10).until(EC.staleness_of(ok_btn)) + print("[UnitedDH PreAuth] step1: Select Insurance modal closed") + except TimeoutException: + print("[UnitedDH PreAuth] step1: Modal staleness timeout — continuing anyway") + WebDriverWait(self.driver, 5).until( + EC.presence_of_element_located((By.XPATH, + "//label[@for='treatmentLocation'] | //label[@for='paymentGroupId']")) + ) + except TimeoutException: + print("[UnitedDH PreAuth] step1: Select Insurance popup not found — proceeding") + + # Provider & Location page — select Treatment Location and Billing Entity, then Continue + print("[UnitedDH PreAuth] step1: Waiting for Provider & Location page...") + try: + WebDriverWait(self.driver, 20).until( + EC.visibility_of_element_located((By.XPATH, "//label[@for='paymentGroupId']")) + ) + + print("[UnitedDH PreAuth] step1: Selecting Treatment Location...") + location_selected = False + try: + location_ng = WebDriverWait(self.driver, 10).until( + EC.element_to_be_clickable((By.XPATH, + "//label[@for='treatmentLocation']/following-sibling::ng-select | " + "//label[@for='treatmentLocation']/..//ng-select" + )) + ) + self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", location_ng) + arrow = location_ng.find_element(By.CSS_SELECTOR, ".ng-arrow-wrapper") + arrow.click() + first_option = WebDriverWait(self.driver, 5).until( + EC.element_to_be_clickable((By.CSS_SELECTOR, ".ng-dropdown-panel .ng-option")) + ) + option_text = first_option.text.strip() + first_option.click() + print(f"[UnitedDH PreAuth] step1: Selected Treatment Location: {option_text}") + location_selected = True + except Exception as e: + print(f"[UnitedDH PreAuth] step1: Treatment Location selection failed: {e}") + + if not location_selected: + print("[UnitedDH PreAuth] step1: WARNING - Could not select Treatment Location") + + print("[UnitedDH PreAuth] step1: Selecting Billing Entity...") + billing_selected = False + try: + billing_ng = WebDriverWait(self.driver, 10).until( + EC.element_to_be_clickable((By.XPATH, + "//label[@for='paymentGroupId']/following-sibling::ng-select | " + "//label[@for='paymentGroupId']/..//ng-select" + )) + ) + self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", billing_ng) + arrow = billing_ng.find_element(By.CSS_SELECTOR, ".ng-arrow-wrapper") + arrow.click() + first_option = WebDriverWait(self.driver, 5).until( + EC.element_to_be_clickable((By.CSS_SELECTOR, ".ng-dropdown-panel .ng-option")) + ) + option_text = first_option.text.strip() + first_option.click() + print(f"[UnitedDH PreAuth] step1: Selected Billing Entity: {option_text}") + billing_selected = True + except Exception as e: + print(f"[UnitedDH PreAuth] step1: Billing Entity selection failed: {e}") + + if not billing_selected: + print("[UnitedDH PreAuth] step1: WARNING - Could not select Billing Entity") + + continue_btn2 = WebDriverWait(self.driver, 10).until( + EC.element_to_be_clickable((By.XPATH, "//button[contains(text(),'Continue')]")) + ) + continue_btn2.click() + print("[UnitedDH PreAuth] step1: Clicked Continue (Provider & Location) → Selected Patient page") + time.sleep(5) + + except TimeoutException: + try: + results_elem = self.driver.find_element(By.XPATH, + "//*[contains(text(),'Selected Patient') or contains(@id,'patient-name') or contains(@id,'eligibility')]" + ) + if results_elem.is_displayed(): + print("[UnitedDH PreAuth] step1: Already on Selected Patient page") + return "OK" + except Exception: + pass + print("[UnitedDH PreAuth] step1: Continue not found on Provider & Location page — proceeding") + except Exception as e: + print(f"[UnitedDH PreAuth] step1: Error clicking Continue on Provider & Location page: {e}") + error_result = self._check_for_error_dialog() + if error_result: + return error_result + + error_result = self._check_for_error_dialog() + if error_result: + return error_result + + print("[UnitedDH PreAuth] step1: Patient search complete — on Selected Patient page") + return "OK" + + except Exception as e: + return f"ERROR: step1_search_patient - {e}" + + def step2_click_preauth_button(self): + """ + On the Selected Patient results page, click the Submit Pre-Authorization button. + Tries pre-auth-specific IDs first, then falls back to text matching. + """ + try: + print("[UnitedDH PreAuth] step2: Looking for Submit Pre-Authorization button...") + time.sleep(2) + + # Try pre-auth specific button IDs first + preauth_selectors = [ + (By.ID, "btnSubmitAuthorization"), + (By.ID, "btnSubmitPreAuth"), + (By.ID, "btnPreAuth"), + (By.ID, "btnPreAuthorization"), + (By.XPATH, + "//button[contains(normalize-space(.),'Submit Authorization') or " + "contains(normalize-space(.),'Pre-Auth') or " + "contains(normalize-space(.),'Pre Authorization') or " + "contains(normalize-space(.),'PreAuth') or " + "contains(normalize-space(.),'Prior Auth')]" + ), + ] + + for by, selector in preauth_selectors: + try: + btn = WebDriverWait(self.driver, 5).until( + EC.element_to_be_clickable((by, selector)) + ) + self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", btn) + time.sleep(0.5) + btn.click() + print(f"[UnitedDH PreAuth] step2: Clicked pre-auth button ({selector})") + time.sleep(4) + print(f"[UnitedDH PreAuth] step2 URL: {self.driver.current_url}") + return "OK" + except (TimeoutException, Exception): + continue + + return "ERROR: step2 - Could not find Submit Pre-Authorization button on Selected Patient page" + + except Exception as e: + return f"ERROR: step2_click_preauth_button - {e}" + + def step3_continue_prefilled(self): + """ + Pre-auth page: member ID and DOB are pre-filled. + Select Payer by typing "UnitedHealthcare Massachusetts" + Enter, then click Continue. + """ + try: + print("[UnitedDH PreAuth] step3: Pre-auth page — selecting Payer then clicking Continue...") + + WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located((By.XPATH, + "//label[contains(text(),'Payer')] | //ng-select" + )) + ) + + 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')]]", + ] + 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) + search_input = payer_ng_select.find_element(By.XPATH, + ".//input[contains(@type,'text') or contains(@role,'combobox')]") + search_input.clear() + search_input.send_keys("UnitedHealthcare Massachusetts") + print("[UnitedDH PreAuth] step3: Typed payer search text") + time.sleep(2) + search_input.send_keys(Keys.ENTER) + print("[UnitedDH PreAuth] step3: Pressed Enter to select Payer") + time.sleep(0.5) + payer_selected = True + else: + print("[UnitedDH PreAuth] step3: Could not find Payer ng-select element") + except Exception as e: + print(f"[UnitedDH PreAuth] step3: Payer selection error: {e}") + + if not payer_selected: + print("[UnitedDH PreAuth] step3: WARNING - Could not select Payer") + + time.sleep(1) + + continue_btn = WebDriverWait(self.driver, 15).until( + EC.element_to_be_clickable((By.XPATH, "//button[contains(text(),'Continue')]")) + ) + self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", continue_btn) + continue_btn.click() + print("[UnitedDH PreAuth] step3: Clicked Continue") + time.sleep(4) + + error_result = self._check_for_error_dialog() + if error_result: + return error_result + + print(f"[UnitedDH PreAuth] step3 URL: {self.driver.current_url}") + return "OK" + + except Exception as e: + return f"ERROR: step3_continue_prefilled - {e}" + + def step4_select_insurance_ok(self): + """Click Ok on the Select Insurance popup.""" + try: + print("[UnitedDH PreAuth] step4: Waiting for Select Insurance popup...") + + 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')] | " + "//modal-container//button[normalize-space(.)='Ok' or normalize-space(.)='OK'] | " + "//div[contains(@class,'modal')]//button[normalize-space(.)='Ok' or normalize-space(.)='OK']" + )) + ) + ActionChains(self.driver).move_to_element(ok_btn).pause(0.5).click().perform() + print("[UnitedDH PreAuth] step4: Clicked Ok on Select Insurance popup") + try: + WebDriverWait(self.driver, 8).until(EC.staleness_of(ok_btn)) + print("[UnitedDH PreAuth] step4: Select Insurance modal closed") + except TimeoutException: + print("[UnitedDH PreAuth] step4: Modal staleness timeout — continuing anyway") + except TimeoutException: + print("[UnitedDH PreAuth] step4: Select Insurance popup not found — proceeding") + + print(f"[UnitedDH PreAuth] step4 URL: {self.driver.current_url}") + return "OK" + + except Exception as e: + return f"ERROR: step4_select_insurance_ok - {e}" + + def step5_practitioner_continue(self): + """Practitioner & Location page — click Continue only, no dropdown selections.""" + try: + print("[UnitedDH PreAuth] step5: Waiting for Practitioner & Location page...") + + try: + WebDriverWait(self.driver, 20).until( + EC.visibility_of_element_located((By.XPATH, + "//label[@for='treatmentLocation'] | //label[@for='paymentGroupId'] | " + "//label[@for='paymentGroup']" + )) + ) + print("[UnitedDH PreAuth] step5: Practitioner & Location page loaded") + except TimeoutException: + print("[UnitedDH PreAuth] step5: Practitioner & Location labels not found — trying Continue anyway") + + 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')] | " + "//button[contains(normalize-space(text()),'Continue')]" + )) + ) + self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", continue_btn) + continue_btn.click() + print("[UnitedDH PreAuth] step5: Clicked Continue — waiting for Code Entry page") + time.sleep(3) + + try: + WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located((By.ID, "procedureCode")) + ) + print("[UnitedDH PreAuth] step5: Code Entry page loaded (procedureCode found)") + except TimeoutException: + print("[UnitedDH PreAuth] step5: procedureCode input not found — proceeding anyway") + + print(f"[UnitedDH PreAuth] step5 URL: {self.driver.current_url}") + return "OK" + + except Exception as e: + return f"ERROR: step5_practitioner_continue - {e}" + + def step6_fill_preauth_form(self): + """ + Fill CDT codes, tooth, billed amount for each service line. + Same structure as the claim form step. + """ + try: + active_lines = [ + ln for ln in self.serviceLines + if str(ln.get("procedureCode") or "").strip() + ] + print(f"[UnitedDH PreAuth] step6: {len(active_lines)} service line(s)") + + if not active_lines: + print("[UnitedDH PreAuth] step6: 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() + tooth = str(line.get("toothNumber") or line.get("tooth_number") or "").strip() + surface = str(line.get("toothSurface") or line.get("tooth_surface") or "").strip().upper() + print(f"[UnitedDH PreAuth] step6: line {idx}: code={code}, billed={billed}, tooth={tooth}, surface={surface}") + + # Click btnAddItem to open/activate the procedure row + try: + add_btn = WebDriverWait(self.driver, 10).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 PreAuth] step6: clicked btnAddItem to open row {idx}") + time.sleep(1) + except Exception as e: + print(f"[UnitedDH PreAuth] step6: could not click btnAddItem to open row {idx}: {e}") + + # Type CDT code + try: + proc_input = WebDriverWait(self.driver, 10).until( + EC.element_to_be_clickable((By.ID, "procedureCode")) + ) + self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", proc_input) + self.driver.execute_script("arguments[0].click();", proc_input) + proc_input.send_keys(Keys.CONTROL + "a") + proc_input.send_keys(Keys.DELETE) + proc_input.send_keys(code) + print(f"[UnitedDH PreAuth] step6: typed procedure code: {code}") + time.sleep(0.5) + except Exception as e: + print(f"[UnitedDH PreAuth] step6: 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 PreAuth] step6: clicked btnAddItem to reveal billedAmount for row {idx}") + time.sleep(1.5) + except Exception as e: + print(f"[UnitedDH PreAuth] step6: could not click btnAddItem for billed amount row {idx}: {e}") + continue + + # Fill tooth number + if tooth: + try: + tooth_input = WebDriverWait(self.driver, 5).until( + EC.element_to_be_clickable((By.ID, "tooth")) + ) + self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", tooth_input) + tooth_input.click() + tooth_input.send_keys(Keys.CONTROL + "a") + tooth_input.send_keys(Keys.DELETE) + tooth_input.send_keys(tooth) + print(f"[UnitedDH PreAuth] step6: entered tooth number: {tooth}") + time.sleep(0.3) + except Exception as e: + print(f"[UnitedDH PreAuth] step6: could not fill tooth number for row {idx}: {e}") + + # Click surface boxes + if surface: + try: + surface_boxes = self.driver.find_elements(By.XPATH, + "//div[contains(@class,'claim-add-item-group__box')]") + if surface_boxes: + for letter in surface: + if not letter.strip(): + continue + try: + box = self.driver.find_element(By.XPATH, + f"//div[contains(@class,'claim-add-item-group__box') " + f"and not(contains(@class,'--disabled')) " + f"and @id='{letter}']" + ) + self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", box) + box.click() + print(f"[UnitedDH PreAuth] step6: clicked surface '{letter}'") + time.sleep(0.2) + except Exception: + print(f"[UnitedDH PreAuth] step6: surface '{letter}' not found or disabled") + else: + print(f"[UnitedDH PreAuth] step6: no surface boxes on page — skipping") + except Exception as e: + print(f"[UnitedDH PreAuth] step6: surface click error for row {idx}: {e}") + + # 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 PreAuth] step6: entered billed amount: {billed}") + time.sleep(0.5) + except Exception as e: + print(f"[UnitedDH PreAuth] step6: 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 PreAuth] step6: clicked span Add — row {idx} confirmed") + time.sleep(1) + except Exception as e: + print(f"[UnitedDH PreAuth] step6: could not click span Add for row {idx}: {e}") + + # Other coverage: click "No" (second radio button) + try: + print("[UnitedDH PreAuth] step6: selecting 'No' for Other coverage") + 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 PreAuth] step6: Clicked 'No' (2nd radio) for Other coverage") + else: + print(f"[UnitedDH PreAuth] step6: Only {len(radio_buttons)} radio button(s) found — skipping") + time.sleep(0.5) + except Exception as e: + print(f"[UnitedDH PreAuth] step6: Could not click 'No' for Other coverage (non-fatal): {e}") + + print("[UnitedDH PreAuth] step6: Done filling pre-auth form") + return "OK" + + except Exception as e: + return f"ERROR: step6_fill_preauth_form - {e}" + + def step7_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 PreAuth] step7: No files to attach") + return "OK" + + 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 PreAuth] step7: Clicked caret-up to expand Attached Documents") + time.sleep(1) + except Exception as e: + print(f"[UnitedDH PreAuth] step7: 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 PreAuth] step7: 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 PreAuth] step7: File not found on disk: {abs_path}") + continue + + print(f"[UnitedDH PreAuth] step7: Attaching: {abs_path}") + try: + 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) + + 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 PreAuth] step7: Attached: {os.path.basename(abs_path)}") + attached += 1 + except Exception as e: + print(f"[UnitedDH PreAuth] step7: Could not attach {abs_path}: {e}") + + print(f"[UnitedDH PreAuth] step7: Attached {attached}/{len(self.claimFiles)} file(s)") + return "OK" + + except Exception as e: + return f"ERROR: step7_attach_files - {e}" + + def step8_submit_preauth(self): + """ + Click Submit Pre-Authorization (or Submit Claim) on the form page, then click + "View Status and History" on the post-submit popup. + """ + try: + print(f"[UnitedDH PreAuth] step8: submitting pre-auth — URL: {self.driver.current_url}") + + submit_btn = WebDriverWait(self.driver, 15).until( + EC.element_to_be_clickable((By.XPATH, + "//button[contains(normalize-space(.),'Submit Pre-Auth') or " + "contains(normalize-space(.),'Submit Pre Authorization') or " + "contains(normalize-space(.),'Submit Pre-Authorization') or " + "contains(normalize-space(.),'Submit Claim')] | " + "//button[contains(@class,'btn-primary') and (" + "contains(normalize-space(text()),'Submit Pre') or " + "contains(normalize-space(text()),'Submit Claim'))]" + )) + ) + self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", submit_btn) + time.sleep(0.5) + submit_btn.click() + print("[UnitedDH PreAuth] step8: Clicked Submit — waiting for post-submit popup") + time.sleep(3) + + 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 PreAuth] step8: Clicked 'View Status and History'") + time.sleep(3) + except TimeoutException: + print("[UnitedDH PreAuth] step8: Post-submit popup not found — proceeding to step9") + + print(f"[UnitedDH PreAuth] step8: URL after popup: {self.driver.current_url}") + return "OK" + + except Exception as e: + return f"ERROR: step8_submit_preauth - {e}" + + def step9_save_confirmation_pdf(self): + """ + On the Status & History page, read the pre-auth/reference number from the + first row, then save the page as PDF. + """ + try: + print("[UnitedDH PreAuth] step9: waiting for Status & History page") + + WebDriverWait(self.driver, 40).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(4) + print(f"[UnitedDH PreAuth] step9: Status & History URL: {self.driver.current_url}") + + self.driver.refresh() + print("[UnitedDH PreAuth] step9: Page refreshed — waiting for table to reload") + WebDriverWait(self.driver, 30).until( + EC.presence_of_element_located((By.XPATH, "//table//tr[td]")) + ) + time.sleep(4) + + preauth_number = None + try: + first_ref = WebDriverWait(self.driver, 20).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() + match = re.search(r'\b(\d{14})\b', ref_text) + if match: + preauth_number = match.group(1) + else: + match = re.search(r'\b(\d{10,})\b', ref_text) + if match: + preauth_number = match.group(1) + print(f"[UnitedDH PreAuth] step9: Pre-auth number: {preauth_number!r} (cell: {ref_text!r})") + except Exception as e: + print(f"[UnitedDH PreAuth] step9: Could not read first-row reference number: {e}") + try: + body_text = self.driver.find_element(By.TAG_NAME, "body").text + match = re.search(r'\b(\d{14})\b', body_text) + if match: + preauth_number = match.group(1) + print(f"[UnitedDH PreAuth] step9: Pre-auth number (body scan): {preauth_number}") + except Exception: + pass + + 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_preauth = ("_" + preauth_number[:20]) if preauth_number else "" + timestamp = time.strftime("%Y%m%d_%H%M%S") + pdf_filename = f"uniteddh_preauth_confirmation_{safe_member}{safe_preauth}_{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 PreAuth] step9: PDF saved: {pdf_path}") + except Exception as e: + print(f"[UnitedDH PreAuth] step9: PDF capture failed: {e}") + return f"ERROR: step9 PDF failed: {e}" + + self._hide_browser() + + return { + "status": "success", + "pdf_path": pdf_path, + "preAuthNumber": preauth_number, + } + + except Exception as e: + return f"ERROR: step9_save_confirmation_pdf - {e}" + + # ── Main workflow ────────────────────────────────────────────────────────── + + def main_workflow(self, url): + 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_search_patient() + 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_click_preauth_button() + print(f"[main_workflow] step2 result: {step2_result}") + if isinstance(step2_result, str) and step2_result.startswith("ERROR"): + return {"status": "error", "message": step2_result} + + step3_result = self.step3_continue_prefilled() + print(f"[main_workflow] step3 result: {step3_result}") + if isinstance(step3_result, str) and step3_result.startswith("ERROR"): + return {"status": "error", "message": step3_result} + + step4_result = self.step4_select_insurance_ok() + print(f"[main_workflow] step4 result: {step4_result}") + if isinstance(step4_result, str) and step4_result.startswith("ERROR"): + return {"status": "error", "message": step4_result} + + step5_result = self.step5_practitioner_continue() + print(f"[main_workflow] step5 result: {step5_result}") + if isinstance(step5_result, str) and step5_result.startswith("ERROR"): + return {"status": "error", "message": step5_result} + + step6_result = self.step6_fill_preauth_form() + print(f"[main_workflow] step6 result: {step6_result}") + if isinstance(step6_result, str) and step6_result.startswith("ERROR"): + return {"status": "error", "message": step6_result} + + step7_result = self.step7_attach_files() + print(f"[main_workflow] step7 result: {step7_result}") + if isinstance(step7_result, str) and step7_result.startswith("ERROR"): + return {"status": "error", "message": step7_result} + + step8_result = self.step8_submit_preauth() + print(f"[main_workflow] step8 result: {step8_result}") + if isinstance(step8_result, str) and step8_result.startswith("ERROR"): + return {"status": "error", "message": step8_result} + + step9_result = self.step9_save_confirmation_pdf() + print(f"[main_workflow] step9 result: {step9_result}") + return step9_result + + except Exception as e: + return {"status": "error", "message": str(e)}