- Add multi_claim intent so AI correctly handles "claim X for patient A and Y for patient B" instead of applying all procedures to all patients (batch_claim) - Each patient carries their own matchedCodes in the queue - Fix batch claim PDF race condition: chatbot queue no longer advances in closeClaim(), instead advances after selenium PDF is downloaded (matching column claim behavior) - Align United SCO eligibility worker with claim worker: only fill subscriber ID + DOB, use treatmentLocation by ID instead of arrow-wrapper click Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1469 lines
52 KiB
TypeScript
1469 lines
52 KiB
TypeScript
/**
|
||
* 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<string, any>;
|
||
}
|
||
|
||
// ─── 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<any[] | null>;
|
||
getPatientByInsuranceId(id: string): Promise<any | null>;
|
||
getPatientByInsuranceIdAndDob(id: string, dob: Date): 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 ───────────────────────────────────────────────────────────
|
||
|
||
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<any | null> {
|
||
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<any | null> {
|
||
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<ChatResponse> {
|
||
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<ChatResponse> {
|
||
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<ChatResponse> {
|
||
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<ChatResponse> {
|
||
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<ChatResponse> {
|
||
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<ChatResponse> {
|
||
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<ChatResponse> {
|
||
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<ChatResponse> {
|
||
// 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<ChatResponse> {
|
||
// 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<ChatResponse> {
|
||
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<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 },
|
||
};
|
||
}
|
||
|
||
// ─── 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;
|
||
}
|