Account Deletion
Overview
MysticX supports two deletion modes:
- Soft delete - Anonymizes the user's PII, cancels subscriptions, and bans the account. Preserves audit data. This is the default for both user self-service and admin actions.
- Hard delete (GDPR) - Permanently removes the user and all related data via cascade delete. Reserved for GDPR right-to-erasure requests.
Both modes record the user's original email in a tombstone table (deleted_email) to prevent re-registration bonus abuse.
Architecture
Tombstone Table
The DeletedEmail model stores emails of deleted accounts:
| Field | Type | Description |
|---|---|---|
| id | String | CUID primary key |
| String | Original email (lowercased) | |
| originalUserId | String | The deleted user's ID (plain string, no FK) |
| stripeCustomerId | String? | Stripe customer ID for audit |
| deletedAt | DateTime | When the deletion occurred |
The email column is indexed for fast lookups during registration. There is intentionally no foreign key from originalUserId to User - the tombstone must survive a hard delete.
Re-registration Bonus Prevention
When a user registers, the databaseHooks.user.create.after hook in lib/auth.tsx calls wasEmailPreviouslyDeleted(email). If the email is found in the tombstone table:
- Skipped:
initializeCredits()(300 credit welcome bonus) - Skipped:
claimInvitationForNewUser()(referral invitee reward) - Still runs:
ensureInvitationStateForUser()(generate invite code) - Still runs:
createNewUserFeatureNotifications()(feature notifications)
The check is fail-open: if the tombstone lookup throws, isReturningUser defaults to false so new users are never incorrectly penalized by transient DB errors.
Soft Delete Flow
Implemented in lib/account-deletion.ts → softDeleteUser(userId).
Steps
- Fetch the user with active/trialing subscriptions
- Cancel each Stripe subscription (try/catch per subscription - failures do not block deletion)
- Inside a single transaction:
- Create tombstone record with the original email
- Anonymize the user row:
- Email →
deleted_{userId}@deleted.mysticx.com - Name →
Deleted User - Image, timezone, invite code, Stripe customer ID, preferences → null
- Credits → 0, tier → FREE
- Banned → true, ban reason → "Account deleted by user"
deletedAt→ current timestamp
- Email →
- Hard-delete 11 tables containing personal data or content:
session,account(auth credentials)userMemory,userProfile,telegramLink(PII)notification(personal)cardOfDayEntry,weeklyGuidance,soulJourney(generated content)userCardSkin,userTarotReader(purchases)
- Preserved on the anonymized user row (audit/referral value):
creditTransaction- Abuse investigationreferral,referralMilestoneReward- Inviter counts stay accuratetarotReading,chat,message- No PII beyond userId FKsubscription- Stripe audit trail
Referral Impact
- Inviter deletes: The anonymized user row still exists, so all Referral/ReferralMilestoneReward FKs remain valid. The invite code is cleared (no new referrals).
- Invitee deletes and re-registers:
claimInvitationForNewUseris skipped, preventing double referral rewards.
Hard Delete Flow (GDPR)
Implemented in app/actions/admin.ts → hardDeleteUser(userId). Admin-only.
Steps
- Fetch the user with active/trialing subscriptions
- Cancel each Stripe subscription (try/catch per subscription)
- If the user was NOT already soft-deleted:
- Check if email is already anonymized (
@deleted.mysticx.com) - If not anonymized, create the tombstone record
- Check if email is already anonymized (
- Execute
prisma.user.delete()which cascades through all 22 FK relations
Cascade Tree
prisma.user.delete(userId)
├── Session ──────────── CASCADE
├── Account ──────────── CASCADE
├── UserProfile ──────── CASCADE
├── TelegramLink ─────── CASCADE
├── CreditTransaction ── CASCADE
├── CardOfDayEntry ────── CASCADE
├── Notification ──────── CASCADE
├── UserCardSkin ──────── CASCADE
├── UserTarotReader ───── CASCADE
├── UserMemory ────────── CASCADE
├── WeeklyGuidance ────── CASCADE
├── SoulJourney ────────── CASCADE
├── Referral (inviter) ── CASCADE
├── Referral (invitee) ── CASCADE
├── ReferralMilestoneReward CASCADE
├── Subscription ──────── CASCADE
├── TarotReading ──────── CASCADE
│ ├── Chat ──────────── CASCADE
│ │ └── Message ───── CASCADE
│ └── AiApiCall ─────── CASCADE
├── UserFeedback ──────── SetNull (record kept)
├── BlogPost.createdBy ── SetNull
├── BlogPost.updatedBy ── SetNull
└── User.invitedByUserId ─ SetNull (other users)No onDelete: Restrict constraints exist on any User relation, so the delete cannot be blocked by FK violations.
Entry Points
User Self-Service
- UI:
DeleteAccountCardcomponent on/account/security - Action:
app/actions/account.ts→deleteMyAccount() - Flow: Requires typing "DELETE" to confirm → calls
softDeleteUser→ signs out → redirects to home
Admin Panel
Two options in the user actions dropdown (UserActionsDropdown):
| Action | Button | Behavior |
|---|---|---|
| Delete User | Soft delete | Anonymize + preserve audit trail |
| Hard Delete (GDPR) | Permanent delete | Remove all data via cascade |
The standalone DeleteUserModal component also performs a soft delete.
Files
| File | Purpose |
|---|---|
lib/account-deletion.ts | Core logic: softDeleteUser, wasEmailPreviouslyDeleted |
lib/auth.tsx | Registration hook with returning-user check |
app/actions/account.ts | User self-delete server action |
app/actions/admin.ts | Admin deleteUser (soft) + hardDeleteUser (GDPR) |
components/DeleteAccountCard.tsx | User-facing delete card (i18n, 12 locales) |
app/admin/_components/UserActionsDropdown.tsx | Admin dropdown with both delete options |
app/admin/_components/DeleteUserModal.tsx | Standalone admin soft-delete button |
__tests__/account-deletion/account-deletion.test.ts | 45 pure logic tests |
Database Migration
Migration: 20260403081911_add_account_deletion_support
- Adds
deletedAt DateTime?tousertable with index - Creates
deleted_emailtable with index onemail