feat(user-deletion): implement file locking to prevent concurrent user deletions and enhance error handling

This commit is contained in:
Andras Bacsai 2025-09-29 14:03:49 +02:00
parent 364080a447
commit 72f5ae0dc6

View file

@ -8,6 +8,7 @@
use App\Actions\User\DeleteUserTeams; use App\Actions\User\DeleteUserTeams;
use App\Models\User; use App\Models\User;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@ -54,124 +55,141 @@ public function handle()
return 1; return 1;
} }
$this->logAction("Starting user deletion process for: {$email}"); // 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
// Phase 1: Show User Overview (outside transaction) if (! $lock->get()) {
if (! $this->showUserOverview()) { $this->error('Another deletion process is already running for this user. Please try again later.');
$this->info('User deletion cancelled.'); $this->logAction("Deletion blocked for user {$email}: Another process is already running");
return 0; return 1;
} }
// If not dry run, wrap everything in a transaction try {
if (! $this->isDryRun) { $this->logAction("Starting user deletion process for: {$email}");
try {
DB::beginTransaction();
// 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 // Phase 2: Delete Resources
if (! $this->skipResources) { if (! $this->skipResources) {
if (! $this->deleteResources()) { if (! $this->deleteResources()) {
DB::rollBack(); $this->info('User deletion would be cancelled at resource deletion phase.');
$this->error('User deletion failed at resource deletion phase. All changes rolled back.');
return 1; return 0;
} }
} }
// Phase 3: Delete Servers // Phase 3: Delete Servers
if (! $this->deleteServers()) { if (! $this->deleteServers()) {
DB::rollBack(); $this->info('User deletion would be cancelled at server deletion phase.');
$this->error('User deletion failed at server deletion phase. All changes rolled back.');
return 1; return 0;
} }
// Phase 4: Handle Teams // Phase 4: Handle Teams
if (! $this->handleTeams()) { if (! $this->handleTeams()) {
DB::rollBack(); $this->info('User deletion would be cancelled at team handling phase.');
$this->error('User deletion failed at team handling phase. All changes rolled back.');
return 1; return 0;
} }
// Phase 5: Cancel Stripe Subscriptions // Phase 5: Cancel Stripe Subscriptions
if (! $this->skipStripe && isCloud()) { if (! $this->skipStripe && isCloud()) {
if (! $this->cancelStripeSubscriptions()) { if (! $this->cancelStripeSubscriptions()) {
DB::rollBack(); $this->info('User deletion would be cancelled at Stripe cancellation phase.');
$this->error('User deletion failed at Stripe cancellation phase. All changes rolled back.');
return 1; return 0;
} }
} }
// Phase 6: Delete User Profile // Phase 6: Delete User Profile
if (! $this->deleteUserProfile()) { if (! $this->deleteUserProfile()) {
DB::rollBack(); $this->info('User deletion would be cancelled at final phase.');
$this->error('User deletion failed at final phase. All changes rolled back.');
return 1; return 0;
} }
// Commit the transaction
DB::commit();
$this->newLine(); $this->newLine();
$this->info('✅ User deletion completed successfully!'); $this->info('✅ DRY RUN completed successfully! No data was deleted.');
$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 return 0;
if (! $this->deleteServers()) { } finally {
$this->info('User deletion would be cancelled at server deletion phase.'); // Ensure lock is always released
$lock->release();
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;
} }
private function showUserOverview(): bool private function showUserOverview(): bool
@ -683,24 +701,21 @@ private function deleteUserProfile(): bool
private function getSubscriptionMonthlyValue(string $planId): int private function getSubscriptionMonthlyValue(string $planId): int
{ {
// Map plan IDs to monthly values based on config // Try to get pricing from subscription metadata or config
$subscriptionConfigs = config('subscription'); // Since we're using dynamic pricing, return 0 for now
// This could be enhanced by fetching the actual price from Stripe API
foreach ($subscriptionConfigs as $key => $value) { // Check if this is a dynamic pricing plan
if ($value === $planId && str_contains($key, 'stripe_price_id_')) { $dynamicMonthlyPlanId = config('subscription.stripe_price_id_dynamic_monthly');
// Extract price from key pattern: stripe_price_id_basic_monthly -> basic $dynamicYearlyPlanId = config('subscription.stripe_price_id_dynamic_yearly');
$planType = str($key)->after('stripe_price_id_')->before('_')->toString();
// Map to known prices (you may need to adjust these based on your actual pricing) if ($planId === $dynamicMonthlyPlanId || $planId === $dynamicYearlyPlanId) {
return match ($planType) { // For dynamic pricing, we can't determine the exact amount without calling Stripe API
'basic' => 29, // Return 0 to indicate dynamic/usage-based pricing
'pro' => 49, return 0;
'ultimate' => 99,
default => 0
};
}
} }
// For any other plans, return 0 as we don't have hardcoded prices
return 0; return 0;
} }
@ -716,6 +731,13 @@ private function logAction(string $message): void
// Also log to a dedicated user deletion log file // Also log to a dedicated user deletion log file
$logFile = storage_path('logs/user-deletions.log'); $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'); $timestamp = now()->format('Y-m-d H:i:s');
file_put_contents($logFile, "[{$timestamp}] {$logMessage}\n", FILE_APPEND | LOCK_EX); file_put_contents($logFile, "[{$timestamp}] {$logMessage}\n", FILE_APPEND | LOCK_EX);
} }