option('chunk')); $legacyConnection = (string) $this->option('legacy-connection'); $legacyTable = (string) $this->option('legacy-table'); $artworksTable = (string) $this->option('artworks-table'); $fixArtworks = (bool) $this->option('fix-artworks'); $dryRun = (bool) $this->option('dry-run'); if (! $this->legacyTableExists($legacyConnection, $legacyTable)) { $this->error("Legacy table {$legacyConnection}.{$legacyTable} does not exist or the connection is unavailable."); return self::FAILURE; } if ($dryRun) { $this->warn('[DRY RUN] No changes will be written.'); } if ($fixArtworks) { $this->handleFixArtworks($chunk, $legacyConnection, $legacyTable, $artworksTable, $dryRun); } $total = (int) DB::connection($legacyConnection) ->table($legacyTable) ->where('user_id', 0) ->count(); if ($total === 0) { if (! $fixArtworks) { $this->info('No legacy wallz rows with user_id = 0 were found.'); } return self::SUCCESS; } $this->info("Scanning {$total} legacy rows in {$legacyConnection}.{$legacyTable}."); $processed = 0; $updatedRows = 0; $matchedUsers = 0; $createdUsers = 0; $skippedRows = 0; $usernameMap = []; DB::connection($legacyConnection) ->table($legacyTable) ->select(['id', 'uname']) ->where('user_id', 0) ->orderBy('id') ->chunkById($chunk, function ($rows) use ( &$processed, &$updatedRows, &$matchedUsers, &$createdUsers, &$skippedRows, &$usernameMap, $dryRun, $legacyConnection, $legacyTable ) { foreach ($rows as $row) { $processed++; $rawUsername = trim((string) ($row->uname ?? '')); if ($rawUsername === '') { $skippedRows++; $this->warn("Skipping wallz id={$row->id}: uname is empty."); continue; } $lookupKey = UsernamePolicy::normalize($rawUsername); if ($lookupKey === '') { $skippedRows++; $this->warn("Skipping wallz id={$row->id}: uname normalizes to empty."); continue; } if (! array_key_exists($lookupKey, $usernameMap)) { $existingUser = $this->findUserByUsername($lookupKey); if ($existingUser !== null) { $usernameMap[$lookupKey] = [ 'user_id' => (int) $existingUser->id, 'created' => false, ]; } else { $usernameMap[$lookupKey] = [ 'user_id' => $dryRun ? 0 : $this->createUserForLegacyUsername($rawUsername, $legacyConnection), 'created' => true, ]; } } $resolved = $usernameMap[$lookupKey]; if ($resolved['created']) { $createdUsers++; $usernameMap[$lookupKey]['created'] = false; $resolved['created'] = false; $this->line($dryRun ? "[dry] Would create user for uname='{$rawUsername}'" : "[create] Created user_id={$usernameMap[$lookupKey]['user_id']} for uname='{$rawUsername}'"); } else { $matchedUsers++; } if ($dryRun) { $targetUser = $usernameMap[$lookupKey]['user_id'] > 0 ? (string) $usernameMap[$lookupKey]['user_id'] : ''; $this->line("[dry] Would update wallz id={$row->id} to user_id={$targetUser} using uname='{$rawUsername}'"); $updatedRows++; continue; } $affected = DB::connection($legacyConnection) ->table($legacyTable) ->where('id', $row->id) ->where('user_id', 0) ->update([ 'user_id' => $usernameMap[$lookupKey]['user_id'], ]); if ($affected > 0) { $updatedRows += $affected; } } }, 'id'); $this->info(sprintf( 'Finished. processed=%d updated=%d matched=%d created=%d skipped=%d', $processed, $updatedRows, $matchedUsers, $createdUsers, $skippedRows )); return self::SUCCESS; } private function handleFixArtworks(int $chunk, string $legacyConnection, string $legacyTable, string $artworksTable, bool $dryRun): void { $this->info("\nAttempting to backfill `{$artworksTable}.user_id` from legacy {$legacyConnection}.{$legacyTable} where user_id = 0"); $total = (int) DB::table($artworksTable)->where('user_id', 0)->count(); $this->info("Found {$total} rows in {$artworksTable} with user_id = 0. Chunk size: {$chunk}."); $processed = 0; $updated = 0; DB::table($artworksTable) ->select(['id']) ->where('user_id', 0) ->orderBy('id') ->chunkById($chunk, function ($rows) use (&$processed, &$updated, $legacyConnection, $legacyTable, $artworksTable, $dryRun) { foreach ($rows as $row) { $processed++; $legacyUser = DB::connection($legacyConnection) ->table($legacyTable) ->where('id', $row->id) ->value('user_id'); $legacyUser = (int) ($legacyUser ?? 0); if ($legacyUser <= 0) { continue; } if ($dryRun) { $this->line("[dry] Would update {$artworksTable} id={$row->id} to user_id={$legacyUser}"); $updated++; continue; } $affected = DB::table($artworksTable) ->where('id', $row->id) ->where('user_id', 0) ->update(['user_id' => $legacyUser]); if ($affected > 0) { $updated += $affected; } } }, 'id'); $this->info(sprintf('Artworks backfill complete. processed=%d updated=%d', $processed, $updated)); } private function legacyTableExists(string $connection, string $table): bool { try { return DB::connection($connection)->getSchemaBuilder()->hasTable($table); } catch (\Throwable) { return false; } } private function findUserByUsername(string $normalizedUsername): ?object { return DB::table('users') ->select(['id', 'username']) ->whereRaw('LOWER(username) = ?', [$normalizedUsername]) ->first(); } private function createUserForLegacyUsername(string $legacyUsername, string $legacyConnection): int { $username = UsernamePolicy::uniqueCandidate($legacyUsername); $emailLocal = $this->sanitizeEmailLocal($username); $email = $this->uniqueEmailCandidate($emailLocal . '@users.skinbase.org'); $now = now(); // Attempt to copy legacy joinDate from the legacy `users` table when available. $legacyJoin = null; try { $legacyJoin = DB::connection($legacyConnection) ->table('users') ->whereRaw('LOWER(uname) = ?', [strtolower((string) $legacyUsername)]) ->value('joinDate'); } catch (\Throwable) { $legacyJoin = null; } $createdAt = $now; if (! empty($legacyJoin) && strpos((string) $legacyJoin, '0000') !== 0) { try { $createdAt = Carbon::parse($legacyJoin); } catch (\Throwable) { $createdAt = $now; } } $userId = (int) DB::table('users')->insertGetId([ 'username' => $username, 'username_changed_at' => $now, 'name' => $legacyUsername, 'email' => $email, 'password' => Hash::make(Str::random(64)), 'is_active' => true, 'needs_password_reset' => true, 'role' => 'user', 'legacy_password_algo' => null, 'created_at' => $createdAt, 'updated_at' => $now, ]); return $userId; } private function uniqueEmailCandidate(string $email): string { $candidate = strtolower(trim($email)); $suffix = 1; while (DB::table('users')->whereRaw('LOWER(email) = ?', [$candidate])->exists()) { $parts = explode('@', $email, 2); $local = $parts[0] ?? 'user'; $domain = $parts[1] ?? 'users.skinbase.org'; $candidate = $local . '+' . $suffix . '@' . $domain; $suffix++; } return $candidate; } private function sanitizeEmailLocal(string $value): string { $local = strtolower(trim($value)); $local = preg_replace('/[^a-z0-9._-]/', '-', $local) ?: 'user'; return trim($local, '.-') ?: 'user'; } }