From 4cb7ec7e2e9949e4db1b3160b13471d31b4272da Mon Sep 17 00:00:00 2001 From: Emile Date: Wed, 25 Feb 2026 22:38:33 -0500 Subject: [PATCH] feat(eligibility-check) - add CCA eligibility workflow with new routes and frontend components; enhance patient data processing and eligibility status updates; update insurance provider handling across various workflows --- .gitignore | 1 + apps/Backend/src/routes/auth.ts | 39 +- apps/Backend/src/routes/index.ts | 2 + apps/Backend/src/routes/insuranceStatus.ts | 12 +- apps/Backend/src/routes/insuranceStatusCCA.ts | 752 ++++++++++++++++++ .../Backend/src/routes/insuranceStatusDDMA.ts | 5 +- .../src/routes/insuranceStatusDeltaIns.ts | 4 +- .../src/routes/insuranceStatusDentaQuest.ts | 7 +- .../src/routes/insuranceStatusUnitedSCO.ts | 5 +- apps/Backend/src/routes/users.ts | 67 +- .../seleniumCCAInsuranceEligibilityClient.ts | 99 +++ apps/Frontend/src/App.tsx | 2 +- .../insurance-status/cca-button-modal.tsx | 357 +++++++++ .../src/components/layout/sidebar.tsx | 55 +- .../components/settings/InsuranceCredForm.tsx | 1 + .../settings/insuranceCredTable.tsx | 1 + apps/Frontend/src/lib/protected-route.tsx | 10 +- apps/Frontend/src/pages/auth-page.tsx | 283 ++----- .../src/pages/insurance-status-page.tsx | 23 +- apps/Frontend/src/pages/settings-page.tsx | 259 +++++- apps/SeleniumService/agent.py | 54 ++ apps/SeleniumService/cca_browser_manager.py | 292 +++++++ .../helpers_cca_eligibility.py | 180 +++++ .../selenium_CCA_eligibilityCheckWorker.py | 723 +++++++++++++++++ packages/db/prisma.config.ts | 0 packages/db/prisma/schema.prisma | 6 + 26 files changed, 2893 insertions(+), 346 deletions(-) create mode 100644 apps/Backend/src/routes/insuranceStatusCCA.ts create mode 100644 apps/Backend/src/services/seleniumCCAInsuranceEligibilityClient.ts create mode 100644 apps/Frontend/src/components/insurance-status/cca-button-modal.tsx create mode 100644 apps/SeleniumService/cca_browser_manager.py create mode 100644 apps/SeleniumService/helpers_cca_eligibility.py create mode 100644 apps/SeleniumService/selenium_CCA_eligibilityCheckWorker.py create mode 100644 packages/db/prisma.config.ts diff --git a/.gitignore b/.gitignore index 1f567cb..05a2f86 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ dist/ # env *.env *chrome_profile_ddma* +*chrome_profile_cca* *chrome_profile_dentaquest* *chrome_profile_unitedsco* *chrome_profile_deltains* diff --git a/apps/Backend/src/routes/auth.ts b/apps/Backend/src/routes/auth.ts index 8be85b0..05e7592 100644 --- a/apps/Backend/src/routes/auth.ts +++ b/apps/Backend/src/routes/auth.ts @@ -8,22 +8,19 @@ import { z } from "zod"; type SelectUser = z.infer; const JWT_SECRET = process.env.JWT_SECRET || "your-jwt-secret"; -const JWT_EXPIRATION = "24h"; // JWT expiration time (1 day) +const JWT_EXPIRATION = "24h"; -// Function to hash password using bcrypt async function hashPassword(password: string) { - const saltRounds = 10; // Salt rounds for bcrypt + const saltRounds = 10; const hashedPassword = await bcrypt.hash(password, saltRounds); return hashedPassword; } -// Function to compare passwords using bcrypt async function comparePasswords(supplied: string, stored: string) { const isMatch = await bcrypt.compare(supplied, stored); return isMatch; } -// Function to generate JWT function generateToken(user: SelectUser) { return jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { expiresIn: JWT_EXPIRATION, @@ -32,35 +29,13 @@ function generateToken(user: SelectUser) { const router = express.Router(); -// User registration route router.post( "/register", async (req: Request, res: Response, next: NextFunction): Promise => { - try { - const existingUser = await storage.getUserByUsername(req.body.username); - if (existingUser) { - return res.status(400).send("Username already exists"); - } - - const hashedPassword = await hashPassword(req.body.password); - const user = await storage.createUser({ - ...req.body, - password: hashedPassword, - }); - - // Generate a JWT token for the user after successful registration - const token = generateToken(user); - - const { password, ...safeUser } = user; - return res.status(201).json({ user: safeUser, token }); - } catch (error) { - console.error("Registration error:", error); - return res.status(500).json({ error: "Internal server error" }); - } + return res.status(403).json({ error: "Public registration is disabled. Please contact your administrator." }); } ); -// User login route router.post( "/login", async (req: Request, res: Response, next: NextFunction): Promise => { @@ -77,12 +52,12 @@ router.post( ); if (!isPasswordMatch) { - return res.status(401).json({ error: "Invalid password or password" }); + return res.status(401).json({ error: "Invalid username or password" }); } - // Generate a JWT token for the user after successful login const token = generateToken(user); - const { password, ...safeUser } = user; + const { password, ...rest } = user; + const safeUser = { ...rest, role: rest.role ?? "USER" }; return res.status(200).json({ user: safeUser, token }); } catch (error) { return res.status(500).json({ error: "Internal server error" }); @@ -90,9 +65,7 @@ router.post( } ); -// Logout route (client-side action to remove the token) router.post("/logout", (req: Request, res: Response) => { - // For JWT-based auth, logout is handled on the client (by removing token) res.status(200).send("Logged out successfully"); }); diff --git a/apps/Backend/src/routes/index.ts b/apps/Backend/src/routes/index.ts index 31d2df6..a14ab6c 100644 --- a/apps/Backend/src/routes/index.ts +++ b/apps/Backend/src/routes/index.ts @@ -12,6 +12,7 @@ import insuranceStatusDdmaRoutes from "./insuranceStatusDDMA"; import insuranceStatusDentaQuestRoutes from "./insuranceStatusDentaQuest"; import insuranceStatusUnitedSCORoutes from "./insuranceStatusUnitedSCO"; import insuranceStatusDeltaInsRoutes from "./insuranceStatusDeltaIns"; +import insuranceStatusCCARoutes from "./insuranceStatusCCA"; import paymentsRoutes from "./payments"; import databaseManagementRoutes from "./database-management"; import notificationsRoutes from "./notifications"; @@ -35,6 +36,7 @@ router.use("/insurance-status-ddma", insuranceStatusDdmaRoutes); router.use("/insurance-status-dentaquest", insuranceStatusDentaQuestRoutes); router.use("/insurance-status-unitedsco", insuranceStatusUnitedSCORoutes); router.use("/insurance-status-deltains", insuranceStatusDeltaInsRoutes); +router.use("/insurance-status-cca", insuranceStatusCCARoutes); router.use("/payments", paymentsRoutes); router.use("/database-management", databaseManagementRoutes); router.use("/notifications", notificationsRoutes); diff --git a/apps/Backend/src/routes/insuranceStatus.ts b/apps/Backend/src/routes/insuranceStatus.ts index 6aee98c..5bcffa4 100644 --- a/apps/Backend/src/routes/insuranceStatus.ts +++ b/apps/Backend/src/routes/insuranceStatus.ts @@ -69,15 +69,15 @@ async function createOrUpdatePatientByInsuranceId(options: { } return; } else { - // inside createOrUpdatePatientByInsuranceId, when creating: const createPayload: any = { firstName: incomingFirst, lastName: incomingLast, - dateOfBirth: dob, // raw from caller (string | Date | null) + dateOfBirth: dob, gender: "", phone: "", userId, insuranceId, + insuranceProvider: "MassHealth", }; let patientData: InsertPatient; @@ -219,8 +219,8 @@ router.post( if (patient && patient.id !== undefined) { const newStatus = seleniumResult.eligibility === "Y" ? "ACTIVE" : "INACTIVE"; - await storage.updatePatient(patient.id, { status: newStatus }); - outputResult.patientUpdateStatus = `Patient status updated to ${newStatus}`; + await storage.updatePatient(patient.id, { status: newStatus, insuranceProvider: "MassHealth" }); + outputResult.patientUpdateStatus = `Patient status updated to ${newStatus}, insuranceProvider=MassHealth`; // ✅ Step 5: Handle PDF Upload if ( @@ -649,8 +649,8 @@ router.post( seleniumResult?.eligibility === "Y" ? "ACTIVE" : "INACTIVE"; // 1. updating patient - await storage.updatePatient(updatedPatient.id, { status: newStatus }); - resultItem.patientUpdateStatus = `Patient status updated to ${newStatus}`; + await storage.updatePatient(updatedPatient.id, { status: newStatus, insuranceProvider: "MassHealth" }); + resultItem.patientUpdateStatus = `Patient status updated to ${newStatus}, insuranceProvider=MassHealth`; // 2. updating appointment status - for aptmnt page try { diff --git a/apps/Backend/src/routes/insuranceStatusCCA.ts b/apps/Backend/src/routes/insuranceStatusCCA.ts new file mode 100644 index 0000000..ad008ca --- /dev/null +++ b/apps/Backend/src/routes/insuranceStatusCCA.ts @@ -0,0 +1,752 @@ +import { Router, Request, Response } from "express"; +import { storage } from "../storage"; +import { + forwardToSeleniumCCAEligibilityAgent, + getSeleniumCCASessionStatus, +} from "../services/seleniumCCAInsuranceEligibilityClient"; +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(); + +interface CCAJobContext { + userId: number; + insuranceEligibilityData: any; + socketId?: string; +} + +const ccaJobs: Record = {}; + +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; + const A4_HEIGHT = 841.89; + + 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); + } + }); +} + +async function createOrUpdatePatientByInsuranceId(options: { + insuranceId: string; + firstName?: string | null; + lastName?: string | null; + dob?: string | Date | null; + userId: number; + eligibilityStatus?: string; +}) { + const { insuranceId, firstName, lastName, dob, userId, eligibilityStatus } = + 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 { + console.log( + `[cca-eligibility] Creating new patient: ${incomingFirst} ${incomingLast} with status: ${eligibilityStatus || "UNKNOWN"}` + ); + const createPayload: any = { + firstName: incomingFirst, + lastName: incomingLast, + dateOfBirth: dob, + gender: "Unknown", + phone: "", + userId, + insuranceId, + insuranceProvider: "CCA", + status: eligibilityStatus || "UNKNOWN", + }; + let patientData: InsertPatient; + try { + patientData = insertPatientSchema.parse(createPayload); + } catch (err) { + const safePayload = { ...createPayload }; + delete (safePayload as any).dateOfBirth; + patientData = insertPatientSchema.parse(safePayload); + } + const newPatient = await storage.createPatient(patientData); + console.log( + `[cca-eligibility] Created new patient: ${newPatient.id} with status: ${eligibilityStatus || "UNKNOWN"}` + ); + } +} + +async function handleCCACompletedJob( + sessionId: string, + job: CCAJobContext, + seleniumResult: any +) { + let createdPdfFileId: number | null = null; + let generatedPdfPath: string | null = null; + const outputResult: any = {}; + + try { + const insuranceEligibilityData = job.insuranceEligibilityData; + + let insuranceId = String(seleniumResult?.memberId ?? "").trim(); + if (!insuranceId) { + insuranceId = String(insuranceEligibilityData.memberId ?? "").trim(); + } + + if (!insuranceId) { + console.log( + "[cca-eligibility] No Member ID found - will use name for patient lookup" + ); + } else { + console.log(`[cca-eligibility] Using Member ID: ${insuranceId}`); + } + + const patientNameFromResult = + typeof seleniumResult?.patientName === "string" + ? seleniumResult.patientName.trim() + : null; + + let firstName = insuranceEligibilityData.firstName || ""; + let lastName = insuranceEligibilityData.lastName || ""; + + if (patientNameFromResult) { + const parsedName = splitName(patientNameFromResult); + firstName = parsedName.firstName || firstName; + lastName = parsedName.lastName || lastName; + } + + const rawEligibility = String( + seleniumResult?.eligibility ?? "" + ).toLowerCase(); + const eligibilityStatus = + rawEligibility.includes("active") || rawEligibility.includes("eligible") + ? "ACTIVE" + : "INACTIVE"; + console.log(`[cca-eligibility] Eligibility status: ${eligibilityStatus}`); + + // Extract extra patient data from selenium result + const extractedAddress = String(seleniumResult?.address ?? "").trim(); + const extractedCity = String(seleniumResult?.city ?? "").trim(); + const extractedZip = String(seleniumResult?.zipCode ?? "").trim(); + const extractedInsurer = String(seleniumResult?.insurerName ?? "").trim() || "CCA"; + + if (extractedAddress || extractedCity || extractedZip) { + console.log(`[cca-eligibility] Extra data: address=${extractedAddress}, city=${extractedCity}, zip=${extractedZip}, insurer=${extractedInsurer}`); + } + + if (insuranceId) { + await createOrUpdatePatientByInsuranceId({ + insuranceId, + firstName, + lastName, + dob: insuranceEligibilityData.dateOfBirth, + userId: job.userId, + eligibilityStatus, + }); + } + + let patient = insuranceId + ? await storage.getPatientByInsuranceId(insuranceId) + : null; + + if (!patient?.id && firstName && lastName) { + const patients = await storage.getAllPatients(job.userId); + patient = + patients.find( + (p) => + p.firstName?.toLowerCase() === firstName.toLowerCase() && + p.lastName?.toLowerCase() === lastName.toLowerCase() + ) ?? null; + if (patient) { + console.log( + `[cca-eligibility] Found patient by name: ${patient.id}` + ); + } + } + + if (!patient && firstName && lastName) { + console.log( + `[cca-eligibility] Creating new patient: ${firstName} ${lastName}` + ); + try { + let parsedDob: Date | undefined = undefined; + if (insuranceEligibilityData.dateOfBirth) { + try { + parsedDob = new Date(insuranceEligibilityData.dateOfBirth); + if (isNaN(parsedDob.getTime())) parsedDob = undefined; + } catch { + parsedDob = undefined; + } + } + + const newPatientData: InsertPatient = { + firstName, + lastName, + dateOfBirth: parsedDob || new Date(), + insuranceId: insuranceId || undefined, + insuranceProvider: extractedInsurer, + gender: "Unknown", + phone: "", + userId: job.userId, + status: eligibilityStatus, + address: extractedAddress || undefined, + city: extractedCity || undefined, + zipCode: extractedZip || undefined, + }; + + const validation = insertPatientSchema.safeParse(newPatientData); + if (validation.success) { + patient = await storage.createPatient(validation.data); + console.log( + `[cca-eligibility] Created new patient: ${patient.id}` + ); + } else { + console.log( + `[cca-eligibility] Patient validation failed: ${validation.error.message}` + ); + } + } catch (createErr: any) { + console.log( + `[cca-eligibility] Failed to create patient: ${createErr.message}` + ); + } + } + + if (!patient?.id) { + outputResult.patientUpdateStatus = + "Patient not found and could not be created; no update performed"; + return { + patientUpdateStatus: outputResult.patientUpdateStatus, + pdfUploadStatus: "none", + pdfFileId: null, + }; + } + + const updatePayload: Record = { + status: eligibilityStatus, + insuranceProvider: extractedInsurer, + }; + if (firstName && (!patient.firstName || patient.firstName.trim() === "")) { + updatePayload.firstName = firstName; + } + if (lastName && (!patient.lastName || patient.lastName.trim() === "")) { + updatePayload.lastName = lastName; + } + if (extractedAddress && (!patient.address || patient.address.trim() === "")) { + updatePayload.address = extractedAddress; + } + if (extractedCity && (!patient.city || patient.city.trim() === "")) { + updatePayload.city = extractedCity; + } + if (extractedZip && (!patient.zipCode || patient.zipCode.trim() === "")) { + updatePayload.zipCode = extractedZip; + } + + await storage.updatePatient(patient.id, updatePayload); + outputResult.patientUpdateStatus = `Patient ${patient.id} updated: status=${eligibilityStatus}, insuranceProvider=${extractedInsurer}, name=${firstName} ${lastName}, address=${extractedAddress}, city=${extractedCity}, zip=${extractedZip}`; + console.log(`[cca-eligibility] ${outputResult.patientUpdateStatus}`); + + // Handle PDF + let pdfBuffer: Buffer | null = null; + + if ( + seleniumResult?.pdfBase64 && + typeof seleniumResult.pdfBase64 === "string" && + seleniumResult.pdfBase64.length > 100 + ) { + try { + pdfBuffer = Buffer.from(seleniumResult.pdfBase64, "base64"); + const pdfFileName = `cca_eligibility_${insuranceId || "unknown"}_${Date.now()}.pdf`; + const downloadDir = path.join(process.cwd(), "seleniumDownloads"); + if (!fsSync.existsSync(downloadDir)) { + fsSync.mkdirSync(downloadDir, { recursive: true }); + } + generatedPdfPath = path.join(downloadDir, pdfFileName); + await fs.writeFile(generatedPdfPath, pdfBuffer); + console.log( + `[cca-eligibility] PDF saved from base64: ${generatedPdfPath}` + ); + } catch (pdfErr: any) { + console.error( + `[cca-eligibility] Failed to save base64 PDF: ${pdfErr.message}` + ); + pdfBuffer = null; + } + } + + if ( + !pdfBuffer && + seleniumResult?.ss_path && + typeof seleniumResult.ss_path === "string" + ) { + try { + if (!fsSync.existsSync(seleniumResult.ss_path)) { + throw new Error(`File not found: ${seleniumResult.ss_path}`); + } + + if (seleniumResult.ss_path.endsWith(".pdf")) { + pdfBuffer = await fs.readFile(seleniumResult.ss_path); + generatedPdfPath = seleniumResult.ss_path; + seleniumResult.pdf_path = generatedPdfPath; + } else if ( + seleniumResult.ss_path.endsWith(".png") || + seleniumResult.ss_path.endsWith(".jpg") || + seleniumResult.ss_path.endsWith(".jpeg") + ) { + pdfBuffer = await imageToPdfBuffer(seleniumResult.ss_path); + const pdfFileName = `cca_eligibility_${insuranceId || "unknown"}_${Date.now()}.pdf`; + generatedPdfPath = path.join( + path.dirname(seleniumResult.ss_path), + pdfFileName + ); + await fs.writeFile(generatedPdfPath, pdfBuffer); + seleniumResult.pdf_path = generatedPdfPath; + } + } catch (err: any) { + console.error( + "[cca-eligibility] Failed to process PDF/screenshot:", + err + ); + outputResult.pdfUploadStatus = `Failed to process file: ${String(err)}`; + } + } + + 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 if (!outputResult.pdfUploadStatus) { + outputResult.pdfUploadStatus = "No PDF available from Selenium"; + } + + const pdfFilename = generatedPdfPath + ? path.basename(generatedPdfPath) + : null; + + return { + patientUpdateStatus: outputResult.patientUpdateStatus, + pdfUploadStatus: outputResult.pdfUploadStatus, + pdfFileId: createdPdfFileId, + pdfFilename, + }; + } catch (err: any) { + const pdfFilename = generatedPdfPath + ? path.basename(generatedPdfPath) + : null; + return { + patientUpdateStatus: outputResult.patientUpdateStatus, + pdfUploadStatus: + outputResult.pdfUploadStatus ?? + `Failed to process CCA job: ${err?.message ?? String(err)}`, + pdfFileId: createdPdfFileId, + pdfFilename, + error: err?.message ?? String(err), + }; + } finally { + try { + if (seleniumResult && seleniumResult.pdf_path) { + await emptyFolderContainingFile(seleniumResult.pdf_path); + } else if (seleniumResult && seleniumResult.ss_path) { + await emptyFolderContainingFile(seleniumResult.ss_path); + } + } catch (cleanupErr) { + console.error(`[cca-eligibility cleanup failed]`, cleanupErr); + } + } +} + +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 }); + } +} + +async function pollAgentSessionAndProcess( + sessionId: string, + socketId?: string, + pollTimeoutMs = 4 * 60 * 1000 +) { + const maxAttempts = 300; + const baseDelayMs = 1000; + const maxTransientErrors = 12; + const noProgressLimit = 200; + + const job = ccaJobs[sessionId]; + let transientErrorCount = 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) { + emitSafe(socketId, "selenium:session_update", { + session_id: sessionId, + status: "error", + message: `Polling timeout reached (${Math.round(pollTimeoutMs / 1000)}s).`, + }); + delete ccaJobs[sessionId]; + return; + } + + log( + "poller-cca", + `attempt=${attempt} session=${sessionId} transientErrCount=${transientErrorCount}` + ); + + try { + const st = await getSeleniumCCASessionStatus(sessionId); + const status = st?.status ?? null; + log("poller-cca", "got status", { + sessionId, + status, + message: st?.message, + resultKeys: st?.result ? Object.keys(st.result) : null, + }); + + transientErrorCount = 0; + + const isTerminalLike = + status === "completed" || status === "error" || status === "not_found"; + if (status === lastStatus && !isTerminalLike) { + consecutiveNoProgress++; + } else { + consecutiveNoProgress = 0; + } + lastStatus = status; + + 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 ccaJobs[sessionId]; + return; + } + + emitSafe(socketId, "selenium:debug", { + session_id: sessionId, + attempt, + status, + serverTime: new Date().toISOString(), + }); + + if (status === "completed") { + log("poller-cca", "agent completed; processing result", { + sessionId, + resultKeys: st.result ? Object.keys(st.result) : null, + }); + + currentFinalSessionId = sessionId; + currentFinalResult = { + rawSelenium: st.result, + processedAt: null, + final: null, + }; + + let finalResult: any = null; + if (job && st.result) { + try { + finalResult = await handleCCACompletedJob( + 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-cca", "handleCCACompletedJob failed", { + sessionId, + err: err?.message ?? err, + }); + } + } else { + currentFinalResult.final = { + error: "no_job_or_no_result", + }; + currentFinalResult.processedAt = Date.now(); + } + + emitSafe(socketId, "selenium:session_update", { + session_id: sessionId, + status: "completed", + rawSelenium: st.result, + final: currentFinalResult.final, + }); + + delete ccaJobs[sessionId]; + return; + } + + 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 ccaJobs[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 ( + axiosStatus === 404 || + (typeof errMsg === "string" && errMsg.includes("not_found")) + ) { + console.warn( + `${new Date().toISOString()} [poller-cca] terminal 404/not_found for ${sessionId}` + ); + + 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); + + delete ccaJobs[sessionId]; + return; + } + + 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 ccaJobs[sessionId]; + return; + } + + const backoffMs = Math.min( + 30_000, + baseDelayMs * Math.pow(2, transientErrorCount - 1) + ); + console.warn( + `${new Date().toISOString()} [poller-cca] transient error (#${transientErrorCount}) for ${sessionId}: code=${errCode} status=${axiosStatus} msg=${errMsg}` + ); + + await new Promise((r) => setTimeout(r, backoffMs)); + continue; + } + + await new Promise((r) => setTimeout(r, baseDelayMs)); + } + + emitSafe(socketId, "selenium:session_update", { + session_id: sessionId, + status: "error", + message: "Polling timeout while waiting for selenium session", + }); + delete ccaJobs[sessionId]; +} + +router.post( + "/cca-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, + "CCA" + ); + if (!credentials) { + return res.status(404).json({ + error: + "No insurance credentials found for this provider, Kindly Update this at Settings Page.", + }); + } + + const enrichedData = { + ...rawData, + cca_username: credentials.username, + cca_password: credentials.password, + }; + + const socketId: string | undefined = req.body.socketId; + + const agentResp = + await forwardToSeleniumCCAEligibilityAgent(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; + + ccaJobs[sessionId] = { + userId: req.user.id, + insuranceEligibilityData: enrichedData, + socketId, + }; + + pollAgentSessionAndProcess(sessionId, socketId).catch((e) => + console.warn("pollAgentSessionAndProcess (cca) failed", e) + ); + + 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 CCA selenium agent", + }); + } + } +); + +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" }); + + if (currentFinalSessionId !== sid || !currentFinalResult) { + return res.status(404).json({ error: "final result not found" }); + } + + return res.json(currentFinalResult); + } +); + +export default router; diff --git a/apps/Backend/src/routes/insuranceStatusDDMA.ts b/apps/Backend/src/routes/insuranceStatusDDMA.ts index ab6d5ad..960d0d3 100644 --- a/apps/Backend/src/routes/insuranceStatusDDMA.ts +++ b/apps/Backend/src/routes/insuranceStatusDDMA.ts @@ -109,6 +109,7 @@ async function createOrUpdatePatientByInsuranceId(options: { phone: "", userId, insuranceId, + insuranceProvider: "Delta MA", }; let patientData: InsertPatient; try { @@ -273,8 +274,8 @@ async function handleDdmaCompletedJob( } // Update patient status from Delta MA eligibility result - await storage.updatePatient(patient.id, { status: eligibilityStatus }); - outputResult.patientUpdateStatus = `Patient ${patient.id} status set to ${eligibilityStatus} (Delta MA eligibility: ${seleniumResult.eligibility})`; + await storage.updatePatient(patient.id, { status: eligibilityStatus, insuranceProvider: "Delta MA" }); + outputResult.patientUpdateStatus = `Patient ${patient.id} status set to ${eligibilityStatus}, insuranceProvider=Delta MA (Delta MA eligibility: ${seleniumResult.eligibility})`; console.log(`[ddma-eligibility] ${outputResult.patientUpdateStatus}`); // Handle PDF or convert screenshot -> pdf if available diff --git a/apps/Backend/src/routes/insuranceStatusDeltaIns.ts b/apps/Backend/src/routes/insuranceStatusDeltaIns.ts index 7f6d6d3..41fa643 100644 --- a/apps/Backend/src/routes/insuranceStatusDeltaIns.ts +++ b/apps/Backend/src/routes/insuranceStatusDeltaIns.ts @@ -246,7 +246,7 @@ async function handleDeltaInsCompletedJob( }; } - const updatePayload: Record = { status: eligibilityStatus }; + const updatePayload: Record = { status: eligibilityStatus, insuranceProvider: "Delta Dental Ins" }; if (firstName && (!patient.firstName || patient.firstName.trim() === "")) { updatePayload.firstName = firstName; } @@ -255,7 +255,7 @@ async function handleDeltaInsCompletedJob( } await storage.updatePatient(patient.id, updatePayload); - outputResult.patientUpdateStatus = `Patient ${patient.id} updated: status=${eligibilityStatus}, name=${firstName} ${lastName}`; + outputResult.patientUpdateStatus = `Patient ${patient.id} updated: status=${eligibilityStatus}, insuranceProvider=Delta Dental Ins, name=${firstName} ${lastName}`; console.log(`[deltains-eligibility] ${outputResult.patientUpdateStatus}`); // Handle PDF diff --git a/apps/Backend/src/routes/insuranceStatusDentaQuest.ts b/apps/Backend/src/routes/insuranceStatusDentaQuest.ts index b231408..12ab0a2 100644 --- a/apps/Backend/src/routes/insuranceStatusDentaQuest.ts +++ b/apps/Backend/src/routes/insuranceStatusDentaQuest.ts @@ -109,6 +109,7 @@ async function createOrUpdatePatientByInsuranceId(options: { phone: "", userId, insuranceId, + insuranceProvider: "Tufts / DentaQuest", }; let patientData: InsertPatient; try { @@ -216,7 +217,7 @@ async function handleDentaQuestCompletedJob( phone: "", userId: job.userId, insuranceId: insuranceId || null, - insuranceProvider: "DentaQuest", // Set insurance provider + insuranceProvider: "Tufts / DentaQuest", status: eligibilityStatus, // Set status from eligibility check }; @@ -255,8 +256,8 @@ async function handleDentaQuestCompletedJob( } // Update patient status from DentaQuest eligibility result - await storage.updatePatient(patient.id, { status: eligibilityStatus }); - outputResult.patientUpdateStatus = `Patient ${patient.id} status set to ${eligibilityStatus} (DentaQuest eligibility: ${seleniumResult.eligibility})`; + await storage.updatePatient(patient.id, { status: eligibilityStatus, insuranceProvider: "Tufts / DentaQuest" }); + outputResult.patientUpdateStatus = `Patient ${patient.id} status set to ${eligibilityStatus}, insuranceProvider=Tufts / DentaQuest (DentaQuest eligibility: ${seleniumResult.eligibility})`; console.log(`[dentaquest-eligibility] ${outputResult.patientUpdateStatus}`); // Handle PDF or convert screenshot -> pdf if available diff --git a/apps/Backend/src/routes/insuranceStatusUnitedSCO.ts b/apps/Backend/src/routes/insuranceStatusUnitedSCO.ts index b166976..b657590 100644 --- a/apps/Backend/src/routes/insuranceStatusUnitedSCO.ts +++ b/apps/Backend/src/routes/insuranceStatusUnitedSCO.ts @@ -260,9 +260,8 @@ async function handleUnitedSCOCompletedJob( } // Update patient status and name from United SCO eligibility result - const updatePayload: Record = { status: eligibilityStatus }; + const updatePayload: Record = { status: eligibilityStatus, insuranceProvider: "United SCO" }; - // Also update first/last name if we extracted them and patient has empty names if (firstName && (!patient.firstName || patient.firstName.trim() === "")) { updatePayload.firstName = firstName; } @@ -271,7 +270,7 @@ async function handleUnitedSCOCompletedJob( } await storage.updatePatient(patient.id, updatePayload); - outputResult.patientUpdateStatus = `Patient ${patient.id} updated: status=${eligibilityStatus}, name=${firstName} ${lastName} (United SCO eligibility: ${seleniumResult.eligibility})`; + outputResult.patientUpdateStatus = `Patient ${patient.id} updated: status=${eligibilityStatus}, insuranceProvider=United SCO, name=${firstName} ${lastName} (United SCO eligibility: ${seleniumResult.eligibility})`; console.log(`[unitedsco-eligibility] ${outputResult.patientUpdateStatus}`); // Handle PDF or convert screenshot -> pdf if available diff --git a/apps/Backend/src/routes/users.ts b/apps/Backend/src/routes/users.ts index baaa7de..9b96af1 100644 --- a/apps/Backend/src/routes/users.ts +++ b/apps/Backend/src/routes/users.ts @@ -3,16 +3,13 @@ import type { Request, Response } from "express"; import { storage } from "../storage"; import { z } from "zod"; import { UserUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas"; -import jwt from 'jsonwebtoken'; -import bcrypt from 'bcrypt'; +import bcrypt from "bcrypt"; const router = Router(); -// Type based on shared schema type SelectUser = z.infer; -// Zod validation const userCreateSchema = UserUncheckedCreateInputObjectSchema; const userUpdateSchema = (UserUncheckedCreateInputObjectSchema as unknown as z.ZodObject).partial(); @@ -25,16 +22,32 @@ router.get("/", async (req: Request, res: Response): Promise => { const user = await storage.getUser(userId); if (!user) return res.status(404).send("User not found"); - - const { password, ...safeUser } = user; - res.json(safeUser); + const { password, ...rest } = user; + res.json({ ...rest, role: rest.role ?? "USER" }); } catch (error) { console.error(error); res.status(500).send("Failed to fetch user"); } }); -// GET: User by ID +router.get("/list", async (req: Request, res: Response): Promise => { + try { + if (!req.user?.id) return res.status(401).send("Unauthorized"); + + const limit = Math.min(Number(req.query.limit) || 100, 500); + const offset = Number(req.query.offset) || 0; + const users = await storage.getUsers(limit, offset); + const safe = users.map((u) => { + const { password: _p, ...rest } = u; + return { ...rest, role: rest.role ?? "USER" }; + }); + res.json(safe); + } catch (error) { + console.error(error); + res.status(500).send("Failed to fetch users"); + } +}); + router.get("/:id", async (req: Request, res: Response): Promise => { try { const idParam = req.params.id; @@ -46,35 +59,36 @@ router.get("/:id", async (req: Request, res: Response): Promise => { const user = await storage.getUser(id); if (!user) return res.status(404).send("User not found"); - const { password, ...safeUser } = user; - res.json(safeUser); + const { password, ...rest } = user; + res.json({ ...rest, role: rest.role ?? "USER" }); } catch (error) { console.error(error); res.status(500).send("Failed to fetch user"); } }); -// POST: Create new user router.post("/", async (req: Request, res: Response) => { try { const input = userCreateSchema.parse(req.body); - const newUser = await storage.createUser(input); - const { password, ...safeUser } = newUser; - res.status(201).json(safeUser); + const existing = await storage.getUserByUsername(input.username); + if (existing) { + return res.status(400).json({ error: "Username already exists" }); + } + const hashed = await hashPassword(input.password); + const newUser = await storage.createUser({ ...input, password: hashed }); + const { password: _p, ...rest } = newUser; + res.status(201).json({ ...rest, role: rest.role ?? "USER" }); } catch (err) { console.error(err); res.status(400).json({ error: "Invalid user data", details: err }); } }); -// Function to hash password using bcrypt async function hashPassword(password: string) { - const saltRounds = 10; // Salt rounds for bcrypt - const hashedPassword = await bcrypt.hash(password, saltRounds); - return hashedPassword; + const saltRounds = 10; + return bcrypt.hash(password, saltRounds); } -// PUT: Update user router.put("/:id", async (req: Request, res: Response):Promise => { try { const idParam = req.params.id; @@ -86,27 +100,24 @@ router.put("/:id", async (req: Request, res: Response):Promise => { const updates = userUpdateSchema.parse(req.body); - // If password is provided and non-empty, hash it if (updates.password && updates.password.trim() !== "") { updates.password = await hashPassword(updates.password); } else { - // Remove password field if empty, so it won't overwrite existing password with blank delete updates.password; } const updatedUser = await storage.updateUser(id, updates); if (!updatedUser) return res.status(404).send("User not found"); - const { password, ...safeUser } = updatedUser; - res.json(safeUser); + const { password, ...rest } = updatedUser; + res.json({ ...rest, role: rest.role ?? "USER" }); } catch (err) { console.error(err); res.status(400).json({ error: "Invalid update data", details: err }); } }); -// DELETE: Delete user -router.delete("/:id", async (req: Request, res: Response):Promise => { +router.delete("/:id", async (req: Request, res: Response): Promise => { try { const idParam = req.params.id; if (!idParam) return res.status(400).send("User ID is required"); @@ -114,6 +125,10 @@ router.delete("/:id", async (req: Request, res: Response):Promise => { const id = parseInt(idParam); if (isNaN(id)) return res.status(400).send("Invalid user ID"); + if (req.user?.id === id) { + return res.status(403).json({ error: "Cannot delete your own account" }); + } + const success = await storage.deleteUser(id); if (!success) return res.status(404).send("User not found"); @@ -124,4 +139,4 @@ router.delete("/:id", async (req: Request, res: Response):Promise => { } }); -export default router; \ No newline at end of file +export default router; diff --git a/apps/Backend/src/services/seleniumCCAInsuranceEligibilityClient.ts b/apps/Backend/src/services/seleniumCCAInsuranceEligibilityClient.ts new file mode 100644 index 0000000..b71f8fe --- /dev/null +++ b/apps/Backend/src/services/seleniumCCAInsuranceEligibilityClient.ts @@ -0,0 +1,99 @@ +import axios from "axios"; +import http from "http"; +import https from "https"; +import dotenv from "dotenv"; +dotenv.config(); + +export interface SeleniumPayload { + data: any; + url?: string; +} + +const SELENIUM_AGENT_BASE = process.env.SELENIUM_AGENT_BASE_URL; + +const httpAgent = new http.Agent({ keepAlive: true, keepAliveMsecs: 60_000 }); +const httpsAgent = new https.Agent({ keepAlive: true, keepAliveMsecs: 60_000 }); + +const client = axios.create({ + baseURL: SELENIUM_AGENT_BASE, + timeout: 5 * 60 * 1000, + httpAgent, + httpsAgent, + validateStatus: (s) => s >= 200 && s < 600, +}); + +async function requestWithRetries( + config: any, + retries = 4, + baseBackoffMs = 300 +) { + for (let attempt = 1; attempt <= retries; attempt++) { + try { + const r = await client.request(config); + if (![502, 503, 504].includes(r.status)) return r; + console.warn( + `[selenium-cca-client] retryable HTTP status ${r.status} (attempt ${attempt})` + ); + } catch (err: any) { + const code = err?.code; + const isTransient = + code === "ECONNRESET" || + code === "ECONNREFUSED" || + code === "EPIPE" || + code === "ETIMEDOUT"; + if (!isTransient) throw err; + console.warn( + `[selenium-cca-client] transient network error ${code} (attempt ${attempt})` + ); + } + await new Promise((r) => setTimeout(r, baseBackoffMs * attempt)); + } + return client.request(config); +} + +function now() { + return new Date().toISOString(); +} +function log(tag: string, msg: string, ctx?: any) { + console.log(`${now()} [${tag}] ${msg}`, ctx ?? ""); +} + +export async function forwardToSeleniumCCAEligibilityAgent( + insuranceEligibilityData: any +): Promise { + const payload = { data: insuranceEligibilityData }; + const url = `/cca-eligibility`; + log("selenium-cca-client", "POST cca-eligibility", { + url: SELENIUM_AGENT_BASE + url, + keys: Object.keys(payload), + }); + const r = await requestWithRetries({ url, method: "POST", data: payload }, 4); + log("selenium-cca-client", "agent response", { + status: r.status, + dataKeys: r.data ? Object.keys(r.data) : null, + }); + if (r.status >= 500) + throw new Error(`Selenium agent server error: ${r.status}`); + return r.data; +} + +export async function getSeleniumCCASessionStatus( + sessionId: string +): Promise { + const url = `/cca-session/${sessionId}/status`; + log("selenium-cca-client", "GET session status", { + url: SELENIUM_AGENT_BASE + url, + sessionId, + }); + const r = await requestWithRetries({ url, method: "GET" }, 4); + log("selenium-cca-client", "session status response", { + status: r.status, + dataKeys: r.data ? Object.keys(r.data) : null, + }); + if (r.status === 404) { + const e: any = new Error("not_found"); + e.response = { status: 404, data: r.data }; + throw e; + } + return r.data; +} diff --git a/apps/Frontend/src/App.tsx b/apps/Frontend/src/App.tsx index bea2914..465a3f1 100644 --- a/apps/Frontend/src/App.tsx +++ b/apps/Frontend/src/App.tsx @@ -41,7 +41,7 @@ function Router() { component={() => } /> } /> - } /> + } adminOnly /> } /> void; +} + +export function CCAEligibilityButton({ + memberId, + dateOfBirth, + firstName, + lastName, + isFormIncomplete, + onPdfReady, +}: CCAEligibilityButtonProps) { + const { toast } = useToast(); + const dispatch = useAppDispatch(); + + const isCCAFormIncomplete = + !dateOfBirth || (!memberId && !firstName && !lastName); + + const socketRef = useRef(null); + const connectingRef = useRef | null>(null); + + const [isStarting, setIsStarting] = useState(false); + + useEffect(() => { + return () => { + if (socketRef.current) { + socketRef.current.removeAllListeners(); + socketRef.current.disconnect(); + socketRef.current = null; + } + connectingRef.current = null; + }; + }, []); + + const closeSocket = () => { + try { + socketRef.current?.removeAllListeners(); + socketRef.current?.disconnect(); + } catch (e) { + // ignore + } finally { + socketRef.current = null; + } + }; + + const ensureSocketConnected = async () => { + if (socketRef.current && socketRef.current.connected) { + return; + } + + if (connectingRef.current) { + return connectingRef.current; + } + + const promise = new Promise((resolve, reject) => { + const socket = ioClient(SOCKET_URL, { + withCredentials: true, + }); + + socketRef.current = socket; + + socket.on("connect", () => { + resolve(); + }); + + socket.on("connect_error", () => { + dispatch( + setTaskStatus({ + status: "error", + message: "Connection failed", + }) + ); + toast({ + title: "Realtime connection failed", + description: + "Could not connect to realtime server. Retrying automatically...", + variant: "destructive", + }); + }); + + socket.on("reconnect_failed", () => { + dispatch( + setTaskStatus({ + status: "error", + message: "Reconnect failed", + }) + ); + closeSocket(); + reject(new Error("Realtime reconnect failed")); + }); + + socket.on("disconnect", () => { + dispatch( + setTaskStatus({ + status: "error", + message: "Connection disconnected", + }) + ); + }); + + socket.on("selenium:session_update", (payload: any) => { + const { session_id, status, final } = payload || {}; + if (!session_id) return; + + if (status === "completed") { + dispatch( + setTaskStatus({ + status: "success", + message: + "CCA eligibility updated and PDF attached to patient documents.", + }) + ); + toast({ + title: "CCA eligibility complete", + description: + "Patient status was updated and the eligibility PDF was saved.", + variant: "default", + }); + + const pdfId = final?.pdfFileId; + if (pdfId) { + const filename = + final?.pdfFilename ?? `eligibility_cca_${memberId}.pdf`; + onPdfReady(Number(pdfId), filename); + } + } else if (status === "error") { + const msg = + payload?.message || + final?.error || + "CCA eligibility session failed."; + dispatch( + setTaskStatus({ + status: "error", + message: msg, + }) + ); + toast({ + title: "CCA selenium error", + description: msg, + variant: "destructive", + }); + + try { + closeSocket(); + } catch (e) {} + } + + queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE }); + }); + + socket.on("selenium:session_error", (payload: any) => { + const msg = payload?.message || "Selenium session error"; + + dispatch( + setTaskStatus({ + status: "error", + message: msg, + }) + ); + + toast({ + title: "Selenium session error", + description: msg, + variant: "destructive", + }); + + try { + closeSocket(); + } catch (e) {} + }); + + const initialConnectTimeout = setTimeout(() => { + if (!socket.connected) { + closeSocket(); + reject(new Error("Realtime initial connection timeout")); + } + }, 8000); + + socket.once("connect", () => { + clearTimeout(initialConnectTimeout); + }); + }); + + connectingRef.current = promise; + + try { + await promise; + } finally { + connectingRef.current = null; + } + }; + + const startCCAEligibility = async () => { + if (!dateOfBirth) { + toast({ + title: "Missing fields", + description: "Date of Birth is required for CCA eligibility.", + variant: "destructive", + }); + return; + } + + if (!memberId && !firstName && !lastName) { + toast({ + title: "Missing fields", + description: + "Member ID, First Name, or Last Name is required for CCA eligibility.", + variant: "destructive", + }); + return; + } + + const formattedDob = dateOfBirth ? formatLocalDate(dateOfBirth) : ""; + + const payload = { + memberId: memberId || "", + dateOfBirth: formattedDob, + firstName: firstName || "", + lastName: lastName || "", + insuranceSiteKey: "CCA", + }; + + try { + setIsStarting(true); + + dispatch( + setTaskStatus({ + status: "pending", + message: "Opening realtime channel for CCA eligibility...", + }) + ); + await ensureSocketConnected(); + + const socket = socketRef.current; + if (!socket || !socket.connected) { + throw new Error("Socket connection failed"); + } + + const socketId = socket.id; + + dispatch( + setTaskStatus({ + status: "pending", + message: "Starting CCA eligibility check via selenium...", + }) + ); + + const response = await apiRequest( + "POST", + "/api/insurance-status-cca/cca-eligibility", + { + data: JSON.stringify(payload), + socketId, + } + ); + + let result: any = null; + let backendError: string | null = null; + + try { + result = await response.clone().json(); + backendError = + result?.error || result?.message || result?.detail || null; + } catch { + try { + const text = await response.clone().text(); + backendError = text?.trim() || null; + } catch { + backendError = null; + } + } + + if (!response.ok) { + throw new Error( + backendError || + `CCA selenium start failed (status ${response.status})` + ); + } + + if (result?.error) { + throw new Error(result.error); + } + + if (result.status === "started" && result.session_id) { + dispatch( + setTaskStatus({ + status: "pending", + message: + "CCA eligibility job started. Waiting for result...", + }) + ); + } else { + dispatch( + setTaskStatus({ + status: "success", + message: "CCA eligibility completed.", + }) + ); + } + } catch (err: any) { + console.error("startCCAEligibility error:", err); + dispatch( + setTaskStatus({ + status: "error", + message: err?.message || "Failed to start CCA eligibility", + }) + ); + toast({ + title: "CCA selenium error", + description: err?.message || "Failed to start CCA eligibility", + variant: "destructive", + }); + } finally { + setIsStarting(false); + } + }; + + return ( + + ); +} diff --git a/apps/Frontend/src/components/layout/sidebar.tsx b/apps/Frontend/src/components/layout/sidebar.tsx index 9f5b673..8e2a920 100644 --- a/apps/Frontend/src/components/layout/sidebar.tsx +++ b/apps/Frontend/src/components/layout/sidebar.tsx @@ -16,10 +16,16 @@ import { import { cn } from "@/lib/utils"; import { useMemo } from "react"; import { useSidebar } from "@/components/ui/sidebar"; +import { useAuth } from "@/hooks/use-auth"; export function Sidebar() { const [location] = useLocation(); - const { state, openMobile, setOpenMobile } = useSidebar(); // "expanded" | "collapsed" + const { state, openMobile, setOpenMobile } = useSidebar(); + const { user } = useAuth(); + + const isAdmin = + user?.role?.toUpperCase() === "ADMIN" || + user?.username?.toLowerCase() === "admin"; const navItems = useMemo( () => [ @@ -82,6 +88,7 @@ export function Sidebar() { name: "Settings", path: "/settings", icon: , + adminOnly: true, }, ], [] @@ -90,43 +97,39 @@ export function Sidebar() { return (
diff --git a/apps/Frontend/src/components/settings/InsuranceCredForm.tsx b/apps/Frontend/src/components/settings/InsuranceCredForm.tsx index 4a584c1..f57807a 100644 --- a/apps/Frontend/src/components/settings/InsuranceCredForm.tsx +++ b/apps/Frontend/src/components/settings/InsuranceCredForm.tsx @@ -21,6 +21,7 @@ const SITE_KEY_OPTIONS = [ { value: "DELTAINS", label: "Delta Dental Ins" }, { value: "DENTAQUEST", label: "Tufts SCO / DentaQuest" }, { value: "UNITEDSCO", label: "United SCO" }, + { value: "CCA", label: "CCA" }, ]; export function CredentialForm({ onClose, userId, defaultValues }: CredentialFormProps) { diff --git a/apps/Frontend/src/components/settings/insuranceCredTable.tsx b/apps/Frontend/src/components/settings/insuranceCredTable.tsx index b1eb82e..b460c68 100644 --- a/apps/Frontend/src/components/settings/insuranceCredTable.tsx +++ b/apps/Frontend/src/components/settings/insuranceCredTable.tsx @@ -20,6 +20,7 @@ const SITE_KEY_LABELS: Record = { DELTAINS: "Delta Dental Ins", DENTAQUEST: "Tufts SCO / DentaQuest", UNITEDSCO: "United SCO", + CCA: "CCA", }; function getSiteKeyLabel(siteKey: string): string { diff --git a/apps/Frontend/src/lib/protected-route.tsx b/apps/Frontend/src/lib/protected-route.tsx index c9369b1..2ec8db0 100644 --- a/apps/Frontend/src/lib/protected-route.tsx +++ b/apps/Frontend/src/lib/protected-route.tsx @@ -4,28 +4,32 @@ import { useAuth } from "@/hooks/use-auth"; import { Suspense } from "react"; import { Redirect, Route } from "wouter"; -type ComponentLike = React.ComponentType; // works for both lazy() and regular components +type ComponentLike = React.ComponentType; export function ProtectedRoute({ path, component: Component, + adminOnly, }: { path: string; component: ComponentLike; + adminOnly?: boolean; }) { const { user, isLoading } = useAuth(); return ( - {/* While auth is resolving: keep layout visible and show a small spinner in the content area */} {isLoading ? ( ) : !user ? ( + ) : adminOnly && + user.role?.toUpperCase() !== "ADMIN" && + user.username?.toLowerCase() !== "admin" ? ( + ) : ( - // Authenticated: render page inside layout. Lazy pages load with an in-layout spinner. }> diff --git a/apps/Frontend/src/pages/auth-page.tsx b/apps/Frontend/src/pages/auth-page.tsx index df67951..59dd363 100644 --- a/apps/Frontend/src/pages/auth-page.tsx +++ b/apps/Frontend/src/pages/auth-page.tsx @@ -1,6 +1,6 @@ import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import { useAuth } from "@/hooks/use-auth"; import { Button } from "@/components/ui/button"; import { @@ -12,7 +12,6 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Checkbox } from "@/components/ui/checkbox"; import { Card } from "@/components/ui/card"; import { CheckCircle, Torus } from "lucide-react"; @@ -22,13 +21,10 @@ import { useLocation } from "wouter"; import { LoginFormValues, loginSchema, - RegisterFormValues, - registerSchema, } from "@repo/db/types"; export default function AuthPage() { - const [activeTab, setActiveTab] = useState("login"); - const { isLoading, user, loginMutation, registerMutation } = useAuth(); + const { isLoading, user, loginMutation } = useAuth(); const [, navigate] = useLocation(); const loginForm = useForm({ @@ -40,37 +36,20 @@ export default function AuthPage() { }, }); - const registerForm = useForm({ - resolver: zodResolver(registerSchema), - defaultValues: { - username: "", - password: "", - confirmPassword: "", - agreeTerms: false, - }, - }); - const onLoginSubmit = (data: LoginFormValues) => { loginMutation.mutate({ username: data.username, password: data.password }); }; - const onRegisterSubmit = (data: RegisterFormValues) => { - registerMutation.mutate({ - username: data.username, - password: data.password, - }); - }; - - if (isLoading) { - return ; - } - useEffect(() => { if (user) { navigate("/insurance-status"); } }, [user, navigate]); + if (isLoading) { + return ; + } + return (
@@ -81,198 +60,78 @@ export default function AuthPage() { My Dental Office Management

- {" "} Comprehensive Practice Management System

- - - Login - Register - +
+ + ( + + Username + + + + + + )} + /> - - - - ( - - Username - - - - - - )} - /> + ( + + Password + + + + + + )} + /> - ( - - Password - - - - - - )} - /> +
+ ( +
+ + +
+ )} + /> +
-
- ( -
- - -
- )} - /> - -
- - - - -
- - -
- - ( - - Username - - - - - - )} - /> - - ( - - Password - - - - - - )} - /> - - ( - - Confirm Password - - - - - - )} - /> - - ( - - - - -
- - I agree to the{" "} - - Terms and Conditions - - - -
-
- )} - /> - - - - -
-
+ + + {/* Hero Section */} diff --git a/apps/Frontend/src/pages/insurance-status-page.tsx b/apps/Frontend/src/pages/insurance-status-page.tsx index 83091e0..d2bef22 100644 --- a/apps/Frontend/src/pages/insurance-status-page.tsx +++ b/apps/Frontend/src/pages/insurance-status-page.tsx @@ -31,6 +31,7 @@ import { DdmaEligibilityButton } from "@/components/insurance-status/ddma-buton- import { DentaQuestEligibilityButton } from "@/components/insurance-status/dentaquest-button-modal"; import { UnitedSCOEligibilityButton } from "@/components/insurance-status/unitedsco-button-modal"; import { DeltaInsEligibilityButton } from "@/components/insurance-status/deltains-button-modal"; +import { CCAEligibilityButton } from "@/components/insurance-status/cca-button-modal"; export default function InsuranceStatusPage() { const { user } = useAuth(); @@ -655,14 +656,20 @@ export default function InsuranceStatusPage() { }} /> - + { + setPreviewPdfId(pdfId); + setPreviewFallbackFilename( + fallbackFilename ?? `eligibility_cca_${memberId}.pdf` + ); + setPreviewOpen(true); + }} + />
{/* Row 3 */} diff --git a/apps/Frontend/src/pages/settings-page.tsx b/apps/Frontend/src/pages/settings-page.tsx index 066fac7..036facf 100644 --- a/apps/Frontend/src/pages/settings-page.tsx +++ b/apps/Frontend/src/pages/settings-page.tsx @@ -10,19 +10,18 @@ import { CredentialTable } from "@/components/settings/insuranceCredTable"; import { useAuth } from "@/hooks/use-auth"; import { Staff } from "@repo/db/types"; +type SafeUser = { id: number; username: string; role: "ADMIN" | "USER" }; export default function SettingsPage() { const { toast } = useToast(); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); - // Modal and editing staff state const [modalOpen, setModalOpen] = useState(false); const [credentialModalOpen, setCredentialModalOpen] = useState(false); const [editingStaff, setEditingStaff] = useState(null); const toggleMobileMenu = () => setIsMobileMenuOpen((prev) => !prev); - // Fetch staff data const { data: staff = [], isLoading, @@ -37,14 +36,13 @@ export default function SettingsPage() { } return res.json(); }, - staleTime: 1000 * 60 * 5, // 5 minutes cache + staleTime: 1000 * 60 * 5, }); - // Add Staff mutation const addStaffMutate = useMutation< - Staff, // Return type - Error, // Error type - Omit // Variables + Staff, + Error, + Omit >({ mutationFn: async (newStaff: Omit) => { const res = await apiRequest("POST", "/api/staffs/", newStaff); @@ -71,7 +69,6 @@ export default function SettingsPage() { }, }); - // Update Staff mutation const updateStaffMutate = useMutation< Staff, Error, @@ -108,7 +105,6 @@ export default function SettingsPage() { }, }); - // Delete Staff mutation const deleteStaffMutation = useMutation({ mutationFn: async (id: number) => { const res = await apiRequest("DELETE", `/api/staffs/${id}`); @@ -136,30 +132,24 @@ export default function SettingsPage() { }, }); - // Extract mutation states for modal control and loading - const isAdding = addStaffMutate.status === "pending"; const isAddSuccess = addStaffMutate.status === "success"; const isUpdating = updateStaffMutate.status === "pending"; const isUpdateSuccess = updateStaffMutate.status === "success"; - // Open Add modal const openAddStaffModal = () => { setEditingStaff(null); setModalOpen(true); }; - // Open Edit modal const openEditStaffModal = (staff: Staff) => { setEditingStaff(staff); setModalOpen(true); }; - // Handle form submit for Add or Edit const handleFormSubmit = (formData: Omit) => { if (editingStaff) { - // Editing existing staff if (editingStaff.id === undefined) { toast({ title: "Error", @@ -181,7 +171,6 @@ export default function SettingsPage() { setModalOpen(false); }; - // Close modal on successful add/update useEffect(() => { if (isAddSuccess || isUpdateSuccess) { setModalOpen(false); @@ -215,10 +204,86 @@ export default function SettingsPage() { `Viewing staff member:\n${staff.name} (${staff.email || "No email"})` ); - // MANAGE USER + // --- Users control (list, add, edit password, delete) --- + const { + data: usersList = [], + isLoading: usersLoading, + isError: usersError, + error: usersErrorObj, + } = useQuery({ + queryKey: ["/api/users/list"], + queryFn: async () => { + const res = await apiRequest("GET", "/api/users/list"); + if (!res.ok) throw new Error("Failed to fetch users"); + return res.json(); + }, + staleTime: 1000 * 60 * 2, + }); + + const addUserMutate = useMutation({ + mutationFn: async (data) => { + const res = await apiRequest("POST", "/api/users/", data); + if (!res.ok) { + const err = await res.json().catch(() => null); + throw new Error(err?.error || "Failed to add user"); + } + return res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["/api/users/list"] }); + setAddUserModalOpen(false); + toast({ title: "User Added", description: "User created successfully.", variant: "default" }); + }, + onError: (e: any) => { + toast({ title: "Error", description: e?.message || "Failed to add user", variant: "destructive" }); + }, + }); + + const updateUserPasswordMutate = useMutation({ + mutationFn: async ({ id, password }) => { + const res = await apiRequest("PUT", `/api/users/${id}`, { password }); + if (!res.ok) { + const err = await res.json().catch(() => null); + throw new Error(err?.error || "Failed to update password"); + } + return res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["/api/users/list"] }); + setEditPasswordUser(null); + toast({ title: "Password Updated", description: "Password changed successfully.", variant: "default" }); + }, + onError: (e: any) => { + toast({ title: "Error", description: e?.message || "Failed to update password", variant: "destructive" }); + }, + }); + + const deleteUserMutate = useMutation({ + mutationFn: async (id) => { + const res = await apiRequest("DELETE", `/api/users/${id}`); + if (!res.ok) { + const err = await res.json().catch(() => null); + throw new Error(err?.error || "Failed to delete user"); + } + return id; + }, + onSuccess: () => { + setUserToDelete(null); + queryClient.invalidateQueries({ queryKey: ["/api/users/list"] }); + toast({ title: "User Removed", description: "User deleted.", variant: "default" }); + }, + onError: (e: any) => { + toast({ title: "Error", description: e?.message || "Failed to delete user", variant: "destructive" }); + }, + }); + + const [addUserModalOpen, setAddUserModalOpen] = useState(false); + const [editPasswordUser, setEditPasswordUser] = useState(null); + const [userToDelete, setUserToDelete] = useState(null); + + // MANAGE USER (current user profile) const [usernameUser, setUsernameUser] = useState(""); - //fetch user const { user } = useAuth(); useEffect(() => { if (user?.username) { @@ -226,7 +291,6 @@ export default function SettingsPage() { } }, [user]); - //update user mutation const updateUserMutate = useMutation({ mutationFn: async ( updates: Partial<{ username: string; password: string }> @@ -303,10 +367,73 @@ export default function SettingsPage() { )} - {/* User Setting section */} + {/* Users control section */} + + +
+

User Accounts

+ +
+
+ + + + + + + + + + {usersLoading && ( + + )} + {usersError && ( + + )} + {!usersLoading && !usersError && usersList.filter((u) => u.id !== user?.id).length === 0 && ( + + )} + {!usersLoading && usersList.filter((u) => u.id !== user?.id).map((u) => ( + + + + + + ))} + +
UsernameRoleActions
Loading users...
{(usersErrorObj as Error)?.message}
No other users.
+ {u.username} + {u.role} + + +
+
+
+
+ + {/* User Setting section (current user profile) */} -

User Settings

+

Admin Setting

{ @@ -358,6 +485,96 @@ export default function SettingsPage() { + {/* Add User modal */} + {addUserModalOpen && ( +
+
+

Add User

+ { + e.preventDefault(); + const form = e.currentTarget; + const username = (form.querySelector('[name="new-username"]') as HTMLInputElement)?.value?.trim(); + const password = (form.querySelector('[name="new-password"]') as HTMLInputElement)?.value; + const role = (form.querySelector('[name="new-role"]') as HTMLSelectElement)?.value as "ADMIN" | "USER"; + if (!username || !password) { + toast({ title: "Error", description: "Username and password are required.", variant: "destructive" }); + return; + } + addUserMutate.mutate({ username, password, role: role || "USER" }); + }} + className="space-y-4" + > +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ )} + + {/* Edit password modal */} + {editPasswordUser && ( +
+
+

Change password for {editPasswordUser.username}

+
{ + e.preventDefault(); + const form = e.currentTarget; + const password = (form.querySelector('[name="edit-password"]') as HTMLInputElement)?.value; + if (!password?.trim()) { + toast({ title: "Error", description: "Password is required.", variant: "destructive" }); + return; + } + updateUserPasswordMutate.mutate({ id: editPasswordUser.id, password }); + }} + className="space-y-4" + > +
+ + +
+
+ + +
+
+
+
+ )} + + userToDelete && deleteUserMutate.mutate(userToDelete.id)} + onCancel={() => setUserToDelete(null)} + entityName={userToDelete?.username} + /> + {/* Credential Section */}
diff --git a/apps/SeleniumService/agent.py b/apps/SeleniumService/agent.py index 72d88ea..7bb18cf 100644 --- a/apps/SeleniumService/agent.py +++ b/apps/SeleniumService/agent.py @@ -12,12 +12,14 @@ import helpers_ddma_eligibility as hddma import helpers_dentaquest_eligibility as hdentaquest import helpers_unitedsco_eligibility as hunitedsco import helpers_deltains_eligibility as hdeltains +import helpers_cca_eligibility as hcca # Import session clear functions for startup from ddma_browser_manager import clear_ddma_session_on_startup from dentaquest_browser_manager import clear_dentaquest_session_on_startup from unitedsco_browser_manager import clear_unitedsco_session_on_startup from deltains_browser_manager import clear_deltains_session_on_startup +from cca_browser_manager import clear_cca_session_on_startup from dotenv import load_dotenv load_dotenv() @@ -31,6 +33,7 @@ clear_ddma_session_on_startup() clear_dentaquest_session_on_startup() clear_unitedsco_session_on_startup() clear_deltains_session_on_startup() +clear_cca_session_on_startup() print("=" * 50) print("SESSION CLEAR COMPLETE - FRESH LOGINS REQUIRED") print("=" * 50) @@ -425,6 +428,48 @@ async def deltains_session_status(sid: str): return s +# Endpoint:9 - CCA eligibility (background, no OTP) + +async def _cca_worker_wrapper(sid: str, data: dict, url: str): + global active_jobs, waiting_jobs + async with semaphore: + async with lock: + waiting_jobs -= 1 + active_jobs += 1 + try: + await hcca.start_cca_run(sid, data, url) + finally: + async with lock: + active_jobs -= 1 + + +@app.post("/cca-eligibility") +async def cca_eligibility(request: Request): + global waiting_jobs + + body = await request.json() + data = body.get("data", {}) + + sid = hcca.make_session_entry() + hcca.sessions[sid]["type"] = "cca_eligibility" + hcca.sessions[sid]["last_activity"] = time.time() + + async with lock: + waiting_jobs += 1 + + asyncio.create_task(_cca_worker_wrapper(sid, data, url="https://pwp.sciondental.com/PWP/Landing")) + + return {"status": "started", "session_id": sid} + + +@app.get("/cca-session/{sid}/status") +async def cca_session_status(sid: str): + s = hcca.get_session_status(sid) + if s.get("status") == "not_found": + raise HTTPException(status_code=404, detail="session not found") + return s + + @app.post("/submit-otp") async def submit_otp(request: Request): """ @@ -511,6 +556,15 @@ async def clear_deltains_session(): return {"status": "error", "message": str(e)} +@app.post("/clear-cca-session") +async def clear_cca_session_endpoint(): + try: + clear_cca_session_on_startup() + return {"status": "success", "message": "CCA session cleared"} + except Exception as e: + return {"status": "error", "message": str(e)} + + if __name__ == "__main__": host = os.getenv("HOST") port = int(os.getenv("PORT")) diff --git a/apps/SeleniumService/cca_browser_manager.py b/apps/SeleniumService/cca_browser_manager.py new file mode 100644 index 0000000..99285b8 --- /dev/null +++ b/apps/SeleniumService/cca_browser_manager.py @@ -0,0 +1,292 @@ +""" +Browser manager for CCA (Commonwealth Care Alliance) via ScionDental portal. +Handles persistent Chrome profile, cookie save/restore, and credential tracking. +No OTP required for this provider. +""" +import os +import json +import shutil +import hashlib +import threading +import subprocess +import time +from selenium import webdriver +from selenium.webdriver.chrome.service import Service +from webdriver_manager.chrome import ChromeDriverManager + +if not os.environ.get("DISPLAY"): + os.environ["DISPLAY"] = ":0" + + +class CCABrowserManager: + _instance = None + _lock = threading.Lock() + + def __new__(cls): + with cls._lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._driver = None + cls._instance.profile_dir = os.path.abspath("chrome_profile_cca") + cls._instance.download_dir = os.path.abspath("seleniumDownloads") + cls._instance._credentials_file = os.path.join(cls._instance.profile_dir, ".last_credentials") + cls._instance._cookies_file = os.path.join(cls._instance.profile_dir, ".saved_cookies.json") + cls._instance._needs_session_clear = False + os.makedirs(cls._instance.profile_dir, exist_ok=True) + os.makedirs(cls._instance.download_dir, exist_ok=True) + return cls._instance + + def save_cookies(self): + try: + if not self._driver: + return + cookies = self._driver.get_cookies() + if not cookies: + return + with open(self._cookies_file, "w") as f: + json.dump(cookies, f) + print(f"[CCA BrowserManager] Saved {len(cookies)} cookies to disk") + except Exception as e: + print(f"[CCA BrowserManager] Failed to save cookies: {e}") + + def restore_cookies(self): + if not os.path.exists(self._cookies_file): + print("[CCA BrowserManager] No saved cookies file found") + return False + try: + with open(self._cookies_file, "r") as f: + cookies = json.load(f) + if not cookies: + print("[CCA BrowserManager] Saved cookies file is empty") + return False + try: + self._driver.get("https://pwp.sciondental.com/favicon.ico") + time.sleep(2) + except Exception: + self._driver.get("https://pwp.sciondental.com") + time.sleep(3) + restored = 0 + for cookie in cookies: + try: + for key in ["sameSite", "storeId", "hostOnly", "session"]: + cookie.pop(key, None) + cookie["sameSite"] = "None" + self._driver.add_cookie(cookie) + restored += 1 + except Exception: + pass + print(f"[CCA BrowserManager] Restored {restored}/{len(cookies)} cookies") + return restored > 0 + except Exception as e: + print(f"[CCA BrowserManager] Failed to restore cookies: {e}") + return False + + def clear_saved_cookies(self): + try: + if os.path.exists(self._cookies_file): + os.remove(self._cookies_file) + print("[CCA BrowserManager] Cleared saved cookies file") + except Exception as e: + print(f"[CCA BrowserManager] Failed to clear saved cookies: {e}") + + def clear_session_on_startup(self): + print("[CCA BrowserManager] Clearing session on startup...") + try: + if os.path.exists(self._credentials_file): + os.remove(self._credentials_file) + self.clear_saved_cookies() + + session_files = [ + "Cookies", "Cookies-journal", + "Login Data", "Login Data-journal", + "Web Data", "Web Data-journal", + ] + for filename in session_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) + except Exception: + pass + + for dirname in ["Session Storage", "Local Storage", "IndexedDB"]: + dirpath = os.path.join(self.profile_dir, "Default", dirname) + if os.path.exists(dirpath): + try: + shutil.rmtree(dirpath) + except Exception: + pass + + for cache_name in ["Cache", "Code Cache", "GPUCache", "Service Worker", "ShaderCache"]: + for base in [os.path.join(self.profile_dir, "Default"), self.profile_dir]: + cache_dir = os.path.join(base, cache_name) + if os.path.exists(cache_dir): + try: + shutil.rmtree(cache_dir) + except Exception: + pass + + self._needs_session_clear = True + print("[CCA BrowserManager] Session cleared - will require fresh login") + except Exception as e: + print(f"[CCA BrowserManager] Error clearing session: {e}") + + def _hash_credentials(self, username: str) -> str: + return hashlib.sha256(username.encode()).hexdigest()[:16] + + def get_last_credentials_hash(self): + try: + if os.path.exists(self._credentials_file): + with open(self._credentials_file, 'r') as f: + return f.read().strip() + except Exception: + pass + return None + + def save_credentials_hash(self, username: str): + try: + cred_hash = self._hash_credentials(username) + with open(self._credentials_file, 'w') as f: + f.write(cred_hash) + except Exception as e: + print(f"[CCA BrowserManager] Failed to save credentials hash: {e}") + + def credentials_changed(self, username: str) -> bool: + last_hash = self.get_last_credentials_hash() + if last_hash is None: + return False + current_hash = self._hash_credentials(username) + changed = last_hash != current_hash + if changed: + print("[CCA BrowserManager] Credentials changed - logout required") + return changed + + def clear_credentials_hash(self): + try: + if os.path.exists(self._credentials_file): + os.remove(self._credentials_file) + except Exception: + pass + + def _kill_existing_chrome_for_profile(self): + try: + result = subprocess.run( + ["pgrep", "-f", f"user-data-dir={self.profile_dir}"], + capture_output=True, text=True + ) + if result.stdout.strip(): + for pid in result.stdout.strip().split('\n'): + try: + subprocess.run(["kill", "-9", pid], check=False) + except Exception: + pass + time.sleep(1) + except Exception: + pass + + for lock_file in ["SingletonLock", "SingletonSocket", "SingletonCookie"]: + lock_path = os.path.join(self.profile_dir, lock_file) + try: + if os.path.islink(lock_path) or os.path.exists(lock_path): + os.remove(lock_path) + except Exception: + pass + + def get_driver(self, headless=False): + with self._lock: + need_cookie_restore = False + if self._driver is None: + print("[CCA BrowserManager] Driver is None, creating new driver") + self._kill_existing_chrome_for_profile() + self._create_driver(headless) + need_cookie_restore = True + elif not self._is_alive(): + print("[CCA BrowserManager] Driver not alive, recreating") + self._kill_existing_chrome_for_profile() + self._create_driver(headless) + need_cookie_restore = True + else: + print("[CCA BrowserManager] Reusing existing driver") + + if need_cookie_restore and os.path.exists(self._cookies_file): + print("[CCA BrowserManager] Restoring saved cookies into new browser...") + self.restore_cookies() + return self._driver + + def _is_alive(self): + try: + if self._driver is None: + return False + _ = self._driver.current_url + return True + except Exception: + return False + + def _create_driver(self, headless=False): + if self._driver: + try: + self._driver.quit() + except Exception: + pass + self._driver = None + time.sleep(1) + + options = webdriver.ChromeOptions() + if headless: + options.add_argument("--headless") + + options.add_argument(f"--user-data-dir={self.profile_dir}") + options.add_argument("--no-sandbox") + options.add_argument("--disable-dev-shm-usage") + options.add_argument("--disable-blink-features=AutomationControlled") + options.add_experimental_option("excludeSwitches", ["enable-automation"]) + options.add_experimental_option("useAutomationExtension", False) + options.add_argument("--disable-infobars") + + prefs = { + "download.default_directory": self.download_dir, + "plugins.always_open_pdf_externally": True, + "download.prompt_for_download": False, + "download.directory_upgrade": True, + "credentials_enable_service": False, + "profile.password_manager_enabled": False, + "profile.password_manager_leak_detection": False, + } + options.add_experimental_option("prefs", prefs) + + service = Service(ChromeDriverManager().install()) + self._driver = webdriver.Chrome(service=service, options=options) + self._driver.maximize_window() + + try: + self._driver.execute_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})") + except Exception: + pass + + self._needs_session_clear = False + + def quit_driver(self): + with self._lock: + if self._driver: + try: + self._driver.quit() + except Exception: + pass + self._driver = None + self._kill_existing_chrome_for_profile() + + +_manager = None + + +def get_browser_manager(): + global _manager + if _manager is None: + _manager = CCABrowserManager() + return _manager + + +def clear_cca_session_on_startup(): + manager = get_browser_manager() + manager.clear_session_on_startup() diff --git a/apps/SeleniumService/helpers_cca_eligibility.py b/apps/SeleniumService/helpers_cca_eligibility.py new file mode 100644 index 0000000..fcca85d --- /dev/null +++ b/apps/SeleniumService/helpers_cca_eligibility.py @@ -0,0 +1,180 @@ +import os +import time +import asyncio +from typing import Dict, Any +from selenium.common.exceptions import WebDriverException + +from selenium_CCA_eligibilityCheckWorker import AutomationCCAEligibilityCheck +from cca_browser_manager import get_browser_manager + +sessions: Dict[str, Dict[str, Any]] = {} + + +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, + "result": None, + "message": None, + "type": 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 + finally: + sessions.pop(sid, None) + + +async def _remove_session_later(sid: str, delay: int = 30): + await asyncio.sleep(delay) + await cleanup_session(sid) + + +def _close_browser(bot): + try: + bm = get_browser_manager() + try: + bm.save_cookies() + except Exception: + pass + try: + bm.quit_driver() + print("[CCA] Browser closed") + except Exception: + pass + except Exception as e: + print(f"[CCA] Could not close browser: {e}") + + +async def start_cca_run(sid: str, data: dict, url: str): + """ + Run the CCA eligibility check workflow (no OTP): + 1. Login + 2. Search patient by Subscriber ID + DOB + 3. Extract eligibility info + PDF + """ + 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 = AutomationCCAEligibilityCheck({"data": data}) + bot.config_driver() + + s["bot"] = bot + s["driver"] = bot.driver + s["last_activity"] = time.time() + + try: + bot.driver.maximize_window() + except Exception: + pass + + try: + login_result = bot.login(url) + except WebDriverException as wde: + s["status"] = "error" + s["message"] = f"Selenium driver error during login: {wde}" + s["result"] = {"status": "error", "message": s["message"]} + _close_browser(bot) + asyncio.create_task(_remove_session_later(sid, 30)) + return {"status": "error", "message": s["message"]} + except Exception as e: + s["status"] = "error" + s["message"] = f"Unexpected error during login: {e}" + s["result"] = {"status": "error", "message": s["message"]} + _close_browser(bot) + asyncio.create_task(_remove_session_later(sid, 30)) + return {"status": "error", "message": s["message"]} + + if isinstance(login_result, str) and login_result == "ALREADY_LOGGED_IN": + s["status"] = "running" + s["message"] = "Session persisted" + print("[CCA] Session persisted - skipping login") + get_browser_manager().save_cookies() + + elif isinstance(login_result, str) and login_result.startswith("ERROR"): + s["status"] = "error" + s["message"] = login_result + s["result"] = {"status": "error", "message": login_result} + _close_browser(bot) + asyncio.create_task(_remove_session_later(sid, 30)) + return {"status": "error", "message": login_result} + + elif isinstance(login_result, str) and login_result == "SUCCESS": + print("[CCA] Login succeeded") + s["status"] = "running" + s["message"] = "Login succeeded" + get_browser_manager().save_cookies() + + # Step 1 - search patient and verify eligibility + step1_result = bot.step1() + print(f"[CCA] step1 result: {step1_result}") + + if isinstance(step1_result, str) and step1_result.startswith("ERROR"): + s["status"] = "error" + s["message"] = step1_result + s["result"] = {"status": "error", "message": step1_result} + _close_browser(bot) + asyncio.create_task(_remove_session_later(sid, 30)) + return {"status": "error", "message": step1_result} + + # Step 2 - extract eligibility info + PDF + step2_result = bot.step2() + print(f"[CCA] step2 result: {step2_result.get('status') if isinstance(step2_result, dict) else step2_result}") + + if isinstance(step2_result, dict): + s["status"] = "completed" + s["result"] = step2_result + s["message"] = "completed" + asyncio.create_task(_remove_session_later(sid, 60)) + return step2_result + else: + s["status"] = "error" + s["message"] = f"step2 returned unexpected result: {step2_result}" + s["result"] = {"status": "error", "message": s["message"]} + _close_browser(bot) + asyncio.create_task(_remove_session_later(sid, 30)) + return {"status": "error", "message": s["message"]} + + except Exception as e: + if s: + s["status"] = "error" + s["message"] = f"worker exception: {e}" + s["result"] = {"status": "error", "message": s["message"]} + if bot: + _close_browser(bot) + asyncio.create_task(_remove_session_later(sid, 30)) + return {"status": "error", "message": f"worker exception: {e}"} + + +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, + } diff --git a/apps/SeleniumService/selenium_CCA_eligibilityCheckWorker.py b/apps/SeleniumService/selenium_CCA_eligibilityCheckWorker.py new file mode 100644 index 0000000..c4d04a2 --- /dev/null +++ b/apps/SeleniumService/selenium_CCA_eligibilityCheckWorker.py @@ -0,0 +1,723 @@ +from selenium import webdriver +from selenium.common.exceptions import WebDriverException, TimeoutException +from selenium.webdriver.chrome.service import Service +from selenium.webdriver.common.by import By +from selenium.webdriver.common.keys import Keys +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC +from webdriver_manager.chrome import ChromeDriverManager +import time +import os +import base64 +import re +import glob +from datetime import datetime + +from cca_browser_manager import get_browser_manager + +LOGIN_URL = "https://pwp.sciondental.com/PWP/Landing" +LANDING_URL = "https://pwp.sciondental.com/PWP/Landing" + + +class AutomationCCAEligibilityCheck: + def __init__(self, data): + self.headless = False + self.driver = None + + self.data = data.get("data", {}) if isinstance(data, dict) else {} + + self.memberId = self.data.get("memberId", "") + self.dateOfBirth = self.data.get("dateOfBirth", "") + self.firstName = self.data.get("firstName", "") + self.lastName = self.data.get("lastName", "") + self.cca_username = self.data.get("cca_username", "") + self.cca_password = self.data.get("cca_password", "") + + self.download_dir = get_browser_manager().download_dir + os.makedirs(self.download_dir, exist_ok=True) + + def config_driver(self): + self.driver = get_browser_manager().get_driver(self.headless) + + def _close_browser(self): + browser_manager = get_browser_manager() + try: + browser_manager.save_cookies() + except Exception as e: + print(f"[CCA] Failed to save cookies before close: {e}") + try: + browser_manager.quit_driver() + print("[CCA] Browser closed") + except Exception as e: + print(f"[CCA] Could not close browser: {e}") + + def _force_logout(self): + try: + print("[CCA login] Forcing logout due to credential change...") + browser_manager = get_browser_manager() + try: + self.driver.delete_all_cookies() + except Exception: + pass + browser_manager.clear_credentials_hash() + print("[CCA login] Logout complete") + return True + except Exception as e: + print(f"[CCA login] Error during forced logout: {e}") + return False + + def _page_has_logged_in_content(self): + """Quick check if the current page shows logged-in portal content.""" + try: + body_text = self.driver.find_element(By.TAG_NAME, "body").text + return ("Verify Patient Eligibility" in body_text + or "Patient Management" in body_text + or "Submit a Claim" in body_text + or "Claim Inquiry" in body_text) + except Exception: + return False + + def login(self, url): + """ + Login to ScionDental portal for CCA. + No OTP required - simple username/password login. + Returns: ALREADY_LOGGED_IN, SUCCESS, or ERROR:... + """ + browser_manager = get_browser_manager() + + try: + if self.cca_username and browser_manager.credentials_changed(self.cca_username): + self._force_logout() + self.driver.get(url) + time.sleep(2) + + # Check current page state first (no navigation needed) + try: + current_url = self.driver.current_url + print(f"[CCA login] Current URL: {current_url}") + if ("sciondental.com" in current_url + and "login" not in current_url.lower() + and self._page_has_logged_in_content()): + print("[CCA login] Already logged in") + return "ALREADY_LOGGED_IN" + except Exception as e: + print(f"[CCA login] Error checking current state: {e}") + + # Navigate to landing page to check session + print("[CCA login] Checking session at landing page...") + self.driver.get(LANDING_URL) + try: + WebDriverWait(self.driver, 10).until( + lambda d: "sciondental.com" in d.current_url + ) + except TimeoutException: + pass + time.sleep(2) + + current_url = self.driver.current_url + print(f"[CCA login] After landing nav URL: {current_url}") + + if self._page_has_logged_in_content(): + print("[CCA login] Session still valid") + return "ALREADY_LOGGED_IN" + + # Session expired — navigate to login URL + print("[CCA login] Session not valid, navigating to login page...") + self.driver.get(url) + time.sleep(2) + + current_url = self.driver.current_url + print(f"[CCA login] After login nav URL: {current_url}") + + # Enter username + print("[CCA login] Looking for username field...") + username_entered = False + for sel in [ + (By.ID, "Username"), + (By.NAME, "Username"), + (By.XPATH, "//input[@type='text']"), + ]: + try: + field = WebDriverWait(self.driver, 6).until( + EC.presence_of_element_located(sel)) + if field.is_displayed(): + field.clear() + field.send_keys(self.cca_username) + username_entered = True + print(f"[CCA login] Username entered via {sel}") + break + except Exception: + continue + + if not username_entered: + if self._page_has_logged_in_content(): + return "ALREADY_LOGGED_IN" + return "ERROR: Could not find username field" + + # Enter password + print("[CCA login] Looking for password field...") + pw_entered = False + for sel in [ + (By.ID, "Password"), + (By.NAME, "Password"), + (By.XPATH, "//input[@type='password']"), + ]: + try: + field = self.driver.find_element(*sel) + if field.is_displayed(): + field.clear() + field.send_keys(self.cca_password) + pw_entered = True + print(f"[CCA login] Password entered via {sel}") + break + except Exception: + continue + + if not pw_entered: + return "ERROR: Password field not found" + + # Click login button + for sel in [ + (By.XPATH, "//button[@type='submit']"), + (By.XPATH, "//input[@type='submit']"), + (By.XPATH, "//button[contains(text(),'Sign In') or contains(text(),'Log In') or contains(text(),'Login')]"), + (By.XPATH, "//input[@value='Sign In' or @value='Log In' or @value='Login']"), + ]: + try: + btn = self.driver.find_element(*sel) + if btn.is_displayed(): + btn.click() + print(f"[CCA login] Clicked login button via {sel}") + break + except Exception: + continue + + if self.cca_username: + browser_manager.save_credentials_hash(self.cca_username) + + # Wait for page to load after login + try: + WebDriverWait(self.driver, 15).until( + lambda d: "Landing" in d.current_url + or "Dental" in d.current_url + or "Home" in d.current_url + ) + print("[CCA login] Redirected to portal page") + except TimeoutException: + time.sleep(3) + + current_url = self.driver.current_url + print(f"[CCA login] After login submit URL: {current_url}") + + # Check for login errors + try: + body_text = self.driver.find_element(By.TAG_NAME, "body").text + if "invalid" in body_text.lower() and ("password" in body_text.lower() or "username" in body_text.lower()): + return "ERROR: Invalid username or password" + except Exception: + pass + + if self._page_has_logged_in_content(): + print("[CCA login] Login successful") + return "SUCCESS" + + if "Landing" in current_url or "Home" in current_url or "Dental" in current_url: + return "SUCCESS" + + # Check for errors + try: + errors = self.driver.find_elements(By.XPATH, + "//*[contains(@class,'error') or contains(@class,'alert-danger') or contains(@class,'validation-summary')]") + for err in errors: + if err.is_displayed() and err.text.strip(): + return f"ERROR: {err.text.strip()[:200]}" + except Exception: + pass + + print("[CCA login] Login completed (assuming success)") + return "SUCCESS" + + except Exception as e: + print(f"[CCA login] Exception: {e}") + return f"ERROR:LOGIN FAILED: {e}" + + def _format_dob(self, dob_str): + if dob_str and "-" in dob_str: + dob_parts = dob_str.split("-") + if len(dob_parts) == 3: + return f"{dob_parts[1]}/{dob_parts[2]}/{dob_parts[0]}" + return dob_str + + def step1(self): + """ + Enter patient info and click Verify Eligibility. + """ + try: + formatted_dob = self._format_dob(self.dateOfBirth) + today_str = datetime.now().strftime("%m/%d/%Y") + print(f"[CCA step1] Starting — memberId={self.memberId}, DOB={formatted_dob}, DateOfService={today_str}") + + # Always navigate fresh to Landing to reset page state + print("[CCA step1] Navigating to eligibility page...") + self.driver.get(LANDING_URL) + + # Wait for the page to fully load with the eligibility form + try: + WebDriverWait(self.driver, 15).until( + lambda d: "Verify Patient Eligibility" in d.find_element(By.TAG_NAME, "body").text + ) + print("[CCA step1] Eligibility form loaded") + except TimeoutException: + print("[CCA step1] Eligibility form not found after 15s, checking page...") + body_text = self.driver.find_element(By.TAG_NAME, "body").text + print(f"[CCA step1] Page text (first 300): {body_text[:300]}") + + time.sleep(1) + + # Select "Subscriber ID and date of birth" radio + print("[CCA step1] Selecting 'Subscriber ID and date of birth' option...") + for sel in [ + (By.XPATH, "//input[@type='radio' and contains(@id,'SubscriberId')]"), + (By.XPATH, "//input[@type='radio'][following-sibling::*[contains(text(),'Subscriber ID')]]"), + (By.XPATH, "//label[contains(text(),'Subscriber ID')]//input[@type='radio']"), + (By.XPATH, "(//input[@type='radio'])[1]"), + ]: + try: + radio = self.driver.find_element(*sel) + if radio.is_displayed(): + if not radio.is_selected(): + radio.click() + print(f"[CCA step1] Selected radio via {sel}") + else: + print("[CCA step1] Radio already selected") + break + except Exception: + continue + + # Enter Subscriber ID + print(f"[CCA step1] Entering Subscriber ID: {self.memberId}") + sub_id_entered = False + for sel in [ + (By.ID, "SubscriberId"), + (By.NAME, "SubscriberId"), + (By.XPATH, "//input[contains(@id,'SubscriberId')]"), + (By.XPATH, "//label[contains(text(),'Subscriber ID')]/following::input[1]"), + ]: + try: + field = self.driver.find_element(*sel) + if field.is_displayed(): + field.click() + field.send_keys(Keys.CONTROL + "a") + field.send_keys(Keys.DELETE) + field.send_keys(self.memberId) + time.sleep(0.3) + print(f"[CCA step1] Subscriber ID entered: '{field.get_attribute('value')}'") + sub_id_entered = True + break + except Exception: + continue + + if not sub_id_entered: + return "ERROR: Subscriber ID field not found" + + # Enter Date of Birth + print(f"[CCA step1] Entering DOB: {formatted_dob}") + dob_entered = False + for sel in [ + (By.ID, "DateOfBirth"), + (By.NAME, "DateOfBirth"), + (By.XPATH, "//input[contains(@id,'DateOfBirth') or contains(@id,'dob')]"), + (By.XPATH, "//label[contains(text(),'Date of Birth')]/following::input[1]"), + ]: + try: + field = self.driver.find_element(*sel) + if field.is_displayed(): + field.click() + field.send_keys(Keys.CONTROL + "a") + field.send_keys(Keys.DELETE) + field.send_keys(formatted_dob) + time.sleep(0.3) + print(f"[CCA step1] DOB entered: '{field.get_attribute('value')}'") + dob_entered = True + break + except Exception: + continue + + if not dob_entered: + return "ERROR: Date of Birth field not found" + + # Set Date of Service to today + print(f"[CCA step1] Setting Date of Service: {today_str}") + for sel in [ + (By.ID, "DateOfService"), + (By.NAME, "DateOfService"), + (By.XPATH, "//input[contains(@id,'DateOfService')]"), + (By.XPATH, "//label[contains(text(),'Date of Service')]/following::input[1]"), + ]: + try: + field = self.driver.find_element(*sel) + if field.is_displayed(): + field.click() + field.send_keys(Keys.CONTROL + "a") + field.send_keys(Keys.DELETE) + field.send_keys(today_str) + time.sleep(0.3) + print(f"[CCA step1] Date of Service set: '{field.get_attribute('value')}'") + break + except Exception: + continue + + # Click "Verify Eligibility" + print("[CCA step1] Clicking 'Verify Eligibility'...") + clicked = False + for sel in [ + (By.XPATH, "//button[contains(text(),'Verify Eligibility')]"), + (By.XPATH, "//input[@value='Verify Eligibility']"), + (By.XPATH, "//a[contains(text(),'Verify Eligibility')]"), + (By.XPATH, "//*[@id='btnVerifyEligibility']"), + ]: + try: + btn = self.driver.find_element(*sel) + if btn.is_displayed(): + btn.click() + clicked = True + print(f"[CCA step1] Clicked Verify Eligibility via {sel}") + break + except Exception: + continue + + if not clicked: + return "ERROR: Could not find 'Verify Eligibility' button" + + # Wait for result using WebDriverWait instead of fixed sleep + print("[CCA step1] Waiting for eligibility result...") + try: + WebDriverWait(self.driver, 30).until( + lambda d: "Patient Selected" in d.find_element(By.TAG_NAME, "body").text + or "Patient Information" in d.find_element(By.TAG_NAME, "body").text + or "patient is eligible" in d.find_element(By.TAG_NAME, "body").text.lower() + or "not eligible" in d.find_element(By.TAG_NAME, "body").text.lower() + or "no results" in d.find_element(By.TAG_NAME, "body").text.lower() + or "not found" in d.find_element(By.TAG_NAME, "body").text.lower() + ) + print("[CCA step1] Eligibility result appeared") + except TimeoutException: + print("[CCA step1] Timed out waiting for result, checking page...") + + time.sleep(1) + + # Check for errors + body_text = self.driver.find_element(By.TAG_NAME, "body").text + if "no results" in body_text.lower() or "not found" in body_text.lower() or "no patient" in body_text.lower(): + return "ERROR: No patient found with the provided Subscriber ID and DOB" + + # Check for error alerts + try: + alerts = self.driver.find_elements(By.XPATH, + "//*[@role='alert'] | //*[contains(@class,'alert-danger')]") + for alert in alerts: + if alert.is_displayed() and alert.text.strip(): + return f"ERROR: {alert.text.strip()[:200]}" + except Exception: + pass + + return "SUCCESS" + + except Exception as e: + print(f"[CCA step1] Exception: {e}") + return f"ERROR: step1 failed: {e}" + + def step2(self): + """ + Extract all patient information from the result popup, + capture the eligibility report PDF, and return everything. + """ + try: + print("[CCA step2] Extracting eligibility data...") + time.sleep(1) + + patientName = "" + extractedDob = "" + foundMemberId = "" + eligibility = "Unknown" + address = "" + city = "" + zipCode = "" + insurerName = "" + + body_text = self.driver.find_element(By.TAG_NAME, "body").text + print(f"[CCA step2] Page text (first 800): {body_text[:800]}") + + # --- Eligibility status --- + if "patient is eligible" in body_text.lower(): + eligibility = "Eligible" + elif "not eligible" in body_text.lower() or "ineligible" in body_text.lower(): + eligibility = "Not Eligible" + + # --- Patient name --- + for sel in [ + (By.XPATH, "//*[contains(@class,'patient-name') or contains(@class,'PatientName')]"), + (By.XPATH, "//div[contains(@class,'modal')]//strong"), + (By.XPATH, "//div[contains(@class,'modal')]//b"), + (By.XPATH, "//*[contains(text(),'Patient Information')]/following::*[1]"), + ]: + try: + el = self.driver.find_element(*sel) + name = el.text.strip() + if name and 2 < len(name) < 100: + patientName = name + print(f"[CCA step2] Patient name via DOM: {patientName}") + break + except Exception: + continue + + if not patientName: + name_match = re.search(r'Patient Information\s*\n+\s*([A-Z][A-Za-z\s\-\']+)', body_text) + if name_match: + raw = name_match.group(1).strip().split('\n')[0].strip() + for stop in ['Subscriber', 'Address', 'Date', 'DOB', 'Member']: + if stop in raw: + raw = raw[:raw.index(stop)].strip() + patientName = raw + print(f"[CCA step2] Patient name via regex: {patientName}") + + # --- Subscriber ID --- + sub_match = re.search(r'Subscriber\s*ID:?\s*(\d+)', body_text) + if sub_match: + foundMemberId = sub_match.group(1).strip() + print(f"[CCA step2] Subscriber ID: {foundMemberId}") + else: + foundMemberId = self.memberId + + # --- Date of Birth --- + dob_match = re.search(r'Date\s*of\s*Birth:?\s*([\d/]+)', body_text) + if dob_match: + extractedDob = dob_match.group(1).strip() + print(f"[CCA step2] DOB: {extractedDob}") + else: + extractedDob = self._format_dob(self.dateOfBirth) + + # --- Address, City, State, Zip --- + # The search results table shows: "YVONNE KADLIK\n107 HARTFORD AVE W\nMENDON, MA 01756" + # Try extracting from the result table row (name followed by address lines) + if patientName: + addr_block_match = re.search( + re.escape(patientName) + r'\s*\n\s*(.+?)\s*\n\s*([A-Z][A-Za-z\s]+),\s*([A-Z]{2})\s+(\d{5}(?:-?\d{4})?)', + body_text + ) + if addr_block_match: + address = addr_block_match.group(1).strip() + city = addr_block_match.group(2).strip() + state = addr_block_match.group(3).strip() + zipCode = addr_block_match.group(4).strip() + address = f"{address}, {city}, {state} {zipCode}" + print(f"[CCA step2] Address: {address}, City: {city}, State: {state}, Zip: {zipCode}") + + # Fallback: look for "Address: ..." in Patient Information section + if not address: + addr_match = re.search( + r'Patient Information.*?Address:?\s+(\d+.+?)(?:Date of Birth|DOB|\n\s*\n)', + body_text, re.DOTALL + ) + if addr_match: + raw_addr = addr_match.group(1).strip().replace('\n', ', ') + address = raw_addr + print(f"[CCA step2] Address (from Patient Info): {address}") + + if not city: + city_match = re.search( + r'([A-Z][A-Za-z]+),\s*([A-Z]{2})\s+(\d{5}(?:-?\d{4})?)', + address or body_text + ) + if city_match: + city = city_match.group(1).strip() + zipCode = city_match.group(3).strip() + print(f"[CCA step2] City: {city}, Zip: {zipCode}") + + # --- Insurance provider name --- + # Look for insurer name like "Commonwealth Care Alliance" + insurer_match = re.search( + r'(?:Commonwealth\s+Care\s+Alliance|' + r'Delta\s+Dental|' + r'Tufts\s+Health|' + r'MassHealth|' + r'United\s+Healthcare)', + body_text, + re.IGNORECASE + ) + if insurer_match: + insurerName = insurer_match.group(0).strip() + print(f"[CCA step2] Insurer: {insurerName}") + + # Also try generic pattern after "View Benefits" section + if not insurerName: + ins_match = re.search( + r'View Eligibility Report\s*\n+\s*(.+?)(?:\n|View Benefits)', + body_text + ) + if ins_match: + candidate = ins_match.group(1).strip() + if 3 < len(candidate) < 80 and not candidate.startswith("Start"): + insurerName = candidate + print(f"[CCA step2] Insurer via context: {insurerName}") + + # --- PDF capture --- + print("[CCA step2] Clicking 'View Eligibility Report'...") + pdfBase64 = "" + + try: + existing_files = set(glob.glob(os.path.join(self.download_dir, "*"))) + original_window = self.driver.current_window_handle + original_handles = set(self.driver.window_handles) + + view_report_clicked = False + for sel in [ + (By.XPATH, "//button[contains(text(),'View Eligibility Report')]"), + (By.XPATH, "//input[@value='View Eligibility Report']"), + (By.XPATH, "//a[contains(text(),'View Eligibility Report')]"), + (By.XPATH, "//*[contains(text(),'View Eligibility Report')]"), + ]: + try: + btn = self.driver.find_element(*sel) + if btn.is_displayed(): + btn.click() + view_report_clicked = True + print(f"[CCA step2] Clicked 'View Eligibility Report' via {sel}") + break + except Exception: + continue + + if not view_report_clicked: + print("[CCA step2] 'View Eligibility Report' button not found") + raise Exception("View Eligibility Report button not found") + + # Wait for download to start + time.sleep(3) + + # Check for downloaded file (this site downloads rather than opens in-tab) + pdf_path = None + for i in range(15): + time.sleep(1) + current_files = set(glob.glob(os.path.join(self.download_dir, "*"))) + new_files = current_files - existing_files + completed = [f for f in new_files + if not f.endswith(".crdownload") and not f.endswith(".tmp")] + if completed: + pdf_path = completed[0] + print(f"[CCA step2] PDF downloaded: {pdf_path}") + break + + if pdf_path and os.path.exists(pdf_path): + with open(pdf_path, "rb") as f: + pdfBase64 = base64.b64encode(f.read()).decode() + print(f"[CCA step2] PDF from download: {os.path.basename(pdf_path)} " + f"({os.path.getsize(pdf_path)} bytes), b64 len={len(pdfBase64)}") + try: + os.remove(pdf_path) + except Exception: + pass + else: + # Fallback: check for new window + new_handles = set(self.driver.window_handles) - original_handles + if new_handles: + new_window = new_handles.pop() + self.driver.switch_to.window(new_window) + time.sleep(3) + print(f"[CCA step2] Switched to new window: {self.driver.current_url}") + + try: + cdp_result = self.driver.execute_cdp_cmd("Page.printToPDF", { + "printBackground": True, + "preferCSSPageSize": True, + "scale": 0.8, + "paperWidth": 8.5, + "paperHeight": 11, + }) + pdf_data = cdp_result.get("data", "") + if len(pdf_data) > 2000: + pdfBase64 = pdf_data + print(f"[CCA step2] PDF from new window, b64 len={len(pdfBase64)}") + except Exception as e: + print(f"[CCA step2] CDP in new window failed: {e}") + + try: + self.driver.close() + self.driver.switch_to.window(original_window) + except Exception: + pass + + # Final fallback: CDP on main page + if not pdfBase64 or len(pdfBase64) < 2000: + print("[CCA step2] Falling back to CDP PDF from main page...") + try: + try: + self.driver.switch_to.window(original_window) + except Exception: + pass + cdp_result = self.driver.execute_cdp_cmd("Page.printToPDF", { + "printBackground": True, + "preferCSSPageSize": True, + "scale": 0.7, + "paperWidth": 11, + "paperHeight": 17, + }) + pdfBase64 = cdp_result.get("data", "") + print(f"[CCA step2] Main page CDP PDF, b64 len={len(pdfBase64)}") + except Exception as e2: + print(f"[CCA step2] Main page CDP failed: {e2}") + + except Exception as e: + print(f"[CCA step2] PDF capture failed: {e}") + try: + cdp_result = self.driver.execute_cdp_cmd("Page.printToPDF", { + "printBackground": True, + "preferCSSPageSize": True, + "scale": 0.7, + "paperWidth": 11, + "paperHeight": 17, + }) + pdfBase64 = cdp_result.get("data", "") + print(f"[CCA step2] CDP fallback PDF, b64 len={len(pdfBase64)}") + except Exception as e2: + print(f"[CCA step2] CDP fallback also failed: {e2}") + + self._close_browser() + + result = { + "status": "success", + "patientName": patientName, + "eligibility": eligibility, + "pdfBase64": pdfBase64, + "extractedDob": extractedDob, + "memberId": foundMemberId, + "address": address, + "city": city, + "zipCode": zipCode, + "insurerName": insurerName, + } + + print(f"[CCA step2] Result: name={result['patientName']}, " + f"eligibility={result['eligibility']}, " + f"memberId={result['memberId']}, " + f"address={result['address']}, " + f"city={result['city']}, zip={result['zipCode']}, " + f"insurer={result['insurerName']}") + + return result + + except Exception as e: + print(f"[CCA step2] Exception: {e}") + self._close_browser() + return { + "status": "error", + "patientName": f"{self.firstName} {self.lastName}".strip(), + "eligibility": "Unknown", + "pdfBase64": "", + "extractedDob": self._format_dob(self.dateOfBirth), + "memberId": self.memberId, + "address": "", + "city": "", + "zipCode": "", + "insurerName": "", + "error": str(e), + } diff --git a/packages/db/prisma.config.ts b/packages/db/prisma.config.ts new file mode 100644 index 0000000..e69de29 diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index c32303a..f25741f 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -18,10 +18,16 @@ datasource db { provider = "postgresql" } +enum UserRole { + ADMIN + USER +} + model User { id Int @id @default(autoincrement()) username String @unique password String + role UserRole @default(USER) patients Patient[] appointments Appointment[] staff Staff[]