diff --git a/apps/sim/app/api/users/me/deletion/route.ts b/apps/sim/app/api/users/me/deletion/route.ts
new file mode 100644
index 00000000000..ebb8b64aca7
--- /dev/null
+++ b/apps/sim/app/api/users/me/deletion/route.ts
@@ -0,0 +1,42 @@
+import { deleteAccountContract, getAccountDeletionPlanContract } from '@/lib/api/contracts'
+import {
+ defineInternalJsonRoute,
+ internalOrchestrationErrorPolicy,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import {
+ deleteAccountUseCase,
+ previewAccountDeletionUseCase,
+} from '@/lib/users/application/delete-account'
+import { userAccountOperations } from '@/lib/users/application/operations'
+
+export const dynamic = 'force-dynamic'
+
+export const GET = defineInternalJsonRoute({
+ contract: getAccountDeletionPlanContract,
+ auth: internalSessionAuth,
+ operation: userAccountOperations.previewDeletion,
+ rateLimit: internalRateLimits.none({ reason: 'Read-only preview of the caller’s own account' }),
+ errorPolicy: internalOrchestrationErrorPolicy,
+ mapInput: () => ({}),
+ useCase: previewAccountDeletionUseCase,
+ present: (plan) => ({ plan }),
+})
+
+/**
+ * `AccountDeletionBlockedError` classifies itself as a conflict, so the shared
+ * orchestration policy renders a refused deletion as a 409 carrying the first
+ * blocker's sentence. The dialog lists every blocker from the GET above; this
+ * message covers only the race where one appears between the two calls.
+ */
+export const POST = defineInternalJsonRoute({
+ contract: deleteAccountContract,
+ auth: internalSessionAuth,
+ operation: userAccountOperations.delete,
+ rateLimit: internalRateLimits.none({ reason: 'Guarded by the email confirmation it requires' }),
+ errorPolicy: internalOrchestrationErrorPolicy,
+ mapInput: ({ body }) => ({ confirmEmail: body.confirmEmail }),
+ useCase: deleteAccountUseCase,
+ present: () => ({ success: true as const }),
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/general/components/delete-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/general/components/delete-account-modal.tsx
new file mode 100644
index 00000000000..0d3b639067c
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/components/delete-account-modal.tsx
@@ -0,0 +1,160 @@
+'use client'
+
+import { useState } from 'react'
+import { ChipConfirmModal, ChipModalError, ChipModalField } from '@sim/emcn'
+import { createLogger } from '@sim/logger'
+import { sleep } from '@sim/utils/helpers'
+import { formatQuotedNameList, normalizeEmail } from '@sim/utils/string'
+import { signOut } from '@/lib/auth/auth-client'
+import { useAccountDeletionPlan, useDeleteAccount } from '@/hooks/queries/account-deletion'
+import { clearUserData } from '@/stores'
+
+const logger = createLogger('DeleteAccountModal')
+
+/** Matches the naming used in the server's blocker sentences. */
+const MAX_NAMES_LISTED = 3
+
+/** How long the post-deletion sign-out and store cleanup may take before the redirect goes anyway. */
+const SIGN_OUT_TIMEOUT_MS = 3000
+
+interface DeleteAccountModalProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ /** The signed-in account's email, which must be retyped to confirm. */
+ email: string
+}
+
+function names(workspaces: { name: string }[]): string {
+ return formatQuotedNameList(
+ workspaces.map((workspace) => workspace.name),
+ MAX_NAMES_LISTED
+ )
+}
+
+/**
+ * Confirms and performs account deletion.
+ *
+ * The dialog is deliberately explicit rather than alarming: it names every
+ * workspace that goes, every workspace that changes hands, and — when the account
+ * cannot be deleted yet — exactly what has to happen first. Retyping the account's
+ * own email address is the only guard, which is the point: the decision should
+ * cost a deliberate action, not a hunt for the right button.
+ */
+export function DeleteAccountModal({ open, onOpenChange, email }: DeleteAccountModalProps) {
+ const [confirmEmail, setConfirmEmail] = useState('')
+ const { data: plan, isFetching: isPlanFetching, error: planError } = useAccountDeletionPlan(open)
+ const deleteAccount = useDeleteAccount()
+
+ const blockers = plan?.blockers ?? []
+ const toDelete = plan?.workspacesToDelete ?? []
+ const toTransfer = plan?.workspacesToTransfer ?? []
+ const isBlocked = blockers.length > 0
+ const isConfirmed = normalizeEmail(confirmEmail) === normalizeEmail(email)
+ const isPending = deleteAccount.isPending
+
+ const close = () => {
+ onOpenChange(false)
+ setConfirmEmail('')
+ deleteAccount.reset()
+ }
+
+ const handleDelete = () => {
+ deleteAccount.mutate(
+ { confirmEmail },
+ {
+ onSuccess: async () => {
+ /**
+ * The session row is already gone, so signing out can only fail by
+ * telling us so — what matters is that its cookie is dropped and no
+ * cached client state survives the redirect. The race bounds that
+ * cleanup: the account is deleted either way, so a request left hanging
+ * must not strand the user on "Deleting..." forever. The redirect is a
+ * full document load, which discards anything the cleanup missed.
+ */
+ await Promise.race([
+ Promise.allSettled([signOut(), clearUserData()]),
+ sleep(SIGN_OUT_TIMEOUT_MS),
+ ])
+ window.location.href = '/login?fromLogout=true'
+ },
+ onError: (error) => {
+ logger.error('Account deletion failed', { error })
+ },
+ }
+ )
+ }
+
+ const errorMessage =
+ deleteAccount.error?.message ??
+ (planError ? 'Could not check whether this account can be deleted. Try again.' : null)
+
+ return (
+ {
+ if (!next) close()
+ }}
+ size='md'
+ title='Delete account'
+ confirm={{
+ label: 'Delete account',
+ pendingLabel: 'Deleting...',
+ onClick: handleDelete,
+ pending: isPending,
+ disabled: isBlocked || isPlanFetching || !isConfirmed || !plan,
+ disabledTooltip: isBlocked
+ ? 'Resolve the items above first'
+ : isConfirmed
+ ? undefined
+ : 'Enter your account email to confirm',
+ }}
+ >
+ {isBlocked ? (
+
+
Your account can’t be deleted yet:
+
+ {blockers.map((blocker) => (
+
+ {blocker.message}
+
+ ))}
+
+
+ ) : (
+
+
+ This permanently deletes {email} along with its
+ workflows, chats, files, knowledge bases and credentials.{' '}
+ This cannot be undone.
+
+ {toDelete.length > 0 && (
+
+ {toDelete.length === 1 ? 'The workspace ' : 'The workspaces '}
+ {names(toDelete)} and everything
+ in {toDelete.length === 1 ? 'it' : 'them'} will be deleted.
+
+ )}
+ {toTransfer.length > 0 && (
+
+ Billing for {names(toTransfer)}{' '}
+ moves to another admin. Nothing in {toTransfer.length === 1 ? 'it' : 'them'} changes.
+
+ Permanently deletes your account and everything only you can reach — workflows,
+ chats, files, knowledge bases and credentials. This cannot be undone.
+
+
+
+ )}
+
+
>
)
}
diff --git a/apps/sim/hooks/queries/account-deletion.ts b/apps/sim/hooks/queries/account-deletion.ts
new file mode 100644
index 00000000000..60fc77ca836
--- /dev/null
+++ b/apps/sim/hooks/queries/account-deletion.ts
@@ -0,0 +1,54 @@
+import { useMutation, useQuery } from '@tanstack/react-query'
+import { requestJson } from '@/lib/api/client/request'
+import {
+ type AccountDeletionPlan,
+ type DeleteAccountBody,
+ deleteAccountContract,
+ getAccountDeletionPlanContract,
+} from '@/lib/api/contracts/user'
+
+export const accountDeletionKeys = {
+ all: ['account-deletion'] as const,
+ plan: () => [...accountDeletionKeys.all, 'plan'] as const,
+}
+
+/**
+ * Zero: the plan is a consent disclosure, so every dialog open must refetch — its
+ * blockers must reflect the account as it is right now, and a workspace that
+ * gained an admin a minute ago changes the answer.
+ *
+ * The dialog stays mounted while closed, so the previous open's plan is still in
+ * the cache and `isLoading` is false during that refetch. The dialog therefore
+ * holds its confirm on `isFetching`, not `isLoading`, until fresh data lands;
+ * `gcTime: 0` only evicts once the settings panel itself unmounts.
+ */
+export const ACCOUNT_DELETION_PLAN_STALE_TIME = 0
+
+async function fetchAccountDeletionPlan(signal?: AbortSignal): Promise {
+ const data = await requestJson(getAccountDeletionPlanContract, { signal })
+ return data.plan
+}
+
+export function useAccountDeletionPlan(enabled: boolean) {
+ return useQuery({
+ queryKey: accountDeletionKeys.plan(),
+ queryFn: ({ signal }) => fetchAccountDeletionPlan(signal),
+ enabled,
+ staleTime: ACCOUNT_DELETION_PLAN_STALE_TIME,
+ gcTime: 0,
+ retry: false,
+ })
+}
+
+/**
+ * Succeeds exactly once per account: the session that authorized it is gone by
+ * the time the response lands, so there is no cache left to invalidate. The
+ * caller is responsible for clearing local state and sending the user to sign-in.
+ */
+export function useDeleteAccount() {
+ return useMutation({
+ mutationFn: async (body: DeleteAccountBody) => {
+ await requestJson(deleteAccountContract, { body })
+ },
+ })
+}
diff --git a/apps/sim/lib/api/contracts/user.ts b/apps/sim/lib/api/contracts/user.ts
index 2056b7e2a95..9b34e9bbf5a 100644
--- a/apps/sim/lib/api/contracts/user.ts
+++ b/apps/sim/lib/api/contracts/user.ts
@@ -413,3 +413,75 @@ export const subscriptionTransferContract = defineRouteContract({
}),
},
})
+
+/** Every reason an account cannot be erased on its own, as rendered to its owner. */
+export const accountDeletionBlockerSchema = z.object({
+ code: z.enum([
+ 'paid_organization_owner',
+ 'organization_member',
+ 'active_subscription',
+ 'shared_workspace',
+ 'organization_workspace',
+ 'data_drain_owner',
+ ]),
+ /** A sentence naming both the obstacle and the way out. */
+ message: z.string(),
+})
+
+const accountDeletionResourceSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+})
+
+export type AccountDeletionResource = z.output
+
+export const accountDeletionPlanSchema = z.object({
+ blockers: z.array(accountDeletionBlockerSchema),
+ /** Workspaces nobody else can reach — erased along with the account. */
+ workspacesToDelete: z.array(accountDeletionResourceSchema),
+ /**
+ * Workspaces the account only anchors — it pays for them or is recorded as
+ * their owner while holding no access to them. The anchor moves to an admin
+ * who does; nothing inside changes hands.
+ */
+ workspacesToTransfer: z.array(accountDeletionResourceSchema),
+})
+
+export type AccountDeletionBlocker = z.output
+export type AccountDeletionPlan = z.output
+
+export const getAccountDeletionPlanContract = defineRouteContract({
+ method: 'GET',
+ path: '/api/users/me/deletion',
+ response: {
+ mode: 'json',
+ schema: z.object({
+ plan: accountDeletionPlanSchema,
+ }),
+ },
+})
+
+export const deleteAccountBodySchema = z.object({
+ /**
+ * The account's own email address, retyped. Checked server-side against the
+ * session's account so a mis-wired client cannot delete anything else.
+ */
+ confirmEmail: z
+ .string({ error: 'Confirm your email address to delete your account' })
+ .min(1, 'Confirm your email address to delete your account')
+ .max(320, 'Email address is too long'),
+})
+
+export type DeleteAccountBody = z.input
+
+export const deleteAccountContract = defineRouteContract({
+ method: 'POST',
+ path: '/api/users/me/deletion',
+ body: deleteAccountBodySchema,
+ response: {
+ mode: 'json',
+ schema: z.object({
+ success: z.literal(true),
+ }),
+ },
+})
diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts
index b012ce3732b..8f7cb189175 100644
--- a/apps/sim/lib/auth/auth.ts
+++ b/apps/sim/lib/auth/auth.ts
@@ -262,40 +262,18 @@ export const auth = betterAuth({
},
},
user: {
+ /**
+ * Account deletion runs through `POST /api/users/me/deletion`, which owns the
+ * whole procedure — the blocker preflight, the storage purge, and the
+ * constraint-ordered teardown that a bare `DELETE FROM "user"` cannot
+ * express. Better Auth's endpoint stays off, and `beforeDelete` refuses
+ * unconditionally so that flipping `enabled` can never route a deletion
+ * around any of it.
+ */
deleteUser: {
enabled: false,
- beforeDelete: async (deletingUser) => {
- const { isSoleOwnerOfPaidOrganization } = await import(
- '@/lib/billing/organizations/membership'
- )
- const check = await isSoleOwnerOfPaidOrganization(deletingUser.id)
- if (check.isBlocker) {
- throw new Error(
- `You are the owner of ${check.organizationName ?? 'an active paid organization'}. Transfer ownership before deleting your account.`
- )
- }
-
- const { reassignBilledAccountForUser, reassignOwnedWorkspacesForUser } = await import(
- '@/lib/workspaces/utils'
- )
- const { unresolved } = await reassignBilledAccountForUser(deletingUser.id)
- if (unresolved.length > 0) {
- throw new Error(
- `Your account is the billing account for ${unresolved.length} workspace${unresolved.length === 1 ? '' : 's'} with no other admin to take it over. Add another admin to ${unresolved.length === 1 ? 'that workspace' : 'those workspaces'} or delete ${unresolved.length === 1 ? 'it' : 'them'} before deleting your account.`
- )
- }
-
- // Reassign workspace ownership BEFORE deletion so the `workspace.owner_id`
- // ON DELETE CASCADE can never silently nuke workspaces this user owns
- // (e.g. org workspaces they created but are billed to the org owner).
- const { unresolved: ownedUnresolved } = await reassignOwnedWorkspacesForUser(
- deletingUser.id
- )
- if (ownedUnresolved.length > 0) {
- throw new Error(
- `Your account owns ${ownedUnresolved.length} workspace${ownedUnresolved.length === 1 ? '' : 's'} with no other admin to take over ownership. Add another admin to ${ownedUnresolved.length === 1 ? 'that workspace' : 'those workspaces'} or delete ${ownedUnresolved.length === 1 ? 'it' : 'them'} before deleting your account.`
- )
- }
+ beforeDelete: async () => {
+ throw new Error('Account deletion runs through POST /api/users/me/deletion')
},
},
},
diff --git a/apps/sim/lib/users/account-deletion.test.ts b/apps/sim/lib/users/account-deletion.test.ts
new file mode 100644
index 00000000000..b9a06c66eec
--- /dev/null
+++ b/apps/sim/lib/users/account-deletion.test.ts
@@ -0,0 +1,209 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import {
+ AccountDeletionBlockedError,
+ type AccountDeletionFacts,
+ classifyAccountDeletion,
+ extractProfilePictureKey,
+ type WorkspaceCompany,
+ type WorkspaceRow,
+} from '@/lib/users/account-deletion'
+
+function workspace(overrides: Partial = {}): WorkspaceRow {
+ return { id: 'ws-1', name: 'My workspace', organizationId: null, ...overrides }
+}
+
+function company(overrides: Partial = {}): WorkspaceCompany {
+ return { hasOtherMembers: false, isMember: true, hasAdminSuccessor: false, ...overrides }
+}
+
+function facts(overrides: Partial = {}): AccountDeletionFacts {
+ return {
+ workspaces: [],
+ company: new Map(),
+ organizationNames: [],
+ paidOrganizationName: null,
+ personalPlan: null,
+ hasDataDrains: false,
+ ...overrides,
+ }
+}
+
+function codes(plan: { blockers: { code: string }[] }): string[] {
+ return plan.blockers.map((blocker) => blocker.code)
+}
+
+describe('classifyAccountDeletion', () => {
+ it('deletes a solo personal workspace with no blockers — the ordinary individual account', () => {
+ const ws = workspace()
+ const plan = classifyAccountDeletion(
+ facts({ workspaces: [ws], company: new Map([[ws.id, company()]]) })
+ )
+
+ expect(plan.blockers).toEqual([])
+ expect(plan.workspacesToDelete).toEqual([{ id: ws.id, name: ws.name }])
+ expect(plan.workspacesToTransfer).toEqual([])
+ })
+
+ it('blocks a workspace that other people are in rather than reassigning it', () => {
+ const ws = workspace({ name: 'Shared' })
+ const plan = classifyAccountDeletion(
+ facts({
+ workspaces: [ws],
+ company: new Map([[ws.id, company({ hasOtherMembers: true, hasAdminSuccessor: true })]]),
+ })
+ )
+
+ expect(codes(plan)).toEqual(['shared_workspace'])
+ expect(plan.workspacesToDelete).toEqual([])
+ expect(plan.workspacesToTransfer).toEqual([])
+ expect(plan.blockers[0].message).toContain('Shared')
+ })
+
+ it('blocks a workspace the account merely belongs to, where it is not the anchor', () => {
+ const ws = workspace()
+ const plan = classifyAccountDeletion(
+ facts({
+ workspaces: [ws],
+ company: new Map([[ws.id, company({ hasOtherMembers: true, hasAdminSuccessor: true })]]),
+ })
+ )
+
+ expect(codes(plan)).toEqual(['shared_workspace'])
+ })
+
+ it('transfers a billing anchor the account holds no access to', () => {
+ const ws = workspace({ name: 'Anchored', ownerId: 'other-admin' })
+ const plan = classifyAccountDeletion(
+ facts({
+ workspaces: [ws],
+ company: new Map([
+ [ws.id, company({ hasOtherMembers: true, isMember: false, hasAdminSuccessor: true })],
+ ]),
+ })
+ )
+
+ expect(plan.blockers).toEqual([])
+ expect(plan.workspacesToTransfer).toEqual([{ id: ws.id, name: 'Anchored' }])
+ })
+
+ it('blocks an anchor nobody can inherit, rather than orphaning the billing reference', () => {
+ const ws = workspace()
+ const plan = classifyAccountDeletion(
+ facts({
+ workspaces: [ws],
+ company: new Map([[ws.id, company({ hasOtherMembers: true, isMember: false })]]),
+ })
+ )
+
+ expect(codes(plan)).toEqual(['shared_workspace'])
+ })
+
+ it('blocks a solo workspace that belongs to an organization, whose ledger is shared', () => {
+ const ws = workspace({ organizationId: 'org-1', name: 'Org space' })
+ const plan = classifyAccountDeletion(
+ facts({ workspaces: [ws], company: new Map([[ws.id, company()]]) })
+ )
+
+ expect(codes(plan)).toEqual(['organization_workspace'])
+ expect(plan.workspacesToDelete).toEqual([])
+ })
+
+ it('reports paid organization ownership instead of plain membership', () => {
+ const plan = classifyAccountDeletion(
+ facts({
+ paidOrganizationName: 'Acme',
+ organizationNames: ['Acme'],
+ })
+ )
+
+ expect(codes(plan)).toEqual(['paid_organization_owner'])
+ expect(plan.blockers[0].message).toContain('Acme')
+ })
+
+ it('asks a plain organization member to leave first, so their seat is released', () => {
+ const plan = classifyAccountDeletion(facts({ organizationNames: ['Acme'] }))
+
+ expect(codes(plan)).toEqual(['organization_member'])
+ })
+
+ it('collects every independent blocker in one pass', () => {
+ const ws = workspace({ name: 'Shared' })
+ const plan = classifyAccountDeletion(
+ facts({
+ workspaces: [ws],
+ company: new Map([[ws.id, company({ hasOtherMembers: true })]]),
+ organizationNames: ['Acme'],
+ personalPlan: 'pro',
+ hasDataDrains: true,
+ })
+ )
+
+ expect(codes(plan)).toEqual([
+ 'organization_member',
+ 'active_subscription',
+ 'data_drain_owner',
+ 'shared_workspace',
+ ])
+ })
+
+ it('names up to three workspaces and summarizes the rest', () => {
+ const workspaces = ['One', 'Two', 'Three', 'Four'].map((name, index) =>
+ workspace({ id: `ws-${index}`, name })
+ )
+ const plan = classifyAccountDeletion(
+ facts({
+ workspaces,
+ company: new Map(
+ workspaces.map((ws) => [ws.id, company({ hasOtherMembers: true })] as const)
+ ),
+ })
+ )
+
+ expect(plan.blockers[0].message).toContain('"One", "Two", "Three" and 1 more')
+ })
+})
+
+describe('AccountDeletionBlockedError', () => {
+ it('classifies itself as a conflict so the route renders a refusal as 409, not 500', () => {
+ const error = new AccountDeletionBlockedError([
+ { code: 'active_subscription', message: 'Your pro plan is still active.' },
+ ])
+
+ expect(error.code).toBe('conflict')
+ expect(error.message).toBe('Your pro plan is still active.')
+ })
+
+ it('still carries a message when constructed with no blockers', () => {
+ expect(new AccountDeletionBlockedError([]).message).toMatch(/cannot be deleted/i)
+ })
+})
+
+describe('extractProfilePictureKey', () => {
+ it('extracts the storage key from an uploaded avatar path', () => {
+ expect(extractProfilePictureKey('/api/files/serve/profile-pictures%2Fu1%2Favatar.png')).toBe(
+ 'profile-pictures/u1/avatar.png'
+ )
+ })
+
+ it('strips the storage-provider segment', () => {
+ expect(extractProfilePictureKey('/api/files/serve/s3/profile-pictures%2Fu1%2Fa.png')).toBe(
+ 'profile-pictures/u1/a.png'
+ )
+ })
+
+ it('ignores an external avatar, which is the provider’s object and not ours to delete', () => {
+ expect(extractProfilePictureKey('https://lh3.googleusercontent.com/a/abc123')).toBeNull()
+ })
+
+ it('ignores a served key outside the profile-pictures prefix', () => {
+ expect(extractProfilePictureKey('/api/files/serve/workspace%2Fw1%2Freport.pdf')).toBeNull()
+ })
+
+ it('handles an account with no picture', () => {
+ expect(extractProfilePictureKey(null)).toBeNull()
+ expect(extractProfilePictureKey('')).toBeNull()
+ })
+})
diff --git a/apps/sim/lib/users/account-deletion.ts b/apps/sim/lib/users/account-deletion.ts
new file mode 100644
index 00000000000..98c063edc0f
--- /dev/null
+++ b/apps/sim/lib/users/account-deletion.ts
@@ -0,0 +1,619 @@
+import { db } from '@sim/db'
+import {
+ dataDrains,
+ document,
+ knowledgeBase,
+ member,
+ organization,
+ permissions,
+ user,
+ workspaceFile,
+ workspaceFiles,
+ workspace as workspaceTable,
+} from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import { formatQuotedNameList } from '@sim/utils/string'
+import { and, eq, gt, inArray, isNotNull, ne, notExists, or, sql } from 'drizzle-orm'
+import type {
+ AccountDeletionBlocker,
+ AccountDeletionPlan,
+ AccountDeletionResource,
+} from '@/lib/api/contracts/user'
+import { getHighestPriorityPersonalSubscription } from '@/lib/billing/core/plan'
+import { isSoleOwnerOfPaidOrganization } from '@/lib/billing/organizations/membership'
+import { OrchestrationError } from '@/lib/core/orchestration/types'
+import type { StorageContext } from '@/lib/uploads'
+import { isUsingCloudStorage, StorageService } from '@/lib/uploads'
+import {
+ reassignBilledAccountForUser,
+ reassignOwnedWorkspacesForUser,
+} from '@/lib/workspaces/utils'
+
+const logger = createLogger('AccountDeletion')
+
+/**
+ * Rows per storage page, and keys per delete call. `StorageService.deleteFiles`
+ * chunks internally at S3's 1,000-key `DeleteObjects` limit, so anything smaller
+ * just under-fills that call and multiplies round trips.
+ */
+const STORAGE_PAGE_SIZE = 1000
+
+/**
+ * Upper bound on stored-object keys held in memory between the pre-commit
+ * collection and the post-commit purge. Beyond this the remainder is left
+ * orphaned and logged — leaking objects beats exhausting the process mid-erasure.
+ */
+const MAX_PURGE_KEYS = 100_000
+
+/** Names listed inline in a blocker sentence before it summarizes the rest. */
+const MAX_NAMES_LISTED = 3
+
+/**
+ * Refuses a deletion whose preconditions are not met. Classified as a conflict
+ * rather than a bad request: the caller asked for something legitimate that
+ * their current entanglements do not allow yet, and the shared orchestration
+ * policy already renders that as a 409 carrying this message.
+ */
+export class AccountDeletionBlockedError extends OrchestrationError {
+ constructor(readonly blockers: AccountDeletionBlocker[]) {
+ super('conflict', blockers[0]?.message ?? 'This account cannot be deleted yet.')
+ this.name = 'AccountDeletionBlockedError'
+ }
+}
+
+export interface WorkspaceRow {
+ id: string
+ name: string
+ organizationId: string | null
+}
+
+const WORKSPACE_COLUMNS = {
+ id: workspaceTable.id,
+ name: workspaceTable.name,
+ organizationId: workspaceTable.organizationId,
+} as const
+
+/**
+ * Loads every workspace the account touches — the ones it anchors as owner or
+ * billing account, and the ones it merely has access to.
+ *
+ * Anchors have to be here because `owner_id` cascades (and would silently take
+ * the workspace with it) while `billed_account_user_id` is `NO ACTION` (and fails
+ * the statement outright, ahead of that cascade). Plain memberships have to be
+ * here for the opposite reason: they impose no constraint at all, yet the
+ * account's workflows, knowledge bases and files inside them would cascade away
+ * with it.
+ */
+async function loadRelatedWorkspaces(userId: string): Promise {
+ const [anchored, joined] = await Promise.all([
+ db
+ .select(WORKSPACE_COLUMNS)
+ .from(workspaceTable)
+ .where(
+ or(eq(workspaceTable.ownerId, userId), eq(workspaceTable.billedAccountUserId, userId))
+ ),
+ db
+ .select(WORKSPACE_COLUMNS)
+ .from(permissions)
+ .innerJoin(workspaceTable, eq(workspaceTable.id, permissions.entityId))
+ .where(and(eq(permissions.entityType, 'workspace'), eq(permissions.userId, userId))),
+ ])
+
+ const byId = new Map()
+ for (const row of [...anchored, ...joined]) byId.set(row.id, row)
+ return [...byId.values()]
+}
+
+export interface WorkspaceCompany {
+ /** Whether anyone other than the departing account holds access to the workspace. */
+ hasOtherMembers: boolean
+ /** Whether the departing account itself holds access to it. */
+ isMember: boolean
+ /**
+ * Whether some other admin could inherit the billing and ownership anchors.
+ * Only the existence matters here — the handover itself is performed by
+ * `reassignBilledAccountForUser` / `reassignOwnedWorkspacesForUser`, which
+ * resolve the successor themselves.
+ */
+ hasAdminSuccessor: boolean
+}
+
+/**
+ * Answers, for each related workspace, who else is in it and whether the
+ * departing account is in it at all.
+ *
+ * Aggregated in Postgres rather than folded in JS: the three facts are booleans,
+ * and a workspace with thousands of members would otherwise transfer thousands of
+ * rows to compute them. One statement still means the answers cannot be read at
+ * different moments.
+ */
+async function loadWorkspaceCompany(
+ userId: string,
+ workspaces: WorkspaceRow[]
+): Promise