From 522009374767ae6279b0189ef02fba65a01f1d25 Mon Sep 17 00:00:00 2001
From: Waleed Latif
Date: Tue, 18 Aug 2026 16:54:36 -0700
Subject: [PATCH 1/3] feat(account): let users delete their own account
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds a GDPR self-serve account deletion path: a preflight that reports
what deletion would remove and every reason it would be refused, and a
confirmed delete that erases the account and everything only it can reach.
Deletion refuses while the account is still entangled rather than
reassigning its content. Most tables reference user.id with ON DELETE
CASCADE, and those cascades do not distinguish content in the account's
own workspace from content it created inside somebody else's, so each
blocker names the existing action that untangles it (leave the workspace,
leave the organization, cancel the plan) — all of which already hand work
over on their own tested paths.
---
apps/sim/app/api/users/me/deletion/route.ts | 42 ++
.../components/delete-account-modal.tsx | 160 ++++++
.../settings/components/general/general.tsx | 25 +
apps/sim/hooks/queries/account-deletion.ts | 54 ++
apps/sim/lib/api/contracts/user.ts | 72 +++
apps/sim/lib/auth/auth.ts | 42 +-
apps/sim/lib/users/account-deletion.test.ts | 165 ++++++
apps/sim/lib/users/account-deletion.ts | 491 ++++++++++++++++++
.../users/application/delete-account.test.ts | 63 +++
.../lib/users/application/delete-account.ts | 90 ++++
apps/sim/lib/users/application/operations.ts | 13 +
packages/audit/src/types.ts | 4 +
.../src/components/chip-modal/chip-modal.tsx | 42 +-
packages/testing/src/mocks/audit.mock.ts | 2 +
scripts/check-api-validation-contracts.ts | 4 +-
15 files changed, 1228 insertions(+), 41 deletions(-)
create mode 100644 apps/sim/app/api/users/me/deletion/route.ts
create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/general/components/delete-account-modal.tsx
create mode 100644 apps/sim/hooks/queries/account-deletion.ts
create mode 100644 apps/sim/lib/users/account-deletion.test.ts
create mode 100644 apps/sim/lib/users/account-deletion.ts
create mode 100644 apps/sim/lib/users/application/delete-account.test.ts
create mode 100644 apps/sim/lib/users/application/delete-account.ts
create mode 100644 apps/sim/lib/users/application/operations.ts
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..7459d0ef457
--- /dev/null
+++ b/apps/sim/lib/users/account-deletion.test.ts
@@ -0,0 +1,165 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import {
+ type AccountDeletionFacts,
+ classifyAccountDeletion,
+ 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')
+ })
+})
diff --git a/apps/sim/lib/users/account-deletion.ts b/apps/sim/lib/users/account-deletion.ts
new file mode 100644
index 00000000000..32444065249
--- /dev/null
+++ b/apps/sim/lib/users/account-deletion.ts
@@ -0,0 +1,491 @@
+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, 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
+
+/** 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