feat: route batch-column claims by insurance type, fix eligibility UX
- claims.ts: batch-column now routes each patient to the correct portal
(MH/CCA/DDMA/TuftsSCO/UnitedSCO) based on patient.insuranceProvider
- appointments-page.tsx: eligibility badge falls back to patientStatus
for all insurance types, not just MassHealth
- unitedDHClaimProcessor/ddmaClaimProcessor/tuftsSCOClaimProcessor:
auto-save claim PDF when no socketId (batch-column path)
- unitedSCOEligibilityProcessor: unknown eligibility no longer stored as INACTIVE
- queues.ts: add tuftssco-claim-submit and uniteddh-claim-submit to SeleniumJobType
- selenium_UnitedSCO_eligibilityCheckWorker.py:
- step1: add Select Insurance OK click with staleness wait; Provider &
Location page just clicks Continue; skip first/last name input
- step2: extract name from eligibility details tab (ALL CAPS → title case);
skip "not available" guard; fix name regex to match only all-caps words
- .gitignore: ignore all chrome_profile_* directories
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,8 @@ import {
|
||||
} from "../../services/seleniumDDMAClaimClient";
|
||||
import { io } from "../../socket";
|
||||
import { storage } from "../../storage";
|
||||
import axios from "axios";
|
||||
import path from "path";
|
||||
|
||||
function log(tag: string, msg: string, ctx?: any) {
|
||||
console.log(`${new Date().toISOString()} [${tag}] ${msg}`, ctx ?? "");
|
||||
@@ -86,6 +88,21 @@ async function pollUntilDone(
|
||||
throw new Error(`DDMA claim polling exhausted all attempts for session ${sessionId}`);
|
||||
}
|
||||
|
||||
async function savePdfFromSelenium(pdf_url: string, patientId: number) {
|
||||
try {
|
||||
const filename = path.basename(new URL(pdf_url).pathname);
|
||||
const seleniumPort = process.env.SELENIUM_PORT || "5002";
|
||||
const localUrl = `http://localhost:${seleniumPort}/downloads/${filename}`;
|
||||
const resp = await axios.get(localUrl, { responseType: "arraybuffer", timeout: 30000 });
|
||||
let group = await storage.findPdfGroupByPatientTitleKey(patientId, "INSURANCE_CLAIM");
|
||||
if (!group) group = await storage.createPdfGroup(patientId, "Claims", "INSURANCE_CLAIM");
|
||||
await storage.createPdfFile(group.id!, filename, resp.data);
|
||||
log("ddma-claim-processor", "PDF saved", { patientId, filename });
|
||||
} catch (err: any) {
|
||||
log("ddma-claim-processor", "failed to save PDF (non-fatal)", { error: err?.message ?? err });
|
||||
}
|
||||
}
|
||||
|
||||
export interface DDMAClaimProcessorInput {
|
||||
enrichedPayload: any;
|
||||
userId: number;
|
||||
@@ -139,6 +156,13 @@ export async function runDDMAClaimProcessor(
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-save PDF for batch-column calls (no socketId = no frontend listener)
|
||||
if (pdf_url && !socketId) {
|
||||
const claim = claimId ? await storage.getClaim(claimId).catch(() => null) : null;
|
||||
const patientId = claim?.patientId ?? enrichedPayload?.claim?.patientId ?? enrichedPayload?.patientId;
|
||||
if (patientId) await savePdfFromSelenium(pdf_url, Number(patientId));
|
||||
}
|
||||
|
||||
emitToSocket(socketId, "selenium:ddma_claim_completed", {
|
||||
jobId,
|
||||
claimId,
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
} from "../../services/seleniumTuftsSCOClaimClient";
|
||||
import { io } from "../../socket";
|
||||
import { storage } from "../../storage";
|
||||
import axios from "axios";
|
||||
import path from "path";
|
||||
|
||||
function log(tag: string, msg: string, ctx?: any) {
|
||||
console.log(`${new Date().toISOString()} [${tag}] ${msg}`, ctx ?? "");
|
||||
@@ -85,6 +87,21 @@ async function pollUntilDone(
|
||||
throw new Error(`Tufts SCO claim polling exhausted all attempts for session ${sessionId}`);
|
||||
}
|
||||
|
||||
async function savePdfFromSelenium(pdf_url: string, patientId: number) {
|
||||
try {
|
||||
const filename = path.basename(new URL(pdf_url).pathname);
|
||||
const seleniumPort = process.env.SELENIUM_PORT || "5002";
|
||||
const localUrl = `http://localhost:${seleniumPort}/downloads/${filename}`;
|
||||
const resp = await axios.get(localUrl, { responseType: "arraybuffer", timeout: 30000 });
|
||||
let group = await storage.findPdfGroupByPatientTitleKey(patientId, "INSURANCE_CLAIM");
|
||||
if (!group) group = await storage.createPdfGroup(patientId, "Claims", "INSURANCE_CLAIM");
|
||||
await storage.createPdfFile(group.id!, filename, resp.data);
|
||||
log("tuftssco-claim-processor", "PDF saved", { patientId, filename });
|
||||
} catch (err: any) {
|
||||
log("tuftssco-claim-processor", "failed to save PDF (non-fatal)", { error: err?.message ?? err });
|
||||
}
|
||||
}
|
||||
|
||||
export interface TuftsSCOClaimProcessorInput {
|
||||
enrichedPayload: any;
|
||||
userId: number;
|
||||
@@ -130,6 +147,13 @@ export async function runTuftsSCOClaimProcessor(
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-save PDF for batch-column calls (no socketId = no frontend listener)
|
||||
if (pdf_url && !socketId) {
|
||||
const claim = claimId ? await storage.getClaim(claimId).catch(() => null) : null;
|
||||
const patientId = claim?.patientId ?? enrichedPayload?.claim?.patientId ?? enrichedPayload?.patientId;
|
||||
if (patientId) await savePdfFromSelenium(pdf_url, Number(patientId));
|
||||
}
|
||||
|
||||
emitToSocket(socketId, "selenium:tuftssco_claim_completed", {
|
||||
jobId,
|
||||
claimId,
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
} from "../../services/seleniumUnitedDHClaimClient";
|
||||
import { io } from "../../socket";
|
||||
import { storage } from "../../storage";
|
||||
import axios from "axios";
|
||||
import path from "path";
|
||||
|
||||
function log(tag: string, msg: string, ctx?: any) {
|
||||
console.log(`${new Date().toISOString()} [${tag}] ${msg}`, ctx ?? "");
|
||||
@@ -97,6 +99,24 @@ async function pollUntilDone(
|
||||
throw new Error(`UnitedDH claim polling exhausted all attempts for session ${sessionId}`);
|
||||
}
|
||||
|
||||
async function savePdfFromSelenium(pdf_url: string, patientId: number) {
|
||||
try {
|
||||
const filename = path.basename(new URL(pdf_url).pathname);
|
||||
const seleniumPort = process.env.SELENIUM_PORT || "5002";
|
||||
const localUrl = `http://localhost:${seleniumPort}/downloads/${filename}`;
|
||||
const resp = await axios.get(localUrl, { responseType: "arraybuffer", timeout: 30000 });
|
||||
|
||||
let group = await storage.findPdfGroupByPatientTitleKey(patientId, "INSURANCE_CLAIM");
|
||||
if (!group) {
|
||||
group = await storage.createPdfGroup(patientId, "Claims", "INSURANCE_CLAIM");
|
||||
}
|
||||
await storage.createPdfFile(group.id!, filename, resp.data);
|
||||
log("uniteddh-claim-processor", "PDF saved", { patientId, filename });
|
||||
} catch (err: any) {
|
||||
log("uniteddh-claim-processor", "failed to save PDF (non-fatal)", { error: err?.message ?? err });
|
||||
}
|
||||
}
|
||||
|
||||
export interface UnitedDHClaimProcessorInput {
|
||||
enrichedPayload: any;
|
||||
userId: number;
|
||||
@@ -149,6 +169,13 @@ export async function runUnitedDHClaimProcessor(
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-save PDF for batch-column calls (no socketId = no frontend listener)
|
||||
if (pdf_url && !socketId) {
|
||||
const claim = claimId ? await storage.getClaim(claimId).catch(() => null) : null;
|
||||
const patientId = claim?.patientId ?? enrichedPayload?.claim?.patientId ?? enrichedPayload?.patientId;
|
||||
if (patientId) await savePdfFromSelenium(pdf_url, Number(patientId));
|
||||
}
|
||||
|
||||
emitToSocket(socketId, "selenium:uniteddh_claim_completed", {
|
||||
jobId,
|
||||
claimId,
|
||||
|
||||
@@ -104,13 +104,21 @@ async function processUnitedSCOResult(
|
||||
|
||||
// 4) Determine eligibility status
|
||||
const eligStatus = (seleniumResult?.eligibility ?? "").toLowerCase();
|
||||
const newStatus = eligStatus === "active" || eligStatus === "y" ? "ACTIVE" : "INACTIVE";
|
||||
const newStatus =
|
||||
eligStatus === "active" || eligStatus === "y" ? "ACTIVE" :
|
||||
eligStatus === "inactive" ? "INACTIVE" : null;
|
||||
|
||||
await storage.updatePatient(patient.id, {
|
||||
status: newStatus,
|
||||
insuranceProvider: "United Healthcare SCO",
|
||||
});
|
||||
output.patientUpdateStatus = `Patient status updated to ${newStatus}`;
|
||||
if (newStatus) {
|
||||
await storage.updatePatient(patient.id, {
|
||||
status: newStatus,
|
||||
insuranceProvider: "United Healthcare SCO",
|
||||
});
|
||||
output.patientUpdateStatus = `Patient status updated to ${newStatus}`;
|
||||
} else {
|
||||
// Unknown eligibility — still update insuranceProvider but leave status unchanged
|
||||
await storage.updatePatient(patient.id, { insuranceProvider: "United Healthcare SCO" });
|
||||
output.patientUpdateStatus = `Eligibility unknown — status not changed`;
|
||||
}
|
||||
|
||||
// 5) Resolve PDF buffer from file path (same as DDMA)
|
||||
let pdfBuffer: Buffer | null = null;
|
||||
|
||||
@@ -14,6 +14,8 @@ export type SeleniumJobType =
|
||||
| "cca-claim-submit"
|
||||
| "cca-preauth-submit"
|
||||
| "ddma-claim-submit"
|
||||
| "tuftssco-claim-submit"
|
||||
| "uniteddh-claim-submit"
|
||||
| "tuftssco-eligibility-check"
|
||||
| "mh-eligibility-history-check"
|
||||
| "cmsp-eligibility-history-remaining-check";
|
||||
|
||||
@@ -9,6 +9,7 @@ import fs from "fs";
|
||||
import axios from "axios";
|
||||
import archiver from "archiver";
|
||||
import { seleniumQueue } from "../queue/queues";
|
||||
import { enqueueSeleniumJob } from "../queue/jobRunner";
|
||||
import { Prisma } from "@repo/db/generated/prisma";
|
||||
import { Decimal } from "decimal.js";
|
||||
import {
|
||||
@@ -415,6 +416,28 @@ router.post(
|
||||
}
|
||||
);
|
||||
|
||||
// Maps patient.insuranceProvider free-text → the siteKey stored in InsuranceCredential
|
||||
function batchColumnDeriveSiteKey(provider: string): string {
|
||||
const p = provider.toLowerCase().trim();
|
||||
if (!p || p.includes("masshealth") || p === "mh" || p === "mass health") return "MH";
|
||||
if (p.includes("commonwealth care alliance") || p === "cca") return "CCA";
|
||||
if (p.includes("ddma") || p.includes("delta dental ma")) return "DDMA";
|
||||
if (p.includes("tufts") || p.includes("dentaquest") || p === "tuftssco") return "TUFTS_SCO";
|
||||
if ((p.includes("united") && p.includes("sco")) || p.includes("dentalhub") || p === "united_sco") return "UNITED_SCO";
|
||||
return "MH"; // default fallback
|
||||
}
|
||||
|
||||
// Returns the canonical insuranceProvider name stored in the Claim record
|
||||
function batchColumnCanonicalName(siteKey: string): string {
|
||||
switch (siteKey) {
|
||||
case "CCA": return "CCA";
|
||||
case "DDMA": return "Delta Dental MA";
|
||||
case "TUFTS_SCO": return "Tufts SCO";
|
||||
case "UNITED_SCO": return "United/DentalHub";
|
||||
default: return "MassHealth";
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/claims/batch-column
|
||||
// Query params: date=YYYY-MM-DD (required), staffIds=1,2 (required)
|
||||
// For each appointment in the selected staff columns:
|
||||
@@ -555,10 +578,15 @@ router.post(
|
||||
continue;
|
||||
}
|
||||
|
||||
// MassHealth credentials
|
||||
const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(req.user.id, "MH");
|
||||
// Always derive siteKey from the patient record — claim.insuranceProvider
|
||||
// may be stale (e.g. previously set to "MassHealth" by the old hardcoded path)
|
||||
const rawInsuranceProvider = (patient.insuranceProvider ?? "").trim();
|
||||
const siteKey = batchColumnDeriveSiteKey(rawInsuranceProvider);
|
||||
const canonicalInsuranceProvider = batchColumnCanonicalName(siteKey);
|
||||
|
||||
const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(req.user.id, siteKey);
|
||||
if (!credentials) {
|
||||
resultItem.error = "No MassHealth credentials found — check Settings";
|
||||
resultItem.error = `No ${canonicalInsuranceProvider} credentials found — check Settings`;
|
||||
results.push(resultItem);
|
||||
continue;
|
||||
}
|
||||
@@ -573,7 +601,7 @@ router.post(
|
||||
// Priority: Select Procedures choice > existing claim > first provider
|
||||
const claimNpiProviderId = procNpiProviderId ?? activeClaim?.npiProviderId ?? npiProvider?.id ?? null;
|
||||
|
||||
console.log(`[batch-column] apt=${apt.id} procNpiId=${procNpiProviderId} claimNpiId=${activeClaim?.npiProviderId} resolved=${claimNpiProviderId}`);
|
||||
console.log(`[batch-column] apt=${apt.id} siteKey=${siteKey} procNpiId=${procNpiProviderId} claimNpiId=${activeClaim?.npiProviderId} resolved=${claimNpiProviderId}`);
|
||||
|
||||
const patientName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim();
|
||||
|
||||
@@ -597,7 +625,7 @@ router.post(
|
||||
? Number(claimNpiProviderId)
|
||||
: null;
|
||||
|
||||
console.log(`[batch-column] creating claim: patientId=${patient.id} aptId=${apt.id} staffId=${safeStaffId} npiProviderId=${safeNpiId} serviceDate=${serviceDate} dobStr=${dobStr} lines=${serviceLines.length}`);
|
||||
console.log(`[batch-column] creating claim: patientId=${patient.id} aptId=${apt.id} staffId=${safeStaffId} npiProviderId=${safeNpiId} serviceDate=${serviceDate} dobStr=${dobStr} lines=${serviceLines.length} insurance=${canonicalInsuranceProvider}`);
|
||||
|
||||
const newClaim = await storage.createClaim({
|
||||
patientId: Number(patient.id),
|
||||
@@ -608,7 +636,7 @@ router.post(
|
||||
memberId,
|
||||
dateOfBirth: new Date(dobStr),
|
||||
serviceDate: new Date(serviceDate),
|
||||
insuranceProvider: "MassHealth",
|
||||
insuranceProvider: canonicalInsuranceProvider,
|
||||
remarks: "",
|
||||
missingTeethStatus: "No_missing",
|
||||
missingTeeth: {},
|
||||
@@ -643,33 +671,6 @@ router.post(
|
||||
if (saved) resolvedNpiProvider = saved;
|
||||
}
|
||||
|
||||
// Build enriched payload for selenium
|
||||
const enrichedPayload: any = {
|
||||
patientId: Number(patient.id),
|
||||
appointmentId: Number(apt.id),
|
||||
userId: req.user.id,
|
||||
staffId: Number(apt.staffId),
|
||||
patientName,
|
||||
memberId,
|
||||
dateOfBirth: dobStr,
|
||||
serviceDate,
|
||||
insuranceProvider: "MassHealth",
|
||||
insuranceSiteKey: "MH",
|
||||
missingTeethStatus: activeClaim?.missingTeethStatus ?? "No_missing",
|
||||
missingTeeth: activeClaim?.missingTeeth ?? {},
|
||||
remarks: activeClaim?.remarks ?? "",
|
||||
serviceLines,
|
||||
claimId,
|
||||
massdhpUsername: credentials.username,
|
||||
massdhpPassword: credentials.password,
|
||||
};
|
||||
if (resolvedNpiProvider) {
|
||||
enrichedPayload.npiProvider = {
|
||||
npiNumber: resolvedNpiProvider.npiNumber,
|
||||
providerName: resolvedNpiProvider.providerName,
|
||||
};
|
||||
}
|
||||
|
||||
// Collect attachments: appointment-level files + claim-level files
|
||||
const apptFiles = await storage.getAppointmentFiles(Number(apt.id));
|
||||
const claimFiles = (activeClaim as any)?.claimFiles ?? [];
|
||||
@@ -689,17 +690,120 @@ router.post(
|
||||
return [{ originalname: f.filename, bufferBase64, mimetype: f.mimeType ?? "application/octet-stream" }];
|
||||
});
|
||||
|
||||
// Enqueue selenium claim-submit job
|
||||
const job = await seleniumQueue.add("claim-submit", {
|
||||
jobType: "claim-submit",
|
||||
// Base claim data shared across all insurance pathways
|
||||
const baseClaimPayload: any = {
|
||||
patientId: Number(patient.id),
|
||||
appointmentId: Number(apt.id),
|
||||
userId: req.user.id,
|
||||
enrichedPayload,
|
||||
files: filesForQueue,
|
||||
staffId: Number(apt.staffId),
|
||||
patientName,
|
||||
memberId,
|
||||
dateOfBirth: dobStr,
|
||||
serviceDate,
|
||||
missingTeethStatus: activeClaim?.missingTeethStatus ?? "No_missing",
|
||||
missingTeeth: activeClaim?.missingTeeth ?? {},
|
||||
remarks: activeClaim?.remarks ?? "",
|
||||
serviceLines,
|
||||
claimId,
|
||||
});
|
||||
...(resolvedNpiProvider ? {
|
||||
npiProvider: {
|
||||
npiNumber: resolvedNpiProvider.npiNumber,
|
||||
providerName: resolvedNpiProvider.providerName,
|
||||
},
|
||||
} : {}),
|
||||
};
|
||||
|
||||
// Enqueue to the correct insurance pathway
|
||||
let jobId: string;
|
||||
if (siteKey === "MH") {
|
||||
const enrichedPayload = {
|
||||
...baseClaimPayload,
|
||||
insuranceProvider: "MassHealth",
|
||||
insuranceSiteKey: "MH",
|
||||
massdhpUsername: credentials.username,
|
||||
massdhpPassword: credentials.password,
|
||||
};
|
||||
const job = await seleniumQueue.add("claim-submit", {
|
||||
jobType: "claim-submit",
|
||||
userId: req.user.id,
|
||||
enrichedPayload,
|
||||
files: filesForQueue,
|
||||
claimId,
|
||||
});
|
||||
jobId = String(job.id);
|
||||
} else if (siteKey === "CCA") {
|
||||
const enrichedPayload = {
|
||||
claim: {
|
||||
...baseClaimPayload,
|
||||
insuranceProvider: "CCA",
|
||||
insuranceSiteKey: "CCA",
|
||||
cca_username: credentials.username,
|
||||
cca_password: credentials.password,
|
||||
},
|
||||
files: filesForQueue,
|
||||
};
|
||||
jobId = enqueueSeleniumJob({
|
||||
jobType: "cca-claim-submit",
|
||||
userId: req.user.id,
|
||||
enrichedPayload,
|
||||
claimId,
|
||||
});
|
||||
} else if (siteKey === "DDMA") {
|
||||
const enrichedPayload = {
|
||||
claim: {
|
||||
...baseClaimPayload,
|
||||
insuranceProvider: "Delta Dental MA",
|
||||
insuranceSiteKey: "DDMA",
|
||||
massddmaUsername: credentials.username,
|
||||
massddmaPassword: credentials.password,
|
||||
},
|
||||
};
|
||||
jobId = enqueueSeleniumJob({
|
||||
jobType: "ddma-claim-submit",
|
||||
userId: req.user.id,
|
||||
enrichedPayload,
|
||||
claimId,
|
||||
});
|
||||
} else if (siteKey === "TUFTS_SCO") {
|
||||
const enrichedPayload = {
|
||||
claim: {
|
||||
...baseClaimPayload,
|
||||
insuranceProvider: "Tufts SCO",
|
||||
insuranceSiteKey: "TuftsSCO",
|
||||
dentaquestUsername: credentials.username,
|
||||
dentaquestPassword: credentials.password,
|
||||
},
|
||||
};
|
||||
jobId = enqueueSeleniumJob({
|
||||
jobType: "tuftssco-claim-submit",
|
||||
userId: req.user.id,
|
||||
enrichedPayload,
|
||||
claimId,
|
||||
});
|
||||
} else if (siteKey === "UNITED_SCO") {
|
||||
const enrichedPayload = {
|
||||
claim: {
|
||||
...baseClaimPayload,
|
||||
insuranceProvider: "United/DentalHub",
|
||||
insuranceSiteKey: "UNITED_SCO",
|
||||
uniteddhUsername: credentials.username,
|
||||
uniteddhPassword: credentials.password,
|
||||
},
|
||||
};
|
||||
jobId = enqueueSeleniumJob({
|
||||
jobType: "uniteddh-claim-submit",
|
||||
userId: req.user.id,
|
||||
enrichedPayload,
|
||||
claimId,
|
||||
});
|
||||
} else {
|
||||
resultItem.error = `Unsupported insurance type: ${siteKey}`;
|
||||
results.push(resultItem);
|
||||
continue;
|
||||
}
|
||||
|
||||
resultItem.processed = true;
|
||||
resultItem.jobId = String(job.id);
|
||||
resultItem.jobId = jobId;
|
||||
|
||||
} catch (aptErr: any) {
|
||||
console.error("[batch-column] apt error:", aptErr);
|
||||
|
||||
Reference in New Issue
Block a user