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

This commit is contained in:
2026-02-25 22:38:33 -05:00
parent 27e6e6a4a0
commit 4cb7ec7e2e
26 changed files with 2893 additions and 346 deletions

View File

@@ -8,22 +8,19 @@ import { z } from "zod";
type SelectUser = z.infer<typeof UserUncheckedCreateInputObjectSchema>;
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<any> => {
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<any> => {
@@ -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");
});

View File

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

View File

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

View File

@@ -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<string, CCAJobContext> = {};
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<Buffer> {
return new Promise<Buffer>((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<string, any> = {
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<any> => {
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;

View File

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

View File

@@ -246,7 +246,7 @@ async function handleDeltaInsCompletedJob(
};
}
const updatePayload: Record<string, any> = { status: eligibilityStatus };
const updatePayload: Record<string, any> = { 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

View File

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

View File

@@ -260,9 +260,8 @@ async function handleUnitedSCOCompletedJob(
}
// Update patient status and name from United SCO eligibility result
const updatePayload: Record<string, any> = { status: eligibilityStatus };
const updatePayload: Record<string, any> = { 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

View File

@@ -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<typeof UserUncheckedCreateInputObjectSchema>;
// Zod validation
const userCreateSchema = UserUncheckedCreateInputObjectSchema;
const userUpdateSchema = (UserUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>).partial();
@@ -25,16 +22,32 @@ router.get("/", async (req: Request, res: Response): Promise<any> => {
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<any> => {
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<any> => {
try {
const idParam = req.params.id;
@@ -46,35 +59,36 @@ router.get("/:id", async (req: Request, res: Response): Promise<any> => {
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<any> => {
try {
const idParam = req.params.id;
@@ -86,27 +100,24 @@ router.put("/:id", async (req: Request, res: Response):Promise<any> => {
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<any> => {
router.delete("/:id", async (req: Request, res: Response): Promise<any> => {
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<any> => {
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<any> => {
}
});
export default router;
export default router;

View File

@@ -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<any> {
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<any> {
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;
}

View File

@@ -41,7 +41,7 @@ function Router() {
component={() => <AppointmentsPage />}
/>
<ProtectedRoute path="/patients" component={() => <PatientsPage />} />
<ProtectedRoute path="/settings" component={() => <SettingsPage />} />
<ProtectedRoute path="/settings" component={() => <SettingsPage />} adminOnly />
<ProtectedRoute path="/claims" component={() => <ClaimsPage />} />
<ProtectedRoute
path="/insurance-status"

View File

@@ -0,0 +1,357 @@
import { useEffect, useRef, useState } from "react";
import { io as ioClient, Socket } from "socket.io-client";
import { Button } from "@/components/ui/button";
import { CheckCircle, LoaderCircleIcon } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useAppDispatch } from "@/redux/hooks";
import { setTaskStatus } from "@/redux/slices/seleniumEligibilityCheckTaskSlice";
import { formatLocalDate } from "@/utils/dateUtils";
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
const SOCKET_URL =
import.meta.env.VITE_API_BASE_URL_BACKEND ||
(typeof window !== "undefined" ? window.location.origin : "");
interface CCAEligibilityButtonProps {
memberId: string;
dateOfBirth: Date | null;
firstName?: string;
lastName?: string;
isFormIncomplete: boolean;
onPdfReady: (pdfId: number, fallbackFilename: string | null) => 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<Socket | null>(null);
const connectingRef = useRef<Promise<void> | 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<void>((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 (
<Button
className="w-full"
disabled={isCCAFormIncomplete || isStarting}
onClick={startCCAEligibility}
>
{isStarting ? (
<>
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin" />
Processing...
</>
) : (
<>
<CheckCircle className="h-4 w-4 mr-2" />
CCA
</>
)}
</Button>
);
}

View File

@@ -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: <Settings className="h-5 w-5" />,
adminOnly: true,
},
],
[]
@@ -90,43 +97,39 @@ export function Sidebar() {
return (
<div
className={cn(
// original look
"bg-white border-r border-gray-200 shadow-sm z-20",
// clip during width animation to avoid text peeking
"overflow-hidden will-change-[width]",
// animate width only
"transition-[width] duration-200 ease-in-out",
// MOBILE: overlay below topbar (h = 100vh - 4rem)
openMobile
? "fixed top-16 left-0 h-[calc(100vh-4rem)] w-64 block md:hidden"
: "hidden md:block",
// DESKTOP: participates in row layout
"md:static md:top-auto md:h-auto md:flex-shrink-0",
state === "collapsed" ? "md:w-0 overflow-hidden" : "md:w-64"
)}
>
<div className="p-2">
<nav role="navigation" aria-label="Main">
{navItems.map((item) => (
<div key={item.path}>
<Link to={item.path} onClick={() => setOpenMobile(false)}>
<div
className={cn(
"flex items-center space-x-3 p-2 rounded-md pl-3 mb-1 transition-colors cursor-pointer",
location === item.path
? "text-primary font-medium border-l-2 border-primary"
: "text-gray-600 hover:bg-gray-100"
)}
>
{item.icon}
{/* show label only after expand animation completes */}
<span className="whitespace-nowrap select-none">
{item.name}
</span>
</div>
</Link>
</div>
))}
{navItems
.filter((item) => !item.adminOnly || isAdmin)
.map((item) => (
<div key={item.path}>
<Link to={item.path} onClick={() => setOpenMobile(false)}>
<div
className={cn(
"flex items-center space-x-3 p-2 rounded-md pl-3 mb-1 transition-colors cursor-pointer",
location === item.path
? "text-primary font-medium border-l-2 border-primary"
: "text-gray-600 hover:bg-gray-100"
)}
>
{item.icon}
<span className="whitespace-nowrap select-none">
{item.name}
</span>
</div>
</Link>
</div>
))}
</nav>
</div>
</div>

View File

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

View File

@@ -20,6 +20,7 @@ const SITE_KEY_LABELS: Record<string, string> = {
DELTAINS: "Delta Dental Ins",
DENTAQUEST: "Tufts SCO / DentaQuest",
UNITEDSCO: "United SCO",
CCA: "CCA",
};
function getSiteKeyLabel(siteKey: string): string {

View File

@@ -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 (
<Route path={path}>
{/* While auth is resolving: keep layout visible and show a small spinner in the content area */}
{isLoading ? (
<AppLayout>
<LoadingScreen />
</AppLayout>
) : !user ? (
<Redirect to="/auth" />
) : adminOnly &&
user.role?.toUpperCase() !== "ADMIN" &&
user.username?.toLowerCase() !== "admin" ? (
<Redirect to="/dashboard" />
) : (
// Authenticated: render page inside layout. Lazy pages load with an in-layout spinner.
<AppLayout>
<Suspense fallback={<LoadingScreen />}>
<Component />

View File

@@ -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<string>("login");
const { isLoading, user, loginMutation, registerMutation } = useAuth();
const { isLoading, user, loginMutation } = useAuth();
const [, navigate] = useLocation();
const loginForm = useForm<LoginFormValues>({
@@ -40,37 +36,20 @@ export default function AuthPage() {
},
});
const registerForm = useForm<RegisterFormValues>({
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 <LoadingScreen />;
}
useEffect(() => {
if (user) {
navigate("/insurance-status");
}
}, [user, navigate]);
if (isLoading) {
return <LoadingScreen />;
}
return (
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
<div className="w-full max-w-4xl grid grid-cols-1 md:grid-cols-2 shadow-lg rounded-lg overflow-hidden">
@@ -81,198 +60,78 @@ export default function AuthPage() {
My Dental Office Management
</h1>
<p className="text-gray-600">
{" "}
Comprehensive Practice Management System
</p>
</div>
<Tabs
defaultValue="login"
value={activeTab}
onValueChange={setActiveTab}
>
<TabsList className="grid w-full grid-cols-2 mb-6">
<TabsTrigger value="login">Login</TabsTrigger>
<TabsTrigger value="register">Register</TabsTrigger>
</TabsList>
<Form {...loginForm}>
<form
onSubmit={loginForm.handleSubmit(onLoginSubmit)}
className="space-y-4"
>
<FormField
control={loginForm.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="Enter your username" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<TabsContent value="login">
<Form {...loginForm}>
<form
onSubmit={loginForm.handleSubmit(onLoginSubmit)}
className="space-y-4"
>
<FormField
control={loginForm.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="Enter your username" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={loginForm.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
placeholder="••••••••"
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={loginForm.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
placeholder="••••••••"
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex items-center justify-between">
<FormField
control={loginForm.control}
name="rememberMe"
render={({ field }) => (
<div className="flex items-center space-x-2">
<Checkbox
id="remember-me"
checked={field.value as CheckedState}
onCheckedChange={field.onChange}
/>
<label
htmlFor="remember-me"
className="text-sm font-medium text-gray-700"
>
Remember me
</label>
</div>
)}
/>
</div>
<div className="flex items-center justify-between">
<FormField
control={loginForm.control}
name="rememberMe"
render={({ field }) => (
<div className="flex items-center space-x-2">
<Checkbox
id="remember-me"
checked={field.value as CheckedState}
onCheckedChange={field.onChange}
/>
<label
htmlFor="remember-me"
className="text-sm font-medium text-gray-700"
>
Remember me
</label>
</div>
)}
/>
<button
type="button"
onClick={() => {
// do something if needed
}}
className="text-sm font-medium text-primary hover:text-primary/80"
>
Forgot password?
</button>
</div>
<Button
type="submit"
className="w-full"
disabled={loginMutation.isPending}
>
{loginMutation.isPending ? "Signing in..." : "Sign in"}
</Button>
</form>
</Form>
</TabsContent>
<TabsContent value="register">
<Form {...registerForm}>
<form
onSubmit={registerForm.handleSubmit(onRegisterSubmit)}
className="space-y-4"
>
<FormField
control={registerForm.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="Choose a username" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={registerForm.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
placeholder="••••••••"
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={registerForm.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<Input
placeholder="••••••••"
type="password"
{...field}
value={
typeof field.value === "string" ? field.value : ""
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={registerForm.control}
name="agreeTerms"
render={({ field }) => (
<FormItem className="flex space-x-2 items-center">
<FormControl>
<Checkbox
checked={field.value as CheckedState}
onCheckedChange={field.onChange}
className="mt-2.5"
/>
</FormControl>
<div className="">
<FormLabel className="text-sm font-bold leading-tight">
I agree to the{" "}
<a href="#" className="text-primary underline">
Terms and Conditions
</a>
</FormLabel>
<FormMessage />
</div>
</FormItem>
)}
/>
<Button
type="submit"
className="w-full"
disabled={registerMutation.isPending}
>
{registerMutation.isPending
? "Creating Account..."
: "Create Account"}
</Button>
</form>
</Form>
</TabsContent>
</Tabs>
<Button
type="submit"
className="w-full"
disabled={loginMutation.isPending}
>
{loginMutation.isPending ? "Signing in..." : "Sign in"}
</Button>
</form>
</Form>
</Card>
{/* Hero Section */}

View File

@@ -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() {
}}
/>
<Button
className="w-full"
variant="outline"
disabled={isFormIncomplete}
>
<CheckCircle className="h-4 w-4 mr-2" />
CCA
</Button>
<CCAEligibilityButton
memberId={memberId}
dateOfBirth={dateOfBirth}
firstName={firstName}
lastName={lastName}
isFormIncomplete={isFormIncomplete}
onPdfReady={(pdfId, fallbackFilename) => {
setPreviewPdfId(pdfId);
setPreviewFallbackFilename(
fallbackFilename ?? `eligibility_cca_${memberId}.pdf`
);
setPreviewOpen(true);
}}
/>
</div>
{/* Row 3 */}

View File

@@ -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<Staff | null>(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<Staff, "id" | "createdAt"> // Variables
Staff,
Error,
Omit<Staff, "id" | "createdAt">
>({
mutationFn: async (newStaff: Omit<Staff, "id" | "createdAt">) => {
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<number, Error, number>({
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<Staff, "id" | "createdAt">) => {
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<SafeUser[]>({
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<SafeUser, Error, { username: string; password: string; role?: "ADMIN" | "USER" }>({
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<SafeUser, Error, { id: number; password: string }>({
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<number, Error, number>({
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<SafeUser | null>(null);
const [userToDelete, setUserToDelete] = useState<SafeUser | null>(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() {
</div>
)}
{/* User Setting section */}
{/* Users control section */}
<Card className="mt-6">
<CardContent className="py-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold">User Accounts</h3>
<button
type="button"
onClick={() => setAddUserModalOpen(true)}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Add User
</button>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Username</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Role</th>
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{usersLoading && (
<tr><td colSpan={3} className="px-4 py-4 text-gray-500">Loading users...</td></tr>
)}
{usersError && (
<tr><td colSpan={3} className="px-4 py-4 text-red-600">{(usersErrorObj as Error)?.message}</td></tr>
)}
{!usersLoading && !usersError && usersList.filter((u) => u.id !== user?.id).length === 0 && (
<tr><td colSpan={3} className="px-4 py-4 text-gray-500">No other users.</td></tr>
)}
{!usersLoading && usersList.filter((u) => u.id !== user?.id).map((u) => (
<tr key={u.id}>
<td className="px-4 py-2">
<span>{u.username}</span>
</td>
<td className="px-4 py-2">{u.role}</td>
<td className="px-4 py-2 text-right space-x-2">
<button
type="button"
onClick={() => setEditPasswordUser(u)}
className="text-blue-600 hover:underline"
>
Edit password
</button>
<button
type="button"
onClick={() => setUserToDelete(u)}
className="text-red-600 hover:underline"
title="Delete user"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
{/* User Setting section (current user profile) */}
<Card className="mt-6">
<CardContent className="space-y-4 py-6">
<h3 className="text-lg font-semibold">User Settings</h3>
<h3 className="text-lg font-semibold">Admin Setting</h3>
<form
className="space-y-4"
onSubmit={(e) => {
@@ -358,6 +485,96 @@ export default function SettingsPage() {
</CardContent>
</Card>
{/* Add User modal */}
{addUserModalOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md shadow-lg">
<h2 className="text-lg font-bold mb-4">Add User</h2>
<form
onSubmit={(e) => {
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"
>
<div>
<label className="block text-sm font-medium">Username</label>
<input name="new-username" type="text" required className="mt-1 p-2 border rounded w-full" />
</div>
<div>
<label className="block text-sm font-medium">Password</label>
<input name="new-password" type="password" required className="mt-1 p-2 border rounded w-full" placeholder="••••••••" />
</div>
<div>
<label className="block text-sm font-medium">Role</label>
<select name="new-role" className="mt-1 p-2 border rounded w-full" defaultValue="USER">
<option value="USER">User</option>
<option value="ADMIN">Admin</option>
</select>
</div>
<div className="flex gap-2 justify-end">
<button type="button" onClick={() => setAddUserModalOpen(false)} className="px-4 py-2 border rounded hover:bg-gray-100">
Cancel
</button>
<button type="submit" disabled={addUserMutate.isPending} className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50">
{addUserMutate.isPending ? "Adding..." : "Add User"}
</button>
</div>
</form>
</div>
</div>
)}
{/* Edit password modal */}
{editPasswordUser && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md shadow-lg">
<h2 className="text-lg font-bold mb-4">Change password for {editPasswordUser.username}</h2>
<form
onSubmit={(e) => {
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"
>
<div>
<label className="block text-sm font-medium">New password</label>
<input name="edit-password" type="password" required className="mt-1 p-2 border rounded w-full" placeholder="••••••••" />
</div>
<div className="flex gap-2 justify-end">
<button type="button" onClick={() => setEditPasswordUser(null)} className="px-4 py-2 border rounded hover:bg-gray-100">
Cancel
</button>
<button type="submit" disabled={updateUserPasswordMutate.isPending} className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50">
{updateUserPasswordMutate.isPending ? "Saving..." : "Save"}
</button>
</div>
</form>
</div>
</div>
)}
<DeleteConfirmationDialog
isOpen={!!userToDelete}
onConfirm={() => userToDelete && deleteUserMutate.mutate(userToDelete.id)}
onCancel={() => setUserToDelete(null)}
entityName={userToDelete?.username}
/>
{/* Credential Section */}
<div className="mt-6">
<CredentialTable />

View File

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

View File

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

View File

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

View File

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