- 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

@@ -0,0 +1,172 @@
<?php
use App\Models\AutomationRule;
use App\Models\Priority;
use App\Models\Team;
use App\Models\User;
use App\Services\TicketService;
use Illuminate\Support\Facades\Artisan;
test('a rule fires once a ticket has been silent past its threshold and applies its action', function () {
seedStatusesAndPriorities();
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
$ticket = makeTicket(['created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
$rule = AutomationRule::query()->create([
'label' => 'Podnieś priorytet po 60 min',
'enabled' => true,
'condition_minutes' => 60,
'action_type' => 'change_priority',
'action_value' => 'low',
]);
Artisan::call('automation:run-rules');
expect($ticket->fresh()->priority_key)->toBe('low');
expect($rule->fresh()->hasFiredFor($ticket->fresh()))->toBeTrue();
// TicketService::setPriority() writes its own "Priorytet zmieniony na: ..."
// history line; RunAutomationRules adds a second, clearly-attributed one.
expect($ticket->fresh()->histories()->count())->toBe(2);
});
test('a rule does not fire again on the same ticket once already latched', function () {
seedStatusesAndPriorities();
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
$ticket = makeTicket(['created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
AutomationRule::query()->create([
'label' => 'Podnieś priorytet po 60 min',
'enabled' => true,
'condition_minutes' => 60,
'action_type' => 'change_priority',
'action_value' => 'low',
]);
Artisan::call('automation:run-rules');
$ticket->refresh()->update(['priority_key' => 'high']); // simulate an operator manually reverting it
Artisan::call('automation:run-rules');
expect($ticket->fresh()->priority_key)->toBe('high');
});
test('a fresh client reply resets the latch so the rule can fire again after new silence', function () {
seedStatusesAndPriorities();
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
$ticket = makeTicket(['created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
$client = User::query()->create(['name' => 'Klient', 'email' => 'client-auto@example.com', 'roles' => ['client']]);
AutomationRule::query()->create([
'label' => 'Podnieś priorytet po 60 min',
'enabled' => true,
'condition_minutes' => 60,
'action_type' => 'change_priority',
'action_value' => 'low',
]);
Artisan::call('automation:run-rules');
expect($ticket->fresh()->priority_key)->toBe('low');
app(TicketService::class)->clientReply($ticket->fresh(), $client, 'Nadal potrzebuję pomocy');
$ticket->refresh()->update(['last_customer_activity_at' => now()->subMinutes(90), 'priority_key' => 'high']);
Artisan::call('automation:run-rules');
expect($ticket->fresh()->priority_key)->toBe('low');
});
test('a rule scoped to a priority ignores tickets with a different priority', function () {
seedStatusesAndPriorities();
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
$ticket = makeTicket(['priority_key' => 'low', 'created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
AutomationRule::query()->create([
'label' => 'Tylko dla wysokiego priorytetu',
'enabled' => true,
'condition_minutes' => 60,
'scope_priority_key' => 'high',
'action_type' => 'change_priority',
'action_value' => 'low',
]);
Artisan::call('automation:run-rules');
expect($ticket->fresh()->priority_key)->toBe('low');
expect($ticket->fresh()->histories()->count())->toBe(0);
});
test('a disabled rule never fires', function () {
seedStatusesAndPriorities();
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
$ticket = makeTicket(['created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
AutomationRule::query()->create([
'label' => 'Wyłączona reguła',
'enabled' => false,
'condition_minutes' => 60,
'action_type' => 'change_priority',
'action_value' => 'low',
]);
Artisan::call('automation:run-rules');
expect($ticket->fresh()->priority_key)->toBe('high');
});
test('a closed ticket is never matched even if it has been silent long enough', function () {
seedStatusesAndPriorities();
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
$ticket = makeTicket(['status_key' => 'closed', 'created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
AutomationRule::query()->create([
'label' => 'Podnieś priorytet po 60 min',
'enabled' => true,
'condition_minutes' => 60,
'action_type' => 'change_priority',
'action_value' => 'low',
]);
Artisan::call('automation:run-rules');
expect($ticket->fresh()->priority_key)->toBe('high');
});
test('two independent rules can both fire on the same ticket in one run', function () {
seedStatusesAndPriorities();
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
$team = Team::query()->create(['name' => 'Wsparcie L2']);
$ticket = makeTicket(['created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
AutomationRule::query()->create([
'label' => 'Podnieś priorytet',
'enabled' => true,
'condition_minutes' => 60,
'action_type' => 'change_priority',
'action_value' => 'low',
]);
AutomationRule::query()->create([
'label' => 'Przekaż do L2',
'enabled' => true,
'condition_minutes' => 60,
'action_type' => 'change_team',
'action_value' => (string) $team->id,
]);
Artisan::call('automation:run-rules');
$ticket->refresh();
expect($ticket->priority_key)->toBe('low');
expect($ticket->team_id)->toBe($team->id);
expect($ticket->histories()->count())->toBe(4);
});
test('reopening a closed ticket clears its automation logs so rules can fire again', function () {
seedStatusesAndPriorities();
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
$ticket = makeTicket(['created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
$rule = AutomationRule::query()->create([
'label' => 'Podnieś priorytet',
'enabled' => true,
'condition_minutes' => 60,
'action_type' => 'change_priority',
'action_value' => 'low',
]);
Artisan::call('automation:run-rules');
expect($rule->logs()->count())->toBe(1);
app(TicketService::class)->setStatus($ticket->fresh(), 'closed');
expect($rule->logs()->count())->toBe(0);
});