/** * Processor for "ddma-claim-submit" jobs. * Opens a claim on the Delta Dental MA provider portal via Selenium. * * Flow: * 1. POST /ddma-claim to Python agent → get session_id * 2. Emit selenium:ddma_claim_started to frontend * 3. Poll until completed/error * 4. Emit result */ import { forwardToSeleniumDDMAClaimAgent, getSeleniumDDMAClaimSessionStatus, } from "../../services/seleniumDDMAClaimClient"; import { io } from "../../socket"; import { storage } from "../../storage"; import axios from "axios"; import path from "path"; function log(tag: string, msg: string, ctx?: any) { console.log(`${new Date().toISOString()} [${tag}] ${msg}`, ctx ?? ""); } 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 = 10 * 60 * 1000 ): Promise { const maxAttempts = 1200; const pollIntervalMs = 500; const maxTransientErrors = 12; let transientErrors = 0; let lastOtpEmit = 0; const deadline = Date.now() + pollTimeoutMs; for (let attempt = 0; attempt < maxAttempts; attempt++) { if (Date.now() > deadline) { throw new Error(`DDMA claim polling timeout for session ${sessionId}`); } try { const st = await getSeleniumDDMAClaimSessionStatus(sessionId); const status: string = st?.status ?? "unknown"; log("ddma-claim-processor", `poll attempt=${attempt}`, { sessionId, status }); transientErrors = 0; if (status === "waiting_for_otp") { // Throttle: emit at most once every 5 s so the frontend isn't flooded if (Date.now() - lastOtpEmit > 5000) { emitToSocket(socketId, "selenium:otp_required", { session_id: sessionId, jobId, message: "OTP required. Please enter the OTP shown by the DDMA portal.", }); lastOtpEmit = Date.now(); } 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 || `DDMA 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 DDMA 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(`DDMA claim polling exhausted all attempts for session ${sessionId}`); } async function savePdfFromSelenium(pdf_url: string, patientId: number) { try { const filename = path.basename(new URL(pdf_url).pathname); const seleniumPort = process.env.SELENIUM_PORT || "5002"; const localUrl = `http://localhost:${seleniumPort}/downloads/${filename}`; const resp = await axios.get(localUrl, { responseType: "arraybuffer", timeout: 30000 }); let group = await storage.findPdfGroupByPatientTitleKey(patientId, "INSURANCE_CLAIM"); if (!group) group = await storage.createPdfGroup(patientId, "Claims", "INSURANCE_CLAIM"); await storage.createPdfFile(group.id!, filename, resp.data); log("ddma-claim-processor", "PDF saved", { patientId, filename }); } catch (err: any) { log("ddma-claim-processor", "failed to save PDF (non-fatal)", { error: err?.message ?? err }); } } export interface DDMAClaimProcessorInput { enrichedPayload: any; userId: number; claimId?: number; socketId?: string; } export async function runDDMAClaimProcessor( input: DDMAClaimProcessorInput, jobId: string ): Promise<{ status: string; pdf_url?: string; claimNumber?: string }> { const { enrichedPayload, userId, claimId, socketId } = input; log("ddma-claim-processor", "starting Python agent session", { claimId }); const agentResp = await forwardToSeleniumDDMAClaimAgent(enrichedPayload); if (!agentResp?.session_id) { throw new Error("Python agent did not return a session_id for DDMA claim"); } const sessionId = agentResp.session_id as string; log("ddma-claim-processor", "got session_id", { sessionId }); emitToSocket(socketId, "selenium:ddma_claim_started", { session_id: sessionId, jobId }); const seleniumResult = await pollUntilDone(sessionId, socketId, jobId); if (!seleniumResult || seleniumResult.status === "error") { throw new Error(seleniumResult?.message ?? "DDMA claim session returned an error"); } const claimNumber: string | undefined = seleniumResult.claimNumber ?? undefined; const pdf_url: string | undefined = seleniumResult.pdf_url ?? undefined; // Persist claim number and update status if (claimId) { try { const updates: Record = { status: "REVIEW" }; 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 }); } } // Auto-save PDF for batch-column calls (no socketId = no frontend listener) if (pdf_url && !socketId) { const claim = claimId ? await storage.getClaim(claimId).catch(() => null) : null; const patientId = claim?.patientId ?? enrichedPayload?.claim?.patientId ?? enrichedPayload?.patientId; if (patientId) await savePdfFromSelenium(pdf_url, Number(patientId)); } emitToSocket(socketId, "selenium:ddma_claim_completed", { jobId, claimId, claimNumber, pdf_url, message: claimNumber ? `DDMA claim submitted — Claim #: ${claimNumber}` : (seleniumResult?.message ?? "DDMA claim submitted successfully"), }); log("ddma-claim-processor", "done", { claimId, claimNumber }); return { status: "success", pdf_url, claimNumber }; }