Co nowego:
- Podgląd logów w panelu admina (Admin > Logi) — pliki storage/logs/*.log
  bez potrzeby dostępu do kontenera, z filtrami poziomu/tekstu/liczby wpisów
  i auto-odświeżaniem.
- Filtr „Bez kategorii” w kolejce operatora — izoluje zgłoszenia bez
  przypisanej kategorii/podkategorii.
- Narzędzie importu z Heska: już nie tworzy automatycznie kont klientów dla
  nieznanych e-maili (pomija takie zgłoszenia zamiast zakładać konto),
  łączy odpowiedzi/właścicieli zgłoszeń z realnymi kontami operatorów po
  e-mailu, nowe flagi --assign-operators i --fix-closed-dates do
  donaprawiania wcześniejszych importów, dedykowany log
  storage/logs/hesk-import.log.
- Poprawka: pulpit statystyk operatora (rozkład wg kategorii/podkategorii i
  filtr kategorii) pomijał zgłoszenia przypisane do samej kategorii bez
  podkategorii (np. z poczty IMAP) — teraz liczone poprawnie.
- Poprawka: błąd JS i zawieszone w tle liczniki przy nawigacji z widoku z
  aktywnym licznikiem (najbardziej odczuwalne w liczniku czasu pracy
  operatora).
- Porządki w bazie: usunięte niewykorzystywane kolumny
  (users.remember_token, users.email_verified_at,
  email_templates.trigger_label); wartości pól dodatkowych, stan
  triage/podsumowania AI i powiązany sprzęt Snipe-IT przeniesione z tabeli
  tickets do osobnych tabel (ticket_field_values, ticket_ai_summaries,
  ticket_snipeit_assets) — bez zmiany zachowania, ale pola dodatkowe są
  teraz efektywnie przeszukiwalne; dodane brakujące indeksy na 4 tabelach
  pivot; tickets.source/ticket_messages.source walidowane względem znanego
  zestawu wartości.

Zaktualizowana dokumentacja: README, CLAUDE.md, ARCHITECTURE.md,
CHANGELOG.md, wiki/admin, wiki/operator.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 14:28:03 +02:00
parent 03c6ec7cae
commit 4b70b910a9
45 changed files with 2038 additions and 156 deletions

View File

@@ -0,0 +1,78 @@
<?php
use App\Livewire\Admin\Logs;
use App\Models\User;
use Livewire\Livewire;
function adminUserForLogsTest(): User
{
return User::query()->create(['name' => 'Admin', 'email' => 'admin-logs@example.com', 'roles' => ['admin']]);
}
beforeEach(function () {
$this->logPath = storage_path('logs/pest-admin-logs-test.log');
file_put_contents($this->logPath, implode("\n", [
'[2026-08-05 08:00:00] production.INFO: normalny wpis o połączeniu',
'[2026-08-05 08:01:00] production.ERROR: coś się nie udało',
'#0 stack trace line belonging to the ERROR entry above',
'[2026-08-05 08:02:00] production.WARNING: uwaga na przyszłość',
])."\n");
});
afterEach(function () {
@unlink($this->logPath);
});
test('admin sees the log file list and its tail content', function () {
$admin = adminUserForLogsTest();
Livewire::actingAs($admin)->test(Logs::class)
->set('selectedFile', 'pest-admin-logs-test.log')
->assertSee('pest-admin-logs-test.log')
->assertSee('normalny wpis o połączeniu')
->assertSee('coś się nie udało')
->assertSee('stack trace line belonging to the ERROR entry above');
});
test('multi-line entries stay grouped and level filter narrows to matching entries', function () {
$admin = adminUserForLogsTest();
$component = Livewire::actingAs($admin)->test(Logs::class)
->set('selectedFile', 'pest-admin-logs-test.log');
expect($component->get('entries'))->toHaveCount(3);
$component->set('levelFilter', 'ERROR');
$entries = $component->get('entries');
expect($entries)->toHaveCount(1)
->and($entries[0]['text'])->toContain('coś się nie udało')
->and($entries[0]['text'])->toContain('stack trace line belonging to the ERROR entry above');
});
test('search filters entries by substring', function () {
$admin = adminUserForLogsTest();
$component = Livewire::actingAs($admin)->test(Logs::class)
->set('selectedFile', 'pest-admin-logs-test.log')
->set('search', 'uwaga');
expect($component->get('entries'))->toHaveCount(1)
->and($component->get('entries')[0]['text'])->toContain('uwaga na przyszłość');
});
test('selecting an unknown file name is ignored, preventing path traversal via the public property', function () {
$admin = adminUserForLogsTest();
$component = Livewire::actingAs($admin)->test(Logs::class)
->set('selectedFile', 'pest-admin-logs-test.log')
->call('selectFile', '../../.env');
expect($component->get('selectedFile'))->toBe('pest-admin-logs-test.log');
});
test('non-admin cannot open the admin panel logs tab', function () {
$operator = User::query()->create(['name' => 'Op', 'email' => 'op-logs@example.com', 'roles' => ['operator']]);
$this->actingAs($operator)->get('/admin')->assertForbidden();
});

View File

@@ -0,0 +1,59 @@
<?php
use App\Livewire\Operator\Stats;
use App\Models\Category;
use App\Models\User;
use Livewire\Livewire;
test('byCategory counts tickets routed to a bare category (no subcategory) alongside subcategorized ones', function () {
seedStatusesAndPriorities();
$admin = User::query()->create(['name' => 'Admin', 'email' => 'stats-admin-cat@example.com', 'roles' => ['admin']]);
$it = Category::query()->create(['name' => 'IT-Pomoc']);
$vpn = $it->subcategories()->create(['name' => 'VPN']);
$delegacje = Category::query()->create(['name' => 'Delegacje']);
makeTicket(['number' => '7001', 'subcategory_id' => $vpn->id]);
makeTicket(['number' => '7002', 'subcategory_id' => $vpn->id]);
// Routed to a whole category with no subcategory (e.g. an IMAP mailbox
// routed to "całą kategorię") — previously invisible to byCategory().
makeTicket(['number' => '7003', 'category_id' => $delegacje->id]);
makeTicket(['number' => '7004', 'category_id' => $delegacje->id]);
makeTicket(['number' => '7005', 'category_id' => $delegacje->id]);
$rows = Livewire::actingAs($admin)->test(Stats::class)->instance()->byCategory;
expect($rows->firstWhere('label', 'IT-Pomoc'))->toBe(['label' => 'IT-Pomoc', 'count' => 2])
->and($rows->firstWhere('label', 'Delegacje'))->toBe(['label' => 'Delegacje', 'count' => 3])
->and($rows->count())->toBe(2);
});
test('byCategory sums both bare-category and subcategorized tickets into the same category total', function () {
seedStatusesAndPriorities();
$admin = User::query()->create(['name' => 'Admin', 'email' => 'stats-admin-cat-2@example.com', 'roles' => ['admin']]);
$it = Category::query()->create(['name' => 'IT-Pomoc']);
$vpn = $it->subcategories()->create(['name' => 'VPN']);
makeTicket(['number' => '7101', 'subcategory_id' => $vpn->id]);
makeTicket(['number' => '7102', 'category_id' => $it->id]);
$rows = Livewire::actingAs($admin)->test(Stats::class)->instance()->byCategory;
expect($rows->firstWhere('label', 'IT-Pomoc'))->toBe(['label' => 'IT-Pomoc', 'count' => 2]);
});
test('filterCategory includes tickets routed to a bare category with no subcategory', function () {
seedStatusesAndPriorities();
$admin = User::query()->create(['name' => 'Admin', 'email' => 'stats-admin-cat-3@example.com', 'roles' => ['admin']]);
$delegacje = Category::query()->create(['name' => 'Delegacje']);
makeTicket(['number' => '7201', 'category_id' => $delegacje->id]);
makeTicket(['number' => '7202']);
$kpis = Livewire::actingAs($admin)->test(Stats::class)
->set('filterCategory', (string) $delegacje->id)
->instance()->kpis;
expect($kpis['total'])->toBe(1);
});

View File

@@ -28,3 +28,22 @@ test('bySubcategory groups tickets per subcategory, labeled "Category / Subcateg
->and($rows->firstWhere('label', 'Zamówienia / Sprzęt'))->toBe(['label' => 'Zamówienia / Sprzęt', 'count' => 1])
->and($rows->count())->toBe(3);
});
test('bySubcategory gives tickets routed to a bare category (no subcategory) their own "(bez podkategorii)" row', function () {
seedStatusesAndPriorities();
$admin = User::query()->create(['name' => 'Admin', 'email' => 'stats-admin-sub-2@example.com', 'roles' => ['admin']]);
$it = Category::query()->create(['name' => 'IT-Pomoc']);
$vpn = $it->subcategories()->create(['name' => 'VPN']);
$delegacje = Category::query()->create(['name' => 'Delegacje']);
makeTicket(['number' => '4101', 'subcategory_id' => $vpn->id]);
makeTicket(['number' => '4102', 'category_id' => $delegacje->id]);
makeTicket(['number' => '4103', 'category_id' => $delegacje->id]);
$rows = Livewire::actingAs($admin)->test(Stats::class)->instance()->bySubcategory;
expect($rows->firstWhere('label', 'IT-Pomoc / VPN'))->toBe(['label' => 'IT-Pomoc / VPN', 'count' => 1])
->and($rows->firstWhere('label', 'Delegacje (bez podkategorii)'))->toBe(['label' => 'Delegacje (bez podkategorii)', 'count' => 2])
->and($rows->count())->toBe(2);
});

View File

@@ -41,6 +41,18 @@ test('next ticket number is one above the current max', function () {
expect(Ticket::nextNumber())->toBe('1002');
});
test('next ticket number compares numerically, not lexicographically, across differing digit counts', function () {
seedStatusesAndPriorities();
// A plain string MAX()/ORDER BY would rank '999' above '1000' (lexicographic
// "9" > "1"), which is exactly the bug nextNumber()'s length-first ordering
// guards against — see Ticket::nextNumber().
makeTicket(['number' => '999']);
makeTicket(['number' => '1000']);
expect(Ticket::nextNumber())->toBe('1001');
});
test('sla info reports overdue once the resolution deadline has passed', function () {
seedStatusesAndPriorities();

View File

@@ -0,0 +1,50 @@
<?php
use App\Models\Category;
use App\Models\CustomField;
test('creating a ticket with custom_fields populates ticket_field_values with matching rows', function () {
seedStatusesAndPriorities();
$category = Category::query()->create(['name' => 'IT-Pomoc']);
$subcategory = $category->subcategories()->create(['name' => 'VPN']);
$field = CustomField::query()->create(['label' => 'Teamviewer-ID', 'type' => 'text', 'required' => false, 'sort_order' => 1]);
$field->subcategories()->attach($subcategory->id, ['position' => 1]);
$ticket = makeTicket(['subcategory_id' => $subcategory->id, 'custom_fields' => [$field->id => '123-456']]);
expect($ticket->fieldValues)->toHaveCount(1);
expect($ticket->fieldValues->first()->custom_field_id)->toBe($field->id);
expect($ticket->fieldValues->first()->value)->toBe('123-456');
});
test('updating custom_fields keeps ticket_field_values in sync — added, changed and removed entries', function () {
seedStatusesAndPriorities();
$category = Category::query()->create(['name' => 'IT-Pomoc']);
$subcategory = $category->subcategories()->create(['name' => 'VPN']);
$fieldA = CustomField::query()->create(['label' => 'A', 'type' => 'text', 'required' => false, 'sort_order' => 1]);
$fieldB = CustomField::query()->create(['label' => 'B', 'type' => 'text', 'required' => false, 'sort_order' => 2]);
$fieldA->subcategories()->attach($subcategory->id, ['position' => 1]);
$fieldB->subcategories()->attach($subcategory->id, ['position' => 2]);
$ticket = makeTicket([
'subcategory_id' => $subcategory->id,
'custom_fields' => [$fieldA->id => 'first', $fieldB->id => 'second'],
]);
$ticket->update(['custom_fields' => [$fieldA->id => 'changed']]);
expect($ticket->fieldValues()->count())->toBe(1);
expect($ticket->fieldValues()->first()->value)->toBe('changed');
});
test('a blank custom field value is not synced into ticket_field_values', function () {
seedStatusesAndPriorities();
$category = Category::query()->create(['name' => 'IT-Pomoc']);
$subcategory = $category->subcategories()->create(['name' => 'VPN']);
$field = CustomField::query()->create(['label' => 'A', 'type' => 'text', 'required' => false, 'sort_order' => 1]);
$field->subcategories()->attach($subcategory->id, ['position' => 1]);
$ticket = makeTicket(['subcategory_id' => $subcategory->id, 'custom_fields' => [$field->id => '']]);
expect($ticket->fieldValues()->count())->toBe(0);
});

View File

@@ -0,0 +1,38 @@
<?php
use App\Models\Ticket;
test('a ticket accepts every known source value', function () {
seedStatusesAndPriorities();
foreach (Ticket::SOURCES as $source) {
$ticket = makeTicket(['number' => uniqid(), 'source' => $source]);
expect($ticket->source)->toBe($source);
}
});
test('creating a ticket with an unrecognized source throws instead of silently storing it', function () {
seedStatusesAndPriorities();
expect(fn () => makeTicket(['source' => 'totally-made-up']))
->toThrow(InvalidArgumentException::class);
});
test('a ticket message accepts a null source (implicit web) and the known email source', function () {
seedStatusesAndPriorities();
$ticket = makeTicket();
$webMessage = $ticket->messages()->create(['author_name' => 'A', 'body' => 'web reply']);
$mailMessage = $ticket->messages()->create(['author_name' => 'B', 'body' => 'mail reply', 'source' => 'email']);
expect($webMessage->source)->toBeNull()
->and($mailMessage->source)->toBe('email');
});
test('a ticket message with an unrecognized source throws instead of silently storing it', function () {
seedStatusesAndPriorities();
$ticket = makeTicket();
expect(fn () => $ticket->messages()->create(['author_name' => 'A', 'body' => 'x', 'source' => 'totally-made-up']))
->toThrow(InvalidArgumentException::class);
});