diff --git a/apps/Backend/package.json b/apps/Backend/package.json index e482e6c7..7e256dbf 100755 --- a/apps/Backend/package.json +++ b/apps/Backend/package.json @@ -4,7 +4,7 @@ "description": "", "main": "index.js", "scripts": { - "dev": "ts-node-dev --respawn --transpile-only src/index.ts", + "dev": "ts-node-dev --respawn --transpile-only --watch ../../packages/db/types src/index.ts", "build": "tsc", "start": "node dist/index.js" }, diff --git a/apps/Backend/src/queue/processors/_shared.ts b/apps/Backend/src/queue/processors/_shared.ts index a8efb02f..ffa94e42 100644 --- a/apps/Backend/src/queue/processors/_shared.ts +++ b/apps/Backend/src/queue/processors/_shared.ts @@ -105,6 +105,10 @@ export async function createOrUpdatePatientByInsuranceId(options: { updates.firstName = incomingFirst; if (incomingLast && String(patient.lastName ?? "").trim() !== incomingLast) updates.lastName = incomingLast; + if (dob && !patient.dateOfBirth) { + const parsed = new Date(dob); + if (!isNaN(parsed.getTime())) updates.dateOfBirth = parsed; + } if (Object.keys(updates).length > 0) { await storage.updatePatient(patient.id, updates); patient = await storage.getPatientByInsuranceId(insuranceId); @@ -126,9 +130,15 @@ export async function createOrUpdatePatientByInsuranceId(options: { try { patientData = insertPatientSchema.parse(createPayload); } catch { + // Remove fields that may fail validation (invalid date or alphanumeric insuranceId) const safePayload = { ...createPayload }; delete safePayload.dateOfBirth; - patientData = insertPatientSchema.parse(safePayload); + try { + patientData = insertPatientSchema.parse(safePayload); + } catch { + // Last resort: skip schema validation and cast directly + patientData = safePayload as InsertPatient; + } } await storage.createPatient(patientData); diff --git a/apps/Backend/src/queue/processors/dentaQuestEligibilityProcessor.ts b/apps/Backend/src/queue/processors/dentaQuestEligibilityProcessor.ts index 83442717..ecce9f36 100644 --- a/apps/Backend/src/queue/processors/dentaQuestEligibilityProcessor.ts +++ b/apps/Backend/src/queue/processors/dentaQuestEligibilityProcessor.ts @@ -84,11 +84,16 @@ async function processDentaQuestResult( } const eligStatus = (seleniumResult?.eligibility ?? "").toLowerCase(); - const newStatus = eligStatus === "active" || eligStatus === "y" ? "ACTIVE" : "INACTIVE"; + const newStatus = + eligStatus === "active" || eligStatus === "y" + ? "ACTIVE" + : eligStatus.includes("plan not accepted") || eligStatus.includes("plan_not_accepted") + ? "PLAN_NOT_ACCEPTED" + : "INACTIVE"; await storage.updatePatient(patient.id, { status: newStatus, - insuranceProvider: "Tufts SCO", + insuranceProvider: "DentaQuest", }); output.patientUpdateStatus = `Patient status updated to ${newStatus}`; @@ -223,12 +228,15 @@ async function pollUntilDone( } if (status === "error" || status === "not_found") { - throw new Error(st?.message || `DentaQuest session ended with status: ${status}`); + const terminalErr: any = new Error(st?.message || `DentaQuest session ended with status: ${status}`); + terminalErr.terminal = true; + throw terminalErr; } await new Promise((r) => setTimeout(r, pollIntervalMs)); } catch (err: any) { const isTerminal = + err?.terminal === true || err?.response?.status === 404 || (typeof err?.message === "string" && (err.message.includes("not_found") || diff --git a/apps/Backend/src/queue/queues.ts b/apps/Backend/src/queue/queues.ts index e04f5d59..2d865a95 100644 --- a/apps/Backend/src/queue/queues.ts +++ b/apps/Backend/src/queue/queues.ts @@ -37,7 +37,7 @@ export interface OcrJobData { const defaultOpts = { removeOnComplete: { count: 100 }, removeOnFail: { count: 50 }, - attempts: 2, + attempts: 1, backoff: { type: "exponential" as const, delay: 5000 }, }; diff --git a/apps/Frontend/src/components/appointments/patient-status-badge.tsx b/apps/Frontend/src/components/appointments/patient-status-badge.tsx index 42ee5da5..312cee0b 100755 --- a/apps/Frontend/src/components/appointments/patient-status-badge.tsx +++ b/apps/Frontend/src/components/appointments/patient-status-badge.tsx @@ -48,8 +48,10 @@ function getVisuals(status: PatientStatus): { label: string; bg: string } { case "ACTIVE": return { label: "Active", bg: "#16A34A" }; // MEDICAL GREEN (not same as staff green) case "INACTIVE": - return { label: "Inactive", bg: "#DC2626" }; // ALERT RED (distinct from card red) + return { label: "Inactive", bg: "#DC2626" }; + case "PLAN_NOT_ACCEPTED": + return { label: "Plan Not Accepted", bg: "#F59E0B" }; // amber default: - return { label: "Unknown", bg: "#6B7280" }; // solid gray + return { label: "Unknown", bg: "#6B7280" }; } } diff --git a/apps/Frontend/src/components/patients/patient-form.tsx b/apps/Frontend/src/components/patients/patient-form.tsx index c72a08c4..d19f8abc 100755 --- a/apps/Frontend/src/components/patients/patient-form.tsx +++ b/apps/Frontend/src/components/patients/patient-form.tsx @@ -303,8 +303,10 @@ export const PatientForm = forwardRef( const options = Object.values( patientStatusOptions, ) as PatientStatus[]; // ['ACTIVE','INACTIVE','UNKNOWN'] - const toLabel = (v: PatientStatus) => - v[0] + v.slice(1).toLowerCase(); // ACTIVE -> Active + const toLabel = (v: PatientStatus) => { + if (v === "PLAN_NOT_ACCEPTED") return "Plan Not Accepted"; + return v[0] + v.slice(1).toLowerCase(); + }; return ( diff --git a/apps/Frontend/src/components/patients/patient-table.tsx b/apps/Frontend/src/components/patients/patient-table.tsx index 2972c451..c0671831 100755 --- a/apps/Frontend/src/components/patients/patient-table.tsx +++ b/apps/Frontend/src/components/patients/patient-table.tsx @@ -1071,6 +1071,12 @@ export function PatientTable({ Unknown )} + + {patient.status === "PLAN_NOT_ACCEPTED" && ( + + Plan Not Accepted + + )} @@ -1201,14 +1207,18 @@ export function PatientTable({ ? "text-green-600" : currentPatient.status === "INACTIVE" ? "text-red-600" - : "text-gray-600", // UNKNOWN or fallback + : currentPatient.status === "PLAN_NOT_ACCEPTED" + ? "text-amber-600" + : "text-gray-600", "font-medium" )} > - {currentPatient.status - ? currentPatient.status.charAt(0).toUpperCase() + - currentPatient.status.slice(1).toLowerCase() - : "Unknown"} + {currentPatient.status === "PLAN_NOT_ACCEPTED" + ? "Plan Not Accepted" + : currentPatient.status + ? currentPatient.status.charAt(0).toUpperCase() + + currentPatient.status.slice(1).toLowerCase() + : "Unknown"}

diff --git a/apps/SeleniumService/dentaquest_browser_manager.py b/apps/SeleniumService/dentaquest_browser_manager.py index d499d2c1..a173a598 100644 --- a/apps/SeleniumService/dentaquest_browser_manager.py +++ b/apps/SeleniumService/dentaquest_browser_manager.py @@ -111,7 +111,26 @@ class DentaQuestBrowserManager: print("[DentaQuest BrowserManager] Cleared IndexedDB") except Exception as e: print(f"[DentaQuest BrowserManager] Could not clear IndexedDB: {e}") - + + # Clear browser caches (prevents Chrome crash from corrupted cache) + cache_dirs = [ + os.path.join(self.profile_dir, "Default", "Cache"), + os.path.join(self.profile_dir, "Default", "Code Cache"), + os.path.join(self.profile_dir, "Default", "GPUCache"), + os.path.join(self.profile_dir, "Default", "Service Worker"), + os.path.join(self.profile_dir, "Cache"), + os.path.join(self.profile_dir, "Code Cache"), + os.path.join(self.profile_dir, "GPUCache"), + os.path.join(self.profile_dir, "ShaderCache"), + ] + for cache_dir in cache_dirs: + if os.path.exists(cache_dir): + try: + shutil.rmtree(cache_dir) + print(f"[DentaQuest BrowserManager] Cleared {os.path.basename(cache_dir)}") + except Exception as e: + print(f"[DentaQuest BrowserManager] Could not clear {os.path.basename(cache_dir)}: {e}") + # Set flag to clear session via JavaScript after browser opens self._needs_session_clear = True @@ -181,13 +200,13 @@ class DentaQuestBrowserManager: except Exception as e: pass - # Remove SingletonLock if exists - lock_file = os.path.join(self.profile_dir, "SingletonLock") - try: - if os.path.islink(lock_file) or os.path.exists(lock_file): - os.remove(lock_file) - except: - pass + for lock_file in ["SingletonLock", "SingletonSocket", "SingletonCookie"]: + lock_path = os.path.join(self.profile_dir, lock_file) + try: + if os.path.islink(lock_path) or os.path.exists(lock_path): + os.remove(lock_path) + except Exception: + pass def get_driver(self, headless=False): """Get or create the persistent browser instance.""" @@ -232,7 +251,12 @@ class DentaQuestBrowserManager: options.add_argument(f"--user-data-dir={self.profile_dir}") options.add_argument("--no-sandbox") options.add_argument("--disable-dev-shm-usage") - + options.add_argument("--disable-gpu") + options.add_argument("--disable-blink-features=AutomationControlled") + options.add_experimental_option("excludeSwitches", ["enable-automation"]) + options.add_experimental_option("useAutomationExtension", False) + options.add_argument("--disable-infobars") + prefs = { "download.default_directory": self.download_dir, "plugins.always_open_pdf_externally": True, diff --git a/apps/SeleniumService/helpers_dentaquest_eligibility.py b/apps/SeleniumService/helpers_dentaquest_eligibility.py index 76b12113..f579c332 100644 --- a/apps/SeleniumService/helpers_dentaquest_eligibility.py +++ b/apps/SeleniumService/helpers_dentaquest_eligibility.py @@ -274,6 +274,9 @@ async def start_dentaquest_run(sid: str, data: dict, url: str): return {"status": "error", "message": s["message"]} except Exception as e: + import traceback + print(f"[start_dentaquest_run] EXCEPTION: {e}") + traceback.print_exc() s["status"] = "error" s["message"] = f"worker exception: {e}" await cleanup_session(sid) diff --git a/apps/SeleniumService/selenium_DentaQuest_eligibilityCheckWorker.py b/apps/SeleniumService/selenium_DentaQuest_eligibilityCheckWorker.py index 67f81b80..f89f63c7 100644 --- a/apps/SeleniumService/selenium_DentaQuest_eligibilityCheckWorker.py +++ b/apps/SeleniumService/selenium_DentaQuest_eligibilityCheckWorker.py @@ -305,65 +305,40 @@ class AutomationDentaQuestEligibilityCheck: print(f"[DentaQuest step1] Error filling {field_name}: {e}") return False - # 1. Select Provider from dropdown (required field) + # 1. Select Location from dropdown (required field before search) try: - print("[DentaQuest step1] Selecting Provider...") - # Try to find and click Provider dropdown - provider_selectors = [ - "//label[contains(text(),'Provider')]/following-sibling::*//div[contains(@class,'select')]", - "//div[contains(@data-testid,'provider')]//div[contains(@class,'select')]", - "//*[@aria-label='Provider']", - "//select[contains(@name,'provider') or contains(@id,'provider')]", - "//div[contains(@class,'provider')]//input", - "//label[contains(text(),'Provider')]/..//div[contains(@class,'control')]" - ] - - provider_clicked = False - for selector in provider_selectors: + print("[DentaQuest step1] Selecting Location...") + + # Click the Location dropdown button (stable data-testid) + location_clicked = False + try: + trigger = WebDriverWait(self.driver, 5).until( + EC.element_to_be_clickable((By.XPATH, '//button[@data-testid="member-search_location_select-btn"]')) + ) + trigger.click() + print("[DentaQuest step1] Clicked location dropdown button") + time.sleep(0.5) + location_clicked = True + except TimeoutException: + print("[DentaQuest step1] Warning: Location button not found by data-testid") + + if location_clicked: + # Wait for options to appear and click the first one (role="option") try: - provider_dropdown = WebDriverWait(self.driver, 3).until( - EC.element_to_be_clickable((By.XPATH, selector)) + first_option = WebDriverWait(self.driver, 5).until( + EC.element_to_be_clickable((By.XPATH, "(//li[@role='option'])[1]")) ) - provider_dropdown.click() - print(f"[DentaQuest step1] Clicked provider dropdown with selector: {selector}") - time.sleep(0.5) - provider_clicked = True - break + opt_text = first_option.get_attribute("aria-label") or first_option.text.strip() + first_option.click() + print(f"[DentaQuest step1] Selected location: {opt_text[:60]}") + time.sleep(0.3) except TimeoutException: - continue - - if provider_clicked: - # Select first available provider option - option_selectors = [ - "//div[contains(@class,'option') and not(contains(@class,'disabled'))]", - "//li[contains(@class,'option')]", - "//option[not(@disabled)]", - "//*[@role='option']" - ] - - for opt_selector in option_selectors: - try: - options = self.driver.find_elements(By.XPATH, opt_selector) - if options: - # Select first non-placeholder option - for opt in options: - opt_text = opt.text.strip() - if opt_text and "select" not in opt_text.lower(): - opt.click() - print(f"[DentaQuest step1] Selected provider: {opt_text}") - break - break - except: - continue - - # Close dropdown if still open - ActionChains(self.driver).send_keys(Keys.ESCAPE).perform() - time.sleep(0.3) + print("[DentaQuest step1] Warning: Location options did not appear") else: - print("[DentaQuest step1] Warning: Could not find Provider dropdown") - + print("[DentaQuest step1] Warning: Could not find Location dropdown trigger") + except Exception as e: - print(f"[DentaQuest step1] Error selecting provider: {e}") + print(f"[DentaQuest step1] Error selecting location: {e}") time.sleep(0.3) @@ -501,29 +476,26 @@ class AutomationDentaQuestEligibilityCheck: if self.memberId: foundMemberId = self.memberId - # Extract eligibility status - status_selectors = [ - "(//tbody//tr)[1]//a[contains(@href, 'eligibility')]", - "//a[contains(@href,'eligibility')]", - "//*[contains(@class,'status')]", - "//*[contains(text(),'Active') or contains(text(),'Inactive') or contains(text(),'Eligible')]" - ] - - for selector in status_selectors: - try: - status_elem = self.driver.find_element(By.XPATH, selector) - status_text = status_elem.text.strip().lower() - if status_text: - print(f"[DentaQuest step2] Found status with selector '{selector}': {status_text}") - if "active" in status_text or "eligible" in status_text: - eligibilityText = "active" - break - elif "inactive" in status_text or "ineligible" in status_text: - eligibilityText = "inactive" - break - except: - continue - + # Extract eligibility status from first result row + try: + status_elem = self.driver.find_element(By.XPATH, + "(//tbody//tr)[1]//*[self::span or self::a or self::div][" + "contains(text(),'Active') or contains(text(),'Inactive') or " + "contains(text(),'Eligible') or contains(text(),'Ineligible') or " + "contains(text(),'Plan not accepted') or contains(text(),'Not eligible')" + "]" + ) + status_text = status_elem.text.strip().lower() + print(f"[DentaQuest step2] Found eligibility status: '{status_text}'") + if "active" in status_text or ("eligible" in status_text and "ineligible" not in status_text): + eligibilityText = "active" + elif "plan not accepted" in status_text: + eligibilityText = "plan not accepted" + else: + eligibilityText = "inactive" + except Exception as e: + print(f"[DentaQuest step2] Could not find eligibility status: {e}") + print(f"[DentaQuest step2] Final eligibility status: {eligibilityText}") # 2) Find the patient detail link and navigate DIRECTLY to it @@ -685,6 +657,11 @@ class AutomationDentaQuestEligibilityCheck: if not patientName: print("[DentaQuest step2] Could not extract patient name") else: + # Strip any trailing date (e.g. "PRINCILLA WALKER 01/09/2026") + import re as _re + patientName = _re.sub(r'\s*\d{1,2}/\d{1,2}/\d{2,4}\s*$', '', patientName).strip() + # Also strip "DOB: ..." prefix/suffix + patientName = _re.sub(r'\s*DOB[:\s]*\d{1,2}/\d{1,2}/\d{2,4}\s*', '', patientName, flags=_re.IGNORECASE).strip() print(f"[DentaQuest step2] Patient name: {patientName}") # Wait for page to fully load before generating PDF @@ -721,14 +698,14 @@ class AutomationDentaQuestEligibilityCheck: f.write(pdf_data) print(f"[DentaQuest step2] PDF saved: {pdf_path}") - # Close the browser window after PDF generation + # Close browser after PDF (session preserved in profile) try: from dentaquest_browser_manager import get_browser_manager get_browser_manager().quit_driver() print("[DentaQuest step2] Browser closed") except Exception as e: print(f"[DentaQuest step2] Error closing browser: {e}") - + output = { "status": "success", "eligibility": eligibilityText, diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 3313fca3..f4fc2f33 100755 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -43,7 +43,7 @@ model Patient { id Int @id @default(autoincrement()) firstName String lastName String - dateOfBirth DateTime @db.Date + dateOfBirth DateTime? @db.Date gender String phone String email String? @@ -76,6 +76,7 @@ enum PatientStatus { ACTIVE INACTIVE UNKNOWN + PLAN_NOT_ACCEPTED } model Appointment { diff --git a/packages/db/types/patient-types.ts b/packages/db/types/patient-types.ts index bac761c5..95b8006a 100755 --- a/packages/db/types/patient-types.ts +++ b/packages/db/types/patient-types.ts @@ -25,7 +25,7 @@ export const insuranceIdSchema = z.preprocess( // After preprocess, require digits-only string (or optional nullable) z .string() - .regex(/^\d+$/, { message: "Insurance ID must contain only digits" }) + .regex(/^[A-Za-z0-9]+$/, { message: "Insurance ID must contain only letters and digits" }) .min(1) .max(32) .optional()