Files
SkinbaseNova/app/Console/Commands/CheckArtworkUserReferencesCommand.php

639 lines
26 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Support\UsernamePolicy;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use RuntimeException;
use Symfony\Component\Console\Output\OutputInterface;
final class CheckArtworkUserReferencesCommand extends Command
{
protected $signature = 'artworks:check-user-refs
{--chunk=1000 : Number of artworks to process per chunk}
{--show-missing=25 : Maximum number of missing references to print}
{--artwork-id= : Only check/copy the user referenced by this specific artwork ID}
{--copy-missing-from-legacy : Copy missing referenced users from the legacy users table into the new users table using the same id}
{--create-placeholder : Create a placeholder tmpu{id} stub user when the legacy user cannot be found}
{--dry-run-copy : Preview legacy user copies without writing them}
{--legacy-connection=legacy : Legacy database connection name}
{--legacy-users-table=users : Legacy users table name}
{--json : Output the summary as JSON}';
protected $description = 'Check that every artworks.user_id points to an existing users.id row.';
public function handle(): int
{
$chunkSize = max(1, (int) $this->option('chunk'));
$showMissing = max(0, (int) $this->option('show-missing'));
$copyMissingFromLegacy = (bool) $this->option('copy-missing-from-legacy');
$createPlaceholder = (bool) $this->option('create-placeholder');
$dryRunCopy = (bool) $this->option('dry-run-copy');
$legacyConnection = (string) $this->option('legacy-connection');
$legacyUsersTable = (string) $this->option('legacy-users-table');
$artworkId = $this->option('artwork-id') !== null ? (int) $this->option('artwork-id') : null;
$this->line(sprintf('Auditing artworks.user_id references in chunks of %d...', $chunkSize));
$audit = $this->auditArtworkUserReferences($chunkSize, $showMissing, $artworkId);
$copySummary = null;
if ($copyMissingFromLegacy) {
$this->newLine();
$this->line(sprintf(
'%s missing referenced users from legacy connection "%s" table "%s".',
$dryRunCopy ? 'Previewing copy of' : 'Copying',
$legacyConnection,
$legacyUsersTable,
));
try {
$copySummary = $this->copyMissingUsersFromLegacy(
array_keys($audit['missing_user_ids']),
$legacyConnection,
$legacyUsersTable,
$dryRunCopy,
$createPlaceholder,
);
} catch (\Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
if (! $dryRunCopy && ($copySummary['copied'] ?? 0) > 0) {
$audit = $this->auditArtworkUserReferences($chunkSize, $showMissing, $artworkId);
}
}
if ((bool) $this->option('json')) {
$payload = [
'summary' => $audit['summary'],
'sample_missing' => $audit['sample_missing'],
];
if ($copySummary !== null) {
$payload['copy_summary'] = $copySummary;
}
$this->line(json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
return ((int) ($audit['summary']['missing'] ?? 0)) === 0 ? self::SUCCESS : self::FAILURE;
}
$this->renderAuditSummary($audit['summary'], $audit['sample_missing']);
if ($copySummary !== null) {
$this->renderCopySummary($copySummary);
}
return ((int) ($audit['summary']['missing'] ?? 0)) === 0 ? self::SUCCESS : self::FAILURE;
}
/**
* @return array{
* summary: array{checked:int, valid:int, missing:int, null_user_ids:int},
* sample_missing: array<int, array{artwork_id:int, user_id:string, title:string, active:string, published:string, artwork_status:string}>,
* missing_user_ids: array<int, true>
* }
*/
private function auditArtworkUserReferences(int $chunkSize, int $showMissing, ?int $artworkId = null): array
{
$checked = 0;
$valid = 0;
$missing = 0;
$nullUserIds = 0;
$sampleRows = [];
$missingUserIds = [];
DB::table('artworks')
->leftJoin('users', 'users.id', '=', 'artworks.user_id')
->select([
'artworks.id',
'artworks.user_id',
'artworks.title',
'artworks.is_public',
'artworks.published_at',
'artworks.artwork_status',
DB::raw('users.id as matched_user_id'),
])
->when($artworkId !== null, fn ($q) => $q->where('artworks.id', $artworkId))
->orderBy('artworks.id')
->chunkById($chunkSize, function ($artworks) use (&$checked, &$valid, &$missing, &$nullUserIds, &$sampleRows, &$missingUserIds, $showMissing): void {
foreach ($artworks as $artwork) {
$checked++;
if ($artwork->matched_user_id !== null) {
$valid++;
continue;
}
$missing++;
if ($artwork->user_id === null) {
$nullUserIds++;
} else {
$missingUserIds[(int) $artwork->user_id] = true;
}
if (count($sampleRows) < $showMissing) {
$sampleRows[] = [
'artwork_id' => (int) $artwork->id,
'user_id' => $artwork->user_id === null ? '[null]' : (string) $artwork->user_id,
'title' => (string) ($artwork->title ?? ''),
'active' => (bool) ($artwork->is_public ?? false) ? 'yes' : 'no',
'published' => $artwork->published_at !== null ? 'yes' : 'no',
'artwork_status' => (string) ($artwork->artwork_status ?? ''),
];
}
}
if ($this->isVerboseOutput()) {
$this->line(sprintf(
' audited %d artworks so far; missing=%d, null_user_id=%d.',
$checked,
$missing,
$nullUserIds,
));
}
}, 'artworks.id', 'id');
return [
'summary' => [
'checked' => $checked,
'valid' => $valid,
'missing' => $missing,
'null_user_ids' => $nullUserIds,
],
'sample_missing' => $sampleRows,
'missing_user_ids' => $missingUserIds,
];
}
/**
* @param array<int, int|string> $legacyIds
* @return array<string, mixed>
*/
private function copyMissingUsersFromLegacy(array $legacyIds, string $legacyConnection, string $legacyUsersTable, bool $dryRun, bool $createPlaceholder = false): array
{
$result = [
'requested_users' => count($legacyIds),
'copied' => 0,
'placeholders_created' => 0,
'would_copy' => 0,
'conflicts' => 0,
'not_found_in_legacy' => 0,
'errors' => 0,
'dry_run' => $dryRun,
'sample_copied_ids' => [],
'sample_placeholder_ids' => [],
'sample_conflict_ids' => [],
'sample_not_found_ids' => [],
'sample_error_messages' => [],
];
if ($legacyIds === []) {
if ($this->isVerboseOutput()) {
$this->line('No missing non-null user ids were found to copy from the legacy users table.');
}
return $result;
}
$this->ensureLegacyConnectionIsUsable($legacyConnection, $legacyUsersTable);
$normalizedLegacyIds = array_values(array_unique(array_map('intval', $legacyIds)));
foreach (array_chunk($normalizedLegacyIds, 200) as $chunkIndex => $chunk) {
$legacyRows = DB::connection($legacyConnection)
->table($legacyUsersTable)
->whereIn('user_id', $chunk)
->get()
->keyBy(fn (object $row): int => (int) $row->user_id);
if ($this->isVerboseOutput()) {
$this->line(sprintf(
' processing legacy chunk %d with %d requested ids; found %d legacy rows.',
$chunkIndex + 1,
count($chunk),
$legacyRows->count(),
));
}
foreach ($chunk as $legacyId) {
if (DB::table('users')->where('id', $legacyId)->exists()) {
$result['conflicts']++;
if (count($result['sample_conflict_ids']) < 10) {
$result['sample_conflict_ids'][] = $legacyId;
}
if ($this->isVerboseOutput()) {
$this->warn(sprintf('[skip-conflict] user #%d already exists in the new users table.', $legacyId));
}
continue;
}
$legacyUser = $legacyRows->get($legacyId);
if (! $legacyUser) {
$result['not_found_in_legacy']++;
if (count($result['sample_not_found_ids']) < 10) {
$result['sample_not_found_ids'][] = $legacyId;
}
if ($this->isVerboseOutput()) {
$this->warn(sprintf(
'[missing-legacy] user #%d was not found in %s.%s.',
$legacyId,
$legacyConnection,
$legacyUsersTable,
));
}
if ($createPlaceholder) {
if ($dryRun) {
$result['would_copy']++;
$this->line(sprintf('[dry-run] would create placeholder tmpu%d for user #%d', $legacyId, $legacyId));
} else {
try {
$this->createPlaceholderUser($legacyId);
$result['placeholders_created']++;
if (count($result['sample_placeholder_ids']) < 10) {
$result['sample_placeholder_ids'][] = $legacyId;
}
$this->info(sprintf('[placeholder] created tmpu%d for user #%d', $legacyId, $legacyId));
} catch (\Throwable $exception) {
$result['errors']++;
$message = sprintf('#%d (placeholder): %s', $legacyId, $exception->getMessage());
if (count($result['sample_error_messages']) < 10) {
$result['sample_error_messages'][] = $message;
}
$this->error('[placeholder-error] ' . $message);
}
}
}
continue;
}
if ($dryRun) {
$result['would_copy']++;
if (count($result['sample_copied_ids']) < 10) {
$result['sample_copied_ids'][] = $legacyId;
}
$this->line(sprintf('[dry-run] would import legacy user %s', $this->describeLegacyUser($legacyUser, $legacyId)));
continue;
}
try {
$this->importLegacyUserBySameId($legacyUser, $legacyId);
$result['copied']++;
if (count($result['sample_copied_ids']) < 10) {
$result['sample_copied_ids'][] = $legacyId;
}
$this->info(sprintf('[copied] imported legacy user %s', $this->describeLegacyUser($legacyUser, $legacyId)));
} catch (\Throwable $exception) {
$result['errors']++;
$message = sprintf('#%d: %s', $legacyId, $exception->getMessage());
if (count($result['sample_error_messages']) < 10) {
$result['sample_error_messages'][] = $message;
}
$this->error('[copy-error] ' . $message);
}
}
}
return $result;
}
private function importLegacyUserBySameId(object $legacyUser, int $legacyId): void
{
$now = now();
$username = $this->resolveImportUsername($legacyUser, $legacyId);
$email = $this->resolveImportEmail($legacyUser, $legacyId);
$name = (string) ($this->legacyField($legacyUser, 'real_name') ?: $username);
$createdAt = $this->parseLegacyDate($this->legacyField($legacyUser, 'joinDate')) ?? $now;
$lastVisitAt = $this->parseLegacyDate($this->legacyField($legacyUser, 'LastVisit'));
$countryCode = $this->legacyField($legacyUser, 'country_code');
DB::transaction(function () use ($legacyId, $legacyUser, $username, $email, $name, $createdAt, $lastVisitAt, $countryCode, $now): void {
if (DB::table('users')->where('id', $legacyId)->exists()) {
throw new RuntimeException(sprintf('Conflict: user id %d already exists in the new users table.', $legacyId));
}
DB::table('users')->insert([
'id' => $legacyId,
'username' => $username,
'username_changed_at' => $now,
'name' => $name,
'email' => $email,
'password' => Hash::make(Str::random(64)),
'is_active' => (int) ($this->legacyField($legacyUser, 'active') ?? 1) === 1,
'needs_password_reset' => true,
'role' => 'user',
'legacy_password_algo' => null,
'last_visit_at' => $lastVisitAt,
'created_at' => $createdAt,
'updated_at' => $now,
]);
if (Schema::hasTable('user_profiles')) {
DB::table('user_profiles')->updateOrInsert(
['user_id' => $legacyId],
[
'bio' => $this->legacyField($legacyUser, 'about_me') ?: $this->legacyField($legacyUser, 'description'),
'country' => $this->legacyField($legacyUser, 'country'),
'country_code' => is_string($countryCode) && $countryCode !== '' ? substr($countryCode, 0, 2) : null,
'website' => $this->legacyField($legacyUser, 'web'),
'gender' => $this->normalizeLegacyGender($this->legacyField($legacyUser, 'gender')),
'created_at' => $now,
'updated_at' => $now,
],
);
}
if (Schema::hasTable('user_statistics')) {
DB::table('user_statistics')->updateOrInsert(
['user_id' => $legacyId],
[
'uploads_count' => 0,
'downloads_received_count' => 0,
'artwork_views_received_count' => 0,
'awards_received_count' => 0,
'created_at' => $now,
'updated_at' => $now,
],
);
}
});
}
private function createPlaceholderUser(int $legacyId): void
{
$now = now();
$username = $this->uniquePlaceholderUsername($legacyId);
$email = $username . '@users.skinbase.org';
DB::transaction(function () use ($legacyId, $username, $email, $now): void {
if (DB::table('users')->where('id', $legacyId)->exists()) {
throw new RuntimeException(sprintf('Conflict: user id %d already exists in the new users table.', $legacyId));
}
DB::table('users')->insert([
'id' => $legacyId,
'username' => $username,
'username_changed_at' => $now,
'name' => $username,
'email' => $email,
'password' => Hash::make(Str::random(64)),
'is_active' => false,
'needs_password_reset' => true,
'role' => 'user',
'legacy_password_algo' => null,
'last_visit_at' => null,
'created_at' => $now,
'updated_at' => $now,
]);
if (Schema::hasTable('user_profiles')) {
DB::table('user_profiles')->updateOrInsert(
['user_id' => $legacyId],
['created_at' => $now, 'updated_at' => $now],
);
}
if (Schema::hasTable('user_statistics')) {
DB::table('user_statistics')->updateOrInsert(
['user_id' => $legacyId],
[
'uploads_count' => 0,
'downloads_received_count' => 0,
'artwork_views_received_count' => 0,
'awards_received_count' => 0,
'created_at' => $now,
'updated_at' => $now,
],
);
}
});
}
private function resolveImportUsername(object $legacyUser, int $legacyId): string
{
$rawUsername = (string) ($this->legacyField($legacyUser, 'uname') ?: ('user' . $legacyId));
$username = $this->sanitizeUsername($rawUsername);
if (! $this->usernameExists($username, $legacyId)) {
return $username;
}
return $this->uniquePlaceholderUsername($legacyId);
}
private function sanitizeUsername(string $username): string
{
return UsernamePolicy::sanitizeLegacy($username);
}
private function usernameExists(string $username, int $ignoreUserId): bool
{
return DB::table('users')
->whereRaw('LOWER(username) = ?', [strtolower($username)])
->where('id', '!=', $ignoreUserId)
->exists();
}
private function uniquePlaceholderUsername(int $legacyId): string
{
$base = 'tmpu' . $legacyId;
$candidate = $base;
$suffix = 1;
while ($this->usernameExists($candidate, $legacyId)) {
$suffixStr = (string) $suffix;
$candidate = substr($base, 0, max(1, 20 - strlen($suffixStr))) . $suffixStr;
$suffix++;
}
return $candidate;
}
private function renderAuditSummary(array $summary, array $sampleRows): void
{
$this->info(sprintf(
'Checked %d artworks: %d valid, %d missing user references, %d null user_id values.',
(int) ($summary['checked'] ?? 0),
(int) ($summary['valid'] ?? 0),
(int) ($summary['missing'] ?? 0),
(int) ($summary['null_user_ids'] ?? 0),
));
if ($sampleRows !== []) {
$this->newLine();
$this->warn('Sample missing references:');
$this->table(['Artwork ID', 'user_id', 'Title', 'Active', 'Published', 'Status'], array_map(
static fn (array $row): array => [
$row['artwork_id'],
$row['user_id'],
$row['title'],
$row['active'],
$row['published'],
$row['artwork_status'],
],
$sampleRows,
));
}
if ((int) ($summary['missing'] ?? 0) === 0) {
$this->info('No missing user references found in artworks.user_id.');
} else {
$this->error('Found artworks with missing user references.');
}
}
private function renderCopySummary(array $copySummary): void
{
$this->newLine();
$this->info(sprintf(
'Legacy copy summary: requested %d users, copied %d, placeholders %d, would copy %d, conflicts %d, not found in legacy %d, errors %d.',
(int) ($copySummary['requested_users'] ?? 0),
(int) ($copySummary['copied'] ?? 0),
(int) ($copySummary['placeholders_created'] ?? 0),
(int) ($copySummary['would_copy'] ?? 0),
(int) ($copySummary['conflicts'] ?? 0),
(int) ($copySummary['not_found_in_legacy'] ?? 0),
(int) ($copySummary['errors'] ?? 0),
));
if (($copySummary['sample_copied_ids'] ?? []) !== []) {
$this->line('Copied or would-copy user ids: ' . implode(', ', $copySummary['sample_copied_ids']));
}
if (($copySummary['sample_placeholder_ids'] ?? []) !== []) {
$this->line('Placeholder users created for ids: ' . implode(', ', $copySummary['sample_placeholder_ids']));
}
if (($copySummary['sample_conflict_ids'] ?? []) !== []) {
$this->warn('Conflicts: user ids already present in new DB: ' . implode(', ', $copySummary['sample_conflict_ids']));
}
if (($copySummary['sample_not_found_ids'] ?? []) !== []) {
$this->warn('Not found in legacy: ' . implode(', ', $copySummary['sample_not_found_ids']));
}
if (($copySummary['sample_error_messages'] ?? []) !== []) {
foreach ($copySummary['sample_error_messages'] as $message) {
$this->warn($message);
}
}
}
private function ensureLegacyConnectionIsUsable(string $connection, string $table): void
{
try {
DB::connection($connection)->getPdo();
} catch (\Throwable $exception) {
throw new RuntimeException(sprintf('Legacy DB connection "%s" is not configured or reachable.', $connection), 0, $exception);
}
if (! DB::connection($connection)->getSchemaBuilder()->hasTable($table)) {
throw new RuntimeException(sprintf('Legacy users table "%s" was not found on connection "%s".', $table, $connection));
}
}
private function resolveImportEmail(object $legacyUser, int $legacyId): string
{
$rawEmail = strtolower(trim((string) ($this->legacyField($legacyUser, 'email') ?? '')));
$candidate = $rawEmail !== ''
? $rawEmail
: ($this->sanitizeEmailLocal((string) ($this->legacyField($legacyUser, 'uname') ?: ('user' . $legacyId))) . '@users.skinbase.org');
return $this->uniqueEmailCandidate($candidate, $legacyId);
}
private function uniqueEmailCandidate(string $email, int $legacyId): string
{
$candidate = strtolower(trim($email));
$suffix = 1;
while ($candidate === '' || DB::table('users')->whereRaw('LOWER(email) = ?', [$candidate])->where('id', '!=', $legacyId)->exists()) {
$parts = explode('@', $email, 2);
$local = $this->sanitizeEmailLocal($parts[0] ?? ('user' . $legacyId));
$domain = $parts[1] ?? 'users.skinbase.org';
$candidate = $local . '+' . $suffix . '@' . $domain;
$suffix++;
}
return $candidate;
}
private function sanitizeEmailLocal(string $value): string
{
$local = strtolower(trim(Str::ascii($value)));
$local = preg_replace('/[^a-z0-9._-]/', '-', $local) ?: 'user';
return trim($local, '.-') ?: 'user';
}
private function normalizeLegacyGender(mixed $value): ?string
{
$normalized = strtoupper(trim((string) ($value ?? '')));
return match ($normalized) {
'M', 'MALE', 'MAN', 'BOY' => 'M',
'F', 'FEMALE', 'WOMAN', 'GIRL' => 'F',
default => null,
};
}
private function isVerboseOutput(): bool
{
return $this->getOutput()->getVerbosity() >= OutputInterface::VERBOSITY_VERBOSE;
}
private function describeLegacyUser(object $legacyUser, int $legacyId): string
{
$username = trim((string) ($this->legacyField($legacyUser, 'uname') ?? ''));
$name = trim((string) ($this->legacyField($legacyUser, 'real_name') ?? ''));
$email = trim((string) ($this->legacyField($legacyUser, 'email') ?? ''));
return sprintf(
'#%d username=%s name=%s email=%s',
$legacyId,
$username !== '' ? '@' . $username : '[missing]',
$name !== '' ? '"' . $name . '"' : '[missing]',
$email !== '' ? '<' . $email . '>' : '[missing]',
);
}
private function parseLegacyDate(mixed $value): ?Carbon
{
if (! is_string($value) || trim($value) === '' || str_starts_with($value, '0000-00-00')) {
return null;
}
try {
return Carbon::parse($value);
} catch (\Throwable) {
return null;
}
}
private function legacyField(object $row, string $field): mixed
{
return property_exists($row, $field) ? $row->{$field} : null;
}
}