diff --git a/app/Actions/Stripe/CancelSubscription.php b/app/Actions/Stripe/CancelSubscription.php index 859aec6f6..71b6ed52b 100644 --- a/app/Actions/Stripe/CancelSubscription.php +++ b/app/Actions/Stripe/CancelSubscription.php @@ -30,7 +30,7 @@ public function getSubscriptionsPreview(): Collection $subscriptions = collect(); // Get all teams the user belongs to - $teams = $this->user->teams; + $teams = $this->user->teams()->get(); foreach ($teams as $team) { // Only include subscriptions from teams where user is owner @@ -49,6 +49,64 @@ public function getSubscriptionsPreview(): Collection return $subscriptions; } + /** + * Verify subscriptions exist and are active in Stripe API + * + * @return array ['verified' => Collection, 'not_found' => Collection, 'errors' => array] + */ + public function verifySubscriptionsInStripe(): array + { + if (! isCloud()) { + return [ + 'verified' => collect(), + 'not_found' => collect(), + 'errors' => [], + ]; + } + + $stripe = new StripeClient(config('subscription.stripe_api_key')); + $subscriptions = $this->getSubscriptionsPreview(); + + $verified = collect(); + $notFound = collect(); + $errors = []; + + foreach ($subscriptions as $subscription) { + try { + $stripeSubscription = $stripe->subscriptions->retrieve($subscription->stripe_subscription_id); + + // Check if subscription is actually active in Stripe + if (in_array($stripeSubscription->status, ['active', 'trialing', 'past_due'])) { + $verified->push([ + 'subscription' => $subscription, + 'stripe_status' => $stripeSubscription->status, + 'current_period_end' => $stripeSubscription->current_period_end, + ]); + } else { + $notFound->push([ + 'subscription' => $subscription, + 'reason' => "Status in Stripe: {$stripeSubscription->status}", + ]); + } + } catch (\Stripe\Exception\InvalidRequestException $e) { + // Subscription doesn't exist in Stripe + $notFound->push([ + 'subscription' => $subscription, + 'reason' => 'Not found in Stripe', + ]); + } catch (\Exception $e) { + $errors[] = "Error verifying subscription {$subscription->stripe_subscription_id}: ".$e->getMessage(); + \Log::error("Error verifying subscription {$subscription->stripe_subscription_id}: ".$e->getMessage()); + } + } + + return [ + 'verified' => $verified, + 'not_found' => $notFound, + 'errors' => $errors, + ]; + } + public function execute(): array { if ($this->isDryRun) { diff --git a/app/Actions/User/DeleteUserResources.php b/app/Actions/User/DeleteUserResources.php index 7b2e7318d..3c539d7c5 100644 --- a/app/Actions/User/DeleteUserResources.php +++ b/app/Actions/User/DeleteUserResources.php @@ -24,23 +24,46 @@ public function getResourcesPreview(): array $services = collect(); // Get all teams the user belongs to - $teams = $this->user->teams; + $teams = $this->user->teams()->get(); foreach ($teams as $team) { + // Only delete resources from teams that will be FULLY DELETED + // This means: user is the ONLY member of the team + // + // DO NOT delete resources if: + // - User is just a member (not owner) + // - Team has other members (ownership will be transferred or user just removed) + + $userRole = $team->pivot->role; + $memberCount = $team->members->count(); + + // Skip if user is not owner + if ($userRole !== 'owner') { + continue; + } + + // Skip if team has other members (will be transferred/user removed, not deleted) + if ($memberCount > 1) { + continue; + } + + // Only delete resources from teams where user is the ONLY member + // These teams will be fully deleted + // Get all servers for this team - $servers = $team->servers; + $servers = $team->servers()->get(); foreach ($servers as $server) { - // Get applications - $serverApplications = $server->applications; + // Get applications (custom method returns Collection) + $serverApplications = $server->applications(); $applications = $applications->merge($serverApplications); - // Get databases - $serverDatabases = $this->getAllDatabasesForServer($server); + // Get databases (custom method returns Collection) + $serverDatabases = $server->databases(); $databases = $databases->merge($serverDatabases); - // Get services - $serverServices = $server->services; + // Get services (relationship needs ->get()) + $serverServices = $server->services()->get(); $services = $services->merge($serverServices); } } @@ -105,21 +128,4 @@ public function execute(): array return $deletedCounts; } - - private function getAllDatabasesForServer($server): Collection - { - $databases = collect(); - - // Get all standalone database types - $databases = $databases->merge($server->postgresqls); - $databases = $databases->merge($server->mysqls); - $databases = $databases->merge($server->mariadbs); - $databases = $databases->merge($server->mongodbs); - $databases = $databases->merge($server->redis); - $databases = $databases->merge($server->keydbs); - $databases = $databases->merge($server->dragonflies); - $databases = $databases->merge($server->clickhouses); - - return $databases; - } } diff --git a/app/Actions/User/DeleteUserServers.php b/app/Actions/User/DeleteUserServers.php index d8caae54d..ca29dd49d 100644 --- a/app/Actions/User/DeleteUserServers.php +++ b/app/Actions/User/DeleteUserServers.php @@ -23,13 +23,13 @@ public function getServersPreview(): Collection $servers = collect(); // Get all teams the user belongs to - $teams = $this->user->teams; + $teams = $this->user->teams()->get(); foreach ($teams as $team) { // Only include servers from teams where user is owner or admin $userRole = $team->pivot->role; if ($userRole === 'owner' || $userRole === 'admin') { - $teamServers = $team->servers; + $teamServers = $team->servers()->get(); $servers = $servers->merge($teamServers); } } diff --git a/app/Console/Commands/AdminDeleteUser.php b/app/Console/Commands/AdminDeleteUser.php new file mode 100644 index 000000000..9b803b1f7 --- /dev/null +++ b/app/Console/Commands/AdminDeleteUser.php @@ -0,0 +1,1173 @@ + false, + 'phase_2_resources' => false, + 'phase_3_servers' => false, + 'phase_4_teams' => false, + 'phase_5_user_profile' => false, + 'phase_6_stripe' => false, + 'db_committed' => false, + ]; + + public function handle() + { + // Register signal handlers for graceful shutdown (Ctrl+C handling) + $this->registerSignalHandlers(); + + $email = $this->argument('email'); + $this->isDryRun = $this->option('dry-run'); + $this->skipStripe = $this->option('skip-stripe'); + $this->skipResources = $this->option('skip-resources'); + $force = $this->option('force'); + + if ($force) { + $this->warn('⚠️ FORCE MODE - Lock check will be bypassed'); + $this->warn(' Use this flag only if you are certain no other deletion is running'); + $this->newLine(); + } + + if ($this->isDryRun) { + $this->info('🔍 DRY RUN MODE - No data will be deleted'); + $this->newLine(); + } + + if ($this->output->isVerbose()) { + $this->info('📊 VERBOSE MODE - Full stack traces will be shown on errors'); + $this->newLine(); + } else { + $this->comment('💡 Tip: Use -v flag for detailed error stack traces'); + $this->newLine(); + } + + if (! $this->isDryRun && ! $this->option('auto-confirm')) { + $this->info('🔄 INTERACTIVE MODE - You will be asked to confirm after each phase'); + $this->comment(' Use --auto-confirm to skip phase confirmations'); + $this->newLine(); + } + + // Notify about instance type and Stripe + if (isCloud()) { + $this->comment('☁️ Cloud instance - Stripe subscriptions will be handled'); + } else { + $this->comment('🏠 Self-hosted instance - Stripe operations will be skipped'); + } + $this->newLine(); + + try { + $this->user = User::whereEmail($email)->firstOrFail(); + } catch (\Exception $e) { + $this->error("User with email '{$email}' not found."); + + return 1; + } + + // Implement file lock to prevent concurrent deletions of the same user + $lockKey = "user_deletion_{$this->user->id}"; + $this->lock = Cache::lock($lockKey, 600); // 10 minute lock + + if (! $force) { + if (! $this->lock->get()) { + $this->error('Another deletion process is already running for this user.'); + $this->error('Use --force to bypass this lock (use with extreme caution).'); + $this->logAction("Deletion blocked for user {$email}: Another process is already running"); + + return 1; + } + } else { + // In force mode, try to get lock but continue even if it fails + if (! $this->lock->get()) { + $this->warn('⚠️ Lock exists but proceeding due to --force flag'); + $this->warn(' There may be another deletion process running!'); + $this->newLine(); + } + } + + try { + $this->logAction("Starting user deletion process for: {$email}"); + + // Phase 1: Show User Overview (outside transaction) + if (! $this->showUserOverview()) { + $this->info('User deletion cancelled by operator.'); + + return 0; + } + $this->deletionState['phase_1_overview'] = true; + + // If not dry run, wrap DB operations in a transaction + // NOTE: Stripe cancellations happen AFTER commit to avoid inconsistent state + if (! $this->isDryRun) { + try { + DB::beginTransaction(); + + // Phase 2: Delete Resources + // WARNING: This triggers Docker container deletion via SSH which CANNOT be rolled back + if (! $this->skipResources) { + if (! $this->deleteResources()) { + DB::rollBack(); + $this->displayErrorState('Phase 2: Resource Deletion'); + $this->error('❌ User deletion failed at resource deletion phase.'); + $this->warn('⚠️ Some Docker containers may have been deleted on remote servers and cannot be restored.'); + $this->displayRecoverySteps(); + + return 1; + } + } + $this->deletionState['phase_2_resources'] = true; + + // Confirmation to continue after Phase 2 + if (! $this->skipResources && ! $this->option('auto-confirm')) { + $this->newLine(); + if (! $this->confirm('Phase 2 completed. Continue to Phase 3 (Delete Servers)?', true)) { + DB::rollBack(); + $this->info('User deletion cancelled by operator after Phase 2.'); + $this->info('Database changes have been rolled back.'); + + return 0; + } + } + + // Phase 3: Delete Servers + // WARNING: This may trigger cleanup operations on remote servers which CANNOT be rolled back + if (! $this->deleteServers()) { + DB::rollBack(); + $this->displayErrorState('Phase 3: Server Deletion'); + $this->error('❌ User deletion failed at server deletion phase.'); + $this->warn('⚠️ Some server cleanup operations may have been performed and cannot be restored.'); + $this->displayRecoverySteps(); + + return 1; + } + $this->deletionState['phase_3_servers'] = true; + + // Confirmation to continue after Phase 3 + if (! $this->option('auto-confirm')) { + $this->newLine(); + if (! $this->confirm('Phase 3 completed. Continue to Phase 4 (Handle Teams)?', true)) { + DB::rollBack(); + $this->info('User deletion cancelled by operator after Phase 3.'); + $this->info('Database changes have been rolled back.'); + + return 0; + } + } + + // Phase 4: Handle Teams + if (! $this->handleTeams()) { + DB::rollBack(); + $this->displayErrorState('Phase 4: Team Handling'); + $this->error('❌ User deletion failed at team handling phase.'); + $this->displayRecoverySteps(); + + return 1; + } + $this->deletionState['phase_4_teams'] = true; + + // Confirmation to continue after Phase 4 + if (! $this->option('auto-confirm')) { + $this->newLine(); + if (! $this->confirm('Phase 4 completed. Continue to Phase 5 (Delete User Profile)?', true)) { + DB::rollBack(); + $this->info('User deletion cancelled by operator after Phase 4.'); + $this->info('Database changes have been rolled back.'); + + return 0; + } + } + + // Phase 5: Delete User Profile + if (! $this->deleteUserProfile()) { + DB::rollBack(); + $this->displayErrorState('Phase 5: User Profile Deletion'); + $this->error('❌ User deletion failed at user profile deletion phase.'); + $this->displayRecoverySteps(); + + return 1; + } + $this->deletionState['phase_5_user_profile'] = true; + + // CRITICAL CONFIRMATION: Database commit is next (PERMANENT) + if (! $this->option('auto-confirm')) { + $this->newLine(); + $this->warn('⚠️ CRITICAL DECISION POINT'); + $this->warn('Next step: COMMIT database changes (PERMANENT and IRREVERSIBLE)'); + $this->warn('All resources, servers, teams, and user profile will be permanently deleted'); + $this->newLine(); + if (! $this->confirm('Phase 5 completed. Commit database changes? (THIS IS PERMANENT)', false)) { + DB::rollBack(); + $this->info('User deletion cancelled by operator before commit.'); + $this->info('Database changes have been rolled back.'); + $this->warn('⚠️ Note: Some Docker containers may have been deleted on remote servers.'); + + return 0; + } + } + + // Commit the database transaction + DB::commit(); + $this->deletionState['db_committed'] = true; + + $this->newLine(); + $this->info('✅ Database operations completed successfully!'); + $this->info('✅ Transaction committed - database changes are now PERMANENT.'); + $this->logAction("Database deletion completed for: {$email}"); + + // Confirmation to continue to Stripe (after commit) + if (! $this->skipStripe && isCloud() && ! $this->option('auto-confirm')) { + $this->newLine(); + $this->warn('⚠️ Database changes are committed (permanent)'); + $this->info('Next: Cancel Stripe subscriptions'); + if (! $this->confirm('Continue to Phase 6 (Cancel Stripe Subscriptions)?', true)) { + $this->warn('User deletion stopped after database commit.'); + $this->error('⚠️ IMPORTANT: User deleted from database but Stripe subscriptions remain active!'); + $this->error('You must cancel subscriptions manually in Stripe Dashboard.'); + $this->error('Go to: https://dashboard.stripe.com/'); + $this->error('Search for: '.$email); + + return 1; + } + } + + // Phase 6: Cancel Stripe Subscriptions (AFTER DB commit) + // This is done AFTER commit because Stripe API calls cannot be rolled back + // If this fails, DB changes are already committed but subscriptions remain active + if (! $this->skipStripe && isCloud()) { + if (! $this->cancelStripeSubscriptions()) { + $this->newLine(); + $this->error('═══════════════════════════════════════'); + $this->error('⚠️ CRITICAL: INCONSISTENT STATE DETECTED'); + $this->error('═══════════════════════════════════════'); + $this->error('✓ User data DELETED from database (committed)'); + $this->error('✗ Stripe subscription cancellation FAILED'); + $this->newLine(); + $this->displayErrorState('Phase 6: Stripe Cancellation (Post-Commit)'); + $this->newLine(); + $this->error('MANUAL ACTION REQUIRED:'); + $this->error('1. Go to Stripe Dashboard: https://dashboard.stripe.com/'); + $this->error('2. Search for customer email: '.$email); + $this->error('3. Cancel all active subscriptions'); + $this->error('4. Check storage/logs/user-deletions.log for subscription IDs'); + $this->newLine(); + $this->logAction("INCONSISTENT STATE: User {$email} deleted but Stripe cancellation failed"); + + return 1; + } + } + $this->deletionState['phase_6_stripe'] = true; + + $this->newLine(); + $this->info('✅ User deletion completed successfully!'); + $this->logAction("User deletion completed for: {$email}"); + + } catch (\Exception $e) { + DB::rollBack(); + $this->newLine(); + $this->error('═══════════════════════════════════════'); + $this->error('❌ EXCEPTION DURING USER DELETION'); + $this->error('═══════════════════════════════════════'); + $this->error('Exception: '.get_class($e)); + $this->error('Message: '.$e->getMessage()); + $this->error('File: '.$e->getFile().':'.$e->getLine()); + $this->newLine(); + + if ($this->output->isVerbose()) { + $this->error('Stack Trace:'); + $this->error($e->getTraceAsString()); + $this->newLine(); + } else { + $this->info('Run with -v for full stack trace'); + $this->newLine(); + } + + $this->displayErrorState('Exception during execution'); + $this->displayRecoverySteps(); + + $this->logAction("User deletion failed for {$email}: {$e->getMessage()} in {$e->getFile()}:{$e->getLine()}"); + + return 1; + } + } else { + // Dry run mode - just run through the phases without transaction + // Phase 2: Delete Resources + if (! $this->skipResources) { + if (! $this->deleteResources()) { + $this->info('User deletion would be cancelled at resource deletion phase.'); + + return 0; + } + } + + // Phase 3: Delete Servers + if (! $this->deleteServers()) { + $this->info('User deletion would be cancelled at server deletion phase.'); + + return 0; + } + + // Phase 4: Handle Teams + if (! $this->handleTeams()) { + $this->info('User deletion would be cancelled at team handling phase.'); + + return 0; + } + + // Phase 5: Delete User Profile + if (! $this->deleteUserProfile()) { + $this->info('User deletion would be cancelled at user profile deletion phase.'); + + return 0; + } + + // Phase 6: Cancel Stripe Subscriptions (shown after DB operations in dry run too) + if (! $this->skipStripe && isCloud()) { + if (! $this->cancelStripeSubscriptions()) { + $this->info('User deletion would be cancelled at Stripe cancellation phase.'); + + return 0; + } + } + + $this->newLine(); + $this->info('✅ DRY RUN completed successfully! No data was deleted.'); + } + + return 0; + } finally { + // Ensure lock is always released + $this->releaseLock(); + } + } + + private function showUserOverview(): bool + { + $this->info('═══════════════════════════════════════'); + $this->info('PHASE 1: USER OVERVIEW'); + $this->info('═══════════════════════════════════════'); + $this->newLine(); + + $teams = $this->user->teams()->get(); + $ownedTeams = $teams->filter(fn ($team) => $team->pivot->role === 'owner'); + $memberTeams = $teams->filter(fn ($team) => $team->pivot->role !== 'owner'); + + // Collect servers and resources ONLY from teams that will be FULLY DELETED + // This means: user is owner AND is the ONLY member + // + // Resources from these teams will NOT be deleted: + // - Teams where user is just a member + // - Teams where user is owner but has other members (will be transferred/user removed) + $allServers = collect(); + $allApplications = collect(); + $allDatabases = collect(); + $allServices = collect(); + $activeSubscriptions = collect(); + + foreach ($teams as $team) { + $userRole = $team->pivot->role; + $memberCount = $team->members->count(); + + // Only show resources from teams where user is the ONLY member + // These are the teams that will be fully deleted + if ($userRole !== 'owner' || $memberCount > 1) { + continue; + } + + $servers = $team->servers()->get(); + $allServers = $allServers->merge($servers); + + foreach ($servers as $server) { + $resources = $server->definedResources(); + foreach ($resources as $resource) { + if ($resource instanceof \App\Models\Application) { + $allApplications->push($resource); + } elseif ($resource instanceof \App\Models\Service) { + $allServices->push($resource); + } else { + $allDatabases->push($resource); + } + } + } + + // Only collect subscriptions on cloud instances + if (isCloud() && $team->subscription && $team->subscription->stripe_subscription_id) { + $activeSubscriptions->push($team->subscription); + } + } + + // Build table data + $tableData = [ + ['User', $this->user->email], + ['User ID', $this->user->id], + ['Created', $this->user->created_at->format('Y-m-d H:i:s')], + ['Last Login', $this->user->updated_at->format('Y-m-d H:i:s')], + ['Teams (Total)', $teams->count()], + ['Teams (Owner)', $ownedTeams->count()], + ['Teams (Member)', $memberTeams->count()], + ['Servers', $allServers->unique('id')->count()], + ['Applications', $allApplications->count()], + ['Databases', $allDatabases->count()], + ['Services', $allServices->count()], + ]; + + // Only show Stripe subscriptions on cloud instances + if (isCloud()) { + $tableData[] = ['Active Stripe Subscriptions', $activeSubscriptions->count()]; + } + + $this->table(['Property', 'Value'], $tableData); + + $this->newLine(); + + $this->warn('⚠️ WARNING: This will permanently delete the user and all associated data!'); + $this->newLine(); + + if (! $this->confirm('Do you want to continue with the deletion process?', false)) { + return false; + } + + return true; + } + + private function deleteResources(): bool + { + $this->newLine(); + $this->info('═══════════════════════════════════════'); + $this->info('PHASE 2: DELETE RESOURCES'); + $this->info('═══════════════════════════════════════'); + $this->newLine(); + + $action = new DeleteUserResources($this->user, $this->isDryRun); + $resources = $action->getResourcesPreview(); + + if ($resources['applications']->isEmpty() && + $resources['databases']->isEmpty() && + $resources['services']->isEmpty()) { + $this->info('No resources to delete.'); + + return true; + } + + $this->info('Resources to be deleted:'); + $this->newLine(); + + if ($resources['applications']->isNotEmpty()) { + $this->warn("Applications to be deleted ({$resources['applications']->count()}):"); + $this->table( + ['Name', 'UUID', 'Server', 'Status'], + $resources['applications']->map(function ($app) { + return [ + $app->name, + $app->uuid, + $app->destination->server->name, + $app->status ?? 'unknown', + ]; + })->toArray() + ); + $this->newLine(); + } + + if ($resources['databases']->isNotEmpty()) { + $this->warn("Databases to be deleted ({$resources['databases']->count()}):"); + $this->table( + ['Name', 'Type', 'UUID', 'Server'], + $resources['databases']->map(function ($db) { + return [ + $db->name, + class_basename($db), + $db->uuid, + $db->destination->server->name, + ]; + })->toArray() + ); + $this->newLine(); + } + + if ($resources['services']->isNotEmpty()) { + $this->warn("Services to be deleted ({$resources['services']->count()}):"); + $this->table( + ['Name', 'UUID', 'Server'], + $resources['services']->map(function ($service) { + return [ + $service->name, + $service->uuid, + $service->server->name, + ]; + })->toArray() + ); + $this->newLine(); + } + + $this->error('⚠️ THIS ACTION CANNOT BE UNDONE!'); + if (! $this->confirm('Are you sure you want to delete all these resources?', false)) { + return false; + } + + if (! $this->isDryRun) { + $this->info('Deleting resources...'); + try { + $result = $action->execute(); + $this->info("✓ Deleted: {$result['applications']} applications, {$result['databases']} databases, {$result['services']} services"); + $this->logAction("Deleted resources for user {$this->user->email}: {$result['applications']} apps, {$result['databases']} databases, {$result['services']} services"); + } catch (\Exception $e) { + $this->error('Failed to delete resources:'); + $this->error('Exception: '.get_class($e)); + $this->error('Message: '.$e->getMessage()); + $this->error('File: '.$e->getFile().':'.$e->getLine()); + + if ($this->output->isVerbose()) { + $this->error('Stack Trace:'); + $this->error($e->getTraceAsString()); + } + + throw $e; // Re-throw to trigger rollback + } + } + + return true; + } + + private function deleteServers(): bool + { + $this->newLine(); + $this->info('═══════════════════════════════════════'); + $this->info('PHASE 3: DELETE SERVERS'); + $this->info('═══════════════════════════════════════'); + $this->newLine(); + + $action = new DeleteUserServers($this->user, $this->isDryRun); + $servers = $action->getServersPreview(); + + if ($servers->isEmpty()) { + $this->info('No servers to delete.'); + + return true; + } + + $this->warn("Servers to be deleted ({$servers->count()}):"); + $this->table( + ['ID', 'Name', 'IP', 'Description', 'Resources Count'], + $servers->map(function ($server) { + $resourceCount = $server->definedResources()->count(); + + return [ + $server->id, + $server->name, + $server->ip, + $server->description ?? '-', + $resourceCount, + ]; + })->toArray() + ); + $this->newLine(); + + $this->error('⚠️ WARNING: Deleting servers will remove all server configurations!'); + if (! $this->confirm('Are you sure you want to delete all these servers?', false)) { + return false; + } + + if (! $this->isDryRun) { + $this->info('Deleting servers...'); + try { + $result = $action->execute(); + $this->info("✓ Deleted {$result['servers']} servers"); + $this->logAction("Deleted {$result['servers']} servers for user {$this->user->email}"); + } catch (\Exception $e) { + $this->error('Failed to delete servers:'); + $this->error('Exception: '.get_class($e)); + $this->error('Message: '.$e->getMessage()); + $this->error('File: '.$e->getFile().':'.$e->getLine()); + + if ($this->output->isVerbose()) { + $this->error('Stack Trace:'); + $this->error($e->getTraceAsString()); + } + + throw $e; // Re-throw to trigger rollback + } + } + + return true; + } + + private function handleTeams(): bool + { + $this->newLine(); + $this->info('═══════════════════════════════════════'); + $this->info('PHASE 4: HANDLE TEAMS'); + $this->info('═══════════════════════════════════════'); + $this->newLine(); + + $action = new DeleteUserTeams($this->user, $this->isDryRun); + $preview = $action->getTeamsPreview(); + + // Check for edge cases first - EXIT IMMEDIATELY if found + if ($preview['edge_cases']->isNotEmpty()) { + $this->error('═══════════════════════════════════════'); + $this->error('⚠️ EDGE CASES DETECTED - CANNOT PROCEED'); + $this->error('═══════════════════════════════════════'); + $this->newLine(); + + foreach ($preview['edge_cases'] as $edgeCase) { + $team = $edgeCase['team']; + $reason = $edgeCase['reason']; + $this->error("Team: {$team->name} (ID: {$team->id})"); + $this->error("Issue: {$reason}"); + + // Show team members for context + $this->info('Current members:'); + foreach ($team->members as $member) { + $role = $member->pivot->role; + $this->line(" - {$member->name} ({$member->email}) - Role: {$role}"); + } + + // Check for active resources + $resourceCount = 0; + foreach ($team->servers()->get() as $server) { + $resources = $server->definedResources(); + $resourceCount += $resources->count(); + } + + if ($resourceCount > 0) { + $this->warn(" ⚠️ This team has {$resourceCount} active resources!"); + } + + // Show subscription details if relevant + if ($team->subscription && $team->subscription->stripe_subscription_id) { + $this->warn(' ⚠️ Active Stripe subscription details:'); + $this->warn(" Subscription ID: {$team->subscription->stripe_subscription_id}"); + $this->warn(" Customer ID: {$team->subscription->stripe_customer_id}"); + + // Show other owners who could potentially take over + $otherOwners = $team->members + ->where('id', '!=', $this->user->id) + ->filter(function ($member) { + return $member->pivot->role === 'owner'; + }); + + if ($otherOwners->isNotEmpty()) { + $this->info(' Other owners who could take over billing:'); + foreach ($otherOwners as $owner) { + $this->line(" - {$owner->name} ({$owner->email})"); + } + } + } + + $this->newLine(); + } + + $this->error('Please resolve these issues manually before retrying:'); + + // Check if any edge case involves subscription payment issues + $hasSubscriptionIssue = $preview['edge_cases']->contains(function ($edgeCase) { + return str_contains($edgeCase['reason'], 'Stripe subscription'); + }); + + if ($hasSubscriptionIssue) { + $this->info('For teams with subscription payment issues:'); + $this->info('1. Cancel the subscription through Stripe dashboard, OR'); + $this->info('2. Transfer the subscription to another owner\'s payment method, OR'); + $this->info('3. Have the other owner create a new subscription after cancelling this one'); + $this->newLine(); + } + + $hasNoOwnerReplacement = $preview['edge_cases']->contains(function ($edgeCase) { + return str_contains($edgeCase['reason'], 'No suitable owner replacement'); + }); + + if ($hasNoOwnerReplacement) { + $this->info('For teams with no suitable owner replacement:'); + $this->info('1. Assign an admin role to a trusted member, OR'); + $this->info('2. Transfer team resources to another team, OR'); + $this->info('3. Delete the team manually if no longer needed'); + $this->newLine(); + } + + $this->error('USER DELETION ABORTED DUE TO EDGE CASES'); + $this->logAction("User deletion aborted for {$this->user->email}: Edge cases in team handling"); + + // Return false to trigger proper cleanup and lock release + return false; + } + + if ($preview['to_delete']->isEmpty() && + $preview['to_transfer']->isEmpty() && + $preview['to_leave']->isEmpty()) { + $this->info('No team changes needed.'); + + return true; + } + + if ($preview['to_delete']->isNotEmpty()) { + $this->warn('Teams to be DELETED (user is the only member):'); + $this->table( + ['ID', 'Name', 'Resources', 'Subscription'], + $preview['to_delete']->map(function ($team) { + $resourceCount = 0; + foreach ($team->servers()->get() as $server) { + $resourceCount += $server->definedResources()->count(); + } + $hasSubscription = $team->subscription && $team->subscription->stripe_subscription_id + ? '⚠️ YES - '.$team->subscription->stripe_subscription_id + : 'No'; + + return [ + $team->id, + $team->name, + $resourceCount, + $hasSubscription, + ]; + })->toArray() + ); + $this->newLine(); + } + + if ($preview['to_transfer']->isNotEmpty()) { + $this->warn('Teams where ownership will be TRANSFERRED:'); + $this->table( + ['Team ID', 'Team Name', 'New Owner', 'New Owner Email'], + $preview['to_transfer']->map(function ($item) { + return [ + $item['team']->id, + $item['team']->name, + $item['new_owner']->name, + $item['new_owner']->email, + ]; + })->toArray() + ); + $this->newLine(); + } + + if ($preview['to_leave']->isNotEmpty()) { + $this->warn('Teams where user will be REMOVED (other owners/admins exist):'); + $userId = $this->user->id; + $this->table( + ['ID', 'Name', 'User Role', 'Other Members'], + $preview['to_leave']->map(function ($team) use ($userId) { + $userRole = $team->members->where('id', $userId)->first()->pivot->role; + $otherMembers = $team->members->count() - 1; + + return [ + $team->id, + $team->name, + $userRole, + $otherMembers, + ]; + })->toArray() + ); + $this->newLine(); + } + + $this->error('⚠️ WARNING: Team changes affect access control and ownership!'); + if (! $this->confirm('Are you sure you want to proceed with these team changes?', false)) { + return false; + } + + if (! $this->isDryRun) { + $this->info('Processing team changes...'); + try { + $result = $action->execute(); + $this->info("✓ Teams deleted: {$result['deleted']}, ownership transferred: {$result['transferred']}, left: {$result['left']}"); + $this->logAction("Team changes for user {$this->user->email}: deleted {$result['deleted']}, transferred {$result['transferred']}, left {$result['left']}"); + } catch (\Exception $e) { + $this->error('Failed to process team changes:'); + $this->error('Exception: '.get_class($e)); + $this->error('Message: '.$e->getMessage()); + $this->error('File: '.$e->getFile().':'.$e->getLine()); + + if ($this->output->isVerbose()) { + $this->error('Stack Trace:'); + $this->error($e->getTraceAsString()); + } + + throw $e; // Re-throw to trigger rollback + } + } + + return true; + } + + private function cancelStripeSubscriptions(): bool + { + $this->newLine(); + $this->info('═══════════════════════════════════════'); + $this->info('PHASE 6: CANCEL STRIPE SUBSCRIPTIONS'); + $this->info('═══════════════════════════════════════'); + $this->newLine(); + + $action = new CancelSubscription($this->user, $this->isDryRun); + $subscriptions = $action->getSubscriptionsPreview(); + + if ($subscriptions->isEmpty()) { + $this->info('No Stripe subscriptions to cancel.'); + + return true; + } + + // Verify subscriptions in Stripe before showing details + $this->info('Verifying subscriptions in Stripe...'); + $verification = $action->verifySubscriptionsInStripe(); + + if (! empty($verification['errors'])) { + $this->warn('⚠️ Errors occurred during verification:'); + foreach ($verification['errors'] as $error) { + $this->warn(" - {$error}"); + } + $this->newLine(); + } + + if ($verification['not_found']->isNotEmpty()) { + $this->warn('⚠️ Subscriptions not found or inactive in Stripe:'); + foreach ($verification['not_found'] as $item) { + $subscription = $item['subscription']; + $reason = $item['reason']; + $this->line(" - {$subscription->stripe_subscription_id} (Team: {$subscription->team->name}) - {$reason}"); + } + $this->newLine(); + } + + if ($verification['verified']->isEmpty()) { + $this->info('No active subscriptions found in Stripe to cancel.'); + + return true; + } + + $this->info('Active Stripe subscriptions to cancel:'); + $this->newLine(); + + $totalMonthlyValue = 0; + foreach ($verification['verified'] as $item) { + $subscription = $item['subscription']; + $stripeStatus = $item['stripe_status']; + $team = $subscription->team; + $planId = $subscription->stripe_plan_id; + + // Try to get the price from config + $monthlyValue = $this->getSubscriptionMonthlyValue($planId); + $totalMonthlyValue += $monthlyValue; + + $this->line(" - {$subscription->stripe_subscription_id} (Team: {$team->name})"); + $this->line(" Stripe Status: {$stripeStatus}"); + if ($monthlyValue > 0) { + $this->line(" Monthly value: \${$monthlyValue}"); + } + if ($subscription->stripe_cancel_at_period_end) { + $this->line(' ⚠️ Already set to cancel at period end'); + } + } + + if ($totalMonthlyValue > 0) { + $this->newLine(); + $this->warn("Total monthly value: \${$totalMonthlyValue}"); + } + $this->newLine(); + + $this->error('⚠️ WARNING: Subscriptions will be cancelled IMMEDIATELY (not at period end)!'); + $this->warn('⚠️ NOTE: This operation happens AFTER database commit and cannot be rolled back!'); + if (! $this->confirm('Are you sure you want to cancel all these subscriptions immediately?', false)) { + return false; + } + + if (! $this->isDryRun) { + $this->info('Cancelling subscriptions...'); + $result = $action->execute(); + $this->info("Cancelled {$result['cancelled']} subscriptions, {$result['failed']} failed"); + if ($result['failed'] > 0 && ! empty($result['errors'])) { + $this->error('Failed subscriptions:'); + foreach ($result['errors'] as $error) { + $this->error(" - {$error}"); + } + + return false; + } + $this->logAction("Cancelled {$result['cancelled']} Stripe subscriptions for user {$this->user->email}"); + } + + return true; + } + + private function deleteUserProfile(): bool + { + $this->newLine(); + $this->info('═══════════════════════════════════════'); + $this->info('PHASE 5: DELETE USER PROFILE'); + $this->info('═══════════════════════════════════════'); + $this->newLine(); + + $this->warn('⚠️ FINAL STEP - This action is IRREVERSIBLE!'); + $this->newLine(); + + $this->info('User profile to be deleted:'); + $this->table( + ['Property', 'Value'], + [ + ['Email', $this->user->email], + ['Name', $this->user->name], + ['User ID', $this->user->id], + ['Created', $this->user->created_at->format('Y-m-d H:i:s')], + ['Email Verified', $this->user->email_verified_at ? 'Yes' : 'No'], + ['2FA Enabled', $this->user->two_factor_confirmed_at ? 'Yes' : 'No'], + ] + ); + + $this->newLine(); + + $this->warn("Type 'DELETE {$this->user->email}' to confirm final deletion:"); + $confirmation = $this->ask('Confirmation'); + + if ($confirmation !== "DELETE {$this->user->email}") { + $this->error('Confirmation text does not match. Deletion cancelled.'); + + return false; + } + + if (! $this->isDryRun) { + $this->info('Deleting user profile...'); + + try { + $this->user->delete(); + $this->info('✓ User profile deleted successfully.'); + $this->logAction("User profile deleted: {$this->user->email}"); + } catch (\Exception $e) { + $this->error('Failed to delete user profile:'); + $this->error('Exception: '.get_class($e)); + $this->error('Message: '.$e->getMessage()); + $this->error('File: '.$e->getFile().':'.$e->getLine()); + + if ($this->output->isVerbose()) { + $this->error('Stack Trace:'); + $this->error($e->getTraceAsString()); + } + + $this->logAction("Failed to delete user profile {$this->user->email}: {$e->getMessage()}"); + + throw $e; // Re-throw to trigger rollback + } + } + + return true; + } + + private function getSubscriptionMonthlyValue(string $planId): int + { + // Try to get pricing from subscription metadata or config + // Since we're using dynamic pricing, return 0 for now + // This could be enhanced by fetching the actual price from Stripe API + + // Check if this is a dynamic pricing plan + $dynamicMonthlyPlanId = config('subscription.stripe_price_id_dynamic_monthly'); + $dynamicYearlyPlanId = config('subscription.stripe_price_id_dynamic_yearly'); + + if ($planId === $dynamicMonthlyPlanId || $planId === $dynamicYearlyPlanId) { + // For dynamic pricing, we can't determine the exact amount without calling Stripe API + // Return 0 to indicate dynamic/usage-based pricing + return 0; + } + + // For any other plans, return 0 as we don't have hardcoded prices + return 0; + } + + private function logAction(string $message): void + { + $logMessage = "[CloudDeleteUser] {$message}"; + + if ($this->isDryRun) { + $logMessage = "[DRY RUN] {$logMessage}"; + } + + Log::channel('single')->info($logMessage); + + // Also log to a dedicated user deletion log file + $logFile = storage_path('logs/user-deletions.log'); + + // Ensure the logs directory exists + $logDir = dirname($logFile); + if (! is_dir($logDir)) { + mkdir($logDir, 0755, true); + } + + $timestamp = now()->format('Y-m-d H:i:s'); + file_put_contents($logFile, "[{$timestamp}] {$logMessage}\n", FILE_APPEND | LOCK_EX); + } + + private function displayErrorState(string $failedAt): void + { + $this->newLine(); + $this->error('═══════════════════════════════════════'); + $this->error('DELETION STATE AT FAILURE'); + $this->error('═══════════════════════════════════════'); + $this->error("Failed at: {$failedAt}"); + $this->newLine(); + + $stateTable = []; + foreach ($this->deletionState as $phase => $completed) { + $phaseLabel = str_replace('_', ' ', ucwords($phase, '_')); + $status = $completed ? '✓ Completed' : '✗ Not completed'; + $stateTable[] = [$phaseLabel, $status]; + } + + $this->table(['Phase', 'Status'], $stateTable); + $this->newLine(); + + // Show what was rolled back vs what remains + if ($this->deletionState['db_committed']) { + $this->error('⚠️ DATABASE COMMITTED - Changes CANNOT be rolled back!'); + } else { + $this->info('✓ Database changes were ROLLED BACK'); + } + + $this->newLine(); + $this->error('User email: '.$this->user->email); + $this->error('User ID: '.$this->user->id); + $this->error('Timestamp: '.now()->format('Y-m-d H:i:s')); + $this->newLine(); + } + + private function displayRecoverySteps(): void + { + $this->error('═══════════════════════════════════════'); + $this->error('RECOVERY STEPS'); + $this->error('═══════════════════════════════════════'); + + if (! $this->deletionState['db_committed']) { + $this->info('✓ Database was rolled back - no recovery needed for database'); + $this->newLine(); + + if ($this->deletionState['phase_2_resources'] || $this->deletionState['phase_3_servers']) { + $this->warn('However, some remote operations may have occurred:'); + $this->newLine(); + + if ($this->deletionState['phase_2_resources']) { + $this->warn('Phase 2 (Resources) was attempted:'); + $this->warn('- Check remote servers for orphaned Docker containers'); + $this->warn('- Use: docker ps -a | grep coolify'); + $this->warn('- Manually remove if needed: docker rm -f '); + $this->newLine(); + } + + if ($this->deletionState['phase_3_servers']) { + $this->warn('Phase 3 (Servers) was attempted:'); + $this->warn('- Check for orphaned server configurations'); + $this->warn('- Verify SSH access to servers listed for this user'); + $this->newLine(); + } + } + } else { + $this->error('⚠️ DATABASE WAS COMMITTED - Manual recovery required!'); + $this->newLine(); + $this->error('The following data has been PERMANENTLY deleted:'); + + if ($this->deletionState['phase_5_user_profile']) { + $this->error('- User profile (email: '.$this->user->email.')'); + } + if ($this->deletionState['phase_4_teams']) { + $this->error('- Team memberships and owned teams'); + } + if ($this->deletionState['phase_3_servers']) { + $this->error('- Server records and configurations'); + } + if ($this->deletionState['phase_2_resources']) { + $this->error('- Applications, databases, and services'); + } + + $this->newLine(); + + if (! $this->deletionState['phase_6_stripe']) { + $this->error('Stripe subscriptions were NOT cancelled:'); + $this->error('1. Go to Stripe Dashboard: https://dashboard.stripe.com/'); + $this->error('2. Search for: '.$this->user->email); + $this->error('3. Cancel all active subscriptions manually'); + $this->newLine(); + } + } + + $this->error('Log file: storage/logs/user-deletions.log'); + $this->error('Check logs for detailed error information'); + $this->newLine(); + } + + /** + * Register signal handlers for graceful shutdown on Ctrl+C (SIGINT) and SIGTERM + */ + private function registerSignalHandlers(): void + { + if (! function_exists('pcntl_signal')) { + // pcntl extension not available, skip signal handling + return; + } + + // Handle Ctrl+C (SIGINT) + pcntl_signal(SIGINT, function () { + $this->newLine(); + $this->warn('═══════════════════════════════════════'); + $this->warn('⚠️ PROCESS INTERRUPTED (Ctrl+C)'); + $this->warn('═══════════════════════════════════════'); + $this->info('Cleaning up and releasing lock...'); + $this->releaseLock(); + $this->info('Lock released. Exiting gracefully.'); + exit(130); // Standard exit code for SIGINT + }); + + // Handle SIGTERM + pcntl_signal(SIGTERM, function () { + $this->newLine(); + $this->warn('═══════════════════════════════════════'); + $this->warn('⚠️ PROCESS TERMINATED (SIGTERM)'); + $this->warn('═══════════════════════════════════════'); + $this->info('Cleaning up and releasing lock...'); + $this->releaseLock(); + $this->info('Lock released. Exiting gracefully.'); + exit(143); // Standard exit code for SIGTERM + }); + + // Enable async signal handling + pcntl_async_signals(true); + } + + /** + * Release the lock if it exists + */ + private function releaseLock(): void + { + if ($this->lock) { + try { + $this->lock->release(); + } catch (\Exception $e) { + // Silently ignore lock release errors + // Lock will expire after 10 minutes anyway + } + } + } +} diff --git a/app/Console/Commands/AdminRemoveUser.php b/app/Console/Commands/AdminRemoveUser.php deleted file mode 100644 index d4534399c..000000000 --- a/app/Console/Commands/AdminRemoveUser.php +++ /dev/null @@ -1,56 +0,0 @@ -argument('email'); - $confirm = $this->confirm('Are you sure you want to remove user with email: '.$email.'?'); - if (! $confirm) { - $this->info('User removal cancelled.'); - - return; - } - $this->info("Removing user with email: $email"); - $user = User::whereEmail($email)->firstOrFail(); - $teams = $user->teams; - foreach ($teams as $team) { - if ($team->members->count() > 1) { - $this->error('User is a member of a team with more than one member. Please remove user from team first.'); - - return; - } - $team->delete(); - } - $user->delete(); - } catch (\Exception $e) { - $this->error('Failed to remove user.'); - $this->error($e->getMessage()); - - return; - } - } -} diff --git a/app/Console/Commands/Cloud/CloudDeleteUser.php b/app/Console/Commands/Cloud/CloudDeleteUser.php deleted file mode 100644 index a2ea9b3e5..000000000 --- a/app/Console/Commands/Cloud/CloudDeleteUser.php +++ /dev/null @@ -1,744 +0,0 @@ -error('This command is only available on cloud instances.'); - - return 1; - } - - $email = $this->argument('email'); - $this->isDryRun = $this->option('dry-run'); - $this->skipStripe = $this->option('skip-stripe'); - $this->skipResources = $this->option('skip-resources'); - - if ($this->isDryRun) { - $this->info('🔍 DRY RUN MODE - No data will be deleted'); - $this->newLine(); - } - - try { - $this->user = User::whereEmail($email)->firstOrFail(); - } catch (\Exception $e) { - $this->error("User with email '{$email}' not found."); - - return 1; - } - - // Implement file lock to prevent concurrent deletions of the same user - $lockKey = "user_deletion_{$this->user->id}"; - $lock = Cache::lock($lockKey, 600); // 10 minute lock - - if (! $lock->get()) { - $this->error('Another deletion process is already running for this user. Please try again later.'); - $this->logAction("Deletion blocked for user {$email}: Another process is already running"); - - return 1; - } - - try { - $this->logAction("Starting user deletion process for: {$email}"); - - // Phase 1: Show User Overview (outside transaction) - if (! $this->showUserOverview()) { - $this->info('User deletion cancelled.'); - $lock->release(); - - return 0; - } - - // If not dry run, wrap everything in a transaction - if (! $this->isDryRun) { - try { - DB::beginTransaction(); - - // Phase 2: Delete Resources - if (! $this->skipResources) { - if (! $this->deleteResources()) { - DB::rollBack(); - $this->error('User deletion failed at resource deletion phase. All changes rolled back.'); - - return 1; - } - } - - // Phase 3: Delete Servers - if (! $this->deleteServers()) { - DB::rollBack(); - $this->error('User deletion failed at server deletion phase. All changes rolled back.'); - - return 1; - } - - // Phase 4: Handle Teams - if (! $this->handleTeams()) { - DB::rollBack(); - $this->error('User deletion failed at team handling phase. All changes rolled back.'); - - return 1; - } - - // Phase 5: Cancel Stripe Subscriptions - if (! $this->skipStripe && isCloud()) { - if (! $this->cancelStripeSubscriptions()) { - DB::rollBack(); - $this->error('User deletion failed at Stripe cancellation phase. All changes rolled back.'); - - return 1; - } - } - - // Phase 6: Delete User Profile - if (! $this->deleteUserProfile()) { - DB::rollBack(); - $this->error('User deletion failed at final phase. All changes rolled back.'); - - return 1; - } - - // Commit the transaction - DB::commit(); - - $this->newLine(); - $this->info('✅ User deletion completed successfully!'); - $this->logAction("User deletion completed for: {$email}"); - - } catch (\Exception $e) { - DB::rollBack(); - $this->error('An error occurred during user deletion: '.$e->getMessage()); - $this->logAction("User deletion failed for {$email}: ".$e->getMessage()); - - return 1; - } - } else { - // Dry run mode - just run through the phases without transaction - // Phase 2: Delete Resources - if (! $this->skipResources) { - if (! $this->deleteResources()) { - $this->info('User deletion would be cancelled at resource deletion phase.'); - - return 0; - } - } - - // Phase 3: Delete Servers - if (! $this->deleteServers()) { - $this->info('User deletion would be cancelled at server deletion phase.'); - - return 0; - } - - // Phase 4: Handle Teams - if (! $this->handleTeams()) { - $this->info('User deletion would be cancelled at team handling phase.'); - - return 0; - } - - // Phase 5: Cancel Stripe Subscriptions - if (! $this->skipStripe && isCloud()) { - if (! $this->cancelStripeSubscriptions()) { - $this->info('User deletion would be cancelled at Stripe cancellation phase.'); - - return 0; - } - } - - // Phase 6: Delete User Profile - if (! $this->deleteUserProfile()) { - $this->info('User deletion would be cancelled at final phase.'); - - return 0; - } - - $this->newLine(); - $this->info('✅ DRY RUN completed successfully! No data was deleted.'); - } - - return 0; - } finally { - // Ensure lock is always released - $lock->release(); - } - } - - private function showUserOverview(): bool - { - $this->info('═══════════════════════════════════════'); - $this->info('PHASE 1: USER OVERVIEW'); - $this->info('═══════════════════════════════════════'); - $this->newLine(); - - $teams = $this->user->teams; - $ownedTeams = $teams->filter(fn ($team) => $team->pivot->role === 'owner'); - $memberTeams = $teams->filter(fn ($team) => $team->pivot->role !== 'owner'); - - // Collect all servers from all teams - $allServers = collect(); - $allApplications = collect(); - $allDatabases = collect(); - $allServices = collect(); - $activeSubscriptions = collect(); - - foreach ($teams as $team) { - $servers = $team->servers; - $allServers = $allServers->merge($servers); - - foreach ($servers as $server) { - $resources = $server->definedResources(); - foreach ($resources as $resource) { - if ($resource instanceof \App\Models\Application) { - $allApplications->push($resource); - } elseif ($resource instanceof \App\Models\Service) { - $allServices->push($resource); - } else { - $allDatabases->push($resource); - } - } - } - - if ($team->subscription && $team->subscription->stripe_subscription_id) { - $activeSubscriptions->push($team->subscription); - } - } - - $this->table( - ['Property', 'Value'], - [ - ['User', $this->user->email], - ['User ID', $this->user->id], - ['Created', $this->user->created_at->format('Y-m-d H:i:s')], - ['Last Login', $this->user->updated_at->format('Y-m-d H:i:s')], - ['Teams (Total)', $teams->count()], - ['Teams (Owner)', $ownedTeams->count()], - ['Teams (Member)', $memberTeams->count()], - ['Servers', $allServers->unique('id')->count()], - ['Applications', $allApplications->count()], - ['Databases', $allDatabases->count()], - ['Services', $allServices->count()], - ['Active Stripe Subscriptions', $activeSubscriptions->count()], - ] - ); - - $this->newLine(); - - $this->warn('⚠️ WARNING: This will permanently delete the user and all associated data!'); - $this->newLine(); - - if (! $this->confirm('Do you want to continue with the deletion process?', false)) { - return false; - } - - return true; - } - - private function deleteResources(): bool - { - $this->newLine(); - $this->info('═══════════════════════════════════════'); - $this->info('PHASE 2: DELETE RESOURCES'); - $this->info('═══════════════════════════════════════'); - $this->newLine(); - - $action = new DeleteUserResources($this->user, $this->isDryRun); - $resources = $action->getResourcesPreview(); - - if ($resources['applications']->isEmpty() && - $resources['databases']->isEmpty() && - $resources['services']->isEmpty()) { - $this->info('No resources to delete.'); - - return true; - } - - $this->info('Resources to be deleted:'); - $this->newLine(); - - if ($resources['applications']->isNotEmpty()) { - $this->warn("Applications to be deleted ({$resources['applications']->count()}):"); - $this->table( - ['Name', 'UUID', 'Server', 'Status'], - $resources['applications']->map(function ($app) { - return [ - $app->name, - $app->uuid, - $app->destination->server->name, - $app->status ?? 'unknown', - ]; - })->toArray() - ); - $this->newLine(); - } - - if ($resources['databases']->isNotEmpty()) { - $this->warn("Databases to be deleted ({$resources['databases']->count()}):"); - $this->table( - ['Name', 'Type', 'UUID', 'Server'], - $resources['databases']->map(function ($db) { - return [ - $db->name, - class_basename($db), - $db->uuid, - $db->destination->server->name, - ]; - })->toArray() - ); - $this->newLine(); - } - - if ($resources['services']->isNotEmpty()) { - $this->warn("Services to be deleted ({$resources['services']->count()}):"); - $this->table( - ['Name', 'UUID', 'Server'], - $resources['services']->map(function ($service) { - return [ - $service->name, - $service->uuid, - $service->server->name, - ]; - })->toArray() - ); - $this->newLine(); - } - - $this->error('⚠️ THIS ACTION CANNOT BE UNDONE!'); - if (! $this->confirm('Are you sure you want to delete all these resources?', false)) { - return false; - } - - if (! $this->isDryRun) { - $this->info('Deleting resources...'); - $result = $action->execute(); - $this->info("Deleted: {$result['applications']} applications, {$result['databases']} databases, {$result['services']} services"); - $this->logAction("Deleted resources for user {$this->user->email}: {$result['applications']} apps, {$result['databases']} databases, {$result['services']} services"); - } - - return true; - } - - private function deleteServers(): bool - { - $this->newLine(); - $this->info('═══════════════════════════════════════'); - $this->info('PHASE 3: DELETE SERVERS'); - $this->info('═══════════════════════════════════════'); - $this->newLine(); - - $action = new DeleteUserServers($this->user, $this->isDryRun); - $servers = $action->getServersPreview(); - - if ($servers->isEmpty()) { - $this->info('No servers to delete.'); - - return true; - } - - $this->warn("Servers to be deleted ({$servers->count()}):"); - $this->table( - ['ID', 'Name', 'IP', 'Description', 'Resources Count'], - $servers->map(function ($server) { - $resourceCount = $server->definedResources()->count(); - - return [ - $server->id, - $server->name, - $server->ip, - $server->description ?? '-', - $resourceCount, - ]; - })->toArray() - ); - $this->newLine(); - - $this->error('⚠️ WARNING: Deleting servers will remove all server configurations!'); - if (! $this->confirm('Are you sure you want to delete all these servers?', false)) { - return false; - } - - if (! $this->isDryRun) { - $this->info('Deleting servers...'); - $result = $action->execute(); - $this->info("Deleted {$result['servers']} servers"); - $this->logAction("Deleted {$result['servers']} servers for user {$this->user->email}"); - } - - return true; - } - - private function handleTeams(): bool - { - $this->newLine(); - $this->info('═══════════════════════════════════════'); - $this->info('PHASE 4: HANDLE TEAMS'); - $this->info('═══════════════════════════════════════'); - $this->newLine(); - - $action = new DeleteUserTeams($this->user, $this->isDryRun); - $preview = $action->getTeamsPreview(); - - // Check for edge cases first - EXIT IMMEDIATELY if found - if ($preview['edge_cases']->isNotEmpty()) { - $this->error('═══════════════════════════════════════'); - $this->error('⚠️ EDGE CASES DETECTED - CANNOT PROCEED'); - $this->error('═══════════════════════════════════════'); - $this->newLine(); - - foreach ($preview['edge_cases'] as $edgeCase) { - $team = $edgeCase['team']; - $reason = $edgeCase['reason']; - $this->error("Team: {$team->name} (ID: {$team->id})"); - $this->error("Issue: {$reason}"); - - // Show team members for context - $this->info('Current members:'); - foreach ($team->members as $member) { - $role = $member->pivot->role; - $this->line(" - {$member->name} ({$member->email}) - Role: {$role}"); - } - - // Check for active resources - $resourceCount = 0; - foreach ($team->servers as $server) { - $resources = $server->definedResources(); - $resourceCount += $resources->count(); - } - - if ($resourceCount > 0) { - $this->warn(" ⚠️ This team has {$resourceCount} active resources!"); - } - - // Show subscription details if relevant - if ($team->subscription && $team->subscription->stripe_subscription_id) { - $this->warn(' ⚠️ Active Stripe subscription details:'); - $this->warn(" Subscription ID: {$team->subscription->stripe_subscription_id}"); - $this->warn(" Customer ID: {$team->subscription->stripe_customer_id}"); - - // Show other owners who could potentially take over - $otherOwners = $team->members - ->where('id', '!=', $this->user->id) - ->filter(function ($member) { - return $member->pivot->role === 'owner'; - }); - - if ($otherOwners->isNotEmpty()) { - $this->info(' Other owners who could take over billing:'); - foreach ($otherOwners as $owner) { - $this->line(" - {$owner->name} ({$owner->email})"); - } - } - } - - $this->newLine(); - } - - $this->error('Please resolve these issues manually before retrying:'); - - // Check if any edge case involves subscription payment issues - $hasSubscriptionIssue = $preview['edge_cases']->contains(function ($edgeCase) { - return str_contains($edgeCase['reason'], 'Stripe subscription'); - }); - - if ($hasSubscriptionIssue) { - $this->info('For teams with subscription payment issues:'); - $this->info('1. Cancel the subscription through Stripe dashboard, OR'); - $this->info('2. Transfer the subscription to another owner\'s payment method, OR'); - $this->info('3. Have the other owner create a new subscription after cancelling this one'); - $this->newLine(); - } - - $hasNoOwnerReplacement = $preview['edge_cases']->contains(function ($edgeCase) { - return str_contains($edgeCase['reason'], 'No suitable owner replacement'); - }); - - if ($hasNoOwnerReplacement) { - $this->info('For teams with no suitable owner replacement:'); - $this->info('1. Assign an admin role to a trusted member, OR'); - $this->info('2. Transfer team resources to another team, OR'); - $this->info('3. Delete the team manually if no longer needed'); - $this->newLine(); - } - - $this->error('USER DELETION ABORTED DUE TO EDGE CASES'); - $this->logAction("User deletion aborted for {$this->user->email}: Edge cases in team handling"); - - // Exit immediately - don't proceed with deletion - if (! $this->isDryRun) { - DB::rollBack(); - } - exit(1); - } - - if ($preview['to_delete']->isEmpty() && - $preview['to_transfer']->isEmpty() && - $preview['to_leave']->isEmpty()) { - $this->info('No team changes needed.'); - - return true; - } - - if ($preview['to_delete']->isNotEmpty()) { - $this->warn('Teams to be DELETED (user is the only member):'); - $this->table( - ['ID', 'Name', 'Resources', 'Subscription'], - $preview['to_delete']->map(function ($team) { - $resourceCount = 0; - foreach ($team->servers as $server) { - $resourceCount += $server->definedResources()->count(); - } - $hasSubscription = $team->subscription && $team->subscription->stripe_subscription_id - ? '⚠️ YES - '.$team->subscription->stripe_subscription_id - : 'No'; - - return [ - $team->id, - $team->name, - $resourceCount, - $hasSubscription, - ]; - })->toArray() - ); - $this->newLine(); - } - - if ($preview['to_transfer']->isNotEmpty()) { - $this->warn('Teams where ownership will be TRANSFERRED:'); - $this->table( - ['Team ID', 'Team Name', 'New Owner', 'New Owner Email'], - $preview['to_transfer']->map(function ($item) { - return [ - $item['team']->id, - $item['team']->name, - $item['new_owner']->name, - $item['new_owner']->email, - ]; - })->toArray() - ); - $this->newLine(); - } - - if ($preview['to_leave']->isNotEmpty()) { - $this->warn('Teams where user will be REMOVED (other owners/admins exist):'); - $userId = $this->user->id; - $this->table( - ['ID', 'Name', 'User Role', 'Other Members'], - $preview['to_leave']->map(function ($team) use ($userId) { - $userRole = $team->members->where('id', $userId)->first()->pivot->role; - $otherMembers = $team->members->count() - 1; - - return [ - $team->id, - $team->name, - $userRole, - $otherMembers, - ]; - })->toArray() - ); - $this->newLine(); - } - - $this->error('⚠️ WARNING: Team changes affect access control and ownership!'); - if (! $this->confirm('Are you sure you want to proceed with these team changes?', false)) { - return false; - } - - if (! $this->isDryRun) { - $this->info('Processing team changes...'); - $result = $action->execute(); - $this->info("Teams deleted: {$result['deleted']}, ownership transferred: {$result['transferred']}, left: {$result['left']}"); - $this->logAction("Team changes for user {$this->user->email}: deleted {$result['deleted']}, transferred {$result['transferred']}, left {$result['left']}"); - } - - return true; - } - - private function cancelStripeSubscriptions(): bool - { - $this->newLine(); - $this->info('═══════════════════════════════════════'); - $this->info('PHASE 5: CANCEL STRIPE SUBSCRIPTIONS'); - $this->info('═══════════════════════════════════════'); - $this->newLine(); - - $action = new CancelSubscription($this->user, $this->isDryRun); - $subscriptions = $action->getSubscriptionsPreview(); - - if ($subscriptions->isEmpty()) { - $this->info('No Stripe subscriptions to cancel.'); - - return true; - } - - $this->info('Stripe subscriptions to cancel:'); - $this->newLine(); - - $totalMonthlyValue = 0; - foreach ($subscriptions as $subscription) { - $team = $subscription->team; - $planId = $subscription->stripe_plan_id; - - // Try to get the price from config - $monthlyValue = $this->getSubscriptionMonthlyValue($planId); - $totalMonthlyValue += $monthlyValue; - - $this->line(" - {$subscription->stripe_subscription_id} (Team: {$team->name})"); - if ($monthlyValue > 0) { - $this->line(" Monthly value: \${$monthlyValue}"); - } - if ($subscription->stripe_cancel_at_period_end) { - $this->line(' ⚠️ Already set to cancel at period end'); - } - } - - if ($totalMonthlyValue > 0) { - $this->newLine(); - $this->warn("Total monthly value: \${$totalMonthlyValue}"); - } - $this->newLine(); - - $this->error('⚠️ WARNING: Subscriptions will be cancelled IMMEDIATELY (not at period end)!'); - if (! $this->confirm('Are you sure you want to cancel all these subscriptions immediately?', false)) { - return false; - } - - if (! $this->isDryRun) { - $this->info('Cancelling subscriptions...'); - $result = $action->execute(); - $this->info("Cancelled {$result['cancelled']} subscriptions, {$result['failed']} failed"); - if ($result['failed'] > 0 && ! empty($result['errors'])) { - $this->error('Failed subscriptions:'); - foreach ($result['errors'] as $error) { - $this->error(" - {$error}"); - } - } - $this->logAction("Cancelled {$result['cancelled']} Stripe subscriptions for user {$this->user->email}"); - } - - return true; - } - - private function deleteUserProfile(): bool - { - $this->newLine(); - $this->info('═══════════════════════════════════════'); - $this->info('PHASE 6: DELETE USER PROFILE'); - $this->info('═══════════════════════════════════════'); - $this->newLine(); - - $this->warn('⚠️ FINAL STEP - This action is IRREVERSIBLE!'); - $this->newLine(); - - $this->info('User profile to be deleted:'); - $this->table( - ['Property', 'Value'], - [ - ['Email', $this->user->email], - ['Name', $this->user->name], - ['User ID', $this->user->id], - ['Created', $this->user->created_at->format('Y-m-d H:i:s')], - ['Email Verified', $this->user->email_verified_at ? 'Yes' : 'No'], - ['2FA Enabled', $this->user->two_factor_confirmed_at ? 'Yes' : 'No'], - ] - ); - - $this->newLine(); - - $this->warn("Type 'DELETE {$this->user->email}' to confirm final deletion:"); - $confirmation = $this->ask('Confirmation'); - - if ($confirmation !== "DELETE {$this->user->email}") { - $this->error('Confirmation text does not match. Deletion cancelled.'); - - return false; - } - - if (! $this->isDryRun) { - $this->info('Deleting user profile...'); - - try { - $this->user->delete(); - $this->info('User profile deleted successfully.'); - $this->logAction("User profile deleted: {$this->user->email}"); - } catch (\Exception $e) { - $this->error('Failed to delete user profile: '.$e->getMessage()); - $this->logAction("Failed to delete user profile {$this->user->email}: ".$e->getMessage()); - - return false; - } - } - - return true; - } - - private function getSubscriptionMonthlyValue(string $planId): int - { - // Try to get pricing from subscription metadata or config - // Since we're using dynamic pricing, return 0 for now - // This could be enhanced by fetching the actual price from Stripe API - - // Check if this is a dynamic pricing plan - $dynamicMonthlyPlanId = config('subscription.stripe_price_id_dynamic_monthly'); - $dynamicYearlyPlanId = config('subscription.stripe_price_id_dynamic_yearly'); - - if ($planId === $dynamicMonthlyPlanId || $planId === $dynamicYearlyPlanId) { - // For dynamic pricing, we can't determine the exact amount without calling Stripe API - // Return 0 to indicate dynamic/usage-based pricing - return 0; - } - - // For any other plans, return 0 as we don't have hardcoded prices - return 0; - } - - private function logAction(string $message): void - { - $logMessage = "[CloudDeleteUser] {$message}"; - - if ($this->isDryRun) { - $logMessage = "[DRY RUN] {$logMessage}"; - } - - Log::channel('single')->info($logMessage); - - // Also log to a dedicated user deletion log file - $logFile = storage_path('logs/user-deletions.log'); - - // Ensure the logs directory exists - $logDir = dirname($logFile); - if (! is_dir($logDir)) { - mkdir($logDir, 0755, true); - } - - $timestamp = now()->format('Y-m-d H:i:s'); - file_put_contents($logFile, "[{$timestamp}] {$logMessage}\n", FILE_APPEND | LOCK_EX); - } -} diff --git a/tests/Unit/Actions/User/DeleteUserResourcesTest.php b/tests/Unit/Actions/User/DeleteUserResourcesTest.php new file mode 100644 index 000000000..3a623fee6 --- /dev/null +++ b/tests/Unit/Actions/User/DeleteUserResourcesTest.php @@ -0,0 +1,182 @@ +user = Mockery::mock(User::class); + $this->user->shouldReceive('getAttribute')->with('id')->andReturn(1); + $this->user->shouldReceive('getAttribute')->with('email')->andReturn('test@example.com'); +}); + +afterEach(function () { + Mockery::close(); +}); + +it('only collects resources from teams where user is the sole member', function () { + // Mock owned team where user is the ONLY member (will be deleted) + $ownedTeamPivot = (object) ['role' => 'owner']; + $ownedTeam = Mockery::mock(Team::class); + $ownedTeam->shouldReceive('getAttribute')->with('id')->andReturn(1); + $ownedTeam->shouldReceive('getAttribute')->with('pivot')->andReturn($ownedTeamPivot); + $ownedTeam->shouldReceive('getAttribute')->with('members')->andReturn(collect([$this->user])); + $ownedTeam->shouldReceive('setAttribute')->andReturnSelf(); + $ownedTeam->pivot = $ownedTeamPivot; + $ownedTeam->members = collect([$this->user]); + + // Mock member team (user is NOT owner) + $memberTeamPivot = (object) ['role' => 'member']; + $memberTeam = Mockery::mock(Team::class); + $memberTeam->shouldReceive('getAttribute')->with('id')->andReturn(2); + $memberTeam->shouldReceive('getAttribute')->with('pivot')->andReturn($memberTeamPivot); + $memberTeam->shouldReceive('getAttribute')->with('members')->andReturn(collect([$this->user])); + $memberTeam->shouldReceive('setAttribute')->andReturnSelf(); + $memberTeam->pivot = $memberTeamPivot; + $memberTeam->members = collect([$this->user]); + + // Mock servers for owned team + $ownedServer = Mockery::mock(Server::class); + $ownedServer->shouldReceive('applications')->andReturn(collect([ + (object) ['id' => 1, 'name' => 'app1'], + ])); + $ownedServer->shouldReceive('databases')->andReturn(collect([ + (object) ['id' => 1, 'name' => 'db1'], + ])); + $ownedServer->shouldReceive('services->get')->andReturn(collect([ + (object) ['id' => 1, 'name' => 'service1'], + ])); + + // Mock teams relationship + $teamsRelation = Mockery::mock(); + $teamsRelation->shouldReceive('get')->andReturn(collect([$ownedTeam, $memberTeam])); + $this->user->shouldReceive('teams')->andReturn($teamsRelation); + + // Mock servers relationship for owned team + $ownedServersRelation = Mockery::mock(); + $ownedServersRelation->shouldReceive('get')->andReturn(collect([$ownedServer])); + $ownedTeam->shouldReceive('servers')->andReturn($ownedServersRelation); + + // Execute + $action = new DeleteUserResources($this->user, true); + $preview = $action->getResourcesPreview(); + + // Assert: Should only include resources from owned team where user is sole member + expect($preview['applications'])->toHaveCount(1); + expect($preview['applications']->first()->id)->toBe(1); + expect($preview['applications']->first()->name)->toBe('app1'); + + expect($preview['databases'])->toHaveCount(1); + expect($preview['databases']->first()->id)->toBe(1); + + expect($preview['services'])->toHaveCount(1); + expect($preview['services']->first()->id)->toBe(1); +}); + +it('does not collect resources when user is owner but team has other members', function () { + // Mock owned team with multiple members (will be transferred, not deleted) + $otherUser = Mockery::mock(User::class); + $otherUser->shouldReceive('getAttribute')->with('id')->andReturn(2); + + $ownedTeamPivot = (object) ['role' => 'owner']; + $ownedTeam = Mockery::mock(Team::class); + $ownedTeam->shouldReceive('getAttribute')->with('id')->andReturn(1); + $ownedTeam->shouldReceive('getAttribute')->with('pivot')->andReturn($ownedTeamPivot); + $ownedTeam->shouldReceive('getAttribute')->with('members')->andReturn(collect([$this->user, $otherUser])); + $ownedTeam->shouldReceive('setAttribute')->andReturnSelf(); + $ownedTeam->pivot = $ownedTeamPivot; + $ownedTeam->members = collect([$this->user, $otherUser]); + + // Mock teams relationship + $teamsRelation = Mockery::mock(); + $teamsRelation->shouldReceive('get')->andReturn(collect([$ownedTeam])); + $this->user->shouldReceive('teams')->andReturn($teamsRelation); + + // Execute + $action = new DeleteUserResources($this->user, true); + $preview = $action->getResourcesPreview(); + + // Assert: Should have no resources (team will be transferred, not deleted) + expect($preview['applications'])->toBeEmpty(); + expect($preview['databases'])->toBeEmpty(); + expect($preview['services'])->toBeEmpty(); +}); + +it('does not collect resources when user is only a member of teams', function () { + // Mock member team (user is NOT owner) + $memberTeamPivot = (object) ['role' => 'member']; + $memberTeam = Mockery::mock(Team::class); + $memberTeam->shouldReceive('getAttribute')->with('id')->andReturn(1); + $memberTeam->shouldReceive('getAttribute')->with('pivot')->andReturn($memberTeamPivot); + $memberTeam->shouldReceive('getAttribute')->with('members')->andReturn(collect([$this->user])); + $memberTeam->shouldReceive('setAttribute')->andReturnSelf(); + $memberTeam->pivot = $memberTeamPivot; + $memberTeam->members = collect([$this->user]); + + // Mock teams relationship + $teamsRelation = Mockery::mock(); + $teamsRelation->shouldReceive('get')->andReturn(collect([$memberTeam])); + $this->user->shouldReceive('teams')->andReturn($teamsRelation); + + // Execute + $action = new DeleteUserResources($this->user, true); + $preview = $action->getResourcesPreview(); + + // Assert: Should have no resources + expect($preview['applications'])->toBeEmpty(); + expect($preview['databases'])->toBeEmpty(); + expect($preview['services'])->toBeEmpty(); +}); + +it('collects resources only from teams where user is sole member', function () { + // Mock first team: user is sole member (will be deleted) + $ownedTeam1Pivot = (object) ['role' => 'owner']; + $ownedTeam1 = Mockery::mock(Team::class); + $ownedTeam1->shouldReceive('getAttribute')->with('id')->andReturn(1); + $ownedTeam1->shouldReceive('getAttribute')->with('pivot')->andReturn($ownedTeam1Pivot); + $ownedTeam1->shouldReceive('getAttribute')->with('members')->andReturn(collect([$this->user])); + $ownedTeam1->shouldReceive('setAttribute')->andReturnSelf(); + $ownedTeam1->pivot = $ownedTeam1Pivot; + $ownedTeam1->members = collect([$this->user]); + + // Mock second team: user is owner but has other members (will be transferred) + $otherUser = Mockery::mock(User::class); + $otherUser->shouldReceive('getAttribute')->with('id')->andReturn(2); + + $ownedTeam2Pivot = (object) ['role' => 'owner']; + $ownedTeam2 = Mockery::mock(Team::class); + $ownedTeam2->shouldReceive('getAttribute')->with('id')->andReturn(2); + $ownedTeam2->shouldReceive('getAttribute')->with('pivot')->andReturn($ownedTeam2Pivot); + $ownedTeam2->shouldReceive('getAttribute')->with('members')->andReturn(collect([$this->user, $otherUser])); + $ownedTeam2->shouldReceive('setAttribute')->andReturnSelf(); + $ownedTeam2->pivot = $ownedTeam2Pivot; + $ownedTeam2->members = collect([$this->user, $otherUser]); + + // Mock server for team 1 (sole member - will be deleted) + $server1 = Mockery::mock(Server::class); + $server1->shouldReceive('applications')->andReturn(collect([ + (object) ['id' => 1, 'name' => 'app1'], + ])); + $server1->shouldReceive('databases')->andReturn(collect([])); + $server1->shouldReceive('services->get')->andReturn(collect([])); + + // Mock teams relationship + $teamsRelation = Mockery::mock(); + $teamsRelation->shouldReceive('get')->andReturn(collect([$ownedTeam1, $ownedTeam2])); + $this->user->shouldReceive('teams')->andReturn($teamsRelation); + + // Mock servers for team 1 + $servers1Relation = Mockery::mock(); + $servers1Relation->shouldReceive('get')->andReturn(collect([$server1])); + $ownedTeam1->shouldReceive('servers')->andReturn($servers1Relation); + + // Execute + $action = new DeleteUserResources($this->user, true); + $preview = $action->getResourcesPreview(); + + // Assert: Should only include resources from team 1 (sole member) + expect($preview['applications'])->toHaveCount(1); + expect($preview['applications']->first()->id)->toBe(1); +});