diff --git a/app/Console/Commands/CheckArtworkUserReferencesCommand.php b/app/Console/Commands/CheckArtworkUserReferencesCommand.php new file mode 100644 index 00000000..9b968758 --- /dev/null +++ b/app/Console/Commands/CheckArtworkUserReferencesCommand.php @@ -0,0 +1,626 @@ +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, + * missing_user_ids: array + * } + */ + 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', + 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 ?? ''), + ]; + } + } + + 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 $legacyIds + * @return array + */ + 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'], array_map( + static fn (array $row): array => [$row['artwork_id'], $row['user_id'], $row['title']], + $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; + } +} \ No newline at end of file