* Main ticket index renders scalar agent_name. * Ticket index still eager-loads user, owner, agent. * Ticket show comments no longer eager-load fake owner / agent relations. * Updated the conflicting regression tests.
808 lines
27 KiB
PHP
808 lines
27 KiB
PHP
<?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\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('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.company', '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);
|
|
}
|
|
})
|
|
->when($ticket->company_id !== null, static function ($query) use ($ticket): void {
|
|
$query->where(static function ($scopeQuery) use ($ticket): void {
|
|
$scopeQuery->where('ticket_id', $ticket->id)
|
|
->orWhereHas('repository', static function ($repositoryQuery) use ($ticket): void {
|
|
$repositoryQuery->whereNull('company_id')
|
|
->orWhere('company_id', $ticket->company_id);
|
|
});
|
|
});
|
|
})
|
|
->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 \Illuminate\Database\Eloquent\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()
|
|
->with('company')
|
|
->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'];
|
|
|
|
if (Schema::hasColumn('users', 'company_id')) {
|
|
$columns[] = 'company_id';
|
|
}
|
|
|
|
$query = User::query()->select($columns)->orderBy('name');
|
|
|
|
if ($this->ticket->company_id !== null && Schema::hasColumn('users', 'company_id')) {
|
|
$tenantUsers = (clone $query)
|
|
->where(function ($query): void {
|
|
$query->where('company_id', $this->ticket->company_id)
|
|
->orWhereNull('company_id');
|
|
})
|
|
->when($this->agent_id !== null, function ($query): void {
|
|
$query->orWhere('id', $this->agent_id);
|
|
})
|
|
->get();
|
|
|
|
if ($tenantUsers->isNotEmpty()) {
|
|
return $tenantUsers;
|
|
}
|
|
}
|
|
|
|
return $query->get();
|
|
}
|
|
|
|
private function canSeeInternalNotes(): bool
|
|
{
|
|
$company = Auth::user()?->company;
|
|
|
|
if ($company === null) {
|
|
return false;
|
|
}
|
|
|
|
return (bool) ($company->is_system_company ?? false)
|
|
|| Str::lower((string) ($company->slug ?? '')) === 'mpba-limited'
|
|
|| Str::contains(Str::lower((string) ($company->name ?? '')), 'mpba');
|
|
}
|
|
|
|
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 \Modules\Helpdesk\Models\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'
|
|
*/
|
|
|
|
}
|