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, // category_id only ever carries a value when there's no // subcategory to derive one from (e.g. an IMAP mailbox routed to // a whole category rather than a specific subcategory). 'category_id' => $subcategory ? null : ($data['category_id'] ?? null), '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'] ?? [], 'last_customer_activity_at' => now(), 'source' => $data['source'] ?? 'web', 'snipeit_asset_id' => $data['snipeit_asset_id'] ?? null, 'snipeit_asset_name' => $data['snipeit_asset_name'] ?? null, ]); $message = $ticket->messages()->create([ 'author_name' => $authorName ?? $ticket->name, 'body' => $data['body'], ]); $message->attachAuthor($customer?->id, 'client'); $this->notify($ticket, 'ticket_created'); $this->notify($ticket, 'ticket_created_team'); app(TriggerEngine::class)->handle($ticket, 'ticket_created'); TicketQueueChanged::dispatch($ticket->id, 'created', Auth::id()); 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. // Checked via the status's stage (not the literal key) since admins // can rename/replace which key maps to the "closed" stage. if (Status::stageFor($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(); // A closed ticket that reopens later should give every automation // rule a clean slate rather than staying latched from before. $ticket->automationRuleLogs()->delete(); } else { $this->notify($ticket, 'status_changed'); } app(TriggerEngine::class)->handle($ticket, 'status_changed'); app(TriggerEngine::class)->handle($ticket, 'ticket_updated'); TicketQueueChanged::dispatch($ticket->id, 'status_changed', Auth::id()); } public function submitCsat(Ticket $ticket, int $rating, ?string $comment = null): void { if (! $ticket->csatSubmittable()) { return; } $rating = max(1, min(5, $rating)); $ticket->update([ 'csat_rating' => $rating, 'csat_comment' => $comment, 'csat_rated_at' => now(), ]); $ticket->addHistory('Klient ocenił obsługę: '.$rating.'/5'); } 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'); app(TriggerEngine::class)->handle($ticket, 'priority_changed'); app(TriggerEngine::class)->handle($ticket, 'ticket_updated'); TicketQueueChanged::dispatch($ticket->id, 'priority_changed', Auth::id()); } 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'); app(TriggerEngine::class)->handle($ticket, 'assignee_changed'); app(TriggerEngine::class)->handle($ticket, 'ticket_updated'); TicketQueueChanged::dispatch($ticket->id, 'assignee_changed', Auth::id()); } 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'); app(TriggerEngine::class)->handle($ticket, 'team_changed'); app(TriggerEngine::class)->handle($ticket, 'ticket_updated'); TicketQueueChanged::dispatch($ticket->id, 'team_changed', Auth::id()); } /** * Applies one AI-triage pass's changes (see TicketAiTriageService) in a * single update, rather than composing setPriority()/updateDetails() — * a pass can touch category, subcategory, subject and priority * together, and those would each write their own generic history line * and fire notify()/TriggerEngine::handle() per field instead of once * per pass, fragmenting one semantic AI decision into several * unrelated-looking edits. $historyLines carries one mechanical * "X changed to: Y" line per changed field (built by the caller, since * it already knows the human-readable labels); this always appends one * more attribution line on top, mirroring how RunAutomationRules logs * "Automatyzacja: {label}" after its own field-change lines. * * @param array $changes column => value, only the fields that actually changed * @param string[] $historyLines */ public function applyAiTriage(Ticket $ticket, array $changes, array $historyLines): void { if (! $changes) { return; } $categoryChanged = array_key_exists('category_id', $changes) || array_key_exists('subcategory_id', $changes); $priorityChanged = array_key_exists('priority_key', $changes); $ticket->update($changes); foreach ($historyLines as $line) { $ticket->addHistory($line); } $ticket->addHistory('Automatyzacja: klasyfikacja AI'); if ($categoryChanged) { $this->notify($ticket, 'category_changed'); app(TriggerEngine::class)->handle($ticket, 'category_changed'); } if ($priorityChanged) { $this->notify($ticket, 'priority_changed'); app(TriggerEngine::class)->handle($ticket, 'priority_changed'); } app(TriggerEngine::class)->handle($ticket, 'ticket_updated'); TicketQueueChanged::dispatch($ticket->id, 'ai_triage', Auth::id()); } 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); } /** * Links/unlinks the Snipe-IT asset attached to a ticket — $asset null * unlinks. Only the label (not live status/assignment) is cached on the * ticket row, so it still shows something if Snipe-IT later becomes * unreachable or the asset is deleted there, without a live API call on * every ticket list render (see SnipeItClient::asset() for the live * fetch used on the ticket-detail page itself). * * @param array{id: int, label: string}|null $asset */ public function setSnipeitAsset(Ticket $ticket, ?array $asset): void { $ticket->update([ 'snipeit_asset_id' => $asset['id'] ?? null, 'snipeit_asset_name' => $asset['label'] ?? null, ]); $ticket->addHistory($asset ? 'Powiązano sprzęt (inwentarz): '.$asset['label'] : 'Odpięto powiązany sprzęt (inwentarz)'); } 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'); app(TriggerEngine::class)->handle($ticket, 'category_changed'); } app(TriggerEngine::class)->handle($ticket, 'ticket_updated'); } 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'); app(TriggerEngine::class)->handle($ticket, 'comment_added'); TicketMessagePosted::dispatch($ticket->id, $message->id, false, $operator->id); TicketQueueChanged::dispatch($ticket->id, 'message_posted', $operator->id); 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); TicketMessagePosted::dispatch($ticket->id, $message->id, true, $operator->id); } public function clientReply(Ticket $ticket, User $client, string $body, array $attachments = [], string $source = 'web'): void { $message = $ticket->messages()->create([ 'author_name' => $client->name, 'body' => $body, 'source' => $source === 'web' ? null : $source, ]); $message->attachAuthor($client->id, 'client'); $ticket->touch(); $this->attachFiles($ticket, $message, $attachments); // A fresh customer reply breaks whatever silence an automation rule // fired on, so it should be able to fire again after a new period of // silence rather than staying latched from before. $ticket->update(['last_customer_activity_at' => now()]); $ticket->automationRuleLogs()->delete(); // Unlike notify(), clientReply() never had a NotificationSetting // trigger_key of its own — comment_added is a Trigger-engine-only // hook, e.g. for a rule that reopens a closed ticket on a fresh // customer reply. app(TriggerEngine::class)->handle($ticket, 'comment_added'); TicketMessagePosted::dispatch($ticket->id, $message->id, false, $client->id); TicketQueueChanged::dispatch($ticket->id, 'message_posted', $client->id); } /** * A reply from a customer with no User account — e.g. an e-mail reply * from an address the IMAP fetcher couldn't resolve to a local/LDAP * user. Mirrors clientReply() (real customer activity: resets SLA * silence, fires comment_added so an admin-configured Trigger can reopen * a closed ticket) rather than apiMessage() (attachAuthor(null, null) — * a system/integration note, not client content). attachAuthor(null, * 'client') matches how create() already tags a guest's opening message. */ public function guestReply(Ticket $ticket, string $authorName, string $body, array $attachments = [], string $source = 'web'): TicketMessage { $message = $ticket->messages()->create([ 'author_name' => $authorName, 'body' => $body, 'source' => $source === 'web' ? null : $source, ]); $message->attachAuthor(null, 'client'); $ticket->touch(); $this->attachFiles($ticket, $message, $attachments); $ticket->update(['last_customer_activity_at' => now()]); $ticket->automationRuleLogs()->delete(); app(TriggerEngine::class)->handle($ticket, 'comment_added'); TicketMessagePosted::dispatch($ticket->id, $message->id, false, null); TicketQueueChanged::dispatch($ticket->id, 'message_posted', null); return $message; } public function toggleWatch(Ticket $ticket, User $user): bool { if ($ticket->isWatchedBy($user)) { $ticket->watchers()->detach($user->id); return false; } $ticket->watchers()->attach($user->id); return true; } /** * 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'); app(TriggerEngine::class)->handle($ticket, 'comment_added'); } TicketMessagePosted::dispatch($ticket->id, $message->id, $internal, null); TicketQueueChanged::dispatch($ticket->id, 'message_posted', null); 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->displayNumber())->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->displayNumber(), ]); $note->attachAuthor(null, 'operator'); TicketQueueChanged::dispatch($other->id, 'merged', Auth::id()); } $primary->touch(); TicketQueueChanged::dispatch($primary->id, 'message_posted', Auth::id()); } /** * Maps the fixed NotificationSetting trigger_keys onto the 3 event * categories a staff member can tune on their personal notification * preferences page (see NotificationPreference::CATEGORIES). Triggers * absent from this map (currently just the client-only 'ticket_created' * ack) have no staff-facing leg at all. */ private const STAFF_EVENT_MAP = [ 'ticket_created_team' => 'new_ticket', 'status_changed' => 'ticket_update', 'priority_changed' => 'ticket_update', 'assignee_changed' => 'ticket_update', 'team_changed' => 'ticket_update', 'category_changed' => 'ticket_update', 'operator_replied' => 'ticket_update', 'ticket_closed' => 'ticket_update', 'sla_breached' => 'escalation', ]; /** * Public so the scheduled SLA-breach check (which isn't a ticket lifecycle * event raised from within this service) can trigger the same way. * * NotificationSetting.enabled is the global kill switch, layered above * every per-user preference below — disabling a trigger here silences * both legs regardless of what any individual staff member configured; * the personal matrix can only narrow within an enabled trigger, never * widen past it. * * Sends exactly one notification to the trigger's fixed * NotificationSetting.recipient (a client, or the ticket's single * assignee) exactly as before, then — for triggers mapped in * STAFF_EVENT_MAP — additionally fans out to every other operator/admin * whose own notification preferences put this ticket in scope. */ 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; } $notifiable = $setting->recipient === 'operator' ? $ticket->assignee : $ticket->customer; $fallbackEmail = $setting->recipient === 'operator' ? $ticket->assignee?->email : $ticket->email; $this->deliverTicketNotification($ticket, $setting->recipient, $setting->email_template_id, notifiable: $notifiable, fallbackEmail: $fallbackEmail); if ($category = self::STAFF_EVENT_MAP[$triggerKey] ?? null) { $this->notifyStaffForCategory($ticket, $category, $setting->email_template_id, skip: $notifiable); } } /** * Notifies every operator/admin whose personal notification preferences * (see NotificationPreference) put this ticket into one of their chosen * scopes for $category — "Wszystkie zgłoszenia" deliberately reuses the * existing Ticket::isVisibleToOperator() ACL rather than meaning * literally every ticket, so it naturally stays within a non-admin * operator's own team(s) + unrouted tickets. $skip excludes whoever * notify() already notified directly via the fixed recipient (so an * assignee with scope_mine enabled doesn't get the same event twice), * and the acting user is always excluded so nobody gets notified about * their own action. */ protected function notifyStaffForCategory(Ticket $ticket, string $category, int $templateId, ?User $skip = null): void { $staff = User::query()->whereHas('roleAssignments', fn ($q) => $q->whereIn('key', ['operator', 'admin']))->get(); foreach ($staff as $user) { if ($user->id === Auth::id() || ($skip && $user->id === $skip->id)) { continue; } $pref = NotificationPreference::rowFor($user, $category); $inScope = ($pref['scope_mine'] && $ticket->assignee_id === $user->id) || ($pref['scope_unassigned'] && $ticket->assignee_id === null) || ($pref['scope_watched'] && $ticket->isWatchedBy($user)) || ($pref['scope_all'] && $ticket->isVisibleToOperator($user)); if (! $inScope) { continue; } $this->deliverTicketNotification($ticket, 'operator', $templateId, $pref['email'] ? ['mail', 'database'] : ['database'], notifiable: $user); } } /** * Entry point for the Trigger engine's send_notification action (see * TriggerEngine) — an admin-authored, explicit business action, not one * of the fixed system lifecycle events, so unlike notify() it doesn't * consult NotificationSetting or any per-user preference; it always * sends both mail and bell, same as the original unconditional * TicketNotification behaviour. */ public function sendCustomNotification(Ticket $ticket, string $recipient, int $templateId): void { $notifiable = $recipient === 'operator' ? $ticket->assignee : $ticket->customer; $fallbackEmail = $recipient === 'operator' ? $ticket->assignee?->email : $ticket->email; $this->deliverTicketNotification($ticket, $recipient, $templateId, notifiable: $notifiable, fallbackEmail: $fallbackEmail, templateSource: 'trigger_email_template'); } /** * Shared by notify()'s fixed-recipient leg, notifyStaffForCategory()'s * per-user fan-out, and sendCustomNotification(). $notifiable, when * given a real User, always wins over $fallbackEmail — the fallback * only exists for a guest customer with no account, where the * "database" (bell) channel has nothing to attach to, so * TicketNotification::via() drops it to mail-only anyway. */ private function deliverTicketNotification( Ticket $ticket, string $recipientRole, int $templateId, array $channels = ['mail', 'database'], ?User $notifiable = null, ?string $fallbackEmail = null, string $templateSource = 'email_template', ): void { if ($notifiable) { $notifiable->notify(new TicketNotification($ticket, $templateId, $recipientRole, $channels, $templateSource)); return; } if ($fallbackEmail) { Notification::route('mail', $fallbackEmail) ->notify(new TicketNotification($ticket, $templateId, $recipientRole, $channels, $templateSource)); } } }