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"
}