feat: fix DDMA eligibility — patient list, name extraction, PDF page, OTP session

- Filter patient list by userId so each user sees only their own patients
- Sort patients by updatedAt DESC so recently checked patients appear first
- Add updatedAt field to Patient model (DB migration via raw SQL + db:generate)
- Fix DDMA name extraction: read from detail page "Name:" label, not search
  results row text which included appended dates
- Fix PDF capture: use driver.get() instead of click() to avoid race condition
  that was saving the search results page instead of the patient detail page
- Strip trailing bare dates from extracted names (e.g. "Rodriguez 04/27/2026")
- Handle "Last, First" comma format and single-word last names in splitName
- Normalize insuranceId consistently in createOrUpdatePatientByInsuranceId
- Fix OTP persistent session: stop clearing LocalStorage/IndexedDB on startup
  (these hold the DDMA device trust token that skips OTP on subsequent logins)
- Increase post-navigation wait time for full page render before PDF generation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gitead
2026-05-01 21:40:04 -04:00
parent 24bbaed6ab
commit e26ebf7fd5
213 changed files with 1698 additions and 1425 deletions

View File

@@ -94,10 +94,16 @@ export async function createOrUpdatePatientByInsuranceId(options: {
const { insuranceId, firstName, lastName, dob, userId } = options;
if (!insuranceId) throw new Error("Missing insuranceId");
// Normalize insuranceId the same way insertPatientSchema does (strip spaces)
const normalizedId = insuranceId.replace(/\s+/g, "");
const incomingFirst = (firstName || "").trim();
const incomingLast = (lastName || "").trim();
let patient = await storage.getPatientByInsuranceId(insuranceId);
console.log(`[createOrUpdatePatient] insuranceId="${normalizedId}" firstName="${incomingFirst}" lastName="${incomingLast}" userId=${userId}`);
let patient = await storage.getPatientByInsuranceId(normalizedId);
console.log(`[createOrUpdatePatient] existing patient lookup: ${patient ? `found id=${patient.id}` : "not found"}`);
if (patient && patient.id) {
const updates: any = {};
@@ -110,8 +116,9 @@ export async function createOrUpdatePatientByInsuranceId(options: {
if (!isNaN(parsed.getTime())) updates.dateOfBirth = parsed;
}
if (Object.keys(updates).length > 0) {
console.log(`[createOrUpdatePatient] updating patient id=${patient.id} with`, updates);
await storage.updatePatient(patient.id, updates);
patient = await storage.getPatientByInsuranceId(insuranceId);
patient = await storage.getPatientByInsuranceId(normalizedId);
}
return patient;
}
@@ -123,24 +130,31 @@ export async function createOrUpdatePatientByInsuranceId(options: {
gender: "",
phone: "",
userId,
insuranceId,
insuranceId: normalizedId,
};
let patientData: InsertPatient;
try {
patientData = insertPatientSchema.parse(createPayload);
} catch {
// Remove fields that may fail validation (invalid date or alphanumeric insuranceId)
} catch (e1) {
console.warn(`[createOrUpdatePatient] schema parse failed (attempt 1):`, e1);
const safePayload = { ...createPayload };
delete safePayload.dateOfBirth;
try {
patientData = insertPatientSchema.parse(safePayload);
} catch {
// Last resort: skip schema validation and cast directly
} catch (e2) {
console.warn(`[createOrUpdatePatient] schema parse failed (attempt 2):`, e2);
patientData = safePayload as InsertPatient;
}
}
await storage.createPatient(patientData);
return storage.getPatientByInsuranceId(insuranceId);
try {
await storage.createPatient(patientData);
console.log(`[createOrUpdatePatient] patient created successfully for insuranceId="${normalizedId}"`);
} catch (dbErr: any) {
console.error(`[createOrUpdatePatient] DB create failed:`, dbErr?.message ?? dbErr);
throw dbErr;
}
return storage.getPatientByInsuranceId(normalizedId);
}

View File

@@ -90,9 +90,33 @@ async function processDdmaResult(
? seleniumResult.patientName.trim()
: null;
const { firstName, lastName } = rawName
? splitName(rawName)
: { firstName: formFirstName ?? "", lastName: formLastName ?? "" };
let firstName: string;
let lastName: string;
if (rawName) {
// Strip trailing bare dates DDMA appends to names e.g. "Christian Rodriguez 04/27/2026"
const cleanName = rawName.replace(/\s+\d{1,2}\/\d{1,2}\/\d{2,4}$/, "").trim();
if (cleanName.includes(",")) {
// "LAST, FIRST" format common on insurance portals
const [last, ...firstParts] = cleanName.split(",").map((s: string) => s.trim());
lastName = last || formLastName || "";
firstName = firstParts.join(" ").trim() || formFirstName || "";
} else {
const parsed = splitName(cleanName);
if (!parsed.lastName) {
// Single word — treat as last name, pull first name from form
lastName = parsed.firstName || formLastName || "";
firstName = formFirstName || "";
} else {
firstName = parsed.firstName || formFirstName || "";
lastName = parsed.lastName || formLastName || "";
}
}
} else {
firstName = formFirstName ?? "";
lastName = formLastName ?? "";
}
// 2) Create / update patient
await createOrUpdatePatientByInsuranceId({
@@ -104,7 +128,9 @@ async function processDdmaResult(
});
// 3) Fetch patient (needed for ID)
const patient = await storage.getPatientByInsuranceId(insuranceId);
const normalizedInsuranceId = insuranceId.replace(/\s+/g, "");
const patient = await storage.getPatientByInsuranceId(normalizedInsuranceId);
log("ddma-processor", `patient lookup after create: ${patient ? `id=${patient.id}` : "NOT FOUND"} for insuranceId="${normalizedInsuranceId}"`);
if (!patient?.id) {
output.patientUpdateStatus = "Patient not found; no update performed";
return output;
@@ -177,6 +203,7 @@ async function processDdmaResult(
output.pdfFileId = createdPdfFileId;
return output;
} catch (err: any) {
log("ddma-processor", `processDdmaResult ERROR: ${err?.message ?? String(err)}`, err);
return {
...output,
pdfUploadStatus:

View File

@@ -24,9 +24,10 @@ router.get("/recent", async (req: Request, res: Response) => {
const limit = parseInt(req.query.limit as string) || 10;
const offset = parseInt(req.query.offset as string) || 0;
const userId = req.user!.id;
const [patients, totalCount] = await Promise.all([
storage.getRecentPatients(limit, offset),
storage.getTotalPatientCount(),
storage.getRecentPatients(limit, offset, userId),
storage.getTotalPatientCount(userId),
]);
res.json({ patients, totalCount });

View File

@@ -11,7 +11,7 @@ export interface IStorage {
getPatient(id: number): Promise<Patient | undefined>;
getPatientByInsuranceId(insuranceId: string): Promise<Patient | null>;
getPatientsByUserId(userId: number): Promise<Patient[]>;
getRecentPatients(limit: number, offset: number): Promise<Patient[]>;
getRecentPatients(limit: number, offset: number, userId: number): Promise<Patient[]>;
getPatientsByIds(ids: number[]): Promise<Patient[]>;
createPatient(patient: InsertPatient): Promise<Patient>;
updatePatient(id: number, patient: UpdatePatient): Promise<Patient>;
@@ -33,7 +33,7 @@ export interface IStorage {
status: string;
}[]
>;
getTotalPatientCount(): Promise<number>;
getTotalPatientCount(userId: number): Promise<number>;
countPatients(filters: any): Promise<number>; // optional but useful
getPatientFinancialRows(
patientId: number,
@@ -59,11 +59,12 @@ export const patientsStorage: IStorage = {
});
},
async getRecentPatients(limit: number, offset: number): Promise<Patient[]> {
async getRecentPatients(limit: number, offset: number, userId: number): Promise<Patient[]> {
return db.patient.findMany({
where: { userId },
skip: offset,
take: limit,
orderBy: { createdAt: "desc" },
orderBy: { updatedAt: "desc" },
});
},
@@ -85,6 +86,7 @@ export const patientsStorage: IStorage = {
status: true,
userId: true,
createdAt: true,
updatedAt: true,
},
});
},
@@ -124,7 +126,7 @@ export const patientsStorage: IStorage = {
}) {
return db.patient.findMany({
where: filters,
orderBy: { createdAt: "desc" },
orderBy: { updatedAt: "desc" },
take: limit,
skip: offset,
select: {
@@ -141,8 +143,8 @@ export const patientsStorage: IStorage = {
});
},
async getTotalPatientCount(): Promise<number> {
return db.patient.count();
async getTotalPatientCount(userId: number): Promise<number> {
return db.patient.count({ where: { userId } });
},
async countPatients(filters: any) {

View File

@@ -44,28 +44,26 @@ class DDMABrowserManager:
def clear_session_on_startup(self):
"""
Clear session cookies from Chrome profile on startup.
This forces a fresh login after PC restart.
Preserves device trust tokens (LocalStorage, IndexedDB) to avoid OTPs.
Clear only login cookies on startup to force credential re-entry after restart.
NEVER clears Local Storage or IndexedDB — those hold the DDMA device trust token
that allows the portal to skip OTP for recognised devices.
"""
print("[DDMA BrowserManager] Clearing session on startup...")
print("[DDMA BrowserManager] Clearing login cookies on startup (preserving device trust)...")
try:
# Clear credentials tracking file
# Reset credentials tracking so the next login re-saves the hash
if os.path.exists(self._credentials_file):
os.remove(self._credentials_file)
print("[DDMA BrowserManager] Cleared credentials tracking file")
# Clear session-related Chrome profile files
# Only remove cookie / login-data files — these expire the session so the
# user must re-enter credentials, but the device trust token is untouched.
session_files = [
"Cookies",
"Cookies-journal",
"Login Data",
"Login Data-journal",
"Web Data",
"Web Data-journal",
]
for filename in session_files:
for base in [os.path.join(self.profile_dir, "Default"), self.profile_dir]:
filepath = os.path.join(base, filename)
@@ -76,55 +74,12 @@ class DDMABrowserManager:
except Exception as e:
print(f"[DDMA BrowserManager] Could not remove {filename}: {e}")
# Clear Session Storage (contains login state)
session_storage_dir = os.path.join(self.profile_dir, "Default", "Session Storage")
if os.path.exists(session_storage_dir):
try:
shutil.rmtree(session_storage_dir)
print("[DDMA BrowserManager] Cleared Session Storage")
except Exception as e:
print(f"[DDMA BrowserManager] Could not clear Session Storage: {e}")
# Clear Local Storage (may contain auth tokens)
local_storage_dir = os.path.join(self.profile_dir, "Default", "Local Storage")
if os.path.exists(local_storage_dir):
try:
shutil.rmtree(local_storage_dir)
print("[DDMA BrowserManager] Cleared Local Storage")
except Exception as e:
print(f"[DDMA BrowserManager] Could not clear Local Storage: {e}")
# Clear IndexedDB (may contain auth tokens)
indexeddb_dir = os.path.join(self.profile_dir, "Default", "IndexedDB")
if os.path.exists(indexeddb_dir):
try:
shutil.rmtree(indexeddb_dir)
print("[DDMA BrowserManager] Cleared IndexedDB")
except Exception as e:
print(f"[DDMA BrowserManager] Could not clear IndexedDB: {e}")
# Clear browser caches
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, "Service Worker"),
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"[DDMA BrowserManager] Cleared {os.path.basename(cache_dir)}")
except Exception as e:
print(f"[DDMA BrowserManager] Could not clear {os.path.basename(cache_dir)}: {e}")
# Local Storage, IndexedDB, and Session Storage are intentionally
# NOT cleared — they contain the DDMA device trust token that prevents
# OTP from being required on every login.
self._needs_session_clear = True
print("[DDMA BrowserManager] Session cleared - will require fresh login")
print("[DDMA BrowserManager] Startup clear done — device trust preserved, OTP not required")
except Exception as e:
print(f"[DDMA BrowserManager] Error clearing session: {e}")

View File

@@ -378,53 +378,35 @@ class AutomationDeltaDentalMAEligibilityCheck:
try:
import re
# Wait for results table
# Wait for results table, then pause for full render
try:
WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located((By.XPATH, "//tbody//tr"))
)
time.sleep(2) # Let the row content fully render after table appears
except TimeoutException:
print("[DDMA step2] Warning: Results table not found within timeout")
eligibilityText = "unknown"
foundMemberId = ""
foundMemberId = self.memberId or ""
patientName = ""
# Extract data from first result row
# Extract eligibility status and member ID from search results row
try:
first_row = self.driver.find_element(By.XPATH, "(//tbody//tr)[1]")
row_text = first_row.text.strip()
print(f"[DDMA step2] First row text: {row_text[:150]}...")
if row_text:
lines = row_text.split('\n')
# Extract patient name (first line, strip DOB if present)
if lines:
potential_name = lines[0].strip()
potential_name = re.sub(r'\s*DOB[:\s]*\d{1,2}/\d{1,2}/\d{2,4}\s*', '', potential_name, flags=re.IGNORECASE).strip()
if potential_name and not potential_name.startswith('DOB') and not potential_name.isdigit():
patientName = potential_name
print(f"[DDMA step2] Extracted patient name: '{patientName}'")
# Extract Member ID
for line in lines:
line = line.strip()
if line and re.match(r'^[A-Z0-9]{5,}$', line) and not line.startswith('DOB'):
foundMemberId = line
print(f"[DDMA step2] Extracted Member ID: {foundMemberId}")
break
if not foundMemberId and self.memberId:
foundMemberId = self.memberId
print(f"[DDMA step2] Using input Member ID: {foundMemberId}")
lines = row_text.split('\n') if row_text else []
for line in lines:
line = line.strip()
if line and re.match(r'^[A-Z0-9]{5,}$', line) and not line.startswith('DOB'):
foundMemberId = line
print(f"[DDMA step2] Extracted Member ID: {foundMemberId}")
break
except Exception as e:
print(f"[DDMA step2] Error extracting data from row: {e}")
if self.memberId:
foundMemberId = self.memberId
print(f"[DDMA step2] Error reading first row: {e}")
# Extract eligibility status
try:
short_wait = WebDriverWait(self.driver, 3)
status_link = short_wait.until(EC.presence_of_element_located((
@@ -445,109 +427,128 @@ class AutomationDeltaDentalMAEligibilityCheck:
except Exception:
pass
# Navigate to detailed patient page
print("[DDMA step2] Navigating to patient detail page...")
patient_name_clicked = False
# Find the member-details URL from the first row
print("[DDMA step2] Looking for patient detail link...")
detail_url = None
patient_link_selectors = [
link_selectors = [
"(//table//tbody//tr)[1]//td[1]//a",
"(//tbody//tr)[1]//a[contains(@href, 'member-details')]",
"(//tbody//tr)[1]//a[contains(@href, 'member')]",
"//a[contains(@href, 'member-details')]",
]
for selector in patient_link_selectors:
for selector in link_selectors:
try:
patient_link = WebDriverWait(self.driver, 5).until(
link_el = WebDriverWait(self.driver, 5).until(
EC.presence_of_element_located((By.XPATH, selector))
)
link_text = patient_link.text.strip()
href = patient_link.get_attribute("href")
print(f"[DDMA step2] Found patient link: text='{link_text}', href={href}")
if link_text and not patientName:
patientName = link_text
href = link_el.get_attribute("href")
if href and "member-details" in href:
detail_url = href
patient_name_clicked = True
print(f"[DDMA step2] Found detail URL: {href}")
break
except Exception as e:
print(f"[DDMA step2] Selector '{selector}' failed: {e}")
except Exception:
continue
if not detail_url:
try:
all_links = self.driver.find_elements(By.XPATH, "//a[contains(@href, 'member-details')]")
if all_links:
detail_url = all_links[0].get_attribute("href")
patient_name_clicked = True
print(f"[DDMA step2] Fallback member-details link: {detail_url}")
except Exception as e:
print(f"[DDMA step2] Could not find member-details link: {e}")
if patient_name_clicked and detail_url:
print(f"[DDMA step2] Navigating directly to: {detail_url}")
if detail_url:
# Always navigate via driver.get() — this blocks until the new page loads,
# unlike click() which returns immediately and causes a race condition.
print(f"[DDMA step2] Navigating to detail page: {detail_url}")
self.driver.get(detail_url)
# Wait for page to be ready
# Confirm we actually landed on the detail page (not redirected away)
try:
WebDriverWait(self.driver, 30).until(
lambda d: d.execute_script("return document.readyState") == "complete"
WebDriverWait(self.driver, 15).until(
lambda d: "member-details" in d.current_url
)
print(f"[DDMA step2] Confirmed on detail page: {self.driver.current_url}")
except Exception:
print("[DDMA step2] Warning: document.readyState did not become 'complete'")
print(f"[DDMA step2] Warning: URL after navigation: {self.driver.current_url}")
# Wait for meaningful content to appear
content_selectors = [
"//div[contains(@class,'member') or contains(@class,'detail') or contains(@class,'patient')]",
"//h1", "//h2", "//table",
"//*[contains(text(),'Member ID') or contains(text(),'Name') or contains(text(),'Date of Birth')]",
]
for selector in content_selectors:
# Wait for meaningful content on the detail page
for selector in [
"//*[contains(text(),'Date of Birth') or contains(text(),'Address') or contains(text(),'Member ID')]",
"//table", "//h1", "//h2",
]:
try:
WebDriverWait(self.driver, 10).until(
WebDriverWait(self.driver, 15).until(
EC.presence_of_element_located((By.XPATH, selector))
)
print(f"[DDMA step2] Content loaded: {selector}")
print(f"[DDMA step2] Detail page content loaded: {selector}")
break
except Exception:
continue
time.sleep(1) # Brief settle for any late-rendering elements
time.sleep(3) # Let JavaScript finish rendering all sections
# Try to extract patient name from detail page if not already found
if not patientName:
for selector in ["//h1", "//h2", "//*[contains(@class,'patient-name') or contains(@class,'member-name')]"]:
try:
name_elem = self.driver.find_element(By.XPATH, selector)
name_text = name_elem.text.strip()
if name_text and len(name_text) > 1:
if not any(x in name_text.lower() for x in ['active', 'inactive', 'eligible', 'search', 'date', 'print']):
patientName = name_text
print(f"[DDMA step2] Found patient name on detail page: {patientName}")
break
except Exception:
continue
# Get full page text as lines
try:
page_lines = self.driver.execute_script(
"return document.body.innerText;"
).split('\n')
page_lines = [l.strip() for l in page_lines if l.strip()]
except Exception as e:
print(f"[DDMA step2] Could not get page text: {e}")
page_lines = []
# UI noise words that appear in accessibility/sort-button text or field labels
ui_noise = ['click', 'sort', 'activate', 'direction', 'enter', 'space',
'current', 'use ', 'table', 'column', 'filter', 'search',
'date', 'birth', 'relationship', 'subscriber', 'coverage',
'status', 'period', 'network', 'plan', 'deductible']
def looks_like_name(text):
"""Return True if text is a plausible patient name."""
t = text.strip()
# Must be 260 chars, letters/spaces/hyphens/apostrophes only (no periods)
if not t or not (2 <= len(t) <= 60):
return False
if not re.match(r"^[A-Za-z\s\-']+$", t):
return False
# Must not be UI noise
if any(w in t.lower() for w in ui_noise):
return False
return True
# Scan lines for the "Name" label. When "Name" appears alone we scan
# forward past accessibility text to the first plausible name value.
for i, line in enumerate(page_lines):
# Case 1: "Name : Value" or "Name: Value" on the same line
same_line = re.match(
r'^(?:member\s+)?name\s*[:\-]\s*(.+)$', line, re.IGNORECASE
)
if same_line and not re.search(
r'(provider|group|subscriber|plan)\s+name', line, re.IGNORECASE
):
candidate = same_line.group(1).strip()
if looks_like_name(candidate):
patientName = candidate
print(f"[DDMA step2] Extracted name from 'Name:' label: '{patientName}'")
break
# Case 2: "Name" or "Name:" alone on a line — scan forward for value
elif re.match(r'^(?:member\s+)?name\s*:?\s*$', line, re.IGNORECASE):
for j in range(i + 1, min(i + 6, len(page_lines))):
candidate = page_lines[j].strip()
if looks_like_name(candidate):
patientName = candidate
print(f"[DDMA step2] Extracted name from 'Name' label (line +{j-i}): '{patientName}'")
break
if patientName:
break
else:
print("[DDMA step2] Warning: Could not navigate to patient detail page")
if not patientName:
try:
name_elem = self.driver.find_element(By.XPATH, "(//tbody//tr)[1]//td[1]")
patientName = name_elem.text.strip()
except Exception:
pass
print("[DDMA step2] Warning: Could not find patient detail link")
if not patientName:
print("[DDMA step2] Could not extract patient name")
# Clean patient name
# Clean patient name — strip any remaining date artifacts
if patientName:
cleaned = re.sub(r'\s*DOB[:\s]*\d{1,2}/\d{1,2}/\d{2,4}\s*', '', patientName, flags=re.IGNORECASE).strip()
if cleaned:
patientName = cleaned
cleaned = re.sub(r'\s+\d{1,2}/\d{1,2}/\d{2,4}$', '', cleaned).strip()
patientName = cleaned if cleaned else patientName
# Wait for page ready before PDF
if not patientName:
print("[DDMA step2] Could not extract patient name from detail page")
# Wait for the page to be fully ready before capturing PDF
try:
WebDriverWait(self.driver, 30).until(
lambda d: d.execute_script("return document.readyState") == "complete"
@@ -555,6 +556,9 @@ class AutomationDeltaDentalMAEligibilityCheck:
except Exception:
pass
# Extra wait for lazy-loaded sections (benefits summary, member history, etc.)
time.sleep(4)
# Generate PDF via Chrome DevTools Protocol
print("[DDMA step2] Generating PDF of patient detail page...")
pdf_options = {