343 lines
11 KiB
PHP
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;
|
|
}
|
|
}
|
|
}
|