From db7ea8ef2f3900ff0f464d846be71ef39512b3bf Mon Sep 17 00:00:00 2001 From: Gitead Date: Tue, 13 Oct 2026 00:29:28 -0400 Subject: [PATCH] feat: MetLife Dental eligibility check with persistent session Mirrors the DDMA flow: persistent Chrome profile + credential-change logout, PingOne login with email MFA selection and OTP from app or browser, Eligibility & Plan Details search (masked Subscriber ID/SSN), family member picked by DOB and re-verified on the plan details page, PDF saved to the patient's Eligibility folder and status set ACTIVE/INACTIVE. Co-Authored-By: Claude Opus 5.5 --- apps/Backend/src/ai/internal-chat-workflow.ts | 1 + apps/Backend/src/data/insuranceAliases.json | 6 +- apps/Backend/src/queue/jobRunner.ts | 15 + .../processors/metlifeEligibilityProcessor.ts | 394 +++++++++ apps/Backend/src/queue/queues.ts | 3 +- apps/Backend/src/routes/index.ts | 2 + .../src/routes/insuranceStatusMetLife.ts | 138 +++ .../seleniumMetLifeEligibilityClient.ts | 122 +++ .../insurance-status/metlife-button-modal.tsx | 371 ++++++++ .../src/components/layout/chatbot.tsx | 1 + .../components/settings/InsuranceCredForm.tsx | 1 + apps/Frontend/src/pages/appointments-page.tsx | 2 + .../src/pages/insurance-status-page.tsx | 35 +- apps/SeleniumService/agent.py | 57 ++ .../helpers_metlife_eligibility.py | 252 ++++++ .../metlife_browser_manager.py | 253 ++++++ ...selenium_MetLife_eligibilityCheckWorker.py | 834 ++++++++++++++++++ 17 files changed, 2475 insertions(+), 12 deletions(-) create mode 100644 apps/Backend/src/queue/processors/metlifeEligibilityProcessor.ts create mode 100644 apps/Backend/src/routes/insuranceStatusMetLife.ts create mode 100644 apps/Backend/src/services/seleniumMetLifeEligibilityClient.ts create mode 100644 apps/Frontend/src/components/insurance-status/metlife-button-modal.tsx create mode 100644 apps/SeleniumService/helpers_metlife_eligibility.py create mode 100644 apps/SeleniumService/metlife_browser_manager.py create mode 100644 apps/SeleniumService/selenium_MetLife_eligibilityCheckWorker.py diff --git a/apps/Backend/src/ai/internal-chat-workflow.ts b/apps/Backend/src/ai/internal-chat-workflow.ts index cc03c8bd..0f76a899 100644 --- a/apps/Backend/src/ai/internal-chat-workflow.ts +++ b/apps/Backend/src/ai/internal-chat-workflow.ts @@ -88,6 +88,7 @@ export function siteKeyToAutoCheck(siteKey: string, dob?: string | Date | null): case "UNITED_SCO": return "united-sco"; case "UNITED_UCARD": return "united-ucard"; case "BCBS_MA": return "bcbs-ma"; + case "METLIFE": return "metlife"; default: { // MassHealth: patients under 21 use the CMSP button if (dob) { diff --git a/apps/Backend/src/data/insuranceAliases.json b/apps/Backend/src/data/insuranceAliases.json index 26978ac2..248e4eee 100644 --- a/apps/Backend/src/data/insuranceAliases.json +++ b/apps/Backend/src/data/insuranceAliases.json @@ -39,5 +39,9 @@ { "keyword": "blue cross blue shield", "siteKey": "BCBS_MA" }, { "keyword": "bcbs ma", "siteKey": "BCBS_MA" }, { "keyword": "blue cross", "siteKey": "BCBS_MA" }, - { "keyword": "bcbs", "siteKey": "BCBS_MA" } + { "keyword": "bcbs", "siteKey": "BCBS_MA" }, + + { "keyword": "metlife", "siteKey": "METLIFE" }, + { "keyword": "met life", "siteKey": "METLIFE" }, + { "keyword": "metdental", "siteKey": "METLIFE" } ] diff --git a/apps/Backend/src/queue/jobRunner.ts b/apps/Backend/src/queue/jobRunner.ts index ea62247e..549e7960 100644 --- a/apps/Backend/src/queue/jobRunner.ts +++ b/apps/Backend/src/queue/jobRunner.ts @@ -30,6 +30,7 @@ import { runUnitedUcardClaimProcessor } from "./processors/unitedUcardClaimProce import { runEligibilityHistoryProcessor } from "./processors/eligibilityHistoryProcessor"; import { runCmspEligibilityHistoryRemainingProcessor } from "./processors/cmspEligibilityHistoryRemainingProcessor"; import { runBcbsMaEligibilityProcessor } from "./processors/bcbsMaEligibilityProcessor"; +import { runMetLifeEligibilityProcessor } from "./processors/metlifeEligibilityProcessor"; import type { SeleniumJobData, OcrJobData } from "./queues"; // ── Queue instances ────────────────────────────────────────────────────────── @@ -104,6 +105,20 @@ export function enqueueSeleniumJob(data: SeleniumJobData): string { job.id ); } + if (jobType === "metlife-eligibility-check") { + return runMetLifeEligibilityProcessor( + { + enrichedPayload: data.enrichedPayload, + userId: data.userId, + insuranceId: data.insuranceId!, + formFirstName: data.formFirstName, + formLastName: data.formLastName, + formDob: data.formDob, + socketId: data.socketId, + }, + job.id + ); + } if (jobType === "deltains-eligibility-check") { return runDeltaInsEligibilityProcessor( { diff --git a/apps/Backend/src/queue/processors/metlifeEligibilityProcessor.ts b/apps/Backend/src/queue/processors/metlifeEligibilityProcessor.ts new file mode 100644 index 00000000..99b2a626 --- /dev/null +++ b/apps/Backend/src/queue/processors/metlifeEligibilityProcessor.ts @@ -0,0 +1,394 @@ +/** + * Processor for "metlife-eligibility-check" jobs. + * + * Integrates the MetLife persistent-session flow into the InProcessQueue + * (same shape as the DDMA processor): + * 1. Start a session on the Python agent (POST /metlife-eligibility) + * 2. Emit selenium:metlife_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-metlife/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 { + forwardToSeleniumMetLifeEligibilityAgent, + getSeleniumMetLifeSessionStatus, +} from "../../services/seleniumMetLifeEligibilityClient"; +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("metlife-processor", `emitted ${event}`, { socketId }); + } + } catch (err: any) { + log("metlife-processor", `emit failed for ${event}`, { err: err?.message }); + } +} + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface MetLifeEligibilityProcessorInput { + enrichedPayload: any; + userId: number; + insuranceId: string; + formFirstName?: string; + formLastName?: string; + formDob?: string; + socketId?: string; +} + +export interface MetLifeEligibilityProcessorResult { + patientUpdateStatus?: string; + pdfUploadStatus?: string; + pdfFileId?: number | string | null; + pdfFilename?: string | null; +} + +// ─── Core DB processing ────────────────────────────────────────────────────── + +async function processMetLifeResult( + userId: number, + insuranceId: string, + formFirstName: string | undefined, + formLastName: string | undefined, + formDob: string | undefined, + seleniumResult: any +): Promise { + const output: MetLifeEligibilityProcessorResult = {}; + let createdPdfFileId: number | string | null = null; + + try { + // 1) Resolve patient name (prefer selenium extraction → form data) + const rawName = + typeof seleniumResult?.patientName === "string" + ? seleniumResult.patientName.trim() + : null; + + let firstName: string; + let lastName: string; + + if (rawName) { + // Strip any trailing bare date e.g. "Christian Rodriguez 04/27/2026" + const cleanName = rawName.replace(/\s+\d{1,2}\/\d{1,2}\/\d{2,4}$/, "").trim(); + + if (cleanName.includes(",")) { + // "LAST, FIRST" format common on insurance portals + const [last, ...firstParts] = cleanName.split(",").map((s: string) => s.trim()); + lastName = last || formLastName || ""; + firstName = firstParts.join(" ").trim() || formFirstName || ""; + } else { + const parsed = splitName(cleanName); + if (!parsed.lastName) { + // Single word — treat as last name, pull first name from form + lastName = parsed.firstName || formLastName || ""; + firstName = formFirstName || ""; + } else { + firstName = parsed.firstName || formFirstName || ""; + lastName = parsed.lastName || formLastName || ""; + } + } + } else { + firstName = formFirstName ?? ""; + lastName = formLastName ?? ""; + } + + // 2) Create / update patient + const upsertedPatient = await createOrUpdatePatientByInsuranceId({ + insuranceId, + firstName, + lastName, + dob: formDob, + userId, + }); + + // 3) Fetch patient (needed for ID) + const normalizedInsuranceId = insuranceId.replace(/\s+/g, ""); + // Use the patient just created/updated (family members share an insuranceId, so an + // id-only lookup could return a different member). + const dobForLookup = formDob ? new Date(formDob) : null; + const patient = + upsertedPatient ?? + (dobForLookup && !isNaN(dobForLookup.getTime()) + ? await storage.getPatientByInsuranceIdAndDob(normalizedInsuranceId, dobForLookup) + : await storage.getPatientByInsuranceId(normalizedInsuranceId)); + log("metlife-processor", `patient lookup after create: ${patient ? `id=${patient.id}` : "NOT FOUND"} for insuranceId="${normalizedInsuranceId}"`); + if (!patient?.id) { + output.patientUpdateStatus = "Patient not found; no update performed"; + return output; + } + + // 4) Determine and update eligibility status + insurance provider name + const eligStatus = (seleniumResult?.eligibility ?? "").toLowerCase(); + let newStatus = "UNKNOWN"; + if (eligStatus === "active" || eligStatus === "y") newStatus = "ACTIVE"; + else if (eligStatus === "inactive" || eligStatus === "n" || eligStatus === "termed") newStatus = "INACTIVE"; + await storage.updatePatient(patient.id, { + status: newStatus, + insuranceProvider: "MetLife Dental", + }); + output.patientUpdateStatus = `Patient status updated to ${newStatus}`; + + // 5) Resolve PDF buffer + // Worker returns a real PDF via pdf_path / ss_path (.pdf); screenshots are converted. + 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("metlife-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 = `metlife_eligibility_${insuranceId}_${Date.now()}.pdf`; + log("metlife-processor", "converted screenshot to PDF", { pdfPath }); + } catch (e: any) { + output.pdfUploadStatus = `Failed to convert screenshot to PDF: ${e.message}`; + } + } + } else { + output.pdfUploadStatus = "No valid file path from Selenium; nothing uploaded."; + } + + // 6) Save PDF to patient document group + if (pdfBuffer && pdfFilename) { + const patientName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim() || `Patient ${patient.id}`; + const cloudFile = await storage.savePdfToCloudStorage( + userId, + patient.id, + patientName, + "Eligibility", + pdfFilename, + pdfBuffer + ); + createdPdfFileId = `cloud:${cloudFile.id}`; + output.pdfUploadStatus = "PDF saved to Eligibility folder"; + output.pdfFilename = pdfFilename; + } + + output.pdfFileId = createdPdfFileId; + return output; + } catch (err: any) { + log("metlife-processor", `processMetLifeResult ERROR: ${err?.message ?? String(err)}`, err); + return { + ...output, + pdfUploadStatus: + output.pdfUploadStatus ?? `Processing failed: ${err?.message ?? String(err)}`, + pdfFileId: createdPdfFileId, + }; + } finally { + // Always clean up temp files + const cleanupPath = + seleniumResult?.pdf_path ?? seleniumResult?.ss_path ?? null; + if (cleanupPath) { + try { + await emptyFolderContainingFile(cleanupPath); + } catch (e) { + log("metlife-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 { + 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( + `MetLife polling timeout (${Math.round(pollTimeoutMs / 1000)}s) for session ${sessionId}` + ); + } + + try { + const st = await getSeleniumMetLifeSessionStatus(sessionId); + const status: string = st?.status ?? "unknown"; + + log("metlife-processor", `poll attempt=${attempt}`, { sessionId, status }); + + transientErrors = 0; // reset on success + + // Track no-progress + const isTerminal = + status === "completed" || status === "error" || status === "not_found"; + // waiting_for_otp legitimately sits unchanged while the user finds the code + if (status === lastStatus && !isTerminal && status !== "waiting_for_otp") { + 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 code sent by the MetLife portal.", + }); + await new Promise((r) => setTimeout(r, pollIntervalMs)); + continue; + } + + if (status === "completed") { + log("metlife-processor", "session completed", { sessionId }); + return st.result; + } + + if (status === "error" || status === "not_found") { + throw new Error( + st?.message || `MetLife 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 MetLife session ${sessionId}` + ); + } + const backoff = Math.min(30_000, 500 * Math.pow(2, transientErrors - 1)); + log("metlife-processor", `transient error #${transientErrors}, backoff ${backoff}ms`, { + err: err?.message, + }); + await new Promise((r) => setTimeout(r, backoff)); + } + } + + throw new Error(`MetLife polling exhausted all attempts for session ${sessionId}`); +} + +// ─── Main processor entry point ─────────────────────────────────────────────── + +export async function runMetLifeEligibilityProcessor( + input: MetLifeEligibilityProcessorInput, + jobId: string +): Promise { + const { + enrichedPayload, + userId, + insuranceId, + formFirstName, + formLastName, + formDob, + socketId, + } = input; + + // 1) Tell Python agent to start a MetLife session + log("metlife-processor", "starting Python agent session", { insuranceId }); + const agentResp = await forwardToSeleniumMetLifeEligibilityAgent(enrichedPayload); + + if (!agentResp?.session_id) { + throw new Error( + "Python agent did not return a session_id for MetLife eligibility" + ); + } + + const sessionId = agentResp.session_id as string; + log("metlife-processor", "got session_id", { sessionId }); + + // 2) Emit session started so frontend can store session_id for OTP submission + emitToSocket(socketId, "selenium:metlife_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 ?? "MetLife session returned an error result" + ); + } + + // 4) Process DB writes and PDF upload + log("metlife-processor", "processing DB result", { insuranceId }); + const result = await processMetLifeResult( + userId, + insuranceId, + formFirstName, + formLastName, + formDob, + seleniumResult + ); + + log("metlife-processor", "done", { result }); + return result; +} diff --git a/apps/Backend/src/queue/queues.ts b/apps/Backend/src/queue/queues.ts index d84bd1aa..20f5caa7 100644 --- a/apps/Backend/src/queue/queues.ts +++ b/apps/Backend/src/queue/queues.ts @@ -25,7 +25,8 @@ export type SeleniumJobType = | "tuftssco-eligibility-check" | "mh-eligibility-history-check" | "cmsp-eligibility-history-remaining-check" - | "bcbs-ma-eligibility-check"; + | "bcbs-ma-eligibility-check" + | "metlife-eligibility-check"; export interface SeleniumJobData { jobType: SeleniumJobType; diff --git a/apps/Backend/src/routes/index.ts b/apps/Backend/src/routes/index.ts index c716bfaa..66a35266 100755 --- a/apps/Backend/src/routes/index.ts +++ b/apps/Backend/src/routes/index.ts @@ -48,6 +48,7 @@ import shoppingVendorsRoutes from "./shopping-vendors"; import feeScheduleRoutes from "./feeSchedule"; import licenseRoutes from "./license"; import insuranceStatusBcbsMaRoutes from "./insuranceStatusBcbsMa"; +import insuranceStatusMetLifeRoutes from "./insuranceStatusMetLife"; import labRxRoutes from "./lab-rx"; import seleniumSettingsRoutes from "./selenium-settings"; import typeAgentRoutes from "./type-agent"; @@ -75,6 +76,7 @@ router.use("/insurance-status-uniteducard", insuranceStatusUnitedUcardRoutes); router.use("/insurance-status-tuftssco", insuranceStatusTuftsSCORoutes); router.use("/insurance-status-cca", insuranceStatusCCARoutes); router.use("/insurance-status-bcbs-ma", insuranceStatusBcbsMaRoutes); +router.use("/insurance-status-metlife", insuranceStatusMetLifeRoutes); router.use("/claims", insuranceStatusCCAClaimRoutes); router.use("/claims", insuranceStatusCCAPreAuthRoutes); router.use("/claims", insuranceStatusDDMAClaimRoutes); diff --git a/apps/Backend/src/routes/insuranceStatusMetLife.ts b/apps/Backend/src/routes/insuranceStatusMetLife.ts new file mode 100644 index 00000000..d3f6e63f --- /dev/null +++ b/apps/Backend/src/routes/insuranceStatusMetLife.ts @@ -0,0 +1,138 @@ +import { Router, Request, Response } from "express"; +import { storage } from "../storage"; +import { forwardOtpToSeleniumMetLifeAgent } from "../services/seleniumMetLifeEligibilityClient"; +import { io } from "../socket"; +import { enqueueSeleniumJob } from "../queue/jobRunner"; + +const router = Router(); + +function log(tag: string, msg: string, ctx?: any) { + console.log(`${new Date().toISOString()} [${tag}] ${msg}`, ctx ?? ""); +} + +function emitSafe(socketId: string | undefined, event: string, payload: any) { + if (!socketId || !io) return; + try { + const socket = io.sockets.sockets.get(socketId); + if (socket) socket.emit(event, payload); + } catch (err: any) { + log("socket", "emit failed", { socketId, event, err: err?.message }); + } +} + +/** + * POST /metlife-eligibility + * + * Enqueues a MetLife Dental eligibility check in the shared InProcessQueue + * (concurrency=1, mirrors the Python semaphore). + * + * Body: + * data — patient + search fields (memberId, dateOfBirth, …) + * socketId — socket.io client id for real-time updates + * + * Response: { status: "queued", jobId: "…" } + * + * Real-time events emitted to socketId during job execution: + * job:update { jobId, jobType, status, … } + * selenium:metlife_session_started { session_id, jobId } + * selenium:otp_required { session_id, jobId, message } + */ +router.post( + "/metlife-eligibility", + async (req: Request, res: Response): Promise => { + if (!req.body.data) { + return res + .status(400) + .json({ error: "Missing Insurance Eligibility data for selenium" }); + } + if (!req.user?.id) { + return res.status(401).json({ error: "Unauthorized: user info missing" }); + } + + try { + const rawData = + typeof req.body.data === "string" + ? JSON.parse(req.body.data) + : req.body.data; + + const credentials = await storage.getInsuranceCredentialByUserAndSiteKey( + req.user.id, + rawData.insuranceSiteKey + ); + if (!credentials) { + return res.status(404).json({ + error: + "No MetLife credentials found for this provider. Please add them at the Settings page.", + }); + } + + const enrichedData = { + ...rawData, + metlifeUsername: credentials.username, + metlifePassword: credentials.password, + }; + + const socketId: string | undefined = req.body.socketId; + + const jobId = enqueueSeleniumJob({ + jobType: "metlife-eligibility-check", + userId: req.user.id, + socketId, + enrichedPayload: enrichedData, + insuranceId: String(rawData.memberId ?? "").trim(), + formFirstName: rawData.firstName, + formLastName: rawData.lastName, + formDob: rawData.dateOfBirth, + }); + + log("metlife-route", "job enqueued", { jobId, insuranceId: rawData.memberId }); + + return res.json({ status: "queued", jobId }); + } catch (err: any) { + console.error("[metlife-route] enqueue failed:", err); + return res.status(500).json({ + error: err.message || "Failed to enqueue MetLife selenium job", + }); + } + } +); + +/** + * POST /selenium/submit-otp + * + * Forwards the OTP entered by the user directly to the Python agent + * (side-channel — does NOT go through the queue). + * + * Body: { session_id, otp, socketId? } + */ +router.post( + "/selenium/submit-otp", + async (req: Request, res: Response): Promise => { + const { session_id: sessionId, otp, socketId } = req.body; + if (!sessionId || !otp) { + return res.status(400).json({ error: "session_id and otp are required" }); + } + + try { + const r = await forwardOtpToSeleniumMetLifeAgent(sessionId, otp); + + emitSafe(socketId, "selenium:otp_submitted", { + session_id: sessionId, + result: r, + }); + + return res.json(r); + } catch (err: any) { + console.error( + "[metlife-route] submit-otp failed:", + err?.response?.data || err?.message || err + ); + return res.status(500).json({ + error: "Failed to forward OTP to selenium agent", + detail: err?.message || err, + }); + } + } +); + +export default router; diff --git a/apps/Backend/src/services/seleniumMetLifeEligibilityClient.ts b/apps/Backend/src/services/seleniumMetLifeEligibilityClient.ts new file mode 100644 index 00000000..783fe1e6 --- /dev/null +++ b/apps/Backend/src/services/seleniumMetLifeEligibilityClient.ts @@ -0,0 +1,122 @@ +import axios from "axios"; +import http from "http"; +import https from "https"; +import dotenv from "dotenv"; +dotenv.config(); + +export interface SeleniumPayload { + data: any; + url?: string; +} + +const SELENIUM_AGENT_BASE = process.env.SELENIUM_AGENT_BASE_URL; + +const httpAgent = new http.Agent({ keepAlive: true, keepAliveMsecs: 60_000 }); +const httpsAgent = new https.Agent({ keepAlive: true, keepAliveMsecs: 60_000 }); + +const client = axios.create({ + baseURL: SELENIUM_AGENT_BASE, + timeout: 5 * 60 * 1000, + httpAgent, + httpsAgent, + validateStatus: (s) => s >= 200 && s < 600, +}); + +async function requestWithRetries( + config: any, + retries = 4, + baseBackoffMs = 300 +) { + for (let attempt = 1; attempt <= retries; attempt++) { + try { + const r = await client.request(config); + if (![502, 503, 504].includes(r.status)) return r; + console.warn( + `[selenium-client] retryable HTTP status ${r.status} (attempt ${attempt})` + ); + } catch (err: any) { + const code = err?.code; + const isTransient = + code === "ECONNRESET" || + code === "ECONNREFUSED" || + code === "EPIPE" || + code === "ETIMEDOUT"; + if (!isTransient) throw err; + console.warn( + `[selenium-client] transient network error ${code} (attempt ${attempt})` + ); + } + await new Promise((r) => setTimeout(r, baseBackoffMs * attempt)); + } + // final attempt (let exception bubble if it fails) + return client.request(config); +} + +function now() { + return new Date().toISOString(); +} +function log(tag: string, msg: string, ctx?: any) { + console.log(`${now()} [${tag}] ${msg}`, ctx ?? ""); +} + +export async function forwardToSeleniumMetLifeEligibilityAgent( + insuranceEligibilityData: any +): Promise { + const payload = { data: insuranceEligibilityData }; + const url = `/metlife-eligibility`; + log("selenium-client", "POST metlife-eligibility", { + url: SELENIUM_AGENT_BASE + url, + keys: Object.keys(payload), + }); + const r = await requestWithRetries({ url, method: "POST", data: payload }, 4); + log("selenium-client", "agent response", { + status: r.status, + dataKeys: r.data ? Object.keys(r.data) : null, + }); + if (r.status >= 500) + throw new Error(`Selenium agent server error: ${r.status}`); + return r.data; +} + +export async function forwardOtpToSeleniumMetLifeAgent( + sessionId: string, + otp: string +): Promise { + const url = `/submit-otp`; + log("selenium-client", "POST submit-otp", { + url: SELENIUM_AGENT_BASE + url, + sessionId, + }); + const r = await requestWithRetries( + { url, method: "POST", data: { session_id: sessionId, otp } }, + 4 + ); + log("selenium-client", "submit-otp response", { + status: r.status, + data: r.data, + }); + if (r.status >= 500) + throw new Error(`Selenium agent server error on submit-otp: ${r.status}`); + return r.data; +} + +export async function getSeleniumMetLifeSessionStatus( + sessionId: string +): Promise { + const url = `/session/${sessionId}/status`; + log("selenium-client", "GET session status", { + url: SELENIUM_AGENT_BASE + url, + sessionId, + }); + const r = await requestWithRetries({ url, method: "GET" }, 4); + log("selenium-client", "session status response", { + status: r.status, + dataKeys: r.data ? Object.keys(r.data) : null, + }); + if (r.status === 404) { + const e: any = new Error("not_found"); + e.response = { status: 404, data: r.data }; + throw e; + } + return r.data; +} diff --git a/apps/Frontend/src/components/insurance-status/metlife-button-modal.tsx b/apps/Frontend/src/components/insurance-status/metlife-button-modal.tsx new file mode 100644 index 00000000..2c62258d --- /dev/null +++ b/apps/Frontend/src/components/insurance-status/metlife-button-modal.tsx @@ -0,0 +1,371 @@ +import { useEffect, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { CheckCircle, LoaderCircleIcon, X } from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; +import { apiRequest, queryClient } from "@/lib/queryClient"; +import { useAppDispatch } from "@/redux/hooks"; +import { setTaskStatus } from "@/redux/slices/seleniumTaskSlice"; +import { formatLocalDate } from "@/utils/dateUtils"; +import { socket } from "@/lib/socket"; +import { QK_PATIENTS_BASE } from "@/components/patients/patient-table"; + +// ─── OTP Modal ──────────────────────────────────────────────────────────────── + +interface MetLifeOtpModalProps { + open: boolean; + onClose: () => void; + onSubmit: (otp: string) => Promise | void; + isSubmitting: boolean; +} + +function MetLifeOtpModal({ open, onClose, onSubmit, isSubmitting }: MetLifeOtpModalProps) { + const [otp, setOtp] = useState(""); + + useEffect(() => { + if (!open) setOtp(""); + }, [open]); + + if (!open) return null; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!otp.trim()) return; + await onSubmit(otp.trim()); + }; + + return ( +
+
+
+

Enter OTP

+ +
+

+ We need the one-time code sent by the MetLife Dental portal to complete this eligibility + check. If the portal asks how to send the code, choose it in the browser window first. +

+
+
+ + setOtp(e.target.value)} + autoFocus + /> +
+
+ + +
+
+
+
+ ); +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +interface MetLifeEligibilityButtonProps { + memberId: string; + dateOfBirth: Date | null; + firstName?: string; + lastName?: string; + isFormIncomplete: boolean; + autoTrigger?: boolean; + onAutoTriggered?: () => void; + onPdfReady: (pdfId: number | string, fallbackFilename: string | null) => void; +} + +export function MetLifeEligibilityButton({ + memberId, + dateOfBirth, + firstName, + lastName, + isFormIncomplete, + autoTrigger, + onAutoTriggered, + onPdfReady, +}: MetLifeEligibilityButtonProps) { + const { toast } = useToast(); + const dispatch = useAppDispatch(); + + const sessionIdRef = useRef(null); + const autoTriggeredRef = useRef(false); + + const [otpModalOpen, setOtpModalOpen] = useState(false); + const [isStarting, setIsStarting] = useState(false); + const [isSubmittingOtp, setIsSubmittingOtp] = useState(false); + + const handleMetLifeStart = async () => { + if (!memberId || !dateOfBirth) { + toast({ + title: "Missing fields", + description: "Member ID (Subscriber ID or SSN) and Date of Birth are required.", + variant: "destructive", + }); + return; + } + + const payload = { + memberId, + dateOfBirth: formatLocalDate(dateOfBirth), + firstName, + lastName, + insuranceSiteKey: "METLIFE", + }; + + setIsStarting(true); + + try { + dispatch( + setTaskStatus({ + key: "eligibilityCheck", + status: "pending", + message: "Starting MetLife eligibility check…", + }) + ); + + // 1) POST to backend — returns { status: "queued", jobId } + const response = await apiRequest( + "POST", + "/api/insurance-status-metlife/metlife-eligibility", + { data: JSON.stringify(payload), socketId: socket.id } + ); + + const result = await response.json(); + if (!response.ok || result.error) { + throw new Error(result.error || `Server error (${response.status})`); + } + + const jobId: string = result.jobId; + if (!jobId) throw new Error("No jobId returned from server"); + + dispatch( + setTaskStatus({ + key: "eligibilityCheck", + status: "pending", + message: "MetLife job queued. Waiting for browser session to start…", + }) + ); + + // 2) Listen for job-lifecycle and MetLife-specific socket events. + + 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…", + }) + ); + }; + + const onOtpRequired = (data: any) => { + if (String(data?.jobId) !== String(jobId)) return; + if (data.session_id) sessionIdRef.current = data.session_id; + setOtpModalOpen(true); + dispatch( + setTaskStatus({ + key: "eligibilityCheck", + status: "pending", + message: "OTP required for MetLife. Please enter the code.", + }) + ); + }; + + const onOtpSubmitted = (data: any) => { + if (data?.session_id && data.session_id !== sessionIdRef.current) return; + dispatch( + setTaskStatus({ + key: "eligibilityCheck", + status: "pending", + message: "OTP submitted. Finishing MetLife eligibility check…", + }) + ); + }; + + const onJobUpdate = (data: any) => { + if (String(data?.jobId) !== String(jobId)) return; + + if (data.status === "active") { + dispatch( + setTaskStatus({ + key: "eligibilityCheck", + status: "pending", + message: data.message ?? "Selenium browser starting…", + }) + ); + return; + } + + // Terminal states + cleanup(); + + if (data.status === "completed") { + dispatch( + setTaskStatus({ + key: "eligibilityCheck", + status: "success", + message: "MetLife eligibility updated and PDF attached to patient documents.", + }) + ); + toast({ + title: "MetLife eligibility complete", + description: "Patient status was updated and the eligibility PDF was saved.", + }); + queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE }); + const pdfId = data.result?.pdfFileId; + if (pdfId) { + onPdfReady(pdfId, data.result?.pdfFilename ?? `eligibility_metlife_${memberId}.pdf`); + } + } else if (data.status === "failed") { + const msg = data.error ?? "MetLife eligibility job failed."; + dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: msg })); + toast({ title: "MetLife selenium error", description: msg, variant: "destructive" }); + } + + setIsStarting(false); + setOtpModalOpen(false); + }; + + // 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: "MetLife job timed out waiting for completion.", + }) + ); + }, 6 * 60 * 1000); + + function cleanup() { + clearTimeout(safetyTimer); + socket.off("selenium:metlife_session_started", onSessionStarted); + socket.off("selenium:otp_required", onOtpRequired); + socket.off("selenium:otp_submitted", onOtpSubmitted); + socket.off("job:update", onJobUpdate); + } + + socket.on("selenium:metlife_session_started", onSessionStarted); + socket.on("selenium:otp_required", onOtpRequired); + socket.on("selenium:otp_submitted", onOtpSubmitted); + socket.on("job:update", onJobUpdate); + } catch (err: any) { + console.error("MetLifeEligibilityButton error:", err); + dispatch( + setTaskStatus({ + key: "eligibilityCheck", + status: "error", + message: err?.message || "Failed to start MetLife eligibility", + }) + ); + toast({ + title: "MetLife selenium error", + description: err?.message || "Failed to start MetLife eligibility", + variant: "destructive", + }); + setIsStarting(false); + } + }; + + // ── OTP submission ──────────────────────────────────────────────────────── + + const handleSubmitOtp = async (otp: string) => { + const sessionId = sessionIdRef.current; + if (!sessionId) { + toast({ + title: "Session not ready", + description: "Cannot submit OTP — MetLife session ID is not available yet.", + variant: "destructive", + }); + return; + } + + try { + setIsSubmittingOtp(true); + const resp = await apiRequest("POST", "/api/insurance-status-metlife/selenium/submit-otp", { + session_id: sessionId, + otp, + socketId: socket.id, + }); + const data = await resp.json(); + if (!resp.ok || data.error) { + throw new Error(data.error || "Failed to submit OTP"); + } + setOtpModalOpen(false); + } catch (err: any) { + console.error("handleSubmitOtp error:", err); + toast({ + title: "Failed to submit OTP", + description: err?.message || "Error forwarding OTP to selenium agent", + variant: "destructive", + }); + } finally { + setIsSubmittingOtp(false); + } + }; + + useEffect(() => { + if (!autoTrigger) { autoTriggeredRef.current = false; return; } + if (autoTriggeredRef.current || isFormIncomplete) return; + autoTriggeredRef.current = true; + onAutoTriggered?.(); + handleMetLifeStart(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autoTrigger, isFormIncomplete]); + + // ── Render ──────────────────────────────────────────────────────────────── + + return ( + <> + + + setOtpModalOpen(false)} + onSubmit={handleSubmitOtp} + isSubmitting={isSubmittingOtp} + /> + + ); +} diff --git a/apps/Frontend/src/components/layout/chatbot.tsx b/apps/Frontend/src/components/layout/chatbot.tsx index 5bf98b7f..24350269 100644 --- a/apps/Frontend/src/components/layout/chatbot.tsx +++ b/apps/Frontend/src/components/layout/chatbot.tsx @@ -142,6 +142,7 @@ const INSURANCE_TYPE_OPTIONS: { value: string; label: string }[] = [ { value: "tufts-sco", label: "Tufts SCO" }, { value: "cca", label: "CCA" }, { value: "bcbs-ma", label: "BCBS MA" }, + { value: "metlife", label: "MetLife" }, ]; function getAutoCheck(_dobISO: string): "mh" { diff --git a/apps/Frontend/src/components/settings/InsuranceCredForm.tsx b/apps/Frontend/src/components/settings/InsuranceCredForm.tsx index cb0fa0f6..b07feced 100755 --- a/apps/Frontend/src/components/settings/InsuranceCredForm.tsx +++ b/apps/Frontend/src/components/settings/InsuranceCredForm.tsx @@ -25,6 +25,7 @@ const SITE_KEY_OPTIONS = [ { value: "UNITED_UCARD", label: "United Ucard (UNITED_UCARD)" }, { value: "CCA", label: "CCA (CCA)" }, { value: "BCBS_MA", label: "BCBS MA (BCBS_MA)" }, + { value: "METLIFE", label: "MetLife Dental (METLIFE)" }, ]; export function CredentialForm({ onClose, userId, defaultValues }: CredentialFormProps) { diff --git a/apps/Frontend/src/pages/appointments-page.tsx b/apps/Frontend/src/pages/appointments-page.tsx index 52d5dbb1..adeb91e6 100755 --- a/apps/Frontend/src/pages/appointments-page.tsx +++ b/apps/Frontend/src/pages/appointments-page.tsx @@ -1196,6 +1196,8 @@ export default function AppointmentsPage() { autoCheck = "tufts-sco"; } else if (p.includes("commonwealth care alliance") || p.includes("cca")) { autoCheck = "cca"; + } else if (p.includes("metlife") || p.includes("met life")) { + autoCheck = "metlife"; } if (autoCheck === "other-providers") { diff --git a/apps/Frontend/src/pages/insurance-status-page.tsx b/apps/Frontend/src/pages/insurance-status-page.tsx index 2cf6d514..097bba27 100755 --- a/apps/Frontend/src/pages/insurance-status-page.tsx +++ b/apps/Frontend/src/pages/insurance-status-page.tsx @@ -38,6 +38,7 @@ import { PdfPreviewModal } from "@/components/insurance-status/pdf-preview-modal import { DualPdfPreviewModal } from "@/components/insurance-status/dual-pdf-preview-modal"; import { useLocation } from "wouter"; import { DdmaEligibilityButton } from "@/components/insurance-status/ddma-buton-modal"; +import { MetLifeEligibilityButton } from "@/components/insurance-status/metlife-button-modal"; import { DeltaIndivEligibilityButton } from "@/components/insurance-status/deltaindiv-button-modal"; import { DeltaInsEligibilityButton } from "@/components/insurance-status/deltains-button-modal"; import { TuftsSCOEligibilityButton } from "@/components/insurance-status/tufts-sco-button-modal"; @@ -175,7 +176,7 @@ export default function InsuranceStatusPage() { const [cmspAccumulatorPdfId, setCmspAccumulatorPdfId] = useState(null); const [cmspAccumulatorFilename, setCmspAccumulatorFilename] = useState(null); - const pendingAutoCheck = useRef<"mh" | "mh-history" | "cmsp" | "ddma" | "delta-ins" | "delta-indiv" | "united-sco" | "united-ucard" | "tufts-sco" | "cca" | "bcbs-ma" | null>(null); + const pendingAutoCheck = useRef<"mh" | "mh-history" | "cmsp" | "ddma" | "delta-ins" | "delta-indiv" | "united-sco" | "united-ucard" | "tufts-sco" | "cca" | "bcbs-ma" | "metlife" | null>(null); const [triggerTarget, setTriggerTarget] = useState(null); const pendingScrollTo = useRef(null); const [prefillTick, setPrefillTick] = useState(0); @@ -916,7 +917,7 @@ export default function InsuranceStatusPage() { const autoCheck = params.get("autoCheck") as typeof pendingAutoCheck.current; const scrollTo = params.get("scrollTo"); - const knownAutoChecks = ["mh", "mh-history", "cmsp", "ddma", "delta-ins", "delta-indiv", "united-sco", "united-ucard", "tufts-sco", "cca", "bcbs-ma"] as const; + const knownAutoChecks = ["mh", "mh-history", "cmsp", "ddma", "delta-ins", "delta-indiv", "united-sco", "united-ucard", "tufts-sco", "cca", "bcbs-ma", "metlife"] as const; if (autoCheck && (knownAutoChecks as readonly string[]).includes(autoCheck)) { pendingAutoCheck.current = autoCheck; } else if (scrollTo) { @@ -1273,14 +1274,28 @@ export default function InsuranceStatusPage() { Altus - +
+ setTriggerTarget(null)} + onPdfReady={async (pdfId, fallbackFilename) => { + await queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE }); + const claimed = await tryClaimFromChatbot(selectedPatient?.id); + void tryAppointmentFromChatbot(); + if (!claimed) { + setPreviewPdfId(pdfId); + setPreviewFallbackFilename(fallbackFilename ?? `eligibility_metlife_${memberId}.pdf`); + setPreviewOpen(true); + } + processNextInQueue(); + }} + /> +