From 9a3da21695046d9fdfce786c011eefcf16fba57f Mon Sep 17 00:00:00 2001 From: Potenz Date: Wed, 29 Oct 2025 19:42:54 +0530 Subject: [PATCH] feat(check-all-eligibility) - v1 --- apps/Backend/src/routes/insuranceStatus.ts | 282 ++++++++++++++++++ .../src/storage/appointements-storage.ts | 28 ++ apps/Frontend/src/pages/appointments-page.tsx | 216 +++++++++++++- .../slices/seleniumClaimSubmitTaskSlice.ts | 1 - .../seleniumEligibilityBatchCheckTaskSlice.ts | 32 ++ apps/Frontend/src/redux/store.ts | 2 + 6 files changed, 548 insertions(+), 13 deletions(-) create mode 100644 apps/Frontend/src/redux/slices/seleniumEligibilityBatchCheckTaskSlice.ts diff --git a/apps/Backend/src/routes/insuranceStatus.ts b/apps/Backend/src/routes/insuranceStatus.ts index acf05d9..c947428 100644 --- a/apps/Backend/src/routes/insuranceStatus.ts +++ b/apps/Backend/src/routes/insuranceStatus.ts @@ -473,4 +473,286 @@ router.post( } ); +router.post( + "/appointments/check-all-eligibilities", + async (req: Request, res: Response): Promise => { + // Query param: date=YYYY-MM-DD (required) + const date = String(req.query.date ?? "").trim(); + if (!date) { + return res + .status(400) + .json({ error: "Missing date query param (YYYY-MM-DD)" }); + } + + if (!req.user || !req.user.id) { + return res.status(401).json({ error: "Unauthorized: user info missing" }); + } + + // Track any paths that couldn't be cleaned immediately so we can try again at the end + const remainingCleanupPaths = new Set(); + + try { + // 1) fetch appointments for the day (reuse your storage API) + const dayAppointments = await storage.getAppointmentsByDateForUser( + date, + req.user.id + ); + if (!Array.isArray(dayAppointments)) { + return res + .status(500) + .json({ error: "Failed to load appointments for date" }); + } + + const results: Array = []; + + // process sequentially so selenium agent / python semaphore isn't overwhelmed + for (const apt of dayAppointments) { + // For each appointment we keep a per-appointment seleniumResult so we can cleanup its files + let seleniumResult: any = undefined; + + const resultItem: any = { + appointmentId: apt.id, + patientId: apt.patientId ?? null, + processed: false, + error: null, + pdfFileId: null, + patientUpdateStatus: null, + warning: null, + }; + + try { + // fetch patient record (use getPatient or getPatientById depending on your storage) + const patient = apt.patientId + ? await storage.getPatient(apt.patientId) + : null; + const memberId = (patient?.insuranceId ?? "").toString().trim(); + + // create a readable patient label for error messages + const patientLabel = patient + ? `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim() || + `patient#${patient.id}` + : `patient#${apt.patientId ?? "unknown"}`; + + const aptLabel = `appointment#${apt.id}${apt.date ? ` (${apt.date}${apt.startTime ? ` ${apt.startTime}` : ""})` : ""}`; + + if (!memberId) { + resultItem.error = `Missing insuranceId for ${patientLabel} — skipping ${aptLabel}`; + + results.push(resultItem); + continue; + } + + // prepare eligibility data; prefer patient DOB + name if present + const dob = patient?.dateOfBirth ? patient.dateOfBirth : null; // string | Date + const payload = { + memberId, + dateOfBirth: dob, + insuranceSiteKey: "MH", + }; + + // Get credentials for this user+site + const credentials = + await storage.getInsuranceCredentialByUserAndSiteKey( + req.user.id, + payload.insuranceSiteKey + ); + if (!credentials) { + resultItem.error = `No insurance credentials found for siteKey — skipping ${aptLabel} for ${patientLabel}`; + results.push(resultItem); + continue; + } + + // enrich payload + const enriched = { + ...payload, + massdhpUsername: credentials.username, + massdhpPassword: credentials.password, + }; + + // forward to selenium agent (sequential) + try { + seleniumResult = + await forwardToSeleniumInsuranceEligibilityAgent(enriched); + } catch (seleniumErr: any) { + resultItem.error = `Selenium agent failed for ${patientLabel} (${aptLabel}): ${seleniumErr?.message ?? String(seleniumErr)}`; + results.push(resultItem); + continue; + } + + // Attempt extraction (if pdf_path present) + const extracted: any = {}; + if ( + seleniumResult?.pdf_path && + seleniumResult.pdf_path.endsWith(".pdf") + ) { + try { + const pdfPath = seleniumResult.pdf_path; + const pdfBuffer = await fs.readFile(pdfPath); + + const extraction = await forwardToPatientDataExtractorService({ + buffer: pdfBuffer, + originalname: path.basename(pdfPath), + mimetype: "application/pdf", + } as any); + + if (extraction.name) { + const parts = splitName(extraction.name); + extracted.firstName = parts.firstName; + extracted.lastName = parts.lastName; + } + } catch (extractErr: any) { + resultItem.warning = `Extraction failed: ${extractErr?.message ?? String(extractErr)}`; + } + } + + // create or update patient by insuranceId — prefer extracted name + const preferFirst = extracted.firstName ?? null; + const preferLast = extracted.lastName ?? null; + try { + await createOrUpdatePatientByInsuranceId({ + insuranceId: memberId, + firstName: preferFirst, + lastName: preferLast, + dob: payload.dateOfBirth, + userId: req.user.id, + }); + } catch (patientOpErr: any) { + resultItem.error = `Failed to create/update patient ${patientLabel} for ${aptLabel}: ${patientOpErr?.message ?? String(patientOpErr)}`; + results.push(resultItem); + continue; + } + + // fetch patient again + const updatedPatient = + await storage.getPatientByInsuranceId(memberId); + if (!updatedPatient || !updatedPatient.id) { + resultItem.error = `Patient not found after create/update for ${patientLabel} (${aptLabel})`; + results.push(resultItem); + continue; + } + + // Update patient status based on seleniumResult.eligibility + const newStatus = + seleniumResult?.eligibility === "Y" ? "active" : "inactive"; + await storage.updatePatient(updatedPatient.id, { status: newStatus }); + resultItem.patientUpdateStatus = `Patient status updated to ${newStatus}`; + + // If PDF exists, upload to PdfGroup (ELIGIBILITY_STATUS) + if ( + seleniumResult?.pdf_path && + seleniumResult.pdf_path.endsWith(".pdf") + ) { + try { + const pdfBuf = await fs.readFile(seleniumResult.pdf_path); + const groupTitle = "Eligibility Status"; + const groupTitleKey = "ELIGIBILITY_STATUS"; + + let group = await storage.findPdfGroupByPatientTitleKey( + updatedPatient.id, + groupTitleKey + ); + if (!group) { + group = await storage.createPdfGroup( + updatedPatient.id, + groupTitle, + groupTitleKey + ); + } + if (!group?.id) + throw new Error("Failed to create/find pdf group"); + + const created = await storage.createPdfFile( + group.id, + path.basename(seleniumResult.pdf_path), + pdfBuf + ); + + if (created && typeof created === "object" && "id" in created) { + resultItem.pdfFileId = Number(created.id); + } else if (typeof created === "number") { + resultItem.pdfFileId = created; + } else if (created && (created as any).id) { + resultItem.pdfFileId = (created as any).id; + } + + resultItem.processed = true; + } catch (pdfErr: any) { + resultItem.warning = `PDF upload failed for ${patientLabel} (${aptLabel}): ${pdfErr?.message ?? String(pdfErr)}`; + } + } else { + // no pdf; still mark processed true (status updated) + resultItem.processed = true; + resultItem.pdfFileId = null; + } + + results.push(resultItem); + } catch (err: any) { + resultItem.error = `Unexpected error for appointment#${apt.id}: ${err?.message ?? String(err)}`; + results.push(resultItem); + + console.error( + "[batch eligibility] unexpected error for appointment", + apt.id, + err + ); + } finally { + // Per-appointment cleanup: always try to remove selenium temp files for this appointment + try { + if ( + seleniumResult && + (seleniumResult.pdf_path || seleniumResult.ss_path) + ) { + // prefer pdf_path, fallback to ss_path + const candidatePath = + seleniumResult.pdf_path ?? seleniumResult.ss_path; + try { + await emptyFolderContainingFile(candidatePath); + } catch (cleanupErr: any) { + console.warn( + `[batch cleanup] failed to clean ${candidatePath} for appointment ${apt.id}`, + cleanupErr + ); + // remember path for final cleanup attempt + remainingCleanupPaths.add(candidatePath); + } + } + } catch (cleanupOuterErr: any) { + console.warn( + "[batch cleanup] unexpected error during per-appointment cleanup", + cleanupOuterErr + ); + // don't throw — we want to continue processing next appointments + } + } // end try/catch/finally per appointment + } // end for appointments + + // return summary + return res.json({ date, count: results.length, results }); + } catch (err: any) { + console.error("[check-all-eligibilities] error", err); + return res + .status(500) + .json({ error: err?.message ?? "Internal server error" }); + } finally { + // Final cleanup attempt for any remaining paths we couldn't delete earlier + try { + if (remainingCleanupPaths.size > 0) { + for (const p of remainingCleanupPaths) { + try { + await emptyFolderContainingFile(p); + } catch (finalCleanupErr: any) { + console.error(`[final cleanup] failed for ${p}`, finalCleanupErr); + } + } + } + } catch (outerFinalErr: any) { + console.error( + "[check-all-eligibilities final cleanup] unexpected error", + outerFinalErr + ); + } + } + } +); + export default router; diff --git a/apps/Backend/src/storage/appointements-storage.ts b/apps/Backend/src/storage/appointements-storage.ts index 23f1747..aad4fbc 100644 --- a/apps/Backend/src/storage/appointements-storage.ts +++ b/apps/Backend/src/storage/appointements-storage.ts @@ -45,6 +45,7 @@ export interface IStorage { startTime: string, excludeId: number ): Promise; + getAppointmentsByDateForUser(dateStr: string, userId: number): Promise; } export const appointmentsStorage: IStorage = { @@ -195,4 +196,31 @@ export const appointmentsStorage: IStorage = { })) ?? undefined ); }, + + /** + * getAppointmentsByDateForUser + * dateStr expected as "YYYY-MM-DD" (same string your frontend sends) + * returns appointments for that date (local midnight-to-midnight) filtered by userId + */ + async getAppointmentsByDateForUser(dateStr: string, userId: number): Promise { + // defensive parsing — if invalid, throw so caller can handle + const start = new Date(dateStr); + if (Number.isNaN(start.getTime())) { + throw new Error(`Invalid date string passed to getAppointmentsByDateForUser: ${dateStr}`); + } + // create exclusive end (next day midnight) + const end = new Date(start); + end.setDate(start.getDate() + 1); + + return db.appointment.findMany({ + where: { + userId, + date: { + gte: start, + lt: end, + }, + }, + orderBy: { startTime: "asc" }, + }); + } }; diff --git a/apps/Frontend/src/pages/appointments-page.tsx b/apps/Frontend/src/pages/appointments-page.tsx index 0621b57..0da7f13 100644 --- a/apps/Frontend/src/pages/appointments-page.tsx +++ b/apps/Frontend/src/pages/appointments-page.tsx @@ -21,6 +21,7 @@ import { StickyNote, Shield, FileCheck, + LoaderCircleIcon, } from "lucide-react"; import { useToast } from "@/hooks/use-toast"; import { Calendar } from "@/components/ui/calendar"; @@ -44,6 +45,12 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { Label } from "@/components/ui/label"; +import { useAppDispatch, useAppSelector } from "@/redux/hooks"; +import { + clearTaskStatus, + setTaskStatus, +} from "@/redux/slices/seleniumEligibilityBatchCheckTaskSlice"; +import { SeleniumTaskBanner } from "@/components/ui/selenium-task-banner"; // Define types for scheduling interface TimeSlot { @@ -93,6 +100,14 @@ export default function AppointmentsPage() { open: boolean; appointmentId?: number; }>({ open: false }); + const dispatch = useAppDispatch(); + const batchTask = useAppSelector( + (state) => state.seleniumEligibilityBatchCheckTask + ); + const [isCheckingAllElig, setIsCheckingAllElig] = useState(false); + const [processedAppointmentIds, setProcessedAppointmentIds] = useState< + Record + >({}); const [, setLocation] = useLocation(); @@ -688,8 +703,166 @@ export default function AppointmentsPage() { console.log(`Opening clinic notes for appointment: ${appointmentId}`); }; + const handleCheckAllEligibilities = async () => { + if (!user) { + toast({ + title: "Unauthorized", + description: "Please login to perform this action.", + variant: "destructive", + }); + return; + } + + const dateParam = formattedSelectedDate; // existing variable in your component + + // Start: set redux task status (visible globally) + dispatch( + setTaskStatus({ + status: "pending", + message: `Checking eligibility for appointments on ${dateParam}...`, + }) + ); + + setIsCheckingAllElig(true); + setProcessedAppointmentIds({}); + + try { + 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 + let body: any; + try { + body = await res.json(); + } catch (e) { + body = null; + } + + if (!res.ok) { + const errMsg = body?.error ?? `Server error ${res.status}`; + // global error + dispatch( + setTaskStatus({ + status: "error", + message: `Batch eligibility failed: ${errMsg}`, + }) + ); + toast({ + title: "Batch check failed", + description: errMsg, + variant: "destructive", + }); + return; + } + + const results: any[] = Array.isArray(body?.results) ? body.results : []; + + // Map appointmentId -> appointment so we can show human friendly toasts + const appointmentMap = new Map(); + for (const a of appointments) { + if (a && typeof a.id === "number") + appointmentMap.set(a.id as number, a); + } + + // Counters for summary + let successCount = 0; + let skippedCount = 0; + let warningCount = 0; + + // Show toast for each skipped appointment (error) and for warnings. + for (const r of results) { + const aptId = Number(r.appointmentId); + const apt = appointmentMap.get(aptId); + const patientName = apt + ? patientsFromDay.find((p) => p.id === apt.patientId) + ? `${patientsFromDay.find((p) => p.id === apt.patientId)!.firstName ?? ""} ${patientsFromDay.find((p) => p.id === apt.patientId)!.lastName ?? ""}`.trim() + : `patient#${apt.patientId ?? "?"}` + : `appointment#${aptId}`; + + const aptTime = apt ? `${apt.date ?? ""} ${apt.startTime ?? ""}` : ""; + + if (r.error) { + skippedCount++; + toast({ + title: `Skipped: ${patientName}`, + description: `${aptTime} — ${r.error}`, + variant: "destructive", + }); + console.warn("[batch skipped]", aptId, r.error); + } else if (r.warning) { + warningCount++; + toast({ + title: `Warning: ${patientName}`, + description: `${aptTime} — ${r.warning}`, + variant: "destructive", + }); + console.info("[batch warning]", aptId, r.warning); + } else if (r.processed) { + successCount++; + // optional: show small non-intrusive toast or nothing for each success. + // comment-in to notify successes (may create many toasts): + // toast({ title: `Processed: ${patientName}`, description: `${aptTime}`, variant: "default" }); + } else { + // fallback: treat as skipped + skippedCount++; + toast({ + title: `Skipped: ${patientName}`, + description: `${aptTime} — Unknown reason`, + variant: "destructive", + }); + } + } + + // Invalidate queries so UI repaints with updated patient statuses + queryClient.invalidateQueries({ + queryKey: qkAppointmentsDay(formattedSelectedDate), + }); + + // global success status (summary) + dispatch( + setTaskStatus({ + status: skippedCount > 0 ? "error" : "success", + message: `Batch processed ${results.length} appointments — success: ${successCount}, warnings: ${warningCount}, skipped: ${skippedCount}.`, + }) + ); + + // also show final toast summary + toast({ + title: "Batch complete", + description: `Processed ${results.length} appointments — success: ${successCount}, warnings: ${warningCount}, skipped: ${skippedCount}.`, + variant: skippedCount > 0 ? "destructive" : "default", + }); + + } catch (err: any) { + console.error("[check-all-eligibilities] error", err); + dispatch( + setTaskStatus({ + status: "error", + message: `Batch eligibility error: ${err?.message ?? String(err)}`, + }) + ); + toast({ + title: "Batch check failed", + description: err?.message ?? String(err), + variant: "destructive", + }); + } finally { + setIsCheckingAllElig(false); + // intentionally do not clear task status here so banner persists until user dismisses it + } + }; return ( -
+
+ dispatch(clearTaskStatus())} + /> +
@@ -700,17 +873,36 @@ export default function AppointmentsPage() { View and manage the dental practice schedule

- + +
+ + + +
{/* Context Menu */} diff --git a/apps/Frontend/src/redux/slices/seleniumClaimSubmitTaskSlice.ts b/apps/Frontend/src/redux/slices/seleniumClaimSubmitTaskSlice.ts index 2844930..b76e738 100644 --- a/apps/Frontend/src/redux/slices/seleniumClaimSubmitTaskSlice.ts +++ b/apps/Frontend/src/redux/slices/seleniumClaimSubmitTaskSlice.ts @@ -28,6 +28,5 @@ const seleniumClaimSubmitTaskSlice = createSlice({ }, }); -// ✅ Make sure you're exporting from the renamed slice export const { setTaskStatus, clearTaskStatus } = seleniumClaimSubmitTaskSlice.actions; export default seleniumClaimSubmitTaskSlice.reducer; diff --git a/apps/Frontend/src/redux/slices/seleniumEligibilityBatchCheckTaskSlice.ts b/apps/Frontend/src/redux/slices/seleniumEligibilityBatchCheckTaskSlice.ts new file mode 100644 index 0000000..1ed8747 --- /dev/null +++ b/apps/Frontend/src/redux/slices/seleniumEligibilityBatchCheckTaskSlice.ts @@ -0,0 +1,32 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; + +export type TaskStatus = "idle" | "pending" | "success" | "error"; + +export interface SeleniumTaskState { + status: TaskStatus; + message: string; + show: boolean; +} + +const initialState: SeleniumTaskState = { + status: "idle", + message: "", + show: false, +}; + +const seleniumEligibilityBatchCheckTaskSlice = createSlice({ + name: "seleniumEligibilityBatchCheckTask", + initialState, + reducers: { + setTaskStatus: ( + state: SeleniumTaskState, + action: PayloadAction> + ) => { + return { ...state, ...action.payload, show: true }; + }, + clearTaskStatus: () => initialState, + }, +}); + +export const { setTaskStatus, clearTaskStatus } = seleniumEligibilityBatchCheckTaskSlice.actions; +export default seleniumEligibilityBatchCheckTaskSlice.reducer; diff --git a/apps/Frontend/src/redux/store.ts b/apps/Frontend/src/redux/store.ts index e193634..be26cd5 100644 --- a/apps/Frontend/src/redux/store.ts +++ b/apps/Frontend/src/redux/store.ts @@ -1,11 +1,13 @@ import { configureStore } from "@reduxjs/toolkit"; import seleniumClaimSubmitTaskReducer from "./slices/seleniumClaimSubmitTaskSlice"; import seleniumEligibilityCheckTaskReducer from "./slices/seleniumEligibilityCheckTaskSlice"; +import seleniumEligibilityBatchCheckTaskReducer from "./slices/seleniumEligibilityBatchCheckTaskSlice"; export const store = configureStore({ reducer: { seleniumClaimSubmitTask: seleniumClaimSubmitTaskReducer, seleniumEligibilityCheckTask: seleniumEligibilityCheckTaskReducer, + seleniumEligibilityBatchCheckTask: seleniumEligibilityBatchCheckTaskReducer, }, });