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";
|
} from "./reschedule-graph";
|
||||||
import { getLlm, type AiProvider } from "./llm-factory";
|
import { getLlm, type AiProvider } from "./llm-factory";
|
||||||
import { runEligibilityProcessor } from "../queue/processors/eligibilityProcessor";
|
import { runEligibilityProcessor } from "../queue/processors/eligibilityProcessor";
|
||||||
|
import { sendAppointmentConfirmationSms } from "./confirmationSms";
|
||||||
import {
|
import {
|
||||||
getPendingReschedule, setPendingReschedule, clearPendingReschedule,
|
getPendingReschedule, setPendingReschedule, clearPendingReschedule,
|
||||||
setStage, type ConversationStage,
|
setStage, type ConversationStage,
|
||||||
@@ -604,6 +605,7 @@ export async function routePatientTurn(params: {
|
|||||||
status: "scheduled",
|
status: "scheduled",
|
||||||
movedByAi: true,
|
movedByAi: true,
|
||||||
} as any);
|
} as any);
|
||||||
|
await sendAppointmentConfirmationSms(patient.userId, patient.id, pending.dayLabel, timeLabel(startTime), language);
|
||||||
} catch { /* silent — message still sent, staff can create manually */ }
|
} catch { /* silent — message still sent, staff can create manually */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
parseDateOnlyFromMessage, parseTime, isOfficeDayOpen, isWithinOfficeHours,
|
parseDateOnlyFromMessage, parseTime, isOfficeDayOpen, isWithinOfficeHours,
|
||||||
getOfficeHoursDisplay, timeLabel,
|
getOfficeHoursDisplay, timeLabel,
|
||||||
} from "./reschedule-graph";
|
} from "./reschedule-graph";
|
||||||
|
import { sendAppointmentConfirmationSms } from "./confirmationSms";
|
||||||
|
|
||||||
const GraphState = Annotation.Root({
|
const GraphState = Annotation.Root({
|
||||||
message: Annotation<string>(),
|
message: Annotation<string>(),
|
||||||
@@ -400,6 +401,7 @@ async function bookRecallSlot(
|
|||||||
const build = slot.matchedRequest
|
const build = slot.matchedRequest
|
||||||
? (BOOKED_EXACT_FALLBACKS[lang] ?? BOOKED_EXACT_FALLBACKS["English"]!)
|
? (BOOKED_EXACT_FALLBACKS[lang] ?? BOOKED_EXACT_FALLBACKS["English"]!)
|
||||||
: (BOOKED_ALT_FALLBACKS[lang] ?? BOOKED_ALT_FALLBACKS["English"]!);
|
: (BOOKED_ALT_FALLBACKS[lang] ?? BOOKED_ALT_FALLBACKS["English"]!);
|
||||||
|
await sendAppointmentConfirmationSms(userId, patientId, dateLabel, timeLbl, lang);
|
||||||
return { reply: build(dateLabel, timeLbl), nextStage: "done" };
|
return { reply: build(dateLabel, timeLbl), nextStage: "done" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
getPendingReschedule,
|
getPendingReschedule,
|
||||||
clearPendingReschedule,
|
clearPendingReschedule,
|
||||||
} from "./aiHandoffStore";
|
} from "./aiHandoffStore";
|
||||||
|
import { sendAppointmentConfirmationSms } from "./confirmationSms";
|
||||||
|
|
||||||
// ── Date helpers ──────────────────────────────────────────────────────────────
|
// ── Date helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -837,6 +838,8 @@ export async function runRescheduleStep(
|
|||||||
fallback,
|
fallback,
|
||||||
apiKey, provider, model
|
apiKey, provider, model
|
||||||
);
|
);
|
||||||
|
const datePart = displayLabel.split(" at ")[0] ?? displayLabel;
|
||||||
|
await sendAppointmentConfirmationSms(userId, patientId, datePart, timeLabel(startTime), lang);
|
||||||
return { reply, nextStage: "done" };
|
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.`,
|
`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
|
`Appointment moved to ${apptLabel}.`, fallback, apiKey, provider, model
|
||||||
);
|
);
|
||||||
|
await sendAppointmentConfirmationSms(userId, patientId, pending.dayLabel, timeLabel(startTime), lang);
|
||||||
return { reply, nextStage: "done" };
|
return { reply, nextStage: "done" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user