From 0d7fe454416134dfd86583f0de3a5cd96594b060 Mon Sep 17 00:00:00 2001 From: DevCalebR Date: Tue, 26 May 2026 02:10:15 -0400 Subject: [PATCH] Replace founder bulk delete with safer single-business flow --- app/admin/actions.ts | 8 +- app/admin/page.tsx | 108 ++++++++++------ components/founder-delete-business-card.tsx | 136 ++++++++++++++++++++ tests/admin-bulk-delete-action.test.ts | 16 ++- tests/admin-business-lifecycle.test.ts | 13 +- tests/admin-operator-routes.test.ts | 6 +- tests/founder-delete-business-card.test.ts | 22 ++++ 7 files changed, 269 insertions(+), 40 deletions(-) create mode 100644 components/founder-delete-business-card.tsx create mode 100644 tests/founder-delete-business-card.test.ts diff --git a/app/admin/actions.ts b/app/admin/actions.ts index 0391e83..53c842d 100644 --- a/app/admin/actions.ts +++ b/app/admin/actions.ts @@ -2247,7 +2247,13 @@ export async function deleteTestBusinessAction(formData: FormData) { }); revalidatePath('/admin'); - redirect(clearBusinessSelectionFromReturnPath(parsed.data.returnTo, { deleted: 1 }, '/admin?deleted=1')); + redirect( + clearBusinessSelectionFromReturnPath( + parsed.data.returnTo, + { deleted: 1, deletedBusinessName: business.name }, + `/admin?deleted=1&deletedBusinessName=${encodeURIComponent(business.name)}` + ) + ); } export async function founderDeleteAllBusinessesAction(formData: FormData) { diff --git a/app/admin/page.tsx b/app/admin/page.tsx index f7137da..1fbc7a2 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -11,6 +11,7 @@ import { restoreBusinessAction, sendBusinessTestSmsAction, } from '@/app/admin/actions'; +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'; import { @@ -32,6 +33,7 @@ import { } 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'; import { Badge } from '@/components/ui/badge'; import { Button, buttonVariants } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; @@ -163,6 +165,7 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor ownerEmail: true, }, }, + ownerClerkId: true, }, }), db.lead.groupBy({ @@ -483,6 +486,16 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor ]; const founderResetBusinessCount = businessPickerOptions.length; const founderResetBusinessPreview = businessPickerOptions.slice(0, 4).map((business) => business.name); + const founderDeleteCandidates = businessPickerOptions.map((business) => ({ + id: business.id, + name: business.name, + ownerEmail: business.notificationSettings?.ownerEmail || null, + isTestDemo: isTestDemoBusiness(business), + isArchived: isBusinessArchived(business), + deleteEligible: canDeleteTestBusiness(business), + deleteBlockedReason: getDeleteTestBusinessBlockedReason(business), + })); + const deletedBusinessName = getQueryValue(searchParams, 'deletedBusinessName'); return (
@@ -514,7 +527,11 @@ 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} {restored ?
Business restored to active triage.
: null} - {deleted ?
Demo/test business deleted permanently.
: null} + {deleted ? ( +
+ {deletedBusinessName ? `Deleted ${deletedBusinessName} permanently.` : 'Demo/test business deleted permanently.'} +
+ ) : null} {founderResetResult === 'deleted' && Number.isFinite(founderResetDeleted) ? (
Deleted {founderResetDeleted} current {founderResetDeleted === 1 ? 'business' : 'businesses'}. You can create one clean business workspace now. @@ -670,47 +687,66 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor Founder reset - One-time founder-only cleanup. Use this only because there are no real customer businesses yet. Normal real-customer lifecycle should still be archive or disable, not hard delete. + Founder-only cleanup. Archive real customers. Hard delete test/demo businesses only, and only when the business is already archived. - -
-

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

-

- This permanently deletes every current business record, including archived ones, so you can restart from one clean workspace. -

- {founderResetBusinessPreview.length > 0 ? ( -

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

+ 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.

- ) : ( -

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} diff --git a/components/founder-delete-business-card.tsx b/components/founder-delete-business-card.tsx new file mode 100644 index 0000000..30d498b --- /dev/null +++ b/components/founder-delete-business-card.tsx @@ -0,0 +1,136 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; + +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select } from '@/components/ui/select'; + +type FounderDeleteCandidate = { + id: string; + name: string; + ownerEmail: string | null; + isTestDemo: boolean; + isArchived: boolean; + deleteEligible: boolean; + deleteBlockedReason: string | null; +}; + +export function FounderDeleteBusinessCard({ + candidates, + deleteAction, +}: { + candidates: FounderDeleteCandidate[]; + deleteAction: (formData: FormData) => void | Promise; +}) { + const [selectedBusinessId, setSelectedBusinessId] = useState(candidates[0]?.id || ''); + const [confirmationName, setConfirmationName] = useState(''); + + const selectedBusiness = useMemo( + () => candidates.find((candidate) => candidate.id === selectedBusinessId) || null, + [candidates, selectedBusinessId] + ); + const exactNameMatch = Boolean(selectedBusiness && confirmationName === selectedBusiness.name); + + useEffect(() => { + setConfirmationName(''); + }, [selectedBusinessId]); + + return ( +
+
+
+ + +
+ + {selectedBusiness ? ( +
+
+

{selectedBusiness.name}

+ + {selectedBusiness.isTestDemo ? 'Test/demo' : 'Real customer'} + + + {selectedBusiness.isArchived ? 'Archived' : 'Active'} + +
+ +
+
+

Owner email

+

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

+
+
+

Delete policy

+

+ {selectedBusiness.deleteEligible + ? 'Hard delete test/demo businesses only.' + : selectedBusiness.isTestDemo + ? 'Archive this workspace first.' + : 'Archive real customers instead.'} +

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

Select a business to review its delete safety details.

+ )} +
+ +
+ + + +
+ + setConfirmationName(event.currentTarget.value)} + placeholder={selectedBusiness?.name || 'Choose a business first'} + value={confirmationName} + /> +
+ +

+ The delete button unlocks only when the typed name matches exactly. Archive real customers. Hard delete test/demo businesses only. +

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

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

+ ) : null} + + +
+
+ ); +} diff --git a/tests/admin-bulk-delete-action.test.ts b/tests/admin-bulk-delete-action.test.ts index 9e38dae..96586f8 100644 --- a/tests/admin-bulk-delete-action.test.ts +++ b/tests/admin-bulk-delete-action.test.ts @@ -9,6 +9,9 @@ function read(path: string) { test('founder bulk delete action keeps redirect outside the try/catch and returns plain admin UI states', () => { const actions = read('app/admin/actions.ts'); const adminPage = read('app/admin/page.tsx'); + const deleteActionStart = actions.indexOf('export async function deleteTestBusinessAction'); + const deleteActionEnd = actions.indexOf('export async function founderDeleteAllBusinessesAction'); + const deleteActionSection = actions.slice(deleteActionStart, deleteActionEnd); const actionStart = actions.indexOf('export async function founderDeleteAllBusinessesAction'); const nextActionStart = actions.indexOf('export async function bulkDeleteTestBusinessesAction'); @@ -26,10 +29,21 @@ test('founder bulk delete action keeps redirect outside the try/catch and return assert.notEqual(finalRedirectStart, -1); assert.doesNotMatch(catchSection, /redirect\(/); + assert.match(actions, /export async function deleteTestBusinessAction/); + assert.match(deleteActionSection, /const admin = await requireAdmin\(\)/); + assert.match(deleteActionSection, /parsed\.data\.confirmationName !== business\.name/); + assert.match(deleteActionSection, /Type the exact business name to delete it\./); + assert.doesNotMatch(deleteActionSection, /catch \(error\)/); + assert.match(deleteActionSection, /deletedBusinessName: business\.name/); + 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, /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, /All businesses have been removed\. Create your first clean business workspace with Fast onboard\./); + assert.match(adminPage, /deletedBusinessName \? `Deleted \$\{deletedBusinessName\} permanently\.`/); }); diff --git a/tests/admin-business-lifecycle.test.ts b/tests/admin-business-lifecycle.test.ts index 8bf0857..d5d1247 100644 --- a/tests/admin-business-lifecycle.test.ts +++ b/tests/admin-business-lifecycle.test.ts @@ -112,6 +112,8 @@ 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 preservedSeed = makeSeed('preserve-demo'); + const preserved = await createArchivedTestBusinessGraph(preservedSeed); try { await deleteDeletableTestBusiness(business.id); @@ -126,6 +128,7 @@ test('deleteDeletableTestBusiness hard deletes an archived demo/test business an deletedOperatorEventCount, deletedSimulatorRunCount, deletedConsentCount, + stillPresentBusiness, ] = await Promise.all([ db.business.findUnique({ where: { id: business.id } }), db.call.findUnique({ where: { id: call.id } }), @@ -136,6 +139,7 @@ test('deleteDeletableTestBusiness hard deletes an archived demo/test business an db.businessOperatorEvent.count({ where: { businessId: business.id } }), db.simulatorRun.count({ where: { businessId: business.id } }), db.smsConsent.count({ where: { businessId: business.id } }), + db.business.findUnique({ where: { id: preserved.business.id } }), ]); assert.equal(deletedBusiness, null); @@ -147,8 +151,15 @@ test('deleteDeletableTestBusiness hard deletes an archived demo/test business an assert.equal(deletedOperatorEventCount, 0); assert.equal(deletedSimulatorRunCount, 0); assert.equal(deletedConsentCount, 0); + assert.equal(stillPresentBusiness?.id, preserved.business.id); } finally { - await db.business.deleteMany({ where: { ownerClerkId: `owner-${seed}` } }); + await db.business.deleteMany({ + where: { + ownerClerkId: { + in: [`owner-${seed}`, `owner-${preservedSeed}`], + }, + }, + }); } }); diff --git a/tests/admin-operator-routes.test.ts b/tests/admin-operator-routes.test.ts index 8719494..51adb16 100644 --- a/tests/admin-operator-routes.test.ts +++ b/tests/admin-operator-routes.test.ts @@ -14,13 +14,17 @@ test('admin routes expose support workspace and safe lifecycle controls', () => const supportWorkspace = read('app/admin/[businessId]/workspace/page.tsx'); 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 appLayout = read('app/app/layout.tsx'); assert.match(adminHome, /Operator control panel/); assert.match(adminHome, /Fast onboard/); assert.match(adminHome, /Create business workspace/); assert.match(adminHome, /Founder reset/); - assert.match(adminHome, /Delete all current businesses/); + 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/); diff --git a/tests/founder-delete-business-card.test.ts b/tests/founder-delete-business-card.test.ts new file mode 100644 index 0000000..cb37db9 --- /dev/null +++ b/tests/founder-delete-business-card.test.ts @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; + +function read(path: string) { + return readFileSync(new URL(`../${path}`, import.meta.url), 'utf8'); +} + +test('founder single-business delete card keeps hard delete narrow and exact-name gated', () => { + 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, /confirmationName === selectedBusiness\.name/); + assert.match(component, /disabled=\{!selectedBusiness \|\| !selectedBusiness\.deleteEligible \|\| !exactNameMatch\}/); +});