diff --git a/Modules/Helpdesk/Http/Controllers/TicketControlsController.php b/Modules/Helpdesk/Http/Controllers/TicketControlsController.php index b3c6e60f..607b4100 100644 --- a/Modules/Helpdesk/Http/Controllers/TicketControlsController.php +++ b/Modules/Helpdesk/Http/Controllers/TicketControlsController.php @@ -16,6 +16,7 @@ use Illuminate\Validation\Rule; use Modules\Helpdesk\Models\Status; use Modules\Helpdesk\Models\Ticket; use Modules\Helpdesk\Services\SlaService; +use Modules\Helpdesk\Services\TicketService; use Modules\Helpdesk\Services\WorkflowNotificationService; final class TicketControlsController extends Controller @@ -64,7 +65,7 @@ final class TicketControlsController extends Controller /** * Persist the selected ticket status from the ticket detail controls. */ - public function updateStatus(Request $request, Ticket $ticket, WorkflowNotificationService $workflow, SlaService $sla): RedirectResponse + public function updateStatus(Request $request, Ticket $ticket, WorkflowNotificationService $workflow, SlaService $sla, TicketService $tickets): RedirectResponse { $this->hydrateUserRelations($ticket); @@ -72,10 +73,21 @@ final class TicketControlsController extends Controller $validated = $request->validate([ 'status_id' => ['required', 'integer', Rule::exists('helpdesk_statuses', 'id')], + 'closure_comment' => ['nullable', 'string', 'min:10', 'max:5000'], + 'closure_internal' => ['nullable', 'boolean'], ]); $status = Status::query()->findOrFail((int) $validated['status_id']); + if ($status->is_closed) { + validator($validated, [ + 'closure_comment' => ['required', 'string', 'min:10', 'max:5000'], + ], [ + 'closure_comment.required' => 'A closure comment is required before closing the ticket.', + 'closure_comment.min' => 'The closure comment must explain why the ticket is being closed.', + ])->validate(); + } + $this->updateTicket($ticket, [ 'status_id' => $status->id, 'completed_at' => $status->is_closed ? now() : null, @@ -85,12 +97,24 @@ final class TicketControlsController extends Controller $this->ensureBoardContainsStatus($ticket, (int) $status->id); $fresh = $this->freshTicketForAction($ticket); + $actor = $this->actor(); if ($status->is_closed) { + $tickets->comment( + $fresh, + $actor, + trim((string) $validated['closure_comment']), + (bool) ($validated['closure_internal'] ?? false), + [], + null, + 'Closure comment' + ); + + $fresh = $this->freshTicketForAction($ticket); $sla->recordClosure($fresh); } - $workflow->dispatch($fresh, $status->is_closed ? 'ticket.closed' : 'ticket.updated', $this->actor()); + $workflow->dispatch($fresh, $status->is_closed ? 'ticket.closed' : 'ticket.updated', $actor); return redirect() ->route('helpdesk.tickets.show', $fresh) diff --git a/Modules/Helpdesk/Livewire/TicketShow.php b/Modules/Helpdesk/Livewire/TicketShow.php index ea731326..abed51e6 100644 --- a/Modules/Helpdesk/Livewire/TicketShow.php +++ b/Modules/Helpdesk/Livewire/TicketShow.php @@ -54,6 +54,10 @@ final class TicketShow extends Component public ?int $agent_id = null; + public string $closureComment = ''; + + public bool $closureInternal = false; + public ?int $reply_parent_id = null; public string $replyBody = ''; @@ -144,7 +148,7 @@ final class TicketShow extends Component $this->ticket = $this->freshTicket($this->ticket); } - public function updateStatus(WorkflowNotificationService $workflow, SlaService $sla): void + public function updateStatus(WorkflowNotificationService $workflow, SlaService $sla, TicketService $tickets): void { $this->authorize('update', $this->ticket); @@ -157,6 +161,17 @@ final class TicketShow extends Component $status = Status::query()->findOrFail($statusId); + if ($status->is_closed) { + validator([ + 'closureComment' => $this->closureComment, + ], [ + 'closureComment' => ['required', 'string', 'min:10', 'max:5000'], + ], [ + 'closureComment.required' => 'A closure comment is required before closing the ticket.', + 'closureComment.min' => 'The closure comment must explain why the ticket is being closed.', + ])->validate(); + } + $updates = [ 'status_id' => $status->id, 'completed_at' => $status->is_closed ? now() : null, @@ -175,6 +190,21 @@ final class TicketShow extends Component $this->status_id = (int) $this->ticket->status_id; if ($status->is_closed) { + $user = Auth::user(); + abort_if($user === null, 403); + + $tickets->comment( + $this->ticket, + $user, + trim($this->closureComment), + $this->closureInternal, + [], + null, + 'Closure comment' + ); + + $this->reset(['closureComment', 'closureInternal']); + $this->ticket = $this->freshTicket($this->ticket); $sla->recordClosure($this->ticket); } diff --git a/Modules/Helpdesk/README.md b/Modules/Helpdesk/README.md index 6eea33ec..0a5f5350 100644 --- a/Modules/Helpdesk/README.md +++ b/Modules/Helpdesk/README.md @@ -167,3 +167,37 @@ Helpdesk changes must preserve the established WMS/Laravel module baseline: - `changelog.yaml` must be append-only. - `module.json` version must match the latest `changelog.yaml` version. - Tests must be added or updated for workflow changes. + +## Closing a Ticket With a Mandatory Comment + +The ticket status control now enforces an auditable closure workflow. + +### How to close a ticket + +1. Open the Helpdesk ticket. +2. Open **Ticket controls**. +3. Change **Status** to a closed status, for example **Closed**. +4. Click **Update status**. +5. The **Close ticket** dialog opens if no closure comment has been entered. +6. Enter a clear closure comment explaining what was resolved or why the ticket is being closed. +7. Leave **Internal closure note** unticked when the customer/requester should see the closure response. +8. Tick **Internal closure note** only when the closure reason is staff-only. +9. Click **Close ticket**. + +### Closure comment rules + +- A closed status cannot be saved without a closure comment. +- The closure comment must be at least 10 characters. +- Public closure comments are saved as normal Helpdesk responses. +- Internal closure comments are saved as internal notes and are only visible to users allowed to see internal notes. +- The ticket `completed_at` timestamp is set when a closed status is saved successfully. +- The resolution SLA is recorded after the closure is saved. + +### Recommended closure wording + +```text +Issue resolved and verified. + +The requested work has been completed and no further action is currently required. This ticket is now closed. +``` + diff --git a/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php b/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php index 248b3b30..a0df77d5 100644 --- a/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php +++ b/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php @@ -274,7 +274,7 @@ data-dusk="ticket-body-editor" dusk="ticket-body-editor">{!! $body ?? '' !!} - + @error('body')
{{ $message }}
@@ -361,57 +361,78 @@ } diff --git a/Modules/Helpdesk/Resources/views/partials/ticket-controls.blade.php b/Modules/Helpdesk/Resources/views/partials/ticket-controls.blade.php index b157108f..27cebcdc 100644 --- a/Modules/Helpdesk/Resources/views/partials/ticket-controls.blade.php +++ b/Modules/Helpdesk/Resources/views/partials/ticket-controls.blade.php @@ -101,7 +101,8 @@ + + @endcan diff --git a/Modules/Helpdesk/Tests/Feature/TicketConversationTest.php b/Modules/Helpdesk/Tests/Feature/TicketConversationTest.php index 03cd5ce3..9205002c 100644 --- a/Modules/Helpdesk/Tests/Feature/TicketConversationTest.php +++ b/Modules/Helpdesk/Tests/Feature/TicketConversationTest.php @@ -222,6 +222,66 @@ final class TicketConversationTest extends TestCase self::assertSame($fixtures['high']->id, $ticket->priority_id); } + public function test_closing_ticket_requires_a_closure_comment(): void + { + $fixtures = $this->seedLookupData(); + $owner = $this->user(); + $agent = $this->agent(); + $ticket = $this->ticket([ + 'owner' => $owner, + 'agent' => $agent, + 'status_id' => $fixtures['open']->id, + 'priority_id' => $fixtures['normal']->id, + ]); + + $this->actingAs($agent) + ->from(route('helpdesk.tickets.show', $ticket)) + ->post(route('helpdesk.tickets.status.update', $ticket), [ + 'status_id' => $fixtures['closed']->id, + ]) + ->assertRedirect(route('helpdesk.tickets.show', $ticket)) + ->assertSessionHasErrors('closure_comment'); + + $ticket->refresh(); + + self::assertSame($fixtures['open']->id, $ticket->status_id); + self::assertNull($ticket->completed_at); + } + + public function test_closing_ticket_creates_a_closure_comment(): void + { + $fixtures = $this->seedLookupData(); + $owner = $this->user(); + $agent = $this->agent(); + $ticket = $this->ticket([ + 'owner' => $owner, + 'agent' => $agent, + 'status_id' => $fixtures['open']->id, + 'priority_id' => $fixtures['normal']->id, + ]); + + $this->actingAs($agent) + ->post(route('helpdesk.tickets.status.update', $ticket), [ + 'status_id' => $fixtures['closed']->id, + 'closure_comment' => 'Issue resolved and verified before closure.', + 'closure_internal' => '0', + ]) + ->assertRedirect(route('helpdesk.tickets.show', $ticket)); + + $ticket->refresh(); + + self::assertSame($fixtures['closed']->id, $ticket->status_id); + self::assertNotNull($ticket->completed_at); + + $this->assertDatabaseHas('helpdesk_comments', [ + 'ticket_id' => $ticket->id, + 'user_id' => $agent->id, + 'subject' => 'Closure comment', + 'body' => 'Issue resolved and verified before closure.', + 'is_internal' => false, + ]); + } + public function test_unassigned_agent_cannot_update_another_users_ticket(): void { $fixtures = $this->seedLookupData(); diff --git a/Modules/Helpdesk/changelog.yaml b/Modules/Helpdesk/changelog.yaml index be19972f..439d560b 100644 --- a/Modules/Helpdesk/changelog.yaml +++ b/Modules/Helpdesk/changelog.yaml @@ -82,3 +82,26 @@ - Documented Public Reply versus Internal Note usage for responses and SLA handling. - Documented recommended close-ticket UI behaviour, closure templates, SLA meanings, audit expectations, reopening guidance, and Helpdesk development standards. - Preserved append-only changelog history. + +- version: 1.9.38 + date: 2026-06-23 + type: bugfix + summary: Enforce mandatory closure comments when closing Helpdesk tickets. + changes: + - Added a close-ticket confirmation dialog that opens when a closed status is selected without a closure comment. + - Added server-side validation so closed statuses cannot be saved without a meaningful closure comment. + - Stored the closure comment as a Helpdesk response with Public Reply as the default visibility and Internal Note as an explicit option. + - Updated the status control UI with closed-status metadata and accessible modal controls. + - Added regression tests for blocked closure without a comment and successful closure with an auditable comment. + - Preserved append-only changelog history. + +- version: 1.9.39 + date: 2026-06-23 + type: bugfix + summary: Fix Helpdesk ticket create editor body synchronisation. + changes: + - Updated the ticket creation rich-text Details editor so content is pushed into the Livewire body property before validation. + - Added Livewire navigation-safe editor initialisation so the sync logic works after wire:navigate page changes. + - Added input, keyup, paste, blur, and submit-capture synchronisation to prevent false "body field is required" validation failures. + - Changed the hidden body field to live Livewire binding so the server receives the latest editor content without requiring a page reload. + - Preserved append-only changelog history. diff --git a/Modules/Helpdesk/module.json b/Modules/Helpdesk/module.json index c83390ce..86234d07 100644 --- a/Modules/Helpdesk/module.json +++ b/Modules/Helpdesk/module.json @@ -22,5 +22,5 @@ "Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider" ], "files": [], - "version": "1.9.37" + "version": "1.9.39" }