diff --git a/Modules/Helpdesk/Livewire/TicketCreate.php b/Modules/Helpdesk/Livewire/TicketCreate.php index 33b523aa..6a2c620f 100644 --- a/Modules/Helpdesk/Livewire/TicketCreate.php +++ b/Modules/Helpdesk/Livewire/TicketCreate.php @@ -240,6 +240,7 @@ final class TicketCreate extends Component private function categoryOptions(): mixed { return Category::query() + ->with('agents:id') ->when($this->hasDefaultColumn('helpdesk_categories'), fn ($query) => $query->orderByDesc('is_default')) ->orderBy('name') ->get(); diff --git a/Modules/Helpdesk/Livewire/TicketEdit.php b/Modules/Helpdesk/Livewire/TicketEdit.php index d4ccaf7a..b40a20c9 100644 --- a/Modules/Helpdesk/Livewire/TicketEdit.php +++ b/Modules/Helpdesk/Livewire/TicketEdit.php @@ -198,7 +198,7 @@ final class TicketEdit extends Component 'boards' => KanbanBoard::query()->with('owner:id,name,email')->orderByDesc('is_default')->orderBy('sort_order')->orderBy('name')->get(), 'statuses' => Status::query()->orderBy('sort_order')->orderBy('name')->get(), 'priorities' => Priority::query()->orderBy('sort_order')->orderBy('name')->get(), - 'categories' => Category::query()->orderBy('name')->get(), + 'categories' => Category::query()->with('agents:id')->orderBy('name')->get(), 'companies' => Schema::hasTable('companies') ? DB::table('companies')->select(['id', 'name'])->orderBy('name')->get() : collect(), 'teams' => Team::query()->whereNull('archived_at')->orderBy('name')->get(), 'projects' => Project::query()->whereNull('archived_at')->orderBy('title')->get(), diff --git a/Modules/Helpdesk/Services/AgentAssignmentService.php b/Modules/Helpdesk/Services/AgentAssignmentService.php index ae6eca15..0fc7b3c6 100644 --- a/Modules/Helpdesk/Services/AgentAssignmentService.php +++ b/Modules/Helpdesk/Services/AgentAssignmentService.php @@ -30,8 +30,8 @@ final class AgentAssignmentService ); if ($categoryId !== null) { - $category = Category::query()->find($categoryId); - $agentIds = $category?->agents()->pluck('users.id')->all() ?? []; + $category = Category::query()->with('agents:id')->find($categoryId); + $agentIds = $category?->agents->pluck('id')->all() ?? []; if ($agentIds !== []) { $query->whereIn('id', $agentIds); diff --git a/Modules/Helpdesk/Services/TicketReferenceService.php b/Modules/Helpdesk/Services/TicketReferenceService.php index bc790c4d..3559285f 100644 --- a/Modules/Helpdesk/Services/TicketReferenceService.php +++ b/Modules/Helpdesk/Services/TicketReferenceService.php @@ -30,16 +30,23 @@ final class TicketReferenceService $this->lockReferenceSeries($prefix); - $lastReference = Ticket::query() + /** @var list $existingReferences */ + $existingReferences = Ticket::query() + ->withoutGlobalScopes() + ->withTrashed() ->where('reference', 'like', $prefix.'%') - ->orderByDesc('reference') - ->value('reference'); + ->pluck('reference') + ->filter(static fn (mixed $reference): bool => is_string($reference) && preg_match('/^'.preg_quote($prefix, '/').'\d+$/', $reference) === 1) + ->values() + ->all(); - $next = $lastReference === null - ? 1 - : ((int) substr((string) $lastReference, -5)) + 1; + $highestSuffix = 0; - return $prefix.str_pad((string) $next, 5, '0', STR_PAD_LEFT); + foreach ($existingReferences as $existingReference) { + $highestSuffix = max($highestSuffix, (int) substr($existingReference, strlen($prefix))); + } + + return $prefix.str_pad((string) ($highestSuffix + 1), 5, '0', STR_PAD_LEFT); } /** diff --git a/Modules/Helpdesk/Tests/Feature/TicketCreateEditorSyncFilesTest.php b/Modules/Helpdesk/Tests/Feature/TicketCreateEditorSyncFilesTest.php new file mode 100644 index 00000000..79d2d8d2 --- /dev/null +++ b/Modules/Helpdesk/Tests/Feature/TicketCreateEditorSyncFilesTest.php @@ -0,0 +1,24 @@ +assertIsString($view); + $this->assertStringContainsString('wire:model.live="body"', $view); + $this->assertStringContainsString("window.Livewire.find(livewireId).set('body', value, false)", $view); + $this->assertStringContainsString("form.addEventListener('submit', syncBody, { capture: true })", $view); + $this->assertStringContainsString("document.addEventListener('livewire:navigated', initialiseTicketCreateEditor)", $view); + $this->assertStringContainsString("editor.addEventListener('paste', () => window.setTimeout(syncBody, 0))", $view); + } +} diff --git a/Modules/Helpdesk/Tests/Feature/TicketFirstResponseSlaFilesTest.php b/Modules/Helpdesk/Tests/Feature/TicketFirstResponseSlaFilesTest.php new file mode 100644 index 00000000..637e68fb --- /dev/null +++ b/Modules/Helpdesk/Tests/Feature/TicketFirstResponseSlaFilesTest.php @@ -0,0 +1,60 @@ +user_id !== (int) $actor->id', $service); + } + + /** + * Existing tickets with public comments but a missing first_response_at are repaired on display. + */ + public function test_ticket_show_synchronises_first_response_from_existing_public_comments(): void + { + $componentPath = dirname(__DIR__, 2).'/Livewire/TicketShow.php'; + $servicePath = dirname(__DIR__, 2).'/Services/SlaService.php'; + + $component = file_get_contents($componentPath); + $service = file_get_contents($servicePath); + + self::assertIsString($component); + self::assertIsString($service); + self::assertStringContainsString('syncFirstResponseFromComments($ticket)', $component); + self::assertStringContainsString("where('is_internal', false)", $service); + self::assertStringContainsString("whereNull('tombstoned_at')", $service); + } + + /** + * The SLA card should show an accurate response state instead of Awaiting response after public replies exist. + */ + public function test_sla_card_has_clear_responded_and_awaiting_public_reply_states(): void + { + $viewPath = dirname(__DIR__, 2).'/Resources/views/livewire/ticket-show.blade.php'; + + $view = file_get_contents($viewPath); + + self::assertIsString($view); + self::assertStringContainsString('Responded', $view); + self::assertStringContainsString('Awaiting public reply', $view); + self::assertStringNotContainsString("?? 'Awaiting response'", $view); + } +} diff --git a/Modules/Helpdesk/Tests/Feature/TicketPublicResponseVisibilityFilesTest.php b/Modules/Helpdesk/Tests/Feature/TicketPublicResponseVisibilityFilesTest.php new file mode 100644 index 00000000..2987ea46 --- /dev/null +++ b/Modules/Helpdesk/Tests/Feature/TicketPublicResponseVisibilityFilesTest.php @@ -0,0 +1,50 @@ +Public', $partial); + self::assertStringContainsString('>Internal', $partial); + } +} diff --git a/Modules/Helpdesk/Tests/Feature/TicketSlaOperationalDetailFilesTest.php b/Modules/Helpdesk/Tests/Feature/TicketSlaOperationalDetailFilesTest.php new file mode 100644 index 00000000..c6eae737 --- /dev/null +++ b/Modules/Helpdesk/Tests/Feature/TicketSlaOperationalDetailFilesTest.php @@ -0,0 +1,89 @@ +slaEvents->take(5)', $view); + self::assertStringContainsString('actor_name', $service); + self::assertStringContainsString('response_seconds', $service); + self::assertStringContainsString('resolved_seconds', $service); + } + + /** + * Public reply counts must include threaded public replies, not only root comments. + */ + public function test_public_reply_count_uses_all_ticket_comments(): void + { + $componentPath = dirname(__DIR__, 2).'/Livewire/TicketShow.php'; + + $component = file_get_contents($componentPath); + + self::assertIsString($component); + self::assertStringContainsString('Comment::query()', $component); + self::assertStringContainsString("where('ticket_id', \$ticket->id)", $component); + self::assertStringContainsString("\$comments->where('is_internal', false)->count()", $component); + self::assertStringNotContainsString('$ticket->comments() + ->active()', $component); + } +} diff --git a/Modules/Helpdesk/Tests/Feature/TicketTimerButtonSizeFilesTest.php b/Modules/Helpdesk/Tests/Feature/TicketTimerButtonSizeFilesTest.php new file mode 100644 index 00000000..02769d55 --- /dev/null +++ b/Modules/Helpdesk/Tests/Feature/TicketTimerButtonSizeFilesTest.php @@ -0,0 +1,46 @@ +format('Y').'-00042', $reference); } + + public function test_it_does_not_reuse_soft_deleted_ticket_references(): void + { + $year = now()->format('Y'); + $ticket = $this->ticket(['reference' => 'HD-'.$year.'-00002']); + + $ticket->delete(); + + $reference = app(TicketReferenceService::class)->next(); + + self::assertSame('HD-'.$year.'-00003', $reference); + } + public function test_it_uses_the_highest_reference_suffix_instead_of_latest_database_id(): void { $year = now()->format('Y'); diff --git a/Modules/Helpdesk/changelog.yaml b/Modules/Helpdesk/changelog.yaml index 1d079397..3904ad16 100644 --- a/Modules/Helpdesk/changelog.yaml +++ b/Modules/Helpdesk/changelog.yaml @@ -116,3 +116,14 @@ - Added retry handling around ticket creation so reference unique-index collisions do not produce a user-facing 500 error. - Added regression coverage for out-of-order ticket references so lower references cannot be reused accidentally. - Preserved append-only changelog history. + +- version: 1.9.41 + date: 2026-06-23 + type: bugfix + summary: Fix Helpdesk ticket reference collisions caused by soft-deleted references and remove category agent N+1 queries. + changes: + - Updated ticket reference generation to include soft-deleted tickets and bypass global scopes when calculating the highest yearly reference suffix. + - Preserved the PostgreSQL advisory transaction lock and collision retry behaviour while preventing deleted references from being reused against the unique index. + - Eager-loaded category agents where categories are used by ticket creation/editing and agent assignment logic to remove the detected N+1 query warning. + - Added regression coverage to ensure soft-deleted ticket references are never reused. + - Preserved append-only changelog history. diff --git a/Modules/Helpdesk/module.json b/Modules/Helpdesk/module.json index 6af09ad3..928d1725 100644 --- a/Modules/Helpdesk/module.json +++ b/Modules/Helpdesk/module.json @@ -22,5 +22,5 @@ "Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider" ], "files": [], - "version": "1.9.40" + "version": "1.9.41" }