From e714749411ec61f95a6ec61725fce7fa5894cd68 Mon Sep 17 00:00:00 2001 From: DevCalebR Date: Mon, 20 Apr 2026 17:39:10 -0400 Subject: [PATCH 1/4] Track SMS delivery in operator onboarding --- app/admin/[businessId]/page.tsx | 77 +++++----- app/admin/actions.ts | 1 + app/admin/page.tsx | 35 ++--- app/api/twilio/message-status/route.ts | 193 +++++++++++++++++++++++++ app/api/twilio/status/route.ts | 2 +- lib/admin-dashboard.ts | 69 +++++++-- lib/lead-presenters.ts | 3 +- lib/outbound-message-events.ts | 96 ++++++++++++ lib/owner-notifications.ts | 1 + lib/twilio-messaging.ts | 14 +- lib/twilio.ts | 34 +++-- tests/admin-dashboard.test.ts | 48 +++++- tests/pilot-safety.test.ts | 2 + tests/twilio-messaging.test.ts | 69 +++++++++ 14 files changed, 557 insertions(+), 87 deletions(-) create mode 100644 app/api/twilio/message-status/route.ts create mode 100644 lib/outbound-message-events.ts create mode 100644 tests/twilio-messaging.test.ts 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', + }); +}); From b21b7aff01f72940a0d5ce12211efb5717d2d339 Mon Sep 17 00:00:00 2001 From: DevCalebR Date: Mon, 20 Apr 2026 22:39:39 -0400 Subject: [PATCH 2/4] Add bulk test business reset tool --- app/admin/actions.ts | 42 +++++- app/admin/page.tsx | 60 ++++++++- lib/admin-dashboard.ts | 5 +- lib/admin-test-data-reset.ts | 77 +++++++++++ lib/validators.ts | 4 + tests/admin-operator-routes.test.ts | 6 + tests/admin-test-data-reset.test.ts | 193 ++++++++++++++++++++++++++++ 7 files changed, 382 insertions(+), 5 deletions(-) create mode 100644 lib/admin-test-data-reset.ts create mode 100644 tests/admin-test-data-reset.test.ts diff --git a/app/admin/actions.ts b/app/admin/actions.ts index 32f00b1..ed5a093 100644 --- a/app/admin/actions.ts +++ b/app/admin/actions.ts @@ -13,6 +13,11 @@ import { } from '@/lib/admin-provisioning'; import { requireAdmin } from '@/lib/admin'; import { canDeleteTestBusiness } from '@/lib/admin-dashboard'; +import { + bulkDeleteTestDemoBusinesses, + BULK_TEST_DATA_RESET_CONFIRMATION, + DEMO_OWNER_CLERK_ID, +} from '@/lib/admin-test-data-reset'; import { logAuditEvent } from '@/lib/audit-log'; import { db } from '@/lib/db'; import { formatPhoneDetail, maskSid, recordBusinessOperatorEvent } from '@/lib/operator-events'; @@ -20,6 +25,7 @@ import { maskPhoneForAudit, normalizePhoneNumber, normalizePhoneNumberToE164 } f import { sendAndPersistOutboundMessage } from '@/lib/twilio-messaging'; import { adminArchiveBusinessSchema, + adminBulkDeleteTestBusinessesSchema, adminBusinessDraftSchema, adminDeleteBusinessSchema, adminSendTestSmsSchema, @@ -30,7 +36,6 @@ import { adminWebhookSyncSchema, } from '@/lib/validators'; -const DEMO_OWNER_CLERK_ID = 'simulator_demo_callbackcloser'; const DEFAULT_DEMO_NAME = 'CallbackCloser Demo'; const DEFAULT_DEMO_TEXTING_NUMBER = '+15005550006'; const DEFAULT_DEMO_FORWARDING_NUMBER = '+15005550001'; @@ -940,3 +945,38 @@ export async function deleteTestBusinessAction(formData: FormData) { revalidatePath('/admin'); redirect('/admin?deleted=1'); } + +export async function bulkDeleteTestBusinessesAction(formData: FormData) { + const admin = await requireAdmin(); + + const parsed = adminBulkDeleteTestBusinessesSchema.safeParse(Object.fromEntries(formData)); + if (!parsed.success) { + redirect(`/admin?error=${encodeURIComponent(parsed.error.issues[0]?.message || 'Invalid reset request.')}`); + } + + try { + const result = await bulkDeleteTestDemoBusinesses({ + confirmation: parsed.data.confirmationText, + }); + + logAuditEvent({ + event: 'admin_test_businesses_bulk_deleted', + actorType: 'user', + actorId: admin.userId, + targetType: 'business_collection', + targetId: 'test_demo_businesses', + metadata: { + actorEmail: admin.email, + deletedCount: result.deletedCount, + deletedBusinessNames: result.deletedBusinessNames, + confirmationText: BULK_TEST_DATA_RESET_CONFIRMATION, + }, + }); + + revalidatePath('/admin'); + redirect(`/admin?resetDeleted=${encodeURIComponent(String(result.deletedCount))}`); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unable to delete test/demo businesses.'; + redirect(`/admin?error=${encodeURIComponent(message)}`); + } +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 94c0048..56ed1ad 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1,12 +1,17 @@ import Link from 'next/link'; import { + bulkDeleteTestBusinessesAction, createAdminBusinessAction, createDemoBusinessAction, provisionBusinessAction, resyncBusinessWebhooksAction, sendBusinessTestSmsAction, } from '@/app/admin/actions'; +import { + BULK_TEST_DATA_RESET_CONFIRMATION, + listTestDemoBusinessesForReset, +} from '@/lib/admin-test-data-reset'; import { adminBoardFilterOptions, buildAdminNextStep, @@ -77,12 +82,13 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor const createdDemo = getQueryValue(searchParams, 'createdDemo') === '1'; const createdBusinessId = getQueryValue(searchParams, 'businessId'); const deleted = getQueryValue(searchParams, 'deleted') === '1'; + const resetDeleted = Number(getQueryValue(searchParams, 'resetDeleted') || '0'); const error = getQueryValue(searchParams, 'error'); const query = getQueryValue(searchParams, 'q')?.trim() || ''; 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, operatorSignals] = await Promise.all([ + const [businesses, leadCounts, leadActivity, callActivity, messageActivity, operatorSignals, resettableBusinesses] = await Promise.all([ query ? searchBusinessesForAdmin(query) : db.business.findMany({ @@ -121,6 +127,7 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor }, take: 600, }), + listTestDemoBusinessesForReset(), ]); const leadCountMap = new Map(leadCounts.map((item) => [item.businessId, item._count._all])); @@ -222,6 +229,7 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor { label: 'Live', value: filterCounts.get('live') ?? 0 }, { label: 'Archived', value: filterCounts.get('archived') ?? 0 }, ]; + const resettableBusinessPreview = resettableBusinesses.slice(0, 4).map((business) => business.name); return (
@@ -252,6 +260,11 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor {error ?
{error}
: null} {deleted ?
Archived test business deleted.
: null} + {Number.isFinite(resetDeleted) && resetDeleted > 0 ? ( +
+ Deleted {resetDeleted} test/demo {resetDeleted === 1 ? 'business' : 'businesses'}. +
+ ) : null} {createdDemo && createdBusinessId ? (
Demo business ready. Use {createdBusinessId} as `SIMULATOR_BUSINESS_ID`, or open the @@ -366,6 +379,51 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor
+ + + Reset test data + + Founder/admin-only destructive cleanup. This removes every current test/demo business and its business-owned data so you can restart from a clean slate. + + + +
+

+ {resettableBusinesses.length} test/demo {resettableBusinesses.length === 1 ? 'business' : 'businesses'} eligible for deletion +

+

+ Only businesses marked as test/demo or the dedicated simulator demo workspace are included. Real customer businesses stay untouched. +

+ {resettableBusinessPreview.length > 0 ? ( +

+ Preview: {resettableBusinessPreview.join(', ')} + {resettableBusinesses.length > resettableBusinessPreview.length ? ` and ${resettableBusinesses.length - resettableBusinessPreview.length} more.` : '.'} +

+ ) : ( +

No test/demo businesses are currently eligible for cleanup.

+ )} +
+ +
+
+ + +
+

+ This is irreversible. Deleting a business also removes its business-scoped leads, calls, messages, notifications, operator events, settings, and related demo data through the existing schema cascades. +

+ +
+
+
+ Business triage board diff --git a/lib/admin-dashboard.ts b/lib/admin-dashboard.ts index 3709854..ac392af 100644 --- a/lib/admin-dashboard.ts +++ b/lib/admin-dashboard.ts @@ -11,6 +11,7 @@ import { type OwnerNotification, } from '@prisma/client'; +import { isTestDemoBusiness } from '@/lib/admin-test-data-reset'; import { getManagedTextingNumber, getManagedTwilioStatusSummary } from '@/lib/managed-twilio-status'; import { formatMessageStatus, isMessageDeliveryIssueStatus } from '@/lib/lead-presenters'; @@ -143,8 +144,6 @@ export const adminBoardFilterOptions: AdminBoardFilterOption[] = [ { key: 'archived', label: 'Archived' }, ]; -const DEMO_OWNER_CLERK_ID = 'simulator_demo_callbackcloser'; - export function isBusinessArchived(business: Pick) { return Boolean(business.archivedAt); } @@ -158,7 +157,7 @@ export function isBusinessAutomationPaused( export function canDeleteTestBusiness( business: Pick ) { - return isBusinessArchived(business) && (business.isTestBusiness || business.ownerClerkId === DEMO_OWNER_CLERK_ID); + return isBusinessArchived(business) && isTestDemoBusiness(business); } export function getBusinessLifecycleLabel( diff --git a/lib/admin-test-data-reset.ts b/lib/admin-test-data-reset.ts new file mode 100644 index 0000000..20a7e13 --- /dev/null +++ b/lib/admin-test-data-reset.ts @@ -0,0 +1,77 @@ +import type { Business, Prisma } from '@prisma/client'; + +import { db } from '@/lib/db'; + +export const DEMO_OWNER_CLERK_ID = 'simulator_demo_callbackcloser'; +export const BULK_TEST_DATA_RESET_CONFIRMATION = 'DELETE TEST BUSINESSES'; + +type TestDemoBusinessMarker = Pick; + +export type TestDemoBusinessResetCandidate = Pick; + +function normalizeConfirmation(value: string | null | undefined) { + return value?.trim().toUpperCase() || ''; +} + +export function isTestDemoBusiness(business: TestDemoBusinessMarker) { + return business.isTestBusiness || business.ownerClerkId === DEMO_OWNER_CLERK_ID; +} + +export function buildTestDemoBusinessWhere(): Prisma.BusinessWhereInput { + return { + OR: [{ isTestBusiness: true }, { ownerClerkId: DEMO_OWNER_CLERK_ID }], + }; +} + +export async function listTestDemoBusinessesForReset(candidateIds?: string[]) { + return db.business.findMany({ + where: { + AND: [ + buildTestDemoBusinessWhere(), + candidateIds?.length + ? { + id: { + in: candidateIds, + }, + } + : {}, + ], + }, + orderBy: [{ isTestBusiness: 'desc' }, { updatedAt: 'desc' }], + select: { + id: true, + name: true, + isTestBusiness: true, + ownerClerkId: true, + archivedAt: true, + }, + }); +} + +export async function bulkDeleteTestDemoBusinesses(params: { confirmation: string; candidateIds?: string[] }) { + if (normalizeConfirmation(params.confirmation) !== BULK_TEST_DATA_RESET_CONFIRMATION) { + throw new Error(`Type ${BULK_TEST_DATA_RESET_CONFIRMATION} to confirm deleting all test/demo businesses.`); + } + + const candidates = await listTestDemoBusinessesForReset(params.candidateIds); + const candidateIds = candidates.map((business) => business.id); + if (candidateIds.length === 0) { + return { + deletedCount: 0, + deletedBusinessNames: [] as string[], + }; + } + + const result = await db.business.deleteMany({ + where: { + id: { + in: candidateIds, + }, + }, + }); + + return { + deletedCount: result.count, + deletedBusinessNames: candidates.map((business) => business.name), + }; +} diff --git a/lib/validators.ts b/lib/validators.ts index 28b2454..215ad4a 100644 --- a/lib/validators.ts +++ b/lib/validators.ts @@ -140,6 +140,10 @@ export const adminDeleteBusinessSchema = z.object({ confirmationName: z.string().trim().min(1), }); +export const adminBulkDeleteTestBusinessesSchema = z.object({ + confirmationText: z.string().trim().min(1), +}); + export const businessTwilioAdminOverrideSchema = z.object({ twilioPhoneNumber: z.string().trim().max(30).optional().or(z.literal('')), twilioPhoneNumberSid: z.string().trim().max(64).optional().or(z.literal('')), diff --git a/tests/admin-operator-routes.test.ts b/tests/admin-operator-routes.test.ts index c89a4e9..f3c995b 100644 --- a/tests/admin-operator-routes.test.ts +++ b/tests/admin-operator-routes.test.ts @@ -10,11 +10,17 @@ test('admin routes expose support workspace and safe lifecycle controls', () => const adminHome = read('app/admin/page.tsx'); const adminDetail = read('app/admin/[businessId]/page.tsx'); const supportWorkspace = read('app/admin/[businessId]/workspace/page.tsx'); + const adminActions = read('app/admin/actions.ts'); assert.match(adminHome, /Operator control panel/); assert.match(adminHome, /Fast onboard/); + assert.match(adminHome, /Reset test data/); + assert.match(adminHome, /Delete all test\/demo businesses/); + assert.match(adminHome, /BULK_TEST_DATA_RESET_CONFIRMATION/); assert.match(adminHome, /Business triage board/); assert.match(adminHome, /Open customer leads/); + assert.match(adminActions, /export async function bulkDeleteTestBusinessesAction/); + assert.match(adminActions, /const admin = await requireAdmin\(\)/); assert.match(adminDetail, /Onboarding confidence/); assert.match(adminDetail, /Business info/); assert.match(adminDetail, /Provisioning health/); diff --git a/tests/admin-test-data-reset.test.ts b/tests/admin-test-data-reset.test.ts new file mode 100644 index 0000000..8e660b0 --- /dev/null +++ b/tests/admin-test-data-reset.test.ts @@ -0,0 +1,193 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import test from 'node:test'; + +import { + bulkDeleteTestDemoBusinesses, + BULK_TEST_DATA_RESET_CONFIRMATION, + DEMO_OWNER_CLERK_ID, + isTestDemoBusiness, +} from '../lib/admin-test-data-reset.ts'; +import { db } from '../lib/db.ts'; + +function uniqueDigits(seed: string) { + const digits = seed.replace(/\D/g, '').padEnd(10, '7'); + return digits.slice(-10); +} + +function makePhone(seed: string, suffix: string) { + return `+1${uniqueDigits(`${seed}${suffix}`)}`; +} + +function makeSid(prefix: string, seed: string) { + const normalized = seed.replace(/-/g, '').padEnd(32, '0'); + return `${prefix}${normalized.slice(0, 32)}`; +} + +async function seedResetCandidateBusiness(params: { seed: string; testBusiness: boolean }) { + const now = new Date('2026-04-20T12:00:00.000Z'); + const ownerClerkId = `owner_${params.seed}`; + const business = await db.business.create({ + data: { + ownerClerkId, + name: `Reset Candidate ${params.seed.slice(0, 6)}`, + isTestBusiness: params.testBusiness, + forwardingNumber: makePhone(params.seed, '100'), + notifyPhone: makePhone(params.seed, '200'), + missedCallSeconds: 20, + timezone: 'America/New_York', + serviceLabel1: 'Repair', + serviceLabel2: 'Install', + serviceLabel3: 'Maintenance', + twilioPhoneNumber: makePhone(params.seed, '300'), + twilioPrimaryPhoneNumber: makePhone(params.seed, '300'), + twilioPhoneNumberSid: makeSid('PN', `${params.seed}phone`), + twilioPrimaryNumberSid: makeSid('PX', `${params.seed}primary`), + twilioMessagingServiceSid: makeSid('MG', `${params.seed}service`), + twilioSubaccountSid: makeSid('AC', `${params.seed}subaccount`), + ownerInviteSentAt: now, + notificationSettings: { + create: { + ownerEmail: `owner-${params.seed.slice(0, 8)}@example.com`, + ownerPhone: makePhone(params.seed, '200'), + }, + }, + }, + }); + + const call = await db.call.create({ + data: { + businessId: business.id, + twilioCallSid: makeSid('CA', `${params.seed}call`), + fromPhone: makePhone(params.seed, '400'), + fromPhoneNormalized: makePhone(params.seed, '400'), + toPhone: business.twilioPrimaryPhoneNumber!, + toPhoneNormalized: business.twilioPrimaryPhoneNumber!, + status: 'MISSED', + missed: true, + }, + }); + + const lead = await db.lead.create({ + data: { + businessId: business.id, + callId: call.id, + callerPhone: call.fromPhone, + callerPhoneNormalized: call.fromPhoneNormalized, + summary: 'Reset candidate lead', + }, + }); + + await db.message.create({ + data: { + businessId: business.id, + leadId: lead.id, + direction: 'OUTBOUND', + participant: 'LEAD', + fromPhone: business.twilioPrimaryPhoneNumber!, + toPhone: lead.callerPhoneNormalized, + body: 'Reset candidate outbound message', + }, + }); + + await db.ownerNotification.create({ + data: { + businessId: business.id, + leadId: lead.id, + channel: 'SMS', + destination: makePhone(params.seed, '200'), + body: 'Reset candidate owner notification', + }, + }); + + await db.businessOperatorEvent.create({ + data: { + businessId: business.id, + type: 'admin.reset_candidate_created', + category: 'ADMIN_ACTIONS', + status: 'INFO', + summary: 'Reset candidate created', + }, + }); + + await db.simulatorRun.create({ + data: { + businessId: business.id, + leadId: lead.id, + publicId: `sim_${params.seed.replace(/-/g, '')}`, + callerPhone: lead.callerPhoneNormalized, + }, + }); + + await db.smsConsent.create({ + data: { + businessId: business.id, + phoneNormalized: lead.callerPhoneNormalized, + phoneRawLastSeen: lead.callerPhone, + optedOut: false, + }, + }); + + return { business, call, lead }; +} + +test('bulkDeleteTestDemoBusinesses deletes only test/demo businesses and cascades business-owned data', async () => { + const seed = randomUUID(); + const preservedSeed = randomUUID(); + const deletable = await seedResetCandidateBusiness({ seed, testBusiness: true }); + const preserved = await seedResetCandidateBusiness({ seed: preservedSeed, testBusiness: false }); + + try { + const result = await bulkDeleteTestDemoBusinesses({ + confirmation: BULK_TEST_DATA_RESET_CONFIRMATION, + candidateIds: [deletable.business.id, preserved.business.id], + }); + + assert.equal(result.deletedCount >= 1, true); + assert.equal(result.deletedBusinessNames.includes(deletable.business.name), true); + + assert.equal(await db.business.findUnique({ where: { id: deletable.business.id } }), null); + assert.equal(await db.call.findUnique({ where: { id: deletable.call.id } }), null); + assert.equal(await db.lead.findUnique({ where: { id: deletable.lead.id } }), null); + assert.equal(await db.message.count({ where: { businessId: deletable.business.id } }), 0); + assert.equal(await db.ownerNotification.count({ where: { businessId: deletable.business.id } }), 0); + assert.equal(await db.businessOperatorEvent.count({ where: { businessId: deletable.business.id } }), 0); + assert.equal(await db.businessNotificationSettings.count({ where: { businessId: deletable.business.id } }), 0); + assert.equal(await db.simulatorRun.count({ where: { businessId: deletable.business.id } }), 0); + assert.equal(await db.smsConsent.count({ where: { businessId: deletable.business.id } }), 0); + + assert.notEqual(await db.business.findUnique({ where: { id: preserved.business.id } }), null); + assert.equal(await db.message.count({ where: { businessId: preserved.business.id } }) > 0, true); + } finally { + await db.business.deleteMany({ where: { id: { in: [deletable.business.id, preserved.business.id] } } }); + } +}); + +test('isTestDemoBusiness includes the dedicated demo workspace even without the test flag', () => { + assert.equal( + isTestDemoBusiness({ + isTestBusiness: false, + ownerClerkId: DEMO_OWNER_CLERK_ID, + }), + true + ); +}); + +test('bulkDeleteTestDemoBusinesses requires explicit confirmation and preserves data when confirmation is wrong', async () => { + const seed = randomUUID(); + const businessRecord = await seedResetCandidateBusiness({ seed, testBusiness: true }); + + try { + await assert.rejects( + bulkDeleteTestDemoBusinesses({ + confirmation: 'delete please', + candidateIds: [businessRecord.business.id], + }), + /DELETE TEST BUSINESSES/ + ); + + assert.notEqual(await db.business.findUnique({ where: { id: businessRecord.business.id } }), null); + } finally { + await db.business.deleteMany({ where: { id: businessRecord.business.id } }); + } +}); From 27a3bf89ad44c27564ade56c6ce76aadf4b657e7 Mon Sep 17 00:00:00 2001 From: DevCalebR Date: Mon, 20 Apr 2026 23:12:29 -0400 Subject: [PATCH 3/4] Fix bulk delete redirect handling --- app/admin/actions.ts | 12 +++++++++-- app/admin/page.tsx | 8 ++++++- tests/admin-bulk-delete-action.test.ts | 30 ++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 tests/admin-bulk-delete-action.test.ts diff --git a/app/admin/actions.ts b/app/admin/actions.ts index ed5a093..11d7570 100644 --- a/app/admin/actions.ts +++ b/app/admin/actions.ts @@ -954,6 +954,8 @@ export async function bulkDeleteTestBusinessesAction(formData: FormData) { redirect(`/admin?error=${encodeURIComponent(parsed.error.issues[0]?.message || 'Invalid reset request.')}`); } + let redirectPath = '/admin'; + try { const result = await bulkDeleteTestDemoBusinesses({ confirmation: parsed.data.confirmationText, @@ -974,9 +976,15 @@ export async function bulkDeleteTestBusinessesAction(formData: FormData) { }); revalidatePath('/admin'); - redirect(`/admin?resetDeleted=${encodeURIComponent(String(result.deletedCount))}`); + + redirectPath = + result.deletedCount > 0 + ? `/admin?resetResult=deleted&resetDeleted=${encodeURIComponent(String(result.deletedCount))}` + : '/admin?resetResult=noop&resetDeleted=0'; } catch (error) { const message = error instanceof Error ? error.message : 'Unable to delete test/demo businesses.'; - redirect(`/admin?error=${encodeURIComponent(message)}`); + redirectPath = `/admin?error=${encodeURIComponent(message)}`; } + + redirect(redirectPath); } diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 56ed1ad..23b7452 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -82,6 +82,7 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor const createdDemo = getQueryValue(searchParams, 'createdDemo') === '1'; const createdBusinessId = getQueryValue(searchParams, 'businessId'); const deleted = getQueryValue(searchParams, 'deleted') === '1'; + const resetResult = getQueryValue(searchParams, 'resetResult'); const resetDeleted = Number(getQueryValue(searchParams, 'resetDeleted') || '0'); const error = getQueryValue(searchParams, 'error'); const query = getQueryValue(searchParams, 'q')?.trim() || ''; @@ -260,11 +261,16 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor {error ?
{error}
: null} {deleted ?
Archived test business deleted.
: null} - {Number.isFinite(resetDeleted) && resetDeleted > 0 ? ( + {resetResult === 'deleted' && Number.isFinite(resetDeleted) ? (
Deleted {resetDeleted} test/demo {resetDeleted === 1 ? 'business' : 'businesses'}.
) : null} + {resetResult === 'noop' ? ( +
+ No test/demo businesses were eligible for deletion. +
+ ) : null} {createdDemo && createdBusinessId ? (
Demo business ready. Use {createdBusinessId} as `SIMULATOR_BUSINESS_ID`, or open the diff --git a/tests/admin-bulk-delete-action.test.ts b/tests/admin-bulk-delete-action.test.ts new file mode 100644 index 0000000..3d0ea7e --- /dev/null +++ b/tests/admin-bulk-delete-action.test.ts @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; + +function read(path: string) { + return readFileSync(new URL(`../${path}`, import.meta.url), 'utf8'); +} + +test('bulk delete action keeps redirect outside the try/catch and returns plain admin UI states', () => { + const actions = read('app/admin/actions.ts'); + const adminPage = read('app/admin/page.tsx'); + + const actionSection = actions.slice(actions.indexOf('export async function bulkDeleteTestBusinessesAction')); + const catchStart = actionSection.indexOf('} catch (error) {'); + const finalRedirectStart = actionSection.lastIndexOf('\n\n redirect(redirectPath);'); + const catchSection = actionSection.slice(catchStart, finalRedirectStart); + + assert.match(actions, /export async function bulkDeleteTestBusinessesAction/); + assert.match(actions, /let redirectPath = '\/admin'/); + assert.match(actions, /redirectPath = `\/admin\?error=\$\{encodeURIComponent\(message\)\}`/); + assert.match(actions, /redirect\(redirectPath\);/); + assert.notEqual(catchStart, -1); + assert.notEqual(finalRedirectStart, -1); + assert.doesNotMatch(catchSection, /redirect\(/); + + assert.match(adminPage, /resetResult === 'deleted'/); + assert.match(adminPage, /Deleted \{resetDeleted\} test\/demo/); + assert.match(adminPage, /resetResult === 'noop'/); + assert.match(adminPage, /No test\/demo businesses were eligible for deletion/); +}); From 9445b65abea30daf0ddd7c71124d2009dfc7831f Mon Sep 17 00:00:00 2001 From: DevCalebR Date: Tue, 21 Apr 2026 09:28:56 -0400 Subject: [PATCH 4/4] Rebuild the Twilio setup flow around account mode --- app/admin/[businessId]/page.tsx | 1797 +++++------------ app/admin/actions.ts | 207 +- app/api/twilio/status/route.ts | 2 +- app/app/onboarding/actions.ts | 30 +- app/app/onboarding/page.tsx | 141 +- app/app/settings/actions.ts | 197 +- app/app/settings/page.tsx | 1134 ++++++----- components/twilio-setup-checklist.tsx | 91 + lib/admin-dashboard.ts | 13 +- lib/admin-provisioning-presenters.ts | 13 +- lib/admin-provisioning.ts | 24 +- lib/business.ts | 10 +- lib/managed-twilio-status.ts | 21 +- lib/managed-twilio.ts | 67 +- lib/missed-call-flow.ts | 5 +- lib/owner-notifications.ts | 4 +- lib/portfolio-demo.ts | 2 + lib/simulator.ts | 3 + lib/system-status.ts | 1 + lib/twilio-messaging.ts | 1 + lib/twilio-provision.ts | 2 + lib/twilio-provisioning-input.ts | 2 + lib/twilio-setup.ts | 473 +++++ lib/validators.ts | 63 + .../migration.sql | 16 + prisma/schema.prisma | 12 + tests/admin-dashboard.test.ts | 11 +- tests/admin-operator-routes.test.ts | 14 +- tests/admin-provisioning.test.ts | 4 +- tests/business-upsert.test.ts | 52 + tests/managed-twilio-status.test.ts | 3 +- tests/pilot-safety.test.ts | 18 +- tests/twilio-provision.test.ts | 3 + tests/twilio-setup-flow.test.ts | 126 ++ 34 files changed, 2613 insertions(+), 1949 deletions(-) create mode 100644 components/twilio-setup-checklist.tsx create mode 100644 lib/twilio-setup.ts create mode 100644 prisma/migrations/20260421101500_add_twilio_setup_modes/migration.sql create mode 100644 tests/business-upsert.test.ts create mode 100644 tests/twilio-setup-flow.test.ts diff --git a/app/admin/[businessId]/page.tsx b/app/admin/[businessId]/page.tsx index 2a59116..22d28a7 100644 --- a/app/admin/[businessId]/page.tsx +++ b/app/admin/[businessId]/page.tsx @@ -8,84 +8,30 @@ import { provisionBusinessAction, resyncBusinessWebhooksAction, restoreBusinessAction, - saveAdminBusinessProfileAction, + saveAdminTwilioSetupAction, sendBusinessTestSmsAction, setBusinessProvisioningStatusAction, } from '@/app/admin/actions'; -import { - buildAdminOnboardingConfidence, - canDeleteTestBusiness, - getAdminTestSmsConfidenceState, - isBusinessArchived, -} from '@/lib/admin-dashboard'; -import { CopyValueButton } from '@/components/copy-value-button'; +import { TwilioSetupChecklist } from '@/components/twilio-setup-checklist'; import { Badge } from '@/components/ui/badge'; import { Button, buttonVariants } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select } from '@/components/ui/select'; -import { Textarea } from '@/components/ui/textarea'; +import { getAdminTestSmsConfidenceState, isBusinessArchived } from '@/lib/admin-dashboard'; +import { getAdminOwnerState, getTwilioWebhookSnapshot, listAdminTwilioNumbers } from '@/lib/admin-provisioning'; import { requireAdmin } from '@/lib/admin'; -import { - adminProvisioningStatusLabels, - buildAdminProvisioningChecklist, - getAdminOwnerState, - getAdminProvisioningStatusVariant, - getTwilioWebhookSnapshot, - listAdminTwilioNumbers, -} from '@/lib/admin-provisioning'; +import { TwilioSetupTone, buildTwilioSetupFlow, twilioAccountModeOptions, twilioNumberSetupModeOptions } from '@/lib/twilio-setup'; import { db } from '@/lib/db'; -import { - formatDateTime, - formatRelativeTime, - getLeadCallbackState, - getLeadStatusBadgeVariant, - leadReadinessLabels, - leadStatusLabels, -} from '@/lib/lead-presenters'; -import { getManagedTextingNumber, getManagedTwilioStatusSummary, managedTwilioStatusLabels } from '@/lib/managed-twilio-status'; -import { - businessTimelineFilterOptions, - countTimelineFilters, - matchesTimelineFilter, - operatorEventCategoryLabels, - operatorEventStatusLabels, - type BusinessTimelineFilter, -} from '@/lib/operator-events'; +import { getManagedTextingNumber, managedTwilioStatusLabels } from '@/lib/managed-twilio-status'; import { formatPhoneForDisplay } from '@/lib/phone'; -import { getBusinessBillingAccessState } from '@/lib/subscription'; -import { getAdminBusinessStatus, getCustomerSystemStatus } from '@/lib/system-status'; -import { cn } from '@/lib/utils'; - -export const dynamic = 'force-dynamic'; - -const changedFieldLabels: Record = { - ownerPhone: 'owner alert phone', - twilioPhoneNumber: 'Twilio number', - twilioPhoneNumberSid: 'Twilio number SID', - twilioMessagingServiceSid: 'messaging service SID', - a2pCustomerProfileSid: 'A2P customer profile SID', - a2pBrandSid: 'A2P brand SID', - a2pCampaignSid: 'A2P campaign SID', - a2pFailureReason: 'A2P failure reason', - managedTwilioStatus: 'managed Twilio status', - isTestBusiness: 'test business flag', -}; -type AdminBusinessFormDefaults = { - name: string; - ownerName: string; - ownerEmail: string; - ownerPhone: string; - isTestBusiness: boolean; - forwardingNumber: string; - timezone: string; - missedCallSeconds: string; - serviceLabel1: string; - serviceLabel2: string; - serviceLabel3: string; - internalNotes: string; +type AdminTwilioDefaults = { + businessId: string; + twilioAccountMode: string; + twilioNumberSetupMode: string; + twilioSubaccountSid: string; twilioPhoneNumber: string; twilioPhoneNumberSid: string; twilioMessagingServiceSid: string; @@ -94,42 +40,6 @@ type AdminBusinessFormDefaults = { a2pCampaignSid: string; a2pFailureReason: string; managedTwilioStatus: string; - notifySms: boolean; - notifyEmail: boolean; - notifyInApp: boolean; - urgentOnly: boolean; -}; - -type AdminBusinessWithSettings = { - name: string; - ownerName: string | null; - notifyPhone: string | null; - isTestBusiness: boolean; - forwardingNumber: string; - timezone: string; - missedCallSeconds: number; - serviceLabel1: string; - serviceLabel2: string; - serviceLabel3: string; - internalNotes: string | null; - twilioPrimaryPhoneNumber: string | null; - twilioPhoneNumber: string | null; - twilioPrimaryNumberSid: string | null; - twilioPhoneNumberSid: string | null; - twilioMessagingServiceSid: string | null; - a2pCustomerProfileSid: string | null; - a2pBrandSid: string | null; - a2pCampaignSid: string | null; - a2pFailureReason: string | null; - managedTwilioStatus: string; - notificationSettings: { - ownerEmail: string | null; - ownerPhone: string | null; - notifySms: boolean; - notifyEmail: boolean; - notifyInApp: boolean; - urgentOnly: boolean; - } | null; }; function getQueryValue(searchParams: Record | undefined, key: string) { @@ -137,164 +47,28 @@ function getQueryValue(searchParams: Record | undefined): BusinessTimelineFilter { - const value = getQueryValue(searchParams, 'activity'); - return businessTimelineFilterOptions.some((option) => option.key === value) ? (value as BusinessTimelineFilter) : 'all'; -} - -function getOperatorEventBadgeVariant(status: keyof typeof operatorEventStatusLabels) { - if (status === 'FAILED') return 'destructive' as const; - if (status === 'WARNING') return 'outline' as const; - if (status === 'SUCCESS') return 'success' as const; - return 'secondary' as const; -} - -function getConfidenceMilestoneBadgeVariant(variant: 'success' | 'warning' | 'pending') { - if (variant === 'success') return 'success' as const; - if (variant === 'warning') return 'outline' as const; - 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; - if (typeof value === 'number' || typeof value === 'boolean') return String(value); - if (Array.isArray(value)) return value.map((item) => formatOperatorEventDetailValue(item)).join(', '); - if (typeof value === 'object') { - return Object.entries(value as Record) - .map(([key, nested]) => `${key}: ${formatOperatorEventDetailValue(nested)}`) - .join(' | '); - } - return String(value); -} - -function getOperatorEventDetails(value: unknown) { - if (!value || typeof value !== 'object' || Array.isArray(value)) return []; - return Object.entries(value as Record).map(([key, detail]) => ({ - key, - label: key.replace(/([A-Z])/g, ' $1').replace(/_/g, ' '), - value: formatOperatorEventDetailValue(detail), - })); -} - -function buildOperatorEventRelatedHref(businessId: string, relatedEntityType: string | null, relatedEntityId: string | null) { - if (!relatedEntityType || !relatedEntityId) return null; - if (relatedEntityType === 'lead') return `/admin/${businessId}/workspace#recent-leads`; - if (relatedEntityType === 'message') return `/admin/${businessId}/workspace#recent-activity`; - if (relatedEntityType === 'call') return `/admin/${businessId}/workspace#call-flow-snapshot`; - return null; -} - -function HiddenAdminBusinessFields({ +function HiddenAdminTwilioFields({ defaults, exclude = [], }: { - defaults: AdminBusinessFormDefaults; - exclude?: Array; + defaults: AdminTwilioDefaults; + exclude?: Array; }) { return ( <> {Object.entries(defaults).map(([name, value]) => { - if (exclude.includes(name as keyof AdminBusinessFormDefaults)) return null; - - if (typeof value === 'boolean') { - if (!value) return null; - return ; - } - + if (exclude.includes(name as keyof AdminTwilioDefaults)) return null; return ; })} ); } -function StatusButton({ - businessId, - status, - label, - variant = 'outline', -}: { - businessId: string; - status: 'DRAFT' | 'ONBOARDING' | 'NEEDS_ATTENTION' | 'LIVE' | 'PAUSED'; - label: string; - variant?: 'default' | 'outline' | 'destructive' | 'secondary'; -}) { - return ( -
- - - -
- ); -} - -function WebhookResyncButton({ - businessId, - target, - label, - variant = 'outline', -}: { - businessId: string; - target: 'VOICE' | 'SMS' | 'ALL'; - label: string; - variant?: 'default' | 'outline' | 'destructive' | 'secondary'; -}) { - return ( -
- - - -
- ); -} - -function buildAdminFormDefaults(business: AdminBusinessWithSettings) { - return { - name: business.name, - ownerName: business.ownerName || '', - ownerEmail: business.notificationSettings?.ownerEmail || '', - ownerPhone: business.notificationSettings?.ownerPhone || business.notifyPhone || '', - isTestBusiness: business.isTestBusiness, - forwardingNumber: business.forwardingNumber, - timezone: business.timezone, - missedCallSeconds: String(business.missedCallSeconds), - serviceLabel1: business.serviceLabel1, - serviceLabel2: business.serviceLabel2, - serviceLabel3: business.serviceLabel3, - internalNotes: business.internalNotes || '', - twilioPhoneNumber: business.twilioPrimaryPhoneNumber || business.twilioPhoneNumber || '', - twilioPhoneNumberSid: business.twilioPrimaryNumberSid || business.twilioPhoneNumberSid || '', - twilioMessagingServiceSid: business.twilioMessagingServiceSid || '', - a2pCustomerProfileSid: business.a2pCustomerProfileSid || '', - a2pBrandSid: business.a2pBrandSid || '', - a2pCampaignSid: business.a2pCampaignSid || '', - a2pFailureReason: business.a2pFailureReason || '', - managedTwilioStatus: business.managedTwilioStatus, - notifySms: business.notificationSettings?.notifySms ?? true, - notifyEmail: business.notificationSettings?.notifyEmail ?? true, - notifyInApp: business.notificationSettings?.notifyInApp ?? true, - urgentOnly: business.notificationSettings?.urgentOnly ?? false, - } satisfies AdminBusinessFormDefaults; +function getBadgeVariant(tone: TwilioSetupTone) { + if (tone === 'success') return 'success' as const; + if (tone === 'attention') return 'destructive' as const; + if (tone === 'pending') return 'outline' as const; + return 'secondary' as const; } export default async function AdminBusinessDetailPage({ @@ -305,72 +79,36 @@ export default async function AdminBusinessDetailPage({ searchParams?: Record; }) { await requireAdmin(); - const activityFilter = getTimelineFilter(searchParams); - const [business, successfulLeadCount, leadCount, callCount, messageCount, recentLeads, recentOwnerNotifications, operatorEvents] = - await Promise.all([ - db.business.findUnique({ - where: { id: params.businessId }, - include: { - notificationSettings: true, + const [business, successfulLeadCount, operatorEvents] = await Promise.all([ + db.business.findUnique({ + where: { id: params.businessId }, + include: { + notificationSettings: true, + }, + }), + db.lead.count({ + where: { + businessId: params.businessId, + OR: [{ ownerNotifiedAt: { not: null } }, { notifiedAt: { not: null } }], + }, + }), + db.businessOperatorEvent.findMany({ + where: { + businessId: params.businessId, + type: { + startsWith: 'admin.test_sms_', }, - }), - db.lead.count({ - where: { - businessId: params.businessId, - OR: [{ ownerNotifiedAt: { not: null } }, { notifiedAt: { not: null } }], - }, - }), - db.lead.count({ where: { businessId: params.businessId } }), - db.call.count({ where: { businessId: params.businessId } }), - db.message.count({ where: { businessId: params.businessId } }), - db.lead.findMany({ - where: { businessId: params.businessId }, - orderBy: [{ lastInteractionAt: 'desc' }, { createdAt: 'desc' }], - take: 6, - select: { - id: true, - status: true, - readiness: true, - billingRequired: true, - smsState: true, - summary: true, - notifiedAt: true, - ownerNotifiedAt: true, - createdAt: true, - lastInteractionAt: true, - }, - }), - db.ownerNotification.findMany({ - where: { businessId: params.businessId }, - orderBy: { createdAt: 'desc' }, - take: 8, - select: { - id: true, - channel: true, - status: true, - error: true, - createdAt: true, - destination: true, - }, - }), - db.businessOperatorEvent.findMany({ - where: { businessId: params.businessId }, - orderBy: { createdAt: 'desc' }, - take: 120, - select: { - id: true, - type: true, - category: true, - status: true, - summary: true, - detailsJson: true, - relatedEntityType: true, - relatedEntityId: true, - createdAt: true, - }, - }), - ]); + }, + orderBy: { createdAt: 'desc' }, + take: 20, + select: { + type: true, + status: true, + createdAt: true, + }, + }), + ]); if (!business) notFound(); @@ -380,1046 +118,545 @@ export default async function AdminBusinessDetailPage({ listAdminTwilioNumbers(business), ]); - const rolloutStatus = getAdminBusinessStatus(business, successfulLeadCount); - const customerStatus = getCustomerSystemStatus(business, successfulLeadCount); - const managedSummary = getManagedTwilioStatusSummary(business); - const billingAccess = getBusinessBillingAccessState(business); - const checklist = buildAdminProvisioningChecklist({ - business, - notificationSettings: business.notificationSettings, - ownerConnected: ownerState.connected, - webhookSnapshot, - }); - const onboardingConfidence = buildAdminOnboardingConfidence({ + const testSmsState = getAdminTestSmsConfidenceState(operatorEvents); + const managedTextingNumber = getManagedTextingNumber(business); + const setupFlow = buildTwilioSetupFlow({ business, notificationSettings: business.notificationSettings, ownerConnected: ownerState.connected, successfulLeadCount, - operatorEvents: operatorEvents.map((event) => ({ - type: event.type, - status: event.status, - 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); + testSmsState, + webhookSnapshot, }); - const assignedNumber = getManagedTextingNumber(business); - const defaults = buildAdminFormDefaults(business); - const webhooksNeedAttention = Boolean( - assignedNumber && - (!business.twilioWebhookSyncedAt || - webhookSnapshot?.voiceSynced === false || - webhookSnapshot?.smsSynced === false || - webhookSnapshot?.statusSynced === false) - ); - const brandStatusLabel = business.a2pBrandSid - ? managedSummary.complianceReady - ? 'Approved' - : managedSummary.attentionRequired - ? 'Needs attention' - : 'Submitted' - : 'Not started'; - const campaignStatusLabel = business.a2pCampaignSid - ? managedSummary.complianceReady - ? 'Approved' - : managedSummary.attentionRequired - ? 'Needs attention' - : 'Pending' - : 'Not started'; - const latestProvisioningEvent = operatorEvents.find((event) => event.category === 'PROVISIONING') || null; - const latestWebhookEvent = - operatorEvents.find((event) => event.category === 'WEBHOOKS') || - (webhooksNeedAttention - ? { - id: 'webhook-attention', - createdAt: business.updatedAt, - status: 'WARNING' as const, - category: 'WEBHOOKS' as const, - summary: 'Webhook mismatch detected', - detailsJson: { - detail: webhookSnapshot?.error || 'Voice, SMS, or status callback sync still needs attention.', - }, - relatedEntityType: null, - relatedEntityId: null, - type: 'webhooks.mismatch_detected', - } - : null); - const latestTestSmsEvent = operatorEvents.find((event) => event.type.startsWith('admin.test_sms_')) || null; - const latestOwnerAlert = recentOwnerNotifications[0] || null; + const defaults: AdminTwilioDefaults = { + businessId: business.id, + twilioAccountMode: business.twilioAccountMode, + twilioNumberSetupMode: business.twilioNumberSetupMode, + twilioSubaccountSid: business.twilioSubaccountSid || '', + twilioPhoneNumber: business.twilioPrimaryPhoneNumber || business.twilioPhoneNumber || '', + twilioPhoneNumberSid: business.twilioPrimaryNumberSid || business.twilioPhoneNumberSid || '', + twilioMessagingServiceSid: business.twilioMessagingServiceSid || '', + a2pCustomerProfileSid: business.a2pCustomerProfileSid || '', + a2pBrandSid: business.a2pBrandSid || '', + a2pCampaignSid: business.a2pCampaignSid || '', + a2pFailureReason: business.a2pFailureReason || '', + managedTwilioStatus: business.managedTwilioStatus, + }; const created = getQueryValue(searchParams, 'created') === '1'; const saved = getQueryValue(searchParams, 'saved') === '1'; const ownerStateMessage = getQueryValue(searchParams, 'ownerState'); const provisioned = getQueryValue(searchParams, 'provisioned') === '1'; const synced = getQueryValue(searchParams, 'synced'); - const statusSaved = getQueryValue(searchParams, 'statusSaved'); const testSms = getQueryValue(searchParams, 'testSms') === '1'; const archived = getQueryValue(searchParams, 'archived') === '1'; const restored = getQueryValue(searchParams, 'restored') === '1'; const error = getQueryValue(searchParams, 'error'); - const changed = (getQueryValue(searchParams, 'changed') || '') - .split(',') - .map((field) => field.trim()) - .filter(Boolean) - .map((field) => changedFieldLabels[field] || field); - return ( -
-
- - Back to operator board - -
-
-
-

{business.name} business control panel

- {business.isTestBusiness ? Test : null} - {isBusinessArchived(business) ? Archived : null} -
-

- Compact owner-first workspace for onboarding, fast edits, support shortcuts, and the most common repair actions. -

-
-
- - Open customer workspace - - - Open customer leads - - - Back to board - + const bannerAction = + setupFlow.banner.stepKey === 'owner_connected' && !ownerState.connected ? ( + + Connect owner + + ) : setupFlow.banner.stepKey === 'voice_webhook_synced' || + setupFlow.banner.stepKey === 'sms_webhook_synced' || + setupFlow.banner.stepKey === 'status_callback_synced' ? ( +
+ + + +
+ ) : setupFlow.banner.stepKey === 'safe_to_mark_live' && setupFlow.safeToMarkLive && business.provisioningStatus !== 'LIVE' ? ( +
+ + + +
+ ) : ( + + Review step + + ); + + const setupSteps = setupFlow.steps.map((step) => { + if (step.key === 'owner_connected') { + return { + ...step, + body: ownerState.connected ? ( +
+ Connected + {ownerState.email || business.notificationSettings?.ownerEmail || 'Owner email not recorded'}
-
-
- - {adminProvisioningStatusLabels[business.provisioningStatus]} - - {rolloutStatus.label} - {customerStatus.label} - {onboardingConfidence.stateLabel} - {onboardingConfidence.readinessLabel} -
-
- - {created ?
Business workspace created and ready for provisioning.
: null} - {saved ? ( -
- Business details saved{changed.length > 0 ? `: ${changed.join(', ')}.` : '.'} -
- ) : null} - {ownerStateMessage ? ( -
- {ownerStateMessage === 'connected' - ? 'Owner account connected.' - : ownerStateMessage === 'invited' - ? 'Owner invite sent. Re-run owner connection after the invite is accepted.' - : 'Owner state updated.'} -
- ) : null} - {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 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} - -
- - - Onboarding confidence - Current state, blockers, next action, and the exact gate between setup, testing, and launch. - - -
-
-
-
-

{onboardingConfidence.stateLabel}

- {onboardingConfidence.readinessLabel} -
-

{onboardingConfidence.summary}

-

Next action: {onboardingConfidence.nextAction}

-

- Confidence checklist: {onboardingConfidence.milestones.filter((item) => item.complete).length} /{' '} - {onboardingConfidence.milestones.length} signals complete -

-
-
- {isBusinessArchived(business) ? ( - - Restore business - - ) : business.provisioningStatus === 'PAUSED' ? ( - - Resume automation - - ) : !ownerState.connected ? ( - - Connect owner - - ) : !(business.notificationSettings?.ownerPhone || business.notifyPhone) ? ( - - Add owner alert phone - - ) : !managedSummary.subaccountReady || !managedSummary.numberAssigned || !managedSummary.messagingServiceReady ? ( -
- - - -
- ) : webhooksNeedAttention ? ( - - ) : managedSummary.messagingReady && business.provisioningStatus !== 'LIVE' ? ( - - ) : ( - - Open support workspace - - )} - - {!isBusinessArchived(business) ? ( -
- - - -
- ) : null} - {assignedNumber ? : null} -
-
+ ) : ( +
+ +
+ +
- - {onboardingConfidence.blockers.length > 0 ? ( -
- {onboardingConfidence.blockers.map((blocker, index) => ( -
- {blocker.message} -
- ))} -
- ) : null} - - {onboardingConfidence.milestones.length > 0 ? ( -
- {onboardingConfidence.milestones.map((item) => ( -
-
-

{item.label}

- {item.complete ? 'Done' : 'Next'} -
-

{item.detail}

-
- ))} -
- ) : ( -
- No onboarding milestones are available for this business yet. -
- )} - -
- {[ - { 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}

-

{item.value}

-
- ))} -
- - - - - - Support mode shortcuts - Safe customer-side entry points without impersonation or tenant bleed. - - -
- - Open customer workspace - - - Open customer leads - - - Open customer settings - - - Open customer call flow - +
+ +
- - - -
- - -

- Sends a short admin verification message from the assigned business line to the destination above. -

-
- - - -
- Support mode stays read-only. Use it to inspect leads, settings, and call flow quickly without weakening business isolation. -
- - -
- -
- - - Business info - Fast edits for the business record, owner identity, and operator notes. - - -
- - -
-
- - -
-
- - -
-
- - -
-
- -