Files
servicedesk/src/app/Services/TicketService.php
Kacper 313e01ad24 v1.2.1
- Generic AI integration (Admin > Integracje > "Integracja AI"), optional and
  off by default: an OpenAI-compatible /chat/completions client (Groq, OpenAI,
  or a self-hosted Ollama instance) configured by base URL, optional API key,
  model, and an SSL-verification toggle. Foundation for the two AI features
  below and anything else that wants an LLM call in the future.
- BookStack automatic content tagging (AI): "Otaguj nową treść"/"Otaguj
  wszystko ponownie" buttons plus `php artisan bookstack:tag-content`
  (--dry-run/--force/--limit=N) tag every book/chapter/page with matching
  helpdesk subcategory names, idempotent by default.
- BookStack search refinement: "Przeszukuj" is now three independent
  checkboxes (Książki/Strony/Rozdziały) instead of a single dropdown, plus a
  new "Szukaj po" setting (nazwa/tagi/oba) — tag matching uses the bare
  subcategory name, matching what auto-tagging writes.
- AI-driven ticket triage + summary (Admin > Integracje > "Automatyzacja AI
  dla zgłoszeń", via new scheduled ai:run-ticket-automation): five toggles
  auto-assign/correct category+subcategory, rewrite an unclear subject, and
  set priority from content, once per ticket in the background; every change
  is logged in the ticket's history. Separately, an AI summary + suggested
  action for every ticket, shown to operators only, with an admin-editable
  prompt.
- Operators can now reassign a ticket to any team, not just one they belong
  to.
- The auto-refresh countdown badges (ticket view, operator queue) are now
  clickable — fetch immediately and reset the countdown.
- All 7 "cyclical" intervals (3 browser refresh countdowns, the notification
  bell poll, and the 4 background scheduled commands) are now configurable
  from Admin > Konfiguracja instead of fixed in code.
- Fixed: an operator viewing a ticket that's deleted or moved outside their
  team scope mid-session is now redirected to the operator queue instead of
  hitting an error.
- Docs: README/ARCHITECTURE/CLAUDE/install/wiki updated for all of the above.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 13:38:39 +02:00

575 lines
24 KiB
PHP

<?php
namespace App\Services;
use App\Events\TicketMessagePosted;
use App\Events\TicketQueueChanged;
use App\Models\ApiClient;
use App\Models\NotificationPreference;
use App\Models\NotificationSetting;
use App\Models\Priority;
use App\Models\Status;
use App\Models\Subcategory;
use App\Models\Team;
use App\Models\Ticket;
use App\Models\TicketMessage;
use App\Models\User;
use App\Notifications\TicketNotification;
use App\Support\Settings;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Storage;
class TicketService
{
/**
* Create a ticket, either from a guest submission (no $customer) or on behalf of a logged-in client.
*/
public function create(array $data, ?User $customer, ?string $authorName = null): Ticket
{
if (! $customer && ! empty($data['email']) && Settings::bool('ldap_auto_provision_guests')) {
$customer = app(LdapUserProvisioner::class)->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',
]);
$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<string, mixed> $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);
}
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));
}
}
}