messages implemented
This commit is contained in:
@@ -223,11 +223,11 @@ class ImportLegacyUsers extends Command
|
||||
DB::table('user_statistics')->updateOrInsert(
|
||||
['user_id' => $legacyId],
|
||||
[
|
||||
'uploads' => $uploads,
|
||||
'downloads' => $downloads,
|
||||
'pageviews' => $pageviews,
|
||||
'awards' => $awards,
|
||||
'updated_at' => $now,
|
||||
'uploads_count' => $uploads,
|
||||
'downloads_received_count' => $downloads,
|
||||
'artwork_views_received_count' => $pageviews,
|
||||
'awards_received_count' => $awards,
|
||||
'updated_at' => $now,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
325
app/Console/Commands/MigrateFavourites.php
Normal file
325
app/Console/Commands/MigrateFavourites.php
Normal file
@@ -0,0 +1,325 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* php artisan skinbase:migrate-favourites
|
||||
*
|
||||
* Migrates rows from the legacy `favourites` table (projekti_old_skinbase)
|
||||
* into the new `artwork_favourites` table on the default connection.
|
||||
*
|
||||
* Skipped rows (logged as warnings):
|
||||
* - artwork_id not found in new artworks table
|
||||
* - user_id not found in new OR legacy users table (unless --import-missing-users)
|
||||
* - row already imported (duplicate legacy_id)
|
||||
* - would create a duplicate (user_id, artwork_id) pair
|
||||
*
|
||||
* Dropped legacy columns (not migrated):
|
||||
* - user_type — membership tier, not relevant to the relationship
|
||||
* - author_id — always derivable via artworks.user_id
|
||||
*
|
||||
* Options:
|
||||
* --dry-run Preview without writing
|
||||
* --chunk=500 Rows per batch
|
||||
* --start-id=0 Resume from this favourite_id
|
||||
* --limit=0 Stop after N inserts (0 = no limit)
|
||||
* --import-missing-users Auto-create a stub user from legacy data when the
|
||||
* user is missing from the new DB (needs_password_reset=true)
|
||||
* --legacy-connection Override legacy DB connection name (default: legacy)
|
||||
* --legacy-table Override legacy favourites table name (default: favourites)
|
||||
* --legacy-users-table Override legacy users table name (default: users)
|
||||
*/
|
||||
class MigrateFavourites extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:migrate-favourites
|
||||
{--dry-run : Preview changes without writing to the database}
|
||||
{--chunk=500 : Number of rows to process per batch}
|
||||
{--start-id=0 : Resume processing from this favourite_id}
|
||||
{--limit=0 : Stop after inserting this many rows (0 = unlimited)}
|
||||
{--import-missing-users : Auto-create stub users from legacy data when missing from new DB}
|
||||
{--legacy-connection=legacy : Name of the legacy DB connection}
|
||||
{--legacy-table=favourites : Name of the legacy favourites table}
|
||||
{--legacy-users-table=users : Name of the legacy users table}';
|
||||
|
||||
protected $description = 'Migrate legacy favourites into artwork_favourites.';
|
||||
|
||||
// ── Counters ─────────────────────────────────────────────────────────────
|
||||
|
||||
private int $inserted = 0;
|
||||
private int $skipped = 0;
|
||||
private int $total = 0;
|
||||
private int $usersImported = 0;
|
||||
|
||||
// ── Runtime config (set in handle()) ─────────────────────────────────────
|
||||
|
||||
private bool $importMissingUsers = false;
|
||||
private string $legacyConn = 'legacy';
|
||||
private string $legacyUsersTable = 'users';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$chunk = max(1, (int) $this->option('chunk'));
|
||||
$startId = max(0, (int) $this->option('start-id'));
|
||||
$limit = max(0, (int) $this->option('limit'));
|
||||
|
||||
$this->importMissingUsers = (bool) $this->option('import-missing-users');
|
||||
$this->legacyConn = (string) $this->option('legacy-connection');
|
||||
$this->legacyUsersTable = (string) $this->option('legacy-users-table');
|
||||
$legacyTable = (string) $this->option('legacy-table');
|
||||
|
||||
$this->info("Migrating <comment>{$this->legacyConn}.{$legacyTable}</comment> → <info>artwork_favourites</info>");
|
||||
|
||||
if ($this->importMissingUsers) {
|
||||
$this->warn('--import-missing-users: stub users will be created with needs_password_reset=true.');
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('DRY-RUN mode — no rows will be written.');
|
||||
}
|
||||
if ($startId > 0) {
|
||||
$this->line("Resuming from favourite_id >= {$startId}");
|
||||
}
|
||||
if ($limit > 0) {
|
||||
$this->line("Will stop after {$limit} inserts.");
|
||||
}
|
||||
|
||||
$query = DB::connection($this->legacyConn)
|
||||
->table($legacyTable)
|
||||
->orderBy('favourite_id');
|
||||
|
||||
if ($startId > 0) {
|
||||
$query->where('favourite_id', '>=', $startId);
|
||||
}
|
||||
|
||||
$query->chunkById(
|
||||
$chunk,
|
||||
function ($rows) use ($dryRun, $limit): bool {
|
||||
foreach ($rows as $row) {
|
||||
$this->total++;
|
||||
|
||||
if ($limit > 0 && $this->inserted >= $limit) {
|
||||
return false; // stop chunking
|
||||
}
|
||||
|
||||
if ($this->processRow($row, $dryRun) === false) {
|
||||
$this->skipped++;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
'favourite_id',
|
||||
);
|
||||
|
||||
$this->newLine();
|
||||
$this->info(sprintf(
|
||||
'Done. %d scanned, %d %s, %d skipped%s.',
|
||||
$this->total,
|
||||
$this->inserted,
|
||||
$dryRun ? 'would be inserted' : 'inserted',
|
||||
$this->skipped,
|
||||
$this->usersImported > 0
|
||||
? ", {$this->usersImported} stub users " . ($dryRun ? 'would be ' : '') . 'created'
|
||||
: '',
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// ── Row processing ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Process a single legacy row. Returns true on success, false when skipped.
|
||||
*/
|
||||
private function processRow(object $row, bool $dryRun): bool
|
||||
{
|
||||
$legacyId = (int) ($row->favourite_id ?? 0);
|
||||
$artworkId = (int) ($row->artwork_id ?? 0);
|
||||
$userId = (int) ($row->user_id ?? 0);
|
||||
$datum = $row->datum ?? null;
|
||||
|
||||
// ── Validate IDs ────────────────────────────────────────────────────
|
||||
|
||||
if ($artworkId <= 0 || $userId <= 0) {
|
||||
$this->skip($legacyId, "invalid artwork_id={$artworkId} or user_id={$userId}");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! DB::table('artworks')->where('id', $artworkId)->exists()) {
|
||||
$this->skip($legacyId, "artwork #{$artworkId} not found in new DB");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! DB::table('users')->where('id', $userId)->exists()) {
|
||||
if ($this->importMissingUsers) {
|
||||
if (! $this->importUserStub($userId, $dryRun)) {
|
||||
$this->skip($legacyId, "user #{$userId} not found in legacy DB either — skipped");
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
$this->skip($legacyId, "user #{$userId} not found in new DB (use --import-missing-users to auto-create)");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Idempotency guards ───────────────────────────────────────────────
|
||||
|
||||
if (DB::table('artwork_favourites')->where('legacy_id', $legacyId)->exists()) {
|
||||
// Already imported — silently skip (not counted as "skipped" error)
|
||||
return true;
|
||||
}
|
||||
|
||||
if (DB::table('artwork_favourites')
|
||||
->where('user_id', $userId)
|
||||
->where('artwork_id', $artworkId)
|
||||
->exists()
|
||||
) {
|
||||
$this->skip($legacyId, "duplicate (user={$userId}, artwork={$artworkId}) already exists");
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Map timestamp ────────────────────────────────────────────────────
|
||||
|
||||
$createdAt = $this->parseDate($datum);
|
||||
|
||||
// ── Insert ───────────────────────────────────────────────────────────
|
||||
|
||||
if (! $dryRun) {
|
||||
DB::table('artwork_favourites')->insert([
|
||||
'user_id' => $userId,
|
||||
'artwork_id' => $artworkId,
|
||||
'legacy_id' => $legacyId,
|
||||
'created_at' => $createdAt,
|
||||
'updated_at' => $createdAt,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->inserted++;
|
||||
|
||||
if ($this->inserted % 500 === 0) {
|
||||
$this->line(" {$this->inserted} inserted, {$this->skipped} skipped…");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Look up $userId in the legacy users table and create a stub record in
|
||||
* the new users table preserving the same primary key.
|
||||
*
|
||||
* The stub has:
|
||||
* - needs_password_reset = true (user must reset before logging in)
|
||||
* - legacy_password_algo = 'legacy' (marks imported credential)
|
||||
* - is_active determined from legacy `active` flag
|
||||
* - email placeholder if original email is null or already taken
|
||||
*
|
||||
* @return bool true = stub created (or already existed), false = not in legacy DB
|
||||
*/
|
||||
private function importUserStub(int $userId, bool $dryRun): bool
|
||||
{
|
||||
// Already exists — nothing to do.
|
||||
if (DB::table('users')->where('id', $userId)->exists()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$legacyUser = DB::connection($this->legacyConn)
|
||||
->table($this->legacyUsersTable)
|
||||
->where('user_id', $userId)
|
||||
->first();
|
||||
|
||||
if (! $legacyUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Map fields ──────────────────────────────────────────────────────
|
||||
|
||||
$username = trim((string) ($legacyUser->uname ?? '')) ?: "user_{$userId}";
|
||||
|
||||
// Ensure username is unique in the new DB.
|
||||
if (DB::table('users')->where('username', $username)->exists()) {
|
||||
$username = $username . '_' . $userId;
|
||||
}
|
||||
|
||||
$name = trim((string) ($legacyUser->real_name ?? '')) ?: $username;
|
||||
$email = trim((string) ($legacyUser->email ?? ''));
|
||||
|
||||
// Resolve email: use placeholder when blank or already taken.
|
||||
if ($email === '' || DB::table('users')->where('email', $email)->exists()) {
|
||||
$email = "legacy_{$userId}@legacy.skinbase.org";
|
||||
}
|
||||
|
||||
$isActive = ((int) ($legacyUser->active ?? 0)) === 1;
|
||||
$createdAt = $this->parseDate($legacyUser->joinDate ?? null);
|
||||
$lastVisit = $this->parseDate($legacyUser->LastVisit ?? null);
|
||||
|
||||
$stub = [
|
||||
'id' => $userId,
|
||||
'username' => $username,
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'password' => bcrypt(Str::random(48)), // unusable random password
|
||||
'needs_password_reset' => true,
|
||||
'legacy_password_algo' => 'legacy',
|
||||
'is_active' => $isActive,
|
||||
'role' => 'user',
|
||||
'last_visit_at' => $lastVisit !== $createdAt ? $lastVisit : null,
|
||||
'created_at' => $createdAt,
|
||||
'updated_at' => $createdAt,
|
||||
];
|
||||
|
||||
$msg = "Stub user created: #{$userId} ({$username}, {$email})";
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(" [dry] {$msg}");
|
||||
$this->usersImported++;
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
// Force explicit ID insert — MySQL respects it even with auto_increment.
|
||||
DB::table('users')->insert($stub);
|
||||
$this->usersImported++;
|
||||
$this->line(" <info>{$msg}</info>");
|
||||
Log::info("skinbase:migrate-favourites {$msg}");
|
||||
} catch (\Throwable $e) {
|
||||
$err = "Failed to create stub user #{$userId}: {$e->getMessage()}";
|
||||
$this->warn(" {$err}");
|
||||
Log::error("skinbase:migrate-favourites {$err}");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a legacy date value (DATE string / null / zero-date) to a
|
||||
* full datetime string safe for MySQL.
|
||||
*/
|
||||
private function parseDate(mixed $value): string
|
||||
{
|
||||
if (empty($value) || $value === '0000-00-00' || $value === '0000-00-00 00:00:00') {
|
||||
return Carbon::now()->toDateTimeString();
|
||||
}
|
||||
|
||||
try {
|
||||
return Carbon::parse((string) $value)->toDateTimeString();
|
||||
} catch (\Throwable) {
|
||||
return Carbon::now()->toDateTimeString();
|
||||
}
|
||||
}
|
||||
|
||||
private function skip(int $legacyId, string $reason): void
|
||||
{
|
||||
$msg = "SKIP favourite#{$legacyId}: {$reason}";
|
||||
$this->warn(" {$msg}");
|
||||
Log::warning("skinbase:migrate-favourites {$msg}");
|
||||
}
|
||||
}
|
||||
351
app/Console/Commands/MigrateFollows.php
Normal file
351
app/Console/Commands/MigrateFollows.php
Normal file
@@ -0,0 +1,351 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Support\UsernamePolicy;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Migrates legacy friends_list (from the legacy DB connection) into user_followers.
|
||||
*
|
||||
* Usage:
|
||||
* php artisan skinbase:migrate-follows [--dry-run] [--chunk=1000] [--import-missing-users]
|
||||
*
|
||||
* Legacy table: friends_list
|
||||
* user_id -> follower_id (the user who added the friend = someone who follows)
|
||||
* friend_id -> user_id (the user being followed)
|
||||
*
|
||||
* With --import-missing-users: any user referenced in friends_list that does not
|
||||
* exist in the new DB will be fetched from the legacy `users` table and created
|
||||
* as a stub before the follow row is inserted.
|
||||
*/
|
||||
class MigrateFollows extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:migrate-follows
|
||||
{--dry-run : Simulate without writing to the database}
|
||||
{--chunk=1000 : Number of rows to process per batch}
|
||||
{--import-missing-users : Import unknown users from legacy DB instead of skipping them}';
|
||||
|
||||
protected $description = 'Migrate legacy friends_list into user_followers';
|
||||
|
||||
/** Cache per-run: id => true (resolved) | null (not in legacy DB) | false (import error) */
|
||||
private array $legacyUserCache = [];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$isDryRun = (bool) $this->option('dry-run');
|
||||
$chunkSize = max(1, (int) $this->option('chunk'));
|
||||
$importMissing = (bool) $this->option('import-missing-users');
|
||||
|
||||
$this->info($isDryRun
|
||||
? '🔍 Dry-run mode – nothing will be written.'
|
||||
: '🚀 Live mode – writing to user_followers.'
|
||||
);
|
||||
if ($importMissing) {
|
||||
$this->info('👤 --import-missing-users: orphan users will be fetched from legacy DB.');
|
||||
}
|
||||
|
||||
try {
|
||||
$totalLegacy = DB::connection('legacy')->table('friends_list')->count();
|
||||
} catch (\Throwable $e) {
|
||||
$this->error('Cannot read legacy friends_list: ' . $e->getMessage());
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info("Total rows in legacy friends_list: {$totalLegacy}");
|
||||
|
||||
$validUserIds = DB::table('users')->pluck('id')->flip()->all();
|
||||
|
||||
$stats = [
|
||||
'processed' => 0,
|
||||
'inserted' => 0,
|
||||
'duplicates' => 0,
|
||||
'self_follows' => 0,
|
||||
'invalid' => 0, // total orphan rows skipped
|
||||
'invalid_zero_id' => 0, // follower_id or friend_id was 0
|
||||
'invalid_not_in_new' => 0, // not in new DB (--import-missing-users not used)
|
||||
'invalid_not_in_legacy' => 0, // not in new DB AND not in legacy DB
|
||||
'invalid_import_error' => 0, // in legacy DB but stub import failed
|
||||
'users_imported' => 0,
|
||||
'errors' => 0,
|
||||
];
|
||||
|
||||
$logPath = storage_path('logs/migrate_follows.log');
|
||||
$logFile = fopen($logPath, 'a');
|
||||
$this->logLine($logFile, '=== migrate-follows started at ' . now()->toISOString()
|
||||
. " (dry_run={$isDryRun}, import_missing={$importMissing}) ===");
|
||||
|
||||
$chunkNum = 0;
|
||||
$reportEvery = max(1, (int) ceil($totalLegacy / $chunkSize / 10));
|
||||
|
||||
DB::connection('legacy')
|
||||
->table('friends_list')
|
||||
->orderBy('id')
|
||||
->chunk($chunkSize, function ($rows) use (
|
||||
$isDryRun,
|
||||
$importMissing,
|
||||
&$validUserIds,
|
||||
&$stats,
|
||||
&$chunkNum,
|
||||
$reportEvery,
|
||||
$totalLegacy,
|
||||
$logFile
|
||||
) {
|
||||
$toInsert = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$stats['processed']++;
|
||||
|
||||
$followerId = (int) ($row->user_id ?? 0);
|
||||
$followedId = (int) ($row->friend_id ?? 0);
|
||||
$createdAt = $row->date_added ?? now();
|
||||
|
||||
if ($followerId === $followedId) {
|
||||
$stats['self_follows']++;
|
||||
$this->logLine($logFile, "SKIP self-follow: user_id={$followerId}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to resolve any user_id that isn't in the new DB yet
|
||||
$skipReasons = [];
|
||||
$sides = ['follower' => $followerId, 'followed' => $followedId];
|
||||
|
||||
foreach ($sides as $role => $uid) {
|
||||
if (isset($validUserIds[$uid])) {
|
||||
continue; // already valid
|
||||
}
|
||||
|
||||
if ($uid === 0) {
|
||||
$skipReasons[] = "{$role}_id is 0/null";
|
||||
$stats['invalid_zero_id']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $importMissing) {
|
||||
$skipReasons[] = "{$role}={$uid} not in users table (use --import-missing-users to auto-import)";
|
||||
$stats['invalid_not_in_new']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ensureLegacyUser returns: true = resolved, null = not in legacy, false = import error
|
||||
$result = $this->ensureLegacyUser($uid, $isDryRun, $logFile);
|
||||
if ($result === true) {
|
||||
$validUserIds[$uid] = true;
|
||||
$stats['users_imported']++;
|
||||
} elseif ($result === null) {
|
||||
$skipReasons[] = "{$role}={$uid} not found in legacy DB";
|
||||
$stats['invalid_not_in_legacy']++;
|
||||
} else {
|
||||
$skipReasons[] = "{$role}={$uid} found in legacy DB but import failed";
|
||||
$stats['invalid_import_error']++;
|
||||
}
|
||||
}
|
||||
|
||||
if (! isset($validUserIds[$followerId]) || ! isset($validUserIds[$followedId])) {
|
||||
$stats['invalid']++;
|
||||
$reason = implode('; ', $skipReasons) ?: 'unknown';
|
||||
$this->logLine($logFile, "SKIP orphan [row_id={$row->id}] follower={$followerId} followed={$followedId} — {$reason}");
|
||||
continue;
|
||||
}
|
||||
|
||||
$toInsert[] = [
|
||||
'follower_id' => $followerId,
|
||||
'user_id' => $followedId,
|
||||
'created_at' => $createdAt,
|
||||
];
|
||||
}
|
||||
|
||||
if (! $isDryRun && ! empty($toInsert)) {
|
||||
try {
|
||||
$inserted = DB::table('user_followers')->insertOrIgnore($toInsert);
|
||||
$stats['inserted'] += $inserted;
|
||||
$stats['duplicates'] += count($toInsert) - $inserted;
|
||||
} catch (\Throwable $e) {
|
||||
$stats['errors']++;
|
||||
$this->logLine($logFile, 'ERROR batch insert: ' . $e->getMessage());
|
||||
}
|
||||
} elseif ($isDryRun) {
|
||||
$stats['inserted'] += count($toInsert);
|
||||
}
|
||||
|
||||
$chunkNum++;
|
||||
if ($chunkNum % $reportEvery === 0 || $stats['processed'] >= $totalLegacy) {
|
||||
$pct = $totalLegacy > 0 ? round($stats['processed'] / $totalLegacy * 100) : 100;
|
||||
$this->line(" {$stats['processed']} / {$totalLegacy} rows ({$pct}%)"
|
||||
. " inserted: {$stats['inserted']}"
|
||||
. " imported: {$stats['users_imported']}"
|
||||
. " skipped: " . ($stats['self_follows'] + $stats['invalid']));
|
||||
}
|
||||
});
|
||||
|
||||
$this->newLine();
|
||||
|
||||
if (! $isDryRun) {
|
||||
$this->info('Backfilling user_statistics counters...');
|
||||
$this->backfillCounters();
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['Metric', 'Count'],
|
||||
[
|
||||
['Processed', $stats['processed']],
|
||||
['Inserted', $stats['inserted']],
|
||||
['Duplicates (already exist)', $stats['duplicates']],
|
||||
['Self-follows skipped', $stats['self_follows']],
|
||||
['Users stub-imported from legacy', $stats['users_imported']],
|
||||
['Invalid (orphan) — total', $stats['invalid']],
|
||||
[' ↳ zero/null user_id', $stats['invalid_zero_id']],
|
||||
[' ↳ not in new DB (not imported)', $stats['invalid_not_in_new']],
|
||||
[' ↳ not in legacy DB either', $stats['invalid_not_in_legacy']],
|
||||
[' ↳ legacy import error', $stats['invalid_import_error']],
|
||||
['Errors', $stats['errors']],
|
||||
]
|
||||
);
|
||||
|
||||
$summary = "Processed={$stats['processed']} Inserted={$stats['inserted']} "
|
||||
. "Duplicates={$stats['duplicates']} SelfFollows={$stats['self_follows']} "
|
||||
. "UsersImported={$stats['users_imported']} Invalid={$stats['invalid']} "
|
||||
. "(ZeroId={$stats['invalid_zero_id']} NotInNew={$stats['invalid_not_in_new']} "
|
||||
. "NotInLegacy={$stats['invalid_not_in_legacy']} ImportError={$stats['invalid_import_error']}) "
|
||||
. "Errors={$stats['errors']}";
|
||||
|
||||
$this->logLine($logFile, "=== DONE: {$summary} ===");
|
||||
fclose($logFile);
|
||||
|
||||
$this->info("Log written to: {$logPath}");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Ensure a legacy user_id exists in the new `users` table.
|
||||
*
|
||||
* Returns:
|
||||
* true – user is valid (was already there, or was just imported / dry-run pretend-imported)
|
||||
* null – user not found in the legacy DB either → cannot be imported
|
||||
* false – user found in legacy DB but the stub-import threw an exception
|
||||
*
|
||||
* Results are cached per command run to avoid redundant DB queries.
|
||||
*/
|
||||
private function ensureLegacyUser(int $legacyId, bool $isDryRun, $logFile): ?bool
|
||||
{
|
||||
if (array_key_exists($legacyId, $this->legacyUserCache)) {
|
||||
return $this->legacyUserCache[$legacyId];
|
||||
}
|
||||
|
||||
if (DB::table('users')->where('id', $legacyId)->exists()) {
|
||||
return $this->legacyUserCache[$legacyId] = true;
|
||||
}
|
||||
|
||||
$legacyUser = DB::connection('legacy')
|
||||
->table('users')
|
||||
->where('user_id', $legacyId)
|
||||
->first();
|
||||
|
||||
if (! $legacyUser) {
|
||||
$this->logLine($logFile, "IMPORT FAIL: user_id={$legacyId} not found in legacy DB");
|
||||
return $this->legacyUserCache[$legacyId] = null;
|
||||
}
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->logLine($logFile, "DRY-RUN IMPORT: would create user_id={$legacyId} uname={$legacyUser->uname}");
|
||||
return $this->legacyUserCache[$legacyId] = true;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->importLegacyUserStub($legacyUser);
|
||||
$this->logLine($logFile, "IMPORTED user_id={$legacyId} uname={$legacyUser->uname}");
|
||||
return $this->legacyUserCache[$legacyId] = true;
|
||||
} catch (\Throwable $e) {
|
||||
$this->logLine($logFile, "IMPORT ERROR user_id={$legacyId}: " . $e->getMessage());
|
||||
return $this->legacyUserCache[$legacyId] = false;
|
||||
}
|
||||
}
|
||||
|
||||
private function importLegacyUserStub(object $row): void
|
||||
{
|
||||
$legacyId = (int) $row->user_id;
|
||||
$now = now();
|
||||
|
||||
$username = UsernamePolicy::sanitizeLegacy((string) ($row->uname ?: ('user' . $legacyId)));
|
||||
if (! $username) {
|
||||
$username = 'user' . $legacyId;
|
||||
}
|
||||
|
||||
if (DB::table('users')->whereRaw('LOWER(username) = ?', [strtolower($username)])->exists()) {
|
||||
$username = $username . $legacyId;
|
||||
}
|
||||
|
||||
$email = ($row->email ? strtolower(trim($row->email)) : null)
|
||||
?: ('user' . $legacyId . '@users.skinbase.org');
|
||||
|
||||
DB::transaction(function () use ($legacyId, $username, $email, $row, $now) {
|
||||
DB::table('users')->insertOrIgnore([
|
||||
'id' => $legacyId,
|
||||
'username' => $username,
|
||||
'name' => $row->real_name ?: $username,
|
||||
'email' => $email,
|
||||
'password' => Hash::make(Str::random(32)),
|
||||
'is_active' => (int) ($row->active ?? 1) === 1,
|
||||
'needs_password_reset' => true,
|
||||
'role' => 'user',
|
||||
'created_at' => $row->joinDate ?? $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
DB::table('user_profiles')->updateOrInsert(
|
||||
['user_id' => $legacyId],
|
||||
[
|
||||
'country' => $row->country ?? null,
|
||||
'country_code' => $row->country_code ? substr((string) $row->country_code, 0, 2) : null,
|
||||
'website' => $row->web ?? null,
|
||||
'updated_at' => $now,
|
||||
]
|
||||
);
|
||||
|
||||
DB::table('user_statistics')->updateOrInsert(
|
||||
['user_id' => $legacyId],
|
||||
['updated_at' => $now, 'created_at' => $now]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private function backfillCounters(): void
|
||||
{
|
||||
DB::statement('
|
||||
UPDATE user_statistics us
|
||||
JOIN (
|
||||
SELECT user_id, COUNT(*) AS cnt
|
||||
FROM user_followers
|
||||
GROUP BY user_id
|
||||
) AS f ON f.user_id = us.user_id
|
||||
SET us.followers_count = f.cnt, us.updated_at = NOW()
|
||||
');
|
||||
|
||||
DB::statement('
|
||||
UPDATE user_statistics us
|
||||
JOIN (
|
||||
SELECT follower_id, COUNT(*) AS cnt
|
||||
FROM user_followers
|
||||
GROUP BY follower_id
|
||||
) AS f ON f.follower_id = us.user_id
|
||||
SET us.following_count = f.cnt, us.updated_at = NOW()
|
||||
');
|
||||
|
||||
$this->info('Counters backfilled.');
|
||||
}
|
||||
|
||||
private function logLine($handle, string $message): void
|
||||
{
|
||||
if (is_resource($handle)) {
|
||||
fwrite($handle, '[' . now()->toISOString() . '] ' . $message . PHP_EOL);
|
||||
}
|
||||
}
|
||||
}
|
||||
246
app/Console/Commands/MigrateMessagesCommand.php
Normal file
246
app/Console/Commands/MigrateMessagesCommand.php
Normal file
@@ -0,0 +1,246 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Conversation;
|
||||
use App\Models\ConversationParticipant;
|
||||
use App\Models\Message;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Migrates legacy `chat` / `messages` tables into the modern conversation-based system.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Load all legacy rows from the `chat` table via the 'legacy' DB connection.
|
||||
* 2. Group by (sender_user_id, receiver_user_id) pair (canonical: min first).
|
||||
* 3. For each pair, find or create a `direct` conversation.
|
||||
* 4. Insert each message in chronological order.
|
||||
* 5. Set last_read_at based on the legacy read_date column (if present).
|
||||
* 6. Skip deleted / inactive rows.
|
||||
* 7. Convert smileys to emoji placeholders.
|
||||
*
|
||||
* Usage:
|
||||
* php artisan skinbase:migrate-messages
|
||||
* php artisan skinbase:migrate-messages --dry-run
|
||||
* php artisan skinbase:migrate-messages --chunk=1000
|
||||
*/
|
||||
class MigrateMessagesCommand extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:migrate-messages
|
||||
{--dry-run : Preview only — no writes to DB}
|
||||
{--chunk=500 : Rows to process per batch}';
|
||||
|
||||
protected $description = 'Migrate legacy chat/messages into the modern conversation system';
|
||||
|
||||
/** Columns we attempt to read; gracefully degrade if missing. */
|
||||
private array $skipped = [];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$chunk = max(1, (int) $this->option('chunk'));
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('[DRY-RUN] No data will be written.');
|
||||
}
|
||||
|
||||
// ── Check legacy connection ───────────────────────────────────────────
|
||||
try {
|
||||
DB::connection('legacy')->getPdo();
|
||||
} catch (Throwable $e) {
|
||||
$this->error('Cannot connect to legacy database: ' . $e->getMessage());
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$legacySchema = DB::connection('legacy')->getSchemaBuilder();
|
||||
|
||||
if (! $legacySchema->hasTable('chat')) {
|
||||
$this->error('Legacy table `chat` not found on the legacy connection.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$columns = $legacySchema->getColumnListing('chat');
|
||||
$this->info('Legacy chat columns: ' . implode(', ', $columns));
|
||||
|
||||
// Map expected legacy columns (adapt if your legacy schema differs)
|
||||
$hasReadDate = in_array('read_date', $columns, true);
|
||||
$hasSoftDelete = in_array('deleted', $columns, true);
|
||||
|
||||
// ── Count total rows ──────────────────────────────────────────────────
|
||||
$query = DB::connection('legacy')->table('chat');
|
||||
|
||||
if ($hasSoftDelete) {
|
||||
$query->where('deleted', 0);
|
||||
}
|
||||
|
||||
$total = $query->count();
|
||||
$this->info("Total legacy rows to process: {$total}");
|
||||
|
||||
if ($total === 0) {
|
||||
$this->info('Nothing to migrate.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$bar = $this->output->createProgressBar($total);
|
||||
$inserted = 0;
|
||||
$skipped = 0;
|
||||
$offset = 0;
|
||||
|
||||
// ── Chunk processing ──────────────────────────────────────────────────
|
||||
while (true) {
|
||||
$rows = DB::connection('legacy')
|
||||
->table('chat')
|
||||
->when($hasSoftDelete, fn ($q) => $q->where('deleted', 0))
|
||||
->orderBy('id')
|
||||
->offset($offset)
|
||||
->limit($chunk)
|
||||
->get();
|
||||
|
||||
if ($rows->isEmpty()) {
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$senderId = (int) ($row->sender_user_id ?? $row->from_user_id ?? $row->user_id ?? 0);
|
||||
$receiverId = (int) ($row->receiver_user_id ?? $row->to_user_id ?? $row->recipient_id ?? 0);
|
||||
$body = trim((string) ($row->message ?? $row->body ?? $row->content ?? ''));
|
||||
$createdAt = $row->created_at ?? $row->date ?? $row->timestamp ?? now();
|
||||
$readDate = $hasReadDate ? $row->read_date : null;
|
||||
|
||||
if ($senderId === 0 || $receiverId === 0 || $body === '') {
|
||||
$skipped++;
|
||||
$this->skipped[] = ['id' => $row->id ?? '?', 'reason' => 'missing sender/receiver/body'];
|
||||
$bar->advance();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip self-messages
|
||||
if ($senderId === $receiverId) {
|
||||
$skipped++;
|
||||
$this->skipped[] = ['id' => $row->id ?? '?', 'reason' => 'self-message'];
|
||||
$bar->advance();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sanitize: strip HTML, convert smileys to emoji
|
||||
$body = $this->sanitize($body);
|
||||
|
||||
if ($dryRun) {
|
||||
$inserted++;
|
||||
$bar->advance();
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($senderId, $receiverId, $body, $createdAt, $readDate, &$inserted) {
|
||||
// Find or create direct conversation
|
||||
$conv = Conversation::findDirect($senderId, $receiverId);
|
||||
|
||||
if (! $conv) {
|
||||
$conv = Conversation::create([
|
||||
'type' => 'direct',
|
||||
'created_by' => $senderId,
|
||||
'last_message_at' => $createdAt,
|
||||
]);
|
||||
|
||||
ConversationParticipant::insert([
|
||||
[
|
||||
'conversation_id' => $conv->id,
|
||||
'user_id' => $senderId,
|
||||
'role' => 'admin',
|
||||
'joined_at' => $createdAt,
|
||||
'last_read_at' => $readDate,
|
||||
],
|
||||
[
|
||||
'conversation_id' => $conv->id,
|
||||
'user_id' => $receiverId,
|
||||
'role' => 'member',
|
||||
'joined_at' => $createdAt,
|
||||
'last_read_at' => $readDate,
|
||||
],
|
||||
]);
|
||||
} else {
|
||||
// Update last_read_at on existing participants when available
|
||||
if ($readDate) {
|
||||
ConversationParticipant::where('conversation_id', $conv->id)
|
||||
->where('user_id', $receiverId)
|
||||
->whereNull('last_read_at')
|
||||
->update(['last_read_at' => $readDate]);
|
||||
}
|
||||
}
|
||||
|
||||
Message::create([
|
||||
'conversation_id' => $conv->id,
|
||||
'sender_id' => $senderId,
|
||||
'body' => $body,
|
||||
'created_at' => $createdAt,
|
||||
'updated_at' => $createdAt,
|
||||
]);
|
||||
|
||||
// Keep last_message_at up to date
|
||||
if ($conv->last_message_at < $createdAt) {
|
||||
$conv->update(['last_message_at' => $createdAt]);
|
||||
}
|
||||
|
||||
$inserted++;
|
||||
});
|
||||
} catch (Throwable $e) {
|
||||
$skipped++;
|
||||
$this->skipped[] = ['id' => $row->id ?? '?', 'reason' => $e->getMessage()];
|
||||
Log::warning('MigrateMessages: skipped row', [
|
||||
'id' => $row->id ?? '?',
|
||||
'reason' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
}
|
||||
|
||||
$offset += $chunk;
|
||||
}
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine();
|
||||
|
||||
$this->info("Done. Inserted: {$inserted} | Skipped: {$skipped}");
|
||||
|
||||
if ($skipped > 0 && $this->option('verbose')) {
|
||||
$this->table(['ID', 'Reason'], $this->skipped);
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip HTML tags and convert common legacy smileys to emoji.
|
||||
*/
|
||||
private function sanitize(string $body): string
|
||||
{
|
||||
// Strip raw HTML
|
||||
$body = strip_tags($body);
|
||||
|
||||
// Decode HTML entities
|
||||
$body = html_entity_decode($body, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
|
||||
// Common smiley → emoji mapping
|
||||
$smileys = [
|
||||
':)' => '🙂', ':-)' => '🙂',
|
||||
':(' => '🙁', ':-(' => '🙁',
|
||||
':D' => '😀', ':-D' => '😀',
|
||||
':P' => '😛', ':-P' => '😛',
|
||||
';)' => '😉', ';-)' => '😉',
|
||||
':o' => '😮', ':O' => '😮',
|
||||
':|' => '😐', ':-|' => '😐',
|
||||
':/' => '😕', ':-/' => '😕',
|
||||
'<3' => '❤️',
|
||||
'xD' => '😂', 'XD' => '😂',
|
||||
];
|
||||
|
||||
return str_replace(array_keys($smileys), array_values($smileys), $body);
|
||||
}
|
||||
}
|
||||
143
app/Console/Commands/MigrateSmileys.php
Normal file
143
app/Console/Commands/MigrateSmileys.php
Normal file
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\LegacySmileyMapper;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* php artisan skinbase:migrate-smileys
|
||||
*
|
||||
* Scans artworks.description, artwork_comments.content, and forum_posts.content,
|
||||
* replaces legacy smiley codes (:beer, :lol, etc.) with Unicode emoji.
|
||||
*
|
||||
* Options:
|
||||
* --dry-run Show what would change without writing to DB
|
||||
* --chunk=200 Rows processed per batch (default 200)
|
||||
* --table=artworks Limit scan to one table
|
||||
*/
|
||||
class MigrateSmileys extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:migrate-smileys
|
||||
{--dry-run : Preview changes without writing to the database}
|
||||
{--chunk=200 : Number of rows to process per batch}
|
||||
{--table= : Limit scan to a single table (artworks|artwork_comments|forum_posts)}';
|
||||
|
||||
protected $description = 'Convert legacy :smiley: codes to Unicode emoji in content fields.';
|
||||
|
||||
/** Tables and their content columns to scan. */
|
||||
private const TARGETS = [
|
||||
'artworks' => 'description',
|
||||
'artwork_comments' => 'content',
|
||||
'forum_posts' => 'content',
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$chunk = max(1, (int) $this->option('chunk'));
|
||||
$tableOpt = $this->option('table');
|
||||
|
||||
$targets = self::TARGETS;
|
||||
if ($tableOpt) {
|
||||
if (! isset($targets[$tableOpt])) {
|
||||
$this->error("Unknown table: {$tableOpt}. Allowed: " . implode(', ', array_keys($targets)));
|
||||
return self::FAILURE;
|
||||
}
|
||||
$targets = [$tableOpt => $targets[$tableOpt]];
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('DRY-RUN mode — no changes will be written.');
|
||||
}
|
||||
|
||||
$totalChanged = 0;
|
||||
$totalRows = 0;
|
||||
|
||||
foreach ($targets as $table => $column) {
|
||||
$this->line("Scanning <info>{$table}.{$column}</info>…");
|
||||
|
||||
[$changed, $rows] = $this->processTable($table, $column, $chunk, $dryRun);
|
||||
|
||||
$totalChanged += $changed;
|
||||
$totalRows += $rows;
|
||||
|
||||
$this->line(" → {$rows} rows scanned, {$changed} updated.");
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Summary: {$totalRows} rows scanned, {$totalChanged} rows " . ($dryRun ? 'would be ' : '') . 'updated.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function processTable(
|
||||
string $table,
|
||||
string $column,
|
||||
int $chunk,
|
||||
bool $dryRun
|
||||
): array {
|
||||
$totalChanged = 0;
|
||||
$totalRows = 0;
|
||||
|
||||
DB::table($table)
|
||||
->whereNotNull($column)
|
||||
->orderBy('id')
|
||||
->chunk($chunk, function ($rows) use ($table, $column, $dryRun, &$totalChanged, &$totalRows) {
|
||||
foreach ($rows as $row) {
|
||||
$original = $row->$column ?? '';
|
||||
$converted = LegacySmileyMapper::convert($original);
|
||||
|
||||
// Collapse emoji flood runs BEFORE size/DB checks so that
|
||||
// rows like ":beer :beer :beer …" (×500) don't exceed MEDIUMTEXT.
|
||||
$collapsed = LegacySmileyMapper::collapseFlood($converted);
|
||||
if ($collapsed !== $converted) {
|
||||
$beforeBytes = mb_strlen($converted, '8bit');
|
||||
$afterBytes = mb_strlen($collapsed, '8bit');
|
||||
$floodMsg = "[{$table}#{$row->id}] Emoji flood collapsed "
|
||||
. "({$beforeBytes} bytes \u{2192} {$afterBytes} bytes).";
|
||||
$this->warn(" {$floodMsg}");
|
||||
Log::warning($floodMsg);
|
||||
$converted = $collapsed;
|
||||
}
|
||||
|
||||
$totalRows++;
|
||||
|
||||
if ($converted === $original) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$totalChanged++;
|
||||
|
||||
$codes = LegacySmileyMapper::detect($original);
|
||||
$msg = "[{$table}#{$row->id}] Converting: " . implode(', ', $codes);
|
||||
$this->line(" {$msg}");
|
||||
Log::info($msg);
|
||||
|
||||
if (! $dryRun) {
|
||||
// Guard: MEDIUMTEXT max is 16,777,215 bytes.
|
||||
if (mb_strlen($converted, '8bit') > 16_777_215) {
|
||||
$warn = "[{$table}#{$row->id}] SKIP — converted content exceeds MEDIUMTEXT limit (" . mb_strlen($converted, '8bit') . " bytes). Row left unchanged.";
|
||||
$this->warn(" {$warn}");
|
||||
Log::warning($warn);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
DB::table($table)
|
||||
->where('id', $row->id)
|
||||
->update([$column => $converted]);
|
||||
} catch (\Throwable $e) {
|
||||
$err = "[{$table}#{$row->id}] DB error: {$e->getMessage()}";
|
||||
$this->warn(" {$err}");
|
||||
Log::error($err);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return [$totalChanged, $totalRows];
|
||||
}
|
||||
}
|
||||
147
app/Console/Commands/RecomputeUserStatsCommand.php
Normal file
147
app/Console/Commands/RecomputeUserStatsCommand.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\RecomputeUserStatsJob;
|
||||
use App\Services\UserStatsService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Recompute user_statistics counters from authoritative source tables.
|
||||
*
|
||||
* Usage:
|
||||
* # Recompute a single user (live)
|
||||
* php artisan skinbase:recompute-user-stats 42
|
||||
*
|
||||
* # Dry-run for a single user
|
||||
* php artisan skinbase:recompute-user-stats 42 --dry-run
|
||||
*
|
||||
* # Recompute all users in chunks of 500
|
||||
* php artisan skinbase:recompute-user-stats --all --chunk=500
|
||||
*
|
||||
* # Recompute all users via queue (one job per chunk)
|
||||
* php artisan skinbase:recompute-user-stats --all --queue
|
||||
*/
|
||||
class RecomputeUserStatsCommand extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:recompute-user-stats
|
||||
{user_id? : The ID of a single user to recompute}
|
||||
{--all : Recompute stats for ALL non-deleted users}
|
||||
{--chunk=1000 : Chunk size when --all is used}
|
||||
{--dry-run : Show what would be written without saving}
|
||||
{--queue : Dispatch recompute jobs to the queue (--all mode only)}';
|
||||
|
||||
protected $description = 'Rebuild user_statistics counters from authoritative source tables';
|
||||
|
||||
public function handle(UserStatsService $statsService): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$all = (bool) $this->option('all');
|
||||
$userId = $this->argument('user_id');
|
||||
$chunk = max(1, (int) $this->option('chunk'));
|
||||
$queue = (bool) $this->option('queue');
|
||||
|
||||
if ($userId !== null && $all) {
|
||||
$this->error('Provide either a user_id OR --all, not both.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($userId !== null) {
|
||||
return $this->recomputeSingle((int) $userId, $statsService, $dryRun);
|
||||
}
|
||||
|
||||
if ($all) {
|
||||
return $this->recomputeAll($statsService, $chunk, $dryRun, $queue);
|
||||
}
|
||||
|
||||
$this->error('Provide a user_id or use --all.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// ─── Single user ─────────────────────────────────────────────────────────
|
||||
|
||||
private function recomputeSingle(int $userId, UserStatsService $statsService, bool $dryRun): int
|
||||
{
|
||||
$exists = DB::table('users')->where('id', $userId)->exists();
|
||||
if (! $exists) {
|
||||
$this->error("User {$userId} not found.");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$label = $dryRun ? '[DRY-RUN]' : '[LIVE]';
|
||||
$this->line("{$label} Recomputing stats for user #{$userId}…");
|
||||
|
||||
$computed = $statsService->recomputeUser($userId, $dryRun);
|
||||
|
||||
$rows = [];
|
||||
foreach ($computed as $col => $val) {
|
||||
$rows[] = [$col, $val ?? '(null)'];
|
||||
}
|
||||
|
||||
$this->table(['Column', 'Value'], $rows);
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('Dry-run: no changes written.');
|
||||
} else {
|
||||
$this->info("Stats saved for user #{$userId}.");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// ─── All users ────────────────────────────────────────────────────────────
|
||||
|
||||
private function recomputeAll(
|
||||
UserStatsService $statsService,
|
||||
int $chunk,
|
||||
bool $dryRun,
|
||||
bool $useQueue
|
||||
): int {
|
||||
$total = DB::table('users')->whereNull('deleted_at')->count();
|
||||
$label = $dryRun ? '[DRY-RUN]' : ($useQueue ? '[QUEUE]' : '[LIVE]');
|
||||
|
||||
$this->info("{$label} Recomputing stats for {$total} users (chunk={$chunk})…");
|
||||
|
||||
if ($useQueue && ! $dryRun) {
|
||||
$dispatched = 0;
|
||||
DB::table('users')
|
||||
->whereNull('deleted_at')
|
||||
->orderBy('id')
|
||||
->chunkById($chunk, function ($users) use (&$dispatched) {
|
||||
$ids = $users->pluck('id')->all();
|
||||
RecomputeUserStatsJob::dispatch($ids);
|
||||
$dispatched += count($ids);
|
||||
$this->line(" Queued chunk of " . count($ids) . " users (total dispatched: {$dispatched})");
|
||||
});
|
||||
|
||||
$this->info("Done – {$dispatched} users queued for recompute.");
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$processed = 0;
|
||||
$bar = $this->output->createProgressBar($total);
|
||||
$bar->start();
|
||||
|
||||
DB::table('users')
|
||||
->whereNull('deleted_at')
|
||||
->orderBy('id')
|
||||
->chunkById($chunk, function ($users) use ($statsService, $dryRun, &$processed, $bar) {
|
||||
foreach ($users as $user) {
|
||||
$statsService->recomputeUser((int) $user->id, $dryRun);
|
||||
$processed++;
|
||||
$bar->advance();
|
||||
}
|
||||
});
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine();
|
||||
|
||||
$suffix = $dryRun ? ' (no changes written – dry-run)' : '';
|
||||
$this->info("Done – {$processed} users recomputed{$suffix}.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
188
app/Console/Commands/SanitizeContent.php
Normal file
188
app/Console/Commands/SanitizeContent.php
Normal file
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\ContentSanitizer;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* php artisan skinbase:sanitize-content
|
||||
*
|
||||
* Scans legacy content for unsafe HTML, converts it to Markdown-safe text,
|
||||
* and populates the raw_content / rendered_content columns on artwork_comments.
|
||||
*
|
||||
* Options:
|
||||
* --dry-run Preview changes without writing
|
||||
* --chunk=200 Rows per batch
|
||||
* --table= Limit to one target
|
||||
* --artwork-id= Limit to a single artwork (filters artwork_comments by artwork_id, artworks by id)
|
||||
*/
|
||||
class SanitizeContent extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:sanitize-content
|
||||
{--dry-run : Preview changes without writing to the database}
|
||||
{--chunk=200 : Number of rows per batch}
|
||||
{--table= : Limit scan to a single target (artwork_comments|artworks|forum_posts)}
|
||||
{--artwork-id= : Limit scan to a single artwork ID (skips forum_posts)}';
|
||||
|
||||
protected $description = 'Strip unsafe HTML from legacy content and populate sanitized columns.';
|
||||
|
||||
/**
|
||||
* table => [read_col, write_raw_col, write_rendered_col|null]
|
||||
*
|
||||
* For artwork_comments we write two columns; for the others we only sanitize in-place.
|
||||
*/
|
||||
private const TARGETS = [
|
||||
'artwork_comments' => [
|
||||
'read' => 'content',
|
||||
'write_raw' => 'raw_content',
|
||||
'write_rendered' => 'rendered_content',
|
||||
],
|
||||
'artworks' => [
|
||||
'read' => 'description',
|
||||
'write_raw' => 'description',
|
||||
'write_rendered' => null,
|
||||
],
|
||||
'forum_posts' => [
|
||||
'read' => 'content',
|
||||
'write_raw' => 'content',
|
||||
'write_rendered' => null,
|
||||
],
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$chunk = max(1, (int) $this->option('chunk'));
|
||||
$tableOpt = $this->option('table');
|
||||
$artworkId = $this->option('artwork-id');
|
||||
|
||||
if ($artworkId !== null) {
|
||||
if (! ctype_digit((string) $artworkId) || (int) $artworkId < 1) {
|
||||
$this->error("--artwork-id must be a positive integer. Got: {$artworkId}");
|
||||
return self::FAILURE;
|
||||
}
|
||||
$artworkId = (int) $artworkId;
|
||||
}
|
||||
|
||||
$targets = self::TARGETS;
|
||||
if ($tableOpt) {
|
||||
if (! isset($targets[$tableOpt])) {
|
||||
$this->error("Unknown table: {$tableOpt}. Allowed: " . implode(', ', array_keys($targets)));
|
||||
return self::FAILURE;
|
||||
}
|
||||
$targets = [$tableOpt => $targets[$tableOpt]];
|
||||
}
|
||||
|
||||
// --artwork-id removes forum_posts (no artwork FK) and informs the user.
|
||||
if ($artworkId !== null) {
|
||||
unset($targets['forum_posts']);
|
||||
$this->line("Filtering to artwork <info>#{$artworkId}</info> (forum_posts skipped).");
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('DRY-RUN mode — no changes will be written.');
|
||||
}
|
||||
|
||||
$totalModified = 0;
|
||||
$totalRows = 0;
|
||||
|
||||
foreach ($targets as $table => $def) {
|
||||
$this->line("Processing <info>{$table}</info>…");
|
||||
|
||||
[$modified, $rows] = $this->processTable($table, $def, $chunk, $dryRun, $artworkId);
|
||||
$totalModified += $modified;
|
||||
$totalRows += $rows;
|
||||
|
||||
$this->line(" → {$rows} rows scanned, {$modified} modified.");
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Summary: {$totalRows} rows, {$totalModified} " . ($dryRun ? 'would be ' : '') . 'modified.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function processTable(
|
||||
string $table,
|
||||
array $def,
|
||||
int $chunk,
|
||||
bool $dryRun,
|
||||
?int $artworkId = null
|
||||
): array {
|
||||
$totalModified = 0;
|
||||
$totalRows = 0;
|
||||
|
||||
$readCol = $def['read'];
|
||||
$writeRawCol = $def['write_raw'];
|
||||
$writeRenderedCol = $def['write_rendered'];
|
||||
|
||||
DB::table($table)
|
||||
->whereNotNull($readCol)
|
||||
->when($artworkId !== null, function ($q) use ($table, $artworkId) {
|
||||
// artwork_comments has artwork_id; artworks is filtered by its own PK.
|
||||
$filterCol = $table === 'artwork_comments' ? 'artwork_id' : 'id';
|
||||
$q->where($filterCol, $artworkId);
|
||||
})
|
||||
->orderBy('id')
|
||||
->chunk($chunk, function ($rows) use (
|
||||
$table, $readCol, $writeRawCol, $writeRenderedCol,
|
||||
$dryRun, &$totalModified, &$totalRows
|
||||
) {
|
||||
foreach ($rows as $row) {
|
||||
$original = $row->$readCol ?? '';
|
||||
$stripped = ContentSanitizer::stripToPlain($original);
|
||||
|
||||
$totalRows++;
|
||||
|
||||
// Detect if content had HTML that we need to clean
|
||||
$hadHtml = $original !== $stripped && preg_match('/<[a-z][^>]*>/i', $original);
|
||||
|
||||
if ($writeRawCol === $readCol && ! $hadHtml) {
|
||||
// Same column, no HTML, skip
|
||||
continue;
|
||||
}
|
||||
|
||||
$rendered = ContentSanitizer::render($stripped);
|
||||
$totalModified++;
|
||||
|
||||
if ($hadHtml) {
|
||||
$this->line(" [{$table}#{$row->id}] Stripped HTML from content.");
|
||||
Log::info("skinbase:sanitize-content stripped HTML from {$table}#{$row->id}");
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$update = [$writeRawCol => $stripped];
|
||||
|
||||
if ($writeRenderedCol) {
|
||||
$update[$writeRenderedCol] = $rendered;
|
||||
}
|
||||
|
||||
DB::table($table)->where('id', $row->id)->update($update);
|
||||
}
|
||||
|
||||
// Also populate rendered_content for rows that have raw_content but no rendered_content
|
||||
if ($writeRenderedCol && ! $dryRun) {
|
||||
DB::table($table)
|
||||
->whereNotNull($writeRawCol)
|
||||
->whereNull($writeRenderedCol)
|
||||
->orderBy('id')
|
||||
->chunk(200, function ($missing) use ($table, $writeRawCol, $writeRenderedCol) {
|
||||
foreach ($missing as $row) {
|
||||
$rendered = ContentSanitizer::render($row->$writeRawCol ?? '');
|
||||
DB::table($table)->where('id', $row->id)->update([
|
||||
$writeRenderedCol => $rendered,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return [$totalModified, $totalRows];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user