- Internal AI chat: schedule_appointment intent books earliest available slot in Column A using office hours; claim_only intent looks up latest past appointment for service date, asks user when two appointments are within 7 days, auto-triggers correct Selenium worker with mapped prices - Gemini model updated to gemini-flash-latest; conversation history (15 messages) passed for pronoun/reference resolution; history trimmed to start with user turn so Gemini doesn't reject the context - Insurance alias file (insuranceAliases.json) replaces hardcoded siteKey matching; "tufs" now resolves to TUFTS_SCO - DOB format normalized (MM/DD/YYYY → YYYY-MM-DD) before parseLocalDate; autoCheck now fires for all insurance types, not just MH/CMSP - Claim form auto-submit: all handlers (MH, CCA, DDMA, UnitedDH, Tufts) accept formToUse and receive fee-schedule-priced form; prefillDone set after chatbot code prefill so autoSubmit gate opens correctly - Chatbot: chat history persisted in sessionStorage, cleared on logout and auto-logout; Clear button writes fresh state synchronously; message history window increased to 15 - DentaQuest/TuftsSCO Selenium: "Remember me" checkbox clicked before sign-in to persist OTP trust cookie across sessions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
677 lines
23 KiB
TypeScript
677 lines
23 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";
|
||
|
||
// ─── 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;
|
||
}
|
||
|
||
export interface ChatResponse {
|
||
reply: string;
|
||
action?:
|
||
| "navigate"
|
||
| "show_patient"
|
||
| "check_eligibility_prefill"
|
||
| "eligibility_id_ready"
|
||
| "check_and_claim_ready"
|
||
| "need_insurance_clarification"
|
||
| "appointment_created"
|
||
| "claim_only_ready"
|
||
| "need_appointment_selection";
|
||
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";
|
||
}
|
||
|
||
// siteKey → autoCheck value used by the insurance-status page prefill
|
||
export function siteKeyToAutoCheck(siteKey: string): 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: return "mh"; // MH (caller may downgrade to "cmsp" by age)
|
||
}
|
||
}
|
||
|
||
// ─── 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>;
|
||
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 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,
|
||
};
|
||
}
|
||
|
||
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" },
|
||
};
|
||
}
|
||
|
||
// ── 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 },
|
||
};
|
||
}
|
||
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);
|
||
}
|
||
|
||
// ── Check eligibility + claim procedures ──────────────────────────────────
|
||
|
||
if (intent === "check_and_claim") {
|
||
return await handleCheckAndClaim(classification, storage, customAliases);
|
||
}
|
||
|
||
// ── Claim only (no eligibility check) ─────────────────────────────────────
|
||
|
||
if (intent === "claim_only") {
|
||
return await handleClaimOnly(classification, storage, customAliases);
|
||
}
|
||
|
||
// ── Schedule appointment ───────────────────────────────────────────────────
|
||
|
||
if (intent === "schedule_appointment") {
|
||
return await handleScheduleAppointment(classification, _userId, storage);
|
||
}
|
||
|
||
// ── General ────────────────────────────────────────────────────────────────
|
||
return { reply: classification.fallbackReply };
|
||
}
|
||
|
||
// ─── 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.",
|
||
};
|
||
}
|
||
|
||
// Try to resolve existing patient for name display + insurance
|
||
const existingPatient = await storage.getPatientByInsuranceId(memberId);
|
||
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.",
|
||
};
|
||
}
|
||
|
||
// 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/DentalHub"],
|
||
},
|
||
};
|
||
}
|
||
|
||
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),
|
||
},
|
||
};
|
||
}
|
||
|
||
// ─── check_and_claim ─────────────────────────────────────────────────────────
|
||
|
||
async function handleCheckAndClaim(
|
||
c: ChatClassification,
|
||
storage: StorageLike,
|
||
customAliases: { phrase: string; cdtCode: string }[] = []
|
||
): Promise<ChatResponse> {
|
||
// 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 storage.getPatientByInsuranceId(memberId);
|
||
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).`,
|
||
};
|
||
}
|
||
|
||
// 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/DentalHub"],
|
||
},
|
||
};
|
||
}
|
||
|
||
// 3. Map procedure names → CDT codes (custom aliases take priority)
|
||
const procedureNames = 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);
|
||
|
||
const label = patient
|
||
? `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim()
|
||
: `Member ID ${memberId}`;
|
||
|
||
let reply = `Ready to check eligibility for ${label} and claim: ${
|
||
matched.map((r) => `${r.code} (${r.description})`).join(", ") || "no procedures mapped"
|
||
}.`;
|
||
|
||
if (unmatched.length > 0) {
|
||
reply += ` Could not map: ${unmatched.map((r) => `"${r.input}"`).join(", ")} — please verify these codes manually.`;
|
||
}
|
||
|
||
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 })),
|
||
},
|
||
};
|
||
}
|
||
|
||
// ─── claim_only ───────────────────────────────────────────────────────────────
|
||
|
||
async function handleClaimOnly(
|
||
c: ChatClassification,
|
||
storage: StorageLike,
|
||
customAliases: { phrase: string; cdtCode: string }[]
|
||
): Promise<ChatResponse> {
|
||
// Resolve patient
|
||
let patient: ResolvedPatient | null = null;
|
||
|
||
if (c.memberId?.trim()) {
|
||
const existing = await storage.getPatientByInsuranceId(c.memberId.trim());
|
||
if (existing) patient = patientToResult(existing);
|
||
} else if (c.patientName?.trim()) {
|
||
const raw = await findPatientByName(c.patientName.trim(), storage);
|
||
if (raw) patient = patientToResult(raw);
|
||
}
|
||
|
||
if (!patient) {
|
||
return {
|
||
reply: "Please include a patient name or Member ID so I can look them up.",
|
||
};
|
||
}
|
||
|
||
const fullName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim();
|
||
|
||
// Map procedure names → CDT codes
|
||
const procedureNames = c.procedureNames ?? [];
|
||
if (procedureNames.length === 0) {
|
||
return { reply: "Please specify which procedures to claim (e.g. perio exam, adult prophy)." };
|
||
}
|
||
|
||
const cdtResults = lookupCdtCodes(procedureNames, customAliases);
|
||
const matched = cdtResults.filter((r) => r.code !== null);
|
||
const unmatched = cdtResults.filter((r) => r.code === null);
|
||
|
||
// Resolve service date: use explicit date from message, then latest appointment, then ask
|
||
let serviceDate: string | null = c.appointmentDate ?? null;
|
||
let appointmentId: number | null = null;
|
||
|
||
if (!serviceDate) {
|
||
const appts = await storage.getAppointmentsByPatientId(patient.id);
|
||
const sorted = appts.sort((a: any, b: any) =>
|
||
new Date(b.date).getTime() - new Date(a.date).getTime()
|
||
);
|
||
|
||
if (sorted.length >= 2) {
|
||
const d1 = new Date(sorted[0].date).getTime();
|
||
const d2 = new Date(sorted[1].date).getTime();
|
||
const diffDays = Math.abs(d1 - d2) / (1000 * 60 * 60 * 24);
|
||
|
||
if (diffDays < 7) {
|
||
// Use UTC methods to avoid local-timezone day-shift on midnight UTC dates
|
||
const fmtUTC = (a: any) => {
|
||
const d = new Date(a.date);
|
||
return `${String(d.getUTCMonth() + 1).padStart(2, "0")}/${String(d.getUTCDate()).padStart(2, "0")}/${d.getUTCFullYear()}`;
|
||
};
|
||
const isoUTC = (a: any) => {
|
||
const d = new Date(a.date);
|
||
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
|
||
};
|
||
return {
|
||
reply: `Found two appointments close together for ${fullName}: ${fmtUTC(sorted[0])} and ${fmtUTC(sorted[1])}. Which date should I use for the claim?`,
|
||
action: "need_appointment_selection",
|
||
actionData: {
|
||
patient,
|
||
siteKey: resolveSiteKey(patient.insuranceProvider ?? null, c.insuranceHint ?? null) ?? "MH",
|
||
matchedCodes: matched.map((r) => ({ code: r.code!, description: r.description })),
|
||
options: [
|
||
{ label: fmtUTC(sorted[0]), appointmentId: sorted[0].id, serviceDate: isoUTC(sorted[0]) },
|
||
{ label: fmtUTC(sorted[1]), appointmentId: sorted[1].id, serviceDate: isoUTC(sorted[1]) },
|
||
],
|
||
},
|
||
};
|
||
}
|
||
}
|
||
|
||
if (sorted.length > 0) {
|
||
const rawDate = new Date(sorted[0].date);
|
||
serviceDate = `${rawDate.getUTCFullYear()}-${String(rawDate.getUTCMonth() + 1).padStart(2, "0")}-${String(rawDate.getUTCDate()).padStart(2, "0")}`;
|
||
appointmentId = sorted[0].id ?? null;
|
||
}
|
||
}
|
||
|
||
if (!serviceDate) {
|
||
// No appointment on file — ask for the service date
|
||
const codesPreview = matched.map((r) => `${r.code}`).join(", ") || "the procedures";
|
||
return {
|
||
reply: `Found ${fullName} but no appointments on file. What was the service date for ${codesPreview}? (e.g. "6/2/2026")`,
|
||
};
|
||
}
|
||
|
||
const siteKey = resolveSiteKey(patient.insuranceProvider ?? null, c.insuranceHint ?? null) ?? "MH";
|
||
|
||
const [sy, sm, sd] = serviceDate.split("-");
|
||
const dateLabel = `${sm}/${sd}/${sy}`;
|
||
|
||
let reply = `Opening claim for ${fullName} (service date ${dateLabel}): ${
|
||
matched.map((r) => `${r.code} (${r.description})`).join(", ") || "no procedures matched"
|
||
}.`;
|
||
if (unmatched.length > 0) {
|
||
reply += ` Could not map: ${unmatched.map((r) => `"${r.input}"`).join(", ")} — verify manually.`;
|
||
}
|
||
|
||
return {
|
||
reply,
|
||
action: "claim_only_ready",
|
||
actionData: {
|
||
patient,
|
||
siteKey,
|
||
serviceDate,
|
||
appointmentId,
|
||
matchedCodes: matched.map((r) => ({ code: r.code!, description: r.description })),
|
||
},
|
||
};
|
||
}
|
||
|
||
// ─── schedule_appointment ─────────────────────────────────────────────────────
|
||
|
||
const DEFAULT_STAFF_ID = 1; // Column A
|
||
const SLOT_DURATION = 30; // minutes
|
||
|
||
/** Convert "HH:MM" to total minutes since midnight */
|
||
function toMinutes(t: string): number {
|
||
const [h, m] = t.split(":").map(Number);
|
||
return (h ?? 0) * 60 + (m ?? 0);
|
||
}
|
||
|
||
/** Convert total minutes since midnight to "HH:MM" */
|
||
function fromMinutes(m: number): string {
|
||
return `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`;
|
||
}
|
||
|
||
/**
|
||
* Build the list of valid start minutes for a day using office hours.
|
||
* Skips the lunch gap (amEnd → pmStart).
|
||
*/
|
||
function buildSlots(dayHours: { amStart: string; amEnd: string; pmStart: string; pmEnd: string }): number[] {
|
||
const slots: number[] = [];
|
||
const ranges = [
|
||
[toMinutes(dayHours.amStart), toMinutes(dayHours.amEnd)],
|
||
[toMinutes(dayHours.pmStart), toMinutes(dayHours.pmEnd)],
|
||
];
|
||
for (const [start, end] of ranges) {
|
||
for (let t = start!; t + SLOT_DURATION <= end!; t += SLOT_DURATION) {
|
||
slots.push(t);
|
||
}
|
||
}
|
||
return slots;
|
||
}
|
||
|
||
async function handleScheduleAppointment(
|
||
c: ChatClassification,
|
||
userId: number,
|
||
storage: StorageLike
|
||
): Promise<ChatResponse> {
|
||
const name = c.patientName?.trim();
|
||
if (!name) {
|
||
return { reply: "Please include the patient's name so I can find them." };
|
||
}
|
||
|
||
const raw = await findPatientByName(name, storage);
|
||
if (!raw) {
|
||
return {
|
||
reply: `No patient found matching "${name}". Please check the spelling or add them on the Patients page first.`,
|
||
};
|
||
}
|
||
|
||
const patient = patientToResult(raw);
|
||
const fullName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim();
|
||
|
||
// Resolve date
|
||
const today = new Date();
|
||
const localDate = c.appointmentDate
|
||
? new Date(c.appointmentDate + "T00:00:00")
|
||
: new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
||
const dateStr = `${localDate.getFullYear()}-${String(localDate.getMonth() + 1).padStart(2, "0")}-${String(localDate.getDate()).padStart(2, "0")}`;
|
||
const dateLabel = localDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||
|
||
// If time was explicitly provided, use it directly
|
||
if (c.appointmentTime) {
|
||
const startTime = c.appointmentTime;
|
||
const startMin = toMinutes(startTime);
|
||
const endTime = fromMinutes(startMin + SLOT_DURATION);
|
||
await storage.createAppointment({
|
||
patientId: patient.id,
|
||
userId,
|
||
staffId: DEFAULT_STAFF_ID,
|
||
title: dateLabel,
|
||
date: localDate,
|
||
startTime,
|
||
endTime,
|
||
type: "recall",
|
||
status: "scheduled",
|
||
movedByAi: true,
|
||
});
|
||
return {
|
||
reply: `Scheduled ${fullName} on ${dateLabel} at ${startTime} (Column A).`,
|
||
action: "appointment_created",
|
||
actionData: { patient, date: dateStr, startTime, endTime },
|
||
};
|
||
}
|
||
|
||
// No time specified — find earliest available slot in Column A
|
||
const dayNames = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
|
||
const dayName = dayNames[localDate.getDay()]!;
|
||
|
||
// Load office hours (fall back to 9–12 / 13–17 if not configured)
|
||
const officeHours = await storage.getOfficeHours(userId);
|
||
const dayHours = officeHours?.data?.doctors?.[dayName] ?? {
|
||
amStart: "09:00", amEnd: "12:00", pmStart: "13:00", pmEnd: "17:00", enabled: true,
|
||
};
|
||
|
||
if (!dayHours.enabled) {
|
||
return { reply: `The office is closed on ${dayName}. Please choose a different day.` };
|
||
}
|
||
|
||
const allSlots = buildSlots(dayHours);
|
||
|
||
// Fetch all Column A appointments for that day
|
||
const existing = (await storage.getAppointmentsByDateForUser(dateStr, userId))
|
||
.filter((a: any) => a.staffId === DEFAULT_STAFF_ID);
|
||
|
||
// Find first slot that doesn't overlap any existing appointment
|
||
const booked = existing.map((a: any) => ({
|
||
start: toMinutes(a.startTime),
|
||
end: toMinutes(a.endTime),
|
||
}));
|
||
|
||
const availableStart = allSlots.find((slotStart) => {
|
||
const slotEnd = slotStart + SLOT_DURATION;
|
||
return !booked.some((b) => slotStart < b.end && slotEnd > b.start);
|
||
});
|
||
|
||
if (availableStart === undefined) {
|
||
return {
|
||
reply: `Column A is fully booked on ${dateLabel}. Please pick a different date or time.`,
|
||
};
|
||
}
|
||
|
||
const startTime = fromMinutes(availableStart);
|
||
const endTime = fromMinutes(availableStart + SLOT_DURATION);
|
||
|
||
await storage.createAppointment({
|
||
patientId: patient.id,
|
||
userId,
|
||
staffId: DEFAULT_STAFF_ID,
|
||
title: dateLabel,
|
||
date: localDate,
|
||
startTime,
|
||
endTime,
|
||
type: "recall",
|
||
status: "scheduled",
|
||
movedByAi: true,
|
||
});
|
||
|
||
return {
|
||
reply: `Scheduled ${fullName} on ${dateLabel} at ${startTime} (Column A) — earliest available slot.`,
|
||
action: "appointment_created",
|
||
actionData: { patient, date: dateStr, startTime, endTime },
|
||
};
|
||
}
|
||
|
||
// ─── Insurance resolution helper ──────────────────────────────────────────────
|
||
|
||
/**
|
||
* 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;
|
||
}
|