- Real-time updates (Laravel Reverb): live operator queue, live ticket
  chat/detail updates for operator and client, periodic fallback refresh
  with a visible countdown as a backstop for dropped websocket connections.
- SLA automation rules (Admin > Automatyzacja SLA): act on a ticket after
  N minutes of customer silence (change priority/status/team/assignee),
  evaluated every 15 minutes, reusing TicketService's own setters so
  automated changes get the same history/notification/broadcast a manual
  change would.
- New notification: every operator on a matching team gets notified when
  a new ticket lands in one of their subcategories.
- BookStack knowledge-base sidebar now also shown on the client's own
  ticket view (previously operator-only); suggestions everywhere now load
  in after first paint instead of blocking it.
- Client ticket view: shows assigned operator + team; page widened to
  match the operator's.
- Notification bell shows unread only; read notifications disappear
  instead of just dimming.
- Stats dashboard: sectioned layout, new breakdowns (by subcategory, CSAT
  by team/operator, top clients, client x subcategory cross-tab).
- Mobile: nav dropdowns (theme/notifications/profile) now expand full
  width instead of overflowing off-screen below 640px.
- Fixed two bugs that silently disabled all real-time updates (missing
  CSRF header on Echo's private-channel auth; a script-load-order race
  that could miss the livewire:init event) and the mariadb healthcheck
  (world-writable credentials file on this stack's NFS mount).
- Assorted test-suite fixes (roles virtual attribute needs the roles
  table seeded; a few missing seeds/wrong assertions found along the way).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 20:43:05 +02:00
parent def7c70887
commit 0b06687ea1
64 changed files with 3613 additions and 168 deletions

View File

@@ -2,6 +2,8 @@
namespace App\Services;
use App\Events\TicketMessagePosted;
use App\Events\TicketQueueChanged;
use App\Models\ApiClient;
use App\Models\NotificationSetting;
use App\Models\Priority;
@@ -14,6 +16,7 @@ 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;
@@ -45,6 +48,7 @@ class TicketService
'team_id' => $this->autoAssignTeam($subcategory),
'assignee_id' => $data['assignee_id'] ?? null,
'custom_fields' => $data['custom_values'] ?? [],
'last_customer_activity_at' => now(),
]);
$message = $ticket->messages()->create([
@@ -54,6 +58,8 @@ class TicketService
$message->attachAuthor($customer?->id, 'client');
$this->notify($ticket, 'ticket_created');
$this->notifyOperatorsForNewTicket($ticket, $subcategory);
TicketQueueChanged::dispatch($ticket->id, 'created', Auth::id());
return $ticket;
}
@@ -68,6 +74,38 @@ class TicketService
->value('id');
}
/**
* Notifies every member of every team the new ticket's subcategory
* routes to independent of whether "auto_assign_by_category" actually
* assigned the ticket's team_id, since the point here is "a ticket
* matching your team's specialty came in", not the routing feature
* itself. Unlike notify(), this fans out to potentially many
* notifiables at once, so it can't reuse that single-recipient method.
*/
protected function notifyOperatorsForNewTicket(Ticket $ticket, ?Subcategory $subcategory): void
{
if (! $subcategory) {
return;
}
$setting = NotificationSetting::query()->where('trigger_key', 'ticket_created_team')->first();
if (! $setting || ! $setting->enabled || ! $setting->email_template_id) {
return;
}
$operators = Team::query()
->whereHas('subcategories', fn ($q) => $q->where('subcategories.id', $subcategory->id))
->with('members')
->get()
->flatMap(fn (Team $team) => $team->members)
->unique('id');
foreach ($operators as $operator) {
$operator->notify(new TicketNotification($ticket, $setting->email_template_id, 'operator'));
}
}
public function setStatus(Ticket $ticket, string $statusKey): void
{
// Any status change (closing, reopening, moving between open sub-statuses)
@@ -87,9 +125,15 @@ class TicketService
// 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');
}
TicketQueueChanged::dispatch($ticket->id, 'status_changed', Auth::id());
}
public function submitCsat(Ticket $ticket, int $rating, ?string $comment = null): void
@@ -113,6 +157,7 @@ class TicketService
$ticket->update(['priority_key' => $priorityKey]);
$ticket->addHistory('Priorytet zmieniony na: '.Priority::labelFor($priorityKey));
$this->notify($ticket, 'priority_changed');
TicketQueueChanged::dispatch($ticket->id, 'priority_changed', Auth::id());
}
public function setAssignee(Ticket $ticket, ?User $assignee): void
@@ -122,6 +167,7 @@ class TicketService
$ticket->update(['assignee_id' => $assignee?->id, 'sla_notified_at' => null]);
$ticket->addHistory('Przypisano do: '.($assignee?->name ?? 'Nieprzypisane'));
$this->notify($ticket, 'assignee_changed');
TicketQueueChanged::dispatch($ticket->id, 'assignee_changed', Auth::id());
}
public function setTeam(Ticket $ticket, ?Team $team): void
@@ -129,6 +175,7 @@ class TicketService
$ticket->update(['team_id' => $team?->id]);
$ticket->addHistory('Zespół zmieniony na: '.($team?->name ?? 'Brak'));
$this->notify($ticket, 'team_changed');
TicketQueueChanged::dispatch($ticket->id, 'team_changed', Auth::id());
}
public function setReporter(Ticket $ticket, User $customer): void
@@ -164,6 +211,8 @@ class TicketService
$ticket->touch();
$this->attachFiles($ticket, $message, $attachments);
$this->notify($ticket, 'operator_replied');
TicketMessagePosted::dispatch($ticket->id, $message->id, false, $operator->id);
TicketQueueChanged::dispatch($ticket->id, 'message_posted', $operator->id);
if ($statusAfter) {
$this->setStatus($ticket, $statusAfter);
@@ -179,6 +228,7 @@ class TicketService
]);
$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 = []): void
@@ -190,6 +240,15 @@ class TicketService
$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();
TicketMessagePosted::dispatch($ticket->id, $message->id, false, $client->id);
TicketQueueChanged::dispatch($ticket->id, 'message_posted', $client->id);
}
/**
@@ -214,6 +273,9 @@ class TicketService
$this->notify($ticket, 'operator_replied');
}
TicketMessagePosted::dispatch($ticket->id, $message->id, $internal, null);
TicketQueueChanged::dispatch($ticket->id, 'message_posted', null);
return $message;
}
@@ -286,9 +348,11 @@ class TicketService
'body' => 'Scalone ze zgłoszeniem #'.$primary->number,
]);
$note->attachAuthor(null, 'operator');
TicketQueueChanged::dispatch($other->id, 'merged', Auth::id());
}
$primary->touch();
TicketQueueChanged::dispatch($primary->id, 'message_posted', Auth::id());
}
/**