$data * @param array $files */ public function create(User $user, array $data, array $files = []): Ticket { for ($attempt = 1; $attempt <= 5; $attempt++) { try { return DB::transaction(fn (): Ticket => $this->createInsideTransaction($user, $data, $files)); } catch (QueryException $exception) { if (! $this->isReferenceCollision($exception) || $attempt === 5) { throw $exception; } } } throw new TicketPersistenceException('Unable to create a unique Helpdesk ticket reference.'); } /** * Create the ticket while the caller owns the database transaction. * * @param array $data * @param array $files */ private function createInsideTransaction(User $user, array $data, array $files = []): Ticket { $status = $this->resolveInitialStatus(); $boardId = $this->resolveBoardId($data['board_id'] ?? null); /** @var Ticket $ticket */ $ticket = Ticket::query()->create([ 'reference' => $this->references->next(), 'company_id' => $data['company_id'] ?? ($user->company_id ?? null), 'user_id' => $user->id, 'agent_id' => $this->assignment->selectAgentId($data['category_id'] ?? null), 'status_id' => $status->id, 'board_id' => $boardId, 'priority_id' => $data['priority_id'], 'category_id' => $data['category_id'] ?? null, 'project_id' => $data['project_id'] ?? null, 'product_id' => $data['product_id'] ?? null, 'team_id' => $data['team_id'] ?? null, 'subject' => $data['subject'], 'body' => $data['body'], 'due_at' => $data['due_at'] ?? null, 'last_activity_at' => now(), ]); $this->syncTags($ticket, $data['tag_ids'] ?? null); $ticket = $this->automation->apply($ticket, $data); $ticket = $this->sla->applyPolicy($ticket); $this->checklists->applyTemplates($ticket); $this->attachments->storeMany($ticket, $files, (int) $user->id); $ticket = $ticket->fresh(['status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'attachments', 'checklistItems']) ?? $ticket; $this->workflow->dispatch($ticket, 'ticket.created', $user); return $ticket; } private function resolveInitialStatus(): Status { $defaultStatusSlug = (string) config('helpdesk.default_status_slug', 'new'); $status = Schema::hasTable('helpdesk_statuses') && Schema::hasColumn('helpdesk_statuses', 'is_default') ? Status::query()->where('is_default', true)->orderBy('sort_order')->first() : null; $status ??= Status::query()->where('slug', $defaultStatusSlug)->first(); return $status ?? Status::query()->create([ 'name' => 'New', 'slug' => $defaultStatusSlug, 'color' => 'sky', 'sort_order' => 1, 'is_closed' => false, ]); } private function resolveBoardId(mixed $boardId): mixed { if (! blank($boardId) || ! Schema::hasTable('helpdesk_kanban_boards')) { return $boardId; } return KanbanBoard::query()->where('is_default', true)->orderBy('sort_order')->value('id') ?: KanbanBoard::query()->orderBy('sort_order')->value('id'); } private function syncTags(Ticket $ticket, mixed $tagIds): void { if (! is_array($tagIds) || $tagIds === []) { return; } $ticket->tags()->sync(array_values(array_unique(array_map('intval', $tagIds)))); } /** * Determine whether a database error was caused by the ticket reference unique index. */ private function isReferenceCollision(QueryException $exception): bool { $message = $exception->getMessage(); return str_contains($message, 'helpdesk_tickets_reference_unique') || str_contains($message, 'Duplicate entry') && str_contains($message, 'reference') || str_contains($message, 'UNIQUE constraint failed') && str_contains($message, 'helpdesk_tickets.reference'); } /** * Add a public or internal comment and optional attachments. * * @param array $files */ public function comment(Ticket $ticket, User $user, string $body, bool $internal = false, array $files = [], ?int $parentId = null, ?string $subject = null): Comment { return DB::transaction(function () use ($ticket, $user, $body, $internal, $files, $parentId, $subject): Comment { $parent = $this->resolveParentComment($ticket, $parentId); $payload = $this->commentPayload($ticket, $user, $body, $internal, $parent, $parentId, $subject); /** @var Comment $comment */ $comment = $ticket->comments()->create($payload); $this->afterCommentCreated($ticket, $user, $comment, $files, $internal, $parentId); return $comment->fresh(['user', 'attachments']) ?? $comment; }); } private function resolveParentComment(Ticket $ticket, ?int $parentId): ?Comment { return $parentId === null ? null : Comment::query()->where('ticket_id', $ticket->id)->findOrFail($parentId); } /** @return array */ private function commentPayload(Ticket $ticket, User $user, string $body, bool $internal, ?Comment $parent, ?int $parentId, ?string $subject): array { $guid = (string) Str::uuid(); $unid = strtoupper(hash('sha256', $guid . '|' . $ticket->id . '|' . microtime(true))); $parentUnid = $parent?->unid; $payload = [ 'parent_id' => $parentId, 'user_id' => $user->id, 'body' => $body, 'body_text' => trim(preg_replace('/\s+/', ' ', strip_tags($body)) ?: ''), 'is_internal' => $internal, 'guid' => $guid, 'unid' => $unid, 'parent_unid' => $parentUnid, 'root_unid' => $parent?->root_unid ?: $parentUnid ?: $unid, 'originator_id' => $unid . ':1', 'sync_sequence' => 1, 'sync_hash' => hash('sha256', $body), 'source_system' => 'wms-helpdesk', ]; if (Schema::hasColumn('helpdesk_comments', 'subject')) { $defaultSubject = $parentId === null ? 'Response' : 'RE: Response'; $payload['subject'] = trim((string) ($subject ?: $defaultSubject)); } return $payload; } /** @param array $files */ private function afterCommentCreated(Ticket $ticket, User $user, Comment $comment, array $files, bool $internal, ?int $parentId): void { $ticket->forceFill(['last_activity_at' => now()])->save(); if (! $internal) { $this->sla->recordPublicResponse($ticket, $user); } $this->attachments->storeMany($ticket, $files, (int) $user->id, $comment); $event = $parentId === null ? 'ticket.commented' : 'ticket.reply_created'; $freshTicket = $ticket->fresh(['status', 'priority', 'category', 'project', 'team']) ?? $ticket; $this->workflow->dispatch($freshTicket, $event, $user); } /** * Close a ticket. */ public function close(Ticket $ticket, User $user): Ticket { $status = Status::query()->where('slug', config('helpdesk.closed_status_slug'))->firstOrFail(); $ticket->forceFill([ 'status_id' => $status->id, 'completed_at' => now(), 'last_activity_at' => now(), ])->save(); $ticket = $ticket->fresh(['status', 'priority', 'category', 'project', 'team', 'product']) ?? $ticket; $this->sla->recordClosure($ticket); $this->workflow->dispatch($ticket, 'ticket.closed', $user); return $ticket; } }