Files
SkinbaseNova/app/Console/Commands/MigrateMessagesCommand.php
2026-02-26 21:12:32 +01:00

247 lines
9.5 KiB
PHP

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