pases 5 ticket updates
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -22,5 +22,5 @@
|
||||
"Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider"
|
||||
],
|
||||
"files": [],
|
||||
"version": "1.9.40"
|
||||
"version": "1.9.41"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user