Initial commit

This commit is contained in:
Ronald Huynen
2026-03-23 21:37:59 +01:00
commit 2547717edb
2193 changed files with 972171 additions and 0 deletions

View File

@@ -0,0 +1,855 @@
<?php
namespace App\Console\Commands;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Migrate Organization to User Command
*
* This command safely migrates an Organization model to a User model while preserving
* all associated relationships, accounts, and data.
*
* IMPORTANT: This is a one-way migration that cannot be easily reversed.
* Always run with --dry-run first to preview changes.
*
* The migration process:
* 1. Creates a new User with all Organization data
* 2. Updates all polymorphic relationships to point to the new User
* 3. Migrates pivot table relationships (bank management, etc.)
* 4. Updates permission system references
* 5. Re-indexes Elasticsearch and clears caches
* 6. Deletes the original Organization and cleanup relationships
*
* Safety validations prevent migration of:
* - Organizations with conflicting names/emails
* - Organizations currently managing critical resources
*
* @author Claude Code
* @version 1.0
*/
class MigrateOrganizationToUserCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'migrate:organization-to-user {organization_id} {--dry-run : Preview changes without executing them}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Migrate an Organization model to a User model while preserving all relationships and data';
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
$organizationId = $this->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");
}
}