247 lines
9.5 KiB
PHP
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);
|
|
}
|
|
}
|