feat: send appointment confirmation SMS after AI-booked appointments
Voice-call bookings previously only spoke the confirmation during the live call, leaving nothing for the patient afterward. Now every successful booking (recall, reschedule, and new-patient) also sends a standalone confirmation text, regardless of which channel the conversation happened on.
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import twilio from "twilio";
|
||||
import { prisma as db } from "@repo/db/client";
|
||||
import { storage } from "../storage";
|
||||
|
||||
const CONFIRMATION_FALLBACKS: Record<string, (dateLabel: string, time: string) => string> = {
|
||||
English: (d, t) => `Confirmed! Your appointment is on ${d} at ${t}. We look forward to seeing you.`,
|
||||
Spanish: (d, t) => `¡Confirmado! Su cita es el ${d} a las ${t}. Le esperamos.`,
|
||||
Portuguese: (d, t) => `Confirmado! Sua consulta é em ${d} às ${t}. Aguardamos sua visita.`,
|
||||
Mandarin: (d, t) => `已确认!您的预约在 ${d} ${t}。期待您的光临。`,
|
||||
Cantonese: (d, t) => `已確認!您的預約喺 ${d} ${t}。期待見到您。`,
|
||||
Arabic: (d, t) => `تم التأكيد! موعدك في ${d} الساعة ${t}. نتطلع لرؤيتك.`,
|
||||
"Haitian Creole": (d, t) => `Konfime! Randevou ou se ${d} a ${t}. N'ap tann ou.`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Sends a standalone appointment-confirmation text, independent of whichever
|
||||
* channel (SMS chat or AI phone call) the booking conversation happened on —
|
||||
* a voice-booked appointment would otherwise leave the patient with nothing
|
||||
* to refer back to once the call ends. Swallows all errors so a notification
|
||||
* failure never breaks the booking flow that already succeeded.
|
||||
*/
|
||||
export async function sendAppointmentConfirmationSms(
|
||||
userId: number,
|
||||
patientId: number,
|
||||
dateLabel: string,
|
||||
time: string,
|
||||
lang: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const [twilioSettings, patient] = await Promise.all([
|
||||
storage.getTwilioSettings(userId),
|
||||
db.patient.findUnique({ where: { id: patientId }, select: { phone: true } }),
|
||||
]);
|
||||
if (!twilioSettings || !patient?.phone) return;
|
||||
|
||||
const build = CONFIRMATION_FALLBACKS[lang] ?? CONFIRMATION_FALLBACKS["English"]!;
|
||||
const body = build(dateLabel, time);
|
||||
|
||||
const client = twilio(twilioSettings.accountSid, twilioSettings.authToken);
|
||||
await client.messages.create({ body, from: twilioSettings.phoneNumber, to: patient.phone });
|
||||
|
||||
await storage.createCommunication({
|
||||
patientId, userId, channel: "sms", direction: "outbound", status: "sent", body,
|
||||
});
|
||||
} catch {
|
||||
// Silent — don't crash the booking flow over a notification failure
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "./reschedule-graph";
|
||||
import { getLlm, type AiProvider } from "./llm-factory";
|
||||
import { runEligibilityProcessor } from "../queue/processors/eligibilityProcessor";
|
||||
import { sendAppointmentConfirmationSms } from "./confirmationSms";
|
||||
import {
|
||||
getPendingReschedule, setPendingReschedule, clearPendingReschedule,
|
||||
setStage, type ConversationStage,
|
||||
@@ -604,6 +605,7 @@ export async function routePatientTurn(params: {
|
||||
status: "scheduled",
|
||||
movedByAi: true,
|
||||
} as any);
|
||||
await sendAppointmentConfirmationSms(patient.userId, patient.id, pending.dayLabel, timeLabel(startTime), language);
|
||||
} catch { /* silent — message still sent, staff can create manually */ }
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
parseDateOnlyFromMessage, parseTime, isOfficeDayOpen, isWithinOfficeHours,
|
||||
getOfficeHoursDisplay, timeLabel,
|
||||
} from "./reschedule-graph";
|
||||
import { sendAppointmentConfirmationSms } from "./confirmationSms";
|
||||
|
||||
const GraphState = Annotation.Root({
|
||||
message: Annotation<string>(),
|
||||
@@ -400,6 +401,7 @@ async function bookRecallSlot(
|
||||
const build = slot.matchedRequest
|
||||
? (BOOKED_EXACT_FALLBACKS[lang] ?? BOOKED_EXACT_FALLBACKS["English"]!)
|
||||
: (BOOKED_ALT_FALLBACKS[lang] ?? BOOKED_ALT_FALLBACKS["English"]!);
|
||||
await sendAppointmentConfirmationSms(userId, patientId, dateLabel, timeLbl, lang);
|
||||
return { reply: build(dateLabel, timeLbl), nextStage: "done" };
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getPendingReschedule,
|
||||
clearPendingReschedule,
|
||||
} from "./aiHandoffStore";
|
||||
import { sendAppointmentConfirmationSms } from "./confirmationSms";
|
||||
|
||||
// ── Date helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -837,6 +838,8 @@ export async function runRescheduleStep(
|
||||
fallback,
|
||||
apiKey, provider, model
|
||||
);
|
||||
const datePart = displayLabel.split(" at ")[0] ?? displayLabel;
|
||||
await sendAppointmentConfirmationSms(userId, patientId, datePart, timeLabel(startTime), lang);
|
||||
return { reply, nextStage: "done" };
|
||||
}
|
||||
|
||||
@@ -1126,6 +1129,7 @@ export async function runRescheduleStep(
|
||||
`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, provider, model
|
||||
);
|
||||
await sendAppointmentConfirmationSms(userId, patientId, pending.dayLabel, timeLabel(startTime), lang);
|
||||
return { reply, nextStage: "done" };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user