feat: AI chat scheduling, claim automation, and session improvements
- Internal AI chat: schedule_appointment intent books earliest available slot in Column A using office hours; claim_only intent looks up latest past appointment for service date, asks user when two appointments are within 7 days, auto-triggers correct Selenium worker with mapped prices - Gemini model updated to gemini-flash-latest; conversation history (15 messages) passed for pronoun/reference resolution; history trimmed to start with user turn so Gemini doesn't reject the context - Insurance alias file (insuranceAliases.json) replaces hardcoded siteKey matching; "tufs" now resolves to TUFTS_SCO - DOB format normalized (MM/DD/YYYY → YYYY-MM-DD) before parseLocalDate; autoCheck now fires for all insurance types, not just MH/CMSP - Claim form auto-submit: all handlers (MH, CCA, DDMA, UnitedDH, Tufts) accept formToUse and receive fee-schedule-priced form; prefillDone set after chatbot code prefill so autoSubmit gate opens correctly - Chatbot: chat history persisted in sessionStorage, cleared on logout and auto-logout; Clear button writes fresh state synchronously; message history window increased to 15 - DentaQuest/TuftsSCO Selenium: "Remember me" checkbox clicked before sign-in to persist OTP trust cookie across sessions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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": "<date of birth in MM/DD/YYYY if given>",
|
||||
"insuranceHint": "<insurance name only if explicitly stated in the message, e.g. 'masshealth', 'BCBS MA', 'CCA'>",
|
||||
"procedureNames": ["<raw procedure name>", ...],
|
||||
"appointmentDate": "<YYYY-MM-DD if a specific date is mentioned, omit for today>",
|
||||
"appointmentTime": "<HH:MM 24h if a specific time is mentioned, omit if not stated>",
|
||||
"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<ChatClassification> {
|
||||
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 },
|
||||
]);
|
||||
|
||||
|
||||
@@ -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<string, any>;
|
||||
}
|
||||
|
||||
@@ -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<any[] | null>;
|
||||
getPatientByInsuranceId(id: string): Promise<any | null>;
|
||||
createAppointment(appointment: any): Promise<any>;
|
||||
getAppointmentsByDateForUser(dateStr: string, userId: number): Promise<any[]>;
|
||||
getOfficeHours(userId: number): Promise<any | null>;
|
||||
getAppointmentsByPatientId(patientId: number): Promise<any[]>;
|
||||
}
|
||||
|
||||
// ─── 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<ChatResponse> {
|
||||
// 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<ChatResponse> {
|
||||
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 ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
32
apps/Backend/src/data/insuranceAliases.json
Normal file
32
apps/Backend/src/data/insuranceAliases.json
Normal file
@@ -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" }
|
||||
]
|
||||
@@ -165,7 +165,7 @@ router.post("/internal-chat", async (req: Request, res: Response): Promise<any>
|
||||
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<any>
|
||||
const classification = await classifyInternalChat(
|
||||
message.trim(),
|
||||
aiSettings.apiKey,
|
||||
extraSystemPrompt || undefined
|
||||
extraSystemPrompt || undefined,
|
||||
Array.isArray(history) ? history : []
|
||||
);
|
||||
|
||||
const response = await runInternalChatWorkflow(classification, userId, storage, customAliases);
|
||||
|
||||
@@ -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<Claim>;
|
||||
@@ -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<HTMLDivElement>) => {
|
||||
|
||||
@@ -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<Step>("menu");
|
||||
const [messages, setMessages] = useState<Message[]>(INITIAL_MESSAGES);
|
||||
const [messages, setMessages] = useState<Message[]>(loadSavedMessages);
|
||||
const [pasteInput, setPasteInput] = useState("");
|
||||
const [parseError, setParseError] = useState("");
|
||||
const [eligibilityData, setEligibilityData] = useState<EligibilityData | null>(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<CheckAndClaimData | null>(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<HTMLDivElement>(null);
|
||||
const pasteRef = useRef<HTMLTextAreaElement>(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() {
|
||||
<Bot className="h-4 w-4" />
|
||||
<span className="font-semibold text-sm">Assistant</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="hover:opacity-70 transition-opacity rounded"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="flex items-center gap-1 text-xs hover:opacity-70 transition-opacity rounded px-1.5 py-0.5 border border-white/30 hover:bg-white/10"
|
||||
title="Clear chat history"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Clear
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="hover:opacity-70 transition-opacity rounded"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
@@ -631,6 +715,44 @@ export function ChatbotButton() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Appointment selection */}
|
||||
{step === "need-appointment-selection" && apptSelectionData && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-3 space-y-2">
|
||||
<p className="text-xs font-semibold text-amber-800">Which appointment date?</p>
|
||||
<div className="flex flex-col gap-1.5 pt-1">
|
||||
{apptSelectionData.options.map((opt) => (
|
||||
<button
|
||||
key={opt.serviceDate}
|
||||
className="text-left text-xs px-3 py-2 rounded-lg border border-amber-300 hover:bg-amber-100 transition-colors font-medium"
|
||||
onClick={() => {
|
||||
addMsg("user", opt.label);
|
||||
addMsg("bot", `Using ${opt.label} as the service date.`);
|
||||
sessionStorage.setItem(
|
||||
"chatbot_claim_prefill",
|
||||
JSON.stringify({
|
||||
codes: apptSelectionData.matchedCodes,
|
||||
siteKey: apptSelectionData.siteKey,
|
||||
serviceDate: opt.serviceDate,
|
||||
autoSubmit: true,
|
||||
})
|
||||
);
|
||||
setTimeout(() => {
|
||||
setLocation(`/claims?appointmentId=${opt.appointmentId}`);
|
||||
setOpen(false);
|
||||
resetStep();
|
||||
}, 600);
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" className="h-7 text-xs w-full" onClick={resetStep}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -38,9 +38,8 @@ import { useLicense } from "@/hooks/use-license";
|
||||
|
||||
export default function ClaimsPage() {
|
||||
const [isClaimFormOpen, setIsClaimFormOpen] = useState(false);
|
||||
const [selectedPatientId, setSelectedPatientId] = useState<number | null>(
|
||||
null
|
||||
);
|
||||
const [selectedPatientId, setSelectedPatientId] = useState<number | null>(null);
|
||||
const [chatbotAutoSubmitSiteKey, setChatbotAutoSubmitSiteKey] = useState<string | undefined>(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() {
|
||||
<ClaimForm
|
||||
patientId={selectedPatientId}
|
||||
appointmentId={selectedAppointmentId ?? undefined}
|
||||
autoSubmit={mode === "direct"}
|
||||
autoSubmit={mode === "direct" || !!chatbotAutoSubmitSiteKey}
|
||||
autoSubmitSiteKey={chatbotAutoSubmitSiteKey}
|
||||
proceduresOnly={mode === "procedures"}
|
||||
onClose={closeClaim}
|
||||
onSubmit={handleClaimSubmit}
|
||||
|
||||
@@ -179,8 +179,14 @@ export default function InsuranceStatusPage() {
|
||||
try {
|
||||
const { memberId: id, dob, autoCheck: ac } = JSON.parse(raw);
|
||||
if (id) setMemberId(id);
|
||||
if (dob) setDateOfBirth(parseLocalDate(dob));
|
||||
if (ac === "mh" || ac === "cmsp") pendingAutoCheck.current = ac;
|
||||
if (dob) {
|
||||
// dob may arrive as MM/DD/YYYY or YYYY-MM-DD — normalize to YYYY-MM-DD
|
||||
const normalized = /^\d{1,2}\/\d{1,2}\/\d{4}$/.test(dob)
|
||||
? (() => { 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 {}
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'] | "
|
||||
|
||||
Reference in New Issue
Block a user