/** * Internal chat workflow — deterministic orchestration after LLM classification. * * Steps (no LLM involved here): * 1. Resolve patient from DB (by name or memberId) * 2. Determine insurance siteKey from patient record OR message hint * 3. Map procedure names → CDT codes via keyword lookup (check_and_claim only) * 4. Return a typed ChatResponse the route handler sends to the frontend */ import { ChatClassification } from "./internal-chat-graph"; import { lookupCdtCodes } from "./cdt-lookup"; import insuranceAliases from "../data/insuranceAliases.json"; // Phrases the user may write to mean "attach the uploaded file" — not CDT procedures const ATTACHMENT_PHRASES = /^(with\s+)?(the\s+)?(x[\s-]?ray[s]?|xray[s]?|radiograph[s]?|image[s]?|film[s]?|attachment[s]?|attach|file[s]?|photo[s]?|picture[s]?|scan[s]?|doc(ument)?[s]?)$/i; function stripAttachmentRefs(names: string[]): string[] { return names.filter((n) => !ATTACHMENT_PHRASES.test(n.trim())); } // ─── Types ──────────────────────────────────────────────────────────────────── export interface ResolvedPatient { id: number; firstName: string | null; lastName: string | null; insuranceId: string | null; insuranceProvider: string | null; dateOfBirth: string | null; } export interface CdtResult { code: string | null; description: string; input: string; toothNumber?: string; toothSurface?: string; quad?: string; } export interface ChatResponse { reply: string; action?: | "navigate" | "show_patient" | "check_eligibility_prefill" | "eligibility_id_ready" | "batch_eligibility_ready" | "batch_claim_ready" | "multi_claim_ready" | "batch_check_and_claim_ready" | "check_and_claim_ready" | "need_insurance_clarification" | "appointment_created" | "claim_only_ready" | "preauth_ready" | "need_appointment_selection" | "need_cdt_clarification"; actionData?: Record; } // ─── insuranceProvider → siteKey mapping (mirrors claims.ts batchColumnDeriveSiteKey) ─ export function deriveSiteKey(provider: string): string { const p = (provider ?? "").toLowerCase().trim(); if (!p) return "MH"; for (const { keyword, siteKey } of insuranceAliases) { if (p.includes(keyword.toLowerCase())) return siteKey; } return "MH"; } // Returns true when the user said "united" without a specific qualifier // (e.g. "united sco", "united healthcare") — needs clarification. function isAmbiguousUnited(hint: string | null | undefined): boolean { const h = (hint ?? "").toLowerCase().trim(); return h === "united"; } // siteKey → autoCheck value used by the insurance-status page prefill. // For MH, pass the patient's DOB so under-21 patients route to CMSP. export function siteKeyToAutoCheck(siteKey: string, dob?: string | Date | null): string { switch (siteKey) { case "CCA": return "cca"; case "DDMA": return "ddma"; case "DELTA_INS": return "delta-ins"; case "TUFTS_SCO": return "tufts-sco"; case "UNITED_SCO": return "united-sco"; default: { // MassHealth: patients under 21 use the CMSP button if (dob) { try { const birth = dob instanceof Date ? dob : new Date(dob); const today = new Date(); let age = today.getFullYear() - birth.getFullYear(); const m = today.getMonth() - birth.getMonth(); if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) age--; if (age < 21) return "cmsp"; } catch {} } return "mh"; } } } // ─── Storage interface (duck-typed, matches the real storage object) ────────── interface StorageLike { searchPatients(opts: { filters: any; limit: number; offset: number; }): Promise; getPatientByInsuranceId(id: string): Promise; getPatientByInsuranceIdAndDob(id: string, dob: Date): Promise; createAppointment(appointment: any): Promise; getAppointmentsByDateForUser(dateStr: string, userId: number): Promise; getOfficeHours(userId: number): Promise; getAppointmentsByPatientId(patientId: number): Promise; } // ─── Shared helpers ─────────────────────────────────────────────────────────── function calcAge(dob: string | null | undefined, on?: string | null): number | null { if (!dob) return null; try { const birth = new Date(dob); const ref = on ? new Date(on) : new Date(); let age = ref.getFullYear() - birth.getFullYear(); const m = ref.getMonth() - birth.getMonth(); if (m < 0 || (m === 0 && ref.getDate() < birth.getDate())) age--; return age; } catch { return null; } } function checkD1120Age( matched: CdtResult[], patientName: string, dob: string | null | undefined, serviceDate?: string | null ): ChatResponse | null { if (!matched.some((r) => r.code === "D1120")) return null; const age = calcAge(dob, serviceDate); if (age === null || age < 14) return null; return { reply: `${patientName} is ${age} years old. D1120 (child prophy) is only for patients under 14 — I recommend using D1110 (adult prophy) instead. If you still need D1120, please claim manually for this patient.`, }; } function patientToResult(p: any): ResolvedPatient { return { id: p.id, firstName: p.firstName ?? null, lastName: p.lastName ?? null, insuranceId: p.insuranceId ?? null, insuranceProvider: p.insuranceProvider ?? null, dateOfBirth: p.dateOfBirth ? (p.dateOfBirth instanceof Date ? p.dateOfBirth.toISOString().split("T")[0] : String(p.dateOfBirth).split("T")[0]) : null, }; } /** Look up by memberId, preferring the insuranceId+DOB combo when DOB is available. */ async function findPatientByMemberId( memberId: string, dob: string | null | undefined, storage: StorageLike ): Promise { const dobDate = dob ? new Date(dob) : null; if (dobDate && !isNaN(dobDate.getTime())) { const byCombo = await storage.getPatientByInsuranceIdAndDob(memberId, dobDate); if (byCombo) return byCombo; // DOB provided but no record with that combo → this is a new family member not yet in DB return null; } return storage.getPatientByInsuranceId(memberId); } async function findPatientByName( name: string, storage: StorageLike ): Promise { const patients = await storage.searchPatients({ filters: { OR: [ { firstName: { contains: name, mode: "insensitive" } }, { lastName: { contains: name, mode: "insensitive" } }, { AND: name.split(/\s+/).map((part: string) => ({ OR: [ { firstName: { contains: part, mode: "insensitive" } }, { lastName: { contains: part, mode: "insensitive" } }, ], })), }, ], }, limit: 5, offset: 0, }); return patients?.[0] ?? null; } // ─── Workflow entry point ───────────────────────────────────────────────────── export async function runInternalChatWorkflow( classification: ChatClassification, _userId: number, storage: StorageLike, customAliases: { phrase: string; cdtCode: string }[] = [] ): Promise { const { intent } = classification; // ── Navigation ────────────────────────────────────────────────────────────── if (intent === "navigate_claims") { return { reply: classification.fallbackReply, action: "navigate", actionData: { url: "/claims" }, }; } if (intent === "navigate_schedule") { return { reply: classification.fallbackReply, action: "navigate", actionData: { url: "/appointments" }, }; } if (intent === "navigate_eligibility") { return { reply: classification.fallbackReply ?? "Opening the eligibility page...", action: "navigate", actionData: { url: "/insurance-status" }, }; } // ── Find patient (record only, no eligibility) ────────────────────────────── if (intent === "find_patient") { const name = classification.patientName?.trim(); if (!name) { return { reply: "Please include the patient's name in your message." }; } const raw = await findPatientByName(name, storage); if (!raw) { return { reply: `No patient found matching "${name}". Try a different spelling or search on the Patients page.`, }; } const patient = patientToResult(raw); const ins = patient.insuranceProvider ? ` · ${patient.insuranceProvider}` : ""; const id = patient.insuranceId ? ` (ID: ${patient.insuranceId})` : ""; return { reply: `Found: ${patient.firstName ?? ""} ${patient.lastName ?? ""}${ins}${id}`.trim(), action: "show_patient", actionData: { patient }, }; } // ── Check eligibility by patient name ────────────────────────────────────── if (intent === "check_eligibility") { const name = classification.patientName?.trim(); if (!name) { return { reply: "Please include the patient's name in your message." }; } const raw = await findPatientByName(name, storage); if (!raw) { return { reply: `No patient found matching "${name}". Try a different spelling or search on the Patients page.`, }; } const patient = patientToResult(raw); const fullName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim(); if (!patient.insuranceId) { return { reply: `Found ${fullName} but no Member ID is on file. Please add their insurance info first.`, action: "show_patient", actionData: { patient }, }; } // If patient has DOB + known insurance, route through eligibility_id_ready so the // correct provider button (DDMA, CCA, etc.) auto-triggers instead of always MH. const resolvedDob = patient.dateOfBirth ?? null; const siteKey = resolveSiteKey( patient.insuranceProvider ?? null, classification.insuranceHint ?? null ); if (resolvedDob && siteKey) { return { reply: `Found ${fullName}. Ready to check eligibility.`, action: "eligibility_id_ready", actionData: { patient, memberId: patient.insuranceId, dob: resolvedDob, siteKey, autoCheck: siteKeyToAutoCheck(siteKey, resolvedDob), }, }; } return { reply: `Found ${fullName}. Ready to check eligibility.`, action: "check_eligibility_prefill", actionData: { patient }, }; } // ── Eligibility by explicit member ID + DOB ──────────────────────────────── if (intent === "eligibility_by_id") { return await handleEligibilityById(classification, storage); } // ── Batch eligibility (multiple patients) ───────────────────────────────── if (intent === "batch_eligibility") { return await handleBatchEligibility(classification, storage); } if (intent === "batch_eligibility_by_name") { return await handleBatchEligibilityByName(classification, storage); } // ── Check eligibility + claim procedures ────────────────────────────────── if (intent === "check_and_claim") { return await handleCheckAndClaim(classification, storage, customAliases); } // ── Batch check & claim (eligibility + claim for multiple patients) ──────── if (intent === "batch_check_and_claim") { return await handleBatchCheckAndClaim(classification, storage, customAliases); } // ── Batch claim (same procedures for multiple patients) ─────────────────── if (intent === "batch_claim") { return await handleBatchClaim(classification, storage, customAliases); } // ── Multi claim (different procedures for different patients) ───────────── if (intent === "multi_claim") { return await handleMultiClaim(classification, storage, customAliases); } // ── Claim only (no eligibility check) ───────────────────────────────────── if (intent === "claim_only") { return await handleClaimOnly(classification, storage, customAliases); } // ── Pre-authorization ────────────────────────────────────────────────────── if (intent === "preauth") { return await handlePreauth(classification, storage, customAliases); } // ── Schedule appointment ─────────────────────────────────────────────────── if (intent === "schedule_appointment") { return await handleScheduleAppointment(classification, _userId, storage); } // ── General ──────────────────────────────────────────────────────────────── return { reply: classification.fallbackReply }; } // ─── eligibility_by_id ──────────────────────────────────────────────────────── async function handleEligibilityById( c: ChatClassification, storage: StorageLike ): Promise { const memberId = c.memberId?.trim(); const dob = c.dob?.trim(); if (!memberId) { return { reply: "Please provide a Member ID to run the eligibility check.", }; } // User explicitly chose "Other United" — it's not in the app if ((c.insuranceHint ?? "").toLowerCase().includes("other united")) { return { reply: "That United plan is not currently supported in the app. Please check it manually.", }; } // Try to resolve existing patient for name display + insurance (use DOB to pick the right family member) const existingPatient = await findPatientByMemberId(memberId, dob, storage); const patient: ResolvedPatient | null = existingPatient ? 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.", }; } // "united" alone is ambiguous — there's United SCO (Dental Hub) in the app // and other United plans that are not supported. if (isAmbiguousUnited(c.insuranceHint) && !patient?.insuranceProvider) { const label = patient ? `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim() : `Member ID ${memberId}`; return { reply: `Which United plan is ${label}?`, action: "need_insurance_clarification", actionData: { memberId, dob: resolvedDob, patient, options: ["United SCO / Dental Hub", "Other United (not in app)"], }, }; } // Determine siteKey const siteKey = resolveSiteKey( patient?.insuranceProvider ?? null, c.insuranceHint ?? null ); if (!siteKey) { const name = patient ? `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim() : `Member ID ${memberId}`; return { reply: `Found ${name} but couldn't determine the insurance type. Which insurance should I use?`, action: "need_insurance_clarification", actionData: { memberId, dob: resolvedDob, patient, options: ["MassHealth", "BCBS MA", "CCA", "Tufts SCO", "Delta Dental MA", "United SCO / Dental Hub"], }, }; } const label = patient ? `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim() : `Member ID ${memberId}`; return { reply: `Ready to check eligibility for ${label}.`, action: "eligibility_id_ready", actionData: { patient, memberId, dob: resolvedDob, siteKey, autoCheck: siteKeyToAutoCheck(siteKey), appointmentDate: c.appointmentDate ?? null, }, }; } // ─── batch_eligibility ─────────────────────────────────────────────────────── async function handleBatchEligibility( c: ChatClassification, storage: StorageLike ): Promise { const pairs = c.patients ?? []; if (pairs.length < 2) { // Fallback to single if somehow only 1 pair if (pairs.length === 1) { return await handleEligibilityById( { ...c, memberId: pairs[0]!.memberId, dob: pairs[0]!.dob, intent: "eligibility_by_id" }, storage ); } return { reply: "Please provide at least two member ID + DOB pairs to batch-check." }; } const resolved: { memberId: string; dob: string; siteKey: string; autoCheck: string; patient: ResolvedPatient | null; }[] = []; for (const { memberId, dob } of pairs) { const id = memberId?.trim(); const d = dob?.trim(); if (!id || !d) continue; const existing = await findPatientByMemberId(id, d, storage); const patient: ResolvedPatient | null = existing ? patientToResult(existing) : null; const resolvedDob = d ?? patient?.dateOfBirth ?? null; if (!resolvedDob) continue; const siteKey = resolveSiteKey( patient?.insuranceProvider ?? null, c.insuranceHint ?? null ) ?? "MH"; resolved.push({ memberId: id, dob: resolvedDob, siteKey, autoCheck: siteKeyToAutoCheck(siteKey, resolvedDob), patient, }); } if (resolved.length === 0) { return { reply: "Could not parse any valid member ID + DOB pairs." }; } const labels = resolved.map((r) => { const name = r.patient ? `${r.patient.firstName ?? ""} ${r.patient.lastName ?? ""}`.trim() : `ID ${r.memberId}`; return name; }); return { reply: `Ready to check eligibility for ${resolved.length} patients: ${labels.join(", ")}.`, action: "batch_eligibility_ready", actionData: { queue: resolved }, }; } // ─── batch_eligibility_by_name ─────────────────────────────────────────────── async function handleBatchEligibilityByName( c: ChatClassification, storage: StorageLike ): Promise { const names = c.patientNames ?? []; if (names.length < 2) { return { reply: "Please include at least two patient names to batch-check eligibility." }; } const resolved: { memberId: string; dob: string; siteKey: string; autoCheck: string; patient: ResolvedPatient | null; }[] = []; const notFound: string[] = []; const noInsurance: string[] = []; for (const name of names) { const trimmed = name.trim(); if (!trimmed) continue; const raw = await findPatientByName(trimmed, storage); if (!raw) { notFound.push(trimmed); continue; } const patient = patientToResult(raw); const fullName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim(); if (!patient.insuranceId) { noInsurance.push(fullName); continue; } const resolvedDob = patient.dateOfBirth ?? null; if (!resolvedDob) { noInsurance.push(fullName); continue; } const siteKey = resolveSiteKey( patient.insuranceProvider ?? null, c.insuranceHint ?? null ) ?? "MH"; resolved.push({ memberId: patient.insuranceId, dob: resolvedDob, siteKey, autoCheck: siteKeyToAutoCheck(siteKey, resolvedDob), patient, }); } if (resolved.length === 0) { if (notFound.length > 0) { return { reply: `Could not find any patients matching: ${notFound.join(", ")}. Please check the spelling.` }; } if (noInsurance.length > 0) { return { reply: `Found ${noInsurance.join(", ")} but they have no Member ID or DOB on file. Please add their insurance info first.` }; } return { reply: "Could not resolve any patients for eligibility check." }; } const labels = resolved.map((r) => { const name = r.patient ? `${r.patient.firstName ?? ""} ${r.patient.lastName ?? ""}`.trim() : `ID ${r.memberId}`; return name; }); let reply = `Ready to check eligibility for ${resolved.length} patients: ${labels.join(", ")}.`; if (notFound.length > 0) { reply += ` Could not find: ${notFound.join(", ")}.`; } if (noInsurance.length > 0) { reply += ` Missing insurance info: ${noInsurance.join(", ")}.`; } return { reply, action: "batch_eligibility_ready", actionData: { queue: resolved }, }; } // ─── batch_check_and_claim ──────────────────────────────────────────────────── async function handleBatchCheckAndClaim( c: ChatClassification, storage: StorageLike, customAliases: { phrase: string; cdtCode: string }[] ): Promise { const pairs = c.patients ?? []; if (pairs.length < 2) { if (pairs.length === 1) { return await handleCheckAndClaim( { ...c, memberId: pairs[0]!.memberId, dob: pairs[0]!.dob, intent: "check_and_claim" }, storage, customAliases ); } return { reply: "Please provide at least two member ID + DOB pairs." }; } const procedureNames = stripAttachmentRefs(c.procedureNames ?? []); if (procedureNames.length === 0) { return { reply: "Please specify which procedures to claim (e.g. perio exam, adult prophy)." }; } const cdtResults: CdtResult[] = lookupCdtCodes(procedureNames, customAliases); const matched = cdtResults.filter((r) => r.code !== null); const unmatched = cdtResults.filter((r) => r.code === null); if (unmatched.length > 0) { const phrases = unmatched.map((r) => r.input); return { reply: `I don't recognize ${phrases.map((p) => `"${p}"`).join(", ")}. What CDT code${phrases.length > 1 ? "s are" : " is"} ${phrases.length > 1 ? "they" : "it"}? (e.g. D0272)`, action: "need_cdt_clarification", actionData: { unknownPhrases: phrases, matchedSoFar: matched.map((r) => ({ code: r.code!, description: r.description })) }, }; } const resolved: { memberId: string; dob: string; siteKey: string; autoCheck: string; patient: ResolvedPatient | null; }[] = []; for (const { memberId, dob } of pairs) { const id = memberId?.trim(); const d = dob?.trim(); if (!id || !d) continue; const existing = await findPatientByMemberId(id, d, storage); const patient: ResolvedPatient | null = existing ? patientToResult(existing) : null; const resolvedDob = d ?? patient?.dateOfBirth ?? null; if (!resolvedDob) continue; const siteKey = resolveSiteKey( patient?.insuranceProvider ?? null, c.insuranceHint ?? null ) ?? "MH"; resolved.push({ memberId: id, dob: resolvedDob, siteKey, autoCheck: siteKeyToAutoCheck(siteKey, resolvedDob), patient, }); } if (resolved.length === 0) { return { reply: "Could not parse any valid member ID + DOB pairs." }; } const labels = resolved.map((r) => { const name = r.patient ? `${r.patient.firstName ?? ""} ${r.patient.lastName ?? ""}`.trim() : `ID ${r.memberId}`; return name; }); const codeList = matched.map((r) => `${r.code} (${r.description})`).join(", "); return { reply: `Ready to check eligibility and claim ${codeList} for ${resolved.length} patients: ${labels.join(", ")}.`, action: "batch_check_and_claim_ready", actionData: { queue: resolved, matchedCodes: matched.map((r) => ({ code: r.code!, description: r.description, toothNumber: r.toothNumber, toothSurface: r.toothSurface, quad: r.quad })), renderingProvider: c.renderingProvider ?? null, }, }; } // ─── batch_claim ───────────────────────────────────────────────────────────── async function handleBatchClaim( c: ChatClassification, storage: StorageLike, customAliases: { phrase: string; cdtCode: string }[] ): Promise { const names = c.patientNames ?? []; if (names.length < 2) { if (names.length === 1) { return await handleClaimOnly( { ...c, patientName: names[0], intent: "claim_only" }, storage, customAliases ); } return { reply: "Please include at least two patient names to batch-claim." }; } const procedureNames = stripAttachmentRefs(c.procedureNames ?? []); if (procedureNames.length === 0) { return { reply: "Please specify which procedures to claim (e.g. perio exam, adult prophy)." }; } const cdtResults: CdtResult[] = lookupCdtCodes(procedureNames, customAliases); const matched = cdtResults.filter((r) => r.code !== null); const unmatched = cdtResults.filter((r) => r.code === null); if (unmatched.length > 0) { const phrases = unmatched.map((r) => r.input); return { reply: `I don't recognize ${phrases.map((p) => `"${p}"`).join(", ")}. What CDT code${phrases.length > 1 ? "s are" : " is"} ${phrases.length > 1 ? "they" : "it"}? (e.g. D0272)`, action: "need_cdt_clarification", actionData: { unknownPhrases: phrases, matchedSoFar: matched.map((r) => ({ code: r.code!, description: r.description })) }, }; } const resolved: { patient: ResolvedPatient; siteKey: string; serviceDate: string | null; appointmentId: number | null; }[] = []; const notFound: string[] = []; for (const name of names) { const trimmed = name.trim(); if (!trimmed) continue; const raw = await findPatientByName(trimmed, storage); if (!raw) { notFound.push(trimmed); continue; } const patient = patientToResult(raw); const fullName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim(); const d1120Warning = checkD1120Age(matched, fullName, patient.dateOfBirth, c.appointmentDate); if (d1120Warning) return d1120Warning; const siteKey = resolveSiteKey(patient.insuranceProvider ?? null, c.insuranceHint ?? null) ?? "MH"; let serviceDate: string | null = c.appointmentDate ?? null; let appointmentId: number | null = null; if (!serviceDate) { const now = new Date(); serviceDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; } resolved.push({ patient, siteKey, serviceDate, appointmentId }); } if (notFound.length > 0 && resolved.length === 0) { return { reply: `Could not find any patients matching: ${notFound.join(", ")}. Please check the spelling.` }; } const labels = resolved.map((r) => `${r.patient.firstName ?? ""} ${r.patient.lastName ?? ""}`.trim() ); const codeList = matched.map((r) => `${r.code} (${r.description})`).join(", "); let reply = `Ready to claim ${codeList} for ${resolved.length} patients: ${labels.join(", ")}.`; if (notFound.length > 0) { reply += ` Could not find: ${notFound.join(", ")}.`; } return { reply, action: "batch_claim_ready", actionData: { queue: resolved.map((r) => ({ patient: r.patient, siteKey: r.siteKey, serviceDate: r.serviceDate, appointmentId: r.appointmentId, })), matchedCodes: matched.map((r) => ({ code: r.code!, description: r.description, toothNumber: r.toothNumber, toothSurface: r.toothSurface, quad: r.quad })), renderingProvider: c.renderingProvider ?? null, }, }; } // ─── multi_claim (different procedures per patient) ───────────────────────── async function handleMultiClaim( c: ChatClassification, storage: StorageLike, customAliases: { phrase: string; cdtCode: string }[] ): Promise { const groups = c.claimGroups ?? []; if (groups.length < 2) { if (groups.length === 1) { return await handleClaimOnly( { ...c, patientName: groups[0]!.patientName, procedureNames: groups[0]!.procedureNames, intent: "claim_only" }, storage, customAliases ); } return { reply: "Please specify at least two patients with their procedures." }; } const queue: { patient: ResolvedPatient; siteKey: string; serviceDate: string | null; appointmentId: number | null; matchedCodes: { code: string; description: string; toothNumber?: string; toothSurface?: string; quad?: string }[]; }[] = []; const notFound: string[] = []; for (const group of groups) { const name = group.patientName?.trim(); if (!name) continue; const procedureNames = stripAttachmentRefs(group.procedureNames ?? []); if (procedureNames.length === 0) continue; const cdtResults: CdtResult[] = lookupCdtCodes(procedureNames, customAliases); const matched = cdtResults.filter((r) => r.code !== null); const unmatched = cdtResults.filter((r) => r.code === null); if (unmatched.length > 0) { const phrases = unmatched.map((r) => r.input); return { reply: `I don't recognize ${phrases.map((p) => `"${p}"`).join(", ")} for ${name}. What CDT code${phrases.length > 1 ? "s are" : " is"} ${phrases.length > 1 ? "they" : "it"}? (e.g. D0272)`, action: "need_cdt_clarification", actionData: { unknownPhrases: phrases, matchedSoFar: matched.map((r) => ({ code: r.code!, description: r.description })) }, }; } const raw = await findPatientByName(name, storage); if (!raw) { notFound.push(name); continue; } const patient = patientToResult(raw); const fullName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim(); const d1120Warning = checkD1120Age(matched, fullName, patient.dateOfBirth, c.appointmentDate); if (d1120Warning) return d1120Warning; const siteKey = resolveSiteKey(patient.insuranceProvider ?? null, c.insuranceHint ?? null) ?? "MH"; let serviceDate: string | null = c.appointmentDate ?? null; if (!serviceDate) { const now = new Date(); serviceDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; } queue.push({ patient, siteKey, serviceDate, appointmentId: null, matchedCodes: matched.map((r) => ({ code: r.code!, description: r.description, toothNumber: r.toothNumber, toothSurface: r.toothSurface, quad: r.quad })), }); } if (notFound.length > 0 && queue.length === 0) { return { reply: `Could not find any patients matching: ${notFound.join(", ")}. Please check the spelling.` }; } const labels = queue.map((r) => { const name = `${r.patient.firstName ?? ""} ${r.patient.lastName ?? ""}`.trim(); const codes = r.matchedCodes.map((c) => `${c.code}`).join(", "); return `${name} (${codes})`; }); let reply = `Ready to claim for ${queue.length} patients: ${labels.join("; ")}.`; if (notFound.length > 0) { reply += ` Could not find: ${notFound.join(", ")}.`; } return { reply, action: "multi_claim_ready", actionData: { queue: queue.map((r) => ({ patient: r.patient, siteKey: r.siteKey, serviceDate: r.serviceDate, appointmentId: r.appointmentId, matchedCodes: r.matchedCodes, })), renderingProvider: c.renderingProvider ?? null, }, }; } // ─── check_and_claim ───────────────────────────────────────────────────────── async function handleCheckAndClaim( c: ChatClassification, storage: StorageLike, customAliases: { phrase: string; cdtCode: string }[] = [] ): Promise { // User explicitly chose "Other United" — it's not in the app if ((c.insuranceHint ?? "").toLowerCase().includes("other united")) { return { reply: "That United plan is not currently supported in the app. Please check it manually.", }; } // 1. Resolve patient let patient: ResolvedPatient | null = null; let memberId = c.memberId?.trim() ?? null; const dob = c.dob?.trim() ?? null; if (memberId) { const existing = await findPatientByMemberId(memberId, dob, storage); if (existing) patient = patientToResult(existing); } else if (c.patientName?.trim()) { const raw = await findPatientByName(c.patientName.trim(), storage); if (raw) { patient = patientToResult(raw); memberId = patient.insuranceId; } } if (!memberId) { return { reply: "Please include either a Member ID or a patient name so I can look up their record.", }; } // 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 couldn't find a Date of Birth on file. Please provide it (MM/DD/YYYY).`, }; } // "united" alone is ambiguous — clarify before proceeding if (isAmbiguousUnited(c.insuranceHint) && !patient?.insuranceProvider) { const label = patient ? `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim() : `Member ID ${memberId}`; return { reply: `Which United plan is ${label}?`, action: "need_insurance_clarification", actionData: { memberId, dob: resolvedDob, patient, procedureNames: c.procedureNames ?? [], options: ["United SCO / Dental Hub", "Other United (not in app)"], }, }; } // 2. Determine siteKey const siteKey = resolveSiteKey( patient?.insuranceProvider ?? null, c.insuranceHint ?? null ); if (!siteKey) { const label = patient ? `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim() : `Member ID ${memberId}`; return { reply: `Found ${label} but couldn't determine the insurance type. Which insurance should I use?`, action: "need_insurance_clarification", actionData: { memberId, dob, patient, procedureNames: c.procedureNames ?? [], options: ["MassHealth", "BCBS MA", "CCA", "Tufts SCO", "Delta Dental MA", "United SCO / Dental Hub"], }, }; } // 3. Map procedure names → CDT codes (custom aliases take priority) const procedureNames = stripAttachmentRefs(c.procedureNames ?? []); const cdtResults: CdtResult[] = procedureNames.length > 0 ? lookupCdtCodes(procedureNames, customAliases) : []; const matched = cdtResults.filter((r) => r.code !== null); const unmatched = cdtResults.filter((r) => r.code === null); // Block if any term couldn't be mapped — ask instead of proceeding if (unmatched.length > 0) { const phrases = unmatched.map((r) => r.input); return { reply: `I don't recognize ${phrases.map((p) => `"${p}"`).join(", ")}. What CDT code${phrases.length > 1 ? "s are" : " is"} ${phrases.length > 1 ? "they" : "it"}? (e.g. D0272)`, action: "need_cdt_clarification", actionData: { unknownPhrases: phrases, matchedSoFar: matched.map((r) => ({ code: r.code!, description: r.description })) }, }; } const label = patient ? `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim() : `Member ID ${memberId}`; // D1120 age check — recommend D1110 for patients 14+ const d1120Warning = checkD1120Age(matched, label, patient?.dateOfBirth ?? resolvedDob, c.appointmentDate); if (d1120Warning) return d1120Warning; const reply = `Ready to check eligibility for ${label} and claim: ${ matched.map((r) => `${r.code} (${r.description})`).join(", ") || "no procedures mapped" }.`; return { reply, action: "check_and_claim_ready", actionData: { patient, memberId, dob: resolvedDob, siteKey, autoCheck: siteKeyToAutoCheck(siteKey), cdtResults, matchedCodes: matched.map((r) => ({ code: r.code!, description: r.description, toothNumber: r.toothNumber, toothSurface: r.toothSurface, quad: r.quad })), serviceDate: c.appointmentDate ?? null, renderingProvider: c.renderingProvider ?? null, }, }; } // ─── claim_only ─────────────────────────────────────────────────────────────── async function handleClaimOnly( c: ChatClassification, storage: StorageLike, customAliases: { phrase: string; cdtCode: string }[] ): Promise { // Resolve patient — use insuranceId+DOB when DOB is available to distinguish family members let patient: ResolvedPatient | null = null; if (c.memberId?.trim()) { const existing = await findPatientByMemberId(c.memberId.trim(), c.dob, storage); 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 = stripAttachmentRefs(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); // Block if any term couldn't be mapped — ask instead of proceeding if (unmatched.length > 0) { const phrases = unmatched.map((r) => r.input); return { reply: `I don't recognize ${phrases.map((p) => `"${p}"`).join(", ")}. What CDT code${phrases.length > 1 ? "s are" : " is"} ${phrases.length > 1 ? "they" : "it"}? (e.g. D0272)`, action: "need_cdt_clarification", actionData: { unknownPhrases: phrases, matchedSoFar: matched.map((r) => ({ code: r.code!, description: r.description })) }, }; } // D1120 age check — recommend D1110 for patients 14+ const d1120Warning = checkD1120Age(matched, fullName, patient.dateOfBirth, c.appointmentDate); if (d1120Warning) return d1120Warning; // 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 now = new Date(); const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; 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 > 0) { const rawDate = new Date(sorted[0].date); const latestStr = `${rawDate.getUTCFullYear()}-${String(rawDate.getUTCMonth() + 1).padStart(2, "0")}-${String(rawDate.getUTCDate()).padStart(2, "0")}`; if (latestStr === todayStr) { serviceDate = todayStr; appointmentId = sorted[0].id ?? null; } else { 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 todayLabel = `${String(now.getMonth() + 1).padStart(2, "0")}/${String(now.getDate()).padStart(2, "0")}/${now.getFullYear()}`; return { reply: `Which service date for ${fullName}?`, 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, toothNumber: r.toothNumber, toothSurface: r.toothSurface, quad: r.quad })), options: [ { label: fmtUTC(sorted[0]), appointmentId: sorted[0].id, serviceDate: latestStr }, { label: `${todayLabel} (Today)`, appointmentId: null, serviceDate: todayStr }, ], }, }; } } else { serviceDate = todayStr; } } 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, toothNumber: r.toothNumber, toothSurface: r.toothSurface, quad: r.quad })), renderingProvider: c.renderingProvider ?? null, }, }; } // ─── preauth ────────────────────────────────────────────────────────────────── async function handlePreauth( c: ChatClassification, storage: StorageLike, customAliases: { phrase: string; cdtCode: string }[] ): Promise { let patient: ResolvedPatient | null = null; if (c.memberId?.trim()) { const existing = await findPatientByMemberId(c.memberId.trim(), c.dob, storage); 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 procedureNames = stripAttachmentRefs(c.procedureNames ?? []); if (procedureNames.length === 0) { return { reply: "Please specify which procedures to pre-authorize (e.g. rct, post, crown)." }; } const cdtResults = lookupCdtCodes(procedureNames, customAliases); const matched = cdtResults.filter((r) => r.code !== null); const unmatched = cdtResults.filter((r) => r.code === null); if (unmatched.length > 0) { const phrases = unmatched.map((r) => r.input); return { reply: `I don't recognize ${phrases.map((p) => `"${p}"`).join(", ")}. What CDT code${phrases.length > 1 ? "s are" : " is"} ${phrases.length > 1 ? "they" : "it"}? (e.g. D3320)`, action: "need_cdt_clarification", actionData: { unknownPhrases: phrases, matchedSoFar: matched.map((r) => ({ code: r.code!, description: r.description })) }, }; } // Use explicit date from message; otherwise today+3 (pre-auth is for a future appointment) let serviceDate: string = c.appointmentDate ?? (() => { const d = new Date(); d.setDate(d.getDate() + 3); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; })(); const siteKey = resolveSiteKey(patient.insuranceProvider ?? null, c.insuranceHint ?? null) ?? "MH"; const fullName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim(); const [sy, sm, sd] = serviceDate.split("-"); const dateLabel = `${sm}/${sd}/${sy}`; return { reply: `Opening pre-auth for ${fullName} (tentative date ${dateLabel}): ${matched.map((r) => `${r.code} (${r.description})`).join(", ")}.`, action: "preauth_ready", actionData: { patient, siteKey, serviceDate, matchedCodes: matched.map((r) => ({ code: r.code!, description: r.description, toothNumber: r.toothNumber, toothSurface: r.toothSurface, quad: r.quad })), renderingProvider: c.renderingProvider ?? null, }, }; } // ─── schedule_appointment ───────────────────────────────────────────────────── const DEFAULT_STAFF_ID = 1; // Column A const SLOT_DURATION = 15; // 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 }, }; } // ─── Standalone helper: create today's appointment for a known patientId ───── export async function createAppointmentToday( patientId: number, userId: number, storage: StorageLike, targetDate?: string // YYYY-MM-DD; defaults to today ): Promise<{ startTime: string; endTime: string; dateStr: string; dateLabel: string; column: string } | { error: string }> { const base = targetDate ? new Date(targetDate + "T00:00:00") : new Date(); const dateStr = `${base.getFullYear()}-${String(base.getMonth() + 1).padStart(2, "0")}-${String(base.getDate()).padStart(2, "0")}`; const dateLabel = base.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); const localDate = new Date(base.getFullYear(), base.getMonth(), base.getDate()); const dayNames = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"]; const dayName = dayNames[base.getDay()]!; const officeHours = await storage.getOfficeHours(userId); const dayHours = officeHours?.data?.doctors?.[dayName] ?? { amStart: "09:00", amEnd: "12:00", pmStart: "13:00", pmEnd: "17:00", enabled: true, }; if (!dayHours.enabled) { return { error: `The office is closed on ${dayName} (${dateLabel}). Cannot create appointment.` }; } const allSlots = buildSlots(dayHours); const allAppointments = await storage.getAppointmentsByDateForUser(dateStr, userId); for (const { staffId, label } of [ { staffId: DEFAULT_STAFF_ID, label: "Column A" }, { staffId: 2, label: "Column B" }, ]) { const booked = allAppointments .filter((a: any) => a.staffId === staffId) .map((a: any) => ({ start: toMinutes(a.startTime), end: toMinutes(a.endTime) })); const availableStart = allSlots.find((slotStart) => { const slotEnd = slotStart + SLOT_DURATION; return !booked.some((b) => slotStart < b.end && slotEnd > b.start); }); if (availableStart !== undefined) { const startTime = fromMinutes(availableStart); const endTime = fromMinutes(availableStart + SLOT_DURATION); await storage.createAppointment({ patientId, userId, staffId, title: dateLabel, date: localDate, startTime, endTime, type: "recall", status: "scheduled", movedByAi: true, }); return { startTime, endTime, dateStr, dateLabel, column: label }; } } return { error: `Both Column A and Column B are fully booked today. Please add the appointment manually.` }; } // ─── Insurance resolution helper ────────────────────────────────────────────── /** * Determine siteKey from: * 1. Patient's stored insuranceProvider (most authoritative) * 2. Insurance hint from the chat message * 3. null → caller must ask for clarification */ function resolveSiteKey( storedProvider: string | null, hint: string | null ): string | null { if (storedProvider) return deriveSiteKey(storedProvider); if (hint) return deriveSiteKey(hint); return null; }