Compare commits

...
6 Commits
Author SHA1 Message Date
GiteadandClaude Opus 5.5 036e26c5d8 docs: add one-time git credential setup to Quick Install
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:46:44 -04:00
GiteadandClaude Opus 5.5 d67aa75c42 docs: add Quick Install (VS Code, Claude Code extension, install-all.sh); add install-all.sh fresh-machine setup script
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:41:01 -04:00
GiteadandClaude Sonnet 5 d357edd4a6 feat: add Delta Dental Individual (DeltaIndiv) eligibility check flow; fix CCA eligibility false-positive error
CCA step1 was treating the harmless "password set to expire" banner as a
fatal error, closing the browser before the eligibility popup/report
could be captured. Now it short-circuits to success once the patient
popup renders and ignores password-expiration notices.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-02 15:37:55 -04:00
GiteadandClaude Sonnet 5 a5a15d86bc feat: CCA pre-auth — select first member in multi-record popup, retry code/tooth/billed fills, stop instead of submitting incomplete lines
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 00:56:36 -04:00
GiteadandClaude Sonnet 5 8c0fcb4267 feat: CCA claim — select first member in multi-record popup, retry grid cell fills, pick latest claim by DOB, faster submit confirmation
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 00:53:51 -04:00
GiteadandClaude Sonnet 5 ef919a2a2f perf: shorten fixed waits in MassHealth claim submit worker
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 23:52:03 -04:00
20 changed files with 2893 additions and 157 deletions
+55 -1
View File
@@ -36,9 +36,63 @@ sudo systemctl stop sshd && sudo systemctl disable sshd
---
## ⚡ Quick Install (Fresh Machine)
Do these three things in order. The script in step C runs Steps 2–14 and 16 of the Setup Guide below for you.
### A — Install VS Code (manually)
1. In a browser, go to https://code.visualstudio.com/download and download the **.deb** (Debian/Ubuntu) file
2. Install it from a terminal:
```sh
sudo apt install -y ~/Downloads/code_*.deb
# Verify
code --version
```
### B — Add the Claude Code extension in VS Code
1. Open VS Code
2. Open the Extensions panel (`Ctrl+Shift+X`)
3. Search for **Claude Code** (published by Anthropic) and click **Install**
4. Click the Claude icon in the sidebar and sign in
### C — Run the install script
Clone the repository to `~/Desktop/DentalManagement09` if it isn't there yet (needs git: `sudo apt install -y git`), then run:
```sh
cd ~/Desktop/DentalManagement09
bash install-all.sh
```
Enter your password when it asks. The script installs Node.js, Python, Chrome, PostgreSQL, Redis, rclone and nginx; installs the Node and Python dependencies; creates any missing `.env` files (existing ones are kept); sets up the database; and creates the desktop shortcuts. It is safe to re-run.
### D — Let git remember your Gitea login (one time)
So `git push` doesn't ask for the password every time:
```sh
git config --global credential.helper store
cd ~/Desktop/DentalManagement09
git push origin main
```
Enter the Gitea username and password once when asked — git saves them and won't prompt again.
> Prefer a Gitea **access token** over your real password: log into https://gitea.mydentalofficemanagement.com → profile picture → **Settings** → **Applications** → **Generate New Token** (give it **Read and Write** access to **repository**), and paste the token when git asks for the password. A token can be revoked in Gitea at any time without changing your password.
> `store` saves the login in plain text in `~/.git-credentials`. The keyring-based helper isn't an option here because Step 1a removes gnome-keyring.
Still done by hand afterwards: the Security Setup above, Step 1 (auto-login / keyring), Step 12a (Cloudflare subdomain), the LAN HTTPS certificate (the script only enables the nginx site once that certificate exists), and the optional Steps 17–18.
---
## 🖥️ Setup Guide (Fresh Machine)
Follow these steps in order after cloning the repository.
Follow these steps in order after cloning the repository. (Steps 2–14 and 16 are automated by `install-all.sh` — see Quick Install above.)
### Step 1 — Enable auto-login (LXQt / SDDM)
+15
View File
@@ -12,6 +12,7 @@ import { runClaimStatusProcessor } from "./processors/claimStatusProcessor";
import { runClaimSubmitProcessor } from "./processors/claimSubmitProcessor";
import { runOcrProcessor } from "./processors/ocrProcessor";
import { runDdmaEligibilityProcessor } from "./processors/ddmaEligibilityProcessor";
import { runDeltaIndivEligibilityProcessor } from "./processors/deltaIndivEligibilityProcessor";
import { runDeltaInsEligibilityProcessor } from "./processors/deltaInsEligibilityProcessor";
import { runUnitedSCOEligibilityProcessor } from "./processors/unitedSCOEligibilityProcessor";
import { runUnitedUcardEligibilityProcessor } from "./processors/unitedUcardEligibilityProcessor";
@@ -117,6 +118,20 @@ export function enqueueSeleniumJob(data: SeleniumJobData): string {
job.id
);
}
if (jobType === "deltaindiv-eligibility-check") {
return runDeltaIndivEligibilityProcessor(
{
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 === "unitedsco-eligibility-check") {
return runUnitedSCOEligibilityProcessor(
{
@@ -0,0 +1,393 @@
/**
* Processor for "deltaindiv-eligibility-check" jobs.
*
* Integrates the full Delta Indiv persistent-session flow into the InProcessQueue:
* 1. Start a session on the Python agent (POST /deltaindiv-eligibility)
* 2. Emit selenium:deltaindiv_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, create/update patient, update eligibility status
* 5. Return { pdfFileId, pdfFilename, patientUpdateStatus, pdfUploadStatus }
*
* The OTP submission endpoint (/api/insurance-status-deltaindiv/selenium/submit-otp)
* continues to forward OTPs directly to the Python agent — it does NOT go
* through the queue.
*/
import fs from "fs/promises";
import fsSync from "fs";
import path from "path";
import { storage } from "../../storage";
import { emptyFolderContainingFile } from "../../utils/emptyTempFolder";
import {
forwardToSeleniumDeltaIndivEligibilityAgent,
getSeleniumDeltaIndivSessionStatus,
} from "../../services/seleniumDeltaIndivEligibilityClient";
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("deltaindiv-processor", `emitted ${event}`, { socketId });
}
} catch (err: any) {
log("deltaindiv-processor", `emit failed for ${event}`, { err: err?.message });
}
}
// ─── Types ───────────────────────────────────────────────────────────────────
export interface DeltaIndivEligibilityProcessorInput {
enrichedPayload: any;
userId: number;
insuranceId: string;
formFirstName?: string;
formLastName?: string;
formDob?: string;
socketId?: string;
}
export interface DeltaIndivEligibilityProcessorResult {
patientUpdateStatus?: string;
pdfUploadStatus?: string;
pdfFileId?: number | string | null;
pdfFilename?: string | null;
}
// ─── Core DB processing (mirrors handleDeltaIndivCompletedJob in the route) ─────────
async function processDeltaIndivResult(
userId: number,
insuranceId: string,
formFirstName: string | undefined,
formLastName: string | undefined,
formDob: string | undefined,
seleniumResult: any
): Promise<DeltaIndivEligibilityProcessorResult> {
const output: DeltaIndivEligibilityProcessorResult = {};
let createdPdfFileId: number | string | null = null;
try {
// 1) Resolve patient name (prefer selenium extraction → form data)
const rawName =
typeof seleniumResult?.patientName === "string"
? seleniumResult.patientName.trim()
: null;
let firstName: string;
let lastName: string;
if (rawName) {
// Strip trailing bare dates Delta Indiv appends to names e.g. "Christian Rodriguez 04/27/2026"
const cleanName = rawName.replace(/\s+\d{1,2}\/\d{1,2}\/\d{2,4}$/, "").trim();
if (cleanName.includes(",")) {
// "LAST, FIRST" format common on insurance portals
const [last, ...firstParts] = cleanName.split(",").map((s: string) => s.trim());
lastName = last || formLastName || "";
firstName = firstParts.join(" ").trim() || formFirstName || "";
} else {
const parsed = splitName(cleanName);
if (!parsed.lastName) {
// Single word — treat as last name, pull first name from form
lastName = parsed.firstName || formLastName || "";
firstName = formFirstName || "";
} else {
firstName = parsed.firstName || formFirstName || "";
lastName = parsed.lastName || formLastName || "";
}
}
} else {
firstName = formFirstName ?? "";
lastName = formLastName ?? "";
}
// 2) Create / update patient
const upsertedPatient = await createOrUpdatePatientByInsuranceId({
insuranceId,
firstName,
lastName,
dob: formDob,
userId,
});
// 3) Fetch patient (needed for ID)
const normalizedInsuranceId = insuranceId.replace(/\s+/g, "");
// Use the patient just created/updated (family members share an insuranceId, so an
// id-only lookup could return a different member).
const dobForLookup = formDob ? new Date(formDob) : null;
const patient =
upsertedPatient ??
(dobForLookup && !isNaN(dobForLookup.getTime())
? await storage.getPatientByInsuranceIdAndDob(normalizedInsuranceId, dobForLookup)
: await storage.getPatientByInsuranceId(normalizedInsuranceId));
log("deltaindiv-processor", `patient lookup after create: ${patient ? `id=${patient.id}` : "NOT FOUND"} for insuranceId="${normalizedInsuranceId}"`);
if (!patient?.id) {
output.patientUpdateStatus = "Patient not found; no update performed";
return output;
}
// 4) Determine and update eligibility status + insurance provider name
const eligStatus = (seleniumResult?.eligibility ?? "").toLowerCase();
let newStatus = "UNKNOWN";
if (eligStatus === "active" || eligStatus === "y") newStatus = "ACTIVE";
else if (eligStatus === "inactive" || eligStatus === "n" || eligStatus === "termed") newStatus = "INACTIVE";
await storage.updatePatient(patient.id, {
status: newStatus,
insuranceProvider: "Delta Dental Indiv",
});
output.patientUpdateStatus = `Patient status updated to ${newStatus}`;
// 5) Resolve PDF buffer
// New Delta Indiv worker returns a real PDF via pdf_path / ss_path (.pdf).
// Old worker returned a screenshot (.png) via ss_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")) {
// Already a PDF — read directly
try {
pdfBuffer = await fs.readFile(pdfPath);
pdfFilename = path.basename(pdfPath);
log("deltaindiv-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")
) {
// Legacy screenshot → convert to PDF
try {
pdfBuffer = await imageToPdfBuffer(pdfPath);
pdfFilename = `deltaindiv_eligibility_${insuranceId}_${Date.now()}.pdf`;
log("deltaindiv-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) {
log("deltaindiv-processor", `processDeltaIndivResult ERROR: ${err?.message ?? String(err)}`, err);
return {
...output,
pdfUploadStatus:
output.pdfUploadStatus ?? `Processing failed: ${err?.message ?? String(err)}`,
pdfFileId: createdPdfFileId,
};
} finally {
// Always clean up temp files
const cleanupPath =
seleniumResult?.pdf_path ?? seleniumResult?.ss_path ?? null;
if (cleanupPath) {
try {
await emptyFolderContainingFile(cleanupPath);
} catch (e) {
log("deltaindiv-processor", "cleanup failed", { cleanupPath });
}
}
}
}
// ─── Polling loop ────────────────────────────────────────────────────────────
async function pollUntilDone(
sessionId: string,
socketId: string | undefined,
jobId: string,
pollTimeoutMs = 5 * 60 * 1000 // 5 min total (includes OTP wait)
): Promise<any> {
const maxAttempts = 600; // 600 × 500ms = 5 min
const pollIntervalMs = 500;
const maxTransientErrors = 12;
const noProgressLimit = 120; // 60 s of same status → abort
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(
`Delta Indiv polling timeout (${Math.round(pollTimeoutMs / 1000)}s) for session ${sessionId}`
);
}
try {
const st = await getSeleniumDeltaIndivSessionStatus(sessionId);
const status: string = st?.status ?? "unknown";
log("deltaindiv-processor", `poll attempt=${attempt}`, { sessionId, status });
transientErrors = 0; // reset on success
// Track no-progress
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`
);
}
// OTP required — notify frontend and keep polling
if (status === "waiting_for_otp") {
emitToSocket(socketId, "selenium:otp_required", {
session_id: sessionId,
jobId,
message: "OTP required. Please enter the OTP shown by the Delta Indiv portal.",
});
await new Promise((r) => setTimeout(r, pollIntervalMs));
continue;
}
if (status === "completed") {
log("deltaindiv-processor", "session completed", { sessionId });
return st.result;
}
if (status === "error" || status === "not_found") {
throw new Error(
st?.message || `Delta Indiv session ended with status: ${status}`
);
}
// Still running / otp_submitted / created — keep polling
await new Promise((r) => setTimeout(r, pollIntervalMs));
} catch (err: any) {
// Propagate terminal errors immediately
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;
// Transient network errors — back off
transientErrors++;
if (transientErrors > maxTransientErrors) {
throw new Error(
`Too many transient network errors polling Delta Indiv session ${sessionId}`
);
}
const backoff = Math.min(30_000, 500 * Math.pow(2, transientErrors - 1));
log("deltaindiv-processor", `transient error #${transientErrors}, backoff ${backoff}ms`, {
err: err?.message,
});
await new Promise((r) => setTimeout(r, backoff));
}
}
throw new Error(`Delta Indiv polling exhausted all attempts for session ${sessionId}`);
}
// ─── Main processor entry point ───────────────────────────────────────────────
export async function runDeltaIndivEligibilityProcessor(
input: DeltaIndivEligibilityProcessorInput,
jobId: string
): Promise<DeltaIndivEligibilityProcessorResult> {
const {
enrichedPayload,
userId,
insuranceId,
formFirstName,
formLastName,
formDob,
socketId,
} = input;
// 1) Tell Python agent to start a Delta Indiv session
log("deltaindiv-processor", "starting Python agent session", { insuranceId });
const agentResp = await forwardToSeleniumDeltaIndivEligibilityAgent(enrichedPayload);
if (!agentResp?.session_id) {
throw new Error(
"Python agent did not return a session_id for Delta Indiv eligibility"
);
}
const sessionId = agentResp.session_id as string;
log("deltaindiv-processor", "got session_id", { sessionId });
// 2) Emit session started so frontend can store session_id for OTP submission
emitToSocket(socketId, "selenium:deltaindiv_session_started", {
session_id: sessionId,
jobId,
});
// 3) Poll until done (handles OTP events internally)
const seleniumResult = await pollUntilDone(sessionId, socketId, jobId);
if (!seleniumResult || seleniumResult.status === "error") {
throw new Error(
seleniumResult?.message ?? "Delta Indiv session returned an error result"
);
}
// 4) Process DB writes and PDF upload
log("deltaindiv-processor", "processing DB result", { insuranceId });
const result = await processDeltaIndivResult(
userId,
insuranceId,
formFirstName,
formLastName,
formDob,
seleniumResult
);
log("deltaindiv-processor", "done", { result });
return result;
}
+1
View File
@@ -9,6 +9,7 @@ export type SeleniumJobType =
| "claim-pre-auth"
| "ddma-eligibility-check"
| "deltains-eligibility-check"
| "deltaindiv-eligibility-check"
| "unitedsco-eligibility-check"
| "uniteducard-eligibility-check"
| "cca-eligibility-check"
+2
View File
@@ -12,6 +12,7 @@ import documentsRoutes from "./documents";
import patientDocumentsRoutes from "./patient-documents";
import insuranceStatusRoutes from "./insuranceStatus";
import insuranceStatusDdmaRoutes from "./insuranceStatusDDMA";
import insuranceStatusDeltaIndivRoutes from "./insuranceStatusDeltaIndiv";
import insuranceStatusDeltaInsRoutes from "./insuranceStatusDeltaIns";
import insuranceStatusUnitedSCORoutes from "./insuranceStatusUnitedSCO";
import insuranceStatusUnitedUcardRoutes from "./insuranceStatusUnitedUcard";
@@ -68,6 +69,7 @@ router.use("/patient-documents", patientDocumentsRoutes);
router.use("/insurance-status", insuranceStatusRoutes);
router.use("/insurance-status-ddma", insuranceStatusDdmaRoutes);
router.use("/insurance-status-deltains", insuranceStatusDeltaInsRoutes);
router.use("/insurance-status-deltaindiv", insuranceStatusDeltaIndivRoutes);
router.use("/insurance-status-unitedsco", insuranceStatusUnitedSCORoutes);
router.use("/insurance-status-uniteducard", insuranceStatusUnitedUcardRoutes);
router.use("/insurance-status-tuftssco", insuranceStatusTuftsSCORoutes);
@@ -0,0 +1,147 @@
import { Router, Request, Response } from "express";
import { storage } from "../storage";
import { forwardOtpToSeleniumDeltaIndivAgent } from "../services/seleniumDeltaIndivEligibilityClient";
import { io } from "../socket";
import { enqueueSeleniumJob } from "../queue/jobRunner";
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 /deltaindiv-eligibility
*
* Enqueues a Delta Indiv eligibility check in the shared InProcessQueue
* (concurrency=1, mirrors the Python semaphore).
*
* Body:
* data — patient + search fields (memberId, dateOfBirth, …)
* socketId — socket.io client id for real-time updates
*
* Response: { status: "queued", jobId: "…" }
*
* Real-time events emitted to socketId during job execution:
* job:update { jobId, jobType, status: "active"|"completed"|"failed", … }
* selenium:deltaindiv_session_started { session_id, jobId }
* selenium:otp_required { session_id, jobId, message }
*/
router.post(
"/deltaindiv-eligibility",
async (req: Request, res: Response): Promise<any> => {
if (!req.body.data) {
return res
.status(400)
.json({ error: "Missing Insurance 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 credentials from DB
const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(
req.user.id,
rawData.insuranceSiteKey
);
if (!credentials) {
return res.status(404).json({
error:
"No insurance credentials found for this provider. Please update them at the Settings page.",
});
}
// Fetch NPI providers to pick the target provider on the Delta Indiv portal
const npiProviders = await storage.getNpiProvidersByUser(req.user.id);
const primaryProvider = npiProviders[0]; // sorted by sortOrder asc, then id asc
const enrichedData = {
...rawData,
deltaindivUsername: credentials.username,
deltaindivPassword: credentials.password,
providerName: primaryProvider?.providerName ?? "",
};
const socketId: string | undefined = req.body.socketId;
// Enqueue — this enforces the same concurrency=1 as all other selenium jobs
const jobId = enqueueSeleniumJob({
jobType: "deltaindiv-eligibility-check",
userId: req.user.id,
socketId,
enrichedPayload: enrichedData,
insuranceId: String(rawData.memberId ?? "").trim(),
formFirstName: rawData.firstName,
formLastName: rawData.lastName,
formDob: rawData.dateOfBirth,
});
log("deltaindiv-route", "job enqueued", { jobId, insuranceId: rawData.memberId });
return res.json({ status: "queued", jobId });
} catch (err: any) {
console.error("[deltaindiv-route] enqueue failed:", err);
return res.status(500).json({
error: err.message || "Failed to enqueue Delta Indiv selenium job",
});
}
}
);
/**
* POST /selenium/submit-otp
*
* Forwards the OTP entered by the user directly to the Python agent.
* This is a side-channel — it does NOT go through the queue.
* The polling loop inside deltaindivEligibilityProcessor picks up the completed
* state after OTP is submitted.
*
* Body: { session_id, otp, socketId? }
*/
router.post(
"/selenium/submit-otp",
async (req: Request, res: Response): Promise<any> => {
const { session_id: sessionId, otp, socketId } = req.body;
if (!sessionId || !otp) {
return res.status(400).json({ error: "session_id and otp are required" });
}
try {
const r = await forwardOtpToSeleniumDeltaIndivAgent(sessionId, otp);
emitSafe(socketId, "selenium:otp_submitted", {
session_id: sessionId,
result: r,
});
return res.json(r);
} catch (err: any) {
console.error(
"[deltaindiv-route] submit-otp failed:",
err?.response?.data || err?.message || err
);
return res.status(500).json({
error: "Failed to forward OTP to selenium agent",
detail: err?.message || err,
});
}
}
);
export default router;
@@ -0,0 +1,122 @@
import axios from "axios";
import http from "http";
import https from "https";
import dotenv from "dotenv";
dotenv.config();
export interface SeleniumPayload {
data: any;
url?: string;
}
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(
`[selenium-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(
`[selenium-client] transient network error ${code} (attempt ${attempt})`
);
}
await new Promise((r) => setTimeout(r, baseBackoffMs * attempt));
}
// final attempt (let exception bubble if it fails)
return client.request(config);
}
function now() {
return new Date().toISOString();
}
function log(tag: string, msg: string, ctx?: any) {
console.log(`${now()} [${tag}] ${msg}`, ctx ?? "");
}
export async function forwardToSeleniumDeltaIndivEligibilityAgent(
insuranceEligibilityData: any
): Promise<any> {
const payload = { data: insuranceEligibilityData };
const url = `/deltaindiv-eligibility`;
log("selenium-client", "POST deltaindiv-eligibility", {
url: SELENIUM_AGENT_BASE + url,
keys: Object.keys(payload),
});
const r = await requestWithRetries({ url, method: "POST", data: payload }, 4);
log("selenium-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 forwardOtpToSeleniumDeltaIndivAgent(
sessionId: string,
otp: string
): Promise<any> {
const url = `/submit-otp`;
log("selenium-client", "POST submit-otp", {
url: SELENIUM_AGENT_BASE + url,
sessionId,
});
const r = await requestWithRetries(
{ url, method: "POST", data: { session_id: sessionId, otp } },
4
);
log("selenium-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 getSeleniumDeltaIndivSessionStatus(
sessionId: string
): Promise<any> {
const url = `/session/${sessionId}/status`;
log("selenium-client", "GET session status", {
url: SELENIUM_AGENT_BASE + url,
sessionId,
});
const r = await requestWithRetries({ url, method: "GET" }, 4);
log("selenium-client", "session status response", {
status: r.status,
dataKeys: r.data ? Object.keys(r.data) : null,
});
if (r.status === 404) {
const e: any = new Error("not_found");
e.response = { status: 404, data: r.data };
throw e;
}
return r.data;
}
@@ -0,0 +1,435 @@
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 DeltaIndivOtpModalProps {
open: boolean;
onClose: () => void;
onSubmit: (otp: string) => Promise<void> | void;
isSubmitting: boolean;
}
function DeltaIndivOtpModal({ open, onClose, onSubmit, isSubmitting }: DeltaIndivOtpModalProps) {
const [otp, setOtp] = useState("");
useEffect(() => {
if (!open) setOtp("");
}, [open]);
if (!open) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!otp.trim()) return;
await onSubmit(otp.trim());
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white dark:bg-slate-900 rounded-xl shadow-lg w-full max-w-md p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Enter OTP</h2>
<button type="button" onClick={onClose} className="text-slate-500 hover:text-slate-800">
<X className="w-4 h-4" />
</button>
</div>
<p className="text-sm text-slate-500 mb-4">
We need the one-time password (OTP) sent by the Delta Dental Indiv portal to complete this
eligibility check.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="deltaindiv-otp">OTP</Label>
<Input
id="deltaindiv-otp"
placeholder="Enter OTP code"
value={otp}
onChange={(e) => setOtp(e.target.value)}
autoFocus
/>
</div>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={onClose} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || !otp.trim()}>
{isSubmitting ? (
<>
<LoaderCircleIcon className="w-4 h-4 mr-2 animate-spin" />
Submitting...
</>
) : (
"Submit OTP"
)}
</Button>
</div>
</form>
</div>
</div>
);
}
// ─── Main component ───────────────────────────────────────────────────────────
interface DeltaIndivEligibilityButtonProps {
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 DeltaIndivEligibilityButton({
memberId,
dateOfBirth,
firstName,
lastName,
isFormIncomplete,
autoTrigger,
onAutoTriggered,
onPdfReady,
}: DeltaIndivEligibilityButtonProps) {
const { toast } = useToast();
const dispatch = useAppDispatch();
const sessionIdRef = useRef<string | null>(null);
const autoTriggeredRef = useRef(false);
const [otpModalOpen, setOtpModalOpen] = useState(false);
const [isStarting, setIsStarting] = useState(false);
const [isSubmittingOtp, setIsSubmittingOtp] = useState(false);
// ── Socket event handlers ─────────────────────────────────────────────────
const handleDeltaIndivStart = 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: "DELTAINDIV",
};
setIsStarting(true);
try {
dispatch(
setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "Starting Delta Indiv eligibility check…",
})
);
// 1) POST to backend — returns { status: "queued", jobId }
const response = await apiRequest(
"POST",
"/api/insurance-status-deltaindiv/deltaindiv-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: "Delta Indiv job queued. Waiting for browser session to start…",
})
);
// 2) Listen for job-lifecycle and Delta Indiv-specific socket events.
// All events come through the shared app socket.
// Handler: Python agent started a browser session → we now have session_id
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 OTP or result…",
})
);
};
// Handler: OTP is required by the Delta Indiv portal
const onOtpRequired = (data: any) => {
if (String(data?.jobId) !== String(jobId)) return;
// Update sessionId in case it arrives here first
if (data.session_id) sessionIdRef.current = data.session_id;
setOtpModalOpen(true);
dispatch(
setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "OTP required for Delta Dental Indiv. Please enter the code.",
})
);
};
// Handler: OTP accepted by Python agent (optional UX feedback)
const onOtpSubmitted = (data: any) => {
if (data?.session_id && data.session_id !== sessionIdRef.current) return;
dispatch(
setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "OTP submitted. Finishing Delta Indiv eligibility check…",
})
);
};
// Handler: job completed or failed (from InProcessQueue)
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;
}
// Terminal states
cleanup();
if (data.status === "completed") {
dispatch(
setTaskStatus({
key: "eligibilityCheck",
status: "success",
message: "Delta Indiv eligibility updated and PDF attached to patient documents.",
})
);
toast({
title: "Delta Indiv 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) {
const filename =
data.result?.pdfFilename ?? `eligibility_deltaindiv_${memberId}.pdf`;
onPdfReady(pdfId, filename);
}
} else if (data.status === "failed") {
const msg = data.error ?? "Delta Indiv eligibility job failed.";
dispatch(
setTaskStatus({ key: "eligibilityCheck", status: "error", message: msg })
);
toast({ title: "Delta Indiv selenium error", description: msg, variant: "destructive" });
}
setIsStarting(false);
setOtpModalOpen(false);
};
// Attach listeners
socket.on("selenium:deltaindiv_session_started", onSessionStarted);
socket.on("selenium:otp_required", onOtpRequired);
socket.on("selenium:otp_submitted", onOtpSubmitted);
socket.on("job:update", onJobUpdate);
// Cleanup helper removes all listeners for this job
function cleanup() {
socket.off("selenium:deltaindiv_session_started", onSessionStarted);
socket.off("selenium:otp_required", onOtpRequired);
socket.off("selenium:otp_submitted", onOtpSubmitted);
socket.off("job:update", onJobUpdate);
}
// Safety timeout — clean up listeners if no terminal event in 6 min
const safetyTimer = setTimeout(() => {
cleanup();
setIsStarting(false);
setOtpModalOpen(false);
dispatch(
setTaskStatus({
key: "eligibilityCheck",
status: "error",
message: "Delta Indiv job timed out waiting for completion.",
})
);
}, 6 * 60 * 1000);
// Patch cleanup to also clear the timer
const originalCleanup = cleanup;
function cleanupWithTimer() {
clearTimeout(safetyTimer);
originalCleanup();
}
// Override the onJobUpdate cleanup reference
socket.off("job:update", onJobUpdate);
socket.on("job:update", (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;
}
cleanupWithTimer();
if (data.status === "completed") {
dispatch(
setTaskStatus({
key: "eligibilityCheck",
status: "success",
message: "Delta Indiv eligibility updated and PDF attached to patient documents.",
})
);
toast({
title: "Delta Indiv 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_deltaindiv_${memberId}.pdf`);
}
} else if (data.status === "failed") {
const msg = data.error ?? "Delta Indiv eligibility job failed.";
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: msg }));
toast({ title: "Delta Indiv selenium error", description: msg, variant: "destructive" });
}
setIsStarting(false);
setOtpModalOpen(false);
});
} catch (err: any) {
console.error("DeltaIndivEligibilityButton error:", err);
dispatch(
setTaskStatus({
key: "eligibilityCheck",
status: "error",
message: err?.message || "Failed to start Delta Indiv eligibility",
})
);
toast({
title: "Delta Indiv selenium error",
description: err?.message || "Failed to start Delta Indiv eligibility",
variant: "destructive",
});
setIsStarting(false);
}
};
// ── OTP submission ────────────────────────────────────────────────────────
const handleSubmitOtp = async (otp: string) => {
const sessionId = sessionIdRef.current;
if (!sessionId) {
toast({
title: "Session not ready",
description: "Cannot submit OTP — Delta Indiv session ID is not available yet.",
variant: "destructive",
});
return;
}
try {
setIsSubmittingOtp(true);
const resp = await apiRequest("POST", "/api/insurance-status-deltaindiv/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 OTP");
}
setOtpModalOpen(false);
} catch (err: any) {
console.error("handleSubmitOtp error:", err);
toast({
title: "Failed to submit OTP",
description: err?.message || "Error forwarding OTP to selenium agent",
variant: "destructive",
});
} finally {
setIsSubmittingOtp(false);
}
};
useEffect(() => {
if (!autoTrigger) { autoTriggeredRef.current = false; return; }
if (autoTriggeredRef.current || isFormIncomplete) return;
autoTriggeredRef.current = true;
onAutoTriggered?.();
handleDeltaIndivStart();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoTrigger, isFormIncomplete]);
// ── Render ────────────────────────────────────────────────────────────────
return (
<>
<Button
className="w-full"
variant="default"
disabled={isFormIncomplete || isStarting}
onClick={handleDeltaIndivStart}
>
{isStarting ? (
<>
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin" />
Processing...
</>
) : (
<>
<CheckCircle className="h-4 w-4 mr-2" />
Delta Indiv
</>
)}
</Button>
<DeltaIndivOtpModal
open={otpModalOpen}
onClose={() => setOtpModalOpen(false)}
onSubmit={handleSubmitOtp}
isSubmitting={isSubmittingOtp}
/>
</>
);
}
@@ -19,6 +19,7 @@ const SITE_KEY_OPTIONS = [
{ value: "MH", label: "MassHealth (MH)" },
{ value: "DDMA", label: "Delta Dental MA (DDMA)" },
{ value: "DELTAINS", label: "Delta Dental Ins (DELTAINS)" },
{ value: "DELTAINDIV", label: "Delta Dental Indiv (DELTAINDIV)" },
{ 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)" },
+26 -2
View File
@@ -38,6 +38,7 @@ import { PdfPreviewModal } from "@/components/insurance-status/pdf-preview-modal
import { DualPdfPreviewModal } from "@/components/insurance-status/dual-pdf-preview-modal";
import { useLocation } from "wouter";
import { DdmaEligibilityButton } from "@/components/insurance-status/ddma-buton-modal";
import { DeltaIndivEligibilityButton } from "@/components/insurance-status/deltaindiv-button-modal";
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";
@@ -174,7 +175,7 @@ export default function InsuranceStatusPage() {
const [cmspAccumulatorPdfId, setCmspAccumulatorPdfId] = useState<number | string | null>(null);
const [cmspAccumulatorFilename, setCmspAccumulatorFilename] = useState<string | null>(null);
const pendingAutoCheck = useRef<"mh" | "mh-history" | "cmsp" | "ddma" | "delta-ins" | "united-sco" | "united-ucard" | "tufts-sco" | "cca" | "bcbs-ma" | null>(null);
const pendingAutoCheck = useRef<"mh" | "mh-history" | "cmsp" | "ddma" | "delta-ins" | "delta-indiv" | "united-sco" | "united-ucard" | "tufts-sco" | "cca" | "bcbs-ma" | null>(null);
const [triggerTarget, setTriggerTarget] = useState<string | null>(null);
const pendingScrollTo = useRef<string | null>(null);
const [prefillTick, setPrefillTick] = useState(0);
@@ -915,7 +916,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", "united-ucard", "tufts-sco", "cca", "bcbs-ma"] as const;
const knownAutoChecks = ["mh", "mh-history", "cmsp", "ddma", "delta-ins", "delta-indiv", "united-sco", "united-ucard", "tufts-sco", "cca", "bcbs-ma"] as const;
if (autoCheck && (knownAutoChecks as readonly string[]).includes(autoCheck)) {
pendingAutoCheck.current = autoCheck;
} else if (scrollTo) {
@@ -1113,6 +1114,29 @@ export default function InsuranceStatusPage() {
/>
</div>
<div title={!isLicensed ? "License required" : undefined} className={!isLicensed ? "opacity-40 pointer-events-none" : ""}>
<DeltaIndivEligibilityButton
memberId={memberId}
dateOfBirth={dateOfBirth}
firstName={firstName}
lastName={lastName}
isFormIncomplete={isFormIncomplete}
autoTrigger={triggerTarget === "delta-indiv"}
onAutoTriggered={() => setTriggerTarget(null)}
onPdfReady={async (pdfId, fallbackFilename) => {
await queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
const claimed = await tryClaimFromChatbot(selectedPatient?.id);
void tryAppointmentFromChatbot();
if (!claimed) {
setPreviewPdfId(pdfId);
setPreviewFallbackFilename(fallbackFilename ?? `eligibility_deltaindiv_${memberId}.pdf`);
setPreviewOpen(true);
}
processNextInQueue();
}}
/>
</div>
<div title={!isLicensed ? "License required" : undefined} className={!isLicensed ? "opacity-40 pointer-events-none" : ""}>
<BcbsMaEligibilityButton
memberId={memberId}
+1
View File
@@ -4,6 +4,7 @@
.last_credentials
chrome_profile_unitedsco/
chrome_profile_ddma/
chrome_profile_deltaindiv/
chrome_profile_dentaquest/
chrome_profile_masshealth/
chrome_profile_*/
+56
View File
@@ -15,6 +15,7 @@ from selenium_MHBatchPaymentCheckWorker import AutomationMassHealthBatchPaymentC
import os
import time
import helpers_ddma_eligibility as hddma
import helpers_deltaindiv_eligibility as hdeltaindiv
import helpers_deltains_eligibility as hdeltains
import helpers_unitedsco_eligibility as hunitedsco
import helpers_uniteducard_eligibility as huniteducard
@@ -33,6 +34,7 @@ import helpers_bcbs_ma_eligibility as hbcbs_ma
# Import startup session-clear functions
from ddma_browser_manager import clear_ddma_session_on_startup
from deltaindiv_browser_manager import clear_deltaindiv_session_on_startup
from deltains_browser_manager import clear_deltains_session_on_startup
from unitedsco_browser_manager import clear_unitedsco_session_on_startup
from uniteducard_browser_manager import clear_uniteducard_session_on_startup
@@ -47,6 +49,7 @@ print("=" * 50)
print("SELENIUM AGENT STARTING - CLEARING SESSIONS")
print("=" * 50)
clear_ddma_session_on_startup()
clear_deltaindiv_session_on_startup()
clear_deltains_session_on_startup()
clear_unitedsco_session_on_startup()
clear_uniteducard_session_on_startup()
@@ -420,6 +423,45 @@ async def ddma_eligibility(request: Request):
return {"status": "started", "session_id": sid}
async def _deltaindiv_worker_wrapper(sid: str, data: dict, url: str):
"""Background worker for Delta Indiv eligibility — acquires semaphore, updates counters."""
global active_jobs, waiting_jobs
async with semaphore:
async with lock:
waiting_jobs -= 1
active_jobs += 1
try:
await hdeltaindiv.start_deltaindiv_run(sid, data, url)
finally:
async with lock:
active_jobs -= 1
@app.post("/deltaindiv-eligibility")
async def deltaindiv_eligibility(request: Request):
"""
Starts a Delta Indiv eligibility session in the background.
Body: { "data": { ... } }
Returns: { status: "started", session_id: "<uuid>" }
"""
global waiting_jobs
body = await request.json()
data = body.get("data", {})
sid = hdeltaindiv.make_session_entry()
hdeltaindiv.sessions[sid]["type"] = "deltaindiv_eligibility"
hdeltaindiv.sessions[sid]["last_activity"] = time.time()
async with lock:
waiting_jobs += 1
# TODO: replace with the real Delta Indiv sign-in URL
asyncio.create_task(_deltaindiv_worker_wrapper(sid, data, url="https://www.deltadentalma.com/sign-in"))
return {"status": "started", "session_id": sid}
async def _deltains_worker_wrapper(sid: str, data: dict, url: str):
"""Background worker for DeltaIns — acquires semaphore, updates counters."""
global active_jobs, waiting_jobs
@@ -1058,6 +1100,8 @@ async def submit_otp(request: Request):
# Try each session store in order
if sid in hddma.sessions:
res = hddma.submit_otp(sid, otp)
elif sid in hdeltaindiv.sessions:
res = hdeltaindiv.submit_otp(sid, otp)
elif sid in hdeltains.sessions:
res = hdeltains.submit_otp(sid, otp)
elif sid in hunitedsco.sessions:
@@ -1095,6 +1139,8 @@ async def session_status(sid: str):
# Try each session store in order
if sid in hddma.sessions:
s = hddma.get_session_status(sid)
elif sid in hdeltaindiv.sessions:
s = hdeltaindiv.get_session_status(sid)
elif sid in hdeltains.sessions:
s = hdeltains.get_session_status(sid)
elif sid in hunitedsco.sessions:
@@ -1144,6 +1190,16 @@ async def clear_ddma_session_endpoint():
return {"status": "error", "message": str(e)}
@app.post("/clear-deltaindiv-session")
async def clear_deltaindiv_session_endpoint():
"""Clears the Delta Indiv browser session. Call when credentials are deleted or changed."""
try:
clear_deltaindiv_session_on_startup()
return {"status": "success", "message": "Delta Indiv session cleared"}
except Exception as e:
return {"status": "error", "message": str(e)}
@app.post("/clear-deltains-session")
async def clear_deltains_session_endpoint():
"""Clears the DeltaIns browser session. Call when credentials are deleted or changed."""
@@ -0,0 +1,253 @@
"""
Browser manager for Delta Indiv (Delta Dental Indiv) - persistent profile with session management.
- Uses --user-data-dir for persistent profile (device trust tokens)
- Clears session cookies on startup (after PC restart) to force fresh login
- Tracks credentials to detect changes mid-session (triggers logout)
- Anti-detection options to avoid bot detection
"""
import os
import glob
import shutil
import hashlib
import threading
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 DeltaIndivBrowserManager:
"""
Singleton that manages a persistent Chrome browser instance.
- 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_deltaindiv")
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
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):
"""
Clear only login cookies on startup to force credential re-entry after restart.
NEVER clears Local Storage or IndexedDB — those hold the Delta Indiv device trust token
that allows the portal to skip OTP for recognised devices.
"""
print("[Delta Indiv BrowserManager] Clearing login cookies on startup (preserving device trust)...")
try:
# Reset credentials tracking so the next login re-saves the hash
if os.path.exists(self._credentials_file):
os.remove(self._credentials_file)
print("[Delta Indiv BrowserManager] Cleared credentials tracking file")
# Only remove cookie / login-data files — these expire the session so the
# user must re-enter credentials, but the device trust token is untouched.
session_files = [
"Cookies",
"Cookies-journal",
"Login Data",
"Login Data-journal",
]
for filename in session_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"[Delta Indiv BrowserManager] Removed {filename}")
except Exception as e:
print(f"[Delta Indiv BrowserManager] Could not remove {filename}: {e}")
# Local Storage, IndexedDB, and Session Storage are intentionally
# NOT cleared — they contain the Delta Indiv device trust token that prevents
# OTP from being required on every login.
self._needs_session_clear = True
print("[Delta Indiv BrowserManager] Startup clear done — device trust preserved, OTP not required")
except Exception as e:
print(f"[Delta Indiv BrowserManager] Error clearing session: {e}")
# ── Credential hash tracking ──────────────────────────────────────────────
def _hash_credentials(self, username: str) -> str:
return hashlib.sha256(username.encode()).hexdigest()[:16]
def get_last_credentials_hash(self) -> str | None:
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):
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"[Delta Indiv BrowserManager] Failed to save credentials hash: {e}")
def credentials_changed(self, username: str) -> bool:
last_hash = self.get_last_credentials_hash()
if last_hash is None:
return False # No previous credentials stored — not a change
current_hash = self._hash_credentials(username)
changed = last_hash != current_hash
if changed:
print("[Delta Indiv BrowserManager] Credentials changed — logout required")
return changed
def clear_credentials_hash(self):
try:
if os.path.exists(self._credentials_file):
os.remove(self._credentials_file)
except Exception as e:
print(f"[Delta Indiv BrowserManager] Failed to clear credentials hash: {e}")
# ── Chrome process management ─────────────────────────────────────────────
def _kill_existing_chrome_for_profile(self):
"""Kill any existing Chrome processes using this profile and remove lock files."""
import subprocess
import time as time_module
try:
result = subprocess.run(
["pgrep", "-f", f"user-data-dir={self.profile_dir}"],
capture_output=True, text=True
)
if result.stdout.strip():
for pid in result.stdout.strip().split('\n'):
try:
subprocess.run(["kill", "-9", pid], check=False)
except Exception:
pass
time_module.sleep(1)
except Exception:
pass
for lock_file in ["SingletonLock", "SingletonSocket", "SingletonCookie"]:
lock_path = os.path.join(self.profile_dir, lock_file)
try:
if os.path.islink(lock_path) or os.path.exists(lock_path):
os.remove(lock_path)
except Exception:
pass
# ── Driver lifecycle ──────────────────────────────────────────────────────
def get_driver(self, headless=False):
"""Get or create the persistent browser instance."""
with self._lock:
if self._driver is None:
print("[Delta Indiv BrowserManager] Driver is None, creating new driver")
self._kill_existing_chrome_for_profile()
self._create_driver(headless)
elif not self._is_alive():
print("[Delta Indiv BrowserManager] Driver not alive, recreating")
self._kill_existing_chrome_for_profile()
self._create_driver(headless)
else:
print("[Delta Indiv BrowserManager] Reusing existing driver")
return self._driver
def _is_alive(self):
try:
if self._driver is None:
return False
url = self._driver.current_url
print(f"[Delta Indiv BrowserManager] Driver alive, current URL: {url[:50]}...")
return True
except Exception as e:
print(f"[Delta Indiv BrowserManager] Driver not alive: {e}")
return False
def _create_driver(self, headless=False):
"""Create browser with persistent profile and anti-detection options."""
if self._driver:
try:
self._driver.quit()
except Exception:
pass
options = webdriver.ChromeOptions()
if headless:
options.add_argument("--headless")
# Persistent profile — keeps device trust tokens between runs
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.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,
}
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 — NOT between patients)."""
with self._lock:
if self._driver:
try:
self._driver.quit()
except Exception:
pass
self._driver = None
# ── Singleton accessor ────────────────────────────────────────────────────────
_manager = None
def get_browser_manager() -> DeltaIndivBrowserManager:
global _manager
if _manager is None:
_manager = DeltaIndivBrowserManager()
return _manager
def clear_deltaindiv_session_on_startup():
"""Called by agent.py on startup to clear Delta Indiv session (after PC restart)."""
manager = get_browser_manager()
manager.clear_session_on_startup()
@@ -0,0 +1,297 @@
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_DeltaIndiv_eligibilityCheckWorker import AutomationDeltaIndivEligibilityCheck
# In-memory session store
sessions: Dict[str, Dict[str, Any]] = {}
SESSION_OTP_TIMEOUT = int(os.getenv("SESSION_OTP_TIMEOUT", "120")) # seconds
def make_session_entry() -> str:
"""Create a new session entry and return its ID."""
import uuid
sid = str(uuid.uuid4())
sessions[sid] = {
"status": "created", # created → running → waiting_for_otp → completed / error
"created_at": time.time(),
"last_activity": time.time(),
"bot": None, # AutomationDeltaIndivEligibilityCheck instance
"driver": None, # selenium webdriver
"otp_event": asyncio.Event(),
"otp_value": None, # OTP submitted from the app
"result": None,
"message": None,
"type": None,
}
return sid
async def cleanup_session(sid: str, message: str | None = None):
"""
Wake any OTP waiter, set final state, and remove the session entry.
Safe to call multiple times (idempotent).
NOTE: Does NOT quit the browser driver — the persistent browser stays alive.
"""
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_deltaindiv] cleaned session {sid}")
async def _remove_session_later(sid: str, delay: int = 20):
await asyncio.sleep(delay)
await cleanup_session(sid)
async def start_deltaindiv_run(sid: str, data: dict, url: str):
"""
Run the full Delta Indiv eligibility workflow for one session.
Called by agent.py inside a wrapper that manages the semaphore/counters.
OTP handling uses two complementary strategies:
1. Accept OTP submitted from the app (via /submit-otp endpoint → otp_value field)
2. Poll the browser URL/DOM directly to detect when the user enters OTP themselves
"""
s = sessions.get(sid)
if not s:
return {"status": "error", "message": "session not found"}
s["status"] = "running"
s["last_activity"] = time.time()
try:
bot = AutomationDeltaIndivEligibilityCheck({"data": data})
bot.config_driver()
s["bot"] = bot
s["driver"] = bot.driver
s["last_activity"] = time.time()
# Navigate to login page
try:
if not url:
raise ValueError("URL not provided for Delta Indiv run")
bot.driver.maximize_window()
bot.driver.get(url)
await asyncio.sleep(1)
except Exception as e:
s["status"] = "error"
s["message"] = f"Navigation failed: {e}"
await cleanup_session(sid)
return {"status": "error", "message": s["message"]}
# Login
try:
login_result = bot.login(url)
except WebDriverException as wde:
s["status"] = "error"
s["message"] = f"Selenium driver error during login: {wde}"
await cleanup_session(sid, s["message"])
return {"status": "error", "message": s["message"]}
except Exception as e:
s["status"] = "error"
s["message"] = f"Unexpected error during login: {e}"
await cleanup_session(sid, s["message"])
return {"status": "error", "message": s["message"]}
# ── Path: already logged in (persistent session) ──────────────────────
if isinstance(login_result, str) and login_result == "ALREADY_LOGGED_IN":
print("[start_deltaindiv_run] Session persisted - skipping OTP")
s["status"] = "running"
s["message"] = "Session persisted"
# ── Path: 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"
s["last_activity"] = time.time()
driver = s["driver"]
# Poll every second for up to SESSION_OTP_TIMEOUT seconds.
# Accept OTP from two sources:
# a) app API (otp_value set by submit_otp())
# b) user entering OTP directly in the browser window
max_polls = SESSION_OTP_TIMEOUT
login_success = False
print(f"[OTP] Polling for OTP completion (up to {SESSION_OTP_TIMEOUT}s)...")
for poll in range(max_polls):
await asyncio.sleep(1)
s["last_activity"] = time.time()
try:
# a) App submitted OTP via /submit-otp endpoint
otp_value = s.get("otp_value")
if otp_value:
print(f"[OTP poll {poll+1}] OTP received from app, typing it in...")
try:
otp_input = driver.find_element(By.XPATH, "//input[@data-test-id='MfaCode']")
otp_input.clear()
otp_input.send_keys(otp_value)
try:
verify_btn = driver.find_element(By.XPATH, "//button[@data-test-id='ConfirmSubmitButton']")
verify_btn.click()
except Exception:
otp_input.send_keys("\n")
print("[OTP] OTP typed and submitted via app")
s["otp_value"] = None # Clear so we don't re-submit
await asyncio.sleep(3)
except Exception as type_err:
print(f"[OTP] Failed to type OTP from app: {type_err}")
# b) Check URL — if we're past OTP page, login succeeded
current_url = driver.current_url.lower()
print(f"[OTP poll {poll+1}/{max_polls}] URL: {current_url[:70]}...")
if "member" in current_url or "dashboard" in current_url or "eligibility" in current_url:
try:
member_search = WebDriverWait(driver, 5).until(
EC.presence_of_element_located((By.XPATH, "//a[contains(@href, 'check-patient-benefits-search')]"))
)
print("[OTP] Member search found — login successful!")
login_success = True
break
except TimeoutException:
print("[OTP] On member page but search input not found, continuing...")
# Check if OTP input is still visible (user hasn't finished)
try:
otp_input_elem = driver.find_element(By.XPATH, "//input[@data-test-id='MfaCode']")
print(f"[OTP poll {poll+1}] OTP input still visible - waiting...")
except Exception:
# OTP input gone — may mean login is completing; try members page
if "sign-in" in current_url:
print("[OTP] OTP input gone, trying to navigate to members page...")
try:
driver.get("https://www.deltadentalma.com/private/provider/dashboard")
await asyncio.sleep(2)
except Exception:
pass
except Exception as poll_err:
print(f"[OTP poll {poll+1}] Error: {poll_err}")
if not login_success:
# Final check — navigate directly to members page
try:
print("[OTP] Final attempt - navigating to members page...")
driver.get("https://www.deltadentalma.com/private/provider/dashboard")
await asyncio.sleep(3)
member_search = WebDriverWait(driver, 10).until(
EC.presence_of_element_located((By.XPATH, "//a[contains(@href, 'check-patient-benefits-search')]"))
)
print("[OTP] Member search 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("[OTP] Proceeding to step1...")
# ── Path: login succeeded without OTP ─────────────────────────────────
elif isinstance(login_result, str) and login_result == "SUCCESS":
print("[start_deltaindiv_run] Login succeeded without OTP")
s["status"] = "running"
s["message"] = "Login succeeded"
# ── Path: 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}
# ── Step 1: search ────────────────────────────────────────────────────
step1_result = bot.step1()
if isinstance(step1_result, str) and step1_result.startswith("ERROR"):
s["status"] = "error"
s["message"] = step1_result
await cleanup_session(sid)
return {"status": "error", "message": step1_result}
# ── Step 2: PDF generation ────────────────────────────────────────────
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)
await cleanup_session(sid)
return {"status": "error", "message": s["message"]}
except Exception as e:
s["status"] = "error"
s["message"] = f"worker exception: {e}"
await cleanup_session(sid)
return {"status": "error", "message": s["message"]}
def submit_otp(sid: str, otp: str) -> Dict[str, Any]:
"""
Called when the app sends an OTP via POST /submit-otp.
Sets otp_value on the session so the polling loop picks it up.
"""
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") == "completed" else None,
}
@@ -343,6 +343,20 @@ class AutomationCCAClaimSubmit:
if not verified:
return "ERROR: 'Verify Eligibility' button not found"
# Multiple records popup: pick the first member (top Select)
try:
sel_btn = WebDriverWait(self.driver, 12).until(
EC.element_to_be_clickable((By.ID, "buttonSelectMember0"))
)
print("[CCA Claim step2] Multiple records popup — clicking top Select")
try:
sel_btn.click()
except Exception:
self.driver.execute_script("arguments[0].click();", sel_btn)
time.sleep(2)
except TimeoutException:
print("[CCA Claim step2] No multiple-records popup")
# Wait for eligibility result
print("[CCA Claim step2] Waiting for eligibility result...")
try:
@@ -449,11 +463,43 @@ class AutomationCCAClaimSubmit:
# Extract Encounter ID from the FIRST row (newest claim is at the top)
claim_number = None
# Try table cells first (Encounter ID is the first column)
for sel in [
# Pick the latest claim for this member: rows matching DOB, highest Encounter ID
try:
def _variants(d):
d = self._format_dob(d) if d else ""
if not d:
return []
m, dd, y = d.split("/")
return list({d, f"{int(m)}/{int(dd)}/{y}", f"{m}/{dd}/{y[-2:]}"})
dos_v = _variants(self.serviceDate)
dob_v = _variants(self.dateOfBirth)
rows = self.driver.execute_script("""
return Array.from(document.querySelectorAll('table tbody tr')).map(function(tr){
return Array.from(tr.querySelectorAll('td')).map(function(td){return td.innerText.trim();});
}).filter(function(c){return c.length;});
""") or []
# Rows for this member (by DOB); the largest Encounter ID is the latest claim
id_rows = [c for c in rows if re.match(r'\d{10,}', c[0])]
dob_rows = [c for c in id_rows if any(v in " | ".join(c) for v in dob_v)]
if not dob_rows:
# DOB not shown/matched — fall back to the service date
dob_rows = [c for c in id_rows if any(v in " | ".join(c) for v in dos_v)]
if dob_rows:
best = max(dob_rows, key=lambda c: int(re.match(r'\d+', c[0]).group(0)))
claim_number = re.match(r'\d+', best[0]).group(0)
print(f"[CCA Claim step4] Encounter ID {claim_number} = latest of "
f"{len(dob_rows)} row(s) for DOB {dob_v[:1]}: {best}")
else:
print(f"[CCA Claim step4] No row matched DOB={dob_v} or DOS={dos_v}; "
f"falling back to top row. Rows seen: {rows[:6]}")
except Exception as e:
print(f"[CCA Claim step4] Row matching failed: {e!r}")
# Fallback: top row (Encounter ID is the first column)
for sel in ([] if claim_number else [
(By.XPATH, "//table//tbody//tr[1]/td[1]"),
(By.XPATH, "//*[contains(@class,'wbx-table-results')]//tr[1]/td[1]"),
]:
]):
try:
cell = WebDriverWait(self.driver, 8).until(
EC.presence_of_element_located(sel))
@@ -806,27 +852,56 @@ class AutomationCCAClaimSubmit:
print(f"[CCA Claim grid] Filled {input_id}={value!r}")
return True
except Exception as e:
print(f"[CCA Claim grid] Fill {input_id} failed: {e}")
print(f"[CCA Claim grid] Fill {input_id} failed: {e!r}")
return False
def click_cell_and_fill_retry(col_expr, input_id, value, attempts=3):
"""Retry when the editor input doesn't appear after the cell click
(the grid sometimes hasn't finished committing the previous row)."""
from selenium.webdriver.common.action_chains import ActionChains
for attempt in range(1, attempts + 1):
if click_cell_and_fill(col_expr, input_id, value):
return True
print(f"[CCA Claim grid] {input_id} attempt {attempt}/{attempts} failed")
time.sleep(1.5)
if attempt >= 2:
# Escape any stray popup/editor and try a double-click on the cell
try:
ActionChains(self.driver).send_keys(Keys.ESCAPE).perform()
cell = self.driver.find_element(
By.XPATH,
f"(//div[contains(@class,'e-gridcontent')]//tr)[{row_num}]/td[{col_expr}]")
ActionChains(self.driver).double_click(cell).perform()
time.sleep(0.5)
field = self.driver.find_element(By.ID, input_id)
field.send_keys(Keys.CONTROL + "a")
field.send_keys(Keys.DELETE)
field.send_keys(str(value))
print(f"[CCA Claim grid] Filled {input_id}={value!r} via double-click")
return True
except Exception as e:
print(f"[CCA Claim grid] Double-click fallback failed: {e!r}")
return False
# CODE
code_ok = click_cell_and_fill(col_map["CODE"], "ServicesCODE", code)
code_ok = click_cell_and_fill_retry(col_map["CODE"], "ServicesCODE", code)
time.sleep(1.0) # let the grid finish its code lookup before the next cell
# TOOTH — skip if empty
tooth_ok = True
if tooth:
tooth_ok = click_cell_and_fill(col_map["TOOTH"], "ServicesTOOTH", tooth)
tooth_ok = click_cell_and_fill_retry(col_map["TOOTH"], "ServicesTOOTH", tooth)
# Surfaces — skip if empty
if surface_chars:
surf_ids = ["ServicesSURF1","ServicesSURF2","ServicesSURF3",
"ServicesSURF4","ServicesSURF5"]
for si, char in enumerate(surface_chars[:5]):
click_cell_and_fill(col_map[f"SURF{si+1}"], surf_ids[si], char)
click_cell_and_fill_retry(col_map[f"SURF{si+1}"], surf_ids[si], char)
# Billed Amount
# QTY and Service Date auto-fill after CODE is entered and this cell is clicked
billed_ok = click_cell_and_fill(col_map["BILLED_AMOUNT"], "ServicesBILLED_AMOUNT", billed_str)
billed_ok = click_cell_and_fill_retry(col_map["BILLED_AMOUNT"], "ServicesBILLED_AMOUNT", billed_str)
if not (code_ok and tooth_ok and billed_ok):
missing = [name for name, ok in
@@ -954,23 +1029,36 @@ class AutomationCCAClaimSubmit:
# ---- Handle confirmation modal popup ----
print("[CCA Claim step3] Waiting for confirmation modal...")
time.sleep(1.5)
modal_confirmed = False
for sel in [
modal_selectors = [
# Most specific: Submit/Confirm/Yes button inside an active modal
(By.XPATH, "//div[contains(@class,'modal') and contains(@class,'in')]//button[contains(text(),'Submit') or contains(text(),'Confirm') or contains(text(),'Yes')]"),
(By.XPATH, "//div[@class='modal-footer']//button[contains(text(),'Submit') or contains(text(),'Confirm') or contains(text(),'Yes')]"),
(By.XPATH, "//button[@ng-click and (contains(@ng-click,'confirm') or contains(@ng-click,'Confirm') or contains(@ng-click,'submit') or contains(@ng-click,'Submit'))]"),
(By.XPATH, "//div[contains(@class,'modal')]//button[not(contains(@class,'cancel')) and not(contains(text(),'Cancel')) and not(contains(text(),'No'))]"),
]:
try:
btn = WebDriverWait(self.driver, 5).until(EC.element_to_be_clickable(sel))
btn.click()
modal_confirmed = True
print(f"[CCA Claim step3] Clicked modal confirm via {sel}")
break
except Exception:
continue
]
# Poll for the modal's own button first (patterns 1-2). The looser patterns (3-4)
# also match the page's own "Submit Claim" button behind the popup, so they are
# only tried after the modal-specific ones have had time to appear.
start = time.monotonic()
while time.monotonic() - start < 8 and not modal_confirmed:
active = modal_selectors[:2] if time.monotonic() - start < 5 else modal_selectors
for sel in active:
try:
btn = next((b for b in self.driver.find_elements(*sel)
if b.is_displayed() and b.is_enabled()), None)
if btn:
btn.click()
modal_confirmed = True
print(f"[CCA Claim step3] Clicked modal confirm via {sel}")
break
except Exception:
continue
if not modal_confirmed:
time.sleep(0.3)
if not modal_confirmed:
time.sleep(0.3)
if not modal_confirmed:
print("[CCA Claim step3] No confirmation modal found — may have auto-submitted")
@@ -978,7 +1066,7 @@ class AutomationCCAClaimSubmit:
# Wait for final confirmation page
print("[CCA Claim step3] Waiting for submission confirmation...")
try:
WebDriverWait(self.driver, 30).until(
WebDriverWait(self.driver, 10).until(
lambda d: any(x in d.find_element(By.TAG_NAME, "body").text.lower() for x in [
"claim submitted", "claim number", "confirmation", "success",
"has been submitted", "claim id", "assigned the number",
@@ -466,13 +466,22 @@ class AutomationCCAEligibilityCheck:
if "no results" in body_text.lower() or "not found" in body_text.lower() or "no patient" in body_text.lower():
return "ERROR: No patient found with the provided Subscriber ID and DOB"
# Patient popup already rendered — this is a success regardless of any
# unrelated banner (e.g. a password-expiration notice) also on the page.
if "patient selected" in body_text.lower() or "patient information" in body_text.lower():
return "SUCCESS"
# Check for error alerts
try:
alerts = self.driver.find_elements(By.XPATH,
"//*[@role='alert'] | //*[contains(@class,'alert-danger')]")
for alert in alerts:
if alert.is_displayed() and alert.text.strip():
return f"ERROR: {alert.text.strip()[:200]}"
alert_text = alert.text.strip()
if not alert.is_displayed() or not alert_text:
continue
if "password" in alert_text.lower() and "expire" in alert_text.lower():
continue
return f"ERROR: {alert_text[:200]}"
except Exception:
pass
+134 -107
View File
@@ -363,6 +363,20 @@ class AutomationCCAPreAuth:
if not verified:
return "ERROR: 'Verify Eligibility' button not found"
# Multiple records popup: pick the first member (top Select)
try:
sel_btn = WebDriverWait(self.driver, 12).until(
EC.element_to_be_clickable((By.ID, "buttonSelectMember0"))
)
print("[CCA PreAuth step2] Multiple records popup — clicking top Select")
try:
sel_btn.click()
except Exception:
self.driver.execute_script("arguments[0].click();", sel_btn)
time.sleep(2)
except TimeoutException:
print("[CCA PreAuth step2] No multiple-records popup")
print("[CCA PreAuth step2] Waiting for eligibility result...")
try:
WebDriverWait(self.driver, 25).until(
@@ -681,108 +695,107 @@ class AutomationCCAPreAuth:
# ── Code: click td[2] to activate → find ServicesCODE by ID globally ──
code_filled = False
for xpath in [
f"//div[@id='Services']//tr[@aria-rowindex='{row_idx}']/td[2]",
f"//div[@id='Services']//div[contains(@class,'e-gridcontent')]//tbody/tr[{idx+1}]/td[2]",
f"//div[@id='Services']//div[contains(@class,'e-content')]//tr[{idx+1}]/td[2]",
]:
try:
code_cell = self.driver.find_element(By.XPATH, xpath)
self.driver.execute_script(
"arguments[0].scrollIntoView({block:'nearest',inline:'nearest'});", code_cell)
code_cell.click()
time.sleep(0.5)
code_input = WebDriverWait(self.driver, 5).until(
EC.presence_of_element_located((By.ID, "ServicesCODE")))
code_input.send_keys(code)
time.sleep(1.5)
print(f"[CCA PreAuth step3] Row {idx+1}: code={code!r} entered")
code_filled = True
for _ca in range(1, 4):
for xpath in [
f"//div[@id='Services']//tr[@aria-rowindex='{row_idx}']/td[2]",
f"//div[@id='Services']//div[contains(@class,'e-gridcontent')]//tbody/tr[{idx+1}]/td[2]",
f"//div[@id='Services']//div[contains(@class,'e-content')]//tr[{idx+1}]/td[2]",
]:
try:
code_cell = self.driver.find_element(By.XPATH, xpath)
self.driver.execute_script(
"arguments[0].scrollIntoView({block:'nearest',inline:'nearest'});", code_cell)
code_cell.click()
time.sleep(0.5)
code_input = WebDriverWait(self.driver, 5).until(
EC.presence_of_element_located((By.ID, "ServicesCODE")))
code_input.send_keys(code)
time.sleep(1.5)
print(f"[CCA PreAuth step3] Row {idx+1}: code={code!r} entered")
code_filled = True
break
except Exception as ce:
print(f"[CCA PreAuth step3] Row {idx+1}: code attempt {_ca}/3 {ce.__class__.__name__}")
continue
if code_filled:
break
except Exception:
continue
time.sleep(1.5)
if not code_filled:
print(f"[CCA PreAuth step3] Row {idx+1}: Code fill failed — skipping row")
continue
print(f"[CCA PreAuth step3] Row {idx+1}: Code fill failed")
return (f"ERROR: failed to fill code for service line {idx+1} "
f"(code={code}) — pre-auth NOT submitted")
# ── Tooth: click td[4] in Services grid → wait for input → clear → type ──
# ── Tooth: click td[4] → wait for input → clear → type (retry on stale/not-ready) ──
if tooth:
try:
from selenium.webdriver.common.action_chains import ActionChains
tooth_cell = None
tooth_cell_xpaths = [
f"//div[@id='Services']//tr[@aria-rowindex='{row_idx}']/td[4]",
f"//div[@id='Services']//div[contains(@class,'e-gridcontent')]//tbody/tr[{idx+1}]/td[4]",
f"//div[@id='Services']//div[contains(@class,'e-content')]//tr[{idx+1}]/td[4]",
]
# Re-find cell fresh each attempt to avoid StaleElement
for xpath in tooth_cell_xpaths:
try:
tooth_cell = self.driver.find_element(By.XPATH, xpath)
break
except Exception:
continue
if tooth_cell:
from selenium.webdriver.common.action_chains import ActionChains
tooth_done = False
time.sleep(0.8) # grid re-renders the row after the code is entered
for _ta in range(1, 5):
try:
tooth_cell = None
for xpath in [
f"//div[@id='Services']//tr[@aria-rowindex='{row_idx}']/td[4]",
f"//div[@id='Services']//div[contains(@class,'e-gridcontent')]//tbody/tr[{idx+1}]/td[4]",
f"//div[@id='Services']//div[contains(@class,'e-content')]//tr[{idx+1}]/td[4]",
]:
try:
tooth_cell = self.driver.find_element(By.XPATH, xpath)
break
except Exception:
continue
if not tooth_cell:
raise Exception("tooth cell not found")
self.driver.execute_script(
"arguments[0].scrollIntoView({block:'nearest',inline:'nearest'});", tooth_cell)
tooth_cell.click()
time.sleep(0.3)
if _ta >= 3:
ActionChains(self.driver).double_click(tooth_cell).perform()
else:
tooth_cell.click()
time.sleep(0.4)
# Wait until an input becomes active/focused inside the cell
# Fresh lookup every time: prefer the pre-rendered ServicesTOOTH input
tooth_input = None
for _ in range(10):
for _ in range(8):
cands = [e for e in self.driver.find_elements(By.ID, "ServicesTOOTH")
if e.is_displayed()]
if cands:
tooth_input = cands[0]
break
active = self.driver.switch_to.active_element
tag = active.tag_name.lower() if active else ""
if tag == "input":
if active.tag_name.lower() == "input":
tooth_input = active
break
# Try clicking again to trigger edit mode
try:
tooth_cell = self.driver.find_element(By.XPATH, tooth_cell_xpaths[0])
except Exception:
pass
try:
tooth_cell.click()
except Exception:
pass
time.sleep(0.2)
time.sleep(0.25)
if not tooth_input:
raise Exception("tooth input not active")
if tooth_input:
# Clear → Type → Verify (with JS fallback)
tooth_input.click()
try:
tooth_input.send_keys(Keys.CONTROL + "a")
tooth_input.send_keys(Keys.DELETE)
time.sleep(0.1)
for ch in str(tooth):
tooth_input.send_keys(ch)
time.sleep(0.05)
time.sleep(0.2)
# Verify value was entered; fall back to JS inject if empty
entered = tooth_input.get_attribute("value") or ""
if entered.strip() == "":
self.driver.execute_script(
"arguments[0].value = arguments[1];"
"arguments[0].dispatchEvent(new Event('input',{bubbles:true}));"
"arguments[0].dispatchEvent(new Event('change',{bubbles:true}));",
tooth_input, str(tooth))
time.sleep(0.2)
print(f"[CCA PreAuth step3] Row {idx+1}: tooth={tooth!r} entered")
else:
# Fallback: JS inject directly into active element
active = self.driver.switch_to.active_element
except Exception:
pass
time.sleep(0.2)
if (tooth_input.get_attribute("value") or "").strip() != str(tooth):
self.driver.execute_script(
"arguments[0].value = arguments[1];"
"arguments[0].focus();arguments[0].value = arguments[1];"
"arguments[0].dispatchEvent(new Event('input',{bubbles:true}));"
"arguments[0].dispatchEvent(new Event('change',{bubbles:true}));",
active, str(tooth))
tooth_input, str(tooth))
time.sleep(0.2)
print(f"[CCA PreAuth step3] Row {idx+1}: tooth={tooth!r} entered via JS fallback")
else:
print(f"[CCA PreAuth step3] Row {idx+1}: Tooth cell not found — skipping")
except Exception as e:
print(f"[CCA PreAuth step3] Row {idx+1}: Tooth fill failed: {e} — skipping tooth")
print(f"[CCA PreAuth step3] Row {idx+1}: tooth={tooth!r} entered")
tooth_done = True
break
except Exception as te:
print(f"[CCA PreAuth step3] Row {idx+1}: tooth attempt {_ta}/4: {te.__class__.__name__}")
time.sleep(1.0)
if not tooth_done:
return (f"ERROR: failed to fill tooth for service line {idx+1} "
f"(code={code}) — pre-auth NOT submitted")
# ── Billed Amount: click TD cell to activate edit mode (turn yellow),
# wait for input, delete default 0.00, type amount ──
@@ -807,38 +820,52 @@ class AutomationCCAPreAuth:
billed_cell.click()
time.sleep(0.3)
# Wait until the input activates (cell turns yellow = edit mode)
# Activate the editor and type. "element not interactable" happens when the
# grid hasn't finished the previous row, so re-click and retry.
from selenium.webdriver.common.action_chains import ActionChains
js_set = (
"arguments[0].focus();arguments[0].value = arguments[1];"
"arguments[0].dispatchEvent(new Event('input',{bubbles:true}));"
"arguments[0].dispatchEvent(new Event('change',{bubbles:true}));")
billed_input = None
for _ in range(10):
for attempt in range(1, 5):
try:
billed_input = WebDriverWait(self.driver, 1).until(
EC.element_to_be_clickable((By.ID, "ServicesBILLED_AMOUNT")))
cands = [e for e in self.driver.find_elements(By.ID, "ServicesBILLED_AMOUNT")
if e.is_displayed()]
if not cands:
raise Exception("no visible ServicesBILLED_AMOUNT input")
inp = cands[0]
try:
inp.send_keys(Keys.CONTROL + "a")
inp.send_keys(Keys.DELETE)
for ch in str(billed_str):
inp.send_keys(ch)
time.sleep(0.05)
except Exception as ke:
print(f"[CCA PreAuth step3] Row {idx+1}: typing failed ({ke.__class__.__name__}), using JS")
self.driver.execute_script(js_set, inp, str(billed_str))
time.sleep(0.2)
if (inp.get_attribute("value") or "").strip() in ("", "0.00", "0"):
self.driver.execute_script(js_set, inp, str(billed_str))
time.sleep(0.2)
billed_input = inp
break
except Exception:
pass
try:
billed_cell = self.driver.find_element(By.XPATH, billed_cell_xpaths[0])
billed_cell.click()
except Exception:
pass
time.sleep(0.2)
except Exception as ae:
print(f"[CCA PreAuth step3] Row {idx+1}: billed attempt {attempt}/4: {ae.__class__.__name__}")
time.sleep(0.8)
try:
billed_cell = self.driver.find_element(By.XPATH, billed_cell_xpaths[0])
self.driver.execute_script(
"arguments[0].scrollIntoView({block:'nearest',inline:'nearest'});", billed_cell)
if attempt >= 2:
ActionChains(self.driver).double_click(billed_cell).perform()
else:
billed_cell.click()
time.sleep(0.5)
except Exception:
pass
if billed_input:
billed_input.send_keys(Keys.DELETE)
time.sleep(0.3)
for ch in str(billed_str):
billed_input.send_keys(ch)
time.sleep(0.05)
time.sleep(0.2)
# Verify; fallback to JS if empty
entered = billed_input.get_attribute("value") or ""
if entered.strip() == "":
self.driver.execute_script(
"arguments[0].value = arguments[1];"
"arguments[0].dispatchEvent(new Event('input',{bubbles:true}));"
"arguments[0].dispatchEvent(new Event('change',{bubbles:true}));",
billed_input, str(billed_str))
time.sleep(0.2)
print(f"[CCA PreAuth step3] Row {idx+1}: billed={billed_str!r} entered")
else:
print(f"[CCA PreAuth step3] Row {idx+1}: Billed Amount input not activated — skipping")
@@ -0,0 +1,704 @@
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.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from webdriver_manager.chrome import ChromeDriverManager
import time
import os
import re
import base64
import calendar
from deltaindiv_browser_manager import get_browser_manager
class AutomationDeltaIndivEligibilityCheck:
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.deltaindiv_username = self.data.get("deltaindivUsername", "")
self.deltaindiv_password = self.data.get("deltaindivPassword", "")
self.providerName = self.data.get("providerName", "")
# 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 when credentials change."""
try:
print("[Delta Indiv login] Forcing logout due to credential change...")
browser_manager = get_browser_manager()
# Try to click logout button if visible
try:
self.driver.get("https://www.deltadentalma.com/")
time.sleep(2)
logout_selectors = [
"//button[contains(text(), 'Log out') or contains(text(), 'Logout') or contains(text(), 'Sign out')]",
"//a[contains(text(), 'Log out') or contains(text(), 'Logout') or contains(text(), 'Sign out')]",
"//button[@aria-label='Log out' or @aria-label='Logout' or @aria-label='Sign out']",
"//*[contains(@class, 'logout') or contains(@class, 'signout')]"
]
for selector in logout_selectors:
try:
logout_btn = WebDriverWait(self.driver, 3).until(
EC.element_to_be_clickable((By.XPATH, selector))
)
logout_btn.click()
print("[Delta Indiv login] Clicked logout button")
time.sleep(2)
break
except TimeoutException:
continue
except Exception as e:
print(f"[Delta Indiv login] Could not click logout button: {e}")
# Clear cookies as backup
try:
self.driver.delete_all_cookies()
print("[Delta Indiv login] Cleared all cookies")
except Exception as e:
print(f"[Delta Indiv login] Error clearing cookies: {e}")
browser_manager.clear_credentials_hash()
print("[Delta Indiv login] Logout complete")
return True
except Exception as e:
print(f"[Delta Indiv login] Error during forced logout: {e}")
return False
def login(self, url):
wait = WebDriverWait(self.driver, 30)
browser_manager = get_browser_manager()
try:
# Check if credentials changed — force logout first
if self.deltaindiv_username and browser_manager.credentials_changed(self.deltaindiv_username):
self._force_logout()
self.driver.get(url)
time.sleep(2)
# Check if already on a logged-in page (persistent session from profile)
try:
current_url = self.driver.current_url
print(f"[login] Current URL: {current_url}")
logged_in_patterns = ["member", "dashboard", "eligibility", "search", "patients", "private/provider"]
is_logged_in_url = any(pattern in current_url.lower() for pattern in logged_in_patterns)
if is_logged_in_url and "sign-in" not in current_url.lower():
print("[login] Already on logged-in page - skipping login entirely")
if "member" not in current_url.lower():
try:
member_search = WebDriverWait(self.driver, 5).until(
EC.presence_of_element_located((By.XPATH, "//a[contains(@href, 'check-patient-benefits-search')]"))
)
print("[login] Found member search input - returning ALREADY_LOGGED_IN")
return "ALREADY_LOGGED_IN"
except TimeoutException:
members_url = "https://www.deltadentalma.com/private/provider/dashboard"
print(f"[login] Navigating to members page: {members_url}")
self.driver.get(members_url)
time.sleep(2)
try:
member_search = WebDriverWait(self.driver, 5).until(
EC.presence_of_element_located((By.XPATH, "//a[contains(@href, 'check-patient-benefits-search')]"))
)
print("[login] Member search found - ALREADY_LOGGED_IN")
return "ALREADY_LOGGED_IN"
except TimeoutException:
print("[login] Could not find member search, will try login")
except Exception as e:
print(f"[login] Error checking current state: {e}")
# Navigate to login URL
self.driver.get(url)
time.sleep(2)
# Check if session redirected us straight to member search
try:
current_url = self.driver.current_url
print(f"[login] URL after navigation: {current_url}")
if "sign-in" not in current_url.lower():
member_search = WebDriverWait(self.driver, 3).until(
EC.presence_of_element_located((By.XPATH, "//a[contains(@href, 'check-patient-benefits-search')]"))
)
if member_search:
print("[login] Session valid - skipping login")
return "ALREADY_LOGGED_IN"
except TimeoutException:
print("[login] Proceeding with login")
# Dismiss any "Authentication flow continued in another tab" modal
modal_dismissed = False
try:
ok_button = WebDriverWait(self.driver, 3).until(
EC.element_to_be_clickable((By.XPATH, "//button[normalize-space(text())='Ok' or normalize-space(text())='OK']"))
)
ok_button.click()
print("[login] Dismissed authentication modal")
modal_dismissed = True
time.sleep(2)
all_windows = self.driver.window_handles
print(f"[login] Windows after modal dismiss: {len(all_windows)}")
if len(all_windows) > 1:
original_window = self.driver.current_window_handle
for window in all_windows:
if window != original_window:
self.driver.switch_to.window(window)
print("[login] Switched to auth popup window")
break
try:
otp_candidate = WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located(
(By.XPATH, "//input[@data-test-id='MfaCode']")
)
)
if otp_candidate:
print("[login] OTP input found in popup -> OTP_REQUIRED")
return "OTP_REQUIRED"
except TimeoutException:
print("[login] No OTP in popup, checking main window")
self.driver.switch_to.window(original_window)
except TimeoutException:
pass # No modal present
if modal_dismissed:
time.sleep(2)
try:
member_search = WebDriverWait(self.driver, 5).until(
EC.presence_of_element_located((By.XPATH, "//a[contains(@href, 'check-patient-benefits-search')]"))
)
if member_search:
print("[login] Already authenticated after modal dismiss")
return "ALREADY_LOGGED_IN"
except TimeoutException:
pass
# Fill login form
try:
email_field = WebDriverWait(self.driver, 10).until(
EC.element_to_be_clickable((By.XPATH, "//input[@data-test-id='Username']"))
)
except TimeoutException:
print("[login] Could not find login form - page may have changed")
return "ERROR: Login form not found"
email_field = wait.until(EC.presence_of_element_located((By.XPATH, "//input[@data-test-id='Username']")))
email_field.clear()
email_field.send_keys(self.deltaindiv_username)
password_field = wait.until(EC.presence_of_element_located((By.XPATH, "//input[@data-test-id='Password']")))
password_field.clear()
password_field.send_keys(self.deltaindiv_password)
login_button = wait.until(EC.element_to_be_clickable((By.XPATH, "//button[@data-test-id='SigninSubmitButton']")))
login_button.click()
# Save credentials hash after login attempt
if self.deltaindiv_username:
browser_manager.save_credentials_hash(self.deltaindiv_username)
# OTP detection — wait up to 30 seconds for OTP input
try:
otp_candidate = WebDriverWait(self.driver, 30).until(
EC.presence_of_element_located(
(By.XPATH, "//input[@data-test-id='MfaCode']")
)
)
if otp_candidate:
print("[login] OTP input detected -> OTP_REQUIRED")
return "OTP_REQUIRED"
except TimeoutException:
print("[login] No OTP input detected in allowed time.")
# Check if we're now on the member search page (login succeeded without OTP)
try:
current_url = self.driver.current_url.lower()
if "member" in current_url or "dashboard" in current_url:
member_search = WebDriverWait(self.driver, 5).until(
EC.presence_of_element_located((By.XPATH, "//a[contains(@href, 'check-patient-benefits-search')]"))
)
print("[login] Login successful - now on member search page")
return "SUCCESS"
except TimeoutException:
pass
# Check for error messages
try:
error_elem = WebDriverWait(self.driver, 3).until(
EC.presence_of_element_located((By.XPATH, "//*[contains(@class,'error') or contains(text(),'invalid') or contains(text(),'failed')]"))
)
print(f"[login] Login failed - error detected: {error_elem.text}")
return f"ERROR:LOGIN FAILED: {error_elem.text}"
except TimeoutException:
pass
if "sign-in" in self.driver.current_url.lower():
print("[login] Login failed - still on login page")
return "ERROR:LOGIN FAILED: Still on login page"
print("[login] Assuming login succeeded (no errors detected)")
return "SUCCESS"
except Exception as e:
print("[login] Exception during login:", e)
return f"ERROR:LOGIN FAILED: {e}"
def step1(self):
"""Open the Group/Health Connector search page and search by Subscriber ID + Birthdate."""
wait = WebDriverWait(self.driver, 30)
try:
fields = []
if self.memberId:
fields.append(f"SubscriberID: {self.memberId}")
if self.dateOfBirth:
fields.append(f"DOB: {self.dateOfBirth}")
print(f"[Delta Indiv step1] Starting search with: {', '.join(fields)}")
search_page_url = "https://www.deltadentalma.com/private/provider/check-patient-benefits-search"
# Try navigating straight to the search page first — avoids any click/visibility issues
print(f"[Delta Indiv step1] Navigating directly to search page: {search_page_url}")
self.driver.get(search_page_url)
time.sleep(2)
if "check-patient-benefits-search" not in self.driver.current_url.lower():
# Direct deep-link didn't stick (e.g. bounced back to sign-in/dashboard) —
# fall back to clicking the Group/Health Connector link from the dashboard.
print(f"[Delta Indiv step1] Direct navigation redirected to: {self.driver.current_url} — falling back to clicking the link")
if "private/provider/dashboard" not in self.driver.current_url.lower():
dashboard_url = "https://www.deltadentalma.com/private/provider/dashboard"
print(f"[Delta Indiv step1] Navigating to dashboard: {dashboard_url}")
self.driver.get(dashboard_url)
time.sleep(2)
link_xpath = "//a[contains(@href, 'check-patient-benefits-search')]"
try:
connector_link = WebDriverWait(self.driver, 20).until(
EC.element_to_be_clickable((By.XPATH, link_xpath))
)
connector_link.click()
except TimeoutException:
# Element may exist but be hidden (e.g. a responsive duplicate) — try any match via JS click
candidates = self.driver.find_elements(By.XPATH, link_xpath)
print(f"[Delta Indiv step1] element_to_be_clickable timed out; found {len(candidates)} matching link(s) in DOM")
if candidates:
try:
self.driver.execute_script("arguments[0].click();", candidates[0])
except Exception as js_err:
print(f"[Delta Indiv step1] JS click failed: {js_err}")
print(f"[Delta Indiv step1] Current URL: {self.driver.current_url}")
return "ERROR: Could not open Group/Health Connector search page"
else:
print(f"[Delta Indiv step1] No Group/Health Connector link found in DOM. Current URL: {self.driver.current_url}")
return "ERROR: Could not open Group/Health Connector search page"
try:
wait.until(lambda d: "check-patient-benefits-search" in d.current_url)
except TimeoutException:
print(f"[Delta Indiv step1] Click did not navigate to search page. Current URL: {self.driver.current_url}")
return "ERROR: Could not open Group/Health Connector search page"
print(f"[Delta Indiv step1] On Group/Health Connector search page: {self.driver.current_url}")
# Fill Birthdate (masked mm/dd/yyyy input — send digits only, mask inserts slashes)
if self.dateOfBirth:
try:
dob_parts = self.dateOfBirth.split("-")
year = dob_parts[0]
month = dob_parts[1].zfill(2)
day = dob_parts[2].zfill(2)
birthdate_input = wait.until(EC.element_to_be_clickable(
(By.XPATH, "//input[@data-test-id='InStateSearchBirthdate']")
))
birthdate_input.click()
birthdate_input.send_keys(Keys.CONTROL, "a")
birthdate_input.send_keys(Keys.BACKSPACE)
birthdate_input.send_keys(f"{month}{day}{year}")
print(f"[Delta Indiv step1] Filled Birthdate: {month}/{day}/{year}")
except Exception as e:
print(f"[Delta Indiv step1] Warning: Could not fill Birthdate: {e}")
# Fill Subscriber ID (== member ID)
if self.memberId:
try:
subscriber_input = wait.until(EC.element_to_be_clickable(
(By.XPATH, "//input[@data-test-id='InStateSearchSubscriberId']")
))
subscriber_input.clear()
subscriber_input.send_keys(self.memberId)
print(f"[Delta Indiv step1] Filled Subscriber ID: {self.memberId}")
except Exception as e:
print(f"[Delta Indiv step1] Warning: Could not fill Subscriber ID: {e}")
time.sleep(0.3)
# Click Search button
search_btn = wait.until(EC.element_to_be_clickable(
(By.XPATH, "//button[@data-test-id='InStateSearchButton']")
))
search_btn.click()
print("[Delta Indiv step1] Clicked Search button")
# Wait for either the Members results table to render, or (when the
# Subscriber ID + DOB match exactly one family member) Delta Indiv to skip
# the results list and navigate straight to the patient detail page.
# Note: the detail page URL is nested UNDER the search page path
# (".../check-patient-benefits-search/check-patient-benefit-details"),
# so "check-patient-benefits-search" alone can't distinguish them —
# only the detail page's own "-benefit-details" segment can.
try:
WebDriverWait(self.driver, 15).until(
lambda d: d.find_elements(By.XPATH, "//table//tbody//tr")
or "check-patient-benefit-details" in d.current_url.lower()
)
time.sleep(2)
except TimeoutException:
print("[Delta Indiv step1] Warning: No results table appeared within timeout")
return "ERROR: INVALID SEARCH CRITERIA"
if "check-patient-benefit-details" in self.driver.current_url.lower():
print(f"[Delta Indiv step1] Search navigated directly to detail page: {self.driver.current_url}")
print("[Delta Indiv step1] Search completed successfully")
return "Success"
except Exception as e:
print(f"[Delta Indiv step1] Exception: {e}")
return f"ERROR:STEP1 - {e}"
def step2(self):
"""Navigate to patient detail page and generate PDF."""
wait = WebDriverWait(self.driver, 90)
try:
import re
eligibilityText = "unknown"
foundMemberId = self.memberId or ""
# If the caller already knows this patient (e.g. an existing patient record
# being rechecked), use that name directly — no need to scrape/parse it off
# the Delta Indiv page, only the eligibility status is actually needed.
patientName = f"{self.firstName} {self.lastName}".strip() if self.firstName and self.lastName else ""
known_patient_name = bool(patientName)
# Delta Indiv now sometimes skips the family-members results table entirely and
# navigates straight to the single-patient detail page (when Subscriber ID +
# DOB match exactly one person). In that case there's no members table to
# parse here — skip straight to the detail-page fallback extraction below.
already_on_detail_page = "check-patient-benefit-details" in self.driver.current_url.lower()
rows = []
target_row = None
if not already_on_detail_page:
# Wait for results table, then pause for full render
try:
WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located((By.XPATH, "//tbody//tr"))
)
time.sleep(2) # Let the row content fully render after table appears
except TimeoutException:
print("[Delta Indiv step2] Warning: Results table not found within timeout")
# The Members table lists the whole family sharing this subscriber ID —
# find the row matching this patient's DOB (falls back to last name, then first row).
rows = self.driver.find_elements(By.XPATH, "//table//tbody//tr")
print(f"[Delta Indiv step2] Found {len(rows)} family member row(s)")
else:
print(f"[Delta Indiv step2] Already on detail page: {self.driver.current_url}")
if self.dateOfBirth and rows:
try:
dob_parts = self.dateOfBirth.split("-")
dob_year, dob_month, dob_day = dob_parts[0], int(dob_parts[1]), int(dob_parts[2])
expected_dob = f"{calendar.month_abbr[dob_month]} {dob_day}, {dob_year}".lower()
for row in rows:
cells = row.find_elements(By.XPATH, "./td")
if len(cells) >= 3 and cells[2].text.strip().lower() == expected_dob:
target_row = row
break
except Exception as e:
print(f"[Delta Indiv step2] DOB matching error (non-fatal): {e}")
if target_row is None and self.lastName and rows:
for row in rows:
if self.lastName.strip().lower() in row.text.lower():
target_row = row
break
if target_row is None and rows:
target_row = rows[0]
print("[Delta Indiv step2] No DOB/name match — defaulting to first family member row")
if target_row is not None:
try:
cells = target_row.find_elements(By.XPATH, "./td")
if cells and not known_patient_name:
patientName = cells[0].text.strip()
print(f"[Delta Indiv step2] Extracted patient name from row: '{patientName}'")
if len(cells) >= 2 and cells[1].text.strip():
foundMemberId = cells[1].text.strip()
if len(cells) >= 7:
status_text = cells[6].text.strip().lower()
if "inactive" in status_text or "termed" in status_text:
eligibilityText = "inactive"
elif "active" in status_text:
eligibilityText = "active"
else:
eligibilityText = status_text
print(f"[Delta Indiv step2] Found eligibility status: {eligibilityText}")
except Exception as e:
print(f"[Delta Indiv step2] Error reading matched row cells: {e}")
# Click into the member's detail page
try:
name_link = target_row.find_element(By.XPATH, ".//a")
name_link.click()
print(f"[Delta Indiv step2] Clicked into detail page for: {patientName}")
except Exception as e:
print(f"[Delta Indiv step2] Warning: Could not click patient name link: {e}")
else:
print("[Delta Indiv step2] Warning: No family member rows found in search results")
# Wait for the detail page to load (URL gains the "-benefit-details" segment)
try:
WebDriverWait(self.driver, 15).until(
lambda d: "check-patient-benefit-details" in d.current_url.lower()
)
print(f"[Delta Indiv step2] Navigated to detail page: {self.driver.current_url}")
except Exception:
print(f"[Delta Indiv step2] Warning: Still on search page: {self.driver.current_url}")
# Wait for meaningful content on the detail page
for selector in [
"//*[contains(text(),'Date of Birth') or contains(text(),'Address') or contains(text(),'Member ID')]",
"//table", "//h1", "//h2",
]:
try:
WebDriverWait(self.driver, 15).until(
EC.presence_of_element_located((By.XPATH, selector))
)
print(f"[Delta Indiv step2] Detail page content loaded: {selector}")
break
except Exception:
continue
time.sleep(3) # Let JavaScript finish rendering all sections
# Fallback: refine patient name from the detail page text if row extraction failed
if not patientName:
try:
page_lines = self.driver.execute_script(
"return document.body.innerText;"
).split('\n')
page_lines = [l.strip() for l in page_lines if l.strip()]
except Exception as e:
print(f"[Delta Indiv step2] Could not get page text: {e}")
page_lines = []
# UI noise words that appear in accessibility/sort-button text or field labels
ui_noise = ['click', 'sort', 'activate', 'direction', 'enter', 'space',
'current', 'use ', 'table', 'column', 'filter', 'search',
'date', 'birth', 'relationship', 'subscriber', 'coverage',
'status', 'period', 'network', 'plan', 'deductible']
def looks_like_name(text):
"""Return True if text is a plausible patient name."""
t = text.strip()
# Must be 2–60 chars, letters/spaces/hyphens/apostrophes only (no periods)
if not t or not (2 <= len(t) <= 60):
return False
if not re.match(r"^[A-Za-z\s\-']+$", t):
return False
# Must not be UI noise
if any(w in t.lower() for w in ui_noise):
return False
return True
# Preferred: the "Benefits for JOSHUA ZHANG" heading names the actual
# patient. "Subscriber name:" is the policy holder (e.g. a parent), so
# it is only used below if this heading is missing.
for line in page_lines:
heading = re.match(r'^benefits\s+for\s+(.+)$', line, re.IGNORECASE)
if heading and looks_like_name(heading.group(1)):
patientName = heading.group(1).strip()
print(f"[Delta Indiv step2] Extracted name from 'Benefits for' heading: '{patientName}'")
break
# Scan lines for the "Name" label. When "Name" appears alone we scan
# forward past accessibility text to the first plausible name value.
for i, line in enumerate(page_lines):
if patientName:
break
# Case 1: "Name : Value" or "Name: Value" on the same line
# (Delta Indiv's detail page labels this "Subscriber name:", so that
# prefix must be accepted rather than excluded)
same_line = re.match(
r'^(?:member\s+|subscriber\s+)?name\s*[:\-]\s*(.+)$', line, re.IGNORECASE
)
if same_line and not re.search(
r'(provider|group|plan)\s+name', line, re.IGNORECASE
):
candidate = same_line.group(1).strip()
if looks_like_name(candidate):
patientName = candidate
print(f"[Delta Indiv step2] Extracted name from 'Name:' label: '{patientName}'")
break
# Case 2: "Name" or "Name:" alone on a line — scan forward for value
elif re.match(r'^(?:member\s+|subscriber\s+)?name\s*:?\s*$', line, re.IGNORECASE):
for j in range(i + 1, min(i + 6, len(page_lines))):
candidate = page_lines[j].strip()
if looks_like_name(candidate):
patientName = candidate
print(f"[Delta Indiv step2] Extracted name from 'Name' label (line +{j-i}): '{patientName}'")
break
if patientName:
break
# Clean patient name — strip any remaining date artifacts
if patientName:
cleaned = re.sub(r'\s*DOB[:\s]*\d{1,2}/\d{1,2}/\d{2,4}\s*', '', patientName, flags=re.IGNORECASE).strip()
cleaned = re.sub(r'\s+\d{1,2}/\d{1,2}/\d{2,4}$', '', cleaned).strip()
patientName = cleaned if cleaned else patientName
if not patientName:
print("[Delta Indiv step2] Could not extract patient name from detail page")
# Fallback: refine eligibility status from the detail page's "Coverage is:"
# label if the family-table row extraction didn't set it (e.g. Delta Indiv
# navigated straight to the detail page, skipping the members table).
if eligibilityText == "unknown":
try:
page_text = self.driver.execute_script("return document.body.innerText;")
coverage_match = re.search(
r'coverage\s+is\s*[:\-]?\s*([A-Za-z]+)', page_text, re.IGNORECASE
)
if coverage_match:
status_text = coverage_match.group(1).strip().lower()
if "inactive" in status_text or "termed" in status_text:
eligibilityText = "inactive"
elif "active" in status_text:
eligibilityText = "active"
else:
eligibilityText = status_text
print(f"[Delta Indiv step2] Extracted eligibility from 'Coverage is:' label: '{eligibilityText}'")
except Exception as e:
print(f"[Delta Indiv step2] Could not extract eligibility from 'Coverage is:' label: {e}")
# Wait for the page to be fully ready before capturing PDF
try:
WebDriverWait(self.driver, 30).until(
lambda d: d.execute_script("return document.readyState") == "complete"
)
except Exception:
pass
# Extra wait for lazy-loaded sections (benefits summary, member history, etc.)
time.sleep(4)
# Generate PDF via Chrome DevTools Protocol
print("[Delta Indiv step2] Generating PDF of patient detail page...")
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,
}
result = self.driver.execute_cdp_cmd("Page.printToPDF", pdf_options)
pdf_data = base64.b64decode(result.get('data', ''))
pdf_id = foundMemberId or self.memberId or "unknown"
pdf_path = os.path.join(self.download_dir, f"eligibility_{pdf_id}.pdf")
with open(pdf_path, "wb") as f:
f.write(pdf_data)
print(f"[Delta Indiv step2] PDF saved at: {pdf_path}")
print(f"[Delta Indiv step2] Final — PatientName: '{patientName}', MemberID: '{foundMemberId}'")
return {
"status": "success",
"eligibility": eligibilityText,
"ss_path": pdf_path, # kept for backward compatibility
"pdf_path": pdf_path, # explicit pdf_path
"patientName": patientName,
"memberId": foundMemberId,
"dateOfBirth": self.dateOfBirth,
}
except Exception as e:
print("ERROR in step2:", e)
# Cleanup download dir on error
try:
dl = os.path.abspath(self.download_dir)
if os.path.isdir(dl):
for name in os.listdir(dl):
item = os.path.join(dl, name)
try:
if os.path.isfile(item) or os.path.islink(item):
os.remove(item)
except Exception as rm_err:
print(f"[cleanup] failed to remove {item}: {rm_err}")
except Exception as cleanup_exc:
print(f"[cleanup] unexpected error: {cleanup_exc}")
return {"status": "error", "message": str(e)}
def main_workflow(self, url):
try:
self.config_driver()
self.driver.maximize_window()
time.sleep(3)
login_result = self.login(url)
if login_result.startswith("ERROR"):
return {"status": "error", "message": login_result}
if login_result == "OTP_REQUIRED":
return {"status": "otp_required", "message": "OTP required after login"}
step1_result = self.step1()
if step1_result.startswith("ERROR"):
return {"status": "error", "message": step1_result}
step2_result = self.step2()
if step2_result.get("status") == "error":
return {"status": "error", "message": step2_result.get("message")}
return step2_result
except Exception as e:
return {"status": "error", "message": str(e)}
# NOTE: Do NOT quit driver — keep browser alive for next patient
@@ -291,15 +291,15 @@ class AutomationMassHealthClaimsLogin:
(By.XPATH, "//input[@name='dateInput' and @placeholder='mm/dd/yyyy' and @ng-model='date']")
)
)
time.sleep(1)
time.sleep(0.3)
# Use JavaScript to set the value directly to avoid date picker interference
self.driver.execute_script(f"arguments[0].value = '{formatted_dob}'; arguments[0].dispatchEvent(new Event('input', {{ bubbles: true }})); arguments[0].dispatchEvent(new Event('change', {{ bubbles: true }}));", dob_input)
time.sleep(1)
time.sleep(0.3)
# Press Tab to move to next field and ensure the date is set
dob_input.send_keys(Keys.TAB)
time.sleep(1)
time.sleep(0.3)
# Step 2: Fill Member Number
if self.memberId:
@@ -309,15 +309,15 @@ class AutomationMassHealthClaimsLogin:
(By.XPATH, "//input[@placeholder='Member Number' and @ng-model='vm.number']")
)
)
time.sleep(1)
time.sleep(0.3)
member_input.click()
time.sleep(1)
time.sleep(0.3)
member_input.clear()
time.sleep(1)
time.sleep(0.3)
member_input.send_keys(self.memberId)
time.sleep(1)
time.sleep(0.3)
member_input.send_keys(Keys.TAB)
time.sleep(1)
time.sleep(0.3)
# Step 3: Fill First Name and Last Name
# if self.firstName:
@@ -383,12 +383,12 @@ class AutomationMassHealthClaimsLogin:
(By.XPATH, "//input[@ng-model='serviceLine.procedure' and @placeholder='Code']")
)
)
time.sleep(1)
time.sleep(0.3)
# Step 2: Type the procedure code to trigger typeahead
procedure_input.clear()
procedure_input.send_keys(self.procedureCode)
time.sleep(3) # Wait for typeahead to load
time.sleep(0.5) # brief debounce; the dropdown wait below does the real waiting
# Step 3: Look for the typeahead dropdown and select the first option
# The dropdown appears as ul with class 'dropdown-menu'
@@ -405,13 +405,13 @@ class AutomationMassHealthClaimsLogin:
if first_option:
print("DEBUG: Found first option in procedure dropdown, clicking...")
first_option.click()
time.sleep(2)
time.sleep(0.5)
return "Success"
except:
# If no dropdown appears, just press Tab to move to next field
print("DEBUG: No procedure dropdown found, pressing Tab")
procedure_input.send_keys(Keys.TAB)
time.sleep(1)
time.sleep(0.3)
return "Success"
return "Success"
@@ -433,16 +433,16 @@ class AutomationMassHealthClaimsLogin:
(By.XPATH, "//input[@ng-model='serviceLine.toothCode' and @placeholder='Tooth']")
)
)
time.sleep(1)
time.sleep(0.3)
# Step 2: Type the tooth number
tooth_input.clear()
tooth_input.send_keys(tooth_value.upper()) # Convert to uppercase as per onkeyup
time.sleep(2)
time.sleep(0.5)
# Press Tab to move to next field
tooth_input.send_keys(Keys.TAB)
time.sleep(1)
time.sleep(0.3)
return "Success"
except Exception as e:
@@ -463,16 +463,16 @@ class AutomationMassHealthClaimsLogin:
(By.XPATH, "//input[@ng-model='serviceLine.surface' and @placeholder='Surface']")
)
)
time.sleep(1)
time.sleep(0.3)
# Step 2: Type the tooth surface (convert to uppercase)
surface_input.clear()
surface_input.send_keys(surface_value.upper())
time.sleep(2)
time.sleep(0.5)
# Press Tab to move to next field
surface_input.send_keys(Keys.TAB)
time.sleep(1)
time.sleep(0.3)
return "Success"
except Exception as e:
@@ -491,12 +491,12 @@ class AutomationMassHealthClaimsLogin:
(By.XPATH, "//select[@ng-model='serviceLine.quadrant']")
)
)
time.sleep(1)
time.sleep(0.3)
# Step 2: Use Select class to choose Upper Right
select = Select(quadrant_dropdown)
select.select_by_visible_text("Upper Right")
time.sleep(2)
time.sleep(0.5)
return "Success"
except Exception as e:
@@ -515,12 +515,12 @@ class AutomationMassHealthClaimsLogin:
(By.XPATH, "//select[@ng-model='serviceLine.arch']")
)
)
time.sleep(1)
time.sleep(0.3)
# Step 2: Use Select class to choose Entire Oral Cavity
select = Select(arch_dropdown)
select.select_by_visible_text("Entire Oral Cavity")
time.sleep(2)
time.sleep(0.5)
return "Success"
except Exception as e:
@@ -541,16 +541,16 @@ class AutomationMassHealthClaimsLogin:
(By.XPATH, "//input[@ng-model='serviceLine.authorizationNumber' and @placeholder='No.']")
)
)
time.sleep(1)
time.sleep(0.3)
# Step 2: Type the authorization number
auth_input.clear()
auth_input.send_keys(auth_number)
time.sleep(2)
time.sleep(0.5)
# Press Tab to move to next field
auth_input.send_keys(Keys.TAB)
time.sleep(1)
time.sleep(0.3)
return "Success"
except Exception as e:
Executable
+107
View File
@@ -0,0 +1,107 @@
#!/bin/bash
# =============================================================
# Dental Manager — fresh-machine install (README Steps 4–14, 16)
# Run in a terminal: bash install-all.sh
# It asks for your sudo password once at the start.
# Safe to re-run: every step skips what's already done.
# =============================================================
set -e
cd "$(dirname "$0")"
PROJECT_DIR="$(pwd)"
step() { echo ""; echo "=============================="; echo " $1"; echo "=============================="; }
sudo -v # ask for password up front
# keep sudo alive while the script runs
while true; do sudo -n true; sleep 50; kill -0 "$$" || exit; done 2>/dev/null &
step "Base tools (curl, wget, git)"
sudo apt-get update
sudo apt-get install -y curl wget ca-certificates git
step "STEP 4 — Node.js 20"
if command -v node >/dev/null && node -v | grep -q '^v2[0-9]'; then
echo "Node already installed: $(node -v)"
else
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs
fi
node -v; npm -v
step "STEP 5 — Python (+ venv, pip)"
sudo apt-get install -y python3 python3-pip python3-venv
python3 --version
step "STEP 6 — Google Chrome"
if command -v google-chrome >/dev/null; then
echo "Chrome already installed"
else
wget -q https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb -O /tmp/google-chrome-stable.deb
sudo apt-get install -y /tmp/google-chrome-stable.deb
fi
google-chrome --version
step "STEP 7 — PostgreSQL"
sudo apt-get install -y postgresql postgresql-contrib
sudo systemctl enable postgresql
sudo systemctl start postgresql
sudo -u postgres psql -tc "SELECT 1 FROM pg_database WHERE datname='dentalapp'" | grep -q 1 \
|| sudo -u postgres psql -c "CREATE DATABASE dentalapp OWNER postgres;"
sudo -u postgres psql -c "ALTER USER postgres WITH PASSWORD 'mypassword';"
sudo sed -i 's/scram-sha-256/md5/g' /etc/postgresql/*/main/pg_hba.conf
sudo systemctl restart postgresql
PGPASSWORD=mypassword psql -U postgres -d dentalapp -h 127.0.0.1 -c 'SELECT 1' >/dev/null && echo "✓ PostgreSQL login works"
step "STEP 8 — Redis"
sudo apt-get install -y redis-server
sudo systemctl enable redis-server
sudo systemctl start redis-server
redis-cli ping
step "STEP 9 — rclone"
bash scripts/install-rclone.sh
step "STEP 10 — Node.js dependencies"
npm install
step "STEP 11 — Python dependencies (venvs)"
npm run setup:python
step "STEP 12 — .env files (only the missing ones; existing ones are kept)"
for f in packages/db/prisma/.env apps/Frontend/.env apps/Backend/.env \
apps/PatientDataExtractorService/.env apps/SeleniumService/.env apps/PaymentOCRService/.env; do
if [ -f "$f" ]; then echo "keep $f"
else cp "$f.example" "$f" && echo "created $f"; fi
done
step "STEP 13 — Database (migrate, generate, seed)"
npm run db:migrate
npm run db:generate
npm run db:seed
step "STEP 14 — nginx"
sudo apt-get install -y nginx
CERT=$(grep -m1 -oP 'ssl_certificate\s+\K\S+(?=;)' nginx.conf)
if sudo test -f "$CERT"; then
sudo cp nginx.conf /etc/nginx/sites-available/dental-app
sudo ln -sf /etc/nginx/sites-available/dental-app /etc/nginx/sites-enabled/dental-app
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx
echo "✓ nginx configured"
else
echo "⚠ Skipped enabling the dental-app nginx site: certificate not found at"
echo " $CERT"
echo " Do the README's 'LAN HTTPS Setup' (certbot) first, then re-run this script."
fi
step "STEP 16 — Desktop shortcuts"
bash setup-desktop-shortcut.sh
echo ""
echo "============================================"
echo " Done. To run the app (Step 15):"
echo " Terminal 1: cd $PROJECT_DIR && npm run dev"
echo " Terminal 2: cd $PROJECT_DIR/apps/SeleniumService && .venv/bin/python3 agent.py"
echo " ...or double-click the 'Dental App' desktop shortcut."
echo "============================================"