pases 5 ticket updates

This commit is contained in:
Mark Gregory
2026-06-23 11:10:52 +01:00
parent 2266298b6c
commit 7c4a94b5ea
12 changed files with 312 additions and 11 deletions
@@ -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();
+1 -1
View File
@@ -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(),
@@ -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);
@@ -30,16 +30,23 @@ final class TicketReferenceService
$this->lockReferenceSeries($prefix);
$lastReference = Ticket::query()
/** @var list<string> $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);
}
/**
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\tests\Feature;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
final class TicketCreateEditorSyncFilesTest extends TestCase
{
#[Test]
public function ticket_create_editor_pushes_details_body_to_livewire_before_validation(): void
{
$view = file_get_contents(base_path('Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php'));
$this->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);
}
}
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use PHPUnit\Framework\TestCase;
final class TicketFirstResponseSlaFilesTest extends TestCase
{
/**
* Public replies must satisfy the first-response SLA instead of only incrementing the counter.
*/
public function test_public_response_updates_first_response_sla_state(): void
{
$servicePath = dirname(__DIR__, 2).'/Services/SlaService.php';
self::assertFileExists($servicePath);
$service = file_get_contents($servicePath);
self::assertIsString($service);
self::assertStringContainsString('public function recordPublicResponse(Ticket $ticket, User $actor): void', $service);
self::assertStringContainsString('markFirstResponse($ticket, $respondedAt, $actor)', $service);
self::assertStringNotContainsString('(int) $ticket->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);
}
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use PHPUnit\Framework\TestCase;
final class TicketPublicResponseVisibilityFilesTest extends TestCase
{
/**
* Ensure the main ticket response form has an explicit public/internal visibility choice.
*/
public function test_main_ticket_response_form_has_public_visibility_control(): void
{
$viewPath = dirname(__DIR__, 2).'/Resources/views/livewire/ticket-show.blade.php';
self::assertFileExists($viewPath);
$view = file_get_contents($viewPath);
self::assertIsString($view);
self::assertStringContainsString('Visibility', $view);
self::assertStringContainsString('Public reply', $view);
self::assertStringContainsString('Internal note', $view);
self::assertStringContainsString('name="internal" value="0"', $view);
self::assertStringContainsString('name="internal" value="1"', $view);
self::assertStringContainsString('counts as the first SLA response', $view);
}
/**
* Ensure threaded responses have the same public/internal visibility choice and visible badges.
*/
public function test_threaded_responses_have_public_visibility_control_and_badges(): void
{
$partialPath = dirname(__DIR__, 2).'/Resources/views/partials/comment-thread.blade.php';
self::assertFileExists($partialPath);
$partial = file_get_contents($partialPath);
self::assertIsString($partial);
self::assertStringContainsString('Public reply', $partial);
self::assertStringContainsString('Internal note', $partial);
self::assertStringContainsString('name="internal" value="0" checked', $partial);
self::assertStringContainsString('name="internal" value="1"', $partial);
self::assertStringContainsString('>Public</span>', $partial);
self::assertStringContainsString('>Internal</span>', $partial);
}
}
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use PHPUnit\Framework\TestCase;
final class TicketSlaOperationalDetailFilesTest extends TestCase
{
/**
* SLA cards must show operationally useful first-response detail.
*/
public function test_first_response_sla_card_shows_duration_responder_and_timestamp(): void
{
$componentPath = dirname(__DIR__, 2).'/Livewire/TicketShow.php';
$viewPath = dirname(__DIR__, 2).'/Resources/views/livewire/ticket-show.blade.php';
$component = file_get_contents($componentPath);
$view = file_get_contents($viewPath);
self::assertIsString($component);
self::assertIsString($view);
self::assertStringContainsString('firstResponseSlaDetail', $component);
self::assertStringContainsString('Responded in ', $component);
self::assertStringContainsString('firstResponderName', $component);
self::assertStringContainsString('Responder', $view);
self::assertStringContainsString('Responded at', $view);
}
/**
* SLA cards should use clear met, due-soon and breached states.
*/
public function test_sla_cards_have_colour_coded_state_helpers(): void
{
$componentPath = dirname(__DIR__, 2).'/Livewire/TicketShow.php';
$viewPath = dirname(__DIR__, 2).'/Resources/views/livewire/ticket-show.blade.php';
$component = file_get_contents($componentPath);
$view = file_get_contents($viewPath);
self::assertIsString($component);
self::assertIsString($view);
self::assertStringContainsString('firstResponseIsDueSoon', $component);
self::assertStringContainsString('resolutionIsDueSoon', $component);
self::assertStringContainsString('firstResponseSlaClasses', $view);
self::assertStringContainsString('resolutionSlaClasses', $view);
self::assertStringContainsString('border-amber-300', $component);
self::assertStringContainsString('border-red-300', $component);
self::assertStringContainsString('border-emerald-200', $component);
}
/**
* The ticket page should expose an SLA audit trail for support managers.
*/
public function test_sla_audit_trail_is_visible_on_ticket_page(): void
{
$servicePath = dirname(__DIR__, 2).'/Services/SlaService.php';
$viewPath = dirname(__DIR__, 2).'/Resources/views/livewire/ticket-show.blade.php';
$service = file_get_contents($servicePath);
$view = file_get_contents($viewPath);
self::assertIsString($service);
self::assertIsString($view);
self::assertStringContainsString('SLA audit trail', $view);
self::assertStringContainsString('$ticket->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);
}
}
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use PHPUnit\Framework\TestCase;
final class TicketTimerButtonSizeFilesTest extends TestCase
{
/**
* Ensure the inactive timer action remains readable, single-line, and does not reintroduce unused script files.
*/
public function test_start_timer_button_uses_readable_single_line_professional_classes(): void
{
$viewPath = dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-timer.blade.php';
self::assertFileExists($viewPath);
$view = file_get_contents($viewPath);
self::assertIsString($view);
self::assertStringContainsString('data-dusk="start-ticket-timer"', $view);
self::assertStringContainsString('min-w-[9.5rem]', $view);
self::assertStringContainsString('whitespace-nowrap rounded-md bg-blue-600 px-4 py-2 text-sm', $view);
self::assertStringNotContainsString('px-3 py-1.5 text-xs', $view);
self::assertStringNotContainsString('ticket-timer.js', $view);
}
/**
* Ensure the active timer action also remains readable and single-line.
*/
public function test_end_timer_button_uses_matching_readable_single_line_professional_classes(): void
{
$viewPath = dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-timer.blade.php';
self::assertFileExists($viewPath);
$view = file_get_contents($viewPath);
self::assertIsString($view);
self::assertStringContainsString('data-dusk="end-ticket-timer"', $view);
self::assertStringContainsString('min-w-[9.5rem] shrink-0 items-center justify-center whitespace-nowrap rounded-md bg-red-600 px-4 py-2 text-sm', $view);
self::assertStringNotContainsString('rounded-lg bg-red-600 px-4 py-2.5 text-sm', $view);
self::assertStringNotContainsString('ticket-timer.js', $view);
}
}
@@ -29,6 +29,19 @@ final class TicketReferenceServiceTest extends TestCase
self::assertSame('HD-'.now()->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');
+11
View File
@@ -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.
+1 -1
View File
@@ -22,5 +22,5 @@
"Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider"
],
"files": [],
"version": "1.9.40"
"version": "1.9.41"
}