import { Router, Request, Response } from "express"; import { storage } from "../storage"; import { forwardToSeleniumDdmaEligibilityAgent, forwardOtpToSeleniumDdmaAgent, getSeleniumDdmaSessionStatus, } from "../services/seleniumDdmaInsuranceEligibilityClient"; import fs from "fs/promises"; import fsSync from "fs"; import path from "path"; import PDFDocument from "pdfkit"; import { emptyFolderContainingFile } from "../utils/emptyTempFolder"; import { InsertPatient, insertPatientSchema, } from "../../../../packages/db/types/patient-types"; import { io } from "../socket"; const router = Router(); /** Job context stored in memory by sessionId */ interface DdmaJobContext { userId: number; insuranceEligibilityData: any; // parsed, enriched (includes username/password) socketId?: string; } const ddmaJobs: Record = {}; /** Utility: naive name splitter */ function splitName(fullName?: string | null) { if (!fullName) return { firstName: "", lastName: "" }; const parts = fullName.trim().split(/\s+/).filter(Boolean); const firstName = parts.shift() ?? ""; const lastName = parts.join(" ") ?? ""; return { firstName, lastName }; } async function imageToPdfBuffer(imagePath: string): Promise { return new Promise((resolve, reject) => { try { const doc = new PDFDocument({ autoFirstPage: false }); const chunks: Uint8Array[] = []; doc.on("data", (chunk: any) => chunks.push(chunk)); doc.on("end", () => resolve(Buffer.concat(chunks))); doc.on("error", (err: any) => reject(err)); const A4_WIDTH = 595.28; // points const A4_HEIGHT = 841.89; // points doc.addPage({ size: [A4_WIDTH, A4_HEIGHT] }); doc.image(imagePath, 0, 0, { fit: [A4_WIDTH, A4_HEIGHT], align: "center", valign: "center", }); doc.end(); } catch (err) { reject(err); } }); } /** * Ensure patient exists for given insuranceId. */ async function createOrUpdatePatientByInsuranceId(options: { insuranceId: string; firstName?: string | null; lastName?: string | null; dob?: string | Date | null; userId: number; }) { const { insuranceId, firstName, lastName, dob, userId } = options; if (!insuranceId) throw new Error("Missing insuranceId"); const incomingFirst = (firstName || "").trim(); const incomingLast = (lastName || "").trim(); let patient = await storage.getPatientByInsuranceId(insuranceId); if (patient && patient.id) { const updates: any = {}; if ( incomingFirst && String(patient.firstName ?? "").trim() !== incomingFirst ) { updates.firstName = incomingFirst; } if ( incomingLast && String(patient.lastName ?? "").trim() !== incomingLast ) { updates.lastName = incomingLast; } if (Object.keys(updates).length > 0) { await storage.updatePatient(patient.id, updates); } return; } else { const createPayload: any = { firstName: incomingFirst, lastName: incomingLast, dateOfBirth: dob, gender: "", phone: "", userId, insuranceId, }; let patientData: InsertPatient; try { patientData = insertPatientSchema.parse(createPayload); } catch (err) { const safePayload = { ...createPayload }; delete (safePayload as any).dateOfBirth; patientData = insertPatientSchema.parse(safePayload); } await storage.createPatient(patientData); } } /** * When Selenium finishes for a given sessionId, run your patient + PDF pipeline, * and return the final API response shape. */ async function handleDdmaCompletedJob( sessionId: string, job: DdmaJobContext, seleniumResult: any ) { let createdPdfFileId: number | null = null; const outputResult: any = {}; // We'll wrap the processing in try/catch/finally so cleanup always runs try { // 1) ensuring memberid. const insuranceEligibilityData = job.insuranceEligibilityData; const insuranceId = String(insuranceEligibilityData.memberId ?? "").trim(); if (!insuranceId) { throw new Error("Missing memberId for ddma job"); } // 2) Create or update patient (with name from selenium result if available) const patientNameFromResult = typeof seleniumResult?.patientName === "string" ? seleniumResult.patientName.trim() : null; const { firstName, lastName } = splitName(patientNameFromResult); await createOrUpdatePatientByInsuranceId({ insuranceId, firstName, lastName, dob: insuranceEligibilityData.dateOfBirth, userId: job.userId, }); // 3) Update patient status + PDF upload const patient = await storage.getPatientByInsuranceId( insuranceEligibilityData.memberId ); if (!patient?.id) { outputResult.patientUpdateStatus = "Patient not found; no update performed"; return { patientUpdateStatus: outputResult.patientUpdateStatus, pdfUploadStatus: "none", pdfFileId: null, }; } // update patient status. const newStatus = seleniumResult.eligibility === "active" ? "ACTIVE" : "INACTIVE"; await storage.updatePatient(patient.id, { status: newStatus }); outputResult.patientUpdateStatus = `Patient status updated to ${newStatus}`; // convert screenshot -> pdf if available let pdfBuffer: Buffer | null = null; let generatedPdfPath: string | null = null; if ( seleniumResult && seleniumResult.ss_path && typeof seleniumResult.ss_path === "string" && (seleniumResult.ss_path.endsWith(".png") || seleniumResult.ss_path.endsWith(".jpg") || seleniumResult.ss_path.endsWith(".jpeg")) ) { try { if (!fsSync.existsSync(seleniumResult.ss_path)) { throw new Error( `Screenshot file not found: ${seleniumResult.ss_path}` ); } pdfBuffer = await imageToPdfBuffer(seleniumResult.ss_path); const pdfFileName = `ddma_eligibility_${insuranceEligibilityData.memberId}_${Date.now()}.pdf`; generatedPdfPath = path.join( path.dirname(seleniumResult.ss_path), pdfFileName ); await fs.writeFile(generatedPdfPath, pdfBuffer); // ensure cleanup uses this seleniumResult.pdf_path = generatedPdfPath; } catch (err: any) { console.error("Failed to convert screenshot to PDF:", err); outputResult.pdfUploadStatus = `Failed to convert screenshot to PDF: ${String(err)}`; } } else { outputResult.pdfUploadStatus = "No valid screenshot (ss_path) provided by Selenium; nothing to upload."; } if (pdfBuffer && generatedPdfPath) { const groupTitle = "Eligibility Status"; const groupTitleKey = "ELIGIBILITY_STATUS"; let group = await storage.findPdfGroupByPatientTitleKey( patient.id, groupTitleKey ); if (!group) { group = await storage.createPdfGroup( patient.id, groupTitle, groupTitleKey ); } if (!group?.id) { throw new Error("PDF group creation failed: missing group ID"); } const created = await storage.createPdfFile( group.id, path.basename(generatedPdfPath), pdfBuffer ); if (created && typeof created === "object" && "id" in created) { createdPdfFileId = Number(created.id); } outputResult.pdfUploadStatus = `PDF saved to group: ${group.title}`; } else { outputResult.pdfUploadStatus = "No valid PDF path provided by Selenium, Couldn't upload pdf to server."; } return { patientUpdateStatus: outputResult.patientUpdateStatus, pdfUploadStatus: outputResult.pdfUploadStatus, pdfFileId: createdPdfFileId, }; } catch (err: any) { return { patientUpdateStatus: outputResult.patientUpdateStatus, pdfUploadStatus: outputResult.pdfUploadStatus ?? `Failed to process DDMA job: ${err?.message ?? String(err)}`, pdfFileId: createdPdfFileId, error: err?.message ?? String(err), }; } finally { // ALWAYS attempt cleanup of temp files try { if (seleniumResult && seleniumResult.pdf_path) { await emptyFolderContainingFile(seleniumResult.pdf_path); } else if (seleniumResult && seleniumResult.ss_path) { await emptyFolderContainingFile(seleniumResult.ss_path); } else { console.log( `[ddma-eligibility] no pdf_path or ss_path available to cleanup` ); } } catch (cleanupErr) { console.error( `[ddma-eligibility cleanup failed for ${seleniumResult?.pdf_path ?? seleniumResult?.ss_path}]`, cleanupErr ); } } } // --- top of file, alongside ddmaJobs --- let currentFinalSessionId: string | null = null; let currentFinalResult: any = null; function now() { return new Date().toISOString(); } function log(tag: string, msg: string, ctx?: any) { console.log(`${now()} [${tag}] ${msg}`, ctx ?? ""); } function emitSafe(socketId: string | undefined, event: string, payload: any) { if (!socketId) { log("socket", "no socketId for emit", { event }); return; } try { const socket = io?.sockets.sockets.get(socketId); if (!socket) { log("socket", "socket not found (maybe disconnected)", { socketId, event, }); return; } socket.emit(event, payload); log("socket", "emitted", { socketId, event }); } catch (err: any) { log("socket", "emit failed", { socketId, event, err: err?.message }); } } /** * Polls Python agent for session status and emits socket events: * - 'selenium:otp_required' when waiting_for_otp * - 'selenium:session_update' when completed/error * - rabsolute timeout + transient error handling. * - pollTimeoutMs default = 2 minutes (adjust where invoked) */ async function pollAgentSessionAndProcess( sessionId: string, socketId?: string, pollTimeoutMs = 2 * 60 * 1000 ) { const maxAttempts = 300; const baseDelayMs = 1000; const maxTransientErrors = 12; // NEW: give up if same non-terminal status repeats this many times const noProgressLimit = 100; const job = ddmaJobs[sessionId]; let transientErrorCount = 0; let consecutiveNoProgress = 0; let lastStatus: string | null = null; const deadline = Date.now() + pollTimeoutMs; for (let attempt = 0; attempt < maxAttempts; attempt++) { // absolute deadline check if (Date.now() > deadline) { emitSafe(socketId, "selenium:session_update", { session_id: sessionId, status: "error", message: `Polling timeout reached (${Math.round(pollTimeoutMs / 1000)}s).`, }); delete ddmaJobs[sessionId]; return; } log( "poller", `attempt=${attempt} session=${sessionId} transientErrCount=${transientErrorCount}` ); try { const st = await getSeleniumDdmaSessionStatus(sessionId); const status = st?.status ?? null; log("poller", "got status", { sessionId, status, message: st?.message, resultKeys: st?.result ? Object.keys(st.result) : null, }); // reset transient errors on success transientErrorCount = 0; // if status unchanged and non-terminal, increment no-progress counter const isTerminalLike = status === "completed" || status === "error" || status === "not_found"; if (status === lastStatus && !isTerminalLike) { consecutiveNoProgress++; } else { consecutiveNoProgress = 0; } lastStatus = status; // if no progress for too many consecutive polls -> abort if (consecutiveNoProgress >= noProgressLimit) { emitSafe(socketId, "selenium:session_update", { session_id: sessionId, status: "error", message: `No progress from selenium agent (status="${status}") after ${consecutiveNoProgress} polls; aborting.`, }); emitSafe(socketId, "selenium:session_error", { session_id: sessionId, status: "error", message: "No progress from selenium agent", }); delete ddmaJobs[sessionId]; return; } // always emit debug to client if socket exists emitSafe(socketId, "selenium:debug", { session_id: sessionId, attempt, status, serverTime: new Date().toISOString(), }); // If agent is waiting for OTP, inform client but keep polling (do not return) if (status === "waiting_for_otp") { emitSafe(socketId, "selenium:otp_required", { session_id: sessionId, message: "OTP required. Please enter the OTP.", }); // do not return — keep polling (allows same poller to pick up completion) await new Promise((r) => setTimeout(r, baseDelayMs)); continue; } // Completed path if (status === "completed") { log("poller", "agent completed; processing result", { sessionId, resultKeys: st.result ? Object.keys(st.result) : null, }); // Persist raw result so frontend can fetch if socket disconnects currentFinalSessionId = sessionId; currentFinalResult = { rawSelenium: st.result, processedAt: null, final: null, }; let finalResult: any = null; if (job && st.result) { try { finalResult = await handleDdmaCompletedJob( sessionId, job, st.result ); currentFinalResult.final = finalResult; currentFinalResult.processedAt = Date.now(); } catch (err: any) { currentFinalResult.final = { error: "processing_failed", detail: err?.message ?? String(err), }; currentFinalResult.processedAt = Date.now(); log("poller", "handleDdmaCompletedJob failed", { sessionId, err: err?.message ?? err, }); } } else { currentFinalResult[sessionId].final = { error: "no_job_or_no_result", }; currentFinalResult[sessionId].processedAt = Date.now(); } // Emit final update (if socket present) emitSafe(socketId, "selenium:session_update", { session_id: sessionId, status: "completed", rawSelenium: st.result, final: currentFinalResult.final, }); // cleanup job context delete ddmaJobs[sessionId]; return; } // Terminal error / not_found if (status === "error" || status === "not_found") { const emitPayload = { session_id: sessionId, status, message: st?.message || "Selenium session error", }; emitSafe(socketId, "selenium:session_update", emitPayload); emitSafe(socketId, "selenium:session_error", emitPayload); delete ddmaJobs[sessionId]; return; } } catch (err: any) { const axiosStatus = err?.response?.status ?? (err?.status ? Number(err.status) : undefined); const errCode = err?.code ?? err?.errno; const errMsg = err?.message ?? String(err); const errData = err?.response?.data ?? null; // If agent explicitly returned 404 -> terminal (session gone) if ( axiosStatus === 404 || (typeof errMsg === "string" && errMsg.includes("not_found")) ) { console.warn( `${new Date().toISOString()} [poller] terminal 404/not_found for ${sessionId}: data=${JSON.stringify(errData)}` ); // Emit not_found to client const emitPayload = { session_id: sessionId, status: "not_found", message: errData?.detail || "Selenium session not found (agent cleaned up).", }; emitSafe(socketId, "selenium:session_update", emitPayload); emitSafe(socketId, "selenium:session_error", emitPayload); // Remove job context and stop polling delete ddmaJobs[sessionId]; return; } // Detailed transient error logging transientErrorCount++; if (transientErrorCount > maxTransientErrors) { const emitPayload = { session_id: sessionId, status: "error", message: "Repeated network errors while polling selenium agent; giving up.", }; emitSafe(socketId, "selenium:session_update", emitPayload); emitSafe(socketId, "selenium:session_error", emitPayload); delete ddmaJobs[sessionId]; return; } const backoffMs = Math.min( 30_000, baseDelayMs * Math.pow(2, transientErrorCount - 1) ); console.warn( `${new Date().toISOString()} [poller] transient error (#${transientErrorCount}) for ${sessionId}: code=${errCode} status=${axiosStatus} msg=${errMsg} data=${JSON.stringify(errData)}` ); console.warn( `${new Date().toISOString()} [poller] backing off ${backoffMs}ms before next attempt` ); await new Promise((r) => setTimeout(r, backoffMs)); continue; } // normal poll interval await new Promise((r) => setTimeout(r, baseDelayMs)); } // overall timeout fallback emitSafe(socketId, "selenium:session_update", { session_id: sessionId, status: "error", message: "Polling timeout while waiting for selenium session", }); delete ddmaJobs[sessionId]; } /** * POST /ddma-eligibility * Starts DDMA eligibility Selenium job. * Expects: * - req.body.data: stringified JSON like your existing /eligibility-check * - req.body.socketId: socket.io client id */ router.post( "/ddma-eligibility", async (req: Request, res: Response): Promise => { if (!req.body.data) { return res .status(400) .json({ error: "Missing Insurance Eligibility data for selenium" }); } if (!req.user || !req.user.id) { return res.status(401).json({ error: "Unauthorized: user info missing" }); } try { const rawData = typeof req.body.data === "string" ? JSON.parse(req.body.data) : req.body.data; const credentials = await storage.getInsuranceCredentialByUserAndSiteKey( req.user.id, rawData.insuranceSiteKey ); if (!credentials) { return res.status(404).json({ error: "No insurance credentials found for this provider, Kindly Update this at Settings Page.", }); } const enrichedData = { ...rawData, massddmaUsername: credentials.username, massddmaPassword: credentials.password, }; const socketId: string | undefined = req.body.socketId; const agentResp = await forwardToSeleniumDdmaEligibilityAgent(enrichedData); if ( !agentResp || agentResp.status !== "started" || !agentResp.session_id ) { return res.status(502).json({ error: "Selenium agent did not return a started session", detail: agentResp, }); } const sessionId = agentResp.session_id as string; // Save job context ddmaJobs[sessionId] = { userId: req.user.id, insuranceEligibilityData: enrichedData, socketId, }; // start polling in background to notify client via socket and process job pollAgentSessionAndProcess(sessionId, socketId).catch((e) => console.warn("pollAgentSessionAndProcess failed", e) ); // reply immediately with started status return res.json({ status: "started", session_id: sessionId }); } catch (err: any) { console.error(err); return res.status(500).json({ error: err.message || "Failed to start ddma selenium agent", }); } } ); /** * POST /selenium/submit-otp * Body: { session_id, otp, socketId? } * Forwards OTP to Python agent and optionally notifies client socket. */ router.post( "/selenium/submit-otp", async (req: Request, res: Response): Promise => { const { session_id: sessionId, otp, socketId } = req.body; if (!sessionId || !otp) { return res.status(400).json({ error: "session_id and otp are required" }); } try { const r = await forwardOtpToSeleniumDdmaAgent(sessionId, otp); // emit OTP accepted (if socket present) emitSafe(socketId, "selenium:otp_submitted", { session_id: sessionId, result: r, }); return res.json(r); } catch (err: any) { console.error( "Failed to forward OTP:", err?.response?.data || err?.message || err ); return res.status(500).json({ error: "Failed to forward otp to selenium agent", detail: err?.message || err, }); } } ); // GET /selenium/session/:sid/final router.get( "/selenium/session/:sid/final", async (req: Request, res: Response) => { const sid = req.params.sid; if (!sid) return res.status(400).json({ error: "session id required" }); // Only the current in-memory result is available if (currentFinalSessionId !== sid || !currentFinalResult) { return res.status(404).json({ error: "final result not found" }); } return res.json(currentFinalResult); } ); export default router;