Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 96 additions & 4 deletions api/src/routes/voice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,12 @@ const MEDICAL_GUARDRAIL_REPLY =
"I cannot provide medical advice, but I can connect you with staff or arrange a callback.";
const NEW_PATIENT_VETERAN_CALLBACK_PROMPT =
"Thank you for contacting Veterans Cannabis Care in Casselberry, Florida. This program provides 100% free medical marijuana cards for qualifying Florida resident Veterans. Please note: This program is for first-time patients only, who have never previously had a Florida medical marijuana card. Per State law, the initial appointment must be completed in person at our Casselberry clinic. Appointments are limited and scheduled based on availability. Due to high demand, all calls are returned in the order received. Please leave your full name, your phone number, and the best time to return your call. A member of our team will return your call as soon as possible. Thank you for your sacrifice.";
const NEW_PATIENT_VETERAN_CALLBACK_CAPTURE_CUE =
"You can say that now: your full name, phone number, and the best time for our team to call you back.";
const NEW_PATIENT_VETERAN_CALLBACK_REPROMPT =
"I didn't catch that. Please say your full name, phone number, and the best time for our team to call you back.";
const NEW_PATIENT_VETERAN_CALLBACK_MISSING_DETAILS_CONFIRMATION =
"I still didn't catch the callback details clearly. Staff will use the phone number from this call if available and follow up as soon as possible.";
const NEW_PATIENT_VETERAN_PRIOR_CARD_PROMPT =
"Since you may have had a card before, staff should confirm the correct path before we promise the free event or any paid option. What time would be best for a callback?";
const NEW_PATIENT_VETERAN_PRICE_PROMPT =
Expand Down Expand Up @@ -510,6 +516,17 @@ const SCHEDULING_CAPTURE_PROMPT =
const SCHEDULING_CAPTURE_SPEECH_TIMEOUT = String(
process.env.TWILIO_SCHEDULING_SPEECH_TIMEOUT || "2"
).trim() || "2";
const VETERAN_CALLBACK_CAPTURE_SPEECH_TIMEOUT = String(
process.env.TWILIO_VETERAN_CALLBACK_SPEECH_TIMEOUT || "auto"
).trim() || "auto";
const RAW_VETERAN_CALLBACK_CAPTURE_TIMEOUT = Number(
process.env.TWILIO_VETERAN_CALLBACK_TIMEOUT || 10
);
const VETERAN_CALLBACK_CAPTURE_TIMEOUT =
Number.isFinite(RAW_VETERAN_CALLBACK_CAPTURE_TIMEOUT) &&
RAW_VETERAN_CALLBACK_CAPTURE_TIMEOUT >= 1
? Math.floor(RAW_VETERAN_CALLBACK_CAPTURE_TIMEOUT)
: 10;
const MAX_VOICE_OFFERED_SLOTS = 4;
const RAW_VOICE_SCHEDULING_CAPTURE_MAX_ATTEMPTS = Number(
process.env.VOICE_SCHEDULING_CAPTURE_MAX_ATTEMPTS || 3
Expand Down Expand Up @@ -8373,6 +8390,7 @@ router.post("/voice/intent", async (req, res) => {
bookingLane?: "press_one";
offeredSlots?: AvailableSlot[];
paymentMethodBookingFailures?: number;
queryParams?: Record<string, string>;
}
) => {
const nextSchedulingAttempt =
Expand Down Expand Up @@ -8467,6 +8485,11 @@ router.post("/voice/intent", async (req, res) => {
const offeredSlotTimes = options.offeredSlots.map((slot) => slot.displayTime);
query.set("offered_slots", offeredSlotTimes.join("|"));
}
if (options?.queryParams) {
for (const [key, value] of Object.entries(options.queryParams)) {
if (key && value) query.set(key, value);
}
}
const gatherAction = `/api/voice/intent?${query.toString()}`;
logVoiceOperationalStep({
eventName: "voice_flow_step",
Expand All @@ -8483,11 +8506,18 @@ router.post("/voice/intent", async (req, res) => {
},
});

const isVeteranCallbackCapture =
options?.capture?.scriptStep === "veteran_callback";
const gather = twiml.gather({
input: ["speech", "dtmf"],
action: gatherAction,
method: "POST",
speechTimeout: SCHEDULING_CAPTURE_SPEECH_TIMEOUT,
speechTimeout: isVeteranCallbackCapture
? VETERAN_CALLBACK_CAPTURE_SPEECH_TIMEOUT
: SCHEDULING_CAPTURE_SPEECH_TIMEOUT,
...(isVeteranCallbackCapture
? { timeout: VETERAN_CALLBACK_CAPTURE_TIMEOUT }
: {}),
actionOnEmptyResult: true,
});
await speakInboundWithElevenLabsBridgeOrFallback({
Expand Down Expand Up @@ -8736,6 +8766,8 @@ router.post("/voice/intent", async (req, res) => {
mapBookingIntentToRequestType(capture.bookingIntent) || "scheduling";
const bookingTimeWindow = buildSchedulingCaptureTimeWindow(capture);
const callbackTimeWindow = String(capture.callbackTime || "").trim();
const suppressDefaultTimeWindow =
options?.reason === "new_patient_veteran_callback_missing_details";
const publicSchedulerUrl = buildPublicSchedulerUrlForOffer(
selection?.appointmentTypeId
);
Expand All @@ -8744,7 +8776,10 @@ router.post("/voice/intent", async (req, res) => {
name: capture.collectedName || "Voice caller",
phone: draftPhone,
requestType,
preferredTime: callbackTimeWindow || bookingTimeWindow || null,
preferredTime:
callbackTimeWindow ||
(suppressDefaultTimeWindow ? "" : bookingTimeWindow) ||
null,
status: "pending",
scenarioType: mapBookingIntentToScenarioType(capture.bookingIntent),
scenarioState: "pending",
Expand All @@ -8768,7 +8803,7 @@ router.post("/voice/intent", async (req, res) => {
"availability_strategy=any_available_then_calendar_pool",
capture.requestedDate ? `requested_date=${capture.requestedDate}` : "",
capture.requestedTime ? `requested_time=${capture.requestedTime}` : "",
bookingTimeWindow ? `time_window=${bookingTimeWindow}` : "",
bookingTimeWindow && !suppressDefaultTimeWindow ? `time_window=${bookingTimeWindow}` : "",
callbackTimeWindow ? `callback_window=${sanitizeVoiceSummaryToken(callbackTimeWindow, 100)}` : "",
`availability_source=${source}`,
options?.reason ? `fallback_reason=${options.reason}` : "",
Expand Down Expand Up @@ -11250,7 +11285,7 @@ router.post("/voice/intent", async (req, res) => {

if (isVeteran) {
return respondWithSchedulingCapturePrompt("retry", {
prompt: NEW_PATIENT_VETERAN_CALLBACK_PROMPT,
prompt: `${NEW_PATIENT_VETERAN_CALLBACK_PROMPT} ${NEW_PATIENT_VETERAN_CALLBACK_CAPTURE_CUE}`,
schedulingAttempt: schedulingAttempt + 1,
capture: {
...capture,
Expand Down Expand Up @@ -11947,6 +11982,63 @@ router.post("/voice/intent", async (req, res) => {

// Veteran callback time capture. This is a staff-confirmed callback lane only:
// no payment capture, no Acuity mutation, and no paid veteran pricing.
if (capture.scriptStep === "veteran_callback" && !utterance) {
const emptyAttemptParam = Number(
req.query?.veteran_callback_empty_attempt || 0
);
const emptyAttempt =
Number.isFinite(emptyAttemptParam) && emptyAttemptParam >= 0
? Math.floor(emptyAttemptParam)
: 0;

if (emptyAttempt < 1) {
return respondWithSchedulingCapturePrompt("retry", {
prompt: NEW_PATIENT_VETERAN_CALLBACK_REPROMPT,
schedulingAttempt: schedulingAttempt + 1,
capture: {
...capture,
isVeteran: capture.isVeteran ?? true,
productType: undefined,
scriptStep: "veteran_callback",
},
queryParams: {
veteran_callback_empty_attempt: String(emptyAttempt + 1),
},
});
}

return respondWithBookingDraftFallback(
"booking_request_required",
{
...capture,
callbackTime: undefined,
requestedDate: undefined,
requestedTime: undefined,
timeWindowRaw: "",
timeWindowConfidence: false,
lookupPhone: capture.collectedPhone || capture.lookupPhone,
isVeteran: capture.isVeteran ?? true,
productType: undefined,
},
{
schedulingAttempt: schedulingAttempt + 1,
lookupPhone: capture.collectedPhone || capture.lookupPhone,
reason: "new_patient_veteran_callback_missing_details",
collectedName: capture.collectedName,
collectedPhone: capture.collectedPhone,
collectedEmail: capture.collectedEmail,
confirmationPrompt:
NEW_PATIENT_VETERAN_CALLBACK_MISSING_DETAILS_CONFIRMATION,
summaryTokens: [
"veteran_callback_details_captured=false",
"veteran_callback_missing_reason=silence",
`veteran_callback_empty_attempts=${emptyAttempt + 1}`,
"veteran_callback_phone_source=fallback",
],
}
);
}

if (capture.scriptStep === "veteran_callback" && utterance) {
const veteranCallbackDetails =
parseVeteranCallbackContactDetails(utterance);
Expand Down
96 changes: 96 additions & 0 deletions api/tests/voicePaymentCapture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,9 @@ describe("voice PCI-safe payment capture", () => {
expect(veteranYes.text).toContain("calls are returned in the order received");
expect(veteranYes.text).toContain("Please leave your full name, your phone number, and the best time to return your call");
expect(veteranYes.text).toContain("Thank you for your sacrifice");
expect(veteranYes.text).toContain("You can say that now: your full name, phone number, and the best time for our team to call you back");
expect(veteranYes.text).toContain('speechTimeout="auto"');
expect(veteranYes.text).toContain('timeout="10"');
expect(veteranYes.text).not.toContain("<Pay");
expect(veteranYes.text).not.toContain("/api/twilio/voice/payment-capture");
expect(veteranYes.text).not.toContain("payment-capture");
Expand Down Expand Up @@ -530,6 +533,9 @@ describe("voice PCI-safe payment capture", () => {
expect(callbackTime.text).toContain("captured that veteran callback request");
expect(callbackTime.text).not.toContain("<Pay");
expect(callbackTime.text).not.toContain("/api/twilio/voice/payment-capture");
expect(callbackTime.text).not.toContain("payment-capture");
expect(callbackTime.text).not.toContain("chargeAmount");
expect(callbackTime.text).not.toContain("$123.76");

const callback = await prisma.callbackRequest.findFirst({
where: {
Expand Down Expand Up @@ -590,6 +596,96 @@ describe("voice PCI-safe payment capture", () => {
expect(callback?.summary || "").toContain("veteran_callback_phone_source=fallback");
});

it("reprompts veteran callback silence without creating a completed callback", async () => {
const callSid = `${CALLSID_PREFIX}VETERAN_CALLBACK_EMPTY_RETRY`;
const veteranCallbackQuery = newPatientPaymentQuery({
script_step: "veteran_callback",
payment_option: "not_ready",
is_veteran: "true",
});

const silence = await request(app)
.post(`/api/voice/intent?${veteranCallbackQuery.toString()}`)
.set("X-VetCan-Tenant", "marijuanaexpressmd.astormscoming.com")
.type("form")
.send({
CallSid: callSid,
SpeechResult: "",
Digits: "",
From: TEST_PHONES[0],
To: "+18005550199",
});

expect(silence.status).toBe(200);
expect(silence.text).toContain("I didn&apos;t catch that");
expect(silence.text).toContain("Please say your full name, phone number, and the best time for our team to call you back");
expect(silence.text).toContain('speechTimeout="auto"');
expect(silence.text).toContain('timeout="10"');
expect(silence.text).not.toContain("captured that veteran callback request");
expect(silence.text).not.toContain("<Pay");
expect(silence.text).not.toContain("payment-capture");
expect(silence.text).not.toContain("chargeAmount");
expect(silence.text).not.toContain("$123.76");

const retryAction = getGatherActionUrl(silence.text);
expect(retryAction.searchParams.get("script_step")).toBe("veteran_callback");
expect(retryAction.searchParams.get("veteran_callback_empty_attempt")).toBe("1");

const callbackCount = await prisma.callbackRequest.count({
where: {
source: "voice",
phone: TEST_PHONES[0],
summary: { contains: "new_patient_veteran_callback" },
},
});
expect(callbackCount).toBe(0);
});

it("falls back veteran callback repeated silence to staff callback with missing-details notes", async () => {
const callSid = `${CALLSID_PREFIX}VETERAN_CALLBACK_EMPTY_FALLBACK`;
const veteranCallbackQuery = newPatientPaymentQuery({
script_step: "veteran_callback",
payment_option: "not_ready",
is_veteran: "true",
veteran_callback_empty_attempt: "1",
});

const fallback = await request(app)
.post(`/api/voice/intent?${veteranCallbackQuery.toString()}`)
.set("X-VetCan-Tenant", "marijuanaexpressmd.astormscoming.com")
.type("form")
.send({
CallSid: callSid,
SpeechResult: "",
Digits: "",
From: TEST_PHONES[0],
To: "+18005550199",
});

expect(fallback.status).toBe(200);
expect(fallback.text).toContain("I still didn&apos;t catch the callback details clearly");
expect(fallback.text).toContain("Staff will use the phone number from this call if available");
expect(fallback.text).not.toContain("<Pay");
expect(fallback.text).not.toContain("/api/twilio/voice/payment-capture");
expect(fallback.text).not.toContain("payment-capture");
expect(fallback.text).not.toContain("chargeAmount");
expect(fallback.text).not.toContain("$123.76");

const callback = await prisma.callbackRequest.findFirst({
where: {
phone: TEST_PHONES[0],
summary: { contains: "fallback_reason=new_patient_veteran_callback_missing_details" },
},
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
});
expect(callback).toBeTruthy();
expect(callback?.preferredTime).toBeNull();
expect(callback?.summary || "").toContain("veteran_callback_details_captured=false");
expect(callback?.summary || "").toContain("veteran_callback_missing_reason=silence");
expect(callback?.summary || "").toContain("veteran_callback_phone_source=fallback");
expect(callback?.summary || "").not.toContain("veteran_callback_original_utterance=");
});

it("routes veteran no to normal callback timing prompt", async () => {
const callSid = `${CALLSID_PREFIX}VETERAN_NO_CALLBACK`;
const veteranQuery = newPatientPaymentQuery({
Expand Down
Loading