*/ private array $legacyTableExistsCache = []; protected $signature = 'skinbase:repair-user-join-dates {--chunk=500 : Number of users to process per batch} {--legacy-connection=legacy : Legacy database connection name} {--legacy-table=users : Legacy users table name} {--only-null : Update only users whose current created_at is null} {--dry-run : Preview join date updates without writing changes}'; protected $description = 'Backfill current users.created_at from legacy users.joinDate'; public function handle(): int { $chunk = max(1, (int) $this->option('chunk')); $legacyConnection = (string) $this->option('legacy-connection'); $legacyTable = (string) $this->option('legacy-table'); $onlyNull = (bool) $this->option('only-null'); $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.'); } $query = DB::table('users')->select(['id', 'created_at']); if ($onlyNull) { $query->whereNull('created_at'); } $this->info('Scanning current users for legacy joinDate backfill.'); $processed = 0; $matched = 0; $updated = 0; $unchanged = 0; $skipped = 0; $query ->chunkById($chunk, function (Collection $rows) use ( &$processed, &$matched, &$updated, &$unchanged, &$skipped, $legacyConnection, $legacyTable, $dryRun ): void { $legacyById = $this->loadLegacyUsersForChunk($rows, $legacyConnection, $legacyTable); $activityById = $this->loadLegacyActivityDatesForChunk($rows, $legacyConnection); foreach ($rows as $row) { $processed++; $legacyMatch = $legacyById[(int) $row->id] ?? null; if ($legacyMatch === null) { $skipped++; continue; } $matched++; $legacyJoinDate = $this->parseLegacyJoinDate($legacyMatch->joinDate ?? null); $dateSource = 'joinDate'; if ($legacyJoinDate === null) { $activityFallback = $activityById[(int) $row->id] ?? null; $legacyJoinDate = $activityFallback['date'] ?? null; $dateSource = $activityFallback['source'] ?? 'activity'; } if ($legacyJoinDate === null) { $skipped++; continue; } $currentCreatedAt = $this->parseCurrentDate($row->created_at ?? null); if ($currentCreatedAt !== null && $currentCreatedAt->equalTo($legacyJoinDate)) { $unchanged++; continue; } if ($dryRun) { $this->line(sprintf( '[dry] Would update user id=%d created_at %s => %s (%s)', (int) $row->id, $currentCreatedAt?->toDateTimeString() ?? '', $legacyJoinDate->toDateTimeString(), $dateSource )); $updated++; continue; } $affected = DB::table('users') ->where('id', (int) $row->id) ->update([ 'created_at' => $legacyJoinDate->toDateTimeString(), ]); if ($affected > 0) { $updated += $affected; $this->line(sprintf( '[update] user id=%d created_at => %s (%s)', (int) $row->id, $legacyJoinDate->toDateTimeString(), $dateSource )); } } }, 'id'); $this->info(sprintf( 'Finished. processed=%d matched=%d updated=%d unchanged=%d skipped=%d', $processed, $matched, $updated, $unchanged, $skipped )); if ($processed === 0) { $this->info('No users matched the requested scope.'); } return self::SUCCESS; } private function legacyTableExists(string $connection, string $table): bool { $cacheKey = strtolower($connection . ':' . $table); if (array_key_exists($cacheKey, $this->legacyTableExistsCache)) { return $this->legacyTableExistsCache[$cacheKey]; } try { return $this->legacyTableExistsCache[$cacheKey] = DB::connection($connection)->getSchemaBuilder()->hasTable($table); } catch (\Throwable) { return $this->legacyTableExistsCache[$cacheKey] = false; } } /** * @return array */ private function loadLegacyUsersForChunk(Collection $rows, string $legacyConnection, string $legacyTable): array { $legacyById = []; $ids = $rows ->pluck('id') ->map(static fn ($id): int => (int) $id) ->filter(static fn (int $id): bool => $id > 0) ->values() ->all(); if ($ids !== []) { DB::connection($legacyConnection) ->table($legacyTable) ->select(['user_id', 'joinDate']) ->whereIn('user_id', $ids) ->get() ->each(function (object $legacyRow) use (&$legacyById): void { $legacyById[(int) $legacyRow->user_id] = $legacyRow; }); } return $legacyById; } /** * @return array */ private function loadLegacyActivityDatesForChunk(Collection $rows, string $legacyConnection): array { $activityById = []; $ids = $rows ->pluck('id') ->map(static fn ($id): int => (int) $id) ->filter(static fn (int $id): bool => $id > 0) ->values() ->all(); if ($ids === []) { return $activityById; } if ($this->legacyTableExists($legacyConnection, 'wallz')) { $this->registerChunkActivityDates( $activityById, DB::connection($legacyConnection) ->table('wallz') ->selectRaw('user_id, MIN(datum) as first_at') ->whereIn('user_id', $ids) ->whereRaw("datum IS NOT NULL AND datum <> '0000-00-00 00:00:00'") ->groupBy('user_id') ->get(), 'first upload' ); } if ($this->legacyTableExists($legacyConnection, 'forum_topics')) { $this->registerChunkActivityDates( $activityById, DB::connection($legacyConnection) ->table('forum_topics') ->selectRaw('user_id, MIN(post_date) as first_at') ->whereIn('user_id', $ids) ->whereRaw("post_date <> '0000-00-00 00:00:00'") ->groupBy('user_id') ->get(), 'first forum topic' ); } if ($this->legacyTableExists($legacyConnection, 'forum_posts')) { $this->registerChunkActivityDates( $activityById, DB::connection($legacyConnection) ->table('forum_posts') ->selectRaw('user_id, MIN(post_date) as first_at') ->whereIn('user_id', $ids) ->whereRaw("post_date <> '0000-00-00 00:00:00'") ->groupBy('user_id') ->get(), 'first forum post' ); } if ($this->legacyTableExists($legacyConnection, 'artworks_comments')) { $this->registerChunkActivityDates( $activityById, DB::connection($legacyConnection) ->table('artworks_comments') ->selectRaw("user_id, MIN(TIMESTAMP(`date`, COALESCE(`time`, '00:00:00'))) as first_at") ->whereIn('user_id', $ids) ->whereRaw("`date` IS NOT NULL AND `date` <> '0000-00-00'") ->groupBy('user_id') ->get(), 'first artwork comment' ); } if ($this->legacyTableExists($legacyConnection, 'users_comments')) { $this->registerChunkActivityDates( $activityById, DB::connection($legacyConnection) ->table('users_comments') ->selectRaw("user_id, MIN(TIMESTAMP(`date`, COALESCE(`time`, '00:00:00'))) as first_at") ->whereIn('user_id', $ids) ->whereRaw("`date` IS NOT NULL AND `date` <> '0000-00-00'") ->groupBy('user_id') ->get(), 'first profile comment' ); } return $activityById; } /** * @param array $activityById */ private function registerChunkActivityDates(array &$activityById, iterable $rows, string $source): void { foreach ($rows as $row) { $candidate = $this->parseLegacyJoinDate($row->first_at ?? null); if ($candidate === null) { continue; } $userId = (int) ($row->user_id ?? 0); if ($userId <= 0) { continue; } $existing = $activityById[$userId]['date'] ?? null; if ($existing === null || $candidate->lt($existing)) { $activityById[$userId] = [ 'date' => $candidate, 'source' => $source, ]; } } } private function parseLegacyJoinDate(mixed $value): ?Carbon { $raw = trim((string) ($value ?? '')); if ($raw === '' || str_starts_with($raw, '0000-00-00')) { return null; } try { return Carbon::parse($raw); } catch (\Throwable) { return null; } } private function parseCurrentDate(mixed $value): ?Carbon { if ($value instanceof Carbon) { return $value; } $raw = trim((string) ($value ?? '')); if ($raw === '') { return null; } try { return Carbon::parse($raw); } catch (\Throwable) { return null; } } }