messages implemented

This commit is contained in:
2026-02-26 21:12:32 +01:00
parent d0aefc5ddc
commit 15b7b77d20
168 changed files with 14728 additions and 6786 deletions

View File

@@ -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,
]
);

View 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}");
}
}

View 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);
}
}
}

View 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);
}
}

View 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];
}
}

View 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;
}
}

View 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];
}
}