feat: United/DentalHub claim submission automation and patient list sync
- Add full Selenium automation for United/DentalHub claim submission (steps 1-8: login, OTP, patient search, practitioner page, code entry, other coverage No, attachments, submit, Status & History PDF) - Consolidate UnitedDH siteKey to UNITED_SCO throughout app - Fix procedure date overwrite with Ctrl+A+Delete before typing service date - Fix OTP popup reliability: emit every poll (no throttle) - Fix Chrome session persistence: only clear cookies on startup - Add touchPatient() to storage: claim submission now pushes patient to top of list across eligibility, claims, and documents pages Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,8 @@ import { runCCAEligibilityProcessor } from "./processors/ccaEligibilityProcessor
|
||||
import { runCCAClaimProcessor } from "./processors/ccaClaimProcessor";
|
||||
import { runCCAPreAuthProcessor } from "./processors/ccaPreAuthProcessor";
|
||||
import { runDDMAClaimProcessor } from "./processors/ddmaClaimProcessor";
|
||||
import { runUnitedDHClaimProcessor } from "./processors/unitedDHClaimProcessor";
|
||||
import { runTuftsSCOClaimProcessor } from "./processors/tuftsSCOClaimProcessor";
|
||||
import { runEligibilityHistoryProcessor } from "./processors/eligibilityHistoryProcessor";
|
||||
import { runCmspEligibilityHistoryRemainingProcessor } from "./processors/cmspEligibilityHistoryRemainingProcessor";
|
||||
import type { SeleniumJobData, OcrJobData } from "./queues";
|
||||
@@ -168,6 +170,28 @@ export function enqueueSeleniumJob(data: SeleniumJobData): string {
|
||||
job.id
|
||||
);
|
||||
}
|
||||
if (jobType === "uniteddh-claim-submit") {
|
||||
return runUnitedDHClaimProcessor(
|
||||
{
|
||||
enrichedPayload: data.enrichedPayload,
|
||||
userId: data.userId,
|
||||
claimId: data.claimId,
|
||||
socketId: data.socketId,
|
||||
},
|
||||
job.id
|
||||
);
|
||||
}
|
||||
if (jobType === "tuftssco-claim-submit") {
|
||||
return runTuftsSCOClaimProcessor(
|
||||
{
|
||||
enrichedPayload: data.enrichedPayload,
|
||||
userId: data.userId,
|
||||
claimId: data.claimId,
|
||||
socketId: data.socketId,
|
||||
},
|
||||
job.id
|
||||
);
|
||||
}
|
||||
if (jobType === "cca-eligibility-check") {
|
||||
return runCCAEligibilityProcessor(
|
||||
{
|
||||
|
||||
@@ -127,6 +127,13 @@ export async function runDDMAClaimProcessor(
|
||||
if (claimNumber) updates.claimNumber = claimNumber;
|
||||
await storage.updateClaim(claimId, updates);
|
||||
log("ddma-claim-processor", "claim record updated", { claimId, claimNumber });
|
||||
|
||||
// Touch patient so they rise to top of the list across all pages
|
||||
const claim = await storage.getClaim(claimId);
|
||||
if (claim?.patientId) {
|
||||
await storage.touchPatient(claim.patientId);
|
||||
log("ddma-claim-processor", "patient touched", { patientId: claim.patientId });
|
||||
}
|
||||
} catch (e) {
|
||||
log("ddma-claim-processor", "failed to update claim record (non-fatal)", { error: e });
|
||||
}
|
||||
|
||||
164
apps/Backend/src/queue/processors/unitedDHClaimProcessor.ts
Normal file
164
apps/Backend/src/queue/processors/unitedDHClaimProcessor.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Processor for "uniteddh-claim-submit" jobs.
|
||||
* Opens a claim on the United/DentalHub provider portal via Selenium.
|
||||
*
|
||||
* Flow:
|
||||
* 1. POST /uniteddh-claim to Python agent → get session_id
|
||||
* 2. Emit selenium:uniteddh_claim_started to frontend
|
||||
* 3. Poll until completed/error
|
||||
* 4. Emit result
|
||||
*/
|
||||
import {
|
||||
forwardToSeleniumUnitedDHClaimAgent,
|
||||
getSeleniumUnitedDHClaimSessionStatus,
|
||||
} from "../../services/seleniumUnitedDHClaimClient";
|
||||
import { io } from "../../socket";
|
||||
import { storage } from "../../storage";
|
||||
|
||||
function log(tag: string, msg: string, ctx?: any) {
|
||||
console.log(`${new Date().toISOString()} [${tag}] ${msg}`, ctx ?? "");
|
||||
}
|
||||
|
||||
function emitToSocket(socketId: string | undefined, event: string, payload: any) {
|
||||
if (!socketId || !io) return;
|
||||
try {
|
||||
const socket = io.sockets.sockets.get(socketId);
|
||||
if (socket) socket.emit(event, payload);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function pollUntilDone(
|
||||
sessionId: string,
|
||||
socketId: string | undefined,
|
||||
jobId: string,
|
||||
pollTimeoutMs = 5 * 60 * 1000
|
||||
): Promise<any> {
|
||||
const maxAttempts = 600;
|
||||
const pollIntervalMs = 500;
|
||||
const maxTransientErrors = 12;
|
||||
const noProgressLimit = 240; // 120s of waiting_for_otp before giving up
|
||||
let transientErrors = 0;
|
||||
let consecutiveNoProgress = 0;
|
||||
let lastStatus: string | null = null;
|
||||
const deadline = Date.now() + pollTimeoutMs;
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error(`UnitedDH claim polling timeout for session ${sessionId}`);
|
||||
}
|
||||
try {
|
||||
const st = await getSeleniumUnitedDHClaimSessionStatus(sessionId);
|
||||
const status: string = st?.status ?? "unknown";
|
||||
log("uniteddh-claim-processor", `poll attempt=${attempt}`, { sessionId, status });
|
||||
transientErrors = 0;
|
||||
|
||||
const isTerminal = status === "completed" || status === "error" || status === "not_found";
|
||||
if (status === lastStatus && !isTerminal) {
|
||||
consecutiveNoProgress++;
|
||||
} else {
|
||||
consecutiveNoProgress = 0;
|
||||
}
|
||||
lastStatus = status;
|
||||
|
||||
if (consecutiveNoProgress >= noProgressLimit) {
|
||||
throw new Error(`No progress from Python agent (status="${status}") after ${consecutiveNoProgress} polls`);
|
||||
}
|
||||
|
||||
if (status === "waiting_for_otp") {
|
||||
// Emit every poll (same as eligibility) so the popup appears immediately
|
||||
emitToSocket(socketId, "selenium:otp_required", {
|
||||
session_id: sessionId,
|
||||
jobId,
|
||||
message: "OTP required. Please enter the OTP shown by the DentalHub portal.",
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (status === "completed") return st.result;
|
||||
if (status === "error" || status === "not_found") {
|
||||
throw new Error(st?.message || `UnitedDH claim session ended with status: ${status}`);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
||||
} catch (err: any) {
|
||||
const isTerminal =
|
||||
err?.response?.status === 404 ||
|
||||
(typeof err?.message === "string" &&
|
||||
(err.message.includes("not_found") || err.message.includes("polling timeout")));
|
||||
if (isTerminal) throw err;
|
||||
transientErrors++;
|
||||
if (transientErrors > maxTransientErrors) {
|
||||
throw new Error(`Too many transient errors polling UnitedDH claim session ${sessionId}`);
|
||||
}
|
||||
const backoff = Math.min(30_000, 500 * Math.pow(2, transientErrors - 1));
|
||||
await new Promise((r) => setTimeout(r, backoff));
|
||||
}
|
||||
}
|
||||
throw new Error(`UnitedDH claim polling exhausted all attempts for session ${sessionId}`);
|
||||
}
|
||||
|
||||
export interface UnitedDHClaimProcessorInput {
|
||||
enrichedPayload: any;
|
||||
userId: number;
|
||||
claimId?: number;
|
||||
socketId?: string;
|
||||
}
|
||||
|
||||
export async function runUnitedDHClaimProcessor(
|
||||
input: UnitedDHClaimProcessorInput,
|
||||
jobId: string
|
||||
): Promise<{ status: string; pdf_url?: string; claimNumber?: string }> {
|
||||
const { enrichedPayload, userId, claimId, socketId } = input;
|
||||
|
||||
log("uniteddh-claim-processor", "starting Python agent session", { claimId });
|
||||
const agentResp = await forwardToSeleniumUnitedDHClaimAgent(enrichedPayload);
|
||||
|
||||
if (!agentResp?.session_id) {
|
||||
throw new Error("Python agent did not return a session_id for UnitedDH claim");
|
||||
}
|
||||
|
||||
const sessionId = agentResp.session_id as string;
|
||||
log("uniteddh-claim-processor", "got session_id", { sessionId });
|
||||
|
||||
emitToSocket(socketId, "selenium:uniteddh_claim_started", { session_id: sessionId, jobId });
|
||||
|
||||
const seleniumResult = await pollUntilDone(sessionId, socketId, jobId);
|
||||
|
||||
if (!seleniumResult || seleniumResult.status === "error") {
|
||||
throw new Error(seleniumResult?.message ?? "UnitedDH claim session returned an error");
|
||||
}
|
||||
|
||||
const claimNumber: string | undefined = seleniumResult.claimNumber ?? undefined;
|
||||
const pdf_url: string | undefined = seleniumResult.pdf_url ?? undefined;
|
||||
|
||||
if (claimId) {
|
||||
try {
|
||||
const updates: Record<string, any> = { status: "REVIEW" };
|
||||
if (claimNumber) updates.claimNumber = claimNumber;
|
||||
await storage.updateClaim(claimId, updates);
|
||||
log("uniteddh-claim-processor", "claim record updated", { claimId, claimNumber });
|
||||
|
||||
// Touch patient so they rise to top of the list across all pages
|
||||
const claim = await storage.getClaim(claimId);
|
||||
if (claim?.patientId) {
|
||||
await storage.touchPatient(claim.patientId);
|
||||
log("uniteddh-claim-processor", "patient touched", { patientId: claim.patientId });
|
||||
}
|
||||
} catch (e) {
|
||||
log("uniteddh-claim-processor", "failed to update claim record (non-fatal)", { error: e });
|
||||
}
|
||||
}
|
||||
|
||||
emitToSocket(socketId, "selenium:uniteddh_claim_completed", {
|
||||
jobId,
|
||||
claimId,
|
||||
claimNumber,
|
||||
pdf_url,
|
||||
message: claimNumber
|
||||
? `United/DentalHub claim submitted — Claim #: ${claimNumber}`
|
||||
: (seleniumResult?.message ?? "United/DentalHub claim submitted successfully"),
|
||||
});
|
||||
|
||||
log("uniteddh-claim-processor", "done", { claimId, claimNumber });
|
||||
return { status: "success", pdf_url, claimNumber };
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import insuranceStatusCCARoutes from "./insuranceStatusCCA";
|
||||
import insuranceStatusCCAClaimRoutes from "./insuranceStatusCCAClaim";
|
||||
import insuranceStatusCCAPreAuthRoutes from "./insuranceStatusCCAPreAuth";
|
||||
import insuranceStatusDDMAClaimRoutes from "./insuranceStatusDDMAClaim";
|
||||
import insuranceStatusUnitedDHClaimRoutes from "./insuranceStatusUnitedDHClaim";
|
||||
import insuranceStatusTuftsSCOClaimRoutes from "./insuranceStatusTuftsSCOClaim";
|
||||
import paymentsRoutes from "./payments";
|
||||
import databaseManagementRoutes from "./database-management";
|
||||
import notificationsRoutes from "./notifications";
|
||||
@@ -60,6 +62,8 @@ router.use("/insurance-status-cca", insuranceStatusCCARoutes);
|
||||
router.use("/claims", insuranceStatusCCAClaimRoutes);
|
||||
router.use("/claims", insuranceStatusCCAPreAuthRoutes);
|
||||
router.use("/claims", insuranceStatusDDMAClaimRoutes);
|
||||
router.use("/claims", insuranceStatusUnitedDHClaimRoutes);
|
||||
router.use("/claims", insuranceStatusTuftsSCOClaimRoutes);
|
||||
router.use("/payments", paymentsRoutes);
|
||||
router.use("/database-management", databaseManagementRoutes);
|
||||
router.use("/notifications", notificationsRoutes);
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface IStorage {
|
||||
getPatientsByIds(ids: number[]): Promise<Patient[]>;
|
||||
createPatient(patient: InsertPatient): Promise<Patient>;
|
||||
updatePatient(id: number, patient: UpdatePatient): Promise<Patient>;
|
||||
touchPatient(id: number): Promise<void>;
|
||||
deletePatient(id: number): Promise<void>;
|
||||
searchPatients(args: {
|
||||
filters: any;
|
||||
@@ -105,6 +106,15 @@ export const patientsStorage: IStorage = {
|
||||
}
|
||||
},
|
||||
|
||||
async touchPatient(id: number): Promise<void> {
|
||||
try {
|
||||
await db.patient.update({
|
||||
where: { id },
|
||||
data: { updatedAt: new Date() },
|
||||
});
|
||||
} catch (_) {}
|
||||
},
|
||||
|
||||
async deletePatient(id: number): Promise<void> {
|
||||
try {
|
||||
await db.patient.delete({ where: { id } });
|
||||
|
||||
@@ -90,6 +90,8 @@ interface ClaimFormProps {
|
||||
onHandleForCCASeleniumClaim: (data: ClaimFormData) => void;
|
||||
onHandleForCCASeleniumPreAuth: (data: ClaimPreAuthData) => void;
|
||||
onHandleForDDMASeleniumClaim: (data: ClaimFormData) => void;
|
||||
onHandleForUnitedDHSeleniumClaim: (data: ClaimFormData) => void;
|
||||
onHandleForTuftsSCOSeleniumClaim: (data: ClaimFormData) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -105,6 +107,8 @@ export function ClaimForm({
|
||||
onHandleForCCASeleniumClaim,
|
||||
onHandleForCCASeleniumPreAuth,
|
||||
onHandleForDDMASeleniumClaim,
|
||||
onHandleForUnitedDHSeleniumClaim,
|
||||
onHandleForTuftsSCOSeleniumClaim,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: ClaimFormProps) {
|
||||
@@ -618,7 +622,7 @@ export function ClaimForm({
|
||||
if (p.includes("ddma") || p.includes("delta dental ma")) return "DDMA";
|
||||
if (p.includes("delta ins") || p === "deltains") return "DeltaIns";
|
||||
if (p.includes("tufts") || p.includes("dentaquest") || p === "tuftssco") return "TuftsSCO";
|
||||
if (p.includes("united sco") || p === "unitedsco") return "UnitedSCO";
|
||||
if ((p.includes("united") && p.includes("sco")) || p === "unitedsco") return "UnitedSCO";
|
||||
if (p.includes("cmsp")) return "CMSP";
|
||||
if (p.includes("bcbs") || p.includes("blue cross")) return "BCBS";
|
||||
if (p.includes("united aapr") || p === "unitedaapr") return "UnitedAAPR";
|
||||
@@ -1070,6 +1074,160 @@ export function ClaimForm({
|
||||
onClose();
|
||||
};
|
||||
|
||||
// United/DentalHub Claim: saves to DB then submits via Selenium
|
||||
const handleUnitedDHClaim = async () => {
|
||||
const missingFields: string[] = [];
|
||||
if (!form.memberId?.trim()) missingFields.push("Member ID");
|
||||
if (!form.dateOfBirth?.trim()) missingFields.push("Date of Birth");
|
||||
if (!patient?.firstName?.trim()) missingFields.push("First Name");
|
||||
if (missingFields.length > 0) {
|
||||
toast({
|
||||
title: "Missing Required Fields",
|
||||
description: `Please fill out the following field(s): ${missingFields.join(", ")}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const filteredServiceLines = (form.serviceLines || []).filter(
|
||||
(line) => (line.procedureCode ?? "").trim() !== "",
|
||||
);
|
||||
if (filteredServiceLines.length === 0) {
|
||||
toast({
|
||||
title: "No procedure codes",
|
||||
description: "Please add at least one procedure code before submitting the claim.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let appointmentIdToUse = appointmentId;
|
||||
if (appointmentIdToUse == null) {
|
||||
const created = await onHandleAppointmentSubmit({
|
||||
patientId,
|
||||
date: serviceDate,
|
||||
staffId: appointmentStaffId ?? staff?.id,
|
||||
});
|
||||
if (typeof created === "number" && created > 0) {
|
||||
appointmentIdToUse = created;
|
||||
} else if (created && typeof (created as any).id === "number") {
|
||||
appointmentIdToUse = (created as any).id;
|
||||
}
|
||||
}
|
||||
|
||||
const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = form;
|
||||
|
||||
const claimFilesMeta: ClaimFileMeta[] = uploadedFiles?.length
|
||||
? await uploadAttachmentsToLocalFolder(uploadedFiles)
|
||||
: [];
|
||||
|
||||
const selectedNpiProviderId = npiProvider?.npiNumber
|
||||
? npiProviders.find((p) => p.npiNumber === npiProvider.npiNumber)?.id ?? null
|
||||
: null;
|
||||
|
||||
const createdClaim = await onSubmit({
|
||||
...formToCreateClaim,
|
||||
serviceLines: filteredServiceLines,
|
||||
staffId: appointmentStaffId ?? Number(staff?.id),
|
||||
patientId,
|
||||
insuranceProvider: "United/DentalHub",
|
||||
appointmentId: appointmentIdToUse!,
|
||||
claimFiles: claimFilesMeta,
|
||||
...(selectedNpiProviderId ? { npiProviderId: selectedNpiProviderId } : {}),
|
||||
});
|
||||
|
||||
onHandleForUnitedDHSeleniumClaim({
|
||||
...form,
|
||||
serviceLines: filteredServiceLines,
|
||||
staffId: appointmentStaffId ?? Number(staff?.id),
|
||||
patientId,
|
||||
insuranceProvider: "United/DentalHub",
|
||||
appointmentId: appointmentIdToUse!,
|
||||
insuranceSiteKey: "UNITED_SCO",
|
||||
claimId: createdClaim.id,
|
||||
claimFiles: claimFilesMeta,
|
||||
});
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Tufts SCO Claim: saves to DB then submits via Selenium
|
||||
const handleTuftsSCOClaim = async () => {
|
||||
const missingFields: string[] = [];
|
||||
if (!form.memberId?.trim()) missingFields.push("Member ID");
|
||||
if (!form.dateOfBirth?.trim()) missingFields.push("Date of Birth");
|
||||
if (!patient?.firstName?.trim()) missingFields.push("First Name");
|
||||
if (missingFields.length > 0) {
|
||||
toast({
|
||||
title: "Missing Required Fields",
|
||||
description: `Please fill out the following field(s): ${missingFields.join(", ")}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const filteredServiceLines = (form.serviceLines || []).filter(
|
||||
(line) => (line.procedureCode ?? "").trim() !== "",
|
||||
);
|
||||
if (filteredServiceLines.length === 0) {
|
||||
toast({
|
||||
title: "No procedure codes",
|
||||
description: "Please add at least one procedure code before submitting the claim.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let appointmentIdToUse = appointmentId;
|
||||
if (appointmentIdToUse == null) {
|
||||
const created = await onHandleAppointmentSubmit({
|
||||
patientId,
|
||||
date: serviceDate,
|
||||
staffId: appointmentStaffId ?? staff?.id,
|
||||
});
|
||||
if (typeof created === "number" && created > 0) {
|
||||
appointmentIdToUse = created;
|
||||
} else if (created && typeof (created as any).id === "number") {
|
||||
appointmentIdToUse = (created as any).id;
|
||||
}
|
||||
}
|
||||
|
||||
const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = form;
|
||||
|
||||
const claimFilesMeta: ClaimFileMeta[] = uploadedFiles?.length
|
||||
? await uploadAttachmentsToLocalFolder(uploadedFiles)
|
||||
: [];
|
||||
|
||||
const selectedNpiProviderId = npiProvider?.npiNumber
|
||||
? npiProviders.find((p) => p.npiNumber === npiProvider.npiNumber)?.id ?? null
|
||||
: null;
|
||||
|
||||
const createdClaim = await onSubmit({
|
||||
...formToCreateClaim,
|
||||
serviceLines: filteredServiceLines,
|
||||
staffId: appointmentStaffId ?? Number(staff?.id),
|
||||
patientId,
|
||||
insuranceProvider: "Tufts SCO",
|
||||
appointmentId: appointmentIdToUse!,
|
||||
claimFiles: claimFilesMeta,
|
||||
...(selectedNpiProviderId ? { npiProviderId: selectedNpiProviderId } : {}),
|
||||
});
|
||||
|
||||
onHandleForTuftsSCOSeleniumClaim({
|
||||
...form,
|
||||
serviceLines: filteredServiceLines,
|
||||
staffId: appointmentStaffId ?? Number(staff?.id),
|
||||
patientId,
|
||||
insuranceProvider: "Tufts SCO",
|
||||
appointmentId: appointmentIdToUse!,
|
||||
insuranceSiteKey: "TuftsSCO",
|
||||
claimId: createdClaim.id,
|
||||
claimFiles: claimFilesMeta,
|
||||
});
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCCAPreAuth = async () => {
|
||||
const missingFields: string[] = [];
|
||||
if (!form.memberId?.trim()) missingFields.push("Member ID");
|
||||
@@ -2007,10 +2165,16 @@ export function ClaimForm({
|
||||
>
|
||||
Delta MA Claim
|
||||
</Button>
|
||||
<Button className="w-44" variant="outline">
|
||||
<Button
|
||||
className="w-44 bg-orange-600 hover:bg-orange-700 text-white"
|
||||
onClick={() => runWithPriceCheck(handleUnitedDHClaim)}
|
||||
>
|
||||
United/DentalHub Claim
|
||||
</Button>
|
||||
<Button className="w-32" variant="outline">
|
||||
<Button
|
||||
className="w-32 bg-teal-600 hover:bg-teal-700 text-white"
|
||||
onClick={() => runWithPriceCheck(handleTuftsSCOClaim)}
|
||||
>
|
||||
Tufts Claim
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -20,7 +20,7 @@ const SITE_KEY_OPTIONS = [
|
||||
{ value: "DDMA", label: "Delta Dental MA (DDMA)" },
|
||||
{ value: "DELTAINS", label: "Delta Dental Ins (DELTAINS)" },
|
||||
{ value: "TUFTS_SCO", label: "Tufts SCO (TUFTS_SCO)" },
|
||||
{ value: "UNITED_SCO", label: "United SCO (UNITED_SCO)" },
|
||||
{ value: "UNITED_SCO", label: "United SCO / DentalHub (UNITED_SCO)" },
|
||||
{ value: "CCA", label: "CCA (CCA)" },
|
||||
];
|
||||
|
||||
|
||||
@@ -1979,6 +1979,10 @@ export default function AppointmentsPage() {
|
||||
onHandleForMHSeleniumClaim={() => {}}
|
||||
onHandleForMHSeleniumClaimPreAuth={() => {}}
|
||||
onHandleForCCASeleniumClaim={() => {}}
|
||||
onHandleForCCASeleniumPreAuth={() => {}}
|
||||
onHandleForDDMASeleniumClaim={() => {}}
|
||||
onHandleForUnitedDHSeleniumClaim={() => {}}
|
||||
onHandleForTuftsSCOSeleniumClaim={() => {}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -59,6 +59,12 @@ export default function ClaimsPage() {
|
||||
const [ddmaClaimOtpOpen, setDdmaClaimOtpOpen] = useState(false);
|
||||
const [ddmaClaimOtpSubmitting, setDdmaClaimOtpSubmitting] = useState(false);
|
||||
const ddmaClaimSessionIdRef = useRef<string | null>(null);
|
||||
const [unitedDHClaimOtpOpen, setUnitedDHClaimOtpOpen] = useState(false);
|
||||
const [unitedDHClaimOtpSubmitting, setUnitedDHClaimOtpSubmitting] = useState(false);
|
||||
const unitedDHClaimSessionIdRef = useRef<string | null>(null);
|
||||
const [tuftsSCOClaimOtpOpen, setTuftsSCOClaimOtpOpen] = useState(false);
|
||||
const [tuftsSCOClaimOtpSubmitting, setTuftsSCOClaimOtpSubmitting] = useState(false);
|
||||
const tuftsSCOClaimSessionIdRef = useRef<string | null>(null);
|
||||
const pendingClaimMeta = useRef<{
|
||||
patientId: number | null;
|
||||
groupKey: "INSURANCE_CLAIM" | "INSURANCE_CLAIM_PREAUTH";
|
||||
@@ -526,6 +532,136 @@ export default function ClaimsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnitedDHClaimOtpSubmit = async (otp: string) => {
|
||||
const sessionId = unitedDHClaimSessionIdRef.current;
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
setUnitedDHClaimOtpSubmitting(true);
|
||||
const resp = await apiRequest("POST", "/api/claims/uniteddh-claim/selenium/submit-otp", {
|
||||
session_id: sessionId,
|
||||
otp,
|
||||
socketId,
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok || data.error) throw new Error(data.error || "Failed to submit OTP");
|
||||
setUnitedDHClaimOtpOpen(false);
|
||||
dispatch(setTaskStatus({ key: "claimSubmit", status: "pending", message: "OTP submitted. Continuing United/DentalHub claim..." }));
|
||||
} catch (err: any) {
|
||||
toast({ title: "Failed to submit OTP", description: err?.message || "Error submitting OTP", variant: "destructive" });
|
||||
} finally {
|
||||
setUnitedDHClaimOtpSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// United/DentalHub claim selenium handler
|
||||
const handleUnitedDHClaimSubmitSelenium = async (data: any) => {
|
||||
try {
|
||||
dispatch(setTaskStatus({ key: "claimSubmit", status: "pending", message: "Submitting United/DentalHub claim..." }));
|
||||
const response = await apiRequest("POST", "/api/claims/uniteddh-claim", {
|
||||
data,
|
||||
socketId,
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.error) throw new Error(result.error);
|
||||
pendingClaimMeta.current = { patientId: selectedPatientId, groupKey: "INSURANCE_CLAIM" };
|
||||
setPendingClaimJobId(result.jobId);
|
||||
|
||||
const jobId = result.jobId;
|
||||
const onSessionStarted = (ev: any) => {
|
||||
if (String(ev?.jobId) !== String(jobId)) return;
|
||||
unitedDHClaimSessionIdRef.current = ev.session_id ?? null;
|
||||
};
|
||||
const onOtpRequired = (ev: any) => {
|
||||
if (String(ev?.jobId) !== String(jobId)) return;
|
||||
if (ev.session_id) unitedDHClaimSessionIdRef.current = ev.session_id;
|
||||
setUnitedDHClaimOtpOpen(true);
|
||||
dispatch(setTaskStatus({ key: "claimSubmit", status: "pending", message: "OTP required for United/DentalHub. Please enter the code." }));
|
||||
};
|
||||
const onDone = (ev: any) => {
|
||||
if (String(ev?.jobId) !== String(jobId)) return;
|
||||
socket.off("selenium:uniteddh_claim_started", onSessionStarted);
|
||||
socket.off("selenium:otp_required", onOtpRequired);
|
||||
socket.off("job:update", onDone);
|
||||
setUnitedDHClaimOtpOpen(false);
|
||||
unitedDHClaimSessionIdRef.current = null;
|
||||
};
|
||||
socket.on("selenium:uniteddh_claim_started", onSessionStarted);
|
||||
socket.on("selenium:otp_required", onOtpRequired);
|
||||
socket.on("job:update", onDone);
|
||||
|
||||
dispatch(setTaskStatus({ key: "claimSubmit", status: "pending", message: "United/DentalHub claim queued. Awaiting Selenium..." }));
|
||||
toast({ title: "United/DentalHub Claim queued", description: "Selenium is opening the claim form.", variant: "default" });
|
||||
} catch (error: any) {
|
||||
dispatch(setTaskStatus({ key: "claimSubmit", status: "error", message: error.message || "United/DentalHub claim failed" }));
|
||||
toast({ title: "United/DentalHub Claim error", description: error.message || "An error occurred.", variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleTuftsSCOClaimOtpSubmit = async (otp: string) => {
|
||||
const sessionId = tuftsSCOClaimSessionIdRef.current;
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
setTuftsSCOClaimOtpSubmitting(true);
|
||||
const resp = await apiRequest("POST", "/api/claims/tuftssco-claim/selenium/submit-otp", {
|
||||
session_id: sessionId,
|
||||
otp,
|
||||
socketId,
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok || data.error) throw new Error(data.error || "Failed to submit OTP");
|
||||
setTuftsSCOClaimOtpOpen(false);
|
||||
dispatch(setTaskStatus({ key: "claimSubmit", status: "pending", message: "OTP submitted. Continuing Tufts SCO claim..." }));
|
||||
} catch (err: any) {
|
||||
toast({ title: "Failed to submit OTP", description: err?.message || "Error submitting OTP", variant: "destructive" });
|
||||
} finally {
|
||||
setTuftsSCOClaimOtpSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Tufts SCO claim selenium handler
|
||||
const handleTuftsSCOClaimSubmitSelenium = async (data: any) => {
|
||||
try {
|
||||
dispatch(setTaskStatus({ key: "claimSubmit", status: "pending", message: "Submitting Tufts SCO claim..." }));
|
||||
const response = await apiRequest("POST", "/api/claims/tuftssco-claim", {
|
||||
data,
|
||||
socketId,
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.error) throw new Error(result.error);
|
||||
pendingClaimMeta.current = { patientId: selectedPatientId, groupKey: "INSURANCE_CLAIM" };
|
||||
setPendingClaimJobId(result.jobId);
|
||||
|
||||
const jobId = result.jobId;
|
||||
const onSessionStarted = (ev: any) => {
|
||||
if (String(ev?.jobId) !== String(jobId)) return;
|
||||
tuftsSCOClaimSessionIdRef.current = ev.session_id ?? null;
|
||||
};
|
||||
const onOtpRequired = (ev: any) => {
|
||||
if (String(ev?.jobId) !== String(jobId)) return;
|
||||
if (ev.session_id) tuftsSCOClaimSessionIdRef.current = ev.session_id;
|
||||
setTuftsSCOClaimOtpOpen(true);
|
||||
dispatch(setTaskStatus({ key: "claimSubmit", status: "pending", message: "OTP required for Tufts SCO. Please enter the code." }));
|
||||
};
|
||||
const onDone = (ev: any) => {
|
||||
if (String(ev?.jobId) !== String(jobId)) return;
|
||||
socket.off("selenium:tuftssco_claim_started", onSessionStarted);
|
||||
socket.off("selenium:otp_required", onOtpRequired);
|
||||
socket.off("job:update", onDone);
|
||||
setTuftsSCOClaimOtpOpen(false);
|
||||
tuftsSCOClaimSessionIdRef.current = null;
|
||||
};
|
||||
socket.on("selenium:tuftssco_claim_started", onSessionStarted);
|
||||
socket.on("selenium:otp_required", onOtpRequired);
|
||||
socket.on("job:update", onDone);
|
||||
|
||||
dispatch(setTaskStatus({ key: "claimSubmit", status: "pending", message: "Tufts SCO claim queued. Awaiting Selenium..." }));
|
||||
toast({ title: "Tufts SCO Claim queued", description: "Selenium is opening the claim form.", variant: "default" });
|
||||
} catch (error: any) {
|
||||
dispatch(setTaskStatus({ key: "claimSubmit", status: "error", message: error.message || "Tufts SCO claim failed" }));
|
||||
toast({ title: "Tufts SCO Claim error", description: error.message || "An error occurred.", variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
// CCA pre-auth selenium handler
|
||||
const handleCCAPreAuthSubmitSelenium = async (data: any) => {
|
||||
const formData = new FormData();
|
||||
@@ -773,6 +909,8 @@ export default function ClaimsPage() {
|
||||
onHandleForCCASeleniumClaim={handleCCAClaimSubmitSelenium}
|
||||
onHandleForCCASeleniumPreAuth={handleCCAPreAuthSubmitSelenium}
|
||||
onHandleForDDMASeleniumClaim={handleDDMAClaimSubmitSelenium}
|
||||
onHandleForUnitedDHSeleniumClaim={handleUnitedDHClaimSubmitSelenium}
|
||||
onHandleForTuftsSCOSeleniumClaim={handleTuftsSCOClaimSubmitSelenium}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -826,6 +964,84 @@ export default function ClaimsPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tufts SCO Claim OTP Modal */}
|
||||
{tuftsSCOClaimOtpOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white dark:bg-slate-900 rounded-xl shadow-lg w-full max-w-md p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Enter OTP — Tufts SCO Claim</h2>
|
||||
<button type="button" onClick={() => setTuftsSCOClaimOtpOpen(false)} className="text-slate-500 hover:text-slate-800">✕</button>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mb-4">
|
||||
The Tufts SCO (DentaQuest) portal requires a one-time password (OTP) to continue claim submission.
|
||||
</p>
|
||||
<form onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const input = (e.currentTarget.elements.namedItem("otp") as HTMLInputElement);
|
||||
if (input?.value.trim()) handleTuftsSCOClaimOtpSubmit(input.value.trim());
|
||||
}} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="tuftssco-claim-otp" className="text-sm font-medium">OTP</label>
|
||||
<input
|
||||
id="tuftssco-claim-otp"
|
||||
name="otp"
|
||||
placeholder="Enter OTP code"
|
||||
autoFocus
|
||||
className="w-full border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button type="button" onClick={() => setTuftsSCOClaimOtpOpen(false)} disabled={tuftsSCOClaimOtpSubmitting}
|
||||
className="px-4 py-2 text-sm border rounded-md hover:bg-slate-50 disabled:opacity-50">Cancel</button>
|
||||
<button type="submit" disabled={tuftsSCOClaimOtpSubmitting}
|
||||
className="px-4 py-2 text-sm bg-primary text-white rounded-md hover:bg-primary/90 disabled:opacity-50">
|
||||
{tuftsSCOClaimOtpSubmitting ? "Submitting..." : "Submit OTP"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* United/DentalHub Claim OTP Modal */}
|
||||
{unitedDHClaimOtpOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white dark:bg-slate-900 rounded-xl shadow-lg w-full max-w-md p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Enter OTP — United/DentalHub Claim</h2>
|
||||
<button type="button" onClick={() => setUnitedDHClaimOtpOpen(false)} className="text-slate-500 hover:text-slate-800">✕</button>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mb-4">
|
||||
The United/DentalHub portal requires a one-time password (OTP) to continue claim submission.
|
||||
</p>
|
||||
<form onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const input = (e.currentTarget.elements.namedItem("otp") as HTMLInputElement);
|
||||
if (input?.value.trim()) handleUnitedDHClaimOtpSubmit(input.value.trim());
|
||||
}} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="uniteddh-claim-otp" className="text-sm font-medium">OTP</label>
|
||||
<input
|
||||
id="uniteddh-claim-otp"
|
||||
name="otp"
|
||||
placeholder="Enter OTP code"
|
||||
autoFocus
|
||||
className="w-full border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button type="button" onClick={() => setUnitedDHClaimOtpOpen(false)} disabled={unitedDHClaimOtpSubmitting}
|
||||
className="px-4 py-2 text-sm border rounded-md hover:bg-slate-50 disabled:opacity-50">Cancel</button>
|
||||
<button type="submit" disabled={unitedDHClaimOtpSubmitting}
|
||||
className="px-4 py-2 text-sm bg-primary text-white rounded-md hover:bg-primary/90 disabled:opacity-50">
|
||||
{unitedDHClaimOtpSubmitting ? "Submitting..." : "Submit OTP"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import Decimal from "decimal.js";
|
||||
import rawCodeTable from "@/assets/data/procedureCodesMH.json";
|
||||
import rawCCACodeTable from "@/assets/data/procedureCodesCCA.json";
|
||||
import rawDDMACodeTable from "@/assets/data/procedureCodesDDMA.json";
|
||||
import rawUnitedDHCodeTable from "@/assets/data/procedureCodesUnitedDH.json";
|
||||
import rawTuftsSCOCodeTable from "@/assets/data/procedureCodesTuftsSCO.json";
|
||||
import { PROCEDURE_COMBOS } from "./procedureCombos";
|
||||
|
||||
/* ----------------------------- Types ----------------------------- */
|
||||
@@ -17,6 +19,8 @@ export type CodeRow = {
|
||||
const CODE_TABLE = rawCodeTable as CodeRow[];
|
||||
const CCA_CODE_TABLE = rawCCACodeTable as CodeRow[];
|
||||
const DDMA_CODE_TABLE = rawDDMACodeTable as CodeRow[];
|
||||
const UNITEDDH_CODE_TABLE = rawUnitedDHCodeTable as CodeRow[];
|
||||
const TUFTSSCO_CODE_TABLE = rawTuftsSCOCodeTable as CodeRow[];
|
||||
|
||||
export type ClaimFormLike = {
|
||||
serviceDate: string; // form-level service date
|
||||
@@ -67,10 +71,30 @@ const DDMA_CODE_MAP: Map<string, CodeRow> = (() => {
|
||||
return m;
|
||||
})();
|
||||
|
||||
const UNITEDDH_CODE_MAP: Map<string, CodeRow> = (() => {
|
||||
const m = new Map<string, CodeRow>();
|
||||
for (const r of UNITEDDH_CODE_TABLE) {
|
||||
const k = normalizeCode(String(r["Procedure Code"] || ""));
|
||||
if (k && !m.has(k)) m.set(k, r);
|
||||
}
|
||||
return m;
|
||||
})();
|
||||
|
||||
const TUFTSSCO_CODE_MAP: Map<string, CodeRow> = (() => {
|
||||
const m = new Map<string, CodeRow>();
|
||||
for (const r of TUFTSSCO_CODE_TABLE) {
|
||||
const k = normalizeCode(String(r["Procedure Code"] || ""));
|
||||
if (k && !m.has(k)) m.set(k, r);
|
||||
}
|
||||
return m;
|
||||
})();
|
||||
|
||||
/** Return the correct fee-schedule map for the given insurance type. */
|
||||
function getCodeMap(insuranceSiteKey?: string): Map<string, CodeRow> {
|
||||
if (insuranceSiteKey === "CCA") return CCA_CODE_MAP;
|
||||
if (insuranceSiteKey === "DDMA") return DDMA_CODE_MAP;
|
||||
if (insuranceSiteKey === "UNITED_SCO") return UNITEDDH_CODE_MAP;
|
||||
if (insuranceSiteKey === "TuftsSCO") return TUFTSSCO_CODE_MAP;
|
||||
return CODE_MAP; // default: MassHealth
|
||||
}
|
||||
|
||||
@@ -345,7 +369,7 @@ export function applyComboToForm<T extends ClaimFormLike>(
|
||||
}
|
||||
|
||||
|
||||
export { CODE_MAP, CCA_CODE_MAP, DDMA_CODE_MAP, getCodeMap, getPriceForCodeWithAgeFromMap };
|
||||
export { CODE_MAP, CCA_CODE_MAP, DDMA_CODE_MAP, UNITEDDH_CODE_MAP, TUFTSSCO_CODE_MAP, getCodeMap, getPriceForCodeWithAgeFromMap };
|
||||
|
||||
export type PriceMismatch = {
|
||||
procedureCode: string;
|
||||
@@ -362,7 +386,7 @@ export function findPriceMismatches(
|
||||
patientDOB: string,
|
||||
serviceDate: string,
|
||||
): PriceMismatch[] {
|
||||
const supported = ["MH", "MASSHEALTH", "CCA", "DDMA"];
|
||||
const supported = ["MH", "MASSHEALTH", "CCA", "DDMA", "UNITEDDH", "TUFTSSCO"];
|
||||
if (!insuranceSiteKey || !supported.includes(insuranceSiteKey.toUpperCase())) return [];
|
||||
|
||||
const map = getCodeMap(insuranceSiteKey);
|
||||
|
||||
@@ -21,6 +21,8 @@ import helpers_cca_eligibility as hcca
|
||||
import helpers_cca_claim as hcca_claim
|
||||
import helpers_cca_preauth as hcca_preauth
|
||||
import helpers_ddma_claim as hddma_claim
|
||||
import helpers_uniteddh_claim as huniteddh_claim
|
||||
import helpers_tuftssco_claim as htuftssco_claim
|
||||
|
||||
# Import startup session-clear functions
|
||||
from ddma_browser_manager import clear_ddma_session_on_startup
|
||||
@@ -628,6 +630,90 @@ async def ddma_claim(request: Request):
|
||||
return {"status": "started", "session_id": sid}
|
||||
|
||||
|
||||
async def _uniteddh_claim_worker_wrapper(sid: str, data: dict, url: str):
|
||||
"""Background worker for United/DentalHub claim submission."""
|
||||
global active_jobs, waiting_jobs
|
||||
async with semaphore:
|
||||
async with lock:
|
||||
waiting_jobs -= 1
|
||||
active_jobs += 1
|
||||
try:
|
||||
await huniteddh_claim.start_uniteddh_claim_run(sid, data, url)
|
||||
finally:
|
||||
async with lock:
|
||||
active_jobs -= 1
|
||||
|
||||
|
||||
@app.post("/uniteddh-claim")
|
||||
async def uniteddh_claim(request: Request):
|
||||
"""
|
||||
Starts a United/DentalHub claim submission session in the background.
|
||||
Logs in, searches patient, opens Member Information page, clicks Create claim,
|
||||
fills service date and procedure codes.
|
||||
Body: { "claim": { "uniteddhUsername": "...", "uniteddhPassword": "...", ... } }
|
||||
Returns: { status: "started", session_id: "<uuid>" }
|
||||
"""
|
||||
global waiting_jobs
|
||||
|
||||
body = await request.json()
|
||||
|
||||
sid = huniteddh_claim.make_session_entry()
|
||||
huniteddh_claim.sessions[sid]["type"] = "uniteddh_claim"
|
||||
huniteddh_claim.sessions[sid]["last_activity"] = time.time()
|
||||
|
||||
async with lock:
|
||||
waiting_jobs += 1
|
||||
|
||||
asyncio.create_task(_uniteddh_claim_worker_wrapper(
|
||||
sid, body,
|
||||
url="https://app.dentalhub.com/app/login"
|
||||
))
|
||||
|
||||
return {"status": "started", "session_id": sid}
|
||||
|
||||
|
||||
async def _tuftssco_claim_worker_wrapper(sid: str, data: dict, url: str):
|
||||
"""Background worker for Tufts SCO (DentaQuest) claim submission."""
|
||||
global active_jobs, waiting_jobs
|
||||
async with semaphore:
|
||||
async with lock:
|
||||
waiting_jobs -= 1
|
||||
active_jobs += 1
|
||||
try:
|
||||
await htuftssco_claim.start_tuftssco_claim_run(sid, data, url)
|
||||
finally:
|
||||
async with lock:
|
||||
active_jobs -= 1
|
||||
|
||||
|
||||
@app.post("/tuftssco-claim")
|
||||
async def tuftssco_claim(request: Request):
|
||||
"""
|
||||
Starts a Tufts SCO (DentaQuest) claim submission session in the background.
|
||||
Logs in, searches patient, opens Member Information page, clicks Create claim,
|
||||
fills service date and procedure codes.
|
||||
Body: { "claim": { "dentaquestUsername": "...", "dentaquestPassword": "...", ... } }
|
||||
Returns: { status: "started", session_id: "<uuid>" }
|
||||
"""
|
||||
global waiting_jobs
|
||||
|
||||
body = await request.json()
|
||||
|
||||
sid = htuftssco_claim.make_session_entry()
|
||||
htuftssco_claim.sessions[sid]["type"] = "tuftssco_claim"
|
||||
htuftssco_claim.sessions[sid]["last_activity"] = time.time()
|
||||
|
||||
async with lock:
|
||||
waiting_jobs += 1
|
||||
|
||||
asyncio.create_task(_tuftssco_claim_worker_wrapper(
|
||||
sid, body,
|
||||
url="https://providers.dentaquest.com/"
|
||||
))
|
||||
|
||||
return {"status": "started", "session_id": sid}
|
||||
|
||||
|
||||
async def _cca_preauth_worker_wrapper(sid: str, data: dict, url: str):
|
||||
"""Background worker for CCA pre-authorization submission."""
|
||||
global active_jobs, waiting_jobs
|
||||
@@ -692,6 +778,10 @@ async def submit_otp(request: Request):
|
||||
res = hdentaquest.submit_otp(sid, otp)
|
||||
elif sid in hddma_claim.sessions:
|
||||
res = hddma_claim.submit_otp(sid, otp)
|
||||
elif sid in huniteddh_claim.sessions:
|
||||
res = huniteddh_claim.submit_otp(sid, otp)
|
||||
elif sid in htuftssco_claim.sessions:
|
||||
res = htuftssco_claim.submit_otp(sid, otp)
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail="session not found")
|
||||
|
||||
@@ -719,6 +809,10 @@ async def session_status(sid: str):
|
||||
s = hcca_preauth.get_session_status(sid)
|
||||
elif sid in hddma_claim.sessions:
|
||||
s = hddma_claim.get_session_status(sid)
|
||||
elif sid in huniteddh_claim.sessions:
|
||||
s = huniteddh_claim.get_session_status(sid)
|
||||
elif sid in htuftssco_claim.sessions:
|
||||
s = htuftssco_claim.get_session_status(sid)
|
||||
else:
|
||||
s = {"status": "not_found"}
|
||||
if s.get("status") == "not_found":
|
||||
|
||||
344
apps/SeleniumService/helpers_uniteddh_claim.py
Normal file
344
apps/SeleniumService/helpers_uniteddh_claim.py
Normal file
@@ -0,0 +1,344 @@
|
||||
import os
|
||||
import time
|
||||
import asyncio
|
||||
from typing import Dict, Any
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
from selenium.common.exceptions import WebDriverException, TimeoutException
|
||||
|
||||
from selenium_UnitedDH_claimSubmitWorker import AutomationUnitedDHClaimSubmit
|
||||
|
||||
sessions: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
SESSION_OTP_TIMEOUT = int(os.getenv("SESSION_OTP_TIMEOUT", "120"))
|
||||
|
||||
|
||||
def make_session_entry() -> str:
|
||||
import uuid
|
||||
sid = str(uuid.uuid4())
|
||||
sessions[sid] = {
|
||||
"status": "created",
|
||||
"created_at": time.time(),
|
||||
"last_activity": time.time(),
|
||||
"bot": None,
|
||||
"driver": None,
|
||||
"otp_event": asyncio.Event(),
|
||||
"otp_value": None,
|
||||
"result": None,
|
||||
"message": None,
|
||||
}
|
||||
return sid
|
||||
|
||||
|
||||
async def cleanup_session(sid: str, message: str | None = None):
|
||||
s = sessions.get(sid)
|
||||
if not s:
|
||||
return
|
||||
try:
|
||||
if s.get("status") not in ("completed", "error", "not_found"):
|
||||
s["status"] = "error"
|
||||
if message:
|
||||
s["message"] = message
|
||||
ev = s.get("otp_event")
|
||||
if ev and not ev.is_set():
|
||||
ev.set()
|
||||
finally:
|
||||
sessions.pop(sid, None)
|
||||
print(f"[helpers_uniteddh_claim] cleaned session {sid}")
|
||||
|
||||
|
||||
async def _remove_session_later(sid: str, delay: int = 20):
|
||||
await asyncio.sleep(delay)
|
||||
await cleanup_session(sid)
|
||||
|
||||
|
||||
def _minimize_browser(bot):
|
||||
try:
|
||||
if bot and bot.driver:
|
||||
try:
|
||||
bot.driver.get("about:blank")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
bot.driver.minimize_window()
|
||||
print("[UnitedDH Claim] Browser minimized after error")
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
bot.driver.set_window_position(-10000, -10000)
|
||||
print("[UnitedDH Claim] Browser moved off-screen after error")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"[UnitedDH Claim] Could not hide browser: {e}")
|
||||
|
||||
|
||||
async def start_uniteddh_claim_run(sid: str, data: dict, url: str):
|
||||
"""
|
||||
Run the United/DentalHub claim workflow.
|
||||
Login/OTP handling mirrors helpers_ddma_claim.py exactly.
|
||||
Claim steps call selenium_UnitedDH_claimSubmitWorker.
|
||||
"""
|
||||
s = sessions.get(sid)
|
||||
if not s:
|
||||
return {"status": "error", "message": "session not found"}
|
||||
|
||||
s["status"] = "running"
|
||||
s["last_activity"] = time.time()
|
||||
|
||||
bot = None
|
||||
try:
|
||||
bot = AutomationUnitedDHClaimSubmit(data)
|
||||
bot.config_driver()
|
||||
|
||||
s["bot"] = bot
|
||||
s["driver"] = bot.driver
|
||||
s["last_activity"] = time.time()
|
||||
|
||||
try:
|
||||
bot.driver.maximize_window()
|
||||
bot.driver.get(url)
|
||||
await asyncio.sleep(1)
|
||||
except Exception as e:
|
||||
s["status"] = "error"
|
||||
s["message"] = f"Navigation failed: {e}"
|
||||
await cleanup_session(sid)
|
||||
return {"status": "error", "message": s["message"]}
|
||||
|
||||
# --- Login ---
|
||||
try:
|
||||
login_result = bot.login(url)
|
||||
except WebDriverException as wde:
|
||||
s["status"] = "error"
|
||||
s["message"] = f"Selenium driver error during login: {wde}"
|
||||
await cleanup_session(sid, s["message"])
|
||||
return {"status": "error", "message": s["message"]}
|
||||
except Exception as e:
|
||||
s["status"] = "error"
|
||||
s["message"] = f"Unexpected error during login: {e}"
|
||||
await cleanup_session(sid, s["message"])
|
||||
return {"status": "error", "message": s["message"]}
|
||||
|
||||
# ── Already logged in ────────────────────────────────────────────────
|
||||
if isinstance(login_result, str) and login_result == "ALREADY_LOGGED_IN":
|
||||
print("[UnitedDH Claim] Session persisted — skipping OTP")
|
||||
s["status"] = "running"
|
||||
s["message"] = "Session persisted"
|
||||
|
||||
# ── OTP required ─────────────────────────────────────────────────────
|
||||
elif isinstance(login_result, str) and login_result == "OTP_REQUIRED":
|
||||
s["status"] = "waiting_for_otp"
|
||||
s["message"] = "OTP required for login - please enter OTP in browser"
|
||||
s["last_activity"] = time.time()
|
||||
|
||||
driver = s["driver"]
|
||||
max_polls = SESSION_OTP_TIMEOUT
|
||||
login_success = False
|
||||
|
||||
print(f"[UnitedDH Claim OTP] Waiting for user to enter OTP (polling browser for {SESSION_OTP_TIMEOUT}s)...")
|
||||
|
||||
for poll in range(max_polls):
|
||||
await asyncio.sleep(1)
|
||||
s["last_activity"] = time.time()
|
||||
|
||||
try:
|
||||
# Check if OTP was submitted via API (from app)
|
||||
otp_value = s.get("otp_value")
|
||||
if otp_value:
|
||||
print(f"[UnitedDH Claim OTP] OTP received from app: {otp_value}")
|
||||
try:
|
||||
otp_input = driver.find_element(By.XPATH,
|
||||
"//input[contains(@name,'otp') or contains(@name,'code') or @type='tel' or contains(@aria-label,'Verification') or contains(@placeholder,'code')]"
|
||||
)
|
||||
otp_input.clear()
|
||||
otp_input.send_keys(otp_value)
|
||||
try:
|
||||
verify_btn = driver.find_element(By.XPATH, "//button[@type='button' and @aria-label='Verify']")
|
||||
verify_btn.click()
|
||||
print("[UnitedDH Claim OTP] Clicked verify button (aria-label)")
|
||||
except:
|
||||
try:
|
||||
verify_btn = driver.find_element(By.XPATH, "//button[contains(text(),'Verify') or contains(text(),'Submit') or @type='submit']")
|
||||
verify_btn.click()
|
||||
print("[UnitedDH Claim OTP] Clicked verify button (text/type)")
|
||||
except:
|
||||
otp_input.send_keys("\n")
|
||||
print("[UnitedDH Claim OTP] Pressed Enter as fallback")
|
||||
print("[UnitedDH Claim OTP] OTP typed and submitted via app")
|
||||
s["otp_value"] = None
|
||||
await asyncio.sleep(3)
|
||||
except Exception as type_err:
|
||||
print(f"[UnitedDH Claim OTP] Failed to type OTP from app: {type_err}")
|
||||
|
||||
# Check current URL - if we're on dashboard/member page, login succeeded
|
||||
current_url = driver.current_url.lower()
|
||||
print(f"[UnitedDH Claim OTP Poll {poll+1}/{max_polls}] URL: {current_url[:60]}...")
|
||||
|
||||
if "member" in current_url or "dashboard" in current_url or "eligibility" in current_url or "home" in current_url:
|
||||
try:
|
||||
WebDriverWait(driver, 5).until(
|
||||
EC.presence_of_element_located((By.XPATH,
|
||||
'//input[@placeholder="Search by member ID"] | //input[contains(@placeholder,"Search")] | //*[contains(@class,"dashboard")]'
|
||||
))
|
||||
)
|
||||
print("[UnitedDH Claim OTP] Dashboard/search element found - login successful!")
|
||||
login_success = True
|
||||
break
|
||||
except TimeoutException:
|
||||
print("[UnitedDH Claim OTP] On member page but search input not found, continuing to poll...")
|
||||
|
||||
# Also check if OTP input is still visible
|
||||
try:
|
||||
driver.find_element(By.XPATH,
|
||||
"//input[contains(@name,'otp') or contains(@name,'code') or @type='tel' or contains(@aria-label,'Verification') or contains(@placeholder,'code') or contains(@placeholder,'Code')]"
|
||||
)
|
||||
print(f"[UnitedDH Claim OTP Poll {poll+1}] OTP input still visible - waiting...")
|
||||
except:
|
||||
if "login" in current_url or "app/login" in current_url:
|
||||
print("[UnitedDH Claim OTP] OTP input gone, trying to navigate to dashboard...")
|
||||
try:
|
||||
driver.get("https://app.dentalhub.com/app/dashboard")
|
||||
await asyncio.sleep(2)
|
||||
except:
|
||||
pass
|
||||
|
||||
except Exception as poll_err:
|
||||
print(f"[UnitedDH Claim OTP Poll {poll+1}] Error: {poll_err}")
|
||||
|
||||
if not login_success:
|
||||
try:
|
||||
print("[UnitedDH Claim OTP] Final attempt - navigating to dashboard...")
|
||||
driver.get("https://app.dentalhub.com/app/dashboard")
|
||||
await asyncio.sleep(3)
|
||||
WebDriverWait(driver, 10).until(
|
||||
EC.presence_of_element_located((By.XPATH,
|
||||
'//input[@placeholder="Search by member ID"] | //input[contains(@placeholder,"Search")] | //*[contains(@class,"dashboard")]'
|
||||
))
|
||||
)
|
||||
print("[UnitedDH Claim OTP] Dashboard element found - login successful!")
|
||||
login_success = True
|
||||
except TimeoutException:
|
||||
s["status"] = "error"
|
||||
s["message"] = "OTP timeout - login not completed"
|
||||
await cleanup_session(sid)
|
||||
return {"status": "error", "message": "OTP not completed in time"}
|
||||
except Exception as final_err:
|
||||
s["status"] = "error"
|
||||
s["message"] = f"OTP verification failed: {final_err}"
|
||||
await cleanup_session(sid)
|
||||
return {"status": "error", "message": s["message"]}
|
||||
|
||||
if login_success:
|
||||
s["status"] = "running"
|
||||
s["message"] = "Login successful after OTP"
|
||||
print("[UnitedDH Claim OTP] Proceeding to claim steps...")
|
||||
|
||||
# ── Login succeeded without OTP ───────────────────────────────────────
|
||||
elif isinstance(login_result, str) and login_result == "SUCCESS":
|
||||
print("[UnitedDH Claim] Login succeeded without OTP")
|
||||
s["status"] = "running"
|
||||
s["message"] = "Login succeeded"
|
||||
|
||||
# ── Login error ───────────────────────────────────────────────────────
|
||||
elif isinstance(login_result, str) and login_result.startswith("ERROR"):
|
||||
s["status"] = "error"
|
||||
s["message"] = login_result
|
||||
await cleanup_session(sid)
|
||||
return {"status": "error", "message": login_result}
|
||||
|
||||
# --- Claim steps ---
|
||||
for step_name, step_fn in [
|
||||
("step1_search_patient", bot.step1_search_patient),
|
||||
("step2_open_member_page", bot.step2_open_member_page),
|
||||
("step3_click_create_claim", bot.step3_click_create_claim),
|
||||
("step4_fill_claim_form", bot.step4_fill_claim_form),
|
||||
("step5_attach_files", bot.step5_attach_files),
|
||||
("step6_click_next", bot.step6_click_next),
|
||||
("step7_submit_claim", bot.step7_submit_claim),
|
||||
]:
|
||||
result = step_fn()
|
||||
print(f"[UnitedDH Claim] {step_name} result: {result}")
|
||||
if isinstance(result, str) and result.startswith("ERROR"):
|
||||
s["status"] = "error"
|
||||
s["message"] = result
|
||||
_minimize_browser(bot)
|
||||
asyncio.create_task(_remove_session_later(sid, 30))
|
||||
return {"status": "error", "message": result}
|
||||
|
||||
# --- Step 8: PDF + claim number ---
|
||||
step8_result = bot.step8_save_confirmation_pdf()
|
||||
print(f"[UnitedDH Claim] step8 result: {step8_result}")
|
||||
if isinstance(step8_result, str) and step8_result.startswith("ERROR"):
|
||||
print(f"[UnitedDH Claim] step8 warning (non-fatal): {step8_result}")
|
||||
step8_result = {}
|
||||
|
||||
pdf_path = step8_result.get("pdf_path") if isinstance(step8_result, dict) else None
|
||||
pdf_url = None
|
||||
if pdf_path:
|
||||
import os as _os
|
||||
filename = _os.path.basename(pdf_path)
|
||||
port = _os.getenv("PORT", "5002")
|
||||
url_host = _os.getenv("HOST", "localhost")
|
||||
pdf_url = f"http://{url_host}:{port}/downloads/{filename}"
|
||||
print(f"[UnitedDH Claim] pdf_url: {pdf_url}")
|
||||
|
||||
claim_number = step8_result.get("claimNumber") if isinstance(step8_result, dict) else None
|
||||
|
||||
result = {
|
||||
"status": "success",
|
||||
"message": "United/DentalHub claim submitted successfully",
|
||||
"claimNumber": claim_number,
|
||||
"pdf_url": pdf_url,
|
||||
}
|
||||
s["status"] = "completed"
|
||||
s["result"] = result
|
||||
s["message"] = "completed"
|
||||
|
||||
# Close browser window (session preserved in profile via UnitedSCO browser manager)
|
||||
try:
|
||||
from unitedsco_browser_manager import get_browser_manager as _gbm
|
||||
_gbm().quit_driver()
|
||||
print("[UnitedDH Claim] Browser closed - session preserved in profile")
|
||||
except Exception as close_err:
|
||||
print(f"[UnitedDH Claim] Could not close browser (non-fatal): {close_err}")
|
||||
|
||||
asyncio.create_task(_remove_session_later(sid, 60))
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
if s:
|
||||
s["status"] = "error"
|
||||
s["message"] = f"worker exception: {e}"
|
||||
await cleanup_session(sid)
|
||||
return {"status": "error", "message": f"worker exception: {e}"}
|
||||
|
||||
|
||||
def submit_otp(sid: str, otp: str) -> Dict[str, Any]:
|
||||
s = sessions.get(sid)
|
||||
if not s:
|
||||
return {"status": "error", "message": "session not found"}
|
||||
if s.get("status") != "waiting_for_otp":
|
||||
return {"status": "error", "message": f"session not waiting for otp (state={s.get('status')})"}
|
||||
s["otp_value"] = otp
|
||||
s["last_activity"] = time.time()
|
||||
try:
|
||||
s["otp_event"].set()
|
||||
except Exception:
|
||||
pass
|
||||
return {"status": "ok", "message": "otp accepted"}
|
||||
|
||||
|
||||
def get_session_status(sid: str) -> Dict[str, Any]:
|
||||
s = sessions.get(sid)
|
||||
if not s:
|
||||
return {"status": "not_found"}
|
||||
return {
|
||||
"session_id": sid,
|
||||
"status": s.get("status"),
|
||||
"message": s.get("message"),
|
||||
"created_at": s.get("created_at"),
|
||||
"last_activity": s.get("last_activity"),
|
||||
"result": s.get("result") if s.get("status") in ("completed", "error") else None,
|
||||
}
|
||||
1017
apps/SeleniumService/selenium_UnitedDH_claimSubmitWorker.py
Normal file
1017
apps/SeleniumService/selenium_UnitedDH_claimSubmitWorker.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -43,100 +43,33 @@ class UnitedSCOBrowserManager:
|
||||
|
||||
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.
|
||||
On startup, only clear the Cookies file so the login session is reset
|
||||
but device trust tokens (Local Storage, IndexedDB) are preserved.
|
||||
Preserving those lets Azure B2C recognise the device and skip OTP.
|
||||
"""
|
||||
print("[UnitedSCO BrowserManager] Clearing session on startup...")
|
||||
|
||||
print("[UnitedSCO BrowserManager] Clearing cookies on startup (preserving device trust)...")
|
||||
|
||||
try:
|
||||
# Clear the credentials tracking file
|
||||
# Clear credentials tracking so the next login re-saves the hash
|
||||
if os.path.exists(self._credentials_file):
|
||||
os.remove(self._credentials_file)
|
||||
print("[UnitedSCO BrowserManager] Cleared credentials tracking file")
|
||||
|
||||
# Clear session-related files from Chrome profile
|
||||
# These are the files that store login session cookies
|
||||
session_files = [
|
||||
"Cookies",
|
||||
"Cookies-journal",
|
||||
"Login Data",
|
||||
"Login Data-journal",
|
||||
"Web Data",
|
||||
"Web Data-journal",
|
||||
]
|
||||
|
||||
for filename in session_files:
|
||||
filepath = os.path.join(self.profile_dir, "Default", filename)
|
||||
if os.path.exists(filepath):
|
||||
try:
|
||||
os.remove(filepath)
|
||||
print(f"[UnitedSCO BrowserManager] Removed {filename}")
|
||||
except Exception as e:
|
||||
print(f"[UnitedSCO BrowserManager] Could not remove {filename}: {e}")
|
||||
|
||||
# Also try root level (some Chrome versions)
|
||||
for filename in session_files:
|
||||
filepath = os.path.join(self.profile_dir, filename)
|
||||
if os.path.exists(filepath):
|
||||
try:
|
||||
os.remove(filepath)
|
||||
print(f"[UnitedSCO BrowserManager] Removed root {filename}")
|
||||
except Exception as e:
|
||||
print(f"[UnitedSCO BrowserManager] Could not remove root {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("[UnitedSCO BrowserManager] Cleared Session Storage")
|
||||
except Exception as e:
|
||||
print(f"[UnitedSCO 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("[UnitedSCO BrowserManager] Cleared Local Storage")
|
||||
except Exception as e:
|
||||
print(f"[UnitedSCO 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("[UnitedSCO BrowserManager] Cleared IndexedDB")
|
||||
except Exception as e:
|
||||
print(f"[UnitedSCO BrowserManager] Could not clear IndexedDB: {e}")
|
||||
|
||||
# Clear browser cache (prevents corrupted cached responses)
|
||||
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"[UnitedSCO BrowserManager] Cleared {os.path.basename(cache_dir)}")
|
||||
except Exception as e:
|
||||
print(f"[UnitedSCO 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
|
||||
|
||||
print("[UnitedSCO BrowserManager] Session cleared - will require fresh login")
|
||||
|
||||
|
||||
# Only remove cookie files — leave everything else intact
|
||||
cookie_files = ["Cookies", "Cookies-journal"]
|
||||
for filename in cookie_files:
|
||||
for base in [os.path.join(self.profile_dir, "Default"), self.profile_dir]:
|
||||
filepath = os.path.join(base, filename)
|
||||
if os.path.exists(filepath):
|
||||
try:
|
||||
os.remove(filepath)
|
||||
print(f"[UnitedSCO BrowserManager] Removed {filepath}")
|
||||
except Exception as e:
|
||||
print(f"[UnitedSCO BrowserManager] Could not remove {filepath}: {e}")
|
||||
|
||||
self._needs_session_clear = False
|
||||
print("[UnitedSCO BrowserManager] Cookies cleared — device trust tokens preserved")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[UnitedSCO BrowserManager] Error clearing session: {e}")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user