From ef18a0017cd7f2d7f77ba9a46f48786548cdf118 Mon Sep 17 00:00:00 2001 From: Emile Date: Mon, 5 Jan 2026 22:01:39 -0500 Subject: [PATCH 1/4] feat(ddma_browser_manager) - added singleton browser manager for persistent Chrome instance; updated Selenium service to utilize the manager for session handling and OTP processing --- apps/SeleniumService/ddma_browser_manager.py | 102 +++++++++++ .../helpers_ddma_eligibility.py | 36 ++-- .../selenium_DDMA_eligibilityCheckWorker.py | 170 ++++++++++++++---- 3 files changed, 266 insertions(+), 42 deletions(-) create mode 100644 apps/SeleniumService/ddma_browser_manager.py diff --git a/apps/SeleniumService/ddma_browser_manager.py b/apps/SeleniumService/ddma_browser_manager.py new file mode 100644 index 0000000..57f165b --- /dev/null +++ b/apps/SeleniumService/ddma_browser_manager.py @@ -0,0 +1,102 @@ +""" +Minimal browser manager for DDMA - only handles persistent profile and keeping browser alive. +Does NOT modify any login/OTP logic. +""" +import os +import threading +from selenium import webdriver +from selenium.webdriver.chrome.service import Service +from webdriver_manager.chrome import ChromeDriverManager + + +class DDMABrowserManager: + """ + Singleton that manages a persistent Chrome browser instance. + - Uses --user-data-dir for persistent profile (device trust tokens, cookies) + - Keeps browser alive between patient runs + """ + _instance = None + _lock = threading.Lock() + + def __new__(cls): + with cls._lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._driver = None + cls._instance.profile_dir = os.path.abspath("chrome_profile_ddma") + cls._instance.download_dir = os.path.abspath("seleniumDownloads") + os.makedirs(cls._instance.profile_dir, exist_ok=True) + os.makedirs(cls._instance.download_dir, exist_ok=True) + return cls._instance + + def get_driver(self, headless=False): + """Get or create the persistent browser instance.""" + with self._lock: + if self._driver is None: + print("[BrowserManager] Driver is None, creating new driver") + self._create_driver(headless) + elif not self._is_alive(): + print("[BrowserManager] Driver not alive, recreating") + self._create_driver(headless) + else: + print("[BrowserManager] Reusing existing driver") + return self._driver + + def _is_alive(self): + """Check if browser is still responsive.""" + try: + url = self._driver.current_url + print(f"[BrowserManager] Driver alive, current URL: {url[:50]}...") + return True + except Exception as e: + print(f"[BrowserManager] Driver not alive: {e}") + return False + + def _create_driver(self, headless=False): + """Create browser with persistent profile.""" + if self._driver: + try: + self._driver.quit() + except: + pass + + options = webdriver.ChromeOptions() + if headless: + options.add_argument("--headless") + + # Persistent profile - THIS IS THE KEY for device trust + options.add_argument(f"--user-data-dir={self.profile_dir}") + options.add_argument("--no-sandbox") + options.add_argument("--disable-dev-shm-usage") + + prefs = { + "download.default_directory": self.download_dir, + "plugins.always_open_pdf_externally": True, + "download.prompt_for_download": False, + "download.directory_upgrade": True + } + options.add_experimental_option("prefs", prefs) + + service = Service(ChromeDriverManager().install()) + self._driver = webdriver.Chrome(service=service, options=options) + self._driver.maximize_window() + + def quit_driver(self): + """Quit browser (only call on shutdown).""" + with self._lock: + if self._driver: + try: + self._driver.quit() + except: + pass + self._driver = None + + +# Singleton accessor +_manager = None + +def get_browser_manager(): + global _manager + if _manager is None: + _manager = DDMABrowserManager() + return _manager diff --git a/apps/SeleniumService/helpers_ddma_eligibility.py b/apps/SeleniumService/helpers_ddma_eligibility.py index 0177013..c9db23c 100644 --- a/apps/SeleniumService/helpers_ddma_eligibility.py +++ b/apps/SeleniumService/helpers_ddma_eligibility.py @@ -60,14 +60,8 @@ async def cleanup_session(sid: str, message: str | None = None): except Exception: pass - # Attempt to quit driver (may already be dead) - driver = s.get("driver") - if driver: - try: - driver.quit() - except Exception: - # ignore errors from quit (session already gone) - pass + # NOTE: Do NOT quit driver - keep browser alive for next patient + # Browser manager handles the persistent browser instance finally: # Remove session entry from map @@ -126,8 +120,15 @@ async def start_ddma_run(sid: str, data: dict, url: str): await cleanup_session(sid, s["message"]) return {"status": "error", "message": s["message"]} + # Already logged in - session persisted from profile, skip to step1 + if isinstance(login_result, str) and login_result == "ALREADY_LOGGED_IN": + print("[start_ddma_run] Session persisted - skipping OTP") + s["status"] = "running" + s["message"] = "Session persisted" + # Continue to step1 below + # OTP required path - if 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["message"] = "OTP required for login" s["last_activity"] = time.time() @@ -147,10 +148,20 @@ async def start_ddma_run(sid: str, data: dict, url: str): await cleanup_session(sid) return {"status": "error", "message": "OTP missing after event"} - # Submit OTP in the same Selenium window + # Submit OTP - check if it's in a popup window try: driver = s["driver"] wait = WebDriverWait(driver, 30) + + # 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( @@ -169,6 +180,11 @@ async def start_ddma_run(sid: str, data: dict, url: str): submit_btn.click() except Exception: otp_input.send_keys("\n") + + # Wait for verification and switch back to main window if needed + await asyncio.sleep(2) + if len(driver.window_handles) > 0: + driver.switch_to.window(driver.window_handles[0]) s["status"] = "otp_submitted" s["last_activity"] = time.time() diff --git a/apps/SeleniumService/selenium_DDMA_eligibilityCheckWorker.py b/apps/SeleniumService/selenium_DDMA_eligibilityCheckWorker.py index d7dc0ea..1f3a010 100644 --- a/apps/SeleniumService/selenium_DDMA_eligibilityCheckWorker.py +++ b/apps/SeleniumService/selenium_DDMA_eligibilityCheckWorker.py @@ -10,6 +10,8 @@ import time import os import base64 +from ddma_browser_manager import get_browser_manager + class AutomationDeltaDentalMAEligibilityCheck: def __init__(self, data): self.headless = False @@ -24,31 +26,139 @@ class AutomationDeltaDentalMAEligibilityCheck: self.massddma_username = self.data.get("massddmaUsername", "") self.massddma_password = self.data.get("massddmaPassword", "") - self.download_dir = os.path.abspath("seleniumDownloads") + # Use browser manager's download dir + self.download_dir = get_browser_manager().download_dir os.makedirs(self.download_dir, exist_ok=True) def config_driver(self): - options = webdriver.ChromeOptions() - if self.headless: - options.add_argument("--headless") - - # Add PDF download preferences - prefs = { - "download.default_directory": self.download_dir, - "plugins.always_open_pdf_externally": True, - "download.prompt_for_download": False, - "download.directory_upgrade": True - } - options.add_experimental_option("prefs", prefs) - - s = Service(ChromeDriverManager().install()) - driver = webdriver.Chrome(service=s, options=options) - self.driver = driver + # Use persistent browser from manager (keeps device trust tokens) + self.driver = get_browser_manager().get_driver(self.headless) def login(self, url): wait = WebDriverWait(self.driver, 30) try: + # First check if we're already on a logged-in page (from previous run) + try: + current_url = self.driver.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"] + 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(): + print(f"[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(): + # Try to find a link to member search or just check for search input + try: + member_search = WebDriverWait(self.driver, 5).until( + EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]')) + ) + print("[login] Found member search input - returning ALREADY_LOGGED_IN") + return "ALREADY_LOGGED_IN" + except TimeoutException: + # Try navigating to members page + members_url = "https://providers.deltadentalma.com/members" + print(f"[login] Navigating to members page: {members_url}") + self.driver.get(members_url) + time.sleep(2) + + # Verify we have the member search input + try: + member_search = WebDriverWait(self.driver, 5).until( + EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]')) + ) + print("[login] Member search found - ALREADY_LOGGED_IN") + return "ALREADY_LOGGED_IN" + except TimeoutException: + print("[login] Could not find member search, will try login") + except Exception as e: + print(f"[login] Error checking current state: {e}") + + # Navigate to login URL self.driver.get(url) + time.sleep(2) # Wait for page to load and any redirects + + # Check if we got redirected to member search (session still valid) + try: + current_url = self.driver.current_url + print(f"[login] URL after navigation: {current_url}") + + if "onboarding" not in current_url.lower(): + member_search = WebDriverWait(self.driver, 3).until( + EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]')) + ) + if member_search: + print("[login] Session valid - skipping login") + return "ALREADY_LOGGED_IN" + except TimeoutException: + print("[login] Proceeding with login") + + # Dismiss any "Authentication flow continued in another tab" modal + modal_dismissed = False + try: + ok_button = WebDriverWait(self.driver, 3).until( + EC.element_to_be_clickable((By.XPATH, "//button[normalize-space(text())='Ok' or normalize-space(text())='OK']")) + ) + ok_button.click() + print("[login] Dismissed authentication modal") + modal_dismissed = True + time.sleep(2) + + # Check if a popup window opened for authentication + all_windows = self.driver.window_handles + print(f"[login] Windows after modal dismiss: {len(all_windows)}") + + if len(all_windows) > 1: + # Switch to the auth popup + original_window = self.driver.current_window_handle + for window in all_windows: + if window != original_window: + self.driver.switch_to.window(window) + print(f"[login] Switched to auth popup window") + break + + # Look for OTP input in the popup + try: + otp_candidate = WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located( + (By.XPATH, "//input[contains(@aria-lable,'Verification code') or contains(@placeholder,'Enter your verification code') or contains(@aria-label,'Verification code')]") + ) + ) + if otp_candidate: + print("[login] OTP input found in popup -> OTP_REQUIRED") + return "OTP_REQUIRED" + except TimeoutException: + print("[login] No OTP in popup, checking main window") + self.driver.switch_to.window(original_window) + + except TimeoutException: + pass # No modal present + + # If modal was dismissed but no popup, page might have changed - wait and check + if modal_dismissed: + time.sleep(2) + # Check if we're now on member search page (already authenticated) + try: + member_search = WebDriverWait(self.driver, 5).until( + EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]')) + ) + if member_search: + print("[login] Already authenticated after modal dismiss") + return "ALREADY_LOGGED_IN" + except TimeoutException: + pass + + # Try to fill login form + try: + email_field = WebDriverWait(self.driver, 10).until( + EC.element_to_be_clickable((By.XPATH, "//input[@name='username' and @type='text']")) + ) + except TimeoutException: + print("[login] Could not find login form - page may have changed") + return "ERROR: Login form not found" + email_field = wait.until(EC.presence_of_element_located((By.XPATH, "//input[@name='username' and @type='text']"))) email_field.clear() email_field.send_keys(self.massddma_username) @@ -226,6 +336,15 @@ class AutomationDeltaDentalMAEligibilityCheck: pass print("Screenshot saved at:", screenshot_path) + + # Close the browser window after screenshot (session preserved in profile) + try: + from ddma_browser_manager import get_browser_manager + get_browser_manager().quit_driver() + print("[step2] Browser closed - session preserved in profile") + except Exception as e: + print(f"[step2] Error closing browser: {e}") + output = { "status": "success", "eligibility": eligibilityText, @@ -254,14 +373,7 @@ class AutomationDeltaDentalMAEligibilityCheck: print(f"[cleanup] unexpected error while cleaning downloads dir: {cleanup_exc}") return {"status": "error", "message": str(e)} - finally: - # Keep your existing quit behavior; if you want the driver to remain open for further - # actions, remove or change this. - if self.driver: - try: - self.driver.quit() - except Exception: - pass + # NOTE: Do NOT quit driver here - keep browser alive for next patient def main_workflow(self, url): try: @@ -289,10 +401,4 @@ class AutomationDeltaDentalMAEligibilityCheck: "status": "error", "message": e } - - finally: - try: - if self.driver: - self.driver.quit() - except Exception: - pass + # NOTE: Do NOT quit driver - keep browser alive for next patient From 0961c016604f60cb7bcffcbe2172cf85d1cb5fb9 Mon Sep 17 00:00:00 2001 From: Emile Date: Mon, 5 Jan 2026 22:45:47 -0500 Subject: [PATCH 2/4] chore(.gitignore) - added rule to ignore Chrome profile files for cleaner repository --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index ee0c31b..311c765 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,4 @@ dist/ # env *.env +*chrome_profile_ddma* \ No newline at end of file From 390767218580a416c9d5ed90aba9ecb411638b31 Mon Sep 17 00:00:00 2001 From: Emile Date: Tue, 6 Jan 2026 09:35:57 -0500 Subject: [PATCH 3/4] Update service dependencies --- apps/PatientDataExtractorService/package.json | 2 +- apps/PaymentOCRService/package.json | 2 +- package-lock.json | 34 +++++++++++++++++-- 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/apps/PatientDataExtractorService/package.json b/apps/PatientDataExtractorService/package.json index 28bf570..62470d3 100644 --- a/apps/PatientDataExtractorService/package.json +++ b/apps/PatientDataExtractorService/package.json @@ -3,6 +3,6 @@ "private": true, "scripts": { "postinstall": "pip install -r requirements.txt", - "dev": "python main.py" + "dev": "python3 main.py" } } diff --git a/apps/PaymentOCRService/package.json b/apps/PaymentOCRService/package.json index 4674a63..d082d6a 100644 --- a/apps/PaymentOCRService/package.json +++ b/apps/PaymentOCRService/package.json @@ -3,6 +3,6 @@ "private": true, "scripts": { "postinstall": "pip install -r requirements.txt", - "dev": "python main.py" + "dev": "python3 main.py" } } diff --git a/package-lock.json b/package-lock.json index 26c024f..974cc0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -239,6 +239,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -586,7 +587,8 @@ "version": "0.3.2", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.2.tgz", "integrity": "sha512-zfWWa+V2ViDCY/cmUfRqeWY1yLto+EpxjXnZzenB1TyxsTiXaTWeZFIZw6mac52BsuQm0RjCnisjBtdBaXOI6w==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/@electric-sql/pglite-socket": { "version": "0.0.6", @@ -4560,6 +4562,7 @@ "integrity": "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "hoist-non-react-statics": "^3.3.0" }, @@ -4619,6 +4622,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.11.tgz", "integrity": "sha512-y+cTCACu92FyA5fgQSAI8A1H429g7aSK2HsO7K4XYUWc4dY5IUz55JSDIYT6/VsOLfGy8vmvQYC2hfb0iF16Uw==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.19.2" } @@ -4683,6 +4687,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4693,6 +4698,7 @@ "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4843,6 +4849,7 @@ "integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.3", "@typescript-eslint/types": "8.46.3", @@ -5126,6 +5133,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5648,6 +5656,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -6696,7 +6705,8 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/embla-carousel-react": { "version": "8.6.0", @@ -7013,6 +7023,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8220,6 +8231,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.7.10.tgz", "integrity": "sha512-QkACju9MiN59CKSY5JsGZCYmPZkA6sIW6OFCUp7qDjZu6S6KHtJHhAc9Uy9mV9F8PJ1/HQ3ybZF2yjCa/73fvQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -9998,6 +10010,7 @@ "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz", "integrity": "sha512-TdTZPf1trZ8/UFu5Cx/GXB7GZM30LT+wWUNfsi6Bq8ePLnb+woNKtDymI2mxZYBpMbonNFqKmiz684DIfnd8dA==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=18" }, @@ -10030,6 +10043,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -10185,6 +10199,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -10402,6 +10417,7 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -11128,6 +11144,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -11219,6 +11236,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -11231,6 +11249,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz", "integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -11262,6 +11281,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -11521,7 +11541,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -12486,6 +12507,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -12766,6 +12788,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -13037,6 +13060,7 @@ "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -13212,6 +13236,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13489,6 +13514,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -13580,6 +13606,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -13891,6 +13918,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } From 279a6b8dbc5f3974c2a4b953a97862842749dd7f Mon Sep 17 00:00:00 2001 From: Emile Date: Tue, 20 Jan 2026 22:08:06 -0500 Subject: [PATCH 4/4] feat(dentaquest) - implement DentaQuest eligibility check with Selenium integration; added routes, services, and frontend components for OTP handling and eligibility status retrieval --- .gitignore | 3 +- apps/Backend/src/routes/index.ts | 2 + .../src/routes/insuranceStatusDentaQuest.ts | 700 ++++++++++++++++++ ...iumDentaQuestInsuranceEligibilityClient.ts | 123 +++ .../dentaquest-button-modal.tsx | 566 ++++++++++++++ .../src/pages/insurance-status-page.tsx | 23 +- apps/SeleniumService/agent.py | 89 +++ apps/SeleniumService/ddma_browser_manager.py | 191 ++++- .../dentaquest_browser_manager.py | 277 +++++++ .../helpers_dentaquest_eligibility.py | 264 +++++++ .../selenium_DDMA_eligibilityCheckWorker.py | 58 ++ ...enium_DentaQuest_eligibilityCheckWorker.py | 479 ++++++++++++ packages/db/types/patient-types.ts | 4 +- 13 files changed, 2760 insertions(+), 19 deletions(-) create mode 100644 apps/Backend/src/routes/insuranceStatusDentaQuest.ts create mode 100644 apps/Backend/src/services/seleniumDentaQuestInsuranceEligibilityClient.ts create mode 100644 apps/Frontend/src/components/insurance-status/dentaquest-button-modal.tsx create mode 100644 apps/SeleniumService/dentaquest_browser_manager.py create mode 100644 apps/SeleniumService/helpers_dentaquest_eligibility.py create mode 100644 apps/SeleniumService/selenium_DentaQuest_eligibilityCheckWorker.py diff --git a/.gitignore b/.gitignore index 311c765..8cb82bf 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,5 @@ dist/ # env *.env -*chrome_profile_ddma* \ No newline at end of file +*chrome_profile_ddma* +*chrome_profile_dentaquest* \ No newline at end of file diff --git a/apps/Backend/src/routes/index.ts b/apps/Backend/src/routes/index.ts index 96a45e9..4eb1ad1 100644 --- a/apps/Backend/src/routes/index.ts +++ b/apps/Backend/src/routes/index.ts @@ -9,6 +9,7 @@ import insuranceCredsRoutes from "./insuranceCreds"; import documentsRoutes from "./documents"; import insuranceStatusRoutes from "./insuranceStatus"; import insuranceStatusDdmaRoutes from "./insuranceStatusDDMA"; +import insuranceStatusDentaQuestRoutes from "./insuranceStatusDentaQuest"; import paymentsRoutes from "./payments"; import databaseManagementRoutes from "./database-management"; import notificationsRoutes from "./notifications"; @@ -29,6 +30,7 @@ router.use("/insuranceCreds", insuranceCredsRoutes); router.use("/documents", documentsRoutes); router.use("/insurance-status", insuranceStatusRoutes); router.use("/insurance-status-ddma", insuranceStatusDdmaRoutes); +router.use("/insurance-status-dentaquest", insuranceStatusDentaQuestRoutes); router.use("/payments", paymentsRoutes); router.use("/database-management", databaseManagementRoutes); router.use("/notifications", notificationsRoutes); diff --git a/apps/Backend/src/routes/insuranceStatusDentaQuest.ts b/apps/Backend/src/routes/insuranceStatusDentaQuest.ts new file mode 100644 index 0000000..544565d --- /dev/null +++ b/apps/Backend/src/routes/insuranceStatusDentaQuest.ts @@ -0,0 +1,700 @@ +import { Router, Request, Response } from "express"; +import { storage } from "../storage"; +import { + forwardToSeleniumDentaQuestEligibilityAgent, + forwardOtpToSeleniumDentaQuestAgent, + getSeleniumDentaQuestSessionStatus, +} from "../services/seleniumDentaQuestInsuranceEligibilityClient"; +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"; + +const router = Router(); + +/** Job context stored in memory by sessionId */ +interface DentaQuestJobContext { + userId: number; + insuranceEligibilityData: any; // parsed, enriched (includes username/password) + socketId?: string; +} + +const dentaquestJobs: Record = {}; + +/** 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 { + return new Promise((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 handleDentaQuestCompletedJob( + sessionId: string, + job: DentaQuestJobContext, + 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 DentaQuest 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 = `dentaquest_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 DentaQuest 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( + `[dentaquest-eligibility] no pdf_path or ss_path available to cleanup` + ); + } + } catch (cleanupErr) { + console.error( + `[dentaquest-eligibility cleanup failed for ${seleniumResult?.pdf_path ?? seleniumResult?.ss_path}]`, + cleanupErr + ); + } + } +} + +// --- top of file, alongside dentaquestJobs --- +let currentFinalSessionId: string | null = null; +let currentFinalResult: any = null; + +function now() { + return new Date().toISOString(); +} +function log(tag: string, msg: string, ctx?: any) { + console.log(`${now()} [${tag}] ${msg}`, ctx ?? ""); +} + +function emitSafe(socketId: string | undefined, event: string, payload: any) { + if (!socketId) { + log("socket", "no socketId for emit", { event }); + return; + } + try { + const socket = io?.sockets.sockets.get(socketId); + if (!socket) { + log("socket", "socket not found (maybe disconnected)", { + socketId, + event, + }); + return; + } + socket.emit(event, payload); + log("socket", "emitted", { socketId, event }); + } catch (err: any) { + 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 + * - absolute 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 = dentaquestJobs[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 dentaquestJobs[sessionId]; + return; + } + + log( + "poller", + `attempt=${attempt} session=${sessionId} transientErrCount=${transientErrorCount}` + ); + + try { + const st = await getSeleniumDentaQuestSessionStatus(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 dentaquestJobs[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 handleDentaQuestCompletedJob( + 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", "handleDentaQuestCompletedJob failed", { + sessionId, + err: err?.message ?? err, + }); + } + } else { + currentFinalResult.final = { + error: "no_job_or_no_result", + }; + currentFinalResult.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 dentaquestJobs[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 dentaquestJobs[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 dentaquestJobs[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 dentaquestJobs[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 dentaquestJobs[sessionId]; +} + +/** + * POST /dentaquest-eligibility + * Starts DentaQuest eligibility Selenium job. + * Expects: + * - req.body.data: stringified JSON like your existing /eligibility-check + * - req.body.socketId: socket.io client id + */ +router.post( + "/dentaquest-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 || !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 insurance credentials found for this provider, Kindly Update this at Settings Page.", + }); + } + + const enrichedData = { + ...rawData, + dentaquestUsername: credentials.username, + dentaquestPassword: credentials.password, + }; + + const socketId: string | undefined = req.body.socketId; + + const agentResp = + await forwardToSeleniumDentaQuestEligibilityAgent(enrichedData); + + 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 + dentaquestJobs[sessionId] = { + userId: req.user.id, + insuranceEligibilityData: enrichedData, + socketId, + }; + + // start polling in background to notify client via socket and process job + pollAgentSessionAndProcess(sessionId, socketId).catch((e) => + console.warn("pollAgentSessionAndProcess failed", e) + ); + + // reply immediately with started status + return res.json({ status: "started", session_id: sessionId }); + } catch (err: any) { + console.error(err); + return res.status(500).json({ + error: err.message || "Failed to start DentaQuest selenium agent", + }); + } + } +); + +/** + * POST /selenium/submit-otp + * Body: { session_id, otp, socketId? } + * Forwards OTP to Python agent and optionally notifies client socket. + */ +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 forwardOtpToSeleniumDentaQuestAgent(sessionId, otp); + + // emit OTP accepted (if socket present) + emitSafe(socketId, "selenium:otp_submitted", { + session_id: sessionId, + result: r, + }); + + return res.json(r); + } catch (err: any) { + console.error( + "Failed to forward OTP:", + err?.response?.data || err?.message || err + ); + return res.status(500).json({ + error: "Failed to forward otp to selenium agent", + 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; + diff --git a/apps/Backend/src/services/seleniumDentaQuestInsuranceEligibilityClient.ts b/apps/Backend/src/services/seleniumDentaQuestInsuranceEligibilityClient.ts new file mode 100644 index 0000000..89c5fcf --- /dev/null +++ b/apps/Backend/src/services/seleniumDentaQuestInsuranceEligibilityClient.ts @@ -0,0 +1,123 @@ +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-dentaquest-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-dentaquest-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 forwardToSeleniumDentaQuestEligibilityAgent( + insuranceEligibilityData: any +): Promise { + const payload = { data: insuranceEligibilityData }; + const url = `/dentaquest-eligibility`; + log("selenium-dentaquest-client", "POST dentaquest-eligibility", { + url: SELENIUM_AGENT_BASE + url, + keys: Object.keys(payload), + }); + const r = await requestWithRetries({ url, method: "POST", data: payload }, 4); + log("selenium-dentaquest-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 forwardOtpToSeleniumDentaQuestAgent( + sessionId: string, + otp: string +): Promise { + const url = `/dentaquest-submit-otp`; + log("selenium-dentaquest-client", "POST dentaquest-submit-otp", { + url: SELENIUM_AGENT_BASE + url, + sessionId, + }); + const r = await requestWithRetries( + { url, method: "POST", data: { session_id: sessionId, otp } }, + 4 + ); + log("selenium-dentaquest-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 getSeleniumDentaQuestSessionStatus( + sessionId: string +): Promise { + const url = `/dentaquest-session/${sessionId}/status`; + log("selenium-dentaquest-client", "GET session status", { + url: SELENIUM_AGENT_BASE + url, + sessionId, + }); + const r = await requestWithRetries({ url, method: "GET" }, 4); + log("selenium-dentaquest-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/dentaquest-button-modal.tsx b/apps/Frontend/src/components/insurance-status/dentaquest-button-modal.tsx new file mode 100644 index 0000000..a183767 --- /dev/null +++ b/apps/Frontend/src/components/insurance-status/dentaquest-button-modal.tsx @@ -0,0 +1,566 @@ +import { useEffect, useRef, useState } from "react"; +import { io as ioClient, Socket } from "socket.io-client"; +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/seleniumEligibilityCheckTaskSlice"; +import { formatLocalDate } from "@/utils/dateUtils"; +import { QK_PATIENTS_BASE } from "@/components/patients/patient-table"; + +const SOCKET_URL = + import.meta.env.VITE_API_BASE_URL_BACKEND || + (typeof window !== "undefined" ? window.location.origin : ""); + +// ---------- OTP Modal component ---------- +interface DentaQuestOtpModalProps { + open: boolean; + onClose: () => void; + onSubmit: (otp: string) => Promise | void; + isSubmitting: boolean; +} + +function DentaQuestOtpModal({ + open, + onClose, + onSubmit, + isSubmitting, +}: DentaQuestOtpModalProps) { + 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 password (OTP) sent by the DentaQuest portal + to complete this eligibility check. +

+
+
+ + setOtp(e.target.value)} + autoFocus + /> +
+
+ + +
+
+
+
+ ); +} + +// ---------- Main DentaQuest Eligibility button component ---------- +interface DentaQuestEligibilityButtonProps { + memberId: string; + dateOfBirth: Date | null; + firstName?: string; + lastName?: string; + isFormIncomplete: boolean; + /** Called when backend has finished and PDF is ready */ + onPdfReady: (pdfId: number, fallbackFilename: string | null) => void; +} + +export function DentaQuestEligibilityButton({ + memberId, + dateOfBirth, + firstName, + lastName, + isFormIncomplete, + onPdfReady, +}: DentaQuestEligibilityButtonProps) { + const { toast } = useToast(); + const dispatch = useAppDispatch(); + + const socketRef = useRef(null); + const connectingRef = useRef | null>(null); + + const [sessionId, setSessionId] = useState(null); + const [otpModalOpen, setOtpModalOpen] = useState(false); + const [isStarting, setIsStarting] = useState(false); + const [isSubmittingOtp, setIsSubmittingOtp] = useState(false); + + // Clean up socket on unmount + useEffect(() => { + return () => { + if (socketRef.current) { + socketRef.current.removeAllListeners(); + socketRef.current.disconnect(); + socketRef.current = null; + } + connectingRef.current = null; + }; + }, []); + + const closeSocket = () => { + 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((resolve, reject) => { + const socket = ioClient(SOCKET_URL, { + withCredentials: true, + }); + + socketRef.current = socket; + + socket.on("connect", () => { + resolve(); + }); + + // connection error when first connecting (or later) + socket.on("connect_error", (err: any) => { + dispatch( + setTaskStatus({ + 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({ + status: "pending", + message: `Realtime reconnect attempt #${attempt}`, + }) + ); + }); + + // when reconnection failed after configured attempts + socket.on("reconnect_failed", () => { + dispatch( + setTaskStatus({ + 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({ + status: "error", + message: "Connection disconnected", + }) + ); + toast({ + title: "Connection Disconnected", + description: + "Connection to the server was lost. If a DentaQuest 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({ + status: "pending", + message: "OTP required for DentaQuest eligibility. Please enter the OTP.", + }) + ); + }); + + // OTP submitted (optional UX) + socket.on("selenium:otp_submitted", (payload: any) => { + if (!payload?.session_id) return; + dispatch( + setTaskStatus({ + status: "pending", + message: "OTP submitted. Finishing DentaQuest 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({ + status: "success", + message: + "DentaQuest eligibility updated and PDF attached to patient documents.", + }) + ); + toast({ + title: "DentaQuest 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_dentaquest_${memberId}.pdf`; + onPdfReady(Number(pdfId), filename); + } + + setSessionId(null); + setOtpModalOpen(false); + } else if (status === "error") { + const msg = + payload?.message || + final?.error || + "DentaQuest eligibility session failed."; + dispatch( + setTaskStatus({ + status: "error", + message: msg, + }) + ); + toast({ + title: "DentaQuest 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({ + 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 startDentaQuestEligibility = async () => { + if (!memberId || !dateOfBirth) { + toast({ + title: "Missing fields", + description: "Member ID and Date of Birth are required.", + variant: "destructive", + }); + return; + } + + const formattedDob = dateOfBirth ? formatLocalDate(dateOfBirth) : ""; + + const payload = { + memberId, + dateOfBirth: formattedDob, + firstName, + lastName, + insuranceSiteKey: "DENTAQUEST", // make sure this matches backend credential key + }; + + try { + setIsStarting(true); + + // 1) Ensure socket is connected (lazy) + dispatch( + setTaskStatus({ + status: "pending", + message: "Opening realtime channel for DentaQuest eligibility...", + }) + ); + 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({ + status: "pending", + message: "Starting DentaQuest eligibility check via selenium...", + }) + ); + + const response = await apiRequest( + "POST", + "/api/insurance-status-dentaquest/dentaquest-eligibility", + { + data: JSON.stringify(payload), + socketId, + } + ); + + // If apiRequest threw, we would have caught above; but just in case it returns. + let result: any = null; + let backendError: string | null = null; + + 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) { + throw new Error( + backendError || + `DentaQuest 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( + setTaskStatus({ + status: "pending", + message: + "DentaQuest eligibility job started. Waiting for OTP or final result...", + }) + ); + } else { + // fallback if backend returns immediate result + dispatch( + setTaskStatus({ + status: "success", + message: "DentaQuest eligibility completed.", + }) + ); + } + } catch (err: any) { + console.error("startDentaQuestEligibility error:", err); + dispatch( + setTaskStatus({ + status: "error", + message: err?.message || "Failed to start DentaQuest eligibility", + }) + ); + toast({ + title: "DentaQuest selenium error", + description: err?.message || "Failed to start DentaQuest eligibility", + variant: "destructive", + }); + } finally { + setIsStarting(false); + } + }; + + const handleSubmitOtp = async (otp: string) => { + if (!sessionId || !socketRef.current || !socketRef.current.connected) { + toast({ + title: "Session not ready", + description: + "Could not submit OTP because the DentaQuest session or socket is not ready.", + variant: "destructive", + }); + return; + } + + try { + setIsSubmittingOtp(true); + const resp = await apiRequest( + "POST", + "/api/insurance-status-dentaquest/selenium/submit-otp", + { + session_id: sessionId, + otp, + socketId: socketRef.current.id, + } + ); + const data = await resp.json(); + if (!resp.ok || data.error) { + throw new Error(data.error || "Failed to submit OTP"); + } + + // from here we rely on websocket events (otp_submitted + session_update) + 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); + } + }; + + return ( + <> + + + setOtpModalOpen(false)} + onSubmit={handleSubmitOtp} + isSubmitting={isSubmittingOtp} + /> + + ); +} + diff --git a/apps/Frontend/src/pages/insurance-status-page.tsx b/apps/Frontend/src/pages/insurance-status-page.tsx index 92951ec..549f636 100644 --- a/apps/Frontend/src/pages/insurance-status-page.tsx +++ b/apps/Frontend/src/pages/insurance-status-page.tsx @@ -28,6 +28,7 @@ import { QK_PATIENTS_BASE } from "@/components/patients/patient-table"; import { PdfPreviewModal } from "@/components/insurance-status/pdf-preview-modal"; import { useLocation } from "wouter"; import { DdmaEligibilityButton } from "@/components/insurance-status/ddma-buton-modal"; +import { DentaQuestEligibilityButton } from "@/components/insurance-status/dentaquest-button-modal"; export default function InsuranceStatusPage() { const { user } = useAuth(); @@ -616,14 +617,20 @@ export default function InsuranceStatusPage() { {/* Row 2 */}
- + { + setPreviewPdfId(pdfId); + setPreviewFallbackFilename( + fallbackFilename ?? `eligibility_dentaquest_${memberId}.pdf` + ); + setPreviewOpen(true); + }} + />