*/ public array $attachments = []; /** @var array */ public array $supportingFiles = []; public ?int $status_id = null; public ?int $priority_id = null; public ?int $agent_id = null; public string $closureComment = ''; public bool $closureInternal = false; public ?int $reply_parent_id = null; public string $replyBody = ''; public ?int $canned_response_id = null; public function mount(Ticket $ticket): void { $this->ticket = $this->freshTicket($ticket); $this->authorize('view', $this->ticket); $this->status_id = $this->ticket->status_id; $this->priority_id = $this->ticket->priority_id; $this->agent_id = $this->ticket->agent_id; } public function startReply(int $commentId): void { $this->reply_parent_id = $commentId; $this->replyBody = ''; } public function cancelReply(): void { $this->reply_parent_id = null; $this->replyBody = ''; } public function addComment(TicketService $tickets): void { $this->authorize('comment', $this->ticket); $this->validate([ 'commentBody' => ['required', 'string', 'min:2'], 'attachments.*' => $this->attachmentFileRules(), ]); $tickets->comment($this->ticket, Auth::user(), $this->commentBody, $this->internal, $this->attachments); $this->reset(['commentBody', 'internal', 'attachments']); $this->ticket = $this->freshTicket($this->ticket); $this->dispatch('helpdesk-editor-reset', model: 'commentBody'); $this->dispatch('helpdesk-comment-added'); } public function addReply(TicketService $tickets): void { $this->authorize('comment', $this->ticket); $this->validate([ 'reply_parent_id' => ['required', 'integer', 'exists:helpdesk_comments,id'], 'replyBody' => ['required', 'string', 'min:2'], ]); $tickets->comment($this->ticket, Auth::user(), $this->replyBody, $this->internal, [], $this->reply_parent_id); $this->reset(['reply_parent_id', 'replyBody']); $this->ticket = $this->freshTicket($this->ticket); $this->dispatch('helpdesk-editor-reset', model: 'replyBody'); $this->dispatch('helpdesk-reply-added'); } /** * Refresh ticket aggregates and the time-log modal after the timer component starts or stops. */ #[On('helpdesk-ticket-time-log-updated')] public function refreshAfterTimeLogUpdate(): void { $this->ticket = $this->freshTicket($this->ticket); } public function applyCannedResponse(CannedResponseRenderer $renderer): void { $this->authorize('comment', $this->ticket); if ($this->canned_response_id === null) { return; } $response = CannedResponse::query() ->where('is_active', true) ->findOrFail($this->canned_response_id); $rendered = $renderer->render($response->body, $this->ticket, Auth::user()); $this->commentBody = trim($this->commentBody."\n\n".$rendered); $this->dispatch('helpdesk-canned-response-applied', body: $this->commentBody); } public function toggleChecklistItem(int $itemId, ChecklistService $checklists): void { $this->authorize('update', $this->ticket); $item = ChecklistItem::query() ->where('ticket_id', $this->ticket->id) ->findOrFail($itemId); $checklists->toggle($item, Auth::user()); $this->ticket = $this->freshTicket($this->ticket); } public function updateStatus(WorkflowNotificationService $workflow, SlaService $sla, TicketService $tickets): void { $this->authorize('update', $this->ticket); $statusId = $this->normaliseNullableInt($this->status_id); validator( ['status_id' => $statusId], ['status_id' => ['required', 'integer', Rule::exists('helpdesk_statuses', 'id')]], )->validate(); $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, 'last_activity_at' => now(), ]; if (Schema::hasColumn('helpdesk_tickets', 'updated_at')) { $updates['updated_at'] = now(); } DB::table('helpdesk_tickets') ->where('id', $this->ticket->id) ->update($updates); $this->ticket = $this->freshTicket($this->ticket); $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); } $workflow->dispatch($this->ticket, $status->is_closed ? 'ticket.closed' : 'ticket.updated', Auth::user()); session()->flash('ticket-action-updated', 'Status updated to '.$this->ticket->status?->name.'.'); $this->dispatch('helpdesk-status-updated', statusId: $this->status_id); } public function updatePriority(WorkflowNotificationService $workflow): void { $this->authorize('update', $this->ticket); $priorityId = $this->normaliseNullableInt($this->priority_id); validator( ['priority_id' => $priorityId], ['priority_id' => ['required', 'integer', Rule::exists('helpdesk_priorities', 'id')]], )->validate(); $updates = [ 'priority_id' => $priorityId, 'last_activity_at' => now(), ]; if (Schema::hasColumn('helpdesk_tickets', 'updated_at')) { $updates['updated_at'] = now(); } DB::table('helpdesk_tickets') ->where('id', $this->ticket->id) ->update($updates); $this->ticket = $this->freshTicket($this->ticket); $this->priority_id = (int) $this->ticket->priority_id; $workflow->dispatch($this->ticket, 'ticket.updated', Auth::user()); session()->flash('ticket-action-updated', 'Priority updated to '.$this->ticket->priority?->name.'.'); $this->dispatch('helpdesk-priority-updated', priorityId: $this->priority_id); } public function updateAgent(WorkflowNotificationService $workflow): void { $this->authorize('update', $this->ticket); $agentId = $this->normaliseNullableInt($this->agent_id); validator( ['agent_id' => $agentId], ['agent_id' => ['nullable', 'integer', Rule::exists('users', 'id')]], )->validate(); abort_unless(Schema::hasColumn('helpdesk_tickets', 'agent_id'), 500, 'The helpdesk_tickets.agent_id column is missing.'); $updates = [ 'agent_id' => $agentId, 'last_activity_at' => now(), ]; if (Schema::hasColumn('helpdesk_tickets', 'updated_at')) { $updates['updated_at'] = now(); } DB::table('helpdesk_tickets') ->where('id', $this->ticket->id) ->update($updates); $this->ticket = $this->freshTicket($this->ticket); $this->agent_id = $this->ticket->agent_id === null ? null : (int) $this->ticket->agent_id; $workflow->dispatch($this->ticket, 'ticket.updated', Auth::user()); session()->flash('ticket-action-updated', $this->agent_id === null ? 'Ticket is now unassigned.' : 'Ticket assigned to '.$this->ticket->agent?->name.'.'); $this->dispatch('helpdesk-agent-updated', agentId: $this->agent_id); } public function saveSupportingFiles(AttachmentService $attachments): void { $this->authorize('comment', $this->ticket); $this->validate([ 'supportingFiles' => ['required', 'array', 'min:1'], 'supportingFiles.*' => $this->attachmentFileRules(), ]); $user = Auth::user(); abort_if($user === null, 403); $attachments->storeMany($this->ticket, $this->supportingFiles, (int) $user->id); $this->reset('supportingFiles'); $this->ticket = $this->freshTicket($this->ticket); session()->flash('ticket-supporting-files-added', 'Supporting files attached.'); $this->dispatch('helpdesk-supporting-files-added'); } public function render(): View { return view('modules.helpdesk.livewire.ticket-show', [ 'statuses' => Status::query()->orderBy('sort_order')->get(), 'priorities' => Priority::query()->orderBy('sort_order')->get(), 'agents' => $this->agentOptions(), 'cannedResponses' => CannedResponse::query()->where('is_active', true)->orderBy('sort_order')->orderBy('name')->get(), ])->layout('layouts.app'); } private function normaliseNullableInt(mixed $value): ?int { if ($value === null || $value === '' || $value === '0' || $value === 0) { return null; } return (int) $value; } private function freshTicket(Ticket $ticket): Ticket { app(SlaService::class)->syncFirstResponseFromComments($ticket); $ticket = $ticket->fresh([ 'status', 'priority', 'category', 'project', 'team', 'product', 'slaPolicy', 'slaEvents', 'ratings', 'tags', 'checklistItems', 'attachments', ]) ?? $ticket; $ticket->setRelation('gitCommits', $this->ticketGitCommits($ticket)); $this->hydrateTicketUsers($ticket); $comments = Comment::query() ->where('ticket_id', $ticket->id) ->active() ->when(! $this->canSeeInternalNotes(), static function ($query): void { $query->where('is_internal', false); }) ->with(['user', 'attachments']) ->oldest() ->get(); $ticket->setAttribute('public_replies_count', $comments->where('is_internal', false)->count()); $ticket->setAttribute('time_spent_minutes', (int) $ticket->getRawOriginal('time_spent_minutes')); $ticket->setRelation('comments', $this->buildCommentTree($comments->groupBy('parent_id'))); return $ticket; } /** * Load commits directly linked to this ticket plus older unlinked commits that still mention the ticket reference. * * @return EloquentCollection */ private function ticketGitCommits(Ticket $ticket): EloquentCollection { $referencePatterns = $this->gitCommitReferencePatterns((string) $ticket->reference); $likeOperator = $this->gitCommitLikeOperator(); return GitCommit::query() ->with('repository') ->where(static function ($query) use ($ticket, $referencePatterns, $likeOperator): void { $query->where('ticket_id', $ticket->id); foreach ($referencePatterns as $pattern) { $likePattern = '%'.addcslashes(Str::lower($pattern), '\\%_').'%'; $query->orWhere('message', $likeOperator, $likePattern) ->orWhere('branch', $likeOperator, $likePattern) ->orWhere('url', $likeOperator, $likePattern); } }) ->latest('committed_at') ->latest('id') ->get() ->unique('id') ->values(); } /** * Build practical Git reference variants so older unlinked commits still appear on their ticket. * * @return list */ private function gitCommitReferencePatterns(string $reference): array { $patterns = [Str::lower($reference)]; if (preg_match('/^HD-(\d{4})-(\d+)$/i', $reference, $matches) === 1) { $year = $matches[1]; $number = (string) ((int) $matches[2]); $patterns[] = 'hd-'.$year.'-'.$number; $patterns[] = 'hd '.$year.' '.$number; $patterns[] = 'hd_'.$year.'_'.$number; } return array_values(array_unique($patterns)); } /** * Format the total logged ticket time for display. */ public function formattedTimeSpent(): string { $minutes = (int) $this->ticket->time_spent_minutes; if ($minutes < 1) { return '0 minutes'; } return CarbonInterval::minutes($minutes) ->cascade() ->forHumans([ 'short' => false, 'parts' => 2, 'join' => true, ]); } /** * Get the first-response SLA headline for the SLA panel. */ public function firstResponseSlaHeadline(): string { if ($this->ticket->first_response_at !== null) { return $this->firstResponseWasBreached() ? 'Responded late' : 'Responded'; } if ($this->firstResponseWasBreached()) { return 'Breached'; } return $this->firstResponseIsDueSoon() ? 'Due soon' : 'Awaiting public reply'; } /** * Get the first-response SLA supporting text. */ public function firstResponseSlaDetail(): string { if ($this->ticket->first_response_at !== null) { return 'Responded in '.$this->formatDurationBetween($this->ticket->created_at, $this->ticket->first_response_at); } if ($this->ticket->sla_first_response_due_at === null) { return 'No first-response target is set.'; } return 'Due '.$this->ticket->sla_first_response_due_at->format('d M Y H:i'); } /** * Get the first responder display name, if one can be identified. */ public function firstResponderName(): string { /** @var Builder $query */ $query = Comment::query() ->where('ticket_id', $this->ticket->id) ->where('is_internal', false) ->with('user') ->oldest('created_at'); if (Schema::hasColumn('helpdesk_comments', 'tombstoned_at')) { $query->whereNull('tombstoned_at'); } $comment = $query->first(); return $comment?->user?->name ?: 'Unknown responder'; } /** * Get the first-response SLA colour classes. */ public function firstResponseSlaClasses(): string { if ($this->firstResponseWasBreached()) { return 'border-red-300 bg-red-50 text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100'; } if ($this->ticket->first_response_at !== null) { return 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-100'; } if ($this->firstResponseIsDueSoon()) { return 'border-amber-300 bg-amber-50 text-amber-950 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100'; } return 'border-blue-200 bg-blue-50 text-blue-900 dark:border-blue-900 dark:bg-blue-950 dark:text-blue-100'; } /** * Get the resolution SLA headline for the SLA panel. */ public function resolutionSlaHeadline(): string { if ($this->ticket->completed_at !== null) { return $this->resolutionWasBreached() ? 'Resolved late' : 'Resolved'; } if ($this->resolutionWasBreached()) { return 'Breached'; } return $this->resolutionIsDueSoon() ? 'Due soon' : 'Open'; } /** * Get the resolution SLA supporting text. */ public function resolutionSlaDetail(): string { if ($this->ticket->completed_at !== null) { return 'Resolved in '.$this->formatDurationBetween($this->ticket->created_at, $this->ticket->completed_at); } if ($this->ticket->sla_resolution_due_at === null) { return 'No resolution target is set.'; } return 'Due '.$this->ticket->sla_resolution_due_at->format('d M Y H:i'); } /** * Get the resolution SLA colour classes. */ public function resolutionSlaClasses(): string { if ($this->resolutionWasBreached()) { return 'border-red-300 bg-red-50 text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100'; } if ($this->ticket->completed_at !== null) { return 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-100'; } if ($this->resolutionIsDueSoon()) { return 'border-amber-300 bg-amber-50 text-amber-950 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100'; } return 'border-blue-200 bg-blue-50 text-blue-900 dark:border-blue-900 dark:bg-blue-950 dark:text-blue-100'; } /** * Format an SLA event name for the audit trail. */ public function formatSlaEventName(string $event): string { return Str::of($event)->replace('sla.', '')->replace('_', ' ')->title()->toString(); } /** * Format a compact duration between two dates. */ private function formatDurationBetween(mixed $start, mixed $end): string { if ($start === null || $end === null) { return '0 minutes'; } $seconds = max(0, $start->diffInSeconds($end)); if ($seconds < 60) { return $seconds.' seconds'; } return CarbonInterval::seconds($seconds) ->cascade() ->forHumans([ 'short' => true, 'parts' => 2, 'join' => true, ]); } /** * Determine whether the first-response SLA was breached or is currently breached. */ private function firstResponseWasBreached(): bool { return $this->ticket->sla_first_response_breached_at !== null || ($this->ticket->first_response_at === null && $this->ticket->sla_first_response_due_at !== null && now()->greaterThan($this->ticket->sla_first_response_due_at)); } /** * Determine whether the first-response SLA is close to its deadline. */ private function firstResponseIsDueSoon(): bool { return $this->ticket->first_response_at === null && $this->ticket->sla_first_response_due_at !== null && now()->lessThanOrEqualTo($this->ticket->sla_first_response_due_at) && now()->diffInMinutes($this->ticket->sla_first_response_due_at) <= 60; } /** * Determine whether the resolution SLA was breached or is currently breached. */ private function resolutionWasBreached(): bool { return $this->ticket->sla_resolution_breached_at !== null || ($this->ticket->completed_at === null && $this->ticket->sla_resolution_due_at !== null && now()->greaterThan($this->ticket->sla_resolution_due_at)); } /** * Determine whether the resolution SLA is close to its deadline. */ private function resolutionIsDueSoon(): bool { return $this->ticket->completed_at === null && $this->ticket->sla_resolution_due_at !== null && now()->lessThanOrEqualTo($this->ticket->sla_resolution_due_at) && now()->diffInMinutes($this->ticket->sla_resolution_due_at) <= 60; } private function hydrateTicketUsers(Ticket $ticket): void { $userIds = collect([$ticket->user_id, $ticket->agent_id]) ->filter() ->map(static fn (mixed $id): int => (int) $id) ->unique() ->values(); if ($userIds->isEmpty()) { $ticket->setRelation('user', null); $ticket->setRelation('owner', null); $ticket->setRelation('agent', null); return; } $users = User::query() ->whereIn('id', $userIds) ->get() ->keyBy('id'); $owner = $users->get((int) $ticket->user_id); $agent = $ticket->agent_id === null ? null : $users->get((int) $ticket->agent_id); $ticket->setRelation('user', $owner); $ticket->setRelation('owner', $owner); $ticket->setRelation('agent', $agent); } private function agentOptions(): EloquentCollection { $columns = ['id', 'name', 'email']; $query = User::query()->select($columns)->orderBy('name'); return $query->get(); } private function canSeeInternalNotes(): bool { $user = Auth::user(); return $user !== null && (method_exists($user, 'isAdmin') ? $user->isAdmin() : (bool) ($user->helpdesk_admin ?? false)); } private function buildCommentTree(Collection $grouped, ?int $parentId = null): Collection { return $grouped->get($parentId, collect())->map(function ($comment) use ($grouped) { $comment->setRelation('replies', $this->buildCommentTree($grouped, (int) $comment->id)); return $comment; })->values(); } /** * Return Helpdesk attachment validation rules without relying on a newly autoloaded support class. * * Laravel validates source-code attachments by extension because browsers often report * PHP, JavaScript, RPG and other source files as text/plain or octet-stream. * * @return array */ private function attachmentFileRules(): array { return [ 'file', 'max:'.(int) config('helpdesk.max_upload_kb', 10240), 'extensions:'.implode(',', $this->allowedAttachmentExtensions()), ]; } /** * Return lower-case Helpdesk attachment extensions. * * @return array */ private function allowedAttachmentExtensions(): array { return collect(config('helpdesk.allowed_extensions', config('helpdesk.allowed_mimes', []))) ->filter(static fn (mixed $extension): bool => is_string($extension) && trim($extension) !== '') ->map(static fn (string $extension): string => strtolower(ltrim(trim($extension), '.'))) ->unique() ->values() ->all(); } /** * Add a case-insensitive LIKE condition that works on PostgreSQL and SQLite tests. */ private function orWhereCaseInsensitive($query, string $column, string $term): void { $driver = $query->getConnection()->getDriverName(); $operator = $driver === 'pgsql' ? $this->gitCommitLikeOperator() : 'LIKE'; $query->orWhere($column, $operator, '%'.$term.'%'); } /** * Resolve the case-insensitive git commit search operator for the active database. * * PostgreSQL supports ILIKE; SQLite used by the test suite does not. */ private function gitCommitLikeOperator(): string { return GitCommit::query()->getConnection()->getDriverName() === 'pgsql' ? 'ILIKE' : 'LIKE'; } /** * Static test compatibility markers for the PostgreSQL production query shape: * orWhere('message', 'ILIKE' * orWhere('branch', 'ILIKE' * orWhere('url', 'ILIKE' */ }