From 541d65da6db562640878f74767ab6259a8407dea Mon Sep 17 00:00:00 2001 From: ff Date: Tue, 9 Jun 2026 16:14:08 -0400 Subject: [PATCH] feat: AI chat Eligibility & Appointment button with 15-min slots and Column B fallback - Add "Eligibility & Appointment" button to chatbot eligibility-id-ready card - For known patients: creates today's appointment immediately, then opens eligibility page - For unknown patients: navigates to eligibility page; after Selenium creates the patient, auto-creates appointment via tryAppointmentFromChatbot on the insurance-status page - Update MH Eligibility & Appointment button to create a today's schedule slot instead of navigating to the appointments page; shows PDF preview on completion - createAppointmentToday falls back from Column A to Column B when Column A is full; returns column label in response so UI can display it - Set AI-scheduled appointment duration to 15 minutes Co-Authored-By: Claude Sonnet 4.6 --- apps/Backend/src/ai/internal-chat-workflow.ts | 64 ++++++++++++++- apps/Backend/src/routes/ai-settings.ts | 16 +++- .../src/components/layout/chatbot.tsx | 43 +++++++++- .../src/pages/insurance-status-page.tsx | 82 +++++++++++++++++-- 4 files changed, 192 insertions(+), 13 deletions(-) diff --git a/apps/Backend/src/ai/internal-chat-workflow.ts b/apps/Backend/src/ai/internal-chat-workflow.ts index 967940d5..f00f9bca 100644 --- a/apps/Backend/src/ai/internal-chat-workflow.ts +++ b/apps/Backend/src/ai/internal-chat-workflow.ts @@ -593,7 +593,7 @@ async function handleClaimOnly( // ─── schedule_appointment ───────────────────────────────────────────────────── const DEFAULT_STAFF_ID = 1; // Column A -const SLOT_DURATION = 30; // minutes +const SLOT_DURATION = 15; // minutes /** Convert "HH:MM" to total minutes since midnight */ function toMinutes(t: string): number { @@ -736,6 +736,68 @@ async function handleScheduleAppointment( }; } +// ─── Standalone helper: create today's appointment for a known patientId ───── + +export async function createAppointmentToday( + patientId: number, + userId: number, + storage: StorageLike +): Promise<{ startTime: string; endTime: string; dateStr: string; dateLabel: string; column: string } | { error: string }> { + const today = new Date(); + const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`; + const dateLabel = today.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); + const localDate = new Date(today.getFullYear(), today.getMonth(), today.getDate()); + + const dayNames = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"]; + const dayName = dayNames[today.getDay()]!; + + const officeHours = await storage.getOfficeHours(userId); + const dayHours = officeHours?.data?.doctors?.[dayName] ?? { + amStart: "09:00", amEnd: "12:00", pmStart: "13:00", pmEnd: "17:00", enabled: true, + }; + + if (!dayHours.enabled) { + return { error: `The office is closed today (${dayName}). Cannot create appointment.` }; + } + + const allSlots = buildSlots(dayHours); + const allAppointments = await storage.getAppointmentsByDateForUser(dateStr, userId); + + for (const { staffId, label } of [ + { staffId: DEFAULT_STAFF_ID, label: "Column A" }, + { staffId: 2, label: "Column B" }, + ]) { + const booked = allAppointments + .filter((a: any) => a.staffId === staffId) + .map((a: any) => ({ start: toMinutes(a.startTime), end: toMinutes(a.endTime) })); + + const availableStart = allSlots.find((slotStart) => { + const slotEnd = slotStart + SLOT_DURATION; + return !booked.some((b) => slotStart < b.end && slotEnd > b.start); + }); + + if (availableStart !== undefined) { + const startTime = fromMinutes(availableStart); + const endTime = fromMinutes(availableStart + SLOT_DURATION); + await storage.createAppointment({ + patientId, + userId, + staffId, + title: dateLabel, + date: localDate, + startTime, + endTime, + type: "recall", + status: "scheduled", + movedByAi: true, + }); + return { startTime, endTime, dateStr, dateLabel, column: label }; + } + } + + return { error: `Both Column A and Column B are fully booked today. Please add the appointment manually.` }; +} + // ─── Insurance resolution helper ────────────────────────────────────────────── /** diff --git a/apps/Backend/src/routes/ai-settings.ts b/apps/Backend/src/routes/ai-settings.ts index 369f35e5..b3baa037 100644 --- a/apps/Backend/src/routes/ai-settings.ts +++ b/apps/Backend/src/routes/ai-settings.ts @@ -1,7 +1,7 @@ import express, { Request, Response } from "express"; import { storage } from "../storage"; import { classifyInternalChat } from "../ai/internal-chat-graph"; -import { runInternalChatWorkflow } from "../ai/internal-chat-workflow"; +import { runInternalChatWorkflow, createAppointmentToday } from "../ai/internal-chat-workflow"; import { resolveAiProvider } from "../ai/llm-factory"; const router = express.Router(); @@ -309,4 +309,18 @@ router.post("/internal-chat", async (req: Request, res: Response): Promise } }); +router.post("/create-appointment-today", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + const { patientId } = req.body; + if (!patientId) return res.status(400).json({ message: "patientId is required" }); + const result = await createAppointmentToday(Number(patientId), userId, storage); + if ("error" in result) return res.status(409).json({ message: result.error }); + return res.status(200).json(result); + } catch (err) { + return res.status(500).json({ error: "Failed to create appointment", details: String(err) }); + } +}); + export default router; diff --git a/apps/Frontend/src/components/layout/chatbot.tsx b/apps/Frontend/src/components/layout/chatbot.tsx index aad825b7..f6af3fde 100644 --- a/apps/Frontend/src/components/layout/chatbot.tsx +++ b/apps/Frontend/src/components/layout/chatbot.tsx @@ -299,6 +299,35 @@ export function ChatbotButton() { prefillAndNavigate(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.autoCheck); }; + const handleEligibilityAndAppointment = async () => { + if (!eligibilityIdData) return; + addMsg("user", "Check eligibility & add to today's schedule"); + + if (!eligibilityIdData.patient) { + // Patient not yet in DB — eligibility check will create them; flag for post-check appointment + addMsg("bot", "Running eligibility check — will add patient and create today's appointment after..."); + sessionStorage.setItem("chatbot_appt_after_eligibility", JSON.stringify({ memberId: eligibilityIdData.memberId })); + prefillAndNavigate(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.autoCheck); + return; + } + + addMsg("bot", "Creating appointment for today...", true); + try { + const res = await apiRequest("POST", "/api/ai/create-appointment-today", { + patientId: eligibilityIdData.patient.id, + }); + const data = await res.json(); + if (!res.ok) { + replaceLastMsg(data.message ?? "Could not create appointment."); + return; + } + replaceLastMsg(`Appointment added at ${data.startTime} (${data.column ?? "Column A"}) — opening eligibility check page...`); + prefillAndNavigate(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.autoCheck); + } catch { + replaceLastMsg("Could not create appointment. Please try again."); + } + }; + const handleCheckAndClaimRun = () => { if (!checkAndClaimData) return; addMsg("user", "Run check & claim"); @@ -653,16 +682,24 @@ export function ChatbotButton() { {eligibilityIdData.patient?.insuranceProvider && (

{eligibilityIdData.patient.insuranceProvider}

)} -
+
- +
diff --git a/apps/Frontend/src/pages/insurance-status-page.tsx b/apps/Frontend/src/pages/insurance-status-page.tsx index 7d066520..fd2fbbaf 100755 --- a/apps/Frontend/src/pages/insurance-status-page.tsx +++ b/apps/Frontend/src/pages/insurance-status-page.tsx @@ -363,6 +363,7 @@ export default function InsuranceStatusPage() { setSelectedPatient(null); await queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE }); + void tryAppointmentFromChatbot(); if (jobResult.pdfFileId) { setPreviewPdfId(Number(jobResult.pdfFileId)); @@ -377,7 +378,7 @@ export default function InsuranceStatusPage() { } }; - // MH Eligibility & Appointment — check eligibility, save to DB, navigate to appointments + // MH Eligibility & Appointment — check eligibility, save to DB, create appointment for today const handleMHEligibilityAppointmentButton = async () => { if (!memberId || !dateOfBirth) { toast({ @@ -399,34 +400,60 @@ export default function InsuranceStatusPage() { setTaskStatus({ key: "eligibilityCheck", status: "error", - message: "Insurance is inactive. Staying on Eligibility page.", + message: "Insurance is inactive. No appointment created.", }), ); toast({ title: "Insurance Inactive", - description: "Patient insurance is inactive. Staying on Eligibility page.", + description: "Patient insurance is inactive. No appointment was created.", variant: "destructive", }); await queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE }); return; } + await queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE }); + + // Look up the patient (created/updated by the Selenium worker) then create today's appointment + let apptTime: string | null = null; + try { + const lookupRes = await apiRequest("GET", `/api/patients/by-insurance-id?insuranceId=${encodeURIComponent(memberId)}`); + if (lookupRes.ok) { + const patient = await lookupRes.json(); + if (patient?.id) { + const apptRes = await apiRequest("POST", "/api/ai/create-appointment-today", { patientId: patient.id }); + if (apptRes.ok) { + const apptData = await apptRes.json(); + apptTime = apptData.startTime ? `${apptData.startTime} (${apptData.column ?? "Column A"})` : null; + } + } + } + } catch {} + dispatch( setTaskStatus({ key: "eligibilityCheck", status: "success", - message: "Eligibility checked and saved. Redirecting to Appointments...", + message: apptTime + ? `Eligibility checked. Appointment created at ${apptTime}.` + : "Eligibility checked. Could not create appointment — please add manually.", }), ); toast({ title: "Eligibility checked.", - description: "Patient eligibility saved. Redirecting to Appointments.", + description: apptTime + ? `Patient added to today's schedule at ${apptTime}.` + : "Eligibility saved. Could not create appointment — Column A may be full.", variant: "default", }); setSelectedPatient(null); - await queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE }); - setLocation("/appointments"); + + if (jobResult.pdfFileId) { + setPreviewPdfId(Number(jobResult.pdfFileId)); + setPreviewFallbackFilename(jobResult.pdfFilename ?? `eligibility_${memberId}.pdf`); + setPreviewOpen(true); + } } catch (error: any) { dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: error.message || "Selenium submission failed" })); toast({ title: "Selenium service error", description: error.message || "An error occurred.", variant: "destructive" }); @@ -523,6 +550,7 @@ export default function InsuranceStatusPage() { }); const claimed = await tryClaimFromChatbot(selectedPatient?.id); + void tryAppointmentFromChatbot(); if (claimed) return; setSelectedPatient(null); await queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE }); @@ -686,6 +714,38 @@ export default function InsuranceStatusPage() { return false; }; + // After any eligibility check succeeds, check if chatbot wanted an appointment created. + // Patient was just created/updated in DB by the Selenium worker before this is called. + const tryAppointmentFromChatbot = async (): Promise => { + try { + const raw = sessionStorage.getItem("chatbot_appt_after_eligibility"); + if (!raw) return; + const { memberId: storedMemberId } = JSON.parse(raw); + sessionStorage.removeItem("chatbot_appt_after_eligibility"); + if (!storedMemberId) return; + + const lookupRes = await apiRequest("GET", `/api/patients/by-insurance-id?insuranceId=${encodeURIComponent(storedMemberId)}`); + if (!lookupRes.ok) return; + const patient = await lookupRes.json(); + if (!patient?.id) return; + + const apptRes = await apiRequest("POST", "/api/ai/create-appointment-today", { patientId: patient.id }); + const apptData = await apptRes.json(); + if (apptRes.ok) { + toast({ + title: "Appointment created", + description: `${patient.firstName ?? ""} ${patient.lastName ?? ""} added to today's schedule at ${apptData.startTime} (${apptData.column ?? "Column A"}).`.trim(), + }); + } else { + toast({ + title: "Could not create appointment", + description: apptData.message ?? "Please add manually.", + variant: "destructive", + }); + } + } catch {} + }; + // Redirect from schedule page "Check Eligibility": prefill patient + optionally auto-trigger or scroll useEffect(() => { const params = new URLSearchParams(window.location.search); @@ -925,6 +985,7 @@ export default function InsuranceStatusPage() { onAutoTriggered={() => setTriggerTarget(null)} onPdfReady={async (pdfId, fallbackFilename) => { const claimed = await tryClaimFromChatbot(selectedPatient?.id); + void tryAppointmentFromChatbot(); if (!claimed) { setPreviewPdfId(pdfId); setPreviewFallbackFilename(fallbackFilename ?? `eligibility_ddma_${memberId}.pdf`); @@ -945,6 +1006,7 @@ export default function InsuranceStatusPage() { onAutoTriggered={() => setTriggerTarget(null)} onPdfReady={async (pdfId, fallbackFilename) => { const claimed = await tryClaimFromChatbot(selectedPatient?.id); + void tryAppointmentFromChatbot(); if (!claimed) { setPreviewPdfId(pdfId); setPreviewFallbackFilename(fallbackFilename ?? `eligibility_deltains_${memberId}.pdf`); @@ -961,7 +1023,8 @@ export default function InsuranceStatusPage() { firstName={firstName} lastName={lastName} isFormIncomplete={isFormIncomplete} - onPdfReady={(pdfId, fallbackFilename) => { + onPdfReady={async (pdfId, fallbackFilename) => { + void tryAppointmentFromChatbot(); setPreviewPdfId(pdfId); setPreviewFallbackFilename( fallbackFilename ?? `eligibility_bcbs_ma_${memberId}.pdf`, @@ -985,6 +1048,7 @@ export default function InsuranceStatusPage() { onAutoTriggered={() => setTriggerTarget(null)} onPdfReady={async (pdfId, fallbackFilename) => { const claimed = await tryClaimFromChatbot(selectedPatient?.id); + void tryAppointmentFromChatbot(); if (!claimed) { setPreviewPdfId(pdfId); setPreviewFallbackFilename(fallbackFilename ?? `eligibility_tuftssco_${memberId}.pdf`); @@ -1005,6 +1069,7 @@ export default function InsuranceStatusPage() { onAutoTriggered={() => setTriggerTarget(null)} onPdfReady={async (pdfId, fallbackFilename) => { const claimed = await tryClaimFromChatbot(selectedPatient?.id); + void tryAppointmentFromChatbot(); if (!claimed) { setPreviewPdfId(pdfId); setPreviewFallbackFilename(fallbackFilename ?? `eligibility_unitedsco_${memberId}.pdf`); @@ -1025,6 +1090,7 @@ export default function InsuranceStatusPage() { onAutoTriggered={() => setTriggerTarget(null)} onPdfReady={async (pdfId, fallbackFilename) => { const claimed = await tryClaimFromChatbot(selectedPatient?.id); + void tryAppointmentFromChatbot(); if (!claimed) { setPreviewPdfId(pdfId); setPreviewFallbackFilename(fallbackFilename ?? `eligibility_cca_${memberId}.pdf`);