findOrCreateByEmail($data['email']); } $subcategory = ! empty($data['subcategory_id']) ? Subcategory::query()->find($data['subcategory_id']) : null; $ticket = Ticket::query()->create([ 'number' => Ticket::nextNumber(), 'customer_id' => $customer?->id, 'email' => $customer?->email ?? $data['email'], 'name' => $customer?->name ?? ($data['name'] ?? $data['email']), 'subcategory_id' => $subcategory?->id, 'subject' => $data['subject'], 'body' => $data['body'], 'status_key' => Settings::get('default_status', 'new'), 'priority_key' => $subcategory?->default_priority_key ?: 'medium', 'team_id' => $this->autoAssignTeam($subcategory), 'assignee_id' => $data['assignee_id'] ?? null, 'custom_fields' => $data['custom_values'] ?? [], ]); $message = $ticket->messages()->create([ 'author_name' => $authorName ?? $ticket->name, 'body' => $data['body'], ]); $message->attachAuthor($customer?->id, 'client'); $this->notify($ticket, 'ticket_created'); return $ticket; } protected function autoAssignTeam(?Subcategory $subcategory): ?int { if (! $subcategory || ! Settings::bool('auto_assign_by_category')) { return null; } return Team::query()->whereHas('subcategories', fn ($q) => $q->where('subcategories.id', $subcategory->id)) ->value('id'); } public function setStatus(Ticket $ticket, string $statusKey): void { // Any status change (closing, reopening, moving between open sub-statuses) // resets the SLA-breach alert so a still-open ticket can report again later. $ticket->update(['status_key' => $statusKey, 'sla_notified_at' => null]); $ticket->addHistory('Status zmieniony na: '.Status::labelFor($statusKey)); // A transition to "closed" fires its own dedicated notification // instead of the generic status-changed one, so closing a ticket // doesn't send the customer/operator two emails for one event. if ($statusKey === 'closed') { $this->notify($ticket, 'ticket_closed'); // Time tracking only applies to open work — checkpoint and pause // the running segment (if any) the moment a ticket is closed, // regardless of which flow triggered the status change. $ticket->stopTimer(); } else { $this->notify($ticket, 'status_changed'); } } public function setPriority(Ticket $ticket, string $priorityKey): void { $ticket->update(['priority_key' => $priorityKey]); $ticket->addHistory('Priorytet zmieniony na: '.Priority::labelFor($priorityKey)); $this->notify($ticket, 'priority_changed'); } public function setAssignee(Ticket $ticket, ?User $assignee): void { // A newly assigned operator should get a fresh chance at an SLA-breach // alert instead of staying silent because the previous one already fired. $ticket->update(['assignee_id' => $assignee?->id, 'sla_notified_at' => null]); $ticket->addHistory('Przypisano do: '.($assignee?->name ?? 'Nieprzypisane')); $this->notify($ticket, 'assignee_changed'); } public function setTeam(Ticket $ticket, ?Team $team): void { $ticket->update(['team_id' => $team?->id]); $ticket->addHistory('Zespół zmieniony na: '.($team?->name ?? 'Brak')); $this->notify($ticket, 'team_changed'); } public function setReporter(Ticket $ticket, User $customer): void { $ticket->update(['customer_id' => $customer->id, 'email' => $customer->email, 'name' => $customer->name]); $ticket->addHistory('Zgłaszający zmieniony na: '.$customer->name); } public function updateDetails(Ticket $ticket, array $data): void { $categoryChanged = ($data['subcategory_id'] ?? null) !== $ticket->subcategory_id; $ticket->update([ 'subject' => $data['subject'], 'body' => $data['body'], 'subcategory_id' => $data['subcategory_id'] ?? null, 'custom_fields' => $data['custom_values'] ?? [], ]); $ticket->addHistory('Zaktualizowano dane zgłoszenia'); if ($categoryChanged) { $this->notify($ticket, 'category_changed'); } } public function operatorReply(Ticket $ticket, User $operator, string $body, ?string $statusAfter = null, array $attachments = []): void { $message = $ticket->messages()->create([ 'author_name' => $operator->name, 'body' => $body, ]); $message->attachAuthor($operator->id, 'operator'); $ticket->touch(); $this->attachFiles($ticket, $message, $attachments); $this->notify($ticket, 'operator_replied'); if ($statusAfter) { $this->setStatus($ticket, $statusAfter); } } public function operatorNote(Ticket $ticket, User $operator, string $body, array $attachments = []): void { $message = $ticket->messages()->create([ 'author_name' => $operator->name, 'internal' => true, 'body' => $body, ]); $message->attachAuthor($operator->id, 'operator'); $this->attachFiles($ticket, $message, $attachments); } public function clientReply(Ticket $ticket, User $client, string $body, array $attachments = []): void { $message = $ticket->messages()->create([ 'author_name' => $client->name, 'body' => $body, ]); $message->attachAuthor($client->id, 'client'); $ticket->touch(); $this->attachFiles($ticket, $message, $attachments); } /** * A message posted by an API integration rather than a logged-in person — * no User to attach as author, so it lands as a "system" message (mirrors * merge()'s system notes). Public replies still fire the same outward * notification as an operator's reply; internal notes don't. */ public function apiMessage(Ticket $ticket, ApiClient $client, string $body, bool $internal, string $authorName, array $attachments = []): TicketMessage { $message = $ticket->messages()->create([ 'author_name' => $authorName, 'internal' => $internal, 'body' => $body, 'api_client_id' => $client->id, ]); $message->attachAuthor(null, null); $ticket->touch(); $this->attachFiles($ticket, $message, $attachments); if (! $internal) { $this->notify($ticket, 'operator_replied'); } return $message; } /** * Stores each uploaded file and links it to the given message (or, for a * ticket's opening message, to $message === null-safe callers passing the * ticket's first message) — shared by every attachment-picking flow, * including the initial ticket-creation forms. * * @param UploadedFile[] $attachments */ public function attachFiles(Ticket $ticket, ?TicketMessage $message, array $attachments): void { if (! $attachments || ! Settings::bool('allow_attachments')) { return; } foreach ($attachments as $attachment) { $path = $attachment->store('attachments', 'public'); $ticket->attachments()->create([ 'message_id' => $message?->id, 'original_name' => $attachment->getClientOriginalName(), 'path' => $path, 'size' => Storage::disk('public')->size($path), 'mime' => $attachment->getMimeType(), ]); } } /** * Merge tickets: first selected id is primary, others get closed and their * messages copied onto the primary (mirrors the design's mergeSelected()). */ public function merge(array $ticketIds): void { if (count($ticketIds) < 2) { return; } $tickets = Ticket::query()->whereIn('id', $ticketIds)->get()->keyBy('id'); $primaryId = $ticketIds[0]; $primary = $tickets->get($primaryId); $others = $tickets->except($primaryId); if (! $primary || $others->isEmpty()) { return; } $primary->messages()->create([ 'author_name' => 'System', 'body' => 'Scalono zgłoszenia: '.$others->map(fn (Ticket $o) => '#'.$o->number)->implode(', '), ]); foreach ($others as $other) { foreach ($other->messages()->get() as $message) { $copy = $primary->messages()->create([ 'author_name' => $message->author_name, 'internal' => $message->internal, 'body' => $message->body, ]); $copy->attachAuthor($message->author_id, $message->authorLink?->role?->key); } $other->update(['status_key' => 'closed']); $other->stopTimer(); $note = $other->messages()->create([ 'author_name' => 'System', 'internal' => true, 'body' => 'Scalone ze zgłoszeniem #'.$primary->number, ]); $note->attachAuthor(null, 'operator'); } $primary->touch(); } /** * Public so the scheduled SLA-breach check (which isn't a ticket lifecycle * event raised from within this service) can trigger the same way. */ public function notify(Ticket $ticket, string $triggerKey): void { $setting = NotificationSetting::query()->where('trigger_key', $triggerKey)->first(); if (! $setting || ! $setting->enabled || ! $setting->email_template_id) { return; } $email = $setting->recipient === 'operator' ? $ticket->assignee?->email : $ticket->email; if (! $email) { return; } Notification::route('mail', $email) ->notify(new TicketNotification($ticket, $setting->email_template_id)); } }