diff --git a/apps/Backend/src/queue/processors/ccaPreAuthProcessor.ts b/apps/Backend/src/queue/processors/ccaPreAuthProcessor.ts new file mode 100644 index 00000000..93f7c06b --- /dev/null +++ b/apps/Backend/src/queue/processors/ccaPreAuthProcessor.ts @@ -0,0 +1,145 @@ +/** + * Processor for "cca-preauth-submit" jobs. + * Submits a dental pre-authorization to CCA via the ScionDental portal. + */ +import { storage } from "../../storage"; +import { + forwardToSeleniumCCAPreAuthAgent, + getSeleniumCCAPreAuthSessionStatus, +} from "../../services/seleniumCCAPreAuthClient"; +import { io } from "../../socket"; + +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, + pollTimeoutMs = 10 * 60 * 1000 +): Promise { + const maxAttempts = 1200; + const pollIntervalMs = 500; + const maxTransientErrors = 12; + let transientErrors = 0; + const deadline = Date.now() + pollTimeoutMs; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + if (Date.now() > deadline) { + throw new Error(`CCA preauth polling timeout for session ${sessionId}`); + } + try { + const st = await getSeleniumCCAPreAuthSessionStatus(sessionId); + const status: string = st?.status ?? "unknown"; + log("cca-preauth-processor", `poll attempt=${attempt}`, { sessionId, status }); + transientErrors = 0; + + if (status === "completed") return st.result; + if (status === "error" || status === "not_found") { + throw new Error(st?.message || `CCA preauth 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 CCA preauth session ${sessionId}`); + } + const backoff = Math.min(30_000, 500 * Math.pow(2, transientErrors - 1)); + await new Promise((r) => setTimeout(r, backoff)); + } + } + throw new Error(`CCA preauth polling exhausted all attempts for session ${sessionId}`); +} + +export interface CCAPreAuthProcessorInput { + enrichedPayload: any; + userId: number; + claimId?: number; + socketId?: string; +} + +export async function runCCAPreAuthProcessor( + input: CCAPreAuthProcessorInput, + jobId: string +): Promise<{ status: string; authNumber?: string | null; pdfFileId?: number | null }> { + const { enrichedPayload, userId, claimId, socketId } = input; + + log("cca-preauth-processor", "starting Python agent session", { claimId }); + const agentResp = await forwardToSeleniumCCAPreAuthAgent(enrichedPayload); + + if (!agentResp?.session_id) { + throw new Error("Python agent did not return a session_id for CCA preauth"); + } + + const sessionId = agentResp.session_id as string; + log("cca-preauth-processor", "got session_id", { sessionId }); + + emitToSocket(socketId, "selenium:cca_preauth_started", { session_id: sessionId, jobId }); + + const seleniumResult = await pollUntilDone(sessionId); + + if (!seleniumResult || seleniumResult.status === "error") { + throw new Error(seleniumResult?.message ?? "CCA preauth session returned an error"); + } + + const authNumber: string | null = seleniumResult?.authNumber ?? null; + const pdfBase64: string = seleniumResult?.pdfBase64 ?? ""; + const pdfFilename: string = + seleniumResult?.pdfFilename || `cca_preauth_${claimId ?? "unknown"}_${Date.now()}.pdf`; + + // Save authNumber into the claim record (preauth no column) + if (claimId && authNumber) { + try { + await storage.updateClaim(claimId, { claimNumber: authNumber, status: "APPROVED" } as any); + log("cca-preauth-processor", "authNumber saved to claim", { claimId, authNumber }); + } catch (e: any) { + log("cca-preauth-processor", "failed to save authNumber to claim", { error: e?.message }); + } + } + + // Save PDF to patient's PreAuth document group + let pdfFileId: number | null = null; + if (pdfBase64 && enrichedPayload?.claim?.patientId) { + try { + const patientId = Number(enrichedPayload.claim.patientId); + const pdfBuffer = Buffer.from(pdfBase64, "base64"); + let group = await storage.findPdfGroupByPatientTitleKey(patientId, "INSURANCE_CLAIM_PREAUTH"); + if (!group) { + group = await storage.createPdfGroup(patientId, "PreAuth", "INSURANCE_CLAIM_PREAUTH"); + } + const created = await storage.createPdfFile(group.id!, pdfFilename, pdfBuffer); + if (created && typeof created === "object" && "id" in created) { + pdfFileId = Number((created as any).id); + } + log("cca-preauth-processor", "PDF saved", { pdfFilename, pdfFileId, patientId }); + } catch (e: any) { + log("cca-preauth-processor", "failed to save PDF", { error: e?.message }); + } + } + + emitToSocket(socketId, "selenium:cca_preauth_completed", { + jobId, + claimId, + authNumber, + pdfFileId, + pdfFilename, + message: authNumber + ? `CCA pre-authorization submitted — auth number: ${authNumber}` + : "CCA pre-authorization submitted successfully", + }); + + log("cca-preauth-processor", "done", { claimId, authNumber }); + return { status: "success", authNumber, pdfFileId }; +} diff --git a/apps/Backend/src/routes/insuranceStatusCCAPreAuth.ts b/apps/Backend/src/routes/insuranceStatusCCAPreAuth.ts new file mode 100644 index 00000000..ad5bd2b9 --- /dev/null +++ b/apps/Backend/src/routes/insuranceStatusCCAPreAuth.ts @@ -0,0 +1,121 @@ +import { Router, Request, Response } from "express"; +import { storage } from "../storage"; +import { enqueueSeleniumJob } from "../queue/jobRunner"; +import multer from "multer"; + +const router = Router(); +const upload = multer({ storage: multer.memoryStorage() }); + +/** + * POST /cca-preauth + * + * Enqueues a CCA pre-authorization submission job. + * Accepts multipart/form-data with optional pdfs/images attachments. + * + * Body fields: + * data — JSON string with preauth payload (memberId, dateOfBirth, serviceDate, + * serviceLines, patientName, etc.) + * socketId — socket.io client id + * claimId — existing claim DB id (optional) + */ +router.post( + "/cca-preauth", + upload.fields([ + { name: "pdfs", maxCount: 10 }, + { name: "images", maxCount: 10 }, + ]), + async (req: Request, res: Response): Promise => { + if (!req.user?.id) { + return res.status(401).json({ error: "Unauthorized: user info missing" }); + } + + try { + const claimData = + typeof req.body.data === "string" + ? JSON.parse(req.body.data) + : req.body.data ?? {}; + + const pdfs = + (req.files as Record)?.pdfs ?? []; + const images = + (req.files as Record)?.images ?? []; + + const credentials = await storage.getInsuranceCredentialByUserAndSiteKey( + req.user.id, + "CCA" + ); + if (!credentials) { + return res.status(404).json({ + error: "No CCA credentials found. Please add them on the Settings page.", + }); + } + + const filesForQueue = [...pdfs, ...images].map((f) => ({ + originalname: f.originalname, + bufferBase64: f.buffer.toString("base64"), + mimetype: f.mimetype, + })); + + const enrichedPayload = { + claim: { + ...claimData, + cca_username: credentials.username, + cca_password: credentials.password, + }, + files: filesForQueue, + }; + + const socketId: string | undefined = req.body.socketId; + let claimId: number | undefined = claimData.claimId + ? Number(claimData.claimId) + : undefined; + + // Create a PREAUTH claim record so authNumber can be stored in claimNumber column + if (!claimId && claimData.patientId) { + try { + const serviceDate = claimData.serviceDate + ? new Date(claimData.serviceDate) + : new Date(); + const dob = claimData.dateOfBirth + ? new Date(claimData.dateOfBirth) + : new Date("2000-01-01"); + const record = await storage.createClaim({ + patientId: Number(claimData.patientId), + appointmentId: claimData.appointmentId ? Number(claimData.appointmentId) : null, + userId: req.user.id, + staffId: Number(claimData.staffId) || 1, + patientName: claimData.patientName || "", + memberId: claimData.memberId || "", + dateOfBirth: dob, + remarks: claimData.remarks || "", + missingTeethStatus: claimData.missingTeethStatus || "No_missing", + serviceDate, + insuranceProvider: "CCA", + status: "PREAUTH", + } as any); + claimId = record.id; + console.log(`[cca-preauth route] created claim record id=${claimId}`); + } catch (e: any) { + console.error("[cca-preauth route] failed to create claim record:", e?.message); + } + } + + const jobId = enqueueSeleniumJob({ + jobType: "cca-preauth-submit", + userId: req.user.id, + socketId, + enrichedPayload, + claimId, + }); + + return res.json({ status: "queued", jobId }); + } catch (err: any) { + console.error("[cca-preauth route] error:", err); + return res.status(500).json({ + error: err.message || "Failed to enqueue CCA preauth job", + }); + } + } +); + +export default router; diff --git a/apps/Frontend/src/components/claims/claim-form.tsx b/apps/Frontend/src/components/claims/claim-form.tsx index e8f85e7f..9a2969f3 100755 --- a/apps/Frontend/src/components/claims/claim-form.tsx +++ b/apps/Frontend/src/components/claims/claim-form.tsx @@ -76,6 +76,7 @@ interface ClaimFormProps { onHandleForMHSeleniumClaim: (data: ClaimFormData) => void; onHandleForMHSeleniumClaimPreAuth: (data: ClaimPreAuthData) => void; onHandleForCCASeleniumClaim: (data: ClaimFormData) => void; + onHandleForCCASeleniumPreAuth: (data: ClaimPreAuthData) => void; onClose: () => void; } @@ -89,6 +90,7 @@ export function ClaimForm({ onHandleForMHSeleniumClaim, onHandleForMHSeleniumClaimPreAuth, onHandleForCCASeleniumClaim, + onHandleForCCASeleniumPreAuth, onSubmit, onClose, }: ClaimFormProps) { @@ -974,6 +976,44 @@ export function ClaimForm({ onClose(); }; + const handleCCAPreAuth = 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 pre-authorization.", + variant: "destructive", + }); + return; + } + + onHandleForCCASeleniumPreAuth({ + ...form, + serviceLines: filteredServiceLines, + staffId: appointmentStaffId ?? Number(staff?.id), + patientId, + insuranceProvider: "CCA", + insuranceSiteKey: "CCA", + }); + + onClose(); + }; + const uploadAttachmentsToLocalFolder = async (files: File[]): Promise => { if (!files.length) return []; @@ -1824,7 +1864,7 @@ export function ClaimForm({ ) : ( -
+
+ + + + +
diff --git a/apps/Frontend/src/components/insurance-status/pdf-preview-modal.tsx b/apps/Frontend/src/components/insurance-status/pdf-preview-modal.tsx index 8162a3fc..5ea48968 100755 --- a/apps/Frontend/src/components/insurance-status/pdf-preview-modal.tsx +++ b/apps/Frontend/src/components/insurance-status/pdf-preview-modal.tsx @@ -54,6 +54,7 @@ export function PdfPreviewModal({ autoDownload = false, }: Props) { const [fileBlobUrl, setFileBlobUrl] = useState(null); + const [isImage, setIsImage] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [resolvedFilename, setResolvedFilename] = useState(null); @@ -98,8 +99,13 @@ export function PdfPreviewModal({ const arrayBuffer = await res.arrayBuffer(); if (aborted) return; - const blob = new Blob([arrayBuffer], { type: "application/pdf" }); + const lowerName = finalName.toLowerCase(); + const isPng = lowerName.endsWith(".png"); + const isJpg = lowerName.endsWith(".jpg") || lowerName.endsWith(".jpeg"); + const mimeType = isPng ? "image/png" : isJpg ? "image/jpeg" : "application/pdf"; + const blob = new Blob([arrayBuffer], { type: mimeType }); objectUrl = URL.createObjectURL(blob); + setIsImage(isPng || isJpg); setFileBlobUrl(objectUrl); if (autoDownload) { @@ -132,6 +138,7 @@ export function PdfPreviewModal({ controller.abort(); if (objectUrl) URL.revokeObjectURL(objectUrl); setFileBlobUrl(null); + setIsImage(false); setError(null); setLoading(false); setResolvedFilename(null); @@ -194,12 +201,20 @@ export function PdfPreviewModal({ {loading &&
Loading PDF…
} {error &&
Error: {error}
} {fileBlobUrl && ( -