Skip to content
Open
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
76 changes: 46 additions & 30 deletions app/admin/[businessId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
connectExistingBusinessOwnerAction,
createBusinessMessagingServiceAction,
createBusinessTwilioSubaccountAction,
deleteTestBusinessAction,
deleteBusinessPermanentlyAction,
inviteBusinessOwnerAction,
markBusinessLiveAction,
provisionBusinessAction,
Expand All @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -211,7 +212,7 @@ export default async function AdminBusinessDetailPage({
params: { businessId: string };
searchParams?: Record<string, string | string[] | undefined>;
}) {
await requireAdmin();
const admin = await requireAdmin();

const [businessRecord, successfulLeadCount, operatorEvents] = await Promise.all([
db.business.findUnique({
Expand Down Expand Up @@ -1346,35 +1347,50 @@ export default async function AdminBusinessDetailPage({
<CardTitle>Advanced</CardTitle>
<CardDescription>Destructive or lifecycle controls stay separate from the guided setup flow.</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-3">
{isBusinessArchived(business) ? (
<form action={restoreBusinessAction}>
<input name="businessId" type="hidden" value={business.id} />
<input name="confirmationName" type="hidden" value={business.name} />
<Button size="sm" type="submit">
Restore business
</Button>
</form>
<CardContent className="grid gap-4 xl:grid-cols-[0.8fr_1.2fr]">
<div className="space-y-3 rounded-xl border bg-background/80 p-4 text-sm">
<p className="font-medium text-foreground">Lifecycle controls</p>
<p className="text-muted-foreground">
Archive remains the normal lifecycle control. Restore re-enables a paused business. Permanent delete is founder-only and requires explicit confirmation.
</p>

{isBusinessArchived(business) ? (
<form action={restoreBusinessAction}>
<input name="businessId" type="hidden" value={business.id} />
<input name="confirmationName" type="hidden" value={business.name} />
<Button size="sm" type="submit">
Restore business
</Button>
</form>
) : (
<form action={archiveBusinessAction}>
<input name="businessId" type="hidden" value={business.id} />
<input name="confirmationName" type="hidden" value={business.name} />
<Button size="sm" type="submit" variant="outline">
Archive business
</Button>
</form>
)}
</div>

{admin.isFounder ? (
<AdminPermanentDeleteBusinessCard
action={deleteBusinessPermanentlyAction}
business={{
id: business.id,
name: business.name,
isTestBusiness: business.isTestBusiness,
archivedAt: business.archivedAt,
ownerClerkId: business.ownerClerkId,
ownerEmail: business.notificationSettings?.ownerEmail || null,
}}
returnTo="/admin"
/>
) : (
<form action={archiveBusinessAction}>
<input name="businessId" type="hidden" value={business.id} />
<input name="confirmationName" type="hidden" value={business.name} />
<Button size="sm" type="submit" variant="outline">
Archive business
</Button>
</form>
<div className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground">
Permanent delete is founder-only and is intentionally separated from the standard admin setup flow.
</div>
)}
{canDeleteTestBusiness(business) ? (
<form action={deleteTestBusinessAction}>
<input name="businessId" type="hidden" value={business.id} />
<input name="confirmationName" type="hidden" value={business.name} />
<Button size="sm" type="submit" variant="destructive">
Delete test business
</Button>
</form>
) : business.isTestBusiness ? (
<p className="text-sm text-muted-foreground">{getDeleteTestBusinessBlockedReason(business)}</p>
) : null}
</CardContent>
</Card>

Expand Down
86 changes: 86 additions & 0 deletions app/admin/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();

Expand Down
70 changes: 35 additions & 35 deletions app/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -19,8 +20,6 @@ import {
buildAdminOnboardingConfidence,
buildAdminBusinessPickerLabel,
buildAdminNextStep,
canDeleteTestBusiness,
getDeleteTestBusinessBlockedReason,
isBusinessArchived,
matchesAdminBoardFilterState,
type AdminBoardFilter,
Expand Down Expand Up @@ -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,
Expand All @@ -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 (
<div className="container space-y-6 py-8">
Expand Down Expand Up @@ -481,11 +477,16 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor
</div>

{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}
{archived ? <div className="rounded-md border border-accent bg-accent/40 p-3 text-sm">Business archived. Automation is paused until you restore it or permanently delete it as founder.</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">
{deletedBusinessName ? `Deleted ${deletedBusinessName} permanently.` : 'Demo/test business deleted permanently.'}
<p>{deletedBusinessName ? `Deleted ${deletedBusinessName} permanently.` : 'Business deleted permanently.'}</p>
{deletedExternalReview ? (
<p className="mt-1 text-xs text-muted-foreground">
Local business record deleted. External Twilio/Stripe/Clerk records may still need manual review.
</p>
) : null}
</div>
) : null}
{founderResetResult === 'deleted' && Number.isFinite(founderResetDeleted) ? (
Expand Down Expand Up @@ -665,22 +666,22 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor
<summary className="cursor-pointer list-none space-y-1">
<CardTitle>Advanced founder tools</CardTitle>
<CardDescription>
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.
</CardDescription>
</summary>
<div className="mt-4 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.
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.
</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-base font-semibold text-foreground">Delete one business permanently</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.
Select a workspace, review its owner and status, then complete the required confirmations before permanent deletion is allowed.
</p>
</div>
<FounderDeleteBusinessCard candidates={founderDeleteCandidates} deleteAction={deleteTestBusinessAction} />
<FounderDeleteBusinessCard candidates={founderDeleteCandidates} deleteAction={deleteBusinessPermanentlyAction} />
</div>

<details className="rounded-xl border border-destructive/30 bg-background/80 p-4 text-sm">
Expand Down Expand Up @@ -796,8 +797,7 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor
<CardHeader className="gap-2">
<CardTitle className="text-xl">Selected business actions</CardTitle>
<CardDescription>
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.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 xl:grid-cols-[1.05fr_0.95fr]">
Expand Down Expand Up @@ -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
</Link>
<Link className={buttonVariants({ variant: 'ghost', size: 'sm' })} href={`/admin/${selectedBusinessRow.business.id}/workspace`}>
View support workspace snapshot
Expand Down Expand Up @@ -897,25 +897,22 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor
</form>
)}

{canDeleteTestBusiness(selectedBusinessRow.business) ? (
<form action={deleteTestBusinessAction} className="rounded-xl border border-destructive/30 bg-destructive/5 p-4 text-sm">
<input type="hidden" name="businessId" value={selectedBusinessRow.business.id} />
<input type="hidden" name="returnTo" value={boardReturnTo} />
<div className="space-y-2">
<Label htmlFor="deleteBusinessName">Type business name to permanently delete</Label>
<Input id="deleteBusinessName" name="confirmationName" placeholder={selectedBusinessRow.business.name} />
</div>
<p className="mt-2 text-xs text-destructive">
Permanent delete removes the business plus its leads, calls, messages, notification settings, owner notifications, operator
events, simulator runs, and SMS consent records.
</p>
<Button className="mt-3" type="submit" variant="destructive">
Delete demo/test business permanently
</Button>
</form>
{admin.isFounder ? (
<AdminPermanentDeleteBusinessCard
action={deleteBusinessPermanentlyAction}
business={{
id: selectedBusinessRow.business.id,
name: selectedBusinessRow.business.name,
isTestBusiness: selectedBusinessRow.business.isTestBusiness,
archivedAt: selectedBusinessRow.business.archivedAt,
ownerClerkId: selectedBusinessRow.business.ownerClerkId,
ownerEmail: selectedBusinessRow.business.notificationSettings?.ownerEmail || null,
}}
returnTo={boardReturnTo}
/>
) : (
<div className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground">
{selectedBusinessDeleteBlockedReason || 'Delete stays unavailable for this business.'}
Permanent delete is founder-only and stays inside managed business controls.
</div>
)}
</div>
Expand Down Expand Up @@ -1031,6 +1028,9 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor
<Link className={buttonVariants({ variant: 'ghost', size: 'sm' })} href={`/admin/${business.id}/workspace`}>
View support workspace snapshot
</Link>
<Link className={buttonVariants({ variant: 'ghost', size: 'sm' })} href={`/admin/${business.id}#advanced`}>
Manage business
</Link>
{assignedNumber && !cardState.isArchived ? (
<form action={resyncBusinessWebhooksAction}>
<input type="hidden" name="businessId" value={business.id} />
Expand Down
Loading
Loading