Admin Panel — Hard Deletes
Hard-delete is permanent and irreversible. There is no undo button and no automated recovery — Neon PITR exists but is a manual DBA operation reserved for catastrophic incidents (see Recovery).
Before reading further, ask yourself: should this actually be a soft-delete? Soft-delete is fully reversible, takes effect immediately, and covers the vast majority of support scenarios. See Admin Panel — Operations for the soft-delete procedure.
Every hard-delete action:
- is a Server Action gated by
requireSuperAdmin()— 404s for non-admins. - writes an
admin_audit_logrow before the mutation runs (MF-L) — even if the delete fails, the attempt is captured. - runs inside a DB transaction — if any step throws, the transaction rolls back. Clerk calls are made after the transaction commits (user) or before the final delete (org) — see each procedure for the exact order.
When NOT to hard-delete
Use hard-delete only for:
- E2E teardown — cleaning test accounts created by Playwright suites
(automated via
/api/test-admin/admin/teardown). - Abandoned test accounts — accounts that were created during exploratory testing and were never associated with real donations, campaigns, or donors.
- Edge-case support requests — a user explicitly requests permanent account deletion and the org has zero financial history.
Do not hard-delete when:
- The org has any payment history, recurring subscriptions, or pending Stripe payouts — the guardrails will block you in production anyway (see Hard-delete org procedure).
- You need a reversible lock-out — use soft-delete instead.
- You're troubleshooting a bug — impersonation is the right tool.
- You're not sure — ask before deleting. There is no recovery.
What hard-delete destroys
Hard-deleting a user removes:
- The
usersrow (CASCADE deletesorg_membersautomatically). user_preferencesrows (explicit delete — no FK, requires manual step).search_logsrows (explicit delete — no FK, requires manual step).- The Clerk user record (
clerkClient.users.deleteUser()).
Hard-deleting an org removes:
- The
organizationsrow and all 58 CASCADE-linked tables (campaigns, contacts, payments, email sequences, memberships, agent configs, webhooks, and more). - The 10 NO ACTION tables (explicitly deleted in a specific order before the
org row):
webhook_deliveries,zapier_deliveries,agent_actions,email_logs,campaign_custom_fields,custom_fields,payment_flags,payment_duplicate_resolutions,recurring_subscriptions,payments. - The Clerk organization record (
clerkClient.organizations.deleteOrganization()).
The full FK dependency graph is documented in
docs/schema/fk-map.md. Consult it before
adding new tables that reference users.id or organizations.id — hard-delete
code must be updated to match.
What hard-delete preserves
Hard-delete is designed to preserve the audit trail even after the deleted entity no longer exists:
-
admin_audit_logrows — never deleted. The four FK columns (admin_user_id,target_user_id,target_org_id, and the newadmin_impersonations.admin_user_idfrom NH-15) are nullable withSET NULLon delete. Snapshot columns (admin_email_snapshot,target_email_snapshot,target_org_name_snapshot) are populated before the delete and remain intact. MF-L guarantees the audit row is committed before the mutation runs. -
admin_impersonationsrows — never deleted. Bothtarget_user_idandadmin_user_id(NH-15) are nullable withSET NULL. Snapshot columns preserve the full context of who impersonated whom. NH-15 specifically added theadmin_user_idnullable pattern and the correspondingadmin_email_snapshotso that hard-deleting the admin user who performed an impersonation does not destroy the audit record. -
orphaned_stripe_accountsrows — if the deleted org had a Stripe Connect account, a snapshot row is inserted intoorphaned_stripe_accountsbefore the org is deleted. The Stripe-side account is not deleted (see Orphaned Stripe Connect accounts).
Hard-delete user procedure
- Navigate to
/admin/users/<id>(or search in/admin/users). - Scroll to the Danger Zone card.
- In production, type the user's email address in the confirmation field. A rate limit of 3 hard-deletes per hour is enforced in production — no confirmation dialog in non-production environments.
- Click Hard delete user.
Execution order (server-side):
- Audit row written to
admin_audit_log(action:user.hard_delete). - Soft-flag set (
users.deleted_at = now()) as a transactional guard. - Explicit deletes:
user_preferences,search_logs(no FK — must be explicit). DELETE FROM users WHERE id = $userId— CASCADE removesorg_members; SET NULL nulls the four admin audit FK columns.- Transaction commits.
clerkClient.users.deleteUser(clerkUserId)— Clerk user record deleted. Skipped for invite stubs (IDs starting withuser_invite_): these placeholders have no Clerk identity until the invitee completes signup, so the Clerk call would 404 and abort the delete.
Failure modes:
- If the DB transaction fails at any step, it rolls back. No data is lost. Clerk is not called if the DB step fails.
- If Clerk
deleteUserfails after the DB commit, the user no longer exists in our DB but still exists in Clerk. This is a known divergence — the Clerk user has no org membership or app access (membership was cascade-deleted), so the practical impact is minimal. Manually delete from the Clerk dashboard if hygiene is required. - Invite stubs skip the Clerk step entirely by design. No divergence — there was never a Clerk record to begin with.
Hard-delete org procedure
- Navigate to
/admin/orgs/<id>. - Scroll to the Danger Zone card.
- In production, the following guardrails are enforced fail-closed —
if any check fails, the action is blocked before any data is touched:
- Payment count must be 0.
- No active recurring subscriptions.
- No pending Stripe payouts.
- Type the org name in the first confirmation field, then type
DELETEin the second field. - Click Hard delete org.
Execution order (server-side):
- Audit row written to
admin_audit_log(action:org.hard_delete). - Soft-flag set (
organizations.deleted_at = now()) as a transactional guard. - Read
organizations.stripe_account_id(before delete). - Explicit deletes in FK-safe order (see
docs/schema/fk-map.mdcascade order):webhook_deliveries,zapier_deliveries(NO ACTION delivery children)agent_actions,email_logs,campaign_custom_fields,custom_fieldspayment_flags,payment_duplicate_resolutions,recurring_subscriptionspayments(financial — last explicit delete)
- If
stripe_account_idwas non-null: insert intoorphaned_stripe_accounts. DELETE FROM organizations WHERE id = $orgId— CASCADE handles 58 tables.- Transaction commits.
clerkClient.organizations.deleteOrganization(clerkOrgId)— Clerk org deleted.
Failure modes:
- If production guardrails block the delete ("blocked — payments exist"), use soft-delete instead. Hard-delete is not available for orgs with financial history.
- If the DB transaction fails, it rolls back. Clerk is not called.
- If Clerk
deleteOrganizationfails after the DB commit, the org no longer exists in our DB but still exists in Clerk (as an empty shell with no GiveLink members). Manually delete from the Clerk dashboard.
Orphaned Stripe Connect accounts
GiveLink intentionally does not auto-delete Stripe Connect accounts when an org is hard-deleted. Reasons:
- The account may have pending payouts or tax reporting obligations.
- Stripe charges fees for account deletion in some circumstances.
- Manual support resolution ensures nothing slips through.
When an org with a Stripe Connect account is hard-deleted, a row is inserted
into orphaned_stripe_accounts with the account ID, the org's name snapshot,
and orphaned_at. The resolved_at column is null until a human reviews and
closes the account manually via the Stripe dashboard.
To find unresolved orphaned accounts:
SELECT *
FROM orphaned_stripe_accounts
WHERE resolved_at IS NULL
ORDER BY orphaned_at DESC;Once you've reviewed and closed the account in Stripe, set resolved_at:
UPDATE orphaned_stripe_accounts
SET resolved_at = now(), resolved_by = 'your-email@givelink.ai'
WHERE stripe_account_id = 'acct_xxx';Recovery
Hard-delete is irreversible.
There is no undo button, no restore flow, and no automated backup restore. Neon PITR (point-in-time recovery) is a manual DBA operation — it restores the entire database to a prior state, which would also roll back any other changes made since that point. It is reserved for catastrophic data loss incidents, not routine hard-delete mistakes. Contact Dustin before attempting a PITR restore.
Prevention is the only recovery strategy: if you're unsure, use soft-delete. Hard-delete only when you are certain.
Troubleshooting
"Blocked — payments exist"
The org has a non-zero payment count. Hard-delete is not available. Use soft-delete if you need to lock the org out, or work with the customer to understand whether the payments are test data that can be cleared via a manual support process (escalate to Dustin — this is a DBA operation).
"In-progress impersonation by admin X; end the impersonation before hard-deleting"
The MF-M guard blocks hard-delete when an active impersonation session
targets the user (or, for orgs, a user whose start-time org was this one).
Active means admin_impersonations.started_successfully = true AND ended_at IS NULL — rows from failed starts (Clerk token minting errored)
do not block the delete. If you see this error, a real impersonation
session is live: use the return-to-admin flow in the banner, or force-close
via forceCloseImpersonationsForUser() if the admin is unreachable.
"Clerk delete failed" after the DB committed
The user or org no longer exists in GiveLink's DB but still exists in Clerk.
Check the Clerk dashboard at clerk.com/dashboard and manually delete the
user or organization. The GiveLink app will not allow the entity to sign in
or create a new org because the DB record is gone — Clerk's record is a stale
shell. Low urgency unless the deleted entity attempts account recovery via
Clerk directly.
"Org hard-delete half-succeeded — transaction rolled back, Clerk untouched"
If the server returned an error during the DB transaction, both the DB delete and the Clerk delete were skipped — the org still exists in both systems. The soft-flag set at step 2 may or may not have persisted depending on where in the transaction the failure occurred. Refresh the admin org detail page and confirm the org's current state. If the soft-flag stuck but the delete failed, you can retry the hard-delete. If the org is in an unknown state, escalate to Dustin for a direct DB inspection before retrying.
References
- Admin Panel — Operations — soft-delete, impersonation, and all reversible actions
- Admin Panel — Basics — access and navigation
docs/schema/fk-map.md— complete FK dependency map- Spec:
docs/superpowers/specs/2026-04-18-spec-d-e2e-and-hard-deletes.md - Code:
src/lib/admin/hard-delete.ts,src/app/(admin)/admin/