*/ #[Validate('array')] public array $tag_ids = []; /** @var array */ #[Validate('array')] public array $attachments = []; /** * Load the editable ticket state, including existing attachments and assignment. */ public function mount(Ticket $ticket): void { $this->authorize('update', $ticket); $this->ticket = $this->freshTicket($ticket); $this->subject = $this->ticket->subject; $this->body = $this->ticket->body; $this->status_id = $this->ticket->status_id; $this->board_id = $this->ticket->board_id; $this->priority_id = $this->ticket->priority_id; $this->category_id = $this->ticket->category_id; $this->project_id = $this->ticket->project_id; $this->product_id = $this->ticket->product_id; $this->team_id = $this->ticket->team_id; $this->agent_id = $this->ticket->agent_id; $this->due_at = $this->ticket->due_at?->format('Y-m-d'); $this->tag_ids = $this->ticket->tags->pluck('id')->map(fn ($id): int => (int) $id)->all(); } /** * Save ticket edits, assignment and any newly uploaded ticket-level attachments. */ public function save(WorkflowNotificationService $workflow, SlaService $sla): mixed { $this->authorize('update', $this->ticket); $this->validate(); $this->validate([ 'attachments' => 'array', 'attachments.*' => $this->attachmentFileRules(), 'tag_ids.*' => 'integer|exists:helpdesk_tags,id', ]); $status = Status::query()->findOrFail($this->status_id); $this->ticket->forceFill([ 'subject' => $this->subject, 'body' => $this->body, 'status_id' => $status->id, 'board_id' => $this->board_id, 'priority_id' => $this->priority_id, 'category_id' => $this->category_id, 'project_id' => $this->project_id, 'product_id' => $this->product_id, 'team_id' => $this->team_id, 'agent_id' => $this->agent_id, 'due_at' => $this->due_at, 'completed_at' => $status->is_closed ? now() : null, 'last_activity_at' => now(), ])->save(); $this->ticket->tags()->sync(array_values(array_unique(array_map('intval', $this->tag_ids)))); $this->storeTicketAttachments(); if ($status->is_closed) { $sla->recordClosure($this->ticket); } $fresh = $this->freshTicket($this->ticket); $workflow->dispatch($fresh, $status->is_closed ? 'ticket.closed' : 'ticket.updated', Auth::user()); session()->flash('success', 'Ticket '.$fresh->reference.' has been updated.'); return redirect()->route('helpdesk.tickets.show', $fresh); } /** * Remove an existing ticket-level attachment from storage and the database. */ public function removeAttachment(int $attachmentId): void { $this->authorize('update', $this->ticket); $attachment = $this->ticket->attachments()->whereKey($attachmentId)->firstOrFail(); if (method_exists($attachment, 'deleteFile')) { $attachment->deleteFile(); } $attachment->delete(); $this->ticket = $this->freshTicket($this->ticket); } /** * Clear the edit form, including Select2 and Flatpickr browser widgets. */ public function clearForm(): void { $this->authorize('update', $this->ticket); $this->resetErrorBag(); $this->resetValidation(); $this->subject = ''; $this->body = ''; $this->status_id = null; $this->board_id = null; $this->priority_id = null; $this->category_id = null; $this->project_id = null; $this->product_id = null; $this->team_id = null; $this->agent_id = null; $this->due_at = null; $this->tag_ids = []; $this->attachments = []; $this->dispatch('helpdesk-form-cleared'); $this->dispatch('helpdesk-editor-reset', model: 'body'); } public function render(): View { return view('helpdesk::livewire.ticket-edit', [ 'boards' => KanbanBoard::query()->with('owner:id,name,email')->orderByDesc('is_default')->orderBy('sort_order')->orderBy('name')->get(), 'statuses' => Status::query()->orderBy('sort_order')->orderBy('name')->get(), 'priorities' => Priority::query()->orderBy('sort_order')->orderBy('name')->get(), 'categories' => Category::query()->with('agents:id')->orderBy('name')->get(), 'teams' => Team::query()->whereNull('archived_at')->orderBy('name')->get(), 'projects' => Project::query()->whereNull('archived_at')->orderBy('title')->get(), 'products' => Product::query()->where('is_active', true)->orderBy('name')->get(), 'tags' => Tag::query()->orderBy('name')->get(), 'agents' => $this->agentOptions(), ])->layout('helpdesk::layouts.app.sidebar'); } /** * Return assignable users for this ticket. * * The dropdown must never be blank just because optional helpdesk_agent/helpdesk_admin * flags are missing or false. Prefer support users, then fall back to all users. * * @return EloquentCollection */ private function agentOptions(): EloquentCollection { $columns = ['id', 'name', 'email']; $query = User::query()->select($columns)->orderBy('name'); return $query->get(); } private function freshTicket(Ticket $ticket): Ticket { return $ticket->fresh([ 'user:id,name,email', 'owner:id,name,email', 'agent:id,name,email', 'status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'attachments.user', ]) ?? $ticket; } /** * Store ticket-level attachments in a schema-tolerant way. */ private function storeTicketAttachments(): void { if ($this->attachments === []) { return; } foreach ($this->attachments as $upload) { if (! $upload instanceof TemporaryUploadedFile) { continue; } $disk = (string) config('helpdesk.disk', 'local'); $path = $upload->store('tickets/'.$this->ticket->id, $disk); $this->ticket->attachments()->create($this->attachmentPayload($upload, $path, $disk)); } $this->attachments = []; $this->ticket = $this->freshTicket($this->ticket); } /** * Build attachment attributes using the columns available in the project migration. * * @return array */ private function attachmentPayload(TemporaryUploadedFile $upload, string $path, string $disk): array { $table = 'helpdesk_attachments'; $payload = []; $columns = [ 'user_id' => Auth::id(), 'uploaded_by_id' => Auth::id(), 'disk' => $disk, 'path' => $path, 'file_path' => $path, 'filename' => basename($path), 'file_name' => basename($path), 'original_name' => $upload->getClientOriginalName(), 'original_filename' => $upload->getClientOriginalName(), 'mime_type' => $upload->getMimeType(), 'mime' => $upload->getMimeType(), 'size' => $upload->getSize(), 'size_bytes' => $upload->getSize(), ]; foreach ($columns as $column => $value) { if (Schema::hasColumn($table, $column)) { $payload[$column] = $value; } } return $payload; } /** * 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(); } }