Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion app/admin/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
108 changes: 72 additions & 36 deletions app/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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';
Expand Down Expand Up @@ -163,6 +165,7 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor
ownerEmail: true,
},
},
ownerClerkId: true,
},
}),
db.lead.groupBy({
Expand Down Expand Up @@ -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 (
<div className="container space-y-6 py-8">
Expand Down Expand Up @@ -514,7 +527,11 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor
{error ? <div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">{error}</div> : null}
{archived ? <div className="rounded-md border border-accent bg-accent/40 p-3 text-sm">Business archived. Permanent delete stays locked until the workspace is clearly demo/test.</div> : null}
{restored ? <div className="rounded-md border border-accent bg-accent/40 p-3 text-sm">Business restored to active triage.</div> : null}
{deleted ? <div className="rounded-md border border-accent bg-accent/40 p-3 text-sm">Demo/test business deleted permanently.</div> : null}
{deleted ? (
<div className="rounded-md border border-accent bg-accent/40 p-3 text-sm">
{deletedBusinessName ? `Deleted ${deletedBusinessName} permanently.` : 'Demo/test business deleted permanently.'}
</div>
) : null}
{founderResetResult === 'deleted' && Number.isFinite(founderResetDeleted) ? (
<div className="rounded-md border border-accent bg-accent/40 p-3 text-sm">
Deleted {founderResetDeleted} current {founderResetDeleted === 1 ? 'business' : 'businesses'}. You can create one clean business workspace now.
Expand Down Expand Up @@ -670,47 +687,66 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor
<CardHeader>
<CardTitle>Founder reset</CardTitle>
<CardDescription>
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.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 xl:grid-cols-[1.1fr_0.9fr]">
<div className="rounded-xl border bg-background/80 p-4 text-sm">
<p className="font-medium text-foreground">
{founderResetBusinessCount} current {founderResetBusinessCount === 1 ? 'business' : 'businesses'} will be deleted
</p>
<p className="mt-2 text-muted-foreground">
This permanently deletes every current business record, including archived ones, so you can restart from one clean workspace.
</p>
{founderResetBusinessPreview.length > 0 ? (
<p className="mt-3 text-muted-foreground">
Preview: {founderResetBusinessPreview.join(', ')}
{founderResetBusinessCount > founderResetBusinessPreview.length
? ` and ${founderResetBusinessCount - founderResetBusinessPreview.length} more.`
: '.'}
<CardContent className="space-y-4">
<div className="rounded-xl border bg-background/80 p-4 text-sm text-muted-foreground">
Delete one test/demo business is the primary founder cleanup tool. Real customer workspaces should be archived or disabled, not hard deleted.
</div>

<div>
<div className="mb-3 space-y-1">
<p className="text-base font-semibold text-foreground">Delete one test/demo business</p>
<p className="text-sm text-muted-foreground">
Select a workspace, review whether it is test/demo or real, then type the exact business name before permanent deletion is allowed.
</p>
) : (
<p className="mt-3 text-muted-foreground">No businesses are currently present.</p>
)}
</div>
<FounderDeleteBusinessCard candidates={founderDeleteCandidates} deleteAction={deleteTestBusinessAction} />
</div>

<form action={founderDeleteAllBusinessesAction} className="rounded-xl border border-destructive/30 bg-background/80 p-4">
<div className="space-y-2">
<Label htmlFor="founderResetConfirmationText">Type {FOUNDER_DELETE_ALL_BUSINESSES_CONFIRMATION}</Label>
<Input
id="founderResetConfirmationText"
name="confirmationText"
autoComplete="off"
placeholder={FOUNDER_DELETE_ALL_BUSINESSES_CONFIRMATION}
/>
<details className="rounded-xl border border-destructive/30 bg-background/80 p-4 text-sm">
<summary className="cursor-pointer font-medium text-foreground">Advanced founder reset: delete all current businesses</summary>
<div className="mt-4 grid gap-4 xl:grid-cols-[1.1fr_0.9fr]">
<div className="rounded-xl border bg-background/80 p-4 text-sm">
<p className="font-medium text-foreground">
{founderResetBusinessCount} current {founderResetBusinessCount === 1 ? 'business' : 'businesses'} would be deleted
</p>
<p className="mt-2 text-muted-foreground">
This permanently deletes every current business record, including archived ones. Keep this as a last-resort cleanup path only.
</p>
{founderResetBusinessPreview.length > 0 ? (
<p className="mt-3 text-muted-foreground">
Preview: {founderResetBusinessPreview.join(', ')}
{founderResetBusinessCount > founderResetBusinessPreview.length
? ` and ${founderResetBusinessCount - founderResetBusinessPreview.length} more.`
: '.'}
</p>
) : (
<p className="mt-3 text-muted-foreground">No businesses are currently present.</p>
)}
</div>

<form action={founderDeleteAllBusinessesAction} className="rounded-xl border border-destructive/30 bg-background/80 p-4">
<div className="space-y-2">
<Label htmlFor="founderResetConfirmationText">Type {FOUNDER_DELETE_ALL_BUSINESSES_CONFIRMATION}</Label>
<Input
id="founderResetConfirmationText"
name="confirmationText"
autoComplete="off"
placeholder={FOUNDER_DELETE_ALL_BUSINESSES_CONFIRMATION}
/>
</div>
<p className="mt-3 text-xs text-muted-foreground">
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.
</p>
<Button className="mt-4" type="submit" variant="destructive" disabled={founderResetBusinessCount === 0}>
Delete all current businesses
</Button>
</form>
</div>
<p className="mt-3 text-xs text-muted-foreground">
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.
</p>
<Button className="mt-4" type="submit" variant="destructive" disabled={founderResetBusinessCount === 0}>
Delete all current businesses
</Button>
</form>
</details>
</CardContent>
</Card>
) : null}
Expand Down
136 changes: 136 additions & 0 deletions components/founder-delete-business-card.tsx
Original file line number Diff line number Diff line change
@@ -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<void>;
}) {
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 (
<div className="grid gap-4 xl:grid-cols-[1.1fr_0.9fr]">
<div className="rounded-xl border bg-background/80 p-4 text-sm">
<div className="space-y-2">
<Label htmlFor="founderDeleteBusinessId">Choose a business</Label>
<Select
id="founderDeleteBusinessId"
onChange={(event) => setSelectedBusinessId(event.currentTarget.value)}
value={selectedBusinessId}
>
{candidates.length === 0 ? <option value="">No businesses available</option> : null}
{candidates.map((candidate) => (
<option key={candidate.id} value={candidate.id}>
{candidate.name}
</option>
))}
</Select>
</div>

{selectedBusiness ? (
<div className="mt-4 space-y-4">
<div className="flex flex-wrap items-center gap-2">
<p className="text-base font-semibold text-foreground">{selectedBusiness.name}</p>
<Badge variant={selectedBusiness.isTestDemo ? 'outline' : 'secondary'}>
{selectedBusiness.isTestDemo ? 'Test/demo' : 'Real customer'}
</Badge>
<Badge variant={selectedBusiness.isArchived ? 'outline' : 'secondary'}>
{selectedBusiness.isArchived ? 'Archived' : 'Active'}
</Badge>
</div>

<div className="grid gap-3 sm:grid-cols-2">
<div>
<p className="font-medium text-foreground">Owner email</p>
<p className="mt-1 text-muted-foreground">{selectedBusiness.ownerEmail || 'Owner email missing'}</p>
</div>
<div>
<p className="font-medium text-foreground">Delete policy</p>
<p className="mt-1 text-muted-foreground">
{selectedBusiness.deleteEligible
? 'Hard delete test/demo businesses only.'
: selectedBusiness.isTestDemo
? 'Archive this workspace first.'
: 'Archive real customers instead.'}
</p>
</div>
</div>

<div className="rounded-lg border border-destructive/20 bg-destructive/5 p-3 text-xs text-muted-foreground">
Deletion is permanent. This removes the business and its business-owned records through the existing schema cascades.
</div>
</div>
) : (
<p className="mt-4 text-muted-foreground">Select a business to review its delete safety details.</p>
)}
</div>

<form action={deleteAction} className="rounded-xl border border-destructive/30 bg-background/80 p-4 text-sm">
<input name="businessId" type="hidden" value={selectedBusiness?.id || ''} />
<input name="returnTo" type="hidden" value="/admin" />

<div className="space-y-2">
<Label htmlFor="founderDeleteBusinessConfirmation">Type the exact business name</Label>
<Input
autoComplete="off"
disabled={!selectedBusiness}
id="founderDeleteBusinessConfirmation"
name="confirmationName"
onChange={(event) => setConfirmationName(event.currentTarget.value)}
placeholder={selectedBusiness?.name || 'Choose a business first'}
value={confirmationName}
/>
</div>

<p className="mt-3 text-xs text-muted-foreground">
The delete button unlocks only when the typed name matches exactly. Archive real customers. Hard delete test/demo businesses only.
</p>

{selectedBusiness && !selectedBusiness.deleteEligible ? (
<p className="mt-3 text-xs text-destructive">
{selectedBusiness.deleteBlockedReason || 'Hard delete stays locked for this business.'}
</p>
) : null}

<Button
className="mt-4"
disabled={!selectedBusiness || !selectedBusiness.deleteEligible || !exactNameMatch}
type="submit"
variant="destructive"
>
Delete this business
</Button>
</form>
</div>
);
}
16 changes: 15 additions & 1 deletion tests/admin-bulk-delete-action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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\.`/);
});
13 changes: 12 additions & 1 deletion tests/admin-business-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 } }),
Expand All @@ -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);
Expand All @@ -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}`],
},
},
});
}
});

Expand Down
Loading
Loading