feat: integrate DDMA eligibility into BullMQ queue with persistent session
- Route DDMA eligibility through InProcessQueue (concurrency=1) so it queues behind other selenium jobs instead of running concurrently - New ddmaEligibilityProcessor: starts Python session, polls for OTP/ completion via socket events, saves PDF and updates patient DB - Frontend ddma-buton-modal now uses shared app socket + job:update pattern (drops private socket connection) - SeleniumService: upgrade ddma_browser_manager with credential hash tracking, anti-detection options, and startup session clearing; upgrade DDMA worker with firstName/lastName support, PDF via printToPDF, force-logout on credential change; upgrade helpers with dual OTP strategy (app API + browser polling); add /clear-ddma-session endpoint; reduce fixed sleeps with smart WebDriverWait Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import { runEligibilityProcessor } from "./processors/eligibilityProcessor";
|
|||||||
import { runClaimStatusProcessor } from "./processors/claimStatusProcessor";
|
import { runClaimStatusProcessor } from "./processors/claimStatusProcessor";
|
||||||
import { runClaimSubmitProcessor } from "./processors/claimSubmitProcessor";
|
import { runClaimSubmitProcessor } from "./processors/claimSubmitProcessor";
|
||||||
import { runOcrProcessor } from "./processors/ocrProcessor";
|
import { runOcrProcessor } from "./processors/ocrProcessor";
|
||||||
|
import { runDdmaEligibilityProcessor } from "./processors/ddmaEligibilityProcessor";
|
||||||
import type { SeleniumJobData, OcrJobData } from "./queues";
|
import type { SeleniumJobData, OcrJobData } from "./queues";
|
||||||
|
|
||||||
// ── Queue instances ──────────────────────────────────────────────────────────
|
// ── Queue instances ──────────────────────────────────────────────────────────
|
||||||
@@ -68,6 +69,20 @@ export function enqueueSeleniumJob(data: SeleniumJobData): string {
|
|||||||
variant: jobType === "claim-pre-auth" ? "claim-pre-auth" : "claimsubmit",
|
variant: jobType === "claim-pre-auth" ? "claim-pre-auth" : "claimsubmit",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (jobType === "ddma-eligibility-check") {
|
||||||
|
return runDdmaEligibilityProcessor(
|
||||||
|
{
|
||||||
|
enrichedPayload: data.enrichedPayload,
|
||||||
|
userId: data.userId,
|
||||||
|
insuranceId: data.insuranceId!,
|
||||||
|
formFirstName: data.formFirstName,
|
||||||
|
formLastName: data.formLastName,
|
||||||
|
formDob: data.formDob,
|
||||||
|
socketId: data.socketId,
|
||||||
|
},
|
||||||
|
job.id
|
||||||
|
);
|
||||||
|
}
|
||||||
throw new Error(`Unknown selenium jobType: ${jobType}`);
|
throw new Error(`Unknown selenium jobType: ${jobType}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
359
apps/Backend/src/queue/processors/ddmaEligibilityProcessor.ts
Normal file
359
apps/Backend/src/queue/processors/ddmaEligibilityProcessor.ts
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
/**
|
||||||
|
* Processor for "ddma-eligibility-check" jobs.
|
||||||
|
*
|
||||||
|
* Integrates the full DDMA persistent-session flow into the InProcessQueue:
|
||||||
|
* 1. Start a session on the Python agent (POST /ddma-eligibility)
|
||||||
|
* 2. Emit selenium:ddma_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-ddma/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 {
|
||||||
|
forwardToSeleniumDdmaEligibilityAgent,
|
||||||
|
getSeleniumDdmaSessionStatus,
|
||||||
|
} from "../../services/seleniumDdmaInsuranceEligibilityClient";
|
||||||
|
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("ddma-processor", `emitted ${event}`, { socketId });
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
log("ddma-processor", `emit failed for ${event}`, { err: err?.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface DdmaEligibilityProcessorInput {
|
||||||
|
enrichedPayload: any;
|
||||||
|
userId: number;
|
||||||
|
insuranceId: string;
|
||||||
|
formFirstName?: string;
|
||||||
|
formLastName?: string;
|
||||||
|
formDob?: string;
|
||||||
|
socketId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DdmaEligibilityProcessorResult {
|
||||||
|
patientUpdateStatus?: string;
|
||||||
|
pdfUploadStatus?: string;
|
||||||
|
pdfFileId?: number | null;
|
||||||
|
pdfFilename?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Core DB processing (mirrors handleDdmaCompletedJob in the route) ─────────
|
||||||
|
|
||||||
|
async function processDdmaResult(
|
||||||
|
userId: number,
|
||||||
|
insuranceId: string,
|
||||||
|
formFirstName: string | undefined,
|
||||||
|
formLastName: string | undefined,
|
||||||
|
formDob: string | undefined,
|
||||||
|
seleniumResult: any
|
||||||
|
): Promise<DdmaEligibilityProcessorResult> {
|
||||||
|
const output: DdmaEligibilityProcessorResult = {};
|
||||||
|
let createdPdfFileId: number | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1) Resolve patient name (prefer selenium extraction → form data)
|
||||||
|
const rawName =
|
||||||
|
typeof seleniumResult?.patientName === "string"
|
||||||
|
? seleniumResult.patientName.trim()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const { firstName, lastName } = rawName
|
||||||
|
? splitName(rawName)
|
||||||
|
: { firstName: formFirstName ?? "", lastName: formLastName ?? "" };
|
||||||
|
|
||||||
|
// 2) Create / update patient
|
||||||
|
await createOrUpdatePatientByInsuranceId({
|
||||||
|
insuranceId,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
dob: formDob,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3) Fetch patient (needed for ID)
|
||||||
|
const patient = await storage.getPatientByInsuranceId(insuranceId);
|
||||||
|
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();
|
||||||
|
const newStatus =
|
||||||
|
eligStatus === "active" || eligStatus === "y" ? "ACTIVE" : "INACTIVE";
|
||||||
|
await storage.updatePatient(patient.id, {
|
||||||
|
status: newStatus,
|
||||||
|
insuranceProvider: "Delta Dental MA",
|
||||||
|
});
|
||||||
|
output.patientUpdateStatus = `Patient status updated to ${newStatus}`;
|
||||||
|
|
||||||
|
// 5) Resolve PDF buffer
|
||||||
|
// New DDMA 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("ddma-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 = `ddma_eligibility_${insuranceId}_${Date.now()}.pdf`;
|
||||||
|
log("ddma-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 groupTitleKey = "ELIGIBILITY_STATUS";
|
||||||
|
const groupTitle = "Eligibility Status";
|
||||||
|
|
||||||
|
let group = await storage.findPdfGroupByPatientTitleKey(patient.id, groupTitleKey);
|
||||||
|
if (!group) group = await storage.createPdfGroup(patient.id, groupTitle, groupTitleKey);
|
||||||
|
if (!group?.id) throw new Error("PDF group creation failed");
|
||||||
|
|
||||||
|
const created = await storage.createPdfFile(group.id, pdfFilename, pdfBuffer);
|
||||||
|
if (created && typeof created === "object" && "id" in created) {
|
||||||
|
createdPdfFileId = Number(created.id);
|
||||||
|
}
|
||||||
|
output.pdfUploadStatus = `PDF saved to group: ${group.title}`;
|
||||||
|
output.pdfFilename = pdfFilename;
|
||||||
|
}
|
||||||
|
|
||||||
|
output.pdfFileId = createdPdfFileId;
|
||||||
|
return output;
|
||||||
|
} catch (err: any) {
|
||||||
|
return {
|
||||||
|
...output,
|
||||||
|
pdfUploadStatus:
|
||||||
|
output.pdfUploadStatus ?? `Processing failed: ${err?.message ?? String(err)}`,
|
||||||
|
pdfFileId: createdPdfFileId,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
// Always clean up temp files
|
||||||
|
const cleanupPath =
|
||||||
|
seleniumResult?.pdf_path ?? seleniumResult?.ss_path ?? null;
|
||||||
|
if (cleanupPath) {
|
||||||
|
try {
|
||||||
|
await emptyFolderContainingFile(cleanupPath);
|
||||||
|
} catch (e) {
|
||||||
|
log("ddma-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(
|
||||||
|
`DDMA polling timeout (${Math.round(pollTimeoutMs / 1000)}s) for session ${sessionId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const st = await getSeleniumDdmaSessionStatus(sessionId);
|
||||||
|
const status: string = st?.status ?? "unknown";
|
||||||
|
|
||||||
|
log("ddma-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 DDMA portal.",
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "completed") {
|
||||||
|
log("ddma-processor", "session completed", { sessionId });
|
||||||
|
return st.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "error" || status === "not_found") {
|
||||||
|
throw new Error(
|
||||||
|
st?.message || `DDMA 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 DDMA session ${sessionId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const backoff = Math.min(30_000, 500 * Math.pow(2, transientErrors - 1));
|
||||||
|
log("ddma-processor", `transient error #${transientErrors}, backoff ${backoff}ms`, {
|
||||||
|
err: err?.message,
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, backoff));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`DDMA polling exhausted all attempts for session ${sessionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main processor entry point ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function runDdmaEligibilityProcessor(
|
||||||
|
input: DdmaEligibilityProcessorInput,
|
||||||
|
jobId: string
|
||||||
|
): Promise<DdmaEligibilityProcessorResult> {
|
||||||
|
const {
|
||||||
|
enrichedPayload,
|
||||||
|
userId,
|
||||||
|
insuranceId,
|
||||||
|
formFirstName,
|
||||||
|
formLastName,
|
||||||
|
formDob,
|
||||||
|
socketId,
|
||||||
|
} = input;
|
||||||
|
|
||||||
|
// 1) Tell Python agent to start a DDMA session
|
||||||
|
log("ddma-processor", "starting Python agent session", { insuranceId });
|
||||||
|
const agentResp = await forwardToSeleniumDdmaEligibilityAgent(enrichedPayload);
|
||||||
|
|
||||||
|
if (!agentResp?.session_id) {
|
||||||
|
throw new Error(
|
||||||
|
"Python agent did not return a session_id for DDMA eligibility"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = agentResp.session_id as string;
|
||||||
|
log("ddma-processor", "got session_id", { sessionId });
|
||||||
|
|
||||||
|
// 2) Emit session started so frontend can store session_id for OTP submission
|
||||||
|
emitToSocket(socketId, "selenium:ddma_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 ?? "DDMA session returned an error result"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Process DB writes and PDF upload
|
||||||
|
log("ddma-processor", "processing DB result", { insuranceId });
|
||||||
|
const result = await processDdmaResult(
|
||||||
|
userId,
|
||||||
|
insuranceId,
|
||||||
|
formFirstName,
|
||||||
|
formLastName,
|
||||||
|
formDob,
|
||||||
|
seleniumResult
|
||||||
|
);
|
||||||
|
|
||||||
|
log("ddma-processor", "done", { result });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -6,7 +6,8 @@ export type SeleniumJobType =
|
|||||||
| "eligibility-check"
|
| "eligibility-check"
|
||||||
| "claim-status-check"
|
| "claim-status-check"
|
||||||
| "claim-submit"
|
| "claim-submit"
|
||||||
| "claim-pre-auth";
|
| "claim-pre-auth"
|
||||||
|
| "ddma-eligibility-check";
|
||||||
|
|
||||||
export interface SeleniumJobData {
|
export interface SeleniumJobData {
|
||||||
jobType: SeleniumJobType;
|
jobType: SeleniumJobType;
|
||||||
|
|||||||
@@ -1,571 +1,41 @@
|
|||||||
import { Router, Request, Response } from "express";
|
import { Router, Request, Response } from "express";
|
||||||
import { storage } from "../storage";
|
import { storage } from "../storage";
|
||||||
import {
|
import { forwardOtpToSeleniumDdmaAgent } from "../services/seleniumDdmaInsuranceEligibilityClient";
|
||||||
forwardToSeleniumDdmaEligibilityAgent,
|
|
||||||
forwardOtpToSeleniumDdmaAgent,
|
|
||||||
getSeleniumDdmaSessionStatus,
|
|
||||||
} from "../services/seleniumDdmaInsuranceEligibilityClient";
|
|
||||||
import fs from "fs/promises";
|
|
||||||
import fsSync from "fs";
|
|
||||||
import path from "path";
|
|
||||||
import PDFDocument from "pdfkit";
|
|
||||||
import { emptyFolderContainingFile } from "../utils/emptyTempFolder";
|
|
||||||
import {
|
|
||||||
InsertPatient,
|
|
||||||
insertPatientSchema,
|
|
||||||
} from "../../../../packages/db/types/patient-types";
|
|
||||||
import { io } from "../socket";
|
import { io } from "../socket";
|
||||||
|
import { enqueueSeleniumJob } from "../queue/jobRunner";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
/** Job context stored in memory by sessionId */
|
|
||||||
interface DdmaJobContext {
|
|
||||||
userId: number;
|
|
||||||
insuranceEligibilityData: any; // parsed, enriched (includes username/password)
|
|
||||||
socketId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ddmaJobs: Record<string, DdmaJobContext> = {};
|
|
||||||
|
|
||||||
/** Utility: naive name splitter */
|
|
||||||
function splitName(fullName?: string | null) {
|
|
||||||
if (!fullName) return { firstName: "", lastName: "" };
|
|
||||||
const parts = fullName.trim().split(/\s+/).filter(Boolean);
|
|
||||||
const firstName = parts.shift() ?? "";
|
|
||||||
const lastName = parts.join(" ") ?? "";
|
|
||||||
return { firstName, lastName };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function imageToPdfBuffer(imagePath: string): Promise<Buffer> {
|
|
||||||
return new Promise<Buffer>((resolve, reject) => {
|
|
||||||
try {
|
|
||||||
const doc = new PDFDocument({ autoFirstPage: false });
|
|
||||||
const chunks: Uint8Array[] = [];
|
|
||||||
|
|
||||||
doc.on("data", (chunk: any) => chunks.push(chunk));
|
|
||||||
doc.on("end", () => resolve(Buffer.concat(chunks)));
|
|
||||||
doc.on("error", (err: any) => reject(err));
|
|
||||||
|
|
||||||
const A4_WIDTH = 595.28; // points
|
|
||||||
const A4_HEIGHT = 841.89; // points
|
|
||||||
|
|
||||||
doc.addPage({ size: [A4_WIDTH, A4_HEIGHT] });
|
|
||||||
|
|
||||||
doc.image(imagePath, 0, 0, {
|
|
||||||
fit: [A4_WIDTH, A4_HEIGHT],
|
|
||||||
align: "center",
|
|
||||||
valign: "center",
|
|
||||||
});
|
|
||||||
|
|
||||||
doc.end();
|
|
||||||
} catch (err) {
|
|
||||||
reject(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure patient exists for given insuranceId.
|
|
||||||
*/
|
|
||||||
async function createOrUpdatePatientByInsuranceId(options: {
|
|
||||||
insuranceId: string;
|
|
||||||
firstName?: string | null;
|
|
||||||
lastName?: string | null;
|
|
||||||
dob?: string | Date | null;
|
|
||||||
userId: number;
|
|
||||||
}) {
|
|
||||||
const { insuranceId, firstName, lastName, dob, userId } = options;
|
|
||||||
if (!insuranceId) throw new Error("Missing insuranceId");
|
|
||||||
|
|
||||||
const incomingFirst = (firstName || "").trim();
|
|
||||||
const incomingLast = (lastName || "").trim();
|
|
||||||
|
|
||||||
let patient = await storage.getPatientByInsuranceId(insuranceId);
|
|
||||||
|
|
||||||
if (patient && patient.id) {
|
|
||||||
const updates: any = {};
|
|
||||||
if (
|
|
||||||
incomingFirst &&
|
|
||||||
String(patient.firstName ?? "").trim() !== incomingFirst
|
|
||||||
) {
|
|
||||||
updates.firstName = incomingFirst;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
incomingLast &&
|
|
||||||
String(patient.lastName ?? "").trim() !== incomingLast
|
|
||||||
) {
|
|
||||||
updates.lastName = incomingLast;
|
|
||||||
}
|
|
||||||
if (Object.keys(updates).length > 0) {
|
|
||||||
await storage.updatePatient(patient.id, updates);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
const createPayload: any = {
|
|
||||||
firstName: incomingFirst,
|
|
||||||
lastName: incomingLast,
|
|
||||||
dateOfBirth: dob,
|
|
||||||
gender: "",
|
|
||||||
phone: "",
|
|
||||||
userId,
|
|
||||||
insuranceId,
|
|
||||||
};
|
|
||||||
let patientData: InsertPatient;
|
|
||||||
try {
|
|
||||||
patientData = insertPatientSchema.parse(createPayload);
|
|
||||||
} catch (err) {
|
|
||||||
const safePayload = { ...createPayload };
|
|
||||||
delete (safePayload as any).dateOfBirth;
|
|
||||||
patientData = insertPatientSchema.parse(safePayload);
|
|
||||||
}
|
|
||||||
await storage.createPatient(patientData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* When Selenium finishes for a given sessionId, run your patient + PDF pipeline,
|
|
||||||
* and return the final API response shape.
|
|
||||||
*/
|
|
||||||
async function handleDdmaCompletedJob(
|
|
||||||
sessionId: string,
|
|
||||||
job: DdmaJobContext,
|
|
||||||
seleniumResult: any
|
|
||||||
) {
|
|
||||||
let createdPdfFileId: number | null = null;
|
|
||||||
const outputResult: any = {};
|
|
||||||
|
|
||||||
// We'll wrap the processing in try/catch/finally so cleanup always runs
|
|
||||||
try {
|
|
||||||
// 1) ensuring memberid.
|
|
||||||
const insuranceEligibilityData = job.insuranceEligibilityData;
|
|
||||||
const insuranceId = String(insuranceEligibilityData.memberId ?? "").trim();
|
|
||||||
if (!insuranceId) {
|
|
||||||
throw new Error("Missing memberId for ddma job");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Create or update patient (with name from selenium result if available)
|
|
||||||
const patientNameFromResult =
|
|
||||||
typeof seleniumResult?.patientName === "string"
|
|
||||||
? seleniumResult.patientName.trim()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const { firstName, lastName } = splitName(patientNameFromResult);
|
|
||||||
|
|
||||||
await createOrUpdatePatientByInsuranceId({
|
|
||||||
insuranceId,
|
|
||||||
firstName,
|
|
||||||
lastName,
|
|
||||||
dob: insuranceEligibilityData.dateOfBirth,
|
|
||||||
userId: job.userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3) Update patient status + PDF upload
|
|
||||||
const patient = await storage.getPatientByInsuranceId(
|
|
||||||
insuranceEligibilityData.memberId
|
|
||||||
);
|
|
||||||
if (!patient?.id) {
|
|
||||||
outputResult.patientUpdateStatus =
|
|
||||||
"Patient not found; no update performed";
|
|
||||||
return {
|
|
||||||
patientUpdateStatus: outputResult.patientUpdateStatus,
|
|
||||||
pdfUploadStatus: "none",
|
|
||||||
pdfFileId: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// update patient status.
|
|
||||||
const newStatus =
|
|
||||||
seleniumResult.eligibility === "active" ? "ACTIVE" : "INACTIVE";
|
|
||||||
await storage.updatePatient(patient.id, { status: newStatus });
|
|
||||||
outputResult.patientUpdateStatus = `Patient status updated to ${newStatus}`;
|
|
||||||
|
|
||||||
// convert screenshot -> pdf if available
|
|
||||||
let pdfBuffer: Buffer | null = null;
|
|
||||||
let generatedPdfPath: string | null = null;
|
|
||||||
|
|
||||||
if (
|
|
||||||
seleniumResult &&
|
|
||||||
seleniumResult.ss_path &&
|
|
||||||
typeof seleniumResult.ss_path === "string" &&
|
|
||||||
(seleniumResult.ss_path.endsWith(".png") ||
|
|
||||||
seleniumResult.ss_path.endsWith(".jpg") ||
|
|
||||||
seleniumResult.ss_path.endsWith(".jpeg"))
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
if (!fsSync.existsSync(seleniumResult.ss_path)) {
|
|
||||||
throw new Error(
|
|
||||||
`Screenshot file not found: ${seleniumResult.ss_path}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
pdfBuffer = await imageToPdfBuffer(seleniumResult.ss_path);
|
|
||||||
|
|
||||||
const pdfFileName = `ddma_eligibility_${insuranceEligibilityData.memberId}_${Date.now()}.pdf`;
|
|
||||||
generatedPdfPath = path.join(
|
|
||||||
path.dirname(seleniumResult.ss_path),
|
|
||||||
pdfFileName
|
|
||||||
);
|
|
||||||
await fs.writeFile(generatedPdfPath, pdfBuffer);
|
|
||||||
|
|
||||||
// ensure cleanup uses this
|
|
||||||
seleniumResult.pdf_path = generatedPdfPath;
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error("Failed to convert screenshot to PDF:", err);
|
|
||||||
outputResult.pdfUploadStatus = `Failed to convert screenshot to PDF: ${String(err)}`;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
outputResult.pdfUploadStatus =
|
|
||||||
"No valid screenshot (ss_path) provided by Selenium; nothing to upload.";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pdfBuffer && generatedPdfPath) {
|
|
||||||
const groupTitle = "Eligibility Status";
|
|
||||||
const groupTitleKey = "ELIGIBILITY_STATUS";
|
|
||||||
|
|
||||||
let group = await storage.findPdfGroupByPatientTitleKey(
|
|
||||||
patient.id,
|
|
||||||
groupTitleKey
|
|
||||||
);
|
|
||||||
if (!group) {
|
|
||||||
group = await storage.createPdfGroup(
|
|
||||||
patient.id,
|
|
||||||
groupTitle,
|
|
||||||
groupTitleKey
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!group?.id) {
|
|
||||||
throw new Error("PDF group creation failed: missing group ID");
|
|
||||||
}
|
|
||||||
|
|
||||||
const created = await storage.createPdfFile(
|
|
||||||
group.id,
|
|
||||||
path.basename(generatedPdfPath),
|
|
||||||
pdfBuffer
|
|
||||||
);
|
|
||||||
if (created && typeof created === "object" && "id" in created) {
|
|
||||||
createdPdfFileId = Number(created.id);
|
|
||||||
}
|
|
||||||
outputResult.pdfUploadStatus = `PDF saved to group: ${group.title}`;
|
|
||||||
} else {
|
|
||||||
outputResult.pdfUploadStatus =
|
|
||||||
"No valid PDF path provided by Selenium, Couldn't upload pdf to server.";
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
patientUpdateStatus: outputResult.patientUpdateStatus,
|
|
||||||
pdfUploadStatus: outputResult.pdfUploadStatus,
|
|
||||||
pdfFileId: createdPdfFileId,
|
|
||||||
};
|
|
||||||
} catch (err: any) {
|
|
||||||
return {
|
|
||||||
patientUpdateStatus: outputResult.patientUpdateStatus,
|
|
||||||
pdfUploadStatus:
|
|
||||||
outputResult.pdfUploadStatus ??
|
|
||||||
`Failed to process DDMA job: ${err?.message ?? String(err)}`,
|
|
||||||
pdfFileId: createdPdfFileId,
|
|
||||||
error: err?.message ?? String(err),
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
// ALWAYS attempt cleanup of temp files
|
|
||||||
try {
|
|
||||||
if (seleniumResult && seleniumResult.pdf_path) {
|
|
||||||
await emptyFolderContainingFile(seleniumResult.pdf_path);
|
|
||||||
} else if (seleniumResult && seleniumResult.ss_path) {
|
|
||||||
await emptyFolderContainingFile(seleniumResult.ss_path);
|
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
`[ddma-eligibility] no pdf_path or ss_path available to cleanup`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (cleanupErr) {
|
|
||||||
console.error(
|
|
||||||
`[ddma-eligibility cleanup failed for ${seleniumResult?.pdf_path ?? seleniumResult?.ss_path}]`,
|
|
||||||
cleanupErr
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- top of file, alongside ddmaJobs ---
|
|
||||||
let currentFinalSessionId: string | null = null;
|
|
||||||
let currentFinalResult: any = null;
|
|
||||||
|
|
||||||
function now() {
|
|
||||||
return new Date().toISOString();
|
|
||||||
}
|
|
||||||
function log(tag: string, msg: string, ctx?: any) {
|
function log(tag: string, msg: string, ctx?: any) {
|
||||||
console.log(`${now()} [${tag}] ${msg}`, ctx ?? "");
|
console.log(`${new Date().toISOString()} [${tag}] ${msg}`, ctx ?? "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitSafe(socketId: string | undefined, event: string, payload: any) {
|
function emitSafe(socketId: string | undefined, event: string, payload: any) {
|
||||||
if (!socketId) {
|
if (!socketId || !io) return;
|
||||||
log("socket", "no socketId for emit", { event });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const socket = io?.sockets.sockets.get(socketId);
|
const socket = io.sockets.sockets.get(socketId);
|
||||||
if (!socket) {
|
if (socket) socket.emit(event, payload);
|
||||||
log("socket", "socket not found (maybe disconnected)", {
|
|
||||||
socketId,
|
|
||||||
event,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
socket.emit(event, payload);
|
|
||||||
log("socket", "emitted", { socketId, event });
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
log("socket", "emit failed", { socketId, event, err: err?.message });
|
log("socket", "emit failed", { socketId, event, err: err?.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Polls Python agent for session status and emits socket events:
|
|
||||||
* - 'selenium:otp_required' when waiting_for_otp
|
|
||||||
* - 'selenium:session_update' when completed/error
|
|
||||||
* - rabsolute timeout + transient error handling.
|
|
||||||
* - pollTimeoutMs default = 2 minutes (adjust where invoked)
|
|
||||||
*/
|
|
||||||
async function pollAgentSessionAndProcess(
|
|
||||||
sessionId: string,
|
|
||||||
socketId?: string,
|
|
||||||
pollTimeoutMs = 2 * 60 * 1000
|
|
||||||
) {
|
|
||||||
const maxAttempts = 300;
|
|
||||||
const baseDelayMs = 1000;
|
|
||||||
const maxTransientErrors = 12;
|
|
||||||
|
|
||||||
// NEW: give up if same non-terminal status repeats this many times
|
|
||||||
const noProgressLimit = 100;
|
|
||||||
|
|
||||||
const job = ddmaJobs[sessionId];
|
|
||||||
let transientErrorCount = 0;
|
|
||||||
let consecutiveNoProgress = 0;
|
|
||||||
let lastStatus: string | null = null;
|
|
||||||
const deadline = Date.now() + pollTimeoutMs;
|
|
||||||
|
|
||||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
||||||
// absolute deadline check
|
|
||||||
if (Date.now() > deadline) {
|
|
||||||
emitSafe(socketId, "selenium:session_update", {
|
|
||||||
session_id: sessionId,
|
|
||||||
status: "error",
|
|
||||||
message: `Polling timeout reached (${Math.round(pollTimeoutMs / 1000)}s).`,
|
|
||||||
});
|
|
||||||
delete ddmaJobs[sessionId];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
log(
|
|
||||||
"poller",
|
|
||||||
`attempt=${attempt} session=${sessionId} transientErrCount=${transientErrorCount}`
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const st = await getSeleniumDdmaSessionStatus(sessionId);
|
|
||||||
const status = st?.status ?? null;
|
|
||||||
log("poller", "got status", {
|
|
||||||
sessionId,
|
|
||||||
status,
|
|
||||||
message: st?.message,
|
|
||||||
resultKeys: st?.result ? Object.keys(st.result) : null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// reset transient errors on success
|
|
||||||
transientErrorCount = 0;
|
|
||||||
|
|
||||||
// if status unchanged and non-terminal, increment no-progress counter
|
|
||||||
const isTerminalLike =
|
|
||||||
status === "completed" || status === "error" || status === "not_found";
|
|
||||||
if (status === lastStatus && !isTerminalLike) {
|
|
||||||
consecutiveNoProgress++;
|
|
||||||
} else {
|
|
||||||
consecutiveNoProgress = 0;
|
|
||||||
}
|
|
||||||
lastStatus = status;
|
|
||||||
|
|
||||||
// if no progress for too many consecutive polls -> abort
|
|
||||||
if (consecutiveNoProgress >= noProgressLimit) {
|
|
||||||
emitSafe(socketId, "selenium:session_update", {
|
|
||||||
session_id: sessionId,
|
|
||||||
status: "error",
|
|
||||||
message: `No progress from selenium agent (status="${status}") after ${consecutiveNoProgress} polls; aborting.`,
|
|
||||||
});
|
|
||||||
emitSafe(socketId, "selenium:session_error", {
|
|
||||||
session_id: sessionId,
|
|
||||||
status: "error",
|
|
||||||
message: "No progress from selenium agent",
|
|
||||||
});
|
|
||||||
delete ddmaJobs[sessionId];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// always emit debug to client if socket exists
|
|
||||||
emitSafe(socketId, "selenium:debug", {
|
|
||||||
session_id: sessionId,
|
|
||||||
attempt,
|
|
||||||
status,
|
|
||||||
serverTime: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// If agent is waiting for OTP, inform client but keep polling (do not return)
|
|
||||||
if (status === "waiting_for_otp") {
|
|
||||||
emitSafe(socketId, "selenium:otp_required", {
|
|
||||||
session_id: sessionId,
|
|
||||||
message: "OTP required. Please enter the OTP.",
|
|
||||||
});
|
|
||||||
// do not return — keep polling (allows same poller to pick up completion)
|
|
||||||
await new Promise((r) => setTimeout(r, baseDelayMs));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Completed path
|
|
||||||
if (status === "completed") {
|
|
||||||
log("poller", "agent completed; processing result", {
|
|
||||||
sessionId,
|
|
||||||
resultKeys: st.result ? Object.keys(st.result) : null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Persist raw result so frontend can fetch if socket disconnects
|
|
||||||
currentFinalSessionId = sessionId;
|
|
||||||
currentFinalResult = {
|
|
||||||
rawSelenium: st.result,
|
|
||||||
processedAt: null,
|
|
||||||
final: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
let finalResult: any = null;
|
|
||||||
if (job && st.result) {
|
|
||||||
try {
|
|
||||||
finalResult = await handleDdmaCompletedJob(
|
|
||||||
sessionId,
|
|
||||||
job,
|
|
||||||
st.result
|
|
||||||
);
|
|
||||||
currentFinalResult.final = finalResult;
|
|
||||||
currentFinalResult.processedAt = Date.now();
|
|
||||||
} catch (err: any) {
|
|
||||||
currentFinalResult.final = {
|
|
||||||
error: "processing_failed",
|
|
||||||
detail: err?.message ?? String(err),
|
|
||||||
};
|
|
||||||
currentFinalResult.processedAt = Date.now();
|
|
||||||
log("poller", "handleDdmaCompletedJob failed", {
|
|
||||||
sessionId,
|
|
||||||
err: err?.message ?? err,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
currentFinalResult[sessionId].final = {
|
|
||||||
error: "no_job_or_no_result",
|
|
||||||
};
|
|
||||||
currentFinalResult[sessionId].processedAt = Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit final update (if socket present)
|
|
||||||
emitSafe(socketId, "selenium:session_update", {
|
|
||||||
session_id: sessionId,
|
|
||||||
status: "completed",
|
|
||||||
rawSelenium: st.result,
|
|
||||||
final: currentFinalResult.final,
|
|
||||||
});
|
|
||||||
|
|
||||||
// cleanup job context
|
|
||||||
delete ddmaJobs[sessionId];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Terminal error / not_found
|
|
||||||
if (status === "error" || status === "not_found") {
|
|
||||||
const emitPayload = {
|
|
||||||
session_id: sessionId,
|
|
||||||
status,
|
|
||||||
message: st?.message || "Selenium session error",
|
|
||||||
};
|
|
||||||
emitSafe(socketId, "selenium:session_update", emitPayload);
|
|
||||||
emitSafe(socketId, "selenium:session_error", emitPayload);
|
|
||||||
delete ddmaJobs[sessionId];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
const axiosStatus =
|
|
||||||
err?.response?.status ?? (err?.status ? Number(err.status) : undefined);
|
|
||||||
const errCode = err?.code ?? err?.errno;
|
|
||||||
const errMsg = err?.message ?? String(err);
|
|
||||||
const errData = err?.response?.data ?? null;
|
|
||||||
|
|
||||||
// If agent explicitly returned 404 -> terminal (session gone)
|
|
||||||
if (
|
|
||||||
axiosStatus === 404 ||
|
|
||||||
(typeof errMsg === "string" && errMsg.includes("not_found"))
|
|
||||||
) {
|
|
||||||
console.warn(
|
|
||||||
`${new Date().toISOString()} [poller] terminal 404/not_found for ${sessionId}: data=${JSON.stringify(errData)}`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Emit not_found to client
|
|
||||||
const emitPayload = {
|
|
||||||
session_id: sessionId,
|
|
||||||
status: "not_found",
|
|
||||||
message:
|
|
||||||
errData?.detail || "Selenium session not found (agent cleaned up).",
|
|
||||||
};
|
|
||||||
emitSafe(socketId, "selenium:session_update", emitPayload);
|
|
||||||
emitSafe(socketId, "selenium:session_error", emitPayload);
|
|
||||||
|
|
||||||
// Remove job context and stop polling
|
|
||||||
delete ddmaJobs[sessionId];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detailed transient error logging
|
|
||||||
transientErrorCount++;
|
|
||||||
if (transientErrorCount > maxTransientErrors) {
|
|
||||||
const emitPayload = {
|
|
||||||
session_id: sessionId,
|
|
||||||
status: "error",
|
|
||||||
message:
|
|
||||||
"Repeated network errors while polling selenium agent; giving up.",
|
|
||||||
};
|
|
||||||
emitSafe(socketId, "selenium:session_update", emitPayload);
|
|
||||||
emitSafe(socketId, "selenium:session_error", emitPayload);
|
|
||||||
delete ddmaJobs[sessionId];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const backoffMs = Math.min(
|
|
||||||
30_000,
|
|
||||||
baseDelayMs * Math.pow(2, transientErrorCount - 1)
|
|
||||||
);
|
|
||||||
console.warn(
|
|
||||||
`${new Date().toISOString()} [poller] transient error (#${transientErrorCount}) for ${sessionId}: code=${errCode} status=${axiosStatus} msg=${errMsg} data=${JSON.stringify(errData)}`
|
|
||||||
);
|
|
||||||
console.warn(
|
|
||||||
`${new Date().toISOString()} [poller] backing off ${backoffMs}ms before next attempt`
|
|
||||||
);
|
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, backoffMs));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// normal poll interval
|
|
||||||
await new Promise((r) => setTimeout(r, baseDelayMs));
|
|
||||||
}
|
|
||||||
|
|
||||||
// overall timeout fallback
|
|
||||||
emitSafe(socketId, "selenium:session_update", {
|
|
||||||
session_id: sessionId,
|
|
||||||
status: "error",
|
|
||||||
message: "Polling timeout while waiting for selenium session",
|
|
||||||
});
|
|
||||||
delete ddmaJobs[sessionId];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /ddma-eligibility
|
* POST /ddma-eligibility
|
||||||
* Starts DDMA eligibility Selenium job.
|
*
|
||||||
* Expects:
|
* Enqueues a DDMA eligibility check in the shared InProcessQueue
|
||||||
* - req.body.data: stringified JSON like your existing /eligibility-check
|
* (concurrency=1, mirrors the Python semaphore).
|
||||||
* - req.body.socketId: socket.io client id
|
*
|
||||||
|
* 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:ddma_session_started { session_id, jobId }
|
||||||
|
* selenium:otp_required { session_id, jobId, message }
|
||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
"/ddma-eligibility",
|
"/ddma-eligibility",
|
||||||
@@ -575,8 +45,7 @@ router.post(
|
|||||||
.status(400)
|
.status(400)
|
||||||
.json({ error: "Missing Insurance Eligibility data for selenium" });
|
.json({ error: "Missing Insurance Eligibility data for selenium" });
|
||||||
}
|
}
|
||||||
|
if (!req.user?.id) {
|
||||||
if (!req.user || !req.user.id) {
|
|
||||||
return res.status(401).json({ error: "Unauthorized: user info missing" });
|
return res.status(401).json({ error: "Unauthorized: user info missing" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -586,6 +55,7 @@ router.post(
|
|||||||
? JSON.parse(req.body.data)
|
? JSON.parse(req.body.data)
|
||||||
: req.body.data;
|
: req.body.data;
|
||||||
|
|
||||||
|
// Fetch credentials from DB
|
||||||
const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(
|
const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(
|
||||||
req.user.id,
|
req.user.id,
|
||||||
rawData.insuranceSiteKey
|
rawData.insuranceSiteKey
|
||||||
@@ -593,7 +63,7 @@ router.post(
|
|||||||
if (!credentials) {
|
if (!credentials) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
error:
|
error:
|
||||||
"No insurance credentials found for this provider, Kindly Update this at Settings Page.",
|
"No insurance credentials found for this provider. Please update them at the Settings page.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -605,40 +75,25 @@ router.post(
|
|||||||
|
|
||||||
const socketId: string | undefined = req.body.socketId;
|
const socketId: string | undefined = req.body.socketId;
|
||||||
|
|
||||||
const agentResp =
|
// Enqueue — this enforces the same concurrency=1 as all other selenium jobs
|
||||||
await forwardToSeleniumDdmaEligibilityAgent(enrichedData);
|
const jobId = enqueueSeleniumJob({
|
||||||
|
jobType: "ddma-eligibility-check",
|
||||||
if (
|
|
||||||
!agentResp ||
|
|
||||||
agentResp.status !== "started" ||
|
|
||||||
!agentResp.session_id
|
|
||||||
) {
|
|
||||||
return res.status(502).json({
|
|
||||||
error: "Selenium agent did not return a started session",
|
|
||||||
detail: agentResp,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionId = agentResp.session_id as string;
|
|
||||||
|
|
||||||
// Save job context
|
|
||||||
ddmaJobs[sessionId] = {
|
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
insuranceEligibilityData: enrichedData,
|
|
||||||
socketId,
|
socketId,
|
||||||
};
|
enrichedPayload: enrichedData,
|
||||||
|
insuranceId: String(rawData.memberId ?? "").trim(),
|
||||||
|
formFirstName: rawData.firstName,
|
||||||
|
formLastName: rawData.lastName,
|
||||||
|
formDob: rawData.dateOfBirth,
|
||||||
|
});
|
||||||
|
|
||||||
// start polling in background to notify client via socket and process job
|
log("ddma-route", "job enqueued", { jobId, insuranceId: rawData.memberId });
|
||||||
pollAgentSessionAndProcess(sessionId, socketId).catch((e) =>
|
|
||||||
console.warn("pollAgentSessionAndProcess failed", e)
|
|
||||||
);
|
|
||||||
|
|
||||||
// reply immediately with started status
|
return res.json({ status: "queued", jobId });
|
||||||
return res.json({ status: "started", session_id: sessionId });
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(err);
|
console.error("[ddma-route] enqueue failed:", err);
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
error: err.message || "Failed to start ddma selenium agent",
|
error: err.message || "Failed to enqueue DDMA selenium job",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -646,8 +101,13 @@ router.post(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /selenium/submit-otp
|
* 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 ddmaEligibilityProcessor picks up the completed
|
||||||
|
* state after OTP is submitted.
|
||||||
|
*
|
||||||
* Body: { session_id, otp, socketId? }
|
* Body: { session_id, otp, socketId? }
|
||||||
* Forwards OTP to Python agent and optionally notifies client socket.
|
|
||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
"/selenium/submit-otp",
|
"/selenium/submit-otp",
|
||||||
@@ -660,7 +120,6 @@ router.post(
|
|||||||
try {
|
try {
|
||||||
const r = await forwardOtpToSeleniumDdmaAgent(sessionId, otp);
|
const r = await forwardOtpToSeleniumDdmaAgent(sessionId, otp);
|
||||||
|
|
||||||
// emit OTP accepted (if socket present)
|
|
||||||
emitSafe(socketId, "selenium:otp_submitted", {
|
emitSafe(socketId, "selenium:otp_submitted", {
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
result: r,
|
result: r,
|
||||||
@@ -669,31 +128,15 @@ router.post(
|
|||||||
return res.json(r);
|
return res.json(r);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(
|
console.error(
|
||||||
"Failed to forward OTP:",
|
"[ddma-route] submit-otp failed:",
|
||||||
err?.response?.data || err?.message || err
|
err?.response?.data || err?.message || err
|
||||||
);
|
);
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
error: "Failed to forward otp to selenium agent",
|
error: "Failed to forward OTP to selenium agent",
|
||||||
detail: err?.message || err,
|
detail: err?.message || err,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// GET /selenium/session/:sid/final
|
|
||||||
router.get(
|
|
||||||
"/selenium/session/:sid/final",
|
|
||||||
async (req: Request, res: Response) => {
|
|
||||||
const sid = req.params.sid;
|
|
||||||
if (!sid) return res.status(400).json({ error: "session id required" });
|
|
||||||
|
|
||||||
// Only the current in-memory result is available
|
|
||||||
if (currentFinalSessionId !== sid || !currentFinalResult) {
|
|
||||||
return res.status(404).json({ error: "final result not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json(currentFinalResult);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { io as ioClient, Socket } from "socket.io-client";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -9,13 +8,11 @@ import { apiRequest, queryClient } from "@/lib/queryClient";
|
|||||||
import { useAppDispatch } from "@/redux/hooks";
|
import { useAppDispatch } from "@/redux/hooks";
|
||||||
import { setTaskStatus } from "@/redux/slices/seleniumTaskSlice";
|
import { setTaskStatus } from "@/redux/slices/seleniumTaskSlice";
|
||||||
import { formatLocalDate } from "@/utils/dateUtils";
|
import { formatLocalDate } from "@/utils/dateUtils";
|
||||||
|
import { socket } from "@/lib/socket";
|
||||||
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
|
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
|
||||||
|
|
||||||
const SOCKET_URL =
|
// ─── OTP Modal ────────────────────────────────────────────────────────────────
|
||||||
import.meta.env.VITE_API_BASE_URL_BACKEND ||
|
|
||||||
(typeof window !== "undefined" ? window.location.origin : "");
|
|
||||||
|
|
||||||
// ---------- OTP Modal component ----------
|
|
||||||
interface DdmaOtpModalProps {
|
interface DdmaOtpModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -23,12 +20,7 @@ interface DdmaOtpModalProps {
|
|||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DdmaOtpModal({
|
function DdmaOtpModal({ open, onClose, onSubmit, isSubmitting }: DdmaOtpModalProps) {
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
onSubmit,
|
|
||||||
isSubmitting,
|
|
||||||
}: DdmaOtpModalProps) {
|
|
||||||
const [otp, setOtp] = useState("");
|
const [otp, setOtp] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -48,17 +40,13 @@ function DdmaOtpModal({
|
|||||||
<div className="bg-white dark:bg-slate-900 rounded-xl shadow-lg w-full max-w-md p-6">
|
<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">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-lg font-semibold">Enter OTP</h2>
|
<h2 className="text-lg font-semibold">Enter OTP</h2>
|
||||||
<button
|
<button type="button" onClick={onClose} className="text-slate-500 hover:text-slate-800">
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="text-slate-500 hover:text-slate-800"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-slate-500 mb-4">
|
<p className="text-sm text-slate-500 mb-4">
|
||||||
We need the one-time password (OTP) sent by the Delta Dental MA portal
|
We need the one-time password (OTP) sent by the Delta Dental MA portal to complete this
|
||||||
to complete this eligibility check.
|
eligibility check.
|
||||||
</p>
|
</p>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -72,12 +60,7 @@ function DdmaOtpModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
<Button
|
<Button type="button" variant="outline" onClick={onClose} disabled={isSubmitting}>
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isSubmitting || !otp.trim()}>
|
<Button type="submit" disabled={isSubmitting || !otp.trim()}>
|
||||||
@@ -97,14 +80,14 @@ function DdmaOtpModal({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Main DDMA Eligibility button component ----------
|
// ─── Main component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface DdmaEligibilityButtonProps {
|
interface DdmaEligibilityButtonProps {
|
||||||
memberId: string;
|
memberId: string;
|
||||||
dateOfBirth: Date | null;
|
dateOfBirth: Date | null;
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
isFormIncomplete: boolean;
|
isFormIncomplete: boolean;
|
||||||
/** Called when backend has finished and PDF is ready */
|
|
||||||
onPdfReady: (pdfId: number, fallbackFilename: string | null) => void;
|
onPdfReady: (pdfId: number, fallbackFilename: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,267 +102,18 @@ export function DdmaEligibilityButton({
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const socketRef = useRef<Socket | null>(null);
|
// session_id is provided by the backend once the Python agent starts the
|
||||||
const connectingRef = useRef<Promise<void> | null>(null);
|
// browser session. We receive it via the selenium:ddma_session_started event
|
||||||
|
// and need it to forward the OTP back.
|
||||||
|
const sessionIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
|
||||||
const [otpModalOpen, setOtpModalOpen] = useState(false);
|
const [otpModalOpen, setOtpModalOpen] = useState(false);
|
||||||
const [isStarting, setIsStarting] = useState(false);
|
const [isStarting, setIsStarting] = useState(false);
|
||||||
const [isSubmittingOtp, setIsSubmittingOtp] = useState(false);
|
const [isSubmittingOtp, setIsSubmittingOtp] = useState(false);
|
||||||
|
|
||||||
// Clean up socket on unmount
|
// ── Socket event handlers ─────────────────────────────────────────────────
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (socketRef.current) {
|
|
||||||
socketRef.current.removeAllListeners();
|
|
||||||
socketRef.current.disconnect();
|
|
||||||
socketRef.current = null;
|
|
||||||
}
|
|
||||||
connectingRef.current = null;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const closeSocket = () => {
|
const handleDdmaStart = async () => {
|
||||||
try {
|
|
||||||
socketRef.current?.removeAllListeners();
|
|
||||||
socketRef.current?.disconnect();
|
|
||||||
} catch (e) {
|
|
||||||
// ignore
|
|
||||||
} finally {
|
|
||||||
socketRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Lazy socket setup: called only when we actually need it (first click)
|
|
||||||
const ensureSocketConnected = async () => {
|
|
||||||
// If already connected, nothing to do
|
|
||||||
if (socketRef.current && socketRef.current.connected) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If a connection is in progress, reuse that promise
|
|
||||||
if (connectingRef.current) {
|
|
||||||
return connectingRef.current;
|
|
||||||
}
|
|
||||||
|
|
||||||
const promise = new Promise<void>((resolve, reject) => {
|
|
||||||
const socket = ioClient(SOCKET_URL, {
|
|
||||||
withCredentials: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
socketRef.current = socket;
|
|
||||||
|
|
||||||
socket.on("connect", () => {
|
|
||||||
console.log("DDMA socket connected:", socket.id);
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
// connection error when first connecting (or later)
|
|
||||||
socket.on("connect_error", (err: any) => {
|
|
||||||
dispatch(
|
|
||||||
setTaskStatus({
|
|
||||||
key: "eligibilityCheck",
|
|
||||||
status: "error",
|
|
||||||
message: "Connection failed",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
toast({
|
|
||||||
title: "Realtime connection failed",
|
|
||||||
description:
|
|
||||||
"Could not connect to realtime server. Retrying automatically...",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
// do not reject here because socket.io will attempt reconnection
|
|
||||||
});
|
|
||||||
|
|
||||||
// socket.io will emit 'reconnect_attempt' for retries
|
|
||||||
socket.on("reconnect_attempt", (attempt: number) => {
|
|
||||||
dispatch(
|
|
||||||
setTaskStatus({
|
|
||||||
key: "eligibilityCheck",
|
|
||||||
status: "pending",
|
|
||||||
message: `Realtime reconnect attempt #${attempt}`,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// when reconnection failed after configured attempts
|
|
||||||
socket.on("reconnect_failed", () => {
|
|
||||||
dispatch(
|
|
||||||
setTaskStatus({
|
|
||||||
key: "eligibilityCheck",
|
|
||||||
status: "error",
|
|
||||||
message: "Reconnect failed",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
toast({
|
|
||||||
title: "Realtime reconnect failed",
|
|
||||||
description:
|
|
||||||
"Connection to realtime server could not be re-established. Please try again later.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
// terminal failure — cleanup and reject so caller can stop start flow
|
|
||||||
closeSocket();
|
|
||||||
reject(new Error("Realtime reconnect failed"));
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on("disconnect", (reason: any) => {
|
|
||||||
dispatch(
|
|
||||||
setTaskStatus({
|
|
||||||
key: "eligibilityCheck",
|
|
||||||
status: "error",
|
|
||||||
message: "Connection disconnected",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
toast({
|
|
||||||
title: "Connection Disconnected",
|
|
||||||
description:
|
|
||||||
"Connection to the server was lost. If a DDMA job was running it may have failed.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
// clear sessionId/OTP modal
|
|
||||||
setSessionId(null);
|
|
||||||
setOtpModalOpen(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
// OTP required
|
|
||||||
socket.on("selenium:otp_required", (payload: any) => {
|
|
||||||
if (!payload?.session_id) return;
|
|
||||||
setSessionId(payload.session_id);
|
|
||||||
setOtpModalOpen(true);
|
|
||||||
dispatch(
|
|
||||||
setTaskStatus({
|
|
||||||
key: "eligibilityCheck",
|
|
||||||
status: "pending",
|
|
||||||
message: "OTP required for DDMA eligibility. Please enter the OTP.",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// OTP submitted (optional UX)
|
|
||||||
socket.on("selenium:otp_submitted", (payload: any) => {
|
|
||||||
if (!payload?.session_id) return;
|
|
||||||
dispatch(
|
|
||||||
setTaskStatus({
|
|
||||||
key: "eligibilityCheck",
|
|
||||||
status: "pending",
|
|
||||||
message: "OTP submitted. Finishing DDMA eligibility check...",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Session update
|
|
||||||
socket.on("selenium:session_update", (payload: any) => {
|
|
||||||
const { session_id, status, final } = payload || {};
|
|
||||||
if (!session_id) return;
|
|
||||||
|
|
||||||
if (status === "completed") {
|
|
||||||
dispatch(
|
|
||||||
setTaskStatus({
|
|
||||||
key: "eligibilityCheck",
|
|
||||||
status: "success",
|
|
||||||
message:
|
|
||||||
"DDMA eligibility updated and PDF attached to patient documents.",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
toast({
|
|
||||||
title: "DDMA eligibility complete",
|
|
||||||
description:
|
|
||||||
"Patient status was updated and the eligibility PDF was saved.",
|
|
||||||
variant: "default",
|
|
||||||
});
|
|
||||||
|
|
||||||
const pdfId = final?.pdfFileId;
|
|
||||||
if (pdfId) {
|
|
||||||
const filename =
|
|
||||||
final?.pdfFilename ?? `eligibility_ddma_${memberId}.pdf`;
|
|
||||||
onPdfReady(Number(pdfId), filename);
|
|
||||||
}
|
|
||||||
|
|
||||||
setSessionId(null);
|
|
||||||
setOtpModalOpen(false);
|
|
||||||
} else if (status === "error") {
|
|
||||||
const msg =
|
|
||||||
payload?.message ||
|
|
||||||
final?.error ||
|
|
||||||
"DDMA eligibility session failed.";
|
|
||||||
dispatch(
|
|
||||||
setTaskStatus({
|
|
||||||
key: "eligibilityCheck",
|
|
||||||
status: "error",
|
|
||||||
message: msg,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
toast({
|
|
||||||
title: "DDMA selenium error",
|
|
||||||
description: msg,
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ensure socket is torn down for this session (stop receiving stale events)
|
|
||||||
try {
|
|
||||||
closeSocket();
|
|
||||||
} catch (e) {}
|
|
||||||
setSessionId(null);
|
|
||||||
setOtpModalOpen(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
|
|
||||||
});
|
|
||||||
|
|
||||||
// explicit session error event (helpful)
|
|
||||||
socket.on("selenium:session_error", (payload: any) => {
|
|
||||||
const msg = payload?.message || "Selenium session error";
|
|
||||||
|
|
||||||
dispatch(
|
|
||||||
setTaskStatus({
|
|
||||||
key: "eligibilityCheck",
|
|
||||||
status: "error",
|
|
||||||
message: msg,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "Selenium session error",
|
|
||||||
description: msg,
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
|
|
||||||
// tear down socket to avoid stale updates
|
|
||||||
try {
|
|
||||||
closeSocket();
|
|
||||||
} catch (e) {}
|
|
||||||
setSessionId(null);
|
|
||||||
setOtpModalOpen(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
// If socket.io initial connection fails permanently (very rare: client-level)
|
|
||||||
// set a longer timeout to reject the first attempt to connect.
|
|
||||||
const initialConnectTimeout = setTimeout(() => {
|
|
||||||
if (!socket.connected) {
|
|
||||||
// if still not connected after 8s, treat as failure and reject so caller can handle it
|
|
||||||
closeSocket();
|
|
||||||
reject(new Error("Realtime initial connection timeout"));
|
|
||||||
}
|
|
||||||
}, 8000);
|
|
||||||
|
|
||||||
// When the connect resolves we should clear this timer
|
|
||||||
socket.once("connect", () => {
|
|
||||||
clearTimeout(initialConnectTimeout);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// store promise to prevent multiple concurrent connections
|
|
||||||
connectingRef.current = promise;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await promise;
|
|
||||||
} finally {
|
|
||||||
connectingRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const startDdmaEligibility = async () => {
|
|
||||||
if (!memberId || !dateOfBirth) {
|
if (!memberId || !dateOfBirth) {
|
||||||
toast({
|
toast({
|
||||||
title: "Missing fields",
|
title: "Missing fields",
|
||||||
@@ -389,107 +123,220 @@ export function DdmaEligibilityButton({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formattedDob = dateOfBirth ? formatLocalDate(dateOfBirth) : "";
|
const formattedDob = formatLocalDate(dateOfBirth);
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
memberId,
|
memberId,
|
||||||
dateOfBirth: formattedDob,
|
dateOfBirth: formattedDob,
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
insuranceSiteKey: "DDMA", // make sure this matches backend credential key
|
insuranceSiteKey: "DDMA",
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
|
||||||
setIsStarting(true);
|
setIsStarting(true);
|
||||||
|
|
||||||
// 1) Ensure socket is connected (lazy)
|
try {
|
||||||
dispatch(
|
dispatch(
|
||||||
setTaskStatus({
|
setTaskStatus({
|
||||||
key: "eligibilityCheck",
|
key: "eligibilityCheck",
|
||||||
status: "pending",
|
status: "pending",
|
||||||
message: "Opening realtime channel for DDMA eligibility...",
|
message: "Starting DDMA eligibility check…",
|
||||||
})
|
|
||||||
);
|
|
||||||
await ensureSocketConnected();
|
|
||||||
|
|
||||||
const socket = socketRef.current;
|
|
||||||
if (!socket || !socket.connected) {
|
|
||||||
throw new Error("Socket connection failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
const socketId = socket.id;
|
|
||||||
|
|
||||||
// 2) Start the selenium job via backend
|
|
||||||
dispatch(
|
|
||||||
setTaskStatus({
|
|
||||||
key: "eligibilityCheck",
|
|
||||||
status: "pending",
|
|
||||||
message: "Starting DDMA eligibility check via selenium...",
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 1) POST to backend — returns { status: "queued", jobId }
|
||||||
const response = await apiRequest(
|
const response = await apiRequest(
|
||||||
"POST",
|
"POST",
|
||||||
"/api/insurance-status-ddma/ddma-eligibility",
|
"/api/insurance-status-ddma/ddma-eligibility",
|
||||||
{
|
{ data: JSON.stringify(payload), socketId: socket.id }
|
||||||
data: JSON.stringify(payload),
|
|
||||||
socketId,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// If apiRequest threw, we would have caught above; but just in case it returns.
|
const result = await response.json();
|
||||||
let result: any = null;
|
if (!response.ok || result.error) {
|
||||||
let backendError: string | null = null;
|
throw new Error(result.error || `Server error (${response.status})`);
|
||||||
|
|
||||||
try {
|
|
||||||
// attempt JSON first
|
|
||||||
result = await response.clone().json();
|
|
||||||
backendError =
|
|
||||||
result?.error || result?.message || result?.detail || null;
|
|
||||||
} catch {
|
|
||||||
// fallback to text response
|
|
||||||
try {
|
|
||||||
const text = await response.clone().text();
|
|
||||||
backendError = text?.trim() || null;
|
|
||||||
} catch {
|
|
||||||
backendError = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
const jobId: string = result.jobId;
|
||||||
throw new Error(
|
if (!jobId) throw new Error("No jobId returned from server");
|
||||||
backendError ||
|
|
||||||
`DDMA selenium start failed (status ${response.status})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normal success path: optional: if backend returns non-error shape still check for result.error
|
|
||||||
if (result?.error) {
|
|
||||||
throw new Error(result.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.status === "started" && result.session_id) {
|
|
||||||
setSessionId(result.session_id as string);
|
|
||||||
dispatch(
|
dispatch(
|
||||||
setTaskStatus({
|
setTaskStatus({
|
||||||
key: "eligibilityCheck",
|
key: "eligibilityCheck",
|
||||||
status: "pending",
|
status: "pending",
|
||||||
message:
|
message: "DDMA job queued. Waiting for browser session to start…",
|
||||||
"DDMA eligibility job started. Waiting for OTP or final result...",
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
// fallback if backend returns immediate result
|
// 2) Listen for job-lifecycle and DDMA-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 DDMA 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 MA. 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 DDMA 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(
|
dispatch(
|
||||||
setTaskStatus({
|
setTaskStatus({
|
||||||
key: "eligibilityCheck",
|
key: "eligibilityCheck",
|
||||||
status: "success",
|
status: "success",
|
||||||
message: "DDMA eligibility completed.",
|
message: "DDMA eligibility updated and PDF attached to patient documents.",
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
toast({
|
||||||
|
title: "DDMA 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_ddma_${memberId}.pdf`;
|
||||||
|
onPdfReady(Number(pdfId), filename);
|
||||||
}
|
}
|
||||||
|
} else if (data.status === "failed") {
|
||||||
|
const msg = data.error ?? "DDMA eligibility job failed.";
|
||||||
|
dispatch(
|
||||||
|
setTaskStatus({ key: "eligibilityCheck", status: "error", message: msg })
|
||||||
|
);
|
||||||
|
toast({ title: "DDMA selenium error", description: msg, variant: "destructive" });
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsStarting(false);
|
||||||
|
setOtpModalOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Attach listeners
|
||||||
|
socket.on("selenium:ddma_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:ddma_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: "DDMA 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: "DDMA eligibility updated and PDF attached to patient documents.",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
toast({
|
||||||
|
title: "DDMA 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(Number(pdfId), data.result?.pdfFilename ?? `eligibility_ddma_${memberId}.pdf`);
|
||||||
|
}
|
||||||
|
} else if (data.status === "failed") {
|
||||||
|
const msg = data.error ?? "DDMA eligibility job failed.";
|
||||||
|
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: msg }));
|
||||||
|
toast({ title: "DDMA selenium error", description: msg, variant: "destructive" });
|
||||||
|
}
|
||||||
|
setIsStarting(false);
|
||||||
|
setOtpModalOpen(false);
|
||||||
|
});
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("startDdmaEligibility error:", err);
|
console.error("DdmaEligibilityButton error:", err);
|
||||||
dispatch(
|
dispatch(
|
||||||
setTaskStatus({
|
setTaskStatus({
|
||||||
key: "eligibilityCheck",
|
key: "eligibilityCheck",
|
||||||
@@ -502,17 +349,18 @@ export function DdmaEligibilityButton({
|
|||||||
description: err?.message || "Failed to start DDMA eligibility",
|
description: err?.message || "Failed to start DDMA eligibility",
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
} finally {
|
|
||||||
setIsStarting(false);
|
setIsStarting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── OTP submission ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const handleSubmitOtp = async (otp: string) => {
|
const handleSubmitOtp = async (otp: string) => {
|
||||||
if (!sessionId || !socketRef.current || !socketRef.current.connected) {
|
const sessionId = sessionIdRef.current;
|
||||||
|
if (!sessionId) {
|
||||||
toast({
|
toast({
|
||||||
title: "Session not ready",
|
title: "Session not ready",
|
||||||
description:
|
description: "Cannot submit OTP — DDMA session ID is not available yet.",
|
||||||
"Could not submit OTP because the DDMA session or socket is not ready.",
|
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -520,21 +368,15 @@ export function DdmaEligibilityButton({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSubmittingOtp(true);
|
setIsSubmittingOtp(true);
|
||||||
const resp = await apiRequest(
|
const resp = await apiRequest("POST", "/api/insurance-status-ddma/selenium/submit-otp", {
|
||||||
"POST",
|
|
||||||
"/api/insurance-status-ddma/selenium/submit-otp",
|
|
||||||
{
|
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
otp,
|
otp,
|
||||||
socketId: socketRef.current.id,
|
socketId: socket.id,
|
||||||
}
|
});
|
||||||
);
|
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (!resp.ok || data.error) {
|
if (!resp.ok || data.error) {
|
||||||
throw new Error(data.error || "Failed to submit OTP");
|
throw new Error(data.error || "Failed to submit OTP");
|
||||||
}
|
}
|
||||||
|
|
||||||
// from here we rely on websocket events (otp_submitted + session_update)
|
|
||||||
setOtpModalOpen(false);
|
setOtpModalOpen(false);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("handleSubmitOtp error:", err);
|
console.error("handleSubmitOtp error:", err);
|
||||||
@@ -548,13 +390,15 @@ export function DdmaEligibilityButton({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Render ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
variant="default"
|
variant="default"
|
||||||
disabled={isFormIncomplete || isStarting}
|
disabled={isFormIncomplete || isStarting}
|
||||||
onClick={startDdmaEligibility}
|
onClick={handleDdmaStart}
|
||||||
>
|
>
|
||||||
{isStarting ? (
|
{isStarting ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -12,9 +12,22 @@ import os
|
|||||||
import time
|
import time
|
||||||
import helpers_ddma_eligibility as hddma
|
import helpers_ddma_eligibility as hddma
|
||||||
|
|
||||||
|
# Import startup session-clear functions
|
||||||
|
from ddma_browser_manager import clear_ddma_session_on_startup
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
# Clear DDMA session on startup so fresh login is required after PC restart.
|
||||||
|
# Device trust tokens are preserved so OTP is still skipped after first login.
|
||||||
|
print("=" * 50)
|
||||||
|
print("SELENIUM AGENT STARTING - CLEARING DDMA SESSION")
|
||||||
|
print("=" * 50)
|
||||||
|
clear_ddma_session_on_startup()
|
||||||
|
print("=" * 50)
|
||||||
|
print("SESSION CLEAR COMPLETE")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
# Allow 1 selenium session at a time
|
# Allow 1 selenium session at a time
|
||||||
@@ -264,6 +277,21 @@ async def session_status(sid: str):
|
|||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
# ── Session management endpoints ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.post("/clear-ddma-session")
|
||||||
|
async def clear_ddma_session_endpoint():
|
||||||
|
"""
|
||||||
|
Clears the DDMA browser session (cookies + cached credentials).
|
||||||
|
Call this when DDMA credentials are deleted or changed.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
clear_ddma_session_on_startup()
|
||||||
|
return {"status": "success", "message": "DDMA session cleared"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "error", "message": str(e)}
|
||||||
|
|
||||||
|
|
||||||
# ✅ Health Check Endpoint
|
# ✅ Health Check Endpoint
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def health_check():
|
async def health_check():
|
||||||
|
|||||||
@@ -1,19 +1,30 @@
|
|||||||
"""
|
"""
|
||||||
Minimal browser manager for DDMA - only handles persistent profile and keeping browser alive.
|
Browser manager for DDMA (Delta Dental MA) - persistent profile with session management.
|
||||||
Does NOT modify any login/OTP logic.
|
- 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 os
|
||||||
|
import glob
|
||||||
|
import shutil
|
||||||
|
import hashlib
|
||||||
import threading
|
import threading
|
||||||
from selenium import webdriver
|
from selenium import webdriver
|
||||||
from selenium.webdriver.chrome.service import Service
|
from selenium.webdriver.chrome.service import Service
|
||||||
from webdriver_manager.chrome import ChromeDriverManager
|
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 DDMABrowserManager:
|
class DDMABrowserManager:
|
||||||
"""
|
"""
|
||||||
Singleton that manages a persistent Chrome browser instance.
|
Singleton that manages a persistent Chrome browser instance.
|
||||||
- Uses --user-data-dir for persistent profile (device trust tokens, cookies)
|
- Uses --user-data-dir for persistent profile (device trust tokens)
|
||||||
- Keeps browser alive between patient runs
|
- Clears session cookies on startup (after PC restart)
|
||||||
|
- Tracks credentials to detect changes mid-session
|
||||||
"""
|
"""
|
||||||
_instance = None
|
_instance = None
|
||||||
_lock = threading.Lock()
|
_lock = threading.Lock()
|
||||||
@@ -25,55 +36,223 @@ class DDMABrowserManager:
|
|||||||
cls._instance._driver = None
|
cls._instance._driver = None
|
||||||
cls._instance.profile_dir = os.path.abspath("chrome_profile_ddma")
|
cls._instance.profile_dir = os.path.abspath("chrome_profile_ddma")
|
||||||
cls._instance.download_dir = os.path.abspath("seleniumDownloads")
|
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.profile_dir, exist_ok=True)
|
||||||
os.makedirs(cls._instance.download_dir, exist_ok=True)
|
os.makedirs(cls._instance.download_dir, exist_ok=True)
|
||||||
return cls._instance
|
return cls._instance
|
||||||
|
|
||||||
|
def clear_session_on_startup(self):
|
||||||
|
"""
|
||||||
|
Clear session cookies from Chrome profile on startup.
|
||||||
|
This forces a fresh login after PC restart.
|
||||||
|
Preserves device trust tokens (LocalStorage, IndexedDB) to avoid OTPs.
|
||||||
|
"""
|
||||||
|
print("[DDMA BrowserManager] Clearing session on startup...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Clear credentials tracking file
|
||||||
|
if os.path.exists(self._credentials_file):
|
||||||
|
os.remove(self._credentials_file)
|
||||||
|
print("[DDMA BrowserManager] Cleared credentials tracking file")
|
||||||
|
|
||||||
|
# Clear session-related Chrome profile files
|
||||||
|
session_files = [
|
||||||
|
"Cookies",
|
||||||
|
"Cookies-journal",
|
||||||
|
"Login Data",
|
||||||
|
"Login Data-journal",
|
||||||
|
"Web Data",
|
||||||
|
"Web 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"[DDMA BrowserManager] Removed {filename}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DDMA BrowserManager] Could not remove {filename}: {e}")
|
||||||
|
|
||||||
|
# Clear Session Storage (contains login state)
|
||||||
|
session_storage_dir = os.path.join(self.profile_dir, "Default", "Session Storage")
|
||||||
|
if os.path.exists(session_storage_dir):
|
||||||
|
try:
|
||||||
|
shutil.rmtree(session_storage_dir)
|
||||||
|
print("[DDMA BrowserManager] Cleared Session Storage")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DDMA BrowserManager] Could not clear Session Storage: {e}")
|
||||||
|
|
||||||
|
# Clear Local Storage (may contain auth tokens)
|
||||||
|
local_storage_dir = os.path.join(self.profile_dir, "Default", "Local Storage")
|
||||||
|
if os.path.exists(local_storage_dir):
|
||||||
|
try:
|
||||||
|
shutil.rmtree(local_storage_dir)
|
||||||
|
print("[DDMA BrowserManager] Cleared Local Storage")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DDMA BrowserManager] Could not clear Local Storage: {e}")
|
||||||
|
|
||||||
|
# Clear IndexedDB (may contain auth tokens)
|
||||||
|
indexeddb_dir = os.path.join(self.profile_dir, "Default", "IndexedDB")
|
||||||
|
if os.path.exists(indexeddb_dir):
|
||||||
|
try:
|
||||||
|
shutil.rmtree(indexeddb_dir)
|
||||||
|
print("[DDMA BrowserManager] Cleared IndexedDB")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DDMA BrowserManager] Could not clear IndexedDB: {e}")
|
||||||
|
|
||||||
|
# Clear browser caches
|
||||||
|
cache_dirs = [
|
||||||
|
os.path.join(self.profile_dir, "Default", "Cache"),
|
||||||
|
os.path.join(self.profile_dir, "Default", "Code Cache"),
|
||||||
|
os.path.join(self.profile_dir, "Default", "GPUCache"),
|
||||||
|
os.path.join(self.profile_dir, "Default", "Service Worker"),
|
||||||
|
os.path.join(self.profile_dir, "Cache"),
|
||||||
|
os.path.join(self.profile_dir, "Code Cache"),
|
||||||
|
os.path.join(self.profile_dir, "GPUCache"),
|
||||||
|
os.path.join(self.profile_dir, "Service Worker"),
|
||||||
|
os.path.join(self.profile_dir, "ShaderCache"),
|
||||||
|
]
|
||||||
|
for cache_dir in cache_dirs:
|
||||||
|
if os.path.exists(cache_dir):
|
||||||
|
try:
|
||||||
|
shutil.rmtree(cache_dir)
|
||||||
|
print(f"[DDMA BrowserManager] Cleared {os.path.basename(cache_dir)}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DDMA BrowserManager] Could not clear {os.path.basename(cache_dir)}: {e}")
|
||||||
|
|
||||||
|
self._needs_session_clear = True
|
||||||
|
print("[DDMA BrowserManager] Session cleared - will require fresh login")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DDMA 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"[DDMA 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("[DDMA 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"[DDMA 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):
|
def get_driver(self, headless=False):
|
||||||
"""Get or create the persistent browser instance."""
|
"""Get or create the persistent browser instance."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if self._driver is None:
|
if self._driver is None:
|
||||||
print("[BrowserManager] Driver is None, creating new driver")
|
print("[DDMA BrowserManager] Driver is None, creating new driver")
|
||||||
|
self._kill_existing_chrome_for_profile()
|
||||||
self._create_driver(headless)
|
self._create_driver(headless)
|
||||||
elif not self._is_alive():
|
elif not self._is_alive():
|
||||||
print("[BrowserManager] Driver not alive, recreating")
|
print("[DDMA BrowserManager] Driver not alive, recreating")
|
||||||
|
self._kill_existing_chrome_for_profile()
|
||||||
self._create_driver(headless)
|
self._create_driver(headless)
|
||||||
else:
|
else:
|
||||||
print("[BrowserManager] Reusing existing driver")
|
print("[DDMA BrowserManager] Reusing existing driver")
|
||||||
return self._driver
|
return self._driver
|
||||||
|
|
||||||
def _is_alive(self):
|
def _is_alive(self):
|
||||||
"""Check if browser is still responsive."""
|
|
||||||
try:
|
try:
|
||||||
|
if self._driver is None:
|
||||||
|
return False
|
||||||
url = self._driver.current_url
|
url = self._driver.current_url
|
||||||
print(f"[BrowserManager] Driver alive, current URL: {url[:50]}...")
|
print(f"[DDMA BrowserManager] Driver alive, current URL: {url[:50]}...")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[BrowserManager] Driver not alive: {e}")
|
print(f"[DDMA BrowserManager] Driver not alive: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _create_driver(self, headless=False):
|
def _create_driver(self, headless=False):
|
||||||
"""Create browser with persistent profile."""
|
"""Create browser with persistent profile and anti-detection options."""
|
||||||
if self._driver:
|
if self._driver:
|
||||||
try:
|
try:
|
||||||
self._driver.quit()
|
self._driver.quit()
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
options = webdriver.ChromeOptions()
|
options = webdriver.ChromeOptions()
|
||||||
if headless:
|
if headless:
|
||||||
options.add_argument("--headless")
|
options.add_argument("--headless")
|
||||||
|
|
||||||
# Persistent profile - THIS IS THE KEY for device trust
|
# Persistent profile — keeps device trust tokens between runs
|
||||||
options.add_argument(f"--user-data-dir={self.profile_dir}")
|
options.add_argument(f"--user-data-dir={self.profile_dir}")
|
||||||
options.add_argument("--no-sandbox")
|
options.add_argument("--no-sandbox")
|
||||||
options.add_argument("--disable-dev-shm-usage")
|
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 = {
|
prefs = {
|
||||||
"download.default_directory": self.download_dir,
|
"download.default_directory": self.download_dir,
|
||||||
"plugins.always_open_pdf_externally": True,
|
"plugins.always_open_pdf_externally": True,
|
||||||
"download.prompt_for_download": False,
|
"download.prompt_for_download": False,
|
||||||
"download.directory_upgrade": True
|
"download.directory_upgrade": True,
|
||||||
}
|
}
|
||||||
options.add_experimental_option("prefs", prefs)
|
options.add_experimental_option("prefs", prefs)
|
||||||
|
|
||||||
@@ -81,22 +260,39 @@ class DDMABrowserManager:
|
|||||||
self._driver = webdriver.Chrome(service=service, options=options)
|
self._driver = webdriver.Chrome(service=service, options=options)
|
||||||
self._driver.maximize_window()
|
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):
|
def quit_driver(self):
|
||||||
"""Quit browser (only call on shutdown)."""
|
"""Quit browser (only call on shutdown — NOT between patients)."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if self._driver:
|
if self._driver:
|
||||||
try:
|
try:
|
||||||
self._driver.quit()
|
self._driver.quit()
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self._driver = None
|
self._driver = None
|
||||||
|
|
||||||
|
|
||||||
# Singleton accessor
|
# ── Singleton accessor ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
_manager = None
|
_manager = None
|
||||||
|
|
||||||
def get_browser_manager():
|
def get_browser_manager() -> DDMABrowserManager:
|
||||||
global _manager
|
global _manager
|
||||||
if _manager is None:
|
if _manager is None:
|
||||||
_manager = DDMABrowserManager()
|
_manager = DDMABrowserManager()
|
||||||
return _manager
|
return _manager
|
||||||
|
|
||||||
|
|
||||||
|
def clear_ddma_session_on_startup():
|
||||||
|
"""Called by agent.py on startup to clear DDMA session (after PC restart)."""
|
||||||
|
manager = get_browser_manager()
|
||||||
|
manager.clear_session_on_startup()
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from typing import Dict, Any
|
|||||||
from selenium.webdriver.common.by import By
|
from selenium.webdriver.common.by import By
|
||||||
from selenium.webdriver.support.ui import WebDriverWait
|
from selenium.webdriver.support.ui import WebDriverWait
|
||||||
from selenium.webdriver.support import expected_conditions as EC
|
from selenium.webdriver.support import expected_conditions as EC
|
||||||
from selenium.common.exceptions import WebDriverException
|
from selenium.common.exceptions import WebDriverException, TimeoutException
|
||||||
|
|
||||||
from selenium_DDMA_eligibilityCheckWorker import AutomationDeltaDentalMAEligibilityCheck
|
from selenium_DDMA_eligibilityCheckWorker import AutomationDeltaDentalMAEligibilityCheck
|
||||||
|
|
||||||
@@ -20,13 +20,13 @@ def make_session_entry() -> str:
|
|||||||
import uuid
|
import uuid
|
||||||
sid = str(uuid.uuid4())
|
sid = str(uuid.uuid4())
|
||||||
sessions[sid] = {
|
sessions[sid] = {
|
||||||
"status": "created", # created -> running -> waiting_for_otp -> otp_submitted -> completed / error
|
"status": "created", # created → running → waiting_for_otp → completed / error
|
||||||
"created_at": time.time(),
|
"created_at": time.time(),
|
||||||
"last_activity": time.time(),
|
"last_activity": time.time(),
|
||||||
"bot": None, # worker instance
|
"bot": None, # AutomationDeltaDentalMAEligibilityCheck instance
|
||||||
"driver": None, # selenium webdriver
|
"driver": None, # selenium webdriver
|
||||||
"otp_event": asyncio.Event(),
|
"otp_event": asyncio.Event(),
|
||||||
"otp_value": None,
|
"otp_value": None, # OTP submitted from the app
|
||||||
"result": None,
|
"result": None,
|
||||||
"message": None,
|
"message": None,
|
||||||
"type": None,
|
"type": None,
|
||||||
@@ -36,37 +36,26 @@ def make_session_entry() -> str:
|
|||||||
|
|
||||||
async def cleanup_session(sid: str, message: str | None = None):
|
async def cleanup_session(sid: str, message: str | None = None):
|
||||||
"""
|
"""
|
||||||
Close driver (if any), wake OTP waiter, set final state, and remove session entry.
|
Wake any OTP waiter, set final state, and remove the session entry.
|
||||||
Idempotent: safe to call multiple times.
|
Safe to call multiple times (idempotent).
|
||||||
|
NOTE: Does NOT quit the browser driver — the persistent browser stays alive.
|
||||||
"""
|
"""
|
||||||
s = sessions.get(sid)
|
s = sessions.get(sid)
|
||||||
if not s:
|
if not s:
|
||||||
return
|
return
|
||||||
try:
|
|
||||||
# Ensure final state
|
|
||||||
try:
|
try:
|
||||||
if s.get("status") not in ("completed", "error", "not_found"):
|
if s.get("status") not in ("completed", "error", "not_found"):
|
||||||
s["status"] = "error"
|
s["status"] = "error"
|
||||||
if message:
|
if message:
|
||||||
s["message"] = message
|
s["message"] = message
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Wake any OTP waiter (so awaiting coroutines don't hang)
|
|
||||||
try:
|
|
||||||
ev = s.get("otp_event")
|
ev = s.get("otp_event")
|
||||||
if ev and not ev.is_set():
|
if ev and not ev.is_set():
|
||||||
ev.set()
|
ev.set()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# NOTE: Do NOT quit driver - keep browser alive for next patient
|
|
||||||
# Browser manager handles the persistent browser instance
|
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
# Remove session entry from map
|
|
||||||
sessions.pop(sid, None)
|
sessions.pop(sid, None)
|
||||||
print(f"[helpers] cleaned session {sid}")
|
print(f"[helpers_ddma] cleaned session {sid}")
|
||||||
|
|
||||||
|
|
||||||
async def _remove_session_later(sid: str, delay: int = 20):
|
async def _remove_session_later(sid: str, delay: int = 20):
|
||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
@@ -75,8 +64,12 @@ async def _remove_session_later(sid: str, delay: int = 20):
|
|||||||
|
|
||||||
async def start_ddma_run(sid: str, data: dict, url: str):
|
async def start_ddma_run(sid: str, data: dict, url: str):
|
||||||
"""
|
"""
|
||||||
Run the DDMA workflow for a session (WITHOUT managing semaphore/counters).
|
Run the full DDMA eligibility workflow for one session.
|
||||||
Called by agent.py inside a wrapper that handles queue/counters.
|
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)
|
s = sessions.get(sid)
|
||||||
if not s:
|
if not s:
|
||||||
@@ -93,7 +86,7 @@ async def start_ddma_run(sid: str, data: dict, url: str):
|
|||||||
s["driver"] = bot.driver
|
s["driver"] = bot.driver
|
||||||
s["last_activity"] = time.time()
|
s["last_activity"] = time.time()
|
||||||
|
|
||||||
# Navigate to login URL
|
# Navigate to login page
|
||||||
try:
|
try:
|
||||||
if not url:
|
if not url:
|
||||||
raise ValueError("URL not provided for DDMA run")
|
raise ValueError("URL not provided for DDMA run")
|
||||||
@@ -120,89 +113,130 @@ async def start_ddma_run(sid: str, data: dict, url: str):
|
|||||||
await cleanup_session(sid, s["message"])
|
await cleanup_session(sid, s["message"])
|
||||||
return {"status": "error", "message": s["message"]}
|
return {"status": "error", "message": s["message"]}
|
||||||
|
|
||||||
# Already logged in - session persisted from profile, skip to step1
|
# ── Path: already logged in (persistent session) ──────────────────────
|
||||||
if isinstance(login_result, str) and login_result == "ALREADY_LOGGED_IN":
|
if isinstance(login_result, str) and login_result == "ALREADY_LOGGED_IN":
|
||||||
print("[start_ddma_run] Session persisted - skipping OTP")
|
print("[start_ddma_run] Session persisted - skipping OTP")
|
||||||
s["status"] = "running"
|
s["status"] = "running"
|
||||||
s["message"] = "Session persisted"
|
s["message"] = "Session persisted"
|
||||||
# Continue to step1 below
|
|
||||||
|
|
||||||
# OTP required path
|
# ── Path: OTP required ────────────────────────────────────────────────
|
||||||
elif isinstance(login_result, str) and login_result == "OTP_REQUIRED":
|
elif isinstance(login_result, str) and login_result == "OTP_REQUIRED":
|
||||||
s["status"] = "waiting_for_otp"
|
s["status"] = "waiting_for_otp"
|
||||||
s["message"] = "OTP required for login"
|
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()
|
s["last_activity"] = time.time()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(s["otp_event"].wait(), timeout=SESSION_OTP_TIMEOUT)
|
# a) App submitted OTP via /submit-otp endpoint
|
||||||
except asyncio.TimeoutError:
|
|
||||||
s["status"] = "error"
|
|
||||||
s["message"] = "OTP timeout"
|
|
||||||
await cleanup_session(sid)
|
|
||||||
return {"status": "error", "message": "OTP not provided in time"}
|
|
||||||
|
|
||||||
otp_value = s.get("otp_value")
|
otp_value = s.get("otp_value")
|
||||||
if not otp_value:
|
if otp_value:
|
||||||
s["status"] = "error"
|
print(f"[OTP poll {poll+1}] OTP received from app, typing it in...")
|
||||||
s["message"] = "OTP missing after event"
|
|
||||||
await cleanup_session(sid)
|
|
||||||
return {"status": "error", "message": "OTP missing after event"}
|
|
||||||
|
|
||||||
# Submit OTP - check if it's in a popup window
|
|
||||||
try:
|
try:
|
||||||
driver = s["driver"]
|
otp_input = driver.find_element(By.XPATH,
|
||||||
wait = WebDriverWait(driver, 30)
|
"//input[contains(@aria-label,'Verification') or contains(@placeholder,'verification') or @type='tel' or contains(@aria-lable,'Verification code') or contains(@placeholder,'Enter your verification code')]"
|
||||||
|
|
||||||
# Check if there's a popup window and switch to it
|
|
||||||
original_window = driver.current_window_handle
|
|
||||||
all_windows = driver.window_handles
|
|
||||||
if len(all_windows) > 1:
|
|
||||||
for window in all_windows:
|
|
||||||
if window != original_window:
|
|
||||||
driver.switch_to.window(window)
|
|
||||||
print(f"[OTP] Switched to popup window for OTP entry")
|
|
||||||
break
|
|
||||||
|
|
||||||
otp_input = wait.until(
|
|
||||||
EC.presence_of_element_located(
|
|
||||||
(By.XPATH, "//input[contains(@aria-lable,'Verification code') or contains(@placeholder,'Enter your verification code')]")
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
otp_input.clear()
|
otp_input.clear()
|
||||||
otp_input.send_keys(otp_value)
|
otp_input.send_keys(otp_value)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
submit_btn = wait.until(
|
verify_btn = driver.find_element(By.XPATH, "//button[@type='button' and @aria-label='Verify']")
|
||||||
EC.element_to_be_clickable(
|
verify_btn.click()
|
||||||
(By.XPATH, "//button[@type='button' and @aria-label='Verify']")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
submit_btn.click()
|
|
||||||
except Exception:
|
except Exception:
|
||||||
otp_input.send_keys("\n")
|
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}")
|
||||||
|
|
||||||
# Wait for verification and switch back to main window if needed
|
# 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, '//input[@placeholder="Search by member ID"]'))
|
||||||
|
)
|
||||||
|
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[contains(@aria-label,'Verification') or contains(@placeholder,'verification') or @type='tel']"
|
||||||
|
)
|
||||||
|
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 "onboarding" in current_url or "start" in current_url:
|
||||||
|
print("[OTP] OTP input gone, trying to navigate to members page...")
|
||||||
|
try:
|
||||||
|
driver.get("https://providers.deltadentalma.com/members")
|
||||||
await asyncio.sleep(2)
|
await asyncio.sleep(2)
|
||||||
if len(driver.window_handles) > 0:
|
except Exception:
|
||||||
driver.switch_to.window(driver.window_handles[0])
|
pass
|
||||||
|
|
||||||
s["status"] = "otp_submitted"
|
except Exception as poll_err:
|
||||||
s["last_activity"] = time.time()
|
print(f"[OTP poll {poll+1}] Error: {poll_err}")
|
||||||
await asyncio.sleep(0.5)
|
|
||||||
|
|
||||||
except Exception as e:
|
if not login_success:
|
||||||
|
# Final check — navigate directly to members page
|
||||||
|
try:
|
||||||
|
print("[OTP] Final attempt - navigating to members page...")
|
||||||
|
driver.get("https://providers.deltadentalma.com/members")
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
member_search = WebDriverWait(driver, 10).until(
|
||||||
|
EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]'))
|
||||||
|
)
|
||||||
|
print("[OTP] Member search found — login successful!")
|
||||||
|
login_success = True
|
||||||
|
except TimeoutException:
|
||||||
s["status"] = "error"
|
s["status"] = "error"
|
||||||
s["message"] = f"Failed to submit OTP into page: {e}"
|
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)
|
await cleanup_session(sid)
|
||||||
return {"status": "error", "message": s["message"]}
|
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_ddma_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"):
|
elif isinstance(login_result, str) and login_result.startswith("ERROR"):
|
||||||
s["status"] = "error"
|
s["status"] = "error"
|
||||||
s["message"] = login_result
|
s["message"] = login_result
|
||||||
await cleanup_session(sid)
|
await cleanup_session(sid)
|
||||||
return {"status": "error", "message": login_result}
|
return {"status": "error", "message": login_result}
|
||||||
|
|
||||||
# Step 1
|
# ── Step 1: search ────────────────────────────────────────────────────
|
||||||
step1_result = bot.step1()
|
step1_result = bot.step1()
|
||||||
if isinstance(step1_result, str) and step1_result.startswith("ERROR"):
|
if isinstance(step1_result, str) and step1_result.startswith("ERROR"):
|
||||||
s["status"] = "error"
|
s["status"] = "error"
|
||||||
@@ -210,7 +244,7 @@ async def start_ddma_run(sid: str, data: dict, url: str):
|
|||||||
await cleanup_session(sid)
|
await cleanup_session(sid)
|
||||||
return {"status": "error", "message": step1_result}
|
return {"status": "error", "message": step1_result}
|
||||||
|
|
||||||
# Step 2 (PDF)
|
# ── Step 2: PDF generation ────────────────────────────────────────────
|
||||||
step2_result = bot.step2()
|
step2_result = bot.step2()
|
||||||
if isinstance(step2_result, dict) and step2_result.get("status") == "success":
|
if isinstance(step2_result, dict) and step2_result.get("status") == "success":
|
||||||
s["status"] = "completed"
|
s["status"] = "completed"
|
||||||
@@ -235,12 +269,15 @@ async def start_ddma_run(sid: str, data: dict, url: str):
|
|||||||
|
|
||||||
|
|
||||||
def submit_otp(sid: str, otp: str) -> Dict[str, Any]:
|
def submit_otp(sid: str, otp: str) -> Dict[str, Any]:
|
||||||
"""Set OTP for a session and wake waiting runner."""
|
"""
|
||||||
|
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)
|
s = sessions.get(sid)
|
||||||
if not s:
|
if not s:
|
||||||
return {"status": "error", "message": "session not found"}
|
return {"status": "error", "message": "session not found"}
|
||||||
if s.get("status") != "waiting_for_otp":
|
if s.get("status") != "waiting_for_otp":
|
||||||
return {"status": "error", "message": f"session not waiting for otp (state={s.get('status')})"}
|
return {"status": "error", "message": f"session not waiting for OTP (state={s.get('status')})"}
|
||||||
s["otp_value"] = otp
|
s["otp_value"] = otp
|
||||||
s["last_activity"] = time.time()
|
s["last_activity"] = time.time()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
from selenium.common.exceptions import TimeoutException
|
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.by import By
|
||||||
from selenium.webdriver.common.keys import Keys
|
from selenium.webdriver.common.keys import Keys
|
||||||
from selenium.webdriver.support.ui import WebDriverWait
|
from selenium.webdriver.support.ui import WebDriverWait
|
||||||
from selenium.webdriver.support import expected_conditions as EC
|
from selenium.webdriver.support import expected_conditions as EC
|
||||||
|
from webdriver_manager.chrome import ChromeDriverManager
|
||||||
import time
|
import time
|
||||||
import os
|
import os
|
||||||
import base64
|
import base64
|
||||||
@@ -16,10 +19,11 @@ class AutomationDeltaDentalMAEligibilityCheck:
|
|||||||
|
|
||||||
self.data = data.get("data", {}) if isinstance(data, dict) else {}
|
self.data = data.get("data", {}) if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
# Flatten values for convenience
|
# Flatten values for convenience
|
||||||
self.memberId = self.data.get("memberId", "")
|
self.memberId = self.data.get("memberId", "")
|
||||||
self.dateOfBirth = self.data.get("dateOfBirth", "")
|
self.dateOfBirth = self.data.get("dateOfBirth", "")
|
||||||
|
self.firstName = self.data.get("firstName", "")
|
||||||
|
self.lastName = self.data.get("lastName", "")
|
||||||
self.massddma_username = self.data.get("massddmaUsername", "")
|
self.massddma_username = self.data.get("massddmaUsername", "")
|
||||||
self.massddma_password = self.data.get("massddmaPassword", "")
|
self.massddma_password = self.data.get("massddmaPassword", "")
|
||||||
|
|
||||||
@@ -31,23 +35,74 @@ class AutomationDeltaDentalMAEligibilityCheck:
|
|||||||
# Use persistent browser from manager (keeps device trust tokens)
|
# Use persistent browser from manager (keeps device trust tokens)
|
||||||
self.driver = get_browser_manager().get_driver(self.headless)
|
self.driver = get_browser_manager().get_driver(self.headless)
|
||||||
|
|
||||||
|
def _force_logout(self):
|
||||||
|
"""Force logout by clearing cookies when credentials change."""
|
||||||
|
try:
|
||||||
|
print("[DDMA login] Forcing logout due to credential change...")
|
||||||
|
browser_manager = get_browser_manager()
|
||||||
|
|
||||||
|
# Try to click logout button if visible
|
||||||
|
try:
|
||||||
|
self.driver.get("https://providers.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("[DDMA login] Clicked logout button")
|
||||||
|
time.sleep(2)
|
||||||
|
break
|
||||||
|
except TimeoutException:
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DDMA login] Could not click logout button: {e}")
|
||||||
|
|
||||||
|
# Clear cookies as backup
|
||||||
|
try:
|
||||||
|
self.driver.delete_all_cookies()
|
||||||
|
print("[DDMA login] Cleared all cookies")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DDMA login] Error clearing cookies: {e}")
|
||||||
|
|
||||||
|
browser_manager.clear_credentials_hash()
|
||||||
|
print("[DDMA login] Logout complete")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DDMA login] Error during forced logout: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
def login(self, url):
|
def login(self, url):
|
||||||
wait = WebDriverWait(self.driver, 30)
|
wait = WebDriverWait(self.driver, 30)
|
||||||
|
browser_manager = get_browser_manager()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# First check if we're already on a logged-in page (from previous run)
|
# Check if credentials changed — force logout first
|
||||||
|
if self.massddma_username and browser_manager.credentials_changed(self.massddma_username):
|
||||||
|
self._force_logout()
|
||||||
|
self.driver.get(url)
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
# Check if already on a logged-in page (persistent session from profile)
|
||||||
try:
|
try:
|
||||||
current_url = self.driver.current_url
|
current_url = self.driver.current_url
|
||||||
print(f"[login] Current URL: {current_url}")
|
print(f"[login] Current URL: {current_url}")
|
||||||
|
|
||||||
# Check if we're on any logged-in page (dashboard, member pages, etc.)
|
|
||||||
logged_in_patterns = ["member", "dashboard", "eligibility", "search", "patients"]
|
logged_in_patterns = ["member", "dashboard", "eligibility", "search", "patients"]
|
||||||
is_logged_in_url = any(pattern in current_url.lower() for pattern in logged_in_patterns)
|
is_logged_in_url = any(pattern in current_url.lower() for pattern in logged_in_patterns)
|
||||||
|
|
||||||
if is_logged_in_url and "onboarding" not in current_url.lower():
|
if is_logged_in_url and "onboarding" not in current_url.lower():
|
||||||
print(f"[login] Already on logged-in page - skipping login entirely")
|
print("[login] Already on logged-in page - skipping login entirely")
|
||||||
# Navigate directly to member search if not already there
|
|
||||||
if "member" not in current_url.lower():
|
if "member" not in current_url.lower():
|
||||||
# Try to find a link to member search or just check for search input
|
|
||||||
try:
|
try:
|
||||||
member_search = WebDriverWait(self.driver, 5).until(
|
member_search = WebDriverWait(self.driver, 5).until(
|
||||||
EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]'))
|
EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]'))
|
||||||
@@ -55,13 +110,11 @@ class AutomationDeltaDentalMAEligibilityCheck:
|
|||||||
print("[login] Found member search input - returning ALREADY_LOGGED_IN")
|
print("[login] Found member search input - returning ALREADY_LOGGED_IN")
|
||||||
return "ALREADY_LOGGED_IN"
|
return "ALREADY_LOGGED_IN"
|
||||||
except TimeoutException:
|
except TimeoutException:
|
||||||
# Try navigating to members page
|
|
||||||
members_url = "https://providers.deltadentalma.com/members"
|
members_url = "https://providers.deltadentalma.com/members"
|
||||||
print(f"[login] Navigating to members page: {members_url}")
|
print(f"[login] Navigating to members page: {members_url}")
|
||||||
self.driver.get(members_url)
|
self.driver.get(members_url)
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
|
|
||||||
# Verify we have the member search input
|
|
||||||
try:
|
try:
|
||||||
member_search = WebDriverWait(self.driver, 5).until(
|
member_search = WebDriverWait(self.driver, 5).until(
|
||||||
EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]'))
|
EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]'))
|
||||||
@@ -75,9 +128,9 @@ class AutomationDeltaDentalMAEligibilityCheck:
|
|||||||
|
|
||||||
# Navigate to login URL
|
# Navigate to login URL
|
||||||
self.driver.get(url)
|
self.driver.get(url)
|
||||||
time.sleep(2) # Wait for page to load and any redirects
|
time.sleep(2)
|
||||||
|
|
||||||
# Check if we got redirected to member search (session still valid)
|
# Check if session redirected us straight to member search
|
||||||
try:
|
try:
|
||||||
current_url = self.driver.current_url
|
current_url = self.driver.current_url
|
||||||
print(f"[login] URL after navigation: {current_url}")
|
print(f"[login] URL after navigation: {current_url}")
|
||||||
@@ -103,20 +156,17 @@ class AutomationDeltaDentalMAEligibilityCheck:
|
|||||||
modal_dismissed = True
|
modal_dismissed = True
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
|
|
||||||
# Check if a popup window opened for authentication
|
|
||||||
all_windows = self.driver.window_handles
|
all_windows = self.driver.window_handles
|
||||||
print(f"[login] Windows after modal dismiss: {len(all_windows)}")
|
print(f"[login] Windows after modal dismiss: {len(all_windows)}")
|
||||||
|
|
||||||
if len(all_windows) > 1:
|
if len(all_windows) > 1:
|
||||||
# Switch to the auth popup
|
|
||||||
original_window = self.driver.current_window_handle
|
original_window = self.driver.current_window_handle
|
||||||
for window in all_windows:
|
for window in all_windows:
|
||||||
if window != original_window:
|
if window != original_window:
|
||||||
self.driver.switch_to.window(window)
|
self.driver.switch_to.window(window)
|
||||||
print(f"[login] Switched to auth popup window")
|
print("[login] Switched to auth popup window")
|
||||||
break
|
break
|
||||||
|
|
||||||
# Look for OTP input in the popup
|
|
||||||
try:
|
try:
|
||||||
otp_candidate = WebDriverWait(self.driver, 10).until(
|
otp_candidate = WebDriverWait(self.driver, 10).until(
|
||||||
EC.presence_of_element_located(
|
EC.presence_of_element_located(
|
||||||
@@ -133,10 +183,8 @@ class AutomationDeltaDentalMAEligibilityCheck:
|
|||||||
except TimeoutException:
|
except TimeoutException:
|
||||||
pass # No modal present
|
pass # No modal present
|
||||||
|
|
||||||
# If modal was dismissed but no popup, page might have changed - wait and check
|
|
||||||
if modal_dismissed:
|
if modal_dismissed:
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
# Check if we're now on member search page (already authenticated)
|
|
||||||
try:
|
try:
|
||||||
member_search = WebDriverWait(self.driver, 5).until(
|
member_search = WebDriverWait(self.driver, 5).until(
|
||||||
EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]'))
|
EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]'))
|
||||||
@@ -147,7 +195,7 @@ class AutomationDeltaDentalMAEligibilityCheck:
|
|||||||
except TimeoutException:
|
except TimeoutException:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Try to fill login form
|
# Fill login form
|
||||||
try:
|
try:
|
||||||
email_field = WebDriverWait(self.driver, 10).until(
|
email_field = WebDriverWait(self.driver, 10).until(
|
||||||
EC.element_to_be_clickable((By.XPATH, "//input[@name='username' and @type='text']"))
|
EC.element_to_be_clickable((By.XPATH, "//input[@name='username' and @type='text']"))
|
||||||
@@ -164,19 +212,23 @@ class AutomationDeltaDentalMAEligibilityCheck:
|
|||||||
password_field.clear()
|
password_field.clear()
|
||||||
password_field.send_keys(self.massddma_password)
|
password_field.send_keys(self.massddma_password)
|
||||||
|
|
||||||
# remember me
|
# Remember me
|
||||||
try:
|
try:
|
||||||
remember_me_checkbox = wait.until(EC.element_to_be_clickable(
|
remember_me_checkbox = wait.until(EC.element_to_be_clickable(
|
||||||
(By.XPATH, "//label[.//span[contains(text(),'Remember me')]]")
|
(By.XPATH, "//label[.//span[contains(text(),'Remember me')]]")
|
||||||
))
|
))
|
||||||
remember_me_checkbox.click()
|
remember_me_checkbox.click()
|
||||||
except:
|
except Exception:
|
||||||
print("[login] Remember me checkbox not found (continuing).")
|
print("[login] Remember me checkbox not found (continuing).")
|
||||||
|
|
||||||
login_button = wait.until(EC.element_to_be_clickable((By.XPATH, "//button[@type='submit' and @aria-label='Sign in']")))
|
login_button = wait.until(EC.element_to_be_clickable((By.XPATH, "//button[@type='submit' and @aria-label='Sign in']")))
|
||||||
login_button.click()
|
login_button.click()
|
||||||
|
|
||||||
# OTP detection
|
# Save credentials hash after login attempt
|
||||||
|
if self.massddma_username:
|
||||||
|
browser_manager.save_credentials_hash(self.massddma_username)
|
||||||
|
|
||||||
|
# OTP detection — wait up to 30 seconds for OTP input
|
||||||
try:
|
try:
|
||||||
otp_candidate = WebDriverWait(self.driver, 30).until(
|
otp_candidate = WebDriverWait(self.driver, 30).until(
|
||||||
EC.presence_of_element_located(
|
EC.presence_of_element_located(
|
||||||
@@ -188,153 +240,373 @@ class AutomationDeltaDentalMAEligibilityCheck:
|
|||||||
return "OTP_REQUIRED"
|
return "OTP_REQUIRED"
|
||||||
except TimeoutException:
|
except TimeoutException:
|
||||||
print("[login] No OTP input detected in allowed time.")
|
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, '//input[@placeholder="Search by member ID"]'))
|
||||||
|
)
|
||||||
|
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 "onboarding" in self.driver.current_url.lower() or "login" 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:
|
except Exception as e:
|
||||||
print("[login] Exception during login:", e)
|
print("[login] Exception during login:", e)
|
||||||
return f"ERROR:LOGIN FAILED: {e}"
|
return f"ERROR:LOGIN FAILED: {e}"
|
||||||
|
|
||||||
def step1(self):
|
def step1(self):
|
||||||
|
"""Fill search form with all available fields (flexible search)."""
|
||||||
wait = WebDriverWait(self.driver, 30)
|
wait = WebDriverWait(self.driver, 30)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Fill Member ID
|
fields = []
|
||||||
member_id_input = wait.until(EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]')))
|
if self.memberId:
|
||||||
|
fields.append(f"ID: {self.memberId}")
|
||||||
|
if self.firstName:
|
||||||
|
fields.append(f"FirstName: {self.firstName}")
|
||||||
|
if self.lastName:
|
||||||
|
fields.append(f"LastName: {self.lastName}")
|
||||||
|
if self.dateOfBirth:
|
||||||
|
fields.append(f"DOB: {self.dateOfBirth}")
|
||||||
|
print(f"[DDMA step1] Starting search with: {', '.join(fields)}")
|
||||||
|
|
||||||
|
def replace_with_sendkeys(el, value):
|
||||||
|
el.click()
|
||||||
|
el.send_keys(Keys.CONTROL, "a")
|
||||||
|
el.send_keys(Keys.BACKSPACE)
|
||||||
|
el.send_keys(value)
|
||||||
|
|
||||||
|
# 1. Fill Member ID if provided
|
||||||
|
if self.memberId:
|
||||||
|
try:
|
||||||
|
member_id_input = wait.until(EC.presence_of_element_located(
|
||||||
|
(By.XPATH, '//input[@placeholder="Search by member ID"]')
|
||||||
|
))
|
||||||
member_id_input.clear()
|
member_id_input.clear()
|
||||||
member_id_input.send_keys(self.memberId)
|
member_id_input.send_keys(self.memberId)
|
||||||
|
print(f"[DDMA step1] Entered Member ID: {self.memberId}")
|
||||||
|
time.sleep(0.2)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DDMA step1] Warning: Could not fill Member ID: {e}")
|
||||||
|
|
||||||
# Fill DOB parts
|
# 2. Fill DOB if provided
|
||||||
|
if self.dateOfBirth:
|
||||||
try:
|
try:
|
||||||
dob_parts = self.dateOfBirth.split("-")
|
dob_parts = self.dateOfBirth.split("-")
|
||||||
year = dob_parts[0] # "1964"
|
year = dob_parts[0]
|
||||||
month = dob_parts[1].zfill(2) # "04"
|
month = dob_parts[1].zfill(2)
|
||||||
day = dob_parts[2].zfill(2) # "17"
|
day = dob_parts[2].zfill(2)
|
||||||
except Exception as e:
|
|
||||||
print(f"Error parsing DOB: {e}")
|
|
||||||
return "ERROR: PARSING DOB"
|
|
||||||
|
|
||||||
# 1) locate the specific member DOB container
|
|
||||||
dob_container = wait.until(
|
dob_container = wait.until(
|
||||||
EC.presence_of_element_located(
|
EC.presence_of_element_located(
|
||||||
(By.XPATH, "//div[@data-testid='member-search_date-of-birth']")
|
(By.XPATH, "//div[@data-testid='member-search_date-of-birth']")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2) find the editable spans *inside that container* using relative XPaths
|
|
||||||
month_elem = dob_container.find_element(By.XPATH, ".//span[@data-type='month' and @contenteditable='true']")
|
month_elem = dob_container.find_element(By.XPATH, ".//span[@data-type='month' and @contenteditable='true']")
|
||||||
day_elem = dob_container.find_element(By.XPATH, ".//span[@data-type='day' and @contenteditable='true']")
|
day_elem = dob_container.find_element(By.XPATH, ".//span[@data-type='day' and @contenteditable='true']")
|
||||||
year_elem = dob_container.find_element(By.XPATH, ".//span[@data-type='year' and @contenteditable='true']")
|
year_elem = dob_container.find_element(By.XPATH, ".//span[@data-type='year' and @contenteditable='true']")
|
||||||
|
|
||||||
# Helper to click, select-all and type (pure send_keys approach)
|
|
||||||
def replace_with_sendkeys(el, value):
|
|
||||||
# focus (same as click)
|
|
||||||
el.click()
|
|
||||||
# select all (Ctrl+A) and delete (some apps pick up BACKSPACE better — we use BACKSPACE after select)
|
|
||||||
el.send_keys(Keys.CONTROL, "a")
|
|
||||||
el.send_keys(Keys.BACKSPACE)
|
|
||||||
# type the value
|
|
||||||
el.send_keys(value)
|
|
||||||
# optionally blur or tab out if app expects it
|
|
||||||
# el.send_keys(Keys.TAB)
|
|
||||||
|
|
||||||
replace_with_sendkeys(month_elem, month)
|
replace_with_sendkeys(month_elem, month)
|
||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
replace_with_sendkeys(day_elem, day)
|
replace_with_sendkeys(day_elem, day)
|
||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
replace_with_sendkeys(year_elem, year)
|
replace_with_sendkeys(year_elem, year)
|
||||||
|
print(f"[DDMA step1] Filled DOB: {month}/{day}/{year}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DDMA step1] Warning: Could not fill DOB: {e}")
|
||||||
|
|
||||||
|
# 3. Fill First Name if provided
|
||||||
# Click Continue button
|
if self.firstName:
|
||||||
continue_btn = wait.until(EC.element_to_be_clickable((By.XPATH, '//button[@data-testid="member-search_search-button"]')))
|
|
||||||
continue_btn.click()
|
|
||||||
|
|
||||||
# Check for error message
|
|
||||||
try:
|
try:
|
||||||
error_msg = WebDriverWait(self.driver, 5).until(EC.presence_of_element_located(
|
first_name_input = wait.until(EC.presence_of_element_located(
|
||||||
(By.XPATH, '//div[@data-testid="member-search-result-no-results"]')
|
(By.XPATH, '//input[@placeholder="First name - 1 char minimum" or contains(@placeholder,"first name") or contains(@name,"firstName")]')
|
||||||
))
|
))
|
||||||
if error_msg:
|
first_name_input.clear()
|
||||||
print("Error: Invalid Member ID or Date of Birth.")
|
first_name_input.send_keys(self.firstName)
|
||||||
return "ERROR: INVALID MEMBERID OR DOB"
|
print(f"[DDMA step1] Entered First Name: {self.firstName}")
|
||||||
|
time.sleep(0.2)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DDMA step1] Warning: Could not fill First Name: {e}")
|
||||||
|
|
||||||
|
# 4. Fill Last Name if provided
|
||||||
|
if self.lastName:
|
||||||
|
try:
|
||||||
|
last_name_input = wait.until(EC.presence_of_element_located(
|
||||||
|
(By.XPATH, '//input[@placeholder="Last name - 2 char minimum" or contains(@placeholder,"last name") or contains(@name,"lastName")]')
|
||||||
|
))
|
||||||
|
last_name_input.clear()
|
||||||
|
last_name_input.send_keys(self.lastName)
|
||||||
|
print(f"[DDMA step1] Entered Last Name: {self.lastName}")
|
||||||
|
time.sleep(0.2)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DDMA step1] Warning: Could not fill Last Name: {e}")
|
||||||
|
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
# Click Search button
|
||||||
|
continue_btn = wait.until(EC.element_to_be_clickable(
|
||||||
|
(By.XPATH, '//button[@data-testid="member-search_search-button"]')
|
||||||
|
))
|
||||||
|
continue_btn.click()
|
||||||
|
print("[DDMA step1] Clicked Search button")
|
||||||
|
|
||||||
|
# Wait for either results row or no-results message (up to 15s)
|
||||||
|
try:
|
||||||
|
WebDriverWait(self.driver, 15).until(
|
||||||
|
EC.any_of(
|
||||||
|
EC.presence_of_element_located((By.XPATH, "//tbody//tr")),
|
||||||
|
EC.presence_of_element_located((By.XPATH, '//div[@data-testid="member-search-result-no-results"]')),
|
||||||
|
)
|
||||||
|
)
|
||||||
except TimeoutException:
|
except TimeoutException:
|
||||||
|
pass # proceed and let step2 handle missing results
|
||||||
|
|
||||||
|
# Check for no-results error
|
||||||
|
try:
|
||||||
|
error_msg = self.driver.find_element(By.XPATH, '//div[@data-testid="member-search-result-no-results"]')
|
||||||
|
if error_msg:
|
||||||
|
print("[DDMA step1] Error: No results found")
|
||||||
|
return "ERROR: INVALID SEARCH CRITERIA"
|
||||||
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
print("[DDMA step1] Search completed successfully")
|
||||||
return "Success"
|
return "Success"
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error while step1 i.e Cheking the MemberId and DOB in: {e}")
|
print(f"[DDMA step1] Exception: {e}")
|
||||||
return "ERROR:STEP1"
|
return f"ERROR:STEP1 - {e}"
|
||||||
|
|
||||||
|
|
||||||
def step2(self):
|
def step2(self):
|
||||||
|
"""Navigate to patient detail page and generate PDF."""
|
||||||
wait = WebDriverWait(self.driver, 90)
|
wait = WebDriverWait(self.driver, 90)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 1) find the eligibility <a> inside the correct cell
|
import re
|
||||||
status_link = wait.until(EC.presence_of_element_located((
|
|
||||||
|
# Wait for results table
|
||||||
|
try:
|
||||||
|
WebDriverWait(self.driver, 10).until(
|
||||||
|
EC.presence_of_element_located((By.XPATH, "//tbody//tr"))
|
||||||
|
)
|
||||||
|
except TimeoutException:
|
||||||
|
print("[DDMA step2] Warning: Results table not found within timeout")
|
||||||
|
|
||||||
|
eligibilityText = "unknown"
|
||||||
|
foundMemberId = ""
|
||||||
|
patientName = ""
|
||||||
|
|
||||||
|
# Extract data from first result row
|
||||||
|
try:
|
||||||
|
first_row = self.driver.find_element(By.XPATH, "(//tbody//tr)[1]")
|
||||||
|
row_text = first_row.text.strip()
|
||||||
|
print(f"[DDMA step2] First row text: {row_text[:150]}...")
|
||||||
|
|
||||||
|
if row_text:
|
||||||
|
lines = row_text.split('\n')
|
||||||
|
|
||||||
|
# Extract patient name (first line, strip DOB if present)
|
||||||
|
if lines:
|
||||||
|
potential_name = lines[0].strip()
|
||||||
|
potential_name = re.sub(r'\s*DOB[:\s]*\d{1,2}/\d{1,2}/\d{2,4}\s*', '', potential_name, flags=re.IGNORECASE).strip()
|
||||||
|
if potential_name and not potential_name.startswith('DOB') and not potential_name.isdigit():
|
||||||
|
patientName = potential_name
|
||||||
|
print(f"[DDMA step2] Extracted patient name: '{patientName}'")
|
||||||
|
|
||||||
|
# Extract Member ID
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
if line and re.match(r'^[A-Z0-9]{5,}$', line) and not line.startswith('DOB'):
|
||||||
|
foundMemberId = line
|
||||||
|
print(f"[DDMA step2] Extracted Member ID: {foundMemberId}")
|
||||||
|
break
|
||||||
|
|
||||||
|
if not foundMemberId and self.memberId:
|
||||||
|
foundMemberId = self.memberId
|
||||||
|
print(f"[DDMA step2] Using input Member ID: {foundMemberId}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DDMA step2] Error extracting data from row: {e}")
|
||||||
|
if self.memberId:
|
||||||
|
foundMemberId = self.memberId
|
||||||
|
|
||||||
|
# Extract eligibility status
|
||||||
|
try:
|
||||||
|
short_wait = WebDriverWait(self.driver, 3)
|
||||||
|
status_link = short_wait.until(EC.presence_of_element_located((
|
||||||
By.XPATH,
|
By.XPATH,
|
||||||
"(//tbody//tr)[1]//a[contains(@href, 'member-eligibility-search')]"
|
"(//tbody//tr)[1]//a[contains(@href, 'member-eligibility-search')]"
|
||||||
)))
|
)))
|
||||||
|
|
||||||
eligibilityText = status_link.text.strip().lower()
|
eligibilityText = status_link.text.strip().lower()
|
||||||
|
print(f"[DDMA step2] Found eligibility status: {eligibilityText}")
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
alt_status = self.driver.find_element(By.XPATH, "//*[contains(text(),'Active') or contains(text(),'Inactive') or contains(text(),'Eligible')]")
|
||||||
|
eligibilityText = alt_status.text.strip().lower()
|
||||||
|
if "active" in eligibilityText or "eligible" in eligibilityText:
|
||||||
|
eligibilityText = "active"
|
||||||
|
elif "inactive" in eligibilityText:
|
||||||
|
eligibilityText = "inactive"
|
||||||
|
print(f"[DDMA step2] Found eligibility via alternative: {eligibilityText}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# 2) finding patient name.
|
# Navigate to detailed patient page
|
||||||
patient_name_div = wait.until(EC.presence_of_element_located((
|
print("[DDMA step2] Navigating to patient detail page...")
|
||||||
By.XPATH,
|
patient_name_clicked = False
|
||||||
'//div[@class="flex flex-row w-full items-center"]'
|
detail_url = None
|
||||||
)))
|
|
||||||
|
|
||||||
patientName = patient_name_div.text.strip().lower()
|
patient_link_selectors = [
|
||||||
|
"(//table//tbody//tr)[1]//td[1]//a",
|
||||||
|
"(//tbody//tr)[1]//a[contains(@href, 'member-details')]",
|
||||||
|
"(//tbody//tr)[1]//a[contains(@href, 'member')]",
|
||||||
|
]
|
||||||
|
|
||||||
|
for selector in patient_link_selectors:
|
||||||
|
try:
|
||||||
|
patient_link = WebDriverWait(self.driver, 5).until(
|
||||||
|
EC.presence_of_element_located((By.XPATH, selector))
|
||||||
|
)
|
||||||
|
link_text = patient_link.text.strip()
|
||||||
|
href = patient_link.get_attribute("href")
|
||||||
|
print(f"[DDMA step2] Found patient link: text='{link_text}', href={href}")
|
||||||
|
|
||||||
|
if link_text and not patientName:
|
||||||
|
patientName = link_text
|
||||||
|
|
||||||
|
if href and "member-details" in href:
|
||||||
|
detail_url = href
|
||||||
|
patient_name_clicked = True
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DDMA step2] Selector '{selector}' failed: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not detail_url:
|
||||||
|
try:
|
||||||
|
all_links = self.driver.find_elements(By.XPATH, "//a[contains(@href, 'member-details')]")
|
||||||
|
if all_links:
|
||||||
|
detail_url = all_links[0].get_attribute("href")
|
||||||
|
patient_name_clicked = True
|
||||||
|
print(f"[DDMA step2] Fallback member-details link: {detail_url}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DDMA step2] Could not find member-details link: {e}")
|
||||||
|
|
||||||
|
if patient_name_clicked and detail_url:
|
||||||
|
print(f"[DDMA step2] Navigating directly to: {detail_url}")
|
||||||
|
self.driver.get(detail_url)
|
||||||
|
|
||||||
|
# Wait for page to be ready
|
||||||
try:
|
try:
|
||||||
WebDriverWait(self.driver, 30).until(
|
WebDriverWait(self.driver, 30).until(
|
||||||
lambda d: d.execute_script("return document.readyState") == "complete"
|
lambda d: d.execute_script("return document.readyState") == "complete"
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
print("Warning: document.readyState did not become 'complete' within timeout")
|
print("[DDMA step2] Warning: document.readyState did not become 'complete'")
|
||||||
|
|
||||||
# Give some time for lazy content to finish rendering (adjust if needed)
|
# Wait for meaningful content to appear
|
||||||
time.sleep(0.6)
|
content_selectors = [
|
||||||
|
"//div[contains(@class,'member') or contains(@class,'detail') or contains(@class,'patient')]",
|
||||||
# Get total page size and DPR
|
"//h1", "//h2", "//table",
|
||||||
total_width = int(self.driver.execute_script(
|
"//*[contains(text(),'Member ID') or contains(text(),'Name') or contains(text(),'Date of Birth')]",
|
||||||
"return Math.max(document.body.scrollWidth, document.documentElement.scrollWidth, document.documentElement.clientWidth);"
|
]
|
||||||
))
|
for selector in content_selectors:
|
||||||
total_height = int(self.driver.execute_script(
|
|
||||||
"return Math.max(document.body.scrollHeight, document.documentElement.scrollHeight, document.documentElement.clientHeight);"
|
|
||||||
))
|
|
||||||
dpr = float(self.driver.execute_script("return window.devicePixelRatio || 1;"))
|
|
||||||
|
|
||||||
# Set device metrics to the full page size so Page.captureScreenshot captures everything
|
|
||||||
# Note: Some pages are extremely tall; if you hit memory limits, you can capture in chunks.
|
|
||||||
self.driver.execute_cdp_cmd('Emulation.setDeviceMetricsOverride', {
|
|
||||||
"mobile": False,
|
|
||||||
"width": total_width,
|
|
||||||
"height": total_height,
|
|
||||||
"deviceScaleFactor": dpr,
|
|
||||||
"screenOrientation": {"angle": 0, "type": "portraitPrimary"}
|
|
||||||
})
|
|
||||||
|
|
||||||
# Small pause for layout to settle after emulation change
|
|
||||||
time.sleep(0.15)
|
|
||||||
|
|
||||||
# Capture screenshot (base64 PNG)
|
|
||||||
result = self.driver.execute_cdp_cmd("Page.captureScreenshot", {"format": "png", "fromSurface": True})
|
|
||||||
image_data = base64.b64decode(result.get('data', ''))
|
|
||||||
screenshot_path = os.path.join(self.download_dir, f"ss_{self.memberId}.png")
|
|
||||||
with open(screenshot_path, "wb") as f:
|
|
||||||
f.write(image_data)
|
|
||||||
|
|
||||||
# Restore original metrics to avoid affecting further interactions
|
|
||||||
try:
|
try:
|
||||||
self.driver.execute_cdp_cmd('Emulation.clearDeviceMetricsOverride', {})
|
WebDriverWait(self.driver, 10).until(
|
||||||
|
EC.presence_of_element_located((By.XPATH, selector))
|
||||||
|
)
|
||||||
|
print(f"[DDMA step2] Content loaded: {selector}")
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
time.sleep(1) # Brief settle for any late-rendering elements
|
||||||
|
|
||||||
|
# Try to extract patient name from detail page if not already found
|
||||||
|
if not patientName:
|
||||||
|
for selector in ["//h1", "//h2", "//*[contains(@class,'patient-name') or contains(@class,'member-name')]"]:
|
||||||
|
try:
|
||||||
|
name_elem = self.driver.find_element(By.XPATH, selector)
|
||||||
|
name_text = name_elem.text.strip()
|
||||||
|
if name_text and len(name_text) > 1:
|
||||||
|
if not any(x in name_text.lower() for x in ['active', 'inactive', 'eligible', 'search', 'date', 'print']):
|
||||||
|
patientName = name_text
|
||||||
|
print(f"[DDMA step2] Found patient name on detail page: {patientName}")
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
print("[DDMA step2] Warning: Could not navigate to patient detail page")
|
||||||
|
if not patientName:
|
||||||
|
try:
|
||||||
|
name_elem = self.driver.find_element(By.XPATH, "(//tbody//tr)[1]//td[1]")
|
||||||
|
patientName = name_elem.text.strip()
|
||||||
except Exception:
|
except Exception:
|
||||||
# non-fatal: continue
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
print("Screenshot saved at:", screenshot_path)
|
if not patientName:
|
||||||
|
print("[DDMA step2] Could not extract patient name")
|
||||||
|
|
||||||
# Close the browser window after screenshot (session preserved in profile)
|
# Clean patient name
|
||||||
|
if patientName:
|
||||||
|
cleaned = re.sub(r'\s*DOB[:\s]*\d{1,2}/\d{1,2}/\d{2,4}\s*', '', patientName, flags=re.IGNORECASE).strip()
|
||||||
|
if cleaned:
|
||||||
|
patientName = cleaned
|
||||||
|
|
||||||
|
# Wait for page ready before PDF
|
||||||
|
try:
|
||||||
|
WebDriverWait(self.driver, 30).until(
|
||||||
|
lambda d: d.execute_script("return document.readyState") == "complete"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Generate PDF via Chrome DevTools Protocol
|
||||||
|
print("[DDMA 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"[DDMA step2] PDF saved at: {pdf_path}")
|
||||||
|
|
||||||
|
# Close the browser window after PDF (session preserved in profile)
|
||||||
try:
|
try:
|
||||||
from ddma_browser_manager import get_browser_manager
|
from ddma_browser_manager import get_browser_manager
|
||||||
get_browser_manager().quit_driver()
|
get_browser_manager().quit_driver()
|
||||||
@@ -342,16 +614,20 @@ class AutomationDeltaDentalMAEligibilityCheck:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[step2] Error closing browser: {e}")
|
print(f"[step2] Error closing browser: {e}")
|
||||||
|
|
||||||
output = {
|
print(f"[DDMA step2] Final — PatientName: '{patientName}', MemberID: '{foundMemberId}'")
|
||||||
|
|
||||||
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"eligibility": eligibilityText,
|
"eligibility": eligibilityText,
|
||||||
"ss_path": screenshot_path,
|
"ss_path": pdf_path, # kept for backward compatibility
|
||||||
"patientName":patientName
|
"pdf_path": pdf_path, # explicit pdf_path
|
||||||
|
"patientName": patientName,
|
||||||
|
"memberId": foundMemberId,
|
||||||
}
|
}
|
||||||
return output
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("ERROR in step2:", e)
|
print("ERROR in step2:", e)
|
||||||
# Empty the download folder (remove files / symlinks only)
|
# Cleanup download dir on error
|
||||||
try:
|
try:
|
||||||
dl = os.path.abspath(self.download_dir)
|
dl = os.path.abspath(self.download_dir)
|
||||||
if os.path.isdir(dl):
|
if os.path.isdir(dl):
|
||||||
@@ -360,18 +636,12 @@ class AutomationDeltaDentalMAEligibilityCheck:
|
|||||||
try:
|
try:
|
||||||
if os.path.isfile(item) or os.path.islink(item):
|
if os.path.isfile(item) or os.path.islink(item):
|
||||||
os.remove(item)
|
os.remove(item)
|
||||||
print(f"[cleanup] removed: {item}")
|
|
||||||
except Exception as rm_err:
|
except Exception as rm_err:
|
||||||
print(f"[cleanup] failed to remove {item}: {rm_err}")
|
print(f"[cleanup] failed to remove {item}: {rm_err}")
|
||||||
print(f"[cleanup] emptied download dir: {dl}")
|
|
||||||
else:
|
|
||||||
print(f"[cleanup] download dir does not exist: {dl}")
|
|
||||||
except Exception as cleanup_exc:
|
except Exception as cleanup_exc:
|
||||||
print(f"[cleanup] unexpected error while cleaning downloads dir: {cleanup_exc}")
|
print(f"[cleanup] unexpected error: {cleanup_exc}")
|
||||||
return {"status": "error", "message": str(e)}
|
return {"status": "error", "message": str(e)}
|
||||||
|
|
||||||
# NOTE: Do NOT quit driver here - keep browser alive for next patient
|
|
||||||
|
|
||||||
def main_workflow(self, url):
|
def main_workflow(self, url):
|
||||||
try:
|
try:
|
||||||
self.config_driver()
|
self.config_driver()
|
||||||
@@ -394,8 +664,5 @@ class AutomationDeltaDentalMAEligibilityCheck:
|
|||||||
|
|
||||||
return step2_result
|
return step2_result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {
|
return {"status": "error", "message": str(e)}
|
||||||
"status": "error",
|
# NOTE: Do NOT quit driver — keep browser alive for next patient
|
||||||
"message": e
|
|
||||||
}
|
|
||||||
# NOTE: Do NOT quit driver - keep browser alive for next patient
|
|
||||||
|
|||||||
Reference in New Issue
Block a user