argument('organization_id'); $dryRun = $this->option('dry-run'); if ($dryRun) { $this->info('DRY RUN MODE - No changes will be made'); $this->newLine(); } // Validate organization exists $organization = Organization::find($organizationId); if (!$organization) { $this->error("Organization with ID {$organizationId} not found"); return 1; } // Safety validations $validationResult = $this->validateOrganizationForMigration($organization); // 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 management conflicts if (!empty($validationResult['management_conflicts'])) { $this->warn("Management Conflicts:"); foreach ($validationResult['management_conflicts'] as $conflict) { $this->line(" • {$conflict}"); } if (!$dryRun && !$this->confirm('Do you still want to migrate? All management relationships will be removed.', false)) { $this->info('Migration cancelled by user.'); return 0; } } $this->info("Migrating Organization '{$organization->name}' (ID: {$organizationId}) to User"); $this->newLine(); if ($dryRun) { return $this->previewMigration($organization); } return $this->executeMigration($organization); } /** * Preview what the migration would do */ private function previewMigration(Organization $organization): int { $this->info('MIGRATION PREVIEW:'); $this->line('─────────────────────'); // Check what would be created $this->info("Would create User:"); $this->line(" Name: {$organization->name}"); $this->line(" Email: {$organization->email}"); $this->line(" Limits: min=" . timebank_config('accounts.user.limit_min', 0) . ", max=" . timebank_config('accounts.user.limit_max', 6000)); // Show management cleanup that would happen try { if (method_exists($organization, 'banksManaged')) { $bankCount = $organization->banksManaged()->count(); if ($bankCount > 0) { $bankNames = $organization->banksManaged()->pluck('name')->toArray(); $this->line("Would remove management of {$bankCount} bank(s):"); foreach ($bankNames as $bankName) { $this->line(" - {$bankName}"); } } } } catch (\Exception $e) { // Skip bank management preview if relationship doesn't exist } // Check polymorphic relationships $polymorphicTables = $this->getPolymorphicTables(); foreach ($polymorphicTables as $table => $columns) { $count = DB::table($table) ->where($columns['type'], 'App\Models\Organization') ->where($columns['id'], $organization->id) ->count(); if ($count > 0) { $this->line("{$table}: {$count} records to update"); } } // Check pivot tables $pivotTables = $this->getPivotTables(); foreach ($pivotTables as $table => $column) { if ($this->tableExists($table)) { $count = DB::table($table)->where($column, $organization->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(Organization $organization): int { DB::beginTransaction(); try { $this->info('Starting migration...'); // Step 1: Remove management relationships $this->removeManagementRelationships($organization); // Step 2: Create User $user = $this->createUserFromOrganization($organization); $this->info("Created User with ID: {$user->id}"); // Step 3: Update polymorphic relationships $this->updatePolymorphicRelationships($organization, $user); // Step 4: Update love package relationships $this->updateLoveRelationships($organization, $user); // Step 5: Handle pivot tables $this->updatePivotTables($organization, $user); // Step 6: Update direct references $this->updateDirectReferences($organization, $user); // Step 7: Handle special cases $this->handleSpecialCases($organization, $user); // Step 8: Delete the original Organization and cleanup relationships $this->deleteOrganizationAndRelationships($organization, $user); DB::commit(); $this->newLine(); $this->info('Migration completed successfully!'); $this->info("Organization ID {$organization->id} is now User ID {$user->id}"); $this->info('Original Organization record and all relationships deleted'); return 0; } catch (\Exception $e) { DB::rollBack(); $this->error('Migration failed: ' . $e->getMessage()); Log::error('Organization to User migration failed', [ 'organization_id' => $organization->id, 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString() ]); return 1; } } /** * Create User from Organization data */ private function createUserFromOrganization(Organization $organization): User { // Copy all common columns between Organization and User models $userData = $organization->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 User-specific limits from config $userData['limit_min'] = timebank_config('profiles.user.limit_min', 0); $userData['limit_max'] = timebank_config('profiles.user.limit_max', 6000); // Copy timestamps $userData['created_at'] = $organization->created_at; $userData['updated_at'] = $organization->updated_at; // Create the user with fillable fields (don't include love IDs yet) $user = User::create($userData); // 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 ($organization->$field !== null) { $user->$field = $organization->$field; } } // Force copy love package IDs from organization AFTER user creation // This ensures we preserve the original organization's love IDs, not newly generated ones if ($organization->love_reactant_id) { $user->love_reactant_id = $organization->love_reactant_id; } if ($organization->love_reacter_id) { $user->love_reacter_id = $organization->love_reacter_id; } // Save the user with all additional fields including love IDs $user->save(); return $user; } /** * Update all polymorphic relationships */ private function updatePolymorphicRelationships(Organization $organization, User $user): void { $this->info('Updating polymorphic relationships...'); $polymorphicTables = $this->getPolymorphicTables(); foreach ($polymorphicTables as $table => $columns) { $count = DB::table($table) ->where($columns['type'], 'App\Models\Organization') ->where($columns['id'], $organization->id) ->update([ $columns['type'] => 'App\Models\User', $columns['id'] => $user->id ]); if ($count > 0) { $this->line(" {$table}: Updated {$count} records"); } } // Update account limits specifically $this->updateAccountLimits($user); } /** * Update account limits to user values */ private function updateAccountLimits(User $user): void { $this->info('Updating account limits and names...'); $orgAccountName = timebank_config('accounts.organization.name', 'organization'); $userAccountName = timebank_config('accounts.user.name', 'personal'); $limitMin = timebank_config('accounts.user.limit_min', 0); $limitMax = timebank_config('accounts.user.limit_max', 6000); // Get all accounts for this user $accounts = DB::table('accounts') ->where('accountable_type', 'App\Models\User') ->where('accountable_id', $user->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 (reverse of user-to-org) if ($account->name === $orgAccountName || preg_match('/^' . preg_quote($orgAccountName, '/') . ' \d+$/', $account->name)) { // Rename 'organization' or 'organization 2', etc. to 'personal' (with numbering if needed) $newName = $this->generateUniqueAccountName($user, $userAccountName, $assignedNames); $updateData['name'] = $newName; $assignedNames[] = $newName; // Track this assigned name $namesRenamed++; $this->line(" Renaming account ID {$account->id}: '{$account->name}' → '{$newName}'"); } elseif ($account->name === 'donation' || preg_match('/^donation \d+$/', $account->name)) { // Rename 'donation' or 'donation 2', etc. to 'gift' (with numbering if needed) $newName = $this->generateUniqueAccountName($user, 'gift', $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) (organization→personal, donation→gift)"); } if ($limitsUpdated === 0) { $this->line(" No accounts found to update"); } } /** * Generate a unique account name for the user */ private function generateUniqueAccountName(User $user, string $baseName, array $accountsBeingRenamed = []): string { // Get all existing account names for this user $existingNames = DB::table('accounts') ->where('accountable_type', 'App\Models\User') ->where('accountable_id', $user->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(Organization $organization, User $user): void { $this->info('Updating love package relationships...'); // Update love_reactants type from Organization to User if ($organization->loveReactant) { $updated = DB::table('love_reactants') ->where('id', $organization->loveReactant->id) ->update(['type' => 'App\Models\User']); if ($updated > 0) { $this->line(" love_reactants: Updated type for reactant ID {$organization->loveReactant->id}"); } } // Update love_reacters type from Organization to User if ($organization->loveReacter) { $updated = DB::table('love_reacters') ->where('id', $organization->loveReacter->id) ->update(['type' => 'App\Models\User']); if ($updated > 0) { $this->line(" love_reacters: Updated type for reacter ID {$organization->loveReacter->id}"); } } if (!$organization->loveReactant && !$organization->loveReacter) { $this->line(" No love relationships to update"); } } /** * Handle pivot table migrations */ private function updatePivotTables(Organization $organization, User $user): void { $this->info(' Updating pivot tables...'); // Handle bank_organization -> bank_user migration (if table exists) if ($this->tableExists('bank_organization')) { $bankRelationships = DB::table('bank_organization')->where('organization_id', $organization->id)->get(); foreach ($bankRelationships as $relationship) { // Create new bank_user relationship DB::table('bank_user')->insertOrIgnore([ 'bank_id' => $relationship->bank_id, 'user_id' => $user->id, 'created_at' => $relationship->created_at ?? now(), 'updated_at' => $relationship->updated_at ?? now() ]); } if ($bankRelationships->count() > 0) { $this->line(" bank_user: Migrated {$bankRelationships->count()} relationships"); // Delete old bank_organization relationships DB::table('bank_organization')->where('organization_id', $organization->id)->delete(); } } // Handle other pivot tables $pivotTables = $this->getPivotTables(); foreach ($pivotTables as $table => $column) { if ($table === 'bank_organization') { continue; } // Already handled above if ($this->tableExists($table)) { $count = DB::table($table)->where($column, $organization->id)->count(); if ($count > 0) { $this->line(" {$table}: {$count} records need manual review"); } } } } /** * Update direct references */ private function updateDirectReferences(Organization $organization, User $user): void { $this->info(' Updating direct references...'); // Handle Spatie Permission tables DB::table('model_has_roles') ->where('model_type', 'App\Models\Organization') ->where('model_id', $organization->id) ->update([ 'model_type' => 'App\Models\User', 'model_id' => $user->id ]); DB::table('model_has_permissions') ->where('model_type', 'App\Models\Organization') ->where('model_id', $organization->id) ->update([ 'model_type' => 'App\Models\User', 'model_id' => $user->id ]); $this->line(" Updated permission system references"); } /** * Handle special cases like Elasticsearch, caches, etc. */ private function handleSpecialCases(Organization $organization, User $user): void { $this->info(' Handling special cases...'); // Re-index in Elasticsearch try { $user->searchable(); $this->line(" Updated Elasticsearch index"); } catch (\Exception $e) { $this->line(" Elasticsearch update failed: " . $e->getMessage()); } // Clear caches if (function_exists('cache')) { cache()->forget("organization.{$organization->id}"); $this->line(" Cleared organization 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_organization' => 'organization_id', 'organization_user' => 'organization_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 organization can be safely migrated */ private function validateOrganizationForMigration(Organization $organization): array { $blockingErrors = []; $warnings = []; // BLOCKING ERRORS - Migration cannot proceed // Check if a user with the same name already exists if (User::where('name', $organization->name)->exists()) { $blockingErrors[] = "A User with name '{$organization->name}' already exists"; } // Check if a user with the same email already exists if (User::where('email', $organization->email)->exists()) { $blockingErrors[] = "A User with email '{$organization->email}' already exists"; } // WARNINGS - Migration can proceed with confirmation // Check if organization has high-value accounts $highValueAccount = $organization->accounts()->where('limit_max', '>', 6000)->first(); if ($highValueAccount) { $warnings[] = "Organization has account(s) with limits higher than user maximum (6000) - limits will be reduced"; } // Check if organization has many accounts (might be complex) $accountCount = $organization->accounts()->count(); if ($accountCount > 3) { $warnings[] = "Organization has {$accountCount} accounts - this might be a complex business organization"; } // MANAGEMENT CONFLICTS - Separate from general warnings $managementConflicts = []; // Check if organization is managing banks (if the relationship exists) try { if (method_exists($organization, 'banksManaged') && $organization->banksManaged()->count() > 0) { $managementConflicts[] = "Organization is managing " . $organization->banksManaged()->count() . " bank(s). After the migration this will not be possible any more."; } } catch (\Exception $e) { // Skip bank management check if relationship doesn't exist or table is missing } return [ 'blocking_errors' => $blockingErrors, 'warnings' => $warnings, 'management_conflicts' => $managementConflicts ]; } /** * Remove management relationships before migration */ private function removeManagementRelationships(Organization $organization): void { $this->info(' Removing management relationships...'); $bankCount = 0; // Remove bank management relationships (if they exist) try { if (method_exists($organization, 'banksManaged')) { $bankCount = $organization->banksManaged()->count(); if ($bankCount > 0) { $bankNames = $organization->banksManaged()->pluck('name')->toArray(); $organization->banksManaged()->detach(); // Un-associate all managed banks $this->line(" Removed management of {$bankCount} bank(s): " . implode(', ', $bankNames)); } } } catch (\Exception $e) { // Skip bank management removal if relationship doesn't exist } if ($bankCount === 0) { $this->line(' No management relationships to remove'); } } /** * Delete Organization and cleanup all remaining relationships */ private function deleteOrganizationAndRelationships(Organization $organization, User $user): void { $this->info(' Deleting Organization and cleaning up relationships...'); // Step 1: Verify critical data was migrated $this->verifyMigrationCompleteness($organization, $user); // Step 2: Clean up pivot table relationships that weren't migrated $this->cleanupPivotRelationships($organization); // Step 3: Clean up remaining foreign key references $this->cleanupForeignKeyReferences($organization); // Step 4: Delete the Organization model $organizationId = $organization->id; $organizationName = $organization->name; $organization->delete(); $this->line(" Deleted Organization '{$organizationName}' (ID: {$organizationId})"); // Step 5: Verify complete deletion $this->verifyOrganizationDeletion($organizationId); } /** * Verify that critical data was successfully migrated */ private function verifyMigrationCompleteness(Organization $organization, User $user): void { // Check that accounts were transferred $orgAccounts = $organization->accounts()->count(); $userAccounts = $user->accounts()->count(); if ($orgAccounts > 0) { throw new \Exception("Organization still has {$orgAccounts} accounts - migration incomplete"); } if ($userAccounts === 0) { $this->line(" User has no accounts - this may be expected"); } $this->line(" Migration verification passed"); } /** * Clean up pivot table relationships */ private function cleanupPivotRelationships(Organization $organization): void { $cleanupTables = [ 'organization_user' => 'organization_id', 'bank_organization' => 'organization_id' ]; foreach ($cleanupTables as $table => $column) { if ($this->tableExists($table)) { $count = DB::table($table)->where($column, $organization->id)->count(); if ($count > 0) { DB::table($table)->where($column, $organization->id)->delete(); $this->line(" Cleaned up {$table}: {$count} records deleted"); } } } // Clean up Spatie permission pivot tables $permissionTables = [ 'model_has_roles' => ['model_type' => 'App\Models\Organization', 'model_id' => $organization->id], 'model_has_permissions' => ['model_type' => 'App\Models\Organization', 'model_id' => $organization->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(Organization $organization): void { // Clean up activity logs where Organization is the causer (not subject - those are audit trail) if ($this->tableExists('activity_log')) { $count = DB::table('activity_log') ->where('causer_type', 'App\Models\Organization') ->where('causer_id', $organization->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\Organization') ->where('causer_id', $organization->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)) { $orgColumn = $table === 'chat_participants' ? 'organization_id' : 'sender_id'; if ($this->tableHasColumn($table, $orgColumn)) { $count = DB::table($table)->where($orgColumn, $organization->id)->count(); if ($count > 0) { if ($table === 'chat_messages') { // For messages, mark as deleted rather than removing for chat history DB::table($table) ->where($orgColumn, $organization->id) ->update(['sender_id' => null, 'deleted_at' => now()]); $this->line(" Cleaned up {$table}: {$count} records marked as deleted"); } else { DB::table($table)->where($orgColumn, $organization->id)->delete(); $this->line(" Cleaned up {$table}: {$count} records deleted"); } } } } } } /** * Check if table has specific column */ private function tableHasColumn(string $tableName, string $columnName): bool { try { return DB::getSchemaBuilder()->hasColumn($tableName, $columnName); } catch (\Exception $e) { return false; } } /** * Verify Organization was completely deleted */ private function verifyOrganizationDeletion(int $organizationId): void { // Check that Organization record is gone if (Organization::find($organizationId)) { throw new \Exception("Organization deletion failed - Organization {$organizationId} still exists"); } // Check for any remaining references in key tables $checkTables = [ 'organization_user' => 'organization_id', 'bank_organization' => 'organization_id' ]; foreach ($checkTables as $table => $column) { if ($this->tableExists($table)) { $remaining = DB::table($table)->where($column, $organizationId)->count(); if ($remaining > 0) { $this->line(" Warning: {$remaining} records remain in {$table}"); } } } $this->line(" Organization deletion verification completed"); } }