diff --git a/app/admin/[businessId]/page.tsx b/app/admin/[businessId]/page.tsx index 6b0586d..2a59116 100644 --- a/app/admin/[businessId]/page.tsx +++ b/app/admin/[businessId]/page.tsx @@ -15,6 +15,7 @@ import { import { buildAdminOnboardingConfidence, canDeleteTestBusiness, + getAdminTestSmsConfidenceState, isBusinessArchived, } from '@/lib/admin-dashboard'; import { CopyValueButton } from '@/components/copy-value-button'; @@ -37,7 +38,6 @@ import { import { db } from '@/lib/db'; import { formatDateTime, - formatMessageStatus, formatRelativeTime, getLeadCallbackState, getLeadStatusBadgeVariant, @@ -155,6 +155,22 @@ function getConfidenceMilestoneBadgeVariant(variant: 'success' | 'warning' | 'pe return 'secondary' as const; } +function getTestSmsConfidenceSummary(state: ReturnType) { + if (state === 'delivered') { + return 'Delivered'; + } + + if (state === 'pending_delivery') { + return 'Pending delivery'; + } + + if (state === 'failed') { + return 'Failed'; + } + + return 'Not run'; +} + function formatOperatorEventDetailValue(value: unknown): string { if (value === null || value === undefined) return '-'; if (typeof value === 'string') return value; @@ -291,7 +307,7 @@ export default async function AdminBusinessDetailPage({ await requireAdmin(); const activityFilter = getTimelineFilter(searchParams); - const [business, successfulLeadCount, leadCount, callCount, messageCount, recentLeads, recentCalls, recentMessages, recentOwnerNotifications, operatorEvents] = + const [business, successfulLeadCount, leadCount, callCount, messageCount, recentLeads, recentOwnerNotifications, operatorEvents] = await Promise.all([ db.business.findUnique({ where: { id: params.businessId }, @@ -325,33 +341,6 @@ export default async function AdminBusinessDetailPage({ lastInteractionAt: true, }, }), - db.call.findMany({ - where: { businessId: params.businessId }, - orderBy: { createdAt: 'desc' }, - take: 6, - select: { - id: true, - status: true, - missed: true, - answered: true, - dialCallStatus: true, - createdAt: true, - }, - }), - db.message.findMany({ - where: { businessId: params.businessId, direction: 'OUTBOUND' }, - orderBy: { createdAt: 'desc' }, - take: 8, - select: { - id: true, - leadId: true, - participant: true, - direction: true, - status: true, - body: true, - createdAt: true, - }, - }), db.ownerNotification.findMany({ where: { businessId: params.businessId }, orderBy: { createdAt: 'desc' }, @@ -412,6 +401,13 @@ export default async function AdminBusinessDetailPage({ createdAt: event.createdAt, })), }); + const testSmsConfidenceState = getAdminTestSmsConfidenceState( + operatorEvents.map((event) => ({ + type: event.type, + status: event.status, + createdAt: event.createdAt, + })) + ); const timelineFilterCounts = countTimelineFilters(operatorEvents); const visibleTimelineEvents = operatorEvents.filter((event) => { return matchesTimelineFilter(event, activityFilter); @@ -457,7 +453,7 @@ export default async function AdminBusinessDetailPage({ type: 'webhooks.mismatch_detected', } : null); - const latestOutboundSms = recentMessages[0] || null; + const latestTestSmsEvent = operatorEvents.find((event) => event.type.startsWith('admin.test_sms_')) || null; const latestOwnerAlert = recentOwnerNotifications[0] || null; const created = getQueryValue(searchParams, 'created') === '1'; @@ -534,7 +530,11 @@ export default async function AdminBusinessDetailPage({ {provisioned ?
Provisioning finished. Review the health cards below.
: null} {synced ?
Webhook sync complete for {synced.toLowerCase()}.
: null} {statusSaved ?
Business status updated to {statusSaved.replace(/_/g, ' ')}.
: null} - {testSms ?
Admin test SMS sent.
: null} + {testSms ? ( +
+ Admin test SMS requested. Watch recent activity for delivery confirmation before you go live. +
+ ) : null} {archived ?
Business archived safely. Automation is paused.
: null} {restored ?
Business restored and ready for review.
: null} {error ?
{error}
: null} @@ -648,12 +648,13 @@ export default async function AdminBusinessDetailPage({ )} -
+
{[ { label: 'Leads', value: leadCount }, { label: 'Calls', value: callCount }, { label: 'Messages', value: messageCount }, { label: 'Qualified alerts', value: successfulLeadCount }, + { label: 'Test SMS', value: getTestSmsConfidenceSummary(testSmsConfidenceState) }, ].map((item) => (

{item.label}

@@ -1156,13 +1157,15 @@ export default async function AdminBusinessDetailPage({

-

Latest outbound SMS

+

Latest test SMS

- {latestOutboundSms - ? `${latestOutboundSms.participant === 'OWNER' ? 'Owner SMS' : 'Lead SMS'}${latestOutboundSms.status ? ` ยท ${formatMessageStatus(latestOutboundSms.status)}` : ''}` - : 'No outbound SMS yet'} + {latestTestSmsEvent?.summary || 'No test SMS recorded'} +

+

+ {latestTestSmsEvent + ? getOperatorEventDetails(latestTestSmsEvent.detailsJson)[0]?.value || 'Open the timeline below for full test delivery detail.' + : 'Run the admin test SMS before you treat onboarding as ready for launch.'}

-

{latestOutboundSms?.body || 'No outbound SMS has been recorded yet.'}

Latest owner alert

diff --git a/app/admin/actions.ts b/app/admin/actions.ts index 0bbde9b..32f00b1 100644 --- a/app/admin/actions.ts +++ b/app/admin/actions.ts @@ -712,6 +712,7 @@ export async function sendBusinessTestSmsAction(formData: FormData) { toPhone: destinationPhone!, body: `CallbackCloser admin test: ${business.name} is using ${fromPhone} for live support verification.`, participant: 'OWNER', + context: 'admin_test', twilioSubaccountSid: business.twilioSubaccountSid, messagingServiceSid: business.twilioMessagingServiceSid, managedTwilioStatus: business.managedTwilioStatus, diff --git a/app/admin/page.tsx b/app/admin/page.tsx index b27c7bd..94c0048 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -82,7 +82,7 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor const view = (getQueryValue(searchParams, 'view') as AdminBoardFilter | null) || 'all'; const adminBusiness = await db.business.findUnique({ where: { ownerClerkId: admin.userId } }); - const [businesses, leadCounts, leadActivity, callActivity, messageActivity, notificationFailures, operatorSignals] = await Promise.all([ + const [businesses, leadCounts, leadActivity, callActivity, messageActivity, operatorSignals] = await Promise.all([ query ? searchBusinessesForAdmin(query) : db.business.findMany({ @@ -108,19 +108,6 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor by: ['businessId'], _max: { createdAt: true }, }), - db.ownerNotification.findMany({ - where: { - status: { in: ['FAILED', 'SKIPPED'] }, - }, - orderBy: { createdAt: 'desc' }, - select: { - businessId: true, - status: true, - error: true, - createdAt: true, - }, - take: 200, - }), db.businessOperatorEvent.findMany({ where: { status: { in: [OperatorEventStatus.FAILED, OperatorEventStatus.WARNING] }, @@ -129,6 +116,8 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor select: { businessId: true, status: true, + summary: true, + createdAt: true, }, take: 600, }), @@ -140,16 +129,16 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor ); const callActivityMap = new Map(callActivity.map((item) => [item.businessId, item._max.createdAt])); const messageActivityMap = new Map(messageActivity.map((item) => [item.businessId, item._max.createdAt])); - const notificationFailureMap = new Map(); + const latestOperatorSignalMap = new Map(); const operatorSignalMap = new Map(); - for (const failure of notificationFailures) { - if (!notificationFailureMap.has(failure.businessId)) { - notificationFailureMap.set(failure.businessId, failure); - } - } - for (const signal of operatorSignals) { + if (!latestOperatorSignalMap.has(signal.businessId)) { + latestOperatorSignalMap.set(signal.businessId, { + summary: signal.summary, + createdAt: signal.createdAt, + }); + } const entry = operatorSignalMap.get(signal.businessId) || { failed: 0, warning: 0 }; if (signal.status === OperatorEventStatus.FAILED) { entry.failed += 1; @@ -174,7 +163,7 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor callActivityMap.get(business.id), messageActivityMap.get(business.id) ); - const latestFailure = notificationFailureMap.get(business.id); + const latestFailure = latestOperatorSignalMap.get(business.id); const assignedNumber = getManagedTextingNumber(business); const archived = isBusinessArchived(business); const paused = !archived && business.provisioningStatus === 'PAUSED'; @@ -190,7 +179,7 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor complianceStarted: managedSummary.complianceStarted, }); const attentionSignal = latestFailure - ? compactCopy(`${latestFailure.error || `${latestFailure.status.toLowerCase()} owner notification`} on ${formatDateTime(latestFailure.createdAt)}.`) + ? compactCopy(`${latestFailure.summary} on ${formatDateTime(latestFailure.createdAt)}.`) : nextStep.tone === 'healthy' ? 'Healthy. No immediate operator action needed.' : compactCopy(`${nextStep.title}. ${nextStep.detail}`); diff --git a/app/api/twilio/message-status/route.ts b/app/api/twilio/message-status/route.ts new file mode 100644 index 0000000..9882a80 --- /dev/null +++ b/app/api/twilio/message-status/route.ts @@ -0,0 +1,193 @@ +import { NextResponse } from 'next/server'; + +import { db } from '@/lib/db'; +import { getCorrelationIdFromRequest, withCorrelationIdHeader } from '@/lib/observability'; +import { + buildOutboundMessageStatusEvent, + isTerminalOutboundMessageStatus, + normalizeOutboundMessageStatus, +} from '@/lib/outbound-message-events'; +import { formatPhoneDetail, recordBusinessOperatorEvent } from '@/lib/operator-events'; +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 { hasValidTwilioWebhookRequest } from '@/lib/twilio-webhook'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +function formField(formData: FormData, key: string) { + const value = formData.get(key); + return typeof value === 'string' ? value : ''; +} + +function okResponse() { + return new NextResponse(null, { status: 200 }); +} + +export async function POST(request: Request) { + let messageSid: string | null = null; + const correlationId = getCorrelationIdFromRequest(request); + const withCorrelation = (response: Response) => withCorrelationIdHeader(response, correlationId); + + try { + const formData = await request.formData(); + const payload = Object.fromEntries(formData.entries()) as Record; + const clientIp = getClientIpAddress(request); + const accountSid = formField(formData, 'AccountSid'); + + const authorized = await hasValidTwilioWebhookRequest(request, payload); + if (!authorized) { + const rateLimit = consumeRateLimit({ + key: `twilio:message-status:unauth:${clientIp}`, + limit: RATE_LIMIT_TWILIO_UNAUTH_MAX, + windowMs: RATE_LIMIT_WINDOW_MS, + }); + if (!rateLimit.allowed) { + logTwilioWarn('sms', 'message_status_unauthorized_rate_limited', { + correlationId, + eventType: 'message_status_callback', + decision: 'reject_429', + clientIp, + }); + return withCorrelation( + new NextResponse(JSON.stringify({ error: 'Too many unauthorized requests' }), { + status: 429, + headers: { 'Content-Type': 'application/json', ...buildRateLimitHeaders(rateLimit) }, + }) + ); + } + + logTwilioWarn('sms', 'message_status_unauthorized', { + correlationId, + eventType: 'message_status_callback', + decision: 'reject_401', + }); + return withCorrelation(NextResponse.json({ error: 'Unauthorized' }, { status: 401 })); + } + + const authRateLimit = consumeRateLimit({ + key: `twilio:message-status:auth:${accountSid || clientIp}`, + limit: RATE_LIMIT_TWILIO_AUTH_MAX, + windowMs: RATE_LIMIT_WINDOW_MS, + }); + if (!authRateLimit.allowed) { + logTwilioWarn('sms', 'message_status_rate_limited', { + correlationId, + eventType: 'message_status_callback', + decision: 'reject_429', + accountSid: accountSid || null, + clientIp, + }); + return withCorrelation( + new NextResponse(null, { + status: 429, + headers: buildRateLimitHeaders(authRateLimit), + }) + ); + } + + messageSid = formField(formData, 'MessageSid') || formField(formData, 'SmsSid') || null; + const messageStatus = normalizeOutboundMessageStatus(formField(formData, 'MessageStatus') || formField(formData, 'SmsStatus')); + const errorCode = formField(formData, 'ErrorCode') || null; + const errorMessage = formField(formData, 'ErrorMessage') || null; + + logTwilioInfo('sms', 'message_status_received', { + messageSid, + correlationId, + eventType: 'message_status_callback', + messageStatus, + decision: 'processing', + }); + + if (!messageSid || !messageStatus) { + logTwilioWarn('sms', 'message_status_missing_fields', { + messageSid, + correlationId, + eventType: 'message_status_callback', + decision: 'noop_missing_message_sid_or_status', + }); + return withCorrelation(okResponse()); + } + + const message = await db.message.findUnique({ + where: { twilioSid: messageSid }, + select: { + id: true, + businessId: true, + leadId: true, + participant: true, + body: true, + fromPhone: true, + toPhone: true, + status: true, + }, + }); + + if (!message) { + logTwilioWarn('sms', 'message_status_message_not_found', { + messageSid, + correlationId, + eventType: 'message_status_callback', + decision: 'noop_message_not_found', + }); + return withCorrelation(okResponse()); + } + + const previousStatus = normalizeOutboundMessageStatus(message.status); + await db.message.update({ + where: { id: message.id }, + data: { + status: messageStatus, + }, + }); + + if (previousStatus !== messageStatus && isTerminalOutboundMessageStatus(messageStatus)) { + const event = buildOutboundMessageStatusEvent(message, messageStatus); + if (event) { + await recordBusinessOperatorEvent({ + businessId: message.businessId, + type: event.type, + category: event.category, + status: event.status, + summary: event.summary, + details: { + messageSid, + messageStatus, + toPhone: formatPhoneDetail(message.toPhone), + fromPhone: formatPhoneDetail(message.fromPhone), + errorCode, + errorMessage, + }, + relatedEntityType: message.leadId ? 'lead' : 'message', + relatedEntityId: message.leadId ?? message.id, + }); + } + } + + logTwilioInfo('sms', 'message_status_persisted', { + messageSid, + correlationId, + eventType: 'message_status_callback', + businessId: message.businessId, + previousStatus, + messageStatus, + decision: previousStatus === messageStatus ? 'noop_duplicate_status' : 'persist_status_update', + }); + + return withCorrelation(okResponse()); + } catch (error) { + logTwilioError( + 'sms', + 'message_status_route_error', + { + messageSid, + correlationId, + eventType: 'message_status_callback', + decision: 'return_retryable_500', + }, + error + ); + return withCorrelation(NextResponse.json({ error: 'Message status callback failed' }, { status: 500 })); + } +} diff --git a/app/api/twilio/status/route.ts b/app/api/twilio/status/route.ts index fe505bb..d1dfa41 100644 --- a/app/api/twilio/status/route.ts +++ b/app/api/twilio/status/route.ts @@ -257,7 +257,7 @@ export async function POST(request: Request) { businessId: business.id, type: 'voice.call_marked_missed', category: 'VOICE', - status: 'WARNING', + status: 'INFO', summary: 'Call marked missed', details: { callSid, diff --git a/lib/admin-dashboard.ts b/lib/admin-dashboard.ts index 1be4d78..3709854 100644 --- a/lib/admin-dashboard.ts +++ b/lib/admin-dashboard.ts @@ -131,6 +131,8 @@ export type AdminOnboardingConfidence = { hasRecentFailures: boolean; }; +export type AdminTestSmsConfidenceState = 'not_started' | 'pending_delivery' | 'delivered' | 'failed'; + export const adminBoardFilterOptions: AdminBoardFilterOption[] = [ { key: 'all', label: 'All' }, { key: 'needs_attention', label: 'Needs attention' }, @@ -341,6 +343,32 @@ function milestoneVariant(params: { complete: boolean; blocking?: boolean }) { return 'pending' as const; } +export function getAdminTestSmsConfidenceState( + operatorEvents: Array<{ type: string; status: OperatorEventStatus; createdAt: Date }> +): AdminTestSmsConfidenceState { + const latest = operatorEvents + .filter((event) => event.type.startsWith('admin.test_sms_')) + .sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime())[0]; + + if (!latest) { + return 'not_started'; + } + + if (latest.type === 'admin.test_sms_delivered') { + return 'delivered'; + } + + if ( + latest.type === 'admin.test_sms_failed' || + latest.type === 'admin.test_sms_suppressed' || + latest.type === 'admin.test_sms_delivery_failed' + ) { + return 'failed'; + } + + return 'pending_delivery'; +} + export function buildAdminOnboardingConfidence(params: { business: DashboardBusiness; notificationSettings: DashboardNotificationSettings | null; @@ -361,12 +389,10 @@ export function buildAdminOnboardingConfidence(params: { const webhooksReady = Boolean(business.twilioWebhookSyncedAt); const compliancePending = managedSummary.onboardingReady && !managedSummary.complianceReady && !managedSummary.attentionRequired; - const hasTestSmsSuccess = operatorEvents.some( - (event) => event.type === 'admin.test_sms_accepted' && event.status === OperatorEventStatus.SUCCESS - ); - const hasTestSmsFailure = operatorEvents.some( - (event) => event.type === 'admin.test_sms_failed' || event.type === 'admin.test_sms_suppressed' - ); + const latestTestSmsState = getAdminTestSmsConfidenceState(operatorEvents); + const hasTestSmsSuccess = latestTestSmsState === 'delivered'; + const hasPendingTestSmsDelivery = latestTestSmsState === 'pending_delivery'; + const hasTestSmsFailure = latestTestSmsState === 'failed'; const hasRecentFailures = operatorEvents.some( (event) => event.status === OperatorEventStatus.FAILED || event.status === OperatorEventStatus.WARNING ); @@ -382,7 +408,12 @@ export function buildAdminOnboardingConfidence(params: { state = 'live_with_warnings'; } else if (business.provisioningStatus === BusinessProvisioningStatus.LIVE && canSafelyMarkLive) { state = 'live'; - } else if (nextStep.tone === 'attention' || managedSummary.attentionRequired || business.provisioningStatus === BusinessProvisioningStatus.NEEDS_ATTENTION) { + } else if ( + nextStep.tone === 'attention' || + managedSummary.attentionRequired || + business.provisioningStatus === BusinessProvisioningStatus.NEEDS_ATTENTION || + hasTestSmsFailure + ) { state = 'needs_attention'; } else if (compliancePending) { state = 'waiting_on_a2p'; @@ -404,9 +435,15 @@ export function buildAdminOnboardingConfidence(params: { if (compliancePending) { blockers.push({ level: 'warning', message: 'A2P review is still pending. No operator action is needed unless Twilio asks for changes.' }); } - if (readyForTest && !hasTestSmsSuccess) { + if (readyForTest && latestTestSmsState === 'not_started') { blockers.push({ level: 'warning', message: 'Run an admin test SMS from the business line before treating this onboarding as launch-ready.' }); } + if (readyForTest && hasPendingTestSmsDelivery) { + blockers.push({ + level: 'warning', + message: 'The latest admin test SMS was accepted by Twilio, but delivery has not been confirmed yet. Wait for delivery or inspect recent activity before you go live.', + }); + } if (readyForTest && hasTestSmsFailure) { blockers.push({ level: 'error', message: 'The latest admin test SMS did not complete cleanly. Fix that before you go live.' }); } @@ -473,10 +510,16 @@ export function buildAdminOnboardingConfidence(params: { }, { key: 'test_sms', - label: 'Test SMS run', + label: 'Test SMS passed', complete: hasTestSmsSuccess, variant: milestoneVariant({ complete: hasTestSmsSuccess, blocking: readyForTest }), - detail: hasTestSmsSuccess ? 'Admin test SMS was accepted by Twilio from the business line.' : 'Run the admin test SMS once the business is ready for messaging.', + detail: hasTestSmsSuccess + ? 'The latest admin test SMS was delivered from the business line.' + : hasPendingTestSmsDelivery + ? 'The latest admin test SMS was accepted by Twilio, but delivery is still pending confirmation.' + : hasTestSmsFailure + ? 'The latest admin test SMS failed or was suppressed. Fix messaging before you go live.' + : 'Run the admin test SMS once the business is ready for messaging.', }, { key: 'missed_call_validation', @@ -532,7 +575,11 @@ export function buildAdminOnboardingConfidence(params: { stateVariant: 'secondary', readinessLabel: 'Ready for test', readinessVariant: 'secondary', - nextAction: hasTestSmsSuccess ? 'Run a real missed-call test' : 'Send a test SMS', + nextAction: hasTestSmsSuccess + ? 'Run a real missed-call test' + : hasPendingTestSmsDelivery + ? 'Confirm test SMS delivery' + : 'Send a test SMS', summary: 'The business looks ready for operator-led validation. Run the checks before you mark it live.', }, ready_to_go_live: { diff --git a/lib/lead-presenters.ts b/lib/lead-presenters.ts index 3fa773b..5c3a082 100644 --- a/lib/lead-presenters.ts +++ b/lib/lead-presenters.ts @@ -36,7 +36,7 @@ export const smsStateLabels: Record = { export function isMessageDeliveryIssueStatus(status: string | null | undefined) { const normalized = status?.trim().toLowerCase(); - return normalized === 'failed' || normalized === 'fallback_webhook_response'; + return normalized === 'failed' || normalized === 'undelivered' || normalized === 'fallback_webhook_response'; } export function formatMessageStatus(status: string | null | undefined) { @@ -47,6 +47,7 @@ export function formatMessageStatus(status: string | null | undefined) { if (normalized === 'sent') return 'Sent'; if (normalized === 'queued') return 'Queued'; if (normalized === 'failed') return 'Failed'; + if (normalized === 'undelivered') return 'Undelivered'; if (normalized === 'fallback_webhook_response') return 'Sent via webhook fallback'; return normalized.replace(/_/g, ' '); diff --git a/lib/outbound-message-events.ts b/lib/outbound-message-events.ts new file mode 100644 index 0000000..4c9a7b4 --- /dev/null +++ b/lib/outbound-message-events.ts @@ -0,0 +1,96 @@ +import { OperatorEventCategory, OperatorEventStatus, type Message } from '@prisma/client'; + +export type OutboundMessageContext = 'lead_recovery' | 'owner_alert' | 'admin_test'; + +type OperatorMessageRecord = Pick; + +export function normalizeOutboundMessageStatus(status: string | null | undefined) { + const normalized = status?.trim().toLowerCase(); + return normalized || null; +} + +export function isTerminalOutboundMessageStatus(status: string | null | undefined) { + const normalized = normalizeOutboundMessageStatus(status); + return normalized === 'delivered' || normalized === 'failed' || normalized === 'undelivered'; +} + +export function isFailedOutboundMessageStatus(status: string | null | undefined) { + const normalized = normalizeOutboundMessageStatus(status); + return normalized === 'failed' || normalized === 'undelivered'; +} + +export function getOutboundMessageContext(message: OperatorMessageRecord): OutboundMessageContext { + if (!message.leadId && message.participant === 'OWNER' && message.body.startsWith('CallbackCloser admin test:')) { + return 'admin_test'; + } + + if (message.participant === 'OWNER') { + return 'owner_alert'; + } + + return 'lead_recovery'; +} + +export function buildOutboundMessageStatusEvent( + message: OperatorMessageRecord, + status: string | null | undefined +): { + type: string; + category: OperatorEventCategory; + status: OperatorEventStatus; + summary: string; +} | null { + const normalized = normalizeOutboundMessageStatus(status); + if (!isTerminalOutboundMessageStatus(normalized)) { + return null; + } + + const failed = isFailedOutboundMessageStatus(normalized); + const context = getOutboundMessageContext(message); + + if (context === 'admin_test') { + return failed + ? { + type: 'admin.test_sms_delivery_failed', + category: OperatorEventCategory.ADMIN_ACTIONS, + status: OperatorEventStatus.FAILED, + summary: 'Test SMS delivery failed', + } + : { + type: 'admin.test_sms_delivered', + category: OperatorEventCategory.ADMIN_ACTIONS, + status: OperatorEventStatus.SUCCESS, + summary: 'Test SMS delivered', + }; + } + + if (context === 'owner_alert') { + return failed + ? { + type: 'owner_alert.sms_delivery_failed', + category: OperatorEventCategory.OWNER_ALERTS, + status: OperatorEventStatus.FAILED, + summary: 'Owner SMS alert delivery failed', + } + : { + type: 'owner_alert.sms_delivered', + category: OperatorEventCategory.OWNER_ALERTS, + status: OperatorEventStatus.SUCCESS, + summary: 'Owner SMS alert delivered', + }; + } + + return failed + ? { + type: 'messaging.outbound_sms_delivery_failed', + category: OperatorEventCategory.MESSAGING, + status: OperatorEventStatus.FAILED, + summary: 'Outbound lead SMS delivery failed', + } + : { + type: 'messaging.outbound_sms_delivered', + category: OperatorEventCategory.MESSAGING, + status: OperatorEventStatus.SUCCESS, + summary: 'Outbound lead SMS delivered', + }; +} diff --git a/lib/owner-notifications.ts b/lib/owner-notifications.ts index d2c1cf2..468195e 100644 --- a/lib/owner-notifications.ts +++ b/lib/owner-notifications.ts @@ -233,6 +233,7 @@ export async function sendOwnerLeadSms(leadId: string) { toPhone: destination, body, participant: 'OWNER', + context: 'owner_alert', twilioSubaccountSid: lead.business.twilioSubaccountSid, messagingServiceSid: lead.business.twilioMessagingServiceSid, managedTwilioStatus: lead.business.managedTwilioStatus, diff --git a/lib/twilio-messaging.ts b/lib/twilio-messaging.ts index 0843a5b..564022e 100644 --- a/lib/twilio-messaging.ts +++ b/lib/twilio-messaging.ts @@ -1,12 +1,13 @@ -import { ManagedTwilioStatus, Prisma, MessageParticipant } from '@prisma/client'; +import { ManagedTwilioStatus, MessageParticipant, Prisma } from '@prisma/client'; import { db } from '@/lib/db'; import { getManagedTwilioStatusSummary } from '@/lib/managed-twilio'; +import { type OutboundMessageContext } from '@/lib/outbound-message-events'; import { formatPhoneDetail, recordBusinessOperatorEvent } from '@/lib/operator-events'; import { normalizePhoneNumber } from '@/lib/phone'; import { logTwilioError, logTwilioInfo, logTwilioWarn } from '@/lib/twilio-logging'; import { isSmsRecipientOptedOut } from '@/lib/twilio-sms-compliance'; -import { getTwilioBusinessClient } from '@/lib/twilio'; +import { getTwilioBusinessClient, getTwilioMessageStatusCallbackUrl } from '@/lib/twilio'; export async function persistInboundMessage(params: { businessId: string; @@ -77,6 +78,7 @@ export async function sendAndPersistOutboundMessage(params: { toPhone: string; body: string; participant?: MessageParticipant; + context?: OutboundMessageContext; twilioSubaccountSid?: string | null; messagingServiceSid?: string | null; managedTwilioStatus?: ManagedTwilioStatus | null; @@ -86,6 +88,8 @@ export async function sendAndPersistOutboundMessage(params: { const from = normalizePhoneNumber(params.fromPhone) || params.fromPhone; const to = normalizePhoneNumber(params.toPhone) || params.toPhone; const participant = params.participant ?? 'LEAD'; + const context = + params.context ?? (!params.leadId && participant === 'OWNER' && params.body.startsWith('CallbackCloser admin test:') ? 'admin_test' : participant === 'OWNER' ? 'owner_alert' : 'lead_recovery'); const recipientLabel = participant === 'OWNER' ? 'owner SMS' : 'lead SMS'; if (await isSmsRecipientOptedOut({ businessId: params.businessId, phone: to })) { @@ -193,11 +197,13 @@ export async function sendAndPersistOutboundMessage(params: { messagingServiceSid: params.messagingServiceSid, to, body: params.body, + statusCallback: getTwilioMessageStatusCallbackUrl(), } : { from, to, body: params.body, + statusCallback: getTwilioMessageStatusCallbackUrl(), } ); @@ -210,6 +216,10 @@ export async function sendAndPersistOutboundMessage(params: { participant, twilioSid: sent.sid, status: sent.status, + rawPayload: { + source: 'twilio_api', + context, + }, twilioCreatedAt: sent.dateCreated ?? undefined, }); await recordBusinessOperatorEvent({ diff --git a/lib/twilio.ts b/lib/twilio.ts index 87284ea..4066458 100644 --- a/lib/twilio.ts +++ b/lib/twilio.ts @@ -17,7 +17,7 @@ export type TwilioWebhookConfig = { export { getTwilioBusinessClient, getTwilioClient, getTwilioSubaccountClient } from '@/lib/twilio-client'; export type { TwilioClient } from '@/lib/twilio-client'; -export function getTwilioWebhookConfig(): TwilioWebhookConfig { +function buildConfiguredTwilioWebhookUrl(path: string) { const appBaseUrl = getConfiguredAppBaseUrl(); if (!appBaseUrl) { const resolution = resolveConfiguredAppBaseUrl(); @@ -46,22 +46,34 @@ export function getTwilioWebhookConfig(): TwilioWebhookConfig { } const normalizedBaseUrl = parsed.toString().replace(/\/$/, ''); - const buildUrl = (path: string) => { - const next = new URL(path, `${normalizedBaseUrl}/`); - if (usesSharedWebhookToken && webhookToken) { - next.searchParams.set('webhook_token', webhookToken); - } - return next.toString(); - }; + const next = new URL(path, `${normalizedBaseUrl}/`); + if (usesSharedWebhookToken && webhookToken) { + next.searchParams.set('webhook_token', webhookToken); + } return { appBaseUrl: normalizedBaseUrl, - voiceUrl: buildUrl('/api/twilio/voice'), - smsUrl: buildUrl('/api/twilio/sms'), - statusUrl: buildUrl('/api/twilio/status'), + url: next.toString(), }; } +export function getTwilioWebhookConfig(): TwilioWebhookConfig { + const voice = buildConfiguredTwilioWebhookUrl('/api/twilio/voice'); + const sms = buildConfiguredTwilioWebhookUrl('/api/twilio/sms'); + const status = buildConfiguredTwilioWebhookUrl('/api/twilio/status'); + + return { + appBaseUrl: voice.appBaseUrl, + voiceUrl: voice.url, + smsUrl: sms.url, + statusUrl: status.url, + }; +} + +export function getTwilioMessageStatusCallbackUrl() { + return buildConfiguredTwilioWebhookUrl('/api/twilio/message-status').url; +} + export async function syncTwilioIncomingPhoneNumberWebhooks( phoneNumberSid: string, client: TwilioClient = getTwilioClient(), diff --git a/tests/admin-dashboard.test.ts b/tests/admin-dashboard.test.ts index 3b6e392..f66fc41 100644 --- a/tests/admin-dashboard.test.ts +++ b/tests/admin-dashboard.test.ts @@ -8,6 +8,7 @@ import { buildAdminBusinessEvents, buildAdminNextStep, canDeleteTestBusiness, + getAdminTestSmsConfidenceState, matchesAdminBoardFilter, } from '../lib/admin-dashboard.ts'; @@ -200,7 +201,7 @@ test('onboarding confidence distinguishes ready-for-test from ready-for-live', ( successfulLeadCount: 1, operatorEvents: [ { - type: 'admin.test_sms_accepted', + type: 'admin.test_sms_delivered', status: OperatorEventStatus.SUCCESS, createdAt: new Date('2026-04-17T12:00:00.000Z'), }, @@ -212,6 +213,51 @@ test('onboarding confidence distinguishes ready-for-test from ready-for-live', ( assert.equal(readyForLive.readinessLabel, 'Ready for live'); }); +test('admin test SMS confidence waits for delivery confirmation', () => { + assert.equal( + getAdminTestSmsConfidenceState([ + { + type: 'admin.test_sms_accepted', + status: OperatorEventStatus.SUCCESS, + createdAt: new Date('2026-04-17T12:00:00.000Z'), + }, + ]), + 'pending_delivery' + ); + + assert.equal( + getAdminTestSmsConfidenceState([ + { + type: 'admin.test_sms_accepted', + status: OperatorEventStatus.SUCCESS, + createdAt: new Date('2026-04-17T12:00:00.000Z'), + }, + { + type: 'admin.test_sms_delivered', + status: OperatorEventStatus.SUCCESS, + createdAt: new Date('2026-04-17T12:01:00.000Z'), + }, + ]), + 'delivered' + ); + + assert.equal( + getAdminTestSmsConfidenceState([ + { + type: 'admin.test_sms_delivered', + status: OperatorEventStatus.SUCCESS, + createdAt: new Date('2026-04-17T12:00:00.000Z'), + }, + { + type: 'admin.test_sms_delivery_failed', + status: OperatorEventStatus.FAILED, + createdAt: new Date('2026-04-17T12:02:00.000Z'), + }, + ]), + 'failed' + ); +}); + test('onboarding confidence stays honest when A2P is pending or live has warnings', () => { const waitingOnA2p = buildAdminOnboardingConfidence({ business: createBusiness({ diff --git a/tests/pilot-safety.test.ts b/tests/pilot-safety.test.ts index cf5629d..f8f32ca 100644 --- a/tests/pilot-safety.test.ts +++ b/tests/pilot-safety.test.ts @@ -26,7 +26,9 @@ test('landing page product promise stays aligned to missed-call recovery workflo test('message delivery issue helpers flag failed and fallback statuses', () => { assert.equal(isMessageDeliveryIssueStatus('failed'), true); + assert.equal(isMessageDeliveryIssueStatus('undelivered'), true); assert.equal(isMessageDeliveryIssueStatus('fallback_webhook_response'), true); assert.equal(isMessageDeliveryIssueStatus('delivered'), false); + assert.equal(formatMessageStatus('undelivered'), 'Undelivered'); assert.equal(formatMessageStatus('fallback_webhook_response'), 'Sent via webhook fallback'); }); diff --git a/tests/twilio-messaging.test.ts b/tests/twilio-messaging.test.ts new file mode 100644 index 0000000..d64c877 --- /dev/null +++ b/tests/twilio-messaging.test.ts @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { OperatorEventCategory, OperatorEventStatus } from '@prisma/client'; + +import { buildOutboundMessageStatusEvent, getOutboundMessageContext } from '../lib/outbound-message-events.ts'; + +test('outbound message context distinguishes admin test, owner alert, and lead flows', () => { + assert.equal( + getOutboundMessageContext({ + leadId: null, + participant: 'OWNER', + body: 'CallbackCloser admin test: Acme Plumbing is using +15550001111 for live support verification.', + }), + 'admin_test' + ); + + assert.equal( + getOutboundMessageContext({ + leadId: 'lead_123', + participant: 'OWNER', + body: 'CallbackCloser lead for Acme Plumbing: Emergency repair.', + }), + 'owner_alert' + ); + + assert.equal( + getOutboundMessageContext({ + leadId: 'lead_456', + participant: 'LEAD', + body: 'What service do you need help with?', + }), + 'lead_recovery' + ); +}); + +test('terminal outbound message status events stay founder-facing', () => { + const deliveredTest = buildOutboundMessageStatusEvent( + { + leadId: null, + participant: 'OWNER', + body: 'CallbackCloser admin test: Acme Plumbing is using +15550001111 for live support verification.', + }, + 'delivered' + ); + + assert.deepEqual(deliveredTest, { + type: 'admin.test_sms_delivered', + category: OperatorEventCategory.ADMIN_ACTIONS, + status: OperatorEventStatus.SUCCESS, + summary: 'Test SMS delivered', + }); + + const failedOwnerAlert = buildOutboundMessageStatusEvent( + { + leadId: 'lead_123', + participant: 'OWNER', + body: 'CallbackCloser lead for Acme Plumbing: Emergency repair.', + }, + 'undelivered' + ); + + assert.deepEqual(failedOwnerAlert, { + type: 'owner_alert.sms_delivery_failed', + category: OperatorEventCategory.OWNER_ALERTS, + status: OperatorEventStatus.FAILED, + summary: 'Owner SMS alert delivery failed', + }); +});