diff --git a/app/admin/[businessId]/page.tsx b/app/admin/[businessId]/page.tsx index 091c0fc..04504d1 100644 --- a/app/admin/[businessId]/page.tsx +++ b/app/admin/[businessId]/page.tsx @@ -63,6 +63,8 @@ import { type TwilioSetupTone, businessPhonePathOptions, buildTwilioSetupFlow, + forwardedCallAnswerOptions, + messagingSetupOptions, twilioAccountModeOptions, } from '@/lib/twilio-setup'; @@ -70,6 +72,8 @@ type AdminTwilioDefaults = { businessId: string; twilioAccountMode: string; phoneSetupPath: string; + forwardedCallAnswerMode: string; + messagingSetupMode: string; twilioNumberSetupMode: string; twilioSubaccountSid: string; twilioPhoneNumber: string; @@ -323,6 +327,8 @@ export default async function AdminBusinessDetailPage({ businessId: business.id, twilioAccountMode: business.twilioAccountMode, phoneSetupPath: business.phoneSetupPath, + forwardedCallAnswerMode: business.forwardedCallAnswerMode, + messagingSetupMode: business.messagingSetupMode, twilioNumberSetupMode: business.twilioNumberSetupMode, twilioSubaccountSid: business.twilioSubaccountSid || '', twilioPhoneNumber: business.twilioPrimaryPhoneNumber || business.twilioPhoneNumber || '', @@ -517,7 +523,11 @@ export default async function AdminBusinessDetailPage({ if (step.key === 'number_path') { return (
- +
{businessPhonePathOptions.map((option) => ( ))}
+
+

Forwarded call answer confirmation

+ {forwardedCallAnswerOptions.map((option) => ( + + ))} +
+
+

Messaging setup mode

+ {messagingSetupOptions.map((option) => ( + + ))} +
); } if (step.key === 'account_ready') { + if (business.messagingSetupMode === 'SHARED_PILOT_MESSAGING_SERVICE') { + return ( +
+ Pilot setup is founder-operated. A dedicated subaccount is optional while SMS sends through the approved CallbackCloser Messaging + Service. +
+ ); + } + if (business.twilioAccountMode === 'MAIN_ACCOUNT') { return
Main account mode is active, so this step does not require a business subaccount.
; } @@ -556,6 +613,15 @@ export default async function AdminBusinessDetailPage({ } if (step.key === 'messaging_service_ready') { + if (business.messagingSetupMode === 'SHARED_PILOT_MESSAGING_SERVICE') { + return ( +
+

Pilot setup: current number forwards to CallbackCloser; SMS sends from the approved CallbackCloser messaging number.

+

Do not create a new per-business Messaging Service here. Save the approved shared Messaging Service SID in the admin override panel below.

+
+ ); + } + return (
@@ -1328,6 +1394,20 @@ export default async function AdminBusinessDetailPage({

Business number path

{getBusinessPhoneSetupPathLabel(business.phoneSetupPath)}

+
+

Routing number

+

+ {managedTextingNumber ? formatPhoneForDisplay(managedTextingNumber) : 'Routing number not assigned yet'} +

+
+
+

Answer confirmation

+

{setupFlow.forwardedCallAnswerModeLabel}

+
+
+

Messaging setup mode

+

{setupFlow.messagingSetupModeLabel}

+
diff --git a/app/admin/actions.ts b/app/admin/actions.ts index b1151be..0391e83 100644 --- a/app/admin/actions.ts +++ b/app/admin/actions.ts @@ -3,8 +3,10 @@ import { BusinessPhoneSetupPath, BusinessProvisioningStatus, + ForwardedCallAnswerMode, ForwardingVerificationStatus, ManagedTwilioStatus, + MessagingSetupMode, MessagingComplianceType, PortingStatus, Prisma, @@ -347,6 +349,8 @@ export async function createDemoBusinessAction(formData: FormData) { notifyPhone: ownerPhone, provisioningStatus: BusinessProvisioningStatus.DRAFT, phoneSetupPath: BusinessPhoneSetupPath.NEW_TWILIO_NUMBER, + forwardedCallAnswerMode: ForwardedCallAnswerMode.PRESS_1_REQUIRED, + messagingSetupMode: MessagingSetupMode.PER_BUSINESS_TWILIO, missedCallSeconds: 20, serviceLabel1: 'Repair', serviceLabel2: 'Install', @@ -369,6 +373,8 @@ export async function createDemoBusinessAction(formData: FormData) { notifyPhone: ownerPhone, provisioningStatus: BusinessProvisioningStatus.DRAFT, phoneSetupPath: BusinessPhoneSetupPath.NEW_TWILIO_NUMBER, + forwardedCallAnswerMode: ForwardedCallAnswerMode.PRESS_1_REQUIRED, + messagingSetupMode: MessagingSetupMode.PER_BUSINESS_TWILIO, subscriptionStatus: SubscriptionStatus.ACTIVE, subscriptionStatusUpdatedAt: new Date(), managedTwilioStatusUpdatedAt: new Date(), @@ -415,6 +421,8 @@ export async function createAdminBusinessAction(formData: FormData) { const forwardingNumber = normalizePhoneNumber(data.forwardingNumber); const publicBusinessPhone = normalizePhoneNumber(data.publicBusinessPhone || '') || null; const phoneSetupPath = data.phoneSetupPath as BusinessPhoneSetupPath; + const forwardedCallAnswerMode = data.forwardedCallAnswerMode as ForwardedCallAnswerMode; + const messagingSetupMode = data.messagingSetupMode as MessagingSetupMode; const business = await db.business.create({ data: { @@ -427,6 +435,8 @@ export async function createAdminBusinessAction(formData: FormData) { notifyPhone: ownerPhone, provisioningStatus: BusinessProvisioningStatus.DRAFT, phoneSetupPath, + forwardedCallAnswerMode, + messagingSetupMode, twilioNumberSetupMode: deriveTwilioNumberSetupModeFromPhoneSetupPath(phoneSetupPath), forwardingVerificationStatus: phoneSetupPath === BusinessPhoneSetupPath.CURRENT_NUMBER_FORWARDING @@ -792,6 +802,8 @@ export async function saveAdminTwilioSetupAction(formData: FormData) { const twilioAccountMode = data.twilioAccountMode as TwilioAccountMode; const phoneSetupPath = data.phoneSetupPath as BusinessPhoneSetupPath; + const forwardedCallAnswerMode = data.forwardedCallAnswerMode as ForwardedCallAnswerMode; + const messagingSetupMode = data.messagingSetupMode as MessagingSetupMode; const twilioNumberSetupMode = deriveTwilioNumberSetupModeFromPhoneSetupPath(phoneSetupPath) as TwilioNumberSetupMode; const twilioSubaccountSid = twilioAccountMode === TwilioAccountMode.MAIN_ACCOUNT ? null : normalizeOptionalSid(data.twilioSubaccountSid); let twilioPhoneNumber: string | null; @@ -859,6 +871,8 @@ export async function saveAdminTwilioSetupAction(formData: FormData) { const twilioMappingChanged = existingBusiness.twilioAccountMode !== twilioAccountMode || existingBusiness.phoneSetupPath !== phoneSetupPath || + existingBusiness.forwardedCallAnswerMode !== forwardedCallAnswerMode || + existingBusiness.messagingSetupMode !== messagingSetupMode || existingBusiness.twilioNumberSetupMode !== twilioNumberSetupMode || existingBusiness.twilioSubaccountSid !== twilioSubaccountSid || (existingBusiness.twilioPrimaryPhoneNumber || existingBusiness.twilioPhoneNumber) !== twilioPhoneNumber || @@ -887,6 +901,22 @@ export async function saveAdminTwilioSetupAction(formData: FormData) { existingBusiness.phoneSetupPath !== phoneSetupPath ? { key: 'phoneSetupPath', label: 'business number path', before: existingBusiness.phoneSetupPath, after: phoneSetupPath } : null, + existingBusiness.forwardedCallAnswerMode !== forwardedCallAnswerMode + ? { + key: 'forwardedCallAnswerMode', + label: 'forwarded call answer mode', + before: existingBusiness.forwardedCallAnswerMode, + after: forwardedCallAnswerMode, + } + : null, + existingBusiness.messagingSetupMode !== messagingSetupMode + ? { + key: 'messagingSetupMode', + label: 'messaging setup mode', + before: existingBusiness.messagingSetupMode, + after: messagingSetupMode, + } + : null, existingBusiness.twilioNumberSetupMode !== twilioNumberSetupMode ? { key: 'twilioNumberSetupMode', @@ -1014,6 +1044,8 @@ export async function saveAdminTwilioSetupAction(formData: FormData) { data: { twilioAccountMode, phoneSetupPath, + forwardedCallAnswerMode, + messagingSetupMode, twilioNumberSetupMode, twilioSubaccountSid, forwardingVerificationStatus: @@ -1951,6 +1983,7 @@ export async function sendBusinessTestSmsAction(formData: FormData) { context: 'admin_test', twilioSubaccountSid: business.twilioAccountMode === TwilioAccountMode.MAIN_ACCOUNT ? null : business.twilioSubaccountSid, messagingServiceSid: business.twilioMessagingServiceSid, + messagingSetupMode: business.messagingSetupMode, managedTwilioStatus: business.managedTwilioStatus, a2pFailureReason: business.a2pFailureReason, messagingComplianceType: business.messagingComplianceType, diff --git a/app/api/twilio/status/route.ts b/app/api/twilio/status/route.ts index cf1fdd2..fa767d5 100644 --- a/app/api/twilio/status/route.ts +++ b/app/api/twilio/status/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server'; +import { ForwardedCallAnswerMode } from '@prisma/client'; import { findBusinessByTwilioNumber } from '@/lib/business'; import { db } from '@/lib/db'; @@ -180,8 +181,20 @@ export async function POST(request: Request) { return withCorrelation(xmlOk()); } - const answered = dialCallStatus.trim().toLowerCase() === 'completed'; - const missed = isMissedDialStatus(dialCallStatus); + const existingCall = await db.call.findUnique({ + where: { twilioCallSid: callSid }, + select: { + id: true, + answerConfirmationRequested: true, + humanAccepted: true, + }, + }); + const completedDial = dialCallStatus.trim().toLowerCase() === 'completed'; + const requiresHumanAcceptance = + existingCall?.answerConfirmationRequested ?? business.forwardedCallAnswerMode === ForwardedCallAnswerMode.PRESS_1_REQUIRED; + const humanAccepted = existingCall?.humanAccepted ?? false; + const answered = completedDial && (!requiresHumanAcceptance || humanAccepted); + const missed = isMissedDialStatus(dialCallStatus) || (completedDial && requiresHumanAcceptance && !humanAccepted); const call = await db.call.upsert({ where: { twilioCallSid: callSid }, @@ -195,11 +208,13 @@ export async function POST(request: Request) { toPhone: to || formField(formData, 'To'), toPhoneNormalized: to || formField(formData, 'To'), dialCallStatus: dialCallStatus || null, + answerConfirmationRequested: requiresHumanAcceptance, status: answered ? 'ANSWERED' : missed ? 'MISSED' : 'COMPLETED', callDurationSeconds: toInt(formField(formData, 'CallDuration')), dialCallDurationSeconds: toInt(formField(formData, 'DialCallDuration')), ...(recordingUpdate ?? {}), answered, + humanAccepted, missed, rawPayload: payload, }, @@ -207,11 +222,13 @@ export async function POST(request: Request) { parentCallSid: formField(formData, 'ParentCallSid') || undefined, dialCallSid, dialCallStatus: dialCallStatus || null, + answerConfirmationRequested: requiresHumanAcceptance, status: answered ? 'ANSWERED' : missed ? 'MISSED' : 'COMPLETED', callDurationSeconds: toInt(formField(formData, 'CallDuration')), dialCallDurationSeconds: toInt(formField(formData, 'DialCallDuration')), ...(recordingUpdate ?? {}), answered, + humanAccepted, missed, rawPayload: payload, }, @@ -224,6 +241,7 @@ export async function POST(request: Request) { eventType: 'dial_status_callback', businessId: business.id, answered, + humanAccepted, missed, decision: 'upsert_call', }); @@ -241,6 +259,42 @@ export async function POST(request: Request) { }); } + if (completedDial && requiresHumanAcceptance && !humanAccepted) { + await recordBusinessOperatorEvent({ + businessId: business.id, + type: 'voice.no_human_acceptance_detected', + category: 'VOICE', + status: 'WARNING', + summary: 'No human acceptance detected', + details: { + callSid, + dialCallSid, + dialCallStatus, + fromPhone: formatPhoneDetail(from || formField(formData, 'From')), + toPhone: formatPhoneDetail(to), + reason: 'timeout_or_voicemail', + }, + relatedEntityType: 'call', + relatedEntityId: call.id, + }); + await recordBusinessOperatorEvent({ + businessId: business.id, + type: 'voice.voicemail_or_timeout_treated_as_missed', + category: 'VOICE', + status: 'WARNING', + summary: 'Voicemail or timeout treated as missed', + details: { + callSid, + dialCallSid, + dialCallStatus, + fromPhone: formatPhoneDetail(from || formField(formData, 'From')), + toPhone: formatPhoneDetail(to), + }, + relatedEntityType: 'call', + relatedEntityId: call.id, + }); + } + if (!missed) { logTwilioInfo('status', 'not_missed_noop', { callSid, @@ -424,6 +478,7 @@ export async function POST(request: Request) { participant: 'OWNER', twilioSubaccountSid: business.twilioAccountMode === 'MAIN_ACCOUNT' ? null : business.twilioSubaccountSid, messagingServiceSid: business.twilioMessagingServiceSid, + messagingSetupMode: business.messagingSetupMode, managedTwilioStatus: business.managedTwilioStatus, a2pFailureReason: business.a2pFailureReason, messagingComplianceType: business.messagingComplianceType, @@ -489,7 +544,7 @@ export async function POST(request: Request) { business, callerPhone: callerPhoneNormalized, callId: call.id, - transport: 'twilio', + transport: process.env.NODE_ENV === 'test' ? 'simulated' : 'twilio', }); if (!recovery.started) { @@ -531,6 +586,19 @@ export async function POST(request: Request) { relatedEntityType: 'lead', relatedEntityId: recovery.lead.id, }); + await recordBusinessOperatorEvent({ + businessId: business.id, + type: 'voice.missed_call_sms_started', + category: 'VOICE', + status: 'SUCCESS', + summary: 'Missed-call SMS started', + details: { + callSid, + leadId: recovery.lead.id, + }, + relatedEntityType: 'lead', + relatedEntityId: recovery.lead.id, + }); logTwilioInfo('status', 'initial_missed_call_sms_started', { callSid, dialCallSid, diff --git a/app/api/twilio/voice/route.ts b/app/api/twilio/voice/route.ts index a6ca896..474b077 100644 --- a/app/api/twilio/voice/route.ts +++ b/app/api/twilio/voice/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from 'next/server'; -import { BusinessPhoneSetupPath, ForwardingVerificationStatus } from '@prisma/client'; +import { BusinessPhoneSetupPath, ForwardedCallAnswerMode, ForwardingVerificationStatus } from '@prisma/client'; import { findBusinessByTwilioNumber } from '@/lib/business'; import { getBusinessRoutingNumber } from '@/lib/business-phone-setup'; @@ -7,10 +7,10 @@ import { db } from '@/lib/db'; import { getCorrelationIdFromRequest, withCorrelationIdHeader } from '@/lib/observability'; import { formatPhoneDetail, recordBusinessOperatorEvent } from '@/lib/operator-events'; import { normalizePhoneNumber, normalizePhoneNumberToE164 } from '@/lib/phone'; -import { RATE_LIMIT_TWILIO_AUTH_MAX, RATE_LIMIT_TWILIO_UNAUTH_MAX, RATE_LIMIT_WINDOW_MS } from '@/lib/rate-limit-config'; import { buildRateLimitHeaders, consumeRateLimit, getClientIpAddress } from '@/lib/rate-limit'; -import { logTwilioError, logTwilioInfo, logTwilioWarn } from '@/lib/twilio-logging'; +import { RATE_LIMIT_TWILIO_AUTH_MAX, RATE_LIMIT_TWILIO_UNAUTH_MAX, RATE_LIMIT_WINDOW_MS } from '@/lib/rate-limit-config'; import { buildDialRecordingOptions } from '@/lib/twilio-recording'; +import { logTwilioError, logTwilioInfo, logTwilioWarn } from '@/lib/twilio-logging'; import { hasValidTwilioWebhookRequest, isTwilioSignatureValidationEnabled } from '@/lib/twilio-webhook'; import { voiceTwiML } from '@/lib/twiml'; import { absoluteUrl } from '@/lib/url'; @@ -18,6 +18,9 @@ import { absoluteUrl } from '@/lib/url'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; +const OWNER_SCREEN_STAGE = 'screen-forwarded-call'; +const OWNER_SCREEN_RESULT_STAGE = 'screen-forwarded-call-result'; + function formField(formData: FormData, key: string) { const value = formData.get(key); return typeof value === 'string' ? value : ''; @@ -49,14 +52,24 @@ function rateLimitVoiceResponse(retryAfterSeconds: number) { }); } +function getStage(request: Request) { + return new URL(request.url).searchParams.get('stage')?.trim() || null; +} + +function getQueryParam(request: Request, key: string) { + return new URL(request.url).searchParams.get(key)?.trim() || ''; +} + export async function POST(request: Request) { let callSid: string | null = null; const correlationId = getCorrelationIdFromRequest(request); const withCorrelation = (response: NextResponse) => withCorrelationIdHeader(response, correlationId); + try { const formData = await request.formData(); const payload = Object.fromEntries(formData.entries()) as Record; const clientIp = getClientIpAddress(request); + const stage = getStage(request); const authorized = await hasValidTwilioWebhookRequest(request, payload); if (!authorized) { @@ -69,14 +82,14 @@ export async function POST(request: Request) { logTwilioWarn('voice', 'webhook_unauthorized_rate_limited', { callSid, correlationId, - eventType: 'incoming_call', + eventType: stage ? 'call_screening' : 'incoming_call', decision: 'reject_429', clientIp, }); - return withCorrelation(new NextResponse( - JSON.stringify({ error: 'Too many unauthorized requests' }), - { status: 429, headers: { 'Content-Type': 'application/json', ...buildRateLimitHeaders(rateLimit) } } - )); + return withCorrelation(new NextResponse(JSON.stringify({ error: 'Too many unauthorized requests' }), { + status: 429, + headers: { 'Content-Type': 'application/json', ...buildRateLimitHeaders(rateLimit) }, + })); } logTwilioWarn('voice', 'webhook_unauthorized', { correlationId, decision: 'reject_401' }); @@ -97,7 +110,7 @@ export async function POST(request: Request) { logTwilioWarn('voice', 'webhook_rate_limited', { callSid, correlationId, - eventType: 'incoming_call', + eventType: stage ? 'call_screening' : 'incoming_call', decision: 'reject_429', accountSid: accountSid || null, clientIp, @@ -112,10 +125,92 @@ export async function POST(request: Request) { logTwilioInfo('voice', 'webhook_received', { callSid, correlationId, - eventType: 'incoming_call', + eventType: stage ? 'call_screening' : 'incoming_call', decision: 'processing', }); + if (stage === OWNER_SCREEN_STAGE) { + const parentCallSid = getQueryParam(request, 'parentCallSid') || formField(formData, 'ParentCallSid') || callSid || ''; + const businessId = getQueryParam(request, 'businessId'); + const actionUrl = withWebhookToken( + absoluteUrl( + `/api/twilio/voice?stage=${OWNER_SCREEN_RESULT_STAGE}&businessId=${encodeURIComponent(businessId)}&parentCallSid=${encodeURIComponent(parentCallSid)}` + ) + ); + + const xml = voiceTwiML((response) => { + const gather = response.gather({ + action: actionUrl, + method: 'POST', + numDigits: 1, + timeout: 6, + actionOnEmptyResult: true, + }); + gather.say('CallbackCloser forwarded call. Press 1 to accept this caller.'); + response.say('No confirmation received. Goodbye.'); + response.hangup(); + }); + + return withCorrelation(new NextResponse(xml, { headers: { 'Content-Type': 'text/xml' } })); + } + + if (stage === OWNER_SCREEN_RESULT_STAGE) { + const parentCallSid = getQueryParam(request, 'parentCallSid') || formField(formData, 'ParentCallSid') || ''; + const businessId = getQueryParam(request, 'businessId'); + const digits = formField(formData, 'Digits').trim(); + const confirmed = digits === '1'; + const existingCall = parentCallSid + ? await db.call.findUnique({ + where: { twilioCallSid: parentCallSid }, + select: { id: true, businessId: true }, + }) + : null; + const resolvedBusinessId = businessId || existingCall?.businessId || null; + + if (confirmed && existingCall) { + await db.call.update({ + where: { id: existingCall.id }, + data: { + answered: true, + missed: false, + humanAccepted: true, + humanAcceptedAt: new Date(), + status: 'ANSWERED', + rawPayload: payload, + }, + }); + } + + if (confirmed && resolvedBusinessId) { + await recordBusinessOperatorEvent({ + businessId: resolvedBusinessId, + type: 'voice.human_accepted_call', + category: 'VOICE', + status: 'SUCCESS', + summary: 'Human accepted forwarded call', + details: { + callSid: parentCallSid || callSid, + screeningCallSid: callSid, + reason: 'pressed_1', + }, + relatedEntityType: existingCall ? 'call' : null, + relatedEntityId: existingCall?.id ?? null, + }); + } + + const xml = voiceTwiML((response) => { + if (confirmed) { + response.say('Connecting you now.'); + return; + } + + response.say('No confirmation received. Goodbye.'); + response.hangup(); + }); + + return withCorrelation(new NextResponse(xml, { headers: { 'Content-Type': 'text/xml' } })); + } + const business = to ? await findBusinessByTwilioNumber(to) : null; if (!business) { logTwilioWarn('voice', 'business_not_found', { @@ -143,14 +238,17 @@ export async function POST(request: Request) { fromPhoneNormalized: from || formField(formData, 'From'), toPhone: to || formField(formData, 'To'), toPhoneNormalized: to || formField(formData, 'To'), + answerConfirmationRequested: business.forwardedCallAnswerMode === ForwardedCallAnswerMode.PRESS_1_REQUIRED, status: 'RECEIVED', rawPayload: payload, }, update: { parentCallSid: formField(formData, 'ParentCallSid') || undefined, + answerConfirmationRequested: business.forwardedCallAnswerMode === ForwardedCallAnswerMode.PRESS_1_REQUIRED, rawPayload: payload, }, }); + await recordBusinessOperatorEvent({ businessId: business.id, type: 'voice.inbound_call_received', @@ -209,16 +307,49 @@ export async function POST(request: Request) { }); } + await recordBusinessOperatorEvent({ + businessId: business.id, + type: 'voice.forwarded_call_attempted', + category: 'VOICE', + status: 'PENDING', + summary: 'Forwarded call attempted', + details: { + callSid, + fromPhone: formatPhoneDetail(from || formField(formData, 'From')), + answerMode: business.forwardedCallAnswerMode, + forwardingNumber: formatPhoneDetail(business.forwardingNumber), + }, + relatedEntityType: 'call', + relatedEntityId: callSid, + }); + const actionUrl = withWebhookToken(absoluteUrl('/api/twilio/status')); + const ownerScreeningUrl = withWebhookToken( + absoluteUrl( + `/api/twilio/voice?stage=${OWNER_SCREEN_STAGE}&businessId=${encodeURIComponent(business.id)}&parentCallSid=${encodeURIComponent(callSid || '')}` + ) + ); const xml = voiceTwiML((response) => { const dial = response.dial({ timeout: business.missedCallSeconds, action: actionUrl, method: 'POST', + answerOnBridge: business.forwardedCallAnswerMode === ForwardedCallAnswerMode.PRESS_1_REQUIRED, callerId: business.twilioPrimaryPhoneNumber || business.twilioPhoneNumber || undefined, ...buildDialRecordingOptions(actionUrl), }); - dial.number(business.forwardingNumber); + + if (business.forwardedCallAnswerMode === ForwardedCallAnswerMode.PRESS_1_REQUIRED) { + dial.number( + { + url: ownerScreeningUrl, + method: 'POST', + }, + business.forwardingNumber + ); + } else { + dial.number(business.forwardingNumber); + } }); logTwilioInfo('voice', 'twiml_returned', { @@ -226,12 +357,20 @@ export async function POST(request: Request) { correlationId, eventType: 'incoming_call', businessId: business.id, - decision: 'dial_forwarding_number_with_recording', + decision: + business.forwardedCallAnswerMode === ForwardedCallAnswerMode.PRESS_1_REQUIRED + ? 'dial_forwarding_number_with_press_1_screening' + : 'dial_forwarding_number_with_recording', }); return withCorrelation(new NextResponse(xml, { headers: { 'Content-Type': 'text/xml' } })); } catch (error) { - logTwilioError('voice', 'route_error', { callSid, correlationId, eventType: 'incoming_call', decision: 'fallback_hangup' }, error); + logTwilioError( + 'voice', + 'route_error', + { callSid, correlationId, eventType: 'incoming_call', decision: 'fallback_hangup' }, + error + ); const xml = voiceTwiML((response) => { response.say('Sorry, we are having trouble connecting your call right now.'); response.hangup(); diff --git a/app/app/call-flow/page.tsx b/app/app/call-flow/page.tsx index 84362f4..b91382f 100644 --- a/app/app/call-flow/page.tsx +++ b/app/app/call-flow/page.tsx @@ -59,7 +59,9 @@ export default async function CallFlowPage() { ? { key: 'compliance', label: - managedTwilioSummary.complianceType === 'TOLL_FREE_VERIFICATION' + managedTwilioSummary.usesSharedPilotMessaging + ? 'Pilot sender setup' + : managedTwilioSummary.complianceType === 'TOLL_FREE_VERIFICATION' ? 'Toll-free verification' : managedTwilioSummary.complianceTypeUnknown ? 'Number type' @@ -116,10 +118,14 @@ export default async function CallFlowPage() { { key: 'compliance', label: managedTwilioSummary.complianceReady - ? managedTwilioSummary.complianceType === 'TOLL_FREE_VERIFICATION' + ? managedTwilioSummary.usesSharedPilotMessaging + ? 'Pilot sender ready' + : managedTwilioSummary.complianceType === 'TOLL_FREE_VERIFICATION' ? 'Toll-free verification complete' : 'A2P approved' - : managedTwilioSummary.complianceType === 'TOLL_FREE_VERIFICATION' + : managedTwilioSummary.usesSharedPilotMessaging + ? 'Pilot sender still needed' + : managedTwilioSummary.complianceType === 'TOLL_FREE_VERIFICATION' ? 'Toll-free verification in progress' : managedTwilioSummary.complianceTypeUnknown ? 'Number type still needed' @@ -163,12 +169,17 @@ export default async function CallFlowPage() { }, { title: 'CallbackCloser sees the missed call', - detail: `Current missed-call timeout is ${business.missedCallSeconds} seconds before the recovery flow starts.`, + detail: + business.forwardedCallAnswerMode === 'PRESS_1_REQUIRED' + ? `Current missed-call timeout is ${business.missedCallSeconds} seconds. Forwarded calls only count as answered after your team presses 1, so voicemail pickups still fall back into recovery.` + : `Current missed-call timeout is ${business.missedCallSeconds} seconds before the recovery flow starts.`, }, { title: 'The caller gets a text right away', detail: managedTwilioSummary.complianceReady - ? 'The conversation collects the service type, urgency, ZIP, callback timing, and optional name without extra admin work.' + ? managedTwilioSummary.usesSharedPilotMessaging + ? 'Pilot setup sends the conversation from the approved CallbackCloser messaging number while the business keeps its public number live.' + : 'The conversation collects the service type, urgency, ZIP, callback timing, and optional name without extra admin work.' : managedTwilioSummary.complianceType === 'TOLL_FREE_VERIFICATION' ? 'The automated SMS handoff stays pending until the managed Twilio setup and toll-free verification are complete.' : managedTwilioSummary.complianceTypeUnknown diff --git a/app/app/onboarding/actions.ts b/app/app/onboarding/actions.ts index 57fa57d..5265de5 100644 --- a/app/app/onboarding/actions.ts +++ b/app/app/onboarding/actions.ts @@ -51,6 +51,8 @@ export async function saveOnboardingAction(formData: FormData) { notifyPhone: data.notifyPhone, twilioAccountMode: data.twilioAccountMode, phoneSetupPath: data.phoneSetupPath, + forwardedCallAnswerMode: data.forwardedCallAnswerMode, + messagingSetupMode: data.messagingSetupMode, twilioNumberSetupMode: deriveTwilioNumberSetupModeFromPhoneSetupPath(data.phoneSetupPath), missedCallSeconds: data.missedCallSeconds, serviceLabel1: data.serviceLabel1, diff --git a/app/app/onboarding/page.tsx b/app/app/onboarding/page.tsx index 313ba45..9445dd8 100644 --- a/app/app/onboarding/page.tsx +++ b/app/app/onboarding/page.tsx @@ -3,8 +3,10 @@ import { redirect } from 'next/navigation'; import { BusinessPhoneSetupPath, BusinessProvisioningStatus, + ForwardedCallAnswerMode, ForwardingVerificationStatus, ManagedTwilioStatus, + MessagingSetupMode, MessagingComplianceType, PortingStatus, TollFreeVerificationStatus, @@ -56,6 +58,8 @@ export default async function OnboardingPage({ provisioningStatus: BusinessProvisioningStatus.DRAFT, twilioAccountMode: TwilioAccountMode.BUSINESS_SUBACCOUNT, phoneSetupPath: BusinessPhoneSetupPath.CURRENT_NUMBER_FORWARDING, + forwardedCallAnswerMode: ForwardedCallAnswerMode.PRESS_1_REQUIRED, + messagingSetupMode: MessagingSetupMode.PER_BUSINESS_TWILIO, twilioNumberSetupMode: 'NEW_NUMBER', twilioSubaccountSid: null, twilioMessagingServiceSid: null, @@ -143,6 +147,8 @@ export default async function OnboardingPage({ ) : null} + +

Twilio account mode

diff --git a/app/app/settings/actions.ts b/app/app/settings/actions.ts index 934d859..0be9874 100644 --- a/app/app/settings/actions.ts +++ b/app/app/settings/actions.ts @@ -5,8 +5,10 @@ import { revalidatePath } from 'next/cache'; import { redirect } from 'next/navigation'; import { BusinessPhoneSetupPath, + ForwardedCallAnswerMode, ForwardingVerificationStatus, ManagedTwilioStatus, + MessagingSetupMode, MessagingComplianceType, PortingStatus, Prisma, @@ -98,6 +100,8 @@ export async function saveBusinessSettingsAction(formData: FormData) { : user?.emailAddresses[0]?.emailAddress) || null; const publicBusinessPhone = normalizePhoneNumber(parsed.data.publicBusinessPhone || '') || null; const phoneSetupPath = parsed.data.phoneSetupPath as BusinessPhoneSetupPath; + const forwardedCallAnswerMode = parsed.data.forwardedCallAnswerMode as ForwardedCallAnswerMode; + const messagingSetupMode = parsed.data.messagingSetupMode as MessagingSetupMode; const shouldResetForwardingVerification = phoneSetupPath === BusinessPhoneSetupPath.CURRENT_NUMBER_FORWARDING && (business.phoneSetupPath !== phoneSetupPath || business.publicBusinessPhone !== publicBusinessPhone); @@ -110,6 +114,8 @@ export async function saveBusinessSettingsAction(formData: FormData) { forwardingNumber: normalizePhoneNumber(parsed.data.forwardingNumber), notifyPhone: normalizePhoneNumber(parsed.data.notifyPhone || '') || null, phoneSetupPath, + forwardedCallAnswerMode, + messagingSetupMode, twilioNumberSetupMode: deriveTwilioNumberSetupModeFromPhoneSetupPath(phoneSetupPath), forwardingVerificationStatus: phoneSetupPath === BusinessPhoneSetupPath.CURRENT_NUMBER_FORWARDING @@ -180,6 +186,8 @@ export async function saveBusinessTwilioSetupChoiceAction(formData: FormData) { data: { twilioAccountMode: parsed.data.twilioAccountMode as TwilioAccountMode, phoneSetupPath, + forwardedCallAnswerMode: parsed.data.forwardedCallAnswerMode as ForwardedCallAnswerMode, + messagingSetupMode: parsed.data.messagingSetupMode as MessagingSetupMode, twilioNumberSetupMode: deriveTwilioNumberSetupModeFromPhoneSetupPath(phoneSetupPath), forwardingVerificationStatus: phoneSetupPath === BusinessPhoneSetupPath.CURRENT_NUMBER_FORWARDING @@ -219,6 +227,8 @@ export async function saveBusinessTwilioAdminOverridesAction(formData: FormData) const twilioAccountMode = parsed.data.twilioAccountMode as TwilioAccountMode; const phoneSetupPath = parsed.data.phoneSetupPath as BusinessPhoneSetupPath; + const forwardedCallAnswerMode = parsed.data.forwardedCallAnswerMode as ForwardedCallAnswerMode; + const messagingSetupMode = parsed.data.messagingSetupMode as MessagingSetupMode; const twilioNumberSetupMode = deriveTwilioNumberSetupModeFromPhoneSetupPath(phoneSetupPath) as TwilioNumberSetupMode; const twilioSubaccountSid = twilioAccountMode === TwilioAccountMode.MAIN_ACCOUNT ? null : normalizeOptionalSid(parsed.data.twilioSubaccountSid); const twilioPhoneNumberSid = normalizeOptionalSid(parsed.data.twilioPhoneNumberSid); @@ -282,6 +292,8 @@ export async function saveBusinessTwilioAdminOverridesAction(formData: FormData) const twilioMappingChanged = business.twilioAccountMode !== twilioAccountMode || business.phoneSetupPath !== phoneSetupPath || + business.forwardedCallAnswerMode !== forwardedCallAnswerMode || + business.messagingSetupMode !== messagingSetupMode || business.twilioNumberSetupMode !== twilioNumberSetupMode || business.twilioSubaccountSid !== twilioSubaccountSid || existingTwilioPhoneNumber !== twilioPhoneNumber || @@ -309,6 +321,12 @@ export async function saveBusinessTwilioAdminOverridesAction(formData: FormData) ? { key: 'twilioAccountMode', before: business.twilioAccountMode, after: twilioAccountMode } : null, business.phoneSetupPath !== phoneSetupPath ? { key: 'phoneSetupPath', before: business.phoneSetupPath, after: phoneSetupPath } : null, + business.forwardedCallAnswerMode !== forwardedCallAnswerMode + ? { key: 'forwardedCallAnswerMode', before: business.forwardedCallAnswerMode, after: forwardedCallAnswerMode } + : null, + business.messagingSetupMode !== messagingSetupMode + ? { key: 'messagingSetupMode', before: business.messagingSetupMode, after: messagingSetupMode } + : null, business.twilioNumberSetupMode !== twilioNumberSetupMode ? { key: 'twilioNumberSetupMode', before: business.twilioNumberSetupMode, after: twilioNumberSetupMode } : null, @@ -366,6 +384,8 @@ export async function saveBusinessTwilioAdminOverridesAction(formData: FormData) data: { twilioAccountMode, phoneSetupPath, + forwardedCallAnswerMode, + messagingSetupMode, twilioNumberSetupMode, twilioSubaccountSid, notifyPhone: ownerPhone, @@ -522,6 +542,7 @@ export async function sendBusinessTwilioTestSmsAction(formData: FormData) { context: 'admin_test', twilioSubaccountSid: business.twilioAccountMode === 'MAIN_ACCOUNT' ? null : business.twilioSubaccountSid, messagingServiceSid: business.twilioMessagingServiceSid, + messagingSetupMode: business.messagingSetupMode, managedTwilioStatus: business.managedTwilioStatus, a2pFailureReason: business.a2pFailureReason, messagingComplianceType: business.messagingComplianceType, diff --git a/app/app/settings/page.tsx b/app/app/settings/page.tsx index cd37771..34be29a 100644 --- a/app/app/settings/page.tsx +++ b/app/app/settings/page.tsx @@ -13,7 +13,14 @@ import { getAdminTestSmsConfidenceState } from '@/lib/admin-dashboard'; import { getTwilioWebhookSnapshot } from '@/lib/admin-provisioning'; import { requireBusiness } from '@/lib/auth'; import { getBusinessNotificationSettingsForBusiness } from '@/lib/business-access'; -import { TwilioSetupTone, buildTwilioSetupFlow, businessPhonePathOptions, twilioAccountModeOptions } from '@/lib/twilio-setup'; +import { + TwilioSetupTone, + buildTwilioSetupFlow, + businessPhonePathOptions, + forwardedCallAnswerOptions, + messagingSetupOptions, + twilioAccountModeOptions, +} from '@/lib/twilio-setup'; import { db } from '@/lib/db'; import { getManagedTextingNumber, @@ -38,6 +45,8 @@ const adminChangedFieldLabels: Record = { ownerPhone: 'owner alert phone', twilioAccountMode: 'Twilio account mode', phoneSetupPath: 'business number path', + forwardedCallAnswerMode: 'forwarded call answer mode', + messagingSetupMode: 'messaging setup mode', twilioNumberSetupMode: 'routing number mode', twilioSubaccountSid: 'Twilio subaccount SID', twilioPhoneNumber: 'Twilio number', @@ -61,6 +70,8 @@ const adminChangedFieldLabels: Record = { type BusinessTwilioDefaults = { twilioAccountMode: string; phoneSetupPath: string; + forwardedCallAnswerMode: string; + messagingSetupMode: string; twilioNumberSetupMode: string; twilioSubaccountSid: string; twilioPhoneNumber: string; @@ -166,6 +177,8 @@ export default async function SettingsPage({ searchParams }: { searchParams?: Re const twilioDefaults: BusinessTwilioDefaults = { twilioAccountMode: business.twilioAccountMode, phoneSetupPath: business.phoneSetupPath, + forwardedCallAnswerMode: business.forwardedCallAnswerMode, + messagingSetupMode: business.messagingSetupMode, twilioNumberSetupMode: business.twilioNumberSetupMode, twilioSubaccountSid: business.twilioSubaccountSid || '', twilioPhoneNumber: business.twilioPrimaryPhoneNumber || business.twilioPhoneNumber || '', @@ -242,6 +255,8 @@ export default async function SettingsPage({ searchParams }: { searchParams?: Re body: ( + +
{twilioAccountModeOptions.map((option) => ( ))}
+
+

Forwarded call answer confirmation

+ {forwardedCallAnswerOptions.map((option) => ( + + ))} +
+
+

Messaging setup mode

+ {messagingSetupOptions.map((option) => ( + + ))} +
{setupFlow.phoneSetupPath === 'PORT_EXISTING_NUMBER' ? (

{setupFlow.existingNumberMessage}

) : null} ), @@ -299,7 +352,11 @@ export default async function SettingsPage({ searchParams }: { searchParams?: Re body: adminSession?.isAdmin ? (
- {setupFlow.accountMode === 'BUSINESS_SUBACCOUNT' ? ( + {setupFlow.messagingSetupMode === 'SHARED_PILOT_MESSAGING_SERVICE' ? ( +
+ Pilot setup uses the approved CallbackCloser sender, so a dedicated subaccount is optional during founder-operated onboarding. +
+ ) : setupFlow.accountMode === 'BUSINESS_SUBACCOUNT' ? (
) : (

- {setupFlow.accountMode === 'BUSINESS_SUBACCOUNT' + {setupFlow.messagingSetupMode === 'SHARED_PILOT_MESSAGING_SERVICE' + ? 'Pilot setup is founder-operated, so a dedicated business subaccount is optional while SMS uses the approved CallbackCloser sender.' + : setupFlow.accountMode === 'BUSINESS_SUBACCOUNT' ? 'CallbackCloser will create or reuse a dedicated Twilio subaccount for this business during setup.' : 'CallbackCloser will keep this business on the parent Twilio account.'}

@@ -335,6 +394,11 @@ export default async function SettingsPage({ searchParams }: { searchParams?: Re body: adminSession?.isAdmin ? ( + {setupFlow.messagingSetupMode === 'SHARED_PILOT_MESSAGING_SERVICE' ? ( +
+ Pilot setup: current number forwards to CallbackCloser; SMS sends from the approved CallbackCloser messaging number. +
+ ) : null}
) : ( -

Current value: {business.twilioMessagingServiceSid || 'Not recorded yet.'}

+

+ {setupFlow.messagingSetupMode === 'SHARED_PILOT_MESSAGING_SERVICE' + ? `Pilot sender: ${business.twilioMessagingServiceSid || 'Approved CallbackCloser Messaging Service not recorded yet.'}` + : `Current value: ${business.twilioMessagingServiceSid || 'Not recorded yet.'}`} +

), }; } @@ -630,6 +698,8 @@ export default async function SettingsPage({ searchParams }: { searchParams?: Re
+ +
diff --git a/lib/admin-dashboard.ts b/lib/admin-dashboard.ts index 914e1aa..ee97152 100644 --- a/lib/admin-dashboard.ts +++ b/lib/admin-dashboard.ts @@ -34,6 +34,7 @@ type DashboardBusiness = Pick< | 'forwardingNumber' | 'notifyPhone' | 'twilioAccountMode' + | 'messagingSetupMode' | 'twilioNumberSetupMode' | 'twilioSubaccountSid' | 'twilioMessagingServiceSid' diff --git a/lib/admin-operator-visibility.ts b/lib/admin-operator-visibility.ts index 4c84faa..f990550 100644 --- a/lib/admin-operator-visibility.ts +++ b/lib/admin-operator-visibility.ts @@ -257,6 +257,8 @@ export function getOperatorEventCategoryBadgeVariant(category: OperatorEventCate const stepKeyByChangedField: Partial> = { twilioAccountMode: 'account_mode', phoneSetupPath: 'number_path', + forwardedCallAnswerMode: 'number_path', + messagingSetupMode: 'number_path', twilioNumberSetupMode: 'number_path', twilioSubaccountSid: 'account_ready', twilioMessagingServiceSid: 'messaging_service_ready', diff --git a/lib/admin-provisioning-presenters.ts b/lib/admin-provisioning-presenters.ts index 8065760..c687965 100644 --- a/lib/admin-provisioning-presenters.ts +++ b/lib/admin-provisioning-presenters.ts @@ -16,6 +16,7 @@ type AdminBusinessSummary = Pick< | 'notifyPhone' | 'forwardingNumber' | 'twilioAccountMode' + | 'messagingSetupMode' | 'twilioNumberSetupMode' | 'twilioSubaccountSid' | 'twilioPhoneNumber' diff --git a/lib/admin-setup-remediation.ts b/lib/admin-setup-remediation.ts index 0a08b39..23c3505 100644 --- a/lib/admin-setup-remediation.ts +++ b/lib/admin-setup-remediation.ts @@ -14,6 +14,7 @@ type SetupBusiness = Pick< | 'forwardingNumber' | 'notifyPhone' | 'twilioAccountMode' + | 'messagingSetupMode' | 'twilioSubaccountSid' | 'twilioMessagingServiceSid' | 'twilioPrimaryPhoneNumber' diff --git a/lib/business-phone-setup.ts b/lib/business-phone-setup.ts index e030d4b..5a03952 100644 --- a/lib/business-phone-setup.ts +++ b/lib/business-phone-setup.ts @@ -1,6 +1,8 @@ import { BusinessPhoneSetupPath, + ForwardedCallAnswerMode, ForwardingVerificationStatus, + MessagingSetupMode, PortingStatus, TwilioNumberSetupMode, type Business, @@ -64,6 +66,36 @@ export const businessPhoneSetupPathOptions = [ }, ] as const; +export const forwardedCallAnswerModeOptions = [ + { + value: ForwardedCallAnswerMode.PRESS_1_REQUIRED, + label: 'Require Press 1 before connecting', + description: + 'Recommended. CallbackCloser only treats the forwarded call as answered after the owner or staff member presses 1, so voicemail pickups still trigger missed-call recovery.', + }, + { + value: ForwardedCallAnswerMode.DIRECT_CONNECT, + label: 'Connect immediately', + description: + 'Use the legacy direct bridge without human confirmation. This can allow voicemail or auto-attendants to count as answered.', + }, +] as const; + +export const messagingSetupModeOptions = [ + { + value: MessagingSetupMode.PER_BUSINESS_TWILIO, + label: 'Dedicated business Twilio setup', + description: + 'Use the business-specific Twilio Messaging Service and compliance path. This is the standard long-term setup for fully managed per-business messaging.', + }, + { + value: MessagingSetupMode.SHARED_PILOT_MESSAGING_SERVICE, + label: 'Pilot setup: shared approved CallbackCloser sender', + description: + 'Pilot setup: current number forwards to CallbackCloser; SMS sends from the approved CallbackCloser messaging number. This is founder-operated and does not imply the business has its own A2P approval yet.', + }, +] as const; + export function getBusinessPhoneSetupPath(path: BusinessPhoneSetupPath | null | undefined) { return path || BusinessPhoneSetupPath.NEW_TWILIO_NUMBER; } @@ -78,6 +110,34 @@ export function getBusinessPhoneSetupPathDescription(path: BusinessPhoneSetupPat return businessPhoneSetupPathOptions.find((option) => option.value === resolved)?.description || businessPhoneSetupPathOptions[2].description; } +export function getForwardedCallAnswerMode(mode: ForwardedCallAnswerMode | null | undefined) { + return mode || ForwardedCallAnswerMode.PRESS_1_REQUIRED; +} + +export function getForwardedCallAnswerModeLabel(mode: ForwardedCallAnswerMode | null | undefined) { + const resolved = getForwardedCallAnswerMode(mode); + return forwardedCallAnswerModeOptions.find((option) => option.value === resolved)?.label || forwardedCallAnswerModeOptions[0].label; +} + +export function getForwardedCallAnswerModeDescription(mode: ForwardedCallAnswerMode | null | undefined) { + const resolved = getForwardedCallAnswerMode(mode); + return forwardedCallAnswerModeOptions.find((option) => option.value === resolved)?.description || forwardedCallAnswerModeOptions[0].description; +} + +export function getMessagingSetupMode(mode: MessagingSetupMode | null | undefined) { + return mode || MessagingSetupMode.PER_BUSINESS_TWILIO; +} + +export function getMessagingSetupModeLabel(mode: MessagingSetupMode | null | undefined) { + const resolved = getMessagingSetupMode(mode); + return messagingSetupModeOptions.find((option) => option.value === resolved)?.label || messagingSetupModeOptions[0].label; +} + +export function getMessagingSetupModeDescription(mode: MessagingSetupMode | null | undefined) { + const resolved = getMessagingSetupMode(mode); + return messagingSetupModeOptions.find((option) => option.value === resolved)?.description || messagingSetupModeOptions[0].description; +} + export function deriveTwilioNumberSetupModeFromPhoneSetupPath(path: BusinessPhoneSetupPath | null | undefined) { const resolved = getBusinessPhoneSetupPath(path); return resolved === BusinessPhoneSetupPath.PORT_EXISTING_NUMBER diff --git a/lib/business.ts b/lib/business.ts index 78ad665..1baeca5 100644 --- a/lib/business.ts +++ b/lib/business.ts @@ -1,8 +1,10 @@ import { BusinessPhoneSetupPath, BusinessProvisioningStatus, + ForwardedCallAnswerMode, ForwardingVerificationStatus, ManagedTwilioStatus, + MessagingSetupMode, TwilioAccountMode, TwilioNumberSetupMode, type Prisma, @@ -22,6 +24,8 @@ export async function upsertBusinessForOwner(ownerClerkId: string, input: { ownerEmail?: string | null; twilioAccountMode?: TwilioAccountMode | null; phoneSetupPath?: BusinessPhoneSetupPath | null; + forwardedCallAnswerMode?: ForwardedCallAnswerMode | null; + messagingSetupMode?: MessagingSetupMode | null; twilioNumberSetupMode?: TwilioNumberSetupMode | null; missedCallSeconds: number; serviceLabel1: string; @@ -40,6 +44,8 @@ export async function upsertBusinessForOwner(ownerClerkId: string, input: { provisioningStatus: BusinessProvisioningStatus.DRAFT, twilioAccountMode: input.twilioAccountMode || TwilioAccountMode.BUSINESS_SUBACCOUNT, phoneSetupPath, + forwardedCallAnswerMode: input.forwardedCallAnswerMode || ForwardedCallAnswerMode.PRESS_1_REQUIRED, + messagingSetupMode: input.messagingSetupMode || MessagingSetupMode.PER_BUSINESS_TWILIO, twilioNumberSetupMode: input.twilioNumberSetupMode || deriveTwilioNumberSetupModeFromPhoneSetupPath(phoneSetupPath), forwardingVerificationStatus: phoneSetupPath === BusinessPhoneSetupPath.CURRENT_NUMBER_FORWARDING ? ForwardingVerificationStatus.PENDING : ForwardingVerificationStatus.NOT_STARTED, missedCallSeconds: input.missedCallSeconds, @@ -63,6 +69,8 @@ export async function upsertBusinessForOwner(ownerClerkId: string, input: { notifyPhone: data.notifyPhone, twilioAccountMode: data.twilioAccountMode, phoneSetupPath: data.phoneSetupPath, + forwardedCallAnswerMode: data.forwardedCallAnswerMode, + messagingSetupMode: data.messagingSetupMode, twilioNumberSetupMode: data.twilioNumberSetupMode, forwardingVerificationStatus: data.phoneSetupPath === BusinessPhoneSetupPath.CURRENT_NUMBER_FORWARDING diff --git a/lib/managed-twilio-status.ts b/lib/managed-twilio-status.ts index b88983d..0bd7262 100644 --- a/lib/managed-twilio-status.ts +++ b/lib/managed-twilio-status.ts @@ -1,5 +1,6 @@ import { ManagedTwilioStatus, + MessagingSetupMode, MessagingComplianceType, TollFreeVerificationStatus, TwilioAccountMode, @@ -17,6 +18,7 @@ type ManagedTwilioBlockerKey = | 'campaign_review' | 'toll_free_details' | 'toll_free_review' + | 'shared_pilot_setup' | 'compliance_rejected' | 'compliance_paused'; @@ -24,6 +26,8 @@ export type ManagedTwilioSummary = { label: string; description: string; accountMode: TwilioAccountMode; + messagingSetupMode: MessagingSetupMode; + usesSharedPilotMessaging: boolean; accountReady: boolean; subaccountReady: boolean; numberAssigned: boolean; @@ -213,6 +217,7 @@ export function getManagedTwilioStatusSummary( business: Pick< Business, | 'managedTwilioStatus' + | 'messagingSetupMode' | 'twilioAccountMode' | 'twilioSubaccountSid' | 'twilioPrimaryPhoneNumber' @@ -232,8 +237,10 @@ export function getManagedTwilioStatusSummary( | 'tollFreeVerificationNote' > ): ManagedTwilioSummary { + const messagingSetupMode = business.messagingSetupMode || MessagingSetupMode.PER_BUSINESS_TWILIO; + const usesSharedPilotMessaging = messagingSetupMode === MessagingSetupMode.SHARED_PILOT_MESSAGING_SERVICE; const accountMode = business.twilioAccountMode || TwilioAccountMode.BUSINESS_SUBACCOUNT; - const requiresSubaccount = accountMode === TwilioAccountMode.BUSINESS_SUBACCOUNT; + const requiresSubaccount = accountMode === TwilioAccountMode.BUSINESS_SUBACCOUNT && !usesSharedPilotMessaging; const subaccountReady = Boolean(business.twilioSubaccountSid); const accountReady = requiresSubaccount ? subaccountReady : true; const numberAssigned = hasManagedTwilioNumber(business); @@ -265,7 +272,9 @@ export function getManagedTwilioStatusSummary( blockers.push({ key: 'messaging_service', label: 'Create Messaging Service', - detail: 'The approved sending number still needs to be attached to a Twilio Messaging Service.', + detail: usesSharedPilotMessaging + ? 'Attach the approved CallbackCloser Messaging Service before pilot SMS goes live.' + : 'The approved sending number still needs to be attached to a Twilio Messaging Service.', }); } @@ -285,7 +294,25 @@ export function getManagedTwilioStatusSummary( let attentionRequired = false; let approvedAt: Date | null | undefined = null; - if (complianceTypeUnknown) { + if (usesSharedPilotMessaging) { + label = 'Founder-operated pilot sender'; + description = messagingServiceReady + ? 'Pilot setup is intentionally using the approved CallbackCloser messaging number. This does not indicate the business has its own A2P approval yet.' + : 'Pilot setup is selected, but the approved CallbackCloser Messaging Service still needs to be attached before live SMS starts.'; + complianceReady = messagingServiceReady; + complianceStarted = messagingServiceReady; + compliancePendingReview = false; + attentionRequired = false; + + if (!messagingServiceReady) { + blockers.push({ + key: 'shared_pilot_setup', + label: 'Attach approved pilot sender', + detail: + 'Pilot setup is founder-operated. Save the approved CallbackCloser Messaging Service SID so SMS can send from the shared approved messaging number.', + }); + } + } else if (complianceTypeUnknown) { blockers.push({ key: 'compliance_type', label: 'Choose number type', @@ -415,13 +442,15 @@ export function getManagedTwilioStatusSummary( label, description, accountMode, + messagingSetupMode, + usesSharedPilotMessaging, accountReady, subaccountReady, numberAssigned, messagingServiceReady, webhooksSynced, complianceType, - complianceTypeLabel: getMessagingComplianceTypeLabel(complianceType), + complianceTypeLabel: usesSharedPilotMessaging ? 'Shared approved CallbackCloser sender' : getMessagingComplianceTypeLabel(complianceType), complianceReady, complianceStarted, compliancePendingReview, diff --git a/lib/missed-call-flow.ts b/lib/missed-call-flow.ts index cf66a3f..9636533 100644 --- a/lib/missed-call-flow.ts +++ b/lib/missed-call-flow.ts @@ -21,6 +21,7 @@ type LeadFlowBusiness = Pick< | 'twilioPhoneNumber' | 'twilioPrimaryPhoneNumber' | 'managedTwilioStatus' + | 'messagingSetupMode' | 'messagingComplianceType' | 'a2pFailureReason' | 'tollFreeVerificationStatus' @@ -69,6 +70,7 @@ async function deliverLeadMessage(params: { isSimulator?: boolean; twilioSubaccountSid?: string | null; twilioMessagingServiceSid?: string | null; + messagingSetupMode?: Business['messagingSetupMode']; managedTwilioStatus?: Business['managedTwilioStatus']; a2pFailureReason?: string | null; messagingComplianceType?: Business['messagingComplianceType']; @@ -196,6 +198,7 @@ export async function startMissedCallRecovery(params: StartRecoveryParams) { twilioSubaccountSid: params.business.twilioAccountMode === 'MAIN_ACCOUNT' ? null : params.business.twilioSubaccountSid, twilioMessagingServiceSid: params.business.twilioMessagingServiceSid, managedTwilioStatus: params.business.managedTwilioStatus, + messagingSetupMode: params.business.messagingSetupMode, a2pFailureReason: params.business.a2pFailureReason, messagingComplianceType: params.business.messagingComplianceType, tollFreeVerificationStatus: params.business.tollFreeVerificationStatus, diff --git a/lib/owner-notifications.ts b/lib/owner-notifications.ts index ba0f8ec..261161d 100644 --- a/lib/owner-notifications.ts +++ b/lib/owner-notifications.ts @@ -24,6 +24,7 @@ type NotificationLeadRecord = Lead & { | 'twilioMessagingServiceSid' | 'twilioPrimaryPhoneNumber' | 'twilioPhoneNumber' + | 'messagingSetupMode' | 'managedTwilioStatus' | 'messagingComplianceType' | 'a2pFailureReason' @@ -143,6 +144,7 @@ async function getLeadForOwnerNotifications(leadId: string) { twilioMessagingServiceSid: true, twilioPrimaryPhoneNumber: true, twilioPhoneNumber: true, + messagingSetupMode: true, managedTwilioStatus: true, messagingComplianceType: true, a2pFailureReason: true, @@ -246,6 +248,7 @@ export async function sendOwnerLeadSms(leadId: string) { context: 'owner_alert', twilioSubaccountSid: lead.business.twilioAccountMode === 'MAIN_ACCOUNT' ? null : lead.business.twilioSubaccountSid, messagingServiceSid: lead.business.twilioMessagingServiceSid, + messagingSetupMode: lead.business.messagingSetupMode, managedTwilioStatus: lead.business.managedTwilioStatus, a2pFailureReason: lead.business.a2pFailureReason, messagingComplianceType: lead.business.messagingComplianceType, diff --git a/lib/portfolio-demo.ts b/lib/portfolio-demo.ts index 269bb56..68bb7e2 100644 --- a/lib/portfolio-demo.ts +++ b/lib/portfolio-demo.ts @@ -1,11 +1,13 @@ import { BusinessPhoneSetupPath, BusinessProvisioningStatus, + ForwardedCallAnswerMode, ForwardingVerificationStatus, LeadStatus, LeadReadiness, ManagedTwilioStatus, MessagingComplianceType, + MessagingSetupMode, MessageDirection, OwnerNotificationChannel, OwnerNotificationStatus, @@ -58,6 +60,8 @@ const demoBusiness: Business = { timezone: 'America/Chicago', twilioAccountMode: 'BUSINESS_SUBACCOUNT', phoneSetupPath: BusinessPhoneSetupPath.NEW_TWILIO_NUMBER, + forwardedCallAnswerMode: ForwardedCallAnswerMode.PRESS_1_REQUIRED, + messagingSetupMode: MessagingSetupMode.PER_BUSINESS_TWILIO, twilioNumberSetupMode: 'NEW_NUMBER', forwardingVerificationStatus: ForwardingVerificationStatus.NOT_STARTED, forwardingVerifiedAt: null, @@ -108,6 +112,9 @@ function makeCall(input: Partial & Pick = {}) { provisioningLastRunAt: new Date('2026-04-15T00:00:00.000Z'), provisioningError: null, twilioAccountMode: TwilioAccountMode.BUSINESS_SUBACCOUNT, + messagingSetupMode: MessagingSetupMode.PER_BUSINESS_TWILIO, twilioNumberSetupMode: TwilioNumberSetupMode.NEW_NUMBER, twilioSubaccountSid: 'AC_TEST_SUBACCOUNT', twilioMessagingServiceSid: 'MG_TEST_SERVICE', diff --git a/tests/admin-provisioning.test.ts b/tests/admin-provisioning.test.ts index 08cd8b9..bb649b8 100644 --- a/tests/admin-provisioning.test.ts +++ b/tests/admin-provisioning.test.ts @@ -5,6 +5,7 @@ import { BusinessProvisioningStatus, ManagedTwilioStatus, MessagingComplianceType, + MessagingSetupMode, SubscriptionStatus, TollFreeVerificationStatus, TwilioAccountMode, @@ -33,6 +34,7 @@ function createBusiness(overrides: Record = {}) { missedCallSeconds: 20, subscriptionStatus: SubscriptionStatus.ACTIVE, twilioAccountMode: TwilioAccountMode.BUSINESS_SUBACCOUNT, + messagingSetupMode: MessagingSetupMode.PER_BUSINESS_TWILIO, twilioNumberSetupMode: TwilioNumberSetupMode.NEW_NUMBER, managedTwilioStatus: ManagedTwilioStatus.AWAITING_BUSINESS_VERIFICATION, managedTwilioStatusUpdatedAt: new Date('2026-04-15T00:00:00.000Z'), diff --git a/tests/admin-setup-remediation.test.ts b/tests/admin-setup-remediation.test.ts index 1c541d2..589677f 100644 --- a/tests/admin-setup-remediation.test.ts +++ b/tests/admin-setup-remediation.test.ts @@ -5,6 +5,7 @@ import { BusinessProvisioningStatus, ManagedTwilioStatus, MessagingComplianceType, + MessagingSetupMode, OperatorEventStatus, SubscriptionStatus, TollFreeVerificationStatus, @@ -26,6 +27,7 @@ function buildSetupFlow(overrides: Partial assert.equal(customerStatus.key, 'live'); }); +test('shared pilot messaging can be launch-ready without per-business A2P approval', () => { + const business = createManagedBusiness({ + messagingSetupMode: MessagingSetupMode.SHARED_PILOT_MESSAGING_SERVICE, + messagingComplianceType: MessagingComplianceType.UNKNOWN, + managedTwilioStatus: ManagedTwilioStatus.DRAFT, + twilioAccountMode: TwilioAccountMode.MAIN_ACCOUNT, + twilioSubaccountSid: null, + twilioPrimaryPhoneNumber: '+15550001111', + twilioPhoneNumber: '+15550001111', + twilioPrimaryNumberSid: 'PN123', + twilioPhoneNumberSid: 'PN123', + twilioMessagingServiceSid: 'MG123', + twilioWebhookSyncedAt: new Date('2026-04-16T12:00:00.000Z'), + phoneSetupPath: BusinessPhoneSetupPath.CURRENT_NUMBER_FORWARDING, + publicBusinessPhone: '+15559990000', + forwardingVerificationStatus: ForwardingVerificationStatus.VERIFIED, + forwardingVerifiedAt: new Date('2026-04-16T12:00:00.000Z'), + }); + + const summary = getManagedTwilioStatusSummary(business); + const customerStatus = getCustomerSystemStatus(business, 1); + + assert.equal(summary.usesSharedPilotMessaging, true); + assert.equal(summary.complianceReady, true); + assert.equal(summary.messagingReady, true); + assert.match(summary.description, /approved CallbackCloser messaging number/i); + assert.equal(customerStatus.key, 'live'); +}); + test('resolveManagedTwilioStatus maps brand and campaign milestones explicitly', () => { assert.equal( resolveManagedTwilioStatus( diff --git a/tests/tenant-fixtures.ts b/tests/tenant-fixtures.ts index 3888e20..a8eeea8 100644 --- a/tests/tenant-fixtures.ts +++ b/tests/tenant-fixtures.ts @@ -1,9 +1,11 @@ import { randomUUID } from 'node:crypto'; import { + ForwardedCallAnswerMode, LeadReadiness, LeadStatus, ManagedTwilioStatus, + MessagingSetupMode, MessagingComplianceType, MessageDirection, MessageParticipant, @@ -57,6 +59,8 @@ export async function seedTenantFixtures() { stripeSubscriptionId: `sub_${seed.replace(/-/g, '').slice(0, 10)}a`, stripePriceId: 'price_starter_fixture', subscriptionStatus: SubscriptionStatus.ACTIVE, + forwardedCallAnswerMode: ForwardedCallAnswerMode.PRESS_1_REQUIRED, + messagingSetupMode: MessagingSetupMode.PER_BUSINESS_TWILIO, messagingComplianceType: MessagingComplianceType.LOCAL_A2P, managedTwilioStatus: ManagedTwilioStatus.COMPLIANT_LIVE, managedTwilioStatusUpdatedAt: now, @@ -86,6 +90,8 @@ export async function seedTenantFixtures() { stripeSubscriptionId: `sub_${seed.replace(/-/g, '').slice(0, 10)}b`, stripePriceId: 'price_growth_fixture', subscriptionStatus: SubscriptionStatus.PAST_DUE, + forwardedCallAnswerMode: ForwardedCallAnswerMode.PRESS_1_REQUIRED, + messagingSetupMode: MessagingSetupMode.PER_BUSINESS_TWILIO, messagingComplianceType: MessagingComplianceType.LOCAL_A2P, managedTwilioStatus: ManagedTwilioStatus.AWAITING_BUSINESS_VERIFICATION, managedTwilioStatusUpdatedAt: now, diff --git a/tests/twilio-setup-flow.test.ts b/tests/twilio-setup-flow.test.ts index f0f5e37..b0dc83c 100644 --- a/tests/twilio-setup-flow.test.ts +++ b/tests/twilio-setup-flow.test.ts @@ -4,8 +4,10 @@ import test from 'node:test'; import { BusinessPhoneSetupPath, BusinessProvisioningStatus, + ForwardedCallAnswerMode, ForwardingVerificationStatus, ManagedTwilioStatus, + MessagingSetupMode, MessagingComplianceType, PortingStatus, TollFreeVerificationStatus, @@ -24,6 +26,8 @@ function createBusiness(overrides: Record = {}) { provisioningStatus: BusinessProvisioningStatus.ONBOARDING, twilioAccountMode: TwilioAccountMode.BUSINESS_SUBACCOUNT, phoneSetupPath: BusinessPhoneSetupPath.NEW_TWILIO_NUMBER, + forwardedCallAnswerMode: ForwardedCallAnswerMode.PRESS_1_REQUIRED, + messagingSetupMode: MessagingSetupMode.PER_BUSINESS_TWILIO, twilioNumberSetupMode: TwilioNumberSetupMode.NEW_NUMBER, forwardingVerificationStatus: ForwardingVerificationStatus.NOT_STARTED, forwardingVerifiedAt: null, @@ -195,3 +199,31 @@ test('unknown number type keeps the live gate blocked', () => { assert.equal(flow.safeToMarkLive, false); assert.match(flow.liveGateDetail, /choose the number type/i); }); + +test('shared pilot messaging path can clear the launch gate intentionally', () => { + const flow = buildTwilioSetupFlow({ + business: createBusiness({ + phoneSetupPath: BusinessPhoneSetupPath.CURRENT_NUMBER_FORWARDING, + forwardedCallAnswerMode: ForwardedCallAnswerMode.PRESS_1_REQUIRED, + messagingSetupMode: MessagingSetupMode.SHARED_PILOT_MESSAGING_SERVICE, + forwardingVerificationStatus: ForwardingVerificationStatus.VERIFIED, + forwardingVerifiedAt: new Date('2026-04-20T00:00:00.000Z'), + messagingComplianceType: MessagingComplianceType.UNKNOWN, + managedTwilioStatus: ManagedTwilioStatus.DRAFT, + a2pCustomerProfileSid: null, + a2pBrandSid: null, + a2pCampaignSid: null, + a2pApprovedAt: null, + }), + notificationSettings: createNotificationSettings(), + ownerConnected: true, + successfulLeadCount: 1, + testSmsState: 'delivered', + webhookSnapshot: createWebhookSnapshot(), + }); + + assert.equal(flow.safeToMarkLive, true); + const complianceStep = flow.steps.find((step) => step.key === 'a2p_status_recorded'); + assert.match(complianceStep?.detail || '', /founder-operated/i); + assert.match(complianceStep?.detail || '', /approved CallbackCloser messaging number/i); +}); diff --git a/tests/twilio-voice-status-flow.test.ts b/tests/twilio-voice-status-flow.test.ts new file mode 100644 index 0000000..9986181 --- /dev/null +++ b/tests/twilio-voice-status-flow.test.ts @@ -0,0 +1,279 @@ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import test from 'node:test'; + +import { BusinessPhoneSetupPath, ForwardedCallAnswerMode, ForwardingVerificationStatus, MessagingSetupMode } from '@prisma/client'; + +import { db } from '../lib/db.ts'; +import { cleanupTenantFixtures, seedTenantFixtures } from './tenant-fixtures.ts'; + +async function loadTwilioRoutes() { + const require = createRequire(import.meta.url); + const serverOnlyPath = require.resolve('server-only'); + require.cache[serverOnlyPath] = { + id: serverOnlyPath, + filename: serverOnlyPath, + loaded: true, + exports: {}, + } as NodeJS.Module; + + const [{ POST: voicePost }, { POST: statusPost }] = await Promise.all([ + import('../app/api/twilio/voice/route.ts'), + import('../app/api/twilio/status/route.ts'), + ]); + + return { voicePost, statusPost }; +} + +async function withEnv(overrides: Record, fn: () => Promise | T) { + const previous = new Map(); + for (const [key, value] of Object.entries(overrides)) { + previous.set(key, process.env[key]); + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + + try { + return await fn(); + } finally { + for (const [key, value] of previous.entries()) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} + +test('voicemail or no press 1 is treated as missed and starts missed-call SMS', async () => { + const fixtures = await seedTenantFixtures(); + + try { + await db.business.update({ + where: { id: fixtures.businessA.id }, + data: { + phoneSetupPath: BusinessPhoneSetupPath.CURRENT_NUMBER_FORWARDING, + publicBusinessPhone: '+15559990000', + forwardedCallAnswerMode: ForwardedCallAnswerMode.PRESS_1_REQUIRED, + messagingSetupMode: MessagingSetupMode.PER_BUSINESS_TWILIO, + forwardingVerificationStatus: ForwardingVerificationStatus.PENDING, + }, + }); + + await withEnv( + { + NODE_ENV: 'test', + NEXT_PUBLIC_APP_URL: 'https://app.callbackcloser.com', + STRIPE_PRICE_STARTER: 'price_starter_fixture', + TWILIO_VALIDATE_SIGNATURE: 'true', + TWILIO_WEBHOOK_AUTH_TOKEN: 'dev-shared-token', + }, + async () => { + const { voicePost, statusPost } = await loadTwilioRoutes(); + const inboundCallSid = 'CA_voice_missed_12345678901234567890'; + const screeningCallSid = 'CA_voice_screen_1234567890123456'; + + const inbound = new FormData(); + inbound.set('AccountSid', fixtures.businessA.twilioSubaccountSid!); + inbound.set('CallSid', inboundCallSid); + inbound.set('From', '+15554443333'); + inbound.set('To', fixtures.businessA.twilioPrimaryPhoneNumber!); + + const voiceResponse = await voicePost( + new Request('https://app.callbackcloser.com/api/twilio/voice?webhook_token=dev-shared-token', { + method: 'POST', + body: inbound, + }) + ); + + assert.equal(voiceResponse.status, 200); + assert.match(await voiceResponse.text(), /screen-forwarded-call/i); + + const noAcceptance = new FormData(); + noAcceptance.set('AccountSid', fixtures.businessA.twilioSubaccountSid!); + noAcceptance.set('CallSid', screeningCallSid); + noAcceptance.set('ParentCallSid', inboundCallSid); + noAcceptance.set('From', '+15554443333'); + noAcceptance.set('To', fixtures.businessA.forwardingNumber); + + const screeningResponse = await voicePost( + new Request( + `https://app.callbackcloser.com/api/twilio/voice?stage=screen-forwarded-call-result&businessId=${fixtures.businessA.id}&parentCallSid=${inboundCallSid}&webhook_token=dev-shared-token`, + { + method: 'POST', + body: noAcceptance, + } + ) + ); + + assert.equal(screeningResponse.status, 200); + + const statusPayload = new FormData(); + statusPayload.set('AccountSid', fixtures.businessA.twilioSubaccountSid!); + statusPayload.set('CallSid', inboundCallSid); + statusPayload.set('DialCallSid', screeningCallSid); + statusPayload.set('DialCallStatus', 'completed'); + statusPayload.set('From', '+15554443333'); + statusPayload.set('To', fixtures.businessA.twilioPrimaryPhoneNumber!); + + const statusResponse = await statusPost( + new Request('https://app.callbackcloser.com/api/twilio/status?webhook_token=dev-shared-token', { + method: 'POST', + body: statusPayload, + }) + ); + + assert.equal(statusResponse.status, 200); + } + ); + + const call = await db.call.findUniqueOrThrow({ + where: { twilioCallSid: 'CA_voice_missed_12345678901234567890' }, + select: { + answered: true, + humanAccepted: true, + missed: true, + status: true, + }, + }); + assert.equal(call.answered, false); + assert.equal(call.humanAccepted, false); + assert.equal(call.missed, true); + assert.equal(call.status, 'MISSED'); + + const lead = await db.lead.findFirst({ + where: { call: { is: { twilioCallSid: 'CA_voice_missed_12345678901234567890' } } }, + select: { smsStartedAt: true }, + }); + assert.ok(lead?.smsStartedAt); + + const eventTypes = await db.businessOperatorEvent.findMany({ + where: { businessId: fixtures.businessA.id }, + orderBy: { createdAt: 'asc' }, + select: { type: true }, + }); + assert.equal(eventTypes.some((event) => event.type === 'voice.no_human_acceptance_detected'), true); + assert.equal(eventTypes.some((event) => event.type === 'voice.voicemail_or_timeout_treated_as_missed'), true); + assert.equal(eventTypes.some((event) => event.type === 'voice.missed_call_sms_started'), true); + } finally { + await cleanupTenantFixtures({ + businessAId: fixtures.businessA.id, + businessBId: fixtures.businessB.id, + }); + } +}); + +test('press 1 marks the call answered and prevents missed-call SMS recovery', async () => { + const fixtures = await seedTenantFixtures(); + + try { + await db.business.update({ + where: { id: fixtures.businessA.id }, + data: { + phoneSetupPath: BusinessPhoneSetupPath.CURRENT_NUMBER_FORWARDING, + publicBusinessPhone: '+15558887777', + forwardedCallAnswerMode: ForwardedCallAnswerMode.PRESS_1_REQUIRED, + messagingSetupMode: MessagingSetupMode.PER_BUSINESS_TWILIO, + forwardingVerificationStatus: ForwardingVerificationStatus.PENDING, + }, + }); + + await withEnv( + { + NODE_ENV: 'test', + NEXT_PUBLIC_APP_URL: 'https://app.callbackcloser.com', + STRIPE_PRICE_STARTER: 'price_starter_fixture', + TWILIO_VALIDATE_SIGNATURE: 'true', + TWILIO_WEBHOOK_AUTH_TOKEN: 'dev-shared-token', + }, + async () => { + const { voicePost, statusPost } = await loadTwilioRoutes(); + const inboundCallSid = 'CA_voice_answered_1234567890123456'; + const screeningCallSid = 'CA_voice_screen_accepted_12345678'; + + const inbound = new FormData(); + inbound.set('AccountSid', fixtures.businessA.twilioSubaccountSid!); + inbound.set('CallSid', inboundCallSid); + inbound.set('From', '+15556667777'); + inbound.set('To', fixtures.businessA.twilioPrimaryPhoneNumber!); + await voicePost( + new Request('https://app.callbackcloser.com/api/twilio/voice?webhook_token=dev-shared-token', { + method: 'POST', + body: inbound, + }) + ); + + const accepted = new FormData(); + accepted.set('AccountSid', fixtures.businessA.twilioSubaccountSid!); + accepted.set('CallSid', screeningCallSid); + accepted.set('ParentCallSid', inboundCallSid); + accepted.set('From', '+15556667777'); + accepted.set('To', fixtures.businessA.forwardingNumber); + accepted.set('Digits', '1'); + + const screeningResponse = await voicePost( + new Request( + `https://app.callbackcloser.com/api/twilio/voice?stage=screen-forwarded-call-result&businessId=${fixtures.businessA.id}&parentCallSid=${inboundCallSid}&webhook_token=dev-shared-token`, + { + method: 'POST', + body: accepted, + } + ) + ); + + assert.equal(screeningResponse.status, 200); + assert.match(await screeningResponse.text(), /Connecting you now/i); + + const statusPayload = new FormData(); + statusPayload.set('AccountSid', fixtures.businessA.twilioSubaccountSid!); + statusPayload.set('CallSid', inboundCallSid); + statusPayload.set('DialCallSid', screeningCallSid); + statusPayload.set('DialCallStatus', 'completed'); + statusPayload.set('From', '+15556667777'); + statusPayload.set('To', fixtures.businessA.twilioPrimaryPhoneNumber!); + + const statusResponse = await statusPost( + new Request('https://app.callbackcloser.com/api/twilio/status?webhook_token=dev-shared-token', { + method: 'POST', + body: statusPayload, + }) + ); + + assert.equal(statusResponse.status, 200); + } + ); + + const call = await db.call.findUniqueOrThrow({ + where: { twilioCallSid: 'CA_voice_answered_1234567890123456' }, + select: { + answered: true, + humanAccepted: true, + missed: true, + status: true, + }, + }); + assert.equal(call.answered, true); + assert.equal(call.humanAccepted, true); + assert.equal(call.missed, false); + assert.equal(call.status, 'ANSWERED'); + + const lead = await db.lead.findFirst({ + where: { call: { is: { twilioCallSid: 'CA_voice_answered_1234567890123456' } } }, + }); + assert.equal(lead, null); + + const eventTypes = await db.businessOperatorEvent.findMany({ + where: { businessId: fixtures.businessA.id }, + orderBy: { createdAt: 'asc' }, + select: { type: true }, + }); + assert.equal(eventTypes.some((event) => event.type === 'voice.human_accepted_call'), true); + assert.equal(eventTypes.some((event) => event.type === 'voice.missed_call_sms_started'), false); + } finally { + await cleanupTenantFixtures({ + businessAId: fixtures.businessA.id, + businessBId: fixtures.businessB.id, + }); + } +});