Initial Commit
This commit is contained in:
@@ -0,0 +1,766 @@
|
||||
<?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('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('helpdesk::layouts.app.sidebar');
|
||||
}
|
||||
|
||||
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'
|
||||
*/
|
||||
}
|
||||
Reference in New Issue
Block a user