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:
Gitead
2026-05-25 00:29:04 -04:00
parent cd1381e9c6
commit 1e581c193c
14 changed files with 2100 additions and 95 deletions

View File

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

View File

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

View 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 };
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1979,6 +1979,10 @@ export default function AppointmentsPage() {
onHandleForMHSeleniumClaim={() => {}}
onHandleForMHSeleniumClaimPreAuth={() => {}}
onHandleForCCASeleniumClaim={() => {}}
onHandleForCCASeleniumPreAuth={() => {}}
onHandleForDDMASeleniumClaim={() => {}}
onHandleForUnitedDHSeleniumClaim={() => {}}
onHandleForTuftsSCOSeleniumClaim={() => {}}
/>
)}
</div>

View File

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

View File

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

View File

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

View 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,
}

File diff suppressed because it is too large Load Diff

View File

@@ -43,99 +43,32 @@ 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",
]
# 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}")
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")
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}")