diff --git a/apps/Backend/src/ai/internal-chat-graph.ts b/apps/Backend/src/ai/internal-chat-graph.ts index e25b9867..9f837b8e 100644 --- a/apps/Backend/src/ai/internal-chat-graph.ts +++ b/apps/Backend/src/ai/internal-chat-graph.ts @@ -7,6 +7,8 @@ export type InternalChatIntent = | "eligibility_by_id" // by explicit memberId + dob (no name) | "check_and_claim" // eligibility + claim procedures | "find_patient" // look up patient record only + | "schedule_appointment" // add patient to today's (or specified) schedule + | "claim_only" // submit claim for procedures (no eligibility check) | "navigate_claims" | "navigate_schedule" | "general"; @@ -14,13 +16,16 @@ export type InternalChatIntent = export interface ChatClassification { intent: InternalChatIntent; // --- patient resolution (one of name OR id+dob) --- - patientName?: string; // for check_eligibility / find_patient + patientName?: string; // for check_eligibility / find_patient / schedule_appointment memberId?: string; // for eligibility_by_id / check_and_claim dob?: string; // for eligibility_by_id / check_and_claim (MM/DD/YYYY) // --- insurance hint (only if explicitly stated in the message) --- insuranceHint?: string; // raw text, e.g. "masshealth", "BCBS", "CCA" // --- procedures (raw text, NOT CDT codes — CDT lookup is done in workflow) --- procedureNames?: string[]; // for check_and_claim, e.g. ["perio exam", "adult cleaning"] + // --- scheduling --- + appointmentDate?: string; // for schedule_appointment, YYYY-MM-DD (omit = today) + appointmentTime?: string; // for schedule_appointment, HH:MM 24h (omit = 09:00) fallbackReply: string; } @@ -38,43 +43,61 @@ Respond ONLY with valid JSON (no markdown fences): "dob": "", "insuranceHint": "", "procedureNames": ["", ...], + "appointmentDate": "", + "appointmentTime": "", "fallbackReply": "<1-2 sentence reply to show the user>" } Omit any field that is not present in the message. Intents: -- check_eligibility : user wants to check insurance for a patient identified by NAME only - e.g. "check Maria Jesus", "verify insurance for John Smith" -- eligibility_by_id : user provides a member ID and date of birth (no patient name) - e.g. "check masshealth for 100xxxx, 10/10/1988" -- check_and_claim : user wants to check eligibility AND submit procedures as claims - e.g. "check masshealth for 100xxxx, 10/10/1988 and claim perio exam and adult cleaning" - e.g. "check Maria Jesus and claim D0120 D1110" -- find_patient : look up a patient record only, no eligibility - e.g. "find patient John", "look up Smith" -- navigate_claims : open the claims page -- navigate_schedule : open the appointments/schedule page -- general : anything else +- check_eligibility : user wants to check insurance for a patient identified by NAME only + e.g. "check Maria Jesus", "verify insurance for John Smith" +- eligibility_by_id : user provides a member ID and date of birth (no patient name) + e.g. "check masshealth for 100xxxx, 10/10/1988" +- check_and_claim : user wants to check eligibility AND submit procedures as claims + e.g. "check masshealth for 100xxxx, 10/10/1988 and claim perio exam and adult cleaning" + e.g. "check Maria Jesus and claim D0120 D1110" +- find_patient : look up a patient record only, no eligibility + e.g. "find patient John", "look up Smith" +- schedule_appointment : add a patient to the schedule (today or a specified date/time) + e.g. "put John Smith in today's schedule" + e.g. "schedule Maria at 2pm tomorrow" + e.g. "add Jane Doe at 10:30" +- claim_only : submit a claim for procedures WITHOUT an eligibility check + e.g. "claim comprehensive exam and Pano for her" + e.g. "claim D0120 and D1110 for John Smith" + e.g. "bill adult cleaning for Maria" + Use this when no eligibility check is requested — just billing/claiming services +- navigate_claims : open the claims page +- navigate_schedule : open the appointments/schedule page +- general : anything else Rules: -- For check_and_claim, procedureNames should be the RAW user text +- For check_and_claim and claim_only, procedureNames should be the RAW user text (e.g. "perio exam", "adult cleaning", "D0120") — do NOT translate to codes - insuranceHint is only set when the user explicitly names an insurance in the message - Keep fallbackReply to 1-2 sentences -- For navigate intents, fallbackReply = "Opening the [page] page..."`; +- For navigate intents, fallbackReply = "Opening the [page] page..." +- For schedule_appointment, appointmentDate omitted means today; appointmentTime omitted means no preference +- IMPORTANT: Use the conversation history to resolve pronouns and references. + If the user says "her", "him", "them", "the patient", or "same patient", look back through + the conversation history to find the patient name that was mentioned most recently. + Always populate patientName (or memberId) from history when a pronoun is used. + Never return an empty patientName just because the current message uses a pronoun.`; // ─── Classifier ─────────────────────────────────────────────────────────────── export async function classifyInternalChat( message: string, apiKey: string, - extraSystemPrompt?: string + extraSystemPrompt?: string, + history: { role: "user" | "assistant"; text: string }[] = [] ): Promise { const fallback: ChatClassification = { intent: "general", fallbackReply: - "I can search for a patient, check eligibility, run check & claim, or navigate to claims or appointments.", + "I can search for a patient, check eligibility, run check & claim, schedule appointments, or navigate to claims or appointments.", }; if (!apiKey) return fallback; @@ -84,9 +107,24 @@ export async function classifyInternalChat( : BASE_SYSTEM_PROMPT; try { - const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey }); + const llm = new ChatGoogleGenerativeAI({ model: "gemini-flash-latest", apiKey }); + + // Gemini requires conversation to start with a user turn — drop any leading assistant messages + const trimmedHistory = history.slice( + history.findIndex((h) => h.role === "user") + ).filter((_, i, arr) => { + // Also drop consecutive same-role messages (keep last of each run) + if (i === arr.length - 1) return true; + return arr[i]!.role !== arr[i + 1]!.role; + }); + + const historyMessages = trimmedHistory.map((h) => ({ + role: h.role, + content: h.text, + })); const response = await llm.invoke([ { role: "system", content: systemPrompt }, + ...historyMessages, { role: "user", content: message }, ]); diff --git a/apps/Backend/src/ai/internal-chat-workflow.ts b/apps/Backend/src/ai/internal-chat-workflow.ts index 9a351b1a..f5d5dc3d 100644 --- a/apps/Backend/src/ai/internal-chat-workflow.ts +++ b/apps/Backend/src/ai/internal-chat-workflow.ts @@ -9,6 +9,7 @@ */ import { ChatClassification } from "./internal-chat-graph"; import { lookupCdtCodes } from "./cdt-lookup"; +import insuranceAliases from "../data/insuranceAliases.json"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -35,7 +36,10 @@ export interface ChatResponse { | "check_eligibility_prefill" | "eligibility_id_ready" | "check_and_claim_ready" - | "need_insurance_clarification"; + | "need_insurance_clarification" + | "appointment_created" + | "claim_only_ready" + | "need_appointment_selection"; actionData?: Record; } @@ -43,13 +47,10 @@ export interface ChatResponse { export function deriveSiteKey(provider: string): string { const p = (provider ?? "").toLowerCase().trim(); - if (!p || p.includes("masshealth") || p === "mh" || p === "mass health") return "MH"; - if (p.includes("commonwealth care alliance") || p === "cca") return "CCA"; - if (p.includes("ddma") || p.includes("delta dental ma")) return "DDMA"; - if (p.includes("delta dental ins") || p.includes("delta ins")) return "DELTA_INS"; - if (p.includes("tufts") || p.includes("dentaquest") || p === "tuftssco") return "TUFTS_SCO"; - if ((p.includes("united") && p.includes("sco")) || p.includes("dentalhub") || p === "united_sco") return "UNITED_SCO"; - if (p.includes("bcbs") || p.includes("blue cross")) return "BCBS_MA"; + if (!p) return "MH"; + for (const { keyword, siteKey } of insuranceAliases) { + if (p.includes(keyword.toLowerCase())) return siteKey; + } return "MH"; } @@ -74,6 +75,10 @@ interface StorageLike { offset: number; }): Promise; getPatientByInsuranceId(id: string): Promise; + createAppointment(appointment: any): Promise; + getAppointmentsByDateForUser(dateStr: string, userId: number): Promise; + getOfficeHours(userId: number): Promise; + getAppointmentsByPatientId(patientId: number): Promise; } // ─── Shared helpers ─────────────────────────────────────────────────────────── @@ -210,6 +215,18 @@ export async function runInternalChatWorkflow( return await handleCheckAndClaim(classification, storage, customAliases); } + // ── Claim only (no eligibility check) ───────────────────────────────────── + + if (intent === "claim_only") { + return await handleClaimOnly(classification, storage, customAliases); + } + + // ── Schedule appointment ─────────────────────────────────────────────────── + + if (intent === "schedule_appointment") { + return await handleScheduleAppointment(classification, _userId, storage); + } + // ── General ──────────────────────────────────────────────────────────────── return { reply: classification.fallbackReply }; } @@ -223,9 +240,9 @@ async function handleEligibilityById( const memberId = c.memberId?.trim(); const dob = c.dob?.trim(); - if (!memberId || !dob) { + if (!memberId) { return { - reply: "Please provide both a Member ID and Date of Birth (MM/DD/YYYY).", + reply: "Please provide a Member ID to run the eligibility check.", }; } @@ -235,6 +252,14 @@ async function handleEligibilityById( ? patientToResult(existingPatient) : null; + // Use stored DOB if not provided in the message + const resolvedDob = dob ?? patient?.dateOfBirth ?? null; + if (!resolvedDob) { + return { + reply: "Please provide a Date of Birth (MM/DD/YYYY) to run the eligibility check.", + }; + } + // Determine siteKey const siteKey = resolveSiteKey( patient?.insuranceProvider ?? null, @@ -250,7 +275,7 @@ async function handleEligibilityById( action: "need_insurance_clarification", actionData: { memberId, - dob, + dob: resolvedDob, patient, options: ["MassHealth", "BCBS MA", "CCA", "Tufts SCO", "Delta Dental MA", "United/DentalHub"], }, @@ -267,7 +292,7 @@ async function handleEligibilityById( actionData: { patient, memberId, - dob, + dob: resolvedDob, siteKey, autoCheck: siteKeyToAutoCheck(siteKey), }, @@ -302,9 +327,12 @@ async function handleCheckAndClaim( reply: "Please include either a Member ID or a patient name so I can look up their record.", }; } - if (!dob) { + + // Use stored DOB if not provided in the message + const resolvedDob = dob ?? patient?.dateOfBirth ?? null; + if (!resolvedDob) { return { - reply: `I have the Member ID (${memberId}) but need a Date of Birth (MM/DD/YYYY) to run the eligibility check.`, + reply: `I have the Member ID (${memberId}) but couldn't find a Date of Birth on file. Please provide it (MM/DD/YYYY).`, }; } @@ -358,7 +386,7 @@ async function handleCheckAndClaim( actionData: { patient, memberId, - dob, + dob: resolvedDob, siteKey, autoCheck: siteKeyToAutoCheck(siteKey), cdtResults, @@ -367,6 +395,269 @@ async function handleCheckAndClaim( }; } +// ─── claim_only ─────────────────────────────────────────────────────────────── + +async function handleClaimOnly( + c: ChatClassification, + storage: StorageLike, + customAliases: { phrase: string; cdtCode: string }[] +): Promise { + // Resolve patient + let patient: ResolvedPatient | null = null; + + if (c.memberId?.trim()) { + const existing = await storage.getPatientByInsuranceId(c.memberId.trim()); + if (existing) patient = patientToResult(existing); + } else if (c.patientName?.trim()) { + const raw = await findPatientByName(c.patientName.trim(), storage); + if (raw) patient = patientToResult(raw); + } + + if (!patient) { + return { + reply: "Please include a patient name or Member ID so I can look them up.", + }; + } + + const fullName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim(); + + // Map procedure names → CDT codes + const procedureNames = c.procedureNames ?? []; + if (procedureNames.length === 0) { + return { reply: "Please specify which procedures to claim (e.g. perio exam, adult prophy)." }; + } + + const cdtResults = lookupCdtCodes(procedureNames, customAliases); + const matched = cdtResults.filter((r) => r.code !== null); + const unmatched = cdtResults.filter((r) => r.code === null); + + // Resolve service date: use explicit date from message, then latest appointment, then ask + let serviceDate: string | null = c.appointmentDate ?? null; + let appointmentId: number | null = null; + + if (!serviceDate) { + const appts = await storage.getAppointmentsByPatientId(patient.id); + const sorted = appts.sort((a: any, b: any) => + new Date(b.date).getTime() - new Date(a.date).getTime() + ); + + if (sorted.length >= 2) { + const d1 = new Date(sorted[0].date).getTime(); + const d2 = new Date(sorted[1].date).getTime(); + const diffDays = Math.abs(d1 - d2) / (1000 * 60 * 60 * 24); + + if (diffDays < 7) { + // Use UTC methods to avoid local-timezone day-shift on midnight UTC dates + const fmtUTC = (a: any) => { + const d = new Date(a.date); + return `${String(d.getUTCMonth() + 1).padStart(2, "0")}/${String(d.getUTCDate()).padStart(2, "0")}/${d.getUTCFullYear()}`; + }; + const isoUTC = (a: any) => { + const d = new Date(a.date); + return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`; + }; + return { + reply: `Found two appointments close together for ${fullName}: ${fmtUTC(sorted[0])} and ${fmtUTC(sorted[1])}. Which date should I use for the claim?`, + action: "need_appointment_selection", + actionData: { + patient, + siteKey: resolveSiteKey(patient.insuranceProvider ?? null, c.insuranceHint ?? null) ?? "MH", + matchedCodes: matched.map((r) => ({ code: r.code!, description: r.description })), + options: [ + { label: fmtUTC(sorted[0]), appointmentId: sorted[0].id, serviceDate: isoUTC(sorted[0]) }, + { label: fmtUTC(sorted[1]), appointmentId: sorted[1].id, serviceDate: isoUTC(sorted[1]) }, + ], + }, + }; + } + } + + if (sorted.length > 0) { + const rawDate = new Date(sorted[0].date); + serviceDate = `${rawDate.getUTCFullYear()}-${String(rawDate.getUTCMonth() + 1).padStart(2, "0")}-${String(rawDate.getUTCDate()).padStart(2, "0")}`; + appointmentId = sorted[0].id ?? null; + } + } + + if (!serviceDate) { + // No appointment on file — ask for the service date + const codesPreview = matched.map((r) => `${r.code}`).join(", ") || "the procedures"; + return { + reply: `Found ${fullName} but no appointments on file. What was the service date for ${codesPreview}? (e.g. "6/2/2026")`, + }; + } + + const siteKey = resolveSiteKey(patient.insuranceProvider ?? null, c.insuranceHint ?? null) ?? "MH"; + + const [sy, sm, sd] = serviceDate.split("-"); + const dateLabel = `${sm}/${sd}/${sy}`; + + let reply = `Opening claim for ${fullName} (service date ${dateLabel}): ${ + matched.map((r) => `${r.code} (${r.description})`).join(", ") || "no procedures matched" + }.`; + if (unmatched.length > 0) { + reply += ` Could not map: ${unmatched.map((r) => `"${r.input}"`).join(", ")} — verify manually.`; + } + + return { + reply, + action: "claim_only_ready", + actionData: { + patient, + siteKey, + serviceDate, + appointmentId, + matchedCodes: matched.map((r) => ({ code: r.code!, description: r.description })), + }, + }; +} + +// ─── schedule_appointment ───────────────────────────────────────────────────── + +const DEFAULT_STAFF_ID = 1; // Column A +const SLOT_DURATION = 30; // minutes + +/** Convert "HH:MM" to total minutes since midnight */ +function toMinutes(t: string): number { + const [h, m] = t.split(":").map(Number); + return (h ?? 0) * 60 + (m ?? 0); +} + +/** Convert total minutes since midnight to "HH:MM" */ +function fromMinutes(m: number): string { + return `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`; +} + +/** + * Build the list of valid start minutes for a day using office hours. + * Skips the lunch gap (amEnd → pmStart). + */ +function buildSlots(dayHours: { amStart: string; amEnd: string; pmStart: string; pmEnd: string }): number[] { + const slots: number[] = []; + const ranges = [ + [toMinutes(dayHours.amStart), toMinutes(dayHours.amEnd)], + [toMinutes(dayHours.pmStart), toMinutes(dayHours.pmEnd)], + ]; + for (const [start, end] of ranges) { + for (let t = start!; t + SLOT_DURATION <= end!; t += SLOT_DURATION) { + slots.push(t); + } + } + return slots; +} + +async function handleScheduleAppointment( + c: ChatClassification, + userId: number, + storage: StorageLike +): Promise { + const name = c.patientName?.trim(); + if (!name) { + return { reply: "Please include the patient's name so I can find them." }; + } + + const raw = await findPatientByName(name, storage); + if (!raw) { + return { + reply: `No patient found matching "${name}". Please check the spelling or add them on the Patients page first.`, + }; + } + + const patient = patientToResult(raw); + const fullName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim(); + + // Resolve date + const today = new Date(); + const localDate = c.appointmentDate + ? new Date(c.appointmentDate + "T00:00:00") + : new Date(today.getFullYear(), today.getMonth(), today.getDate()); + const dateStr = `${localDate.getFullYear()}-${String(localDate.getMonth() + 1).padStart(2, "0")}-${String(localDate.getDate()).padStart(2, "0")}`; + const dateLabel = localDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); + + // If time was explicitly provided, use it directly + if (c.appointmentTime) { + const startTime = c.appointmentTime; + const startMin = toMinutes(startTime); + const endTime = fromMinutes(startMin + SLOT_DURATION); + await storage.createAppointment({ + patientId: patient.id, + userId, + staffId: DEFAULT_STAFF_ID, + title: dateLabel, + date: localDate, + startTime, + endTime, + type: "recall", + status: "scheduled", + movedByAi: true, + }); + return { + reply: `Scheduled ${fullName} on ${dateLabel} at ${startTime} (Column A).`, + action: "appointment_created", + actionData: { patient, date: dateStr, startTime, endTime }, + }; + } + + // No time specified — find earliest available slot in Column A + const dayNames = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"]; + const dayName = dayNames[localDate.getDay()]!; + + // Load office hours (fall back to 9–12 / 13–17 if not configured) + 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 { reply: `The office is closed on ${dayName}. Please choose a different day.` }; + } + + const allSlots = buildSlots(dayHours); + + // Fetch all Column A appointments for that day + const existing = (await storage.getAppointmentsByDateForUser(dateStr, userId)) + .filter((a: any) => a.staffId === DEFAULT_STAFF_ID); + + // Find first slot that doesn't overlap any existing appointment + const booked = existing.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) { + return { + reply: `Column A is fully booked on ${dateLabel}. Please pick a different date or time.`, + }; + } + + const startTime = fromMinutes(availableStart); + const endTime = fromMinutes(availableStart + SLOT_DURATION); + + await storage.createAppointment({ + patientId: patient.id, + userId, + staffId: DEFAULT_STAFF_ID, + title: dateLabel, + date: localDate, + startTime, + endTime, + type: "recall", + status: "scheduled", + movedByAi: true, + }); + + return { + reply: `Scheduled ${fullName} on ${dateLabel} at ${startTime} (Column A) — earliest available slot.`, + action: "appointment_created", + actionData: { patient, date: dateStr, startTime, endTime }, + }; +} + // ─── Insurance resolution helper ────────────────────────────────────────────── /** diff --git a/apps/Backend/src/data/insuranceAliases.json b/apps/Backend/src/data/insuranceAliases.json new file mode 100644 index 00000000..b9a454c1 --- /dev/null +++ b/apps/Backend/src/data/insuranceAliases.json @@ -0,0 +1,32 @@ +[ + { "keyword": "masshealth", "siteKey": "MH" }, + { "keyword": "mass health", "siteKey": "MH" }, + { "keyword": "mh", "siteKey": "MH" }, + + { "keyword": "commonwealth care alliance", "siteKey": "CCA" }, + { "keyword": "cca", "siteKey": "CCA" }, + + { "keyword": "delta dental of massachusetts","siteKey": "DDMA" }, + { "keyword": "delta dental ma", "siteKey": "DDMA" }, + { "keyword": "ddma", "siteKey": "DDMA" }, + + { "keyword": "delta dental ins", "siteKey": "DELTA_INS" }, + { "keyword": "delta ins", "siteKey": "DELTA_INS" }, + { "keyword": "delta dental", "siteKey": "DELTA_INS" }, + + { "keyword": "tufts sco", "siteKey": "TUFTS_SCO" }, + { "keyword": "tufts", "siteKey": "TUFTS_SCO" }, + { "keyword": "tufs", "siteKey": "TUFTS_SCO" }, + { "keyword": "dentaquest", "siteKey": "TUFTS_SCO" }, + { "keyword": "tuftssco", "siteKey": "TUFTS_SCO" }, + + { "keyword": "united healthone sco", "siteKey": "UNITED_SCO" }, + { "keyword": "united sco", "siteKey": "UNITED_SCO" }, + { "keyword": "dentalhub", "siteKey": "UNITED_SCO" }, + { "keyword": "united_sco", "siteKey": "UNITED_SCO" }, + + { "keyword": "blue cross blue shield", "siteKey": "BCBS_MA" }, + { "keyword": "bcbs ma", "siteKey": "BCBS_MA" }, + { "keyword": "blue cross", "siteKey": "BCBS_MA" }, + { "keyword": "bcbs", "siteKey": "BCBS_MA" } +] diff --git a/apps/Backend/src/routes/ai-settings.ts b/apps/Backend/src/routes/ai-settings.ts index 30c1d042..882b8e97 100644 --- a/apps/Backend/src/routes/ai-settings.ts +++ b/apps/Backend/src/routes/ai-settings.ts @@ -165,7 +165,7 @@ router.post("/internal-chat", async (req: Request, res: Response): Promise const userId = req.user?.id; if (!userId) return res.status(401).json({ message: "Unauthorized" }); - const { message } = req.body; + const { message, history } = req.body; if (!message?.trim()) return res.status(400).json({ message: "message is required" }); const aiSettings = await storage.getAiSettings(userId); @@ -183,7 +183,8 @@ router.post("/internal-chat", async (req: Request, res: Response): Promise const classification = await classifyInternalChat( message.trim(), aiSettings.apiKey, - extraSystemPrompt || undefined + extraSystemPrompt || undefined, + Array.isArray(history) ? history : [] ); const response = await runInternalChatWorkflow(classification, userId, storage, customAliases); diff --git a/apps/Frontend/src/components/claims/claim-form.tsx b/apps/Frontend/src/components/claims/claim-form.tsx index cbad964e..74f67252 100755 --- a/apps/Frontend/src/components/claims/claim-form.tsx +++ b/apps/Frontend/src/components/claims/claim-form.tsx @@ -79,6 +79,8 @@ interface ClaimFormProps { patientId: number; appointmentId?: number; autoSubmit?: boolean; + /** When set, autoSubmit triggers this insurance's Selenium worker instead of MH */ + autoSubmitSiteKey?: string; /** When true: form saves to AppointmentProcedure (Select Procedures flow), shows only Save button */ proceduresOnly?: boolean; onSubmit: (data: ClaimFormData) => Promise; @@ -101,6 +103,7 @@ export function ClaimForm({ patientId, appointmentId, autoSubmit, + autoSubmitSiteKey, proceduresOnly = false, onHandleAppointmentSubmit, onHandleUpdatePatient, @@ -487,6 +490,41 @@ export function ClaimForm({ }; }, [appointmentId, serviceDate, existingClaimId]); + // Prefill service lines (and optional service date) from chatbot claim_only flow + useEffect(() => { + const raw = sessionStorage.getItem("chatbot_claim_prefill"); + if (!raw) return; + try { + const { codes, serviceDate } = JSON.parse(raw) as { + codes: { code: string; description: string }[]; + serviceDate?: string; + }; + sessionStorage.removeItem("chatbot_claim_prefill"); + if (!codes?.length) return; + + if (serviceDate) { + try { + const d = parseLocalDate(serviceDate); + setServiceDateValue(d); + setServiceDate(formatLocalDate(d)); + } catch {} + } + + setForm((prev) => { + const date = serviceDate ? serviceDate : prev.serviceDate; + const updatedLines = [...prev.serviceLines]; + codes.forEach((c, i) => { + if (i < updatedLines.length) { + updatedLines[i] = { ...updatedLines[i]!, procedureCode: c.code, procedureDate: date }; + } + }); + return { ...prev, serviceLines: updatedLines }; + }); + + if (!appointmentId) setPrefillDone(true); + } catch {} + }, []); + // Restore NPI provider from saved procedures when npiProviders list loads after 2b useEffect(() => { if (!savedProcNpiId || !npiProviders.length) return; @@ -949,10 +987,11 @@ export function ClaimForm({ }; // 3rd Button workflow — CCA Claim: saves to DB then submits via Selenium - const handleCCAClaim = async () => { + const handleCCAClaim = async (formToUse?: ClaimFormData & { uploadedFiles?: File[] }) => { + const f = formToUse ?? form; const missingFields: string[] = []; - if (!form.memberId?.trim()) missingFields.push("Member ID"); - if (!form.dateOfBirth?.trim()) missingFields.push("Date of Birth"); + if (!f.memberId?.trim()) missingFields.push("Member ID"); + if (!f.dateOfBirth?.trim()) missingFields.push("Date of Birth"); if (!patient?.firstName?.trim()) missingFields.push("First Name"); if (missingFields.length > 0) { toast({ @@ -963,7 +1002,7 @@ export function ClaimForm({ return; } - const filteredServiceLines = (form.serviceLines || []).filter( + const filteredServiceLines = (f.serviceLines || []).filter( (line) => (line.procedureCode ?? "").trim() !== "", ); if (filteredServiceLines.length === 0) { @@ -990,10 +1029,10 @@ export function ClaimForm({ } } - const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = form; - const claimFilesMeta: ClaimFileMeta[] = (uploadedFiles || []).map((f) => ({ - filename: f.name, - mimeType: f.type, + const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = f; + const claimFilesMeta: ClaimFileMeta[] = (uploadedFiles || []).map((file) => ({ + filename: file.name, + mimeType: file.type, })); const selectedNpiProviderId = npiProvider?.npiNumber @@ -1014,7 +1053,7 @@ export function ClaimForm({ // Send to CCA Selenium — send raw YYYY-MM-DD so Python _format_dob converts correctly onHandleForCCASeleniumClaim({ - ...form, + ...f, serviceLines: filteredServiceLines, staffId: appointmentStaffId ?? Number(staff?.id), patientId, @@ -1028,10 +1067,11 @@ export function ClaimForm({ }; // Delta MA Claim: saves to DB then submits via Selenium - const handleDDMAClaim = async () => { + const handleDDMAClaim = async (formToUse?: ClaimFormData & { uploadedFiles?: File[] }) => { + const f = formToUse ?? form; const missingFields: string[] = []; - if (!form.memberId?.trim()) missingFields.push("Member ID"); - if (!form.dateOfBirth?.trim()) missingFields.push("Date of Birth"); + if (!f.memberId?.trim()) missingFields.push("Member ID"); + if (!f.dateOfBirth?.trim()) missingFields.push("Date of Birth"); if (!patient?.firstName?.trim()) missingFields.push("First Name"); if (missingFields.length > 0) { toast({ @@ -1042,7 +1082,7 @@ export function ClaimForm({ return; } - const filteredServiceLines = (form.serviceLines || []).filter( + const filteredServiceLines = (f.serviceLines || []).filter( (line) => (line.procedureCode ?? "").trim() !== "", ); if (filteredServiceLines.length === 0) { @@ -1068,7 +1108,7 @@ export function ClaimForm({ } } - const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = form; + const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = f; // Upload files to server so we get local filePaths for Selenium const claimFilesMeta: ClaimFileMeta[] = uploadedFiles?.length @@ -1091,7 +1131,7 @@ export function ClaimForm({ }); onHandleForDDMASeleniumClaim({ - ...form, + ...f, serviceLines: filteredServiceLines, staffId: appointmentStaffId ?? Number(staff?.id), patientId, @@ -1106,10 +1146,11 @@ export function ClaimForm({ }; // United/DentalHub Claim: saves to DB then submits via Selenium - const handleUnitedDHClaim = async () => { + const handleUnitedDHClaim = async (formToUse?: ClaimFormData & { uploadedFiles?: File[] }) => { + const f = formToUse ?? form; const missingFields: string[] = []; - if (!form.memberId?.trim()) missingFields.push("Member ID"); - if (!form.dateOfBirth?.trim()) missingFields.push("Date of Birth"); + if (!f.memberId?.trim()) missingFields.push("Member ID"); + if (!f.dateOfBirth?.trim()) missingFields.push("Date of Birth"); if (!patient?.firstName?.trim()) missingFields.push("First Name"); if (missingFields.length > 0) { toast({ @@ -1120,7 +1161,7 @@ export function ClaimForm({ return; } - const filteredServiceLines = (form.serviceLines || []).filter( + const filteredServiceLines = (f.serviceLines || []).filter( (line) => (line.procedureCode ?? "").trim() !== "", ); if (filteredServiceLines.length === 0) { @@ -1146,7 +1187,7 @@ export function ClaimForm({ } } - const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = form; + const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = f; const claimFilesMeta: ClaimFileMeta[] = uploadedFiles?.length ? await uploadAttachmentsToLocalFolder(uploadedFiles) @@ -1168,7 +1209,7 @@ export function ClaimForm({ }); onHandleForUnitedDHSeleniumClaim({ - ...form, + ...f, serviceLines: filteredServiceLines, staffId: appointmentStaffId ?? Number(staff?.id), patientId, @@ -1183,10 +1224,11 @@ export function ClaimForm({ }; // Tufts SCO Claim: saves to DB then submits via Selenium - const handleTuftsSCOClaim = async () => { + const handleTuftsSCOClaim = async (formToUse?: ClaimFormData & { uploadedFiles?: File[] }) => { + const f = formToUse ?? form; const missingFields: string[] = []; - if (!form.memberId?.trim()) missingFields.push("Member ID"); - if (!form.dateOfBirth?.trim()) missingFields.push("Date of Birth"); + if (!f.memberId?.trim()) missingFields.push("Member ID"); + if (!f.dateOfBirth?.trim()) missingFields.push("Date of Birth"); if (!patient?.firstName?.trim()) missingFields.push("First Name"); if (missingFields.length > 0) { toast({ @@ -1197,7 +1239,7 @@ export function ClaimForm({ return; } - const filteredServiceLines = (form.serviceLines || []).filter( + const filteredServiceLines = (f.serviceLines || []).filter( (line) => (line.procedureCode ?? "").trim() !== "", ); if (filteredServiceLines.length === 0) { @@ -1223,7 +1265,7 @@ export function ClaimForm({ } } - const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = form; + const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = f; const claimFilesMeta: ClaimFileMeta[] = uploadedFiles?.length ? await uploadAttachmentsToLocalFolder(uploadedFiles) @@ -1255,7 +1297,7 @@ export function ClaimForm({ } onHandleForTuftsSCOSeleniumClaim({ - ...form, + ...f, serviceLines: filteredServiceLines, staffId: appointmentStaffId ?? Number(staff?.id), patientId, @@ -1656,8 +1698,30 @@ export function ClaimForm({ if (autoSubmittedRef.current) return; autoSubmittedRef.current = true; - handleMHSubmit(); - }, [autoSubmit, prefillDone, isFormReady]); + // Apply fee-schedule prices before triggering so billed amounts are populated + const siteKeyForPricing = autoSubmitSiteKey + ? autoSubmitSiteKey.replace(/_/g, "").toLowerCase() + : deriveInsuranceSiteKey(form.insuranceProvider || ""); + + const pricedForm = mapPricesForForm({ + form: { ...form, insuranceSiteKey: siteKeyForPricing }, + patientDOB: patient?.dateOfBirth ?? "", + insuranceSiteKey: siteKeyForPricing, + }); + + const key = (autoSubmitSiteKey ?? "").toLowerCase(); + if (key === "tufts_sco" || key === "tuftsco" || key === "tufts sco") { + handleTuftsSCOClaim(pricedForm); + } else if (key === "cca") { + handleCCAClaim(pricedForm); + } else if (key === "ddma") { + handleDDMAClaim(pricedForm); + } else if (key === "united_sco" || key === "unitedco" || key === "dentalhub") { + handleUnitedDHClaim(pricedForm); + } else { + handleMHSubmit(pricedForm); + } + }, [autoSubmit, autoSubmitSiteKey, prefillDone, isFormReady]); // overlay click handler (close when clicking backdrop) const onOverlayMouseDown = (e: React.MouseEvent) => { diff --git a/apps/Frontend/src/components/layout/chatbot.tsx b/apps/Frontend/src/components/layout/chatbot.tsx index f854255c..7a4ccca4 100644 --- a/apps/Frontend/src/components/layout/chatbot.tsx +++ b/apps/Frontend/src/components/layout/chatbot.tsx @@ -9,6 +9,7 @@ import { MessageSquare, Send, Loader2, + RotateCcw, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; @@ -24,7 +25,8 @@ type Step = | "patient-found" | "eligibility-id-ready" | "check-and-claim-ready" - | "need-insurance-clarification"; + | "need-insurance-clarification" + | "need-appointment-selection"; interface Message { id: number; @@ -96,14 +98,20 @@ function parseEligibilityInput( }; } -const INITIAL_MESSAGES: Message[] = [ - makeMsg("bot", "Hi! What can I help you with today?"), -]; +const CHAT_STORAGE_KEY = "chatbot_messages"; + +function loadSavedMessages(): Message[] { + try { + const raw = sessionStorage.getItem(CHAT_STORAGE_KEY); + if (raw) return JSON.parse(raw) as Message[]; + } catch {} + return [makeMsg("bot", "Hi! What can I help you with today?")]; +} export function ChatbotButton() { const [open, setOpen] = useState(false); const [step, setStep] = useState("menu"); - const [messages, setMessages] = useState(INITIAL_MESSAGES); + const [messages, setMessages] = useState(loadSavedMessages); const [pasteInput, setPasteInput] = useState(""); const [parseError, setParseError] = useState(""); const [eligibilityData, setEligibilityData] = useState(null); @@ -112,6 +120,12 @@ export function ChatbotButton() { const [eligibilityIdData, setEligibilityIdData] = useState<{ memberId: string; dob: string; siteKey: string; autoCheck: string; patient: PatientResult | null } | null>(null); const [checkAndClaimData, setCheckAndClaimData] = useState(null); const [clarificationData, setClarificationData] = useState<{ memberId: string; dob: string; patient: PatientResult | null; procedureNames: string[]; options: string[] } | null>(null); + const [apptSelectionData, setApptSelectionData] = useState<{ + patient: PatientResult; + siteKey: string; + matchedCodes: { code: string; description: string }[]; + options: { label: string; appointmentId: number; serviceDate: string }[]; + } | null>(null); const [, setLocation] = useLocation(); const messagesEndRef = useRef(null); const pasteRef = useRef(null); @@ -121,6 +135,14 @@ export function ChatbotButton() { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages, step]); + // Persist messages across navigation (cleared on logout) + useEffect(() => { + try { + const saveable = messages.filter((m) => !m.isLoading); + sessionStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(saveable)); + } catch {} + }, [messages]); + useEffect(() => { if (step === "eligibility-input") { setTimeout(() => pasteRef.current?.focus(), 50); @@ -141,9 +163,9 @@ export function ChatbotButton() { return next; }); - const reset = () => { + // Resets step/data only — keeps message history + const resetStep = () => { setStep("menu"); - setMessages([makeMsg("bot", "Hi! What can I help you with today?")]); setPasteInput(""); setParseError(""); setEligibilityData(null); @@ -152,22 +174,33 @@ export function ChatbotButton() { setEligibilityIdData(null); setCheckAndClaimData(null); setClarificationData(null); + setApptSelectionData(null); + }; + + // Full reset including message history and stored session + const reset = () => { + resetStep(); + const fresh = [makeMsg("bot", "Hi! What can I help you with today?")]; + try { + sessionStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(fresh)); + } catch {} + setMessages(fresh); }; const handleClose = () => { setOpen(false); - reset(); + resetStep(); }; const handleOptionSelect = (option: "eligibility" | "schedule" | "claims") => { if (option === "schedule") { addMsg("user", "Schedule an appointment"); addMsg("bot", "Opening the appointments page..."); - setTimeout(() => { setLocation("/appointments"); setOpen(false); reset(); }, 600); + setTimeout(() => { setLocation("/appointments"); setOpen(false); resetStep(); }, 600); } else if (option === "claims") { addMsg("user", "View claims"); addMsg("bot", "Opening the claims page..."); - setTimeout(() => { setLocation("/claims"); setOpen(false); reset(); }, 600); + setTimeout(() => { setLocation("/claims"); setOpen(false); resetStep(); }, 600); } else if (option === "eligibility") { addMsg("user", "Check Eligibility"); addMsg("bot", "Please enter the patient's Member ID and Date of Birth:"); @@ -199,7 +232,7 @@ export function ChatbotButton() { autoCheck: getAutoCheck(eligibilityData.dobISO), })); window.dispatchEvent(new CustomEvent("chatbot:eligibility-prefill")); - setTimeout(() => { setLocation("/insurance-status"); setOpen(false); reset(); }, 600); + setTimeout(() => { setLocation("/insurance-status"); setOpen(false); resetStep(); }, 600); }; const handleEligibilityFromPatient = () => { @@ -214,13 +247,13 @@ export function ChatbotButton() { })); window.dispatchEvent(new CustomEvent("chatbot:eligibility-prefill")); } - setTimeout(() => { setLocation("/insurance-status"); setOpen(false); reset(); }, 600); + setTimeout(() => { setLocation("/insurance-status"); setOpen(false); resetStep(); }, 600); }; const prefillAndNavigate = (memberId: string, dobISO: string, autoCheck: string) => { sessionStorage.setItem("chatbot_eligibility", JSON.stringify({ memberId, dob: dobISO, autoCheck })); window.dispatchEvent(new CustomEvent("chatbot:eligibility-prefill")); - setTimeout(() => { setLocation("/insurance-status"); setOpen(false); reset(); }, 600); + setTimeout(() => { setLocation("/insurance-status"); setOpen(false); resetStep(); }, 600); }; const handleEligibilityIdRun = () => { @@ -257,13 +290,17 @@ export function ChatbotButton() { setStep("ai-loading"); try { - const res = await apiRequest("POST", "/api/ai/internal-chat", { message: text }); + const history = messages + .filter((m) => !m.isLoading) + .slice(-15) + .map((m) => ({ role: m.role === "user" ? "user" : "assistant", text: m.text })); + const res = await apiRequest("POST", "/api/ai/internal-chat", { message: text, history }); const data = await res.json(); replaceLastMsg(data.reply ?? "Sorry, I couldn't process that."); if (data.action === "navigate" && data.actionData?.url) { - setTimeout(() => { setLocation(data.actionData.url); setOpen(false); reset(); }, 800); + setTimeout(() => { setLocation(data.actionData.url); setOpen(false); resetStep(); }, 800); return; } @@ -313,6 +350,41 @@ export function ChatbotButton() { return; } + if (data.action === "appointment_created") { + setStep("menu"); + return; + } + + if (data.action === "need_appointment_selection" && data.actionData) { + setApptSelectionData({ + patient: data.actionData.patient, + siteKey: data.actionData.siteKey, + matchedCodes: data.actionData.matchedCodes ?? [], + options: data.actionData.options ?? [], + }); + setStep("need-appointment-selection"); + return; + } + + if (data.action === "claim_only_ready" && data.actionData) { + const { patient, matchedCodes, siteKey, serviceDate, appointmentId } = data.actionData; + if (patient?.id && matchedCodes?.length > 0) { + sessionStorage.setItem( + "chatbot_claim_prefill", + JSON.stringify({ codes: matchedCodes, siteKey, serviceDate, autoSubmit: true }) + ); + } + const url = appointmentId + ? `/claims?appointmentId=${appointmentId}` + : `/claims?newPatient=${patient?.id}`; + setTimeout(() => { + setLocation(url); + setOpen(false); + resetStep(); + }, 600); + return; + } + setStep("menu"); } catch { replaceLastMsg("Sorry, something went wrong. Please try again."); @@ -333,7 +405,8 @@ export function ChatbotButton() { step === "patient-found" || step === "eligibility-id-ready" || step === "check-and-claim-ready" || - step === "need-insurance-clarification"; + step === "need-insurance-clarification" || + step === "need-appointment-selection"; return ( <> @@ -360,13 +433,24 @@ export function ChatbotButton() { Assistant - +
+ + +
{/* Messages */} @@ -631,6 +715,44 @@ export function ChatbotButton() { )} + {/* Appointment selection */} + {step === "need-appointment-selection" && apptSelectionData && ( +
+

Which appointment date?

+
+ {apptSelectionData.options.map((opt) => ( + + ))} +
+ +
+ )} +
diff --git a/apps/Frontend/src/hooks/use-auth.tsx b/apps/Frontend/src/hooks/use-auth.tsx index 3839cce2..b00278aa 100755 --- a/apps/Frontend/src/hooks/use-auth.tsx +++ b/apps/Frontend/src/hooks/use-auth.tsx @@ -117,6 +117,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const logoutMutation = useMutation({ mutationFn: async () => { localStorage.removeItem("token"); + sessionStorage.removeItem("chatbot_messages"); await apiRequest("POST", "/api/auth/logout"); }, onSuccess: () => { diff --git a/apps/Frontend/src/pages/claims-page.tsx b/apps/Frontend/src/pages/claims-page.tsx index b133a208..f7215545 100755 --- a/apps/Frontend/src/pages/claims-page.tsx +++ b/apps/Frontend/src/pages/claims-page.tsx @@ -38,9 +38,8 @@ import { useLicense } from "@/hooks/use-license"; export default function ClaimsPage() { const [isClaimFormOpen, setIsClaimFormOpen] = useState(false); - const [selectedPatientId, setSelectedPatientId] = useState( - null - ); + const [selectedPatientId, setSelectedPatientId] = useState(null); + const [chatbotAutoSubmitSiteKey, setChatbotAutoSubmitSiteKey] = useState(undefined); // for redirect from appointment page directly, then passing to claimform const [selectedAppointmentId, setSelectedAppointmentId] = useState< number | null @@ -239,6 +238,17 @@ export default function ClaimsPage() { const id = Number(newPatient); if (!Number.isFinite(id) || id <= 0) return; + // Check if chatbot requested auto-submit with a specific insurance + try { + const raw = sessionStorage.getItem("chatbot_claim_prefill"); + if (raw) { + const parsed = JSON.parse(raw); + if (parsed?.autoSubmit && parsed?.siteKey) { + setChatbotAutoSubmitSiteKey(parsed.siteKey); + } + } + } catch {} + handleNewClaim(id); clearUrlParams(["newPatient"]); }, [newPatient]); @@ -786,6 +796,7 @@ export default function ClaimsPage() { const closeClaim = () => { setSelectedPatientId(null); setSelectedAppointmentId(null); + setChatbotAutoSubmitSiteKey(undefined); setIsClaimFormOpen(false); clearUrlParams(["newPatient", "appointmentId"]); @@ -904,7 +915,8 @@ export default function ClaimsPage() { { const [m, d, y] = dob.split("/"); return `${y}-${m!.padStart(2,"0")}-${d!.padStart(2,"0")}`; })() + : dob; + setDateOfBirth(parseLocalDate(normalized)); + } + if (ac) pendingAutoCheck.current = ac; sessionStorage.removeItem("chatbot_eligibility"); } catch {} }; diff --git a/apps/SeleniumService/selenium_DentaQuest_eligibilityCheckWorker.py b/apps/SeleniumService/selenium_DentaQuest_eligibilityCheckWorker.py index d463f2ec..510e4410 100644 --- a/apps/SeleniumService/selenium_DentaQuest_eligibilityCheckWorker.py +++ b/apps/SeleniumService/selenium_DentaQuest_eligibilityCheckWorker.py @@ -210,7 +210,23 @@ class AutomationDentaQuestEligibilityCheck: password_field = wait.until(EC.presence_of_element_located((By.XPATH, "//input[@type='password']"))) password_field.clear() password_field.send_keys(self.dentaquest_password) - + + # Check "Remember me" before signing in — mirrors DDMA to avoid OTP on next login + try: + remember_me = WebDriverWait(self.driver, 3).until( + EC.element_to_be_clickable((By.XPATH, + "//label[.//span[contains(text(),'Remember me')] or " + "contains(translate(text(),'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'remember me')] | " + "//input[@type='checkbox' and (" + "contains(translate(@name,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'remember') or " + "contains(translate(@id,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'remember'))]" + )) + ) + remember_me.click() + print("[DentaQuest login] Checked 'Remember me'") + except Exception: + print("[DentaQuest login] No 'Remember me' found on login page (continuing)") + # Click login button login_button = wait.until(EC.element_to_be_clickable((By.XPATH, "//button[@type='submit']"))) login_button.click() diff --git a/apps/SeleniumService/selenium_TuftsSCO_claimSubmitWorker.py b/apps/SeleniumService/selenium_TuftsSCO_claimSubmitWorker.py index b38fe9b3..e02e8697 100644 --- a/apps/SeleniumService/selenium_TuftsSCO_claimSubmitWorker.py +++ b/apps/SeleniumService/selenium_TuftsSCO_claimSubmitWorker.py @@ -187,6 +187,22 @@ class AutomationTuftsSCOClaimSubmit: password_field.send_keys(self.dentaquest_password) print("[TuftsSCO Claim login] Entered password") + # Check "Remember me" before signing in — mirrors DDMA to avoid OTP on next login + try: + remember_me = WebDriverWait(self.driver, 3).until( + EC.element_to_be_clickable((By.XPATH, + "//label[.//span[contains(text(),'Remember me')] or " + "contains(translate(text(),'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'remember me')] | " + "//input[@type='checkbox' and (" + "contains(translate(@name,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'remember') or " + "contains(translate(@id,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'remember'))]" + )) + ) + remember_me.click() + print("[TuftsSCO Claim login] Checked 'Remember me'") + except Exception: + print("[TuftsSCO Claim login] No 'Remember me' found on login page (continuing)") + signin_btn = WebDriverWait(self.driver, 10).until( EC.element_to_be_clickable((By.XPATH, "//button[@type='submit'] | //input[@type='submit'] | "