diff --git a/apps/Backend/src/ai/aiHandoffStore.ts b/apps/Backend/src/ai/aiHandoffStore.ts index 695178f5..a5e71517 100644 --- a/apps/Backend/src/ai/aiHandoffStore.ts +++ b/apps/Backend/src/ai/aiHandoffStore.ts @@ -17,7 +17,10 @@ export type ConversationStage = | "asked_reschedule_preference" | "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"; // ── Conversation stage + AI handoff per patient (DB-persisted) ──────────────── @@ -75,8 +78,9 @@ export async function startRescheduleConversation(userId: number, patientId: num // ── Pending reschedule (in-memory — seconds-lived within a single exchange) ─── interface PendingReschedule { - newDate: Date; - dayLabel: string; + newDate: Date; + dayLabel: string; + startTime?: string; // "HH:MM" — set when full datetime was parsed together } const pendingRescheduleStore = new Map(); diff --git a/apps/Backend/src/ai/reminder-graph.ts b/apps/Backend/src/ai/reminder-graph.ts index 3ac006c9..048c162f 100644 --- a/apps/Backend/src/ai/reminder-graph.ts +++ b/apps/Backend/src/ai/reminder-graph.ts @@ -19,7 +19,7 @@ function classifyNode(state: GraphStateType) { 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 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 (noPatterns.test(text)) return { intent: "no" }; @@ -51,13 +51,13 @@ function buildConfirmFallback(lang: string, apptDatetime: string): string { // ── Reschedule fallbacks ────────────────────────────────────────────────────── const RESCHEDULE_FALLBACKS: Record = { - English: "It is understandable! Would you like to reschedule?", - Spanish: "¡Lo entendemos! ¿Le gustaría reprogramar su cita?", - Portuguese: "Entendemos! Gostaria de reagendar a sua consulta?", - Mandarin: "我们理解!您想重新安排预约吗?", - Cantonese: "我們理解!您想重新安排預約嗎?", - Arabic: "نتفهم ذلك! هل تود إعادة جدولة موعدك؟", - "Haitian Creole": "Nou konprann! Èske ou ta renmen repwograme randevou ou?", + English: "It is understandable! When would you like to reschedule?", + Spanish: "¡Lo entendemos! ¿Cuándo le gustaría reprogramar su cita?", + Portuguese: "Entendemos! Quando gostaria de reagendar a sua consulta?", + Mandarin: "我们理解!您想什么时候重新安排预约?", + Cantonese: "我們理解!您想幾時重新安排預約?", + Arabic: "نتفهم ذلك! متى تود إعادة جدولة موعدك؟", + "Haitian Creole": "Nou konprann! Ki lè ou ta renmen repwograme randevou ou?", }; // ── New-appointment fallbacks (other intent, appointment keywords detected) ─── @@ -101,7 +101,7 @@ async function confirmNode(state: GraphStateType, config: any) { { role: "system", 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}"` }, ]); @@ -113,7 +113,7 @@ async function confirmNode(state: GraphStateType, config: any) { async function rescheduleNode(state: GraphStateType, config: any) { const apiKey: string | undefined = config?.configurable?.apiKey; - const lang = state.language || "English"; + const lang = state.language || "English"; const fallback = state.rescheduleGreeting || (RESCHEDULE_FALLBACKS[lang] ?? RESCHEDULE_FALLBACKS["English"]!); if (!apiKey) return { reply: fallback }; @@ -124,7 +124,7 @@ async function rescheduleNode(state: GraphStateType, config: any) { { role: "system", 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}"` }, ]); @@ -149,7 +149,7 @@ async function otherNode(state: GraphStateType, config: any) { const response = await llm.invoke([ { 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}"` }, ]); @@ -166,7 +166,7 @@ async function otherNode(state: GraphStateType, config: any) { const response = await llm.invoke([ { 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}"` }, ]); diff --git a/apps/Backend/src/ai/reschedule-graph.ts b/apps/Backend/src/ai/reschedule-graph.ts index d6164135..e94ec316 100644 --- a/apps/Backend/src/ai/reschedule-graph.ts +++ b/apps/Backend/src/ai/reschedule-graph.ts @@ -20,18 +20,6 @@ function formattedDate(d: Date): string { 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 { - 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 { const t = new Date(); t.setDate(t.getDate() + 1); @@ -41,7 +29,6 @@ function getTomorrow(): string { function getNextWeekDays(): { mon: string; tue: string; wed: string } { const today = new Date(); const dow = today.getDay(); - // Days until next Monday (always at least 1 day away, never 0) const daysToMon = (8 - dow) % 7 || 7; const mon = new Date(today); mon.setDate(today.getDate() + daysToMon); 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) }; } -// ── Date objects for rescheduling ───────────────────────────────────────────── - function getTomorrowDate(): { date: Date; label: string } { const d = new Date(); 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)) }; } -function getNextWeekDateObjects(): { mon: { date: Date; label: string }; tue: { date: Date; label: string }; wed: { date: Date; label: string } } { +function getNextWeekDateObjects() { const today = new Date(); const dow = today.getDay(); const daysToMon = (8 - dow) % 7 || 7; - const mkDate = (offset: number) => { const d = new Date(today); 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) }; } -// ── 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 { + 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 { + 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 { + 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 { const t = message.toLowerCase(); - - // Keyword shortcuts 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"; - - // Numeric patterns: "10am", "10:30", "2pm", "14:00" 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/); - if (ampm) { let h = parseInt(ampm[1]!); const m = ampm[2] ? parseInt(ampm[2]) : 0; if (ampm[3] === "pm" && h < 12) h += 12; 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]!; - - // LLM fallback try { const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey }); 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 }, ]); const raw = String(res.content).trim(); if (/^([01]?\d|2[0-3]):[0-5]\d$/.test(raw)) return raw; } catch { /* fall through */ } - return null; } @@ -115,8 +418,8 @@ async function parseTime(message: string, apiKey: string): Promise { + newStartTime: string, +): Promise<"ok" | "no_appointment"> { const today = new Date(); today.setHours(0, 0, 0, 0); const appt = await db.appointment.findFirst({ where: { patientId, status: "scheduled", date: { gte: today } }, @@ -124,13 +427,12 @@ async function moveAppointment( }); if (!appt) return "no_appointment"; - // Preserve original duration const [sh, sm] = appt.startTime.split(":").map(Number); const [eh, em] = appt.endTime.split(":").map(Number); const durationMin = (eh! * 60 + em!) - (sh! * 60 + sm!); - const [nh, nm] = newStartTime.split(":").map(Number); - const endTotalMin = nh! * 60 + nm! + durationMin; - const newEndTime = `${String(Math.floor(endTotalMin / 60)).padStart(2, "0")}:${String(endTotalMin % 60).padStart(2, "0")}`; + 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")}`; await storage.updateAppointment(appt.id, { date: newDate, @@ -202,7 +504,319 @@ export async function runRescheduleStep( const t = message.toLowerCase(); 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 (no(t)) { const fallbacks: Record = { @@ -217,52 +831,28 @@ export async function runRescheduleStep( const fallback = fallbacks[lang] ?? fallbacks["English"]!; 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.`, - `Patient said: "${message}"`, fallback, apiKey + `Patient said: "${message}"`, fallback, apiKey, ); return { reply, nextStage: "done" }; } + // Patient confirmed they want to reschedule — move straight to datetime request if (yes(t)) { - // Check if original appointment was Mon–Thu (days 1–4) - const dow = await getAppointmentDow(patientId); - const isMonToThu = dow >= 1 && dow <= 4; - - if (isMonToThu) { - // Offer ASAP or next week - const fallbacks: Record = { - English: "Would you like to reschedule as soon as possible, or would you prefer next week?", - Spanish: "¿Le gustaría reprogramar lo antes posible, o prefiere la semana que viene?", - Portuguese: "Gostaria de reagendar o mais rápido possível, ou prefere a semana que vem?", - Mandarin: "您想尽快重新安排预约,还是下周更方便?", - Cantonese: "您想盡快重新安排預約,還是下週更方便?", - Arabic: "هل تفضل إعادة الجدولة في أقرب وقت ممكن، أم تفضل الأسبوع القادم؟", - "Haitian Creole": "Èske ou ta renmen repwograme pi vit posib, oswa ou prefere semèn pwochèn?", - }; - const fallback = fallbacks[lang] ?? fallbacks["English"]!; - 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.`, - `Patient said: "${message}"`, fallback, apiKey - ); - return { reply, nextStage: "asked_reschedule_preference" }; - } else { - // Original appointment was Fri/Sat/Sun — go straight to next week - const { mon, tue, wed } = getNextWeekDays(); - const fallbacks: Record = { - 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" }; - } + const fallbacks: Record = { + English: "What day and time would you like? For example: 'Monday at 10am' or 'next Tuesday afternoon'.", + Spanish: "¿Qué día y hora prefiere? Por ejemplo: 'lunes a las 10am' o 'martes por la tarde'.", + Portuguese: "Que dia e horário você prefere? Por exemplo: 'Segunda às 10h' ou 'terça de tarde'.", + Mandarin: "您希望哪天几点?例如:'星期一上午10点'或'下周二下午'。", + Cantonese: "您希望哪天幾點?例如:'星期一上午10點'或'下週二下午'。", + Arabic: "ما اليوم والوقت الذي تفضله؟ مثلاً: 'الاثنين الساعة 10 صباحاً' أو 'الثلاثاء بعد الظهر'.", + "Haitian Creole": "Ki jou ak ki lè ou prefere? Pa egzanp: 'Lendi 10am' oswa 'Madi apremidi'.", + }; + const fallback = fallbacks[lang] ?? fallbacks["English"]!; + const reply = await llmReply( + `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 wants to reschedule.`, fallback, apiKey, + ); + return { reply, nextStage: "asked_reschedule_datetime" }; } return { reply: tx, nextStage: "done" }; @@ -284,7 +874,7 @@ export async function runRescheduleStep( const fallback = fallbacks[lang] ?? fallbacks["English"]!; 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.`, - `Patient wants to reschedule ASAP.`, fallback, apiKey + `Patient wants to reschedule ASAP.`, fallback, apiKey, ); return { reply, nextStage: "asked_reschedule_asap" }; } @@ -294,16 +884,16 @@ export async function runRescheduleStep( const fallbacks: Record = { 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ê?`, + Portuguese: `Posso agendá-lo para a semana que vem. ${mon}, ${tue} ou ${wed} seria bom?`, 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?`, + "Haitian Creole": `Mwen ka pwograme ou pou semèn pwochèn. ${mon}, ${tue}, oswa ${wed} ka travay?`, }; const fallback = fallbacks[lang] ?? fallbacks["English"]!; 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.`, - `Patient prefers next week.`, fallback, apiKey + `Patient prefers next week.`, fallback, apiKey, ); return { reply, nextStage: "asked_reschedule_next_week" }; } @@ -316,7 +906,6 @@ export async function runRescheduleStep( if (yes(t)) { const { date, label } = getTomorrowDate(); setPendingReschedule(userId, patientId, { newDate: date, dayLabel: label }); - const fallbacks: Record = { English: `${label} it is! Would you prefer morning (9am–12pm) or afternoon (1pm–5pm)?`, Spanish: `¡${label} perfecto! ¿Prefiere la mañana (9am–12pm) o la tarde (1pm–5pm)?`, @@ -329,31 +918,28 @@ export async function runRescheduleStep( const fallback = fallbacks[lang] ?? fallbacks["English"]!; 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.`, - `Patient confirmed tomorrow.`, fallback, apiKey + `Patient confirmed tomorrow.`, fallback, apiKey, ); return { reply, nextStage: "asked_reschedule_time" }; } - if (no(t)) { - // Can't make tomorrow — offer next week instead const { mon, tue, wed } = getNextWeekDays(); const fallbacks: Record = { - English: `No problem! What about next week? Would ${mon}, ${tue}, or ${wed} work for you?`, - Spanish: `¡Sin problema! ¿Qué le parece la semana que viene? ¿Le vendría bien el ${mon}, ${tue} o el ${wed}?`, - Portuguese: `Sem problema! E na semana que vem? ${mon}, ${tue} ou ${wed} seria bom?`, + English: `No problem! What about next week? Would ${mon}, ${tue}, or ${wed} work?`, + 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}?`, Mandarin: `没关系!下周怎么样?${mon}、${tue} 或 ${wed} 方便吗?`, Cantonese: `沒問題!下週怎麼樣?${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?`, + Arabic: `لا بأس! ماذا عن الأسبوع القادم؟ ${mon} أو ${tue} أو ${wed}؟`, + "Haitian Creole": `Pa gen pwoblèm! Semèn pwochèn? ${mon}, ${tue}, oswa ${wed}?`, }; const fallback = fallbacks[lang] ?? fallbacks["English"]!; 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.`, - `Patient can't come tomorrow.`, fallback, apiKey + `Patient can't come tomorrow.`, fallback, apiKey, ); return { reply, nextStage: "asked_reschedule_next_week" }; } - return { reply: tx, nextStage: "done" }; } @@ -367,7 +953,6 @@ export async function runRescheduleStep( if (chosen) { setPendingReschedule(userId, patientId, { newDate: chosen.date, dayLabel: chosen.label }); - const day = chosen.label; const fallbacks: Record = { English: `${day} works! Would you prefer morning (9am–12pm) or afternoon (1pm–5pm)?`, @@ -381,12 +966,11 @@ export async function runRescheduleStep( const fallback = fallbacks[lang] ?? fallbacks["English"]!; 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.`, - `Patient chose ${day}.`, fallback, apiKey + `Patient chose ${day}.`, fallback, apiKey, ); return { reply, nextStage: "asked_reschedule_time" }; } - // Day not clearly detected — ask again with the specific options const { mon, tue, wed } = getNextWeekDays(); const fallbacks: Record = { 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}?`, Mandarin: `哪天最方便——${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}?`, }; - const fallback = fallbacks[lang] ?? fallbacks["English"]!; - return { reply: fallback, nextStage: "asked_reschedule_next_week" }; + return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_next_week" }; } // ── asked_reschedule_time: patient picked morning / afternoon / specific time ── if (stage === "asked_reschedule_time") { const pending = getPendingReschedule(userId, patientId); - - if (!pending) { - // Edge case: lost state — fall back gracefully - return { reply: tx, nextStage: "done" }; - } + if (!pending) return { reply: tx, nextStage: "done" }; const startTime = await parseTime(message, apiKey); - if (!startTime) { - // Couldn't parse time — ask again const fallbacks: Record = { English: "I didn't catch the time. Would you prefer morning (9am–12pm) or afternoon (1pm–5pm)?", Spanish: "No entendí la hora. ¿Prefiere la mañana (9am–12pm) o la tarde (1pm–5pm)?", @@ -426,43 +1003,28 @@ export async function runRescheduleStep( return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_time" }; } - // Update the appointment in the database const updateResult = await moveAppointment(patientId, pending.newDate, startTime); clearPendingReschedule(userId, patientId); - const [h, m] = startTime.split(":").map(Number); - 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}`; + const apptLabel = `${pending.dayLabel} at ${timeLabel(startTime)}`; if (updateResult === "no_appointment") { const fallbacks: Record = { 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}.`, - 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}。`, Cantonese: `我找不到您的預約進行更新。我們的工作人員將聯絡您確認${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" }; } - // Success - const fallbacks: Record = { - 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 fallback = `Your appointment has been moved to ${apptLabel}. Our dental receptionist will confirm it with you tomorrow.`; 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.`, - `Appointment moved to ${apptLabel}.`, fallback, apiKey + `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, ); return { reply, nextStage: "done" }; } diff --git a/apps/Backend/src/routes/twilio-webhooks.ts b/apps/Backend/src/routes/twilio-webhooks.ts index 38219b4e..125c3fa5 100644 --- a/apps/Backend/src/routes/twilio-webhooks.ts +++ b/apps/Backend/src/routes/twilio-webhooks.ts @@ -266,12 +266,39 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise => 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") { 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 ──────── @@ -283,7 +310,7 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise => ); if (aiReply) { 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 nextStage = "done"; return reply(aiReply, nextStage); @@ -292,9 +319,10 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise => // ── Rescheduling flow stages ─────────────────────────────────────────── const rescheduleStages: ConversationStage[] = [ - "asked_reschedule_confirm", "asked_reschedule_preference", - "asked_reschedule_asap", "asked_reschedule_next_week", - "asked_reschedule_time", + "asked_reschedule_confirm", "asked_reschedule_preference", + "asked_reschedule_asap", "asked_reschedule_next_week", + "asked_reschedule_time", "asked_reschedule_datetime", + "asked_reschedule_time_for_date", "asked_reschedule_confirm_datetime", ]; if (rescheduleStages.includes(stage)) { const { reply: aiReply, nextStage } = await runRescheduleStep( @@ -412,6 +440,56 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise => 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 = { + 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) ─────────────────────────── // Check after-hours: if enabled and currently outside office hours → start new-patient flow if (stage === "initial" || stage === "done") { diff --git a/apps/Frontend/src/pages/appointments-page.tsx b/apps/Frontend/src/pages/appointments-page.tsx index 4b2bd0b9..0c69fd77 100755 --- a/apps/Frontend/src/pages/appointments-page.tsx +++ b/apps/Frontend/src/pages/appointments-page.tsx @@ -27,6 +27,8 @@ import { Stethoscope, Download, MessageSquare, + Clock, + ExternalLink, } from "lucide-react"; import { useToast } from "@/hooks/use-toast"; import { Calendar } from "@/components/ui/calendar"; @@ -1681,6 +1683,53 @@ export default function AppointmentsPage() { + {/* 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 ( +
+
+ + Office Hours + +
+ {!officeHours ? ( + Not configured — + ) : isOverride ? ( + Override active — all slots open today + ) : ( + <> + + Doctors (A–C):{" "} + {doctorHours?.enabled + ? `${fmt(doctorHours.amStart)}–${fmt(doctorHours.amEnd)}, ${fmt(doctorHours.pmStart)}–${fmt(doctorHours.pmEnd)}` + : Closed} + + + Hygienists (D–F):{" "} + {hygHours?.enabled + ? `${fmt(hygHours.amStart)}–${fmt(hygHours.amEnd)}, ${fmt(hygHours.pmStart)}–${fmt(hygHours.pmEnd)}` + : Closed} + + + )} +
+ ); + })()} + {/* Schedule Grid with Drag and Drop */}