feat: batch eligibility, batch claim, and batch check+claim from AI chat

- Add batch_eligibility, batch_claim, and batch_check_and_claim intents
  to AI classifier so multiple patients can be processed one by one
- Add queue processing on insurance-status and claims pages to auto-start
  the next patient after each check/claim completes
- Make patient schema firstName, lastName, phone optional so patients can
  be created with just member ID + DOB from eligibility checks
- Cancel buttons now preserve chat history instead of clearing it
- Patient-found card shows Check Eligibility, Eligibility & Appointment
  Today, and Cancel buttons
- Claim service date asks user to pick between latest appointment and
  today when they differ
- Login page subtitle styled with animated gradient

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ff
2026-06-18 23:41:56 -04:00
parent a2e5c157ad
commit a52ff2d723
9 changed files with 710 additions and 66 deletions

View File

@@ -5,6 +5,9 @@ import { getLlm, type AiProvider } from "./llm-factory";
export type InternalChatIntent =
| "check_eligibility" // by patient name → look up in DB
| "eligibility_by_id" // by explicit memberId + dob (no name)
| "batch_eligibility" // multiple patients by memberId + dob
| "batch_claim" // claim same procedures for multiple patients by name
| "batch_check_and_claim" // eligibility + claim for multiple patients by memberId+dob
| "check_and_claim" // eligibility + claim procedures
| "find_patient" // look up patient record only
| "schedule_appointment" // add patient to today's (or specified) schedule
@@ -21,6 +24,10 @@ export interface ChatClassification {
patientName?: string; // for check_eligibility / find_patient / schedule_appointment
memberId?: string; // for eligibility_by_id / check_and_claim
dob?: string; // for eligibility_by_id / check_and_claim (MM/DD/YYYY)
// --- batch eligibility (multiple patients) ---
patients?: { memberId: string; dob: string }[]; // for batch_eligibility
// --- batch claim (same procedures for multiple patients by name) ---
patientNames?: string[]; // for batch_claim
// --- insurance hint (only if explicitly stated in the message) ---
insuranceHint?: string; // raw text, e.g. "masshealth", "BCBS", "CCA"
// --- rendering/treating provider (only if explicitly stated, e.g. "with provider Kai Gao") ---
@@ -48,6 +55,8 @@ Respond ONLY with valid JSON (no markdown fences):
"patientName": "<full name if mentioned by name>",
"memberId": "<member/insurance ID if given explicitly or found in history>",
"dob": "<date of birth in MM/DD/YYYY if given explicitly or found in history>",
"patients": [{"memberId": "<id>", "dob": "<MM/DD/YYYY>"}, ...],
"patientNames": ["<name1>", "<name2>", ...],
"insuranceHint": "<insurance name only if explicitly stated in the message, e.g. 'masshealth', 'BCBS MA', 'CCA'>",
"renderingProvider": "<provider/doctor name only if explicitly stated, e.g. 'Kai Gao', 'Dr. Smith' — omit if not mentioned>",
"procedureNames": ["<raw procedure name>", ...],
@@ -61,12 +70,27 @@ Omit any field that is not present in the message or history.
Intents:
- check_eligibility : user wants to check insurance for a patient identified by NAME only
e.g. "check Maria Jesus", "verify insurance for John Smith"
- eligibility_by_id : user provides a member ID and date of birth (no patient name)
- eligibility_by_id : user provides a SINGLE member ID and date of birth (no patient name)
e.g. "check masshealth for 100xxxx, 10/10/1988"
ALSO use this when user wants to check eligibility AND schedule/add an appointment on a date
e.g. "check mh for 100xxxx, 10/10/1988 and schedule on 4/10/2026"
e.g. "check mh for 100xxxx, 10/10/1988 and make appointment on 5/1/2026"
In these cases set appointmentDate to the mentioned date (YYYY-MM-DD)
- batch_eligibility : user provides MULTIPLE member IDs with dates of birth in one message
e.g. "check mh for 100xxxx 10/10/1988 and 200xxxx 5/5/2000"
e.g. "check 100xxxx, 10/10/1988 and 200xxxx, 5/5/2000"
Use this ONLY when TWO OR MORE distinct memberId+dob pairs are given.
Put each pair into the "patients" array. Also set insuranceHint if stated.
- batch_claim : user wants to claim the SAME procedures for MULTIPLE patients identified by NAME
e.g. "claim perio exam and adult prophy for Jackaline and Keioson"
e.g. "perio exam, adult cleaning for Maria and John"
Use this ONLY when procedures AND two or more patient names are given.
Put each patient name into the "patientNames" array. Put procedure names in "procedureNames".
- batch_check_and_claim : user provides MULTIPLE member IDs with DOBs AND wants to claim PROCEDURES for all of them
e.g. "check mh for 100xxxx 10/10/1988 and 200xxxx 5/5/2000, and claim perio exam and adult prophy"
e.g. "check 100xxxx, 10/10/1988 and 200xxxx, 5/5/2000 and claim D0120 D1110 for them"
Use this when TWO OR MORE memberId+dob pairs are given WITH procedures.
Put each pair into "patients" array. Put procedure names in "procedureNames".
- check_and_claim : user wants to check eligibility AND submit PROCEDURES/BILLING as claims
e.g. "check masshealth for 100xxxx, 10/10/1988 and claim perio exam and adult cleaning"
e.g. "check Maria Jesus and claim D0120 D1110"

View File

@@ -45,6 +45,9 @@ export interface ChatResponse {
| "show_patient"
| "check_eligibility_prefill"
| "eligibility_id_ready"
| "batch_eligibility_ready"
| "batch_claim_ready"
| "batch_check_and_claim_ready"
| "check_and_claim_ready"
| "need_insurance_clarification"
| "appointment_created"
@@ -314,12 +317,30 @@ export async function runInternalChatWorkflow(
return await handleEligibilityById(classification, storage);
}
// ── Batch eligibility (multiple patients) ─────────────────────────────────
if (intent === "batch_eligibility") {
return await handleBatchEligibility(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);
}
// ── Claim only (no eligibility check) ─────────────────────────────────────
if (intent === "claim_only") {
@@ -436,6 +457,266 @@ async function handleEligibilityById(
};
}
// ─── 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_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,
},
};
}
// ─── check_and_claim ─────────────────────────────────────────────────────────
async function handleCheckAndClaim(
@@ -624,53 +905,44 @@ async function handleClaimOnly(
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 >= 2) {
const d1 = new Date(sorted[0].date).getTime();
const d2 = new Date(sorted[1].date).getTime();
const diffDays = Math.abs(d1 - d2) / (1000 * 60 * 60 * 24);
if (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 (diffDays < 7) {
// Use UTC methods to avoid local-timezone day-shift on midnight UTC dates
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 isoUTC = (a: any) => {
const d = new Date(a.date);
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
};
const todayLabel = `${String(now.getMonth() + 1).padStart(2, "0")}/${String(now.getDate()).padStart(2, "0")}/${now.getFullYear()}`;
return {
reply: `Found two appointments close together for ${fullName}: ${fmtUTC(sorted[0])} and ${fmtUTC(sorted[1])}. Which date should I use for the claim?`,
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: isoUTC(sorted[0]) },
{ label: fmtUTC(sorted[1]), appointmentId: sorted[1].id, serviceDate: isoUTC(sorted[1]) },
{ label: fmtUTC(sorted[0]), appointmentId: sorted[0].id, serviceDate: latestStr },
{ label: `${todayLabel} (Today)`, appointmentId: null, serviceDate: todayStr },
],
},
};
}
} else {
serviceDate = todayStr;
}
if (sorted.length > 0) {
const rawDate = new Date(sorted[0].date);
serviceDate = `${rawDate.getUTCFullYear()}-${String(rawDate.getUTCMonth() + 1).padStart(2, "0")}-${String(rawDate.getUTCDate()).padStart(2, "0")}`;
appointmentId = sorted[0].id ?? null;
}
}
if (!serviceDate) {
// No appointment on file and no date in message — default to today
const now = new Date();
serviceDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
}
const siteKey = resolveSiteKey(patient.insuranceProvider ?? null, c.insuranceHint ?? null) ?? "MH";

View File

@@ -137,7 +137,6 @@ export async function createOrUpdatePatientByInsuranceId(options: {
updates.firstName = incomingFirst;
if (incomingLast && String(patient.lastName ?? "").trim() !== incomingLast)
updates.lastName = incomingLast;
// Store DOB if not already set
if (dobDate && !patient.dateOfBirth) updates.dateOfBirth = dobDate;
if (Object.keys(updates).length > 0) {
console.log(`[createOrUpdatePatient] updating patient id=${patient.id} with`, updates);
@@ -163,15 +162,8 @@ export async function createOrUpdatePatientByInsuranceId(options: {
try {
patientData = insertPatientSchema.parse(createPayload);
} catch (e1) {
console.warn(`[createOrUpdatePatient] schema parse failed (attempt 1):`, e1);
const safePayload = { ...createPayload };
delete safePayload.dateOfBirth;
try {
patientData = insertPatientSchema.parse(safePayload);
} catch (e2) {
console.warn(`[createOrUpdatePatient] schema parse failed (attempt 2):`, e2);
patientData = safePayload as InsertPatient;
}
console.warn(`[createOrUpdatePatient] schema parse failed:`, e1);
patientData = createPayload as InsertPatient;
}
try {