follower_id (the user who added the friend = someone who follows) * friend_id -> user_id (the user being followed) * * With --import-missing-users: any user referenced in friends_list that does not * exist in the new DB will be fetched from the legacy `users` table and created * as a stub before the follow row is inserted. */ class MigrateFollows extends Command { protected $signature = 'skinbase:migrate-follows {--dry-run : Simulate without writing to the database} {--chunk=1000 : Number of rows to process per batch} {--import-missing-users : Import unknown users from legacy DB instead of skipping them}'; protected $description = 'Migrate legacy friends_list into user_followers'; /** Cache per-run: id => true (resolved) | null (not in legacy DB) | false (import error) */ private array $legacyUserCache = []; public function handle(): int { $isDryRun = (bool) $this->option('dry-run'); $chunkSize = max(1, (int) $this->option('chunk')); $importMissing = (bool) $this->option('import-missing-users'); $this->info($isDryRun ? '🔍 Dry-run mode – nothing will be written.' : '🚀 Live mode – writing to user_followers.' ); if ($importMissing) { $this->info('👤 --import-missing-users: orphan users will be fetched from legacy DB.'); } try { $totalLegacy = DB::connection('legacy')->table('friends_list')->count(); } catch (\Throwable $e) { $this->error('Cannot read legacy friends_list: ' . $e->getMessage()); return self::FAILURE; } $this->info("Total rows in legacy friends_list: {$totalLegacy}"); $validUserIds = DB::table('users')->pluck('id')->flip()->all(); $stats = [ 'processed' => 0, 'inserted' => 0, 'duplicates' => 0, 'self_follows' => 0, 'invalid' => 0, // total orphan rows skipped 'invalid_zero_id' => 0, // follower_id or friend_id was 0 'invalid_not_in_new' => 0, // not in new DB (--import-missing-users not used) 'invalid_not_in_legacy' => 0, // not in new DB AND not in legacy DB 'invalid_import_error' => 0, // in legacy DB but stub import failed 'users_imported' => 0, 'errors' => 0, ]; $logPath = storage_path('logs/migrate_follows.log'); $logFile = fopen($logPath, 'a'); $this->logLine($logFile, '=== migrate-follows started at ' . now()->toISOString() . " (dry_run={$isDryRun}, import_missing={$importMissing}) ==="); $chunkNum = 0; $reportEvery = max(1, (int) ceil($totalLegacy / $chunkSize / 10)); DB::connection('legacy') ->table('friends_list') ->orderBy('id') ->chunk($chunkSize, function ($rows) use ( $isDryRun, $importMissing, &$validUserIds, &$stats, &$chunkNum, $reportEvery, $totalLegacy, $logFile ) { $toInsert = []; foreach ($rows as $row) { $stats['processed']++; $followerId = (int) ($row->user_id ?? 0); $followedId = (int) ($row->friend_id ?? 0); $createdAt = $row->date_added ?? now(); if ($followerId === $followedId) { $stats['self_follows']++; $this->logLine($logFile, "SKIP self-follow: user_id={$followerId}"); continue; } // Try to resolve any user_id that isn't in the new DB yet $skipReasons = []; $sides = ['follower' => $followerId, 'followed' => $followedId]; foreach ($sides as $role => $uid) { if (isset($validUserIds[$uid])) { continue; // already valid } if ($uid === 0) { $skipReasons[] = "{$role}_id is 0/null"; $stats['invalid_zero_id']++; continue; } if (! $importMissing) { $skipReasons[] = "{$role}={$uid} not in users table (use --import-missing-users to auto-import)"; $stats['invalid_not_in_new']++; continue; } // ensureLegacyUser returns: true = resolved, null = not in legacy, false = import error $result = $this->ensureLegacyUser($uid, $isDryRun, $logFile); if ($result === true) { $validUserIds[$uid] = true; $stats['users_imported']++; } elseif ($result === null) { $skipReasons[] = "{$role}={$uid} not found in legacy DB"; $stats['invalid_not_in_legacy']++; } else { $skipReasons[] = "{$role}={$uid} found in legacy DB but import failed"; $stats['invalid_import_error']++; } } if (! isset($validUserIds[$followerId]) || ! isset($validUserIds[$followedId])) { $stats['invalid']++; $reason = implode('; ', $skipReasons) ?: 'unknown'; $this->logLine($logFile, "SKIP orphan [row_id={$row->id}] follower={$followerId} followed={$followedId} — {$reason}"); continue; } $toInsert[] = [ 'follower_id' => $followerId, 'user_id' => $followedId, 'created_at' => $createdAt, ]; } if (! $isDryRun && ! empty($toInsert)) { try { $inserted = DB::table('user_followers')->insertOrIgnore($toInsert); $stats['inserted'] += $inserted; $stats['duplicates'] += count($toInsert) - $inserted; } catch (\Throwable $e) { $stats['errors']++; $this->logLine($logFile, 'ERROR batch insert: ' . $e->getMessage()); } } elseif ($isDryRun) { $stats['inserted'] += count($toInsert); } $chunkNum++; if ($chunkNum % $reportEvery === 0 || $stats['processed'] >= $totalLegacy) { $pct = $totalLegacy > 0 ? round($stats['processed'] / $totalLegacy * 100) : 100; $this->line(" {$stats['processed']} / {$totalLegacy} rows ({$pct}%)" . " inserted: {$stats['inserted']}" . " imported: {$stats['users_imported']}" . " skipped: " . ($stats['self_follows'] + $stats['invalid'])); } }); $this->newLine(); if (! $isDryRun) { $this->info('Backfilling user_statistics counters...'); $this->backfillCounters(); } $this->table( ['Metric', 'Count'], [ ['Processed', $stats['processed']], ['Inserted', $stats['inserted']], ['Duplicates (already exist)', $stats['duplicates']], ['Self-follows skipped', $stats['self_follows']], ['Users stub-imported from legacy', $stats['users_imported']], ['Invalid (orphan) — total', $stats['invalid']], [' ↳ zero/null user_id', $stats['invalid_zero_id']], [' ↳ not in new DB (not imported)', $stats['invalid_not_in_new']], [' ↳ not in legacy DB either', $stats['invalid_not_in_legacy']], [' ↳ legacy import error', $stats['invalid_import_error']], ['Errors', $stats['errors']], ] ); $summary = "Processed={$stats['processed']} Inserted={$stats['inserted']} " . "Duplicates={$stats['duplicates']} SelfFollows={$stats['self_follows']} " . "UsersImported={$stats['users_imported']} Invalid={$stats['invalid']} " . "(ZeroId={$stats['invalid_zero_id']} NotInNew={$stats['invalid_not_in_new']} " . "NotInLegacy={$stats['invalid_not_in_legacy']} ImportError={$stats['invalid_import_error']}) " . "Errors={$stats['errors']}"; $this->logLine($logFile, "=== DONE: {$summary} ==="); fclose($logFile); $this->info("Log written to: {$logPath}"); return self::SUCCESS; } // ------------------------------------------------------------------------- /** * Ensure a legacy user_id exists in the new `users` table. * * Returns: * true – user is valid (was already there, or was just imported / dry-run pretend-imported) * null – user not found in the legacy DB either → cannot be imported * false – user found in legacy DB but the stub-import threw an exception * * Results are cached per command run to avoid redundant DB queries. */ private function ensureLegacyUser(int $legacyId, bool $isDryRun, $logFile): ?bool { if (array_key_exists($legacyId, $this->legacyUserCache)) { return $this->legacyUserCache[$legacyId]; } if (DB::table('users')->where('id', $legacyId)->exists()) { return $this->legacyUserCache[$legacyId] = true; } $legacyUser = DB::connection('legacy') ->table('users') ->where('user_id', $legacyId) ->first(); if (! $legacyUser) { $this->logLine($logFile, "IMPORT FAIL: user_id={$legacyId} not found in legacy DB"); return $this->legacyUserCache[$legacyId] = null; } if ($isDryRun) { $this->logLine($logFile, "DRY-RUN IMPORT: would create user_id={$legacyId} uname={$legacyUser->uname}"); return $this->legacyUserCache[$legacyId] = true; } try { $this->importLegacyUserStub($legacyUser); $this->logLine($logFile, "IMPORTED user_id={$legacyId} uname={$legacyUser->uname}"); return $this->legacyUserCache[$legacyId] = true; } catch (\Throwable $e) { $this->logLine($logFile, "IMPORT ERROR user_id={$legacyId}: " . $e->getMessage()); return $this->legacyUserCache[$legacyId] = false; } } private function importLegacyUserStub(object $row): void { $legacyId = (int) $row->user_id; $now = now(); $username = UsernamePolicy::sanitizeLegacy((string) ($row->uname ?: ('user' . $legacyId))); if (! $username) { $username = 'user' . $legacyId; } if (DB::table('users')->whereRaw('LOWER(username) = ?', [strtolower($username)])->exists()) { $username = $username . $legacyId; } $email = ($row->email ? strtolower(trim($row->email)) : null) ?: ('user' . $legacyId . '@users.skinbase.org'); DB::transaction(function () use ($legacyId, $username, $email, $row, $now) { DB::table('users')->insertOrIgnore([ 'id' => $legacyId, 'username' => $username, 'name' => $row->real_name ?: $username, 'email' => $email, 'password' => Hash::make(Str::random(32)), 'is_active' => (int) ($row->active ?? 1) === 1, 'needs_password_reset' => true, 'role' => 'user', 'created_at' => $row->joinDate ?? $now, 'updated_at' => $now, ]); DB::table('user_profiles')->updateOrInsert( ['user_id' => $legacyId], [ 'country' => $row->country ?? null, 'country_code' => $row->country_code ? substr((string) $row->country_code, 0, 2) : null, 'website' => $row->web ?? null, 'updated_at' => $now, ] ); DB::table('user_statistics')->updateOrInsert( ['user_id' => $legacyId], ['updated_at' => $now, 'created_at' => $now] ); }); } // ------------------------------------------------------------------------- private function backfillCounters(): void { DB::statement(' UPDATE user_statistics us JOIN ( SELECT user_id, COUNT(*) AS cnt FROM user_followers GROUP BY user_id ) AS f ON f.user_id = us.user_id SET us.followers_count = f.cnt, us.updated_at = NOW() '); DB::statement(' UPDATE user_statistics us JOIN ( SELECT follower_id, COUNT(*) AS cnt FROM user_followers GROUP BY follower_id ) AS f ON f.follower_id = us.user_id SET us.following_count = f.cnt, us.updated_at = NOW() '); $this->info('Counters backfilled.'); } private function logLine($handle, string $message): void { if (is_resource($handle)) { fwrite($handle, '[' . now()->toISOString() . '] ' . $message . PHP_EOL); } } }