info('Scanning for artworks with missing users…'); $chunkSize = (int) $this->option('chunk'); $outputPath = $this->option('output'); $sqlPath = $this->option('sql'); // ── Step 1: find user_ids in artworks missing from the NEW users table ── $missingFromNew = DB::table('artworks') ->select('user_id') ->distinct() ->whereNotIn('user_id', function ($sub) { $sub->select('id')->from('users'); }) ->pluck('user_id') ->sort() ->values(); if ($missingFromNew->isEmpty()) { $this->info('✓ No orphaned artworks found — all user_ids exist in users.'); return self::SUCCESS; } $this->warn("Found {$missingFromNew->count()} user_id(s) in artworks missing from the new users table."); // ── Step 2: cross-check against legacy DB ── $legacyAvailable = false; $legacyRows = collect(); try { DB::connection('legacy')->getPdo(); $legacyAvailable = true; } catch (\Throwable) { $this->warn('Legacy DB connection is not available — skipping legacy cross-check.'); } if ($legacyAvailable) { $this->info('Cross-checking against legacy DB…'); // Legacy users table uses `user_id` as PK (not `id`). $legacyRows = DB::connection('legacy') ->table('users') ->whereIn('user_id', $missingFromNew->all()) ->get() ->keyBy('user_id'); $this->line(" • {$legacyRows->count()} of those exist in the legacy DB (recoverable)."); $this->line(' • ' . $missingFromNew->diff($legacyRows->keys())->count() . ' do NOT exist in legacy DB either (truly orphaned).'); } $this->newLine(); // ── Step 3: collect artwork rows ── $rows = []; DB::table('artworks') ->whereIn('user_id', $missingFromNew->all()) ->orderBy('user_id') ->orderBy('id') ->chunk($chunkSize, function ($artworks) use ($legacyRows, &$rows) { foreach ($artworks as $artwork) { $inLegacy = $legacyRows->has($artwork->user_id); $rows[] = [ 'user_id' => $artwork->user_id, 'artwork_id' => $artwork->id, 'artwork_slug' => $artwork->slug ?? '', 'artwork_title' => $artwork->title ?? '', 'status' => $artwork->status ?? '', 'created_at' => $artwork->created_at ?? '', 'in_legacy_db' => $inLegacy ? 'yes' : 'no', ]; } }); // ── Step 4: console table ── $tableRows = collect($rows)->map(fn($r) => [ $r['user_id'], $r['artwork_id'], $r['artwork_slug'], mb_strimwidth((string) $r['artwork_title'], 0, 48, '…'), $r['status'], $r['created_at'], $r['in_legacy_db'], ])->all(); $this->table( ['user_id', 'artwork_id', 'slug', 'title', 'status', 'created_at', 'in_legacy_db'], $tableRows, ); $this->newLine(); // ── Step 5: per-user summary ── $countPerUser = collect($rows)->groupBy('user_id'); $this->info('Summary per missing user:'); foreach ($countPerUser as $userId => $group) { $inLegacy = $group->first()['in_legacy_db']; $this->line(sprintf( ' user_id %-8s %3d artwork(s) legacy_db: %s', $userId, $group->count(), $inLegacy, )); } $this->newLine(); $this->warn('Total orphaned artworks: ' . count($rows)); // ── Step 6: optional CSV export ── if ($outputPath) { $fp = fopen($outputPath, 'w'); if ($fp === false) { $this->error("Could not open output file: {$outputPath}"); return self::FAILURE; } fputcsv($fp, ['user_id', 'artwork_id', 'artwork_slug', 'artwork_title', 'status', 'created_at', 'in_legacy_db']); foreach ($rows as $row) { fputcsv($fp, array_values($row)); } fclose($fp); $this->info("Report written to: {$outputPath}"); } // ── Step 7: optional SQL import script for recoverable users ── if ($sqlPath) { if (! $legacyAvailable) { $this->error('Cannot generate SQL: legacy DB connection is not available.'); return self::FAILURE; } $result = $this->generateImportSql($sqlPath, $legacyRows, $missingFromNew); if ($result !== self::SUCCESS) { return $result; } } // ── Step 8: optional username diff between legacy and new DB ── if ($this->option('check-usernames')) { $this->checkUsernameDiff((int) $this->option('chunk'), $this->option('username-output'), $this->option('username-fix-sql')); } return self::SUCCESS; } /** * Generate a self-contained SQL script that re-inserts recoverable users * (plus their user_profiles and user_statistics rows) into the new DB. * * @param \Illuminate\Support\Collection $legacyRows keyed by user_id * @param \Illuminate\Support\Collection $missingFromNew */ protected function generateImportSql(string $path, $legacyRows, $missingFromNew): int { // Also pull statistics from legacy for each recoverable user. $legacyStats = DB::connection('legacy') ->table('users_statistics') ->whereIn('user_id', $legacyRows->keys()->all()) ->get() ->keyBy('user_id'); $now = now()->format('Y-m-d H:i:s'); $lines = []; $lines[] = '-- ============================================================'; $lines[] = '-- Skinbase orphaned-artwork user recovery script'; $lines[] = '-- Generated: ' . $now; $lines[] = '-- Source: legacy DB → new users / user_profiles / user_statistics'; $lines[] = '-- REVIEW CAREFULLY before running on production.'; $lines[] = '-- ============================================================'; $lines[] = ''; $lines[] = 'SET NAMES utf8mb4;'; $lines[] = 'USE `' . config('database.connections.mysql.database') . '`;'; $lines[] = 'START TRANSACTION;'; $lines[] = ''; $recovered = 0; $skipped = 0; foreach ($legacyRows as $userId => $row) { $userId = (int) $userId; $stat = $legacyStats->get($userId); // --- resolve username (mirrors ImportLegacyUsers: lowercase, alnum+dash+underscore, max 20) --- $rawUsername = trim((string) ($row->uname ?? '')); $username = $rawUsername !== '' ? $rawUsername : ('user' . $userId); $username = strtolower(preg_replace('/[^A-Za-z0-9_\-]/', '', $username)); $username = substr($username !== '' ? $username : ('user' . $userId), 0, 20); // --- resolve email --- // Use a guaranteed-unique synthetic email for the INSERT so it never conflicts // with an existing account (email has a unique constraint). The real legacy email // is stored in a comment — patch it manually after verifying no conflict. $rawEmail = strtolower(trim((string) ($row->email ?? ''))); $safeEmail = $userId . '@legacy.skinbase.org'; // collision-free, always unique // --- dates --- $createdAt = $this->sqlDate($row->joinDate ?? null, $now); $lastVisitAt = $this->sqlDate($row->LastVisit ?? null, null); // --- stats --- $uploads = $this->safeInt($stat->uploads ?? 0); $downloads = $this->safeInt($stat->downloads ?? 0); $pageviews = $this->safeInt($stat->pageviews ?? 0); $awards = $this->safeInt($stat->awards ?? 0); // --- profile fields --- $about = $this->sqlString($row->about_me ?? $row->description ?? null); $country = $this->sqlString($row->country ?? null); $countryCode = $this->sqlString($row->country_code ? substr($row->country_code, 0, 2) : null); $language = $this->sqlString($row->lang ?? null); $website = $this->sqlString($row->web ?? null); $gender = $this->sqlString($this->normalizeLegacyGender($row->gender ?? null)); $birthdate = $this->sqlDate($row->birth ?? null, null); $avatarLegacy = $this->sqlString($row->picture ?? null); $coverImage = $this->sqlString($row->cover_art ?? null); $lines[] = "-- user_id={$userId} username={$username} real_email={$rawEmail} (password placeholder; user must reset)"; $lines[] = "-- To restore real email after import: UPDATE \`users\` SET \`email\`=" . $this->sqlString($rawEmail) . " WHERE \`id\`={$userId} AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM \`users\` WHERE \`email\`=" . $this->sqlString($rawEmail) . " AND \`id\`!={$userId}) _c);"; $lines[] = "SAVEPOINT sp_{$userId};"; // users: synthetic email guarantees no unique-constraint conflict on INSERT $name = $this->sqlString($row->real_name ?: $username); $usernameQ = $this->sqlString($username); $emailQ = $this->sqlString($safeEmail); $passwordQ = $this->sqlString('$2y$12$' . Str::random(53)); $isActive = ($row->active ?? 1) ? '1' : '0'; $lastVisitQ = $lastVisitAt ? "'$lastVisitAt'" : 'NULL'; $lines[] = "INSERT IGNORE INTO `users`" . " (`id`, `username`, `username_changed_at`, `name`, `email`, `password`, `role`," . " `is_active`, `needs_password_reset`, `legacy_password_algo`, `last_visit_at`, `created_at`, `updated_at`)" . " VALUES ({$userId}, {$usernameQ}, '{$now}', {$name}, {$emailQ}, {$passwordQ}," . " 'user', {$isActive}, 1, NULL, {$lastVisitQ}, '{$createdAt}', '{$now}');"; $lines[] = "UPDATE `users` SET `username`={$usernameQ}, `username_changed_at`='{$now}'," . " `name`={$name}, `updated_at`='{$now}' WHERE `id` = {$userId};"; // user_profiles: INSERT IGNORE (FK-safe — silently skipped if parent missing) + UPDATE $lines[] = "INSERT IGNORE INTO `user_profiles`" . " (`user_id`, `about`, `avatar_legacy`, `cover_image`," . " `country`, `country_code`, `language`, `website`, `gender`, `birthdate`, `updated_at`)" . " VALUES ({$userId}, {$about}, {$avatarLegacy}, {$coverImage}," . " {$country}, {$countryCode}, {$language}, {$website}, {$gender}," . " " . ($birthdate ? "'$birthdate'" : 'NULL') . ", '{$now}');"; $lines[] = "UPDATE `user_profiles` SET" . " `about`={$about}, `avatar_legacy`={$avatarLegacy}, `cover_image`={$coverImage}," . " `country`={$country}, `country_code`={$countryCode}, `language`={$language}," . " `website`={$website}, `updated_at`='{$now}' WHERE `user_id` = {$userId};"; // user_statistics: same INSERT IGNORE + UPDATE pattern $lines[] = "INSERT IGNORE INTO `user_statistics`" . " (`user_id`, `uploads_count`, `downloads_received_count`," . " `artwork_views_received_count`, `awards_received_count`, `updated_at`)" . " VALUES ({$userId}, {$uploads}, {$downloads}, {$pageviews}, {$awards}, '{$now}');"; $lines[] = "UPDATE `user_statistics` SET" . " `uploads_count`={$uploads}, `downloads_received_count`={$downloads}," . " `artwork_views_received_count`={$pageviews}, `awards_received_count`={$awards}," . " `updated_at`='{$now}' WHERE `user_id` = {$userId};"; $lines[] = ''; $recovered++; } // List truly-orphaned user_ids (not in legacy DB either) as comments. $trulyOrphaned = $missingFromNew->diff($legacyRows->keys()); if ($trulyOrphaned->isNotEmpty()) { $lines[] = '-- ============================================================'; $lines[] = '-- The following user_ids were NOT found in legacy DB.'; $lines[] = '-- Their artworks are truly orphaned and cannot be auto-recovered.'; $lines[] = '-- ============================================================'; foreach ($trulyOrphaned as $uid) { $lines[] = "-- user_id={$uid}"; $skipped++; } $lines[] = ''; } $lines[] = 'COMMIT;'; $lines[] = ''; $lines[] = "-- Recovered: {$recovered} | Truly orphaned (no SQL generated): {$skipped}"; $sql = implode("\n", $lines) . "\n"; if (file_put_contents($path, $sql) === false) { $this->error("Could not write SQL file: {$path}"); return self::FAILURE; } $this->info("SQL import script written to: {$path} ({$recovered} users)"); if ($skipped > 0) { $this->warn("{$skipped} user_id(s) not found in legacy DB — listed as comments at the bottom of the SQL file."); } return self::SUCCESS; } // ── Helpers ────────────────────────────────────────────────────────────── protected function checkUsernameDiff(int $chunkSize, ?string $outputPath, ?string $fixSqlPath): void { try { DB::connection('legacy')->getPdo(); } catch (\Throwable) { $this->error('--check-usernames: legacy DB connection is not available.'); return; } $this->info('Comparing usernames between legacy and new DB…'); $this->newLine(); $diffs = []; $matches = 0; $missing = 0; // Stream legacy users in chunks; for each one look up the new users table by same ID. DB::connection('legacy') ->table('users') ->orderBy('user_id') ->chunk($chunkSize, function ($legacyUsers) use (&$diffs, &$matches, &$missing) { $ids = $legacyUsers->pluck('user_id')->all(); $newUsers = DB::table('users') ->whereIn('id', $ids) ->pluck('username', 'id'); // keyed by id foreach ($legacyUsers as $lu) { $id = (int) $lu->user_id; if (! $newUsers->has($id)) { $legacyUsername = strtolower(preg_replace('/[^A-Za-z0-9_\-]/', '', trim((string) ($lu->uname ?? '')))); $legacyUsername = substr($legacyUsername !== '' ? $legacyUsername : ('user' . $id), 0, 20); if (! $this->option('hide-missing')) { $this->line(sprintf( ' MISS id=%-8d legacy=%s', $id, $legacyUsername, )); } $missing++; continue; } $legacyUsername = strtolower(preg_replace('/[^A-Za-z0-9_\-]/', '', trim((string) ($lu->uname ?? '')))); $legacyUsername = substr($legacyUsername !== '' ? $legacyUsername : ('user' . $id), 0, 20); $newUsername = (string) $newUsers->get($id); if ($legacyUsername !== $newUsername) { $this->line(sprintf( ' FAIL id=%-8d legacy=%-20s new=%s', $id, $legacyUsername, $newUsername, )); $diffs[] = [ 'user_id' => $id, 'legacy_username' => $legacyUsername, 'new_username' => $newUsername, 'legacy_email' => strtolower(trim((string) ($lu->email ?? ''))), ]; } else { $matches++; } } }); $this->newLine(); if (empty($diffs) && $missing === 0) { $this->info("✓ All {$matches} checked username(s) match."); return; } $this->warn(count($diffs) . ' FAIL / ' . $missing . ' MISSING / ' . $matches . ' OK'); if ($outputPath) { $fp = fopen($outputPath, 'w'); if ($fp === false) { $this->error("Could not open output file: {$outputPath}"); return; } fputcsv($fp, ['user_id', 'legacy_username', 'new_username', 'legacy_email']); foreach ($diffs as $d) { fputcsv($fp, array_values($d)); } fclose($fp); $this->info("Username diff written to: {$outputPath}"); } if ($fixSqlPath && ! empty($diffs)) { $lines = []; $lines[] = "-- Username fix: update new DB usernames to match normalized legacy usernames"; $lines[] = "-- Generated: " . now()->toDateTimeString(); $lines[] = "-- " . count($diffs) . " row(s) affected"; $lines[] = "SET NAMES utf8mb4;"; $lines[] = "USE `projekti_2026_skinbase`;"; $lines[] = "START TRANSACTION;"; $lines[] = ''; foreach ($diffs as $d) { $newUsername = addslashes($d['new_username']); $legacyUsername = addslashes($d['legacy_username']); $lines[] = "-- id={$d['user_id']} old='{$newUsername}' -> new='{$legacyUsername}'"; $lines[] = "UPDATE `users` SET `username` = '{$legacyUsername}', `updated_at` = NOW() WHERE `id` = {$d['user_id']} AND `username` = '{$newUsername}';"; } $lines[] = ''; $lines[] = 'COMMIT;'; $written = file_put_contents($fixSqlPath, implode("\n", $lines) . "\n"); if ($written === false) { $this->error("Could not write SQL fix file: {$fixSqlPath}"); } else { $this->info("Username fix SQL written to: {$fixSqlPath}"); } } } // ── Helpers ────────────────────────────────────────────────────────────── private function sqlString(?string $value): string { if ($value === null || trim($value) === '') { return 'NULL'; } // Escape single-quotes and backslashes for SQL. $escaped = str_replace(['\\', "'"], ['\\\\', "\\'"], $value); return "'{$escaped}'"; } private function sqlDate($value, ?string $fallback): ?string { if (! $value) { return $fallback; } try { return \Carbon\Carbon::parse($value)->format('Y-m-d H:i:s'); } catch (\Throwable) { return $fallback; } } private function safeInt($value): int { $n = (int) $value; return $n < 0 ? 0 : $n; } private function normalizeLegacyGender(?string $value): ?string { return match (strtolower(trim((string) $value))) { 'm', 'male' => 'M', 'f', 'female' => 'F', default => null, }; } }