diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 8533819..65d0dae 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -20,7 +20,11 @@ "Bash(echo \"=== Press 4 Live Test ===\")", "Bash(echo \"\")", "Bash(tee -a /mnt/c/Users/mich3/VetCan/tmp/press4-live-smoke/responses.log)", - "Bash(docker exec vetcan-db-1 *)" + "Bash(docker exec vetcan-db-1 *)", + "Bash(docker compose ps *)", + "Bash(docker compose config *)", + "Bash(docker compose build *)", + "Bash(docker compose up *)" ] } } diff --git a/api/src/routes/voice.ts b/api/src/routes/voice.ts index 675c1e6..e3e6d47 100644 --- a/api/src/routes/voice.ts +++ b/api/src/routes/voice.ts @@ -165,10 +165,6 @@ 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 = @@ -447,7 +443,7 @@ type SchedulingCaptureResult = { directBookingReady: boolean; offeredSlots?: string[]; bookingStep?: "collect_name" | "collect_phone" | "collect_email" | "confirm_booking" | "correct_detail" | "select_alternate_slot" | "select_alternate_date"; - scriptStep?: "florida_id" | "conditions" | "referral" | "appt_confirm" | "contact_confirm" | "payment" | "deposit_offer" | "payment_method_choice" | "payment_email" | "veteran" | "callback" | "veteran_callback" | "veteran_donation" | "donation_amount" | "renewal_payment_choice"; + scriptStep?: "florida_id" | "conditions" | "referral" | "appt_confirm" | "contact_confirm" | "payment" | "deposit_offer" | "payment_method_choice" | "payment_email" | "veteran" | "callback" | "veteran_callback" | "veteran_callback_name" | "veteran_callback_phone" | "veteran_callback_time" | "veteran_donation" | "donation_amount" | "renewal_payment_choice"; collectedName?: string; collectedPhone?: string; collectedEmail?: string; @@ -772,6 +768,151 @@ function parseVeteranCallbackContactDetails(value: string): { }; } +type VeteranCallbackScriptStep = Extract< + SchedulingCaptureResult["scriptStep"], + "veteran_callback" | "veteran_callback_name" | "veteran_callback_phone" | "veteran_callback_time" +>; + +function isVeteranCallbackScriptStep( + scriptStep: SchedulingCaptureResult["scriptStep"] | undefined +): scriptStep is VeteranCallbackScriptStep { + return ( + scriptStep === "veteran_callback" || + scriptStep === "veteran_callback_name" || + scriptStep === "veteran_callback_phone" || + scriptStep === "veteran_callback_time" + ); +} + +function resolveVeteranCallbackNextStep( + capture: SchedulingCaptureResult +): VeteranCallbackScriptStep { + if (!capture.collectedName) return "veteran_callback_name"; + if (!capture.collectedPhone && !capture.lookupPhone) return "veteran_callback_phone"; + return "veteran_callback_time"; +} + +function buildVeteranCallbackStagePrompt( + capture: SchedulingCaptureResult, + step: VeteranCallbackScriptStep +) { + const phoneLast4 = safeLast4Digits(capture.collectedPhone || capture.lookupPhone); + + if (step === "veteran_callback_time") { + if (capture.collectedName && phoneLast4) { + return `I have ${capture.collectedName} and a phone number ending in ${phoneLast4}. What is the best time for our team to call you back about Veterans Cannabis Care?`; + } + return "What time is best for our team to call you back?"; + } + + if (step === "veteran_callback_phone") { + return capture.collectedName + ? `I have ${capture.collectedName}. What phone number should our team use for your Veterans Cannabis Care callback?` + : "What phone number should our team use for your Veterans Cannabis Care callback?"; + } + + if (phoneLast4) { + return `I have a phone number ending in ${phoneLast4}. What is your full name for the Veterans Cannabis Care callback?`; + } + + return "What is your full name for the Veterans Cannabis Care callback?"; +} + +function buildVeteranCallbackInitialPrompt(capture: SchedulingCaptureResult) { + return `${NEW_PATIENT_VETERAN_CALLBACK_PROMPT} ${buildVeteranCallbackStagePrompt( + capture, + resolveVeteranCallbackNextStep(capture) + )}`; +} + +function isShortVeteranCallbackAcknowledgement(value: string) { + const normalized = String(value || "") + .toLowerCase() + .replace(/[^a-z\s]/g, " ") + .replace(/\s+/g, " ") + .trim(); + return /^(yes|yeah|yep|yup|ok|okay|sure|right|correct|alright|all right|uh huh|mm hmm|sounds good|that works|fine|thanks|thank you)$/.test( + normalized + ); +} + +function hasVeteranCallbackTimeHint(value: string) { + const normalized = String(value || "").toLowerCase(); + return ( + /\b(today|tomorrow|tonight|morning|afternoon|evening|night|noon|lunch|after|before|between|around|anytime|asap|soon|later|early|late|weekday|weekdays|weekend|weekends)\b/.test( + normalized + ) || + /\b(monday|tuesday|wednesday|thursday|friday|saturday|sunday)\b/.test( + normalized + ) || + /\b(next|this)\s+(week|month|monday|tuesday|wednesday|thursday|friday|saturday|sunday)\b/.test( + normalized + ) || + /\b\d{1,2}(?::\d{2})?\s*(?:a\.?m\.?|p\.?m\.?)\b/.test(normalized) || + /\b\d{1,2}\s*(?:o'clock|oclock)\b/.test(normalized) || + /\bwhen you can\b/.test(normalized) + ); +} + +function hasVeteranCallbackAvailabilityLanguage(value: string) { + const normalized = String(value || "") + .toLowerCase() + .replace(/[^a-z0-9\s:]/g, " ") + .replace(/\s+/g, " ") + .trim(); + return ( + hasVeteranCallbackTimeHint(normalized) || + /\b(available|availability|convenience|convenient|whenever|works|work|best|call|someone|team)\b/.test( + normalized + ) || + /\b(any\s+time|anytime|when\s+you\s+can|when\s+someone\s+is\s+available|at\s+your\s+convenience)\b/.test( + normalized + ) + ); +} + +function isPartialPhoneOnly(value: string) { + const text = String(value || "").trim(); + const digits = text.replace(/\D/g, ""); + if (!digits) return false; + if (hasVeteranCallbackTimeHint(text)) return false; + return digits.length < 10 || /^[\d\s().+-]+$/.test(text); +} + +function isLikelyNameOnlyVeteranCallback(value: string) { + const text = String(value || "").trim(); + if (!text || /\d|@/.test(text) || hasVeteranCallbackAvailabilityLanguage(text)) { + return false; + } + + const explicitNameMatch = text.match( + /^\s*(?:my\s+name\s+is|name\s+is|this\s+is)\s+(.+)$/i + ); + if (explicitNameMatch?.[1]) { + return Boolean(validateNameInput(normalizeSpokenName(explicitNameMatch[1])).name); + } + + const normalizedName = normalizeSpokenName(text); + const words = normalizedName + .split(/\s+/) + .filter((word) => /^[a-z][a-z'.-]*$/i.test(word)); + if (words.length !== normalizedName.split(/\s+/).length) return false; + if (words.length !== 2) return false; + + return Boolean(validateNameInput(normalizedName).name); +} + +function isValidVeteranCallbackTime(value: string) { + const text = String(value || "").replace(/\s+/g, " ").trim(); + if (!text) return false; + if (isShortVeteranCallbackAcknowledgement(text)) return false; + if (isPartialPhoneOnly(text)) return false; + if (text.replace(/\D/g, "").length >= 7) return false; + if (isLikelyNameOnlyVeteranCallback(text)) return false; + if (hasVeteranCallbackAvailabilityLanguage(text)) return true; + return text.split(/\s+/).length >= 3; +} + function buildPublicSchedulerUrlForOffer(appointmentTypeId?: string) { const baseUrl = String(process.env.ACUITY_PUBLIC_SCHEDULER_URL || "") .trim(); @@ -8507,7 +8648,7 @@ router.post("/voice/intent", async (req, res) => { }); const isVeteranCallbackCapture = - options?.capture?.scriptStep === "veteran_callback"; + isVeteranCallbackScriptStep(options?.capture?.scriptStep); const gather = twiml.gather({ input: ["speech", "dtmf"], action: gatherAction, @@ -11285,13 +11426,13 @@ router.post("/voice/intent", async (req, res) => { if (isVeteran) { return respondWithSchedulingCapturePrompt("retry", { - prompt: `${NEW_PATIENT_VETERAN_CALLBACK_PROMPT} ${NEW_PATIENT_VETERAN_CALLBACK_CAPTURE_CUE}`, + prompt: buildVeteranCallbackInitialPrompt(capture), schedulingAttempt: schedulingAttempt + 1, capture: { ...capture, isVeteran: true, productType: undefined, - scriptStep: "veteran_callback", + scriptStep: resolveVeteranCallbackNextStep(capture), }, }); } @@ -11980,114 +12121,279 @@ 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 getVeteranCallbackAttempt = () => { const emptyAttemptParam = Number( req.query?.veteran_callback_empty_attempt || 0 ); - const emptyAttempt = + return ( Number.isFinite(emptyAttemptParam) && emptyAttemptParam >= 0 ? Math.floor(emptyAttemptParam) - : 0; + : 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), - }, - }); + const respondWithVeteranCallbackMissingDetails = async ( + currentCapture: SchedulingCaptureResult, + params: { + reason: string; + attempts: number; + originalUtterance?: string; + phoneSource?: "utterance" | "fallback"; + nameSource?: "utterance" | "fallback"; } - + ) => { return respondWithBookingDraftFallback( "booking_request_required", { - ...capture, + ...currentCapture, callbackTime: undefined, requestedDate: undefined, requestedTime: undefined, timeWindowRaw: "", timeWindowConfidence: false, - lookupPhone: capture.collectedPhone || capture.lookupPhone, - isVeteran: capture.isVeteran ?? true, + lookupPhone: currentCapture.collectedPhone || currentCapture.lookupPhone, + isVeteran: currentCapture.isVeteran ?? true, productType: undefined, }, { schedulingAttempt: schedulingAttempt + 1, - lookupPhone: capture.collectedPhone || capture.lookupPhone, + lookupPhone: currentCapture.collectedPhone || currentCapture.lookupPhone, reason: "new_patient_veteran_callback_missing_details", - collectedName: capture.collectedName, - collectedPhone: capture.collectedPhone, - collectedEmail: capture.collectedEmail, + collectedName: currentCapture.collectedName, + collectedPhone: currentCapture.collectedPhone, + collectedEmail: currentCapture.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", + `veteran_callback_missing_reason=${params.reason}`, + `veteran_callback_empty_attempts=${params.attempts}`, + "preferred_callback_time_not_captured=true", + "preferred_callback_time_note=not_captured_clearly", + params.originalUtterance + ? `veteran_callback_original_utterance=${sanitizeVoiceSummaryToken( + params.originalUtterance, + 240 + )}` + : "", + `veteran_callback_phone_source=${params.phoneSource || "fallback"}`, + `veteran_callback_name_source=${params.nameSource || "fallback"}`, ], } ); - } + }; - if (capture.scriptStep === "veteran_callback" && utterance) { - const veteranCallbackDetails = - parseVeteranCallbackContactDetails(utterance); + const repromptVeteranCallbackStage = async ( + currentCapture: SchedulingCaptureResult, + params: { + prompt: string; + reason: string; + originalUtterance?: string; + phoneSource?: "utterance" | "fallback"; + nameSource?: "utterance" | "fallback"; + } + ) => { + const emptyAttempt = getVeteranCallbackAttempt(); + if (emptyAttempt < 1) { + return respondWithSchedulingCapturePrompt("retry", { + prompt: params.prompt, + schedulingAttempt: schedulingAttempt + 1, + capture: { + ...currentCapture, + isVeteran: currentCapture.isVeteran ?? true, + productType: undefined, + }, + queryParams: { + veteran_callback_empty_attempt: String(emptyAttempt + 1), + }, + }); + } + + return respondWithVeteranCallbackMissingDetails(currentCapture, { + reason: params.reason, + attempts: emptyAttempt + 1, + originalUtterance: params.originalUtterance, + phoneSource: params.phoneSource, + nameSource: params.nameSource, + }); + }; + + const completeVeteranCallbackCapture = async ( + currentCapture: SchedulingCaptureResult, + params: { + callbackTime: string; + originalUtterance?: string; + phoneSource: "utterance" | "fallback"; + nameSource: "utterance" | "fallback"; + } + ) => { const veteranCallbackPhone = - veteranCallbackDetails.phone || - capture.collectedPhone || - capture.lookupPhone; - const veteranCallbackName = - veteranCallbackDetails.name || capture.collectedName; - const veteranCallbackContext = - veteranCallbackDetails.callbackContext || utterance; + currentCapture.collectedPhone || currentCapture.lookupPhone; const captureWithCallback: SchedulingCaptureResult = { - ...capture, - callbackTime: veteranCallbackContext, - collectedName: veteranCallbackName, - collectedPhone: veteranCallbackPhone, - lookupPhone: veteranCallbackPhone || capture.lookupPhone, - isVeteran: capture.isVeteran ?? true, + ...currentCapture, + callbackTime: params.callbackTime, + lookupPhone: veteranCallbackPhone || currentCapture.lookupPhone, + isVeteran: currentCapture.isVeteran ?? true, productType: undefined, }; return respondWithBookingDraftFallback( "booking_request_required", { ...captureWithCallback, - lookupPhone: veteranCallbackPhone || capture.lookupPhone, - offerKey: capture.offerKey, + lookupPhone: veteranCallbackPhone || currentCapture.lookupPhone, + offerKey: currentCapture.offerKey, }, { schedulingAttempt: schedulingAttempt + 1, - lookupPhone: veteranCallbackPhone || capture.lookupPhone, + lookupPhone: veteranCallbackPhone || currentCapture.lookupPhone, reason: "new_patient_veteran_callback", - collectedName: veteranCallbackName, + collectedName: currentCapture.collectedName, collectedPhone: veteranCallbackPhone, - collectedEmail: capture.collectedEmail, + collectedEmail: currentCapture.collectedEmail, confirmationPrompt: NEW_PATIENT_VETERAN_CALLBACK_CONFIRMATION, summaryTokens: [ - `veteran_callback_original_utterance=${sanitizeVoiceSummaryToken( - veteranCallbackDetails.originalUtterance, - 240 - )}`, - veteranCallbackDetails.phone - ? "veteran_callback_phone_source=utterance" - : "veteran_callback_phone_source=fallback", - veteranCallbackDetails.name - ? "veteran_callback_name_source=utterance" - : "veteran_callback_name_source=fallback", + params.originalUtterance + ? `veteran_callback_original_utterance=${sanitizeVoiceSummaryToken( + params.originalUtterance, + 240 + )}` + : "", + `veteran_callback_phone_source=${params.phoneSource}`, + `veteran_callback_name_source=${params.nameSource}`, ], } ); + }; + + // Veteran callback capture is staged to avoid completing on partial speech: + // name, then phone, then callback time as needed. This lane remains staff-only. + if (isVeteranCallbackScriptStep(capture.scriptStep)) { + if (!utterance) { + const nextStep = + capture.scriptStep === "veteran_callback" + ? resolveVeteranCallbackNextStep(capture) + : capture.scriptStep; + return repromptVeteranCallbackStage( + { + ...capture, + scriptStep: nextStep, + isVeteran: capture.isVeteran ?? true, + productType: undefined, + }, + { + prompt: + nextStep === "veteran_callback_time" + ? "What time is best for our team to call you back?" + : buildVeteranCallbackStagePrompt(capture, nextStep), + reason: "silence", + } + ); + } + + const veteranCallbackDetails = + parseVeteranCallbackContactDetails(utterance); + const spokenName = + veteranCallbackDetails.name || + (!veteranCallbackDetails.phone && + !hasVeteranCallbackAvailabilityLanguage(utterance) + ? validateNameInput(normalizeSpokenName(utterance)).name || undefined + : undefined); + const captureWithUtteranceDetails: SchedulingCaptureResult = { + ...capture, + collectedName: spokenName || capture.collectedName, + collectedPhone: veteranCallbackDetails.phone || capture.collectedPhone, + lookupPhone: + veteranCallbackDetails.phone || + capture.collectedPhone || + capture.lookupPhone, + isVeteran: capture.isVeteran ?? true, + productType: undefined, + }; + + const callbackTimeCandidate = String( + veteranCallbackDetails.callbackContext || "" + ).trim(); + const fallbackTimeCandidate = + capture.scriptStep === "veteran_callback_time" || + capture.scriptStep === "veteran_callback" + ? String(utterance || "").trim() + : ""; + const callbackTime = + callbackTimeCandidate && isValidVeteranCallbackTime(callbackTimeCandidate) + ? callbackTimeCandidate + : fallbackTimeCandidate && + isValidVeteranCallbackTime(fallbackTimeCandidate) + ? fallbackTimeCandidate + : ""; + + if ( + captureWithUtteranceDetails.collectedName && + (captureWithUtteranceDetails.collectedPhone || + captureWithUtteranceDetails.lookupPhone) && + callbackTime + ) { + return completeVeteranCallbackCapture(captureWithUtteranceDetails, { + callbackTime, + originalUtterance: veteranCallbackDetails.originalUtterance, + phoneSource: veteranCallbackDetails.phone ? "utterance" : "fallback", + nameSource: spokenName ? "utterance" : "fallback", + }); + } + + if (!captureWithUtteranceDetails.collectedName) { + return repromptVeteranCallbackStage( + { + ...captureWithUtteranceDetails, + scriptStep: "veteran_callback_name", + }, + { + prompt: buildVeteranCallbackStagePrompt( + captureWithUtteranceDetails, + "veteran_callback_name" + ), + reason: "missing_name", + originalUtterance: veteranCallbackDetails.originalUtterance, + phoneSource: veteranCallbackDetails.phone ? "utterance" : "fallback", + } + ); + } + + if ( + !captureWithUtteranceDetails.collectedPhone && + !captureWithUtteranceDetails.lookupPhone + ) { + return repromptVeteranCallbackStage( + { + ...captureWithUtteranceDetails, + scriptStep: "veteran_callback_phone", + }, + { + prompt: buildVeteranCallbackStagePrompt( + captureWithUtteranceDetails, + "veteran_callback_phone" + ), + reason: "missing_phone", + originalUtterance: veteranCallbackDetails.originalUtterance, + nameSource: spokenName ? "utterance" : "fallback", + } + ); + } + + return repromptVeteranCallbackStage( + { + ...captureWithUtteranceDetails, + scriptStep: "veteran_callback_time", + }, + { + prompt: "What time is best for our team to call you back?", + reason: isShortVeteranCallbackAcknowledgement(utterance) + ? "short_acknowledgement" + : "missing_callback_time", + originalUtterance: veteranCallbackDetails.originalUtterance, + phoneSource: veteranCallbackDetails.phone ? "utterance" : "fallback", + nameSource: spokenName ? "utterance" : "fallback", + } + ); } // Veterans donation question (post-booking) diff --git a/api/tests/voicePaymentCapture.test.ts b/api/tests/voicePaymentCapture.test.ts index 829a4f1..ba58d17 100644 --- a/api/tests/voicePaymentCapture.test.ts +++ b/api/tests/voicePaymentCapture.test.ts @@ -150,6 +150,14 @@ function wait(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } +function expectNoVeteranCallbackPaymentArtifacts(text: string) { + expect(text).not.toContain(" { beforeEach(async () => { process.env.NODE_ENV = "test"; @@ -445,9 +453,13 @@ describe("voice PCI-safe payment capture", () => { expect(veteranQuestionAction.searchParams.get("product_type")).toBeNull(); }); - it("routes veteran yes to owner-approved Veterans Cannabis Care callback script", async () => { + it("routes veteran yes with existing contact to owner-approved script and callback time only", async () => { const callSid = `${CALLSID_PREFIX}VETERAN_YES_CALLBACK`; const paymentQuery = newPatientPaymentQuery(); + const acuityNoteSpy = jest.spyOn( + acuityProvider, + "writeAcuityPaymentReconciliationNote" + ); const declinedFull = await request(app) .post(`/api/voice/intent?${paymentQuery.toString()}`) @@ -499,21 +511,19 @@ 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("I have Jane Patient and a phone number ending in 0101"); + expect(veteranYes.text).toContain("What is the best time for our team to call you back about Veterans Cannabis Care?"); + expect(veteranYes.text).not.toContain("You can say that now: your full name, phone number"); expect(veteranYes.text).toContain('speechTimeout="auto"'); expect(veteranYes.text).toContain('timeout="10"'); - expect(veteranYes.text).not.toContain(" { .post(`${callbackAction.pathname}${callbackAction.search}`) .set("X-VetCan-Tenant", "marijuanaexpressmd.astormscoming.com") .type("form") + .send({ + CallSid: callSid, + SpeechResult: "tomorrow morning", + Digits: "", + From: TEST_PHONES[0], + To: "+18005550199", + }); + + expect(callbackTime.status).toBe(200); + expect(callbackTime.text).toContain("captured that veteran callback request"); + expectNoVeteranCallbackPaymentArtifacts(callbackTime.text); + + const callback = await prisma.callbackRequest.findFirst({ + where: { + phone: TEST_PHONES[0], + summary: { contains: "fallback_reason=new_patient_veteran_callback" }, + }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + }); + expect(callback).toBeTruthy(); + expect(callback?.name).toBe("Jane Patient"); + expect(callback?.preferredTime).toBe("tomorrow morning"); + expect(callback?.summary || "").toContain( + "veteran_callback_original_utterance=tomorrow morning" + ); + expect(callback?.summary || "").toContain("callback_window=tomorrow morning"); + expect(callback?.summary || "").toContain("veteran_callback_phone_source=fallback"); + expect(acuityNoteSpy).not.toHaveBeenCalled(); + }); + + it("rejects short veteran callback time acknowledgements without completing callback", async () => { + const callSid = `${CALLSID_PREFIX}VETERAN_CALLBACK_YES_RETRY`; + const veteranCallbackQuery = newPatientPaymentQuery({ + script_step: "veteran_callback_time", + payment_option: "not_ready", + is_veteran: "true", + }); + + const yes = await request(app) + .post(`/api/voice/intent?${veteranCallbackQuery.toString()}`) + .set("X-VetCan-Tenant", "marijuanaexpressmd.astormscoming.com") + .type("form") + .send({ + CallSid: callSid, + SpeechResult: "Yes", + Digits: "", + From: TEST_PHONES[0], + To: "+18005550199", + }); + + expect(yes.status).toBe(200); + expect(yes.text).toContain("What time is best for our team to call you back?"); + expect(yes.text).not.toContain("captured that veteran callback request"); + expectNoVeteranCallbackPaymentArtifacts(yes.text); + + const retryAction = getGatherActionUrl(yes.text); + expect(retryAction.searchParams.get("script_step")).toBe("veteran_callback_time"); + 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("accepts natural free-form veteran callback windows", async () => { + const callbackWindows = [ + "when someone is available", + "at your convenience", + "any weekday afternoon", + "whenever works", + ]; + + for (const [index, callbackWindow] of callbackWindows.entries()) { + const callSid = `${CALLSID_PREFIX}VETERAN_CALLBACK_FREEFORM_${index}`; + const veteranCallbackQuery = newPatientPaymentQuery({ + script_step: "veteran_callback_time", + payment_option: "not_ready", + is_veteran: "true", + }); + + const callbackTime = await request(app) + .post(`/api/voice/intent?${veteranCallbackQuery.toString()}`) + .set("X-VetCan-Tenant", "marijuanaexpressmd.astormscoming.com") + .type("form") + .send({ + CallSid: callSid, + SpeechResult: callbackWindow, + Digits: "", + From: TEST_PHONES[0], + To: "+18005550199", + }); + + expect(callbackTime.status).toBe(200); + expect(callbackTime.text).toContain("captured that veteran callback request"); + expectNoVeteranCallbackPaymentArtifacts(callbackTime.text); + + const callback = await prisma.callbackRequest.findFirst({ + where: { + phone: TEST_PHONES[0], + summary: { + contains: `veteran_callback_original_utterance=${callbackWindow}`, + }, + }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + }); + expect(callback).toBeTruthy(); + expect(callback?.name).toBe("Jane Patient"); + expect(callback?.preferredTime).toBe(callbackWindow); + expect(callback?.summary || "").toContain(`callback_window=${callbackWindow}`); + } + }); + + it("rejects clear name-only veteran callback time utterances", async () => { + const callSid = `${CALLSID_PREFIX}VETERAN_CALLBACK_NAME_RETRY`; + const veteranCallbackQuery = newPatientPaymentQuery({ + script_step: "veteran_callback_time", + payment_option: "not_ready", + is_veteran: "true", + }); + + const nameOnly = await request(app) + .post(`/api/voice/intent?${veteranCallbackQuery.toString()}`) + .set("X-VetCan-Tenant", "marijuanaexpressmd.astormscoming.com") + .type("form") + .send({ + CallSid: callSid, + SpeechResult: "John Smith", + Digits: "", + From: TEST_PHONES[0], + To: "+18005550199", + }); + + expect(nameOnly.status).toBe(200); + expect(nameOnly.text).toContain("What time is best for our team to call you back?"); + expect(nameOnly.text).not.toContain("captured that veteran callback request"); + expectNoVeteranCallbackPaymentArtifacts(nameOnly.text); + + const retryAction = getGatherActionUrl(nameOnly.text); + expect(retryAction.searchParams.get("script_step")).toBe("veteran_callback_time"); + 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("asks for missing veteran callback phone before callback time", async () => { + const callSid = `${CALLSID_PREFIX}VETERAN_CALLBACK_MISSING_PHONE`; + const veteranQuery = newPatientPaymentQuery({ + script_step: "veteran", + payment_option: "not_ready", + collected_phone: "", + }); + + const veteranYes = await request(app) + .post(`/api/voice/intent?${veteranQuery.toString()}`) + .set("X-VetCan-Tenant", "marijuanaexpressmd.astormscoming.com") + .type("form") + .send({ + CallSid: callSid, + SpeechResult: "Yes, I am a veteran", + Digits: "", + From: TEST_PHONES[0], + To: "+18005550199", + }); + + expect(veteranYes.status).toBe(200); + expect(veteranYes.text).toContain("Veterans Cannabis Care in Casselberry, Florida"); + expect(veteranYes.text).toContain("I have Jane Patient. What phone number should our team use"); + expectNoVeteranCallbackPaymentArtifacts(veteranYes.text); + + const phoneAction = getGatherActionUrl(veteranYes.text); + expect(phoneAction.searchParams.get("script_step")).toBe("veteran_callback_phone"); + + const phone = await request(app) + .post(`${phoneAction.pathname}${phoneAction.search}`) + .set("X-VetCan-Tenant", "marijuanaexpressmd.astormscoming.com") + .type("form") + .send({ + CallSid: callSid, + SpeechResult: "407-555-1212", + Digits: "", + From: TEST_PHONES[0], + To: "+18005550199", + }); + + expect(phone.status).toBe(200); + expect(phone.text).toContain("What time is best for our team to call you back?"); + expect(phone.text).not.toContain("captured that veteran callback request"); + expectNoVeteranCallbackPaymentArtifacts(phone.text); + + const timeAction = getGatherActionUrl(phone.text); + expect(timeAction.searchParams.get("script_step")).toBe("veteran_callback_time"); + expect(timeAction.searchParams.get("collected_phone")).toBe("+14075551212"); + }); + + it("asks for missing veteran callback name before callback time", async () => { + const callSid = `${CALLSID_PREFIX}VETERAN_CALLBACK_MISSING_NAME`; + const veteranQuery = newPatientPaymentQuery({ + script_step: "veteran", + payment_option: "not_ready", + collected_name: "", + }); + + const veteranYes = await request(app) + .post(`/api/voice/intent?${veteranQuery.toString()}`) + .set("X-VetCan-Tenant", "marijuanaexpressmd.astormscoming.com") + .type("form") + .send({ + CallSid: callSid, + SpeechResult: "Yes, I am a veteran", + Digits: "", + From: TEST_PHONES[0], + To: "+18005550199", + }); + + expect(veteranYes.status).toBe(200); + expect(veteranYes.text).toContain("Veterans Cannabis Care in Casselberry, Florida"); + expect(veteranYes.text).toContain("I have a phone number ending in 0101"); + expect(veteranYes.text).toContain("What is your full name for the Veterans Cannabis Care callback?"); + expectNoVeteranCallbackPaymentArtifacts(veteranYes.text); + + const nameAction = getGatherActionUrl(veteranYes.text); + expect(nameAction.searchParams.get("script_step")).toBe("veteran_callback_name"); + + const name = await request(app) + .post(`${nameAction.pathname}${nameAction.search}`) + .set("X-VetCan-Tenant", "marijuanaexpressmd.astormscoming.com") + .type("form") + .send({ + CallSid: callSid, + SpeechResult: "John Smith", + Digits: "", + From: TEST_PHONES[0], + To: "+18005550199", + }); + + expect(name.status).toBe(200); + expect(name.text).toContain("What time is best for our team to call you back?"); + expect(name.text).not.toContain("captured that veteran callback request"); + expectNoVeteranCallbackPaymentArtifacts(name.text); + + const timeAction = getGatherActionUrl(name.text); + expect(timeAction.searchParams.get("script_step")).toBe("veteran_callback_time"); + expect(timeAction.searchParams.get("collected_name")).toBe("John Smith"); + }); + + it("still accepts full veteran callback details in one utterance when provided", async () => { + const callSid = `${CALLSID_PREFIX}VETERAN_CALLBACK_FULL_UTTERANCE`; + const veteranCallbackQuery = newPatientPaymentQuery({ + script_step: "veteran_callback", + payment_option: "not_ready", + is_veteran: "true", + collected_name: "", + collected_phone: "", + }); + const acuityNoteSpy = jest.spyOn( + acuityProvider, + "writeAcuityPaymentReconciliationNote" + ); + + const callbackTime = await request(app) + .post(`/api/voice/intent?${veteranCallbackQuery.toString()}`) + .set("X-VetCan-Tenant", "marijuanaexpressmd.astormscoming.com") + .type("form") .send({ CallSid: callSid, SpeechResult: "John Smith, 407-555-1212, tomorrow morning", @@ -531,11 +816,7 @@ describe("voice PCI-safe payment capture", () => { expect(callbackTime.status).toBe(200); expect(callbackTime.text).toContain("captured that veteran callback request"); - expect(callbackTime.text).not.toContain(" { expect(callback?.summary || "").toContain( "veteran_callback_original_utterance=John Smith, 407-555-1212, tomorrow morning" ); - expect(callback?.summary || "").toContain("callback_window=tomorrow morning"); expect(callback?.summary || "").toContain("veteran_callback_phone_source=utterance"); + expect(acuityNoteSpy).not.toHaveBeenCalled(); }); it("veteran callback capture falls back to existing phone when utterance has no parseable phone", async () => { @@ -614,21 +895,17 @@ describe("voice PCI-safe payment capture", () => { Digits: "", From: TEST_PHONES[0], To: "+18005550199", - }); + }); expect(silence.status).toBe(200); - expect(silence.text).toContain("I didn'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("What time is best 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("