argument('user_id'); $dryRun = $this->option('dry-run'); if ($dryRun) { $this->info('DRY RUN MODE - No changes will be made'); $this->newLine(); } // Validate user exists $user = User::find($userId); if (!$user) { $this->error("User with ID {$userId} not found"); return 1; } // Safety validations $validationResult = $this->validateUserForMigration($user); // Handle blocking errors (cannot proceed) if (!empty($validationResult['blocking_errors'])) { $this->error("Migration validation failed:"); foreach ($validationResult['blocking_errors'] as $error) { $this->line(" • {$error}"); } return 1; } // Handle warnings that require confirmation if (!empty($validationResult['warnings'])) { $this->warn("Migration warnings:"); foreach ($validationResult['warnings'] as $warning) { $this->line(" • {$warning}"); } if (!$dryRun && !$this->confirm('Do you want to continue with the migration despite these warnings?', false)) { $this->info('Migration cancelled by user.'); return 0; } } // Handle specific organization management conflicts if (!empty($validationResult['organization_conflicts'])) { $this->warn("Organization Management Conflicts:"); foreach ($validationResult['organization_conflicts'] as $conflict) { $this->line(" • {$conflict}"); } if (!$dryRun && !$this->confirm('Do you still want to migrate? All organization management relationships will be removed.', false)) { $this->info('Migration cancelled by user.'); return 0; } } $this->info("Migrating User '{$user->name}' (ID: {$userId}) to Organization"); $this->newLine(); if ($dryRun) { return $this->previewMigration($user); } return $this->executeMigration($user); } /** * Preview what the migration would do */ private function previewMigration(User $user): int { $this->info('MIGRATION PREVIEW:'); $this->line('─────────────────────'); // Check what would be created $this->info("Would create Organization:"); $this->line(" Name: {$user->name}"); $this->line(" Email: {$user->email}"); $this->line(" Limits: min=" . timebank_config('accounts.organization.limit_min', 0) . ", max=" . timebank_config('accounts.organization.limit_max', 12000)); // Show admin cleanup that would happen $adminRoles = ['admin', 'super-admin', 'Admin', 'Super Admin']; $hasAdminRoles = false; foreach ($adminRoles as $roleName) { if ($user->hasRole($roleName)) { if (!$hasAdminRoles) { $this->line("Would remove admin roles:"); $hasAdminRoles = true; } $this->line(" - {$roleName}"); } } $adminCount = $user->admins()->count(); if ($adminCount > 0) { $this->line("Would remove {$adminCount} admin relationships"); } // Show bank management cleanup that would happen $bankCount = $user->banksManaged()->count(); if ($bankCount > 0) { $bankNames = $user->banksManaged()->pluck('name')->toArray(); $this->line("Would remove management of {$bankCount} bank(s):"); foreach ($bankNames as $bankName) { $this->line(" - {$bankName}"); } } // Show organization management cleanup that would happen $organizationCount = $user->organizations()->count(); if ($organizationCount > 0) { $orgNames = $user->organizations()->pluck('name')->toArray(); $this->line("Would remove management of {$organizationCount} organization(s):"); foreach ($orgNames as $orgName) { $this->line(" - {$orgName}"); } } // Check polymorphic relationships $polymorphicTables = $this->getPolymorphicTables(); foreach ($polymorphicTables as $table => $columns) { $count = DB::table($table) ->where($columns['type'], 'App\Models\User') ->where($columns['id'], $user->id) ->count(); if ($count > 0) { $this->line("{$table}: {$count} records to update"); } } // Check pivot tables $pivotTables = $this->getPivotTables(); foreach ($pivotTables as $table => $column) { $count = DB::table($table)->where($column, $user->id)->count(); if ($count > 0) { $this->line("{$table}: {$count} records to migrate"); } } $this->newLine(); $this->info('Run without --dry-run to execute the migration'); return 0; } /** * Execute the actual migration */ private function executeMigration(User $user): int { DB::beginTransaction(); try { $this->info('Starting migration...'); // Step 1: Remove admin relationships and roles $this->removeAdminRelationshipsAndRoles($user); // Step 2: Create Organization $organization = $this->createOrganizationFromUser($user); $this->info("Created Organization with ID: {$organization->id}"); // Step 3: Update polymorphic relationships $this->updatePolymorphicRelationships($user, $organization); // Step 4: Update love package relationships $this->updateLoveRelationships($user, $organization); // Step 5: Handle pivot tables $this->updatePivotTables($user, $organization); // Step 6: Update direct references $this->updateDirectReferences($user, $organization); // Step 7: Handle special cases $this->handleSpecialCases($user, $organization); // Step 8: Delete the original User and cleanup relationships $this->deleteUserAndRelationships($user, $organization); DB::commit(); $this->newLine(); $this->info('Migration completed successfully!'); $this->info("User ID {$user->id} is now Organization ID {$organization->id}"); $this->info('Original User record and all relationships deleted'); return 0; } catch (\Exception $e) { DB::rollBack(); $this->error('Migration failed: ' . $e->getMessage()); Log::error('User to Organization migration failed', [ 'user_id' => $user->id, 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString() ]); return 1; } } /** * Create Organization from User data */ private function createOrganizationFromUser(User $user): Organization { // Copy all common columns between User and Organization models $organizationData = $user->only([ 'name', 'full_name', 'email', 'profile_photo_path', 'about', 'about_short', 'motivation', 'website', 'phone', 'phone_public', 'password', 'lang_preference', 'last_login_at', 'last_login_ip', 'comment', 'cyclos_id', 'cyclos_salt', 'cyclos_skills', 'email_verified_at', 'inactive_at', 'deleted_at' ]); // Set Organization-specific limits from config $organizationData['limit_min'] = timebank_config('profiles.organization.limit_min', 0); $organizationData['limit_max'] = timebank_config('profiles.organization.limit_max', 6000); // Copy timestamps $organizationData['created_at'] = $user->created_at; $organizationData['updated_at'] = $user->updated_at; // Create the organization with fillable fields (don't include love IDs yet) $organization = Organization::create($organizationData); // Set non-fillable fields directly on the model to bypass mass assignment protection $nonFillableFields = [ 'comment', 'cyclos_id', 'cyclos_salt', 'cyclos_skills', 'email_verified_at', 'inactive_at', 'deleted_at' ]; foreach ($nonFillableFields as $field) { if ($user->$field !== null) { $organization->$field = $user->$field; } } // Force copy love package IDs from user AFTER organization creation // This ensures we preserve the original user's love IDs, not newly generated ones if ($user->love_reactant_id) { $organization->love_reactant_id = $user->love_reactant_id; } if ($user->love_reacter_id) { $organization->love_reacter_id = $user->love_reacter_id; } // Save the organization with all additional fields including love IDs $organization->save(); return $organization; } /** * Update all polymorphic relationships */ private function updatePolymorphicRelationships(User $user, Organization $organization): void { $this->info('Updating polymorphic relationships...'); $polymorphicTables = $this->getPolymorphicTables(); foreach ($polymorphicTables as $table => $columns) { $count = DB::table($table) ->where($columns['type'], 'App\Models\User') ->where($columns['id'], $user->id) ->update([ $columns['type'] => 'App\Models\Organization', $columns['id'] => $organization->id ]); if ($count > 0) { $this->line(" {$table}: Updated {$count} records"); } } // Update account limits specifically $this->updateAccountLimits($organization); } /** * Update account limits to organization values */ private function updateAccountLimits(Organization $organization): void { $this->info('Updating account limits and names...'); $userAccountName = timebank_config('accounts.user.name', 'personal'); $orgAccountName = timebank_config('accounts.organization.name', 'organization'); $limitMin = timebank_config('accounts.organization.limit_min', 0); $limitMax = timebank_config('accounts.organization.limit_max', 12000); // Get all accounts for this organization $accounts = DB::table('accounts') ->where('accountable_type', 'App\Models\Organization') ->where('accountable_id', $organization->id) ->get(['id', 'name']); $limitsUpdated = 0; $namesRenamed = 0; $assignedNames = []; // Track names assigned during this migration foreach ($accounts as $account) { $updateData = [ 'limit_min' => $limitMin, 'limit_max' => $limitMax ]; // Rename accounts based on their current name if ($account->name === $userAccountName) { // Rename 'personal' to 'organization' (with numbering if needed) $newName = $this->generateUniqueAccountName($organization, $orgAccountName, $assignedNames); $updateData['name'] = $newName; $assignedNames[] = $newName; // Track this assigned name $namesRenamed++; $this->line(" Renaming account ID {$account->id}: '{$account->name}' → '{$newName}'"); } elseif ($account->name === 'gift') { // Rename 'gift' to 'donation' (with numbering if needed) $newName = $this->generateUniqueAccountName($organization, 'donation', $assignedNames); $updateData['name'] = $newName; $assignedNames[] = $newName; // Track this assigned name $namesRenamed++; $this->line(" Renaming account ID {$account->id}: '{$account->name}' → '{$newName}'"); } // Update the account DB::table('accounts') ->where('id', $account->id) ->update($updateData); $limitsUpdated++; } if ($limitsUpdated > 0) { $this->line(" Updated limits for {$limitsUpdated} account(s): min={$limitMin}, max={$limitMax}"); } if ($namesRenamed > 0) { $this->line(" Renamed {$namesRenamed} account(s) (personal→organization, gift→donation)"); } if ($limitsUpdated === 0) { $this->line(" No accounts found to update"); } } /** * Generate a unique account name for the organization */ private function generateUniqueAccountName(Organization $organization, string $baseName, array $accountsBeingRenamed = []): string { // Get all existing account names for this organization $existingNames = DB::table('accounts') ->where('accountable_type', 'App\Models\Organization') ->where('accountable_id', $organization->id) ->pluck('name') ->toArray(); // Also consider names that are being assigned in this migration batch $existingNames = array_merge($existingNames, $accountsBeingRenamed); // If base name doesn't exist, use it if (!in_array($baseName, $existingNames)) { return $baseName; } // Try numbered versions until we find one that doesn't exist $counter = 2; while (true) { $candidateName = $baseName . ' ' . $counter; if (!in_array($candidateName, $existingNames)) { return $candidateName; } $counter++; } } /** * Update love package relationships */ private function updateLoveRelationships(User $user, Organization $organization): void { $this->info('Updating love package relationships...'); // Update love_reactants type from User to Organization if ($user->loveReactant) { $updated = DB::table('love_reactants') ->where('id', $user->loveReactant->id) ->update(['type' => 'App\Models\Organization']); if ($updated > 0) { $this->line(" love_reactants: Updated type for reactant ID {$user->loveReactant->id}"); } } // Update love_reacters type from User to Organization if ($user->loveReacter) { $updated = DB::table('love_reacters') ->where('id', $user->loveReacter->id) ->update(['type' => 'App\Models\Organization']); if ($updated > 0) { $this->line(" love_reacters: Updated type for reacter ID {$user->loveReacter->id}"); } } if (!$user->loveReactant && !$user->loveReacter) { $this->line(" No love relationships to update"); } } /** * Handle pivot table migrations */ private function updatePivotTables(User $user, Organization $organization): void { $this->info('Updating pivot tables...'); // Handle bank_user -> bank_organization migration $bankRelationships = DB::table('bank_user')->where('user_id', $user->id)->get(); foreach ($bankRelationships as $relationship) { // Create new bank_organization relationship DB::table('bank_organization')->insertOrIgnore([ 'bank_id' => $relationship->bank_id, 'organization_id' => $organization->id, 'created_at' => $relationship->created_at ?? now(), 'updated_at' => $relationship->updated_at ?? now() ]); } if ($bankRelationships->count() > 0) { $this->line(" bank_organization: Migrated {$bankRelationships->count()} relationships"); // Delete old bank_user relationships DB::table('bank_user')->where('user_id', $user->id)->delete(); } // Make the User a manager of the new Organization DB::table('organization_user')->insertOrIgnore([ 'organization_id' => $organization->id, 'user_id' => $user->id, 'created_at' => now(), 'updated_at' => now() ]); $this->line(" organization_user: Added User as manager"); // Handle other pivot tables $pivotTables = $this->getPivotTables(); foreach ($pivotTables as $table => $column) { if ($table === 'bank_user') { continue; } // Already handled above $count = DB::table($table)->where($column, $user->id)->count(); if ($count > 0) { $this->line(" {$table}: {$count} records need manual review"); } } } /** * Update direct references */ private function updateDirectReferences(User $user, Organization $organization): void { $this->info('Updating direct references...'); // Handle Spatie Permission tables DB::table('model_has_roles') ->where('model_type', 'App\Models\User') ->where('model_id', $user->id) ->update([ 'model_type' => 'App\Models\Organization', 'model_id' => $organization->id ]); DB::table('model_has_permissions') ->where('model_type', 'App\Models\User') ->where('model_id', $user->id) ->update([ 'model_type' => 'App\Models\Organization', 'model_id' => $organization->id ]); $this->line(" Updated permission system references"); } /** * Handle special cases like Elasticsearch, caches, etc. */ private function handleSpecialCases(User $user, Organization $organization): void { $this->info('Handling special cases...'); // Re-index in Elasticsearch try { $organization->searchable(); $this->line(" Updated Elasticsearch index"); } catch (\Exception $e) { $this->line(" Elasticsearch update failed: " . $e->getMessage()); } // Clear caches if (function_exists('cache')) { cache()->forget("user.{$user->id}"); $this->line(" Cleared user cache"); } } /** * Get polymorphic table mappings */ private function getPolymorphicTables(): array { $tables = [ 'accounts' => ['type' => 'accountable_type', 'id' => 'accountable_id'], 'locations' => ['type' => 'locatable_type', 'id' => 'locatable_id'], 'posts' => ['type' => 'postable_type', 'id' => 'postable_id'], 'categories' => ['type' => 'categoryable_type', 'id' => 'categoryable_id'], 'activity_log' => ['type' => 'subject_type', 'id' => 'subject_id'], ]; // Check for optional tables that might exist if ($this->tableExists('languagables')) { $tables['languagables'] = ['type' => 'languagable_type', 'id' => 'languagable_id']; } if ($this->tableExists('sociables')) { $tables['sociables'] = ['type' => 'sociable_type', 'id' => 'sociable_id']; } if ($this->tableExists('bank_clients')) { $tables['bank_clients'] = ['type' => 'client_type', 'id' => 'client_id']; } // Love package tables are handled separately in updateLoveRelationships() // as they use a different pattern (type column instead of polymorphic columns) return $tables; } /** * Get pivot table mappings */ private function getPivotTables(): array { return [ 'bank_user' => 'user_id', 'admin_user' => 'user_id', 'organization_user' => 'user_id', ]; } /** * Check if a table exists */ private function tableExists(string $tableName): bool { try { DB::table($tableName)->limit(1)->count(); return true; } catch (\Exception $e) { return false; } } /** * Validate if user can be safely migrated */ private function validateUserForMigration(User $user): array { $blockingErrors = []; $warnings = []; // BLOCKING ERRORS - Migration cannot proceed // Check if an organization with the same name already exists if (Organization::where('name', $user->name)->exists()) { $blockingErrors[] = "An Organization with name '{$user->name}' already exists"; } // Check if an organization with the same email already exists if (Organization::where('email', $user->email)->exists()) { $blockingErrors[] = "An Organization with email '{$user->email}' already exists"; } // Check if user is currently online (has recent presence) try { if (method_exists($user, 'isOnline') && $user->isOnline()) { $blockingErrors[] = "User is currently online - wait for them to go offline before migrating"; } } catch (\Exception $e) { // Skip online check if presence system is not available } // WARNINGS - Migration can proceed with confirmation // Check if user is a super admin or critical system user if ($user->hasRole('Super Admin') || $user->hasRole('super-admin')) { $warnings[] = "User has Super Admin role - all admin privileges will be removed during migration"; } // Check if user has critical system permissions $criticalPermissions = ['manage system', 'manage users', 'super-admin']; foreach ($criticalPermissions as $permission) { if ($user->can($permission)) { $warnings[] = "User has critical permission '{$permission}' - all admin permissions will be removed during migration"; break; } } // Check if user has active two-factor authentication if (!empty($user->two_factor_secret)) { $warnings[] = "User has two-factor authentication enabled - this will be lost during migration"; } // ORGANIZATION CONFLICTS - Separate from general warnings $organizationConflicts = []; // Check if user is already managing banks if ($user->banksManaged()->count() > 0) { $organizationConflicts[] = "User is managing " . $user->banksManaged()->count() . " bank(s). After the migration this will not be possible any more."; } // Check if user is already managing organizations if ($user->organizations()->count() > 0) { $organizationConflicts[] = "User is managing " . $user->organizations()->count() . " organization(s). After the migration this will not be possible any more."; } return [ 'blocking_errors' => $blockingErrors, 'warnings' => $warnings, 'organization_conflicts' => $organizationConflicts ]; } /** * Remove admin relationships and roles before migration */ private function removeAdminRelationshipsAndRoles(User $user): void { $this->info('Removing admin relationships and roles...'); // Remove admin roles using Spatie method $adminRoles = ['admin', 'super-admin', 'Admin', 'Super Admin']; $rolesRemoved = []; foreach ($adminRoles as $roleName) { if ($user->hasRole($roleName)) { $user->removeRole($roleName); $rolesRemoved[] = $roleName; } } if (!empty($rolesRemoved)) { $this->line(' Removed roles: ' . implode(', ', $rolesRemoved)); } // Remove admin relationships (many-to-many) $adminCount = $user->admins()->count(); if ($adminCount > 0) { $user->admins()->detach(); $this->line(" Removed {$adminCount} admin relationships"); } // Remove bank management relationships $bankCount = $user->banksManaged()->count(); if ($bankCount > 0) { $bankNames = $user->banksManaged()->pluck('name')->toArray(); $user->banksManaged()->detach(); // Un-associate all managed banks $this->line(" Removed management of {$bankCount} bank(s): " . implode(', ', $bankNames)); } // Remove organization management relationships $organizationCount = $user->organizations()->count(); if ($organizationCount > 0) { $orgNames = $user->organizations()->pluck('name')->toArray(); $user->organizations()->detach(); // Un-associate all managed organizations $this->line(" Removed management of {$organizationCount} organization(s): " . implode(', ', $orgNames)); } if (empty($rolesRemoved) && $adminCount === 0 && $bankCount === 0 && $organizationCount === 0) { $this->line(' No admin relationships or roles to remove'); } } /** * Delete User and cleanup all remaining relationships */ private function deleteUserAndRelationships(User $user, Organization $organization): void { $this->info('Deleting User and cleaning up relationships...'); // Step 1: Verify critical data was migrated $this->verifyMigrationCompleteness($user, $organization); // Step 2: Clean up pivot table relationships that weren't migrated $this->cleanupPivotRelationships($user); // Step 3: Clean up remaining foreign key references $this->cleanupForeignKeyReferences($user); // Step 4: Delete the User model $userId = $user->id; $userName = $user->name; $user->delete(); $this->line(" Deleted User '{$userName}' (ID: {$userId})"); // Step 5: Verify complete deletion $this->verifyUserDeletion($userId); } /** * Verify that critical data was successfully migrated */ private function verifyMigrationCompleteness(User $user, Organization $organization): void { // Check that accounts were transferred $userAccounts = $user->accounts()->count(); $orgAccounts = $organization->accounts()->count(); if ($userAccounts > 0) { throw new \Exception("User still has {$userAccounts} accounts - migration incomplete"); } if ($orgAccounts === 0) { $this->line(" Organization has no accounts - this may be expected"); } $this->line(" Migration verification passed"); } /** * Clean up pivot table relationships */ private function cleanupPivotRelationships(User $user): void { $cleanupTables = [ 'organization_user' => 'user_id', 'bank_user' => 'user_id' // admin_user is already handled in removeAdminRelationshipsAndRoles() ]; foreach ($cleanupTables as $table => $column) { if ($this->tableExists($table)) { $count = DB::table($table)->where($column, $user->id)->count(); if ($count > 0) { DB::table($table)->where($column, $user->id)->delete(); $this->line(" Cleaned up {$table}: {$count} records deleted"); } } } // Clean up Spatie permission pivot tables $permissionTables = [ 'model_has_roles' => ['model_type' => 'App\Models\User', 'model_id' => $user->id], 'model_has_permissions' => ['model_type' => 'App\Models\User', 'model_id' => $user->id] ]; foreach ($permissionTables as $table => $conditions) { if ($this->tableExists($table)) { $count = DB::table($table)->where($conditions)->count(); if ($count > 0) { DB::table($table)->where($conditions)->delete(); $this->line(" Cleaned up {$table}: {$count} records deleted"); } } } } /** * Clean up foreign key references */ private function cleanupForeignKeyReferences(User $user): void { // Clean up activity logs where User is the causer (not subject - those are audit trail) if ($this->tableExists('activity_log')) { $count = DB::table('activity_log') ->where('causer_type', 'App\Models\User') ->where('causer_id', $user->id) ->count(); if ($count > 0) { // Set causer to null instead of deleting logs for audit trail DB::table('activity_log') ->where('causer_type', 'App\Models\User') ->where('causer_id', $user->id) ->update([ 'causer_type' => null, 'causer_id' => null ]); $this->line(" Cleaned up activity_log causers: {$count} records updated"); } } // Love package cleanup is handled by updateLoveRelationships() method // No additional cleanup needed as we're updating types, not deleting records // Clean up any remaining chat/messaging relationships $chatTables = ['chat_participants', 'chat_messages']; foreach ($chatTables as $table) { if ($this->tableExists($table)) { $userColumn = $table === 'chat_participants' ? 'user_id' : 'sender_id'; $count = DB::table($table)->where($userColumn, $user->id)->count(); if ($count > 0) { if ($table === 'chat_messages') { // For messages, mark as deleted rather than removing for chat history DB::table($table) ->where($userColumn, $user->id) ->update(['sender_id' => null, 'deleted_at' => now()]); $this->line(" Cleaned up {$table}: {$count} records marked as deleted"); } else { DB::table($table)->where($userColumn, $user->id)->delete(); $this->line(" Cleaned up {$table}: {$count} records deleted"); } } } } } /** * Verify User was completely deleted */ private function verifyUserDeletion(int $userId): void { // Check that User record is gone if (User::find($userId)) { throw new \Exception("User deletion failed - User {$userId} still exists"); } // Check for any remaining references in key tables $checkTables = [ 'organization_user' => 'user_id', 'bank_user' => 'user_id', 'admin_user' => 'user_id' ]; foreach ($checkTables as $table => $column) { if ($this->tableExists($table)) { $remaining = DB::table($table)->where($column, $userId)->count(); if ($remaining > 0) { $this->line(" Warning: {$remaining} records remain in {$table}"); } } } $this->line(" User deletion verification completed"); } }