feat: AI chat scheduling, claim automation, and session improvements
- Internal AI chat: schedule_appointment intent books earliest available slot in Column A using office hours; claim_only intent looks up latest past appointment for service date, asks user when two appointments are within 7 days, auto-triggers correct Selenium worker with mapped prices - Gemini model updated to gemini-flash-latest; conversation history (15 messages) passed for pronoun/reference resolution; history trimmed to start with user turn so Gemini doesn't reject the context - Insurance alias file (insuranceAliases.json) replaces hardcoded siteKey matching; "tufs" now resolves to TUFTS_SCO - DOB format normalized (MM/DD/YYYY → YYYY-MM-DD) before parseLocalDate; autoCheck now fires for all insurance types, not just MH/CMSP - Claim form auto-submit: all handlers (MH, CCA, DDMA, UnitedDH, Tufts) accept formToUse and receive fee-schedule-priced form; prefillDone set after chatbot code prefill so autoSubmit gate opens correctly - Chatbot: chat history persisted in sessionStorage, cleared on logout and auto-logout; Clear button writes fresh state synchronously; message history window increased to 15 - DentaQuest/TuftsSCO Selenium: "Remember me" checkbox clicked before sign-in to persist OTP trust cookie across sessions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,8 @@ export type InternalChatIntent =
|
||||
| "eligibility_by_id" // by explicit memberId + dob (no name)
|
||||
| "check_and_claim" // eligibility + claim procedures
|
||||
| "find_patient" // look up patient record only
|
||||
| "schedule_appointment" // add patient to today's (or specified) schedule
|
||||
| "claim_only" // submit claim for procedures (no eligibility check)
|
||||
| "navigate_claims"
|
||||
| "navigate_schedule"
|
||||
| "general";
|
||||
@@ -14,13 +16,16 @@ export type InternalChatIntent =
|
||||
export interface ChatClassification {
|
||||
intent: InternalChatIntent;
|
||||
// --- patient resolution (one of name OR id+dob) ---
|
||||
patientName?: string; // for check_eligibility / find_patient
|
||||
patientName?: string; // for check_eligibility / find_patient / schedule_appointment
|
||||
memberId?: string; // for eligibility_by_id / check_and_claim
|
||||
dob?: string; // for eligibility_by_id / check_and_claim (MM/DD/YYYY)
|
||||
// --- insurance hint (only if explicitly stated in the message) ---
|
||||
insuranceHint?: string; // raw text, e.g. "masshealth", "BCBS", "CCA"
|
||||
// --- procedures (raw text, NOT CDT codes — CDT lookup is done in workflow) ---
|
||||
procedureNames?: string[]; // for check_and_claim, e.g. ["perio exam", "adult cleaning"]
|
||||
// --- scheduling ---
|
||||
appointmentDate?: string; // for schedule_appointment, YYYY-MM-DD (omit = today)
|
||||
appointmentTime?: string; // for schedule_appointment, HH:MM 24h (omit = 09:00)
|
||||
fallbackReply: string;
|
||||
}
|
||||
|
||||
@@ -38,43 +43,61 @@ Respond ONLY with valid JSON (no markdown fences):
|
||||
"dob": "<date of birth in MM/DD/YYYY if given>",
|
||||
"insuranceHint": "<insurance name only if explicitly stated in the message, e.g. 'masshealth', 'BCBS MA', 'CCA'>",
|
||||
"procedureNames": ["<raw procedure name>", ...],
|
||||
"appointmentDate": "<YYYY-MM-DD if a specific date is mentioned, omit for today>",
|
||||
"appointmentTime": "<HH:MM 24h if a specific time is mentioned, omit if not stated>",
|
||||
"fallbackReply": "<1-2 sentence reply to show the user>"
|
||||
}
|
||||
|
||||
Omit any field that is not present in the message.
|
||||
|
||||
Intents:
|
||||
- check_eligibility : user wants to check insurance for a patient identified by NAME only
|
||||
e.g. "check Maria Jesus", "verify insurance for John Smith"
|
||||
- eligibility_by_id : user provides a member ID and date of birth (no patient name)
|
||||
e.g. "check masshealth for 100xxxx, 10/10/1988"
|
||||
- check_and_claim : user wants to check eligibility AND submit procedures as claims
|
||||
e.g. "check masshealth for 100xxxx, 10/10/1988 and claim perio exam and adult cleaning"
|
||||
e.g. "check Maria Jesus and claim D0120 D1110"
|
||||
- find_patient : look up a patient record only, no eligibility
|
||||
e.g. "find patient John", "look up Smith"
|
||||
- navigate_claims : open the claims page
|
||||
- navigate_schedule : open the appointments/schedule page
|
||||
- general : anything else
|
||||
- check_eligibility : user wants to check insurance for a patient identified by NAME only
|
||||
e.g. "check Maria Jesus", "verify insurance for John Smith"
|
||||
- eligibility_by_id : user provides a member ID and date of birth (no patient name)
|
||||
e.g. "check masshealth for 100xxxx, 10/10/1988"
|
||||
- check_and_claim : user wants to check eligibility AND submit procedures as claims
|
||||
e.g. "check masshealth for 100xxxx, 10/10/1988 and claim perio exam and adult cleaning"
|
||||
e.g. "check Maria Jesus and claim D0120 D1110"
|
||||
- find_patient : look up a patient record only, no eligibility
|
||||
e.g. "find patient John", "look up Smith"
|
||||
- schedule_appointment : add a patient to the schedule (today or a specified date/time)
|
||||
e.g. "put John Smith in today's schedule"
|
||||
e.g. "schedule Maria at 2pm tomorrow"
|
||||
e.g. "add Jane Doe at 10:30"
|
||||
- claim_only : submit a claim for procedures WITHOUT an eligibility check
|
||||
e.g. "claim comprehensive exam and Pano for her"
|
||||
e.g. "claim D0120 and D1110 for John Smith"
|
||||
e.g. "bill adult cleaning for Maria"
|
||||
Use this when no eligibility check is requested — just billing/claiming services
|
||||
- navigate_claims : open the claims page
|
||||
- navigate_schedule : open the appointments/schedule page
|
||||
- general : anything else
|
||||
|
||||
Rules:
|
||||
- For check_and_claim, procedureNames should be the RAW user text
|
||||
- For check_and_claim and claim_only, procedureNames should be the RAW user text
|
||||
(e.g. "perio exam", "adult cleaning", "D0120") — do NOT translate to codes
|
||||
- insuranceHint is only set when the user explicitly names an insurance in the message
|
||||
- Keep fallbackReply to 1-2 sentences
|
||||
- For navigate intents, fallbackReply = "Opening the [page] page..."`;
|
||||
- For navigate intents, fallbackReply = "Opening the [page] page..."
|
||||
- For schedule_appointment, appointmentDate omitted means today; appointmentTime omitted means no preference
|
||||
- IMPORTANT: Use the conversation history to resolve pronouns and references.
|
||||
If the user says "her", "him", "them", "the patient", or "same patient", look back through
|
||||
the conversation history to find the patient name that was mentioned most recently.
|
||||
Always populate patientName (or memberId) from history when a pronoun is used.
|
||||
Never return an empty patientName just because the current message uses a pronoun.`;
|
||||
|
||||
// ─── Classifier ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function classifyInternalChat(
|
||||
message: string,
|
||||
apiKey: string,
|
||||
extraSystemPrompt?: string
|
||||
extraSystemPrompt?: string,
|
||||
history: { role: "user" | "assistant"; text: string }[] = []
|
||||
): Promise<ChatClassification> {
|
||||
const fallback: ChatClassification = {
|
||||
intent: "general",
|
||||
fallbackReply:
|
||||
"I can search for a patient, check eligibility, run check & claim, or navigate to claims or appointments.",
|
||||
"I can search for a patient, check eligibility, run check & claim, schedule appointments, or navigate to claims or appointments.",
|
||||
};
|
||||
|
||||
if (!apiKey) return fallback;
|
||||
@@ -84,9 +107,24 @@ export async function classifyInternalChat(
|
||||
: BASE_SYSTEM_PROMPT;
|
||||
|
||||
try {
|
||||
const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey });
|
||||
const llm = new ChatGoogleGenerativeAI({ model: "gemini-flash-latest", apiKey });
|
||||
|
||||
// Gemini requires conversation to start with a user turn — drop any leading assistant messages
|
||||
const trimmedHistory = history.slice(
|
||||
history.findIndex((h) => h.role === "user")
|
||||
).filter((_, i, arr) => {
|
||||
// Also drop consecutive same-role messages (keep last of each run)
|
||||
if (i === arr.length - 1) return true;
|
||||
return arr[i]!.role !== arr[i + 1]!.role;
|
||||
});
|
||||
|
||||
const historyMessages = trimmedHistory.map((h) => ({
|
||||
role: h.role,
|
||||
content: h.text,
|
||||
}));
|
||||
const response = await llm.invoke([
|
||||
{ role: "system", content: systemPrompt },
|
||||
...historyMessages,
|
||||
{ role: "user", content: message },
|
||||
]);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
import { ChatClassification } from "./internal-chat-graph";
|
||||
import { lookupCdtCodes } from "./cdt-lookup";
|
||||
import insuranceAliases from "../data/insuranceAliases.json";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -35,7 +36,10 @@ export interface ChatResponse {
|
||||
| "check_eligibility_prefill"
|
||||
| "eligibility_id_ready"
|
||||
| "check_and_claim_ready"
|
||||
| "need_insurance_clarification";
|
||||
| "need_insurance_clarification"
|
||||
| "appointment_created"
|
||||
| "claim_only_ready"
|
||||
| "need_appointment_selection";
|
||||
actionData?: Record<string, any>;
|
||||
}
|
||||
|
||||
@@ -43,13 +47,10 @@ export interface ChatResponse {
|
||||
|
||||
export function deriveSiteKey(provider: string): string {
|
||||
const p = (provider ?? "").toLowerCase().trim();
|
||||
if (!p || p.includes("masshealth") || p === "mh" || p === "mass health") return "MH";
|
||||
if (p.includes("commonwealth care alliance") || p === "cca") return "CCA";
|
||||
if (p.includes("ddma") || p.includes("delta dental ma")) return "DDMA";
|
||||
if (p.includes("delta dental ins") || p.includes("delta ins")) return "DELTA_INS";
|
||||
if (p.includes("tufts") || p.includes("dentaquest") || p === "tuftssco") return "TUFTS_SCO";
|
||||
if ((p.includes("united") && p.includes("sco")) || p.includes("dentalhub") || p === "united_sco") return "UNITED_SCO";
|
||||
if (p.includes("bcbs") || p.includes("blue cross")) return "BCBS_MA";
|
||||
if (!p) return "MH";
|
||||
for (const { keyword, siteKey } of insuranceAliases) {
|
||||
if (p.includes(keyword.toLowerCase())) return siteKey;
|
||||
}
|
||||
return "MH";
|
||||
}
|
||||
|
||||
@@ -74,6 +75,10 @@ interface StorageLike {
|
||||
offset: number;
|
||||
}): Promise<any[] | null>;
|
||||
getPatientByInsuranceId(id: string): Promise<any | null>;
|
||||
createAppointment(appointment: any): Promise<any>;
|
||||
getAppointmentsByDateForUser(dateStr: string, userId: number): Promise<any[]>;
|
||||
getOfficeHours(userId: number): Promise<any | null>;
|
||||
getAppointmentsByPatientId(patientId: number): Promise<any[]>;
|
||||
}
|
||||
|
||||
// ─── Shared helpers ───────────────────────────────────────────────────────────
|
||||
@@ -210,6 +215,18 @@ export async function runInternalChatWorkflow(
|
||||
return await handleCheckAndClaim(classification, storage, customAliases);
|
||||
}
|
||||
|
||||
// ── Claim only (no eligibility check) ─────────────────────────────────────
|
||||
|
||||
if (intent === "claim_only") {
|
||||
return await handleClaimOnly(classification, storage, customAliases);
|
||||
}
|
||||
|
||||
// ── Schedule appointment ───────────────────────────────────────────────────
|
||||
|
||||
if (intent === "schedule_appointment") {
|
||||
return await handleScheduleAppointment(classification, _userId, storage);
|
||||
}
|
||||
|
||||
// ── General ────────────────────────────────────────────────────────────────
|
||||
return { reply: classification.fallbackReply };
|
||||
}
|
||||
@@ -223,9 +240,9 @@ async function handleEligibilityById(
|
||||
const memberId = c.memberId?.trim();
|
||||
const dob = c.dob?.trim();
|
||||
|
||||
if (!memberId || !dob) {
|
||||
if (!memberId) {
|
||||
return {
|
||||
reply: "Please provide both a Member ID and Date of Birth (MM/DD/YYYY).",
|
||||
reply: "Please provide a Member ID to run the eligibility check.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -235,6 +252,14 @@ async function handleEligibilityById(
|
||||
? patientToResult(existingPatient)
|
||||
: null;
|
||||
|
||||
// Use stored DOB if not provided in the message
|
||||
const resolvedDob = dob ?? patient?.dateOfBirth ?? null;
|
||||
if (!resolvedDob) {
|
||||
return {
|
||||
reply: "Please provide a Date of Birth (MM/DD/YYYY) to run the eligibility check.",
|
||||
};
|
||||
}
|
||||
|
||||
// Determine siteKey
|
||||
const siteKey = resolveSiteKey(
|
||||
patient?.insuranceProvider ?? null,
|
||||
@@ -250,7 +275,7 @@ async function handleEligibilityById(
|
||||
action: "need_insurance_clarification",
|
||||
actionData: {
|
||||
memberId,
|
||||
dob,
|
||||
dob: resolvedDob,
|
||||
patient,
|
||||
options: ["MassHealth", "BCBS MA", "CCA", "Tufts SCO", "Delta Dental MA", "United/DentalHub"],
|
||||
},
|
||||
@@ -267,7 +292,7 @@ async function handleEligibilityById(
|
||||
actionData: {
|
||||
patient,
|
||||
memberId,
|
||||
dob,
|
||||
dob: resolvedDob,
|
||||
siteKey,
|
||||
autoCheck: siteKeyToAutoCheck(siteKey),
|
||||
},
|
||||
@@ -302,9 +327,12 @@ async function handleCheckAndClaim(
|
||||
reply: "Please include either a Member ID or a patient name so I can look up their record.",
|
||||
};
|
||||
}
|
||||
if (!dob) {
|
||||
|
||||
// Use stored DOB if not provided in the message
|
||||
const resolvedDob = dob ?? patient?.dateOfBirth ?? null;
|
||||
if (!resolvedDob) {
|
||||
return {
|
||||
reply: `I have the Member ID (${memberId}) but need a Date of Birth (MM/DD/YYYY) to run the eligibility check.`,
|
||||
reply: `I have the Member ID (${memberId}) but couldn't find a Date of Birth on file. Please provide it (MM/DD/YYYY).`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -358,7 +386,7 @@ async function handleCheckAndClaim(
|
||||
actionData: {
|
||||
patient,
|
||||
memberId,
|
||||
dob,
|
||||
dob: resolvedDob,
|
||||
siteKey,
|
||||
autoCheck: siteKeyToAutoCheck(siteKey),
|
||||
cdtResults,
|
||||
@@ -367,6 +395,269 @@ async function handleCheckAndClaim(
|
||||
};
|
||||
}
|
||||
|
||||
// ─── claim_only ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleClaimOnly(
|
||||
c: ChatClassification,
|
||||
storage: StorageLike,
|
||||
customAliases: { phrase: string; cdtCode: string }[]
|
||||
): Promise<ChatResponse> {
|
||||
// Resolve patient
|
||||
let patient: ResolvedPatient | null = null;
|
||||
|
||||
if (c.memberId?.trim()) {
|
||||
const existing = await storage.getPatientByInsuranceId(c.memberId.trim());
|
||||
if (existing) patient = patientToResult(existing);
|
||||
} else if (c.patientName?.trim()) {
|
||||
const raw = await findPatientByName(c.patientName.trim(), storage);
|
||||
if (raw) patient = patientToResult(raw);
|
||||
}
|
||||
|
||||
if (!patient) {
|
||||
return {
|
||||
reply: "Please include a patient name or Member ID so I can look them up.",
|
||||
};
|
||||
}
|
||||
|
||||
const fullName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim();
|
||||
|
||||
// Map procedure names → CDT codes
|
||||
const procedureNames = c.procedureNames ?? [];
|
||||
if (procedureNames.length === 0) {
|
||||
return { reply: "Please specify which procedures to claim (e.g. perio exam, adult prophy)." };
|
||||
}
|
||||
|
||||
const cdtResults = lookupCdtCodes(procedureNames, customAliases);
|
||||
const matched = cdtResults.filter((r) => r.code !== null);
|
||||
const unmatched = cdtResults.filter((r) => r.code === null);
|
||||
|
||||
// Resolve service date: use explicit date from message, then latest appointment, then ask
|
||||
let serviceDate: string | null = c.appointmentDate ?? null;
|
||||
let appointmentId: number | null = null;
|
||||
|
||||
if (!serviceDate) {
|
||||
const appts = await storage.getAppointmentsByPatientId(patient.id);
|
||||
const sorted = appts.sort((a: any, b: any) =>
|
||||
new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
);
|
||||
|
||||
if (sorted.length >= 2) {
|
||||
const d1 = new Date(sorted[0].date).getTime();
|
||||
const d2 = new Date(sorted[1].date).getTime();
|
||||
const diffDays = Math.abs(d1 - d2) / (1000 * 60 * 60 * 24);
|
||||
|
||||
if (diffDays < 7) {
|
||||
// Use UTC methods to avoid local-timezone day-shift on midnight UTC dates
|
||||
const fmtUTC = (a: any) => {
|
||||
const d = new Date(a.date);
|
||||
return `${String(d.getUTCMonth() + 1).padStart(2, "0")}/${String(d.getUTCDate()).padStart(2, "0")}/${d.getUTCFullYear()}`;
|
||||
};
|
||||
const isoUTC = (a: any) => {
|
||||
const d = new Date(a.date);
|
||||
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
|
||||
};
|
||||
return {
|
||||
reply: `Found two appointments close together for ${fullName}: ${fmtUTC(sorted[0])} and ${fmtUTC(sorted[1])}. Which date should I use for the claim?`,
|
||||
action: "need_appointment_selection",
|
||||
actionData: {
|
||||
patient,
|
||||
siteKey: resolveSiteKey(patient.insuranceProvider ?? null, c.insuranceHint ?? null) ?? "MH",
|
||||
matchedCodes: matched.map((r) => ({ code: r.code!, description: r.description })),
|
||||
options: [
|
||||
{ label: fmtUTC(sorted[0]), appointmentId: sorted[0].id, serviceDate: isoUTC(sorted[0]) },
|
||||
{ label: fmtUTC(sorted[1]), appointmentId: sorted[1].id, serviceDate: isoUTC(sorted[1]) },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (sorted.length > 0) {
|
||||
const rawDate = new Date(sorted[0].date);
|
||||
serviceDate = `${rawDate.getUTCFullYear()}-${String(rawDate.getUTCMonth() + 1).padStart(2, "0")}-${String(rawDate.getUTCDate()).padStart(2, "0")}`;
|
||||
appointmentId = sorted[0].id ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!serviceDate) {
|
||||
// No appointment on file — ask for the service date
|
||||
const codesPreview = matched.map((r) => `${r.code}`).join(", ") || "the procedures";
|
||||
return {
|
||||
reply: `Found ${fullName} but no appointments on file. What was the service date for ${codesPreview}? (e.g. "6/2/2026")`,
|
||||
};
|
||||
}
|
||||
|
||||
const siteKey = resolveSiteKey(patient.insuranceProvider ?? null, c.insuranceHint ?? null) ?? "MH";
|
||||
|
||||
const [sy, sm, sd] = serviceDate.split("-");
|
||||
const dateLabel = `${sm}/${sd}/${sy}`;
|
||||
|
||||
let reply = `Opening claim for ${fullName} (service date ${dateLabel}): ${
|
||||
matched.map((r) => `${r.code} (${r.description})`).join(", ") || "no procedures matched"
|
||||
}.`;
|
||||
if (unmatched.length > 0) {
|
||||
reply += ` Could not map: ${unmatched.map((r) => `"${r.input}"`).join(", ")} — verify manually.`;
|
||||
}
|
||||
|
||||
return {
|
||||
reply,
|
||||
action: "claim_only_ready",
|
||||
actionData: {
|
||||
patient,
|
||||
siteKey,
|
||||
serviceDate,
|
||||
appointmentId,
|
||||
matchedCodes: matched.map((r) => ({ code: r.code!, description: r.description })),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── schedule_appointment ─────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_STAFF_ID = 1; // Column A
|
||||
const SLOT_DURATION = 30; // minutes
|
||||
|
||||
/** Convert "HH:MM" to total minutes since midnight */
|
||||
function toMinutes(t: string): number {
|
||||
const [h, m] = t.split(":").map(Number);
|
||||
return (h ?? 0) * 60 + (m ?? 0);
|
||||
}
|
||||
|
||||
/** Convert total minutes since midnight to "HH:MM" */
|
||||
function fromMinutes(m: number): string {
|
||||
return `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the list of valid start minutes for a day using office hours.
|
||||
* Skips the lunch gap (amEnd → pmStart).
|
||||
*/
|
||||
function buildSlots(dayHours: { amStart: string; amEnd: string; pmStart: string; pmEnd: string }): number[] {
|
||||
const slots: number[] = [];
|
||||
const ranges = [
|
||||
[toMinutes(dayHours.amStart), toMinutes(dayHours.amEnd)],
|
||||
[toMinutes(dayHours.pmStart), toMinutes(dayHours.pmEnd)],
|
||||
];
|
||||
for (const [start, end] of ranges) {
|
||||
for (let t = start!; t + SLOT_DURATION <= end!; t += SLOT_DURATION) {
|
||||
slots.push(t);
|
||||
}
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
|
||||
async function handleScheduleAppointment(
|
||||
c: ChatClassification,
|
||||
userId: number,
|
||||
storage: StorageLike
|
||||
): Promise<ChatResponse> {
|
||||
const name = c.patientName?.trim();
|
||||
if (!name) {
|
||||
return { reply: "Please include the patient's name so I can find them." };
|
||||
}
|
||||
|
||||
const raw = await findPatientByName(name, storage);
|
||||
if (!raw) {
|
||||
return {
|
||||
reply: `No patient found matching "${name}". Please check the spelling or add them on the Patients page first.`,
|
||||
};
|
||||
}
|
||||
|
||||
const patient = patientToResult(raw);
|
||||
const fullName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim();
|
||||
|
||||
// Resolve date
|
||||
const today = new Date();
|
||||
const localDate = c.appointmentDate
|
||||
? new Date(c.appointmentDate + "T00:00:00")
|
||||
: new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
||||
const dateStr = `${localDate.getFullYear()}-${String(localDate.getMonth() + 1).padStart(2, "0")}-${String(localDate.getDate()).padStart(2, "0")}`;
|
||||
const dateLabel = localDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||
|
||||
// If time was explicitly provided, use it directly
|
||||
if (c.appointmentTime) {
|
||||
const startTime = c.appointmentTime;
|
||||
const startMin = toMinutes(startTime);
|
||||
const endTime = fromMinutes(startMin + SLOT_DURATION);
|
||||
await storage.createAppointment({
|
||||
patientId: patient.id,
|
||||
userId,
|
||||
staffId: DEFAULT_STAFF_ID,
|
||||
title: dateLabel,
|
||||
date: localDate,
|
||||
startTime,
|
||||
endTime,
|
||||
type: "recall",
|
||||
status: "scheduled",
|
||||
movedByAi: true,
|
||||
});
|
||||
return {
|
||||
reply: `Scheduled ${fullName} on ${dateLabel} at ${startTime} (Column A).`,
|
||||
action: "appointment_created",
|
||||
actionData: { patient, date: dateStr, startTime, endTime },
|
||||
};
|
||||
}
|
||||
|
||||
// No time specified — find earliest available slot in Column A
|
||||
const dayNames = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
|
||||
const dayName = dayNames[localDate.getDay()]!;
|
||||
|
||||
// Load office hours (fall back to 9–12 / 13–17 if not configured)
|
||||
const officeHours = await storage.getOfficeHours(userId);
|
||||
const dayHours = officeHours?.data?.doctors?.[dayName] ?? {
|
||||
amStart: "09:00", amEnd: "12:00", pmStart: "13:00", pmEnd: "17:00", enabled: true,
|
||||
};
|
||||
|
||||
if (!dayHours.enabled) {
|
||||
return { reply: `The office is closed on ${dayName}. Please choose a different day.` };
|
||||
}
|
||||
|
||||
const allSlots = buildSlots(dayHours);
|
||||
|
||||
// Fetch all Column A appointments for that day
|
||||
const existing = (await storage.getAppointmentsByDateForUser(dateStr, userId))
|
||||
.filter((a: any) => a.staffId === DEFAULT_STAFF_ID);
|
||||
|
||||
// Find first slot that doesn't overlap any existing appointment
|
||||
const booked = existing.map((a: any) => ({
|
||||
start: toMinutes(a.startTime),
|
||||
end: toMinutes(a.endTime),
|
||||
}));
|
||||
|
||||
const availableStart = allSlots.find((slotStart) => {
|
||||
const slotEnd = slotStart + SLOT_DURATION;
|
||||
return !booked.some((b) => slotStart < b.end && slotEnd > b.start);
|
||||
});
|
||||
|
||||
if (availableStart === undefined) {
|
||||
return {
|
||||
reply: `Column A is fully booked on ${dateLabel}. Please pick a different date or time.`,
|
||||
};
|
||||
}
|
||||
|
||||
const startTime = fromMinutes(availableStart);
|
||||
const endTime = fromMinutes(availableStart + SLOT_DURATION);
|
||||
|
||||
await storage.createAppointment({
|
||||
patientId: patient.id,
|
||||
userId,
|
||||
staffId: DEFAULT_STAFF_ID,
|
||||
title: dateLabel,
|
||||
date: localDate,
|
||||
startTime,
|
||||
endTime,
|
||||
type: "recall",
|
||||
status: "scheduled",
|
||||
movedByAi: true,
|
||||
});
|
||||
|
||||
return {
|
||||
reply: `Scheduled ${fullName} on ${dateLabel} at ${startTime} (Column A) — earliest available slot.`,
|
||||
action: "appointment_created",
|
||||
actionData: { patient, date: dateStr, startTime, endTime },
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Insurance resolution helper ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
32
apps/Backend/src/data/insuranceAliases.json
Normal file
32
apps/Backend/src/data/insuranceAliases.json
Normal file
@@ -0,0 +1,32 @@
|
||||
[
|
||||
{ "keyword": "masshealth", "siteKey": "MH" },
|
||||
{ "keyword": "mass health", "siteKey": "MH" },
|
||||
{ "keyword": "mh", "siteKey": "MH" },
|
||||
|
||||
{ "keyword": "commonwealth care alliance", "siteKey": "CCA" },
|
||||
{ "keyword": "cca", "siteKey": "CCA" },
|
||||
|
||||
{ "keyword": "delta dental of massachusetts","siteKey": "DDMA" },
|
||||
{ "keyword": "delta dental ma", "siteKey": "DDMA" },
|
||||
{ "keyword": "ddma", "siteKey": "DDMA" },
|
||||
|
||||
{ "keyword": "delta dental ins", "siteKey": "DELTA_INS" },
|
||||
{ "keyword": "delta ins", "siteKey": "DELTA_INS" },
|
||||
{ "keyword": "delta dental", "siteKey": "DELTA_INS" },
|
||||
|
||||
{ "keyword": "tufts sco", "siteKey": "TUFTS_SCO" },
|
||||
{ "keyword": "tufts", "siteKey": "TUFTS_SCO" },
|
||||
{ "keyword": "tufs", "siteKey": "TUFTS_SCO" },
|
||||
{ "keyword": "dentaquest", "siteKey": "TUFTS_SCO" },
|
||||
{ "keyword": "tuftssco", "siteKey": "TUFTS_SCO" },
|
||||
|
||||
{ "keyword": "united healthone sco", "siteKey": "UNITED_SCO" },
|
||||
{ "keyword": "united sco", "siteKey": "UNITED_SCO" },
|
||||
{ "keyword": "dentalhub", "siteKey": "UNITED_SCO" },
|
||||
{ "keyword": "united_sco", "siteKey": "UNITED_SCO" },
|
||||
|
||||
{ "keyword": "blue cross blue shield", "siteKey": "BCBS_MA" },
|
||||
{ "keyword": "bcbs ma", "siteKey": "BCBS_MA" },
|
||||
{ "keyword": "blue cross", "siteKey": "BCBS_MA" },
|
||||
{ "keyword": "bcbs", "siteKey": "BCBS_MA" }
|
||||
]
|
||||
@@ -165,7 +165,7 @@ router.post("/internal-chat", async (req: Request, res: Response): Promise<any>
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||
|
||||
const { message } = req.body;
|
||||
const { message, history } = req.body;
|
||||
if (!message?.trim()) return res.status(400).json({ message: "message is required" });
|
||||
|
||||
const aiSettings = await storage.getAiSettings(userId);
|
||||
@@ -183,7 +183,8 @@ router.post("/internal-chat", async (req: Request, res: Response): Promise<any>
|
||||
const classification = await classifyInternalChat(
|
||||
message.trim(),
|
||||
aiSettings.apiKey,
|
||||
extraSystemPrompt || undefined
|
||||
extraSystemPrompt || undefined,
|
||||
Array.isArray(history) ? history : []
|
||||
);
|
||||
|
||||
const response = await runInternalChatWorkflow(classification, userId, storage, customAliases);
|
||||
|
||||
Reference in New Issue
Block a user