diff --git a/Modules/Helpdesk/Livewire/TicketShow.php b/Modules/Helpdesk/Livewire/TicketShow.php index 69f7a848..fb4872d0 100644 --- a/Modules/Helpdesk/Livewire/TicketShow.php +++ b/Modules/Helpdesk/Livewire/TicketShow.php @@ -300,6 +300,8 @@ final class TicketShow extends Component private function freshTicket(Ticket $ticket): Ticket { + app(SlaService::class)->syncFirstResponseFromComments($ticket); + $ticket = $ticket->fresh([ 'status', 'priority', diff --git a/Modules/Helpdesk/Resources/views/livewire/ticket-show.blade.php b/Modules/Helpdesk/Resources/views/livewire/ticket-show.blade.php index c0a0196e..644a3634 100644 --- a/Modules/Helpdesk/Resources/views/livewire/ticket-show.blade.php +++ b/Modules/Helpdesk/Resources/views/livewire/ticket-show.blade.php @@ -887,12 +887,19 @@
+ class="rounded-lg border {{ $ticket->sla_first_response_breached ? 'border-red-300 bg-red-50 text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100' : ($ticket->first_response_at ? 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-100' : 'border-amber-200 bg-amber-50 text-amber-950 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100') }} p-3">
First response
-
- {{ $ticket->first_response_at?->format('d M Y H:i') ?? 'Awaiting response' }}
-
Due - {{ $ticket->sla_first_response_due_at?->format('d M Y H:i') ?? 'Not set' }}
+ + @if ($ticket->first_response_at) +
Responded
+
+ {{ $ticket->first_response_at->format('d M Y H:i') }} +
+ @else +
Awaiting public reply
+
Due + {{ $ticket->sla_first_response_due_at?->format('d M Y H:i') ?? 'Not set' }}
+ @endif
fresh() ?? $ticket; } + /** + * Record a public ticket response and satisfy the first-response SLA. + * + * A public Helpdesk response is deliberate customer-visible communication, + * so it must satisfy the first-response SLA even in local/single-user + * support workflows where the same authenticated user may be testing both + * sides of the ticket conversation. + */ public function recordPublicResponse(Ticket $ticket, User $actor): void { - if ($ticket->first_response_at === null && (int) $ticket->user_id !== (int) $actor->id) { - $breached = $ticket->sla_first_response_due_at !== null && now()->greaterThan($ticket->sla_first_response_due_at); - $ticket->forceFill([ - 'first_response_at' => now(), - 'sla_first_response_breached_at' => $breached ? now() : $ticket->sla_first_response_breached_at, - ])->save(); + $respondedAt = now(); - TicketSlaEvent::query()->create([ - 'ticket_id' => $ticket->id, - 'sla_policy_id' => $ticket->sla_policy_id, - 'event' => 'sla.first_response', - 'due_at' => $ticket->sla_first_response_due_at, - 'occurred_at' => now(), - 'breached' => $breached, - ]); + if ($ticket->first_response_at === null) { + $this->markFirstResponse($ticket, $respondedAt); } $ticket->increment('public_replies_count'); } + /** + * Rebuild first-response SLA state from the earliest public comment. + * + * This keeps older tickets correct when public comments existed before the + * explicit Public/Internal selector was added or when counters were manually + * changed outside the SLA service. + */ + public function syncFirstResponseFromComments(Ticket $ticket): void + { + if ($ticket->first_response_at !== null) { + return; + } + + /** @var Comment|null $firstPublicComment */ + $firstPublicComment = Comment::query() + ->where('ticket_id', $ticket->id) + ->where('is_internal', false) + ->whereNull('tombstoned_at') + ->oldest('created_at') + ->first(); + + if ($firstPublicComment === null) { + return; + } + + $this->markFirstResponse($ticket, $firstPublicComment->created_at ?? now()); + } + + private function markFirstResponse(Ticket $ticket, mixed $respondedAt): void + { + $respondedAt = $respondedAt instanceof \Illuminate\Support\Carbon + ? $respondedAt + : now(); + + $breached = $ticket->sla_first_response_due_at !== null + && $respondedAt->greaterThan($ticket->sla_first_response_due_at); + + $ticket->forceFill([ + 'first_response_at' => $respondedAt, + 'sla_first_response_breached_at' => $breached ? $respondedAt : null, + ])->save(); + + TicketSlaEvent::query()->firstOrCreate( + [ + 'ticket_id' => $ticket->id, + 'event' => 'sla.first_response', + ], + [ + 'sla_policy_id' => $ticket->sla_policy_id, + 'due_at' => $ticket->sla_first_response_due_at, + 'occurred_at' => $respondedAt, + 'breached' => $breached, + ], + ); + } + public function recordClosure(Ticket $ticket): void { $breached = $ticket->sla_resolution_due_at !== null && now()->greaterThan($ticket->sla_resolution_due_at); diff --git a/Modules/Helpdesk/changelog.yaml b/Modules/Helpdesk/changelog.yaml index 10b00d47..2aefec8f 100644 --- a/Modules/Helpdesk/changelog.yaml +++ b/Modules/Helpdesk/changelog.yaml @@ -50,3 +50,13 @@ - Added the same visibility selector to threaded response forms so replies can be made public deliberately. - Added public/internal badges to conversation entries so response visibility is obvious after posting. - Preserved append-only changelog history and avoided adding unused JavaScript or orphan files. + +- version: 1.9.35 + date: 2026-06-23 + type: bugfix + summary: Fix Helpdesk first-response SLA status after public replies. + changes: + - Updated public Helpdesk responses so they satisfy the first-response SLA state instead of only increasing the public reply counter. + - Added first-response SLA synchronisation for existing tickets that already have public replies but a missing first_response_at value. + - Improved the SLA card wording so pending tickets say Awaiting public reply and responded tickets show Responded. + - Added regression coverage for public response SLA state and preserved append-only changelog history. diff --git a/Modules/Helpdesk/module.json b/Modules/Helpdesk/module.json index 2898fed1..0ff280e8 100644 --- a/Modules/Helpdesk/module.json +++ b/Modules/Helpdesk/module.json @@ -22,5 +22,5 @@ "Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider" ], "files": [], - "version": "1.9.34" + "version": "1.9.35" }