feat: AI SMS reminder flow with two-message intro, smart reschedule with availability checks

- Reminder flow: send AI self-introduction as message 1 (Twilio REST API), intent response as message 2 (TwiML) so intro always arrives first
- LangGraph reminder graph: classify yes/no/other from patient reply; 'no' now asks 'When would you like to reschedule?' directly
- Reschedule flow: new asked_reschedule_datetime stage replaces multi-step ASAP/next-week flow
  - Date-only reply (e.g. '5/18'): ask for time separately, then confirm
  - Date+time reply (e.g. '5/18 at 10am'): go straight to confirmation
  - new asked_reschedule_time_for_date and asked_reschedule_confirm_datetime stages
- Date/time parsing: regex handles M/D and am/pm formats first; falls back to Gemini for natural language
- Day-level office hours check: if requested day is closed (e.g. Sunday), reply 'Our office is closed on [date]. Choose another day?'
- Time-level office hours check: if requested time is outside working hours (e.g. 12pm during lunch), reply with actual hours (e.g. '9:00 am – 12:00 pm and 1:00 pm – 5:00 pm')
- Slot availability check: verifies no conflicting appointment for same staff member
- After appointment confirmed: patient thank-you reply triggers warm closing with upcoming appointment time
- Schedule page: office hours summary bar above grid showing today's configured hours with link to settings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gitead
2026-05-11 16:01:23 -04:00
parent 585b448b6e
commit 1ff843bc79
5 changed files with 842 additions and 149 deletions

View File

@@ -17,7 +17,10 @@ export type ConversationStage =
| "asked_reschedule_preference" | "asked_reschedule_preference"
| "asked_reschedule_asap" | "asked_reschedule_asap"
| "asked_reschedule_next_week" | "asked_reschedule_next_week"
| "asked_reschedule_time"; | "asked_reschedule_time"
| "asked_reschedule_datetime"
| "asked_reschedule_time_for_date"
| "asked_reschedule_confirm_datetime";
// ── Conversation stage + AI handoff per patient (DB-persisted) ──────────────── // ── Conversation stage + AI handoff per patient (DB-persisted) ────────────────
@@ -77,6 +80,7 @@ export async function startRescheduleConversation(userId: number, patientId: num
interface PendingReschedule { interface PendingReschedule {
newDate: Date; newDate: Date;
dayLabel: string; dayLabel: string;
startTime?: string; // "HH:MM" — set when full datetime was parsed together
} }
const pendingRescheduleStore = new Map<string, PendingReschedule>(); const pendingRescheduleStore = new Map<string, PendingReschedule>();

View File

@@ -19,7 +19,7 @@ function classifyNode(state: GraphStateType) {
const text = state.message.toLowerCase().trim(); const text = state.message.toLowerCase().trim();
const yesPatterns = /\b(yes|yeah|yep|yup|sure|ok|okay|confirmed|confirm|will be there|sounds good|see you|great|perfect|absolutely|definitely|sí|si|claro|por supuesto|confirmo|de acuerdo|seguro|estaré|sim|confirmado|com certeza|好的|确认|可以|好|明白|نعم|حسنا|موافق|wi|dakò|oke)\b/; const yesPatterns = /\b(yes|yeah|yep|yup|sure|ok|okay|confirmed|confirm|will be there|sounds good|see you|great|perfect|absolutely|definitely|sí|si|claro|por supuesto|confirmo|de acuerdo|seguro|estaré|sim|confirmado|com certeza|好的|确认|可以|好|明白|نعم|حسنا|موافق|wi|dakò|oke)\b/;
const noPatterns = /\b(no|nope|can't|cannot|won't|not available|unavailable|cancel|reschedule|busy|sorry|unable|not coming|not going|no puedo|no podré|cancelar|reprogramar|ocupado|lo siento|não posso|não vou|reagendar|占线|无法|取消|لا|لا أستطيع|إلغاء|pa kapab|pa ka)\b/; const noPatterns = /\b(no|nope|can't|cannot|won't|not available|unavailable|cancel|reschedule|busy|sorry|unable|not coming|not going|no puedo|no podré|cancelar|reprogramar|ocupado|lo siento|não posso|não vou|reagendar|占线|无法|取消|لا|لا أستطيع|إلغاء|pa kapab|pa ka|can not make|cannot make|not make|make it)\b/;
if (yesPatterns.test(text)) return { intent: "yes" }; if (yesPatterns.test(text)) return { intent: "yes" };
if (noPatterns.test(text)) return { intent: "no" }; if (noPatterns.test(text)) return { intent: "no" };
@@ -51,13 +51,13 @@ function buildConfirmFallback(lang: string, apptDatetime: string): string {
// ── Reschedule fallbacks ────────────────────────────────────────────────────── // ── Reschedule fallbacks ──────────────────────────────────────────────────────
const RESCHEDULE_FALLBACKS: Record<string, string> = { const RESCHEDULE_FALLBACKS: Record<string, string> = {
English: "It is understandable! Would you like to reschedule?", English: "It is understandable! When would you like to reschedule?",
Spanish: "¡Lo entendemos! ¿Le gustaría reprogramar su cita?", Spanish: "¡Lo entendemos! ¿Cuándo le gustaría reprogramar su cita?",
Portuguese: "Entendemos! Gostaria de reagendar a sua consulta?", Portuguese: "Entendemos! Quando gostaria de reagendar a sua consulta?",
Mandarin: "我们理解!您想重新安排预约", Mandarin: "我们理解!您想什么时候重新安排预约?",
Cantonese: "我們理解!您想重新安排預約", Cantonese: "我們理解!您想幾時重新安排預約?",
Arabic: "نتفهم ذلك! هل تود إعادة جدولة موعدك؟", Arabic: "نتفهم ذلك! متى تود إعادة جدولة موعدك؟",
"Haitian Creole": "Nou konprann! Èske ou ta renmen repwograme randevou ou?", "Haitian Creole": "Nou konprann! Ki lè ou ta renmen repwograme randevou ou?",
}; };
// ── New-appointment fallbacks (other intent, appointment keywords detected) ─── // ── New-appointment fallbacks (other intent, appointment keywords detected) ───
@@ -101,7 +101,7 @@ async function confirmNode(state: GraphStateType, config: any) {
{ {
role: "system", role: "system",
content: content:
`You are a friendly dental office assistant. Write a short, warm SMS reply (1-2 sentences max) thanking the patient for confirming their appointment and reminding them of the date and time.${apptClause} You MUST reply in ${lang} regardless of the language the patient used. Do not add any formatting or extra text.`, `You are a friendly dental office assistant. Write a short, warm SMS reply (1-2 sentences max) thanking the patient for confirming their appointment and reminding them of the date and time.${apptClause} You MUST reply in ${lang}. Do not add any formatting or extra text.`,
}, },
{ role: "user", content: `Patient replied: "${state.message}"` }, { role: "user", content: `Patient replied: "${state.message}"` },
]); ]);
@@ -124,7 +124,7 @@ async function rescheduleNode(state: GraphStateType, config: any) {
{ {
role: "system", role: "system",
content: content:
`You are a friendly dental office assistant. The patient cannot make their appointment. Write a short, empathetic SMS reply (1 sentence max) that says it is understandable and asks if they would like to reschedule. You MUST reply in ${lang} regardless of the language the patient used. Do not add any formatting or extra text.`, `You are a friendly dental office assistant. The patient cannot make their appointment. Write a short, empathetic SMS reply (1-2 sentences max) acknowledging they can't make it and asking when they would like to reschedule. You MUST reply in ${lang}. Do not add any formatting or extra text.`,
}, },
{ role: "user", content: `Patient replied: "${state.message}"` }, { role: "user", content: `Patient replied: "${state.message}"` },
]); ]);
@@ -149,7 +149,7 @@ async function otherNode(state: GraphStateType, config: any) {
const response = await llm.invoke([ const response = await llm.invoke([
{ {
role: "system", role: "system",
content: `You are a friendly dental office AI assistant named Lisa. The patient wants to schedule an appointment. Ask them in ${lang} whether they are a new patient or an existing patient. One sentence, no formatting.`, content: `You are a friendly dental office AI assistant. The patient wants to schedule an appointment. Ask them in ${lang} whether they are a new patient or an existing patient. One sentence, no formatting.`,
}, },
{ role: "user", content: `Patient said: "${state.message}"` }, { role: "user", content: `Patient said: "${state.message}"` },
]); ]);
@@ -166,7 +166,7 @@ async function otherNode(state: GraphStateType, config: any) {
const response = await llm.invoke([ const response = await llm.invoke([
{ {
role: "system", role: "system",
content: `You are a friendly dental office AI assistant named Lisa. Respond helpfully to the patient's message in ${lang}. Keep it to 1-2 sentences, no formatting. For non-dental questions, let them know our office staff can assist.`, content: `You are a friendly dental office AI assistant. Respond helpfully to the patient's message in ${lang}. Keep it to 1-2 sentences, no formatting. For non-dental questions, let them know our office staff can assist.`,
}, },
{ role: "user", content: `Patient said: "${state.message}"` }, { role: "user", content: `Patient said: "${state.message}"` },
]); ]);

View File

@@ -20,18 +20,6 @@ function formattedDate(d: Date): string {
return `${DAYS[d.getDay()]}, ${MONTHS[d.getMonth()]} ${d.getDate()}`; return `${DAYS[d.getDay()]}, ${MONTHS[d.getMonth()]} ${d.getDate()}`;
} }
/** Get the day-of-week (0=Sun…6=Sat) of the patient's next scheduled appointment. */
async function getAppointmentDow(patientId: number): Promise<number> {
const today = new Date(); today.setHours(0, 0, 0, 0);
const appt = await db.appointment.findFirst({
where: { patientId, status: "scheduled", date: { gte: today } },
orderBy: { date: "asc" },
select: { date: true },
});
if (!appt) return -1;
return new Date(appt.date).getUTCDay();
}
function getTomorrow(): string { function getTomorrow(): string {
const t = new Date(); const t = new Date();
t.setDate(t.getDate() + 1); t.setDate(t.getDate() + 1);
@@ -41,7 +29,6 @@ function getTomorrow(): string {
function getNextWeekDays(): { mon: string; tue: string; wed: string } { function getNextWeekDays(): { mon: string; tue: string; wed: string } {
const today = new Date(); const today = new Date();
const dow = today.getDay(); const dow = today.getDay();
// Days until next Monday (always at least 1 day away, never 0)
const daysToMon = (8 - dow) % 7 || 7; const daysToMon = (8 - dow) % 7 || 7;
const mon = new Date(today); mon.setDate(today.getDate() + daysToMon); const mon = new Date(today); mon.setDate(today.getDate() + daysToMon);
const tue = new Date(mon); tue.setDate(mon.getDate() + 1); const tue = new Date(mon); tue.setDate(mon.getDate() + 1);
@@ -49,8 +36,6 @@ function getNextWeekDays(): { mon: string; tue: string; wed: string } {
return { mon: formattedDate(mon), tue: formattedDate(tue), wed: formattedDate(wed) }; return { mon: formattedDate(mon), tue: formattedDate(tue), wed: formattedDate(wed) };
} }
// ── Date objects for rescheduling ─────────────────────────────────────────────
function getTomorrowDate(): { date: Date; label: string } { function getTomorrowDate(): { date: Date; label: string } {
const d = new Date(); const d = new Date();
d.setDate(d.getDate() + 1); d.setDate(d.getDate() + 1);
@@ -58,11 +43,10 @@ function getTomorrowDate(): { date: Date; label: string } {
return { date: d, label: formattedDate(new Date(d.getTime() + d.getTimezoneOffset() * 60000)) }; return { date: d, label: formattedDate(new Date(d.getTime() + d.getTimezoneOffset() * 60000)) };
} }
function getNextWeekDateObjects(): { mon: { date: Date; label: string }; tue: { date: Date; label: string }; wed: { date: Date; label: string } } { function getNextWeekDateObjects() {
const today = new Date(); const today = new Date();
const dow = today.getDay(); const dow = today.getDay();
const daysToMon = (8 - dow) % 7 || 7; const daysToMon = (8 - dow) % 7 || 7;
const mkDate = (offset: number) => { const mkDate = (offset: number) => {
const d = new Date(today); const d = new Date(today);
d.setDate(today.getDate() + offset); d.setDate(today.getDate() + offset);
@@ -73,40 +57,359 @@ function getNextWeekDateObjects(): { mon: { date: Date; label: string }; tue: {
return { mon: mkDate(daysToMon), tue: mkDate(daysToMon + 1), wed: mkDate(daysToMon + 2) }; return { mon: mkDate(daysToMon), tue: mkDate(daysToMon + 1), wed: mkDate(daysToMon + 2) };
} }
// ── Time parsing ────────────────────────────────────────────────────────────── /** Format "HH:MM" (24-h) → "H:MM am/pm" */
function timeLabel(hhmm: string): string {
const [h, m] = hhmm.split(":").map(Number);
const h12 = (h! % 12) || 12;
const ampm = (h! >= 12) ? "pm" : "am";
return `${h12}:${String(m!).padStart(2, "0")} ${ampm}`;
}
// ── Datetime helpers ─────────────────────────────────────────────────────────
/** Returns true if the message contains an explicit time (not just a date). */
function messageHasTime(msg: string): boolean {
return (
/\b\d{1,2}(?::\d{2})?\s*(?:am|pm)\b/i.test(msg) ||
/\b([01]?\d|2[0-3]):[0-5]\d\b/.test(msg) ||
/\b(morning|afternoon|evening|mañana|tarde|manhã|上午|下午|صباح|مساء|maten|aprèmidi)\b/i.test(msg)
);
}
/**
* Parse only a date (no time) from a message like "5/18" or "next Monday".
* Returns a UTC-midnight Date and a date-only display label, or null.
*/
async function parseDateOnlyFromMessage(
message: string,
apiKey: string,
): Promise<{ date: Date; dateLabel: string } | null> {
const now = new Date();
const todayStr = now.toLocaleDateString("en-CA");
// Regex: M/D, M-D, MM/DD patterns
const slashMatch = message.match(/\b(\d{1,2})[\/\-](\d{1,2})(?:[\/\-](\d{2,4}))?\b/);
if (slashMatch) {
const mo = parseInt(slashMatch[1]!);
const dy = parseInt(slashMatch[2]!);
let yr = slashMatch[3]
? (slashMatch[3].length === 2 ? 2000 + parseInt(slashMatch[3]) : parseInt(slashMatch[3]))
: now.getFullYear();
const candidate = new Date(`${yr}-${String(mo).padStart(2,"0")}-${String(dy).padStart(2,"0")}T00:00:00`);
if (candidate <= now) yr += 1;
const dateStr = `${yr}-${String(mo).padStart(2,"0")}-${String(dy).padStart(2,"0")}`;
const parsedDate = new Date(dateStr + "T00:00:00.000Z");
if (!isNaN(parsedDate.getTime())) {
const local = new Date(dateStr + "T00:00:00");
const dateLabel = `${DAYS[local.getDay()]!}, ${MONTHS[local.getMonth()]!} ${local.getDate()}`;
return { date: parsedDate, dateLabel };
}
}
// Gemini fallback for "next Monday", "Tuesday", etc.
try {
const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey });
const res = await llm.invoke([
{
role: "system",
content:
`Today is ${todayStr}. Extract only the date from the patient message.
Return ONLY raw JSON: {"date":"YYYY-MM-DD"}
- date must be a future date after ${todayStr}.
- For relative names like "Monday" or "next Tuesday", use the next upcoming calendar date.
- If no valid future date found, return {"date":null}.`,
},
{ role: "user", content: message },
]);
const jsonMatch = String(res.content).match(/\{[\s\S]*?"date"[\s\S]*?\}/);
if (!jsonMatch) return null;
const json = JSON.parse(jsonMatch[0]);
if (!json.date || json.date === "null") return null;
const dateStr = String(json.date).trim();
const parsedDate = new Date(dateStr + "T00:00:00.000Z");
if (isNaN(parsedDate.getTime())) return null;
const local = new Date(dateStr + "T00:00:00");
const dateLabel = `${DAYS[local.getDay()]!}, ${MONTHS[local.getMonth()]!} ${local.getDate()}`;
return { date: parsedDate, dateLabel };
} catch {
return null;
}
}
// ── Datetime parsing ──────────────────────────────────────────────────────────
/**
* Parse a natural-language date + time preference from a patient SMS.
* Returns a future Date (UTC midnight), a 24-h startTime string, and a display label.
*/
async function parseDatetimeFromMessage(
message: string,
apiKey: string,
): Promise<{ date: Date; startTime: string; displayLabel: string } | null> {
const now = new Date();
const todayStr = now.toLocaleDateString("en-CA"); // YYYY-MM-DD local
// ── Step 1: regex pre-processing for common formats ───────────────────────
// Extract time (handles "10am", "10:30am", "2 pm", "14:00", "morning", "afternoon")
let preTime: string | null = null;
const ampmMatch = message.match(/\b(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b/i);
if (ampmMatch) {
let h = parseInt(ampmMatch[1]!);
const m = ampmMatch[2] ? parseInt(ampmMatch[2]) : 0;
const period = ampmMatch[3]!.toLowerCase();
if (period === "pm" && h < 12) h += 12;
if (period === "am" && h === 12) h = 0;
preTime = `${String(h).padStart(2,"0")}:${String(m).padStart(2,"0")}`;
} else if (/\bmorning\b/i.test(message)) preTime = "09:00";
else if (/\bafternoon\b/i.test(message)) preTime = "13:00";
// Extract date for M/D, M-D, MM/DD, MM-DD, M/D/YYYY patterns
let preDate: string | null = null;
const slashMatch = message.match(/\b(\d{1,2})[\/\-](\d{1,2})(?:[\/\-](\d{2,4}))?\b/);
if (slashMatch) {
const mo = parseInt(slashMatch[1]!);
const dy = parseInt(slashMatch[2]!);
let yr = slashMatch[3]
? (slashMatch[3].length === 2 ? 2000 + parseInt(slashMatch[3]) : parseInt(slashMatch[3]))
: now.getFullYear();
// If the resulting date is in the past, assume next year
const candidate = new Date(`${yr}-${String(mo).padStart(2,"0")}-${String(dy).padStart(2,"0")}T00:00:00`);
if (candidate <= now) yr += 1;
preDate = `${yr}-${String(mo).padStart(2,"0")}-${String(dy).padStart(2,"0")}`;
}
// If regex resolved both date and time, return immediately — no AI call needed
if (preDate && preTime) {
const parsedDate = new Date(preDate + "T00:00:00.000Z");
if (!isNaN(parsedDate.getTime())) {
const localDate = new Date(preDate + "T00:00:00");
const displayLabel = `${DAYS[localDate.getDay()]!}, ${MONTHS[localDate.getMonth()]!} ${localDate.getDate()} at ${timeLabel(preTime)}`;
return { date: parsedDate, startTime: preTime, displayLabel };
}
}
// ── Step 2: fall back to Google AI for natural language ───────────────────
try {
const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey });
const res = await llm.invoke([
{
role: "system",
content:
`Today is ${todayStr}. Extract the preferred appointment date and time from the patient's message.
Return ONLY a raw JSON object — no markdown, no code block, no explanation:
{"date":"YYYY-MM-DD","time":"HH:MM"}
Rules:
- date must be a future date after ${todayStr}, in YYYY-MM-DD format.
- time must be 24-hour HH:MM format. "morning" → "09:00", "afternoon" → "13:00".
- For relative names like "Monday" or "next Tuesday", use the next upcoming calendar date.
- If no time is mentioned, use "09:00".
- If no valid future date can be found, return {"date":null,"time":null}.`,
},
{ role: "user", content: message },
]);
// Robustly extract the JSON object from any wrapping text Gemini might add
const raw = String(res.content);
const jsonMatch = raw.match(/\{[\s\S]*?"date"[\s\S]*?\}/);
if (!jsonMatch) return null;
const json = JSON.parse(jsonMatch[0]);
if (!json.date || json.date === "null") return null;
// Normalise date to YYYY-MM-DD
const dateStr = String(json.date).trim();
const parsedDate = new Date(dateStr + "T00:00:00.000Z");
if (isNaN(parsedDate.getTime())) return null;
// Normalise time: accept "HH:MM", "H:MM", or "HH:MM AM/PM"
let startTime = preTime ?? "09:00"; // use regex-extracted time if available
if (!preTime && typeof json.time === "string") {
const rawTime = json.time.trim();
const t24 = rawTime.match(/^(\d{1,2}):(\d{2})(?:\s*(am|pm))?$/i);
if (t24) {
let h = parseInt(t24[1]!);
const m = parseInt(t24[2]!);
const per = t24[3]?.toLowerCase();
if (per === "pm" && h < 12) h += 12;
if (per === "am" && h === 12) h = 0;
startTime = `${String(h).padStart(2,"0")}:${String(m).padStart(2,"0")}`;
}
}
const localDate = new Date(dateStr + "T00:00:00");
const displayLabel = `${DAYS[localDate.getDay()]!}, ${MONTHS[localDate.getMonth()]!} ${localDate.getDate()} at ${timeLabel(startTime)}`;
return { date: parsedDate, startTime, displayLabel };
} catch {
return null;
}
}
// ── Availability checks ───────────────────────────────────────────────────────
/**
* Returns true if the given date+time is within the office's configured hours.
* Falls back to true (unrestricted) if no hours are configured.
*/
async function isWithinOfficeHours(
date: Date,
time: string,
userId: number,
): Promise<boolean> {
try {
const record = await storage.getOfficeHours(userId);
if (!record?.data) return true;
const data = record.data as any;
const dayNames = ["sunday","monday","tuesday","wednesday","thursday","friday","saturday"] as const;
const dayName = dayNames[date.getUTCDay()]!;
// Override dates: all slots are open
const ymd = date.toISOString().split("T")[0]!;
if ((data.overrideDates as string[] | undefined)?.includes(ymd)) return true;
// Check both doctors' and hygienists' hours — if within either, it's valid
for (const group of [data.doctors, data.hygienists]) {
const slot = group?.[dayName];
if (!slot?.enabled) continue;
if (
(time >= slot.amStart && time <= slot.amEnd) ||
(time >= slot.pmStart && time <= slot.pmEnd)
) return true;
}
return false;
} catch {
return true;
}
}
/**
* Returns whether the office is open at all on a given date (day-level check).
* Also returns the display day name (e.g. "Sunday") for use in error messages.
*/
async function isOfficeDayOpen(
date: Date,
userId: number,
): Promise<{ open: boolean; displayDay: string }> {
const displayDays = ["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"];
const displayDay = displayDays[date.getUTCDay()]!;
try {
const record = await storage.getOfficeHours(userId);
if (!record?.data) return { open: true, displayDay };
const data = record.data as any;
const dayKeys = ["sunday","monday","tuesday","wednesday","thursday","friday","saturday"] as const;
const dayKey = dayKeys[date.getUTCDay()]!;
// Override date — office is fully open
const ymd = date.toISOString().split("T")[0]!;
if ((data.overrideDates as string[] | undefined)?.includes(ymd)) return { open: true, displayDay };
// Open if at least one group (doctors or hygienists) has the day enabled
const doctorDay = data.doctors?.[dayKey];
const hygDay = data.hygienists?.[dayKey];
if (doctorDay?.enabled || hygDay?.enabled) return { open: true, displayDay };
return { open: false, displayDay };
} catch {
return { open: true, displayDay };
}
}
/**
* Returns the office hours for a given day as a human-readable string,
* e.g. "9:00 am 12:00 pm and 1:00 pm 5:00 pm".
* Returns null if no hours are configured or the day is closed.
*/
async function getOfficeHoursDisplay(date: Date, userId: number): Promise<string | null> {
try {
const record = await storage.getOfficeHours(userId);
if (!record?.data) return null;
const data = record.data as any;
const dayKeys = ["sunday","monday","tuesday","wednesday","thursday","friday","saturday"] as const;
const dayKey = dayKeys[date.getUTCDay()]!;
for (const group of [data.doctors, data.hygienists]) {
const slot = group?.[dayKey];
if (!slot?.enabled) continue;
return `${timeLabel(slot.amStart)} ${timeLabel(slot.amEnd)} and ${timeLabel(slot.pmStart)} ${timeLabel(slot.pmEnd)}`;
}
return null;
} catch {
return null;
}
}
/**
* Returns true if no existing appointment for the patient's staff conflicts with
* the new date+startTime.
*/
async function isSlotAvailable(
patientId: number,
newDate: Date,
newStartTime: string,
): Promise<boolean> {
try {
const today = new Date(); today.setHours(0, 0, 0, 0);
const current = await db.appointment.findFirst({
where: { patientId, status: "scheduled", date: { gte: today } },
orderBy: { date: "asc" },
select: { staffId: true, startTime: true, endTime: true },
});
if (!current) return true;
// Compute new end time preserving original appointment duration
const [sh, sm] = current.startTime.split(":").map(Number);
const [eh, em] = current.endTime.split(":").map(Number);
const durationMin = (eh! * 60 + em!) - (sh! * 60 + sm!);
const [nh, nm] = newStartTime.split(":").map(Number);
const endTotal = nh! * 60 + nm! + durationMin;
const newEndTime = `${String(Math.floor(endTotal / 60)).padStart(2,"0")}:${String(endTotal % 60).padStart(2,"0")}`;
// Check for overlapping appointments for the same staff member on that day
const conflicts = await db.appointment.findMany({
where: {
staffId: current.staffId,
date: newDate,
status: { not: "cancelled" },
patientId: { not: patientId }, // exclude the patient's own appointment
},
select: { startTime: true, endTime: true },
});
for (const c of conflicts) {
if (newStartTime < c.endTime && newEndTime > c.startTime) return false;
}
return true;
} catch {
return true;
}
}
// ── Time parsing (legacy) ─────────────────────────────────────────────────────
/** Parse patient's time preference into 24-h "HH:MM" string or null. */
async function parseTime(message: string, apiKey: string): Promise<string | null> { async function parseTime(message: string, apiKey: string): Promise<string | null> {
const t = message.toLowerCase(); const t = message.toLowerCase();
// Keyword shortcuts
if (/\bmorning\b|mañana|manhã|上午|صباح|maten/i.test(t)) return "09:00"; if (/\bmorning\b|mañana|manhã|上午|صباح|maten/i.test(t)) return "09:00";
if (/\bafternoon\b|tarde|après-midi|下午|مساء|aprèmidi/i.test(t)) return "13:00"; if (/\bafternoon\b|tarde|après-midi|下午|مساء|aprèmidi/i.test(t)) return "13:00";
// Numeric patterns: "10am", "10:30", "2pm", "14:00"
const ampm = t.match(/\b(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b/); const ampm = t.match(/\b(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b/);
const clock = t.match(/\b([01]?\d|2[0-3]):([0-5]\d)\b/); const clock = t.match(/\b([01]?\d|2[0-3]):([0-5]\d)\b/);
if (ampm) { if (ampm) {
let h = parseInt(ampm[1]!); let h = parseInt(ampm[1]!);
const m = ampm[2] ? parseInt(ampm[2]) : 0; const m = ampm[2] ? parseInt(ampm[2]) : 0;
if (ampm[3] === "pm" && h < 12) h += 12; if (ampm[3] === "pm" && h < 12) h += 12;
if (ampm[3] === "am" && h === 12) h = 0; if (ampm[3] === "am" && h === 12) h = 0;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`; return `${String(h).padStart(2,"0")}:${String(m).padStart(2,"0")}`;
} }
if (clock) return clock[0]!; if (clock) return clock[0]!;
// LLM fallback
try { try {
const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey }); const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey });
const res = await llm.invoke([ const res = await llm.invoke([
{ role: "system", content: 'Extract the time from the message. Return ONLY a 24-hour time in "HH:MM" format (e.g., "10:00", "14:30"). If no time is mentioned, return "null".' }, { role: "system", content: 'Extract the time from the message. Return ONLY a 24-hour time in "HH:MM" format. If no time is mentioned, return "null".' },
{ role: "user", content: message }, { role: "user", content: message },
]); ]);
const raw = String(res.content).trim(); const raw = String(res.content).trim();
if (/^([01]?\d|2[0-3]):[0-5]\d$/.test(raw)) return raw; if (/^([01]?\d|2[0-3]):[0-5]\d$/.test(raw)) return raw;
} catch { /* fall through */ } } catch { /* fall through */ }
return null; return null;
} }
@@ -115,8 +418,8 @@ async function parseTime(message: string, apiKey: string): Promise<string | null
async function moveAppointment( async function moveAppointment(
patientId: number, patientId: number,
newDate: Date, newDate: Date,
newStartTime: string, // "HH:MM" newStartTime: string,
): Promise<string> { ): Promise<"ok" | "no_appointment"> {
const today = new Date(); today.setHours(0, 0, 0, 0); const today = new Date(); today.setHours(0, 0, 0, 0);
const appt = await db.appointment.findFirst({ const appt = await db.appointment.findFirst({
where: { patientId, status: "scheduled", date: { gte: today } }, where: { patientId, status: "scheduled", date: { gte: today } },
@@ -124,13 +427,12 @@ async function moveAppointment(
}); });
if (!appt) return "no_appointment"; if (!appt) return "no_appointment";
// Preserve original duration
const [sh, sm] = appt.startTime.split(":").map(Number); const [sh, sm] = appt.startTime.split(":").map(Number);
const [eh, em] = appt.endTime.split(":").map(Number); const [eh, em] = appt.endTime.split(":").map(Number);
const durationMin = (eh! * 60 + em!) - (sh! * 60 + sm!); const durationMin = (eh! * 60 + em!) - (sh! * 60 + sm!);
const [nh, nm] = newStartTime.split(":").map(Number); const [nh, nm] = newStartTime.split(":").map(Number);
const endTotalMin = nh! * 60 + nm! + durationMin; const endTotal = nh! * 60 + nm! + durationMin;
const newEndTime = `${String(Math.floor(endTotalMin / 60)).padStart(2, "0")}:${String(endTotalMin % 60).padStart(2, "0")}`; const newEndTime = `${String(Math.floor(endTotal / 60)).padStart(2,"0")}:${String(endTotal % 60).padStart(2,"0")}`;
await storage.updateAppointment(appt.id, { await storage.updateAppointment(appt.id, {
date: newDate, date: newDate,
@@ -202,7 +504,319 @@ export async function runRescheduleStep(
const t = message.toLowerCase(); const t = message.toLowerCase();
const tx = TRANSFER[lang] ?? TRANSFER["English"]!; const tx = TRANSFER[lang] ?? TRANSFER["English"]!;
// ── asked_reschedule_confirm: patient answered "Would you like to reschedule?" ── // ── asked_reschedule_datetime ─────────────────────────────────────────────
// Patient provides date/time preference.
// If only a date is given, ask for the time. If both are given, ask to confirm.
if (stage === "asked_reschedule_datetime") {
const hasTime = messageHasTime(message);
if (!hasTime) {
// Try to parse date only
const parsedDate = await parseDateOnlyFromMessage(message, apiKey);
if (!parsedDate) {
const fallbacks: Record<string, string> = {
English: "I didn't catch that. What day would you prefer? For example: 'Monday', 'next Tuesday', or '5/18'.",
Spanish: "No entendí. ¿Qué día prefiere? Por ejemplo: 'lunes', 'próximo martes' o '18/5'.",
Portuguese: "Não entendi. Que dia você prefere? Por exemplo: 'Segunda', 'próxima terça' ou '18/5'.",
Mandarin: "我没听清。您希望哪天?例如:'星期一'、'下周二'或'5月18日'。",
Cantonese: "我沒聽清。您希望哪天?例如:'星期一'、'下週二'或'5月18日'。",
Arabic: "لم أفهم. ما اليوم الذي تفضله؟ مثلاً: 'الاثنين' أو '18/5'.",
"Haitian Creole": "Mwen pa konprann. Ki jou ou prefere? Pa egzanp: 'Lendi', 'Madi pwochèn', oswa '5/18'.",
};
return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_datetime" };
}
// Date found — check that the office is actually open that day
const { date, dateLabel } = parsedDate;
const dayCheck = await isOfficeDayOpen(date, userId);
if (!dayCheck.open) {
const fallbacks: Record<string, string> = {
English: `Our office is closed on ${dateLabel} (${dayCheck.displayDay}). Can you please choose another day?`,
Spanish: `Nuestra oficina está cerrada el ${dateLabel} (${dayCheck.displayDay}). ¿Puede elegir otro día?`,
Portuguese: `Nosso consultório está fechado em ${dateLabel} (${dayCheck.displayDay}). Pode escolher outro dia?`,
Mandarin: `我们诊所在 ${dateLabel}${dayCheck.displayDay})不开放。请您选择另一天好吗?`,
Cantonese: `我們診所在 ${dateLabel}${dayCheck.displayDay})不開放。請您選擇另一天好嗎?`,
Arabic: `مكتبنا مغلق في ${dateLabel} (${dayCheck.displayDay}). هل يمكنك اختيار يوم آخر؟`,
"Haitian Creole": `Biwo nou fèmen nan ${dateLabel} (${dayCheck.displayDay}). Tanpri chwazi yon lòt jou?`,
};
return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_datetime" };
}
// Day is open — save date and ask for time
setPendingReschedule(userId, patientId, { newDate: date, dayLabel: dateLabel });
const fallbacks: Record<string, string> = {
English: `What time do you prefer on ${dateLabel}?`,
Spanish: `¿A qué hora prefiere el ${dateLabel}?`,
Portuguese: `Que horário você prefere em ${dateLabel}?`,
Mandarin: `您希望在 ${dateLabel} 几点?`,
Cantonese: `您希望在 ${dateLabel} 幾點?`,
Arabic: `ما الوقت الذي تفضله في ${dateLabel}؟`,
"Haitian Creole": `Ki lè ou prefere nan ${dateLabel}?`,
};
const askTimeReply = await llmReply(
`You are a friendly dental office assistant. The patient wants to reschedule to ${dateLabel} but hasn't given a time. Ask them in ${lang} what time they prefer on that day. 1 sentence, no formatting.`,
`Patient wants ${dateLabel} but gave no time.`,
fallbacks[lang] ?? fallbacks["English"]!,
apiKey,
);
return { reply: askTimeReply, nextStage: "asked_reschedule_time_for_date" };
}
// Both date and time present — parse the full datetime
const parsed = await parseDatetimeFromMessage(message, apiKey);
if (!parsed) {
const fallbacks: Record<string, string> = {
English: "I didn't catch that. What day and time would you prefer? For example: 'Monday at 10am' or '5/18 at 2pm'.",
Spanish: "No entendí. ¿Qué día y hora prefiere? Por ejemplo: 'lunes a las 10am' o '18/5 a las 2pm'.",
Portuguese: "Não entendi. Que dia e horário você prefere? Por exemplo: 'Segunda às 10h' ou '18/5 às 14h'.",
Mandarin: "我没听清。您希望哪天几点?例如:'星期一上午10点'或'5月18日下午2点'。",
Cantonese: "我沒聽清。您希望哪天幾點?例如:'星期一上午10點'或'5月18日下午2點'。",
Arabic: "لم أفهم. ما اليوم والوقت الذي تفضله؟ مثلاً: 'الاثنين الساعة 10 صباحاً'.",
"Haitian Creole": "Mwen pa konprann. Ki jou ak ki lè ou prefere? Pa egzanp: 'Lendi 10am' oswa '5/18 2pm'.",
};
return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_datetime" };
}
const { date, startTime, displayLabel } = parsed;
// Check the office is open on that day before confirming
const dayCheck2 = await isOfficeDayOpen(date, userId);
if (!dayCheck2.open) {
// Extract a date-only label for the message (strip the "at HH:MM" part)
const datePart = displayLabel.split(" at ")[0] ?? displayLabel;
const fallbacks: Record<string, string> = {
English: `Our office is closed on ${datePart} (${dayCheck2.displayDay}). Can you please choose another day?`,
Spanish: `Nuestra oficina está cerrada el ${datePart} (${dayCheck2.displayDay}). ¿Puede elegir otro día?`,
Portuguese: `Nosso consultório está fechado em ${datePart} (${dayCheck2.displayDay}). Pode escolher outro dia?`,
Mandarin: `我们诊所在 ${datePart}${dayCheck2.displayDay})不开放。请您选择另一天好吗?`,
Cantonese: `我們診所在 ${datePart}${dayCheck2.displayDay})不開放。請您選擇另一天好嗎?`,
Arabic: `مكتبنا مغلق في ${datePart} (${dayCheck2.displayDay}). هل يمكنك اختيار يوم آخر؟`,
"Haitian Creole": `Biwo nou fèmen nan ${datePart} (${dayCheck2.displayDay}). Tanpri chwazi yon lòt jou?`,
};
return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_datetime" };
}
setPendingReschedule(userId, patientId, { newDate: date, dayLabel: displayLabel, startTime });
const fallbacks: Record<string, string> = {
English: `Just to confirm — do you prefer ${displayLabel}?`,
Spanish: `Solo para confirmar — ¿prefiere el ${displayLabel}?`,
Portuguese: `Só para confirmar — você prefere ${displayLabel}?`,
Mandarin: `确认一下——您希望的时间是 ${displayLabel} 吗?`,
Cantonese: `確認一下——您希望的時間是 ${displayLabel} 嗎?`,
Arabic: `فقط للتأكيد — هل تفضل ${displayLabel}؟`,
"Haitian Creole": `Jis pou konfime — èske ou prefere ${displayLabel}?`,
};
const confirmReply = await llmReply(
`You are a friendly dental office AI assistant. The patient mentioned a date/time preference that you interpreted as "${displayLabel}". Ask them in ${lang} to confirm: "Do you mean ${displayLabel}?" 1 sentence, natural and friendly. No formatting.`,
`Patient said: "${message}"`,
fallbacks[lang] ?? fallbacks["English"]!,
apiKey,
);
return { reply: confirmReply, nextStage: "asked_reschedule_confirm_datetime" };
}
// ── asked_reschedule_time_for_date ────────────────────────────────────────
// Patient was asked "What time do you prefer on [date]?" and now replies with a time.
if (stage === "asked_reschedule_time_for_date") {
const pending = getPendingReschedule(userId, patientId);
if (!pending) {
const fallbacks: Record<string, string> = {
English: "Sorry, I lost track. What day and time would you prefer?",
Spanish: "Lo siento, perdí el hilo. ¿Qué día y hora prefiere?",
Portuguese: "Desculpe, perdi o fio. Que dia e horário você prefere?",
Mandarin: "抱歉,我失去了之前的信息。您希望哪天几点?",
Cantonese: "抱歉,我失去了之前的資訊。您希望哪天幾點?",
Arabic: "عذراً، فقدت المعلومات السابقة. ما اليوم والوقت الذي تفضله؟",
"Haitian Creole": "Regrèt, mwen pèdi trak. Ki jou ak ki lè ou prefere?",
};
return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_datetime" };
}
// Parse the time from the patient's reply
const startTime = await parseTime(message, apiKey);
if (!startTime) {
const fallbacks: Record<string, string> = {
English: `I didn't catch the time. What time would you prefer on ${pending.dayLabel}? For example: '10am', '2pm', or '1:30pm'.`,
Spanish: `No entendí la hora. ¿Qué hora prefiere el ${pending.dayLabel}? Por ejemplo: '10am', '2pm' o '1:30pm'.`,
Portuguese: `Não entendi o horário. Que hora você prefere em ${pending.dayLabel}? Por exemplo: '10h', '14h' ou '13:30'.`,
Mandarin: `我没听清时间。您在 ${pending.dayLabel} 几点例如上午10点、下午2点或下午1:30。`,
Cantonese: `我沒聽清時間。您在 ${pending.dayLabel} 幾點例如上午10點、下午2點或下午1:30。`,
Arabic: `لم أفهم الوقت. ما الوقت الذي تفضله في ${pending.dayLabel}؟ مثلاً: 10 صباحاً أو 2 مساءً.`,
"Haitian Creole": `Mwen pa konprann lè a. Ki lè ou prefere nan ${pending.dayLabel}? Pa egzanp: 10am, 2pm, oswa 1:30pm.`,
};
return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_time_for_date" };
}
// Check that the requested time is within the office's working hours
const withinHours = await isWithinOfficeHours(pending.newDate, startTime, userId);
if (!withinHours) {
const hoursDisplay = await getOfficeHoursDisplay(pending.newDate, userId);
const timeLbl = timeLabel(startTime);
const fallbacks: Record<string, string> = {
English: hoursDisplay
? `Our office is not available at ${timeLbl} on ${pending.dayLabel}. Our hours are ${hoursDisplay}. What other time do you prefer?`
: `Our office is not available at ${timeLbl} on ${pending.dayLabel}. What other time do you prefer?`,
Spanish: hoursDisplay
? `Nuestra oficina no está disponible a las ${timeLbl} el ${pending.dayLabel}. Nuestro horario es ${hoursDisplay}. ¿Qué otro horario prefiere?`
: `Nuestra oficina no está disponible a las ${timeLbl} el ${pending.dayLabel}. ¿Qué otro horario prefiere?`,
Portuguese: hoursDisplay
? `Nosso consultório não está disponível às ${timeLbl} em ${pending.dayLabel}. Nosso horário é ${hoursDisplay}. Que outro horário você prefere?`
: `Nosso consultório não está disponível às ${timeLbl} em ${pending.dayLabel}. Que outro horário você prefere?`,
Mandarin: hoursDisplay
? `我们诊所在 ${pending.dayLabel} ${timeLbl} 不开放。我们的工作时间是 ${hoursDisplay}。您希望改什么时间?`
: `我们诊所在 ${pending.dayLabel} ${timeLbl} 不开放。您希望改什么时间?`,
Cantonese: hoursDisplay
? `我們診所在 ${pending.dayLabel} ${timeLbl} 不開放。我們的工作時間是 ${hoursDisplay}。您希望改什麼時間?`
: `我們診所在 ${pending.dayLabel} ${timeLbl} 不開放。您希望改什麼時間?`,
Arabic: hoursDisplay
? `مكتبنا غير متاح في ${timeLbl} يوم ${pending.dayLabel}. ساعات العمل: ${hoursDisplay}. ما وقت آخر تفضله؟`
: `مكتبنا غير متاح في ${timeLbl} يوم ${pending.dayLabel}. ما وقت آخر تفضله؟`,
"Haitian Creole": hoursDisplay
? `Biwo nou pa disponib a ${timeLbl} nan ${pending.dayLabel}. Orè nou se ${hoursDisplay}. Ki lòt lè ou prefere?`
: `Biwo nou pa disponib a ${timeLbl} nan ${pending.dayLabel}. Ki lòt lè ou prefere?`,
};
return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_time_for_date" };
}
// Time is valid — build the full display label and ask for confirmation
const fullLabel = `${pending.dayLabel} at ${timeLabel(startTime)}`;
setPendingReschedule(userId, patientId, { newDate: pending.newDate, dayLabel: fullLabel, startTime });
const fallbacks: Record<string, string> = {
English: `Just to confirm — do you prefer ${fullLabel}?`,
Spanish: `Solo para confirmar — ¿prefiere el ${fullLabel}?`,
Portuguese: `Só para confirmar — você prefere ${fullLabel}?`,
Mandarin: `确认一下——您希望的时间是 ${fullLabel} 吗?`,
Cantonese: `確認一下——您希望的時間是 ${fullLabel} 嗎?`,
Arabic: `فقط للتأكيد — هل تفضل ${fullLabel}؟`,
"Haitian Creole": `Jis pou konfime — èske ou prefere ${fullLabel}?`,
};
const confirmReply = await llmReply(
`You are a friendly dental office AI assistant. The patient wants ${fullLabel}. Ask them in ${lang} to confirm: "Do you mean ${fullLabel}?" 1 sentence, natural and friendly. No formatting.`,
`Patient said: "${message}"`,
fallbacks[lang] ?? fallbacks["English"]!,
apiKey,
);
return { reply: confirmReply, nextStage: "asked_reschedule_confirm_datetime" };
}
// ── asked_reschedule_confirm_datetime ─────────────────────────────────────
// Patient confirms (yes) or corrects (no) the parsed date/time.
if (stage === "asked_reschedule_confirm_datetime") {
const pending = getPendingReschedule(userId, patientId);
if (!pending) {
// State lost — ask again from scratch
const fallbacks: Record<string, string> = {
English: "Sorry, I lost track. What day and time would you prefer?",
Spanish: "Lo siento, perdí el hilo. ¿Qué día y hora prefiere?",
Portuguese: "Desculpe, perdi o fio. Que dia e horário você prefere?",
Mandarin: "抱歉,我失去了之前的信息。您希望哪天几点?",
Cantonese: "抱歉,我失去了之前的資訊。您希望哪天幾點?",
Arabic: "عذراً، فقدت المعلومات السابقة. ما اليوم والوقت الذي تفضله؟",
"Haitian Creole": "Regrèt, mwen pèdi trak. Ki jou ak ki lè ou prefere?",
};
return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_datetime" };
}
// Patient said NO / wants to change
if (no(t)) {
clearPendingReschedule(userId, patientId);
const fallbacks: Record<string, string> = {
English: "No problem! What day and time would you prefer? For example: 'Monday at 10am' or '5/18 at 2pm'.",
Spanish: "¡Sin problema! ¿Qué día y hora prefiere? Por ejemplo: 'lunes a las 10am'.",
Portuguese: "Sem problema! Que dia e horário você prefere? Por exemplo: 'Segunda às 10h'.",
Mandarin: "没关系!您希望哪天几点?例如:'星期一上午10点'。",
Cantonese: "沒問題!您希望哪天幾點?例如:'星期一上午10點'。",
Arabic: "لا بأس! ما اليوم والوقت الذي تفضله؟ مثلاً: 'الاثنين الساعة 10 صباحاً'.",
"Haitian Creole": "Pa gen pwoblèm! Ki jou ak ki lè ou prefere? Pa egzanp: 'Lendi 10am'.",
};
return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_datetime" };
}
// Patient said YES — proceed with availability check
const { newDate: date, startTime: pendingTime, dayLabel: displayLabel } = pending;
const startTime = pendingTime ?? "09:00";
clearPendingReschedule(userId, patientId);
// Check office hours — include actual hours in the message so patient knows what's available
const withinHours = await isWithinOfficeHours(date, startTime, userId);
if (!withinHours) {
const hoursDisplay = await getOfficeHoursDisplay(date, userId);
const timeLbl = timeLabel(startTime);
// Extract date portion for the message (strip "at HH:MM" if present)
const datePart = displayLabel.split(" at ")[0] ?? displayLabel;
const fallbacks: Record<string, string> = {
English: hoursDisplay
? `Our office is not available at ${timeLbl} on ${datePart}. Our hours are ${hoursDisplay}. What other time do you prefer?`
: `Our office is not available at ${timeLbl} on ${datePart}. What other time do you prefer?`,
Spanish: hoursDisplay
? `Nuestra oficina no está disponible a las ${timeLbl} el ${datePart}. Nuestro horario es ${hoursDisplay}. ¿Qué otro horario prefiere?`
: `Nuestra oficina no está disponible a las ${timeLbl} el ${datePart}. ¿Qué otro horario prefiere?`,
Portuguese: hoursDisplay
? `Nosso consultório não está disponível às ${timeLbl} em ${datePart}. Nosso horário é ${hoursDisplay}. Que outro horário você prefere?`
: `Nosso consultório não está disponível às ${timeLbl} em ${datePart}. Que outro horário você prefere?`,
Mandarin: hoursDisplay
? `我们诊所在 ${datePart} ${timeLbl} 不开放。我们的工作时间是 ${hoursDisplay}。您希望改什么时间?`
: `我们诊所在 ${datePart} ${timeLbl} 不开放。您希望改什么时间?`,
Cantonese: hoursDisplay
? `我們診所在 ${datePart} ${timeLbl} 不開放。我們的工作時間是 ${hoursDisplay}。您希望改什麼時間?`
: `我們診所在 ${datePart} ${timeLbl} 不開放。您希望改什麼時間?`,
Arabic: hoursDisplay
? `مكتبنا غير متاح في ${timeLbl} يوم ${datePart}. ساعات العمل: ${hoursDisplay}. ما وقت آخر تفضله؟`
: `مكتبنا غير متاح في ${timeLbl} يوم ${datePart}. ما وقت آخر تفضله؟`,
"Haitian Creole": hoursDisplay
? `Biwo nou pa disponib a ${timeLbl} nan ${datePart}. Orè nou se ${hoursDisplay}. Ki lòt lè ou prefere?`
: `Biwo nou pa disponib a ${timeLbl} nan ${datePart}. Ki lòt lè ou prefere?`,
};
return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_datetime" };
}
// Check slot availability
const available = await isSlotAvailable(patientId, date, startTime);
if (!available) {
const fallbacks: Record<string, string> = {
English: `Sorry, ${displayLabel} is not available. What other time do you prefer?`,
Spanish: `Lo sentimos, ${displayLabel} no está disponible. ¿Qué otro horario prefiere?`,
Portuguese: `Infelizmente ${displayLabel} não está disponível. Que outro horário você prefere?`,
Mandarin: `抱歉,${displayLabel} 该时间段不可用。您有其他偏好的时间吗?`,
Cantonese: `抱歉,${displayLabel} 該時間段不可用。您有其他偏好的時間嗎?`,
Arabic: `عذراً، ${displayLabel} غير متاح. ما وقت آخر تفضله؟`,
"Haitian Creole": `Regrèt, ${displayLabel} pa disponib. Ki lòt lè ou prefere?`,
};
return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_datetime" };
}
// Move the appointment
const result = await moveAppointment(patientId, date, startTime);
if (result === "no_appointment") {
const fallbacks: Record<string, string> = {
English: `I wasn't able to find your appointment to update. Our dental receptionist will contact you to confirm ${displayLabel}.`,
Spanish: `No encontré su cita. El personal le contactará para confirmar el ${displayLabel}.`,
Portuguese: `Não encontrei sua consulta. Nossa equipe confirmará ${displayLabel}.`,
Mandarin: `我找不到您的预约进行更新。我们的工作人员将联系您确认${displayLabel}`,
Cantonese: `我找不到您的預約進行更新。我們的工作人員將聯絡您確認${displayLabel}`,
Arabic: `لم أجد موعدك لتحديثه. سيتصل بك موظفونا لتأكيد ${displayLabel}.`,
"Haitian Creole": `Mwen pa jwenn randevou ou. Anplwaye nou yo pral kontakte ou pou konfime ${displayLabel}.`,
};
return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "done" };
}
// Success — confirm to patient
const fallback = `Your appointment is moved to ${displayLabel}. Our dental receptionist will confirm it with you tomorrow.`;
const reply = await llmReply(
`You are a friendly dental office AI assistant. The patient's appointment has been successfully moved to ${displayLabel}. Write a warm confirmation message in ${lang}: tell them the appointment is moved to ${displayLabel}, and that our dental receptionist will confirm it with them tomorrow. 2 sentences max, no formatting.`,
`Appointment rescheduled to ${displayLabel}.`,
fallback,
apiKey,
);
return { reply, nextStage: "done" };
}
// ── asked_reschedule_confirm: patient answered the reschedule question ────
if (stage === "asked_reschedule_confirm") { if (stage === "asked_reschedule_confirm") {
if (no(t)) { if (no(t)) {
const fallbacks: Record<string, string> = { const fallbacks: Record<string, string> = {
@@ -217,52 +831,28 @@ export async function runRescheduleStep(
const fallback = fallbacks[lang] ?? fallbacks["English"]!; const fallback = fallbacks[lang] ?? fallbacks["English"]!;
const reply = await llmReply( const reply = await llmReply(
`You are a friendly dental office assistant. The patient does not want to reschedule. Write a warm, brief closing message in ${lang}. 1 sentence, no formatting.`, `You are a friendly dental office assistant. The patient does not want to reschedule. Write a warm, brief closing message in ${lang}. 1 sentence, no formatting.`,
`Patient said: "${message}"`, fallback, apiKey `Patient said: "${message}"`, fallback, apiKey,
); );
return { reply, nextStage: "done" }; return { reply, nextStage: "done" };
} }
// Patient confirmed they want to reschedule — move straight to datetime request
if (yes(t)) { if (yes(t)) {
// Check if original appointment was MonThu (days 14)
const dow = await getAppointmentDow(patientId);
const isMonToThu = dow >= 1 && dow <= 4;
if (isMonToThu) {
// Offer ASAP or next week
const fallbacks: Record<string, string> = { const fallbacks: Record<string, string> = {
English: "Would you like to reschedule as soon as possible, or would you prefer next week?", English: "What day and time would you like? For example: 'Monday at 10am' or 'next Tuesday afternoon'.",
Spanish: "¿Le gustaría reprogramar lo antes posible, o prefiere la semana que viene?", Spanish: "¿Qué día y hora prefiere? Por ejemplo: 'lunes a las 10am' o 'martes por la tarde'.",
Portuguese: "Gostaria de reagendar o mais rápido possível, ou prefere a semana que vem?", Portuguese: "Que dia e horário você prefere? Por exemplo: 'Segunda às 10h' ou 'terça de tarde'.",
Mandarin: "您想尽快重新安排预约,还是下周更方便?", Mandarin: "您希望哪天几点?例如:'星期一上午10点'或'下周二下午'。",
Cantonese: "您想盡快重新安排預約,還是下週更方便?", Cantonese: "您希望哪天幾點?例如:'星期一上午10點'或'下週二下午'。",
Arabic: "هل تفضل إعادة الجدولة في أقرب وقت ممكن، أم تفضل الأسبوع القادم؟", Arabic: "ما اليوم والوقت الذي تفضله؟ مثلاً: 'الاثنين الساعة 10 صباحاً' أو 'الثلاثاء بعد الظهر'.",
"Haitian Creole": "Èske ou ta renmen repwograme pi vit posib, oswa ou prefere semèn pwochèn?", "Haitian Creole": "Ki jou ak ki lè ou prefere? Pa egzanp: 'Lendi 10am' oswa 'Madi apremidi'.",
}; };
const fallback = fallbacks[lang] ?? fallbacks["English"]!; const fallback = fallbacks[lang] ?? fallbacks["English"]!;
const reply = await llmReply( const reply = await llmReply(
`You are a friendly dental office assistant. The patient wants to reschedule. Their original appointment was on a weekday. Ask in ${lang} whether they prefer to reschedule as soon as possible or next week. 1 sentence, no formatting.`, `You are a friendly dental office assistant. The patient wants to reschedule. Ask them in ${lang} what day and time they prefer. Give 1-2 examples like "Monday at 10am" or "next Tuesday afternoon". 1-2 sentences, no formatting.`,
`Patient said: "${message}"`, fallback, apiKey `Patient wants to reschedule.`, fallback, apiKey,
); );
return { reply, nextStage: "asked_reschedule_preference" }; return { reply, nextStage: "asked_reschedule_datetime" };
} else {
// Original appointment was Fri/Sat/Sun — go straight to next week
const { mon, tue, wed } = getNextWeekDays();
const fallbacks: Record<string, string> = {
English: `I can schedule you for next week. Would ${mon}, ${tue}, or ${wed} work for you?`,
Spanish: `Puedo programarle para la semana que viene. ¿Le vendría bien el ${mon}, ${tue} o el ${wed}?`,
Portuguese: `Posso agendá-lo para a semana que vem. ${mon}, ${tue} ou ${wed} seria bom para você?`,
Mandarin: `我可以安排您在下周预约。${mon}${tue}${wed} 方便吗?`,
Cantonese: `我可以安排您在下週預約。${mon}${tue}${wed} 方便嗎?`,
Arabic: `يمكنني جدولتك للأسبوع القادم. هل ${mon} أو ${tue} أو ${wed} مناسب لك؟`,
"Haitian Creole": `Mwen ka pwograme ou pou semèn pwochèn. ${mon}, ${tue}, oswa ${wed} ka travay pou ou?`,
};
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
const reply = await llmReply(
`You are a friendly dental office assistant. Offer the patient next week's Monday (${mon}), Tuesday (${tue}), or Wednesday (${wed}) in ${lang}. 1-2 sentences, no formatting.`,
`Patient wants to reschedule.`, fallback, apiKey
);
return { reply, nextStage: "asked_reschedule_next_week" };
}
} }
return { reply: tx, nextStage: "done" }; return { reply: tx, nextStage: "done" };
@@ -284,7 +874,7 @@ export async function runRescheduleStep(
const fallback = fallbacks[lang] ?? fallbacks["English"]!; const fallback = fallbacks[lang] ?? fallbacks["English"]!;
const reply = await llmReply( const reply = await llmReply(
`You are a friendly dental office assistant. Ask the patient in ${lang} if they can come in tomorrow, ${tomorrow}. 1 sentence, no formatting.`, `You are a friendly dental office assistant. Ask the patient in ${lang} if they can come in tomorrow, ${tomorrow}. 1 sentence, no formatting.`,
`Patient wants to reschedule ASAP.`, fallback, apiKey `Patient wants to reschedule ASAP.`, fallback, apiKey,
); );
return { reply, nextStage: "asked_reschedule_asap" }; return { reply, nextStage: "asked_reschedule_asap" };
} }
@@ -294,16 +884,16 @@ export async function runRescheduleStep(
const fallbacks: Record<string, string> = { const fallbacks: Record<string, string> = {
English: `I can schedule you for next week. Would ${mon}, ${tue}, or ${wed} work for you?`, English: `I can schedule you for next week. Would ${mon}, ${tue}, or ${wed} work for you?`,
Spanish: `Puedo programarle para la semana que viene. ¿Le vendría bien el ${mon}, ${tue} o el ${wed}?`, Spanish: `Puedo programarle para la semana que viene. ¿Le vendría bien el ${mon}, ${tue} o el ${wed}?`,
Portuguese: `Posso agendá-lo para a semana que vem. ${mon}, ${tue} ou ${wed} seria bom para você?`, Portuguese: `Posso agendá-lo para a semana que vem. ${mon}, ${tue} ou ${wed} seria bom?`,
Mandarin: `我可以安排您在下周预约。${mon}${tue}${wed} 方便吗?`, Mandarin: `我可以安排您在下周预约。${mon}${tue}${wed} 方便吗?`,
Cantonese: `我可以安排您在下週預約。${mon}${tue}${wed} 方便嗎?`, Cantonese: `我可以安排您在下週預約。${mon}${tue}${wed} 方便嗎?`,
Arabic: `يمكنني جدولتك للأسبوع القادم. هل ${mon} أو ${tue} أو ${wed} مناسب لك؟`, Arabic: `يمكنني جدولتك للأسبوع القادم. هل ${mon} أو ${tue} أو ${wed} مناسب لك؟`,
"Haitian Creole": `Mwen ka pwograme ou pou semèn pwochèn. ${mon}, ${tue}, oswa ${wed} ka travay pou ou?`, "Haitian Creole": `Mwen ka pwograme ou pou semèn pwochèn. ${mon}, ${tue}, oswa ${wed} ka travay?`,
}; };
const fallback = fallbacks[lang] ?? fallbacks["English"]!; const fallback = fallbacks[lang] ?? fallbacks["English"]!;
const reply = await llmReply( const reply = await llmReply(
`You are a friendly dental office assistant. Offer next week's Monday (${mon}), Tuesday (${tue}), or Wednesday (${wed}) in ${lang}. 1-2 sentences, no formatting.`, `You are a friendly dental office assistant. Offer next week's Monday (${mon}), Tuesday (${tue}), or Wednesday (${wed}) in ${lang}. 1-2 sentences, no formatting.`,
`Patient prefers next week.`, fallback, apiKey `Patient prefers next week.`, fallback, apiKey,
); );
return { reply, nextStage: "asked_reschedule_next_week" }; return { reply, nextStage: "asked_reschedule_next_week" };
} }
@@ -316,7 +906,6 @@ export async function runRescheduleStep(
if (yes(t)) { if (yes(t)) {
const { date, label } = getTomorrowDate(); const { date, label } = getTomorrowDate();
setPendingReschedule(userId, patientId, { newDate: date, dayLabel: label }); setPendingReschedule(userId, patientId, { newDate: date, dayLabel: label });
const fallbacks: Record<string, string> = { const fallbacks: Record<string, string> = {
English: `${label} it is! Would you prefer morning (9am12pm) or afternoon (1pm5pm)?`, English: `${label} it is! Would you prefer morning (9am12pm) or afternoon (1pm5pm)?`,
Spanish: `¡${label} perfecto! ¿Prefiere la mañana (9am12pm) o la tarde (1pm5pm)?`, Spanish: `¡${label} perfecto! ¿Prefiere la mañana (9am12pm) o la tarde (1pm5pm)?`,
@@ -329,31 +918,28 @@ export async function runRescheduleStep(
const fallback = fallbacks[lang] ?? fallbacks["English"]!; const fallback = fallbacks[lang] ?? fallbacks["English"]!;
const reply = await llmReply( const reply = await llmReply(
`You are a friendly dental office assistant. The patient confirmed ${label}. Ask in ${lang} whether they prefer morning (9am-12pm) or afternoon (1pm-5pm). 1 sentence, no formatting.`, `You are a friendly dental office assistant. The patient confirmed ${label}. Ask in ${lang} whether they prefer morning (9am-12pm) or afternoon (1pm-5pm). 1 sentence, no formatting.`,
`Patient confirmed tomorrow.`, fallback, apiKey `Patient confirmed tomorrow.`, fallback, apiKey,
); );
return { reply, nextStage: "asked_reschedule_time" }; return { reply, nextStage: "asked_reschedule_time" };
} }
if (no(t)) { if (no(t)) {
// Can't make tomorrow — offer next week instead
const { mon, tue, wed } = getNextWeekDays(); const { mon, tue, wed } = getNextWeekDays();
const fallbacks: Record<string, string> = { const fallbacks: Record<string, string> = {
English: `No problem! What about next week? Would ${mon}, ${tue}, or ${wed} work for you?`, English: `No problem! What about next week? Would ${mon}, ${tue}, or ${wed} work?`,
Spanish: `¡Sin problema! ¿Qué le parece la semana que viene? ¿Le vendría bien el ${mon}, ${tue} o el ${wed}?`, Spanish: `¡Sin problema! ¿Qué le parece la semana que viene? ¿El ${mon}, ${tue} o el ${wed}?`,
Portuguese: `Sem problema! E na semana que vem? ${mon}, ${tue} ou ${wed} seria bom?`, Portuguese: `Sem problema! E na semana que vem? ${mon}, ${tue} ou ${wed}?`,
Mandarin: `没关系!下周怎么样?${mon}${tue}${wed} 方便吗?`, Mandarin: `没关系!下周怎么样?${mon}${tue}${wed} 方便吗?`,
Cantonese: `沒問題!下週怎麼樣?${mon}${tue}${wed} 方便嗎?`, Cantonese: `沒問題!下週怎麼樣?${mon}${tue}${wed} 方便嗎?`,
Arabic: `لا بأس! ماذا عن الأسبوع القادم؟ هل ${mon} أو ${tue} أو ${wed} يناسبك؟`, Arabic: `لا بأس! ماذا عن الأسبوع القادم؟ ${mon} أو ${tue} أو ${wed}؟`,
"Haitian Creole": `Pa gen pwoblèm! Ki sa ki dire semèn pwochèn? ${mon}, ${tue}, oswa ${wed} ka travay?`, "Haitian Creole": `Pa gen pwoblèm! Semèn pwochèn? ${mon}, ${tue}, oswa ${wed}?`,
}; };
const fallback = fallbacks[lang] ?? fallbacks["English"]!; const fallback = fallbacks[lang] ?? fallbacks["English"]!;
const reply = await llmReply( const reply = await llmReply(
`You are a friendly dental office assistant. The patient cannot come tomorrow. Offer next week: ${mon}, ${tue}, or ${wed} in ${lang}. 1-2 sentences, no formatting.`, `You are a friendly dental office assistant. The patient cannot come tomorrow. Offer next week: ${mon}, ${tue}, or ${wed} in ${lang}. 1-2 sentences, no formatting.`,
`Patient can't come tomorrow.`, fallback, apiKey `Patient can't come tomorrow.`, fallback, apiKey,
); );
return { reply, nextStage: "asked_reschedule_next_week" }; return { reply, nextStage: "asked_reschedule_next_week" };
} }
return { reply: tx, nextStage: "done" }; return { reply: tx, nextStage: "done" };
} }
@@ -367,7 +953,6 @@ export async function runRescheduleStep(
if (chosen) { if (chosen) {
setPendingReschedule(userId, patientId, { newDate: chosen.date, dayLabel: chosen.label }); setPendingReschedule(userId, patientId, { newDate: chosen.date, dayLabel: chosen.label });
const day = chosen.label; const day = chosen.label;
const fallbacks: Record<string, string> = { const fallbacks: Record<string, string> = {
English: `${day} works! Would you prefer morning (9am12pm) or afternoon (1pm5pm)?`, English: `${day} works! Would you prefer morning (9am12pm) or afternoon (1pm5pm)?`,
@@ -381,12 +966,11 @@ export async function runRescheduleStep(
const fallback = fallbacks[lang] ?? fallbacks["English"]!; const fallback = fallbacks[lang] ?? fallbacks["English"]!;
const reply = await llmReply( const reply = await llmReply(
`You are a friendly dental office assistant. The patient chose ${day}. Ask in ${lang} whether they prefer morning (9am-12pm) or afternoon (1pm-5pm). 1 sentence, no formatting.`, `You are a friendly dental office assistant. The patient chose ${day}. Ask in ${lang} whether they prefer morning (9am-12pm) or afternoon (1pm-5pm). 1 sentence, no formatting.`,
`Patient chose ${day}.`, fallback, apiKey `Patient chose ${day}.`, fallback, apiKey,
); );
return { reply, nextStage: "asked_reschedule_time" }; return { reply, nextStage: "asked_reschedule_time" };
} }
// Day not clearly detected — ask again with the specific options
const { mon, tue, wed } = getNextWeekDays(); const { mon, tue, wed } = getNextWeekDays();
const fallbacks: Record<string, string> = { const fallbacks: Record<string, string> = {
English: `Which day works best — ${mon}, ${tue}, or ${wed}?`, English: `Which day works best — ${mon}, ${tue}, or ${wed}?`,
@@ -394,26 +978,19 @@ export async function runRescheduleStep(
Portuguese: `Qual dia é melhor — ${mon}, ${tue} ou ${wed}?`, Portuguese: `Qual dia é melhor — ${mon}, ${tue} ou ${wed}?`,
Mandarin: `哪天最方便——${mon}${tue} 还是 ${wed}`, Mandarin: `哪天最方便——${mon}${tue} 还是 ${wed}`,
Cantonese: `哪天最方便——${mon}${tue} 還是 ${wed}`, Cantonese: `哪天最方便——${mon}${tue} 還是 ${wed}`,
Arabic: `أي يوم هو الأفضل لك${mon} أو ${tue} أو ${wed}؟`, Arabic: `أي يوم هو الأفضل — ${mon} أو ${tue} أو ${wed}؟`,
"Haitian Creole": `Ki jou ki pi bon — ${mon}, ${tue}, oswa ${wed}?`, "Haitian Creole": `Ki jou ki pi bon — ${mon}, ${tue}, oswa ${wed}?`,
}; };
const fallback = fallbacks[lang] ?? fallbacks["English"]!; return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_next_week" };
return { reply: fallback, nextStage: "asked_reschedule_next_week" };
} }
// ── asked_reschedule_time: patient picked morning / afternoon / specific time ── // ── asked_reschedule_time: patient picked morning / afternoon / specific time ──
if (stage === "asked_reschedule_time") { if (stage === "asked_reschedule_time") {
const pending = getPendingReschedule(userId, patientId); const pending = getPendingReschedule(userId, patientId);
if (!pending) return { reply: tx, nextStage: "done" };
if (!pending) {
// Edge case: lost state — fall back gracefully
return { reply: tx, nextStage: "done" };
}
const startTime = await parseTime(message, apiKey); const startTime = await parseTime(message, apiKey);
if (!startTime) { if (!startTime) {
// Couldn't parse time — ask again
const fallbacks: Record<string, string> = { const fallbacks: Record<string, string> = {
English: "I didn't catch the time. Would you prefer morning (9am12pm) or afternoon (1pm5pm)?", English: "I didn't catch the time. Would you prefer morning (9am12pm) or afternoon (1pm5pm)?",
Spanish: "No entendí la hora. ¿Prefiere la mañana (9am12pm) o la tarde (1pm5pm)?", Spanish: "No entendí la hora. ¿Prefiere la mañana (9am12pm) o la tarde (1pm5pm)?",
@@ -426,43 +1003,28 @@ export async function runRescheduleStep(
return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_time" }; return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_time" };
} }
// Update the appointment in the database
const updateResult = await moveAppointment(patientId, pending.newDate, startTime); const updateResult = await moveAppointment(patientId, pending.newDate, startTime);
clearPendingReschedule(userId, patientId); clearPendingReschedule(userId, patientId);
const [h, m] = startTime.split(":").map(Number); const apptLabel = `${pending.dayLabel} at ${timeLabel(startTime)}`;
const h12 = h! % 12 || 12;
const ampm = h! >= 12 ? "pm" : "am";
const timeLabel = `${h12}:${String(m!).padStart(2, "0")} ${ampm}`;
const apptLabel = `${pending.dayLabel} at ${timeLabel}`;
if (updateResult === "no_appointment") { if (updateResult === "no_appointment") {
const fallbacks: Record<string, string> = { const fallbacks: Record<string, string> = {
English: `I couldn't find your appointment to update. Our staff will contact you to confirm ${apptLabel}.`, English: `I couldn't find your appointment to update. Our staff will contact you to confirm ${apptLabel}.`,
Spanish: `No encontré su cita para actualizar. El personal le contactará para confirmar el ${apptLabel}.`, Spanish: `No encontré su cita para actualizar. El personal le contactará para confirmar el ${apptLabel}.`,
Portuguese: `Não encontrei sua consulta para atualizar. Nossa equipe entrará em contato para confirmar ${apptLabel}.`, Portuguese: `Não encontrei sua consulta. Nossa equipe entrará em contato para confirmar ${apptLabel}.`,
Mandarin: `我找不到您的预约进行更新。我们的工作人员将联系您确认${apptLabel}`, Mandarin: `我找不到您的预约进行更新。我们的工作人员将联系您确认${apptLabel}`,
Cantonese: `我找不到您的預約進行更新。我們的工作人員將聯絡您確認${apptLabel}`, Cantonese: `我找不到您的預約進行更新。我們的工作人員將聯絡您確認${apptLabel}`,
Arabic: `لم أجد موعدك لتحديثه. سيتصل بك موظفونا لتأكيد ${apptLabel}.`, Arabic: `لم أجد موعدك لتحديثه. سيتصل بك موظفونا لتأكيد ${apptLabel}.`,
"Haitian Creole": `Mwen pa jwenn randevou ou pou mete ajou. Anplwaye nou yo pral kontakte ou pou konfime ${apptLabel}.`, "Haitian Creole": `Mwen pa jwenn randevou ou. Anplwaye nou yo pral kontakte ou pou konfime ${apptLabel}.`,
}; };
return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "done" }; return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "done" };
} }
// Success const fallback = `Your appointment has been moved to ${apptLabel}. Our dental receptionist will confirm it with you tomorrow.`;
const fallbacks: Record<string, string> = {
English: `Your appointment has been moved to ${apptLabel}. See you then!`,
Spanish: `Su cita ha sido cambiada al ${apptLabel}. ¡Hasta entonces!`,
Portuguese: `Sua consulta foi remarcada para ${apptLabel}. Até lá!`,
Mandarin: `您的预约已更改为${apptLabel}。到时见!`,
Cantonese: `您的預約已更改為${apptLabel}。到時見!`,
Arabic: `تم تغيير موعدك إلى ${apptLabel}. نراك قريباً!`,
"Haitian Creole": `Randevou ou a deplase ale nan ${apptLabel}. N'ap wè ou lè sa a!`,
};
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
const reply = await llmReply( const reply = await llmReply(
`You are a friendly dental office assistant. The patient's appointment has been successfully rescheduled to ${apptLabel}. Confirm in ${lang} with enthusiasm. 1 sentence, no formatting.`, `You are a friendly dental office assistant. The patient's appointment has been rescheduled to ${apptLabel}. Confirm in ${lang}: say the appointment is moved to ${apptLabel} and that our dental receptionist will confirm it with them tomorrow. 2 sentences, no formatting.`,
`Appointment moved to ${apptLabel}.`, fallback, apiKey `Appointment moved to ${apptLabel}.`, fallback, apiKey,
); );
return { reply, nextStage: "done" }; return { reply, nextStage: "done" };
} }

View File

@@ -266,12 +266,39 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
return res.send(twimlReply(text)); return res.send(twimlReply(text));
}; };
// ── Stage: reminder_initial → send reminder greeting ───────────────── // ── Stage: reminder_initial → two messages: 1) AI intro, 2) intent response ──
if (stage === "reminder_initial") { if (stage === "reminder_initial") {
const rawGreeting = chatTemplates.reminderGreeting || const rawGreeting = chatTemplates.reminderGreeting ||
`Hi! My name is Lisa, the dedicated AI assistant at {officeName}. I can confirm or reschedule your appointment and answer general questions 24/7. I will reply your message at any time you need.`; `Hi! My name is Lisa, the dedicated AI assistant at {officeName}. I can confirm or reschedule your appointment and answer general questions 24/7.`;
const introText = applyOfficeName(rawGreeting, officeName);
return reply(applyOfficeName(rawGreeting, officeName), "greeted"); // Use Google AI (LangGraph) to read the patient's reply and classify yes/no
const apptDatetime = await getAppointmentDatetime(patient.id);
const { reply: intentReply, intent } = await runReminderGraph(
Body, aiSettings.apiKey, language, apptDatetime,
chatTemplates.rescheduleGreeting, chatTemplates.generalFallback
);
if (intentReply) {
let nextStage: ConversationStage;
if (intent === "no") nextStage = "asked_reschedule_datetime";
else if (intent === "wants_appointment") nextStage = "asked_new_or_existing";
else nextStage = "done";
// Send message 1 (AI intro) via REST API — queued FIRST in Twilio so it arrives first
const twilioSettings = await storage.getTwilioSettings(patient.userId);
if (twilioSettings) {
const client = twilio(twilioSettings.accountSid, twilioSettings.authToken);
await client.messages.create({ body: introText, from: twilioSettings.phoneNumber, to: From });
await saveOutbound(patient.id, introText);
}
// Send message 2 (yes/no response) via TwiML — queued SECOND
return reply(intentReply, nextStage);
}
// No clear intent detected — send only the intro and wait for next reply
return reply(introText, "greeted");
} }
// ── Stage: greeted → classify yes/no for appointment reminder ──────── // ── Stage: greeted → classify yes/no for appointment reminder ────────
@@ -283,7 +310,7 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
); );
if (aiReply) { if (aiReply) {
let nextStage: ConversationStage; let nextStage: ConversationStage;
if (intent === "no") nextStage = "asked_reschedule_confirm"; if (intent === "no") nextStage = "asked_reschedule_datetime";
else if (intent === "wants_appointment") nextStage = "asked_new_or_existing"; else if (intent === "wants_appointment") nextStage = "asked_new_or_existing";
else nextStage = "done"; else nextStage = "done";
return reply(aiReply, nextStage); return reply(aiReply, nextStage);
@@ -294,7 +321,8 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
const rescheduleStages: ConversationStage[] = [ const rescheduleStages: ConversationStage[] = [
"asked_reschedule_confirm", "asked_reschedule_preference", "asked_reschedule_confirm", "asked_reschedule_preference",
"asked_reschedule_asap", "asked_reschedule_next_week", "asked_reschedule_asap", "asked_reschedule_next_week",
"asked_reschedule_time", "asked_reschedule_time", "asked_reschedule_datetime",
"asked_reschedule_time_for_date", "asked_reschedule_confirm_datetime",
]; ];
if (rescheduleStages.includes(stage)) { if (rescheduleStages.includes(stage)) {
const { reply: aiReply, nextStage } = await runRescheduleStep( const { reply: aiReply, nextStage } = await runRescheduleStep(
@@ -412,6 +440,56 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
return reply(aiReply, nextStage); return reply(aiReply, nextStage);
} }
// ── Stage: done → closing thank-you reply ────────────────────────────
// When the patient sends a thank-you / acknowledgement after the conversation
// is complete, reply warmly with their upcoming appointment time.
if (stage === "done") {
const isThanks = /\b(thank|thanks|thank you|ty|ok|okay|great|perfect|sounds good|got it|understood|alright|appreciate|wonderful|excellent|awesome|cool|nice|good)\b/i.test(Body);
if (isThanks) {
const apptDatetime = await getAppointmentDatetime(patient.id);
const CLOSING: Record<string, string> = {
English: apptDatetime
? `Thank you for choosing our office! We look forward to seeing you on ${apptDatetime}.`
: `Thank you for choosing our office! We look forward to seeing you soon.`,
Spanish: apptDatetime
? `¡Gracias por elegirnos! Le esperamos el ${apptDatetime}.`
: `¡Gracias por elegirnos! Le esperamos pronto.`,
Portuguese: apptDatetime
? `Obrigado por nos escolher! Aguardamos sua visita em ${apptDatetime}.`
: `Obrigado por nos escolher! Aguardamos sua visita em breve.`,
Mandarin: apptDatetime
? `感谢您选择我们!期待在 ${apptDatetime} 见到您。`
: `感谢您选择我们!期待很快见到您。`,
Cantonese: apptDatetime
? `感謝您選擇我們!期待在 ${apptDatetime} 見到您。`
: `感謝您選擇我們!期待很快見到您。`,
Arabic: apptDatetime
? `شكراً لاختيارك عيادتنا! نتطلع إلى رؤيتك في ${apptDatetime}.`
: `شكراً لاختيارك عيادتنا! نتطلع إلى رؤيتك قريباً.`,
"Haitian Creole": apptDatetime
? `Mèsi dèske ou chwazi nou! N'ap tann ou ${apptDatetime}.`
: `Mèsi dèske ou chwazi nou! N'ap tann ou byento.`,
};
const fallback = CLOSING[language] ?? CLOSING["English"]!;
if (aiSettings?.apiKey && apptDatetime) {
try {
const { ChatGoogleGenerativeAI } = await import("@langchain/google-genai");
const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey: aiSettings.apiKey });
const res = await llm.invoke([
{
role: "system",
content: `You are a friendly dental office AI assistant. The patient just said "${Body}" after completing a conversation. Reply warmly in ${language}, thanking them for choosing the office and reminding them of their upcoming appointment on ${apptDatetime}. 1-2 sentences, no formatting.`,
},
{ role: "user", content: Body },
]);
const aiMsg = String(res.content).trim();
if (aiMsg) return reply(aiMsg, "done");
} catch { /* fall through to fallback */ }
}
return reply(fallback, "done");
}
}
// ── Stage: initial (no active conversation) ─────────────────────────── // ── Stage: initial (no active conversation) ───────────────────────────
// Check after-hours: if enabled and currently outside office hours → start new-patient flow // Check after-hours: if enabled and currently outside office hours → start new-patient flow
if (stage === "initial" || stage === "done") { if (stage === "initial" || stage === "done") {

View File

@@ -27,6 +27,8 @@ import {
Stethoscope, Stethoscope,
Download, Download,
MessageSquare, MessageSquare,
Clock,
ExternalLink,
} from "lucide-react"; } from "lucide-react";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { Calendar } from "@/components/ui/calendar"; import { Calendar } from "@/components/ui/calendar";
@@ -1681,6 +1683,53 @@ export default function AppointmentsPage() {
</div> </div>
</div> </div>
{/* Office Hours Summary */}
{(() => {
const dayNames = ["sunday","monday","tuesday","wednesday","thursday","friday","saturday"] as const;
const dayName = dayNames[selectedDate.getDay()]!;
const fmt = (t: string) => {
const [hh, mm] = t.split(":").map(Number);
const period = (hh ?? 0) >= 12 ? "PM" : "AM";
const h12 = (hh ?? 0) > 12 ? (hh ?? 0) - 12 : (hh ?? 0) === 0 ? 12 : (hh ?? 0);
return `${h12}:${String(mm ?? 0).padStart(2,"0")} ${period}`;
};
const doctorHours = officeHours?.doctors?.[dayName];
const hygHours = officeHours?.hygienists?.[dayName];
const isOverride = officeHours?.overrideDates?.includes(selectedDate.toLocaleDateString("en-CA"));
return (
<div className="px-4 pb-3 flex items-center gap-4 flex-wrap text-xs text-gray-500 border-b">
<div className="flex items-center gap-1.5 font-medium text-gray-700">
<Clock className="h-3.5 w-3.5 text-teal-600" />
<span>Office Hours</span>
<button onClick={() => setLocation("/settings/officehours")} className="ml-1 text-teal-600 hover:text-teal-700" title="Edit office hours">
<ExternalLink className="h-3 w-3" />
</button>
</div>
{!officeHours ? (
<span className="italic text-gray-400">Not configured <button onClick={() => setLocation("/settings/officehours")} className="text-teal-600 underline">set up office hours</button></span>
) : isOverride ? (
<span className="text-teal-600 font-medium">Override active all slots open today</span>
) : (
<>
<span>
<span className="font-medium text-gray-600">Doctors (AC):</span>{" "}
{doctorHours?.enabled
? `${fmt(doctorHours.amStart)}${fmt(doctorHours.amEnd)}, ${fmt(doctorHours.pmStart)}${fmt(doctorHours.pmEnd)}`
: <span className="text-gray-400">Closed</span>}
</span>
<span>
<span className="font-medium text-gray-600">Hygienists (DF):</span>{" "}
{hygHours?.enabled
? `${fmt(hygHours.amStart)}${fmt(hygHours.amEnd)}, ${fmt(hygHours.pmStart)}${fmt(hygHours.pmEnd)}`
: <span className="text-gray-400">Closed</span>}
</span>
</>
)}
</div>
);
})()}
{/* Schedule Grid with Drag and Drop */} {/* Schedule Grid with Drag and Drop */}
<DndProvider backend={HTML5Backend}> <DndProvider backend={HTML5Backend}>
<div className="overflow-x-auto"> <div className="overflow-x-auto">