feat: DentaQuest eligibility — PLAN_NOT_ACCEPTED status, DOB save, no retries

- Add PLAN_NOT_ACCEPTED to PatientStatus enum (prisma schema + db push)
- Selenium: return "plan not accepted" eligibility text instead of collapsing to inactive
- Backend processor: map "plan not accepted" → PLAN_NOT_ACCEPTED, fix insuranceProvider label
- _shared.ts: save DOB for existing patients when field is currently empty
- Frontend: show amber "Plan Not Accepted" badge in patient table and detail panel
- patient-form.tsx: display "Plan Not Accepted" label in status dropdown
- BullMQ: set attempts=1 (no retry on selenium failure)
- DDMA: remove first/last name from search (member ID + DOB only)
- patient-types.ts: allow alphanumeric insurance IDs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gitead
2026-04-17 23:47:50 -04:00
parent f5ec4a1480
commit 4505d5db85
12 changed files with 141 additions and 104 deletions

View File

@@ -4,7 +4,7 @@
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "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", "build": "tsc",
"start": "node dist/index.js" "start": "node dist/index.js"
}, },

View File

@@ -105,6 +105,10 @@ export async function createOrUpdatePatientByInsuranceId(options: {
updates.firstName = incomingFirst; updates.firstName = incomingFirst;
if (incomingLast && String(patient.lastName ?? "").trim() !== incomingLast) if (incomingLast && String(patient.lastName ?? "").trim() !== incomingLast)
updates.lastName = 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) { if (Object.keys(updates).length > 0) {
await storage.updatePatient(patient.id, updates); await storage.updatePatient(patient.id, updates);
patient = await storage.getPatientByInsuranceId(insuranceId); patient = await storage.getPatientByInsuranceId(insuranceId);
@@ -126,9 +130,15 @@ export async function createOrUpdatePatientByInsuranceId(options: {
try { try {
patientData = insertPatientSchema.parse(createPayload); patientData = insertPatientSchema.parse(createPayload);
} catch { } catch {
// Remove fields that may fail validation (invalid date or alphanumeric insuranceId)
const safePayload = { ...createPayload }; const safePayload = { ...createPayload };
delete safePayload.dateOfBirth; 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); await storage.createPatient(patientData);

View File

@@ -84,11 +84,16 @@ async function processDentaQuestResult(
} }
const eligStatus = (seleniumResult?.eligibility ?? "").toLowerCase(); 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, { await storage.updatePatient(patient.id, {
status: newStatus, status: newStatus,
insuranceProvider: "Tufts SCO", insuranceProvider: "DentaQuest",
}); });
output.patientUpdateStatus = `Patient status updated to ${newStatus}`; output.patientUpdateStatus = `Patient status updated to ${newStatus}`;
@@ -223,12 +228,15 @@ async function pollUntilDone(
} }
if (status === "error" || status === "not_found") { 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)); await new Promise((r) => setTimeout(r, pollIntervalMs));
} catch (err: any) { } catch (err: any) {
const isTerminal = const isTerminal =
err?.terminal === true ||
err?.response?.status === 404 || err?.response?.status === 404 ||
(typeof err?.message === "string" && (typeof err?.message === "string" &&
(err.message.includes("not_found") || (err.message.includes("not_found") ||

View File

@@ -37,7 +37,7 @@ export interface OcrJobData {
const defaultOpts = { const defaultOpts = {
removeOnComplete: { count: 100 }, removeOnComplete: { count: 100 },
removeOnFail: { count: 50 }, removeOnFail: { count: 50 },
attempts: 2, attempts: 1,
backoff: { type: "exponential" as const, delay: 5000 }, backoff: { type: "exponential" as const, delay: 5000 },
}; };

View File

@@ -48,8 +48,10 @@ function getVisuals(status: PatientStatus): { label: string; bg: string } {
case "ACTIVE": case "ACTIVE":
return { label: "Active", bg: "#16A34A" }; // MEDICAL GREEN (not same as staff green) return { label: "Active", bg: "#16A34A" }; // MEDICAL GREEN (not same as staff green)
case "INACTIVE": 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: default:
return { label: "Unknown", bg: "#6B7280" }; // solid gray return { label: "Unknown", bg: "#6B7280" };
} }
} }

View File

@@ -303,8 +303,10 @@ export const PatientForm = forwardRef<PatientFormRef, PatientFormProps>(
const options = Object.values( const options = Object.values(
patientStatusOptions, patientStatusOptions,
) as PatientStatus[]; // ['ACTIVE','INACTIVE','UNKNOWN'] ) as PatientStatus[]; // ['ACTIVE','INACTIVE','UNKNOWN']
const toLabel = (v: PatientStatus) => const toLabel = (v: PatientStatus) => {
v[0] + v.slice(1).toLowerCase(); // ACTIVE -> Active if (v === "PLAN_NOT_ACCEPTED") return "Plan Not Accepted";
return v[0] + v.slice(1).toLowerCase();
};
return ( return (
<FormItem> <FormItem>

View File

@@ -1071,6 +1071,12 @@ export function PatientTable({
Unknown Unknown
</span> </span>
)} )}
{patient.status === "PLAN_NOT_ACCEPTED" && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800">
Plan Not Accepted
</span>
)}
</div> </div>
</TableCell> </TableCell>
@@ -1201,14 +1207,18 @@ export function PatientTable({
? "text-green-600" ? "text-green-600"
: currentPatient.status === "INACTIVE" : currentPatient.status === "INACTIVE"
? "text-red-600" ? "text-red-600"
: "text-gray-600", // UNKNOWN or fallback : currentPatient.status === "PLAN_NOT_ACCEPTED"
? "text-amber-600"
: "text-gray-600",
"font-medium" "font-medium"
)} )}
> >
{currentPatient.status {currentPatient.status === "PLAN_NOT_ACCEPTED"
? currentPatient.status.charAt(0).toUpperCase() + ? "Plan Not Accepted"
currentPatient.status.slice(1).toLowerCase() : currentPatient.status
: "Unknown"} ? currentPatient.status.charAt(0).toUpperCase() +
currentPatient.status.slice(1).toLowerCase()
: "Unknown"}
</span> </span>
</p> </p>
</div> </div>

View File

@@ -111,7 +111,26 @@ class DentaQuestBrowserManager:
print("[DentaQuest BrowserManager] Cleared IndexedDB") print("[DentaQuest BrowserManager] Cleared IndexedDB")
except Exception as e: except Exception as e:
print(f"[DentaQuest BrowserManager] Could not clear IndexedDB: {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 # Set flag to clear session via JavaScript after browser opens
self._needs_session_clear = True self._needs_session_clear = True
@@ -181,13 +200,13 @@ class DentaQuestBrowserManager:
except Exception as e: except Exception as e:
pass pass
# Remove SingletonLock if exists for lock_file in ["SingletonLock", "SingletonSocket", "SingletonCookie"]:
lock_file = os.path.join(self.profile_dir, "SingletonLock") lock_path = os.path.join(self.profile_dir, lock_file)
try: try:
if os.path.islink(lock_file) or os.path.exists(lock_file): if os.path.islink(lock_path) or os.path.exists(lock_path):
os.remove(lock_file) os.remove(lock_path)
except: except Exception:
pass pass
def get_driver(self, headless=False): def get_driver(self, headless=False):
"""Get or create the persistent browser instance.""" """Get or create the persistent browser instance."""
@@ -232,7 +251,12 @@ class DentaQuestBrowserManager:
options.add_argument(f"--user-data-dir={self.profile_dir}") options.add_argument(f"--user-data-dir={self.profile_dir}")
options.add_argument("--no-sandbox") options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage") options.add_argument("--disable-dev-shm-usage")
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 = { prefs = {
"download.default_directory": self.download_dir, "download.default_directory": self.download_dir,
"plugins.always_open_pdf_externally": True, "plugins.always_open_pdf_externally": True,

View File

@@ -274,6 +274,9 @@ async def start_dentaquest_run(sid: str, data: dict, url: str):
return {"status": "error", "message": s["message"]} return {"status": "error", "message": s["message"]}
except Exception as e: except Exception as e:
import traceback
print(f"[start_dentaquest_run] EXCEPTION: {e}")
traceback.print_exc()
s["status"] = "error" s["status"] = "error"
s["message"] = f"worker exception: {e}" s["message"] = f"worker exception: {e}"
await cleanup_session(sid) await cleanup_session(sid)

View File

@@ -305,65 +305,40 @@ class AutomationDentaQuestEligibilityCheck:
print(f"[DentaQuest step1] Error filling {field_name}: {e}") print(f"[DentaQuest step1] Error filling {field_name}: {e}")
return False return False
# 1. Select Provider from dropdown (required field) # 1. Select Location from dropdown (required field before search)
try: try:
print("[DentaQuest step1] Selecting Provider...") print("[DentaQuest step1] Selecting Location...")
# Try to find and click Provider dropdown
provider_selectors = [ # Click the Location dropdown button (stable data-testid)
"//label[contains(text(),'Provider')]/following-sibling::*//div[contains(@class,'select')]", location_clicked = False
"//div[contains(@data-testid,'provider')]//div[contains(@class,'select')]", try:
"//*[@aria-label='Provider']", trigger = WebDriverWait(self.driver, 5).until(
"//select[contains(@name,'provider') or contains(@id,'provider')]", EC.element_to_be_clickable((By.XPATH, '//button[@data-testid="member-search_location_select-btn"]'))
"//div[contains(@class,'provider')]//input", )
"//label[contains(text(),'Provider')]/..//div[contains(@class,'control')]" trigger.click()
] print("[DentaQuest step1] Clicked location dropdown button")
time.sleep(0.5)
provider_clicked = False location_clicked = True
for selector in provider_selectors: 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: try:
provider_dropdown = WebDriverWait(self.driver, 3).until( first_option = WebDriverWait(self.driver, 5).until(
EC.element_to_be_clickable((By.XPATH, selector)) EC.element_to_be_clickable((By.XPATH, "(//li[@role='option'])[1]"))
) )
provider_dropdown.click() opt_text = first_option.get_attribute("aria-label") or first_option.text.strip()
print(f"[DentaQuest step1] Clicked provider dropdown with selector: {selector}") first_option.click()
time.sleep(0.5) print(f"[DentaQuest step1] Selected location: {opt_text[:60]}")
provider_clicked = True time.sleep(0.3)
break
except TimeoutException: except TimeoutException:
continue print("[DentaQuest step1] Warning: Location options did not appear")
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)
else: else:
print("[DentaQuest step1] Warning: Could not find Provider dropdown") print("[DentaQuest step1] Warning: Could not find Location dropdown trigger")
except Exception as e: except Exception as e:
print(f"[DentaQuest step1] Error selecting provider: {e}") print(f"[DentaQuest step1] Error selecting location: {e}")
time.sleep(0.3) time.sleep(0.3)
@@ -501,29 +476,26 @@ class AutomationDentaQuestEligibilityCheck:
if self.memberId: if self.memberId:
foundMemberId = self.memberId foundMemberId = self.memberId
# Extract eligibility status # Extract eligibility status from first result row
status_selectors = [ try:
"(//tbody//tr)[1]//a[contains(@href, 'eligibility')]", status_elem = self.driver.find_element(By.XPATH,
"//a[contains(@href,'eligibility')]", "(//tbody//tr)[1]//*[self::span or self::a or self::div]["
"//*[contains(@class,'status')]", "contains(text(),'Active') or contains(text(),'Inactive') or "
"//*[contains(text(),'Active') or contains(text(),'Inactive') or contains(text(),'Eligible')]" "contains(text(),'Eligible') or contains(text(),'Ineligible') or "
] "contains(text(),'Plan not accepted') or contains(text(),'Not eligible')"
"]"
for selector in status_selectors: )
try: status_text = status_elem.text.strip().lower()
status_elem = self.driver.find_element(By.XPATH, selector) print(f"[DentaQuest step2] Found eligibility status: '{status_text}'")
status_text = status_elem.text.strip().lower() if "active" in status_text or ("eligible" in status_text and "ineligible" not in status_text):
if status_text: eligibilityText = "active"
print(f"[DentaQuest step2] Found status with selector '{selector}': {status_text}") elif "plan not accepted" in status_text:
if "active" in status_text or "eligible" in status_text: eligibilityText = "plan not accepted"
eligibilityText = "active" else:
break eligibilityText = "inactive"
elif "inactive" in status_text or "ineligible" in status_text: except Exception as e:
eligibilityText = "inactive" print(f"[DentaQuest step2] Could not find eligibility status: {e}")
break
except:
continue
print(f"[DentaQuest step2] Final eligibility status: {eligibilityText}") print(f"[DentaQuest step2] Final eligibility status: {eligibilityText}")
# 2) Find the patient detail link and navigate DIRECTLY to it # 2) Find the patient detail link and navigate DIRECTLY to it
@@ -685,6 +657,11 @@ class AutomationDentaQuestEligibilityCheck:
if not patientName: if not patientName:
print("[DentaQuest step2] Could not extract patient name") print("[DentaQuest step2] Could not extract patient name")
else: 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}") print(f"[DentaQuest step2] Patient name: {patientName}")
# Wait for page to fully load before generating PDF # Wait for page to fully load before generating PDF
@@ -721,14 +698,14 @@ class AutomationDentaQuestEligibilityCheck:
f.write(pdf_data) f.write(pdf_data)
print(f"[DentaQuest step2] PDF saved: {pdf_path}") print(f"[DentaQuest step2] PDF saved: {pdf_path}")
# Close the browser window after PDF generation # Close browser after PDF (session preserved in profile)
try: try:
from dentaquest_browser_manager import get_browser_manager from dentaquest_browser_manager import get_browser_manager
get_browser_manager().quit_driver() get_browser_manager().quit_driver()
print("[DentaQuest step2] Browser closed") print("[DentaQuest step2] Browser closed")
except Exception as e: except Exception as e:
print(f"[DentaQuest step2] Error closing browser: {e}") print(f"[DentaQuest step2] Error closing browser: {e}")
output = { output = {
"status": "success", "status": "success",
"eligibility": eligibilityText, "eligibility": eligibilityText,

View File

@@ -43,7 +43,7 @@ model Patient {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
firstName String firstName String
lastName String lastName String
dateOfBirth DateTime @db.Date dateOfBirth DateTime? @db.Date
gender String gender String
phone String phone String
email String? email String?
@@ -76,6 +76,7 @@ enum PatientStatus {
ACTIVE ACTIVE
INACTIVE INACTIVE
UNKNOWN UNKNOWN
PLAN_NOT_ACCEPTED
} }
model Appointment { model Appointment {

View File

@@ -25,7 +25,7 @@ export const insuranceIdSchema = z.preprocess(
// After preprocess, require digits-only string (or optional nullable) // After preprocess, require digits-only string (or optional nullable)
z z
.string() .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) .min(1)
.max(32) .max(32)
.optional() .optional()