Files
SkinbaseNova/app/Console/Commands/RepairLegacyUserJoinDatesCommand.php
2026-03-28 19:15:39 +01:00

343 lines
11 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class RepairLegacyUserJoinDatesCommand extends Command
{
/** @var array<string, bool> */
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() ?? '<null>',
$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<int, object>
*/
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<int, array{date: Carbon, source: string}>
*/
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<int, array{date: Carbon, source: string}> $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;
}
}
}