From 5099a8d9bf93f3f30d143e86ea9a411664c3bfea Mon Sep 17 00:00:00 2001 From: DevCalebR Date: Fri, 29 May 2026 14:24:51 -0400 Subject: [PATCH] Tighten admin business triage states --- app/admin/page.tsx | 638 ++++++++++++--------------- lib/admin-dashboard.ts | 540 +++++++++++++++++++++-- tests/admin-dashboard.test.ts | 259 ++++++++++- tests/admin-operator-routes.test.ts | 10 +- tests/managed-customer-setup.test.ts | 4 +- 5 files changed, 1054 insertions(+), 397 deletions(-) diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 7acd775..0fe8ea9 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1,5 +1,4 @@ import Link from 'next/link'; -import { BusinessProvisioningStatus } from '@prisma/client'; import { archiveBusinessAction, @@ -7,7 +6,6 @@ import { createDemoBusinessAction, deleteTestBusinessAction, founderDeleteAllBusinessesAction, - provisionBusinessAction, resyncBusinessWebhooksAction, restoreBusinessAction, sendBusinessTestSmsAction, @@ -17,21 +15,19 @@ import { buildAdminCustomerOpenHref } from '@/lib/admin-customer-paths'; import { FOUNDER_DELETE_ALL_BUSINESSES_CONFIRMATION } from '@/lib/admin-business-lifecycle'; import { adminBoardFilterOptions, + buildAdminBusinessCardState, buildAdminOnboardingConfidence, buildAdminBusinessPickerLabel, buildAdminNextStep, canDeleteTestBusiness, getDeleteTestBusinessBlockedReason, isBusinessArchived, - matchesAdminBoardFilter, + matchesAdminBoardFilterState, type AdminBoardFilter, + type AdminBusinessCardState, } from '@/lib/admin-dashboard'; import { buildAdminMissedCallValidationTruth } from '@/lib/admin-operator-proof'; -import { - buildAdminBusinessIssue, - buildAdminTestSmsTruth, - getOperatorToneBadgeVariant, -} from '@/lib/admin-operator-visibility'; +import { buildAdminBusinessIssue, buildAdminTestSmsTruth } from '@/lib/admin-operator-visibility'; import { AdminBusinessPicker } from '@/components/admin-business-picker'; import { requireAdmin } from '@/lib/admin'; import { isTestDemoBusiness } from '@/lib/admin-test-data-reset'; @@ -66,42 +62,20 @@ function maxDate(...values: Array) { return new Date(Math.max(...timestamps)); } -function getOverallStatus(params: { - archived: boolean; - paused: boolean; - live: boolean; - needsAttention: boolean; -}) { - if (params.archived) return { label: 'Archived', variant: 'outline' as const }; - if (params.paused) return { label: 'Paused', variant: 'outline' as const }; - if (params.needsAttention) return { label: 'Needs attention', variant: 'destructive' as const }; - if (params.live) return { label: 'Live', variant: 'success' as const }; - return { label: 'Pending', variant: 'secondary' as const }; +function buildAdminStepHref(businessId: string, stepKey: string | null) { + return stepKey ? `/admin/${businessId}?step=${stepKey}#step-${stepKey}` : `/admin/${businessId}`; } -function getA2pStateLabel(params: { - complianceReady: boolean; - attentionRequired: boolean; - complianceStarted: boolean; - complianceTypeUnknown: boolean; - complianceTypeLabel: string; -}) { - if (params.complianceReady) { - return { label: params.complianceTypeLabel === 'Toll-free verification' ? 'Verified' : 'Approved', variant: 'success' as const }; +function getCardPrimaryActionHref(businessId: string, cardState: AdminBusinessCardState) { + if (cardState.nextActionStepKey) { + return buildAdminStepHref(businessId, cardState.nextActionStepKey); } - if (params.attentionRequired) return { label: 'Needs attention', variant: 'destructive' as const }; - if (params.complianceTypeUnknown) return { label: 'Not selected', variant: 'outline' as const }; - if (params.complianceStarted) { - return { label: params.complianceTypeLabel === 'Toll-free verification' ? 'Pending verification' : 'Pending', variant: 'secondary' as const }; + + if (cardState.primaryState === 'archived') { + return `/admin/${businessId}#advanced`; } - return { label: 'Not started', variant: 'outline' as const }; -} -function compactCopy(value: string, maxLength = 110) { - const trimmed = value.trim(); - if (!trimmed) return 'No issues recorded.'; - if (trimmed.length <= maxLength) return trimmed; - return `${trimmed.slice(0, maxLength - 1)}…`; + return `/admin/${businessId}`; } function buildAdminBoardReturnPath(params: { @@ -377,21 +351,6 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor messageActivityMap.get(business.id) ); const assignedNumber = getManagedTextingNumber(business); - const archived = isBusinessArchived(business); - const paused = !archived && business.provisioningStatus === 'PAUSED'; - const overallStatus = getOverallStatus({ - archived, - paused, - live: business.provisioningStatus === 'LIVE' && managedSummary.messagingReady, - needsAttention: nextStep.tone === 'attention' || managedSummary.attentionRequired, - }); - const a2pState = getA2pStateLabel({ - complianceReady: managedSummary.complianceReady, - attentionRequired: managedSummary.attentionRequired, - complianceStarted: managedSummary.complianceStarted, - complianceTypeUnknown: managedSummary.complianceTypeUnknown, - complianceTypeLabel: managedSummary.complianceTypeLabel, - }); const lastIssue = buildAdminBusinessIssue({ events: latestIssueMap.has(business.id) ? [latestIssueMap.get(business.id)!] : [], currentStep: { @@ -414,17 +373,18 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor operatorEvents: businessConfidenceEvents, missedCallValidation, }); - const attentionSignal = - lastIssue.state === 'issue' - ? compactCopy(`${lastIssue.summary}. ${lastIssue.detail}`) - : nextStep.tone === 'healthy' - ? 'Healthy. No immediate operator action needed.' - : compactCopy(`${nextStep.title}. ${nextStep.detail}`); const operatorSignals = operatorSignalMap.get(business.id) || { failed: 0, warning: 0 }; - const blockerStepHref = lastIssue.remediationStepKey - ? `/admin/${business.id}?step=${lastIssue.remediationStepKey}#step-${lastIssue.remediationStepKey}` - : null; - + const cardState = buildAdminBusinessCardState({ + business, + notificationSettings: business.notificationSettings, + ownerConnected, + nextStep, + managedSummary, + onboardingConfidence, + lastIssue, + testSmsTruth, + missedCallValidation, + }); return { business, ownerConnected, @@ -436,31 +396,24 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor assignedNumber, lastActivityAt, lastIssue, - overallStatus, - a2pState, - attentionSignal, operatorSignals, testSmsTruth, missedCallValidation, onboardingConfidence, canSendTestSms: Boolean(assignedNumber && (business.notificationSettings?.ownerPhone || business.notifyPhone)), - blockerStepHref, + cardState, }; }); const filterCounts = new Map( adminBoardFilterOptions.map((option) => [ option.key, - businessRows.filter((item) => - matchesAdminBoardFilter(item.business, item.business.notificationSettings, item.ownerConnected, option.key) - ).length, + businessRows.filter((item) => matchesAdminBoardFilterState(item.cardState, option.key)).length, ]) ); const visibleRows = businessRows.filter((item) => - selectedBusinessId - ? item.business.id === selectedBusinessId - : matchesAdminBoardFilter(item.business, item.business.notificationSettings, item.ownerConnected, view) + selectedBusinessId ? item.business.id === selectedBusinessId : matchesAdminBoardFilterState(item.cardState, view) ); const pickerOptions = businessPickerOptions.map((business) => ({ id: business.id, @@ -480,10 +433,12 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor }); const summaryStats = [ - { label: 'Pending setup', value: businessRows.filter((row) => row.business.provisioningStatus === BusinessProvisioningStatus.DRAFT).length }, - { label: 'In setup', value: businessRows.filter((row) => row.business.provisioningStatus === BusinessProvisioningStatus.ONBOARDING).length }, - { label: 'Needs attention', value: businessRows.filter((row) => row.onboardingConfidence.state === 'needs_attention').length }, - { label: 'Ready for live', value: businessRows.filter((row) => row.onboardingConfidence.state === 'ready_to_go_live').length }, + { label: 'Needs attention', value: businessRows.filter((row) => row.cardState.shouldAppearInNeedsAttention).length }, + { label: 'Pending compliance', value: businessRows.filter((row) => row.cardState.shouldAppearInPendingCompliance).length }, + { label: 'Ready for test', value: businessRows.filter((row) => row.cardState.isReadyForTest).length }, + { label: 'Ready for live', value: businessRows.filter((row) => row.cardState.isReadyForLive).length }, + { label: 'Live', value: businessRows.filter((row) => row.cardState.isLive).length }, + { label: 'Archived', value: businessRows.filter((row) => row.cardState.isArchived).length }, ]; const founderResetBusinessCount = businessPickerOptions.length; const founderResetBusinessPreview = businessPickerOptions.slice(0, 4).map((business) => business.name); @@ -510,7 +465,7 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor hunting through long pages.

-
+
{summaryStats.map((item) => ( @@ -556,206 +511,232 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor
- Fast onboard - - Create the workspace with the minimum founder-needed inputs. The guided Twilio flow, explicit account-mode choice, and owner decision stay inside the business setup panel. - - - -
- - - - +
+
+ + Create new business + + Fast onboard stays available here, collapsed by default so the triage board stays easier to reach during daily operator use. + + +
+ + + + + -
- - -
-
- - -
-
- - -
-
- - -
-
-
-

Business number path

-

Choose whether this business will forward its current number, port it later, or use a new CallbackCloser number.

-
-
- {businessPhonePathOptions.map((option, index) => ( -
+
+ + +
+
+ + +
+
+ + +
- + -
- -

CallbackCloser will auto-connect an existing owner account or send an invite if needed. Twilio account mode and number connection stay explicit on the next screen.

-
- - +
+ +

+ CallbackCloser will auto-connect an existing owner account or send an invite if needed. Twilio account mode and number connection stay + explicit on the next screen. +

+
+ +
+
+
+ +
- Demo / support tools - Keep demo traffic and founder troubleshooting inside the same operator flow. - - -
-
- - -
-
- - -
-
- - -
- -
+
+
+ + Demo / support tools + Keep demo traffic and founder troubleshooting available without crowding the triage board. + +
+
+
+ + +
+
+ + +
+
+ + +
+ +
-
-

What this board should answer immediately

-
    -
  • Which businesses are healthy
  • -
  • Which ones need intervention
  • -
  • What the next likely operator action is
  • -
+
+

What this board should answer immediately

+
    +
  • Which businesses are healthy
  • +
  • Which ones need intervention
  • +
  • What the next likely operator action is
  • +
+
+
+
-
+ +
{admin.isFounder ? ( - Founder reset - - Founder-only cleanup. Archive real customers. Hard delete test/demo businesses only, and only when the business is already archived. - - - -
- Delete one test/demo business is the primary founder cleanup tool. Real customer workspaces should be archived or disabled, not hard deleted. -
+
+
+ + Advanced founder tools + + Founder-only cleanup. Archive real customers. Hard delete test/demo businesses only, and only when the business is already archived. + + +
+
+ Delete one test/demo business is the primary founder cleanup tool. Real customer workspaces should be archived or disabled, not hard deleted. +
-
-
-

Delete one test/demo business

-

- Select a workspace, review whether it is test/demo or real, then type the exact business name before permanent deletion is allowed. -

-
- -
+
+
+

Delete one test/demo business

+

+ Select a workspace, review whether it is test/demo or real, then type the exact business name before permanent deletion is allowed. +

+
+ +
-
- Advanced founder reset: delete all current businesses -
-
-

- {founderResetBusinessCount} current {founderResetBusinessCount === 1 ? 'business' : 'businesses'} would be deleted -

-

- This permanently deletes every current business record, including archived ones. Keep this as a last-resort cleanup path only. -

- {founderResetBusinessPreview.length > 0 ? ( -

- Preview: {founderResetBusinessPreview.join(', ')} - {founderResetBusinessCount > founderResetBusinessPreview.length - ? ` and ${founderResetBusinessCount - founderResetBusinessPreview.length} more.` - : '.'} -

- ) : ( -

No businesses are currently present.

- )} -
+
+ Advanced founder reset: delete all current businesses +
+
+

+ {founderResetBusinessCount} current {founderResetBusinessCount === 1 ? 'business' : 'businesses'} would be deleted +

+

+ This permanently deletes every current business record, including archived ones. Keep this as a last-resort cleanup path only. +

+ {founderResetBusinessPreview.length > 0 ? ( +

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

+ ) : ( +

No businesses are currently present.

+ )} +
-
-
- - -
-

- This is irreversible. Deleting a business also removes its leads, calls, messages, owner notifications, business settings, operator events, - simulator runs, SMS consent records, and other business-owned data through the existing schema cascades. -

- -
-
-
- +
+
+ + +
+

+ This is irreversible. Deleting a business also removes its leads, calls, messages, owner notifications, business settings, operator + events, simulator runs, SMS consent records, and other business-owned data through the existing schema cascades. +

+ +
+
+
+
+
+
+
+
) : null} Business triage board - Compact rows with status, readiness, one clear attention signal, and the fastest next actions. + One primary state, one reason, one exact next action, and only the secondary badges that still matter.
@@ -811,11 +792,11 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor
{selectedBusinessRow ? ( - - - Selected business actions - - Focused from the business picker. Archive stays the normal lifecycle action. Permanent delete only unlocks for archived demo/test + + + Selected business actions + + Focused from the business picker. Archive stays the normal lifecycle action. Permanent delete only unlocks for archived demo/test workspaces. @@ -823,10 +804,12 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor

{selectedBusinessRow.business.name}

- {selectedBusinessRow.overallStatus.label} - {selectedBusinessRow.onboardingConfidence.stateLabel} - {selectedBusinessRow.business.isTestBusiness ? Test : null} - {isBusinessArchived(selectedBusinessRow.business) ? Archived : null} + {selectedBusinessRow.cardState.primaryLabel} + {selectedBusinessRow.cardState.badges.map((badge) => ( + + {badge.label} + + ))}
@@ -842,30 +825,26 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor

-

Go-live blocker

-

- {selectedBusinessRow.onboardingConfidence.blockers[0]?.message || selectedBusinessRow.onboardingConfidence.summary} -

+

Current reason

+

{selectedBusinessRow.cardState.primaryReason}

-

Test SMS

-
- - {selectedBusinessRow.testSmsTruth.label} - - {selectedBusinessRow.onboardingConfidence.readinessLabel} -
+

Next action

+

{selectedBusinessRow.cardState.nextActionLabel}

+ {selectedBusinessRow.cardState.nextActionLabel !== 'No action needed' ? ( + + {selectedBusinessRow.cardState.nextActionLabel} + + ) : null} Open business - {selectedBusinessRow.blockerStepHref ? ( - - Open blocker step - - ) : null} Open customer settings - + Open full advanced controls @@ -956,38 +938,31 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor business, ownerStatusLabel, ownerStatusVariant, - nextStep, leadCount, assignedNumber, lastActivityAt, lastIssue, - overallStatus, - a2pState, - attentionSignal, operatorSignals, - testSmsTruth, - missedCallValidation, - onboardingConfidence, canSendTestSms, - blockerStepHref, + cardState, }) => ( -
+
{business.name} - {overallStatus.label} - {business.provisioningStatus === BusinessProvisioningStatus.DRAFT ? Pending setup : null} - {business.provisioningStatus === BusinessProvisioningStatus.ONBOARDING ? In setup : null} - {onboardingConfidence.stateLabel} - {business.isTestBusiness ? Test : null} - {isBusinessArchived(business) ? Archived : null} + {cardState.primaryLabel} + {cardState.badges.map((badge) => ( + + {badge.label} + + ))}
-

Needs attention

-

- {onboardingConfidence.blockers[0]?.message || attentionSignal} +

Current reason

+

+ {cardState.primaryReason}

@@ -999,7 +974,7 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor
-
+

Owner

{business.ownerName || 'Owner name missing'}

{business.notificationSettings?.ownerEmail || 'Owner email missing'}

@@ -1009,57 +984,41 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor {formatPhoneForDisplay(business.notificationSettings?.ownerPhone || business.notifyPhone)} ) : null}
-
- -
-
-

Readiness

-

{onboardingConfidence.readinessLabel}

-

{onboardingConfidence.summary}

-
-
-

Messaging compliance

-
- {a2pState.label} -
-

Assigned number

{assignedNumber ? formatPhoneForDisplay(assignedNumber) : 'Not assigned yet'}

-
-

Next action

-

{onboardingConfidence.nextAction}

-

{nextStep.title}

-
- Test SMS {testSmsTruth.label} - {onboardingConfidence.readinessLabel} - {!missedCallValidation.countsAsLaunchProof ? Missed-call proof missing : null} - {onboardingConfidence.state === 'live_with_warnings' ? Live with warnings : null} - {lastIssue.state === 'issue' ? Needs attention : null} +
+
+

Next action

+

{cardState.nextActionLabel}

+

+ {cardState.nextActionLabel === 'No action needed' + ? 'This business has no active operator blockers right now.' + : 'Open the exact remediation step from the business control panel.'} +

{business.twilioWebhookSyncedAt ? (

Webhook sync {formatRelativeTime(business.twilioWebhookSyncedAt)}

- ) : ( + ) : !cardState.isArchived ? (

Webhook sync still needed

- )} - {missedCallValidation.verifiedAt ? ( -

Missed-call proof {formatRelativeTime(missedCallValidation.verifiedAt)}

) : null} - {lastIssue.createdAt ?

Latest issue recorded {formatRelativeTime(lastIssue.createdAt)}

: null} + {lastIssue.createdAt && !cardState.isArchived ? ( +

Latest issue recorded {formatRelativeTime(lastIssue.createdAt)}

+ ) : null}
+ {cardState.nextActionLabel !== 'No action needed' ? ( + + {cardState.nextActionLabel} + + ) : null} Open business - {blockerStepHref ? ( - - Open blocker step - - ) : null} Open customer workspace @@ -1072,16 +1031,7 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor View support workspace snapshot - {!isBusinessArchived(business) ? ( -
- - - -
- ) : null} - {assignedNumber ? ( + {assignedNumber && !cardState.isArchived ? (
@@ -1100,7 +1050,7 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor
) : null} - {isBusinessArchived(business) ? 'Restore / manage' : 'Archive / manage'} + {cardState.isArchived ? 'Restore business' : 'Archive business'}
diff --git a/lib/admin-dashboard.ts b/lib/admin-dashboard.ts index 21bf910..1dd2218 100644 --- a/lib/admin-dashboard.ts +++ b/lib/admin-dashboard.ts @@ -15,9 +15,11 @@ import { import type { TwilioWebhookSnapshot } from '@/lib/admin-provisioning-presenters'; import { DEMO_OWNER_CLERK_ID, isTestDemoBusiness } from '@/lib/admin-test-data-reset'; import type { AdminMissedCallValidationTruth } from '@/lib/admin-operator-proof'; +import type { AdminBusinessIssue, AdminTestSmsTruth } from '@/lib/admin-operator-visibility'; import { getBusinessPhoneSetupGate, getPublicBusinessPhone } from '@/lib/business-phone-setup'; -import { getManagedTextingNumber, getManagedTwilioStatusSummary } from '@/lib/managed-twilio-status'; +import { getManagedTextingNumber, getManagedTwilioStatusSummary, type ManagedTwilioSummary } from '@/lib/managed-twilio-status'; import { formatMessageStatus, isMessageDeliveryIssueStatus } from '@/lib/lead-presenters'; +import type { TwilioSetupStepKey } from '@/lib/twilio-setup'; type DashboardBusiness = Pick< Business, @@ -159,6 +161,46 @@ export type AdminOnboardingConfidence = { hasRecentFailures: boolean; }; +export type AdminBusinessPrimaryState = + | 'archived' + | 'paused' + | 'provisioning_failed' + | 'owner_blocked' + | 'compliance_pending' + | 'needs_attention' + | 'ready_for_test' + | 'ready_for_live' + | 'live_with_warnings' + | 'live' + | 'draft' + | 'in_setup'; + +export type AdminBusinessCardBadge = { + key: string; + label: string; + variant: 'success' | 'secondary' | 'outline' | 'destructive'; +}; + +export type AdminBusinessCardState = { + primaryState: AdminBusinessPrimaryState; + primaryLabel: string; + primaryVariant: 'success' | 'secondary' | 'outline' | 'destructive'; + primaryReason: string; + nextActionLabel: string; + nextActionStepKey: TwilioSetupStepKey | null; + badges: AdminBusinessCardBadge[]; + shouldAppearInNeedsAttention: boolean; + shouldAppearInPendingCompliance: boolean; + isArchived: boolean; + isLive: boolean; + isLiveWithWarnings: boolean; + isReadyForTest: boolean; + isReadyForLive: boolean; + isDraft: boolean; + isInSetup: boolean; + isPaused: boolean; +}; + export type AdminTestSmsConfidenceState = 'not_started' | 'pending_delivery' | 'delivered' | 'failed'; export const adminBoardFilterOptions: AdminBoardFilterOption[] = [ @@ -272,7 +314,7 @@ export function buildAdminNextStep(params: { title: 'Provisioning needs attention', detail: business.provisioningError, tone: 'attention', - actionLabel: hasTextingNumber ? 'Re-run provisioning' : 'Finish provisioning', + actionLabel: 'Fix provisioning', }; } @@ -294,7 +336,7 @@ export function buildAdminNextStep(params: { : 'The business is saved, but the owner account still needs a deliberate admin action. Use Invite owner by email or Connect existing owner.' : 'Add the owner email first, then choose Invite owner by email or Connect existing owner.', tone: 'attention', - actionLabel: 'Review owner setup', + actionLabel: 'Invite or connect owner', }; } @@ -306,7 +348,7 @@ export function buildAdminNextStep(params: { ? 'This business is set to use the main Twilio account directly. Confirm the parent-account mapping before continuing.' : 'Managed provisioning cannot finish until the business has a Twilio subaccount attached.', tone: 'attention', - actionLabel: 'Re-run provisioning', + actionLabel: 'Fix provisioning', }; } @@ -315,7 +357,7 @@ export function buildAdminNextStep(params: { title: 'No texting number is assigned', detail: 'Provision a new number or attach the approved existing number so missed-call SMS can start.', tone: 'attention', - actionLabel: 'Provision number', + actionLabel: 'Fix provisioning', }; } @@ -324,7 +366,7 @@ export function buildAdminNextStep(params: { title: 'Messaging service is missing', detail: 'The business has a number, but Twilio messaging still needs a Messaging Service for compliant delivery.', tone: 'attention', - actionLabel: 'Re-run provisioning', + actionLabel: 'Fix provisioning', }; } @@ -351,7 +393,7 @@ export function buildAdminNextStep(params: { title: 'Toll-free verification still needs recording', detail: 'Messaging is wired up, but toll-free verification still needs to be recorded before compliant live texting can launch.', tone: 'pending', - actionLabel: 'Review toll-free verification', + actionLabel: 'Open toll-free verification', }; } @@ -360,7 +402,7 @@ export function buildAdminNextStep(params: { title: 'Business verification still needed', detail: 'Messaging is wired up, but the A2P business details still need to be completed before compliant live texting can launch.', tone: 'pending', - actionLabel: 'Review A2P readiness', + actionLabel: 'Open A2P readiness', }; } @@ -396,7 +438,7 @@ export function buildAdminNextStep(params: { title: 'Compliance review needs attention', detail: business.a2pFailureReason || business.tollFreeVerificationNote || managedSummary.nextStep, tone: 'attention', - actionLabel: 'Review compliance notes', + actionLabel: managedSummary.complianceType === MessagingComplianceType.TOLL_FREE_VERIFICATION ? 'Open toll-free verification' : 'Fix compliance', }; } @@ -405,16 +447,16 @@ export function buildAdminNextStep(params: { title: 'Ready to go live', detail: 'Infrastructure and compliance look ready. Mark the business live when you want automation active.', tone: 'pending', - actionLabel: 'Mark live', + actionLabel: 'Mark business live', }; } if (managedSummary.messagingReady && business.provisioningStatus === BusinessProvisioningStatus.LIVE) { return { - title: 'Business is live and healthy', - detail: 'Twilio, webhooks, alerts, and rollout status all look ready for normal operator monitoring.', + title: 'Business is live', + detail: 'Core Twilio, webhook, alert, and rollout setup is active. Use the proof and issue signals to decide whether any follow-up is still needed.', tone: 'healthy', - actionLabel: 'Open support workspace', + actionLabel: 'No action needed', }; } @@ -693,7 +735,7 @@ export function buildAdminOnboardingConfidence(params: { stateVariant: 'outline', readinessLabel: 'Not ready', readinessVariant: 'outline', - nextAction: nextStep.title, + nextAction: nextStep.actionLabel, summary: 'Core onboarding details are still missing. Start with owner connection and Twilio setup.', }, in_setup: { @@ -701,7 +743,7 @@ export function buildAdminOnboardingConfidence(params: { stateVariant: 'secondary', readinessLabel: 'Not ready', readinessVariant: 'outline', - nextAction: nextStep.title, + nextAction: nextStep.actionLabel, summary: 'Onboarding is in progress, but at least one blocking setup step still needs attention.', }, needs_attention: { @@ -709,7 +751,7 @@ export function buildAdminOnboardingConfidence(params: { stateVariant: 'destructive', readinessLabel: 'Not ready', readinessVariant: 'destructive', - nextAction: nextStep.title, + nextAction: nextStep.actionLabel, summary: 'Something is broken or incomplete enough that the founder should stop and repair it before testing.', }, waiting_on_a2p: { @@ -731,7 +773,7 @@ export function buildAdminOnboardingConfidence(params: { nextAction: hasTestSmsSuccess ? 'Run a real missed-call test' : hasPendingTestSmsDelivery - ? 'Confirm test SMS delivery' + ? 'Send test SMS' : 'Send a test SMS', summary: 'The business looks ready for operator-led validation. Run the checks before you mark it live.', }, @@ -740,7 +782,7 @@ export function buildAdminOnboardingConfidence(params: { stateVariant: 'success', readinessLabel: 'Ready for live', readinessVariant: 'success', - nextAction: 'Mark this business live', + nextAction: 'Mark business live', summary: 'Setup, compliance, and operator validation checks are in place for a clean launch decision.', }, live: { @@ -748,7 +790,7 @@ export function buildAdminOnboardingConfidence(params: { stateVariant: 'success', readinessLabel: 'Ready for live', readinessVariant: 'success', - nextAction: 'Monitor normal activity', + nextAction: 'No action needed', summary: 'This business is live and the operator confidence checks are green.', }, live_with_warnings: { @@ -756,7 +798,7 @@ export function buildAdminOnboardingConfidence(params: { stateVariant: 'destructive', readinessLabel: 'Live with warnings', readinessVariant: 'destructive', - nextAction: nextStep.title, + nextAction: nextStep.actionLabel, summary: 'The business is live, but recent failures or missing validations mean the founder should review it closely.', }, archived: { @@ -781,49 +823,459 @@ export function buildAdminOnboardingConfidence(params: { } satisfies AdminOnboardingConfidence; } -export function matchesAdminBoardFilter( - business: DashboardBusiness, - notificationSettings: DashboardNotificationSettings | null, - ownerConnected: boolean, - filter: AdminBoardFilter +function buildSecondaryBadge( + key: string, + label: string, + variant: AdminBusinessCardBadge['variant'] ) { - if (filter === 'all') return true; + return { key, label, variant } satisfies AdminBusinessCardBadge; +} - const managedSummary = getManagedTwilioStatusSummary(business); - const nextStep = buildAdminNextStep({ business, notificationSettings, ownerConnected }); - const archived = isBusinessArchived(business); - const paused = isBusinessAutomationPaused(business) && !archived; +function dedupeAdminBusinessCardBadges(badges: AdminBusinessCardBadge[], limit = 3) { + const seen = new Set(); + const deduped: AdminBusinessCardBadge[] = []; + + for (const badge of badges) { + const signature = `${badge.key}:${badge.label}`; + if (seen.has(signature)) continue; + seen.add(signature); + deduped.push(badge); + if (deduped.length >= limit) break; + } + + return deduped; +} - if (filter === 'archived') return archived; - if (archived) return false; +function getTestSmsBadge(testSmsTruth: AdminTestSmsTruth) { + if (testSmsTruth.state === 'delivered') { + return buildSecondaryBadge('test_sms', 'Test SMS Delivered', 'success'); + } + + if (testSmsTruth.state === 'failed') { + return buildSecondaryBadge('test_sms', 'Test SMS Failed', 'destructive'); + } + + if (testSmsTruth.state === 'pending') { + return buildSecondaryBadge('test_sms', 'Test SMS Pending', 'outline'); + } + + return buildSecondaryBadge('test_sms', 'Test SMS Not Run', 'outline'); +} + +function getComplianceBadge(params: { + managedSummary: ManagedTwilioSummary; + business: Pick; +}) { + if (params.managedSummary.complianceTypeUnknown) { + return buildSecondaryBadge('compliance', 'Compliance Not Selected', 'outline'); + } + + if (params.managedSummary.attentionRequired) { + return buildSecondaryBadge('compliance', 'Compliance Needs Attention', 'destructive'); + } + + if ( + params.managedSummary.compliancePendingReview || + (params.managedSummary.complianceType === MessagingComplianceType.TOLL_FREE_VERIFICATION && !params.managedSummary.complianceStarted) || + (params.managedSummary.complianceType === MessagingComplianceType.LOCAL_A2P && + params.business.managedTwilioStatus === ManagedTwilioStatus.AWAITING_BUSINESS_VERIFICATION) + ) { + return buildSecondaryBadge('compliance', 'Compliance Pending', 'outline'); + } - if (filter === 'paused') { - return paused || business.managedTwilioStatus === ManagedTwilioStatus.PAUSED_NONCOMPLIANT; + return null; +} + +function getProvisioningRepairStepKey( + business: DashboardBusiness, + managedSummary: ManagedTwilioSummary +): TwilioSetupStepKey { + if (!managedSummary.accountReady) return 'account_ready'; + if (!getManagedTextingNumber(business) || !(business.twilioPrimaryNumberSid || business.twilioPhoneNumberSid)) return 'number_assigned'; + if (!business.twilioMessagingServiceSid) return 'messaging_service_ready'; + if (!business.twilioWebhookSyncedAt) return 'voice_webhook_synced'; + return 'account_ready'; +} + +function getAttentionReasonAndStep(params: { + business: DashboardBusiness; + managedSummary: ManagedTwilioSummary; + nextStep: AdminNextStep; + onboardingConfidence: AdminOnboardingConfidence; + lastIssue: AdminBusinessIssue; + testSmsTruth: AdminTestSmsTruth; + missedCallValidation: AdminMissedCallValidationTruth; +}) { + const { business, managedSummary, nextStep, onboardingConfidence, lastIssue, testSmsTruth, missedCallValidation } = params; + const hasAssignedNumber = Boolean(getManagedTextingNumber(business) && (business.twilioPrimaryNumberSid || business.twilioPhoneNumberSid)); + + if (lastIssue.eventType === 'webhooks.sync_failed' || (hasAssignedNumber && !business.twilioWebhookSyncedAt)) { + return { + primaryState: 'needs_attention' as const, + primaryLabel: 'Needs attention', + primaryVariant: 'destructive' as const, + primaryReason: 'Twilio webhooks are not synced to the current CallbackCloser URLs.', + nextActionLabel: 'Re-sync webhooks', + nextActionStepKey: 'voice_webhook_synced' as const, + }; } - if (filter === 'live') { - return business.provisioningStatus === BusinessProvisioningStatus.LIVE && managedSummary.messagingReady; + if (testSmsTruth.state === 'failed') { + return { + primaryState: 'needs_attention' as const, + primaryLabel: 'Needs attention', + primaryVariant: 'destructive' as const, + primaryReason: 'The latest test SMS did not complete cleanly.', + nextActionLabel: 'Send test SMS', + nextActionStepKey: 'test_sms_delivered' as const, + }; } - if (filter === 'pending_a2p') { - return managedSummary.compliancePendingReview; + if (onboardingConfidence.readyForTest && testSmsTruth.state !== 'delivered') { + return { + primaryState: 'ready_for_test' as const, + primaryLabel: 'Ready for test', + primaryVariant: 'secondary' as const, + primaryReason: + testSmsTruth.state === 'pending' + ? 'The latest test SMS is still waiting on delivery confirmation.' + : 'A delivered test SMS is still missing.', + nextActionLabel: 'Send test SMS', + nextActionStepKey: 'test_sms_delivered' as const, + }; } - if (filter === 'pending_setup') { - return business.provisioningStatus === BusinessProvisioningStatus.DRAFT; + if (onboardingConfidence.readyForTest && !missedCallValidation.countsAsLaunchProof) { + return { + primaryState: 'ready_for_test' as const, + primaryLabel: 'Ready for test', + primaryVariant: 'secondary' as const, + primaryReason: 'The missed-call flow has not been fully validated yet.', + nextActionLabel: 'Validate missed-call flow', + nextActionStepKey: 'missed_call_validated' as const, + }; } - if (filter === 'in_setup') { - return business.provisioningStatus === BusinessProvisioningStatus.ONBOARDING || (!managedSummary.onboardingReady && ownerConnected); + return { + primaryState: nextStep.tone === 'attention' ? ('needs_attention' as const) : ('in_setup' as const), + primaryLabel: nextStep.tone === 'attention' ? 'Needs attention' : 'In setup', + primaryVariant: nextStep.tone === 'attention' ? ('destructive' as const) : ('secondary' as const), + primaryReason: nextStep.detail, + nextActionLabel: nextStep.actionLabel || 'Open blocker step', + nextActionStepKey: lastIssue.remediationStepKey, + }; +} + +export function buildAdminBusinessCardState(params: { + business: DashboardBusiness; + notificationSettings: DashboardNotificationSettings | null; + ownerConnected: boolean; + nextStep: AdminNextStep; + managedSummary: ManagedTwilioSummary; + onboardingConfidence: AdminOnboardingConfidence; + lastIssue: AdminBusinessIssue; + testSmsTruth: AdminTestSmsTruth; + missedCallValidation: AdminMissedCallValidationTruth; +}) { + const { business, notificationSettings, ownerConnected, nextStep, managedSummary, onboardingConfidence, lastIssue, testSmsTruth, missedCallValidation } = + params; + const archived = isBusinessArchived(business); + const paused = !archived && business.provisioningStatus === BusinessProvisioningStatus.PAUSED; + const ownerEmail = notificationSettings?.ownerEmail?.trim() || null; + const ownerPhone = notificationSettings?.ownerPhone?.trim() || business.notifyPhone || null; + const missingOwnerContact = !ownerEmail && !ownerPhone; + const needsOwnerConnection = !ownerConnected; + const complianceNeedsOperatorAction = + managedSummary.attentionRequired || + managedSummary.complianceTypeUnknown || + (managedSummary.complianceType === MessagingComplianceType.TOLL_FREE_VERIFICATION && !managedSummary.complianceStarted) || + (managedSummary.complianceType === MessagingComplianceType.LOCAL_A2P && + business.managedTwilioStatus === ManagedTwilioStatus.AWAITING_BUSINESS_VERIFICATION); + const compliancePendingReview = managedSummary.compliancePendingReview; + + let state: Pick< + AdminBusinessCardState, + 'primaryState' | 'primaryLabel' | 'primaryVariant' | 'primaryReason' | 'nextActionLabel' | 'nextActionStepKey' + >; + + if (archived) { + state = { + primaryState: 'archived', + primaryLabel: 'Archived', + primaryVariant: 'outline', + primaryReason: 'Automation is paused because this business is archived.', + nextActionLabel: 'Restore business', + nextActionStepKey: null, + }; + } else if (paused) { + state = { + primaryState: 'paused', + primaryLabel: 'Paused', + primaryVariant: 'outline', + primaryReason: 'Automation is paused until you intentionally resume this business.', + nextActionLabel: 'Resume automation', + nextActionStepKey: 'safe_to_mark_live', + }; + } else if (business.provisioningError || business.provisioningStatus === BusinessProvisioningStatus.NEEDS_ATTENTION) { + state = { + primaryState: 'provisioning_failed', + primaryLabel: 'Provisioning failed', + primaryVariant: 'destructive', + primaryReason: business.provisioningError || 'The latest provisioning run needs operator repair before this business can move forward.', + nextActionLabel: 'Fix provisioning', + nextActionStepKey: getProvisioningRepairStepKey(business, managedSummary), + }; + } else if (missingOwnerContact) { + state = { + primaryState: 'owner_blocked', + primaryLabel: 'Owner blocked', + primaryVariant: 'destructive', + primaryReason: 'Owner contact information is missing, so invites and alerts cannot be trusted yet.', + nextActionLabel: 'Save owner contact info', + nextActionStepKey: 'owner_connected', + }; + } else if (needsOwnerConnection) { + state = { + primaryState: 'owner_blocked', + primaryLabel: 'Owner blocked', + primaryVariant: 'destructive', + primaryReason: business.ownerInviteSentAt + ? 'The owner account is still not connected even though an invite was already sent.' + : 'The owner account still needs to be connected before this business can be trusted.', + nextActionLabel: 'Invite or connect owner', + nextActionStepKey: 'owner_connected', + }; + } else if (business.provisioningStatus === BusinessProvisioningStatus.LIVE && onboardingConfidence.blockers.length > 0) { + state = testSmsTruth.state !== 'delivered' + ? { + primaryState: 'live_with_warnings', + primaryLabel: 'Live with warnings', + primaryVariant: 'destructive', + primaryReason: + testSmsTruth.state === 'pending' + ? 'The latest test SMS is still waiting on delivery confirmation.' + : 'The latest test SMS is not fully proven.', + nextActionLabel: 'Send test SMS', + nextActionStepKey: 'test_sms_delivered', + } + : !missedCallValidation.countsAsLaunchProof + ? { + primaryState: 'live_with_warnings', + primaryLabel: 'Live with warnings', + primaryVariant: 'destructive', + primaryReason: 'Missed-call flow has not been fully validated.', + nextActionLabel: 'Validate missed-call flow', + nextActionStepKey: 'missed_call_validated', + } + : { + primaryState: 'live_with_warnings', + primaryLabel: 'Live with warnings', + primaryVariant: 'destructive', + primaryReason: onboardingConfidence.blockers[0]?.message || 'This live business still has unresolved operator warnings.', + nextActionLabel: nextStep.actionLabel || 'Open blocker step', + nextActionStepKey: lastIssue.remediationStepKey, + }; + } else if (business.provisioningStatus === BusinessProvisioningStatus.LIVE) { + state = { + primaryState: 'live', + primaryLabel: 'Live', + primaryVariant: 'success', + primaryReason: 'No active warnings are recorded for this live business.', + nextActionLabel: 'No action needed', + nextActionStepKey: null, + }; + } else if (complianceNeedsOperatorAction || compliancePendingReview) { + state = { + primaryState: 'compliance_pending', + primaryLabel: managedSummary.attentionRequired ? 'Compliance blocked' : 'Pending compliance', + primaryVariant: managedSummary.attentionRequired ? 'destructive' : 'outline', + primaryReason: managedSummary.attentionRequired + ? business.a2pFailureReason || business.tollFreeVerificationNote || managedSummary.nextStep + : managedSummary.complianceTypeUnknown + ? 'Messaging compliance type has not been selected yet.' + : managedSummary.complianceType === MessagingComplianceType.TOLL_FREE_VERIFICATION && !managedSummary.complianceStarted + ? 'Toll-free verification has not been recorded yet.' + : managedSummary.nextStep, + nextActionLabel: managedSummary.complianceTypeUnknown + ? 'Choose number type' + : managedSummary.complianceType === MessagingComplianceType.TOLL_FREE_VERIFICATION + ? 'Open toll-free verification' + : managedSummary.attentionRequired + ? 'Fix compliance' + : 'Wait for approval', + nextActionStepKey: 'a2p_status_recorded', + }; + } else if (onboardingConfidence.readyForLive) { + state = { + primaryState: 'ready_for_live', + primaryLabel: 'Ready for live', + primaryVariant: 'success', + primaryReason: 'Launch proof is in place and automation can go live safely.', + nextActionLabel: 'Mark business live', + nextActionStepKey: 'safe_to_mark_live', + }; + } else if (onboardingConfidence.readyForTest) { + state = getAttentionReasonAndStep({ + business, + managedSummary, + nextStep, + onboardingConfidence, + lastIssue, + testSmsTruth, + missedCallValidation, + }); + } else if (onboardingConfidence.state === 'draft') { + state = { + primaryState: 'draft', + primaryLabel: 'Draft', + primaryVariant: 'outline', + primaryReason: onboardingConfidence.summary, + nextActionLabel: nextStep.actionLabel, + nextActionStepKey: lastIssue.remediationStepKey, + }; + } else { + state = getAttentionReasonAndStep({ + business, + managedSummary, + nextStep, + onboardingConfidence, + lastIssue, + testSmsTruth, + missedCallValidation, + }); } - if (filter === 'needs_attention') { - return nextStep.tone === 'attention'; + const badges: AdminBusinessCardBadge[] = []; + badges.push(buildSecondaryBadge('customer_type', business.isTestBusiness ? 'Test' : 'Real customer', 'outline')); + + if (!archived) { + if ( + state.primaryState === 'ready_for_test' || + state.primaryState === 'ready_for_live' || + state.primaryState === 'live_with_warnings' || + state.primaryState === 'live' || + state.primaryState === 'needs_attention' + ) { + badges.push(getTestSmsBadge(testSmsTruth)); + } + + const complianceBadge = getComplianceBadge({ managedSummary, business }); + if (complianceBadge) { + badges.push(complianceBadge); + } + + if ( + !missedCallValidation.countsAsLaunchProof && + (state.primaryState === 'ready_for_test' || + state.primaryState === 'ready_for_live' || + state.primaryState === 'live_with_warnings' || + state.primaryState === 'live') + ) { + badges.push(buildSecondaryBadge('missed_call_proof', 'Missed-call proof missing', 'outline')); + } } + return { + ...state, + badges: dedupeAdminBusinessCardBadges(badges), + shouldAppearInNeedsAttention: + !archived && + (state.primaryState === 'provisioning_failed' || + state.primaryState === 'owner_blocked' || + state.primaryState === 'needs_attention' || + state.primaryState === 'live_with_warnings'), + shouldAppearInPendingCompliance: !archived && state.primaryState === 'compliance_pending', + isArchived: archived, + isLive: state.primaryState === 'live', + isLiveWithWarnings: state.primaryState === 'live_with_warnings', + isReadyForTest: state.primaryState === 'ready_for_test', + isReadyForLive: state.primaryState === 'ready_for_live', + isDraft: state.primaryState === 'draft', + isInSetup: + state.primaryState === 'draft' || + state.primaryState === 'in_setup' || + state.primaryState === 'ready_for_test' || + state.primaryState === 'ready_for_live', + isPaused: state.primaryState === 'paused', + } satisfies AdminBusinessCardState; +} + +export function matchesAdminBoardFilterState(state: AdminBusinessCardState, filter: AdminBoardFilter) { + if (filter === 'all') return true; + if (filter === 'archived') return state.isArchived; + if (state.isArchived) return false; + + if (filter === 'paused') return state.isPaused; + if (filter === 'live') return state.isLive; + if (filter === 'pending_a2p') return state.shouldAppearInPendingCompliance; + if (filter === 'pending_setup') return state.isDraft; + if (filter === 'in_setup') return state.isInSetup; + if (filter === 'needs_attention') return state.shouldAppearInNeedsAttention; + return true; } +export function matchesAdminBoardFilter( + business: DashboardBusiness, + notificationSettings: DashboardNotificationSettings | null, + ownerConnected: boolean, + filter: AdminBoardFilter +) { + const managedSummary = getManagedTwilioStatusSummary(business); + const nextStep = buildAdminNextStep({ business, notificationSettings, ownerConnected }); + const onboardingConfidence = buildAdminOnboardingConfidence({ + business, + notificationSettings, + ownerConnected, + successfulLeadCount: 0, + operatorEvents: [], + }); + const state = buildAdminBusinessCardState({ + business, + notificationSettings, + ownerConnected, + nextStep, + managedSummary, + onboardingConfidence, + lastIssue: { + state: 'healthy', + tone: 'neutral', + summary: 'No open issues recorded', + detail: 'Recent business events do not show an unresolved failure or blocker right now.', + createdAt: null, + categoryLabel: null, + statusLabel: null, + eventType: null, + remediationStepKey: null, + }, + testSmsTruth: { + state: 'not_run', + label: 'Not run', + tone: 'neutral', + summary: 'No admin test SMS recorded', + detail: 'Run a test SMS from this page before treating delivery as proven.', + reason: null, + lastAttemptAt: null, + eventType: null, + }, + missedCallValidation: { + state: 'not_run', + label: 'Not run', + tone: 'neutral', + summary: 'Missed-call flow not validated yet', + detail: 'Run one real missed call from start to finish, or record a manual confirmation note if you validated it outside this console.', + verifiedAt: null, + sourceLabel: null, + evidenceSummary: null, + relatedLeadId: null, + latestRelatedEventAt: null, + countsAsLaunchProof: false, + }, + }); + + return matchesAdminBoardFilterState(state, filter); +} + function compactBody(value: string, maxLength = 120) { const trimmed = value.trim(); if (!trimmed) return 'No extra detail recorded.'; diff --git a/tests/admin-dashboard.test.ts b/tests/admin-dashboard.test.ts index bc7d50a..29de24b 100644 --- a/tests/admin-dashboard.test.ts +++ b/tests/admin-dashboard.test.ts @@ -14,6 +14,7 @@ import { } from '@prisma/client'; import { + buildAdminBusinessCardState, buildAdminBusinessPickerLabel, buildAdminOnboardingConfidence, buildAdminBusinessEvents, @@ -22,7 +23,9 @@ import { getAdminTestSmsConfidenceState, getDeleteTestBusinessBlockedReason, matchesAdminBoardFilter, + matchesAdminBoardFilterState, } from '../lib/admin-dashboard.ts'; +import { getManagedTwilioStatusSummary } from '../lib/managed-twilio-status.ts'; function createBusiness(overrides: Record = {}) { return { @@ -77,6 +80,96 @@ function createNotificationSettings(overrides: Record = {}) { }; } +function createLastIssue(overrides: Record = {}) { + return { + state: 'healthy' as const, + tone: 'neutral' as const, + summary: 'No open issues recorded', + detail: 'Recent business events do not show an unresolved failure or blocker right now.', + createdAt: null, + categoryLabel: null, + statusLabel: null, + eventType: null, + remediationStepKey: null, + ...overrides, + }; +} + +function createTestSmsTruth(overrides: Record = {}) { + return { + state: 'not_run' as const, + label: 'Not run', + tone: 'neutral' as const, + summary: 'No admin test SMS recorded', + detail: 'Run a test SMS from this page before treating delivery as proven.', + reason: null, + lastAttemptAt: null, + eventType: null, + ...overrides, + }; +} + +function createMissedCallValidation(overrides: Record = {}) { + return { + state: 'not_run' as const, + label: 'Not run', + tone: 'neutral' as const, + summary: 'Missed-call flow not validated yet', + detail: 'Run one real missed call from start to finish, or record a manual confirmation note if you validated it outside this console.', + verifiedAt: null, + sourceLabel: null, + evidenceSummary: null, + relatedLeadId: null, + latestRelatedEventAt: null, + countsAsLaunchProof: false, + ...overrides, + }; +} + +function createCardState(params: { + business?: Record; + notificationSettings?: Record; + ownerConnected?: boolean; + successfulLeadCount?: number; + operatorEvents?: Array<{ type: string; status: OperatorEventStatus; createdAt: Date }>; + missedCallValidation?: Record; + lastIssue?: Record; +}) { + const business = createBusiness(params.business); + const notificationSettings = createNotificationSettings(params.notificationSettings); + const nextStep = buildAdminNextStep({ + business, + notificationSettings, + ownerConnected: params.ownerConnected ?? true, + }); + const onboardingConfidence = buildAdminOnboardingConfidence({ + business, + notificationSettings, + ownerConnected: params.ownerConnected ?? true, + successfulLeadCount: params.successfulLeadCount ?? 0, + operatorEvents: params.operatorEvents || [], + missedCallValidation: params.missedCallValidation ? createMissedCallValidation(params.missedCallValidation) : undefined, + }); + + return buildAdminBusinessCardState({ + business, + notificationSettings, + ownerConnected: params.ownerConnected ?? true, + nextStep, + managedSummary: getManagedTwilioStatusSummary(business), + onboardingConfidence, + lastIssue: createLastIssue(params.lastIssue), + testSmsTruth: createTestSmsTruth( + params.operatorEvents?.some((event) => event.type === 'admin.test_sms_delivered') + ? { state: 'delivered', label: 'Delivered', tone: 'success', summary: 'Test SMS delivered', detail: 'Delivery confirmed.' } + : params.operatorEvents?.some((event) => event.type === 'admin.test_sms_failed' || event.type === 'admin.test_sms_delivery_failed') + ? { state: 'failed', label: 'Failed', tone: 'attention', summary: 'Test SMS failed', detail: 'Delivery failed.' } + : {} + ), + missedCallValidation: createMissedCallValidation(params.missedCallValidation), + }); +} + test('next-step guidance calls out webhook recovery and live health clearly', () => { const webhookMissing = buildAdminNextStep({ business: createBusiness({ twilioWebhookSyncedAt: null, managedTwilioStatus: ManagedTwilioStatus.AWAITING_BUSINESS_VERIFICATION }), @@ -97,8 +190,9 @@ test('next-step guidance calls out webhook recovery and live health clearly', () ownerConnected: true, }); - assert.equal(healthy.title, 'Business is live and healthy'); + assert.equal(healthy.title, 'Business is live'); assert.equal(healthy.tone, 'healthy'); + assert.equal(healthy.actionLabel, 'No action needed'); }); test('next-step guidance stays explicit for owner setup and pending A2P review', () => { @@ -139,7 +233,7 @@ test('next-step guidance stays explicit for owner setup and pending A2P review', }); assert.equal(invitedOwner.title, 'Owner invitation is still pending'); - assert.equal(invitedOwner.actionLabel, 'Review owner setup'); + assert.equal(invitedOwner.actionLabel, 'Invite or connect owner'); const pendingA2p = buildAdminNextStep({ business: createBusiness({ @@ -218,6 +312,167 @@ test('board filters and delete guard stay conservative', () => { ); }); +test('business card state turns missing live proof into live-with-warnings instead of healthy', () => { + const state = createCardState({ + business: { + provisioningStatus: BusinessProvisioningStatus.LIVE, + managedTwilioStatus: ManagedTwilioStatus.COMPLIANT_LIVE, + a2pApprovedAt: new Date('2026-04-17T00:00:00.000Z'), + }, + successfulLeadCount: 0, + operatorEvents: [ + { + type: 'admin.test_sms_delivered', + status: OperatorEventStatus.SUCCESS, + createdAt: new Date('2026-04-17T12:00:00.000Z'), + }, + ], + }); + + assert.equal(state.primaryState, 'live_with_warnings'); + assert.equal(state.primaryLabel, 'Live with warnings'); + assert.equal(state.primaryReason, 'Missed-call flow has not been fully validated.'); + assert.equal(state.nextActionLabel, 'Validate missed-call flow'); + assert.equal(state.badges.some((badge) => badge.label === 'Missed-call proof missing'), true); +}); + +test('business card state keeps a clean live business decisive', () => { + const state = createCardState({ + business: { + provisioningStatus: BusinessProvisioningStatus.LIVE, + managedTwilioStatus: ManagedTwilioStatus.COMPLIANT_LIVE, + a2pApprovedAt: new Date('2026-04-17T00:00:00.000Z'), + }, + successfulLeadCount: 1, + operatorEvents: [ + { + type: 'admin.test_sms_delivered', + status: OperatorEventStatus.SUCCESS, + createdAt: new Date('2026-04-17T12:00:00.000Z'), + }, + ], + missedCallValidation: { + countsAsLaunchProof: true, + detail: 'Recent event sequence proves the missed-call flow reached the owner alert path.', + }, + }); + + assert.equal(state.primaryState, 'live'); + assert.equal(state.nextActionLabel, 'No action needed'); +}); + +test('archived businesses stay out of active needs-attention flow and point to restore', () => { + const state = createCardState({ + business: { + archivedAt: new Date('2026-04-16T00:00:00.000Z'), + provisioningStatus: BusinessProvisioningStatus.LIVE, + }, + operatorEvents: [ + { + type: 'admin.test_sms_failed', + status: OperatorEventStatus.FAILED, + createdAt: new Date('2026-04-17T12:00:00.000Z'), + }, + ], + }); + + assert.equal(state.primaryState, 'archived'); + assert.equal(state.nextActionLabel, 'Restore business'); + assert.equal(state.shouldAppearInNeedsAttention, false); + assert.equal(matchesAdminBoardFilterState(state, 'needs_attention'), false); + assert.equal(matchesAdminBoardFilterState(state, 'archived'), true); +}); + +test('business card state uses specific next actions for provisioning and test SMS failures', () => { + const provisioningFailed = createCardState({ + business: { + provisioningError: 'Messaging service attach failed.', + }, + }); + assert.equal(provisioningFailed.primaryState, 'provisioning_failed'); + assert.equal(provisioningFailed.nextActionLabel, 'Fix provisioning'); + + const smsFailed = createCardState({ + business: { + managedTwilioStatus: ManagedTwilioStatus.COMPLIANT_LIVE, + a2pApprovedAt: new Date('2026-04-17T00:00:00.000Z'), + }, + operatorEvents: [ + { + type: 'admin.test_sms_failed', + status: OperatorEventStatus.FAILED, + createdAt: new Date('2026-04-17T12:00:00.000Z'), + }, + ], + }); + assert.equal(smsFailed.nextActionLabel, 'Send test SMS'); +}); + +test('business card badges stay deduped and capped', () => { + const state = createCardState({ + business: { + provisioningStatus: BusinessProvisioningStatus.LIVE, + managedTwilioStatus: ManagedTwilioStatus.COMPLIANT_LIVE, + a2pApprovedAt: new Date('2026-04-17T00:00:00.000Z'), + }, + operatorEvents: [ + { + type: 'admin.test_sms_delivered', + status: OperatorEventStatus.SUCCESS, + createdAt: new Date('2026-04-17T12:00:00.000Z'), + }, + ], + }); + + const labels = state.badges.map((badge) => badge.label); + assert.equal(new Set(labels).size, labels.length); + assert.equal(labels.length <= 3, true); +}); + +test('state-based filters use the same card model as the board counts', () => { + const states = [ + createCardState({ + business: { + archivedAt: new Date('2026-04-16T00:00:00.000Z'), + }, + }), + createCardState({ + business: { + provisioningError: 'Messaging service attach failed.', + }, + }), + createCardState({ + business: { + managedTwilioStatus: ManagedTwilioStatus.CAMPAIGN_SUBMITTED, + }, + }), + createCardState({ + business: { + provisioningStatus: BusinessProvisioningStatus.LIVE, + managedTwilioStatus: ManagedTwilioStatus.COMPLIANT_LIVE, + a2pApprovedAt: new Date('2026-04-17T00:00:00.000Z'), + }, + successfulLeadCount: 1, + operatorEvents: [ + { + type: 'admin.test_sms_delivered', + status: OperatorEventStatus.SUCCESS, + createdAt: new Date('2026-04-17T12:00:00.000Z'), + }, + ], + missedCallValidation: { + countsAsLaunchProof: true, + detail: 'Recent event sequence proves the missed-call flow reached the owner alert path.', + }, + }), + ]; + + assert.equal(states.filter((state) => matchesAdminBoardFilterState(state, 'needs_attention')).length, 1); + assert.equal(states.filter((state) => matchesAdminBoardFilterState(state, 'pending_a2p')).length, 1); + assert.equal(states.filter((state) => matchesAdminBoardFilterState(state, 'archived')).length, 1); + assert.equal(states.filter((state) => matchesAdminBoardFilterState(state, 'live')).length, 1); +}); + test('business picker labels keep the name primary and add a fast secondary identifier', () => { const labelWithEmail = buildAdminBusinessPickerLabel({ business: createBusiness({ diff --git a/tests/admin-operator-routes.test.ts b/tests/admin-operator-routes.test.ts index 51adb16..48297b4 100644 --- a/tests/admin-operator-routes.test.ts +++ b/tests/admin-operator-routes.test.ts @@ -18,17 +18,17 @@ test('admin routes expose support workspace and safe lifecycle controls', () => const appLayout = read('app/app/layout.tsx'); assert.match(adminHome, /Operator control panel/); - assert.match(adminHome, /Fast onboard/); + assert.match(adminHome, /Create new business/); assert.match(adminHome, /Create business workspace/); - assert.match(adminHome, /Founder reset/); + assert.match(adminHome, /Advanced founder tools/); assert.match(adminHome, /Delete one test\/demo business/); assert.match(adminHome, /Advanced founder reset: delete all current businesses/); assert.match(founderDeleteCard, /Delete this business/); assert.match(founderDeleteCard, /Type the exact business name/); assert.match(adminHome, /FOUNDER_DELETE_ALL_BUSINESSES_CONFIRMATION/); assert.match(adminHome, /Business triage board/); - assert.match(adminHome, /Go-live blocker/); - assert.match(adminHome, /Test SMS/); + assert.match(adminHome, /Current reason/); + assert.match(adminHome, /Next action/); assert.match(adminHome, /Open customer workspace/); assert.match(adminHome, /Delete demo\/test business permanently/); assert.match(adminHome, /Type business name to permanently delete/); @@ -74,7 +74,7 @@ test('admin routes expose support workspace and safe lifecycle controls', () => assert.match(adminDetail, /View support workspace snapshot/); assert.match(adminDetail, /canDeleteTestBusiness\(business\)/); assert.match(adminDetail, /getDeleteTestBusinessBlockedReason\(business\)/); - assert.match(adminHome, /Open blocker step/); + assert.match(adminHome, /Restore business/); assert.match(activityTimeline, /Recent activity/); assert.match(activityTimeline, /Show more activity/); assert.match(activityTimeline, /Collapse activity/); diff --git a/tests/managed-customer-setup.test.ts b/tests/managed-customer-setup.test.ts index ea8cc01..62e908e 100644 --- a/tests/managed-customer-setup.test.ts +++ b/tests/managed-customer-setup.test.ts @@ -44,8 +44,8 @@ test('managed setup handoff is wired through customer and admin surfaces', () => assert.match(auth, /getOrCreateOwnedBusinessForClerkUser/); assert.match(appLayout, /CustomerSetupWaitingPage/); assert.match(appLayout, /getCustomerWorkspaceNotice/); - assert.match(adminHome, /Pending setup/); - assert.match(adminHome, /In setup/); + assert.match(adminHome, /Create new business/); + assert.match(adminHome, /Business triage board/); assert.match(adminHome, /New public pilot signups land here waiting for founder setup/i); assert.match(adminDetail, /This business is waiting for founder setup/); assert.match(adminActions, /await sendCustomerReadyNotification\(business\.id\)/);