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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 82 additions & 2 deletions app/admin/[businessId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,17 @@ import {
type TwilioSetupTone,
businessPhonePathOptions,
buildTwilioSetupFlow,
forwardedCallAnswerOptions,
messagingSetupOptions,
twilioAccountModeOptions,
} from '@/lib/twilio-setup';

type AdminTwilioDefaults = {
businessId: string;
twilioAccountMode: string;
phoneSetupPath: string;
forwardedCallAnswerMode: string;
messagingSetupMode: string;
twilioNumberSetupMode: string;
twilioSubaccountSid: string;
twilioPhoneNumber: string;
Expand Down Expand Up @@ -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 || '',
Expand Down Expand Up @@ -517,7 +523,11 @@ export default async function AdminBusinessDetailPage({
if (step.key === 'number_path') {
return (
<form action={saveAdminTwilioSetupAction} className="space-y-4">
<HiddenAdminTwilioFields defaults={defaults} exclude={['phoneSetupPath']} returnStep={step.key} />
<HiddenAdminTwilioFields
defaults={defaults}
exclude={['phoneSetupPath', 'forwardedCallAnswerMode', 'messagingSetupMode']}
returnStep={step.key}
/>
<div className="grid gap-3">
{businessPhonePathOptions.map((option) => (
<label key={option.value} className="rounded-xl border bg-background/80 p-4 text-sm">
Expand All @@ -531,14 +541,61 @@ export default async function AdminBusinessDetailPage({
</label>
))}
</div>
<div className="grid gap-3">
<p className="text-sm font-medium">Forwarded call answer confirmation</p>
{forwardedCallAnswerOptions.map((option) => (
<label key={option.value} className="rounded-xl border bg-background/80 p-4 text-sm">
<div className="flex items-start gap-3">
<input
defaultChecked={business.forwardedCallAnswerMode === option.value}
name="forwardedCallAnswerMode"
type="radio"
value={option.value}
/>
<div className="space-y-1">
<p className="font-medium">{option.label}</p>
<p className="text-muted-foreground">{option.description}</p>
</div>
</div>
</label>
))}
</div>
<div className="grid gap-3">
<p className="text-sm font-medium">Messaging setup mode</p>
{messagingSetupOptions.map((option) => (
<label key={option.value} className="rounded-xl border bg-background/80 p-4 text-sm">
<div className="flex items-start gap-3">
<input
defaultChecked={business.messagingSetupMode === option.value}
name="messagingSetupMode"
type="radio"
value={option.value}
/>
<div className="space-y-1">
<p className="font-medium">{option.label}</p>
<p className="text-muted-foreground">{option.description}</p>
</div>
</div>
</label>
))}
</div>
<Button size="sm" type="submit" variant="outline">
Save business number path
Save call and messaging path
</Button>
</form>
);
}

if (step.key === 'account_ready') {
if (business.messagingSetupMode === 'SHARED_PILOT_MESSAGING_SERVICE') {
return (
<div className="rounded-xl border bg-background/80 p-4 text-sm text-muted-foreground">
Pilot setup is founder-operated. A dedicated subaccount is optional while SMS sends through the approved CallbackCloser Messaging
Service.
</div>
);
}

if (business.twilioAccountMode === 'MAIN_ACCOUNT') {
return <div className="rounded-xl border bg-background/80 p-4 text-sm text-muted-foreground">Main account mode is active, so this step does not require a business subaccount.</div>;
}
Expand All @@ -556,6 +613,15 @@ export default async function AdminBusinessDetailPage({
}

if (step.key === 'messaging_service_ready') {
if (business.messagingSetupMode === 'SHARED_PILOT_MESSAGING_SERVICE') {
return (
<div className="space-y-3 rounded-xl border bg-background/80 p-4 text-sm text-muted-foreground">
<p>Pilot setup: current number forwards to CallbackCloser; SMS sends from the approved CallbackCloser messaging number.</p>
<p>Do not create a new per-business Messaging Service here. Save the approved shared Messaging Service SID in the admin override panel below.</p>
</div>
);
}

return (
<form action={createBusinessMessagingServiceAction} className="space-y-3 rounded-xl border bg-background/80 p-4">
<input name="businessId" type="hidden" value={business.id} />
Expand Down Expand Up @@ -1328,6 +1394,20 @@ export default async function AdminBusinessDetailPage({
<p className="font-medium">Business number path</p>
<p className="mt-2 text-muted-foreground">{getBusinessPhoneSetupPathLabel(business.phoneSetupPath)}</p>
</div>
<div className="rounded-xl border bg-background/80 p-4 text-sm">
<p className="font-medium">Routing number</p>
<p className="mt-2 text-muted-foreground">
{managedTextingNumber ? formatPhoneForDisplay(managedTextingNumber) : 'Routing number not assigned yet'}
</p>
</div>
<div className="rounded-xl border bg-background/80 p-4 text-sm">
<p className="font-medium">Answer confirmation</p>
<p className="mt-2 text-muted-foreground">{setupFlow.forwardedCallAnswerModeLabel}</p>
</div>
<div className="rounded-xl border bg-background/80 p-4 text-sm">
<p className="font-medium">Messaging setup mode</p>
<p className="mt-2 text-muted-foreground">{setupFlow.messagingSetupModeLabel}</p>
</div>
</CardContent>
</Card>
</div>
Expand Down
33 changes: 33 additions & 0 deletions app/admin/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
import {
BusinessPhoneSetupPath,
BusinessProvisioningStatus,
ForwardedCallAnswerMode,
ForwardingVerificationStatus,
ManagedTwilioStatus,
MessagingSetupMode,
MessagingComplianceType,
PortingStatus,
Prisma,
Expand Down Expand Up @@ -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',
Expand All @@ -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(),
Expand Down Expand Up @@ -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: {
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 ||
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -1014,6 +1044,8 @@ export async function saveAdminTwilioSetupAction(formData: FormData) {
data: {
twilioAccountMode,
phoneSetupPath,
forwardedCallAnswerMode,
messagingSetupMode,
twilioNumberSetupMode,
twilioSubaccountSid,
forwardingVerificationStatus:
Expand Down Expand Up @@ -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,
Expand Down
74 changes: 71 additions & 3 deletions app/api/twilio/status/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { NextResponse } from 'next/server';
import { ForwardedCallAnswerMode } from '@prisma/client';

import { findBusinessByTwilioNumber } from '@/lib/business';
import { db } from '@/lib/db';
Expand Down Expand Up @@ -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 },
Expand All @@ -195,23 +208,27 @@ 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,
},
update: {
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,
},
Expand All @@ -224,6 +241,7 @@ export async function POST(request: Request) {
eventType: 'dial_status_callback',
businessId: business.id,
answered,
humanAccepted,
missed,
decision: 'upsert_call',
});
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading