policyFor($ticket); if (! $policy) { return $ticket; } $createdAt = $ticket->created_at ?? now(); $ticket->forceFill([ 'sla_policy_id' => $policy->id, 'sla_first_response_due_at' => $createdAt->copy()->addMinutes($policy->first_response_minutes), 'sla_resolution_due_at' => $createdAt->copy()->addMinutes($policy->resolution_minutes), ])->save(); TicketSlaEvent::query()->create([ 'ticket_id' => $ticket->id, 'sla_policy_id' => $policy->id, 'event' => 'sla.policy_applied', 'due_at' => $ticket->sla_resolution_due_at, 'occurred_at' => now(), 'meta' => ['policy' => $policy->name], ]); return $ticket->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 { $respondedAt = now(); if ($ticket->first_response_at === null) { $this->markFirstResponse($ticket, $respondedAt, $actor); } $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 Builder $query */ $query = Comment::query() ->where('ticket_id', $ticket->id) ->where('is_internal', false) ->with('user') ->oldest('created_at'); if (Schema::hasColumn('helpdesk_comments', 'tombstoned_at')) { $query->whereNull('tombstoned_at'); } /** @var Comment|null $firstPublicComment */ $firstPublicComment = $query->first(); if ($firstPublicComment === null) { return; } $this->markFirstResponse($ticket, $firstPublicComment->created_at ?? now(), $firstPublicComment->user); } private function markFirstResponse(Ticket $ticket, mixed $respondedAt, ?User $actor = null): void { $respondedAt = $respondedAt instanceof 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, 'meta' => [ 'actor_id' => $actor?->id, 'actor_name' => $actor?->name, 'response_seconds' => max(0, ($ticket->created_at ?? $respondedAt)->diffInSeconds($respondedAt)), ], ], ); } public function recordClosure(Ticket $ticket): void { $breached = $ticket->sla_resolution_due_at !== null && now()->greaterThan($ticket->sla_resolution_due_at); $ticket->forceFill(['sla_resolution_breached_at' => $breached ? now() : $ticket->sla_resolution_breached_at])->save(); TicketSlaEvent::query()->create([ 'ticket_id' => $ticket->id, 'sla_policy_id' => $ticket->sla_policy_id, 'event' => 'sla.resolved', 'due_at' => $ticket->sla_resolution_due_at, 'occurred_at' => now(), 'breached' => $breached, 'meta' => [ 'resolved_seconds' => max(0, ($ticket->created_at ?? now())->diffInSeconds(now())), ], ]); } public function refreshBreaches(Ticket $ticket): void { $updates = []; if ($ticket->first_response_at === null && $ticket->sla_first_response_due_at !== null && now()->greaterThan($ticket->sla_first_response_due_at)) { $updates['sla_first_response_breached_at'] = $ticket->sla_first_response_breached_at ?? now(); } if ($ticket->completed_at === null && $ticket->sla_resolution_due_at !== null && now()->greaterThan($ticket->sla_resolution_due_at)) { $updates['sla_resolution_breached_at'] = $ticket->sla_resolution_breached_at ?? now(); } if ($updates !== []) { $ticket->forceFill($updates)->save(); } } private function policyFor(Ticket $ticket): ?SlaPolicy { return SlaPolicy::query() ->where('is_active', true) ->where(static function ($query) use ($ticket): void { $query->whereNull('company_id')->orWhere('company_id', $ticket->company_id); }) ->orderByRaw('CASE WHEN priority_id = ? THEN 0 WHEN priority_id IS NULL THEN 1 ELSE 2 END', [$ticket->priority_id]) ->orderByRaw('CASE WHEN category_id = ? THEN 0 WHEN category_id IS NULL THEN 1 ELSE 2 END', [$ticket->category_id]) ->orderByDesc('is_default') ->first(); } }