started to add indexes and fixed N+1 issue

This commit is contained in:
Mark Gregory
2026-06-12 13:52:45 +01:00
parent 8ec2c33c47
commit 4dcee08d71
7 changed files with 136 additions and 25 deletions
@@ -0,0 +1,65 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class HelpdeskPerformanceIndexes extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up(): void
{
Schema::table('helpdesk_tickets', function (Blueprint $table): void {
$table->index(['company_id', 'status_id']);
$table->index(['company_id', 'priority_id']);
$table->index(['company_id', 'category_id']);
$table->index(['company_id', 'project_id']);
$table->index(['company_id', 'team_id']);
$table->index(['agent_id', 'completed_at']);
$table->index(['user_id', 'created_at']);
$table->index(['status_id', 'created_at']);
$table->index(['last_activity_at']);
$table->index(['due_at']);
$table->index(['reference']);
});
Schema::table('helpdesk_comments', function (Blueprint $table): void {
$table->index(['ticket_id', 'parent_id']);
$table->index(['ticket_id', 'created_at']);
$table->index(['parent_id', 'created_at']);
$table->index(['user_id', 'created_at']);
});
Schema::table('helpdesk_attachments', function (Blueprint $table): void {
$table->index(['ticket_id']);
$table->index(['comment_id']);
$table->index(['user_id']);
});
Schema::table('helpdesk_projects', function (Blueprint $table): void {
$table->index(['company_id', 'archived_at']);
});
Schema::table('helpdesk_teams', function (Blueprint $table): void {
$table->index(['company_id', 'archived_at']);
});
Schema::table('helpdesk_category_user', function (Blueprint $table): void {
$table->index(['category_id', 'user_id']);
$table->index(['user_id', 'category_id']);
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
//
}
}
+44 -14
View File
@@ -4,12 +4,15 @@ declare(strict_types=1);
namespace Modules\Helpdesk\Livewire; namespace Modules\Helpdesk\Livewire;
use App\Models\User;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Validate; use Livewire\Attributes\Validate;
use Livewire\Component; use Livewire\Component;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads; use Livewire\WithFileUploads;
use Modules\Helpdesk\Models\Comment;
use Modules\Helpdesk\Models\Priority; use Modules\Helpdesk\Models\Priority;
use Modules\Helpdesk\Models\Status; use Modules\Helpdesk\Models\Status;
use Modules\Helpdesk\Models\Ticket; use Modules\Helpdesk\Models\Ticket;
@@ -38,7 +41,6 @@ final class TicketShow extends Component
public string $replyBody = ''; public string $replyBody = '';
/** @return void */
public function mount(Ticket $ticket): void public function mount(Ticket $ticket): void
{ {
$this->authorize('view', $ticket); $this->authorize('view', $ticket);
@@ -47,28 +49,25 @@ final class TicketShow extends Component
$this->priority_id = $this->ticket->priority_id; $this->priority_id = $this->ticket->priority_id;
} }
/** @return void */
public function startReply(int $commentId): void public function startReply(int $commentId): void
{ {
$this->reply_parent_id = $commentId; $this->reply_parent_id = $commentId;
$this->replyBody = ''; $this->replyBody = '';
} }
/** @return void */
public function cancelReply(): void public function cancelReply(): void
{ {
$this->reply_parent_id = null; $this->reply_parent_id = null;
$this->replyBody = ''; $this->replyBody = '';
} }
/** @return void */
public function addComment(TicketService $tickets): void public function addComment(TicketService $tickets): void
{ {
$this->authorize('comment', $this->ticket); $this->authorize('comment', $this->ticket);
$this->validate([ $this->validate([
'commentBody' => ['required', 'string', 'min:2'], 'commentBody' => ['required', 'string', 'min:2'],
'attachments.*' => 'file|max:' . config('helpdesk.max_upload_kb') . '|mimes:' . implode(',', config('helpdesk.allowed_mimes')), 'attachments.*' => 'file|max:'.config('helpdesk.max_upload_kb').'|mimes:'.implode(',', config('helpdesk.allowed_mimes')),
]); ]);
$tickets->comment($this->ticket, Auth::user(), $this->commentBody, $this->internal, $this->attachments); $tickets->comment($this->ticket, Auth::user(), $this->commentBody, $this->internal, $this->attachments);
@@ -78,7 +77,6 @@ final class TicketShow extends Component
$this->dispatch('helpdesk-comment-added'); $this->dispatch('helpdesk-comment-added');
} }
/** @return void */
public function addReply(TicketService $tickets): void public function addReply(TicketService $tickets): void
{ {
$this->authorize('comment', $this->ticket); $this->authorize('comment', $this->ticket);
@@ -95,7 +93,6 @@ final class TicketShow extends Component
$this->dispatch('helpdesk-reply-added'); $this->dispatch('helpdesk-reply-added');
} }
/** @return void */
public function updateStatus(WorkflowNotificationService $workflow): void public function updateStatus(WorkflowNotificationService $workflow): void
{ {
$this->authorize('update', $this->ticket); $this->authorize('update', $this->ticket);
@@ -113,7 +110,6 @@ final class TicketShow extends Component
$this->dispatch('helpdesk-status-updated'); $this->dispatch('helpdesk-status-updated');
} }
/** @return void */
public function updatePriority(WorkflowNotificationService $workflow): void public function updatePriority(WorkflowNotificationService $workflow): void
{ {
$this->authorize('update', $this->ticket); $this->authorize('update', $this->ticket);
@@ -129,7 +125,6 @@ final class TicketShow extends Component
$this->dispatch('helpdesk-priority-updated'); $this->dispatch('helpdesk-priority-updated');
} }
/** @return View */
public function render(): View public function render(): View
{ {
return view('helpdesk::livewire.ticket-show', [ return view('helpdesk::livewire.ticket-show', [
@@ -138,13 +133,48 @@ final class TicketShow extends Component
])->layout('layouts.app'); ])->layout('layouts.app');
} }
/** @return Ticket */
private function freshTicket(Ticket $ticket): Ticket private function freshTicket(Ticket $ticket): Ticket
{ {
return $ticket->fresh([ $ticket = $ticket->fresh([
'owner', 'agent', 'status', 'priority', 'category', 'project', 'team', 'owner',
'comments.user', 'comments.attachments', 'comments.replies.user', 'comments.replies.attachments', 'comments.replies.replies.user', 'agent',
'attachments.user', 'gitCommits.repository', 'status',
'priority',
'category',
'project',
'team',
'attachments.user',
'gitCommits.repository',
]) ?? $ticket; ]) ?? $ticket;
$comments = Comment::query()
->where('ticket_id', $ticket->id)
->with(['user', 'attachments'])
->oldest()
->get();
$grouped = $comments->groupBy('parent_id');
$comments->each(function ($comment) use ($grouped): void {
$comment->setRelation('replies', $grouped->get($comment->id, collect()));
});
$ticket->setRelation('comments', $this->buildCommentTree($grouped));
return $ticket;
}
private function buildCommentTree($grouped, ?int $parentId = null)
{
return $grouped->get($parentId, collect())->map(function ($comment) use ($grouped) {
$comment->setRelation('replies', $this->buildCommentTree($grouped, $comment->id));
return $comment;
});
} }
} }
-3
View File
@@ -5,15 +5,12 @@ declare(strict_types=1);
namespace Modules\Helpdesk\Models; namespace Modules\Helpdesk\Models;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
final class Comment extends Model final class Comment extends Model
{ {
use HasFactory;
protected $table = 'helpdesk_comments'; protected $table = 'helpdesk_comments';
/** @var list<string> */ /** @var list<string> */
+3 -2
View File
@@ -20,7 +20,6 @@ use Illuminate\Database\Eloquent\SoftDeletes;
*/ */
final class Ticket extends Model final class Ticket extends Model
{ {
use HasFactory;
use SoftDeletes; use SoftDeletes;
protected $table = 'helpdesk_tickets'; protected $table = 'helpdesk_tickets';
@@ -86,7 +85,9 @@ final class Ticket extends Model
/** @return HasMany<Comment> */ /** @return HasMany<Comment> */
public function comments(): HasMany public function comments(): HasMany
{ {
return $this->hasMany(Comment::class, 'ticket_id')->whereNull('parent_id')->with(['user', 'attachments', 'replies.user', 'replies.attachments', 'replies.replies.user'])->oldest(); return $this->hasMany(Comment::class, 'ticket_id')
->whereNull('parent_id')
->oldest();
} }
/** @return HasMany<GitCommit> */ /** @return HasMany<GitCommit> */
@@ -1,3 +1,5 @@
import './bootstrap'; import './bootstrap';
import Sortable from 'sortablejs';
window.Sortable = Sortable;
console.info('Helpdesk module assets loaded'); console.info('Helpdesk module assets loaded');
@@ -2,12 +2,23 @@
<div class="space-y-3"> <div class="space-y-3">
@foreach ($comments as $comment) @foreach ($comments as $comment)
@php
$commentUser = $comment->relationLoaded('user') ? $comment->getRelation('user') : null;
$commentAttachments = $comment->relationLoaded('attachments') ? $comment->getRelation('attachments') : collect();
$commentReplies = $comment->relationLoaded('replies') ? $comment->getRelation('replies') : collect();
@endphp
<div class="rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900" style="margin-left: {{ min((int) $level, 4) * 1.25 }}rem"> <div class="rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900" style="margin-left: {{ min((int) $level, 4) * 1.25 }}rem">
<div class="flex items-start justify-between gap-3"> <div class="flex items-start justify-between gap-3">
<div> <div>
<div class="font-medium text-zinc-900 dark:text-zinc-100">{{ $comment->user?->name ?? 'Unknown user' }}</div> <div class="font-medium text-zinc-900 dark:text-zinc-100">
<div class="text-xs text-zinc-500">{{ $comment->created_at?->format('d M Y H:i') }}</div> {{ $commentUser?->name ?? 'Unknown user' }}
</div>
<div class="text-xs text-zinc-500">
{{ $comment->created_at?->format('d M Y H:i') }}
</div>
</div> </div>
@if ($comment->is_internal) @if ($comment->is_internal)
<flux:badge color="amber">Internal</flux:badge> <flux:badge color="amber">Internal</flux:badge>
@endif @endif
@@ -17,9 +28,9 @@
{!! $comment->body !!} {!! $comment->body !!}
</div> </div>
@if ($comment->attachments->isNotEmpty()) @if ($commentAttachments->isNotEmpty())
<div class="mt-3 flex flex-wrap gap-2"> <div class="mt-3 flex flex-wrap gap-2">
@foreach ($comment->attachments as $attachment) @foreach ($commentAttachments as $attachment)
<a href="{{ $attachment->url() }}" target="_blank" class="rounded-lg border border-zinc-200 px-3 py-1 text-xs text-zinc-600 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800"> <a href="{{ $attachment->url() }}" target="_blank" class="rounded-lg border border-zinc-200 px-3 py-1 text-xs text-zinc-600 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800">
{{ $attachment->original_name }} {{ $attachment->original_name }}
</a> </a>
@@ -34,6 +45,7 @@
@if (($reply_parent_id ?? null) === $comment->id) @if (($reply_parent_id ?? null) === $comment->id)
<form wire:submit="addReply" class="mt-4 rounded-xl border border-blue-200 bg-blue-50 p-4 dark:border-blue-900 dark:bg-blue-950/40"> <form wire:submit="addReply" class="mt-4 rounded-xl border border-blue-200 bg-blue-50 p-4 dark:border-blue-900 dark:bg-blue-950/40">
<x-helpdesk::html-editor wire:model="replyBody" label="Reply" rows="4" placeholder="Write a reply..." /> <x-helpdesk::html-editor wire:model="replyBody" label="Reply" rows="4" placeholder="Write a reply..." />
<div class="mt-4 flex justify-end gap-2"> <div class="mt-4 flex justify-end gap-2">
<flux:button type="button" wire:click="cancelReply" variant="ghost">Cancel</flux:button> <flux:button type="button" wire:click="cancelReply" variant="ghost">Cancel</flux:button>
<flux:button type="submit" variant="primary">Post reply</flux:button> <flux:button type="submit" variant="primary">Post reply</flux:button>
@@ -41,9 +53,12 @@
</form> </form>
@endif @endif
@if ($comment->replies->isNotEmpty()) @if ($commentReplies->isNotEmpty())
<div class="mt-4"> <div class="mt-4">
@include('helpdesk::partials.comment-thread', ['comments' => $comment->replies, 'level' => ((int) $level) + 1]) @include('helpdesk::partials.comment-thread', [
'comments' => $commentReplies,
'level' => ((int) $level) + 1,
])
</div> </div>
@endif @endif
</div> </div>
+1
View File
@@ -20,6 +20,7 @@
"flatpickr": "^4.6.13", "flatpickr": "^4.6.13",
"laravel-vite-plugin": "^3.1", "laravel-vite-plugin": "^3.1",
"sass-embedded": "^1.100.0", "sass-embedded": "^1.100.0",
"sortablejs": "^1.15.7",
"tailwindcss": "^4.0.7", "tailwindcss": "^4.0.7",
"vite": "^8.0.0" "vite": "^8.0.0"
}, },