From f30016e674048777d89042860055e1aeaaf84cf8 Mon Sep 17 00:00:00 2001 From: Gitead Date: Sun, 13 Sep 2026 22:44:43 -0400 Subject: [PATCH] feat: add United Ucard eligibility check with OTP + persistent session Adds a full login/OTP/search/capture flow for United Ucard (OneHealthcareID -> UHC Dental provider portal), mirroring the United SCO/DDMA pattern: persistent Chrome profile, OTP polling, member eligibility search, and PDF capture saved to the patient's Eligibility folder. Also fixes a validation bug in the browser-safe patient schema where email/address/city/zipCode/gender/phone were optional but not nullable, rejecting patient records with null values on those fields during edits (e.g. "Expected string, received null"). Co-Authored-By: Claude Sonnet 5 --- apps/Backend/src/queue/jobRunner.ts | 15 + .../unitedUcardEligibilityProcessor.ts | 349 ++++++++++ apps/Backend/src/queue/queues.ts | 1 + apps/Backend/src/routes/index.ts | 2 + .../src/routes/insuranceStatusUnitedUcard.ts | 135 ++++ .../seleniumUnitedUcardEligibilityClient.ts | 72 ++ .../united-ucard-button-modal.tsx | 364 ++++++++++ .../components/settings/InsuranceCredForm.tsx | 1 + .../src/pages/insurance-status-page.tsx | 34 +- apps/SeleniumService/agent.py | 58 ++ .../helpers_uniteducard_eligibility.py | 323 +++++++++ ...nium_UnitedUcard_eligibilityCheckWorker.py | 632 ++++++++++++++++++ .../uniteducard_browser_manager.py | 243 +++++++ packages/db/usedSchemas/browser.ts | 14 +- 14 files changed, 2226 insertions(+), 17 deletions(-) create mode 100644 apps/Backend/src/queue/processors/unitedUcardEligibilityProcessor.ts create mode 100644 apps/Backend/src/routes/insuranceStatusUnitedUcard.ts create mode 100644 apps/Backend/src/services/seleniumUnitedUcardEligibilityClient.ts create mode 100644 apps/Frontend/src/components/insurance-status/united-ucard-button-modal.tsx create mode 100644 apps/SeleniumService/helpers_uniteducard_eligibility.py create mode 100644 apps/SeleniumService/selenium_UnitedUcard_eligibilityCheckWorker.py create mode 100644 apps/SeleniumService/uniteducard_browser_manager.py diff --git a/apps/Backend/src/queue/jobRunner.ts b/apps/Backend/src/queue/jobRunner.ts index 13688a4e..39a420f9 100644 --- a/apps/Backend/src/queue/jobRunner.ts +++ b/apps/Backend/src/queue/jobRunner.ts @@ -14,6 +14,7 @@ import { runOcrProcessor } from "./processors/ocrProcessor"; import { runDdmaEligibilityProcessor } from "./processors/ddmaEligibilityProcessor"; import { runDeltaInsEligibilityProcessor } from "./processors/deltaInsEligibilityProcessor"; import { runUnitedSCOEligibilityProcessor } from "./processors/unitedSCOEligibilityProcessor"; +import { runUnitedUcardEligibilityProcessor } from "./processors/unitedUcardEligibilityProcessor"; import { runDentaQuestEligibilityProcessor } from "./processors/dentaQuestEligibilityProcessor"; import { runCCAEligibilityProcessor } from "./processors/ccaEligibilityProcessor"; import { runCCAClaimProcessor } from "./processors/ccaClaimProcessor"; @@ -128,6 +129,20 @@ export function enqueueSeleniumJob(data: SeleniumJobData): string { job.id ); } + if (jobType === "uniteducard-eligibility-check") { + return runUnitedUcardEligibilityProcessor( + { + enrichedPayload: data.enrichedPayload, + userId: data.userId, + insuranceId: data.insuranceId!, + formFirstName: data.formFirstName, + formLastName: data.formLastName, + formDob: data.formDob, + socketId: data.socketId, + }, + job.id + ); + } if (jobType === "tuftssco-eligibility-check") { return runDentaQuestEligibilityProcessor( { diff --git a/apps/Backend/src/queue/processors/unitedUcardEligibilityProcessor.ts b/apps/Backend/src/queue/processors/unitedUcardEligibilityProcessor.ts new file mode 100644 index 00000000..23a17186 --- /dev/null +++ b/apps/Backend/src/queue/processors/unitedUcardEligibilityProcessor.ts @@ -0,0 +1,349 @@ +/** + * Processor for "uniteducard-eligibility-check" jobs (United Ucard / OneHealthcareID). + * + * Same persistent-session flow as UnitedSCO/DDMA: + * 1. Start a session on the Python agent (POST /uniteducard-eligibility) + * 2. Emit selenium:uniteducard_session_started → frontend stores session_id for OTP + * 3. Poll agent status, emitting selenium:otp_required when OTP is needed + * 4. On completion: save PDF (file path), create/update patient, update status + * 5. Return { pdfFileId, pdfFilename, patientUpdateStatus, pdfUploadStatus } + */ +import fs from "fs/promises"; +import fsSync from "fs"; +import path from "path"; +import { storage } from "../../storage"; +import { emptyFolderContainingFile } from "../../utils/emptyTempFolder"; +import { + forwardToSeleniumUnitedUcardEligibilityAgent, + getSeleniumUnitedUcardSessionStatus, +} from "../../services/seleniumUnitedUcardEligibilityClient"; +import { splitName, createOrUpdatePatientByInsuranceId, imageToPdfBuffer } from "./_shared"; +import { io } from "../../socket"; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function now() { + return new Date().toISOString(); +} + +function log(tag: string, msg: string, ctx?: any) { + console.log(`${now()} [${tag}] ${msg}`, ctx ?? ""); +} + +function emitToSocket(socketId: string | undefined, event: string, payload: any) { + if (!socketId || !io) return; + try { + const socket = io.sockets.sockets.get(socketId); + if (socket) { + socket.emit(event, payload); + log("uniteducard-processor", `emitted ${event}`, { socketId }); + } + } catch (err: any) { + log("uniteducard-processor", `emit failed for ${event}`, { err: err?.message }); + } +} + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface UnitedUcardEligibilityProcessorInput { + enrichedPayload: any; + userId: number; + insuranceId: string; + formFirstName?: string; + formLastName?: string; + formDob?: string; + socketId?: string; +} + +export interface UnitedUcardEligibilityProcessorResult { + patientUpdateStatus?: string; + pdfUploadStatus?: string; + pdfFileId?: number | string | null; + pdfFilename?: string | null; +} + +// ─── Core DB processing ─────────────────────────────────────────────────────── + +async function processUnitedUcardResult( + userId: number, + insuranceId: string, + formFirstName: string | undefined, + formLastName: string | undefined, + formDob: string | undefined, + seleniumResult: any +): Promise { + const output: UnitedUcardEligibilityProcessorResult = {}; + let createdPdfFileId: number | string | null = null; + + try { + // 1) Resolve patient name + const rawName = + typeof seleniumResult?.patientName === "string" + ? seleniumResult.patientName.trim() + : null; + + const { firstName, lastName } = rawName + ? splitName(rawName) + : { firstName: formFirstName ?? "", lastName: formLastName ?? "" }; + + // 2) Create / update patient + await createOrUpdatePatientByInsuranceId({ + insuranceId, + firstName, + lastName, + dob: formDob, + userId, + }); + + // 3) Fetch patient + const patient = await storage.getPatientByInsuranceId(insuranceId); + if (!patient?.id) { + output.patientUpdateStatus = "Patient not found; no update performed"; + return output; + } + + // 4) Determine eligibility status + const eligStatus = (seleniumResult?.eligibility ?? "").toLowerCase(); + const newStatus = + eligStatus === "active" || eligStatus === "y" ? "ACTIVE" : + eligStatus === "inactive" ? "INACTIVE" : null; + + const statusUpdates: any = { insuranceProvider: "United Ucard" }; + if (newStatus) statusUpdates.status = newStatus; + + const scrapedMemberId = + typeof seleniumResult?.memberId === "string" + ? seleniumResult.memberId.replace(/\s+/g, "") + : null; + if (scrapedMemberId && scrapedMemberId !== insuranceId) { + statusUpdates.insuranceId = scrapedMemberId; + log("uniteducard-processor", "member ID changed, overwriting on patient record", { + patientId: patient.id, + oldInsuranceId: insuranceId, + newInsuranceId: scrapedMemberId, + }); + } + + await storage.updatePatient(patient.id, statusUpdates); + output.patientUpdateStatus = newStatus + ? `Patient status updated to ${newStatus}` + : `Eligibility unknown — status not changed`; + if (statusUpdates.insuranceId) { + output.patientUpdateStatus += ` (Member ID updated from ${insuranceId} to ${statusUpdates.insuranceId})`; + } + + // 5) Resolve PDF buffer from file path + let pdfBuffer: Buffer | null = null; + let pdfFilename: string | null = null; + + const pdfPath: string | null = + seleniumResult?.pdf_path ?? seleniumResult?.ss_path ?? null; + + if (pdfPath && fsSync.existsSync(pdfPath)) { + if (pdfPath.endsWith(".pdf")) { + try { + pdfBuffer = await fs.readFile(pdfPath); + pdfFilename = path.basename(pdfPath); + log("uniteducard-processor", "read PDF directly", { pdfPath }); + } catch (e: any) { + output.pdfUploadStatus = `Failed to read PDF: ${e.message}`; + } + } else if ( + pdfPath.endsWith(".png") || + pdfPath.endsWith(".jpg") || + pdfPath.endsWith(".jpeg") + ) { + try { + pdfBuffer = await imageToPdfBuffer(pdfPath); + pdfFilename = `uniteducard_eligibility_${insuranceId}_${Date.now()}.pdf`; + log("uniteducard-processor", "converted screenshot to PDF", { pdfPath }); + } catch (e: any) { + output.pdfUploadStatus = `Failed to convert screenshot to PDF: ${e.message}`; + } + } + } else { + output.pdfUploadStatus = "No valid file path from Selenium; nothing uploaded."; + } + + // 6) Save PDF to patient document group + if (pdfBuffer && pdfFilename) { + const patientName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim() || `Patient ${patient.id}`; + const cloudFile = await storage.savePdfToCloudStorage( + userId, + patient.id, + patientName, + "Eligibility", + pdfFilename, + pdfBuffer + ); + createdPdfFileId = `cloud:${cloudFile.id}`; + output.pdfUploadStatus = "PDF saved to Eligibility folder"; + output.pdfFilename = pdfFilename; + } + + output.pdfFileId = createdPdfFileId; + return output; + } catch (err: any) { + return { + ...output, + pdfUploadStatus: + output.pdfUploadStatus ?? `Processing failed: ${err?.message ?? String(err)}`, + pdfFileId: createdPdfFileId, + }; + } finally { + const cleanupPath = seleniumResult?.pdf_path ?? seleniumResult?.ss_path ?? null; + if (cleanupPath) { + try { + await emptyFolderContainingFile(cleanupPath); + } catch (e) { + log("uniteducard-processor", "cleanup failed", { cleanupPath }); + } + } + } +} + +// ─── Polling loop ──────────────────────────────────────────────────────────── + +async function pollUntilDone( + sessionId: string, + socketId: string | undefined, + jobId: string, + pollTimeoutMs = 5 * 60 * 1000 +): Promise { + const maxAttempts = 600; + const pollIntervalMs = 500; + const maxTransientErrors = 12; + const noProgressLimit = 120; + + let transientErrors = 0; + let consecutiveNoProgress = 0; + let lastStatus: string | null = null; + const deadline = Date.now() + pollTimeoutMs; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + if (Date.now() > deadline) { + throw new Error( + `UnitedUcard polling timeout (${Math.round(pollTimeoutMs / 1000)}s) for session ${sessionId}` + ); + } + + try { + const st = await getSeleniumUnitedUcardSessionStatus(sessionId); + const status: string = st?.status ?? "unknown"; + + log("uniteducard-processor", `poll attempt=${attempt}`, { sessionId, status }); + + transientErrors = 0; + + const isTerminal = + status === "completed" || status === "error" || status === "not_found"; + if (status === lastStatus && !isTerminal) { + consecutiveNoProgress++; + } else { + consecutiveNoProgress = 0; + } + lastStatus = status; + + if (consecutiveNoProgress >= noProgressLimit) { + throw new Error( + `No progress from Python agent (status="${status}") after ${consecutiveNoProgress} polls` + ); + } + + if (status === "waiting_for_otp") { + emitToSocket(socketId, "selenium:otp_required", { + session_id: sessionId, + jobId, + message: "OTP required. Please enter the verification code.", + }); + await new Promise((r) => setTimeout(r, pollIntervalMs)); + continue; + } + + if (status === "completed") { + log("uniteducard-processor", "session completed", { sessionId }); + return st.result; + } + + if (status === "error" || status === "not_found") { + throw new Error(st?.message || `UnitedUcard session ended with status: ${status}`); + } + + await new Promise((r) => setTimeout(r, pollIntervalMs)); + } catch (err: any) { + const isTerminal = + err?.response?.status === 404 || + (typeof err?.message === "string" && + (err.message.includes("not_found") || + err.message.includes("polling timeout") || + err.message.includes("No progress"))); + + if (isTerminal) throw err; + + transientErrors++; + if (transientErrors > maxTransientErrors) { + throw new Error( + `Too many transient network errors polling UnitedUcard session ${sessionId}` + ); + } + const backoff = Math.min(30_000, 500 * Math.pow(2, transientErrors - 1)); + log("uniteducard-processor", `transient error #${transientErrors}, backoff ${backoff}ms`, { + err: err?.message, + }); + await new Promise((r) => setTimeout(r, backoff)); + } + } + + throw new Error(`UnitedUcard polling exhausted all attempts for session ${sessionId}`); +} + +// ─── Main processor entry point ─────────────────────────────────────────────── + +export async function runUnitedUcardEligibilityProcessor( + input: UnitedUcardEligibilityProcessorInput, + jobId: string +): Promise { + const { + enrichedPayload, + userId, + insuranceId, + formFirstName, + formLastName, + formDob, + socketId, + } = input; + + log("uniteducard-processor", "starting Python agent session", { insuranceId }); + const agentResp = await forwardToSeleniumUnitedUcardEligibilityAgent(enrichedPayload); + + if (!agentResp?.session_id) { + throw new Error("Python agent did not return a session_id for United Ucard eligibility"); + } + + const sessionId = agentResp.session_id as string; + log("uniteducard-processor", "got session_id", { sessionId }); + + emitToSocket(socketId, "selenium:uniteducard_session_started", { + session_id: sessionId, + jobId, + }); + + const seleniumResult = await pollUntilDone(sessionId, socketId, jobId); + + if (!seleniumResult || seleniumResult.status === "error") { + throw new Error(seleniumResult?.message ?? "United Ucard session returned an error result"); + } + + log("uniteducard-processor", "processing DB result", { insuranceId }); + const result = await processUnitedUcardResult( + userId, + insuranceId, + formFirstName, + formLastName, + formDob, + seleniumResult + ); + + log("uniteducard-processor", "done", { result }); + return result; +} diff --git a/apps/Backend/src/queue/queues.ts b/apps/Backend/src/queue/queues.ts index 9e1f64d8..ed263a34 100644 --- a/apps/Backend/src/queue/queues.ts +++ b/apps/Backend/src/queue/queues.ts @@ -10,6 +10,7 @@ export type SeleniumJobType = | "ddma-eligibility-check" | "deltains-eligibility-check" | "unitedsco-eligibility-check" + | "uniteducard-eligibility-check" | "cca-eligibility-check" | "cca-claim-submit" | "cca-preauth-submit" diff --git a/apps/Backend/src/routes/index.ts b/apps/Backend/src/routes/index.ts index 15b70d9c..2150614c 100755 --- a/apps/Backend/src/routes/index.ts +++ b/apps/Backend/src/routes/index.ts @@ -14,6 +14,7 @@ import insuranceStatusRoutes from "./insuranceStatus"; import insuranceStatusDdmaRoutes from "./insuranceStatusDDMA"; import insuranceStatusDeltaInsRoutes from "./insuranceStatusDeltaIns"; import insuranceStatusUnitedSCORoutes from "./insuranceStatusUnitedSCO"; +import insuranceStatusUnitedUcardRoutes from "./insuranceStatusUnitedUcard"; import insuranceStatusTuftsSCORoutes from "./insuranceStatusTuftsSCO"; import insuranceStatusCCARoutes from "./insuranceStatusCCA"; import insuranceStatusCCAClaimRoutes from "./insuranceStatusCCAClaim"; @@ -66,6 +67,7 @@ router.use("/insurance-status", insuranceStatusRoutes); router.use("/insurance-status-ddma", insuranceStatusDdmaRoutes); router.use("/insurance-status-deltains", insuranceStatusDeltaInsRoutes); router.use("/insurance-status-unitedsco", insuranceStatusUnitedSCORoutes); +router.use("/insurance-status-uniteducard", insuranceStatusUnitedUcardRoutes); router.use("/insurance-status-tuftssco", insuranceStatusTuftsSCORoutes); router.use("/insurance-status-cca", insuranceStatusCCARoutes); router.use("/insurance-status-bcbs-ma", insuranceStatusBcbsMaRoutes); diff --git a/apps/Backend/src/routes/insuranceStatusUnitedUcard.ts b/apps/Backend/src/routes/insuranceStatusUnitedUcard.ts new file mode 100644 index 00000000..6d79dbe7 --- /dev/null +++ b/apps/Backend/src/routes/insuranceStatusUnitedUcard.ts @@ -0,0 +1,135 @@ +import { Router, Request, Response } from "express"; +import { storage } from "../storage"; +import { forwardOtpToSeleniumUnitedUcardAgent } from "../services/seleniumUnitedUcardEligibilityClient"; +import { io } from "../socket"; +import { enqueueSeleniumJob } from "../queue/jobRunner"; +import { prisma as db } from "@repo/db/client"; + +const router = Router(); + +function log(tag: string, msg: string, ctx?: any) { + console.log(`${new Date().toISOString()} [${tag}] ${msg}`, ctx ?? ""); +} + +function emitSafe(socketId: string | undefined, event: string, payload: any) { + if (!socketId || !io) return; + try { + const socket = io.sockets.sockets.get(socketId); + if (socket) socket.emit(event, payload); + } catch (err: any) { + log("socket", "emit failed", { socketId, event, err: err?.message }); + } +} + +/** + * POST /uniteducard-eligibility + * + * Enqueues a United Ucard (OneHealthcareID) eligibility check (concurrency=1). + * + * Body: + * data — patient + search fields (memberId, dateOfBirth, firstName, lastName, …) + * socketId — socket.io client id for real-time updates + * + * Response: { status: "queued", jobId: "…" } + * + * Real-time socket events: + * job:update { jobId, jobType, status: "active"|"completed"|"failed", … } + * selenium:uniteducard_session_started { session_id, jobId } + * selenium:otp_required { session_id, jobId, message } + */ +router.post( + "/uniteducard-eligibility", + async (req: Request, res: Response): Promise => { + if (!req.body.data) { + return res.status(400).json({ error: "Missing eligibility data for selenium" }); + } + if (!req.user?.id) { + return res.status(401).json({ error: "Unauthorized: user info missing" }); + } + + try { + const rawData = + typeof req.body.data === "string" ? JSON.parse(req.body.data) : req.body.data; + + // Fetch United Ucard credentials from DB + const credentials = await storage.getInsuranceCredentialByUserAndSiteKey( + req.user.id, + rawData.insuranceSiteKey + ); + if (!credentials) { + return res.status(404).json({ + error: + "No credentials found for United Ucard. Please add them on the Settings page.", + }); + } + + const seleniumSettings = await db.seleniumSettings.findUnique({ where: { userId: req.user.id } }); + + const enrichedData = { + ...rawData, + uniteducardUsername: credentials.username, + uniteducardPassword: credentials.password, + paymentGroupId: seleniumSettings?.paymentGroupId ?? "", + }; + + const socketId: string | undefined = req.body.socketId; + + const jobId = enqueueSeleniumJob({ + jobType: "uniteducard-eligibility-check", + userId: req.user.id, + socketId, + enrichedPayload: enrichedData, + insuranceId: String(rawData.memberId ?? "").trim(), + formFirstName: rawData.firstName, + formLastName: rawData.lastName, + formDob: rawData.dateOfBirth, + }); + + log("uniteducard-route", "job enqueued", { jobId, insuranceId: rawData.memberId }); + + return res.json({ status: "queued", jobId }); + } catch (err: any) { + console.error("[uniteducard-route] enqueue failed:", err); + return res.status(500).json({ + error: err.message || "Failed to enqueue United Ucard selenium job", + }); + } + } +); + +/** + * POST /selenium/submit-otp + * Side-channel OTP forwarding — does NOT go through the queue. + * Body: { session_id, otp, socketId? } + */ +router.post( + "/selenium/submit-otp", + async (req: Request, res: Response): Promise => { + const { session_id: sessionId, otp, socketId } = req.body; + if (!sessionId || !otp) { + return res.status(400).json({ error: "session_id and otp are required" }); + } + + try { + const r = await forwardOtpToSeleniumUnitedUcardAgent(sessionId, otp); + + emitSafe(socketId, "selenium:otp_submitted", { + session_id: sessionId, + result: r, + }); + + return res.json(r); + } catch (err: any) { + console.error( + "[uniteducard-route] submit-otp failed:", + err?.response?.data || err?.message || err + ); + return res.status(500).json({ + error: "Failed to forward OTP to selenium agent", + detail: err?.message || err, + }); + } + } +); + +export default router; diff --git a/apps/Backend/src/services/seleniumUnitedUcardEligibilityClient.ts b/apps/Backend/src/services/seleniumUnitedUcardEligibilityClient.ts new file mode 100644 index 00000000..03f75743 --- /dev/null +++ b/apps/Backend/src/services/seleniumUnitedUcardEligibilityClient.ts @@ -0,0 +1,72 @@ +import axios from "axios"; +import http from "http"; +import https from "https"; +import dotenv from "dotenv"; +dotenv.config(); + +const SELENIUM_AGENT_BASE = process.env.SELENIUM_AGENT_BASE_URL; + +const httpAgent = new http.Agent({ keepAlive: true, keepAliveMsecs: 60_000 }); +const httpsAgent = new https.Agent({ keepAlive: true, keepAliveMsecs: 60_000 }); + +const client = axios.create({ + baseURL: SELENIUM_AGENT_BASE, + timeout: 5 * 60 * 1000, + httpAgent, + httpsAgent, + validateStatus: (s) => s >= 200 && s < 600, +}); + +async function requestWithRetries(config: any, retries = 4, baseBackoffMs = 300) { + for (let attempt = 1; attempt <= retries; attempt++) { + try { + const r = await client.request(config); + if (![502, 503, 504].includes(r.status)) return r; + console.warn(`[uniteducard-client] retryable HTTP status ${r.status} (attempt ${attempt})`); + } catch (err: any) { + const code = err?.code; + const isTransient = + code === "ECONNRESET" || code === "ECONNREFUSED" || code === "EPIPE" || code === "ETIMEDOUT"; + if (!isTransient) throw err; + console.warn(`[uniteducard-client] transient network error ${code} (attempt ${attempt})`); + } + await new Promise((r) => setTimeout(r, baseBackoffMs * attempt)); + } + return client.request(config); +} + +function log(tag: string, msg: string, ctx?: any) { + console.log(`${new Date().toISOString()} [${tag}] ${msg}`, ctx ?? ""); +} + +export async function forwardToSeleniumUnitedUcardEligibilityAgent(data: any): Promise { + const payload = { data }; + const url = `/uniteducard-eligibility`; + log("uniteducard-client", "POST uniteducard-eligibility", { url: SELENIUM_AGENT_BASE + url }); + const r = await requestWithRetries({ url, method: "POST", data: payload }, 4); + log("uniteducard-client", "agent response", { status: r.status, dataKeys: r.data ? Object.keys(r.data) : null }); + if (r.status >= 500) throw new Error(`Selenium agent server error: ${r.status}`); + return r.data; +} + +export async function forwardOtpToSeleniumUnitedUcardAgent(sessionId: string, otp: string): Promise { + const url = `/submit-otp`; + log("uniteducard-client", "POST submit-otp", { url: SELENIUM_AGENT_BASE + url, sessionId }); + const r = await requestWithRetries({ url, method: "POST", data: { session_id: sessionId, otp } }, 4); + log("uniteducard-client", "submit-otp response", { status: r.status, data: r.data }); + if (r.status >= 500) throw new Error(`Selenium agent server error on submit-otp: ${r.status}`); + return r.data; +} + +export async function getSeleniumUnitedUcardSessionStatus(sessionId: string): Promise { + const url = `/session/${sessionId}/status`; + log("uniteducard-client", "GET session status", { url: SELENIUM_AGENT_BASE + url, sessionId }); + const r = await requestWithRetries({ url, method: "GET" }, 4); + log("uniteducard-client", "session status response", { status: r.status, dataKeys: r.data ? Object.keys(r.data) : null }); + if (r.status === 404) { + const e: any = new Error("not_found"); + e.response = { status: 404, data: r.data }; + throw e; + } + return r.data; +} diff --git a/apps/Frontend/src/components/insurance-status/united-ucard-button-modal.tsx b/apps/Frontend/src/components/insurance-status/united-ucard-button-modal.tsx new file mode 100644 index 00000000..21063ce4 --- /dev/null +++ b/apps/Frontend/src/components/insurance-status/united-ucard-button-modal.tsx @@ -0,0 +1,364 @@ +import { useEffect, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { CheckCircle, LoaderCircleIcon, X } from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; +import { apiRequest, queryClient } from "@/lib/queryClient"; +import { useAppDispatch } from "@/redux/hooks"; +import { setTaskStatus } from "@/redux/slices/seleniumTaskSlice"; +import { formatLocalDate } from "@/utils/dateUtils"; +import { socket } from "@/lib/socket"; +import { QK_PATIENTS_BASE } from "@/components/patients/patient-table"; + +// ─── OTP Modal ──────────────────────────────────────────────────────────────── + +interface UnitedUcardOtpModalProps { + open: boolean; + onClose: () => void; + onSubmit: (otp: string) => Promise | void; + isSubmitting: boolean; +} + +function UnitedUcardOtpModal({ open, onClose, onSubmit, isSubmitting }: UnitedUcardOtpModalProps) { + const [otp, setOtp] = useState(""); + + useEffect(() => { + if (!open) setOtp(""); + }, [open]); + + if (!open) return null; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!otp.trim()) return; + await onSubmit(otp.trim()); + }; + + return ( +
+
+
+

Enter OTP

+ +
+

+ We need the verification code sent to your phone or email to complete this check. +

+
+
+ + setOtp(e.target.value)} + autoFocus + /> +
+
+ + +
+
+
+
+ ); +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +interface UnitedUcardEligibilityButtonProps { + memberId: string; + dateOfBirth: Date | null; + firstName?: string; + lastName?: string; + isFormIncomplete: boolean; + autoTrigger?: boolean; + onAutoTriggered?: () => void; + onPdfReady: (pdfId: number | string, fallbackFilename: string | null) => void; +} + +export function UnitedUcardEligibilityButton({ + memberId, + dateOfBirth, + firstName, + lastName, + isFormIncomplete, + autoTrigger, + onAutoTriggered, + onPdfReady, +}: UnitedUcardEligibilityButtonProps) { + const { toast } = useToast(); + const dispatch = useAppDispatch(); + + const sessionIdRef = useRef(null); + const autoTriggeredRef = useRef(false); + + const [otpModalOpen, setOtpModalOpen] = useState(false); + const [isStarting, setIsStarting] = useState(false); + const [isSubmittingOtp, setIsSubmittingOtp] = useState(false); + + const handleStart = async () => { + if (!memberId || !dateOfBirth) { + toast({ + title: "Missing fields", + description: "Member ID and Date of Birth are required.", + variant: "destructive", + }); + return; + } + + const formattedDob = formatLocalDate(dateOfBirth); + const payload = { + memberId, + dateOfBirth: formattedDob, + firstName, + lastName, + insuranceSiteKey: "UNITED_UCARD", + }; + + setIsStarting(true); + + try { + dispatch( + setTaskStatus({ + key: "eligibilityCheck", + status: "pending", + message: "Starting United Ucard eligibility check…", + }) + ); + + const response = await apiRequest( + "POST", + "/api/insurance-status-uniteducard/uniteducard-eligibility", + { data: JSON.stringify(payload), socketId: socket.id } + ); + + const result = await response.json(); + if (!response.ok || result.error) { + throw new Error(result.error || `Server error (${response.status})`); + } + + const jobId: string = result.jobId; + if (!jobId) throw new Error("No jobId returned from server"); + + dispatch( + setTaskStatus({ + key: "eligibilityCheck", + status: "pending", + message: "United Ucard job queued. Waiting for browser session…", + }) + ); + + const onSessionStarted = (data: any) => { + if (String(data?.jobId) !== String(jobId)) return; + sessionIdRef.current = data.session_id ?? null; + dispatch( + setTaskStatus({ + key: "eligibilityCheck", + status: "pending", + message: "Browser session started. Waiting for verification code or result…", + }) + ); + }; + + const onOtpRequired = (data: any) => { + if (String(data?.jobId) !== String(jobId)) return; + if (data.session_id) sessionIdRef.current = data.session_id; + setOtpModalOpen(true); + dispatch( + setTaskStatus({ + key: "eligibilityCheck", + status: "pending", + message: "Verification code required. Please enter the code.", + }) + ); + }; + + const onOtpSubmitted = (data: any) => { + if (data?.session_id && data.session_id !== sessionIdRef.current) return; + dispatch( + setTaskStatus({ + key: "eligibilityCheck", + status: "pending", + message: "Code submitted. Finishing eligibility check…", + }) + ); + }; + + socket.on("selenium:uniteducard_session_started", onSessionStarted); + socket.on("selenium:otp_required", onOtpRequired); + socket.on("selenium:otp_submitted", onOtpSubmitted); + + function cleanup() { + clearTimeout(safetyTimer); + socket.off("selenium:uniteducard_session_started", onSessionStarted); + socket.off("selenium:otp_required", onOtpRequired); + socket.off("selenium:otp_submitted", onOtpSubmitted); + socket.off("job:update", onJobUpdate); + } + + const onJobUpdate = (data: any) => { + if (String(data?.jobId) !== String(jobId)) return; + + if (data.status === "active") { + dispatch( + setTaskStatus({ + key: "eligibilityCheck", + status: "pending", + message: data.message ?? "Selenium browser starting…", + }) + ); + return; + } + + cleanup(); + + if (data.status === "completed") { + dispatch( + setTaskStatus({ + key: "eligibilityCheck", + status: "success", + message: "United Ucard eligibility updated and PDF attached to patient documents.", + }) + ); + toast({ + title: "United Ucard eligibility complete", + description: "Patient status was updated and the eligibility PDF was saved.", + }); + queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE }); + const pdfId = data.result?.pdfFileId; + if (pdfId) { + onPdfReady(pdfId, data.result?.pdfFilename ?? `eligibility_uniteducard_${memberId}.pdf`); + } + } else if (data.status === "failed") { + const msg = data.error ?? "United Ucard eligibility job failed."; + dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: msg })); + toast({ title: "United Ucard selenium error", description: msg, variant: "destructive" }); + } + + setIsStarting(false); + setOtpModalOpen(false); + }; + + socket.on("job:update", onJobUpdate); + + const safetyTimer = setTimeout(() => { + cleanup(); + setIsStarting(false); + setOtpModalOpen(false); + dispatch( + setTaskStatus({ + key: "eligibilityCheck", + status: "error", + message: "United Ucard job timed out waiting for completion.", + }) + ); + }, 6 * 60 * 1000); + + } catch (err: any) { + console.error("UnitedUcardEligibilityButton error:", err); + dispatch( + setTaskStatus({ + key: "eligibilityCheck", + status: "error", + message: err?.message || "Failed to start United Ucard eligibility", + }) + ); + toast({ + title: "United Ucard selenium error", + description: err?.message || "Failed to start United Ucard eligibility", + variant: "destructive", + }); + setIsStarting(false); + } + }; + + const handleSubmitOtp = async (otp: string) => { + const sessionId = sessionIdRef.current; + if (!sessionId) { + toast({ + title: "Session not ready", + description: "Cannot submit code — session ID is not available yet.", + variant: "destructive", + }); + return; + } + + try { + setIsSubmittingOtp(true); + const resp = await apiRequest("POST", "/api/insurance-status-uniteducard/selenium/submit-otp", { + session_id: sessionId, + otp, + socketId: socket.id, + }); + const data = await resp.json(); + if (!resp.ok || data.error) { + throw new Error(data.error || "Failed to submit code"); + } + setOtpModalOpen(false); + } catch (err: any) { + console.error("handleSubmitOtp error:", err); + toast({ + title: "Failed to submit code", + description: err?.message || "Error forwarding code to selenium agent", + variant: "destructive", + }); + } finally { + setIsSubmittingOtp(false); + } + }; + + useEffect(() => { + if (!autoTrigger) { autoTriggeredRef.current = false; return; } + if (autoTriggeredRef.current || isFormIncomplete) return; + autoTriggeredRef.current = true; + onAutoTriggered?.(); + handleStart(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autoTrigger, isFormIncomplete]); + + return ( + <> + + + setOtpModalOpen(false)} + onSubmit={handleSubmitOtp} + isSubmitting={isSubmittingOtp} + /> + + ); +} diff --git a/apps/Frontend/src/components/settings/InsuranceCredForm.tsx b/apps/Frontend/src/components/settings/InsuranceCredForm.tsx index 9e683391..77581b60 100755 --- a/apps/Frontend/src/components/settings/InsuranceCredForm.tsx +++ b/apps/Frontend/src/components/settings/InsuranceCredForm.tsx @@ -21,6 +21,7 @@ const SITE_KEY_OPTIONS = [ { value: "DELTAINS", label: "Delta Dental Ins (DELTAINS)" }, { value: "TUFTS_SCO", label: "Tufts SCO (TUFTS_SCO)" }, { value: "UNITED_SCO", label: "United SCO / DentalHub (UNITED_SCO)" }, + { value: "UNITED_UCARD", label: "United Ucard (UNITED_UCARD)" }, { value: "CCA", label: "CCA (CCA)" }, { value: "BCBS_MA", label: "BCBS MA (BCBS_MA)" }, ]; diff --git a/apps/Frontend/src/pages/insurance-status-page.tsx b/apps/Frontend/src/pages/insurance-status-page.tsx index da7390d1..d8a38767 100755 --- a/apps/Frontend/src/pages/insurance-status-page.tsx +++ b/apps/Frontend/src/pages/insurance-status-page.tsx @@ -41,6 +41,7 @@ import { DdmaEligibilityButton } from "@/components/insurance-status/ddma-buton- import { DeltaInsEligibilityButton } from "@/components/insurance-status/deltains-button-modal"; import { TuftsSCOEligibilityButton } from "@/components/insurance-status/tufts-sco-button-modal"; import { UnitedSCOEligibilityButton } from "@/components/insurance-status/united-sco-button-modal"; +import { UnitedUcardEligibilityButton } from "@/components/insurance-status/united-ucard-button-modal"; import { CCAEligibilityButton } from "@/components/insurance-status/cca-button-modal"; import { BcbsMaEligibilityButton } from "@/components/insurance-status/bcbs-ma-button-modal"; import { useLicense } from "@/hooks/use-license"; @@ -171,7 +172,7 @@ export default function InsuranceStatusPage() { const [cmspAccumulatorPdfId, setCmspAccumulatorPdfId] = useState(null); const [cmspAccumulatorFilename, setCmspAccumulatorFilename] = useState(null); - const pendingAutoCheck = useRef<"mh" | "mh-history" | "cmsp" | "ddma" | "delta-ins" | "united-sco" | "tufts-sco" | "cca" | null>(null); + const pendingAutoCheck = useRef<"mh" | "mh-history" | "cmsp" | "ddma" | "delta-ins" | "united-sco" | "united-ucard" | "tufts-sco" | "cca" | null>(null); const [triggerTarget, setTriggerTarget] = useState(null); const pendingScrollTo = useRef(null); const [prefillTick, setPrefillTick] = useState(0); @@ -909,7 +910,7 @@ export default function InsuranceStatusPage() { const autoCheck = params.get("autoCheck") as typeof pendingAutoCheck.current; const scrollTo = params.get("scrollTo"); - const knownAutoChecks = ["mh", "mh-history", "cmsp", "ddma", "delta-ins", "united-sco", "tufts-sco", "cca"] as const; + const knownAutoChecks = ["mh", "mh-history", "cmsp", "ddma", "delta-ins", "united-sco", "united-ucard", "tufts-sco", "cca"] as const; if (autoCheck && (knownAutoChecks as readonly string[]).includes(autoCheck)) { pendingAutoCheck.current = autoCheck; } else if (scrollTo) { @@ -1196,14 +1197,27 @@ export default function InsuranceStatusPage() { {/* Row 3 */}
- +
+ setTriggerTarget(null)} + onPdfReady={async (pdfId, fallbackFilename) => { + const claimed = await tryClaimFromChatbot(selectedPatient?.id); + void tryAppointmentFromChatbot(); + if (!claimed) { + setPreviewPdfId(pdfId); + setPreviewFallbackFilename(fallbackFilename ?? `eligibility_uniteducard_${memberId}.pdf`); + setPreviewOpen(true); + } + processNextInQueue(); + }} + /> +
+ otp_input = driver.find_element(By.XPATH, + "//input[@id='otpBox' or contains(@id,'otp') or contains(@name,'otp') or " + "contains(@name,'code') or @type='tel' or contains(@aria-label,'Verification') or " + "contains(@placeholder,'code')]" + ) + otp_input.clear() + otp_input.send_keys(otp_value) + try: + # Try the confirmed id first — the broader text/type + # fallback below can match the WRONG button if more + # than one element on the page qualifies (find_element + # returns the first DOM match, not necessarily the + # right one), which was landing on a blank page. + try: + verify_btn = driver.find_element(By.ID, "continuebtn") + except Exception: + verify_btn = driver.find_element(By.XPATH, + "//button[@type='submit' or contains(text(),'Verify') or " + "contains(text(),'Submit') or contains(text(),'Continue')]" + ) + driver.execute_script("arguments[0].scrollIntoView({block:'center'});", verify_btn) + verify_btn.click() + print("[UnitedUcard OTP] Clicked verify button") + except Exception: + otp_input.send_keys("\n") # Press Enter as fallback + print("[UnitedUcard OTP] Pressed Enter as fallback") + print("[UnitedUcard OTP] OTP typed and submitted via app") + s["otp_value"] = None # Clear so we don't submit again + await asyncio.sleep(3) # Wait for verification + except Exception as type_err: + print(f"[UnitedUcard OTP] Failed to type OTP from app: {type_err}") + + # Check current URL - success lands on the UHC Dental provider dashboard + # (secure.uhcdental.com/.../postloginhomescreen.html) + current_url = driver.current_url.lower() + print(f"[UnitedUcard OTP Poll {poll+1}/{max_polls}] URL: {current_url[:60]}...") + + if "secure.uhcdental.com" in current_url: + print("[UnitedUcard OTP] Reached UHC Dental dashboard - login successful!") + login_success = True + break + + # Also check if OTP input is still visible + try: + driver.find_element(By.XPATH, + "//input[@id='otpBox' or contains(@id,'otp') or contains(@name,'otp') or " + "contains(@name,'code') or @type='tel' or contains(@aria-label,'Verification') or " + "contains(@placeholder,'code') or contains(@placeholder,'Code')]" + ) + print(f"[UnitedUcard OTP Poll {poll+1}] OTP input still visible - waiting...") + except Exception: + print(f"[UnitedUcard OTP Poll {poll+1}] OTP input not found, continuing to poll...") + + except Exception as poll_err: + print(f"[UnitedUcard OTP Poll {poll+1}] Error: {poll_err}") + + if not login_success: + s["status"] = "error" + s["message"] = "OTP timeout - login not completed" + await cleanup_session(sid) + return {"status": "error", "message": "OTP not completed in time"} + + if login_success: + s["status"] = "running" + s["message"] = "Login successful after OTP" + print("[UnitedUcard OTP] Proceeding to step1...") + + elif isinstance(login_result, str) and login_result.startswith("ERROR"): + s["status"] = "error" + s["message"] = login_result + await cleanup_session(sid) + return {"status": "error", "message": login_result} + + # Login succeeded without OTP (SUCCESS) + elif isinstance(login_result, str) and login_result == "SUCCESS": + print("[start_uniteducard_run] Login succeeded without OTP") + s["status"] = "running" + s["message"] = "Login succeeded" + + # Step 1 + step1_result = bot.step1() + if isinstance(step1_result, str) and step1_result.startswith("ERROR"): + s["status"] = "error" + s["message"] = step1_result + s["result"] = {"status": "error", "message": step1_result} + _minimize_browser(bot) + asyncio.create_task(_remove_session_later(sid, 30)) + return {"status": "error", "message": step1_result} + + # Step 2 (PDF) + step2_result = bot.step2() + if isinstance(step2_result, dict) and step2_result.get("status") == "success": + s["status"] = "completed" + s["result"] = step2_result + s["message"] = "completed" + asyncio.create_task(_remove_session_later(sid, 30)) + return step2_result + else: + s["status"] = "error" + if isinstance(step2_result, dict): + s["message"] = step2_result.get("message", "unknown error") + else: + s["message"] = str(step2_result) + s["result"] = {"status": "error", "message": s["message"]} + _minimize_browser(bot) + asyncio.create_task(_remove_session_later(sid, 30)) + return {"status": "error", "message": s["message"]} + + except Exception as e: + s["status"] = "error" + s["message"] = f"worker exception: {e}" + try: + if bot and bot.driver: + bot.driver.minimize_window() + except Exception: + pass + s["result"] = {"status": "error", "message": s["message"]} + asyncio.create_task(_remove_session_later(sid, 30)) + return {"status": "error", "message": s["message"]} + + +def submit_otp(sid: str, otp: str) -> Dict[str, Any]: + """Set OTP for a session and wake waiting runner.""" + s = sessions.get(sid) + if not s: + return {"status": "error", "message": "session not found"} + if s.get("status") != "waiting_for_otp": + return {"status": "error", "message": f"session not waiting for otp (state={s.get('status')})"} + s["otp_value"] = otp + s["last_activity"] = time.time() + try: + s["otp_event"].set() + except Exception: + pass + return {"status": "ok", "message": "otp accepted"} + + +def get_session_status(sid: str) -> Dict[str, Any]: + s = sessions.get(sid) + if not s: + return {"status": "not_found"} + return { + "session_id": sid, + "status": s.get("status"), + "message": s.get("message"), + "created_at": s.get("created_at"), + "last_activity": s.get("last_activity"), + "result": s.get("result") if s.get("status") in ("completed", "error") else None, + } diff --git a/apps/SeleniumService/selenium_UnitedUcard_eligibilityCheckWorker.py b/apps/SeleniumService/selenium_UnitedUcard_eligibilityCheckWorker.py new file mode 100644 index 00000000..510a8abc --- /dev/null +++ b/apps/SeleniumService/selenium_UnitedUcard_eligibilityCheckWorker.py @@ -0,0 +1,632 @@ +""" +United Ucard (OneHealthcareID) eligibility check worker. + +Scaffolded from selenium_UnitedSCO_eligibilityCheckWorker.py — same shape +(config_driver / login / step1 / step2 / main_workflow) so it plugs into the +same helpers_*_eligibility.py + agent.py session/OTP pattern. + +Login flow (confirmed against the live site, 2026-09-13): + 0. https://www.uhcdental.com/content/provider/dental.html?state=abc (public site) + span.header-signin-btn ("Sign In") -> a.sign-in-button-link ("Provider Login") + which OIDC-redirects to identity.onehealthcareid.com (or straight back to the + dashboard if there's already a valid SSO session) + 1. https://identity.onehealthcareid.com/oneapp/index.html#/login + input#username -> button#btnLogin ("Continue") + 2. #/signin/password + input#login-pwd -> button#btnLogin ("Continue") + 3. #/rba/options ("Verify Your Identity") + button#textMsg ("Via Text Message") + 4. #/rba/options/text ("Access Code") + input#otpBox -> button#continuebtn ("Continue") + 5. Lands on the UHC Dental provider dashboard: + https://secure.uhcdental.com/.../postloginhomescreen.html?... + +step1()/step2() (the actual eligibility search + PDF capture, once logged in) +are left as TODO stubs — fill these in once we know the exact DOM for the +member search + eligibility result pages on the dashboard. +""" +from selenium.common.exceptions import WebDriverException, TimeoutException +from selenium.webdriver.common.by import By +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC +import time +import os +import re +import base64 + +from uniteducard_browser_manager import get_browser_manager + + +class AutomationUnitedUcardEligibilityCheck: + def __init__(self, data): + self.headless = False + self.driver = None + + self.data = data.get("data", {}) if isinstance(data, dict) else {} + + # Flatten values for convenience + self.memberId = self.data.get("memberId", "") + self.dateOfBirth = self.data.get("dateOfBirth", "") + self.firstName = self.data.get("firstName", "") + self.lastName = self.data.get("lastName", "") + self.uniteducard_username = self.data.get("uniteducardUsername", "") + self.uniteducard_password = self.data.get("uniteducardPassword", "") + self.payment_group_id = self.data.get("paymentGroupId", "") + + # Use browser manager's download dir + self.download_dir = get_browser_manager().download_dir + os.makedirs(self.download_dir, exist_ok=True) + + def config_driver(self): + # Use persistent browser from manager (keeps device trust tokens) + self.driver = get_browser_manager().get_driver(self.headless) + + def _force_logout(self): + """Force logout by clearing cookies for the One Healthcare ID domain.""" + try: + print("[UnitedUcard login] Forcing logout due to credential change...") + browser_manager = get_browser_manager() + + try: + self.driver.delete_all_cookies() + print("[UnitedUcard login] Cleared all cookies") + except Exception as e: + print(f"[UnitedUcard login] Error clearing cookies: {e}") + + browser_manager.clear_credentials_hash() + print("[UnitedUcard login] Logout complete") + return True + except Exception as e: + print(f"[UnitedUcard login] Error during forced logout: {e}") + return False + + def login(self, url): + """ + Log in via https://www.uhcdental.com/content/provider/dental.html (Sign In -> + Provider Login), which OIDC-redirects through identity.onehealthcareid.com. + + Returns one of: + "ALREADY_LOGGED_IN" / "SUCCESS" - logged in, caller should proceed to step1 + "OTP_REQUIRED" - caller should poll for OTP entry + "ERROR: " - login failed + """ + wait = WebDriverWait(self.driver, 30) + browser_manager = get_browser_manager() + + try: + # Check if credentials have changed - if so, force logout first + if self.uniteducard_username and browser_manager.credentials_changed(self.uniteducard_username): + self._force_logout() + self.driver.get(url) + time.sleep(2) + + # Navigate to the public UHC Dental provider portal (NOT directly to + # identity.onehealthcareid.com) — Sign In -> Provider Login triggers + # the OIDC redirect to the identity login page. + self.driver.get(url) + time.sleep(3) + + current_url = self.driver.current_url + print(f"[UnitedUcard login] Current URL: {current_url}") + + # If we've already landed on the UHC Dental provider dashboard + # (secure.uhcdental.com/.../postloginhomescreen.html), a prior + # session is still valid. + if "secure.uhcdental.com" in current_url.lower(): + print("[UnitedUcard login] Already logged in - on UHC Dental dashboard") + return "ALREADY_LOGGED_IN" + + # ── Step 0: public site -> Sign In -> Provider Login ──────────── + # Confirmed against live site (2026-09-13): + # (opens dropdown) + # + if "onehealthcareid.com" not in current_url.lower(): + try: + signin_btn = WebDriverWait(self.driver, 10).until( + EC.element_to_be_clickable((By.XPATH, + "//span[contains(@class,'header-signin-btn') or contains(text(),'Sign In')]")) + ) + signin_btn.click() + print("[UnitedUcard login] Clicked 'Sign In' to open dropdown") + time.sleep(1) + except TimeoutException: + return "ERROR: 'Sign In' button not found on UHC Dental provider site" + + # Match by exact visible text "Provider Login" — the dropdown + # also has an "Administrator Login" link that shares the same + # class AND the same oidc/authorize href pattern (just a + # different client_id), so text is the only reliable + # differentiator. It's also always the first item in the + # dropdown (Provider Login above Administrator Login). + clicked_provider_login = False + try: + provider_login_link = WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located((By.XPATH, + "//a[normalize-space(text())='Provider Login']")) + ) + self.driver.execute_script( + "arguments[0].scrollIntoView({block:'center'});", provider_login_link + ) + time.sleep(0.3) + try: + provider_login_link.click() + print("[UnitedUcard login] Clicked 'Provider Login' (direct)") + clicked_provider_login = True + except Exception as direct_click_err: + print(f"[UnitedUcard login] Direct click failed ({direct_click_err}); trying JS click") + self.driver.execute_script("arguments[0].click();", provider_login_link) + print("[UnitedUcard login] Clicked 'Provider Login' (JS)") + clicked_provider_login = True + time.sleep(3) + except TimeoutException: + pass + + if not clicked_provider_login: + # Fallback: the sign-in dropdown's first link is always + # "Provider Login" (Administrator Login is second) — + # click by position instead of matching text/href. + try: + dropdown_link = WebDriverWait(self.driver, 5).until( + EC.presence_of_element_located((By.XPATH, + "(//a[contains(@class,'sign-in-button-link')])[1]")) + ) + self.driver.execute_script("arguments[0].click();", dropdown_link) + print("[UnitedUcard login] Clicked first dropdown link (Provider Login, by position)") + clicked_provider_login = True + time.sleep(3) + except Exception as e: + return f"ERROR: 'Provider Login' link not found in Sign In dropdown: {e}" + + current_url = self.driver.current_url + print(f"[UnitedUcard login] After Provider Login click URL: {current_url}") + + # SSO may skip straight back to the dashboard if already authenticated + if "secure.uhcdental.com" in current_url.lower(): + print("[UnitedUcard login] Already logged in - redirected straight to dashboard") + return "ALREADY_LOGGED_IN" + + # Check for OTP input first (in case device left mid-OTP) + try: + WebDriverWait(self.driver, 3).until( + EC.presence_of_element_located((By.XPATH, + "//input[@type='tel' or contains(@placeholder,'code') or contains(@placeholder,'Code') or " + "contains(@aria-label,'Verification') or contains(@id,'otp') or contains(@name,'otp')]")) + ) + print("[UnitedUcard login] OTP input found on landing") + return "OTP_REQUIRED" + except TimeoutException: + pass + + # ── Step 1: "One Healthcare ID or Email Address" ──────────────── + # Confirmed against live site (2026-09-13): + # + # + try: + username_field = wait.until( + EC.element_to_be_clickable((By.ID, "username")) + ) + username_field.clear() + username_field.send_keys(self.uniteducard_username) + print(f"[UnitedUcard login] Entered username: {self.uniteducard_username}") + except TimeoutException: + return "ERROR: Username field not found on login page" + + try: + continue_btn = wait.until( + EC.element_to_be_clickable((By.ID, "btnLogin")) + ) + continue_btn.click() + print("[UnitedUcard login] Clicked Continue (btnLogin) after username") + time.sleep(2) + except TimeoutException: + return "ERROR: Continue button (btnLogin) not found after entering username" + + # ── Step 2: "Enter Your Password" page (#/signin/password) ───── + # Confirmed against live site (2026-09-13): + # + # + # The email field on this page is pre-filled/read-only from step 1. + try: + password_field = wait.until( + EC.presence_of_element_located((By.ID, "login-pwd")) + ) + password_field.clear() + password_field.send_keys(self.uniteducard_password) + print("[UnitedUcard login] Entered password") + except TimeoutException: + return "ERROR: Password field not found on login page" + + try: + signin_button = wait.until( + EC.element_to_be_clickable((By.ID, "btnLogin")) + ) + signin_button.click() + print("[UnitedUcard login] Clicked Continue (btnLogin) on password page") + except TimeoutException: + return "ERROR: Continue button (btnLogin) not found on password page" + + if self.uniteducard_username: + browser_manager.save_credentials_hash(self.uniteducard_username) + + time.sleep(5) # Wait for login to process + + current_url_after_login = self.driver.current_url.lower() + print(f"[UnitedUcard login] After login URL: {current_url_after_login}") + + if "secure.uhcdental.com" in current_url_after_login: + print("[UnitedUcard login] Login successful - redirected to UHC Dental dashboard") + return "SUCCESS" + + # ── Step 3: "Verify Your Identity" MFA method page (#/rba/options) ── + # Confirmed against live site (2026-09-13): + # + if "rba/options" in current_url_after_login: + try: + text_msg_btn = WebDriverWait(self.driver, 10).until( + EC.element_to_be_clickable((By.ID, "textMsg")) + ) + text_msg_btn.click() + print("[UnitedUcard login] Clicked 'Via Text Message' on MFA options page") + time.sleep(3) + except TimeoutException: + print("[UnitedUcard login] MFA options page detected but 'Via Text Message' button not found") + + current_url_after_login = self.driver.current_url.lower() + print(f"[UnitedUcard login] After MFA method selection URL: {current_url_after_login}") + + # Check for OTP input after submitting credentials + try: + WebDriverWait(self.driver, 15).until( + EC.presence_of_element_located((By.XPATH, + "//input[@type='tel' or contains(@placeholder,'code') or contains(@placeholder,'Code') or " + "contains(@aria-label,'Verification') or contains(@aria-label,'verification') or " + "contains(@name,'otp') or contains(@name,'code') or contains(@id,'otp') or contains(@id,'code')]" + )) + ) + print("[UnitedUcard login] OTP input detected -> OTP_REQUIRED") + return "OTP_REQUIRED" + except TimeoutException: + print("[UnitedUcard login] No OTP input detected") + + # Re-check after waiting for OTP check + current_url_after_login = self.driver.current_url.lower() + if "secure.uhcdental.com" in current_url_after_login: + print("[UnitedUcard login] Login successful - redirected to UHC Dental dashboard") + return "SUCCESS" + + # Check for error messages on the page + try: + error_elem = self.driver.find_element(By.XPATH, + "//*[contains(@class,'error') or contains(@class,'alert')]") + error_text = error_elem.text + if error_text: + print(f"[UnitedUcard login] Error on page: {error_text}") + return f"ERROR: {error_text}" + except Exception: + pass + + return "ERROR: Login did not complete - still on identity domain" + + except Exception as e: + print(f"[UnitedUcard login] Exception: {e}") + return f"ERROR:LOGIN FAILED: {e}" + + def _format_dob(self, dob_str): + """Convert DOB from YYYY-MM-DD to MM/DD/YYYY format""" + if dob_str and "-" in dob_str: + dob_parts = dob_str.split("-") + if len(dob_parts) == 3: + # YYYY-MM-DD -> MM/DD/YYYY + return f"{dob_parts[1]}/{dob_parts[2]}/{dob_parts[0]}" + return dob_str + + def step1(self): + """ + Fill in the "Eligibility Search" form — it's the default tab already + showing on the dashboard (postloginhomescreen.html) right after login. + + Confirmed against live site (2026-09-13): + - defaults blank; fill with today's date, MM/DD/YYYY + - Member Date of Birth, MM/DD/YYYY + - Subscriber ID + + "Search By: Subscriber ID" and "Search For: Individual" are already + the default radio selections, so no extra clicks needed there. + """ + from datetime import date + + def _fill_field(field_id, value, label): + """Wait for field to be clickable, scroll into view, then fill it. + Falls back to a JS value-set (+dispatch input/change events, which + Angular's ng-model listens for) if send_keys hits an + ElementNotInteractable error — the dashboard's Angular app can + take a beat to finish rendering/enabling these fields.""" + field = WebDriverWait(self.driver, 20).until( + EC.presence_of_element_located((By.ID, field_id)) + ) + self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", field) + time.sleep(0.3) + try: + WebDriverWait(self.driver, 10).until(EC.element_to_be_clickable((By.ID, field_id))) + field.clear() + field.send_keys(value) + print(f"[UnitedUcard step1] Entered {label}: {value}") + except Exception as e: + print(f"[UnitedUcard step1] send_keys failed for {label} ({e}); falling back to JS set") + self.driver.execute_script(""" + var el = arguments[0]; + var val = arguments[1]; + var nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; + nativeSetter.call(el, val); + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + el.dispatchEvent(new Event('keyup', { bubbles: true })); + """, field, value) + print(f"[UnitedUcard step1] Entered {label} via JS: {value}") + return field + + try: + print(f"[UnitedUcard step1] Starting eligibility search for memberId={self.memberId}, DOB={self.dateOfBirth}") + + # Give the dashboard's Angular app a moment to finish rendering + # after the redirect from login — filling fields too early was + # throwing "element not interactable". + WebDriverWait(self.driver, 20).until( + lambda d: d.execute_script("return document.readyState") == "complete" + ) + time.sleep(2) + + # Service Date - default to today. + # NOTE: serviceDate/eligMemDob both use onkeydown="dateFunction(...)", + # a JS input mask that auto-inserts "/" as you type raw digits + # (same as manually typing it). Sending an already-formatted + # "MM/DD/YYYY" string double-processes the slashes through the + # mask and corrupts the stored value — send digits only instead, + # matching what a human typing the field actually does. + try: + today_digits = date.today().strftime("%m%d%Y") + _fill_field("serviceDate", today_digits, "Service Date") + except TimeoutException: + return "ERROR: Service Date field (serviceDate) not found on Eligibility Search page" + except Exception as e: + return f"ERROR: Could not enter Service Date: {e}" + + # Member Date of Birth + try: + dob_digits = re.sub(r"\D", "", self._format_dob(self.dateOfBirth)) + _fill_field("eligMemDob", dob_digits, "Member DOB") + except Exception as e: + return f"ERROR: Could not enter Member Date of Birth: {e}" + + # Subscriber ID + try: + _fill_field("eligSubsId", self.memberId, "Subscriber ID") + except Exception as e: + return f"ERROR: Could not enter Subscriber ID: {e}" + + time.sleep(0.5) + + # Click SEARCH button + try: + search_btn = WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located((By.XPATH, + "//input[@type='button' and @value='search']")) + ) + self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", search_btn) + time.sleep(0.3) + try: + search_btn.click() + except Exception: + self.driver.execute_script("arguments[0].click();", search_btn) + print("[UnitedUcard step1] Clicked SEARCH button") + except Exception as e: + return f"ERROR: Could not click SEARCH button: {e}" + + time.sleep(3) + + # TODO: confirm what the results look like (same page update? new + # tab? modal?) once we see it, then extend step2() accordingly. + print("[UnitedUcard step1] Eligibility search submitted") + return "Success" + + except Exception as e: + print(f"[UnitedUcard step1] Exception: {e}") + return f"ERROR:STEP1 - {e}" + + def step2(self): + """ + Extract patient/eligibility info from the "Eligibility Summary" page + (secure.uhcdental.com/.../eligibility-summary.html) that step1()'s + SEARCH click lands on, then capture it as a PDF. + + Confirmed against live site (2026-09-13) via screenshot/PDF export: + "Patient" panel -> patient full name (e.g. "WINGHUN KWONG") + "Insurance Information" -> status badges: "In Network", + "Eligible" (or "Not Eligible"/"Ineligible"), "No Essential + Health Benefits", and a MASKED Subscriber ID (e.g. "******444") + — we deliberately do NOT use that masked ID to overwrite the + patient record. + Exact DOM ids/classes for the badges haven't been inspected yet, so + this uses text-based extraction (same approach as UnitedSCO/DDMA) + as a robust starting point. + """ + try: + print("[UnitedUcard step2] Starting eligibility capture") + + try: + WebDriverWait(self.driver, 20).until( + lambda d: d.execute_script("return document.readyState") == "complete" + ) + except Exception: + pass + time.sleep(2) + + current_url = self.driver.current_url + print(f"[UnitedUcard step2] Current URL: {current_url}") + + page_text = "" + try: + page_text = self.driver.find_element(By.TAG_NAME, "body").text + except Exception: + pass + + # ── Eligibility status ─────────────────────────────────────── + eligibilityText = "unknown" + lower_text = page_text.lower() + if "not eligible" in lower_text or "ineligible" in lower_text: + eligibilityText = "inactive" + elif "eligible" in lower_text: + eligibilityText = "active" + print(f"[UnitedUcard step2] Eligibility status: {eligibilityText}") + + # ── Patient name ───────────────────────────────────────────── + patientName = f"{self.firstName} {self.lastName}".strip() + name_extracted = False + + # Strategy 1: text right after the "Patient" heading, e.g. + # "Patient\nWINGHUN KWONG\nSUBSCRIBER/INSURED" + try: + name_match = re.search( + r'Patient\s*\n\s*([A-Z][A-Za-z\-\']+(?:\s+[A-Z][A-Za-z\-\']+)+)\s*\n', + page_text + ) + if name_match: + candidate = name_match.group(1).strip() + if candidate and len(candidate) < 60 and "eligible" not in candidate.lower(): + patientName = candidate.title() + name_extracted = True + print(f"[UnitedUcard step2] Extracted patient name from text: {patientName}") + except Exception as e: + print(f"[UnitedUcard step2] Name regex failed: {e}") + + # Strategy 2: DOM heuristic fallback — heading/strong element + # following a "Patient" label + if not name_extracted: + try: + elems = self.driver.find_elements(By.XPATH, + "//*[contains(text(),'Patient')]/following::*[self::h1 or self::h2 or self::h3 or self::strong][1]" + ) + for elem in elems: + txt = elem.text.strip() + if txt and len(txt.split()) >= 2 and txt[0].isupper() and len(txt) < 60: + patientName = txt.title() + name_extracted = True + print(f"[UnitedUcard step2] Extracted patient name from DOM: {patientName}") + break + except Exception: + pass + + if not name_extracted: + print("[UnitedUcard step2] WARNING: Could not extract patient name from page; using form values") + + # ── Capture PDF ────────────────────────────────────────────── + pdf_path = self._capture_pdf(self.memberId) + if not pdf_path: + return {"status": "error", "message": "STEP2 FAILED: Could not generate PDF"} + + print(f"[UnitedUcard step2] PDF saved: {pdf_path}") + + self._hide_browser() + + print("[UnitedUcard step2] Eligibility capture complete") + + return { + "status": "success", + "eligibility": eligibilityText, + "ss_path": pdf_path, + "pdf_path": pdf_path, + "patientName": patientName, + # NOTE: Subscriber ID shown on this page is masked (e.g. + # "******444"), so we return the ID we searched with rather + # than a scraped-and-truncated value. + "memberId": self.memberId, + } + + except Exception as e: + print(f"[UnitedUcard step2] Exception: {e}") + return {"status": "error", "message": f"STEP2 FAILED: {str(e)}"} + + def _capture_pdf(self, member_id): + """Capture the current page as PDF using Chrome DevTools Protocol.""" + try: + pdf_options = { + "landscape": False, + "displayHeaderFooter": False, + "printBackground": True, + "preferCSSPageSize": True, + "paperWidth": 8.5, + "paperHeight": 11, + "marginTop": 0.4, + "marginBottom": 0.4, + "marginLeft": 0.4, + "marginRight": 0.4, + "scale": 0.9, + } + + file_identifier = member_id if member_id else f"{self.firstName}_{self.lastName}" + + result = self.driver.execute_cdp_cmd("Page.printToPDF", pdf_options) + pdf_data = base64.b64decode(result.get('data', '')) + pdf_path = os.path.join(self.download_dir, f"uniteducard_eligibility_{file_identifier}_{int(time.time())}.pdf") + with open(pdf_path, "wb") as f: + f.write(pdf_data) + return pdf_path + except Exception as e: + print(f"[UnitedUcard _capture_pdf] Error: {e}") + return None + + def _hide_browser(self): + """Hide the browser window after task completion using multiple strategies.""" + try: + try: + self.driver.get("about:blank") + time.sleep(0.5) + except Exception: + pass + + try: + self.driver.minimize_window() + print("[UnitedUcard step2] Browser window minimized") + return + except Exception: + pass + + try: + self.driver.set_window_position(-10000, -10000) + print("[UnitedUcard step2] Browser window moved off-screen") + return + except Exception: + pass + except Exception as e: + print(f"[UnitedUcard step2] Could not hide browser: {e}") + + def main_workflow(self, url): + """Main workflow that runs all steps.""" + try: + self.config_driver() + + login_result = self.login(url) + print(f"[main_workflow] Login result: {login_result}") + + if login_result == "OTP_REQUIRED": + return {"status": "otp_required", "message": "OTP required after login"} + + if isinstance(login_result, str) and login_result.startswith("ERROR"): + return {"status": "error", "message": login_result} + + step1_result = self.step1() + print(f"[main_workflow] Step1 result: {step1_result}") + + if isinstance(step1_result, str) and step1_result.startswith("ERROR"): + return {"status": "error", "message": step1_result} + + step2_result = self.step2() + print(f"[main_workflow] Step2 result: {step2_result}") + + return step2_result + + except Exception as e: + return {"status": "error", "message": str(e)} diff --git a/apps/SeleniumService/uniteducard_browser_manager.py b/apps/SeleniumService/uniteducard_browser_manager.py new file mode 100644 index 00000000..c6f4541c --- /dev/null +++ b/apps/SeleniumService/uniteducard_browser_manager.py @@ -0,0 +1,243 @@ +""" +Minimal browser manager for United Ucard (OneHealthcareID) - only handles persistent +profile and keeping browser alive. Clears session cookies on startup (after PC restart) +to force fresh login. Tracks credentials to detect changes mid-session. + +Mirrors unitedsco_browser_manager.py — see that file for the pattern this is based on. +""" +import os +import hashlib +import threading +import subprocess +import time +from selenium import webdriver +from selenium.webdriver.chrome.service import Service +from webdriver_manager.chrome import ChromeDriverManager + +# Ensure DISPLAY is set for Chrome to work (needed when running from SSH/background) +if not os.environ.get("DISPLAY"): + os.environ["DISPLAY"] = ":0" + + +class UnitedUcardBrowserManager: + """ + Singleton that manages a persistent Chrome browser instance for United Ucard. + - Uses --user-data-dir for persistent profile (device trust tokens) + - Clears session cookies on startup (after PC restart) + - Tracks credentials to detect changes mid-session + """ + _instance = None + _lock = threading.Lock() + + def __new__(cls): + with cls._lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._driver = None + cls._instance.profile_dir = os.path.abspath("chrome_profile_uniteducard") + cls._instance.download_dir = os.path.abspath("seleniumDownloads") + cls._instance._credentials_file = os.path.join(cls._instance.profile_dir, ".last_credentials") + cls._instance._needs_session_clear = False # Flag to clear session on next driver creation + os.makedirs(cls._instance.profile_dir, exist_ok=True) + os.makedirs(cls._instance.download_dir, exist_ok=True) + return cls._instance + + def clear_session_on_startup(self): + """ + On startup, only clear the Cookies file so the login session is reset + but device trust tokens (Local Storage, IndexedDB) are preserved. + """ + print("[UnitedUcard BrowserManager] Clearing cookies on startup (preserving device trust)...") + + try: + # Clear credentials tracking so the next login re-saves the hash + if os.path.exists(self._credentials_file): + os.remove(self._credentials_file) + print("[UnitedUcard BrowserManager] Cleared credentials tracking file") + + # Only remove cookie files — leave everything else intact + cookie_files = ["Cookies", "Cookies-journal"] + for filename in cookie_files: + for base in [os.path.join(self.profile_dir, "Default"), self.profile_dir]: + filepath = os.path.join(base, filename) + if os.path.exists(filepath): + try: + os.remove(filepath) + print(f"[UnitedUcard BrowserManager] Removed {filepath}") + except Exception as e: + print(f"[UnitedUcard BrowserManager] Could not remove {filepath}: {e}") + + self._needs_session_clear = False + print("[UnitedUcard BrowserManager] Cookies cleared — device trust tokens preserved") + + except Exception as e: + print(f"[UnitedUcard BrowserManager] Error clearing session: {e}") + + def _hash_credentials(self, username: str) -> str: + """Create a hash of the username to track credential changes.""" + return hashlib.sha256(username.encode()).hexdigest()[:16] + + def get_last_credentials_hash(self) -> str | None: + """Get the hash of the last-used credentials.""" + try: + if os.path.exists(self._credentials_file): + with open(self._credentials_file, 'r') as f: + return f.read().strip() + except Exception: + pass + return None + + def save_credentials_hash(self, username: str): + """Save the hash of the current credentials.""" + try: + cred_hash = self._hash_credentials(username) + with open(self._credentials_file, 'w') as f: + f.write(cred_hash) + except Exception as e: + print(f"[UnitedUcard BrowserManager] Failed to save credentials hash: {e}") + + def credentials_changed(self, username: str) -> bool: + """Check if the credentials have changed since last login.""" + last_hash = self.get_last_credentials_hash() + if last_hash is None: + return False # No previous credentials, not a change + current_hash = self._hash_credentials(username) + changed = last_hash != current_hash + if changed: + print(f"[UnitedUcard BrowserManager] Credentials changed - logout required") + return changed + + def clear_credentials_hash(self): + """Clear the saved credentials hash (used after logout).""" + try: + if os.path.exists(self._credentials_file): + os.remove(self._credentials_file) + except Exception as e: + print(f"[UnitedUcard BrowserManager] Failed to clear credentials hash: {e}") + + def _kill_existing_chrome_for_profile(self): + """Kill any existing Chrome processes using this profile.""" + try: + result = subprocess.run( + ["pgrep", "-f", f"user-data-dir={self.profile_dir}"], + capture_output=True, text=True + ) + if result.stdout.strip(): + pids = result.stdout.strip().split('\n') + for pid in pids: + try: + subprocess.run(["kill", "-9", pid], check=False) + except Exception: + pass + time.sleep(1) + except Exception: + pass + + # Remove SingletonLock if exists + lock_file = os.path.join(self.profile_dir, "SingletonLock") + try: + if os.path.islink(lock_file) or os.path.exists(lock_file): + os.remove(lock_file) + except Exception: + pass + + def get_driver(self, headless=False): + """Get or create the persistent browser instance.""" + with self._lock: + if self._driver is None: + print("[UnitedUcard BrowserManager] Driver is None, creating new driver") + self._kill_existing_chrome_for_profile() + self._create_driver(headless) + elif not self._is_alive(): + print("[UnitedUcard BrowserManager] Driver not alive, recreating") + self._kill_existing_chrome_for_profile() + self._create_driver(headless) + else: + print("[UnitedUcard BrowserManager] Reusing existing driver") + return self._driver + + def _is_alive(self): + """Check if browser is still responsive.""" + try: + if self._driver is None: + return False + _ = self._driver.current_url + return True + except Exception: + return False + + def _create_driver(self, headless=False): + """Create browser with persistent profile.""" + if self._driver: + try: + self._driver.quit() + except Exception: + pass + self._driver = None + time.sleep(1) + + options = webdriver.ChromeOptions() + if headless: + options.add_argument("--headless") + + # Persistent profile - THIS IS THE KEY for device trust + options.add_argument(f"--user-data-dir={self.profile_dir}") + options.add_argument("--no-sandbox") + options.add_argument("--disable-dev-shm-usage") + + # Anti-detection options (prevent bot detection) + options.add_argument("--disable-blink-features=AutomationControlled") + options.add_experimental_option("excludeSwitches", ["enable-automation"]) + options.add_experimental_option("useAutomationExtension", False) + options.add_argument("--disable-infobars") + + prefs = { + "download.default_directory": self.download_dir, + "plugins.always_open_pdf_externally": True, + "download.prompt_for_download": False, + "download.directory_upgrade": True, + # Disable password save dialog that blocks page interactions + "credentials_enable_service": False, + "profile.password_manager_enabled": False, + "profile.password_manager_leak_detection": False, + } + options.add_experimental_option("prefs", prefs) + + service = Service(ChromeDriverManager().install()) + self._driver = webdriver.Chrome(service=service, options=options) + self._driver.maximize_window() + + # Remove webdriver property to avoid detection + try: + self._driver.execute_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})") + except Exception: + pass + + self._needs_session_clear = False + + def quit_driver(self): + """Quit browser (only call on shutdown).""" + with self._lock: + if self._driver: + try: + self._driver.quit() + except Exception: + pass + self._driver = None + self._kill_existing_chrome_for_profile() + + +# Singleton accessor +_manager = None + +def get_browser_manager(): + global _manager + if _manager is None: + _manager = UnitedUcardBrowserManager() + return _manager + + +def clear_uniteducard_session_on_startup(): + """Called by agent.py on startup to clear session.""" + manager = get_browser_manager() + manager.clear_session_on_startup() diff --git a/packages/db/usedSchemas/browser.ts b/packages/db/usedSchemas/browser.ts index 84ac1ee4..47ea0947 100644 --- a/packages/db/usedSchemas/browser.ts +++ b/packages/db/usedSchemas/browser.ts @@ -85,13 +85,13 @@ export const PatientUncheckedCreateInputObjectSchema = z.object({ firstName: z.string(), lastName: z.string(), dateOfBirth: z.string(), - gender: z.string().optional(), - phone: z.string().optional(), - email: z.string().optional(), - address: z.string().optional(), - city: z.string().optional(), - state: z.string().optional(), - zipCode: z.string().optional(), + gender: z.string().optional().nullable(), + phone: z.string().optional().nullable(), + email: z.string().optional().nullable(), + address: z.string().optional().nullable(), + city: z.string().optional().nullable(), + state: z.string().optional().nullable(), + zipCode: z.string().optional().nullable(), insuranceProvider: z.string().optional().nullable(), insuranceId: z.string().optional().nullable(), groupNumber: z.string().optional().nullable(),