From d86598f3a37a200b31095920e5c1e8e77bc0fdd9 Mon Sep 17 00:00:00 2001 From: DevCalebR Date: Fri, 29 May 2026 17:47:21 -0400 Subject: [PATCH] Add founder-safe permanent business delete --- app/admin/[businessId]/page.tsx | 76 ++++---- app/admin/actions.ts | 86 +++++++++ app/admin/page.tsx | 70 ++++---- .../admin-permanent-delete-business-card.tsx | 103 +++++++++++ components/founder-delete-business-card.tsx | 58 +++--- lib/admin-business-delete.ts | 48 +++++ lib/admin-business-lifecycle.ts | 52 +++++- lib/validators.ts | 1 + tests/admin-auth.test.ts | 4 +- tests/admin-bulk-delete-action.test.ts | 25 ++- tests/admin-business-lifecycle.test.ts | 168 +++++++++++++++--- tests/admin-operator-routes.test.ts | 17 +- tests/founder-delete-business-card.test.ts | 11 +- 13 files changed, 589 insertions(+), 130 deletions(-) create mode 100644 components/admin-permanent-delete-business-card.tsx create mode 100644 lib/admin-business-delete.ts diff --git a/app/admin/[businessId]/page.tsx b/app/admin/[businessId]/page.tsx index 14ef98b..5f59d57 100644 --- a/app/admin/[businessId]/page.tsx +++ b/app/admin/[businessId]/page.tsx @@ -8,7 +8,7 @@ import { connectExistingBusinessOwnerAction, createBusinessMessagingServiceAction, createBusinessTwilioSubaccountAction, - deleteTestBusinessAction, + deleteBusinessPermanentlyAction, inviteBusinessOwnerAction, markBusinessLiveAction, provisionBusinessAction, @@ -20,6 +20,7 @@ import { setBusinessProvisioningStatusAction, } from '@/app/admin/actions'; import { AdminBusinessActivityTimeline } from '@/components/admin-business-activity-timeline'; +import { AdminPermanentDeleteBusinessCard } from '@/components/admin-permanent-delete-business-card'; import { AdminBusinessSetupStepCard } from '@/components/admin-business-setup-step-card'; import { MessagingComplianceFields } from '@/components/messaging-compliance-fields'; import { Badge } from '@/components/ui/badge'; @@ -30,7 +31,7 @@ import { Label } from '@/components/ui/label'; import { Select } from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; import { buildAdminCustomerOpenHref } from '@/lib/admin-customer-paths'; -import { buildAdminOnboardingConfidence, canDeleteTestBusiness, getDeleteTestBusinessBlockedReason, isBusinessArchived } from '@/lib/admin-dashboard'; +import { buildAdminOnboardingConfidence, isBusinessArchived } from '@/lib/admin-dashboard'; import { customerSetupStatusLabels, shouldShowCustomerSetupWaitingPage } from '@/lib/customer-setup'; import { buildAdminMissedCallValidationTruth, buildAdminOperationalProofs } from '@/lib/admin-operator-proof'; import { buildAdminNextStepGuide, buildAdminSetupPanels } from '@/lib/admin-setup-remediation'; @@ -211,7 +212,7 @@ export default async function AdminBusinessDetailPage({ params: { businessId: string }; searchParams?: Record; }) { - await requireAdmin(); + const admin = await requireAdmin(); const [businessRecord, successfulLeadCount, operatorEvents] = await Promise.all([ db.business.findUnique({ @@ -1346,35 +1347,50 @@ export default async function AdminBusinessDetailPage({ Advanced Destructive or lifecycle controls stay separate from the guided setup flow. - - {isBusinessArchived(business) ? ( -
- - - -
+ +
+

Lifecycle controls

+

+ Archive remains the normal lifecycle control. Restore re-enables a paused business. Permanent delete is founder-only and requires explicit confirmation. +

+ + {isBusinessArchived(business) ? ( +
+ + + +
+ ) : ( +
+ + + +
+ )} +
+ + {admin.isFounder ? ( + ) : ( -
- - - -
+
+ Permanent delete is founder-only and is intentionally separated from the standard admin setup flow. +
)} - {canDeleteTestBusiness(business) ? ( -
- - - -
- ) : business.isTestBusiness ? ( -

{getDeleteTestBusinessBlockedReason(business)}

- ) : null}
diff --git a/app/admin/actions.ts b/app/admin/actions.ts index 0397899..349dc48 100644 --- a/app/admin/actions.ts +++ b/app/admin/actions.ts @@ -31,9 +31,15 @@ import { } from '@/lib/admin-provisioning'; import { deleteAllBusinessesForFounderReset, + deleteBusinessPermanently, deleteDeletableTestBusiness, FOUNDER_DELETE_ALL_BUSINESSES_CONFIRMATION, } from '@/lib/admin-business-lifecycle'; +import { + PERMANENT_DELETE_EXTERNAL_REVIEW_NOTE, + requiresRealCustomerDeleteConfirmation, + validatePermanentDeleteConfirmation, +} from '@/lib/admin-business-delete'; import { buildAdminOnboardingConfidence, canDeleteTestBusiness, getDeleteTestBusinessBlockedReason } from '@/lib/admin-dashboard'; import { buildAdminMissedCallValidationTruth } from '@/lib/admin-operator-proof'; import { requireAdmin, requireFounderAdmin } from '@/lib/admin'; @@ -2258,6 +2264,86 @@ export async function deleteTestBusinessAction(formData: FormData) { ); } +export async function deleteBusinessPermanentlyAction(formData: FormData) { + const founder = await requireFounderAdmin(); + + const parsed = adminDeleteBusinessSchema.safeParse(Object.fromEntries(formData)); + if (!parsed.success) { + redirect(`/admin?error=${encodeURIComponent(parsed.error.issues[0]?.message || 'Invalid permanent delete request.')}`); + } + + let redirectPath = '/admin'; + + try { + const business = await loadBusinessForLifecycleAction(parsed.data.businessId); + if (!business) { + throw new Error('Business not found.'); + } + + validatePermanentDeleteConfirmation({ + business, + confirmationName: parsed.data.confirmationName, + realCustomerConfirmation: parsed.data.realCustomerConfirmation, + }); + + await recordBusinessOperatorEvent({ + businessId: business.id, + type: 'admin.business_permanently_deleted', + category: 'ADMIN_ACTIONS', + status: 'WARNING', + summary: 'Business permanently deleted', + details: { + businessName: business.name, + isTestBusiness: business.isTestBusiness, + ownerEmail: business.notificationSettings?.ownerEmail || null, + archivedAt: business.archivedAt?.toISOString() || null, + externalReviewNote: PERMANENT_DELETE_EXTERNAL_REVIEW_NOTE, + }, + }); + + const result = await deleteBusinessPermanently({ + businessId: business.id, + confirmationName: parsed.data.confirmationName, + realCustomerConfirmation: parsed.data.realCustomerConfirmation, + }); + + logAuditEvent({ + event: 'admin_business_permanently_deleted', + actorType: 'user', + actorId: founder.userId, + businessId: business.id, + targetType: 'business', + targetId: business.id, + metadata: { + actorEmail: founder.email, + businessName: business.name, + isTestBusiness: business.isTestBusiness, + requiredRealCustomerConfirmation: requiresRealCustomerDeleteConfirmation(business), + externalReviewNote: result.externalReviewNote, + }, + }); + + revalidatePath('/admin'); + revalidatePath(`/admin/${business.id}`); + revalidatePath(`/admin/${business.id}/workspace`); + + redirectPath = clearBusinessSelectionFromReturnPath( + parsed.data.returnTo, + { + deleted: 1, + deletedBusinessName: result.business.name, + deletedExternalReview: 1, + }, + `/admin?deleted=1&deletedBusinessName=${encodeURIComponent(result.business.name)}&deletedExternalReview=1` + ); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unable to permanently delete this business.'; + redirectPath = appendParamsToAdminPath(parsed.data.returnTo, { error: message }, `/admin?error=${encodeURIComponent(message)}`); + } + + redirect(redirectPath); +} + export async function founderDeleteAllBusinessesAction(formData: FormData) { const founder = await requireFounderAdmin(); diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 0fe8ea9..a3e02e5 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -4,12 +4,13 @@ import { archiveBusinessAction, createAdminBusinessAction, createDemoBusinessAction, - deleteTestBusinessAction, + deleteBusinessPermanentlyAction, founderDeleteAllBusinessesAction, resyncBusinessWebhooksAction, restoreBusinessAction, sendBusinessTestSmsAction, } from '@/app/admin/actions'; +import { AdminPermanentDeleteBusinessCard } from '@/components/admin-permanent-delete-business-card'; import { FounderDeleteBusinessCard } from '@/components/founder-delete-business-card'; import { buildAdminCustomerOpenHref } from '@/lib/admin-customer-paths'; import { FOUNDER_DELETE_ALL_BUSINESSES_CONFIRMATION } from '@/lib/admin-business-lifecycle'; @@ -19,8 +20,6 @@ import { buildAdminOnboardingConfidence, buildAdminBusinessPickerLabel, buildAdminNextStep, - canDeleteTestBusiness, - getDeleteTestBusinessBlockedReason, isBusinessArchived, matchesAdminBoardFilterState, type AdminBoardFilter, @@ -423,9 +422,6 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor }), })); const selectedBusinessRow = selectedBusinessId ? businessRows.find((item) => item.business.id === selectedBusinessId) || null : null; - const selectedBusinessDeleteBlockedReason = selectedBusinessRow - ? getDeleteTestBusinessBlockedReason(selectedBusinessRow.business) - : null; const boardReturnTo = buildAdminBoardReturnPath({ view, selectedBusinessId, @@ -448,10 +444,10 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor ownerEmail: business.notificationSettings?.ownerEmail || null, isTestDemo: isTestDemoBusiness(business), isArchived: isBusinessArchived(business), - deleteEligible: canDeleteTestBusiness(business), - deleteBlockedReason: getDeleteTestBusinessBlockedReason(business), + ownerClerkId: business.ownerClerkId, })); const deletedBusinessName = getQueryValue(searchParams, 'deletedBusinessName'); + const deletedExternalReview = getQueryValue(searchParams, 'deletedExternalReview') === '1'; return (
@@ -481,11 +477,16 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor
{error ?
{error}
: null} - {archived ?
Business archived. Permanent delete stays locked until the workspace is clearly demo/test.
: null} + {archived ?
Business archived. Automation is paused until you restore it or permanently delete it as founder.
: null} {restored ?
Business restored to active triage.
: null} {deleted ? (
- {deletedBusinessName ? `Deleted ${deletedBusinessName} permanently.` : 'Demo/test business deleted permanently.'} +

{deletedBusinessName ? `Deleted ${deletedBusinessName} permanently.` : 'Business deleted permanently.'}

+ {deletedExternalReview ? ( +

+ Local business record deleted. External Twilio/Stripe/Clerk records may still need manual review. +

+ ) : null}
) : null} {founderResetResult === 'deleted' && Number.isFinite(founderResetDeleted) ? ( @@ -665,22 +666,22 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor Advanced founder tools - Founder-only cleanup. Archive real customers. Hard delete test/demo businesses only, and only when the business is already archived. + Founder-only cleanup. Archive remains the normal lifecycle control. Permanent delete is available for any business only after the required confirmations.
- Delete one test/demo business is the primary founder cleanup tool. Real customer workspaces should be archived or disabled, not hard deleted. + Archive first whenever you are handling normal churn, cancellation, or pauses. Permanent delete is reserved for founder cleanup when the business truly needs to be removed.
-

Delete one test/demo business

+

Delete one business permanently

- Select a workspace, review whether it is test/demo or real, then type the exact business name before permanent deletion is allowed. + Select a workspace, review its owner and status, then complete the required confirmations before permanent deletion is allowed.

- +
@@ -796,8 +797,7 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor Selected business actions - Focused from the business picker. Archive stays the normal lifecycle action. Permanent delete only unlocks for archived demo/test - workspaces. + Focused from the business picker. Archive stays the normal lifecycle action. Founder-only permanent delete stays inside this managed area. @@ -861,7 +861,7 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor className={buttonVariants({ variant: 'ghost', size: 'sm' })} href={`/admin/${selectedBusinessRow.business.id}#advanced`} > - Open full advanced controls + Manage business View support workspace snapshot @@ -897,25 +897,22 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor )} - {canDeleteTestBusiness(selectedBusinessRow.business) ? ( -
- - -
- - -
-

- Permanent delete removes the business plus its leads, calls, messages, notification settings, owner notifications, operator - events, simulator runs, and SMS consent records. -

- -
+ {admin.isFounder ? ( + ) : (
- {selectedBusinessDeleteBlockedReason || 'Delete stays unavailable for this business.'} + Permanent delete is founder-only and stays inside managed business controls.
)}
@@ -1031,6 +1028,9 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor View support workspace snapshot + + Manage business + {assignedNumber && !cardState.isArchived ? (
diff --git a/components/admin-permanent-delete-business-card.tsx b/components/admin-permanent-delete-business-card.tsx new file mode 100644 index 0000000..9e8b60a --- /dev/null +++ b/components/admin-permanent-delete-business-card.tsx @@ -0,0 +1,103 @@ +'use client'; + +import { useState } from 'react'; + +import { + getPermanentDeleteButtonLabel, + getPermanentDeleteWarningText, + PERMANENT_DELETE_EXTERNAL_REVIEW_NOTE, + REAL_CUSTOMER_DELETE_CONFIRMATION, + requiresRealCustomerDeleteConfirmation, + type PermanentDeleteBusinessCandidate, +} from '@/lib/admin-business-delete'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; + +type PermanentDeleteBusinessCardProps = { + business: PermanentDeleteBusinessCandidate & { + id: string; + archivedAt: Date | null; + ownerEmail?: string | null; + }; + action: (formData: FormData) => void | Promise; + returnTo: string; +}; + +export function AdminPermanentDeleteBusinessCard({ + business, + action, + returnTo, +}: PermanentDeleteBusinessCardProps) { + const [confirmationName, setConfirmationName] = useState(''); + const [realCustomerConfirmation, setRealCustomerConfirmation] = useState(''); + + const requiresPhrase = requiresRealCustomerDeleteConfirmation(business); + const exactNameMatch = confirmationName === business.name; + const phraseMatch = !requiresPhrase || realCustomerConfirmation.trim() === REAL_CUSTOMER_DELETE_CONFIRMATION; + const deleteLabel = getPermanentDeleteButtonLabel(business); + + return ( + + + + +
+

{business.name}

+ + {business.isTestBusiness ? 'Test/demo' : 'Real customer'} + + {business.archivedAt ? 'Archived' : 'Live or in setup'} +
+ +
+
+

Owner email

+

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

+
+
+

Safety guidance

+

+ {requiresPhrase ? 'Archive is safer for churn, cancellation, and pauses.' : 'Use this for permanent test/demo cleanup only when needed.'} +

+
+
+ +
+

{getPermanentDeleteWarningText(business)}

+

{PERMANENT_DELETE_EXTERNAL_REVIEW_NOTE}

+
+ +
+ + setConfirmationName(event.currentTarget.value)} + placeholder={business.name} + value={confirmationName} + /> +
+ + {requiresPhrase ? ( +
+ + setRealCustomerConfirmation(event.currentTarget.value)} + placeholder={REAL_CUSTOMER_DELETE_CONFIRMATION} + value={realCustomerConfirmation} + /> +
+ ) : null} + + + + ); +} diff --git a/components/founder-delete-business-card.tsx b/components/founder-delete-business-card.tsx index 30d498b..da85429 100644 --- a/components/founder-delete-business-card.tsx +++ b/components/founder-delete-business-card.tsx @@ -1,7 +1,13 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; +import { + getPermanentDeleteButtonLabel, + getPermanentDeleteWarningText, + PERMANENT_DELETE_EXTERNAL_REVIEW_NOTE, + REAL_CUSTOMER_DELETE_CONFIRMATION, +} from '@/lib/admin-business-delete'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -14,8 +20,7 @@ type FounderDeleteCandidate = { ownerEmail: string | null; isTestDemo: boolean; isArchived: boolean; - deleteEligible: boolean; - deleteBlockedReason: string | null; + ownerClerkId: string; }; export function FounderDeleteBusinessCard({ @@ -27,15 +32,16 @@ export function FounderDeleteBusinessCard({ }) { const [selectedBusinessId, setSelectedBusinessId] = useState(candidates[0]?.id || ''); const [confirmationName, setConfirmationName] = useState(''); + const [realCustomerConfirmation, setRealCustomerConfirmation] = useState(''); - const selectedBusiness = useMemo( - () => candidates.find((candidate) => candidate.id === selectedBusinessId) || null, - [candidates, selectedBusinessId] - ); + const selectedBusiness = candidates.find((candidate) => candidate.id === selectedBusinessId) || null; + const requiresPhrase = selectedBusiness ? !selectedBusiness.isTestDemo : false; const exactNameMatch = Boolean(selectedBusiness && confirmationName === selectedBusiness.name); + const phraseMatch = !requiresPhrase || realCustomerConfirmation.trim() === REAL_CUSTOMER_DELETE_CONFIRMATION; useEffect(() => { setConfirmationName(''); + setRealCustomerConfirmation(''); }, [selectedBusinessId]); return ( @@ -77,17 +83,16 @@ export function FounderDeleteBusinessCard({

Delete policy

- {selectedBusiness.deleteEligible - ? 'Hard delete test/demo businesses only.' - : selectedBusiness.isTestDemo - ? 'Archive this workspace first.' - : 'Archive real customers instead.'} + {selectedBusiness.isTestDemo + ? 'Archive first when possible, then permanently delete if you need a full cleanup.' + : 'Archive is safer for churn or cancellation. Permanent delete stays available only with the real-customer phrase.'}

- Deletion is permanent. This removes the business and its business-owned records through the existing schema cascades. +

{getPermanentDeleteWarningText({ ...selectedBusiness, isTestBusiness: selectedBusiness.isTestDemo })}

+

{PERMANENT_DELETE_EXTERNAL_REVIEW_NOTE}

) : ( @@ -112,23 +117,32 @@ export function FounderDeleteBusinessCard({ /> + {requiresPhrase ? ( +
+ + setRealCustomerConfirmation(event.currentTarget.value)} + placeholder={REAL_CUSTOMER_DELETE_CONFIRMATION} + value={realCustomerConfirmation} + /> +
+ ) : null} +

- The delete button unlocks only when the typed name matches exactly. Archive real customers. Hard delete test/demo businesses only. + The delete button unlocks only when the typed name matches exactly. Real customers also require the explicit founder phrase.

- {selectedBusiness && !selectedBusiness.deleteEligible ? ( -

- {selectedBusiness.deleteBlockedReason || 'Hard delete stays locked for this business.'} -

- ) : null} - diff --git a/lib/admin-business-delete.ts b/lib/admin-business-delete.ts new file mode 100644 index 0000000..892eabd --- /dev/null +++ b/lib/admin-business-delete.ts @@ -0,0 +1,48 @@ +import type { Business } from '@prisma/client'; + +import { isTestDemoBusiness } from '@/lib/admin-test-data-reset'; + +export const REAL_CUSTOMER_DELETE_CONFIRMATION = 'DELETE REAL CUSTOMER'; +export const PERMANENT_DELETE_EXTERNAL_REVIEW_NOTE = + 'Local business record deleted. External Twilio/Stripe/Clerk records may still need manual review.'; + +export type PermanentDeleteBusinessCandidate = { + name: string; + isTestBusiness: boolean; + ownerClerkId: string; +}; + +export function requiresRealCustomerDeleteConfirmation(business: PermanentDeleteBusinessCandidate) { + return !isTestDemoBusiness(business); +} + +export function getPermanentDeleteWarningText(business: PermanentDeleteBusinessCandidate) { + if (requiresRealCustomerDeleteConfirmation(business)) { + return 'This permanently deletes the business and business-owned records. This should only be used when you are certain this customer should be removed. Archive is safer for normal churn or cancellation.'; + } + + return 'This permanently deletes the business and business-owned records. Use this only when you are sure this test or demo workspace should be removed.'; +} + +export function getPermanentDeleteButtonLabel(business: PermanentDeleteBusinessCandidate) { + return requiresRealCustomerDeleteConfirmation(business) + ? 'Delete real customer permanently' + : 'Delete test/demo business permanently'; +} + +export function validatePermanentDeleteConfirmation(params: { + business: PermanentDeleteBusinessCandidate; + confirmationName: string | null | undefined; + realCustomerConfirmation?: string | null | undefined; +}) { + if (params.confirmationName?.trim() !== params.business.name) { + throw new Error('Type the exact business name to delete it.'); + } + + if ( + requiresRealCustomerDeleteConfirmation(params.business) && + (params.realCustomerConfirmation?.trim() || '') !== REAL_CUSTOMER_DELETE_CONFIRMATION + ) { + throw new Error(`Type ${REAL_CUSTOMER_DELETE_CONFIRMATION} to permanently delete a real customer business.`); + } +} diff --git a/lib/admin-business-lifecycle.ts b/lib/admin-business-lifecycle.ts index 17a2a9d..0447e49 100644 --- a/lib/admin-business-lifecycle.ts +++ b/lib/admin-business-lifecycle.ts @@ -1,16 +1,34 @@ import type { Business } from '@prisma/client'; +import { + PERMANENT_DELETE_EXTERNAL_REVIEW_NOTE, + requiresRealCustomerDeleteConfirmation, + type PermanentDeleteBusinessCandidate, + validatePermanentDeleteConfirmation, +} from '@/lib/admin-business-delete'; import { canDeleteTestBusiness, getDeleteTestBusinessBlockedReason } from '@/lib/admin-dashboard'; import { db } from '@/lib/db'; export const FOUNDER_DELETE_ALL_BUSINESSES_CONFIRMATION = 'DELETE ALL BUSINESSES'; export type FounderBusinessResetCandidate = Pick; - function normalizeConfirmation(value: string | null | undefined) { return value?.trim().toUpperCase() || ''; } +function deleteBusinessRecord(businessId: string) { + return db.business.delete({ + where: { id: businessId }, + select: { + id: true, + name: true, + isTestBusiness: true, + archivedAt: true, + ownerClerkId: true, + }, + }); +} + export async function listBusinessesForFounderReset(candidateIds?: string[]) { return db.business.findMany({ where: candidateIds?.length @@ -96,12 +114,40 @@ export async function deleteDeletableTestBusiness(businessId: string) { throw new Error(blockedReason || 'Only archived demo/test businesses can be deleted.'); } - return db.business.delete({ - where: { id: business.id }, + return deleteBusinessRecord(business.id); +} + +export async function deleteBusinessPermanently(params: { + businessId: string; + confirmationName: string; + realCustomerConfirmation?: string | null; +}) { + const business = await db.business.findUnique({ + where: { id: params.businessId }, select: { id: true, name: true, isTestBusiness: true, + archivedAt: true, + ownerClerkId: true, }, }); + + if (!business) { + throw new Error('Business not found.'); + } + + validatePermanentDeleteConfirmation({ + business, + confirmationName: params.confirmationName, + realCustomerConfirmation: params.realCustomerConfirmation, + }); + + const deleted = await deleteBusinessRecord(business.id); + + return { + business: deleted, + externalReviewNote: PERMANENT_DELETE_EXTERNAL_REVIEW_NOTE, + requiredRealCustomerConfirmation: requiresRealCustomerDeleteConfirmation(business), + }; } diff --git a/lib/validators.ts b/lib/validators.ts index b7866d5..5cae49c 100644 --- a/lib/validators.ts +++ b/lib/validators.ts @@ -282,6 +282,7 @@ export const adminArchiveBusinessSchema = z.object({ export const adminDeleteBusinessSchema = z.object({ businessId: z.string().min(1), confirmationName: z.string().trim().min(1), + realCustomerConfirmation: z.string().trim().optional().or(z.literal('')), returnTo: z.string().trim().optional().or(z.literal('')), }); diff --git a/tests/admin-auth.test.ts b/tests/admin-auth.test.ts index 2839d03..dbf020a 100644 --- a/tests/admin-auth.test.ts +++ b/tests/admin-auth.test.ts @@ -29,8 +29,10 @@ test('admin pages and customer-mode routes require real admin authorization', () const adminContext = read('lib/admin-customer-context.ts'); assert.match(adminPage, /const admin = await requireAdmin\(\)/); - assert.match(adminBusinessPage, /await requireAdmin\(\)/); + assert.match(adminBusinessPage, /const admin = await requireAdmin\(\)/); assert.match(adminActions, /const admin = await requireAdmin\(\)/); + assert.match(adminActions, /const founder = await requireFounderAdmin\(\)/); + assert.match(adminActions, /export async function deleteBusinessPermanentlyAction/); assert.match(openCustomerRoute, /const adminSession = await getAdminSession\(\)/); assert.match(openCustomerRoute, /if \(!adminSession\.isAdmin\)/); assert.match(exitCustomerModeRoute, /const adminSession = await getAdminSession\(\)/); diff --git a/tests/admin-bulk-delete-action.test.ts b/tests/admin-bulk-delete-action.test.ts index 96586f8..c15773e 100644 --- a/tests/admin-bulk-delete-action.test.ts +++ b/tests/admin-bulk-delete-action.test.ts @@ -10,8 +10,11 @@ test('founder bulk delete action keeps redirect outside the try/catch and return const actions = read('app/admin/actions.ts'); const adminPage = read('app/admin/page.tsx'); const deleteActionStart = actions.indexOf('export async function deleteTestBusinessAction'); - const deleteActionEnd = actions.indexOf('export async function founderDeleteAllBusinessesAction'); + const deleteActionEnd = actions.indexOf('export async function deleteBusinessPermanentlyAction'); const deleteActionSection = actions.slice(deleteActionStart, deleteActionEnd); + const permanentDeleteActionStart = actions.indexOf('export async function deleteBusinessPermanentlyAction'); + const permanentDeleteActionEnd = actions.indexOf('export async function founderDeleteAllBusinessesAction'); + const permanentDeleteActionSection = actions.slice(permanentDeleteActionStart, permanentDeleteActionEnd); const actionStart = actions.indexOf('export async function founderDeleteAllBusinessesAction'); const nextActionStart = actions.indexOf('export async function bulkDeleteTestBusinessesAction'); @@ -36,14 +39,28 @@ test('founder bulk delete action keeps redirect outside the try/catch and return assert.doesNotMatch(deleteActionSection, /catch \(error\)/); assert.match(deleteActionSection, /deletedBusinessName: business\.name/); + const permanentCatchStart = permanentDeleteActionSection.indexOf('} catch (error) {'); + const permanentFinalRedirectStart = permanentDeleteActionSection.lastIndexOf('\n\n redirect(redirectPath);'); + const permanentCatchSection = permanentDeleteActionSection.slice(permanentCatchStart, permanentFinalRedirectStart); + assert.match(actions, /export async function deleteBusinessPermanentlyAction/); + assert.match(permanentDeleteActionSection, /const founder = await requireFounderAdmin\(\)/); + assert.match(permanentDeleteActionSection, /validatePermanentDeleteConfirmation/); + assert.match(permanentDeleteActionSection, /recordBusinessOperatorEvent/); + assert.match(permanentDeleteActionSection, /deleteBusinessPermanently/); + assert.match(permanentDeleteActionSection, /deletedExternalReview: 1/); + assert.notEqual(permanentCatchStart, -1); + assert.notEqual(permanentFinalRedirectStart, -1); + assert.doesNotMatch(permanentCatchSection, /redirect\(/); + assert.match(adminPage, /admin\.isFounder \?/); - assert.match(adminPage, /Delete one test\/demo business/); - assert.match(adminPage, /Select a workspace, review whether it is test\/demo or real, then type the exact business name/); - assert.match(adminPage, /Archive real customers\. Hard delete test\/demo businesses only/); + assert.match(adminPage, /Delete one business permanently/); + assert.match(adminPage, /review its owner and status, then complete the required confirmations/); + assert.match(adminPage, /Founder-only cleanup\. Archive remains the normal lifecycle control\./); assert.match(adminPage, /Advanced founder reset: delete all current businesses/); assert.match(adminPage, /founderResetResult === 'deleted'/); assert.match(adminPage, /Deleted \{founderResetDeleted\} current/); assert.match(adminPage, /founderResetResult === 'noop'/); assert.match(adminPage, /No businesses were available for founder reset/); assert.match(adminPage, /deletedBusinessName \? `Deleted \$\{deletedBusinessName\} permanently\.`/); + assert.match(adminPage, /deletedExternalReview \? \(/); }); diff --git a/tests/admin-business-lifecycle.test.ts b/tests/admin-business-lifecycle.test.ts index d5d1247..16756dd 100644 --- a/tests/admin-business-lifecycle.test.ts +++ b/tests/admin-business-lifecycle.test.ts @@ -2,20 +2,34 @@ import assert from 'node:assert/strict'; import { randomUUID } from 'node:crypto'; import test from 'node:test'; -import { deleteDeletableTestBusiness } from '../lib/admin-business-lifecycle.ts'; +import { + deleteBusinessPermanently, + deleteDeletableTestBusiness, +} from '../lib/admin-business-lifecycle.ts'; +import { + PERMANENT_DELETE_EXTERNAL_REVIEW_NOTE, + REAL_CUSTOMER_DELETE_CONFIRMATION, +} from '../lib/admin-business-delete.ts'; import { db } from '../lib/db.ts'; function makeSeed(prefix: string) { return `${prefix}-${randomUUID().slice(0, 8)}`; } -async function createArchivedTestBusinessGraph(seed: string) { +async function createBusinessGraph(params: { + seed: string; + isTestBusiness: boolean; + archived?: boolean; + ownerClerkId?: string; +}) { + const ownerClerkId = params.ownerClerkId || `owner-${params.seed}`; + const businessName = `${params.isTestBusiness ? 'Demo Cleanup' : 'Real Tenant'} ${params.seed}`; const business = await db.business.create({ data: { - ownerClerkId: `owner-${seed}`, - name: `Demo Cleanup ${seed}`, - isTestBusiness: true, - archivedAt: new Date('2026-04-18T12:00:00.000Z'), + ownerClerkId, + name: businessName, + isTestBusiness: params.isTestBusiness, + archivedAt: params.archived ? new Date('2026-04-18T12:00:00.000Z') : null, forwardingNumber: '+15125550100', notifyPhone: '+15125550101', missedCallSeconds: 20, @@ -25,7 +39,7 @@ async function createArchivedTestBusinessGraph(seed: string) { serviceLabel3: 'Maintenance', notificationSettings: { create: { - ownerEmail: `${seed}@example.com`, + ownerEmail: `${params.seed}@example.com`, ownerPhone: '+15125550101', }, }, @@ -35,7 +49,7 @@ async function createArchivedTestBusinessGraph(seed: string) { const call = await db.call.create({ data: { businessId: business.id, - twilioCallSid: `CA${seed.replace(/-/g, '').padEnd(32, '0').slice(0, 32)}`, + twilioCallSid: `CA${params.seed.replace(/-/g, '').padEnd(32, '0').slice(0, 32)}`, fromPhone: '+15125550102', fromPhoneNormalized: '+15125550102', toPhone: '+15125550100', @@ -99,7 +113,7 @@ async function createArchivedTestBusinessGraph(seed: string) { await db.simulatorRun.create({ data: { - publicId: `sim-${seed}`, + publicId: `sim-${params.seed}`, businessId: business.id, leadId: lead.id, callerPhone: '+15125550102', @@ -111,9 +125,9 @@ async function createArchivedTestBusinessGraph(seed: string) { test('deleteDeletableTestBusiness hard deletes an archived demo/test business and its dependent records', async () => { const seed = makeSeed('delete-demo'); - const { business, call, lead } = await createArchivedTestBusinessGraph(seed); + const { business, call, lead } = await createBusinessGraph({ seed, isTestBusiness: true, archived: true }); const preservedSeed = makeSeed('preserve-demo'); - const preserved = await createArchivedTestBusinessGraph(preservedSeed); + const preserved = await createBusinessGraph({ seed: preservedSeed, isTestBusiness: true, archived: true }); try { await deleteDeletableTestBusiness(business.id); @@ -165,19 +179,7 @@ test('deleteDeletableTestBusiness hard deletes an archived demo/test business an test('deleteDeletableTestBusiness rejects real businesses and leaves them archive-only', async () => { const seed = makeSeed('keep-real'); - const business = await db.business.create({ - data: { - ownerClerkId: `owner-${seed}`, - name: `Real Tenant ${seed}`, - isTestBusiness: false, - forwardingNumber: '+15125550110', - missedCallSeconds: 20, - timezone: 'America/New_York', - serviceLabel1: 'Repair', - serviceLabel2: 'Install', - serviceLabel3: 'Maintenance', - }, - }); + const { business } = await createBusinessGraph({ seed, isTestBusiness: false }); try { await assert.rejects( @@ -191,3 +193,121 @@ test('deleteDeletableTestBusiness rejects real businesses and leaves them archiv await db.business.deleteMany({ where: { id: business.id } }); } }); + +test('deleteBusinessPermanently deletes a test/demo business with the exact name confirmation', async () => { + const seed = makeSeed('permanent-demo'); + const { business, call, lead } = await createBusinessGraph({ seed, isTestBusiness: true }); + + try { + const result = await deleteBusinessPermanently({ + businessId: business.id, + confirmationName: business.name, + }); + + assert.equal(result.business.id, business.id); + assert.equal(result.requiredRealCustomerConfirmation, false); + assert.equal(result.externalReviewNote, PERMANENT_DELETE_EXTERNAL_REVIEW_NOTE); + assert.equal(await db.business.findUnique({ where: { id: business.id } }), null); + assert.equal(await db.call.findUnique({ where: { id: call.id } }), null); + assert.equal(await db.lead.findUnique({ where: { id: lead.id } }), null); + assert.equal(await db.message.count({ where: { businessId: business.id } }), 0); + assert.equal(await db.ownerNotification.count({ where: { businessId: business.id } }), 0); + assert.equal(await db.businessNotificationSettings.count({ where: { businessId: business.id } }), 0); + assert.equal(await db.businessOperatorEvent.count({ where: { businessId: business.id } }), 0); + assert.equal(await db.simulatorRun.count({ where: { businessId: business.id } }), 0); + assert.equal(await db.smsConsent.count({ where: { businessId: business.id } }), 0); + } finally { + await db.business.deleteMany({ where: { id: business.id } }); + } +}); + +test('deleteBusinessPermanently rejects a test/demo business when the name confirmation is wrong', async () => { + const seed = makeSeed('wrong-demo'); + const { business } = await createBusinessGraph({ seed, isTestBusiness: true }); + + try { + await assert.rejects( + deleteBusinessPermanently({ + businessId: business.id, + confirmationName: 'Wrong business name', + }), + /Type the exact business name to delete it\./ + ); + + assert.notEqual(await db.business.findUnique({ where: { id: business.id } }), null); + } finally { + await db.business.deleteMany({ where: { id: business.id } }); + } +}); + +test('deleteBusinessPermanently rejects a real customer when the founder phrase is missing or wrong', async () => { + const seed = makeSeed('real-customer'); + const { business } = await createBusinessGraph({ seed, isTestBusiness: false }); + + try { + await assert.rejects( + deleteBusinessPermanently({ + businessId: business.id, + confirmationName: business.name, + }), + /DELETE REAL CUSTOMER/ + ); + + await assert.rejects( + deleteBusinessPermanently({ + businessId: business.id, + confirmationName: business.name, + realCustomerConfirmation: 'delete real customer', + }), + /DELETE REAL CUSTOMER/ + ); + + assert.notEqual(await db.business.findUnique({ where: { id: business.id } }), null); + } finally { + await db.business.deleteMany({ where: { id: business.id } }); + } +}); + +test('deleteBusinessPermanently deletes a real customer only with exact name plus DELETE REAL CUSTOMER', async () => { + const seed = makeSeed('real-delete'); + const { business, call, lead } = await createBusinessGraph({ seed, isTestBusiness: false }); + + try { + const result = await deleteBusinessPermanently({ + businessId: business.id, + confirmationName: business.name, + realCustomerConfirmation: REAL_CUSTOMER_DELETE_CONFIRMATION, + }); + + assert.equal(result.business.id, business.id); + assert.equal(result.requiredRealCustomerConfirmation, true); + assert.equal(await db.business.findUnique({ where: { id: business.id } }), null); + assert.equal(await db.call.findUnique({ where: { id: call.id } }), null); + assert.equal(await db.lead.findUnique({ where: { id: lead.id } }), null); + assert.equal(await db.message.count({ where: { businessId: business.id } }), 0); + assert.equal(await db.ownerNotification.count({ where: { businessId: business.id } }), 0); + assert.equal(await db.businessNotificationSettings.count({ where: { businessId: business.id } }), 0); + assert.equal(await db.businessOperatorEvent.count({ where: { businessId: business.id } }), 0); + assert.equal(await db.simulatorRun.count({ where: { businessId: business.id } }), 0); + assert.equal(await db.smsConsent.count({ where: { businessId: business.id } }), 0); + } finally { + await db.business.deleteMany({ where: { id: business.id } }); + } +}); + +test('deleteBusinessPermanently allows an archived real customer to be permanently deleted with full confirmation', async () => { + const seed = makeSeed('archived-real-delete'); + const { business } = await createBusinessGraph({ seed, isTestBusiness: false, archived: true }); + + try { + await deleteBusinessPermanently({ + businessId: business.id, + confirmationName: business.name, + realCustomerConfirmation: REAL_CUSTOMER_DELETE_CONFIRMATION, + }); + + assert.equal(await db.business.findUnique({ where: { id: business.id } }), null); + } finally { + await db.business.deleteMany({ where: { id: business.id } }); + } +}); diff --git a/tests/admin-operator-routes.test.ts b/tests/admin-operator-routes.test.ts index 48297b4..ee3763d 100644 --- a/tests/admin-operator-routes.test.ts +++ b/tests/admin-operator-routes.test.ts @@ -15,25 +15,29 @@ test('admin routes expose support workspace and safe lifecycle controls', () => const adminActions = read('app/admin/actions.ts'); const businessPicker = read('components/admin-business-picker.tsx'); const founderDeleteCard = read('components/founder-delete-business-card.tsx'); + const permanentDeleteCard = read('components/admin-permanent-delete-business-card.tsx'); const appLayout = read('app/app/layout.tsx'); assert.match(adminHome, /Operator control panel/); assert.match(adminHome, /Create new business/); assert.match(adminHome, /Create business workspace/); assert.match(adminHome, /Advanced founder tools/); - assert.match(adminHome, /Delete one test\/demo business/); + assert.match(adminHome, /Delete one business permanently/); assert.match(adminHome, /Advanced founder reset: delete all current businesses/); - assert.match(founderDeleteCard, /Delete this business/); + assert.match(founderDeleteCard, /getPermanentDeleteButtonLabel/); assert.match(founderDeleteCard, /Type the exact business name/); + assert.match(permanentDeleteCard, /getPermanentDeleteButtonLabel/); + assert.match(permanentDeleteCard, /REAL_CUSTOMER_DELETE_CONFIRMATION/); assert.match(adminHome, /FOUNDER_DELETE_ALL_BUSINESSES_CONFIRMATION/); assert.match(adminHome, /Business triage board/); 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/); + assert.match(permanentDeleteCard, /getPermanentDeleteButtonLabel/); + assert.match(adminHome, /Manage business/); assert.match(adminHome, /Open customer leads/); assert.match(adminActions, /export async function founderDeleteAllBusinessesAction/); + assert.match(adminActions, /export async function deleteBusinessPermanentlyAction/); assert.match(adminActions, /export async function saveAdminTwilioSetupAction/); assert.match(adminActions, /export async function saveAdminSetupBasicsAction/); assert.match(adminActions, /export async function createBusinessTwilioSubaccountAction/); @@ -43,6 +47,7 @@ test('admin routes expose support workspace and safe lifecycle controls', () => assert.match(adminActions, /getOptionalTwilioSidError/); assert.match(adminActions, /getMessagingComplianceSidValidationError/); assert.match(adminActions, /const admin = await requireAdmin\(\)/); + assert.match(adminActions, /const founder = await requireFounderAdmin\(\)/); assert.match(adminHome, /View support workspace snapshot/); assert.match(businessPicker, /Jump to business/); assert.match(businessPicker, /Clear selection/); @@ -58,6 +63,7 @@ test('admin routes expose support workspace and safe lifecycle controls', () => assert.match(adminDetail, /Mark missed-call flow validated/); assert.match(adminDetail, /Mark live with warnings/); assert.match(adminDetail, /Advanced/); + assert.match(adminDetail, /Permanent delete is founder-only/); assert.match(adminDetail, /Invite owner by email/); assert.match(adminDetail, /Connect existing owner/); assert.match(adminDetail, /Send test SMS/); @@ -72,8 +78,6 @@ test('admin routes expose support workspace and safe lifecycle controls', () => assert.match(adminDetail, /Open customer settings/); assert.match(adminDetail, /Open customer call flow/); assert.match(adminDetail, /View support workspace snapshot/); - assert.match(adminDetail, /canDeleteTestBusiness\(business\)/); - assert.match(adminDetail, /getDeleteTestBusinessBlockedReason\(business\)/); assert.match(adminHome, /Restore business/); assert.match(activityTimeline, /Recent activity/); assert.match(activityTimeline, /Show more activity/); @@ -89,4 +93,5 @@ test('admin routes expose support workspace and safe lifecycle controls', () => assert.match(supportWorkspace, /Customer call flow snapshot/); assert.match(appLayout, /Admin customer mode/); assert.match(appLayout, /Exit customer mode/); + assert.doesNotMatch(adminHome, /Delete demo\/test business permanently[\s\S]*Delete demo\/test business permanently/); }); diff --git a/tests/founder-delete-business-card.test.ts b/tests/founder-delete-business-card.test.ts index cb37db9..ca86e9b 100644 --- a/tests/founder-delete-business-card.test.ts +++ b/tests/founder-delete-business-card.test.ts @@ -6,17 +6,18 @@ function read(path: string) { return readFileSync(new URL(`../${path}`, import.meta.url), 'utf8'); } -test('founder single-business delete card keeps hard delete narrow and exact-name gated', () => { +test('founder single-business delete card keeps permanent delete exact-name gated and adds the real-customer phrase', () => { const component = read('components/founder-delete-business-card.tsx'); assert.match(component, /Choose a business/); assert.match(component, /Owner email/); assert.match(component, /Test\/demo/); assert.match(component, /Real customer/); - assert.match(component, /Deletion is permanent/); assert.match(component, /Type the exact business name/); - assert.match(component, /Archive real customers\. Hard delete test\/demo businesses only\./); - assert.match(component, /Delete this business/); + assert.match(component, /REAL_CUSTOMER_DELETE_CONFIRMATION/); + assert.match(component, /getPermanentDeleteButtonLabel/); + assert.match(component, /getPermanentDeleteWarningText/); + assert.match(component, /Real customers also require the explicit founder phrase/); assert.match(component, /confirmationName === selectedBusiness\.name/); - assert.match(component, /disabled=\{!selectedBusiness \|\| !selectedBusiness\.deleteEligible \|\| !exactNameMatch\}/); + assert.match(component, /disabled=\{!selectedBusiness \|\| !exactNameMatch \|\| !phraseMatch\}/); });