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 { runCCAClaimProcessor } from "./processors/ccaClaimProcessor";
|
||||||
import { runCCAPreAuthProcessor } from "./processors/ccaPreAuthProcessor";
|
import { runCCAPreAuthProcessor } from "./processors/ccaPreAuthProcessor";
|
||||||
import { runDDMAClaimProcessor } from "./processors/ddmaClaimProcessor";
|
import { runDDMAClaimProcessor } from "./processors/ddmaClaimProcessor";
|
||||||
|
import { runUnitedDHClaimProcessor } from "./processors/unitedDHClaimProcessor";
|
||||||
|
import { runTuftsSCOClaimProcessor } from "./processors/tuftsSCOClaimProcessor";
|
||||||
import { runEligibilityHistoryProcessor } from "./processors/eligibilityHistoryProcessor";
|
import { runEligibilityHistoryProcessor } from "./processors/eligibilityHistoryProcessor";
|
||||||
import { runCmspEligibilityHistoryRemainingProcessor } from "./processors/cmspEligibilityHistoryRemainingProcessor";
|
import { runCmspEligibilityHistoryRemainingProcessor } from "./processors/cmspEligibilityHistoryRemainingProcessor";
|
||||||
import type { SeleniumJobData, OcrJobData } from "./queues";
|
import type { SeleniumJobData, OcrJobData } from "./queues";
|
||||||
@@ -168,6 +170,28 @@ export function enqueueSeleniumJob(data: SeleniumJobData): string {
|
|||||||
job.id
|
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") {
|
if (jobType === "cca-eligibility-check") {
|
||||||
return runCCAEligibilityProcessor(
|
return runCCAEligibilityProcessor(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -127,6 +127,13 @@ export async function runDDMAClaimProcessor(
|
|||||||
if (claimNumber) updates.claimNumber = claimNumber;
|
if (claimNumber) updates.claimNumber = claimNumber;
|
||||||
await storage.updateClaim(claimId, updates);
|
await storage.updateClaim(claimId, updates);
|
||||||
log("ddma-claim-processor", "claim record updated", { claimId, claimNumber });
|
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) {
|
} catch (e) {
|
||||||
log("ddma-claim-processor", "failed to update claim record (non-fatal)", { error: 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 insuranceStatusCCAClaimRoutes from "./insuranceStatusCCAClaim";
|
||||||
import insuranceStatusCCAPreAuthRoutes from "./insuranceStatusCCAPreAuth";
|
import insuranceStatusCCAPreAuthRoutes from "./insuranceStatusCCAPreAuth";
|
||||||
import insuranceStatusDDMAClaimRoutes from "./insuranceStatusDDMAClaim";
|
import insuranceStatusDDMAClaimRoutes from "./insuranceStatusDDMAClaim";
|
||||||
|
import insuranceStatusUnitedDHClaimRoutes from "./insuranceStatusUnitedDHClaim";
|
||||||
|
import insuranceStatusTuftsSCOClaimRoutes from "./insuranceStatusTuftsSCOClaim";
|
||||||
import paymentsRoutes from "./payments";
|
import paymentsRoutes from "./payments";
|
||||||
import databaseManagementRoutes from "./database-management";
|
import databaseManagementRoutes from "./database-management";
|
||||||
import notificationsRoutes from "./notifications";
|
import notificationsRoutes from "./notifications";
|
||||||
@@ -60,6 +62,8 @@ router.use("/insurance-status-cca", insuranceStatusCCARoutes);
|
|||||||
router.use("/claims", insuranceStatusCCAClaimRoutes);
|
router.use("/claims", insuranceStatusCCAClaimRoutes);
|
||||||
router.use("/claims", insuranceStatusCCAPreAuthRoutes);
|
router.use("/claims", insuranceStatusCCAPreAuthRoutes);
|
||||||
router.use("/claims", insuranceStatusDDMAClaimRoutes);
|
router.use("/claims", insuranceStatusDDMAClaimRoutes);
|
||||||
|
router.use("/claims", insuranceStatusUnitedDHClaimRoutes);
|
||||||
|
router.use("/claims", insuranceStatusTuftsSCOClaimRoutes);
|
||||||
router.use("/payments", paymentsRoutes);
|
router.use("/payments", paymentsRoutes);
|
||||||
router.use("/database-management", databaseManagementRoutes);
|
router.use("/database-management", databaseManagementRoutes);
|
||||||
router.use("/notifications", notificationsRoutes);
|
router.use("/notifications", notificationsRoutes);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface IStorage {
|
|||||||
getPatientsByIds(ids: number[]): Promise<Patient[]>;
|
getPatientsByIds(ids: number[]): Promise<Patient[]>;
|
||||||
createPatient(patient: InsertPatient): Promise<Patient>;
|
createPatient(patient: InsertPatient): Promise<Patient>;
|
||||||
updatePatient(id: number, patient: UpdatePatient): Promise<Patient>;
|
updatePatient(id: number, patient: UpdatePatient): Promise<Patient>;
|
||||||
|
touchPatient(id: number): Promise<void>;
|
||||||
deletePatient(id: number): Promise<void>;
|
deletePatient(id: number): Promise<void>;
|
||||||
searchPatients(args: {
|
searchPatients(args: {
|
||||||
filters: any;
|
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> {
|
async deletePatient(id: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await db.patient.delete({ where: { id } });
|
await db.patient.delete({ where: { id } });
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ interface ClaimFormProps {
|
|||||||
onHandleForCCASeleniumClaim: (data: ClaimFormData) => void;
|
onHandleForCCASeleniumClaim: (data: ClaimFormData) => void;
|
||||||
onHandleForCCASeleniumPreAuth: (data: ClaimPreAuthData) => void;
|
onHandleForCCASeleniumPreAuth: (data: ClaimPreAuthData) => void;
|
||||||
onHandleForDDMASeleniumClaim: (data: ClaimFormData) => void;
|
onHandleForDDMASeleniumClaim: (data: ClaimFormData) => void;
|
||||||
|
onHandleForUnitedDHSeleniumClaim: (data: ClaimFormData) => void;
|
||||||
|
onHandleForTuftsSCOSeleniumClaim: (data: ClaimFormData) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +107,8 @@ export function ClaimForm({
|
|||||||
onHandleForCCASeleniumClaim,
|
onHandleForCCASeleniumClaim,
|
||||||
onHandleForCCASeleniumPreAuth,
|
onHandleForCCASeleniumPreAuth,
|
||||||
onHandleForDDMASeleniumClaim,
|
onHandleForDDMASeleniumClaim,
|
||||||
|
onHandleForUnitedDHSeleniumClaim,
|
||||||
|
onHandleForTuftsSCOSeleniumClaim,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onClose,
|
onClose,
|
||||||
}: ClaimFormProps) {
|
}: ClaimFormProps) {
|
||||||
@@ -618,7 +622,7 @@ export function ClaimForm({
|
|||||||
if (p.includes("ddma") || p.includes("delta dental ma")) return "DDMA";
|
if (p.includes("ddma") || p.includes("delta dental ma")) return "DDMA";
|
||||||
if (p.includes("delta ins") || p === "deltains") return "DeltaIns";
|
if (p.includes("delta ins") || p === "deltains") return "DeltaIns";
|
||||||
if (p.includes("tufts") || p.includes("dentaquest") || p === "tuftssco") return "TuftsSCO";
|
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("cmsp")) return "CMSP";
|
||||||
if (p.includes("bcbs") || p.includes("blue cross")) return "BCBS";
|
if (p.includes("bcbs") || p.includes("blue cross")) return "BCBS";
|
||||||
if (p.includes("united aapr") || p === "unitedaapr") return "UnitedAAPR";
|
if (p.includes("united aapr") || p === "unitedaapr") return "UnitedAAPR";
|
||||||
@@ -1070,6 +1074,160 @@ export function ClaimForm({
|
|||||||
onClose();
|
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 handleCCAPreAuth = async () => {
|
||||||
const missingFields: string[] = [];
|
const missingFields: string[] = [];
|
||||||
if (!form.memberId?.trim()) missingFields.push("Member ID");
|
if (!form.memberId?.trim()) missingFields.push("Member ID");
|
||||||
@@ -2007,10 +2165,16 @@ export function ClaimForm({
|
|||||||
>
|
>
|
||||||
Delta MA Claim
|
Delta MA Claim
|
||||||
</Button>
|
</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
|
United/DentalHub Claim
|
||||||
</Button>
|
</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
|
Tufts Claim
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const SITE_KEY_OPTIONS = [
|
|||||||
{ value: "DDMA", label: "Delta Dental MA (DDMA)" },
|
{ value: "DDMA", label: "Delta Dental MA (DDMA)" },
|
||||||
{ value: "DELTAINS", label: "Delta Dental Ins (DELTAINS)" },
|
{ value: "DELTAINS", label: "Delta Dental Ins (DELTAINS)" },
|
||||||
{ value: "TUFTS_SCO", label: "Tufts SCO (TUFTS_SCO)" },
|
{ 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)" },
|
{ value: "CCA", label: "CCA (CCA)" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1979,6 +1979,10 @@ export default function AppointmentsPage() {
|
|||||||
onHandleForMHSeleniumClaim={() => {}}
|
onHandleForMHSeleniumClaim={() => {}}
|
||||||
onHandleForMHSeleniumClaimPreAuth={() => {}}
|
onHandleForMHSeleniumClaimPreAuth={() => {}}
|
||||||
onHandleForCCASeleniumClaim={() => {}}
|
onHandleForCCASeleniumClaim={() => {}}
|
||||||
|
onHandleForCCASeleniumPreAuth={() => {}}
|
||||||
|
onHandleForDDMASeleniumClaim={() => {}}
|
||||||
|
onHandleForUnitedDHSeleniumClaim={() => {}}
|
||||||
|
onHandleForTuftsSCOSeleniumClaim={() => {}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -59,6 +59,12 @@ export default function ClaimsPage() {
|
|||||||
const [ddmaClaimOtpOpen, setDdmaClaimOtpOpen] = useState(false);
|
const [ddmaClaimOtpOpen, setDdmaClaimOtpOpen] = useState(false);
|
||||||
const [ddmaClaimOtpSubmitting, setDdmaClaimOtpSubmitting] = useState(false);
|
const [ddmaClaimOtpSubmitting, setDdmaClaimOtpSubmitting] = useState(false);
|
||||||
const ddmaClaimSessionIdRef = useRef<string | null>(null);
|
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<{
|
const pendingClaimMeta = useRef<{
|
||||||
patientId: number | null;
|
patientId: number | null;
|
||||||
groupKey: "INSURANCE_CLAIM" | "INSURANCE_CLAIM_PREAUTH";
|
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
|
// CCA pre-auth selenium handler
|
||||||
const handleCCAPreAuthSubmitSelenium = async (data: any) => {
|
const handleCCAPreAuthSubmitSelenium = async (data: any) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@@ -773,6 +909,8 @@ export default function ClaimsPage() {
|
|||||||
onHandleForCCASeleniumClaim={handleCCAClaimSubmitSelenium}
|
onHandleForCCASeleniumClaim={handleCCAClaimSubmitSelenium}
|
||||||
onHandleForCCASeleniumPreAuth={handleCCAPreAuthSubmitSelenium}
|
onHandleForCCASeleniumPreAuth={handleCCAPreAuthSubmitSelenium}
|
||||||
onHandleForDDMASeleniumClaim={handleDDMAClaimSubmitSelenium}
|
onHandleForDDMASeleniumClaim={handleDDMAClaimSubmitSelenium}
|
||||||
|
onHandleForUnitedDHSeleniumClaim={handleUnitedDHClaimSubmitSelenium}
|
||||||
|
onHandleForTuftsSCOSeleniumClaim={handleTuftsSCOClaimSubmitSelenium}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -826,6 +964,84 @@ export default function ClaimsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import Decimal from "decimal.js";
|
|||||||
import rawCodeTable from "@/assets/data/procedureCodesMH.json";
|
import rawCodeTable from "@/assets/data/procedureCodesMH.json";
|
||||||
import rawCCACodeTable from "@/assets/data/procedureCodesCCA.json";
|
import rawCCACodeTable from "@/assets/data/procedureCodesCCA.json";
|
||||||
import rawDDMACodeTable from "@/assets/data/procedureCodesDDMA.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";
|
import { PROCEDURE_COMBOS } from "./procedureCombos";
|
||||||
|
|
||||||
/* ----------------------------- Types ----------------------------- */
|
/* ----------------------------- Types ----------------------------- */
|
||||||
@@ -17,6 +19,8 @@ export type CodeRow = {
|
|||||||
const CODE_TABLE = rawCodeTable as CodeRow[];
|
const CODE_TABLE = rawCodeTable as CodeRow[];
|
||||||
const CCA_CODE_TABLE = rawCCACodeTable as CodeRow[];
|
const CCA_CODE_TABLE = rawCCACodeTable as CodeRow[];
|
||||||
const DDMA_CODE_TABLE = rawDDMACodeTable 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 = {
|
export type ClaimFormLike = {
|
||||||
serviceDate: string; // form-level service date
|
serviceDate: string; // form-level service date
|
||||||
@@ -67,10 +71,30 @@ const DDMA_CODE_MAP: Map<string, CodeRow> = (() => {
|
|||||||
return m;
|
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. */
|
/** Return the correct fee-schedule map for the given insurance type. */
|
||||||
function getCodeMap(insuranceSiteKey?: string): Map<string, CodeRow> {
|
function getCodeMap(insuranceSiteKey?: string): Map<string, CodeRow> {
|
||||||
if (insuranceSiteKey === "CCA") return CCA_CODE_MAP;
|
if (insuranceSiteKey === "CCA") return CCA_CODE_MAP;
|
||||||
if (insuranceSiteKey === "DDMA") return DDMA_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
|
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 = {
|
export type PriceMismatch = {
|
||||||
procedureCode: string;
|
procedureCode: string;
|
||||||
@@ -362,7 +386,7 @@ export function findPriceMismatches(
|
|||||||
patientDOB: string,
|
patientDOB: string,
|
||||||
serviceDate: string,
|
serviceDate: string,
|
||||||
): PriceMismatch[] {
|
): PriceMismatch[] {
|
||||||
const supported = ["MH", "MASSHEALTH", "CCA", "DDMA"];
|
const supported = ["MH", "MASSHEALTH", "CCA", "DDMA", "UNITEDDH", "TUFTSSCO"];
|
||||||
if (!insuranceSiteKey || !supported.includes(insuranceSiteKey.toUpperCase())) return [];
|
if (!insuranceSiteKey || !supported.includes(insuranceSiteKey.toUpperCase())) return [];
|
||||||
|
|
||||||
const map = getCodeMap(insuranceSiteKey);
|
const map = getCodeMap(insuranceSiteKey);
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import helpers_cca_eligibility as hcca
|
|||||||
import helpers_cca_claim as hcca_claim
|
import helpers_cca_claim as hcca_claim
|
||||||
import helpers_cca_preauth as hcca_preauth
|
import helpers_cca_preauth as hcca_preauth
|
||||||
import helpers_ddma_claim as hddma_claim
|
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
|
# Import startup session-clear functions
|
||||||
from ddma_browser_manager import clear_ddma_session_on_startup
|
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}
|
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):
|
async def _cca_preauth_worker_wrapper(sid: str, data: dict, url: str):
|
||||||
"""Background worker for CCA pre-authorization submission."""
|
"""Background worker for CCA pre-authorization submission."""
|
||||||
global active_jobs, waiting_jobs
|
global active_jobs, waiting_jobs
|
||||||
@@ -692,6 +778,10 @@ async def submit_otp(request: Request):
|
|||||||
res = hdentaquest.submit_otp(sid, otp)
|
res = hdentaquest.submit_otp(sid, otp)
|
||||||
elif sid in hddma_claim.sessions:
|
elif sid in hddma_claim.sessions:
|
||||||
res = hddma_claim.submit_otp(sid, otp)
|
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:
|
else:
|
||||||
raise HTTPException(status_code=404, detail="session not found")
|
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)
|
s = hcca_preauth.get_session_status(sid)
|
||||||
elif sid in hddma_claim.sessions:
|
elif sid in hddma_claim.sessions:
|
||||||
s = hddma_claim.get_session_status(sid)
|
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:
|
else:
|
||||||
s = {"status": "not_found"}
|
s = {"status": "not_found"}
|
||||||
if s.get("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):
|
def clear_session_on_startup(self):
|
||||||
"""
|
"""
|
||||||
Clear session cookies from Chrome profile on startup.
|
On startup, only clear the Cookies file so the login session is reset
|
||||||
This forces a fresh login after PC restart.
|
but device trust tokens (Local Storage, IndexedDB) are preserved.
|
||||||
Preserves device trust tokens (LocalStorage, IndexedDB) to avoid OTPs.
|
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:
|
try:
|
||||||
# Clear the credentials tracking file
|
# Clear credentials tracking so the next login re-saves the hash
|
||||||
if os.path.exists(self._credentials_file):
|
if os.path.exists(self._credentials_file):
|
||||||
os.remove(self._credentials_file)
|
os.remove(self._credentials_file)
|
||||||
print("[UnitedSCO BrowserManager] Cleared credentials tracking file")
|
print("[UnitedSCO BrowserManager] Cleared credentials tracking file")
|
||||||
|
|
||||||
# Clear session-related files from Chrome profile
|
# Only remove cookie files — leave everything else intact
|
||||||
# These are the files that store login session cookies
|
cookie_files = ["Cookies", "Cookies-journal"]
|
||||||
session_files = [
|
for filename in cookie_files:
|
||||||
"Cookies",
|
for base in [os.path.join(self.profile_dir, "Default"), self.profile_dir]:
|
||||||
"Cookies-journal",
|
filepath = os.path.join(base, filename)
|
||||||
"Login Data",
|
if os.path.exists(filepath):
|
||||||
"Login Data-journal",
|
try:
|
||||||
"Web Data",
|
os.remove(filepath)
|
||||||
"Web Data-journal",
|
print(f"[UnitedSCO BrowserManager] Removed {filepath}")
|
||||||
]
|
except Exception as e:
|
||||||
|
print(f"[UnitedSCO BrowserManager] Could not remove {filepath}: {e}")
|
||||||
for filename in session_files:
|
|
||||||
filepath = os.path.join(self.profile_dir, "Default", filename)
|
self._needs_session_clear = False
|
||||||
if os.path.exists(filepath):
|
print("[UnitedSCO BrowserManager] Cookies cleared — device trust tokens preserved")
|
||||||
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")
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[UnitedSCO BrowserManager] Error clearing session: {e}")
|
print(f"[UnitedSCO BrowserManager] Error clearing session: {e}")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user