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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -38,6 +38,7 @@ dist/
|
|||||||
# env
|
# env
|
||||||
*.env
|
*.env
|
||||||
*chrome_profile_ddma*
|
*chrome_profile_ddma*
|
||||||
|
*chrome_profile_cca*
|
||||||
*chrome_profile_dentaquest*
|
*chrome_profile_dentaquest*
|
||||||
*chrome_profile_unitedsco*
|
*chrome_profile_unitedsco*
|
||||||
*chrome_profile_deltains*
|
*chrome_profile_deltains*
|
||||||
|
|||||||
@@ -8,22 +8,19 @@ import { z } from "zod";
|
|||||||
type SelectUser = z.infer<typeof UserUncheckedCreateInputObjectSchema>;
|
type SelectUser = z.infer<typeof UserUncheckedCreateInputObjectSchema>;
|
||||||
|
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || "your-jwt-secret";
|
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) {
|
async function hashPassword(password: string) {
|
||||||
const saltRounds = 10; // Salt rounds for bcrypt
|
const saltRounds = 10;
|
||||||
const hashedPassword = await bcrypt.hash(password, saltRounds);
|
const hashedPassword = await bcrypt.hash(password, saltRounds);
|
||||||
return hashedPassword;
|
return hashedPassword;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to compare passwords using bcrypt
|
|
||||||
async function comparePasswords(supplied: string, stored: string) {
|
async function comparePasswords(supplied: string, stored: string) {
|
||||||
const isMatch = await bcrypt.compare(supplied, stored);
|
const isMatch = await bcrypt.compare(supplied, stored);
|
||||||
return isMatch;
|
return isMatch;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to generate JWT
|
|
||||||
function generateToken(user: SelectUser) {
|
function generateToken(user: SelectUser) {
|
||||||
return jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, {
|
return jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, {
|
||||||
expiresIn: JWT_EXPIRATION,
|
expiresIn: JWT_EXPIRATION,
|
||||||
@@ -32,35 +29,13 @@ function generateToken(user: SelectUser) {
|
|||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// User registration route
|
|
||||||
router.post(
|
router.post(
|
||||||
"/register",
|
"/register",
|
||||||
async (req: Request, res: Response, next: NextFunction): Promise<any> => {
|
async (req: Request, res: Response, next: NextFunction): Promise<any> => {
|
||||||
try {
|
return res.status(403).json({ error: "Public registration is disabled. Please contact your administrator." });
|
||||||
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" });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// User login route
|
|
||||||
router.post(
|
router.post(
|
||||||
"/login",
|
"/login",
|
||||||
async (req: Request, res: Response, next: NextFunction): Promise<any> => {
|
async (req: Request, res: Response, next: NextFunction): Promise<any> => {
|
||||||
@@ -77,12 +52,12 @@ router.post(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!isPasswordMatch) {
|
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 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 });
|
return res.status(200).json({ user: safeUser, token });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return res.status(500).json({ error: "Internal server 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) => {
|
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");
|
res.status(200).send("Logged out successfully");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import insuranceStatusDdmaRoutes from "./insuranceStatusDDMA";
|
|||||||
import insuranceStatusDentaQuestRoutes from "./insuranceStatusDentaQuest";
|
import insuranceStatusDentaQuestRoutes from "./insuranceStatusDentaQuest";
|
||||||
import insuranceStatusUnitedSCORoutes from "./insuranceStatusUnitedSCO";
|
import insuranceStatusUnitedSCORoutes from "./insuranceStatusUnitedSCO";
|
||||||
import insuranceStatusDeltaInsRoutes from "./insuranceStatusDeltaIns";
|
import insuranceStatusDeltaInsRoutes from "./insuranceStatusDeltaIns";
|
||||||
|
import insuranceStatusCCARoutes from "./insuranceStatusCCA";
|
||||||
import paymentsRoutes from "./payments";
|
import paymentsRoutes from "./payments";
|
||||||
import databaseManagementRoutes from "./database-management";
|
import databaseManagementRoutes from "./database-management";
|
||||||
import notificationsRoutes from "./notifications";
|
import notificationsRoutes from "./notifications";
|
||||||
@@ -35,6 +36,7 @@ router.use("/insurance-status-ddma", insuranceStatusDdmaRoutes);
|
|||||||
router.use("/insurance-status-dentaquest", insuranceStatusDentaQuestRoutes);
|
router.use("/insurance-status-dentaquest", insuranceStatusDentaQuestRoutes);
|
||||||
router.use("/insurance-status-unitedsco", insuranceStatusUnitedSCORoutes);
|
router.use("/insurance-status-unitedsco", insuranceStatusUnitedSCORoutes);
|
||||||
router.use("/insurance-status-deltains", insuranceStatusDeltaInsRoutes);
|
router.use("/insurance-status-deltains", insuranceStatusDeltaInsRoutes);
|
||||||
|
router.use("/insurance-status-cca", insuranceStatusCCARoutes);
|
||||||
router.use("/payments", paymentsRoutes);
|
router.use("/payments", paymentsRoutes);
|
||||||
router.use("/database-management", databaseManagementRoutes);
|
router.use("/database-management", databaseManagementRoutes);
|
||||||
router.use("/notifications", notificationsRoutes);
|
router.use("/notifications", notificationsRoutes);
|
||||||
|
|||||||
@@ -69,15 +69,15 @@ async function createOrUpdatePatientByInsuranceId(options: {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
// inside createOrUpdatePatientByInsuranceId, when creating:
|
|
||||||
const createPayload: any = {
|
const createPayload: any = {
|
||||||
firstName: incomingFirst,
|
firstName: incomingFirst,
|
||||||
lastName: incomingLast,
|
lastName: incomingLast,
|
||||||
dateOfBirth: dob, // raw from caller (string | Date | null)
|
dateOfBirth: dob,
|
||||||
gender: "",
|
gender: "",
|
||||||
phone: "",
|
phone: "",
|
||||||
userId,
|
userId,
|
||||||
insuranceId,
|
insuranceId,
|
||||||
|
insuranceProvider: "MassHealth",
|
||||||
};
|
};
|
||||||
|
|
||||||
let patientData: InsertPatient;
|
let patientData: InsertPatient;
|
||||||
@@ -219,8 +219,8 @@ router.post(
|
|||||||
if (patient && patient.id !== undefined) {
|
if (patient && patient.id !== undefined) {
|
||||||
const newStatus =
|
const newStatus =
|
||||||
seleniumResult.eligibility === "Y" ? "ACTIVE" : "INACTIVE";
|
seleniumResult.eligibility === "Y" ? "ACTIVE" : "INACTIVE";
|
||||||
await storage.updatePatient(patient.id, { status: newStatus });
|
await storage.updatePatient(patient.id, { status: newStatus, insuranceProvider: "MassHealth" });
|
||||||
outputResult.patientUpdateStatus = `Patient status updated to ${newStatus}`;
|
outputResult.patientUpdateStatus = `Patient status updated to ${newStatus}, insuranceProvider=MassHealth`;
|
||||||
|
|
||||||
// ✅ Step 5: Handle PDF Upload
|
// ✅ Step 5: Handle PDF Upload
|
||||||
if (
|
if (
|
||||||
@@ -649,8 +649,8 @@ router.post(
|
|||||||
seleniumResult?.eligibility === "Y" ? "ACTIVE" : "INACTIVE";
|
seleniumResult?.eligibility === "Y" ? "ACTIVE" : "INACTIVE";
|
||||||
|
|
||||||
// 1. updating patient
|
// 1. updating patient
|
||||||
await storage.updatePatient(updatedPatient.id, { status: newStatus });
|
await storage.updatePatient(updatedPatient.id, { status: newStatus, insuranceProvider: "MassHealth" });
|
||||||
resultItem.patientUpdateStatus = `Patient status updated to ${newStatus}`;
|
resultItem.patientUpdateStatus = `Patient status updated to ${newStatus}, insuranceProvider=MassHealth`;
|
||||||
|
|
||||||
// 2. updating appointment status - for aptmnt page
|
// 2. updating appointment status - for aptmnt page
|
||||||
try {
|
try {
|
||||||
|
|||||||
752
apps/Backend/src/routes/insuranceStatusCCA.ts
Normal file
752
apps/Backend/src/routes/insuranceStatusCCA.ts
Normal 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;
|
||||||
@@ -109,6 +109,7 @@ async function createOrUpdatePatientByInsuranceId(options: {
|
|||||||
phone: "",
|
phone: "",
|
||||||
userId,
|
userId,
|
||||||
insuranceId,
|
insuranceId,
|
||||||
|
insuranceProvider: "Delta MA",
|
||||||
};
|
};
|
||||||
let patientData: InsertPatient;
|
let patientData: InsertPatient;
|
||||||
try {
|
try {
|
||||||
@@ -273,8 +274,8 @@ async function handleDdmaCompletedJob(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update patient status from Delta MA eligibility result
|
// Update patient status from Delta MA eligibility result
|
||||||
await storage.updatePatient(patient.id, { status: eligibilityStatus });
|
await storage.updatePatient(patient.id, { status: eligibilityStatus, insuranceProvider: "Delta MA" });
|
||||||
outputResult.patientUpdateStatus = `Patient ${patient.id} status set to ${eligibilityStatus} (Delta MA eligibility: ${seleniumResult.eligibility})`;
|
outputResult.patientUpdateStatus = `Patient ${patient.id} status set to ${eligibilityStatus}, insuranceProvider=Delta MA (Delta MA eligibility: ${seleniumResult.eligibility})`;
|
||||||
console.log(`[ddma-eligibility] ${outputResult.patientUpdateStatus}`);
|
console.log(`[ddma-eligibility] ${outputResult.patientUpdateStatus}`);
|
||||||
|
|
||||||
// Handle PDF or convert screenshot -> pdf if available
|
// Handle PDF or convert screenshot -> pdf if available
|
||||||
|
|||||||
@@ -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() === "")) {
|
if (firstName && (!patient.firstName || patient.firstName.trim() === "")) {
|
||||||
updatePayload.firstName = firstName;
|
updatePayload.firstName = firstName;
|
||||||
}
|
}
|
||||||
@@ -255,7 +255,7 @@ async function handleDeltaInsCompletedJob(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await storage.updatePatient(patient.id, updatePayload);
|
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}`);
|
console.log(`[deltains-eligibility] ${outputResult.patientUpdateStatus}`);
|
||||||
|
|
||||||
// Handle PDF
|
// Handle PDF
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ async function createOrUpdatePatientByInsuranceId(options: {
|
|||||||
phone: "",
|
phone: "",
|
||||||
userId,
|
userId,
|
||||||
insuranceId,
|
insuranceId,
|
||||||
|
insuranceProvider: "Tufts / DentaQuest",
|
||||||
};
|
};
|
||||||
let patientData: InsertPatient;
|
let patientData: InsertPatient;
|
||||||
try {
|
try {
|
||||||
@@ -216,7 +217,7 @@ async function handleDentaQuestCompletedJob(
|
|||||||
phone: "",
|
phone: "",
|
||||||
userId: job.userId,
|
userId: job.userId,
|
||||||
insuranceId: insuranceId || null,
|
insuranceId: insuranceId || null,
|
||||||
insuranceProvider: "DentaQuest", // Set insurance provider
|
insuranceProvider: "Tufts / DentaQuest",
|
||||||
status: eligibilityStatus, // Set status from eligibility check
|
status: eligibilityStatus, // Set status from eligibility check
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -255,8 +256,8 @@ async function handleDentaQuestCompletedJob(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update patient status from DentaQuest eligibility result
|
// Update patient status from DentaQuest eligibility result
|
||||||
await storage.updatePatient(patient.id, { status: eligibilityStatus });
|
await storage.updatePatient(patient.id, { status: eligibilityStatus, insuranceProvider: "Tufts / DentaQuest" });
|
||||||
outputResult.patientUpdateStatus = `Patient ${patient.id} status set to ${eligibilityStatus} (DentaQuest eligibility: ${seleniumResult.eligibility})`;
|
outputResult.patientUpdateStatus = `Patient ${patient.id} status set to ${eligibilityStatus}, insuranceProvider=Tufts / DentaQuest (DentaQuest eligibility: ${seleniumResult.eligibility})`;
|
||||||
console.log(`[dentaquest-eligibility] ${outputResult.patientUpdateStatus}`);
|
console.log(`[dentaquest-eligibility] ${outputResult.patientUpdateStatus}`);
|
||||||
|
|
||||||
// Handle PDF or convert screenshot -> pdf if available
|
// Handle PDF or convert screenshot -> pdf if available
|
||||||
|
|||||||
@@ -260,9 +260,8 @@ async function handleUnitedSCOCompletedJob(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update patient status and name from United SCO eligibility result
|
// 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() === "")) {
|
if (firstName && (!patient.firstName || patient.firstName.trim() === "")) {
|
||||||
updatePayload.firstName = firstName;
|
updatePayload.firstName = firstName;
|
||||||
}
|
}
|
||||||
@@ -271,7 +270,7 @@ async function handleUnitedSCOCompletedJob(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await storage.updatePatient(patient.id, updatePayload);
|
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}`);
|
console.log(`[unitedsco-eligibility] ${outputResult.patientUpdateStatus}`);
|
||||||
|
|
||||||
// Handle PDF or convert screenshot -> pdf if available
|
// Handle PDF or convert screenshot -> pdf if available
|
||||||
|
|||||||
@@ -3,16 +3,13 @@ import type { Request, Response } from "express";
|
|||||||
import { storage } from "../storage";
|
import { storage } from "../storage";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { UserUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas";
|
import { UserUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas";
|
||||||
import jwt from 'jsonwebtoken';
|
import bcrypt from "bcrypt";
|
||||||
import bcrypt from 'bcrypt';
|
|
||||||
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// Type based on shared schema
|
|
||||||
type SelectUser = z.infer<typeof UserUncheckedCreateInputObjectSchema>;
|
type SelectUser = z.infer<typeof UserUncheckedCreateInputObjectSchema>;
|
||||||
|
|
||||||
// Zod validation
|
|
||||||
const userCreateSchema = UserUncheckedCreateInputObjectSchema;
|
const userCreateSchema = UserUncheckedCreateInputObjectSchema;
|
||||||
const userUpdateSchema = (UserUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>).partial();
|
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);
|
const user = await storage.getUser(userId);
|
||||||
if (!user) return res.status(404).send("User not found");
|
if (!user) return res.status(404).send("User not found");
|
||||||
|
|
||||||
|
const { password, ...rest } = user;
|
||||||
const { password, ...safeUser } = user;
|
res.json({ ...rest, role: rest.role ?? "USER" });
|
||||||
res.json(safeUser);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
res.status(500).send("Failed to fetch user");
|
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> => {
|
router.get("/:id", async (req: Request, res: Response): Promise<any> => {
|
||||||
try {
|
try {
|
||||||
const idParam = req.params.id;
|
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);
|
const user = await storage.getUser(id);
|
||||||
if (!user) return res.status(404).send("User not found");
|
if (!user) return res.status(404).send("User not found");
|
||||||
|
|
||||||
const { password, ...safeUser } = user;
|
const { password, ...rest } = user;
|
||||||
res.json(safeUser);
|
res.json({ ...rest, role: rest.role ?? "USER" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
res.status(500).send("Failed to fetch user");
|
res.status(500).send("Failed to fetch user");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST: Create new user
|
|
||||||
router.post("/", async (req: Request, res: Response) => {
|
router.post("/", async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const input = userCreateSchema.parse(req.body);
|
const input = userCreateSchema.parse(req.body);
|
||||||
const newUser = await storage.createUser(input);
|
const existing = await storage.getUserByUsername(input.username);
|
||||||
const { password, ...safeUser } = newUser;
|
if (existing) {
|
||||||
res.status(201).json(safeUser);
|
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) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
res.status(400).json({ error: "Invalid user data", details: err });
|
res.status(400).json({ error: "Invalid user data", details: err });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Function to hash password using bcrypt
|
|
||||||
async function hashPassword(password: string) {
|
async function hashPassword(password: string) {
|
||||||
const saltRounds = 10; // Salt rounds for bcrypt
|
const saltRounds = 10;
|
||||||
const hashedPassword = await bcrypt.hash(password, saltRounds);
|
return bcrypt.hash(password, saltRounds);
|
||||||
return hashedPassword;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PUT: Update user
|
|
||||||
router.put("/:id", async (req: Request, res: Response):Promise<any> => {
|
router.put("/:id", async (req: Request, res: Response):Promise<any> => {
|
||||||
try {
|
try {
|
||||||
const idParam = req.params.id;
|
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);
|
const updates = userUpdateSchema.parse(req.body);
|
||||||
|
|
||||||
// If password is provided and non-empty, hash it
|
|
||||||
if (updates.password && updates.password.trim() !== "") {
|
if (updates.password && updates.password.trim() !== "") {
|
||||||
updates.password = await hashPassword(updates.password);
|
updates.password = await hashPassword(updates.password);
|
||||||
} else {
|
} else {
|
||||||
// Remove password field if empty, so it won't overwrite existing password with blank
|
|
||||||
delete updates.password;
|
delete updates.password;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedUser = await storage.updateUser(id, updates);
|
const updatedUser = await storage.updateUser(id, updates);
|
||||||
if (!updatedUser) return res.status(404).send("User not found");
|
if (!updatedUser) return res.status(404).send("User not found");
|
||||||
|
|
||||||
const { password, ...safeUser } = updatedUser;
|
const { password, ...rest } = updatedUser;
|
||||||
res.json(safeUser);
|
res.json({ ...rest, role: rest.role ?? "USER" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
res.status(400).json({ error: "Invalid update data", details: 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 {
|
try {
|
||||||
const idParam = req.params.id;
|
const idParam = req.params.id;
|
||||||
if (!idParam) return res.status(400).send("User ID is required");
|
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);
|
const id = parseInt(idParam);
|
||||||
if (isNaN(id)) return res.status(400).send("Invalid user ID");
|
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);
|
const success = await storage.deleteUser(id);
|
||||||
if (!success) return res.status(404).send("User not found");
|
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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -41,7 +41,7 @@ function Router() {
|
|||||||
component={() => <AppointmentsPage />}
|
component={() => <AppointmentsPage />}
|
||||||
/>
|
/>
|
||||||
<ProtectedRoute path="/patients" component={() => <PatientsPage />} />
|
<ProtectedRoute path="/patients" component={() => <PatientsPage />} />
|
||||||
<ProtectedRoute path="/settings" component={() => <SettingsPage />} />
|
<ProtectedRoute path="/settings" component={() => <SettingsPage />} adminOnly />
|
||||||
<ProtectedRoute path="/claims" component={() => <ClaimsPage />} />
|
<ProtectedRoute path="/claims" component={() => <ClaimsPage />} />
|
||||||
<ProtectedRoute
|
<ProtectedRoute
|
||||||
path="/insurance-status"
|
path="/insurance-status"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,10 +16,16 @@ import {
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useSidebar } from "@/components/ui/sidebar";
|
import { useSidebar } from "@/components/ui/sidebar";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const [location] = useLocation();
|
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(
|
const navItems = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -82,6 +88,7 @@ export function Sidebar() {
|
|||||||
name: "Settings",
|
name: "Settings",
|
||||||
path: "/settings",
|
path: "/settings",
|
||||||
icon: <Settings className="h-5 w-5" />,
|
icon: <Settings className="h-5 w-5" />,
|
||||||
|
adminOnly: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[]
|
[]
|
||||||
@@ -90,43 +97,39 @@ export function Sidebar() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
// original look
|
|
||||||
"bg-white border-r border-gray-200 shadow-sm z-20",
|
"bg-white border-r border-gray-200 shadow-sm z-20",
|
||||||
// clip during width animation to avoid text peeking
|
|
||||||
"overflow-hidden will-change-[width]",
|
"overflow-hidden will-change-[width]",
|
||||||
// animate width only
|
|
||||||
"transition-[width] duration-200 ease-in-out",
|
"transition-[width] duration-200 ease-in-out",
|
||||||
// MOBILE: overlay below topbar (h = 100vh - 4rem)
|
|
||||||
openMobile
|
openMobile
|
||||||
? "fixed top-16 left-0 h-[calc(100vh-4rem)] w-64 block md:hidden"
|
? "fixed top-16 left-0 h-[calc(100vh-4rem)] w-64 block md:hidden"
|
||||||
: "hidden md:block",
|
: "hidden md:block",
|
||||||
// DESKTOP: participates in row layout
|
|
||||||
"md:static md:top-auto md:h-auto md:flex-shrink-0",
|
"md:static md:top-auto md:h-auto md:flex-shrink-0",
|
||||||
state === "collapsed" ? "md:w-0 overflow-hidden" : "md:w-64"
|
state === "collapsed" ? "md:w-0 overflow-hidden" : "md:w-64"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
<nav role="navigation" aria-label="Main">
|
<nav role="navigation" aria-label="Main">
|
||||||
{navItems.map((item) => (
|
{navItems
|
||||||
<div key={item.path}>
|
.filter((item) => !item.adminOnly || isAdmin)
|
||||||
<Link to={item.path} onClick={() => setOpenMobile(false)}>
|
.map((item) => (
|
||||||
<div
|
<div key={item.path}>
|
||||||
className={cn(
|
<Link to={item.path} onClick={() => setOpenMobile(false)}>
|
||||||
"flex items-center space-x-3 p-2 rounded-md pl-3 mb-1 transition-colors cursor-pointer",
|
<div
|
||||||
location === item.path
|
className={cn(
|
||||||
? "text-primary font-medium border-l-2 border-primary"
|
"flex items-center space-x-3 p-2 rounded-md pl-3 mb-1 transition-colors cursor-pointer",
|
||||||
: "text-gray-600 hover:bg-gray-100"
|
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.icon}
|
||||||
{item.name}
|
<span className="whitespace-nowrap select-none">
|
||||||
</span>
|
{item.name}
|
||||||
</div>
|
</span>
|
||||||
</Link>
|
</div>
|
||||||
</div>
|
</Link>
|
||||||
))}
|
</div>
|
||||||
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const SITE_KEY_OPTIONS = [
|
|||||||
{ value: "DELTAINS", label: "Delta Dental Ins" },
|
{ value: "DELTAINS", label: "Delta Dental Ins" },
|
||||||
{ value: "DENTAQUEST", label: "Tufts SCO / DentaQuest" },
|
{ value: "DENTAQUEST", label: "Tufts SCO / DentaQuest" },
|
||||||
{ value: "UNITEDSCO", label: "United SCO" },
|
{ value: "UNITEDSCO", label: "United SCO" },
|
||||||
|
{ value: "CCA", label: "CCA" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function CredentialForm({ onClose, userId, defaultValues }: CredentialFormProps) {
|
export function CredentialForm({ onClose, userId, defaultValues }: CredentialFormProps) {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const SITE_KEY_LABELS: Record<string, string> = {
|
|||||||
DELTAINS: "Delta Dental Ins",
|
DELTAINS: "Delta Dental Ins",
|
||||||
DENTAQUEST: "Tufts SCO / DentaQuest",
|
DENTAQUEST: "Tufts SCO / DentaQuest",
|
||||||
UNITEDSCO: "United SCO",
|
UNITEDSCO: "United SCO",
|
||||||
|
CCA: "CCA",
|
||||||
};
|
};
|
||||||
|
|
||||||
function getSiteKeyLabel(siteKey: string): string {
|
function getSiteKeyLabel(siteKey: string): string {
|
||||||
|
|||||||
@@ -4,28 +4,32 @@ import { useAuth } from "@/hooks/use-auth";
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { Redirect, Route } from "wouter";
|
import { Redirect, Route } from "wouter";
|
||||||
|
|
||||||
type ComponentLike = React.ComponentType; // works for both lazy() and regular components
|
type ComponentLike = React.ComponentType;
|
||||||
|
|
||||||
export function ProtectedRoute({
|
export function ProtectedRoute({
|
||||||
path,
|
path,
|
||||||
component: Component,
|
component: Component,
|
||||||
|
adminOnly,
|
||||||
}: {
|
}: {
|
||||||
path: string;
|
path: string;
|
||||||
component: ComponentLike;
|
component: ComponentLike;
|
||||||
|
adminOnly?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { user, isLoading } = useAuth();
|
const { user, isLoading } = useAuth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Route path={path}>
|
<Route path={path}>
|
||||||
{/* While auth is resolving: keep layout visible and show a small spinner in the content area */}
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<LoadingScreen />
|
<LoadingScreen />
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
) : !user ? (
|
) : !user ? (
|
||||||
<Redirect to="/auth" />
|
<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>
|
<AppLayout>
|
||||||
<Suspense fallback={<LoadingScreen />}>
|
<Suspense fallback={<LoadingScreen />}>
|
||||||
<Component />
|
<Component />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect } from "react";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { CheckCircle, Torus } from "lucide-react";
|
import { CheckCircle, Torus } from "lucide-react";
|
||||||
@@ -22,13 +21,10 @@ import { useLocation } from "wouter";
|
|||||||
import {
|
import {
|
||||||
LoginFormValues,
|
LoginFormValues,
|
||||||
loginSchema,
|
loginSchema,
|
||||||
RegisterFormValues,
|
|
||||||
registerSchema,
|
|
||||||
} from "@repo/db/types";
|
} from "@repo/db/types";
|
||||||
|
|
||||||
export default function AuthPage() {
|
export default function AuthPage() {
|
||||||
const [activeTab, setActiveTab] = useState<string>("login");
|
const { isLoading, user, loginMutation } = useAuth();
|
||||||
const { isLoading, user, loginMutation, registerMutation } = useAuth();
|
|
||||||
const [, navigate] = useLocation();
|
const [, navigate] = useLocation();
|
||||||
|
|
||||||
const loginForm = useForm<LoginFormValues>({
|
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) => {
|
const onLoginSubmit = (data: LoginFormValues) => {
|
||||||
loginMutation.mutate({ username: data.username, password: data.password });
|
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(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
navigate("/insurance-status");
|
navigate("/insurance-status");
|
||||||
}
|
}
|
||||||
}, [user, navigate]);
|
}, [user, navigate]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <LoadingScreen />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
|
<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">
|
<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
|
My Dental Office Management
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">
|
||||||
{" "}
|
|
||||||
Comprehensive Practice Management System
|
Comprehensive Practice Management System
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs
|
<Form {...loginForm}>
|
||||||
defaultValue="login"
|
<form
|
||||||
value={activeTab}
|
onSubmit={loginForm.handleSubmit(onLoginSubmit)}
|
||||||
onValueChange={setActiveTab}
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
<FormField
|
||||||
<TabsTrigger value="login">Login</TabsTrigger>
|
control={loginForm.control}
|
||||||
<TabsTrigger value="register">Register</TabsTrigger>
|
name="username"
|
||||||
</TabsList>
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Username</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Enter your username" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<TabsContent value="login">
|
<FormField
|
||||||
<Form {...loginForm}>
|
control={loginForm.control}
|
||||||
<form
|
name="password"
|
||||||
onSubmit={loginForm.handleSubmit(onLoginSubmit)}
|
render={({ field }) => (
|
||||||
className="space-y-4"
|
<FormItem>
|
||||||
>
|
<FormLabel>Password</FormLabel>
|
||||||
<FormField
|
<FormControl>
|
||||||
control={loginForm.control}
|
<Input
|
||||||
name="username"
|
placeholder="••••••••"
|
||||||
render={({ field }) => (
|
type="password"
|
||||||
<FormItem>
|
{...field}
|
||||||
<FormLabel>Username</FormLabel>
|
/>
|
||||||
<FormControl>
|
</FormControl>
|
||||||
<Input placeholder="Enter your username" {...field} />
|
<FormMessage />
|
||||||
</FormControl>
|
</FormItem>
|
||||||
<FormMessage />
|
)}
|
||||||
</FormItem>
|
/>
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
<div className="flex items-center justify-between">
|
||||||
control={loginForm.control}
|
<FormField
|
||||||
name="password"
|
control={loginForm.control}
|
||||||
render={({ field }) => (
|
name="rememberMe"
|
||||||
<FormItem>
|
render={({ field }) => (
|
||||||
<FormLabel>Password</FormLabel>
|
<div className="flex items-center space-x-2">
|
||||||
<FormControl>
|
<Checkbox
|
||||||
<Input
|
id="remember-me"
|
||||||
placeholder="••••••••"
|
checked={field.value as CheckedState}
|
||||||
type="password"
|
onCheckedChange={field.onChange}
|
||||||
{...field}
|
/>
|
||||||
/>
|
<label
|
||||||
</FormControl>
|
htmlFor="remember-me"
|
||||||
<FormMessage />
|
className="text-sm font-medium text-gray-700"
|
||||||
</FormItem>
|
>
|
||||||
)}
|
Remember me
|
||||||
/>
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<Button
|
||||||
<FormField
|
type="submit"
|
||||||
control={loginForm.control}
|
className="w-full"
|
||||||
name="rememberMe"
|
disabled={loginMutation.isPending}
|
||||||
render={({ field }) => (
|
>
|
||||||
<div className="flex items-center space-x-2">
|
{loginMutation.isPending ? "Signing in..." : "Sign in"}
|
||||||
<Checkbox
|
</Button>
|
||||||
id="remember-me"
|
</form>
|
||||||
checked={field.value as CheckedState}
|
</Form>
|
||||||
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>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { DdmaEligibilityButton } from "@/components/insurance-status/ddma-buton-
|
|||||||
import { DentaQuestEligibilityButton } from "@/components/insurance-status/dentaquest-button-modal";
|
import { DentaQuestEligibilityButton } from "@/components/insurance-status/dentaquest-button-modal";
|
||||||
import { UnitedSCOEligibilityButton } from "@/components/insurance-status/unitedsco-button-modal";
|
import { UnitedSCOEligibilityButton } from "@/components/insurance-status/unitedsco-button-modal";
|
||||||
import { DeltaInsEligibilityButton } from "@/components/insurance-status/deltains-button-modal";
|
import { DeltaInsEligibilityButton } from "@/components/insurance-status/deltains-button-modal";
|
||||||
|
import { CCAEligibilityButton } from "@/components/insurance-status/cca-button-modal";
|
||||||
|
|
||||||
export default function InsuranceStatusPage() {
|
export default function InsuranceStatusPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@@ -655,14 +656,20 @@ export default function InsuranceStatusPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<CCAEligibilityButton
|
||||||
className="w-full"
|
memberId={memberId}
|
||||||
variant="outline"
|
dateOfBirth={dateOfBirth}
|
||||||
disabled={isFormIncomplete}
|
firstName={firstName}
|
||||||
>
|
lastName={lastName}
|
||||||
<CheckCircle className="h-4 w-4 mr-2" />
|
isFormIncomplete={isFormIncomplete}
|
||||||
CCA
|
onPdfReady={(pdfId, fallbackFilename) => {
|
||||||
</Button>
|
setPreviewPdfId(pdfId);
|
||||||
|
setPreviewFallbackFilename(
|
||||||
|
fallbackFilename ?? `eligibility_cca_${memberId}.pdf`
|
||||||
|
);
|
||||||
|
setPreviewOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 3 */}
|
{/* Row 3 */}
|
||||||
|
|||||||
@@ -10,19 +10,18 @@ import { CredentialTable } from "@/components/settings/insuranceCredTable";
|
|||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { Staff } from "@repo/db/types";
|
import { Staff } from "@repo/db/types";
|
||||||
|
|
||||||
|
type SafeUser = { id: number; username: string; role: "ADMIN" | "USER" };
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
|
|
||||||
// Modal and editing staff state
|
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [credentialModalOpen, setCredentialModalOpen] = useState(false);
|
const [credentialModalOpen, setCredentialModalOpen] = useState(false);
|
||||||
const [editingStaff, setEditingStaff] = useState<Staff | null>(null);
|
const [editingStaff, setEditingStaff] = useState<Staff | null>(null);
|
||||||
|
|
||||||
const toggleMobileMenu = () => setIsMobileMenuOpen((prev) => !prev);
|
const toggleMobileMenu = () => setIsMobileMenuOpen((prev) => !prev);
|
||||||
|
|
||||||
// Fetch staff data
|
|
||||||
const {
|
const {
|
||||||
data: staff = [],
|
data: staff = [],
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -37,14 +36,13 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
staleTime: 1000 * 60 * 5, // 5 minutes cache
|
staleTime: 1000 * 60 * 5,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add Staff mutation
|
|
||||||
const addStaffMutate = useMutation<
|
const addStaffMutate = useMutation<
|
||||||
Staff, // Return type
|
Staff,
|
||||||
Error, // Error type
|
Error,
|
||||||
Omit<Staff, "id" | "createdAt"> // Variables
|
Omit<Staff, "id" | "createdAt">
|
||||||
>({
|
>({
|
||||||
mutationFn: async (newStaff: Omit<Staff, "id" | "createdAt">) => {
|
mutationFn: async (newStaff: Omit<Staff, "id" | "createdAt">) => {
|
||||||
const res = await apiRequest("POST", "/api/staffs/", newStaff);
|
const res = await apiRequest("POST", "/api/staffs/", newStaff);
|
||||||
@@ -71,7 +69,6 @@ export default function SettingsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update Staff mutation
|
|
||||||
const updateStaffMutate = useMutation<
|
const updateStaffMutate = useMutation<
|
||||||
Staff,
|
Staff,
|
||||||
Error,
|
Error,
|
||||||
@@ -108,7 +105,6 @@ export default function SettingsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete Staff mutation
|
|
||||||
const deleteStaffMutation = useMutation<number, Error, number>({
|
const deleteStaffMutation = useMutation<number, Error, number>({
|
||||||
mutationFn: async (id: number) => {
|
mutationFn: async (id: number) => {
|
||||||
const res = await apiRequest("DELETE", `/api/staffs/${id}`);
|
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 isAdding = addStaffMutate.status === "pending";
|
||||||
const isAddSuccess = addStaffMutate.status === "success";
|
const isAddSuccess = addStaffMutate.status === "success";
|
||||||
|
|
||||||
const isUpdating = updateStaffMutate.status === "pending";
|
const isUpdating = updateStaffMutate.status === "pending";
|
||||||
const isUpdateSuccess = updateStaffMutate.status === "success";
|
const isUpdateSuccess = updateStaffMutate.status === "success";
|
||||||
|
|
||||||
// Open Add modal
|
|
||||||
const openAddStaffModal = () => {
|
const openAddStaffModal = () => {
|
||||||
setEditingStaff(null);
|
setEditingStaff(null);
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Open Edit modal
|
|
||||||
const openEditStaffModal = (staff: Staff) => {
|
const openEditStaffModal = (staff: Staff) => {
|
||||||
setEditingStaff(staff);
|
setEditingStaff(staff);
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle form submit for Add or Edit
|
|
||||||
const handleFormSubmit = (formData: Omit<Staff, "id" | "createdAt">) => {
|
const handleFormSubmit = (formData: Omit<Staff, "id" | "createdAt">) => {
|
||||||
if (editingStaff) {
|
if (editingStaff) {
|
||||||
// Editing existing staff
|
|
||||||
if (editingStaff.id === undefined) {
|
if (editingStaff.id === undefined) {
|
||||||
toast({
|
toast({
|
||||||
title: "Error",
|
title: "Error",
|
||||||
@@ -181,7 +171,6 @@ export default function SettingsPage() {
|
|||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Close modal on successful add/update
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAddSuccess || isUpdateSuccess) {
|
if (isAddSuccess || isUpdateSuccess) {
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
@@ -215,10 +204,86 @@ export default function SettingsPage() {
|
|||||||
`Viewing staff member:\n${staff.name} (${staff.email || "No email"})`
|
`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("");
|
const [usernameUser, setUsernameUser] = useState("");
|
||||||
|
|
||||||
//fetch user
|
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user?.username) {
|
if (user?.username) {
|
||||||
@@ -226,7 +291,6 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
//update user mutation
|
|
||||||
const updateUserMutate = useMutation({
|
const updateUserMutate = useMutation({
|
||||||
mutationFn: async (
|
mutationFn: async (
|
||||||
updates: Partial<{ username: string; password: string }>
|
updates: Partial<{ username: string; password: string }>
|
||||||
@@ -303,10 +367,73 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</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">
|
<Card className="mt-6">
|
||||||
<CardContent className="space-y-4 py-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
|
<form
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
@@ -358,6 +485,96 @@ export default function SettingsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 */}
|
{/* Credential Section */}
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<CredentialTable />
|
<CredentialTable />
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ import helpers_ddma_eligibility as hddma
|
|||||||
import helpers_dentaquest_eligibility as hdentaquest
|
import helpers_dentaquest_eligibility as hdentaquest
|
||||||
import helpers_unitedsco_eligibility as hunitedsco
|
import helpers_unitedsco_eligibility as hunitedsco
|
||||||
import helpers_deltains_eligibility as hdeltains
|
import helpers_deltains_eligibility as hdeltains
|
||||||
|
import helpers_cca_eligibility as hcca
|
||||||
|
|
||||||
# Import session clear functions for startup
|
# Import session clear functions for startup
|
||||||
from ddma_browser_manager import clear_ddma_session_on_startup
|
from ddma_browser_manager import clear_ddma_session_on_startup
|
||||||
from dentaquest_browser_manager import clear_dentaquest_session_on_startup
|
from dentaquest_browser_manager import clear_dentaquest_session_on_startup
|
||||||
from unitedsco_browser_manager import clear_unitedsco_session_on_startup
|
from unitedsco_browser_manager import clear_unitedsco_session_on_startup
|
||||||
from deltains_browser_manager import clear_deltains_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
|
from dotenv import load_dotenv
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
@@ -31,6 +33,7 @@ clear_ddma_session_on_startup()
|
|||||||
clear_dentaquest_session_on_startup()
|
clear_dentaquest_session_on_startup()
|
||||||
clear_unitedsco_session_on_startup()
|
clear_unitedsco_session_on_startup()
|
||||||
clear_deltains_session_on_startup()
|
clear_deltains_session_on_startup()
|
||||||
|
clear_cca_session_on_startup()
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
print("SESSION CLEAR COMPLETE - FRESH LOGINS REQUIRED")
|
print("SESSION CLEAR COMPLETE - FRESH LOGINS REQUIRED")
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
@@ -425,6 +428,48 @@ async def deltains_session_status(sid: str):
|
|||||||
return s
|
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")
|
@app.post("/submit-otp")
|
||||||
async def submit_otp(request: Request):
|
async def submit_otp(request: Request):
|
||||||
"""
|
"""
|
||||||
@@ -511,6 +556,15 @@ async def clear_deltains_session():
|
|||||||
return {"status": "error", "message": str(e)}
|
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__":
|
if __name__ == "__main__":
|
||||||
host = os.getenv("HOST")
|
host = os.getenv("HOST")
|
||||||
port = int(os.getenv("PORT"))
|
port = int(os.getenv("PORT"))
|
||||||
|
|||||||
292
apps/SeleniumService/cca_browser_manager.py
Normal file
292
apps/SeleniumService/cca_browser_manager.py
Normal 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()
|
||||||
180
apps/SeleniumService/helpers_cca_eligibility.py
Normal file
180
apps/SeleniumService/helpers_cca_eligibility.py
Normal 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,
|
||||||
|
}
|
||||||
723
apps/SeleniumService/selenium_CCA_eligibilityCheckWorker.py
Normal file
723
apps/SeleniumService/selenium_CCA_eligibilityCheckWorker.py
Normal 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),
|
||||||
|
}
|
||||||
0
packages/db/prisma.config.ts
Normal file
0
packages/db/prisma.config.ts
Normal file
@@ -18,10 +18,16 @@ datasource db {
|
|||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum UserRole {
|
||||||
|
ADMIN
|
||||||
|
USER
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
username String @unique
|
username String @unique
|
||||||
password String
|
password String
|
||||||
|
role UserRole @default(USER)
|
||||||
patients Patient[]
|
patients Patient[]
|
||||||
appointments Appointment[]
|
appointments Appointment[]
|
||||||
staff Staff[]
|
staff Staff[]
|
||||||
|
|||||||
Reference in New Issue
Block a user