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": "",
"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"
},

View File

@@ -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);

View File

@@ -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") ||

View File

@@ -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 },
};

View File

@@ -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" };
}
}

View File

@@ -303,8 +303,10 @@ export const PatientForm = forwardRef<PatientFormRef, PatientFormProps>(
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 (
<FormItem>

View File

@@ -1071,6 +1071,12 @@ export function PatientTable({
Unknown
</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>
</TableCell>
@@ -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"}
</span>
</p>
</div>

View File

@@ -112,6 +112,25 @@ class DentaQuestBrowserManager:
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,6 +251,11 @@ 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,

View File

@@ -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)

View File

@@ -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')]"
]
print("[DentaQuest step1] Selecting Location...")
provider_clicked = False
for selector in provider_selectors:
# 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,28 +476,25 @@ 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}")
@@ -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,7 +698,7 @@ 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()

View File

@@ -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 {

View File

@@ -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()