Files
2026-09-02 17:08:57 +00:00

767 lines
25 KiB
PHP
Executable File

<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Livewire;
use App\Models\User;
use Carbon\CarbonInterval;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Attributes\On;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads;
use Modules\Helpdesk\Models\CannedResponse;
use Modules\Helpdesk\Models\ChecklistItem;
use Modules\Helpdesk\Models\Comment;
use Modules\Helpdesk\Models\GitCommit;
use Modules\Helpdesk\Models\Priority;
use Modules\Helpdesk\Models\Status;
use Modules\Helpdesk\Models\Ticket;
use Modules\Helpdesk\Services\AttachmentService;
use Modules\Helpdesk\Services\CannedResponseRenderer;
use Modules\Helpdesk\Services\ChecklistService;
use Modules\Helpdesk\Services\SlaService;
use Modules\Helpdesk\Services\TicketService;
use Modules\Helpdesk\Services\WorkflowNotificationService;
final class TicketShow extends Component
{
use WithFileUploads;
public Ticket $ticket;
#[Validate('required|string|min:2')]
public string $commentBody = '';
public bool $internal = false;
/** @var array<int, TemporaryUploadedFile> */
public array $attachments = [];
/** @var array<int, TemporaryUploadedFile> */
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<int, GitCommit>
*/
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<string>
*/
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<Comment> $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<int, string>
*/
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<int, string>
*/
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'
*/
}