Compare commits

27 Commits

Author SHA1 Message Date
1ebe3bc6f2 Merge remote-tracking branch 'kai-gitea/develop' into develop 2026-01-28 03:46:51 +05:30
9975fe9252 feat(sitekey - select added) 2026-01-28 02:49:46 +05:30
add03289c6 fix(type error): 2026-01-28 02:49:19 +05:30
4594a264a1 feat(staff column order in aptmpt page) 2026-01-28 02:25:18 +05:30
b8df9459ca feat(added combo buttons) 2026-01-25 01:26:51 +05:30
1b66474913 feat(added notes in procedureCodes dialog) 2026-01-25 01:00:38 +05:30
e2b67b26fe npiProvider - v4 2026-01-23 10:34:44 +05:30
c2167a65dd npiProvider - v3 2026-01-23 10:02:26 +05:30
eca21f398c npiProvider - v2 2026-01-23 09:32:51 +05:30
f1ea2d603a npiProvider - v1 2026-01-23 09:23:51 +05:30
279a6b8dbc feat(dentaquest) - implement DentaQuest eligibility check with Selenium integration; added routes, services, and frontend components for OTP handling and eligibility status retrieval 2026-01-20 22:08:06 -05:00
aa609da33d appointmentProcedureNotes added in aptmnt model 2026-01-20 19:51:39 +05:30
f88a5bf7ec fix - ui updated - headers added 2026-01-19 02:33:38 +05:30
fd6d55be18 fix - procedure combos file source made 2026-01-19 02:22:44 +05:30
c8d2c139c7 fix - autorun selenium from aptmnt page - deleted this 2026-01-18 23:52:06 +05:30
e2daca574f updated doc 2026-01-18 23:31:40 +05:30
a0b3189430 feat(procedureCodes-dialog) - v2 done 2026-01-15 02:50:46 +05:30
c53dfd544d feat(procedure-combos) - v1 2026-01-12 02:15:46 +05:30
fce816f13f update- updated backup time 2026-01-11 21:23:10 +05:30
a52fdb3b9f fix- command fixed for window 2026-01-11 21:18:27 +05:30
04eb7209ce Merge branch 'dev-emile' 2026-01-11 20:48:04 +05:30
656e98687f feat(singleton-selenium-driver) - by emile 2026-01-11 20:43:46 +05:30
Emile
3907672185 Update service dependencies 2026-01-06 09:35:57 -05:00
Emile
0961c01660 chore(.gitignore) - added rule to ignore Chrome profile files for cleaner repository 2026-01-05 22:45:47 -05:00
Emile
ef18a0017c feat(ddma_browser_manager) - added singleton browser manager for persistent Chrome instance; updated Selenium service to utilize the manager for session handling and OTP processing 2026-01-05 22:01:39 -05:00
7a4ea21658 fix- staff deletion 2026-01-06 02:02:37 +05:30
b45383de33 updated doc - with prisma updation 2026-01-03 18:16:28 +05:30
56 changed files with 5841 additions and 746 deletions

2
.gitignore vendored
View File

@@ -37,3 +37,5 @@ dist/
# env
*.env
*chrome_profile_ddma*
*chrome_profile_dentaquest*

View File

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

View 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;

View File

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

View File

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

View File

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

View File

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

View 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;

View 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;

View File

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

View File

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

View 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 },
});
},
};

View File

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

View 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;
}
},
};

View File

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

View File

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

View File

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

View File

@@ -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 */}

View File

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

View File

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

View File

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

View 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>
);
}

View 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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -308,3 +308,6 @@ export function applyComboToForm<T extends ClaimFormLike>(
return next;
}
export { CODE_MAP, getPriceForCodeWithAgeFromMap };

View File

@@ -3,6 +3,6 @@
"private": true,
"scripts": {
"postinstall": "pip install -r requirements.txt",
"dev": "python main.py"
"dev": "python3 main.py"
}
}

View File

@@ -3,6 +3,6 @@
"private": true,
"scripts": {
"postinstall": "pip install -r requirements.txt",
"dev": "python main.py"
"dev": "python3 main.py"
}
}

View File

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

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -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.
## 4Restore 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 its `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 Prismas 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

View File

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

View File

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

View File

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

View File

@@ -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[];

View File

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

View 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
>;

View File

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

View File

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

View File

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