added support for legacy tickets

This commit is contained in:
Mark Gregory
2026-09-28 10:47:18 +01:00
parent 36a4353269
commit 5c51cc4c73
17 changed files with 444 additions and 1 deletions
+1
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
return [
'route_prefix' => env('HELPDESK_ROUTE_PREFIX', 'admin/helpdesk'),
'middleware' => ['web', 'auth'],
'legacy_connection' => env('HELPDESK_LEGACY_DB_CONNECTION', config('database.default')),
'disk' => env('HELPDESK_ATTACHMENTS_DISK', 'local'),
'max_upload_kb' => (int) env('HELPDESK_MAX_UPLOAD_KB', 10240),
@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Console;
use Illuminate\Console\Command;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\Helpdesk\Models\LegacyCategory;
use Modules\Helpdesk\Models\LegacyComment;
use Modules\Helpdesk\Models\LegacyTicket;
use Throwable;
final class ImportLegacyHelpdeskCommand extends Command
{
protected $signature = 'helpdesk:import-legacy {--connection= : Laravel database connection containing the old tickets tables} {--fresh : Clear previously imported legacy records first}';
protected $description = 'Import the old Helpdesk tickets and categories into the read-only Legacy System';
public function handle(): int
{
$connectionName = (string) ($this->option('connection') ?: config('helpdesk.legacy_connection', config('database.default')));
$source = DB::connection($connectionName);
$schema = Schema::connection($connectionName);
foreach (['tickets', 'tickets_categories'] as $table) {
if (! $schema->hasTable($table)) {
$this->error("Source table {$table} was not found on connection [{$connectionName}].");
return self::FAILURE;
}
}
if ($this->option('fresh')) {
LegacyComment::query()->delete();
LegacyTicket::query()->delete();
LegacyCategory::query()->delete();
}
$this->importCategories($source);
$this->importTickets($source, $schema);
$this->importComments($source, $schema);
$this->refreshCategoryCounts();
$this->info(sprintf('Legacy import complete: %d tickets, %d categories, %d comments.', LegacyTicket::count(), LegacyCategory::count(), LegacyComment::count()));
return self::SUCCESS;
}
private function importCategories(ConnectionInterface $source): void
{
$source->table('tickets_categories')->orderBy('id')->chunk(500, function ($rows): void {
foreach ($rows as $row) {
LegacyCategory::updateOrCreate(['legacy_id' => $row->id], ['name' => (string) $row->name, 'color' => $row->color ?? null]);
}
});
}
private function importTickets(ConnectionInterface $source, $schema): void
{
$columns = collect($schema->getColumnListing('tickets'));
$lookups = [
'category' => $this->lookup($source, $schema, 'tickets_categories'),
'status' => $this->lookup($source, $schema, 'tickets_statuses'),
'priority' => $this->lookup($source, $schema, 'tickets_priorities'),
'project' => $this->lookup($source, $schema, 'projects'),
'user' => $this->lookup($source, $schema, 'users'),
];
$source->table('tickets')->orderBy('id')->chunk(250, function ($rows) use ($columns, $lookups): void {
foreach ($rows as $row) {
$data = (array) $row;
LegacyTicket::updateOrCreate(['legacy_id' => $row->id], [
'reference' => $data['reference'] ?? null,
'subject' => (string) ($data['subject'] ?? 'Legacy ticket #'.$row->id),
'content' => $data['content'] ?? null,
'html_content' => $data['htmlcontent'] ?? null,
'legacy_category_id' => $data['category_id'] ?? null,
'category_name' => $lookups['category'][$data['category_id'] ?? 0] ?? null,
'status_name' => $lookups['status'][$data['status_id'] ?? 0] ?? null,
'priority_name' => $lookups['priority'][$data['priority_id'] ?? 0] ?? null,
'requester_name' => $lookups['user'][$data['user_id'] ?? 0] ?? null,
'agent_name' => $lookups['user'][$data['agent_id'] ?? 0] ?? null,
'project_name' => $lookups['project'][$data['project_id'] ?? 0] ?? null,
'legacy_created_at' => $data['created_at'] ?? null,
'legacy_updated_at' => $data['updated_at'] ?? null,
'completed_at' => $data['completed_at'] ?? null,
'archived_at' => $data['archived_at'] ?? null,
'legacy_data' => $data,
]);
}
});
}
private function importComments(ConnectionInterface $source, $schema): void
{
if (! $schema->hasTable('tickets_comments')) return;
$users = $this->lookup($source, $schema, 'users');
$source->table('tickets_comments')->orderBy('id')->chunk(500, function ($rows) use ($users): void {
foreach ($rows as $row) {
$data = (array) $row;
LegacyComment::updateOrCreate(['legacy_id' => $row->id], [
'legacy_ticket_id' => $data['ticket_id'],
'author_name' => $users[$data['user_id'] ?? 0] ?? null,
'content' => $data['content'] ?? null,
'html_content' => $data['htmlcontent'] ?? null,
'legacy_created_at' => $data['created_at'] ?? null,
'legacy_updated_at' => $data['updated_at'] ?? null,
]);
}
});
}
private function lookup(ConnectionInterface $source, $schema, string $table): array
{
if (! $schema->hasTable($table)) return [];
$columns = $schema->getColumnListing($table);
$nameColumn = in_array('name', $columns, true) ? 'name' : (in_array('username', $columns, true) ? 'username' : null);
if ($nameColumn === null) return [];
try { return $source->table($table)->pluck($nameColumn, 'id')->map(fn ($v) => (string) $v)->all(); } catch (Throwable) { return []; }
}
private function refreshCategoryCounts(): void
{
LegacyCategory::query()->each(function (LegacyCategory $category): void {
$category->update(['ticket_count' => LegacyTicket::query()->where('legacy_category_id', $category->legacy_id)->count()]);
});
}
}
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('helpdesk_legacy_categories', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('legacy_id')->unique();
$table->string('name');
$table->string('color', 32)->nullable();
$table->unsignedInteger('ticket_count')->default(0);
$table->timestamps();
});
Schema::create('helpdesk_legacy_tickets', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('legacy_id')->unique();
$table->string('reference')->nullable()->index();
$table->string('subject');
$table->longText('content')->nullable();
$table->longText('html_content')->nullable();
$table->unsignedBigInteger('legacy_category_id')->nullable()->index();
$table->string('category_name')->nullable();
$table->string('status_name')->nullable();
$table->string('priority_name')->nullable();
$table->string('requester_name')->nullable();
$table->string('agent_name')->nullable();
$table->string('project_name')->nullable();
$table->timestamp('legacy_created_at')->nullable()->index();
$table->timestamp('legacy_updated_at')->nullable();
$table->timestamp('completed_at')->nullable();
$table->timestamp('archived_at')->nullable();
$table->json('legacy_data')->nullable();
$table->timestamps();
});
Schema::create('helpdesk_legacy_comments', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('legacy_id')->unique();
$table->unsignedBigInteger('legacy_ticket_id')->index();
$table->string('author_name')->nullable();
$table->longText('content')->nullable();
$table->longText('html_content')->nullable();
$table->timestamp('legacy_created_at')->nullable();
$table->timestamp('legacy_updated_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('helpdesk_legacy_comments');
Schema::dropIfExists('helpdesk_legacy_tickets');
Schema::dropIfExists('helpdesk_legacy_categories');
}
};
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Http\Controllers;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
use Modules\Helpdesk\Models\LegacyCategory;
use Modules\Helpdesk\Models\LegacyTicket;
final class LegacySystemController
{
public function tickets(Request $request): View
{
$search = trim((string) $request->query('search', ''));
$category = $request->integer('category');
$tickets = LegacyTicket::query()
->when($search !== '', fn ($query) => $query->where(function ($q) use ($search): void {
$q->where('reference', 'like', "%{$search}%")
->orWhere('subject', 'like', "%{$search}%")
->orWhere('requester_name', 'like', "%{$search}%");
}))
->when($category > 0, fn ($query) => $query->where('legacy_category_id', $category))
->orderByDesc('legacy_created_at')
->orderByDesc('legacy_id')
->paginate(30)
->withQueryString();
return view('helpdesk::legacy.tickets', [
'tickets' => $tickets,
'categories' => LegacyCategory::query()->orderBy('name')->get(),
'search' => $search,
'selectedCategory' => $category,
]);
}
public function show(LegacyTicket $ticket): View
{
$ticket->load('comments');
return view('helpdesk::legacy.show', compact('ticket'));
}
public function categories(): View
{
return view('helpdesk::legacy.categories', [
'categories' => LegacyCategory::query()->orderBy('name')->paginate(50),
]);
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Models;
use Illuminate\Database\Eloquent\Model;
final class LegacyCategory extends Model
{
protected $table = 'helpdesk_legacy_categories';
protected $guarded = [];
}
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Models;
use Illuminate\Database\Eloquent\Model;
final class LegacyComment extends Model
{
protected $table = 'helpdesk_legacy_comments';
protected $guarded = [];
protected $casts = ['legacy_created_at' => 'datetime', 'legacy_updated_at' => 'datetime'];
}
+20
View File
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
final class LegacyTicket extends Model
{
protected $table = 'helpdesk_legacy_tickets';
protected $guarded = [];
protected $casts = ['legacy_data' => 'array', 'legacy_created_at' => 'datetime', 'legacy_updated_at' => 'datetime', 'completed_at' => 'datetime', 'archived_at' => 'datetime'];
public function comments(): HasMany
{
return $this->hasMany(LegacyComment::class, 'legacy_ticket_id', 'legacy_id')->orderBy('legacy_created_at');
}
}
@@ -0,0 +1,6 @@
<x-layouts::app :title="__('Legacy Categories')">
<div class="hd-helpdesk-surface hd-mobile-ready space-y-6">
<div class="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900"><div class="bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-6 py-7 text-white"><p class="text-xs font-semibold uppercase tracking-[0.28em] text-blue-200">Legacy System</p><h1 class="mt-2 text-3xl font-semibold tracking-tight">Legacy Categories</h1><p class="mt-2 text-sm text-blue-100/90">Categories retained from the previous Helpdesk.</p></div></div>
<div class="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900"><table class="w-full text-left text-sm"><thead class="border-b border-zinc-200 bg-zinc-50 text-xs uppercase text-zinc-500 dark:border-zinc-700 dark:bg-zinc-800/60"><tr><th class="px-5 py-3">Category</th><th class="px-5 py-3">Legacy ID</th><th class="px-5 py-3 text-right">Tickets</th></tr></thead><tbody class="divide-y divide-zinc-200 dark:divide-zinc-800">@forelse($categories as $category)<tr><td class="px-5 py-4"><a class="font-semibold text-blue-600 hover:underline" href="{{ route('helpdesk.legacy.tickets', ['category' => $category->legacy_id]) }}" wire:navigate>{{ $category->name }}</a></td><td class="px-5 py-4 text-zinc-500">{{ $category->legacy_id }}</td><td class="px-5 py-4 text-right">{{ $category->ticket_count }}</td></tr>@empty<tr><td colspan="3" class="px-5 py-10 text-center text-zinc-500">No legacy categories have been imported.</td></tr>@endforelse</tbody></table>@if($categories->hasPages())<div class="border-t border-zinc-200 p-4 dark:border-zinc-700">{{ $categories->links() }}</div>@endif</div>
</div>
</x-layouts::app>
@@ -0,0 +1,6 @@
<x-layouts::app :title="$ticket->reference ?: 'Legacy Ticket #'.$ticket->legacy_id">
<div class="hd-helpdesk-surface hd-mobile-ready space-y-6">
<div class="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900"><div class="bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-6 py-7 text-white"><div class="flex items-start justify-between gap-4"><div><p class="text-xs font-semibold uppercase tracking-[0.28em] text-blue-200">Legacy System · Read only</p><h1 class="mt-2 text-3xl font-semibold tracking-tight">{{ $ticket->subject }}</h1><p class="mt-2 text-sm text-blue-100/90">{{ $ticket->reference ?: 'Legacy ticket #'.$ticket->legacy_id }}</p></div><a href="{{ route('helpdesk.legacy.tickets') }}" wire:navigate class="hd-btn hd-btn-blue"><i class="fa-solid fa-arrow-left"></i><span>Legacy Tickets</span></a></div></div></div>
<div class="grid gap-6 xl:grid-cols-[1fr_320px]"><div class="space-y-6"><section class="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-900"><h2 class="mb-4 text-lg font-semibold">Original request</h2><div class="prose max-w-none dark:prose-invert">{!! $ticket->html_content ?: nl2br(e($ticket->content ?: 'No content recorded.')) !!}</div></section>@if($ticket->comments->isNotEmpty())<section class="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-900"><h2 class="mb-4 text-lg font-semibold">Comments</h2><div class="space-y-4">@foreach($ticket->comments as $comment)<article class="rounded-xl border border-zinc-200 p-4 dark:border-zinc-700"><div class="mb-2 flex justify-between text-xs text-zinc-500"><strong>{{ $comment->author_name ?: 'Legacy user' }}</strong><span>{{ $comment->legacy_created_at?->format('d M Y H:i') }}</span></div><div class="prose max-w-none text-sm dark:prose-invert">{!! $comment->html_content ?: nl2br(e($comment->content ?: '')) !!}</div></article>@endforeach</div></section>@endif</div><aside><div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900"><h2 class="mb-4 font-semibold">Legacy details</h2><dl class="space-y-3 text-sm">@foreach(['Category'=>$ticket->category_name,'Status'=>$ticket->status_name,'Priority'=>$ticket->priority_name,'Requester'=>$ticket->requester_name,'Agent'=>$ticket->agent_name,'Project'=>$ticket->project_name] as $label=>$value)<div><dt class="text-xs uppercase tracking-wide text-zinc-500">{{ $label }}</dt><dd class="mt-1 font-medium">{{ $value ?: '—' }}</dd></div>@endforeach<div><dt class="text-xs uppercase tracking-wide text-zinc-500">Created</dt><dd class="mt-1 font-medium">{{ $ticket->legacy_created_at?->format('d M Y H:i') ?: '—' }}</dd></div></dl></div></aside></div>
</div>
</x-layouts::app>
@@ -0,0 +1,78 @@
<x-layouts::app :title="__('Legacy Tickets')">
<div class="hd-helpdesk-surface hd-mobile-ready space-y-6">
<div class="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-6 py-7 text-white">
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-blue-200">Legacy System</p>
<h1 class="mt-2 text-3xl font-semibold tracking-tight">Legacy Tickets</h1>
<p class="mt-2 text-sm text-blue-100/90">Historical tickets from the previous Helpdesk. These records are read-only.</p>
</div>
</div>
<div class="rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<form class="grid gap-3 md:grid-cols-[1fr_260px_auto]" method="GET">
<input class="hd-ticket-search" type="search" name="search" value="{{ $search }}" placeholder="Search reference, subject or requester">
<select class="hd-select" name="category"><option value="">All legacy categories</option>@foreach($categories as $category)<option value="{{ $category->legacy_id }}" @selected($selectedCategory === $category->legacy_id)>{{ $category->name }}</option>@endforeach</select>
<button class="hd-btn hd-btn-blue" type="submit"><i class="fa-solid fa-magnifying-glass"></i><span>Search</span></button>
</form>
</div>
<div class="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="overflow-x-auto"><table class="w-full text-left text-sm"><thead class="border-b border-zinc-200 bg-zinc-50 text-xs uppercase text-zinc-500 dark:border-zinc-700 dark:bg-zinc-800/60"><tr><th class="px-5 py-3">Reference</th><th class="px-5 py-3">Subject</th><th class="px-5 py-3">Category</th><th class="px-5 py-3">Status</th><th class="px-5 py-3">Requester</th><th class="px-5 py-3">Created</th></tr></thead><tbody class="divide-y divide-zinc-200 dark:divide-zinc-800">@forelse($tickets as $ticket)
<tr class="transition-colors dark:hover:bg-zinc-800/30">
<td class="px-5 py-4"><a class="font-semibold text-blue-600 hover:underline" href="{{ route('helpdesk.legacy.tickets.show', $ticket) }}" wire:navigate>{{ $ticket->reference ?: '#'.$ticket->legacy_id }}</a></td><td class="px-5 py-4 font-medium">{{ $ticket->subject }}</td><td class="px-5 py-4">{{ $ticket->category_name ?: '—' }}</td><td class="px-5 py-4">{{ $ticket->status_name ?: '—' }}</td><td class="px-5 py-4">{{ $ticket->requester_name ?: '—' }}</td><td class="px-5 py-4 text-zinc-500">{{ $ticket->legacy_created_at?->format('d M Y H:i') ?: '—' }}</td></tr>@empty<tr><td class="px-5 py-10 text-center text-zinc-500" colspan="6">No legacy tickets have been imported.</td></tr>@endforelse</tbody></table></div>
@if($tickets->hasPages())
<div class="flex items-center justify-between border-t border-zinc-800 px-5 py-4">
{{-- Previous --}}
@if($tickets->onFirstPage())
<span
class="inline-flex cursor-not-allowed items-center rounded-lg border border-zinc-700 bg-zinc-900 px-4 py-2 text-sm font-medium text-zinc-600"
>
<i class="fa-solid fa-chevron-left mr-2 text-xs"></i>
Previous
</span>
@else
<a
href="{{ $tickets->previousPageUrl() }}"
class="inline-flex items-center rounded-lg border border-zinc-700 bg-zinc-900 px-4 py-2 text-sm font-medium text-zinc-300 transition hover:border-zinc-600 hover:bg-zinc-800 hover:text-white"
wire:navigate
>
<i class="fa-solid fa-chevron-left mr-2 text-xs"></i>
Previous
</a>
@endif
{{-- Page information --}}
<div class="text-sm text-zinc-500">
Page
<span class="font-semibold text-zinc-300">
{{ $tickets->currentPage() }}
</span>
of
<span class="font-semibold text-zinc-300">
{{ $tickets->lastPage() }}
</span>
</div>
{{-- Next --}}
@if($tickets->hasMorePages())
<a
href="{{ $tickets->nextPageUrl() }}"
class="inline-flex items-center rounded-lg border border-zinc-700 bg-zinc-900 px-4 py-2 text-sm font-medium text-zinc-300 transition hover:border-zinc-600 hover:bg-zinc-800 hover:text-white"
wire:navigate
>
Next
<i class="fa-solid fa-chevron-right ml-2 text-xs"></i>
</a>
@else
<span
class="inline-flex cursor-not-allowed items-center rounded-lg border border-zinc-700 bg-zinc-900 px-4 py-2 text-sm font-medium text-zinc-600"
>
Next
<i class="fa-solid fa-chevron-right ml-2 text-xs"></i>
</span>
@endif
</div>
@endif
</div>
</div>
</x-layouts::app>
+5
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
use Illuminate\Support\Facades\Route;
use Modules\Helpdesk\Http\Controllers\AttachmentDownloadController;
use Modules\Helpdesk\Http\Controllers\HelpdeskController;
use Modules\Helpdesk\Http\Controllers\LegacySystemController;
use Modules\Helpdesk\Http\Controllers\TicketAttachmentStoreController;
use Modules\Helpdesk\Http\Controllers\TicketBoardController;
use Modules\Helpdesk\Http\Controllers\TicketCommentDeleteController;
@@ -28,6 +29,10 @@ Route::get('/', HelpdeskController::class)->name('index');
Route::get('/settings', SettingsManager::class)->name('settings');
Route::get('/kanban', KanbanBoard::class)->name('kanban');
Route::get('/legacy/tickets', [LegacySystemController::class, 'tickets'])->name('legacy.tickets');
Route::get('/legacy/tickets/{ticket}', [LegacySystemController::class, 'show'])->name('legacy.tickets.show');
Route::get('/legacy/categories', [LegacySystemController::class, 'categories'])->name('legacy.categories');
Route::get('/tickets', TicketIndex::class)->name('tickets.index');
Route::get('/tickets/archived', ArchivedTicketIndex::class)->name('tickets.archived');
Route::post('/tickets', TicketStoreController::class)->name('tickets.store');