Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ebe3bc6f2 | |||
| 9975fe9252 | |||
| add03289c6 | |||
| 4594a264a1 | |||
| b8df9459ca | |||
| 1b66474913 | |||
| e2b67b26fe | |||
| c2167a65dd | |||
| eca21f398c | |||
| f1ea2d603a | |||
| 279a6b8dbc | |||
| aa609da33d | |||
| f88a5bf7ec | |||
| fd6d55be18 | |||
| c8d2c139c7 | |||
| e2daca574f | |||
| a0b3189430 | |||
| c53dfd544d | |||
| fce816f13f | |||
| a52fdb3b9f | |||
| 04eb7209ce | |||
| 656e98687f | |||
|
|
3907672185 | ||
|
|
0961c01660 | ||
|
|
ef18a0017c | ||
| 7a4ea21658 | |||
| b45383de33 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -37,3 +37,5 @@ dist/
|
||||
|
||||
# env
|
||||
*.env
|
||||
*chrome_profile_ddma*
|
||||
*chrome_profile_dentaquest*
|
||||
@@ -9,8 +9,8 @@ import { backupDatabaseToPath } from "../services/databaseBackupService";
|
||||
* Creates a backup notification if overdue
|
||||
*/
|
||||
export const startBackupCron = () => {
|
||||
cron.schedule("0 2 * * *", async () => {
|
||||
// Every calendar days, at 2 AM
|
||||
cron.schedule("0 22 * * *", async () => {
|
||||
// Every calendar days, at 10 PM
|
||||
// cron.schedule("*/10 * * * * *", async () => { // Every 10 seconds (for Test)
|
||||
|
||||
console.log("🔄 Running backup check...");
|
||||
|
||||
164
apps/Backend/src/routes/appointments-procedures.ts
Normal file
164
apps/Backend/src/routes/appointments-procedures.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { Router, Request, Response } from "express";
|
||||
import { storage } from "../storage";
|
||||
import {
|
||||
insertAppointmentProcedureSchema,
|
||||
updateAppointmentProcedureSchema,
|
||||
} from "@repo/db/types";
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/appointment-procedures/:appointmentId
|
||||
* Get all procedures for an appointment
|
||||
*/
|
||||
router.get("/:appointmentId", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const appointmentId = Number(req.params.appointmentId);
|
||||
if (isNaN(appointmentId)) {
|
||||
return res.status(400).json({ message: "Invalid appointmentId" });
|
||||
}
|
||||
|
||||
const rows = await storage.getByAppointmentId(appointmentId);
|
||||
|
||||
return res.json(rows);
|
||||
} catch (err: any) {
|
||||
console.error("GET appointment procedures error", err);
|
||||
return res.status(500).json({ message: err.message ?? "Server error" });
|
||||
}
|
||||
});
|
||||
|
||||
router.get(
|
||||
"/prefill-from-appointment/:appointmentId",
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const appointmentId = Number(req.params.appointmentId);
|
||||
|
||||
if (!appointmentId || isNaN(appointmentId)) {
|
||||
return res.status(400).json({ error: "Invalid appointmentId" });
|
||||
}
|
||||
|
||||
const data = await storage.getPrefillDataByAppointmentId(appointmentId);
|
||||
|
||||
if (!data) {
|
||||
return res.status(404).json({ error: "Appointment not found" });
|
||||
}
|
||||
|
||||
return res.json(data);
|
||||
} catch (err: any) {
|
||||
console.error("prefill-from-appointment error", err);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: err.message ?? "Failed to prefill claim data" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/appointment-procedures
|
||||
* Add single manual procedure
|
||||
*/
|
||||
router.post("/", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const parsed = insertAppointmentProcedureSchema.parse(req.body);
|
||||
|
||||
const created = await storage.createProcedure(parsed);
|
||||
|
||||
return res.json(created);
|
||||
} catch (err: any) {
|
||||
console.error("POST appointment procedure error", err);
|
||||
if (err.name === "ZodError") {
|
||||
return res.status(400).json({ message: err.errors });
|
||||
}
|
||||
return res.status(500).json({ message: err.message ?? "Server error" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/appointment-procedures/bulk
|
||||
* Add multiple procedures (combos)
|
||||
*/
|
||||
router.post("/bulk", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const rows = req.body;
|
||||
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
return res.status(400).json({ message: "Invalid payload" });
|
||||
}
|
||||
|
||||
const count = await storage.createProceduresBulk(rows);
|
||||
|
||||
return res.json({ success: true, count });
|
||||
} catch (err: any) {
|
||||
console.error("POST bulk appointment procedures error", err);
|
||||
return res.status(500).json({ message: err.message ?? "Server error" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/appointment-procedures/:id
|
||||
* Update a procedure
|
||||
*/
|
||||
router.put("/:id", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
if (isNaN(id)) {
|
||||
return res.status(400).json({ message: "Invalid id" });
|
||||
}
|
||||
|
||||
const parsed = updateAppointmentProcedureSchema.parse(req.body);
|
||||
|
||||
const updated = await storage.updateProcedure(id, parsed);
|
||||
|
||||
return res.json(updated);
|
||||
} catch (err: any) {
|
||||
console.error("PUT appointment procedure error", err);
|
||||
|
||||
if (err.name === "ZodError") {
|
||||
return res.status(400).json({ message: err.errors });
|
||||
}
|
||||
|
||||
return res.status(500).json({ message: err.message ?? "Server error" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/appointment-procedures/:id
|
||||
* Delete single procedure
|
||||
*/
|
||||
router.delete("/:id", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
if (isNaN(id)) {
|
||||
return res.status(400).json({ message: "Invalid id" });
|
||||
}
|
||||
|
||||
await storage.deleteProcedure(id);
|
||||
|
||||
return res.json({ success: true });
|
||||
} catch (err: any) {
|
||||
console.error("DELETE appointment procedure error", err);
|
||||
return res.status(500).json({ message: err.message ?? "Server error" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/appointment-procedures/clear/:appointmentId
|
||||
* Clear all procedures for appointment
|
||||
*/
|
||||
router.delete("/clear/:appointmentId", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const appointmentId = Number(req.params.appointmentId);
|
||||
if (isNaN(appointmentId)) {
|
||||
return res.status(400).json({ message: "Invalid appointmentId" });
|
||||
}
|
||||
|
||||
await storage.clearByAppointmentId(appointmentId);
|
||||
|
||||
return res.json({ success: true });
|
||||
} catch (err: any) {
|
||||
console.error("CLEAR appointment procedures error", err);
|
||||
return res.status(500).json({ message: err.message ?? "Server error" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -49,7 +49,7 @@ router.get("/day", async (req: Request, res: Response): Promise<any> => {
|
||||
|
||||
// dedupe patient ids referenced by those appointments
|
||||
const patientIds = Array.from(
|
||||
new Set(appointments.map((a) => a.patientId).filter(Boolean))
|
||||
new Set(appointments.map((a) => a.patientId).filter(Boolean)),
|
||||
);
|
||||
|
||||
const patients = patientIds.length
|
||||
@@ -76,6 +76,43 @@ router.get("/recent", async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/appointments/:id/procedure-notes
|
||||
*/
|
||||
router.get("/:id/procedure-notes", async (req: Request, res: Response) => {
|
||||
const appointmentId = Number(req.params.id);
|
||||
if (isNaN(appointmentId)) {
|
||||
return res.status(400).json({ message: "Invalid appointment ID" });
|
||||
}
|
||||
|
||||
const appointment = await storage.getAppointment(appointmentId);
|
||||
if (!appointment) {
|
||||
return res.status(404).json({ message: "Appointment not found" });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
procedureNotes: appointment.procedureCodeNotes ?? "",
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/appointments/:id/procedure-notes
|
||||
*/
|
||||
router.put("/:id/procedure-notes", async (req: Request, res: Response) => {
|
||||
const appointmentId = Number(req.params.id);
|
||||
if (isNaN(appointmentId)) {
|
||||
return res.status(400).json({ message: "Invalid appointment ID" });
|
||||
}
|
||||
|
||||
const { procedureNotes } = req.body as { procedureNotes?: string };
|
||||
|
||||
const updated = await storage.updateAppointment(appointmentId, {
|
||||
procedureCodeNotes: procedureNotes ?? null,
|
||||
});
|
||||
|
||||
return res.json(updated);
|
||||
});
|
||||
|
||||
// Get a single appointment by ID
|
||||
router.get(
|
||||
"/:id",
|
||||
@@ -101,7 +138,7 @@ router.get(
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: "Failed to retrieve appointment" });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Get all appointments for a specific patient
|
||||
@@ -128,7 +165,7 @@ router.get(
|
||||
} catch (err) {
|
||||
res.status(500).json({ message: "Failed to get patient appointments" });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -162,7 +199,7 @@ router.get(
|
||||
.status(500)
|
||||
.json({ message: "Failed to retrieve patient for appointment" });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Create a new appointment
|
||||
@@ -202,7 +239,7 @@ router.post(
|
||||
await storage.getPatientAppointmentByDateTime(
|
||||
appointmentData.patientId,
|
||||
appointmentData.date,
|
||||
currentStartTime
|
||||
currentStartTime,
|
||||
);
|
||||
|
||||
// Check staff conflict at this time
|
||||
@@ -210,7 +247,7 @@ router.post(
|
||||
appointmentData.staffId,
|
||||
appointmentData.date,
|
||||
currentStartTime,
|
||||
sameDayAppointment?.id // Ignore self if updating
|
||||
sameDayAppointment?.id, // Ignore self if updating
|
||||
);
|
||||
|
||||
if (!staffConflict) {
|
||||
@@ -231,7 +268,7 @@ router.post(
|
||||
if (sameDayAppointment?.id !== undefined) {
|
||||
const updated = await storage.updateAppointment(
|
||||
sameDayAppointment.id,
|
||||
payload
|
||||
payload,
|
||||
);
|
||||
responseData = {
|
||||
...updated,
|
||||
@@ -276,7 +313,7 @@ router.post(
|
||||
if (error instanceof z.ZodError) {
|
||||
console.log(
|
||||
"Validation error details:",
|
||||
JSON.stringify(error.format(), null, 2)
|
||||
JSON.stringify(error.format(), null, 2),
|
||||
);
|
||||
return res.status(400).json({
|
||||
message: "Validation error",
|
||||
@@ -289,7 +326,7 @@ router.post(
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Update an existing appointment
|
||||
@@ -343,7 +380,7 @@ router.put(
|
||||
existingAppointment.patientId,
|
||||
date,
|
||||
startTime,
|
||||
appointmentId
|
||||
appointmentId,
|
||||
);
|
||||
|
||||
if (patientConflict) {
|
||||
@@ -356,7 +393,7 @@ router.put(
|
||||
staffId,
|
||||
date,
|
||||
startTime,
|
||||
appointmentId
|
||||
appointmentId,
|
||||
);
|
||||
|
||||
if (staffConflict) {
|
||||
@@ -381,7 +418,7 @@ router.put(
|
||||
// Update appointment
|
||||
const updatedAppointment = await storage.updateAppointment(
|
||||
appointmentId,
|
||||
updatePayload
|
||||
updatePayload,
|
||||
);
|
||||
return res.json(updatedAppointment);
|
||||
} catch (error) {
|
||||
@@ -390,7 +427,7 @@ router.put(
|
||||
if (error instanceof z.ZodError) {
|
||||
console.log(
|
||||
"Validation error details:",
|
||||
JSON.stringify(error.format(), null, 2)
|
||||
JSON.stringify(error.format(), null, 2),
|
||||
);
|
||||
return res.status(400).json({
|
||||
message: "Validation error",
|
||||
@@ -403,7 +440,7 @@ router.put(
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Delete an appointment
|
||||
|
||||
@@ -336,6 +336,15 @@ router.post("/", async (req: Request, res: Response): Promise<any> => {
|
||||
}
|
||||
|
||||
// --- TRANSFORM serviceLines
|
||||
if (
|
||||
!Array.isArray(req.body.serviceLines) ||
|
||||
req.body.serviceLines.length === 0
|
||||
) {
|
||||
return res.status(400).json({
|
||||
message: "At least one service line is required to create a claim",
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(req.body.serviceLines)) {
|
||||
req.body.serviceLines = req.body.serviceLines.map(
|
||||
(line: InputServiceLine) => ({
|
||||
|
||||
@@ -385,7 +385,7 @@ router.put(
|
||||
|
||||
const updated = await storage.updatePdfFile(id, {
|
||||
filename: file?.originalname,
|
||||
pdfData: file?.buffer,
|
||||
pdfData: file?.buffer as any,
|
||||
});
|
||||
|
||||
if (!updated)
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { Router } from "express";
|
||||
import patientsRoutes from "./patients";
|
||||
import appointmentsRoutes from "./appointments";
|
||||
import appointmentProceduresRoutes from "./appointments-procedures";
|
||||
import usersRoutes from "./users";
|
||||
import staffsRoutes from "./staffs";
|
||||
import npiProvidersRoutes from "./npiProviders";
|
||||
import claimsRoutes from "./claims";
|
||||
import patientDataExtractionRoutes from "./patientDataExtraction";
|
||||
import insuranceCredsRoutes from "./insuranceCreds";
|
||||
import documentsRoutes from "./documents";
|
||||
import insuranceStatusRoutes from "./insuranceStatus";
|
||||
import insuranceStatusDdmaRoutes from "./insuranceStatusDDMA";
|
||||
import insuranceStatusDentaQuestRoutes from "./insuranceStatusDentaQuest";
|
||||
import paymentsRoutes from "./payments";
|
||||
import databaseManagementRoutes from "./database-management";
|
||||
import notificationsRoutes from "./notifications";
|
||||
@@ -21,14 +24,17 @@ const router = Router();
|
||||
|
||||
router.use("/patients", patientsRoutes);
|
||||
router.use("/appointments", appointmentsRoutes);
|
||||
router.use("/appointment-procedures", appointmentProceduresRoutes);
|
||||
router.use("/users", usersRoutes);
|
||||
router.use("/staffs", staffsRoutes);
|
||||
router.use("/npiProviders", npiProvidersRoutes);
|
||||
router.use("/patientDataExtraction", patientDataExtractionRoutes);
|
||||
router.use("/claims", claimsRoutes);
|
||||
router.use("/insuranceCreds", insuranceCredsRoutes);
|
||||
router.use("/documents", documentsRoutes);
|
||||
router.use("/insurance-status", insuranceStatusRoutes);
|
||||
router.use("/insurance-status-ddma", insuranceStatusDdmaRoutes);
|
||||
router.use("/insurance-status-dentaquest", insuranceStatusDentaQuestRoutes);
|
||||
router.use("/payments", paymentsRoutes);
|
||||
router.use("/database-management", databaseManagementRoutes);
|
||||
router.use("/notifications", notificationsRoutes);
|
||||
|
||||
700
apps/Backend/src/routes/insuranceStatusDentaQuest.ts
Normal file
700
apps/Backend/src/routes/insuranceStatusDentaQuest.ts
Normal file
@@ -0,0 +1,700 @@
|
||||
import { Router, Request, Response } from "express";
|
||||
import { storage } from "../storage";
|
||||
import {
|
||||
forwardToSeleniumDentaQuestEligibilityAgent,
|
||||
forwardOtpToSeleniumDentaQuestAgent,
|
||||
getSeleniumDentaQuestSessionStatus,
|
||||
} from "../services/seleniumDentaQuestInsuranceEligibilityClient";
|
||||
import fs from "fs/promises";
|
||||
import fsSync from "fs";
|
||||
import path from "path";
|
||||
import PDFDocument from "pdfkit";
|
||||
import { emptyFolderContainingFile } from "../utils/emptyTempFolder";
|
||||
import {
|
||||
InsertPatient,
|
||||
insertPatientSchema,
|
||||
} from "../../../../packages/db/types/patient-types";
|
||||
import { io } from "../socket";
|
||||
|
||||
const router = Router();
|
||||
|
||||
/** Job context stored in memory by sessionId */
|
||||
interface DentaQuestJobContext {
|
||||
userId: number;
|
||||
insuranceEligibilityData: any; // parsed, enriched (includes username/password)
|
||||
socketId?: string;
|
||||
}
|
||||
|
||||
const dentaquestJobs: Record<string, DentaQuestJobContext> = {};
|
||||
|
||||
/** Utility: naive name splitter */
|
||||
function splitName(fullName?: string | null) {
|
||||
if (!fullName) return { firstName: "", lastName: "" };
|
||||
const parts = fullName.trim().split(/\s+/).filter(Boolean);
|
||||
const firstName = parts.shift() ?? "";
|
||||
const lastName = parts.join(" ") ?? "";
|
||||
return { firstName, lastName };
|
||||
}
|
||||
|
||||
async function imageToPdfBuffer(imagePath: string): Promise<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; // points
|
||||
const A4_HEIGHT = 841.89; // points
|
||||
|
||||
doc.addPage({ size: [A4_WIDTH, A4_HEIGHT] });
|
||||
|
||||
doc.image(imagePath, 0, 0, {
|
||||
fit: [A4_WIDTH, A4_HEIGHT],
|
||||
align: "center",
|
||||
valign: "center",
|
||||
});
|
||||
|
||||
doc.end();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure patient exists for given insuranceId.
|
||||
*/
|
||||
async function createOrUpdatePatientByInsuranceId(options: {
|
||||
insuranceId: string;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
dob?: string | Date | null;
|
||||
userId: number;
|
||||
}) {
|
||||
const { insuranceId, firstName, lastName, dob, userId } = options;
|
||||
if (!insuranceId) throw new Error("Missing insuranceId");
|
||||
|
||||
const incomingFirst = (firstName || "").trim();
|
||||
const incomingLast = (lastName || "").trim();
|
||||
|
||||
let patient = await storage.getPatientByInsuranceId(insuranceId);
|
||||
|
||||
if (patient && patient.id) {
|
||||
const updates: any = {};
|
||||
if (
|
||||
incomingFirst &&
|
||||
String(patient.firstName ?? "").trim() !== incomingFirst
|
||||
) {
|
||||
updates.firstName = incomingFirst;
|
||||
}
|
||||
if (
|
||||
incomingLast &&
|
||||
String(patient.lastName ?? "").trim() !== incomingLast
|
||||
) {
|
||||
updates.lastName = incomingLast;
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await storage.updatePatient(patient.id, updates);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
const createPayload: any = {
|
||||
firstName: incomingFirst,
|
||||
lastName: incomingLast,
|
||||
dateOfBirth: dob,
|
||||
gender: "",
|
||||
phone: "",
|
||||
userId,
|
||||
insuranceId,
|
||||
};
|
||||
let patientData: InsertPatient;
|
||||
try {
|
||||
patientData = insertPatientSchema.parse(createPayload);
|
||||
} catch (err) {
|
||||
const safePayload = { ...createPayload };
|
||||
delete (safePayload as any).dateOfBirth;
|
||||
patientData = insertPatientSchema.parse(safePayload);
|
||||
}
|
||||
await storage.createPatient(patientData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When Selenium finishes for a given sessionId, run your patient + PDF pipeline,
|
||||
* and return the final API response shape.
|
||||
*/
|
||||
async function handleDentaQuestCompletedJob(
|
||||
sessionId: string,
|
||||
job: DentaQuestJobContext,
|
||||
seleniumResult: any
|
||||
) {
|
||||
let createdPdfFileId: number | null = null;
|
||||
const outputResult: any = {};
|
||||
|
||||
// We'll wrap the processing in try/catch/finally so cleanup always runs
|
||||
try {
|
||||
// 1) ensuring memberid.
|
||||
const insuranceEligibilityData = job.insuranceEligibilityData;
|
||||
const insuranceId = String(insuranceEligibilityData.memberId ?? "").trim();
|
||||
if (!insuranceId) {
|
||||
throw new Error("Missing memberId for DentaQuest job");
|
||||
}
|
||||
|
||||
// 2) Create or update patient (with name from selenium result if available)
|
||||
const patientNameFromResult =
|
||||
typeof seleniumResult?.patientName === "string"
|
||||
? seleniumResult.patientName.trim()
|
||||
: null;
|
||||
|
||||
const { firstName, lastName } = splitName(patientNameFromResult);
|
||||
|
||||
await createOrUpdatePatientByInsuranceId({
|
||||
insuranceId,
|
||||
firstName,
|
||||
lastName,
|
||||
dob: insuranceEligibilityData.dateOfBirth,
|
||||
userId: job.userId,
|
||||
});
|
||||
|
||||
// 3) Update patient status + PDF upload
|
||||
const patient = await storage.getPatientByInsuranceId(
|
||||
insuranceEligibilityData.memberId
|
||||
);
|
||||
if (!patient?.id) {
|
||||
outputResult.patientUpdateStatus =
|
||||
"Patient not found; no update performed";
|
||||
return {
|
||||
patientUpdateStatus: outputResult.patientUpdateStatus,
|
||||
pdfUploadStatus: "none",
|
||||
pdfFileId: null,
|
||||
};
|
||||
}
|
||||
|
||||
// update patient status.
|
||||
const newStatus =
|
||||
seleniumResult.eligibility === "active" ? "ACTIVE" : "INACTIVE";
|
||||
await storage.updatePatient(patient.id, { status: newStatus });
|
||||
outputResult.patientUpdateStatus = `Patient status updated to ${newStatus}`;
|
||||
|
||||
// convert screenshot -> pdf if available
|
||||
let pdfBuffer: Buffer | null = null;
|
||||
let generatedPdfPath: string | null = null;
|
||||
|
||||
if (
|
||||
seleniumResult &&
|
||||
seleniumResult.ss_path &&
|
||||
typeof seleniumResult.ss_path === "string" &&
|
||||
(seleniumResult.ss_path.endsWith(".png") ||
|
||||
seleniumResult.ss_path.endsWith(".jpg") ||
|
||||
seleniumResult.ss_path.endsWith(".jpeg"))
|
||||
) {
|
||||
try {
|
||||
if (!fsSync.existsSync(seleniumResult.ss_path)) {
|
||||
throw new Error(
|
||||
`Screenshot file not found: ${seleniumResult.ss_path}`
|
||||
);
|
||||
}
|
||||
|
||||
pdfBuffer = await imageToPdfBuffer(seleniumResult.ss_path);
|
||||
|
||||
const pdfFileName = `dentaquest_eligibility_${insuranceEligibilityData.memberId}_${Date.now()}.pdf`;
|
||||
generatedPdfPath = path.join(
|
||||
path.dirname(seleniumResult.ss_path),
|
||||
pdfFileName
|
||||
);
|
||||
await fs.writeFile(generatedPdfPath, pdfBuffer);
|
||||
|
||||
// ensure cleanup uses this
|
||||
seleniumResult.pdf_path = generatedPdfPath;
|
||||
} catch (err: any) {
|
||||
console.error("Failed to convert screenshot to PDF:", err);
|
||||
outputResult.pdfUploadStatus = `Failed to convert screenshot to PDF: ${String(err)}`;
|
||||
}
|
||||
} else {
|
||||
outputResult.pdfUploadStatus =
|
||||
"No valid screenshot (ss_path) provided by Selenium; nothing to upload.";
|
||||
}
|
||||
|
||||
if (pdfBuffer && generatedPdfPath) {
|
||||
const groupTitle = "Eligibility Status";
|
||||
const groupTitleKey = "ELIGIBILITY_STATUS";
|
||||
|
||||
let group = await storage.findPdfGroupByPatientTitleKey(
|
||||
patient.id,
|
||||
groupTitleKey
|
||||
);
|
||||
if (!group) {
|
||||
group = await storage.createPdfGroup(
|
||||
patient.id,
|
||||
groupTitle,
|
||||
groupTitleKey
|
||||
);
|
||||
}
|
||||
if (!group?.id) {
|
||||
throw new Error("PDF group creation failed: missing group ID");
|
||||
}
|
||||
|
||||
const created = await storage.createPdfFile(
|
||||
group.id,
|
||||
path.basename(generatedPdfPath),
|
||||
pdfBuffer
|
||||
);
|
||||
if (created && typeof created === "object" && "id" in created) {
|
||||
createdPdfFileId = Number(created.id);
|
||||
}
|
||||
outputResult.pdfUploadStatus = `PDF saved to group: ${group.title}`;
|
||||
} else {
|
||||
outputResult.pdfUploadStatus =
|
||||
"No valid PDF path provided by Selenium, Couldn't upload pdf to server.";
|
||||
}
|
||||
|
||||
return {
|
||||
patientUpdateStatus: outputResult.patientUpdateStatus,
|
||||
pdfUploadStatus: outputResult.pdfUploadStatus,
|
||||
pdfFileId: createdPdfFileId,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
patientUpdateStatus: outputResult.patientUpdateStatus,
|
||||
pdfUploadStatus:
|
||||
outputResult.pdfUploadStatus ??
|
||||
`Failed to process DentaQuest job: ${err?.message ?? String(err)}`,
|
||||
pdfFileId: createdPdfFileId,
|
||||
error: err?.message ?? String(err),
|
||||
};
|
||||
} finally {
|
||||
// ALWAYS attempt cleanup of temp files
|
||||
try {
|
||||
if (seleniumResult && seleniumResult.pdf_path) {
|
||||
await emptyFolderContainingFile(seleniumResult.pdf_path);
|
||||
} else if (seleniumResult && seleniumResult.ss_path) {
|
||||
await emptyFolderContainingFile(seleniumResult.ss_path);
|
||||
} else {
|
||||
console.log(
|
||||
`[dentaquest-eligibility] no pdf_path or ss_path available to cleanup`
|
||||
);
|
||||
}
|
||||
} catch (cleanupErr) {
|
||||
console.error(
|
||||
`[dentaquest-eligibility cleanup failed for ${seleniumResult?.pdf_path ?? seleniumResult?.ss_path}]`,
|
||||
cleanupErr
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- top of file, alongside dentaquestJobs ---
|
||||
let currentFinalSessionId: string | null = null;
|
||||
let currentFinalResult: any = null;
|
||||
|
||||
function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
function log(tag: string, msg: string, ctx?: any) {
|
||||
console.log(`${now()} [${tag}] ${msg}`, ctx ?? "");
|
||||
}
|
||||
|
||||
function emitSafe(socketId: string | undefined, event: string, payload: any) {
|
||||
if (!socketId) {
|
||||
log("socket", "no socketId for emit", { event });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const socket = io?.sockets.sockets.get(socketId);
|
||||
if (!socket) {
|
||||
log("socket", "socket not found (maybe disconnected)", {
|
||||
socketId,
|
||||
event,
|
||||
});
|
||||
return;
|
||||
}
|
||||
socket.emit(event, payload);
|
||||
log("socket", "emitted", { socketId, event });
|
||||
} catch (err: any) {
|
||||
log("socket", "emit failed", { socketId, event, err: err?.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls Python agent for session status and emits socket events:
|
||||
* - 'selenium:otp_required' when waiting_for_otp
|
||||
* - 'selenium:session_update' when completed/error
|
||||
* - absolute timeout + transient error handling.
|
||||
* - pollTimeoutMs default = 2 minutes (adjust where invoked)
|
||||
*/
|
||||
async function pollAgentSessionAndProcess(
|
||||
sessionId: string,
|
||||
socketId?: string,
|
||||
pollTimeoutMs = 2 * 60 * 1000
|
||||
) {
|
||||
const maxAttempts = 300;
|
||||
const baseDelayMs = 1000;
|
||||
const maxTransientErrors = 12;
|
||||
|
||||
// NEW: give up if same non-terminal status repeats this many times
|
||||
const noProgressLimit = 100;
|
||||
|
||||
const job = dentaquestJobs[sessionId];
|
||||
let transientErrorCount = 0;
|
||||
let consecutiveNoProgress = 0;
|
||||
let lastStatus: string | null = null;
|
||||
const deadline = Date.now() + pollTimeoutMs;
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
// absolute deadline check
|
||||
if (Date.now() > deadline) {
|
||||
emitSafe(socketId, "selenium:session_update", {
|
||||
session_id: sessionId,
|
||||
status: "error",
|
||||
message: `Polling timeout reached (${Math.round(pollTimeoutMs / 1000)}s).`,
|
||||
});
|
||||
delete dentaquestJobs[sessionId];
|
||||
return;
|
||||
}
|
||||
|
||||
log(
|
||||
"poller",
|
||||
`attempt=${attempt} session=${sessionId} transientErrCount=${transientErrorCount}`
|
||||
);
|
||||
|
||||
try {
|
||||
const st = await getSeleniumDentaQuestSessionStatus(sessionId);
|
||||
const status = st?.status ?? null;
|
||||
log("poller", "got status", {
|
||||
sessionId,
|
||||
status,
|
||||
message: st?.message,
|
||||
resultKeys: st?.result ? Object.keys(st.result) : null,
|
||||
});
|
||||
|
||||
// reset transient errors on success
|
||||
transientErrorCount = 0;
|
||||
|
||||
// if status unchanged and non-terminal, increment no-progress counter
|
||||
const isTerminalLike =
|
||||
status === "completed" || status === "error" || status === "not_found";
|
||||
if (status === lastStatus && !isTerminalLike) {
|
||||
consecutiveNoProgress++;
|
||||
} else {
|
||||
consecutiveNoProgress = 0;
|
||||
}
|
||||
lastStatus = status;
|
||||
|
||||
// if no progress for too many consecutive polls -> abort
|
||||
if (consecutiveNoProgress >= noProgressLimit) {
|
||||
emitSafe(socketId, "selenium:session_update", {
|
||||
session_id: sessionId,
|
||||
status: "error",
|
||||
message: `No progress from selenium agent (status="${status}") after ${consecutiveNoProgress} polls; aborting.`,
|
||||
});
|
||||
emitSafe(socketId, "selenium:session_error", {
|
||||
session_id: sessionId,
|
||||
status: "error",
|
||||
message: "No progress from selenium agent",
|
||||
});
|
||||
delete dentaquestJobs[sessionId];
|
||||
return;
|
||||
}
|
||||
|
||||
// always emit debug to client if socket exists
|
||||
emitSafe(socketId, "selenium:debug", {
|
||||
session_id: sessionId,
|
||||
attempt,
|
||||
status,
|
||||
serverTime: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// If agent is waiting for OTP, inform client but keep polling (do not return)
|
||||
if (status === "waiting_for_otp") {
|
||||
emitSafe(socketId, "selenium:otp_required", {
|
||||
session_id: sessionId,
|
||||
message: "OTP required. Please enter the OTP.",
|
||||
});
|
||||
// do not return — keep polling (allows same poller to pick up completion)
|
||||
await new Promise((r) => setTimeout(r, baseDelayMs));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Completed path
|
||||
if (status === "completed") {
|
||||
log("poller", "agent completed; processing result", {
|
||||
sessionId,
|
||||
resultKeys: st.result ? Object.keys(st.result) : null,
|
||||
});
|
||||
|
||||
// Persist raw result so frontend can fetch if socket disconnects
|
||||
currentFinalSessionId = sessionId;
|
||||
currentFinalResult = {
|
||||
rawSelenium: st.result,
|
||||
processedAt: null,
|
||||
final: null,
|
||||
};
|
||||
|
||||
let finalResult: any = null;
|
||||
if (job && st.result) {
|
||||
try {
|
||||
finalResult = await handleDentaQuestCompletedJob(
|
||||
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", "handleDentaQuestCompletedJob failed", {
|
||||
sessionId,
|
||||
err: err?.message ?? err,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
currentFinalResult.final = {
|
||||
error: "no_job_or_no_result",
|
||||
};
|
||||
currentFinalResult.processedAt = Date.now();
|
||||
}
|
||||
|
||||
// Emit final update (if socket present)
|
||||
emitSafe(socketId, "selenium:session_update", {
|
||||
session_id: sessionId,
|
||||
status: "completed",
|
||||
rawSelenium: st.result,
|
||||
final: currentFinalResult.final,
|
||||
});
|
||||
|
||||
// cleanup job context
|
||||
delete dentaquestJobs[sessionId];
|
||||
return;
|
||||
}
|
||||
|
||||
// Terminal error / not_found
|
||||
if (status === "error" || status === "not_found") {
|
||||
const emitPayload = {
|
||||
session_id: sessionId,
|
||||
status,
|
||||
message: st?.message || "Selenium session error",
|
||||
};
|
||||
emitSafe(socketId, "selenium:session_update", emitPayload);
|
||||
emitSafe(socketId, "selenium:session_error", emitPayload);
|
||||
delete dentaquestJobs[sessionId];
|
||||
return;
|
||||
}
|
||||
} catch (err: any) {
|
||||
const axiosStatus =
|
||||
err?.response?.status ?? (err?.status ? Number(err.status) : undefined);
|
||||
const errCode = err?.code ?? err?.errno;
|
||||
const errMsg = err?.message ?? String(err);
|
||||
const errData = err?.response?.data ?? null;
|
||||
|
||||
// If agent explicitly returned 404 -> terminal (session gone)
|
||||
if (
|
||||
axiosStatus === 404 ||
|
||||
(typeof errMsg === "string" && errMsg.includes("not_found"))
|
||||
) {
|
||||
console.warn(
|
||||
`${new Date().toISOString()} [poller] terminal 404/not_found for ${sessionId}: data=${JSON.stringify(errData)}`
|
||||
);
|
||||
|
||||
// Emit not_found to client
|
||||
const emitPayload = {
|
||||
session_id: sessionId,
|
||||
status: "not_found",
|
||||
message:
|
||||
errData?.detail || "Selenium session not found (agent cleaned up).",
|
||||
};
|
||||
emitSafe(socketId, "selenium:session_update", emitPayload);
|
||||
emitSafe(socketId, "selenium:session_error", emitPayload);
|
||||
|
||||
// Remove job context and stop polling
|
||||
delete dentaquestJobs[sessionId];
|
||||
return;
|
||||
}
|
||||
|
||||
// Detailed transient error logging
|
||||
transientErrorCount++;
|
||||
if (transientErrorCount > maxTransientErrors) {
|
||||
const emitPayload = {
|
||||
session_id: sessionId,
|
||||
status: "error",
|
||||
message:
|
||||
"Repeated network errors while polling selenium agent; giving up.",
|
||||
};
|
||||
emitSafe(socketId, "selenium:session_update", emitPayload);
|
||||
emitSafe(socketId, "selenium:session_error", emitPayload);
|
||||
delete dentaquestJobs[sessionId];
|
||||
return;
|
||||
}
|
||||
|
||||
const backoffMs = Math.min(
|
||||
30_000,
|
||||
baseDelayMs * Math.pow(2, transientErrorCount - 1)
|
||||
);
|
||||
console.warn(
|
||||
`${new Date().toISOString()} [poller] transient error (#${transientErrorCount}) for ${sessionId}: code=${errCode} status=${axiosStatus} msg=${errMsg} data=${JSON.stringify(errData)}`
|
||||
);
|
||||
console.warn(
|
||||
`${new Date().toISOString()} [poller] backing off ${backoffMs}ms before next attempt`
|
||||
);
|
||||
|
||||
await new Promise((r) => setTimeout(r, backoffMs));
|
||||
continue;
|
||||
}
|
||||
|
||||
// normal poll interval
|
||||
await new Promise((r) => setTimeout(r, baseDelayMs));
|
||||
}
|
||||
|
||||
// overall timeout fallback
|
||||
emitSafe(socketId, "selenium:session_update", {
|
||||
session_id: sessionId,
|
||||
status: "error",
|
||||
message: "Polling timeout while waiting for selenium session",
|
||||
});
|
||||
delete dentaquestJobs[sessionId];
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /dentaquest-eligibility
|
||||
* Starts DentaQuest eligibility Selenium job.
|
||||
* Expects:
|
||||
* - req.body.data: stringified JSON like your existing /eligibility-check
|
||||
* - req.body.socketId: socket.io client id
|
||||
*/
|
||||
router.post(
|
||||
"/dentaquest-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,
|
||||
rawData.insuranceSiteKey
|
||||
);
|
||||
if (!credentials) {
|
||||
return res.status(404).json({
|
||||
error:
|
||||
"No insurance credentials found for this provider, Kindly Update this at Settings Page.",
|
||||
});
|
||||
}
|
||||
|
||||
const enrichedData = {
|
||||
...rawData,
|
||||
dentaquestUsername: credentials.username,
|
||||
dentaquestPassword: credentials.password,
|
||||
};
|
||||
|
||||
const socketId: string | undefined = req.body.socketId;
|
||||
|
||||
const agentResp =
|
||||
await forwardToSeleniumDentaQuestEligibilityAgent(enrichedData);
|
||||
|
||||
if (
|
||||
!agentResp ||
|
||||
agentResp.status !== "started" ||
|
||||
!agentResp.session_id
|
||||
) {
|
||||
return res.status(502).json({
|
||||
error: "Selenium agent did not return a started session",
|
||||
detail: agentResp,
|
||||
});
|
||||
}
|
||||
|
||||
const sessionId = agentResp.session_id as string;
|
||||
|
||||
// Save job context
|
||||
dentaquestJobs[sessionId] = {
|
||||
userId: req.user.id,
|
||||
insuranceEligibilityData: enrichedData,
|
||||
socketId,
|
||||
};
|
||||
|
||||
// start polling in background to notify client via socket and process job
|
||||
pollAgentSessionAndProcess(sessionId, socketId).catch((e) =>
|
||||
console.warn("pollAgentSessionAndProcess failed", e)
|
||||
);
|
||||
|
||||
// reply immediately with started status
|
||||
return res.json({ status: "started", session_id: sessionId });
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
return res.status(500).json({
|
||||
error: err.message || "Failed to start DentaQuest selenium agent",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /selenium/submit-otp
|
||||
* Body: { session_id, otp, socketId? }
|
||||
* Forwards OTP to Python agent and optionally notifies client socket.
|
||||
*/
|
||||
router.post(
|
||||
"/selenium/submit-otp",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
const { session_id: sessionId, otp, socketId } = req.body;
|
||||
if (!sessionId || !otp) {
|
||||
return res.status(400).json({ error: "session_id and otp are required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const r = await forwardOtpToSeleniumDentaQuestAgent(sessionId, otp);
|
||||
|
||||
// emit OTP accepted (if socket present)
|
||||
emitSafe(socketId, "selenium:otp_submitted", {
|
||||
session_id: sessionId,
|
||||
result: r,
|
||||
});
|
||||
|
||||
return res.json(r);
|
||||
} catch (err: any) {
|
||||
console.error(
|
||||
"Failed to forward OTP:",
|
||||
err?.response?.data || err?.message || err
|
||||
);
|
||||
return res.status(500).json({
|
||||
error: "Failed to forward otp to selenium agent",
|
||||
detail: err?.message || err,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// GET /selenium/session/:sid/final
|
||||
router.get(
|
||||
"/selenium/session/:sid/final",
|
||||
async (req: Request, res: Response) => {
|
||||
const sid = req.params.sid;
|
||||
if (!sid) return res.status(400).json({ error: "session id required" });
|
||||
|
||||
// Only the current in-memory result is available
|
||||
if (currentFinalSessionId !== sid || !currentFinalResult) {
|
||||
return res.status(404).json({ error: "final result not found" });
|
||||
}
|
||||
|
||||
return res.json(currentFinalResult);
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
99
apps/Backend/src/routes/npiProviders.ts
Normal file
99
apps/Backend/src/routes/npiProviders.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import express, { Request, Response } from "express";
|
||||
import { z } from "zod";
|
||||
import { storage } from "../storage";
|
||||
import { insertNpiProviderSchema } from "@repo/db/types";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/", async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.user?.id) {
|
||||
return res.status(401).json({ message: "Unauthorized" });
|
||||
}
|
||||
|
||||
const providers = await storage.getNpiProvidersByUser(req.user.id);
|
||||
res.status(200).json(providers);
|
||||
} catch (err) {
|
||||
res.status(500).json({
|
||||
error: "Failed to fetch NPI providers",
|
||||
details: String(err),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/", async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.user?.id) {
|
||||
return res.status(401).json({ message: "Unauthorized" });
|
||||
}
|
||||
|
||||
const parsed = insertNpiProviderSchema.safeParse({
|
||||
...req.body,
|
||||
userId: req.user.id,
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
const flat = parsed.error.flatten();
|
||||
const firstError =
|
||||
Object.values(flat.fieldErrors)[0]?.[0] || "Invalid input";
|
||||
|
||||
return res.status(400).json({
|
||||
message: firstError,
|
||||
details: flat.fieldErrors,
|
||||
});
|
||||
}
|
||||
|
||||
const provider = await storage.createNpiProvider(parsed.data);
|
||||
res.status(201).json(provider);
|
||||
} catch (err: any) {
|
||||
if (err.code === "P2002") {
|
||||
return res.status(400).json({
|
||||
message: "This NPI already exists for the user",
|
||||
});
|
||||
}
|
||||
res.status(500).json({
|
||||
error: "Failed to create NPI provider",
|
||||
details: String(err),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.put("/:id", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
if (isNaN(id)) return res.status(400).send("Invalid ID");
|
||||
|
||||
const provider = await storage.updateNpiProvider(id, req.body);
|
||||
res.status(200).json(provider);
|
||||
} catch (err) {
|
||||
res.status(500).json({
|
||||
error: "Failed to update NPI provider",
|
||||
details: String(err),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/:id", async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.user?.id) {
|
||||
return res.status(401).json({ message: "Unauthorized" });
|
||||
}
|
||||
|
||||
const id = Number(req.params.id);
|
||||
if (isNaN(id)) return res.status(400).send("Invalid ID");
|
||||
|
||||
const ok = await storage.deleteNpiProvider(req.user.id, id);
|
||||
if (!ok) {
|
||||
return res.status(404).json({ message: "NPI provider not found" });
|
||||
}
|
||||
|
||||
res.status(204).send();
|
||||
} catch (err) {
|
||||
res.status(500).json({
|
||||
error: "Failed to delete NPI provider",
|
||||
details: String(err),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,22 +1,27 @@
|
||||
import { Router } from "express";
|
||||
import type { Request, Response } from "express";
|
||||
import { storage } from "../storage";
|
||||
import { z } from "zod";
|
||||
import { StaffUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas";
|
||||
|
||||
type Staff = z.infer<typeof StaffUncheckedCreateInputObjectSchema>;
|
||||
|
||||
const staffCreateSchema = StaffUncheckedCreateInputObjectSchema;
|
||||
const staffUpdateSchema = (
|
||||
StaffUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>
|
||||
).partial();
|
||||
import {
|
||||
StaffCreateBody,
|
||||
StaffCreateInput,
|
||||
staffCreateSchema,
|
||||
staffUpdateSchema,
|
||||
} from "@repo/db/types";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post("/", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const validatedData = staffCreateSchema.parse(req.body);
|
||||
const newStaff = await storage.createStaff(validatedData);
|
||||
const userId = req.user!.id; // from auth middleware
|
||||
|
||||
const body = staffCreateSchema.parse(req.body) as StaffCreateBody;
|
||||
|
||||
const data: StaffCreateInput = {
|
||||
...body,
|
||||
userId,
|
||||
};
|
||||
|
||||
const newStaff = await storage.createStaff(data);
|
||||
res.status(200).json(newStaff);
|
||||
} catch (error) {
|
||||
console.error("Failed to create staff:", error);
|
||||
@@ -46,12 +51,17 @@ router.put("/:id", async (req: Request, res: Response): Promise<any> => {
|
||||
const validatedData = staffUpdateSchema.parse(req.body);
|
||||
const updatedStaff = await storage.updateStaff(
|
||||
parsedStaffId,
|
||||
validatedData
|
||||
validatedData,
|
||||
);
|
||||
if (!updatedStaff) return res.status(404).send("Staff not found");
|
||||
|
||||
res.json(updatedStaff);
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes("displayOrder")) {
|
||||
return res.status(400).json({
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
console.error("Failed to update staff:", error);
|
||||
res.status(500).send("Failed to update staff");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
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-dentaquest-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-dentaquest-client] transient network error ${code} (attempt ${attempt})`
|
||||
);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, baseBackoffMs * attempt));
|
||||
}
|
||||
// final attempt (let exception bubble if it fails)
|
||||
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 forwardToSeleniumDentaQuestEligibilityAgent(
|
||||
insuranceEligibilityData: any
|
||||
): Promise<any> {
|
||||
const payload = { data: insuranceEligibilityData };
|
||||
const url = `/dentaquest-eligibility`;
|
||||
log("selenium-dentaquest-client", "POST dentaquest-eligibility", {
|
||||
url: SELENIUM_AGENT_BASE + url,
|
||||
keys: Object.keys(payload),
|
||||
});
|
||||
const r = await requestWithRetries({ url, method: "POST", data: payload }, 4);
|
||||
log("selenium-dentaquest-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 forwardOtpToSeleniumDentaQuestAgent(
|
||||
sessionId: string,
|
||||
otp: string
|
||||
): Promise<any> {
|
||||
const url = `/dentaquest-submit-otp`;
|
||||
log("selenium-dentaquest-client", "POST dentaquest-submit-otp", {
|
||||
url: SELENIUM_AGENT_BASE + url,
|
||||
sessionId,
|
||||
});
|
||||
const r = await requestWithRetries(
|
||||
{ url, method: "POST", data: { session_id: sessionId, otp } },
|
||||
4
|
||||
);
|
||||
log("selenium-dentaquest-client", "submit-otp response", {
|
||||
status: r.status,
|
||||
data: r.data,
|
||||
});
|
||||
if (r.status >= 500)
|
||||
throw new Error(`Selenium agent server error on submit-otp: ${r.status}`);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
export async function getSeleniumDentaQuestSessionStatus(
|
||||
sessionId: string
|
||||
): Promise<any> {
|
||||
const url = `/dentaquest-session/${sessionId}/status`;
|
||||
log("selenium-dentaquest-client", "GET session status", {
|
||||
url: SELENIUM_AGENT_BASE + url,
|
||||
sessionId,
|
||||
});
|
||||
const r = await requestWithRetries({ url, method: "GET" }, 4);
|
||||
log("selenium-dentaquest-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;
|
||||
}
|
||||
|
||||
100
apps/Backend/src/storage/appointment-procedures-storage.ts
Normal file
100
apps/Backend/src/storage/appointment-procedures-storage.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
Appointment,
|
||||
AppointmentProcedure,
|
||||
InsertAppointmentProcedure,
|
||||
Patient,
|
||||
UpdateAppointmentProcedure,
|
||||
} from "@repo/db/types";
|
||||
import { prisma as db } from "@repo/db/client";
|
||||
|
||||
export interface IAppointmentProceduresStorage {
|
||||
getByAppointmentId(appointmentId: number): Promise<AppointmentProcedure[]>;
|
||||
getPrefillDataByAppointmentId(appointmentId: number): Promise<{
|
||||
appointment: Appointment;
|
||||
patient: Patient;
|
||||
procedures: AppointmentProcedure[];
|
||||
} | null>;
|
||||
|
||||
createProcedure(
|
||||
data: InsertAppointmentProcedure
|
||||
): Promise<AppointmentProcedure>;
|
||||
createProceduresBulk(data: InsertAppointmentProcedure[]): Promise<number>;
|
||||
updateProcedure(
|
||||
id: number,
|
||||
data: UpdateAppointmentProcedure
|
||||
): Promise<AppointmentProcedure>;
|
||||
deleteProcedure(id: number): Promise<void>;
|
||||
clearByAppointmentId(appointmentId: number): Promise<void>;
|
||||
}
|
||||
|
||||
export const appointmentProceduresStorage: IAppointmentProceduresStorage = {
|
||||
async getByAppointmentId(
|
||||
appointmentId: number
|
||||
): Promise<AppointmentProcedure[]> {
|
||||
return db.appointmentProcedure.findMany({
|
||||
where: { appointmentId },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
},
|
||||
|
||||
async getPrefillDataByAppointmentId(appointmentId: number) {
|
||||
const appointment = await db.appointment.findUnique({
|
||||
where: { id: appointmentId },
|
||||
include: {
|
||||
patient: true,
|
||||
procedures: {
|
||||
orderBy: { createdAt: "asc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!appointment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
appointment,
|
||||
patient: appointment.patient,
|
||||
procedures: appointment.procedures,
|
||||
};
|
||||
},
|
||||
|
||||
async createProcedure(
|
||||
data: InsertAppointmentProcedure
|
||||
): Promise<AppointmentProcedure> {
|
||||
return db.appointmentProcedure.create({
|
||||
data: data as AppointmentProcedure,
|
||||
});
|
||||
},
|
||||
|
||||
async createProceduresBulk(
|
||||
data: InsertAppointmentProcedure[]
|
||||
): Promise<number> {
|
||||
const result = await db.appointmentProcedure.createMany({
|
||||
data: data as any[],
|
||||
});
|
||||
return result.count;
|
||||
},
|
||||
|
||||
async updateProcedure(
|
||||
id: number,
|
||||
data: UpdateAppointmentProcedure
|
||||
): Promise<AppointmentProcedure> {
|
||||
return db.appointmentProcedure.update({
|
||||
where: { id },
|
||||
data: data as any,
|
||||
});
|
||||
},
|
||||
|
||||
async deleteProcedure(id: number): Promise<void> {
|
||||
await db.appointmentProcedure.delete({
|
||||
where: { id },
|
||||
});
|
||||
},
|
||||
|
||||
async clearByAppointmentId(appointmentId: number): Promise<void> {
|
||||
await db.appointmentProcedure.deleteMany({
|
||||
where: { appointmentId },
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import { usersStorage } from './users-storage';
|
||||
import { patientsStorage } from './patients-storage';
|
||||
import { appointmentsStorage } from './appointements-storage';
|
||||
import { appointmentsStorage } from './appointments-storage';
|
||||
import { appointmentProceduresStorage } from './appointment-procedures-storage';
|
||||
import { staffStorage } from './staff-storage';
|
||||
import { npiProviderStorage } from './npi-providers-storage';
|
||||
import { claimsStorage } from './claims-storage';
|
||||
import { insuranceCredsStorage } from './insurance-creds-storage';
|
||||
import { generalPdfStorage } from './general-pdf-storage';
|
||||
@@ -19,7 +21,9 @@ export const storage = {
|
||||
...usersStorage,
|
||||
...patientsStorage,
|
||||
...appointmentsStorage,
|
||||
...appointmentProceduresStorage,
|
||||
...staffStorage,
|
||||
...npiProviderStorage,
|
||||
...claimsStorage,
|
||||
...insuranceCredsStorage,
|
||||
...generalPdfStorage,
|
||||
|
||||
50
apps/Backend/src/storage/npi-providers-storage.ts
Normal file
50
apps/Backend/src/storage/npi-providers-storage.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { prisma as db } from "@repo/db/client";
|
||||
import { InsertNpiProvider, NpiProvider } from "@repo/db/types";
|
||||
|
||||
export interface INpiProviderStorage {
|
||||
getNpiProvider(id: number): Promise<NpiProvider | null>;
|
||||
getNpiProvidersByUser(userId: number): Promise<NpiProvider[]>;
|
||||
createNpiProvider(data: InsertNpiProvider): Promise<NpiProvider>;
|
||||
updateNpiProvider(
|
||||
id: number,
|
||||
updates: Partial<NpiProvider>,
|
||||
): Promise<NpiProvider | null>;
|
||||
deleteNpiProvider(userId: number, id: number): Promise<boolean>;
|
||||
}
|
||||
|
||||
export const npiProviderStorage: INpiProviderStorage = {
|
||||
async getNpiProvider(id: number) {
|
||||
return db.npiProvider.findUnique({ where: { id } });
|
||||
},
|
||||
|
||||
async getNpiProvidersByUser(userId: number) {
|
||||
return db.npiProvider.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
},
|
||||
|
||||
async createNpiProvider(data: InsertNpiProvider) {
|
||||
return db.npiProvider.create({
|
||||
data: data as NpiProvider,
|
||||
});
|
||||
},
|
||||
|
||||
async updateNpiProvider(id: number, updates: Partial<NpiProvider>) {
|
||||
return db.npiProvider.update({
|
||||
where: { id },
|
||||
data: updates,
|
||||
});
|
||||
},
|
||||
|
||||
async deleteNpiProvider(userId: number, id: number) {
|
||||
try {
|
||||
await db.npiProvider.delete({
|
||||
where: { id, userId },
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Staff } from "@repo/db/types";
|
||||
import { Staff, StaffCreateInput, StaffUpdateInput } from "@repo/db/types";
|
||||
import { prisma as db } from "@repo/db/client";
|
||||
|
||||
export interface IStorage {
|
||||
getStaff(id: number): Promise<Staff | undefined>;
|
||||
getAllStaff(): Promise<Staff[]>;
|
||||
createStaff(staff: Staff): Promise<Staff>;
|
||||
updateStaff(id: number, updates: Partial<Staff>): Promise<Staff | undefined>;
|
||||
createStaff(staff: StaffCreateInput): Promise<Staff>;
|
||||
updateStaff(
|
||||
id: number,
|
||||
updates: StaffUpdateInput,
|
||||
): Promise<Staff | undefined>;
|
||||
deleteStaff(id: number): Promise<boolean>;
|
||||
countAppointmentsByStaffId(staffId: number): Promise<number>;
|
||||
countClaimsByStaffId(staffId: number): Promise<number>;
|
||||
@@ -19,38 +22,122 @@ export const staffStorage: IStorage = {
|
||||
},
|
||||
|
||||
async getAllStaff(): Promise<Staff[]> {
|
||||
const staff = await db.staff.findMany();
|
||||
return staff;
|
||||
},
|
||||
|
||||
async createStaff(staff: Staff): Promise<Staff> {
|
||||
const createdStaff = await db.staff.create({
|
||||
data: staff,
|
||||
return db.staff.findMany({
|
||||
orderBy: { displayOrder: "asc" },
|
||||
});
|
||||
return createdStaff;
|
||||
},
|
||||
|
||||
async createStaff(staff: StaffCreateInput): Promise<Staff> {
|
||||
const max = await db.staff.aggregate({
|
||||
where: {
|
||||
userId: staff.userId,
|
||||
displayOrder: { gt: 0 },
|
||||
},
|
||||
_max: { displayOrder: true },
|
||||
});
|
||||
|
||||
return db.staff.create({
|
||||
data: {
|
||||
...staff,
|
||||
displayOrder: (max._max.displayOrder ?? 0) + 1,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async updateStaff(
|
||||
id: number,
|
||||
updates: Partial<Staff>
|
||||
updates: StaffUpdateInput,
|
||||
): Promise<Staff | undefined> {
|
||||
const updatedStaff = await db.staff.update({
|
||||
where: { id },
|
||||
data: updates,
|
||||
return db.$transaction(async (tx: any) => {
|
||||
const staff = await tx.staff.findUnique({ where: { id } });
|
||||
if (!staff) return undefined;
|
||||
|
||||
const { userId, displayOrder: oldOrder } = staff;
|
||||
const newOrder = updates.displayOrder;
|
||||
|
||||
if (newOrder === undefined || newOrder === oldOrder) {
|
||||
return tx.staff.update({
|
||||
where: { id },
|
||||
data: updates,
|
||||
});
|
||||
}
|
||||
|
||||
const totalStaff = await tx.staff.count({ where: { userId } });
|
||||
|
||||
if (newOrder < 1 || newOrder > totalStaff) {
|
||||
throw new Error(`displayOrder must be between 1 and ${totalStaff}`);
|
||||
}
|
||||
|
||||
const occupyingStaff = await tx.staff.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
displayOrder: newOrder,
|
||||
},
|
||||
});
|
||||
|
||||
// CASE 1: staff already had a slot → SWAP
|
||||
if (oldOrder && oldOrder > 0 && occupyingStaff) {
|
||||
await tx.staff.update({
|
||||
where: { id: occupyingStaff.id },
|
||||
data: { displayOrder: oldOrder },
|
||||
});
|
||||
}
|
||||
|
||||
// CASE 2: first-time assignment (oldOrder = 0)
|
||||
if ((!oldOrder || oldOrder === 0) && occupyingStaff) {
|
||||
// find first free slot
|
||||
const usedOrders = await tx.staff.findMany({
|
||||
where: {
|
||||
userId,
|
||||
displayOrder: { gt: 0 },
|
||||
},
|
||||
select: { displayOrder: true },
|
||||
orderBy: { displayOrder: "asc" },
|
||||
});
|
||||
|
||||
const usedSet = new Set(usedOrders.map((s: any) => s.displayOrder));
|
||||
let freeSlot = 1;
|
||||
while (usedSet.has(freeSlot)) freeSlot++;
|
||||
|
||||
await tx.staff.update({
|
||||
where: { id: occupyingStaff.id },
|
||||
data: { displayOrder: freeSlot },
|
||||
});
|
||||
}
|
||||
|
||||
return tx.staff.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...updates,
|
||||
displayOrder: newOrder,
|
||||
},
|
||||
});
|
||||
});
|
||||
return updatedStaff ?? undefined;
|
||||
},
|
||||
|
||||
async deleteStaff(id: number): Promise<boolean> {
|
||||
try {
|
||||
await db.staff.delete({ where: { id } });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error deleting staff:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
return db.$transaction(async (tx: any) => {
|
||||
const staff = await tx.staff.findUnique({ where: { id } });
|
||||
if (!staff) return false;
|
||||
|
||||
const { userId, displayOrder } = staff;
|
||||
|
||||
await tx.staff.delete({ where: { id } });
|
||||
|
||||
// Shift left to remove gap
|
||||
await tx.staff.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
displayOrder: { gt: displayOrder },
|
||||
},
|
||||
data: {
|
||||
displayOrder: { decrement: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
});
|
||||
},
|
||||
async countAppointmentsByStaffId(staffId: number): Promise<number> {
|
||||
return await db.appointment.count({ where: { staffId } });
|
||||
},
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { Save, Pencil, X } from "lucide-react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
interface Props {
|
||||
appointmentId: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export function AppointmentProcedureNotes({
|
||||
appointmentId,
|
||||
enabled,
|
||||
}: Props) {
|
||||
const { toast } = useToast();
|
||||
|
||||
const [notes, setNotes] = useState("");
|
||||
const [originalNotes, setOriginalNotes] = useState("");
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// -------------------------
|
||||
// Load procedure notes
|
||||
// -------------------------
|
||||
useQuery({
|
||||
queryKey: ["appointment-procedure-notes", appointmentId],
|
||||
enabled: enabled && !!appointmentId,
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest(
|
||||
"GET",
|
||||
`/api/appointments/${appointmentId}/procedure-notes`
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed to load notes");
|
||||
|
||||
const data = await res.json();
|
||||
const value = data.procedureNotes ?? "";
|
||||
|
||||
setNotes(value);
|
||||
setOriginalNotes(value);
|
||||
setIsEditing(false);
|
||||
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
// -------------------------
|
||||
// Save procedure notes
|
||||
// -------------------------
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await apiRequest(
|
||||
"PUT",
|
||||
`/api/appointments/${appointmentId}/procedure-notes`,
|
||||
{ procedureNotes: notes }
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed to save notes");
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: "Procedure notes saved" });
|
||||
setOriginalNotes(notes);
|
||||
setIsEditing(false);
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["appointment-procedure-notes", appointmentId],
|
||||
});
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: err.message ?? "Failed to save notes",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Autofocus textarea when editing
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
const handleCancel = () => {
|
||||
setNotes(originalNotes);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const hasChanges = notes !== originalNotes;
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg p-4 bg-muted/20 space-y-2">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">Procedure Notes</div>
|
||||
|
||||
{!isEditing ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
<Pencil className="h-4 w-4 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={saveMutation.isPending || !hasChanges}
|
||||
>
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Textarea */}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className={`w-full min-h-[90px] rounded-md border px-3 py-2 text-sm transition
|
||||
focus:outline-none focus:ring-2 focus:ring-ring
|
||||
${
|
||||
isEditing
|
||||
? "bg-background text-foreground"
|
||||
: "bg-white text-foreground border-dashed border-muted-foreground/40 cursor-default"
|
||||
}
|
||||
`}
|
||||
placeholder='Example: "#20 DO filling"'
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
readOnly={!isEditing}
|
||||
/>
|
||||
|
||||
{/* View-mode hint */}
|
||||
{!isEditing && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
View only — click <span className="font-medium">Edit</span> to modify
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Trash2, Plus, Save, X } from "lucide-react";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { PROCEDURE_COMBOS } from "@/utils/procedureCombos";
|
||||
import {
|
||||
CODE_MAP,
|
||||
getPriceForCodeWithAgeFromMap,
|
||||
} from "@/utils/procedureCombosMapping";
|
||||
import { Patient, AppointmentProcedure } from "@repo/db/types";
|
||||
import { useLocation } from "wouter";
|
||||
import { DeleteConfirmationDialog } from "../ui/deleteDialog";
|
||||
import {
|
||||
DirectComboButtons,
|
||||
RegularComboButtons,
|
||||
} from "@/components/procedure/procedure-combo-buttons";
|
||||
import { AppointmentProcedureNotes } from "./appointment-procedure-notes";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
appointmentId: number;
|
||||
patientId: number;
|
||||
patient: Patient;
|
||||
}
|
||||
|
||||
export function AppointmentProceduresDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
appointmentId,
|
||||
patientId,
|
||||
patient,
|
||||
}: Props) {
|
||||
const { toast } = useToast();
|
||||
|
||||
// -----------------------------
|
||||
// state for manual add
|
||||
// -----------------------------
|
||||
const [manualCode, setManualCode] = useState("");
|
||||
const [manualLabel, setManualLabel] = useState("");
|
||||
const [manualFee, setManualFee] = useState("");
|
||||
const [manualTooth, setManualTooth] = useState("");
|
||||
const [manualSurface, setManualSurface] = useState("");
|
||||
|
||||
// -----------------------------
|
||||
// state for inline edit
|
||||
// -----------------------------
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editRow, setEditRow] = useState<Partial<AppointmentProcedure>>({});
|
||||
const [clearAllOpen, setClearAllOpen] = useState(false);
|
||||
|
||||
// for redirection to claim submission
|
||||
const [, setLocation] = useLocation();
|
||||
|
||||
// -----------------------------
|
||||
// fetch procedures
|
||||
// -----------------------------
|
||||
const { data: procedures = [], isLoading } = useQuery<AppointmentProcedure[]>(
|
||||
{
|
||||
queryKey: ["appointment-procedures", appointmentId],
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest(
|
||||
"GET",
|
||||
`/api/appointment-procedures/${appointmentId}`,
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed to load procedures");
|
||||
return res.json();
|
||||
},
|
||||
enabled: open && !!appointmentId,
|
||||
},
|
||||
);
|
||||
|
||||
// -----------------------------
|
||||
// mutations
|
||||
// -----------------------------
|
||||
const addManualMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload = {
|
||||
appointmentId,
|
||||
patientId,
|
||||
procedureCode: manualCode,
|
||||
procedureLabel: manualLabel || null,
|
||||
fee: manualFee ? Number(manualFee) : null,
|
||||
toothNumber: manualTooth || null,
|
||||
toothSurface: manualSurface || null,
|
||||
source: "MANUAL",
|
||||
};
|
||||
|
||||
const res = await apiRequest(
|
||||
"POST",
|
||||
"/api/appointment-procedures",
|
||||
payload,
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed to add procedure");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: "Procedure added" });
|
||||
setManualCode("");
|
||||
setManualLabel("");
|
||||
setManualFee("");
|
||||
setManualTooth("");
|
||||
setManualSurface("");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["appointment-procedures", appointmentId],
|
||||
});
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: err.message ?? "Failed to add procedure",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const bulkAddMutation = useMutation({
|
||||
mutationFn: async (rows: any[]) => {
|
||||
const res = await apiRequest(
|
||||
"POST",
|
||||
"/api/appointment-procedures/bulk",
|
||||
rows,
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed to add combo procedures");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: "Combo added" });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["appointment-procedures", appointmentId],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const res = await apiRequest(
|
||||
"DELETE",
|
||||
`/api/appointment-procedures/${id}`,
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed to delete");
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: "Deleted" });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["appointment-procedures", appointmentId],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const clearAllMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await apiRequest(
|
||||
"DELETE",
|
||||
`/api/appointment-procedures/clear/${appointmentId}`,
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed to clear procedures");
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: "All procedures cleared" });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["appointment-procedures", appointmentId],
|
||||
});
|
||||
setClearAllOpen(false);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: err.message ?? "Failed to clear procedures",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!editingId) return;
|
||||
const res = await apiRequest(
|
||||
"PUT",
|
||||
`/api/appointment-procedures/${editingId}`,
|
||||
editRow,
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed to update");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: "Updated" });
|
||||
setEditingId(null);
|
||||
setEditRow({});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["appointment-procedures", appointmentId],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// -----------------------------
|
||||
// handlers
|
||||
// -----------------------------
|
||||
const handleAddCombo = (comboKey: string) => {
|
||||
const combo = PROCEDURE_COMBOS[comboKey];
|
||||
if (!combo || !patient?.dateOfBirth) return;
|
||||
|
||||
const serviceDate = new Date();
|
||||
const dob = patient.dateOfBirth;
|
||||
|
||||
const age = (() => {
|
||||
const birth = new Date(dob);
|
||||
const ref = new Date(serviceDate);
|
||||
let a = ref.getFullYear() - birth.getFullYear();
|
||||
const hadBirthday =
|
||||
ref.getMonth() > birth.getMonth() ||
|
||||
(ref.getMonth() === birth.getMonth() &&
|
||||
ref.getDate() >= birth.getDate());
|
||||
if (!hadBirthday) a -= 1;
|
||||
return a;
|
||||
})();
|
||||
|
||||
const rows = combo.codes.map((code: string, idx: number) => {
|
||||
const priceDecimal = getPriceForCodeWithAgeFromMap(CODE_MAP, code, age);
|
||||
|
||||
return {
|
||||
appointmentId,
|
||||
patientId,
|
||||
procedureCode: code,
|
||||
procedureLabel: combo.label,
|
||||
fee: priceDecimal.toNumber(),
|
||||
source: "COMBO",
|
||||
comboKey: comboKey,
|
||||
toothNumber: combo.toothNumbers?.[idx] ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
bulkAddMutation.mutate(rows);
|
||||
};
|
||||
|
||||
const startEdit = (row: AppointmentProcedure) => {
|
||||
if (!row.id) return;
|
||||
|
||||
setEditingId(row.id);
|
||||
setEditRow({
|
||||
procedureCode: row.procedureCode,
|
||||
procedureLabel: row.procedureLabel,
|
||||
fee: row.fee,
|
||||
toothNumber: row.toothNumber,
|
||||
toothSurface: row.toothSurface,
|
||||
});
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingId(null);
|
||||
setEditRow({});
|
||||
};
|
||||
|
||||
const handleDirectClaim = () => {
|
||||
setLocation(`/claims?appointmentId=${appointmentId}&mode=direct`);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleManualClaim = () => {
|
||||
setLocation(`/claims?appointmentId=${appointmentId}&mode=manual`);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
// -----------------------------
|
||||
// UI
|
||||
// -----------------------------
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-w-6xl max-h-[90vh] overflow-y-auto pointer-events-none"
|
||||
onPointerDownOutside={(e) => {
|
||||
if (clearAllOpen) {
|
||||
e.preventDefault(); // block only when delete dialog is open
|
||||
}
|
||||
}}
|
||||
onInteractOutside={(e) => {
|
||||
if (clearAllOpen) {
|
||||
e.preventDefault(); // block only when delete dialog is open
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-semibold">
|
||||
Appointment Procedures
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<AppointmentProcedureNotes
|
||||
appointmentId={appointmentId}
|
||||
enabled={open}
|
||||
/>
|
||||
|
||||
{/* ================= COMBOS ================= */}
|
||||
<div className="space-y-8 pointer-events-auto">
|
||||
<DirectComboButtons
|
||||
onDirectCombo={(comboKey) => {
|
||||
handleAddCombo(comboKey);
|
||||
}}
|
||||
/>
|
||||
|
||||
<RegularComboButtons
|
||||
onRegularCombo={(comboKey) => {
|
||||
handleAddCombo(comboKey);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ================= MANUAL ADD ================= */}
|
||||
<div className="mt-8 border rounded-lg p-4 bg-muted/20 space-y-3">
|
||||
<div className="font-medium text-sm">Add Manual Procedure</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-3">
|
||||
<div>
|
||||
<Label>Code</Label>
|
||||
<Input
|
||||
value={manualCode}
|
||||
onChange={(e) => setManualCode(e.target.value)}
|
||||
placeholder="D0120"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Label</Label>
|
||||
<Input
|
||||
value={manualLabel}
|
||||
onChange={(e) => setManualLabel(e.target.value)}
|
||||
placeholder="Exam"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Fee</Label>
|
||||
<Input
|
||||
value={manualFee}
|
||||
onChange={(e) => setManualFee(e.target.value)}
|
||||
placeholder="100"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Tooth</Label>
|
||||
<Input
|
||||
value={manualTooth}
|
||||
onChange={(e) => setManualTooth(e.target.value)}
|
||||
placeholder="14"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Surface</Label>
|
||||
<Input
|
||||
value={manualSurface}
|
||||
onChange={(e) => setManualSurface(e.target.value)}
|
||||
placeholder="MO"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => addManualMutation.mutate()}
|
||||
disabled={!manualCode || addManualMutation.isPending}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Procedure
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ================= LIST ================= */}
|
||||
<div className="mt-8 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-semibold">Selected Procedures</div>
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={!procedures.length}
|
||||
onClick={() => setClearAllOpen(true)}
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg divide-y bg-white">
|
||||
{/* ===== TABLE HEADER ===== */}
|
||||
<div className="grid grid-cols-[90px_1fr_90px_80px_80px_72px_72px] gap-2 px-3 py-2 text-xs font-semibold text-muted-foreground bg-muted/40">
|
||||
<div>Code</div>
|
||||
<div>Label</div>
|
||||
<div>Fee</div>
|
||||
<div>Tooth</div>
|
||||
<div>Surface</div>
|
||||
<div className="text-center">Edit</div>
|
||||
<div className="text-center">Delete</div>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="p-4 text-sm text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && procedures.length === 0 && (
|
||||
<div className="p-4 text-sm text-muted-foreground">
|
||||
No procedures added
|
||||
</div>
|
||||
)}
|
||||
|
||||
{procedures.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="grid grid-cols-[90px_1fr_90px_80px_80px_72px_72px] gap-2 px-3 py-3 text-sm hover:bg-muted/40 transition"
|
||||
>
|
||||
{editingId === p.id ? (
|
||||
<>
|
||||
<Input
|
||||
className="w-[90px]"
|
||||
value={editRow.procedureCode ?? ""}
|
||||
onChange={(e) =>
|
||||
setEditRow({
|
||||
...editRow,
|
||||
procedureCode: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
className="flex-1"
|
||||
value={editRow.procedureLabel ?? ""}
|
||||
onChange={(e) =>
|
||||
setEditRow({
|
||||
...editRow,
|
||||
procedureLabel: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
className="w-[90px]"
|
||||
value={
|
||||
editRow.fee !== undefined && editRow.fee !== null
|
||||
? String(editRow.fee)
|
||||
: ""
|
||||
}
|
||||
onChange={(e) =>
|
||||
setEditRow({ ...editRow, fee: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
|
||||
<Input
|
||||
className="w-[80px]"
|
||||
value={editRow.toothNumber ?? ""}
|
||||
onChange={(e) =>
|
||||
setEditRow({
|
||||
...editRow,
|
||||
toothNumber: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
className="w-[80px]"
|
||||
value={editRow.toothSurface ?? ""}
|
||||
onChange={(e) =>
|
||||
setEditRow({
|
||||
...editRow,
|
||||
toothSurface: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => updateMutation.mutate()}
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Button size="icon" variant="ghost" onClick={cancelEdit}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-[90px] font-medium">
|
||||
{p.procedureCode}
|
||||
</div>
|
||||
<div className="flex-1 text-muted-foreground">
|
||||
{p.procedureLabel}
|
||||
</div>
|
||||
<div className="w-[90px]">
|
||||
{p.fee !== null && p.fee !== undefined
|
||||
? String(p.fee)
|
||||
: ""}
|
||||
</div>
|
||||
|
||||
<div className="w-[80px]">{p.toothNumber}</div>
|
||||
<div className="w-[80px]">{p.toothSurface}</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => startEdit(p)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => deleteMutation.mutate(p.id!)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ================= FOOTER ================= */}
|
||||
<div className="flex justify-between items-center gap-2 mt-8 pt-4 border-t">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
disabled={!procedures.length}
|
||||
onClick={handleDirectClaim}
|
||||
>
|
||||
Direct Claim
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-blue-500 text-blue-600 hover:bg-blue-50"
|
||||
disabled={!procedures.length}
|
||||
onClick={handleManualClaim}
|
||||
>
|
||||
Manual Claim
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={clearAllOpen}
|
||||
entityName="all procedures for this appointment"
|
||||
onCancel={() => setClearAllOpen(false)}
|
||||
onConfirm={() => {
|
||||
setClearAllOpen(false);
|
||||
clearAllMutation.mutate();
|
||||
}}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef, useCallback, memo } from "react";
|
||||
import { useState, useEffect, useRef, useCallback, memo, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
InputServiceLine,
|
||||
InsertAppointment,
|
||||
MissingTeethStatus,
|
||||
NpiProvider,
|
||||
Patient,
|
||||
Staff,
|
||||
UpdateAppointment,
|
||||
@@ -50,17 +51,22 @@ import {
|
||||
applyComboToForm,
|
||||
getDescriptionForCode,
|
||||
} from "@/utils/procedureCombosMapping";
|
||||
import { COMBO_CATEGORIES, PROCEDURE_COMBOS } from "@/utils/procedureCombos";
|
||||
import { PROCEDURE_COMBOS } from "@/utils/procedureCombos";
|
||||
import { DateInput } from "../ui/dateInput";
|
||||
import { MissingTeethSimple, type MissingMapStrict } from "./tooth-ui";
|
||||
import { RemarksField } from "./claims-ui";
|
||||
import {
|
||||
DirectComboButtons,
|
||||
RegularComboButtons,
|
||||
} from "@/components/procedure/procedure-combo-buttons";
|
||||
|
||||
interface ClaimFormProps {
|
||||
patientId: number;
|
||||
appointmentId?: number;
|
||||
autoSubmit?: boolean;
|
||||
onSubmit: (data: ClaimFormData) => Promise<Claim>;
|
||||
onHandleAppointmentSubmit: (
|
||||
appointmentData: InsertAppointment | UpdateAppointment
|
||||
appointmentData: InsertAppointment | UpdateAppointment,
|
||||
) => Promise<number | { id: number }>;
|
||||
onHandleUpdatePatient: (patient: UpdatePatient & { id: number }) => void;
|
||||
onHandleForMHSeleniumClaim: (data: ClaimFormData) => void;
|
||||
@@ -68,23 +74,10 @@ interface ClaimFormProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const PERMANENT_TOOTH_NAMES = Array.from(
|
||||
{ length: 32 },
|
||||
(_, i) => `T_${i + 1}`
|
||||
);
|
||||
const PRIMARY_TOOTH_NAMES = Array.from("ABCDEFGHIJKLMNOPQRST").map(
|
||||
(ch) => `T_${ch}`
|
||||
);
|
||||
|
||||
function isValidToothKey(key: string) {
|
||||
return (
|
||||
PERMANENT_TOOTH_NAMES.includes(key) || PRIMARY_TOOTH_NAMES.includes(key)
|
||||
);
|
||||
}
|
||||
|
||||
export function ClaimForm({
|
||||
patientId,
|
||||
appointmentId,
|
||||
autoSubmit,
|
||||
onHandleAppointmentSubmit,
|
||||
onHandleUpdatePatient,
|
||||
onHandleForMHSeleniumClaim,
|
||||
@@ -95,6 +88,9 @@ export function ClaimForm({
|
||||
const { toast } = useToast();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [prefillDone, setPrefillDone] = useState(false);
|
||||
const autoSubmittedRef = useRef(false);
|
||||
|
||||
const [patient, setPatient] = useState<Patient | null>(null);
|
||||
|
||||
// Query patient based on given patient id
|
||||
@@ -132,17 +128,50 @@ export function ClaimForm({
|
||||
useEffect(() => {
|
||||
if (staffMembersRaw.length > 0 && !staff) {
|
||||
const kaiGao = staffMembersRaw.find(
|
||||
(member) => member.name === "Kai Gao"
|
||||
(member) => member.name === "Kai Gao",
|
||||
);
|
||||
const defaultStaff = kaiGao || staffMembersRaw[0];
|
||||
if (defaultStaff) setStaff(defaultStaff);
|
||||
}
|
||||
}, [staffMembersRaw, staff]);
|
||||
|
||||
// fetching npi providers
|
||||
const { data: npiProviders = [] } = useQuery<NpiProvider[]>({
|
||||
queryKey: ["/api/npiProviders/"],
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest("GET", "/api/npiProviders/");
|
||||
if (!res.ok) throw new Error("Failed to fetch NPI providers");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!npiProviders.length) return;
|
||||
|
||||
// do not override if user already selected
|
||||
if (form.npiProvider?.npiNumber) return;
|
||||
|
||||
const kaiGaoNpi = npiProviders.find(
|
||||
(p) => p.providerName.toLowerCase() === "kai gao",
|
||||
);
|
||||
|
||||
const fallback = kaiGaoNpi || npiProviders[0];
|
||||
|
||||
if (fallback) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
npiProvider: {
|
||||
npiNumber: fallback.npiNumber,
|
||||
providerName: fallback.providerName,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}, [npiProviders]);
|
||||
|
||||
// Service date state
|
||||
const [serviceDateValue, setServiceDateValue] = useState<Date>(new Date());
|
||||
const [serviceDate, setServiceDate] = useState<string>(
|
||||
formatLocalDate(new Date())
|
||||
formatLocalDate(new Date()),
|
||||
);
|
||||
const [serviceDateOpen, setServiceDateOpen] = useState(false);
|
||||
const [openProcedureDateIndex, setOpenProcedureDateIndex] = useState<
|
||||
@@ -161,7 +190,7 @@ export function ClaimForm({
|
||||
try {
|
||||
const res = await apiRequest(
|
||||
"GET",
|
||||
`/api/appointments/${appointmentId}`
|
||||
`/api/appointments/${appointmentId}`,
|
||||
);
|
||||
if (!res.ok) {
|
||||
let body: any = null;
|
||||
@@ -200,7 +229,7 @@ export function ClaimForm({
|
||||
dateVal = new Date(
|
||||
maybe.getFullYear(),
|
||||
maybe.getMonth(),
|
||||
maybe.getDate()
|
||||
maybe.getDate(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -225,6 +254,53 @@ export function ClaimForm({
|
||||
};
|
||||
}, [appointmentId]);
|
||||
|
||||
//
|
||||
|
||||
// 2. effect - prefill proceduresCodes (if exists for appointment) into serviceLines
|
||||
useEffect(() => {
|
||||
if (!appointmentId) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiRequest(
|
||||
"GET",
|
||||
`/api/appointment-procedures/prefill-from-appointment/${appointmentId}`,
|
||||
);
|
||||
if (!res.ok) return;
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
const mappedLines = (data.procedures || []).map((p: any) => ({
|
||||
procedureCode: p.procedureCode,
|
||||
procedureDate: serviceDate,
|
||||
oralCavityArea: p.oralCavityArea || "",
|
||||
toothNumber: p.toothNumber || "",
|
||||
toothSurface: p.toothSurface || "",
|
||||
totalBilled: new Decimal(p.fee || 0),
|
||||
totalAdjusted: new Decimal(0),
|
||||
totalPaid: new Decimal(0),
|
||||
}));
|
||||
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
serviceLines: mappedLines,
|
||||
}));
|
||||
|
||||
setPrefillDone(true);
|
||||
} catch (err) {
|
||||
console.error("Failed to prefill procedures:", err);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [appointmentId, serviceDate]);
|
||||
|
||||
// Update service date when calendar date changes
|
||||
const onServiceDateChange = (date: Date | undefined) => {
|
||||
if (date) {
|
||||
@@ -391,7 +467,7 @@ export function ClaimForm({
|
||||
const updateServiceLine = (
|
||||
index: number,
|
||||
field: keyof InputServiceLine,
|
||||
value: any
|
||||
value: any,
|
||||
) => {
|
||||
const updatedLines = [...form.serviceLines];
|
||||
|
||||
@@ -439,21 +515,6 @@ export function ClaimForm({
|
||||
});
|
||||
};
|
||||
|
||||
const updateMissingTooth = useCallback(
|
||||
(name: string, value: "" | "X" | "O") => {
|
||||
if (!isValidToothKey(name)) return;
|
||||
setForm((prev) => {
|
||||
const current = prev.missingTeeth[name] ?? "";
|
||||
if (current === value) return prev;
|
||||
const nextMap = { ...prev.missingTeeth };
|
||||
if (!value) delete nextMap[name];
|
||||
else nextMap[name] = value;
|
||||
return { ...prev, missingTeeth: nextMap };
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const clearAllToothSelections = () =>
|
||||
setForm((prev) => ({ ...prev, missingTeeth: {} as MissingMapStrict }));
|
||||
|
||||
@@ -473,7 +534,7 @@ export function ClaimForm({
|
||||
mapPricesForForm({
|
||||
form: prev,
|
||||
patientDOB: patient?.dateOfBirth ?? "",
|
||||
})
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -488,7 +549,7 @@ export function ClaimForm({
|
||||
|
||||
// 1st Button workflow - Mass Health Button Handler
|
||||
const handleMHSubmit = async (
|
||||
formToUse?: ClaimFormData & { uploadedFiles?: File[] }
|
||||
formToUse?: ClaimFormData & { uploadedFiles?: File[] },
|
||||
) => {
|
||||
// Use the passed form, or fallback to current state
|
||||
const f = formToUse ?? form;
|
||||
@@ -511,7 +572,7 @@ export function ClaimForm({
|
||||
|
||||
// require at least one procedure code before proceeding
|
||||
const filteredServiceLines = (f.serviceLines || []).filter(
|
||||
(line) => (line.procedureCode ?? "").trim() !== ""
|
||||
(line) => (line.procedureCode ?? "").trim() !== "",
|
||||
);
|
||||
if (filteredServiceLines.length === 0) {
|
||||
toast({
|
||||
@@ -523,6 +584,15 @@ export function ClaimForm({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!f.npiProvider?.npiNumber) {
|
||||
toast({
|
||||
title: "NPI Provider Required",
|
||||
description: "Please select a NPI Provider.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Create or update appointment
|
||||
let appointmentIdToUse = appointmentId;
|
||||
|
||||
@@ -560,7 +630,12 @@ export function ClaimForm({
|
||||
|
||||
// 3. Create Claim(if not)
|
||||
// Filter out empty service lines (empty procedureCode)
|
||||
const { uploadedFiles, insuranceSiteKey, ...formToCreateClaim } = f;
|
||||
const {
|
||||
uploadedFiles,
|
||||
insuranceSiteKey,
|
||||
npiProvider,
|
||||
...formToCreateClaim
|
||||
} = f;
|
||||
|
||||
// build claimFiles metadata from uploadedFiles (only filename + mimeType)
|
||||
const claimFilesMeta: ClaimFileMeta[] = (uploadedFiles || []).map((f) => ({
|
||||
@@ -584,6 +659,7 @@ export function ClaimForm({
|
||||
dateOfBirth: toMMDDYYYY(f.dateOfBirth),
|
||||
serviceLines: filteredServiceLines,
|
||||
staffId: Number(staff?.id),
|
||||
npiProvider: f.npiProvider,
|
||||
patientId: patientId,
|
||||
insuranceProvider: "Mass Health",
|
||||
appointmentId: appointmentIdToUse!,
|
||||
@@ -597,7 +673,7 @@ export function ClaimForm({
|
||||
|
||||
// 2st Button workflow - Mass Health Pre Auth Button Handler
|
||||
const handleMHPreAuth = async (
|
||||
formToUse?: ClaimFormData & { uploadedFiles?: File[] }
|
||||
formToUse?: ClaimFormData & { uploadedFiles?: File[] },
|
||||
) => {
|
||||
// Use the passed form, or fallback to current state
|
||||
const f = formToUse ?? form;
|
||||
@@ -620,7 +696,7 @@ export function ClaimForm({
|
||||
|
||||
// require at least one procedure code before proceeding
|
||||
const filteredServiceLines = (f.serviceLines || []).filter(
|
||||
(line) => (line.procedureCode ?? "").trim() !== ""
|
||||
(line) => (line.procedureCode ?? "").trim() !== "",
|
||||
);
|
||||
if (filteredServiceLines.length === 0) {
|
||||
toast({
|
||||
@@ -632,6 +708,15 @@ export function ClaimForm({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!f.npiProvider?.npiNumber) {
|
||||
toast({
|
||||
title: "NPI Provider Required",
|
||||
description: "Please select a NPI Provider.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Update patient
|
||||
if (patient && typeof patient.id === "number") {
|
||||
const { id, createdAt, userId, ...sanitizedFields } = patient;
|
||||
@@ -655,6 +740,7 @@ export function ClaimForm({
|
||||
dateOfBirth: toMMDDYYYY(f.dateOfBirth),
|
||||
serviceLines: filteredServiceLines,
|
||||
staffId: Number(staff?.id),
|
||||
npiProvider: f.npiProvider,
|
||||
patientId: patientId,
|
||||
insuranceProvider: "Mass Health",
|
||||
insuranceSiteKey: "MH",
|
||||
@@ -684,7 +770,7 @@ export function ClaimForm({
|
||||
|
||||
// require at least one procedure code before proceeding
|
||||
const filteredServiceLines = (form.serviceLines || []).filter(
|
||||
(line) => (line.procedureCode ?? "").trim() !== ""
|
||||
(line) => (line.procedureCode ?? "").trim() !== "",
|
||||
);
|
||||
if (filteredServiceLines.length === 0) {
|
||||
toast({
|
||||
@@ -757,13 +843,13 @@ export function ClaimForm({
|
||||
|
||||
// for direct combo button.
|
||||
const applyComboAndThenMH = async (
|
||||
comboId: keyof typeof PROCEDURE_COMBOS
|
||||
comboId: keyof typeof PROCEDURE_COMBOS,
|
||||
) => {
|
||||
const nextForm = applyComboToForm(
|
||||
form,
|
||||
comboId,
|
||||
patient?.dateOfBirth ?? "",
|
||||
{ replaceAll: false, lineDate: form.serviceDate }
|
||||
{ replaceAll: false, lineDate: form.serviceDate },
|
||||
);
|
||||
|
||||
setForm(nextForm);
|
||||
@@ -772,6 +858,37 @@ export function ClaimForm({
|
||||
await handleMHSubmit(nextForm);
|
||||
};
|
||||
|
||||
const isFormReady = useMemo(() => {
|
||||
return (
|
||||
!!patient &&
|
||||
!!form.memberId?.trim() &&
|
||||
!!form.dateOfBirth?.trim() &&
|
||||
!!form.patientName?.trim() &&
|
||||
Array.isArray(form.serviceLines) &&
|
||||
form.serviceLines.some(
|
||||
(l) => l.procedureCode && l.procedureCode.trim() !== "",
|
||||
)
|
||||
);
|
||||
}, [
|
||||
patient,
|
||||
form.memberId,
|
||||
form.dateOfBirth,
|
||||
form.patientName,
|
||||
form.serviceLines,
|
||||
]);
|
||||
|
||||
// when autoSubmit mode is given, it will then submit the claims.
|
||||
useEffect(() => {
|
||||
if (!autoSubmit) return;
|
||||
if (!prefillDone) return;
|
||||
if (!isFormReady) return;
|
||||
|
||||
if (autoSubmittedRef.current) return;
|
||||
autoSubmittedRef.current = true;
|
||||
|
||||
handleMHSubmit();
|
||||
}, [autoSubmit, prefillDone, isFormReady]);
|
||||
|
||||
// overlay click handler (close when clicking backdrop)
|
||||
const onOverlayMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// only close if clicked the backdrop itself (not inner modal)
|
||||
@@ -780,6 +897,14 @@ export function ClaimForm({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// reset when ClaimForm unmounts (modal closes)
|
||||
autoSubmittedRef.current = false;
|
||||
setPrefillDone(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 overflow-y-auto"
|
||||
@@ -901,7 +1026,7 @@ export function ClaimForm({
|
||||
value={staff?.id?.toString() || ""}
|
||||
onValueChange={(id) => {
|
||||
const selected = staffMembersRaw.find(
|
||||
(member) => member.id?.toString() === id
|
||||
(member) => member.id?.toString() === id,
|
||||
);
|
||||
if (selected) {
|
||||
setStaff(selected);
|
||||
@@ -934,6 +1059,42 @@ export function ClaimForm({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Rendering Npi Provider */}
|
||||
<Label className="flex items-center ml-2">
|
||||
Rendering Provider
|
||||
</Label>
|
||||
|
||||
<Select
|
||||
value={form.npiProvider?.npiNumber || ""}
|
||||
onValueChange={(npiNumber) => {
|
||||
const selected = npiProviders.find(
|
||||
(p) => p.npiNumber === npiNumber,
|
||||
);
|
||||
if (!selected) return;
|
||||
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
npiProvider: {
|
||||
// ✅ CORRECT KEY
|
||||
npiNumber: selected.npiNumber,
|
||||
providerName: selected.providerName,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-56">
|
||||
<SelectValue placeholder="Select NPI Provider" />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
{npiProviders.map((p) => (
|
||||
<SelectItem key={p.id} value={p.npiNumber}>
|
||||
{p.npiNumber} — {p.providerName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Map Price Button */}
|
||||
<Button
|
||||
className="ml-4"
|
||||
@@ -945,163 +1106,11 @@ export function ClaimForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Section Title */}
|
||||
<div className="text-sm font-semibold text-muted-foreground">
|
||||
Direct Claim Submission Buttons
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* CHILD RECALL GROUP */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium opacity-80">
|
||||
Child Recall
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
"childRecallDirect",
|
||||
"childRecallDirect2BW",
|
||||
"childRecallDirect4BW",
|
||||
"childRecallDirect2PA2BW",
|
||||
"childRecallDirect2PA4BW",
|
||||
"childRecallDirect3PA2BW",
|
||||
"childRecallDirect3PA",
|
||||
"childRecallDirect4PA",
|
||||
"childRecallDirectPANO",
|
||||
].map((comboId) => {
|
||||
const b = PROCEDURE_COMBOS[comboId];
|
||||
if (!b) return null;
|
||||
const codesWithTooth = b.codes.map((code, idx) => {
|
||||
const tooth = b.toothNumbers?.[idx];
|
||||
return tooth ? `${code} (tooth ${tooth})` : code;
|
||||
});
|
||||
const tooltipText = codesWithTooth.join(", ");
|
||||
const labelMap: Record<string, string> = {
|
||||
childRecallDirect: "Direct",
|
||||
childRecallDirect2BW: "Direct 2BW",
|
||||
childRecallDirect4BW: "Direct 4BW",
|
||||
childRecallDirect2PA2BW: "Direct 2PA 2BW",
|
||||
childRecallDirect2PA4BW: "Direct 2PA 4BW",
|
||||
childRecallDirect3PA2BW: "Direct 3PA 2BW",
|
||||
childRecallDirect3PA: "Direct 3PA",
|
||||
childRecallDirect4PA: "Direct 4PA",
|
||||
childRecallDirectPANO: "Direct Pano",
|
||||
};
|
||||
return (
|
||||
<Tooltip key={b.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => applyComboAndThenMH(b.id)}
|
||||
aria-label={`${b.label} — codes: ${tooltipText}`}
|
||||
>
|
||||
{labelMap[comboId] ?? b.label}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center">
|
||||
<div className="text-sm max-w-xs break-words">
|
||||
{tooltipText}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ADULT RECALL GROUP */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium opacity-80">
|
||||
Adult Recall
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
"adultRecallDirect",
|
||||
"adultRecallDirect2BW",
|
||||
"adultRecallDirect4BW",
|
||||
"adultRecallDirect2PA2BW",
|
||||
"adultRecallDirect2PA4BW",
|
||||
"adultRecallDirect4PA",
|
||||
"adultRecallDirectPano",
|
||||
].map((comboId) => {
|
||||
const b = PROCEDURE_COMBOS[comboId];
|
||||
if (!b) return null;
|
||||
const codesWithTooth = b.codes.map((code, idx) => {
|
||||
const tooth = b.toothNumbers?.[idx];
|
||||
return tooth ? `${code} (tooth ${tooth})` : code;
|
||||
});
|
||||
const tooltipText = codesWithTooth.join(", ");
|
||||
const labelMap: Record<string, string> = {
|
||||
adultRecallDirect: "Direct",
|
||||
adultRecallDirect2BW: "Direct 2BW",
|
||||
adultRecallDirect4BW: "Direct 4BW",
|
||||
adultRecallDirect2PA2BW: "Direct 2PA 2BW",
|
||||
adultRecallDirect2PA4BW: "Direct 2PA 4BW",
|
||||
adultRecallDirect4PA: "Direct 4PA",
|
||||
adultRecallDirectPano: "Direct Pano",
|
||||
};
|
||||
return (
|
||||
<Tooltip key={b.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => applyComboAndThenMH(b.id)}
|
||||
aria-label={`${b.label} — codes: ${tooltipText}`}
|
||||
>
|
||||
{labelMap[comboId] ?? b.label}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center">
|
||||
<div className="text-sm max-w-xs break-words">
|
||||
{tooltipText}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ORTH GROUP */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium opacity-80">Orth</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
"orthPreExamDirect",
|
||||
"orthRecordDirect",
|
||||
"orthPerioVisitDirect",
|
||||
"orthRetentionDirect",
|
||||
].map((comboId) => {
|
||||
const b = PROCEDURE_COMBOS[comboId];
|
||||
if (!b) return null;
|
||||
|
||||
const tooltipText = b.codes.join(", ");
|
||||
|
||||
return (
|
||||
<Tooltip key={b.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => applyComboAndThenMH(b.id)}
|
||||
aria-label={`${b.label} — codes: ${tooltipText}`}
|
||||
>
|
||||
{b.label}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent side="top" align="center">
|
||||
<div className="text-sm max-w-xs break-words">
|
||||
{tooltipText}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DirectComboButtons
|
||||
onDirectCombo={(comboKey) =>
|
||||
applyComboAndThenMH(comboKey as any)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
@@ -1159,7 +1168,7 @@ export function ClaimForm({
|
||||
updateServiceLine(
|
||||
i,
|
||||
"procedureCode",
|
||||
e.target.value.toUpperCase()
|
||||
e.target.value.toUpperCase(),
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -1246,7 +1255,7 @@ export function ClaimForm({
|
||||
updateServiceLine(
|
||||
i,
|
||||
"totalBilled",
|
||||
isNaN(rounded) ? 0 : rounded
|
||||
isNaN(rounded) ? 0 : rounded,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
@@ -1279,66 +1288,24 @@ export function ClaimForm({
|
||||
+ Add Service Line
|
||||
</Button>
|
||||
|
||||
<div className="space-y-4 mt-8">
|
||||
{Object.entries(COMBO_CATEGORIES).map(([section, ids]) => (
|
||||
<div key={section}>
|
||||
<div className="mb-3 text-sm font-semibold opacity-70">
|
||||
{section}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ids.map((id) => {
|
||||
const b = PROCEDURE_COMBOS[id];
|
||||
if (!b) {
|
||||
return;
|
||||
}
|
||||
// Build a human readable string for the tooltip
|
||||
const codesWithTooth = b.codes.map((code, idx) => {
|
||||
const tooth = b.toothNumbers?.[idx];
|
||||
return tooth ? `${code} (tooth ${tooth})` : code;
|
||||
});
|
||||
const tooltipText = codesWithTooth.join(", ");
|
||||
<RegularComboButtons
|
||||
onRegularCombo={(comboKey) => {
|
||||
setForm((prev) => {
|
||||
const next = applyComboToForm(
|
||||
prev,
|
||||
comboKey as any,
|
||||
patient?.dateOfBirth ?? "",
|
||||
{
|
||||
replaceAll: false,
|
||||
lineDate: prev.serviceDate,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip key={b.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
key={b.id}
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
setForm((prev) => {
|
||||
const next = applyComboToForm(
|
||||
prev,
|
||||
b.id as any,
|
||||
patient?.dateOfBirth ?? "",
|
||||
{
|
||||
replaceAll: false,
|
||||
lineDate: prev.serviceDate,
|
||||
}
|
||||
);
|
||||
|
||||
setTimeout(() => scrollToLine(0), 0);
|
||||
|
||||
return next;
|
||||
})
|
||||
}
|
||||
aria-label={`${b.label} — codes: ${tooltipText}`}
|
||||
>
|
||||
{b.label}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent side="top" align="center">
|
||||
<div className="text-sm max-w-xs break-words">
|
||||
{tooltipText}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
setTimeout(() => scrollToLine(0), 0);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* File Upload Section */}
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { io as ioClient, Socket } from "socket.io-client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { CheckCircle, LoaderCircleIcon, X } 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 : "");
|
||||
|
||||
// ---------- OTP Modal component ----------
|
||||
interface DentaQuestOtpModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (otp: string) => Promise<void> | void;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
function DentaQuestOtpModal({
|
||||
open,
|
||||
onClose,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
}: DentaQuestOtpModalProps) {
|
||||
const [otp, setOtp] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setOtp("");
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!otp.trim()) return;
|
||||
await onSubmit(otp.trim());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white dark:bg-slate-900 rounded-xl shadow-lg w-full max-w-md p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Enter OTP</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-slate-500 hover:text-slate-800"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mb-4">
|
||||
We need the one-time password (OTP) sent by the DentaQuest portal
|
||||
to complete this eligibility check.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dentaquest-otp">OTP</Label>
|
||||
<Input
|
||||
id="dentaquest-otp"
|
||||
placeholder="Enter OTP code"
|
||||
value={otp}
|
||||
onChange={(e) => setOtp(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting || !otp.trim()}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<LoaderCircleIcon className="w-4 h-4 mr-2 animate-spin" />
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
"Submit OTP"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Main DentaQuest Eligibility button component ----------
|
||||
interface DentaQuestEligibilityButtonProps {
|
||||
memberId: string;
|
||||
dateOfBirth: Date | null;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
isFormIncomplete: boolean;
|
||||
/** Called when backend has finished and PDF is ready */
|
||||
onPdfReady: (pdfId: number, fallbackFilename: string | null) => void;
|
||||
}
|
||||
|
||||
export function DentaQuestEligibilityButton({
|
||||
memberId,
|
||||
dateOfBirth,
|
||||
firstName,
|
||||
lastName,
|
||||
isFormIncomplete,
|
||||
onPdfReady,
|
||||
}: DentaQuestEligibilityButtonProps) {
|
||||
const { toast } = useToast();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const connectingRef = useRef<Promise<void> | null>(null);
|
||||
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [otpModalOpen, setOtpModalOpen] = useState(false);
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
const [isSubmittingOtp, setIsSubmittingOtp] = useState(false);
|
||||
|
||||
// Clean up socket on unmount
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
// Lazy socket setup: called only when we actually need it (first click)
|
||||
const ensureSocketConnected = async () => {
|
||||
// If already connected, nothing to do
|
||||
if (socketRef.current && socketRef.current.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If a connection is in progress, reuse that promise
|
||||
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();
|
||||
});
|
||||
|
||||
// connection error when first connecting (or later)
|
||||
socket.on("connect_error", (err: any) => {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "error",
|
||||
message: "Connection failed",
|
||||
})
|
||||
);
|
||||
toast({
|
||||
title: "Realtime connection failed",
|
||||
description:
|
||||
"Could not connect to realtime server. Retrying automatically...",
|
||||
variant: "destructive",
|
||||
});
|
||||
// do not reject here because socket.io will attempt reconnection
|
||||
});
|
||||
|
||||
// socket.io will emit 'reconnect_attempt' for retries
|
||||
socket.on("reconnect_attempt", (attempt: number) => {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "pending",
|
||||
message: `Realtime reconnect attempt #${attempt}`,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// when reconnection failed after configured attempts
|
||||
socket.on("reconnect_failed", () => {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "error",
|
||||
message: "Reconnect failed",
|
||||
})
|
||||
);
|
||||
toast({
|
||||
title: "Realtime reconnect failed",
|
||||
description:
|
||||
"Connection to realtime server could not be re-established. Please try again later.",
|
||||
variant: "destructive",
|
||||
});
|
||||
// terminal failure — cleanup and reject so caller can stop start flow
|
||||
closeSocket();
|
||||
reject(new Error("Realtime reconnect failed"));
|
||||
});
|
||||
|
||||
socket.on("disconnect", (reason: any) => {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "error",
|
||||
message: "Connection disconnected",
|
||||
})
|
||||
);
|
||||
toast({
|
||||
title: "Connection Disconnected",
|
||||
description:
|
||||
"Connection to the server was lost. If a DentaQuest job was running it may have failed.",
|
||||
variant: "destructive",
|
||||
});
|
||||
// clear sessionId/OTP modal
|
||||
setSessionId(null);
|
||||
setOtpModalOpen(false);
|
||||
});
|
||||
|
||||
// OTP required
|
||||
socket.on("selenium:otp_required", (payload: any) => {
|
||||
if (!payload?.session_id) return;
|
||||
setSessionId(payload.session_id);
|
||||
setOtpModalOpen(true);
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "pending",
|
||||
message: "OTP required for DentaQuest eligibility. Please enter the OTP.",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// OTP submitted (optional UX)
|
||||
socket.on("selenium:otp_submitted", (payload: any) => {
|
||||
if (!payload?.session_id) return;
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "pending",
|
||||
message: "OTP submitted. Finishing DentaQuest eligibility check...",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Session update
|
||||
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:
|
||||
"DentaQuest eligibility updated and PDF attached to patient documents.",
|
||||
})
|
||||
);
|
||||
toast({
|
||||
title: "DentaQuest 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_dentaquest_${memberId}.pdf`;
|
||||
onPdfReady(Number(pdfId), filename);
|
||||
}
|
||||
|
||||
setSessionId(null);
|
||||
setOtpModalOpen(false);
|
||||
} else if (status === "error") {
|
||||
const msg =
|
||||
payload?.message ||
|
||||
final?.error ||
|
||||
"DentaQuest eligibility session failed.";
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "error",
|
||||
message: msg,
|
||||
})
|
||||
);
|
||||
toast({
|
||||
title: "DentaQuest selenium error",
|
||||
description: msg,
|
||||
variant: "destructive",
|
||||
});
|
||||
|
||||
// Ensure socket is torn down for this session (stop receiving stale events)
|
||||
try {
|
||||
closeSocket();
|
||||
} catch (e) {}
|
||||
setSessionId(null);
|
||||
setOtpModalOpen(false);
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
|
||||
});
|
||||
|
||||
// explicit session error event (helpful)
|
||||
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",
|
||||
});
|
||||
|
||||
// tear down socket to avoid stale updates
|
||||
try {
|
||||
closeSocket();
|
||||
} catch (e) {}
|
||||
setSessionId(null);
|
||||
setOtpModalOpen(false);
|
||||
});
|
||||
|
||||
// If socket.io initial connection fails permanently (very rare: client-level)
|
||||
// set a longer timeout to reject the first attempt to connect.
|
||||
const initialConnectTimeout = setTimeout(() => {
|
||||
if (!socket.connected) {
|
||||
// if still not connected after 8s, treat as failure and reject so caller can handle it
|
||||
closeSocket();
|
||||
reject(new Error("Realtime initial connection timeout"));
|
||||
}
|
||||
}, 8000);
|
||||
|
||||
// When the connect resolves we should clear this timer
|
||||
socket.once("connect", () => {
|
||||
clearTimeout(initialConnectTimeout);
|
||||
});
|
||||
});
|
||||
|
||||
// store promise to prevent multiple concurrent connections
|
||||
connectingRef.current = promise;
|
||||
|
||||
try {
|
||||
await promise;
|
||||
} finally {
|
||||
connectingRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const startDentaQuestEligibility = async () => {
|
||||
if (!memberId || !dateOfBirth) {
|
||||
toast({
|
||||
title: "Missing fields",
|
||||
description: "Member ID and Date of Birth are required.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const formattedDob = dateOfBirth ? formatLocalDate(dateOfBirth) : "";
|
||||
|
||||
const payload = {
|
||||
memberId,
|
||||
dateOfBirth: formattedDob,
|
||||
firstName,
|
||||
lastName,
|
||||
insuranceSiteKey: "DENTAQUEST", // make sure this matches backend credential key
|
||||
};
|
||||
|
||||
try {
|
||||
setIsStarting(true);
|
||||
|
||||
// 1) Ensure socket is connected (lazy)
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "pending",
|
||||
message: "Opening realtime channel for DentaQuest eligibility...",
|
||||
})
|
||||
);
|
||||
await ensureSocketConnected();
|
||||
|
||||
const socket = socketRef.current;
|
||||
if (!socket || !socket.connected) {
|
||||
throw new Error("Socket connection failed");
|
||||
}
|
||||
|
||||
const socketId = socket.id;
|
||||
|
||||
// 2) Start the selenium job via backend
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "pending",
|
||||
message: "Starting DentaQuest eligibility check via selenium...",
|
||||
})
|
||||
);
|
||||
|
||||
const response = await apiRequest(
|
||||
"POST",
|
||||
"/api/insurance-status-dentaquest/dentaquest-eligibility",
|
||||
{
|
||||
data: JSON.stringify(payload),
|
||||
socketId,
|
||||
}
|
||||
);
|
||||
|
||||
// If apiRequest threw, we would have caught above; but just in case it returns.
|
||||
let result: any = null;
|
||||
let backendError: string | null = null;
|
||||
|
||||
try {
|
||||
// attempt JSON first
|
||||
result = await response.clone().json();
|
||||
backendError =
|
||||
result?.error || result?.message || result?.detail || null;
|
||||
} catch {
|
||||
// fallback to text response
|
||||
try {
|
||||
const text = await response.clone().text();
|
||||
backendError = text?.trim() || null;
|
||||
} catch {
|
||||
backendError = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
backendError ||
|
||||
`DentaQuest selenium start failed (status ${response.status})`
|
||||
);
|
||||
}
|
||||
|
||||
// Normal success path: optional: if backend returns non-error shape still check for result.error
|
||||
if (result?.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
if (result.status === "started" && result.session_id) {
|
||||
setSessionId(result.session_id as string);
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "pending",
|
||||
message:
|
||||
"DentaQuest eligibility job started. Waiting for OTP or final result...",
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// fallback if backend returns immediate result
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "success",
|
||||
message: "DentaQuest eligibility completed.",
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("startDentaQuestEligibility error:", err);
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "error",
|
||||
message: err?.message || "Failed to start DentaQuest eligibility",
|
||||
})
|
||||
);
|
||||
toast({
|
||||
title: "DentaQuest selenium error",
|
||||
description: err?.message || "Failed to start DentaQuest eligibility",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitOtp = async (otp: string) => {
|
||||
if (!sessionId || !socketRef.current || !socketRef.current.connected) {
|
||||
toast({
|
||||
title: "Session not ready",
|
||||
description:
|
||||
"Could not submit OTP because the DentaQuest session or socket is not ready.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmittingOtp(true);
|
||||
const resp = await apiRequest(
|
||||
"POST",
|
||||
"/api/insurance-status-dentaquest/selenium/submit-otp",
|
||||
{
|
||||
session_id: sessionId,
|
||||
otp,
|
||||
socketId: socketRef.current.id,
|
||||
}
|
||||
);
|
||||
const data = await resp.json();
|
||||
if (!resp.ok || data.error) {
|
||||
throw new Error(data.error || "Failed to submit OTP");
|
||||
}
|
||||
|
||||
// from here we rely on websocket events (otp_submitted + session_update)
|
||||
setOtpModalOpen(false);
|
||||
} catch (err: any) {
|
||||
console.error("handleSubmitOtp error:", err);
|
||||
toast({
|
||||
title: "Failed to submit OTP",
|
||||
description: err?.message || "Error forwarding OTP to selenium agent",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmittingOtp(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
disabled={isFormIncomplete || isStarting}
|
||||
onClick={startDentaQuestEligibility}
|
||||
>
|
||||
{isStarting ? (
|
||||
<>
|
||||
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Tufts SCO/SWH/Navi/Mass Gen
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<DentaQuestOtpModal
|
||||
open={otpModalOpen}
|
||||
onClose={() => setOtpModalOpen(false)}
|
||||
onSubmit={handleSubmitOtp}
|
||||
isSubmitting={isSubmittingOtp}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
PROCEDURE_COMBOS,
|
||||
COMBO_CATEGORIES,
|
||||
} from "@/utils/procedureCombos";
|
||||
|
||||
/* =========================================================
|
||||
DIRECT COMBO BUTTONS (TOP SECTION)
|
||||
========================================================= */
|
||||
|
||||
export function DirectComboButtons({
|
||||
onDirectCombo,
|
||||
}: {
|
||||
onDirectCombo: (comboKey: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Section Title */}
|
||||
<div className="text-sm font-semibold text-muted-foreground">
|
||||
Direct Claim Submission Buttons
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* CHILD RECALL */}
|
||||
<DirectGroup
|
||||
title="Child Recall"
|
||||
combos={[
|
||||
"childRecallDirect",
|
||||
"childRecallDirect2BW",
|
||||
"childRecallDirect4BW",
|
||||
"childRecallDirect2PA2BW",
|
||||
"childRecallDirect2PA4BW",
|
||||
"childRecallDirect3PA2BW",
|
||||
"childRecallDirect3PA",
|
||||
"childRecallDirect4PA",
|
||||
"childRecallDirectPANO",
|
||||
]}
|
||||
labelMap={{
|
||||
childRecallDirect: "Direct",
|
||||
childRecallDirect2BW: "Direct 2BW",
|
||||
childRecallDirect4BW: "Direct 4BW",
|
||||
childRecallDirect2PA2BW: "Direct 2PA 2BW",
|
||||
childRecallDirect2PA4BW: "Direct 2PA 4BW",
|
||||
childRecallDirect3PA2BW: "Direct 3PA 2BW",
|
||||
childRecallDirect3PA: "Direct 3PA",
|
||||
childRecallDirect4PA: "Direct 4PA",
|
||||
childRecallDirectPANO: "Direct Pano",
|
||||
}}
|
||||
onSelect={onDirectCombo}
|
||||
/>
|
||||
|
||||
{/* ADULT RECALL */}
|
||||
<DirectGroup
|
||||
title="Adult Recall"
|
||||
combos={[
|
||||
"adultRecallDirect",
|
||||
"adultRecallDirect2BW",
|
||||
"adultRecallDirect4BW",
|
||||
"adultRecallDirect2PA2BW",
|
||||
"adultRecallDirect2PA4BW",
|
||||
"adultRecallDirect4PA",
|
||||
"adultRecallDirectPano",
|
||||
]}
|
||||
labelMap={{
|
||||
adultRecallDirect: "Direct",
|
||||
adultRecallDirect2BW: "Direct 2BW",
|
||||
adultRecallDirect4BW: "Direct 4BW",
|
||||
adultRecallDirect2PA2BW: "Direct 2PA 2BW",
|
||||
adultRecallDirect2PA4BW: "Direct 2PA 4BW",
|
||||
adultRecallDirect4PA: "Direct 4PA",
|
||||
adultRecallDirectPano: "Direct Pano",
|
||||
}}
|
||||
onSelect={onDirectCombo}
|
||||
/>
|
||||
|
||||
{/* ORTH */}
|
||||
<DirectGroup
|
||||
title="Orth"
|
||||
combos={[
|
||||
"orthPreExamDirect",
|
||||
"orthRecordDirect",
|
||||
"orthPerioVisitDirect",
|
||||
"orthRetentionDirect",
|
||||
]}
|
||||
onSelect={onDirectCombo}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
REGULAR COMBO BUTTONS (BOTTOM SECTION)
|
||||
========================================================= */
|
||||
|
||||
export function RegularComboButtons({
|
||||
onRegularCombo,
|
||||
}: {
|
||||
onRegularCombo: (comboKey: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4 mt-8">
|
||||
{Object.entries(COMBO_CATEGORIES).map(([section, ids]) => (
|
||||
<div key={section}>
|
||||
<div className="mb-3 text-sm font-semibold opacity-70">
|
||||
{section}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ids.map((id) => {
|
||||
const b = PROCEDURE_COMBOS[id];
|
||||
if (!b) return null;
|
||||
|
||||
const tooltipText = b.codes
|
||||
.map((code, idx) => {
|
||||
const tooth = b.toothNumbers?.[idx];
|
||||
return tooth ? `${code} (tooth ${tooth})` : code;
|
||||
})
|
||||
.join(", ");
|
||||
|
||||
return (
|
||||
<Tooltip key={id}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => onRegularCombo(id)}
|
||||
aria-label={`${b.label} — codes: ${tooltipText}`}
|
||||
>
|
||||
{b.label}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent side="top" align="center">
|
||||
<div className="text-sm max-w-xs break-words">
|
||||
{tooltipText}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
INTERNAL HELPERS
|
||||
========================================================= */
|
||||
|
||||
function DirectGroup({
|
||||
title,
|
||||
combos,
|
||||
labelMap,
|
||||
onSelect,
|
||||
}: {
|
||||
title: string;
|
||||
combos: string[];
|
||||
labelMap?: Record<string, string>;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium opacity-80">{title}</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{combos.map((id) => {
|
||||
const b = PROCEDURE_COMBOS[id];
|
||||
if (!b) return null;
|
||||
|
||||
const tooltipText = b.codes
|
||||
.map((code, idx) => {
|
||||
const tooth = b.toothNumbers?.[idx];
|
||||
return tooth ? `${code} (tooth ${tooth})` : code;
|
||||
})
|
||||
.join(", ");
|
||||
|
||||
return (
|
||||
<Tooltip key={id}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => onSelect(id)}
|
||||
aria-label={`${b.label} — codes: ${tooltipText}`}
|
||||
>
|
||||
{labelMap?.[id] ?? b.label}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent side="top" align="center">
|
||||
<div className="text-sm max-w-xs break-words">
|
||||
{tooltipText}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,13 @@ import { useEffect, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
type CredentialFormProps = {
|
||||
onClose: () => void;
|
||||
@@ -14,7 +21,16 @@ type CredentialFormProps = {
|
||||
};
|
||||
};
|
||||
|
||||
export function CredentialForm({ onClose, userId, defaultValues }: CredentialFormProps) {
|
||||
const SITE_KEYS = [
|
||||
{ label: "MassHealth (MH)", value: "MH" },
|
||||
{ label: "Delta Dental MA", value: "DDMA" },
|
||||
];
|
||||
|
||||
export function CredentialForm({
|
||||
onClose,
|
||||
userId,
|
||||
defaultValues,
|
||||
}: CredentialFormProps) {
|
||||
const [siteKey, setSiteKey] = useState(defaultValues?.siteKey || "");
|
||||
const [username, setUsername] = useState(defaultValues?.username || "");
|
||||
const [password, setPassword] = useState(defaultValues?.password || "");
|
||||
@@ -92,13 +108,19 @@ export function CredentialForm({ onClose, userId, defaultValues }: CredentialFor
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium">Site Key</label>
|
||||
<input
|
||||
type="text"
|
||||
value={siteKey}
|
||||
onChange={(e) => setSiteKey(e.target.value)}
|
||||
className="mt-1 p-2 border rounded w-full"
|
||||
placeholder="e.g., MH, Delta MA, (keep the site key exact same)"
|
||||
/>
|
||||
<Select value={siteKey} onValueChange={setSiteKey}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="Select Site" />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
{SITE_KEYS.map((site) => (
|
||||
<SelectItem key={site.value} value={site.value}>
|
||||
{site.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium">Username</label>
|
||||
@@ -137,8 +159,8 @@ export function CredentialForm({ onClose, userId, defaultValues }: CredentialFor
|
||||
? "Updating..."
|
||||
: "Creating..."
|
||||
: defaultValues?.id
|
||||
? "Update"
|
||||
: "Create"}
|
||||
? "Update"
|
||||
: "Create"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
149
apps/Frontend/src/components/settings/npiProviderForm.tsx
Normal file
149
apps/Frontend/src/components/settings/npiProviderForm.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
|
||||
type Props = {
|
||||
onClose: () => void;
|
||||
defaultValues?: {
|
||||
id?: number;
|
||||
npiNumber: string;
|
||||
providerName: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function NpiProviderForm({ onClose, defaultValues }: Props) {
|
||||
const [npiNumber, setNpiNumber] = useState(
|
||||
defaultValues?.npiNumber || ""
|
||||
);
|
||||
const [providerName, setProviderName] = useState(
|
||||
defaultValues?.providerName || ""
|
||||
);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload = {
|
||||
npiNumber: npiNumber.trim(),
|
||||
providerName: providerName.trim(),
|
||||
};
|
||||
|
||||
const url = defaultValues?.id
|
||||
? `/api/npiProviders/${defaultValues.id}`
|
||||
: "/api/npiProviders/";
|
||||
|
||||
const method = defaultValues?.id ? "PUT" : "POST";
|
||||
|
||||
const res = await apiRequest(method, url, payload);
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => null);
|
||||
throw new Error(err?.message || "Failed to save NPI provider");
|
||||
}
|
||||
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: `NPI provider ${
|
||||
defaultValues?.id ? "updated" : "created"
|
||||
}.`,
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["/api/npiProviders/"],
|
||||
});
|
||||
onClose();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setNpiNumber(defaultValues?.npiNumber || "");
|
||||
setProviderName(defaultValues?.providerName || "");
|
||||
}, [defaultValues]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!npiNumber || !providerName) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "All fields are required.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
mutation.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-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">
|
||||
{defaultValues?.id
|
||||
? "Edit NPI Provider"
|
||||
: "Create NPI Provider"}
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium">
|
||||
NPI Number
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={npiNumber}
|
||||
onChange={(e) => setNpiNumber(e.target.value)}
|
||||
className="mt-1 p-2 border rounded w-full"
|
||||
placeholder="e.g., 1489890992"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium">
|
||||
Provider Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={providerName}
|
||||
onChange={(e) => setProviderName(e.target.value)}
|
||||
className="mt-1 p-2 border rounded w-full"
|
||||
placeholder="e.g., Kai Gao"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-gray-600 hover:underline"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={mutation.isPending}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{mutation.isPending
|
||||
? defaultValues?.id
|
||||
? "Updating..."
|
||||
: "Creating..."
|
||||
: defaultValues?.id
|
||||
? "Update"
|
||||
: "Create"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
199
apps/Frontend/src/components/settings/npiProviderTable.tsx
Normal file
199
apps/Frontend/src/components/settings/npiProviderTable.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import React, { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { Button } from "../ui/button";
|
||||
import { Edit, Delete, Plus } from "lucide-react";
|
||||
import { DeleteConfirmationDialog } from "../ui/deleteDialog";
|
||||
import { NpiProviderForm } from "./npiProviderForm";
|
||||
|
||||
type NpiProvider = {
|
||||
id: number;
|
||||
npiNumber: string;
|
||||
providerName: string;
|
||||
};
|
||||
|
||||
export function NpiProviderTable() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingProvider, setEditingProvider] =
|
||||
useState<NpiProvider | null>(null);
|
||||
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [providerToDelete, setProviderToDelete] =
|
||||
useState<NpiProvider | null>(null);
|
||||
|
||||
const providersPerPage = 5;
|
||||
|
||||
const {
|
||||
data: providers = [],
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["/api/npiProviders/"],
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest("GET", "/api/npiProviders/");
|
||||
if (!res.ok) throw new Error("Failed to fetch NPI providers");
|
||||
return res.json() as Promise<NpiProvider[]>;
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (provider: NpiProvider) => {
|
||||
const res = await apiRequest(
|
||||
"DELETE",
|
||||
`/api/npiProviders/${provider.id}`
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed to delete NPI provider");
|
||||
return true;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["/api/npiProviders/"],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleDeleteClick = (provider: NpiProvider) => {
|
||||
setProviderToDelete(provider);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
if (!providerToDelete) return;
|
||||
|
||||
deleteMutation.mutate(providerToDelete, {
|
||||
onSuccess: () => {
|
||||
setIsDeleteDialogOpen(false);
|
||||
setProviderToDelete(null);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const indexOfLast = currentPage * providersPerPage;
|
||||
const indexOfFirst = indexOfLast - providersPerPage;
|
||||
const currentProviders = providers.slice(
|
||||
indexOfFirst,
|
||||
indexOfLast
|
||||
);
|
||||
const totalPages = Math.ceil(providers.length / providersPerPage);
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||
<div className="flex justify-between items-center p-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
NPI Providers
|
||||
</h2>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditingProvider(null);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" /> Add NPI Provider
|
||||
</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">
|
||||
NPI Number
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Provider Name
|
||||
</th>
|
||||
<th className="px-4 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={3} className="text-center py-4">
|
||||
Loading NPI providers...
|
||||
</td>
|
||||
</tr>
|
||||
) : error ? (
|
||||
<tr>
|
||||
<td colSpan={3} className="text-center py-4 text-red-600">
|
||||
Error loading NPI providers
|
||||
</td>
|
||||
</tr>
|
||||
) : currentProviders.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={3} className="text-center py-4">
|
||||
No NPI providers found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
currentProviders.map((provider) => (
|
||||
<tr key={provider.id}>
|
||||
<td className="px-4 py-2">
|
||||
{provider.npiNumber}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{provider.providerName}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingProvider(provider);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteClick(provider)}
|
||||
>
|
||||
<Delete className="h-4 w-4 text-red-600" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{providers.length > providersPerPage && (
|
||||
<div className="px-4 py-3 border-t flex justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={currentPage === 1}
|
||||
onClick={() => setCurrentPage((p) => p - 1)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={currentPage === totalPages}
|
||||
onClick={() => setCurrentPage((p) => p + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modalOpen && (
|
||||
<NpiProviderForm
|
||||
defaultValues={editingProvider || undefined}
|
||||
onClose={() => setModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={isDeleteDialogOpen}
|
||||
onConfirm={handleConfirmDelete}
|
||||
onCancel={() => setIsDeleteDialogOpen(false)}
|
||||
entityName={providerToDelete?.providerName}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Staff } from "@repo/db/types";
|
||||
import { Staff, StaffFormData } from "@repo/db/types";
|
||||
import React, { useState, useEffect } from "react";
|
||||
|
||||
interface StaffFormProps {
|
||||
initialData?: Partial<Staff>;
|
||||
onSubmit: (data: Omit<Staff, "id" | "createdAt">) => void;
|
||||
onSubmit: (data: StaffFormData) => void;
|
||||
onCancel: () => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
@@ -21,6 +21,8 @@ export function StaffForm({
|
||||
|
||||
const [hasTypedRole, setHasTypedRole] = useState(false);
|
||||
|
||||
const [displayOrder, setDisplayOrder] = useState<number | "">("");
|
||||
|
||||
// Set initial values once on mount
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
@@ -28,6 +30,9 @@ export function StaffForm({
|
||||
if (initialData.email) setEmail(initialData.email);
|
||||
if (initialData.role) setRole(initialData.role);
|
||||
if (initialData.phone) setPhone(initialData.phone);
|
||||
if (initialData?.displayOrder !== undefined) {
|
||||
setDisplayOrder(initialData.displayOrder);
|
||||
}
|
||||
}
|
||||
}, []); // run once only
|
||||
|
||||
@@ -43,6 +48,7 @@ export function StaffForm({
|
||||
email: email.trim() || undefined,
|
||||
role: role.trim(),
|
||||
phone: phone.trim() || undefined,
|
||||
displayOrder: displayOrder === "" ? undefined : displayOrder,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -95,6 +101,24 @@ export function StaffForm({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Column Order
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className="mt-1 block w-full border rounded p-2"
|
||||
value={displayOrder}
|
||||
onChange={(e) =>
|
||||
setDisplayOrder(e.target.value === "" ? "" : Number(e.target.value))
|
||||
}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
Lower number appears first in appointment schedule
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Phone</label>
|
||||
<input
|
||||
|
||||
@@ -12,22 +12,36 @@ export const DeleteConfirmationDialog = ({
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50">
|
||||
<div className="bg-white p-6 rounded-md shadow-md w-[90%] max-w-md">
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-[9999] pointer-events-auto">
|
||||
<div
|
||||
className="bg-white p-6 rounded-md shadow-md w-[90%] max-w-md pointer-events-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-xl font-semibold mb-4">Confirm Deletion</h2>
|
||||
|
||||
<p>
|
||||
Are you sure you want to delete <strong>{entityName}</strong>?
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex justify-end space-x-4">
|
||||
<button
|
||||
type="button"
|
||||
className="bg-gray-200 px-4 py-2 rounded hover:bg-gray-300"
|
||||
onClick={onCancel}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCancel();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700"
|
||||
onClick={onConfirm}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onConfirm();
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
|
||||
@@ -4,7 +4,7 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL_BACKEND ?? "";
|
||||
|
||||
async function throwIfResNotOk(res: Response) {
|
||||
if (!res.ok) {
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
if (res.status === 401) {
|
||||
localStorage.removeItem("token");
|
||||
if (!window.location.pathname.startsWith("/auth")) {
|
||||
window.location.href = "/auth";
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
Patient,
|
||||
PatientStatus,
|
||||
UpdateAppointment,
|
||||
Staff as DBStaff,
|
||||
} from "@repo/db/types";
|
||||
import {
|
||||
Popover,
|
||||
@@ -53,6 +54,7 @@ import {
|
||||
} from "@/redux/slices/seleniumEligibilityBatchCheckTaskSlice";
|
||||
import { SeleniumTaskBanner } from "@/components/ui/selenium-task-banner";
|
||||
import { PatientStatusBadge } from "@/components/appointments/patient-status-badge";
|
||||
import { AppointmentProceduresDialog } from "@/components/appointment-procedures/appointment-procedures-dialog";
|
||||
|
||||
// Define types for scheduling
|
||||
interface TimeSlot {
|
||||
@@ -60,12 +62,9 @@ interface TimeSlot {
|
||||
displayTime: string;
|
||||
}
|
||||
|
||||
interface Staff {
|
||||
id: string;
|
||||
name: string;
|
||||
role: "doctor" | "hygienist";
|
||||
type StaffWithColor = DBStaff & {
|
||||
color: string;
|
||||
}
|
||||
};
|
||||
|
||||
interface ScheduledAppointment {
|
||||
id?: number;
|
||||
@@ -93,6 +92,17 @@ export default function AppointmentsPage() {
|
||||
const { toast } = useToast();
|
||||
const { user } = useAuth();
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
const [proceduresDialogOpen, setProceduresDialogOpen] = useState(false);
|
||||
const [proceduresAppointmentId, setProceduresAppointmentId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [proceduresPatientId, setProceduresPatientId] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [proceduresPatient, setProceduresPatient] = useState<Patient | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const [calendarOpen, setCalendarOpen] = useState(false);
|
||||
const [editingAppointment, setEditingAppointment] = useState<
|
||||
Appointment | undefined
|
||||
@@ -105,7 +115,7 @@ export default function AppointmentsPage() {
|
||||
}>({ open: false });
|
||||
const dispatch = useAppDispatch();
|
||||
const batchTask = useAppSelector(
|
||||
(state) => state.seleniumEligibilityBatchCheckTask
|
||||
(state) => state.seleniumEligibilityBatchCheckTask,
|
||||
);
|
||||
const [isCheckingAllElig, setIsCheckingAllElig] = useState(false);
|
||||
const [processedAppointmentIds, setProcessedAppointmentIds] = useState<
|
||||
@@ -141,7 +151,7 @@ export default function AppointmentsPage() {
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest(
|
||||
"GET",
|
||||
`/api/appointments/day?date=${formattedSelectedDate}`
|
||||
`/api/appointments/day?date=${formattedSelectedDate}`,
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to load appointments for date");
|
||||
@@ -156,7 +166,7 @@ export default function AppointmentsPage() {
|
||||
const patientsFromDay = dayPayload.patients ?? [];
|
||||
|
||||
// Staff memebers
|
||||
const { data: staffMembersRaw = [] as Staff[] } = useQuery<Staff[]>({
|
||||
const { data: staffMembersRaw = [] as DBStaff[] } = useQuery<DBStaff[]>({
|
||||
queryKey: ["/api/staffs/"],
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest("GET", "/api/staffs/");
|
||||
@@ -174,11 +184,18 @@ export default function AppointmentsPage() {
|
||||
"bg-orange-500", // softer warm orange
|
||||
];
|
||||
|
||||
// Assign colors cycling through the list
|
||||
const staffMembers = staffMembersRaw.map((staff, index) => ({
|
||||
...staff,
|
||||
// Assign colors cycling through the list, and order them by display order for the page column.
|
||||
|
||||
color: colors[index % colors.length] || "bg-gray-400",
|
||||
const orderedStaff = staffMembersRaw.filter(
|
||||
(s): s is DBStaff & { displayOrder: number } =>
|
||||
typeof s.displayOrder === "number" && s.displayOrder > 0,
|
||||
);
|
||||
|
||||
orderedStaff.sort((a, b) => a.displayOrder - b.displayOrder);
|
||||
|
||||
const staffMembers: StaffWithColor[] = orderedStaff.map((staff, index) => ({
|
||||
...staff,
|
||||
color: colors[index % colors.length] ?? "bg-gray-400",
|
||||
}));
|
||||
|
||||
// Generate time slots from 8:00 AM to 6:00 PM in 15-minute increments
|
||||
@@ -233,13 +250,13 @@ export default function AppointmentsPage() {
|
||||
};
|
||||
sessionStorage.setItem(
|
||||
"newAppointmentData",
|
||||
JSON.stringify(newAppointmentData)
|
||||
JSON.stringify(newAppointmentData),
|
||||
);
|
||||
} catch (err) {
|
||||
// If sessionStorage parsing fails, overwrite with a fresh object
|
||||
sessionStorage.setItem(
|
||||
"newAppointmentData",
|
||||
JSON.stringify({ patientId: patientId })
|
||||
JSON.stringify({ patientId: patientId }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -260,7 +277,7 @@ export default function AppointmentsPage() {
|
||||
const res = await apiRequest(
|
||||
"POST",
|
||||
"/api/appointments/upsert",
|
||||
appointment
|
||||
appointment,
|
||||
);
|
||||
return await res.json();
|
||||
},
|
||||
@@ -296,7 +313,7 @@ export default function AppointmentsPage() {
|
||||
const res = await apiRequest(
|
||||
"PUT",
|
||||
`/api/appointments/${id}`,
|
||||
appointment
|
||||
appointment,
|
||||
);
|
||||
return await res.json();
|
||||
},
|
||||
@@ -347,7 +364,7 @@ export default function AppointmentsPage() {
|
||||
|
||||
// Handle appointment submission (create or update)
|
||||
const handleAppointmentSubmit = (
|
||||
appointmentData: InsertAppointment | UpdateAppointment
|
||||
appointmentData: InsertAppointment | UpdateAppointment,
|
||||
) => {
|
||||
// Converts local date to exact UTC date with no offset issues
|
||||
const rawDate = parseLocalDate(appointmentData.date);
|
||||
@@ -467,7 +484,7 @@ export default function AppointmentsPage() {
|
||||
// Handle creating a new appointment at a specific time slot and for a specific staff member
|
||||
const handleCreateAppointmentAtSlot = (
|
||||
timeSlot: TimeSlot,
|
||||
staffId: number
|
||||
staffId: number,
|
||||
) => {
|
||||
// Calculate end time (30 minutes after start time)
|
||||
const startHour = parseInt(timeSlot.time.split(":")[0] as string);
|
||||
@@ -520,7 +537,7 @@ export default function AppointmentsPage() {
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
"newAppointmentData",
|
||||
JSON.stringify(mergedAppointment)
|
||||
JSON.stringify(mergedAppointment),
|
||||
);
|
||||
} catch (e) {
|
||||
// ignore sessionStorage write failures
|
||||
@@ -538,7 +555,7 @@ export default function AppointmentsPage() {
|
||||
const handleMoveAppointment = (
|
||||
appointmentId: number,
|
||||
newTimeSlot: TimeSlot,
|
||||
newStaffId: number
|
||||
newStaffId: number,
|
||||
) => {
|
||||
const appointment = appointments.find((a) => a.id === appointmentId);
|
||||
if (!appointment) return;
|
||||
@@ -591,7 +608,7 @@ export default function AppointmentsPage() {
|
||||
staff,
|
||||
}: {
|
||||
appointment: ScheduledAppointment;
|
||||
staff: Staff;
|
||||
staff: StaffWithColor;
|
||||
}) {
|
||||
const [{ isDragging }, drag] = useDrag(() => ({
|
||||
type: ItemTypes.APPOINTMENT,
|
||||
@@ -612,7 +629,7 @@ export default function AppointmentsPage() {
|
||||
// Only allow edit on click if we're not dragging
|
||||
if (!isDragging) {
|
||||
const fullAppointment = appointments.find(
|
||||
(a) => a.id === appointment.id
|
||||
(a) => a.id === appointment.id,
|
||||
);
|
||||
if (fullAppointment) {
|
||||
e.stopPropagation();
|
||||
@@ -647,7 +664,7 @@ export default function AppointmentsPage() {
|
||||
timeSlot: TimeSlot;
|
||||
staffId: number;
|
||||
appointment: ScheduledAppointment | undefined;
|
||||
staff: Staff;
|
||||
staff: StaffWithColor;
|
||||
}) {
|
||||
const [{ isOver, canDrop }, drop] = useDrop(() => ({
|
||||
accept: ItemTypes.APPOINTMENT,
|
||||
@@ -688,13 +705,13 @@ export default function AppointmentsPage() {
|
||||
// appointment page — update these handlers
|
||||
const handleCheckEligibility = (appointmentId: number) => {
|
||||
setLocation(
|
||||
`/insurance-status?appointmentId=${appointmentId}&action=eligibility`
|
||||
`/insurance-status?appointmentId=${appointmentId}&action=eligibility`,
|
||||
);
|
||||
};
|
||||
|
||||
const handleCheckClaimStatus = (appointmentId: number) => {
|
||||
setLocation(
|
||||
`/insurance-status?appointmentId=${appointmentId}&action=claim`
|
||||
`/insurance-status?appointmentId=${appointmentId}&action=claim`,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -708,7 +725,7 @@ export default function AppointmentsPage() {
|
||||
|
||||
const handleChartPlan = (appointmentId: number) => {
|
||||
console.log(
|
||||
`Viewing chart/treatment plan for appointment: ${appointmentId}`
|
||||
`Viewing chart/treatment plan for appointment: ${appointmentId}`,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -733,7 +750,7 @@ export default function AppointmentsPage() {
|
||||
setTaskStatus({
|
||||
status: "pending",
|
||||
message: `Checking eligibility for appointments on ${dateParam}...`,
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
setIsCheckingAllElig(true);
|
||||
@@ -743,7 +760,7 @@ export default function AppointmentsPage() {
|
||||
const res = await apiRequest(
|
||||
"POST",
|
||||
`/api/insurance-status/appointments/check-all-eligibilities?date=${dateParam}`,
|
||||
{}
|
||||
{},
|
||||
);
|
||||
|
||||
// read body for all cases so we can show per-appointment info
|
||||
@@ -761,7 +778,7 @@ export default function AppointmentsPage() {
|
||||
setTaskStatus({
|
||||
status: "error",
|
||||
message: `Batch eligibility failed: ${errMsg}`,
|
||||
})
|
||||
}),
|
||||
);
|
||||
toast({
|
||||
title: "Batch check failed",
|
||||
@@ -839,7 +856,7 @@ export default function AppointmentsPage() {
|
||||
setTaskStatus({
|
||||
status: skippedCount > 0 ? "error" : "success",
|
||||
message: `Batch processed ${results.length} appointments — success: ${successCount}, warnings: ${warningCount}, skipped: ${skippedCount}.`,
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
// also show final toast summary
|
||||
@@ -854,7 +871,7 @@ export default function AppointmentsPage() {
|
||||
setTaskStatus({
|
||||
status: "error",
|
||||
message: `Batch eligibility error: ${err?.message ?? String(err)}`,
|
||||
})
|
||||
}),
|
||||
);
|
||||
toast({
|
||||
title: "Batch check failed",
|
||||
@@ -866,6 +883,34 @@ export default function AppointmentsPage() {
|
||||
// intentionally do not clear task status here so banner persists until user dismisses it
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenProcedures = (appointmentId: number) => {
|
||||
const apt = appointments.find((a) => a.id === appointmentId);
|
||||
if (!apt) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Appointment not found",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const patient = patientsFromDay.find((p) => p.id === apt.patientId);
|
||||
if (!patient) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Patient not found for this appointment",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setProceduresAppointmentId(Number(apt.id));
|
||||
setProceduresPatientId(apt.patientId);
|
||||
setProceduresPatient(patient);
|
||||
setProceduresDialogOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SeleniumTaskBanner
|
||||
@@ -930,7 +975,7 @@ export default function AppointmentsPage() {
|
||||
<Item
|
||||
onClick={({ props }) => {
|
||||
const fullAppointment = appointments.find(
|
||||
(a) => a.id === props.appointmentId
|
||||
(a) => a.id === props.appointmentId,
|
||||
);
|
||||
if (fullAppointment) {
|
||||
handleEditAppointment(fullAppointment);
|
||||
@@ -999,6 +1044,16 @@ export default function AppointmentsPage() {
|
||||
</span>
|
||||
</Item>
|
||||
|
||||
{/* Procedures */}
|
||||
<Item
|
||||
onClick={({ props }) => handleOpenProcedures(props.appointmentId)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<ClipboardList className="h-4 w-4" />
|
||||
Procedures
|
||||
</span>
|
||||
</Item>
|
||||
|
||||
{/* Clinic Notes */}
|
||||
<Item onClick={({ props }) => handleClinicNotes(props.appointmentId)}>
|
||||
<span className="flex items-center gap-2 text-yellow-600">
|
||||
@@ -1098,7 +1153,7 @@ export default function AppointmentsPage() {
|
||||
staffId={Number(staff.id)}
|
||||
appointment={getAppointmentAtSlot(
|
||||
timeSlot,
|
||||
Number(staff.id)
|
||||
Number(staff.id),
|
||||
)}
|
||||
staff={staff}
|
||||
/>
|
||||
@@ -1126,6 +1181,24 @@ export default function AppointmentsPage() {
|
||||
onDelete={handleDeleteAppointment}
|
||||
/>
|
||||
|
||||
{/* Appointment Procedure Dialog */}
|
||||
{proceduresAppointmentId && proceduresPatientId && proceduresPatient && (
|
||||
<AppointmentProceduresDialog
|
||||
open={proceduresDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setProceduresDialogOpen(open);
|
||||
if (!open) {
|
||||
setProceduresAppointmentId(null);
|
||||
setProceduresPatientId(null);
|
||||
setProceduresPatient(null);
|
||||
}
|
||||
}}
|
||||
appointmentId={proceduresAppointmentId}
|
||||
patientId={proceduresPatientId}
|
||||
patient={proceduresPatient}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={confirmDeleteState.open}
|
||||
onConfirm={handleConfirmDelete}
|
||||
|
||||
@@ -146,9 +146,13 @@ export default function ClaimsPage() {
|
||||
|
||||
// case1: - this params are set by pdf extraction/patient page or either by patient-add-form. then used in claim page here.
|
||||
const [location] = useLocation();
|
||||
const { newPatient } = useMemo(() => {
|
||||
|
||||
const { newPatient, mode } = useMemo(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return { newPatient: params.get("newPatient") };
|
||||
return {
|
||||
newPatient: params.get("newPatient"),
|
||||
mode: params.get("mode"), // direct | manual | null};
|
||||
};
|
||||
}, [location]);
|
||||
|
||||
const handleNewClaim = (patientId: number, appointmentId?: number) => {
|
||||
@@ -532,6 +536,7 @@ export default function ClaimsPage() {
|
||||
<ClaimForm
|
||||
patientId={selectedPatientId}
|
||||
appointmentId={selectedAppointmentId ?? undefined}
|
||||
autoSubmit={mode === "direct"}
|
||||
onClose={closeClaim}
|
||||
onSubmit={handleClaimSubmit}
|
||||
onHandleAppointmentSubmit={handleAppointmentSubmit}
|
||||
|
||||
@@ -28,13 +28,14 @@ import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
|
||||
import { PdfPreviewModal } from "@/components/insurance-status/pdf-preview-modal";
|
||||
import { useLocation } from "wouter";
|
||||
import { DdmaEligibilityButton } from "@/components/insurance-status/ddma-buton-modal";
|
||||
import { DentaQuestEligibilityButton } from "@/components/insurance-status/dentaquest-button-modal";
|
||||
|
||||
export default function InsuranceStatusPage() {
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const dispatch = useAppDispatch();
|
||||
const { status, message, show } = useAppSelector(
|
||||
(state) => state.seleniumEligibilityCheckTask
|
||||
(state) => state.seleniumEligibilityCheckTask,
|
||||
);
|
||||
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null);
|
||||
const [location] = useLocation();
|
||||
@@ -56,12 +57,6 @@ export default function InsuranceStatusPage() {
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
// 1) state to remember we should auto-run once patient arrives
|
||||
const [pendingAutoAction, setPendingAutoAction] = useState<{
|
||||
appointmentId: number;
|
||||
action: "eligibility" | "claim";
|
||||
} | null>(null);
|
||||
|
||||
// Populate fields from selected patient
|
||||
useEffect(() => {
|
||||
if (selectedPatient) {
|
||||
@@ -129,12 +124,12 @@ export default function InsuranceStatusPage() {
|
||||
setTaskStatus({
|
||||
status: "pending",
|
||||
message: "Sending Data to Selenium...",
|
||||
})
|
||||
}),
|
||||
);
|
||||
const response = await apiRequest(
|
||||
"POST",
|
||||
"/api/insurance-status/eligibility-check",
|
||||
{ data: JSON.stringify(data) }
|
||||
{ data: JSON.stringify(data) },
|
||||
);
|
||||
const result = await response.json();
|
||||
if (result.error) throw new Error(result.error);
|
||||
@@ -144,7 +139,7 @@ export default function InsuranceStatusPage() {
|
||||
status: "success",
|
||||
message:
|
||||
"Patient status is updated, and its eligibility pdf is uploaded at Document Page.",
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
toast({
|
||||
@@ -161,7 +156,7 @@ export default function InsuranceStatusPage() {
|
||||
setPreviewPdfId(Number(result.pdfFileId));
|
||||
// optional fallback name while header is parsed
|
||||
setPreviewFallbackFilename(
|
||||
result.pdfFilename ?? `eligibility_${memberId}.pdf`
|
||||
result.pdfFilename ?? `eligibility_${memberId}.pdf`,
|
||||
);
|
||||
setPreviewOpen(true);
|
||||
}
|
||||
@@ -170,7 +165,7 @@ export default function InsuranceStatusPage() {
|
||||
setTaskStatus({
|
||||
status: "error",
|
||||
message: error.message || "Selenium submission failed",
|
||||
})
|
||||
}),
|
||||
);
|
||||
toast({
|
||||
title: "Selenium service error",
|
||||
@@ -194,12 +189,12 @@ export default function InsuranceStatusPage() {
|
||||
setTaskStatus({
|
||||
status: "pending",
|
||||
message: "Sending Data to Selenium...",
|
||||
})
|
||||
}),
|
||||
);
|
||||
const response = await apiRequest(
|
||||
"POST",
|
||||
"/api/insurance-status/claim-status-check",
|
||||
{ data: JSON.stringify(data) }
|
||||
{ data: JSON.stringify(data) },
|
||||
);
|
||||
const result = await response.json();
|
||||
if (result.error) throw new Error(result.error);
|
||||
@@ -209,7 +204,7 @@ export default function InsuranceStatusPage() {
|
||||
status: "success",
|
||||
message:
|
||||
"Claim status is updated, and its pdf is uploaded at Document Page.",
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
toast({
|
||||
@@ -226,7 +221,7 @@ export default function InsuranceStatusPage() {
|
||||
setPreviewPdfId(Number(result.pdfFileId));
|
||||
// optional fallback name while header is parsed
|
||||
setPreviewFallbackFilename(
|
||||
result.pdfFilename ?? `eligibility_${memberId}.pdf`
|
||||
result.pdfFilename ?? `eligibility_${memberId}.pdf`,
|
||||
);
|
||||
setPreviewOpen(true);
|
||||
}
|
||||
@@ -235,7 +230,7 @@ export default function InsuranceStatusPage() {
|
||||
setTaskStatus({
|
||||
status: "error",
|
||||
message: error.message || "Selenium submission failed",
|
||||
})
|
||||
}),
|
||||
);
|
||||
toast({
|
||||
title: "Selenium service error",
|
||||
@@ -369,7 +364,6 @@ export default function InsuranceStatusPage() {
|
||||
// set selectedPatient as before
|
||||
setSelectedPatient(patient as Patient);
|
||||
|
||||
setPendingAutoAction({ appointmentId: id, action: action as any });
|
||||
clearUrlParams(["appointmentId", "action"]);
|
||||
}
|
||||
} catch (err: any) {
|
||||
@@ -390,82 +384,41 @@ export default function InsuranceStatusPage() {
|
||||
};
|
||||
}, [location]);
|
||||
|
||||
// ---------- same case1: runs when selectedPatient AND form fields are ready ----------
|
||||
// handling case-1, when redirect happens from appointment page:
|
||||
useEffect(() => {
|
||||
if (!pendingAutoAction) return;
|
||||
if (!selectedPatient) return; // wait until fetch effect set it
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const appointmentId = params.get("appointmentId");
|
||||
if (!appointmentId) return;
|
||||
|
||||
if (
|
||||
selectedPatient &&
|
||||
memberId === "" &&
|
||||
firstName === "" &&
|
||||
dateOfBirth === null
|
||||
) {
|
||||
// form hasn't been populated yet; do nothing and wait for the next re-render
|
||||
return;
|
||||
}
|
||||
const id = Number(appointmentId);
|
||||
if (Number.isNaN(id) || id <= 0) return;
|
||||
|
||||
let cancelled = false;
|
||||
let inFlight = false;
|
||||
|
||||
// helper: determine final values using both selectedPatient and current form state
|
||||
const finalMemberId =
|
||||
(selectedPatient?.insuranceId
|
||||
? String(selectedPatient.insuranceId).trim()
|
||||
: "") || (memberId ? memberId.trim() : "");
|
||||
|
||||
const finalFirstName =
|
||||
(selectedPatient?.firstName
|
||||
? String(selectedPatient.firstName).trim()
|
||||
: "") || (firstName ? firstName.trim() : "");
|
||||
|
||||
// DOB: try component state first (user may have typed), else patient fallback
|
||||
const parsedDobFromPatient =
|
||||
selectedPatient?.dateOfBirth != null
|
||||
? typeof selectedPatient.dateOfBirth === "string"
|
||||
? parseLocalDate(selectedPatient.dateOfBirth)
|
||||
: selectedPatient.dateOfBirth
|
||||
: null;
|
||||
const finalDob = dateOfBirth ?? parsedDobFromPatient ?? null;
|
||||
|
||||
const missing: string[] = [];
|
||||
if (!finalMemberId) missing.push("Member ID");
|
||||
if (!finalFirstName) missing.push("First Name");
|
||||
if (!finalDob) missing.push("Date of Birth");
|
||||
|
||||
if (missing.length > 0) {
|
||||
toast({
|
||||
title: "Missing Fields",
|
||||
description: `Cannot auto-run. Missing: ${missing.join(", ")}.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// If ready, call the requested handler once. Clear pendingAutoAction afterwards.
|
||||
(async () => {
|
||||
if (cancelled) return;
|
||||
if (inFlight) return;
|
||||
inFlight = true;
|
||||
|
||||
try {
|
||||
if (pendingAutoAction.action === "eligibility") {
|
||||
await handleMHEligibilityButton();
|
||||
} else {
|
||||
await handleMHStatusButton();
|
||||
const res = await apiRequest("GET", `/api/appointments/${id}/patient`);
|
||||
if (!res.ok) return;
|
||||
|
||||
const data = await res.json();
|
||||
const patient = data?.patient ?? data;
|
||||
|
||||
if (!cancelled && patient) {
|
||||
// ✅ ONLY prefill patient
|
||||
setSelectedPatient(patient as Patient);
|
||||
|
||||
// ✅ clean URL (no auto selenium)
|
||||
clearUrlParams(["appointmentId", "action"]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Auto MH action failed:", err);
|
||||
} finally {
|
||||
inFlight = false;
|
||||
if (!cancelled) setPendingAutoAction(null); // clear so it doesn't run again
|
||||
console.error("Failed to fetch patient from appointment", err);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [pendingAutoAction, selectedPatient, memberId, firstName, dateOfBirth]);
|
||||
}, [location]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -589,7 +542,7 @@ export default function InsuranceStatusPage() {
|
||||
onPdfReady={(pdfId, fallbackFilename) => {
|
||||
setPreviewPdfId(pdfId);
|
||||
setPreviewFallbackFilename(
|
||||
fallbackFilename ?? `eligibility_ddma_${memberId}.pdf`
|
||||
fallbackFilename ?? `eligibility_ddma_${memberId}.pdf`,
|
||||
);
|
||||
setPreviewOpen(true);
|
||||
}}
|
||||
@@ -616,14 +569,20 @@ export default function InsuranceStatusPage() {
|
||||
|
||||
{/* Row 2 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
disabled={isFormIncomplete}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Tufts SCO/SWH/Navi/Mass Gen
|
||||
</Button>
|
||||
<DentaQuestEligibilityButton
|
||||
memberId={memberId}
|
||||
dateOfBirth={dateOfBirth}
|
||||
firstName={firstName}
|
||||
lastName={lastName}
|
||||
isFormIncomplete={isFormIncomplete}
|
||||
onPdfReady={(pdfId, fallbackFilename) => {
|
||||
setPreviewPdfId(pdfId);
|
||||
setPreviewFallbackFilename(
|
||||
fallbackFilename ?? `eligibility_dentaquest_${memberId}.pdf`
|
||||
);
|
||||
setPreviewOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
|
||||
@@ -8,8 +8,8 @@ import { StaffForm } from "@/components/staffs/staff-form";
|
||||
import { DeleteConfirmationDialog } from "@/components/ui/deleteDialog";
|
||||
import { CredentialTable } from "@/components/settings/insuranceCredTable";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { Staff } from "@repo/db/types";
|
||||
|
||||
import { Staff, StaffFormData } from "@repo/db/types";
|
||||
import { NpiProviderTable } from "@/components/settings/npiProviderTable";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { toast } = useToast();
|
||||
@@ -44,9 +44,9 @@ export default function SettingsPage() {
|
||||
const addStaffMutate = useMutation<
|
||||
Staff, // Return type
|
||||
Error, // Error type
|
||||
Omit<Staff, "id" | "createdAt"> // Variables
|
||||
StaffFormData
|
||||
>({
|
||||
mutationFn: async (newStaff: Omit<Staff, "id" | "createdAt">) => {
|
||||
mutationFn: async (newStaff: StaffFormData) => {
|
||||
const res = await apiRequest("POST", "/api/staffs/", newStaff);
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => null);
|
||||
@@ -75,7 +75,7 @@ export default function SettingsPage() {
|
||||
const updateStaffMutate = useMutation<
|
||||
Staff,
|
||||
Error,
|
||||
{ id: number; updatedFields: Partial<Staff> }
|
||||
{ id: number; updatedFields: Partial<StaffFormData> }
|
||||
>({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
@@ -157,7 +157,7 @@ export default function SettingsPage() {
|
||||
};
|
||||
|
||||
// Handle form submit for Add or Edit
|
||||
const handleFormSubmit = (formData: Omit<Staff, "id" | "createdAt">) => {
|
||||
const handleFormSubmit = (formData: StaffFormData) => {
|
||||
if (editingStaff) {
|
||||
// Editing existing staff
|
||||
if (editingStaff.id === undefined) {
|
||||
@@ -190,7 +190,7 @@ export default function SettingsPage() {
|
||||
|
||||
const [isDeleteStaffOpen, setIsDeleteStaffOpen] = useState(false);
|
||||
const [currentStaff, setCurrentStaff] = useState<Staff | undefined>(
|
||||
undefined
|
||||
undefined,
|
||||
);
|
||||
|
||||
const handleDeleteStaff = (staff: Staff) => {
|
||||
@@ -212,7 +212,7 @@ export default function SettingsPage() {
|
||||
|
||||
const handleViewStaff = (staff: Staff) =>
|
||||
alert(
|
||||
`Viewing staff member:\n${staff.name} (${staff.email || "No email"})`
|
||||
`Viewing staff member:\n${staff.name} (${staff.email || "No email"})`,
|
||||
);
|
||||
|
||||
// MANAGE USER
|
||||
@@ -229,7 +229,7 @@ export default function SettingsPage() {
|
||||
//update user mutation
|
||||
const updateUserMutate = useMutation({
|
||||
mutationFn: async (
|
||||
updates: Partial<{ username: string; password: string }>
|
||||
updates: Partial<{ username: string; password: string }>,
|
||||
) => {
|
||||
if (!user?.id) throw new Error("User not loaded");
|
||||
const res = await apiRequest("PUT", `/api/users/${user.id}`, updates);
|
||||
@@ -258,110 +258,113 @@ export default function SettingsPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="mt-8">
|
||||
<StaffTable
|
||||
staff={staff}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
onAdd={openAddStaffModal}
|
||||
onEdit={openEditStaffModal}
|
||||
onDelete={handleDeleteStaff}
|
||||
onView={handleViewStaff}
|
||||
/>
|
||||
{isError && (
|
||||
<p className="mt-4 text-red-600">
|
||||
{(error as Error)?.message || "Failed to load staff data."}
|
||||
</p>
|
||||
)}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="mt-8">
|
||||
<StaffTable
|
||||
staff={staff}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
onAdd={openAddStaffModal}
|
||||
onEdit={openEditStaffModal}
|
||||
onDelete={handleDeleteStaff}
|
||||
onView={handleViewStaff}
|
||||
/>
|
||||
{isError && (
|
||||
<p className="mt-4 text-red-600">
|
||||
{(error as Error)?.message || "Failed to load staff data."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={isDeleteStaffOpen}
|
||||
onConfirm={handleConfirmDeleteStaff}
|
||||
onCancel={() => setIsDeleteStaffOpen(false)}
|
||||
entityName={currentStaff?.name}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Modal Overlay */}
|
||||
{modalOpen && (
|
||||
<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">
|
||||
{editingStaff ? "Edit Staff" : "Add Staff"}
|
||||
</h2>
|
||||
<StaffForm
|
||||
initialData={editingStaff || undefined}
|
||||
onSubmit={handleFormSubmit}
|
||||
onCancel={handleModalCancel}
|
||||
isLoading={isAdding || isUpdating}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User Setting section */}
|
||||
<Card className="mt-6">
|
||||
<CardContent className="space-y-4 py-6">
|
||||
<h3 className="text-lg font-semibold">User Settings</h3>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const password =
|
||||
formData.get("password")?.toString().trim() || undefined;
|
||||
|
||||
updateUserMutate.mutate({
|
||||
username: usernameUser?.trim() || undefined,
|
||||
password: password || undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label className="block text-sm font-medium">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
value={usernameUser}
|
||||
onChange={(e) => setUsernameUser(e.target.value)}
|
||||
className="mt-1 p-2 border rounded w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium">
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
className="mt-1 p-2 border rounded w-full"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Leave blank to keep current password.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
|
||||
disabled={updateUserMutate.isPending}
|
||||
>
|
||||
{updateUserMutate.isPending ? "Saving..." : "Save Changes"}
|
||||
</button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Credential Section */}
|
||||
<div className="mt-6">
|
||||
<CredentialTable />
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={isDeleteStaffOpen}
|
||||
onConfirm={handleConfirmDeleteStaff}
|
||||
onCancel={() => setIsDeleteStaffOpen(false)}
|
||||
entityName={currentStaff?.name}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Modal Overlay */}
|
||||
{modalOpen && (
|
||||
<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">
|
||||
{editingStaff ? "Edit Staff" : "Add Staff"}
|
||||
</h2>
|
||||
<StaffForm
|
||||
initialData={editingStaff || undefined}
|
||||
onSubmit={handleFormSubmit}
|
||||
onCancel={handleModalCancel}
|
||||
isLoading={isAdding || isUpdating}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User Setting section */}
|
||||
<Card className="mt-6">
|
||||
<CardContent className="space-y-4 py-6">
|
||||
<h3 className="text-lg font-semibold">User Settings</h3>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const password =
|
||||
formData.get("password")?.toString().trim() || undefined;
|
||||
|
||||
updateUserMutate.mutate({
|
||||
username: usernameUser?.trim() || undefined,
|
||||
password: password || undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label className="block text-sm font-medium">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
value={usernameUser}
|
||||
onChange={(e) => setUsernameUser(e.target.value)}
|
||||
className="mt-1 p-2 border rounded w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium">New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
className="mt-1 p-2 border rounded w-full"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Leave blank to keep current password.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
|
||||
disabled={updateUserMutate.isPending}
|
||||
>
|
||||
{updateUserMutate.isPending ? "Saving..." : "Save Changes"}
|
||||
</button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Credential Section */}
|
||||
<div className="mt-6">
|
||||
<CredentialTable />
|
||||
</div>
|
||||
|
||||
{/* NpiProvider Section */}
|
||||
<div className="mt-6">
|
||||
<NpiProviderTable />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -253,22 +253,22 @@ export const PROCEDURE_COMBOS: Record<
|
||||
// Orthodontics
|
||||
orthPreExamDirect: {
|
||||
id: "orthPreExamDirect",
|
||||
label: "Pre-Orth Exam",
|
||||
label: "Direct Pre-Orth Exam",
|
||||
codes: ["D9310"],
|
||||
},
|
||||
orthRecordDirect: {
|
||||
id: "orthRecordDirect",
|
||||
label: "Orth Record",
|
||||
label: "Direct Orth Record",
|
||||
codes: ["D8660"],
|
||||
},
|
||||
orthPerioVisitDirect: {
|
||||
id: "orthPerioVisitDirect",
|
||||
label: "Perio Orth Visit ",
|
||||
label: "Direct Perio Orth Visit ",
|
||||
codes: ["D8670"],
|
||||
},
|
||||
orthRetentionDirect: {
|
||||
id: "orthRetentionDirect",
|
||||
label: "Orth Retention",
|
||||
label: "Direct Orth Retention",
|
||||
codes: ["D8680"],
|
||||
},
|
||||
orthPA: {
|
||||
@@ -276,6 +276,20 @@ export const PROCEDURE_COMBOS: Record<
|
||||
label: "Orth PA",
|
||||
codes: ["D8080", "D8670", "D8660"],
|
||||
},
|
||||
|
||||
// Exam
|
||||
limitedExamPA: {
|
||||
id: "limitedExamPA",
|
||||
label: "Limited Exam – PA",
|
||||
codes: ["D0140", "D0220"],
|
||||
},
|
||||
|
||||
emergencyExamPA: {
|
||||
id: "emergencyExamPA",
|
||||
label: "Emergency Exam – PA",
|
||||
codes: ["D9110", "D0220"],
|
||||
},
|
||||
|
||||
// add more…
|
||||
};
|
||||
|
||||
@@ -291,6 +305,9 @@ export const COMBO_CATEGORIES: Record<
|
||||
"newAdultPatientPano",
|
||||
"newAdultPatientFMX",
|
||||
],
|
||||
|
||||
Exams: ["limitedExamPA", "emergencyExamPA"],
|
||||
|
||||
"Composite Fillings (Front)": [
|
||||
"oneSurfCompFront",
|
||||
"twoSurfCompFront",
|
||||
|
||||
@@ -308,3 +308,6 @@ export function applyComboToForm<T extends ClaimFormLike>(
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
|
||||
export { CODE_MAP, getPriceForCodeWithAgeFromMap };
|
||||
@@ -3,6 +3,6 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"postinstall": "pip install -r requirements.txt",
|
||||
"dev": "python main.py"
|
||||
"dev": "python3 main.py"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"postinstall": "pip install -r requirements.txt",
|
||||
"dev": "python main.py"
|
||||
"dev": "python3 main.py"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,26 @@ from selenium_preAuthWorker import AutomationMassHealthPreAuth
|
||||
import os
|
||||
import time
|
||||
import helpers_ddma_eligibility as hddma
|
||||
import helpers_dentaquest_eligibility as hdentaquest
|
||||
|
||||
# Import session clear functions for startup
|
||||
from ddma_browser_manager import clear_ddma_session_on_startup
|
||||
from dentaquest_browser_manager import clear_dentaquest_session_on_startup
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
# Clear all sessions on startup (after PC restart)
|
||||
# This ensures users must login again after PC restart
|
||||
print("=" * 50)
|
||||
print("SELENIUM AGENT STARTING - CLEARING ALL SESSIONS")
|
||||
print("=" * 50)
|
||||
clear_ddma_session_on_startup()
|
||||
clear_dentaquest_session_on_startup()
|
||||
print("=" * 50)
|
||||
print("SESSION CLEAR COMPLETE - FRESH LOGINS REQUIRED")
|
||||
print("=" * 50)
|
||||
|
||||
app = FastAPI()
|
||||
# Allow 1 selenium session at a time
|
||||
semaphore = asyncio.Semaphore(1)
|
||||
@@ -186,6 +202,79 @@ async def ddma_eligibility(request: Request):
|
||||
return {"status": "started", "session_id": sid}
|
||||
|
||||
|
||||
# Endpoint:6 - DentaQuest eligibility (background, OTP)
|
||||
|
||||
async def _dentaquest_worker_wrapper(sid: str, data: dict, url: str):
|
||||
"""
|
||||
Background worker that:
|
||||
- acquires semaphore (to keep 1 selenium at a time),
|
||||
- updates active/queued counters,
|
||||
- runs the DentaQuest flow via helpers.start_dentaquest_run.
|
||||
"""
|
||||
global active_jobs, waiting_jobs
|
||||
async with semaphore:
|
||||
async with lock:
|
||||
waiting_jobs -= 1
|
||||
active_jobs += 1
|
||||
try:
|
||||
await hdentaquest.start_dentaquest_run(sid, data, url)
|
||||
finally:
|
||||
async with lock:
|
||||
active_jobs -= 1
|
||||
|
||||
|
||||
@app.post("/dentaquest-eligibility")
|
||||
async def dentaquest_eligibility(request: Request):
|
||||
"""
|
||||
Starts a DentaQuest eligibility session in the background.
|
||||
Body: { "data": { ... }, "url"?: string }
|
||||
Returns: { status: "started", session_id: "<uuid>" }
|
||||
"""
|
||||
global waiting_jobs
|
||||
|
||||
body = await request.json()
|
||||
data = body.get("data", {})
|
||||
|
||||
# create session
|
||||
sid = hdentaquest.make_session_entry()
|
||||
hdentaquest.sessions[sid]["type"] = "dentaquest_eligibility"
|
||||
hdentaquest.sessions[sid]["last_activity"] = time.time()
|
||||
|
||||
async with lock:
|
||||
waiting_jobs += 1
|
||||
|
||||
# run in background (queued under semaphore)
|
||||
asyncio.create_task(_dentaquest_worker_wrapper(sid, data, url="https://providers.dentaquest.com/onboarding/start/"))
|
||||
|
||||
return {"status": "started", "session_id": sid}
|
||||
|
||||
|
||||
@app.post("/dentaquest-submit-otp")
|
||||
async def dentaquest_submit_otp(request: Request):
|
||||
"""
|
||||
Body: { "session_id": "<sid>", "otp": "123456" }
|
||||
Node / frontend call this when user provides OTP for DentaQuest.
|
||||
"""
|
||||
body = await request.json()
|
||||
sid = body.get("session_id")
|
||||
otp = body.get("otp")
|
||||
if not sid or not otp:
|
||||
raise HTTPException(status_code=400, detail="session_id and otp required")
|
||||
|
||||
res = hdentaquest.submit_otp(sid, otp)
|
||||
if res.get("status") == "error":
|
||||
raise HTTPException(status_code=400, detail=res.get("message"))
|
||||
return res
|
||||
|
||||
|
||||
@app.get("/dentaquest-session/{sid}/status")
|
||||
async def dentaquest_session_status(sid: str):
|
||||
s = hdentaquest.get_session_status(sid)
|
||||
if s.get("status") == "not_found":
|
||||
raise HTTPException(status_code=404, detail="session not found")
|
||||
return s
|
||||
|
||||
|
||||
@app.post("/submit-otp")
|
||||
async def submit_otp(request: Request):
|
||||
"""
|
||||
|
||||
277
apps/SeleniumService/ddma_browser_manager.py
Normal file
277
apps/SeleniumService/ddma_browser_manager.py
Normal file
@@ -0,0 +1,277 @@
|
||||
"""
|
||||
Minimal browser manager for DDMA - only handles persistent profile and keeping browser alive.
|
||||
Clears session cookies on startup (after PC restart) to force fresh login.
|
||||
Tracks credentials to detect changes mid-session.
|
||||
"""
|
||||
import os
|
||||
import glob
|
||||
import shutil
|
||||
import hashlib
|
||||
import threading
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.service import Service
|
||||
from webdriver_manager.chrome import ChromeDriverManager
|
||||
|
||||
# Ensure DISPLAY is set for Chrome to work (needed when running from SSH/background)
|
||||
if not os.environ.get("DISPLAY"):
|
||||
os.environ["DISPLAY"] = ":0"
|
||||
|
||||
|
||||
class DDMABrowserManager:
|
||||
"""
|
||||
Singleton that manages a persistent Chrome browser instance.
|
||||
- Uses --user-data-dir for persistent profile (device trust tokens)
|
||||
- Clears session cookies on startup (after PC restart)
|
||||
- Tracks credentials to detect changes mid-session
|
||||
"""
|
||||
_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_ddma")
|
||||
cls._instance.download_dir = os.path.abspath("seleniumDownloads")
|
||||
cls._instance._credentials_file = os.path.join(cls._instance.profile_dir, ".last_credentials")
|
||||
cls._instance._needs_session_clear = False # Flag to clear session on next driver creation
|
||||
os.makedirs(cls._instance.profile_dir, exist_ok=True)
|
||||
os.makedirs(cls._instance.download_dir, exist_ok=True)
|
||||
return cls._instance
|
||||
|
||||
def clear_session_on_startup(self):
|
||||
"""
|
||||
Clear session cookies from Chrome profile on startup.
|
||||
This forces a fresh login after PC restart.
|
||||
Preserves device trust tokens (LocalStorage, IndexedDB) to avoid OTPs.
|
||||
"""
|
||||
print("[DDMA BrowserManager] Clearing session on startup...")
|
||||
|
||||
try:
|
||||
# Clear the credentials tracking file
|
||||
if os.path.exists(self._credentials_file):
|
||||
os.remove(self._credentials_file)
|
||||
print("[DDMA BrowserManager] Cleared credentials tracking file")
|
||||
|
||||
# Clear session-related files from Chrome profile
|
||||
# These are the files that store login session cookies
|
||||
session_files = [
|
||||
"Cookies",
|
||||
"Cookies-journal",
|
||||
"Login Data",
|
||||
"Login Data-journal",
|
||||
"Web Data",
|
||||
"Web Data-journal",
|
||||
]
|
||||
|
||||
for filename in session_files:
|
||||
filepath = os.path.join(self.profile_dir, "Default", filename)
|
||||
if os.path.exists(filepath):
|
||||
try:
|
||||
os.remove(filepath)
|
||||
print(f"[DDMA BrowserManager] Removed {filename}")
|
||||
except Exception as e:
|
||||
print(f"[DDMA BrowserManager] Could not remove {filename}: {e}")
|
||||
|
||||
# Also try root level (some Chrome versions)
|
||||
for filename in session_files:
|
||||
filepath = os.path.join(self.profile_dir, filename)
|
||||
if os.path.exists(filepath):
|
||||
try:
|
||||
os.remove(filepath)
|
||||
print(f"[DDMA BrowserManager] Removed root {filename}")
|
||||
except Exception as e:
|
||||
print(f"[DDMA BrowserManager] Could not remove root {filename}: {e}")
|
||||
|
||||
# Clear Session Storage (contains login state)
|
||||
session_storage_dir = os.path.join(self.profile_dir, "Default", "Session Storage")
|
||||
if os.path.exists(session_storage_dir):
|
||||
try:
|
||||
shutil.rmtree(session_storage_dir)
|
||||
print("[DDMA BrowserManager] Cleared Session Storage")
|
||||
except Exception as e:
|
||||
print(f"[DDMA BrowserManager] Could not clear Session Storage: {e}")
|
||||
|
||||
# Clear Local Storage (may contain auth tokens)
|
||||
local_storage_dir = os.path.join(self.profile_dir, "Default", "Local Storage")
|
||||
if os.path.exists(local_storage_dir):
|
||||
try:
|
||||
shutil.rmtree(local_storage_dir)
|
||||
print("[DDMA BrowserManager] Cleared Local Storage")
|
||||
except Exception as e:
|
||||
print(f"[DDMA BrowserManager] Could not clear Local Storage: {e}")
|
||||
|
||||
# Clear IndexedDB (may contain auth tokens)
|
||||
indexeddb_dir = os.path.join(self.profile_dir, "Default", "IndexedDB")
|
||||
if os.path.exists(indexeddb_dir):
|
||||
try:
|
||||
shutil.rmtree(indexeddb_dir)
|
||||
print("[DDMA BrowserManager] Cleared IndexedDB")
|
||||
except Exception as e:
|
||||
print(f"[DDMA BrowserManager] Could not clear IndexedDB: {e}")
|
||||
|
||||
# Set flag to clear session via JavaScript after browser opens
|
||||
self._needs_session_clear = True
|
||||
|
||||
print("[DDMA BrowserManager] Session cleared - will require fresh login")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[DDMA BrowserManager] Error clearing session: {e}")
|
||||
|
||||
def _hash_credentials(self, username: str) -> str:
|
||||
"""Create a hash of the username to track credential changes."""
|
||||
return hashlib.sha256(username.encode()).hexdigest()[:16]
|
||||
|
||||
def get_last_credentials_hash(self) -> str | None:
|
||||
"""Get the hash of the last-used credentials."""
|
||||
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):
|
||||
"""Save the hash of the current credentials."""
|
||||
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"[DDMA BrowserManager] Failed to save credentials hash: {e}")
|
||||
|
||||
def credentials_changed(self, username: str) -> bool:
|
||||
"""Check if the credentials have changed since last login."""
|
||||
last_hash = self.get_last_credentials_hash()
|
||||
if last_hash is None:
|
||||
return False # No previous credentials, not a change
|
||||
current_hash = self._hash_credentials(username)
|
||||
changed = last_hash != current_hash
|
||||
if changed:
|
||||
print(f"[DDMA BrowserManager] Credentials changed - logout required")
|
||||
return changed
|
||||
|
||||
def clear_credentials_hash(self):
|
||||
"""Clear the saved credentials hash (used after logout)."""
|
||||
try:
|
||||
if os.path.exists(self._credentials_file):
|
||||
os.remove(self._credentials_file)
|
||||
except Exception as e:
|
||||
print(f"[DDMA BrowserManager] Failed to clear credentials hash: {e}")
|
||||
|
||||
def _kill_existing_chrome_for_profile(self):
|
||||
"""Kill any existing Chrome processes using this profile and clean up locks."""
|
||||
import subprocess
|
||||
import time as time_module
|
||||
try:
|
||||
# Find and kill Chrome processes using this profile
|
||||
result = subprocess.run(
|
||||
["pgrep", "-f", f"user-data-dir={self.profile_dir}"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if result.stdout.strip():
|
||||
pids = result.stdout.strip().split('\n')
|
||||
for pid in pids:
|
||||
try:
|
||||
subprocess.run(["kill", "-9", pid], check=False)
|
||||
except:
|
||||
pass
|
||||
time_module.sleep(1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Remove lock files if they exist
|
||||
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:
|
||||
pass
|
||||
|
||||
def get_driver(self, headless=False):
|
||||
"""Get or create the persistent browser instance."""
|
||||
with self._lock:
|
||||
if self._driver is None:
|
||||
print("[DDMA BrowserManager] Driver is None, creating new driver")
|
||||
self._kill_existing_chrome_for_profile()
|
||||
self._create_driver(headless)
|
||||
elif not self._is_alive():
|
||||
print("[DDMA BrowserManager] Driver not alive, recreating")
|
||||
self._kill_existing_chrome_for_profile()
|
||||
self._create_driver(headless)
|
||||
else:
|
||||
print("[DDMA BrowserManager] Reusing existing driver")
|
||||
return self._driver
|
||||
|
||||
def _is_alive(self):
|
||||
"""Check if browser is still responsive."""
|
||||
try:
|
||||
if self._driver is None:
|
||||
return False
|
||||
url = self._driver.current_url
|
||||
print(f"[DDMA BrowserManager] Driver alive, current URL: {url[:50]}...")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[DDMA BrowserManager] Driver not alive: {e}")
|
||||
return False
|
||||
|
||||
def _create_driver(self, headless=False):
|
||||
"""Create browser with persistent profile."""
|
||||
if self._driver:
|
||||
try:
|
||||
self._driver.quit()
|
||||
except:
|
||||
pass
|
||||
|
||||
options = webdriver.ChromeOptions()
|
||||
if headless:
|
||||
options.add_argument("--headless")
|
||||
|
||||
# Persistent profile - THIS IS THE KEY for device trust
|
||||
options.add_argument(f"--user-data-dir={self.profile_dir}")
|
||||
options.add_argument("--no-sandbox")
|
||||
options.add_argument("--disable-dev-shm-usage")
|
||||
|
||||
prefs = {
|
||||
"download.default_directory": self.download_dir,
|
||||
"plugins.always_open_pdf_externally": True,
|
||||
"download.prompt_for_download": False,
|
||||
"download.directory_upgrade": True
|
||||
}
|
||||
options.add_experimental_option("prefs", prefs)
|
||||
|
||||
service = Service(ChromeDriverManager().install())
|
||||
self._driver = webdriver.Chrome(service=service, options=options)
|
||||
self._driver.maximize_window()
|
||||
|
||||
# Reset the session clear flag (file-based clearing is done on startup)
|
||||
self._needs_session_clear = False
|
||||
|
||||
def quit_driver(self):
|
||||
"""Quit browser (only call on shutdown)."""
|
||||
with self._lock:
|
||||
if self._driver:
|
||||
try:
|
||||
self._driver.quit()
|
||||
except:
|
||||
pass
|
||||
self._driver = None
|
||||
|
||||
|
||||
# Singleton accessor
|
||||
_manager = None
|
||||
|
||||
def get_browser_manager():
|
||||
global _manager
|
||||
if _manager is None:
|
||||
_manager = DDMABrowserManager()
|
||||
return _manager
|
||||
|
||||
|
||||
def clear_ddma_session_on_startup():
|
||||
"""Called by agent.py on startup to clear session."""
|
||||
manager = get_browser_manager()
|
||||
manager.clear_session_on_startup()
|
||||
277
apps/SeleniumService/dentaquest_browser_manager.py
Normal file
277
apps/SeleniumService/dentaquest_browser_manager.py
Normal file
@@ -0,0 +1,277 @@
|
||||
"""
|
||||
Minimal browser manager for DentaQuest - only handles persistent profile and keeping browser alive.
|
||||
Clears session cookies on startup (after PC restart) to force fresh login.
|
||||
Tracks credentials to detect changes mid-session.
|
||||
"""
|
||||
import os
|
||||
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
|
||||
|
||||
# Ensure DISPLAY is set for Chrome to work (needed when running from SSH/background)
|
||||
if not os.environ.get("DISPLAY"):
|
||||
os.environ["DISPLAY"] = ":0"
|
||||
|
||||
|
||||
class DentaQuestBrowserManager:
|
||||
"""
|
||||
Singleton that manages a persistent Chrome browser instance for DentaQuest.
|
||||
- Uses --user-data-dir for persistent profile (device trust tokens)
|
||||
- Clears session cookies on startup (after PC restart)
|
||||
- Tracks credentials to detect changes mid-session
|
||||
"""
|
||||
_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_dentaquest")
|
||||
cls._instance.download_dir = os.path.abspath("seleniumDownloads")
|
||||
cls._instance._credentials_file = os.path.join(cls._instance.profile_dir, ".last_credentials")
|
||||
cls._instance._needs_session_clear = False # Flag to clear session on next driver creation
|
||||
os.makedirs(cls._instance.profile_dir, exist_ok=True)
|
||||
os.makedirs(cls._instance.download_dir, exist_ok=True)
|
||||
return cls._instance
|
||||
|
||||
def clear_session_on_startup(self):
|
||||
"""
|
||||
Clear session cookies from Chrome profile on startup.
|
||||
This forces a fresh login after PC restart.
|
||||
Preserves device trust tokens (LocalStorage, IndexedDB) to avoid OTPs.
|
||||
"""
|
||||
print("[DentaQuest BrowserManager] Clearing session on startup...")
|
||||
|
||||
try:
|
||||
# Clear the credentials tracking file
|
||||
if os.path.exists(self._credentials_file):
|
||||
os.remove(self._credentials_file)
|
||||
print("[DentaQuest BrowserManager] Cleared credentials tracking file")
|
||||
|
||||
# Clear session-related files from Chrome profile
|
||||
# These are the files that store login session cookies
|
||||
session_files = [
|
||||
"Cookies",
|
||||
"Cookies-journal",
|
||||
"Login Data",
|
||||
"Login Data-journal",
|
||||
"Web Data",
|
||||
"Web Data-journal",
|
||||
]
|
||||
|
||||
for filename in session_files:
|
||||
filepath = os.path.join(self.profile_dir, "Default", filename)
|
||||
if os.path.exists(filepath):
|
||||
try:
|
||||
os.remove(filepath)
|
||||
print(f"[DentaQuest BrowserManager] Removed {filename}")
|
||||
except Exception as e:
|
||||
print(f"[DentaQuest BrowserManager] Could not remove {filename}: {e}")
|
||||
|
||||
# Also try root level (some Chrome versions)
|
||||
for filename in session_files:
|
||||
filepath = os.path.join(self.profile_dir, filename)
|
||||
if os.path.exists(filepath):
|
||||
try:
|
||||
os.remove(filepath)
|
||||
print(f"[DentaQuest BrowserManager] Removed root {filename}")
|
||||
except Exception as e:
|
||||
print(f"[DentaQuest BrowserManager] Could not remove root {filename}: {e}")
|
||||
|
||||
# Clear Session Storage (contains login state)
|
||||
session_storage_dir = os.path.join(self.profile_dir, "Default", "Session Storage")
|
||||
if os.path.exists(session_storage_dir):
|
||||
try:
|
||||
shutil.rmtree(session_storage_dir)
|
||||
print("[DentaQuest BrowserManager] Cleared Session Storage")
|
||||
except Exception as e:
|
||||
print(f"[DentaQuest BrowserManager] Could not clear Session Storage: {e}")
|
||||
|
||||
# Clear Local Storage (may contain auth tokens)
|
||||
local_storage_dir = os.path.join(self.profile_dir, "Default", "Local Storage")
|
||||
if os.path.exists(local_storage_dir):
|
||||
try:
|
||||
shutil.rmtree(local_storage_dir)
|
||||
print("[DentaQuest BrowserManager] Cleared Local Storage")
|
||||
except Exception as e:
|
||||
print(f"[DentaQuest BrowserManager] Could not clear Local Storage: {e}")
|
||||
|
||||
# Clear IndexedDB (may contain auth tokens)
|
||||
indexeddb_dir = os.path.join(self.profile_dir, "Default", "IndexedDB")
|
||||
if os.path.exists(indexeddb_dir):
|
||||
try:
|
||||
shutil.rmtree(indexeddb_dir)
|
||||
print("[DentaQuest BrowserManager] Cleared IndexedDB")
|
||||
except Exception as e:
|
||||
print(f"[DentaQuest BrowserManager] Could not clear IndexedDB: {e}")
|
||||
|
||||
# Set flag to clear session via JavaScript after browser opens
|
||||
self._needs_session_clear = True
|
||||
|
||||
print("[DentaQuest BrowserManager] Session cleared - will require fresh login")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[DentaQuest BrowserManager] Error clearing session: {e}")
|
||||
|
||||
def _hash_credentials(self, username: str) -> str:
|
||||
"""Create a hash of the username to track credential changes."""
|
||||
return hashlib.sha256(username.encode()).hexdigest()[:16]
|
||||
|
||||
def get_last_credentials_hash(self) -> str | None:
|
||||
"""Get the hash of the last-used credentials."""
|
||||
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):
|
||||
"""Save the hash of the current credentials."""
|
||||
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"[DentaQuest BrowserManager] Failed to save credentials hash: {e}")
|
||||
|
||||
def credentials_changed(self, username: str) -> bool:
|
||||
"""Check if the credentials have changed since last login."""
|
||||
last_hash = self.get_last_credentials_hash()
|
||||
if last_hash is None:
|
||||
return False # No previous credentials, not a change
|
||||
current_hash = self._hash_credentials(username)
|
||||
changed = last_hash != current_hash
|
||||
if changed:
|
||||
print(f"[DentaQuest BrowserManager] Credentials changed - logout required")
|
||||
return changed
|
||||
|
||||
def clear_credentials_hash(self):
|
||||
"""Clear the saved credentials hash (used after logout)."""
|
||||
try:
|
||||
if os.path.exists(self._credentials_file):
|
||||
os.remove(self._credentials_file)
|
||||
except Exception as e:
|
||||
print(f"[DentaQuest BrowserManager] Failed to clear credentials hash: {e}")
|
||||
|
||||
def _kill_existing_chrome_for_profile(self):
|
||||
"""Kill any existing Chrome processes using this profile."""
|
||||
try:
|
||||
# Find and kill Chrome processes using this profile
|
||||
result = subprocess.run(
|
||||
["pgrep", "-f", f"user-data-dir={self.profile_dir}"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if result.stdout.strip():
|
||||
pids = result.stdout.strip().split('\n')
|
||||
for pid in pids:
|
||||
try:
|
||||
subprocess.run(["kill", "-9", pid], check=False)
|
||||
except:
|
||||
pass
|
||||
time.sleep(1)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
# Remove SingletonLock if exists
|
||||
lock_file = os.path.join(self.profile_dir, "SingletonLock")
|
||||
try:
|
||||
if os.path.islink(lock_file) or os.path.exists(lock_file):
|
||||
os.remove(lock_file)
|
||||
except:
|
||||
pass
|
||||
|
||||
def get_driver(self, headless=False):
|
||||
"""Get or create the persistent browser instance."""
|
||||
with self._lock:
|
||||
if self._driver is None:
|
||||
print("[DentaQuest BrowserManager] Driver is None, creating new driver")
|
||||
self._kill_existing_chrome_for_profile()
|
||||
self._create_driver(headless)
|
||||
elif not self._is_alive():
|
||||
print("[DentaQuest BrowserManager] Driver not alive, recreating")
|
||||
self._kill_existing_chrome_for_profile()
|
||||
self._create_driver(headless)
|
||||
else:
|
||||
print("[DentaQuest BrowserManager] Reusing existing driver")
|
||||
return self._driver
|
||||
|
||||
def _is_alive(self):
|
||||
"""Check if browser is still responsive."""
|
||||
try:
|
||||
if self._driver is None:
|
||||
return False
|
||||
url = self._driver.current_url
|
||||
return True
|
||||
except Exception as e:
|
||||
return False
|
||||
|
||||
def _create_driver(self, headless=False):
|
||||
"""Create browser with persistent profile."""
|
||||
if self._driver:
|
||||
try:
|
||||
self._driver.quit()
|
||||
except:
|
||||
pass
|
||||
self._driver = None
|
||||
time.sleep(1)
|
||||
|
||||
options = webdriver.ChromeOptions()
|
||||
if headless:
|
||||
options.add_argument("--headless")
|
||||
|
||||
# Persistent profile - THIS IS THE KEY for device trust
|
||||
options.add_argument(f"--user-data-dir={self.profile_dir}")
|
||||
options.add_argument("--no-sandbox")
|
||||
options.add_argument("--disable-dev-shm-usage")
|
||||
|
||||
prefs = {
|
||||
"download.default_directory": self.download_dir,
|
||||
"plugins.always_open_pdf_externally": True,
|
||||
"download.prompt_for_download": False,
|
||||
"download.directory_upgrade": True
|
||||
}
|
||||
options.add_experimental_option("prefs", prefs)
|
||||
|
||||
service = Service(ChromeDriverManager().install())
|
||||
self._driver = webdriver.Chrome(service=service, options=options)
|
||||
self._driver.maximize_window()
|
||||
|
||||
# Reset the session clear flag (file-based clearing is done on startup)
|
||||
self._needs_session_clear = False
|
||||
|
||||
def quit_driver(self):
|
||||
"""Quit browser (only call on shutdown)."""
|
||||
with self._lock:
|
||||
if self._driver:
|
||||
try:
|
||||
self._driver.quit()
|
||||
except:
|
||||
pass
|
||||
self._driver = None
|
||||
# Also clean up any orphaned processes
|
||||
self._kill_existing_chrome_for_profile()
|
||||
|
||||
|
||||
# Singleton accessor
|
||||
_manager = None
|
||||
|
||||
def get_browser_manager():
|
||||
global _manager
|
||||
if _manager is None:
|
||||
_manager = DentaQuestBrowserManager()
|
||||
return _manager
|
||||
|
||||
|
||||
def clear_dentaquest_session_on_startup():
|
||||
"""Called by agent.py on startup to clear session."""
|
||||
manager = get_browser_manager()
|
||||
manager.clear_session_on_startup()
|
||||
@@ -60,14 +60,8 @@ async def cleanup_session(sid: str, message: str | None = None):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Attempt to quit driver (may already be dead)
|
||||
driver = s.get("driver")
|
||||
if driver:
|
||||
try:
|
||||
driver.quit()
|
||||
except Exception:
|
||||
# ignore errors from quit (session already gone)
|
||||
pass
|
||||
# NOTE: Do NOT quit driver - keep browser alive for next patient
|
||||
# Browser manager handles the persistent browser instance
|
||||
|
||||
finally:
|
||||
# Remove session entry from map
|
||||
@@ -126,8 +120,15 @@ async def start_ddma_run(sid: str, data: dict, url: str):
|
||||
await cleanup_session(sid, s["message"])
|
||||
return {"status": "error", "message": s["message"]}
|
||||
|
||||
# Already logged in - session persisted from profile, skip to step1
|
||||
if isinstance(login_result, str) and login_result == "ALREADY_LOGGED_IN":
|
||||
print("[start_ddma_run] Session persisted - skipping OTP")
|
||||
s["status"] = "running"
|
||||
s["message"] = "Session persisted"
|
||||
# Continue to step1 below
|
||||
|
||||
# OTP required path
|
||||
if isinstance(login_result, str) and login_result == "OTP_REQUIRED":
|
||||
elif isinstance(login_result, str) and login_result == "OTP_REQUIRED":
|
||||
s["status"] = "waiting_for_otp"
|
||||
s["message"] = "OTP required for login"
|
||||
s["last_activity"] = time.time()
|
||||
@@ -147,10 +148,20 @@ async def start_ddma_run(sid: str, data: dict, url: str):
|
||||
await cleanup_session(sid)
|
||||
return {"status": "error", "message": "OTP missing after event"}
|
||||
|
||||
# Submit OTP in the same Selenium window
|
||||
# Submit OTP - check if it's in a popup window
|
||||
try:
|
||||
driver = s["driver"]
|
||||
wait = WebDriverWait(driver, 30)
|
||||
|
||||
# Check if there's a popup window and switch to it
|
||||
original_window = driver.current_window_handle
|
||||
all_windows = driver.window_handles
|
||||
if len(all_windows) > 1:
|
||||
for window in all_windows:
|
||||
if window != original_window:
|
||||
driver.switch_to.window(window)
|
||||
print(f"[OTP] Switched to popup window for OTP entry")
|
||||
break
|
||||
|
||||
otp_input = wait.until(
|
||||
EC.presence_of_element_located(
|
||||
@@ -169,6 +180,11 @@ async def start_ddma_run(sid: str, data: dict, url: str):
|
||||
submit_btn.click()
|
||||
except Exception:
|
||||
otp_input.send_keys("\n")
|
||||
|
||||
# Wait for verification and switch back to main window if needed
|
||||
await asyncio.sleep(2)
|
||||
if len(driver.window_handles) > 0:
|
||||
driver.switch_to.window(driver.window_handles[0])
|
||||
|
||||
s["status"] = "otp_submitted"
|
||||
s["last_activity"] = time.time()
|
||||
|
||||
264
apps/SeleniumService/helpers_dentaquest_eligibility.py
Normal file
264
apps/SeleniumService/helpers_dentaquest_eligibility.py
Normal file
@@ -0,0 +1,264 @@
|
||||
import os
|
||||
import time
|
||||
import asyncio
|
||||
from typing import Dict, Any
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
from selenium.common.exceptions import WebDriverException
|
||||
|
||||
from selenium_DentaQuest_eligibilityCheckWorker import AutomationDentaQuestEligibilityCheck
|
||||
|
||||
# In-memory session store
|
||||
sessions: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
SESSION_OTP_TIMEOUT = int(os.getenv("SESSION_OTP_TIMEOUT", "120")) # seconds
|
||||
|
||||
|
||||
def make_session_entry() -> str:
|
||||
"""Create a new session entry and return its ID."""
|
||||
import uuid
|
||||
sid = str(uuid.uuid4())
|
||||
sessions[sid] = {
|
||||
"status": "created", # created -> running -> waiting_for_otp -> otp_submitted -> completed / error
|
||||
"created_at": time.time(),
|
||||
"last_activity": time.time(),
|
||||
"bot": None, # worker instance
|
||||
"driver": None, # selenium webdriver
|
||||
"otp_event": asyncio.Event(),
|
||||
"otp_value": None,
|
||||
"result": None,
|
||||
"message": None,
|
||||
"type": None,
|
||||
}
|
||||
return sid
|
||||
|
||||
|
||||
async def cleanup_session(sid: str, message: str | None = None):
|
||||
"""
|
||||
Close driver (if any), wake OTP waiter, set final state, and remove session entry.
|
||||
Idempotent: safe to call multiple times.
|
||||
"""
|
||||
s = sessions.get(sid)
|
||||
if not s:
|
||||
return
|
||||
try:
|
||||
# Ensure final state
|
||||
try:
|
||||
if s.get("status") not in ("completed", "error", "not_found"):
|
||||
s["status"] = "error"
|
||||
if message:
|
||||
s["message"] = message
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Wake any OTP waiter (so awaiting coroutines don't hang)
|
||||
try:
|
||||
ev = s.get("otp_event")
|
||||
if ev and not ev.is_set():
|
||||
ev.set()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# NOTE: Do NOT quit driver - keep browser alive for next patient
|
||||
# Browser manager handles the persistent browser instance
|
||||
|
||||
finally:
|
||||
# Remove session entry from map
|
||||
sessions.pop(sid, None)
|
||||
|
||||
|
||||
async def _remove_session_later(sid: str, delay: int = 20):
|
||||
await asyncio.sleep(delay)
|
||||
await cleanup_session(sid)
|
||||
|
||||
|
||||
async def start_dentaquest_run(sid: str, data: dict, url: str):
|
||||
"""
|
||||
Run the DentaQuest workflow for a session (WITHOUT managing semaphore/counters).
|
||||
Called by agent.py inside a wrapper that handles queue/counters.
|
||||
"""
|
||||
s = sessions.get(sid)
|
||||
if not s:
|
||||
return {"status": "error", "message": "session not found"}
|
||||
|
||||
s["status"] = "running"
|
||||
s["last_activity"] = time.time()
|
||||
|
||||
try:
|
||||
bot = AutomationDentaQuestEligibilityCheck({"data": data})
|
||||
bot.config_driver()
|
||||
|
||||
s["bot"] = bot
|
||||
s["driver"] = bot.driver
|
||||
s["last_activity"] = time.time()
|
||||
|
||||
# Navigate to login URL
|
||||
try:
|
||||
if not url:
|
||||
raise ValueError("URL not provided for DentaQuest run")
|
||||
bot.driver.maximize_window()
|
||||
bot.driver.get(url)
|
||||
await asyncio.sleep(1)
|
||||
except Exception as e:
|
||||
s["status"] = "error"
|
||||
s["message"] = f"Navigation failed: {e}"
|
||||
await cleanup_session(sid)
|
||||
return {"status": "error", "message": s["message"]}
|
||||
|
||||
# Login
|
||||
try:
|
||||
login_result = bot.login(url)
|
||||
except WebDriverException as wde:
|
||||
s["status"] = "error"
|
||||
s["message"] = f"Selenium driver error during login: {wde}"
|
||||
await cleanup_session(sid, s["message"])
|
||||
return {"status": "error", "message": s["message"]}
|
||||
except Exception as e:
|
||||
s["status"] = "error"
|
||||
s["message"] = f"Unexpected error during login: {e}"
|
||||
await cleanup_session(sid, s["message"])
|
||||
return {"status": "error", "message": s["message"]}
|
||||
|
||||
# Already logged in - session persisted from profile, skip to step1
|
||||
if isinstance(login_result, str) and login_result == "ALREADY_LOGGED_IN":
|
||||
s["status"] = "running"
|
||||
s["message"] = "Session persisted"
|
||||
# Continue to step1 below
|
||||
|
||||
# OTP required path
|
||||
elif isinstance(login_result, str) and login_result == "OTP_REQUIRED":
|
||||
s["status"] = "waiting_for_otp"
|
||||
s["message"] = "OTP required for login"
|
||||
s["last_activity"] = time.time()
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(s["otp_event"].wait(), timeout=SESSION_OTP_TIMEOUT)
|
||||
except asyncio.TimeoutError:
|
||||
s["status"] = "error"
|
||||
s["message"] = "OTP timeout"
|
||||
await cleanup_session(sid)
|
||||
return {"status": "error", "message": "OTP not provided in time"}
|
||||
|
||||
otp_value = s.get("otp_value")
|
||||
if not otp_value:
|
||||
s["status"] = "error"
|
||||
s["message"] = "OTP missing after event"
|
||||
await cleanup_session(sid)
|
||||
return {"status": "error", "message": "OTP missing after event"}
|
||||
|
||||
# Submit OTP
|
||||
try:
|
||||
driver = s["driver"]
|
||||
wait = WebDriverWait(driver, 30)
|
||||
|
||||
# Check if there's a popup window and switch to it
|
||||
original_window = driver.current_window_handle
|
||||
all_windows = driver.window_handles
|
||||
if len(all_windows) > 1:
|
||||
for window in all_windows:
|
||||
if window != original_window:
|
||||
driver.switch_to.window(window)
|
||||
break
|
||||
|
||||
# DentaQuest OTP input field - adjust selectors as needed
|
||||
otp_input = wait.until(
|
||||
EC.presence_of_element_located(
|
||||
(By.XPATH, "//input[contains(@name,'otp') or contains(@name,'code') or contains(@placeholder,'code') or contains(@id,'otp') or @type='tel']")
|
||||
)
|
||||
)
|
||||
otp_input.clear()
|
||||
otp_input.send_keys(otp_value)
|
||||
|
||||
try:
|
||||
submit_btn = wait.until(
|
||||
EC.element_to_be_clickable(
|
||||
(By.XPATH, "//button[contains(text(),'Verify') or contains(text(),'Submit') or contains(text(),'Continue') or @type='submit']")
|
||||
)
|
||||
)
|
||||
submit_btn.click()
|
||||
except Exception:
|
||||
otp_input.send_keys("\n")
|
||||
|
||||
# Wait for verification and switch back to main window if needed
|
||||
await asyncio.sleep(2)
|
||||
if len(driver.window_handles) > 0:
|
||||
driver.switch_to.window(driver.window_handles[0])
|
||||
|
||||
s["status"] = "otp_submitted"
|
||||
s["last_activity"] = time.time()
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
except Exception as e:
|
||||
s["status"] = "error"
|
||||
s["message"] = f"Failed to submit OTP into page: {e}"
|
||||
await cleanup_session(sid)
|
||||
return {"status": "error", "message": s["message"]}
|
||||
|
||||
elif isinstance(login_result, str) and login_result.startswith("ERROR"):
|
||||
s["status"] = "error"
|
||||
s["message"] = login_result
|
||||
await cleanup_session(sid)
|
||||
return {"status": "error", "message": login_result}
|
||||
|
||||
# Step 1
|
||||
step1_result = bot.step1()
|
||||
if isinstance(step1_result, str) and step1_result.startswith("ERROR"):
|
||||
s["status"] = "error"
|
||||
s["message"] = step1_result
|
||||
await cleanup_session(sid)
|
||||
return {"status": "error", "message": step1_result}
|
||||
|
||||
# Step 2 (PDF)
|
||||
step2_result = bot.step2()
|
||||
if isinstance(step2_result, dict) and step2_result.get("status") == "success":
|
||||
s["status"] = "completed"
|
||||
s["result"] = step2_result
|
||||
s["message"] = "completed"
|
||||
asyncio.create_task(_remove_session_later(sid, 30))
|
||||
return step2_result
|
||||
else:
|
||||
s["status"] = "error"
|
||||
if isinstance(step2_result, dict):
|
||||
s["message"] = step2_result.get("message", "unknown error")
|
||||
else:
|
||||
s["message"] = str(step2_result)
|
||||
await cleanup_session(sid)
|
||||
return {"status": "error", "message": s["message"]}
|
||||
|
||||
except Exception as e:
|
||||
s["status"] = "error"
|
||||
s["message"] = f"worker exception: {e}"
|
||||
await cleanup_session(sid)
|
||||
return {"status": "error", "message": s["message"]}
|
||||
|
||||
|
||||
def submit_otp(sid: str, otp: str) -> Dict[str, Any]:
|
||||
"""Set OTP for a session and wake waiting runner."""
|
||||
s = sessions.get(sid)
|
||||
if not s:
|
||||
return {"status": "error", "message": "session not found"}
|
||||
if s.get("status") != "waiting_for_otp":
|
||||
return {"status": "error", "message": f"session not waiting for otp (state={s.get('status')})"}
|
||||
s["otp_value"] = otp
|
||||
s["last_activity"] = time.time()
|
||||
try:
|
||||
s["otp_event"].set()
|
||||
except Exception:
|
||||
pass
|
||||
return {"status": "ok", "message": "otp accepted"}
|
||||
|
||||
|
||||
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") == "completed" else None,
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
from selenium import webdriver
|
||||
from selenium.common.exceptions import WebDriverException, TimeoutException
|
||||
from selenium.webdriver.chrome.service import Service
|
||||
from selenium.common.exceptions import TimeoutException
|
||||
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
|
||||
|
||||
from ddma_browser_manager import get_browser_manager
|
||||
|
||||
class AutomationDeltaDentalMAEligibilityCheck:
|
||||
def __init__(self, data):
|
||||
self.headless = False
|
||||
@@ -24,31 +23,193 @@ class AutomationDeltaDentalMAEligibilityCheck:
|
||||
self.massddma_username = self.data.get("massddmaUsername", "")
|
||||
self.massddma_password = self.data.get("massddmaPassword", "")
|
||||
|
||||
self.download_dir = os.path.abspath("seleniumDownloads")
|
||||
# Use browser manager's download dir
|
||||
self.download_dir = get_browser_manager().download_dir
|
||||
os.makedirs(self.download_dir, exist_ok=True)
|
||||
|
||||
def config_driver(self):
|
||||
options = webdriver.ChromeOptions()
|
||||
if self.headless:
|
||||
options.add_argument("--headless")
|
||||
# Use persistent browser from manager (keeps device trust tokens)
|
||||
self.driver = get_browser_manager().get_driver(self.headless)
|
||||
|
||||
# Add PDF download preferences
|
||||
prefs = {
|
||||
"download.default_directory": self.download_dir,
|
||||
"plugins.always_open_pdf_externally": True,
|
||||
"download.prompt_for_download": False,
|
||||
"download.directory_upgrade": True
|
||||
}
|
||||
options.add_experimental_option("prefs", prefs)
|
||||
|
||||
s = Service(ChromeDriverManager().install())
|
||||
driver = webdriver.Chrome(service=s, options=options)
|
||||
self.driver = driver
|
||||
def _force_logout(self):
|
||||
"""Force logout by clearing cookies for Delta Dental domain."""
|
||||
try:
|
||||
print("[DDMA login] Forcing logout due to credential change...")
|
||||
browser_manager = get_browser_manager()
|
||||
|
||||
# First try to click logout button if visible
|
||||
try:
|
||||
self.driver.get("https://providers.deltadentalma.com/")
|
||||
time.sleep(2)
|
||||
|
||||
logout_selectors = [
|
||||
"//button[contains(text(), 'Log out') or contains(text(), 'Logout') or contains(text(), 'Sign out')]",
|
||||
"//a[contains(text(), 'Log out') or contains(text(), 'Logout') or contains(text(), 'Sign out')]",
|
||||
"//button[@aria-label='Log out' or @aria-label='Logout' or @aria-label='Sign out']",
|
||||
"//*[contains(@class, 'logout') or contains(@class, 'signout')]"
|
||||
]
|
||||
|
||||
for selector in logout_selectors:
|
||||
try:
|
||||
logout_btn = WebDriverWait(self.driver, 3).until(
|
||||
EC.element_to_be_clickable((By.XPATH, selector))
|
||||
)
|
||||
logout_btn.click()
|
||||
print("[DDMA login] Clicked logout button")
|
||||
time.sleep(2)
|
||||
break
|
||||
except TimeoutException:
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"[DDMA login] Could not click logout button: {e}")
|
||||
|
||||
# Clear cookies as backup
|
||||
try:
|
||||
self.driver.delete_all_cookies()
|
||||
print("[DDMA login] Cleared all cookies")
|
||||
except Exception as e:
|
||||
print(f"[DDMA login] Error clearing cookies: {e}")
|
||||
|
||||
browser_manager.clear_credentials_hash()
|
||||
print("[DDMA login] Logout complete")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[DDMA login] Error during forced logout: {e}")
|
||||
return False
|
||||
|
||||
def login(self, url):
|
||||
wait = WebDriverWait(self.driver, 30)
|
||||
browser_manager = get_browser_manager()
|
||||
|
||||
try:
|
||||
# Check if credentials have changed - if so, force logout first
|
||||
if self.massddma_username and browser_manager.credentials_changed(self.massddma_username):
|
||||
self._force_logout()
|
||||
self.driver.get(url)
|
||||
time.sleep(2)
|
||||
|
||||
# First check if we're already on a logged-in page (from previous run)
|
||||
try:
|
||||
current_url = self.driver.current_url
|
||||
print(f"[login] Current URL: {current_url}")
|
||||
|
||||
# Check if we're on any logged-in page (dashboard, member pages, etc.)
|
||||
logged_in_patterns = ["member", "dashboard", "eligibility", "search", "patients"]
|
||||
is_logged_in_url = any(pattern in current_url.lower() for pattern in logged_in_patterns)
|
||||
|
||||
if is_logged_in_url and "onboarding" not in current_url.lower():
|
||||
print(f"[login] Already on logged-in page - skipping login entirely")
|
||||
# Navigate directly to member search if not already there
|
||||
if "member" not in current_url.lower():
|
||||
# Try to find a link to member search or just check for search input
|
||||
try:
|
||||
member_search = WebDriverWait(self.driver, 5).until(
|
||||
EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]'))
|
||||
)
|
||||
print("[login] Found member search input - returning ALREADY_LOGGED_IN")
|
||||
return "ALREADY_LOGGED_IN"
|
||||
except TimeoutException:
|
||||
# Try navigating to members page
|
||||
members_url = "https://providers.deltadentalma.com/members"
|
||||
print(f"[login] Navigating to members page: {members_url}")
|
||||
self.driver.get(members_url)
|
||||
time.sleep(2)
|
||||
|
||||
# Verify we have the member search input
|
||||
try:
|
||||
member_search = WebDriverWait(self.driver, 5).until(
|
||||
EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]'))
|
||||
)
|
||||
print("[login] Member search found - ALREADY_LOGGED_IN")
|
||||
return "ALREADY_LOGGED_IN"
|
||||
except TimeoutException:
|
||||
print("[login] Could not find member search, will try login")
|
||||
except Exception as e:
|
||||
print(f"[login] Error checking current state: {e}")
|
||||
|
||||
# Navigate to login URL
|
||||
self.driver.get(url)
|
||||
time.sleep(2) # Wait for page to load and any redirects
|
||||
|
||||
# Check if we got redirected to member search (session still valid)
|
||||
try:
|
||||
current_url = self.driver.current_url
|
||||
print(f"[login] URL after navigation: {current_url}")
|
||||
|
||||
if "onboarding" not in current_url.lower():
|
||||
member_search = WebDriverWait(self.driver, 3).until(
|
||||
EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]'))
|
||||
)
|
||||
if member_search:
|
||||
print("[login] Session valid - skipping login")
|
||||
return "ALREADY_LOGGED_IN"
|
||||
except TimeoutException:
|
||||
print("[login] Proceeding with login")
|
||||
|
||||
# Dismiss any "Authentication flow continued in another tab" modal
|
||||
modal_dismissed = False
|
||||
try:
|
||||
ok_button = WebDriverWait(self.driver, 3).until(
|
||||
EC.element_to_be_clickable((By.XPATH, "//button[normalize-space(text())='Ok' or normalize-space(text())='OK']"))
|
||||
)
|
||||
ok_button.click()
|
||||
print("[login] Dismissed authentication modal")
|
||||
modal_dismissed = True
|
||||
time.sleep(2)
|
||||
|
||||
# Check if a popup window opened for authentication
|
||||
all_windows = self.driver.window_handles
|
||||
print(f"[login] Windows after modal dismiss: {len(all_windows)}")
|
||||
|
||||
if len(all_windows) > 1:
|
||||
# Switch to the auth popup
|
||||
original_window = self.driver.current_window_handle
|
||||
for window in all_windows:
|
||||
if window != original_window:
|
||||
self.driver.switch_to.window(window)
|
||||
print(f"[login] Switched to auth popup window")
|
||||
break
|
||||
|
||||
# Look for OTP input in the popup
|
||||
try:
|
||||
otp_candidate = WebDriverWait(self.driver, 10).until(
|
||||
EC.presence_of_element_located(
|
||||
(By.XPATH, "//input[contains(@aria-lable,'Verification code') or contains(@placeholder,'Enter your verification code') or contains(@aria-label,'Verification code')]")
|
||||
)
|
||||
)
|
||||
if otp_candidate:
|
||||
print("[login] OTP input found in popup -> OTP_REQUIRED")
|
||||
return "OTP_REQUIRED"
|
||||
except TimeoutException:
|
||||
print("[login] No OTP in popup, checking main window")
|
||||
self.driver.switch_to.window(original_window)
|
||||
|
||||
except TimeoutException:
|
||||
pass # No modal present
|
||||
|
||||
# If modal was dismissed but no popup, page might have changed - wait and check
|
||||
if modal_dismissed:
|
||||
time.sleep(2)
|
||||
# Check if we're now on member search page (already authenticated)
|
||||
try:
|
||||
member_search = WebDriverWait(self.driver, 5).until(
|
||||
EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]'))
|
||||
)
|
||||
if member_search:
|
||||
print("[login] Already authenticated after modal dismiss")
|
||||
return "ALREADY_LOGGED_IN"
|
||||
except TimeoutException:
|
||||
pass
|
||||
|
||||
# Try to fill login form
|
||||
try:
|
||||
email_field = WebDriverWait(self.driver, 10).until(
|
||||
EC.element_to_be_clickable((By.XPATH, "//input[@name='username' and @type='text']"))
|
||||
)
|
||||
except TimeoutException:
|
||||
print("[login] Could not find login form - page may have changed")
|
||||
return "ERROR: Login form not found"
|
||||
|
||||
email_field = wait.until(EC.presence_of_element_located((By.XPATH, "//input[@name='username' and @type='text']")))
|
||||
email_field.clear()
|
||||
email_field.send_keys(self.massddma_username)
|
||||
@@ -68,6 +229,10 @@ class AutomationDeltaDentalMAEligibilityCheck:
|
||||
|
||||
login_button = wait.until(EC.element_to_be_clickable((By.XPATH, "//button[@type='submit' and @aria-label='Sign in']")))
|
||||
login_button.click()
|
||||
|
||||
# Save credentials hash after login attempt
|
||||
if self.massddma_username:
|
||||
browser_manager.save_credentials_hash(self.massddma_username)
|
||||
|
||||
# OTP detection
|
||||
try:
|
||||
@@ -226,6 +391,15 @@ class AutomationDeltaDentalMAEligibilityCheck:
|
||||
pass
|
||||
|
||||
print("Screenshot saved at:", screenshot_path)
|
||||
|
||||
# Close the browser window after screenshot (session preserved in profile)
|
||||
try:
|
||||
from ddma_browser_manager import get_browser_manager
|
||||
get_browser_manager().quit_driver()
|
||||
print("[step2] Browser closed - session preserved in profile")
|
||||
except Exception as e:
|
||||
print(f"[step2] Error closing browser: {e}")
|
||||
|
||||
output = {
|
||||
"status": "success",
|
||||
"eligibility": eligibilityText,
|
||||
@@ -254,14 +428,7 @@ class AutomationDeltaDentalMAEligibilityCheck:
|
||||
print(f"[cleanup] unexpected error while cleaning downloads dir: {cleanup_exc}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
finally:
|
||||
# Keep your existing quit behavior; if you want the driver to remain open for further
|
||||
# actions, remove or change this.
|
||||
if self.driver:
|
||||
try:
|
||||
self.driver.quit()
|
||||
except Exception:
|
||||
pass
|
||||
# NOTE: Do NOT quit driver here - keep browser alive for next patient
|
||||
|
||||
def main_workflow(self, url):
|
||||
try:
|
||||
@@ -289,10 +456,4 @@ class AutomationDeltaDentalMAEligibilityCheck:
|
||||
"status": "error",
|
||||
"message": e
|
||||
}
|
||||
|
||||
finally:
|
||||
try:
|
||||
if self.driver:
|
||||
self.driver.quit()
|
||||
except Exception:
|
||||
pass
|
||||
# NOTE: Do NOT quit driver - keep browser alive for next patient
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
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
|
||||
|
||||
from dentaquest_browser_manager import get_browser_manager
|
||||
|
||||
class AutomationDentaQuestEligibilityCheck:
|
||||
def __init__(self, data):
|
||||
self.headless = False
|
||||
self.driver = None
|
||||
|
||||
self.data = data.get("data", {}) if isinstance(data, dict) else {}
|
||||
|
||||
# Flatten values for convenience
|
||||
self.memberId = self.data.get("memberId", "")
|
||||
self.dateOfBirth = self.data.get("dateOfBirth", "")
|
||||
self.dentaquest_username = self.data.get("dentaquestUsername", "")
|
||||
self.dentaquest_password = self.data.get("dentaquestPassword", "")
|
||||
|
||||
# Use browser manager's download dir
|
||||
self.download_dir = get_browser_manager().download_dir
|
||||
os.makedirs(self.download_dir, exist_ok=True)
|
||||
|
||||
def config_driver(self):
|
||||
# Use persistent browser from manager (keeps device trust tokens)
|
||||
self.driver = get_browser_manager().get_driver(self.headless)
|
||||
|
||||
def _force_logout(self):
|
||||
"""Force logout by clearing cookies for DentaQuest domain."""
|
||||
try:
|
||||
print("[DentaQuest login] Forcing logout due to credential change...")
|
||||
browser_manager = get_browser_manager()
|
||||
|
||||
# First try to click logout button if visible
|
||||
try:
|
||||
self.driver.get("https://providers.dentaquest.com/")
|
||||
time.sleep(2)
|
||||
|
||||
logout_selectors = [
|
||||
"//button[contains(text(), 'Log out') or contains(text(), 'Logout') or contains(text(), 'Sign out')]",
|
||||
"//a[contains(text(), 'Log out') or contains(text(), 'Logout') or contains(text(), 'Sign out')]",
|
||||
"//button[@aria-label='Log out' or @aria-label='Logout' or @aria-label='Sign out']",
|
||||
"//*[contains(@class, 'logout') or contains(@class, 'signout')]"
|
||||
]
|
||||
|
||||
for selector in logout_selectors:
|
||||
try:
|
||||
logout_btn = WebDriverWait(self.driver, 3).until(
|
||||
EC.element_to_be_clickable((By.XPATH, selector))
|
||||
)
|
||||
logout_btn.click()
|
||||
print("[DentaQuest login] Clicked logout button")
|
||||
time.sleep(2)
|
||||
break
|
||||
except TimeoutException:
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"[DentaQuest login] Could not click logout button: {e}")
|
||||
|
||||
# Clear cookies as backup
|
||||
try:
|
||||
self.driver.delete_all_cookies()
|
||||
print("[DentaQuest login] Cleared all cookies")
|
||||
except Exception as e:
|
||||
print(f"[DentaQuest login] Error clearing cookies: {e}")
|
||||
|
||||
browser_manager.clear_credentials_hash()
|
||||
print("[DentaQuest login] Logout complete")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[DentaQuest login] Error during forced logout: {e}")
|
||||
return False
|
||||
|
||||
def login(self, url):
|
||||
wait = WebDriverWait(self.driver, 30)
|
||||
browser_manager = get_browser_manager()
|
||||
|
||||
try:
|
||||
# Check if credentials have changed - if so, force logout first
|
||||
if self.dentaquest_username and browser_manager.credentials_changed(self.dentaquest_username):
|
||||
self._force_logout()
|
||||
self.driver.get(url)
|
||||
time.sleep(2)
|
||||
|
||||
# First check if we're already on a logged-in page (from previous run)
|
||||
try:
|
||||
current_url = self.driver.current_url
|
||||
print(f"[DentaQuest login] Current URL: {current_url}")
|
||||
|
||||
# Check if we're already on dashboard with member search
|
||||
if "dashboard" in current_url.lower() or "member" in current_url.lower():
|
||||
try:
|
||||
member_search = WebDriverWait(self.driver, 3).until(
|
||||
EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]'))
|
||||
)
|
||||
print("[DentaQuest login] Already on dashboard with member search")
|
||||
return "ALREADY_LOGGED_IN"
|
||||
except TimeoutException:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"[DentaQuest login] Error checking current state: {e}")
|
||||
|
||||
# Navigate to login URL
|
||||
self.driver.get(url)
|
||||
time.sleep(3)
|
||||
|
||||
current_url = self.driver.current_url
|
||||
print(f"[DentaQuest login] After navigation URL: {current_url}")
|
||||
|
||||
# If already on dashboard, we're logged in
|
||||
if "dashboard" in current_url.lower():
|
||||
print("[DentaQuest login] Already on dashboard")
|
||||
return "ALREADY_LOGGED_IN"
|
||||
|
||||
# Try to dismiss the modal by clicking OK
|
||||
try:
|
||||
ok_button = WebDriverWait(self.driver, 5).until(
|
||||
EC.element_to_be_clickable((By.XPATH, "//button[normalize-space(text())='Ok' or normalize-space(text())='OK' or normalize-space(text())='Continue']"))
|
||||
)
|
||||
ok_button.click()
|
||||
print("[DentaQuest login] Clicked OK modal button")
|
||||
time.sleep(3)
|
||||
except TimeoutException:
|
||||
print("[DentaQuest login] No OK modal button found")
|
||||
|
||||
# Check if we're now on dashboard (session was valid)
|
||||
current_url = self.driver.current_url
|
||||
print(f"[DentaQuest login] After modal click URL: {current_url}")
|
||||
|
||||
if "dashboard" in current_url.lower():
|
||||
# Check for member search input to confirm logged in
|
||||
try:
|
||||
member_search = WebDriverWait(self.driver, 5).until(
|
||||
EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]'))
|
||||
)
|
||||
print("[DentaQuest login] Session valid - on dashboard with member search")
|
||||
return "ALREADY_LOGGED_IN"
|
||||
except TimeoutException:
|
||||
pass
|
||||
|
||||
# Check if OTP is required (popup window or OTP input)
|
||||
if len(self.driver.window_handles) > 1:
|
||||
original_window = self.driver.current_window_handle
|
||||
for window in self.driver.window_handles:
|
||||
if window != original_window:
|
||||
self.driver.switch_to.window(window)
|
||||
print("[DentaQuest login] Switched to popup window")
|
||||
break
|
||||
|
||||
try:
|
||||
otp_input = WebDriverWait(self.driver, 5).until(
|
||||
EC.presence_of_element_located((By.XPATH, "//input[@type='tel' or contains(@placeholder,'code') or contains(@aria-label,'Verification')]"))
|
||||
)
|
||||
print("[DentaQuest login] OTP input found in popup")
|
||||
return "OTP_REQUIRED"
|
||||
except TimeoutException:
|
||||
self.driver.switch_to.window(original_window)
|
||||
|
||||
# Check for OTP input on main page
|
||||
try:
|
||||
otp_input = WebDriverWait(self.driver, 3).until(
|
||||
EC.presence_of_element_located((By.XPATH, "//input[@type='tel' or contains(@placeholder,'code') or contains(@aria-label,'Verification')]"))
|
||||
)
|
||||
print("[DentaQuest login] OTP input found")
|
||||
return "OTP_REQUIRED"
|
||||
except TimeoutException:
|
||||
pass
|
||||
|
||||
# If still on login page, need to fill credentials
|
||||
if "onboarding" in current_url.lower() or "login" in current_url.lower():
|
||||
print("[DentaQuest login] Need to fill login credentials")
|
||||
|
||||
try:
|
||||
email_field = WebDriverWait(self.driver, 10).until(
|
||||
EC.element_to_be_clickable((By.XPATH, "//input[@name='username' or @type='text']"))
|
||||
)
|
||||
email_field.clear()
|
||||
email_field.send_keys(self.dentaquest_username)
|
||||
|
||||
password_field = wait.until(EC.presence_of_element_located((By.XPATH, "//input[@type='password']")))
|
||||
password_field.clear()
|
||||
password_field.send_keys(self.dentaquest_password)
|
||||
|
||||
# Click login button
|
||||
login_button = wait.until(EC.element_to_be_clickable((By.XPATH, "//button[@type='submit']")))
|
||||
login_button.click()
|
||||
print("[DentaQuest login] Submitted login form")
|
||||
|
||||
# Save credentials hash after login attempt
|
||||
if self.dentaquest_username:
|
||||
browser_manager.save_credentials_hash(self.dentaquest_username)
|
||||
|
||||
time.sleep(5)
|
||||
|
||||
# Check for OTP after login
|
||||
try:
|
||||
otp_input = WebDriverWait(self.driver, 10).until(
|
||||
EC.presence_of_element_located((By.XPATH, "//input[@type='tel' or contains(@placeholder,'code') or contains(@aria-label,'Verification')]"))
|
||||
)
|
||||
return "OTP_REQUIRED"
|
||||
except TimeoutException:
|
||||
pass
|
||||
|
||||
# Check if login succeeded
|
||||
if "dashboard" in self.driver.current_url.lower():
|
||||
return "SUCCESS"
|
||||
|
||||
except TimeoutException:
|
||||
print("[DentaQuest login] Login form elements not found")
|
||||
return "ERROR: Login form not found"
|
||||
|
||||
return "SUCCESS"
|
||||
|
||||
except Exception as e:
|
||||
print(f"[DentaQuest login] Exception: {e}")
|
||||
return f"ERROR:LOGIN FAILED: {e}"
|
||||
|
||||
def step1(self):
|
||||
"""Navigate to member search and enter member ID + DOB"""
|
||||
wait = WebDriverWait(self.driver, 30)
|
||||
|
||||
try:
|
||||
print(f"[DentaQuest step1] Starting member search for ID: {self.memberId}, DOB: {self.dateOfBirth}")
|
||||
|
||||
# Wait for page to be ready
|
||||
time.sleep(2)
|
||||
|
||||
# Parse DOB - format: YYYY-MM-DD
|
||||
try:
|
||||
dob_parts = self.dateOfBirth.split("-")
|
||||
dob_year = dob_parts[0]
|
||||
dob_month = dob_parts[1].zfill(2)
|
||||
dob_day = dob_parts[2].zfill(2)
|
||||
print(f"[DentaQuest step1] Parsed DOB: {dob_month}/{dob_day}/{dob_year}")
|
||||
except Exception as e:
|
||||
print(f"[DentaQuest step1] Error parsing DOB: {e}")
|
||||
return "ERROR: PARSING DOB"
|
||||
|
||||
# Get today's date for Date of Service
|
||||
from datetime import datetime
|
||||
today = datetime.now()
|
||||
service_month = str(today.month).zfill(2)
|
||||
service_day = str(today.day).zfill(2)
|
||||
service_year = str(today.year)
|
||||
print(f"[DentaQuest step1] Service date: {service_month}/{service_day}/{service_year}")
|
||||
|
||||
# Helper function to fill contenteditable date spans within a specific container
|
||||
def fill_date_by_testid(testid, month_val, day_val, year_val, field_name):
|
||||
try:
|
||||
container = self.driver.find_element(By.XPATH, f"//div[@data-testid='{testid}']")
|
||||
month_elem = container.find_element(By.XPATH, ".//span[@data-type='month' and @contenteditable='true']")
|
||||
day_elem = container.find_element(By.XPATH, ".//span[@data-type='day' and @contenteditable='true']")
|
||||
year_elem = container.find_element(By.XPATH, ".//span[@data-type='year' and @contenteditable='true']")
|
||||
|
||||
def replace_with_sendkeys(el, value):
|
||||
el.click()
|
||||
time.sleep(0.05)
|
||||
el.send_keys(Keys.CONTROL, "a")
|
||||
el.send_keys(Keys.BACKSPACE)
|
||||
el.send_keys(value)
|
||||
|
||||
replace_with_sendkeys(month_elem, month_val)
|
||||
time.sleep(0.1)
|
||||
replace_with_sendkeys(day_elem, day_val)
|
||||
time.sleep(0.1)
|
||||
replace_with_sendkeys(year_elem, year_val)
|
||||
print(f"[DentaQuest step1] Filled {field_name}: {month_val}/{day_val}/{year_val}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[DentaQuest step1] Error filling {field_name}: {e}")
|
||||
return False
|
||||
|
||||
# 1. Fill Date of Service with TODAY's date using specific data-testid
|
||||
fill_date_by_testid("member-search_date-of-service", service_month, service_day, service_year, "Date of Service")
|
||||
time.sleep(0.3)
|
||||
|
||||
# 2. Fill Date of Birth with patient's DOB using specific data-testid
|
||||
fill_date_by_testid("member-search_date-of-birth", dob_month, dob_day, dob_year, "Date of Birth")
|
||||
time.sleep(0.3)
|
||||
|
||||
# 3. Fill Member ID
|
||||
member_id_input = wait.until(EC.presence_of_element_located(
|
||||
(By.XPATH, '//input[@placeholder="Search by member ID"]')
|
||||
))
|
||||
member_id_input.clear()
|
||||
member_id_input.send_keys(self.memberId)
|
||||
print(f"[DentaQuest step1] Entered member ID: {self.memberId}")
|
||||
|
||||
time.sleep(0.3)
|
||||
|
||||
# 4. Click Search button
|
||||
try:
|
||||
search_btn = wait.until(EC.element_to_be_clickable(
|
||||
(By.XPATH, '//button[@data-testid="member-search_search-button"]')
|
||||
))
|
||||
search_btn.click()
|
||||
print("[DentaQuest step1] Clicked search button")
|
||||
except TimeoutException:
|
||||
# Fallback
|
||||
try:
|
||||
search_btn = self.driver.find_element(By.XPATH, '//button[contains(text(),"Search")]')
|
||||
search_btn.click()
|
||||
print("[DentaQuest step1] Clicked search button (fallback)")
|
||||
except:
|
||||
member_id_input.send_keys(Keys.RETURN)
|
||||
print("[DentaQuest step1] Pressed Enter to search")
|
||||
|
||||
time.sleep(5)
|
||||
|
||||
# Check for "no results" error
|
||||
try:
|
||||
error_msg = WebDriverWait(self.driver, 3).until(EC.presence_of_element_located(
|
||||
(By.XPATH, '//*[contains(@data-testid,"no-results") or contains(@class,"no-results") or contains(text(),"No results") or contains(text(),"not found") or contains(text(),"No member found") or contains(text(),"Nothing was found")]')
|
||||
))
|
||||
if error_msg and error_msg.is_displayed():
|
||||
print("[DentaQuest step1] No results found")
|
||||
return "ERROR: INVALID MEMBERID OR DOB"
|
||||
except TimeoutException:
|
||||
pass
|
||||
|
||||
print("[DentaQuest step1] Search completed successfully")
|
||||
return "Success"
|
||||
|
||||
except Exception as e:
|
||||
print(f"[DentaQuest step1] Exception: {e}")
|
||||
return f"ERROR:STEP1 - {e}"
|
||||
|
||||
|
||||
def step2(self):
|
||||
"""Get eligibility status and capture screenshot"""
|
||||
wait = WebDriverWait(self.driver, 90)
|
||||
|
||||
try:
|
||||
print("[DentaQuest step2] Starting eligibility capture")
|
||||
|
||||
# Wait for results to load
|
||||
time.sleep(3)
|
||||
|
||||
# Try to find eligibility status from the results
|
||||
eligibilityText = "unknown"
|
||||
try:
|
||||
# Look for a link or element with eligibility status
|
||||
status_elem = wait.until(EC.presence_of_element_located((
|
||||
By.XPATH,
|
||||
"//a[contains(@href,'eligibility')] | //*[contains(@class,'status')] | //*[contains(text(),'Active') or contains(text(),'Inactive') or contains(text(),'Eligible')]"
|
||||
)))
|
||||
eligibilityText = status_elem.text.strip().lower()
|
||||
print(f"[DentaQuest step2] Found status element: {eligibilityText}")
|
||||
|
||||
# Normalize status
|
||||
if "active" in eligibilityText or "eligible" in eligibilityText:
|
||||
eligibilityText = "active"
|
||||
elif "inactive" in eligibilityText or "ineligible" in eligibilityText:
|
||||
eligibilityText = "inactive"
|
||||
except TimeoutException:
|
||||
print("[DentaQuest step2] Could not find specific eligibility status")
|
||||
|
||||
# Try to find patient name
|
||||
patientName = ""
|
||||
try:
|
||||
# Look for the patient name in the results
|
||||
name_elem = self.driver.find_element(By.XPATH, "//h1 | //div[contains(@class,'name')] | //*[contains(@class,'member-name') or contains(@class,'patient-name')]")
|
||||
patientName = name_elem.text.strip()
|
||||
print(f"[DentaQuest step2] Found patient name: {patientName}")
|
||||
except:
|
||||
pass
|
||||
|
||||
# Wait for page to fully load
|
||||
try:
|
||||
WebDriverWait(self.driver, 30).until(
|
||||
lambda d: d.execute_script("return document.readyState") == "complete"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
# Capture full page screenshot
|
||||
print("[DentaQuest step2] Capturing screenshot")
|
||||
total_width = int(self.driver.execute_script(
|
||||
"return Math.max(document.body.scrollWidth, document.documentElement.scrollWidth, document.documentElement.clientWidth);"
|
||||
))
|
||||
total_height = int(self.driver.execute_script(
|
||||
"return Math.max(document.body.scrollHeight, document.documentElement.scrollHeight, document.documentElement.clientHeight);"
|
||||
))
|
||||
dpr = float(self.driver.execute_script("return window.devicePixelRatio || 1;"))
|
||||
|
||||
self.driver.execute_cdp_cmd('Emulation.setDeviceMetricsOverride', {
|
||||
"mobile": False,
|
||||
"width": total_width,
|
||||
"height": total_height,
|
||||
"deviceScaleFactor": dpr,
|
||||
"screenOrientation": {"angle": 0, "type": "portraitPrimary"}
|
||||
})
|
||||
|
||||
time.sleep(0.2)
|
||||
|
||||
# Capture screenshot
|
||||
result = self.driver.execute_cdp_cmd("Page.captureScreenshot", {"format": "png", "fromSurface": True})
|
||||
image_data = base64.b64decode(result.get('data', ''))
|
||||
screenshot_path = os.path.join(self.download_dir, f"dentaquest_ss_{self.memberId}_{int(time.time())}.png")
|
||||
with open(screenshot_path, "wb") as f:
|
||||
f.write(image_data)
|
||||
print(f"[DentaQuest step2] Screenshot saved: {screenshot_path}")
|
||||
|
||||
# Restore original metrics
|
||||
try:
|
||||
self.driver.execute_cdp_cmd('Emulation.clearDeviceMetricsOverride', {})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Close the browser window after screenshot
|
||||
try:
|
||||
from dentaquest_browser_manager import get_browser_manager
|
||||
get_browser_manager().quit_driver()
|
||||
print("[DentaQuest step2] Browser closed")
|
||||
except Exception as e:
|
||||
print(f"[DentaQuest step2] Error closing browser: {e}")
|
||||
|
||||
output = {
|
||||
"status": "success",
|
||||
"eligibility": eligibilityText,
|
||||
"ss_path": screenshot_path,
|
||||
"patientName": patientName
|
||||
}
|
||||
print(f"[DentaQuest step2] Success: {output}")
|
||||
return output
|
||||
|
||||
except Exception as e:
|
||||
print(f"[DentaQuest step2] Exception: {e}")
|
||||
# Cleanup download folder on error
|
||||
try:
|
||||
dl = os.path.abspath(self.download_dir)
|
||||
if os.path.isdir(dl):
|
||||
for name in os.listdir(dl):
|
||||
item = os.path.join(dl, name)
|
||||
try:
|
||||
if os.path.isfile(item) or os.path.islink(item):
|
||||
os.remove(item)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
def main_workflow(self, url):
|
||||
try:
|
||||
self.config_driver()
|
||||
self.driver.maximize_window()
|
||||
time.sleep(3)
|
||||
|
||||
login_result = self.login(url)
|
||||
if login_result.startswith("ERROR"):
|
||||
return {"status": "error", "message": login_result}
|
||||
if login_result == "OTP_REQUIRED":
|
||||
return {"status": "otp_required", "message": "OTP required after login"}
|
||||
|
||||
step1_result = self.step1()
|
||||
if step1_result.startswith("ERROR"):
|
||||
return {"status": "error", "message": step1_result}
|
||||
|
||||
step2_result = self.step2()
|
||||
if step2_result.get("status") == "error":
|
||||
return {"status": "error", "message": step2_result.get("message")}
|
||||
|
||||
return step2_result
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": str(e)
|
||||
}
|
||||
@@ -27,6 +27,9 @@ class AutomationMassHealth:
|
||||
self.remarks = self.claim.get("remarks", "")
|
||||
self.massdhp_username = self.claim.get("massdhpUsername", "")
|
||||
self.massdhp_password = self.claim.get("massdhpPassword", "")
|
||||
self.npiProvider = self.claim.get("npiProvider", {})
|
||||
self.npiNumber = self.npiProvider.get("npiNumber", "")
|
||||
self.npiName = self.npiProvider.get("providerName", "")
|
||||
self.serviceLines = self.claim.get("serviceLines", [])
|
||||
self.missingTeethStatus = self.claim.get("missingTeethStatus", "")
|
||||
self.missingTeeth = self.claim.get("missingTeeth", {})
|
||||
@@ -40,6 +43,39 @@ class AutomationMassHealth:
|
||||
driver = webdriver.Chrome(service=s, options=options)
|
||||
self.driver = driver
|
||||
|
||||
def select_rendering_npi(self, select_element):
|
||||
options = select_element.options
|
||||
|
||||
target_npi = (self.npiNumber or "").strip()
|
||||
target_name = (self.npiName or "").strip().lower()
|
||||
|
||||
# 1️⃣ Exact NPI match (value or text)
|
||||
for opt in options:
|
||||
value = (opt.get_attribute("value") or "").strip()
|
||||
text = (opt.text or "").lower()
|
||||
|
||||
if target_npi and (
|
||||
value == target_npi or target_npi in text
|
||||
):
|
||||
opt.click()
|
||||
return True
|
||||
|
||||
# 2️⃣ Name match fallback
|
||||
for opt in options:
|
||||
text = (opt.text or "").lower()
|
||||
if target_name and target_name in text:
|
||||
opt.click()
|
||||
return True
|
||||
|
||||
# 3️⃣ Last fallback → first valid option
|
||||
for opt in options:
|
||||
if opt.get_attribute("value"):
|
||||
opt.click()
|
||||
return True
|
||||
|
||||
raise Exception("No valid Rendering Provider NPI found")
|
||||
|
||||
|
||||
def login(self):
|
||||
wait = WebDriverWait(self.driver, 30)
|
||||
|
||||
@@ -98,7 +134,7 @@ class AutomationMassHealth:
|
||||
# Rendering Provider NPI dropdown
|
||||
npi_dropdown = wait.until(EC.presence_of_element_located((By.XPATH, '//*[@id="Select1"]')))
|
||||
select_npi = Select(npi_dropdown)
|
||||
select_npi.select_by_index(1)
|
||||
self.select_rendering_npi(select_npi)
|
||||
|
||||
# Office Location dropdown
|
||||
location_dropdown = wait.until(EC.presence_of_element_located((By.XPATH, '//*[@id="Select2"]')))
|
||||
|
||||
@@ -26,6 +26,9 @@ class AutomationMassHealthPreAuth:
|
||||
self.remarks = self.claim.get("remarks", "")
|
||||
self.massdhp_username = self.claim.get("massdhpUsername", "")
|
||||
self.massdhp_password = self.claim.get("massdhpPassword", "")
|
||||
self.npiProvider = self.claim.get("npiProvider", {})
|
||||
self.npiNumber = self.npiProvider.get("npiNumber", "")
|
||||
self.npiName = self.npiProvider.get("providerName", "")
|
||||
self.serviceLines = self.claim.get("serviceLines", [])
|
||||
self.missingTeethStatus = self.claim.get("missingTeethStatus", "")
|
||||
self.missingTeeth = self.claim.get("missingTeeth", {})
|
||||
@@ -39,6 +42,39 @@ class AutomationMassHealthPreAuth:
|
||||
driver = webdriver.Chrome(service=s, options=options)
|
||||
self.driver = driver
|
||||
|
||||
def select_rendering_npi(self, select_element):
|
||||
options = select_element.options
|
||||
|
||||
target_npi = (self.npiNumber or "").strip()
|
||||
target_name = (self.npiName or "").strip().lower()
|
||||
|
||||
# 1️⃣ Exact NPI match (value or text)
|
||||
for opt in options:
|
||||
value = (opt.get_attribute("value") or "").strip()
|
||||
text = (opt.text or "").lower()
|
||||
|
||||
if target_npi and (
|
||||
value == target_npi or target_npi in text
|
||||
):
|
||||
opt.click()
|
||||
return True
|
||||
|
||||
# 2️⃣ Name match fallback
|
||||
for opt in options:
|
||||
text = (opt.text or "").lower()
|
||||
if target_name and target_name in text:
|
||||
opt.click()
|
||||
return True
|
||||
|
||||
# 3️⃣ Last fallback → first valid option
|
||||
for opt in options:
|
||||
if opt.get_attribute("value"):
|
||||
opt.click()
|
||||
return True
|
||||
|
||||
raise Exception("No valid Rendering Provider NPI found")
|
||||
|
||||
|
||||
def login(self):
|
||||
wait = WebDriverWait(self.driver, 30)
|
||||
|
||||
@@ -97,7 +133,7 @@ class AutomationMassHealthPreAuth:
|
||||
# Rendering Provider NPI dropdown
|
||||
npi_dropdown = wait.until(EC.presence_of_element_located((By.XPATH, '//*[@id="Select1"]')))
|
||||
select_npi = Select(npi_dropdown)
|
||||
select_npi.select_by_index(1)
|
||||
self.select_rendering_npi(select_npi)
|
||||
|
||||
# Office Location dropdown
|
||||
location_dropdown = wait.until(EC.presence_of_element_located((By.XPATH, '//*[@id="Select2"]')))
|
||||
|
||||
34
package-lock.json
generated
34
package-lock.json
generated
@@ -239,6 +239,7 @@
|
||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@@ -586,7 +587,8 @@
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.2.tgz",
|
||||
"integrity": "sha512-zfWWa+V2ViDCY/cmUfRqeWY1yLto+EpxjXnZzenB1TyxsTiXaTWeZFIZw6mac52BsuQm0RjCnisjBtdBaXOI6w==",
|
||||
"license": "Apache-2.0"
|
||||
"license": "Apache-2.0",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@electric-sql/pglite-socket": {
|
||||
"version": "0.0.6",
|
||||
@@ -4560,6 +4562,7 @@
|
||||
"integrity": "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"hoist-non-react-statics": "^3.3.0"
|
||||
},
|
||||
@@ -4619,6 +4622,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.11.tgz",
|
||||
"integrity": "sha512-y+cTCACu92FyA5fgQSAI8A1H429g7aSK2HsO7K4XYUWc4dY5IUz55JSDIYT6/VsOLfGy8vmvQYC2hfb0iF16Uw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.19.2"
|
||||
}
|
||||
@@ -4683,6 +4687,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -4693,6 +4698,7 @@
|
||||
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -4843,6 +4849,7 @@
|
||||
"integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.46.3",
|
||||
"@typescript-eslint/types": "8.46.3",
|
||||
@@ -5126,6 +5133,7 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -5648,6 +5656,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.19",
|
||||
"caniuse-lite": "^1.0.30001751",
|
||||
@@ -6696,7 +6705,8 @@
|
||||
"version": "8.6.0",
|
||||
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
|
||||
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/embla-carousel-react": {
|
||||
"version": "8.6.0",
|
||||
@@ -7013,6 +7023,7 @@
|
||||
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -8220,6 +8231,7 @@
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.7.10.tgz",
|
||||
"integrity": "sha512-QkACju9MiN59CKSY5JsGZCYmPZkA6sIW6OFCUp7qDjZu6S6KHtJHhAc9Uy9mV9F8PJ1/HQ3ybZF2yjCa/73fvQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
@@ -9998,6 +10010,7 @@
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz",
|
||||
"integrity": "sha512-TdTZPf1trZ8/UFu5Cx/GXB7GZM30LT+wWUNfsi6Bq8ePLnb+woNKtDymI2mxZYBpMbonNFqKmiz684DIfnd8dA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -10030,6 +10043,7 @@
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
||||
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.9.1",
|
||||
"pg-pool": "^3.10.1",
|
||||
@@ -10185,6 +10199,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -10402,6 +10417,7 @@
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
|
||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -11128,6 +11144,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -11219,6 +11236,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
||||
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -11231,6 +11249,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
|
||||
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@@ -11262,6 +11281,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
@@ -11521,7 +11541,8 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
@@ -12486,6 +12507,7 @@
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz",
|
||||
"integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"arg": "^5.0.2",
|
||||
@@ -12766,6 +12788,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -13037,6 +13060,7 @@
|
||||
"integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.25.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -13212,6 +13236,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -13489,6 +13514,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
@@ -13580,6 +13606,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -13891,6 +13918,7 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"db:generate": "npx prisma generate --config=packages/db/prisma/prisma.config.ts --schema=packages/db/prisma/schema.prisma && npx ts-node packages/db/scripts/patch-prisma-imports.ts && ts-node packages/db/scripts/patch-zod-buffer.ts",
|
||||
"db:migrate": "npx prisma migrate dev --config=packages/db/prisma/prisma.config.ts --schema=packages/db/prisma/schema.prisma",
|
||||
"db:seed": "npx prisma db seed --config=packages/db/prisma/prisma.config.ts --schema=packages/db/prisma/schema.prisma",
|
||||
"db:studio": "npx prisma studio --config=packages/db/prisma/prisma.config.ts",
|
||||
"setup:env": "shx cp packages/db/prisma/.env.example packages/db/prisma/.env && shx cp apps/Frontend/.env.example apps/Frontend/.env && shx cp apps/Backend/.env.example apps/Backend/.env && shx cp apps/PatientDataExtractorService/.env.example apps/PatientDataExtractorService/.env && shx cp apps/SeleniumService/.env.example apps/SeleniumService/.env && shx cp apps/PaymentOCRService/.env.example apps/PaymentOCRService/.env",
|
||||
"postinstall": "npm --prefix apps/PatientDataExtractorService run postinstall && npm --prefix apps/PaymentOCRService run postinstall"
|
||||
},
|
||||
|
||||
@@ -23,21 +23,9 @@ tar -xzf /path/to/backup.tar.gz -C /tmp/dental_dump_dir
|
||||
|
||||
# create DB (if not exists) and restore (data-only or custom format)
|
||||
PGPASSWORD='mypassword' createdb -U postgres -h localhost -O postgres dentalapp
|
||||
PGPASSWORD='mypassword' pg_restore -U postgres -h localhost -d dentalapp -j 4 /tmp/dental_dump_dir
|
||||
# (or use /usr/lib/postgresql/<ver>/bin/pg_restore if version mismatch)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 2 — Confirm DB has tables
|
||||
|
||||
```bash
|
||||
PGPASSWORD='mypassword' psql -U postgres -h localhost -d dentalapp -c "\dt"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 3 — (If needed) fix postgres user password / auth
|
||||
# 1.2 — (If needed) fix postgres user password / auth
|
||||
|
||||
If `createdb` or `pg_restore` fails with password auth:
|
||||
|
||||
@@ -47,110 +35,74 @@ sudo -u postgres psql -c "ALTER ROLE postgres WITH PASSWORD 'mypassword';"
|
||||
```
|
||||
---
|
||||
|
||||
# 4 — Inspect `_prisma_migrations` in restored DB
|
||||
---
|
||||
|
||||
## 3 — Let Prisma create the schema (RUN ONCE)
|
||||
|
||||
```bash
|
||||
PGPASSWORD='mypassword' psql -U postgres -h localhost -d dentalapp -c "SELECT id, migration_name, finished_at FROM _prisma_migrations ORDER BY finished_at;"
|
||||
npx prisma migrate dev --config=packages/db/prisma/prisma.config.ts
|
||||
```
|
||||
|
||||
**Why:** the backup included `_prisma_migrations` from the original PC, which causes Prisma to detect "missing migrations" locally.
|
||||
Expected:
|
||||
|
||||
```
|
||||
Your database is now in sync with your schema.
|
||||
```
|
||||
|
||||
This step:
|
||||
|
||||
* Creates tables, enums, indexes, FKs
|
||||
* Creates `_prisma_migrations`
|
||||
* Creates the first local migration
|
||||
|
||||
---
|
||||
|
||||
# 5 — (If present) remove old Prisma bookkeeping from DB
|
||||
|
||||
> We prefer to *not* use the old history from PC1 and create a fresh baseline on PC2.
|
||||
## 4 — Restore DATA ONLY from backup
|
||||
|
||||
```bash
|
||||
PGPASSWORD='mypassword' pg_restore -U postgres -h localhost -d dentalapp -Fd /tmp/dental_dump_dir --data-only --disable-triggers
|
||||
```
|
||||
|
||||
⚠️ This will also restore old `_prisma_migrations` rows — we fix that next.
|
||||
|
||||
---
|
||||
|
||||
## 5 — Remove old Prisma bookkeeping from backup
|
||||
|
||||
```bash
|
||||
# truncate migration records (bookkeeping only)
|
||||
PGPASSWORD='mypassword' psql -U postgres -h localhost -d dentalapp -c "TRUNCATE TABLE _prisma_migrations;"
|
||||
# verify
|
||||
PGPASSWORD='mypassword' psql -U postgres -h localhost -d dentalapp -c "SELECT count(*) FROM _prisma_migrations;"
|
||||
```
|
||||
|
||||
**Why:** remove migration rows copied from PC1 so we can register a clean baseline for PC2.
|
||||
This:
|
||||
|
||||
* Does NOT touch data
|
||||
* Does NOT touch schema
|
||||
* Removes old PC1 migration history
|
||||
|
||||
---
|
||||
|
||||
# 6 — Create a migrations directory + baseline migration folder (bookkeeping)
|
||||
## 6 — Re-register the current migration as applied (CRITICAL STEP)
|
||||
|
||||
From project root (where `prisma/schema.prisma` lives — in your repo it’s `packages/db/prisma/schema.prisma`):
|
||||
Replace the migration name with the one created in step 3
|
||||
(example: `20260103121811`).
|
||||
|
||||
```bash
|
||||
# create migrations dir if missing (adjust path if your prisma folder is elsewhere)
|
||||
mkdir -p packages/db/prisma/migrations
|
||||
|
||||
# create a timestamped folder (example uses date command)
|
||||
folder="packages/db/prisma/migrations/$(date +%Y%m%d%H%M%S)_init"
|
||||
mkdir -p "$folder"
|
||||
|
||||
# create placeholder migration files
|
||||
cat > "$folder/migration.sql" <<'SQL'
|
||||
-- Baseline migration for PC2 (will be replaced with real SQL)
|
||||
SQL
|
||||
|
||||
cat > "$folder/README.md" <<'TXT'
|
||||
Initial baseline migration created on PC2.
|
||||
This is intended as a bookkeeping-only migration.
|
||||
TXT
|
||||
|
||||
# confirm folder name
|
||||
ls -la packages/db/prisma/migrations
|
||||
npx prisma migrate resolve --applied 20260103121811 --config=packages/db/prisma/prisma.config.ts
|
||||
```
|
||||
|
||||
**Why:** Prisma requires at least one migration file locally as a baseline.
|
||||
This tells Prisma:
|
||||
|
||||
> “Yes, this migration already created the schema.”
|
||||
|
||||
---
|
||||
|
||||
# 7 — Generate the full baseline SQL (so Prisma’s expected schema matches DB)
|
||||
|
||||
Use Prisma `migrate diff` to produce SQL that creates your current schema, writing it into the migration file you created:
|
||||
## 7 — Verify Prisma state
|
||||
|
||||
```bash
|
||||
# replace the folder name with the real one printed above, e.g. 20251203101323_init
|
||||
npx prisma migrate diff \
|
||||
--from-empty \
|
||||
--to-schema-datamodel=packages/db/prisma/schema.prisma \
|
||||
--script > packages/db/prisma/migrations/20251203101323_init/migration.sql
|
||||
npx prisma migrate status --config=packages/db/prisma/prisma.config.ts
|
||||
```
|
||||
|
||||
If your shell complains about line breaks, run the whole command on one line (as above).
|
||||
|
||||
**Fallback (if `migrate diff` not available):**
|
||||
|
||||
```bash
|
||||
PGPASSWORD='mypassword' pg_dump -U postgres -h localhost -s dentalapp > /tmp/dental_schema.sql
|
||||
cp /tmp/dental_schema.sql packages/db/prisma/migrations/20251203101323_init/migration.sql
|
||||
```
|
||||
|
||||
**Why:** this makes the migration file contain CREATE TABLE / CREATE TYPE / FK / INDEX statements matching the DB so Prisma's expected schema = actual DB.
|
||||
|
||||
---
|
||||
|
||||
# 8 — Register the baseline migration with Prisma (using the exact env/schema your scripts use)
|
||||
|
||||
Important: use same env file and `--schema` (and `--config` if used) that your npm script uses. Example for your repo:
|
||||
|
||||
```bash
|
||||
# from repo root, mark applied for the migrations folder we created
|
||||
npx dotenv -e packages/db/.env -- npx prisma migrate resolve --applied "20251203101323_init" --schema=packages/db/prisma/schema.prisma
|
||||
```
|
||||
|
||||
**Why:** record the baseline in `_prisma_migrations` with the checksum matching the `migration.sql` file.
|
||||
|
||||
---
|
||||
|
||||
|
||||
# 9 — Verify status and generate client
|
||||
|
||||
```bash
|
||||
# same env/schema flags
|
||||
npx dotenv -e packages/db/.env -- npx prisma migrate status --schema=packages/db/prisma/schema.prisma
|
||||
|
||||
npx dotenv -e packages/db/.env -- npx prisma generate --schema=packages/db/prisma/schema.prisma
|
||||
```
|
||||
|
||||
You should see:
|
||||
Expected:
|
||||
|
||||
```
|
||||
1 migration found in prisma/migrations
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"./client": "./src/index.ts",
|
||||
"./shared/schemas": "./shared/schemas/index.ts",
|
||||
"./usedSchemas": "./usedSchemas/index.ts",
|
||||
"./types": "./types/index.ts"
|
||||
"./types": "./types/index.ts",
|
||||
"./generated/prisma": "./generated/prisma/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.0.1",
|
||||
|
||||
@@ -25,6 +25,7 @@ model User {
|
||||
patients Patient[]
|
||||
appointments Appointment[]
|
||||
staff Staff[]
|
||||
npiProviders NpiProvider[]
|
||||
claims Claim[]
|
||||
insuranceCredentials InsuranceCredential[]
|
||||
updatedPayments Payment[] @relation("PaymentUpdatedBy")
|
||||
@@ -37,10 +38,10 @@ model User {
|
||||
}
|
||||
|
||||
model Patient {
|
||||
id Int @id @default(autoincrement())
|
||||
id Int @id @default(autoincrement())
|
||||
firstName String
|
||||
lastName String
|
||||
dateOfBirth DateTime @db.Date
|
||||
dateOfBirth DateTime @db.Date
|
||||
gender String
|
||||
phone String
|
||||
email String?
|
||||
@@ -53,11 +54,12 @@ model Patient {
|
||||
policyHolder String?
|
||||
allergies String?
|
||||
medicalConditions String?
|
||||
status PatientStatus @default(UNKNOWN)
|
||||
status PatientStatus @default(UNKNOWN)
|
||||
userId Int
|
||||
createdAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
appointments Appointment[]
|
||||
procedures AppointmentProcedure[]
|
||||
claims Claim[]
|
||||
groups PdfGroup[]
|
||||
payment Payment[]
|
||||
@@ -74,25 +76,27 @@ enum PatientStatus {
|
||||
}
|
||||
|
||||
model Appointment {
|
||||
id Int @id @default(autoincrement())
|
||||
patientId Int
|
||||
userId Int
|
||||
staffId Int
|
||||
title String
|
||||
date DateTime @db.Date
|
||||
startTime String // Store time as "hh:mm"
|
||||
endTime String // Store time as "hh:mm"
|
||||
type String // e.g., "checkup", "cleaning", "filling", etc.
|
||||
notes String?
|
||||
status String @default("scheduled") // "scheduled", "completed", "cancelled", "no-show"
|
||||
createdAt DateTime @default(now())
|
||||
id Int @id @default(autoincrement())
|
||||
patientId Int
|
||||
userId Int
|
||||
staffId Int
|
||||
title String
|
||||
date DateTime @db.Date
|
||||
startTime String // Store time as "hh:mm"
|
||||
endTime String // Store time as "hh:mm"
|
||||
type String // e.g., "checkup", "cleaning", "filling", etc.
|
||||
notes String?
|
||||
procedureCodeNotes String?
|
||||
status String @default("scheduled") // "scheduled", "completed", "cancelled", "no-show"
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
eligibilityStatus PatientStatus @default(UNKNOWN)
|
||||
|
||||
patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
staff Staff? @relation(fields: [staffId], references: [id])
|
||||
claims Claim[]
|
||||
patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
staff Staff? @relation(fields: [staffId], references: [id])
|
||||
procedures AppointmentProcedure[]
|
||||
claims Claim[]
|
||||
|
||||
@@index([patientId])
|
||||
@@index([date])
|
||||
@@ -100,17 +104,63 @@ model Appointment {
|
||||
|
||||
model Staff {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int?
|
||||
userId Int
|
||||
name String
|
||||
email String?
|
||||
role String // e.g., "Dentist", "Hygienist", "Assistant"
|
||||
phone String?
|
||||
createdAt DateTime @default(now())
|
||||
displayOrder Int @default(0)
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
appointments Appointment[]
|
||||
claims Claim[] @relation("ClaimStaff")
|
||||
}
|
||||
|
||||
model NpiProvider {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
npiNumber String
|
||||
providerName String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, npiNumber])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
enum ProcedureSource {
|
||||
COMBO
|
||||
MANUAL
|
||||
}
|
||||
|
||||
model AppointmentProcedure {
|
||||
id Int @id @default(autoincrement())
|
||||
appointmentId Int
|
||||
patientId Int
|
||||
|
||||
procedureCode String
|
||||
procedureLabel String?
|
||||
fee Decimal? @db.Decimal(10, 2)
|
||||
|
||||
category String?
|
||||
|
||||
toothNumber String?
|
||||
toothSurface String?
|
||||
oralCavityArea String?
|
||||
|
||||
source ProcedureSource @default(MANUAL)
|
||||
comboKey String?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
appointment Appointment @relation(fields: [appointmentId], references: [id], onDelete: Cascade)
|
||||
patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([appointmentId])
|
||||
@@index([patientId])
|
||||
}
|
||||
|
||||
model Claim {
|
||||
id Int @id @default(autoincrement())
|
||||
patientId Int
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AppointmentUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas";
|
||||
import { AppointmentUncheckedCreateInputObjectSchema, AppointmentProcedureUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas";
|
||||
import {z} from "zod";
|
||||
|
||||
export type Appointment = z.infer<typeof AppointmentUncheckedCreateInputObjectSchema>;
|
||||
@@ -19,4 +19,35 @@ export const updateAppointmentSchema = (
|
||||
createdAt: true,
|
||||
})
|
||||
.partial();
|
||||
export type UpdateAppointment = z.infer<typeof updateAppointmentSchema>;
|
||||
export type UpdateAppointment = z.infer<typeof updateAppointmentSchema>;
|
||||
|
||||
|
||||
// Appointment Procedure Types.
|
||||
|
||||
export type AppointmentProcedure = z.infer<
|
||||
typeof AppointmentProcedureUncheckedCreateInputObjectSchema
|
||||
>;
|
||||
|
||||
export const insertAppointmentProcedureSchema = (
|
||||
AppointmentProcedureUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>
|
||||
).omit({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
});
|
||||
|
||||
export type InsertAppointmentProcedure = z.infer<
|
||||
typeof insertAppointmentProcedureSchema
|
||||
>;
|
||||
|
||||
export const updateAppointmentProcedureSchema = (
|
||||
AppointmentProcedureUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>
|
||||
)
|
||||
.omit({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
})
|
||||
.partial();
|
||||
|
||||
export type UpdateAppointmentProcedure = z.infer<
|
||||
typeof updateAppointmentProcedureSchema
|
||||
>;
|
||||
|
||||
@@ -99,6 +99,10 @@ export interface ClaimFormData {
|
||||
serviceDate: string; // YYYY-MM-DD
|
||||
insuranceProvider: string;
|
||||
insuranceSiteKey?: string;
|
||||
npiProvider?: {
|
||||
npiNumber: string;
|
||||
providerName: string;
|
||||
};
|
||||
status: string; // default "pending"
|
||||
serviceLines: InputServiceLine[];
|
||||
claimId?: number;
|
||||
@@ -118,6 +122,10 @@ export interface ClaimPreAuthData {
|
||||
serviceDate: string; // YYYY-MM-DD
|
||||
insuranceProvider: string;
|
||||
insuranceSiteKey?: string;
|
||||
npiProvider?: {
|
||||
npiNumber: string;
|
||||
providerName: string;
|
||||
};
|
||||
status: string; // default "pending"
|
||||
serviceLines: InputServiceLine[];
|
||||
claimFiles?: ClaimFileMeta[];
|
||||
|
||||
@@ -10,4 +10,5 @@ export * from "./databaseBackup-types";
|
||||
export * from "./notifications-types";
|
||||
export * from "./cloudStorage-types";
|
||||
export * from "./payments-reports-types";
|
||||
export * from "./patientConnection-types";
|
||||
export * from "./patientConnection-types";
|
||||
export * from "./npiProviders-types";
|
||||
14
packages/db/types/npiProviders-types.ts
Normal file
14
packages/db/types/npiProviders-types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { z } from "zod";
|
||||
import { NpiProviderUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas";
|
||||
|
||||
export type NpiProvider = z.infer<
|
||||
typeof NpiProviderUncheckedCreateInputObjectSchema
|
||||
>;
|
||||
|
||||
export const insertNpiProviderSchema = (
|
||||
NpiProviderUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>
|
||||
).omit({ id: true });
|
||||
|
||||
export type InsertNpiProvider = z.infer<
|
||||
typeof insertNpiProviderSchema
|
||||
>;
|
||||
@@ -22,10 +22,10 @@ export const insuranceIdSchema = z.preprocess(
|
||||
}
|
||||
return val;
|
||||
},
|
||||
// After preprocess, require digits-only string (or optional nullable)
|
||||
// After preprocess, allow alphanumeric insurance IDs (some providers like DentaQuest use letter prefixes)
|
||||
z
|
||||
.string()
|
||||
.regex(/^\d+$/, { message: "Insurance ID must contain only digits" })
|
||||
.regex(/^[A-Za-z0-9]+$/, { message: "Insurance ID must contain only letters and digits" })
|
||||
.min(1)
|
||||
.max(32)
|
||||
.optional()
|
||||
|
||||
@@ -1,4 +1,44 @@
|
||||
import { StaffUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas";
|
||||
import {z} from "zod";
|
||||
import { z } from "zod";
|
||||
|
||||
export type Staff = z.infer<typeof StaffUncheckedCreateInputObjectSchema>;
|
||||
|
||||
export const staffCreateSchema = (
|
||||
StaffUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>
|
||||
).omit({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
displayOrder: true,
|
||||
userId: true,
|
||||
});
|
||||
|
||||
export const staffUpdateSchema = (
|
||||
StaffUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>
|
||||
)
|
||||
.partial()
|
||||
.omit({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
userId: true,
|
||||
});
|
||||
|
||||
export type StaffFormData = {
|
||||
name: string;
|
||||
email?: string;
|
||||
role: string;
|
||||
phone?: string;
|
||||
displayOrder?: number;
|
||||
};
|
||||
|
||||
export type StaffCreateInput = z.infer<
|
||||
typeof StaffUncheckedCreateInputObjectSchema
|
||||
>;
|
||||
|
||||
export type StaffCreateBody = Omit<
|
||||
StaffCreateInput,
|
||||
"id" | "createdAt" | "userId" | "displayOrder"
|
||||
>;
|
||||
|
||||
export type StaffUpdateInput = Partial<
|
||||
Omit<StaffCreateInput, "id" | "createdAt" | "userId">
|
||||
>;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// using this, as the browser load only the required files , not whole db/shared/schemas/ files.
|
||||
export * from '../shared/schemas/objects/AppointmentUncheckedCreateInput.schema';
|
||||
export * from '../shared/schemas/objects/AppointmentProcedureUncheckedCreateInput.schema';
|
||||
export * from '../shared/schemas/objects/PatientUncheckedCreateInput.schema';
|
||||
export * from '../shared/schemas/enums/PatientStatus.schema';
|
||||
export * from '../shared/schemas/objects/UserUncheckedCreateInput.schema';
|
||||
export * from '../shared/schemas/objects/StaffUncheckedCreateInput.schema'
|
||||
export * from '../shared/schemas/objects/NpiProviderUncheckedCreateInput.schema'
|
||||
export * from '../shared/schemas/objects/ClaimUncheckedCreateInput.schema'
|
||||
export * from '../shared/schemas/objects/InsuranceCredentialUncheckedCreateInput.schema'
|
||||
export * from '../shared/schemas/objects/PdfFileUncheckedCreateInput.schema'
|
||||
|
||||
Reference in New Issue
Block a user