From 4b70b910a9c03592f13ed86aabee47dc715955aa Mon Sep 17 00:00:00 2001 From: Kacper Date: Wed, 5 Aug 2026 14:28:03 +0200 Subject: [PATCH] v1.5.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ARCHITECTURE.md | 72 ++- CHANGELOG.md | 61 +++ CLAUDE.md | 6 +- README.md | 4 + scripts/hesk-import/README.md | 58 +- .../Console/Commands/ImportHeskTickets.php | 500 ++++++++++++++++-- src/app/Livewire/Admin/Logs.php | 171 ++++++ src/app/Livewire/Admin/Panel.php | 1 + src/app/Livewire/Operator/Queue.php | 4 +- src/app/Livewire/Operator/Stats.php | 69 ++- src/app/Models/EmailTemplate.php | 2 +- src/app/Models/Ticket.php | 174 +++++- src/app/Models/TicketAiSummary.php | 24 + src/app/Models/TicketFieldValue.php | 21 + src/app/Models/TicketMessage.php | 17 + src/app/Models/TicketSnipeitAsset.php | 23 + src/app/Models/User.php | 4 +- src/app/Services/AiClient.php | 27 +- src/app/Services/TicketAiSummaryService.php | 40 +- src/app/Services/TicketAiTriageService.php | 19 +- src/config/logging.php | 23 + src/database/factories/UserFactory.php | 13 - ...5_000160_add_hesk_ticket_id_to_tickets.php | 30 ++ .../2026_08_05_000161_drop_unused_columns.php | 51 ++ ...00162_create_ticket_field_values_table.php | 77 +++ ...63_add_reverse_indexes_to_pivot_tables.php | 54 ++ ...00164_create_ticket_ai_summaries_table.php | 74 +++ ...165_create_ticket_snipeit_assets_table.php | 64 +++ src/database/seeders/DatabaseSeeder.php | 21 +- .../views/components/theme-toggle.blade.php | 4 +- .../views/livewire/admin/logs.blade.php | 69 +++ .../views/livewire/admin/panel.blade.php | 20 +- .../livewire/client/new-ticket.blade.php | 2 +- .../livewire/client/ticket-show.blade.php | 20 +- .../livewire/operator/new-ticket.blade.php | 2 +- .../views/livewire/operator/queue.blade.php | 35 +- .../livewire/operator/ticket-show.blade.php | 67 ++- src/tests/Feature/AdminLogsViewerTest.php | 78 +++ .../Feature/StatsCategoryBreakdownTest.php | 59 +++ .../Feature/StatsSubcategoryBreakdownTest.php | 19 + src/tests/Feature/TicketBusinessRulesTest.php | 12 + .../Feature/TicketFieldValuesSyncTest.php | 50 ++ .../Feature/TicketSourceValidationTest.php | 38 ++ wiki/admin/README.md | 11 + wiki/operator/README.md | 4 +- 45 files changed, 2038 insertions(+), 156 deletions(-) create mode 100644 src/app/Livewire/Admin/Logs.php create mode 100644 src/app/Models/TicketAiSummary.php create mode 100644 src/app/Models/TicketFieldValue.php create mode 100644 src/app/Models/TicketSnipeitAsset.php create mode 100644 src/database/migrations/2026_08_05_000160_add_hesk_ticket_id_to_tickets.php create mode 100644 src/database/migrations/2026_08_05_000161_drop_unused_columns.php create mode 100644 src/database/migrations/2026_08_05_000162_create_ticket_field_values_table.php create mode 100644 src/database/migrations/2026_08_05_000163_add_reverse_indexes_to_pivot_tables.php create mode 100644 src/database/migrations/2026_08_05_000164_create_ticket_ai_summaries_table.php create mode 100644 src/database/migrations/2026_08_05_000165_create_ticket_snipeit_assets_table.php create mode 100644 src/resources/views/livewire/admin/logs.blade.php create mode 100644 src/tests/Feature/AdminLogsViewerTest.php create mode 100644 src/tests/Feature/StatsCategoryBreakdownTest.php create mode 100644 src/tests/Feature/TicketFieldValuesSyncTest.php create mode 100644 src/tests/Feature/TicketSourceValidationTest.php diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 14c7bbf..d25542c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -36,6 +36,9 @@ Category ─< Subcategory ─< CustomField (per-subcategory custom fields ├──< TicketMessage (public replies + internal notes) ├──< TicketAttachment ├──< TicketHistory + ├──< TicketFieldValue (queryable custom_fields values, kept in sync) + ├── aiSummary → TicketAiSummary (1:1, triage+summary state) + ├── snipeitAsset → TicketSnipeitAsset (1:1, linked asset) ├── customer/assignee → User ├── status → Status (fixed stages: new/open/closed) ├── priority → Priority → SlaRule (response/resolution minutes) @@ -63,6 +66,38 @@ queue + unassigned + anything assigned to them, an admin sees everything), and work-timer tracking (`timerElapsedSeconds()`). Keep ticket-shaped logic here rather than spreading it across Livewire components. +**Virtual `ai_*`/`snipeit_*` attributes.** The AI triage/summary fields +(`ai_triaged_at`, `ai_summary`, `ai_suggested_action`, `ai_summary_generated_at`) +and the Snipe-IT link (`snipeit_asset_id`, `snipeit_asset_name`) are **not** +real columns on `tickets` — they live on the related `TicketAiSummary`/ +`TicketSnipeitAsset` rows shown in the diagram above (each table's own columns +drop the prefix, e.g. `ticket_ai_summaries.summary`). `Ticket` overrides +`getAttribute()`/`setAttribute()` (see `AI_SUMMARY_FIELD_MAP`/ +`SNIPEIT_FIELD_MAP`) so every existing `$ticket->ai_summary`/ +`$ticket->update(['snipeit_asset_id' => ...])` call site keeps working +unchanged against the new tables — the same pattern `TicketMessage` already +uses for its own virtual `role`/`author_id`. A write is queued +(`$pendingVirtualAttributes`) and flushed into the related row's +`updateOrCreate()` on the model's `saved` event, since a brand-new ticket has +no id yet to key the related row on until that point. If you add a new +`ai_*`/`snipeit_*` field, add it to the relevant `FIELD_MAP` rather than to +`tickets` directly. + +**Custom field values.** `tickets.custom_fields` (a JSON blob, `field.id => +value`) stays the source of truth for reads/writes — `TicketFieldValue` +(`ticket_field_values`, one row per non-blank entry) is a queryable mirror +kept in sync automatically by `Ticket::syncFieldValues()` (called from the +same `saved` hook whenever `custom_fields` changes), so reporting can +filter/join on "tickets where custom field X = Y" without scanning JSON. +Nothing else needs to write to `ticket_field_values` directly. + +**`source` validation.** `Ticket::SOURCES`/`TicketMessage::SOURCES` are the +only values ever allowed in `tickets.source`/`ticket_messages.source` +(`'web'`/`'email'`/`'hesk_import'`; `null` still means "web" for messages) — +enforced by a `saving` listener that throws `InvalidArgumentException` on +anything else, so a typo'd literal fails loudly instead of sticking silently. +Add new values to the constant before writing them anywhere. + ## Ticket numbering & URLs A ticket carries three distinct identifiers, each with a different job: @@ -449,6 +484,26 @@ Requires the same external `schedule:run` cron entry as SLA/automation (see [CLAUDE.md](CLAUDE.md)) — without it, only the manual "Pobierz teraz" button does anything. +## Log channels & the admin log viewer + +`config/logging.php` defines three dedicated channels alongside the app's +default one, each daily/14-day-retention and always `debug` level regardless +of `.env`'s `LOG_LEVEL` (so they stay useful even when the app itself runs at +`error`): `imap` (`storage/logs/imap-*.log` — see "IMAP e-mail intake" +above), `ai` (`storage/logs/ai.log` — every `ai:run-ticket-automation` run, +used by both `TicketAiTriageService` and `TicketAiSummaryService`, plus +`AiClient`'s own request/response/failure logging), and `hesk_import` +(`storage/logs/hesk-import.log` — every `hesk:import` run). `Admin\Logs` +(`app/Livewire/Admin/Logs.php`, Admin > Logi) is a read-only viewer over +`storage/logs/*.log` (any file, not just these three) — it reads only the +last 4 MB of a file to bound memory on large ones, splits raw log text back +into individual entries by the `[YYYY-MM-DD HH:MM:SS]` line prefix (so a +multi-line stack trace stays grouped with the line that started it), and +offers level/free-text/entry-count filters plus an optional `wire:poll.5s` +auto-refresh. `selectedFile` is validated against the real glob'd file list +on every read, not trusted as a path — a crafted value (e.g. `../../.env`) +is silently ignored rather than read. + ## API `routes/api.php` + `app/Http/Controllers/Api/` expose a small ability-scoped REST @@ -592,9 +647,12 @@ Blade component renders that shape everywhere an asset list shows up skips its own wrapping `
` when embedded inside a caller-provided one (the inventory-search box + its results share one card). -A linked ticket only stores `tickets.snipeit_asset_id` + a cached -`snipeit_asset_name` label (`TicketService::setSnipeitAsset()`, which also -writes a ticket-history line) — no other Snipe-IT fields are persisted. +A linked ticket only stores an `asset_id` + a cached `asset_name` label on +the related `ticket_snipeit_assets` row (`TicketService::setSnipeitAsset()`, +which also writes a ticket-history line — see "Virtual `ai_*`/`snipeit_*` +attributes" above for how this reads/writes as `$ticket->snipeit_asset_id` +despite not being a `tickets` column) — no other Snipe-IT fields are +persisted. Anywhere a linked asset's live detail is shown (the "Powiązany sprzęt" card), it's re-fetched fresh via `SnipeItClient::asset($id)` rather than trusted from the cache, so a status/reassignment change made directly in Snipe-IT is @@ -617,7 +675,9 @@ live customer submitting a ticket: state (no category/subcategory at all → assign both; category but no subcategory → pick one within it; already has a subcategory → recheck and possibly correct), independently of the subject/priority toggles. Every - scanned ticket gets `tickets.ai_triaged_at` stamped exactly once — this is + scanned ticket gets `ai_triaged_at` stamped exactly once (on the related + `ticket_ai_summaries` row, not `tickets` itself — see "Virtual + `ai_*`/`snipeit_*` attributes" above) — this is a one-shot pass, not a continuous recheck, and there's deliberately no manual per-ticket re-trigger. Resolution is fail-closed the same way as the BookStack tagger: every value the model returns is matched against the @@ -637,8 +697,8 @@ live customer submitting a ticket: per pass. - **`App\Services\TicketAiSummaryService`** — a summary + suggested next action for **every** ticket (gated by a single `ai_summary_enabled` - toggle), cached on `tickets.ai_summary`/`ai_suggested_action`/ - `ai_summary_generated_at` and shown only in the operator ticket view (a + toggle), cached on the related `ticket_ai_summaries` row's `summary`/ + `suggested_action`/`summary_generated_at` and shown only in the operator ticket view (a "Podsumowanie AI" sidebar card, lazy-loaded via `wire:init` like the BookStack suggestions card next to it). `run()` (the scheduled sweep) regenerates whenever a ticket's latest message postdates its last summary diff --git a/CHANGELOG.md b/CHANGELOG.md index 75e13f1..d1d2443 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,67 @@ All notable changes to this project are documented in this file. Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [1.5.0] - 2026-08-05 + +### Added + +- **Admin log viewer** (Admin > Logi) — browse `storage/logs/*.log` from the + admin panel without shell access to the container: a file picker (size + + last-modified, most recent first), level/free-text/entry-count filters, and + an optional 5s auto-refresh. Read-only, admin-only; reads only the tail of + large files to keep it fast. +- **"Bez kategorii" filter** in the operator queue's category dropdown — + isolates tickets with neither a category nor subcategory assigned (e.g. an + IMAP mailbox routed to nothing in particular), previously only reachable by + scanning the unfiltered list. + +### Changed + +- **Hesk import tool** (`scripts/hesk-import/`) no longer auto-creates client + accounts for unrecognized requester e-mails — the servicedesk user base is + now treated as authoritative, so a Hesk ticket whose requester has no + matching account is skipped instead (reported at the end, with the list of + skipped e-mails). Hesk staff replies/notes and ticket ownership are now + linked to real operator/admin accounts when the staff e-mail matches one. + Two new backfill flags cover tickets imported before these existed: + `--assign-operators` (sets `assignee_id` from Hesk's ticket owner, never + overwriting a manual reassignment) and `--fix-closed-dates` (corrects a + closed ticket's date to Hesk's own `closedat` column instead of the + drifting `lastchange`, and adds the missing "Zamknięte" history entry). + Every newly imported ticket also records its source Hesk id + (`tickets.hesk_ticket_id`, unique) as a second, DB-level guard against + duplicate imports on top of the existing state file. See + `scripts/hesk-import/README.md` for details. +- Internal database cleanup: removed three columns confirmed unused against + live data (`users.remember_token`, `users.email_verified_at`, + `email_templates.trigger_label`); moved custom field values, AI + triage/summary state, and the linked Snipe-IT asset off the `tickets` row + into three dedicated one-to-one tables (`ticket_field_values`, + `ticket_ai_summaries`, `ticket_snipeit_assets`) — no visible behavior + change, but custom field values are now efficiently queryable instead of + living only in a JSON blob, and the `tickets` row itself is narrower; added + missing reverse indexes on 4 pivot tables (`team_subcategory`, `role_user`, + `team_user`, `custom_field_subcategory`); `tickets.source` and + `ticket_messages.source` now validate against a known set of values + instead of silently accepting any string. + +### Fixed + +- The operator statistics dashboard's category/subcategory breakdown (and + its category filter) only counted tickets routed through a subcategory — + a ticket routed to a whole category with no subcategory (e.g. via an IMAP + mailbox routed to "całą kategorię") was silently excluded from those + charts and from filtering by that category. Now counted correctly, with + bare-category tickets shown as their own "(bez podkategorii)" row in the + subcategory breakdown. +- A JavaScript error (and stray background timers left running) could occur + when navigating away from a page with an active countdown/timer widget — + ticket/queue auto-refresh, the theme switcher, file-attachment + drag-and-drop. Most consequential in the operator ticket time tracker, + where it could throw console errors and momentarily break other UI + elements (e.g. dropdown menus) after leaving a ticket with the timer + running. + ## [1.4.0] - 2026-08-05 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index f3cbf07..c2e7387 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,7 +86,11 @@ case, just check the container instead of the crontab. IMAP-specific activity ids) is logged separately from the app's normal `LOG_LEVEL` to `storage/logs/imap-*.log` (see the `imap` channel in `config/logging.php`) — check there first when a mailbox isn't behaving as expected, before assuming -the scheduler itself isn't firing. +the scheduler itself isn't firing. `ai:run-ticket-automation` and +`hesk:import` get the same always-debug treatment via the `ai`/`hesk_import` +channels (`storage/logs/ai.log`/`hesk-import.log`). All of `storage/logs/*.log` +is also browsable from Admin > Logi (`app/Livewire/Admin/Logs.php`) if you'd +rather not `docker exec` in just to tail a file. All four commands' intervals are admin-configurable (Admin > Konfiguracja — `schedule_sla_check_minutes`/`schedule_automation_rules_minutes`/ diff --git a/README.md b/README.md index 36cd19c..b524617 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,10 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides. e-mail layout/footer, SMTP connection (Admin > E-MAIL), attachment limits, session lifetime, timezone (Admin > Konfiguracja), and LDAP connection + user sync + BookStack (Admin > Integracje). +- **Log viewer** (Admin > Logi) — browse `storage/logs/*.log` (app, IMAP, AI + automation, Hesk import, ...) from the admin panel, with level/text/entry-count + filters and an optional auto-refresh, so diagnosing a scheduled integration + doesn't need shell access to the container. - **LDAP auth** — logins bind against a directory (`config/auth.php`, `config/ldap.php`); local accounts (e.g. the emergency `admin` account) fall back to e-mail + local password when the LDAP bind doesn't match. A "Typ katalogu" diff --git a/scripts/hesk-import/README.md b/scripts/hesk-import/README.md index 05aa3a5..d70defb 100644 --- a/scripts/hesk-import/README.md +++ b/scripts/hesk-import/README.md @@ -20,13 +20,24 @@ old Hesk install; it doesn't pick up edits made in Hesk afterward. - Auto-assigns a team when every subcategory under the matched category routes to the same single team (same rule `TicketService::autoAssignTeam()` uses for normal ticket creation); ambiguous categories are left unrouted. -- Finds or creates a client account per requester e-mail, reusing an existing - account (adding the `client` role if it doesn't have it yet) rather than - duplicating. -- Hesk staff replies are **not** linked to a real operator account (this - script never creates operator accounts) — the reply still shows the - correct staff name and "operator" badge via `author_name`, just without a - clickable user behind it. +- Finds the local client account per requester e-mail (matched by e-mail, + adding the `client` role if it doesn't have it yet). **Never creates a + User** — the servicedesk user base is treated as authoritative/complete, so + a Hesk requester e-mail with no matching account means that ticket is + skipped (reported at the end, with the list of skipped e-mails). +- Hesk staff replies/notes are linked to a real operator account when the + Hesk staff member's e-mail matches an existing servicedesk operator/admin + account; otherwise they fall back to showing the correct staff name and + "operator" badge via `author_name` only, without a clickable user behind + it (this script never creates operator accounts either). +- Every imported ticket also stores its source Hesk ticket id + (`tickets.hesk_ticket_id`, unique). This is a second, DB-level guard + against duplicate imports on top of the state file below — if the state + file is ever lost or out of sync, a re-run still can't create a duplicate + ticket for the same Hesk id. +- A ticket's Hesk owner is matched the same way as reply/note authors and + set as `assignee_id`, so imported tickets show up correctly assigned in + the operator queue instead of everything landing in "Nieprzypisane". ## Setup @@ -73,6 +84,39 @@ tickets without importing anything new: scripts/hesk-import/hesk-import.sh --assign-teams --commit ``` +### Fixing closed-ticket dates + +New imports already use Hesk's dedicated `closedat` column (not `lastchange`, +which moves forward on any later edit — e.g. a note added after closing) for +a closed ticket's date, and record a matching "Status zmieniony na: Zamknięte" +history entry. To apply the same correction to tickets imported before this +existed (including the original import, from before `hesk_ticket_id` was +even tracked — matched back to Hesk via the unique `(email, created_at)` pair +instead): + +```bash +scripts/hesk-import/hesk-import.sh --fix-closed-dates --commit +``` + +Doesn't import anything new; safe to re-run (already-correct tickets are left +alone). + +### Backfilling ticket ownership + +New imports already set `assignee_id` from Hesk's ticket owner. To apply the +same to tickets imported before this existed: + +```bash +scripts/hesk-import/hesk-import.sh --assign-operators --commit +``` + +Only touches tickets with no `assignee_id` yet (never overwrites a manual +reassignment made since import) and never invents an assignment — a Hesk +owner of 0 or one with no matching servicedesk account is left unassigned, +unless it's one of the two ids in `ImportHeskTickets::DELETED_STAFF_REASSIGNMENT` +(Hesk staff accounts deleted since, with historical tickets explicitly +reassigned to a current operator per the app owner). + ## How it's wired up `hesk-import.sh` is a thin wrapper: it loads `.env` in this folder, then runs diff --git a/src/app/Console/Commands/ImportHeskTickets.php b/src/app/Console/Commands/ImportHeskTickets.php index fd9265e..5611066 100644 --- a/src/app/Console/Commands/ImportHeskTickets.php +++ b/src/app/Console/Commands/ImportHeskTickets.php @@ -4,13 +4,16 @@ namespace App\Console\Commands; use App\Models\Category; use App\Models\Role; +use App\Models\Status; use App\Models\Team; use App\Models\Ticket; +use App\Models\TicketHistory; use App\Models\User; use Illuminate\Console\Command; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; use PDO; /** @@ -20,10 +23,16 @@ use PDO; * Runs in dry-run mode by default (reports what it would do); pass --commit * to actually write. Resumable: every successfully imported Hesk ticket id * is recorded in a local state file (--state, defaults to - * storage/app/hesk-import-state.json), so a re-run (interrupted connection, - * crashed midway, etc.) skips tickets already imported instead of - * duplicating them. Not idempotent across *edits* on the Hesk side — this is - * a one-time historical import, not an ongoing sync. + * storage/app/hesk-import-state.json). Belt-and-suspenders: tickets.hesk_ticket_id + * is also unique at the DB level, so a state file that's lost/desynced from + * a crash between commit and state-save can't turn into a silent duplicate — + * see alreadyImported(). Not idempotent across *edits* on the Hesk side — + * this is a one-time historical import, not an ongoing sync. + * + * As of the 2026-08 re-import pass, this never creates new local User + * accounts (the servicedesk user base is considered authoritative/complete) + * — a Hesk requester e-mail with no matching account means the ticket is + * skipped rather than auto-provisioning one. See resolveCustomer(). */ class ImportHeskTickets extends Command { @@ -33,7 +42,9 @@ class ImportHeskTickets extends Command {--limit= : Only process this many Hesk tickets (after the domain filter), useful for a test run} {--state= : Path to the resume-state JSON file (default storage/app/hesk-import-state.json)} {--include-unmapped-categories : Also import tickets whose Hesk category has no matching servicedesk category (default: skip them)} - {--assign-teams : Backfill team_id (by category) on already-imported tickets that don\'t have one yet, then exit — does not import anything}'; + {--assign-teams : Backfill team_id (by category) on already-imported tickets that don\'t have one yet, then exit — does not import anything} + {--fix-closed-dates : Backfill accurate closedat-based updated_at + a closure history entry on already-imported closed tickets, then exit — does not import anything} + {--assign-operators : Backfill assignee_id (from Hesk\'s ticket owner, matched by e-mail to an existing operator account) on already-imported tickets that don\'t have one yet, then exit — does not import anything}'; protected $description = 'Import tickets (with full reply/note history) from a Hesk 3.x database, restricted to one e-mail domain'; @@ -71,6 +82,21 @@ class ImportHeskTickets extends Command /** @var array Hesk help_users.id => name, loaded once */ private array $heskStaffNames = []; + /** @var array Hesk help_users.id => email, loaded once */ + private array $heskStaffEmails = []; + + /** @var array Hesk help_users.id => matching local operator/admin account (or null), memoized */ + private array $operatorCache = []; + + /** @var int[] hesk ticket ids already present in tickets.hesk_ticket_id — see alreadyImported() */ + private array $importedHeskIds = []; + + /** @var Collection Hesk ticket id => its help_replies rows, preloaded in bulk for the whole run — see fix for the old per-ticket N+1 query */ + private Collection $repliesByTicket; + + /** @var Collection Hesk ticket id => its help_notes rows, preloaded in bulk */ + private Collection $notesByTicket; + private array $state = ['imported' => []]; private string $statePath; @@ -81,6 +107,18 @@ class ImportHeskTickets extends Command return $this->runAssignTeams((bool) $this->option('commit')); } + if ($this->option('fix-closed-dates')) { + return $this->configureHeskConnection() + ? $this->runFixClosedDates((bool) $this->option('commit')) + : self::FAILURE; + } + + if ($this->option('assign-operators')) { + return $this->configureHeskConnection() + ? $this->runAssignOperators((bool) $this->option('commit')) + : self::FAILURE; + } + $domain = trim((string) $this->option('domain'), " \t\n\r\0\x0B@"); if ($domain === '') { @@ -102,6 +140,7 @@ class ImportHeskTickets extends Command $this->buildCategoryMap(); $this->buildTeamMap(); $this->loadHeskStaffNames(); + $this->importedHeskIds = Ticket::query()->whereNotNull('hesk_ticket_id')->pluck('hesk_ticket_id')->all(); $tickets = DB::connection('hesk')->table('help_tickets') ->where('email', 'like', '%@'.$domain) @@ -109,16 +148,29 @@ class ImportHeskTickets extends Command ->when($limit, fn ($q) => $q->limit($limit)) ->get(); - $this->info(sprintf( + // Only needed once a ticket is actually about to be imported, so + // skip the two bulk queries entirely on a dry run. + if ($commit) { + $ticketIds = $tickets->pluck('id')->all(); + $this->repliesByTicket = DB::connection('hesk')->table('help_replies') + ->whereIn('replyto', $ticketIds)->orderBy('dt')->get()->groupBy('replyto'); + $this->notesByTicket = DB::connection('hesk')->table('help_notes') + ->whereIn('ticket', $ticketIds)->orderBy('dt')->get()->groupBy('ticket'); + } + + $startMessage = sprintf( '%s tryb: %d zgłoszeń z Heska pasuje do domeny @%s (%d już zaimportowanych wcześniej, zostaną pominięte).', $commit ? 'KOMMIT' : 'DRY-RUN', $tickets->count(), $domain, - $tickets->whereIn('id', $this->state['imported'])->count(), - )); + $tickets->pluck('id')->filter(fn ($id) => $this->alreadyImported($id))->count(), + ); + $this->info($startMessage); + Log::channel('hesk_import')->info($startMessage); - $stats = ['created' => 0, 'skipped' => 0, 'skipped_unmapped_category' => 0, 'failed' => 0, 'messages' => 0, 'customers_created' => 0]; + $stats = ['created' => 0, 'skipped' => 0, 'skipped_unmapped_category' => 0, 'skipped_unknown_customer' => 0, 'failed' => 0, 'messages' => 0, 'operator_messages_linked' => 0]; $unmappedCategories = []; + $unknownCustomerEmails = []; $bar = $this->output->createProgressBar($tickets->count()); $bar->start(); @@ -126,7 +178,7 @@ class ImportHeskTickets extends Command foreach ($tickets as $heskTicket) { $bar->advance(); - if (in_array($heskTicket->id, $this->state['imported'], true)) { + if ($this->alreadyImported($heskTicket->id)) { $stats['skipped']++; continue; @@ -147,6 +199,22 @@ class ImportHeskTickets extends Command continue; } + // The local user base is authoritative as of the 2026-08 + // re-import — a requester e-mail with no matching account is + // skipped rather than auto-provisioning a new client (see + // resolveCustomer()). Checked even in dry-run so the preview + // accurately reflects what --commit would do. + $customerEmail = trim($heskTicket->email); + if (! User::query()->where('email', $customerEmail)->exists()) { + $stats['skipped_unknown_customer']++; + + if (! in_array($customerEmail, $unknownCustomerEmails, true)) { + $unknownCustomerEmails[] = $customerEmail; + } + + continue; + } + if (! $commit) { $stats['created']++; @@ -163,6 +231,9 @@ class ImportHeskTickets extends Command $stats['failed']++; $this->newLine(); $this->error("Zgłoszenie Hesk #{$heskTicket->id} ({$heskTicket->trackid}) nie zostało zaimportowane: ".$e->getMessage()); + Log::channel('hesk_import')->error( + "Zgłoszenie Hesk #{$heskTicket->id} ({$heskTicket->trackid}) nie zostało zaimportowane: {$e->getMessage()}\n".$e->getTraceAsString() + ); } } @@ -172,9 +243,10 @@ class ImportHeskTickets extends Command $this->table(['Miara', 'Wartość'], [ ['Zgłoszenia utworzone', $stats['created']], ['Wiadomości/notatki utworzone', $stats['messages']], - ['Nowe konta klientów', $stats['customers_created']], + ['...w tym powiązane z realnym kontem operatora', $stats['operator_messages_linked']], ['Pominięte (już zaimportowane)', $stats['skipped']], ['Pominięte (kategoria bez odpowiednika)', $stats['skipped_unmapped_category']], + ['Pominięte (brak konta klienta w servicedesk)', $stats['skipped_unknown_customer']], ['Błędy', $stats['failed']], ]); @@ -186,6 +258,26 @@ class ImportHeskTickets extends Command $this->warn("Kategorie Heska bez odpowiednika w servicedesk ({$action}): {$names}"); } + if ($unknownCustomerEmails) { + $this->warn(sprintf( + 'E-maile z Heska bez konta w servicedesk (%d zgłoszeń POMINIĘTYCH): %s', + $stats['skipped_unknown_customer'], + implode(', ', $unknownCustomerEmails), + )); + } + + $summaryMessage = sprintf( + 'Zakończono (%s): utworzone=%d, wiadomości=%d, pominięte=%d, pominięte(kategoria)=%d, pominięte(brak konta)=%d, błędy=%d.', + $commit ? 'commit' : 'dry-run', + $stats['created'], + $stats['messages'], + $stats['skipped'], + $stats['skipped_unmapped_category'], + $stats['skipped_unknown_customer'], + $stats['failed'], + ); + Log::channel('hesk_import')->info($summaryMessage); + if (! $commit) { $this->newLine(); $this->comment('To był dry-run — nic nie zostało zapisane. Uruchom ponownie z --commit, żeby faktycznie zaimportować.'); @@ -194,6 +286,29 @@ class ImportHeskTickets extends Command return self::SUCCESS; } + /** + * State file first (cheap, in-memory), then the DB as a fallback/self-heal + * — a hesk_ticket_id already present on a ticket row means it was + * genuinely committed even if the state file never got updated (crash + * between the transaction commit and saveState()). Tickets imported + * before the hesk_ticket_id column existed have no such row to fall back + * on, so the state file remains authoritative for those — no regression. + */ + private function alreadyImported(int $heskTicketId): bool + { + if (in_array($heskTicketId, $this->state['imported'], true)) { + return true; + } + + if (in_array($heskTicketId, $this->importedHeskIds, true)) { + $this->state['imported'][] = $heskTicketId; + + return true; + } + + return false; + } + private function configureHeskConnection(): bool { $host = env('HESK_DB_HOST'); @@ -328,15 +443,283 @@ class ImportHeskTickets extends Command return self::SUCCESS; } + /** + * Backfill mode (--fix-closed-dates): corrects already-imported closed + * tickets whose updated_at is Hesk's lastchange (any edit — a later note + * or reply — rather than the actual closure) instead of Hesk's dedicated + * closedat column, and adds the missing "Status zmieniony na: Zamknięte" + * history entry every closed ticket should have (importOneTicket() now + * does both automatically for new imports — see there). Idempotent: an + * already-correct ticket, or one that already has that exact history + * line, is left alone. + * + * Also covers tickets imported before tickets.hesk_ticket_id existed + * (no direct link back to Hesk at all — the vast majority of closed + * imported tickets are in this group) by matching them to their source + * Hesk row via (email, created_at) <-> Hesk's (email, dt): every + * imported ticket's created_at is Hesk's dt passed through unchanged, and + * that pair is unique across every hesk_import ticket today (checked — + * zero collisions), since dt is second-precision and two tickets from + * the same requester in the same second essentially never happens. Their + * hesk_ticket_id gets backfilled too as a side effect, closing the gap + * where that link never existed for them. + */ + private function runFixClosedDates(bool $commit): int + { + $tickets = DB::table('tickets') + ->where('source', 'hesk_import') + ->where('status_key', 'closed') + ->get(['id', 'hesk_ticket_id', 'email', 'created_at', 'updated_at']); + + if ($tickets->isEmpty()) { + $this->warn('Brak zamkniętych, zaimportowanych z Heska zgłoszeń.'); + + return self::SUCCESS; + } + + [$withId, $withoutId] = $tickets->partition(fn ($t) => $t->hesk_ticket_id !== null); + + $heskById = $withId->isEmpty() ? collect() : DB::connection('hesk')->table('help_tickets') + ->whereIn('id', $withId->pluck('hesk_ticket_id')) + ->get(['id', 'closedat', 'lastchange']) + ->keyBy('id'); + + $heskByEmailDt = $withoutId->isEmpty() ? collect() : DB::connection('hesk')->table('help_tickets') + ->whereIn('email', $withoutId->pluck('email')->unique()) + ->get(['id', 'email', 'dt', 'closedat', 'lastchange']) + ->keyBy(fn ($row) => $row->email.'|'.$row->dt); + + $closureLabel = 'Status zmieniony na: '.Status::labelFor('closed'); + $alreadyRecorded = TicketHistory::query() + ->whereIn('ticket_id', $tickets->pluck('id')) + ->where('text', $closureLabel) + ->pluck('ticket_id') + ->all(); + + $dateFixed = 0; + $historyAdded = 0; + $idBackfilled = 0; + $unmatched = 0; + + foreach ($tickets as $ticket) { + $heskRow = $ticket->hesk_ticket_id !== null + ? $heskById->get($ticket->hesk_ticket_id) + : $heskByEmailDt->get($ticket->email.'|'.$ticket->created_at); + + if (! $heskRow) { + $unmatched++; + + continue; + } + + if ($ticket->hesk_ticket_id === null) { + $idBackfilled++; + + if ($commit) { + DB::table('tickets')->where('id', $ticket->id)->update(['hesk_ticket_id' => $heskRow->id]); + } + } + + $closedAt = $heskRow->closedat ?: $heskRow->lastchange; + + if (! $closedAt) { + continue; + } + + if ((string) $ticket->updated_at !== (string) $closedAt) { + $dateFixed++; + + if ($commit) { + DB::table('tickets')->where('id', $ticket->id)->update(['updated_at' => $closedAt]); + } + } + + if (! in_array($ticket->id, $alreadyRecorded, true)) { + $historyAdded++; + + if ($commit) { + TicketHistory::query()->create([ + 'ticket_id' => $ticket->id, + 'text' => $closureLabel, + 'created_at' => $closedAt, + ]); + } + } + } + + $this->table(['Miara', 'Wartość'], [ + ['Sprawdzone zamknięte zgłoszenia', $tickets->count()], + ['Poprawiona data zamknięcia', $dateFixed], + ['Dodany wpis historii zamknięcia', $historyAdded], + ['Uzupełniony hesk_ticket_id (stare importy)', $idBackfilled], + ['Bez dopasowania w bazie Heska', $unmatched], + ]); + + if (! $commit) { + $this->newLine(); + $this->comment('To był dry-run — uruchom ponownie z --fix-closed-dates --commit, żeby faktycznie zapisać.'); + } + + return self::SUCCESS; + } + + /** + * Hesk's dedicated closedat column (set once when a ticket transitions + * to Resolved) is a more accurate "when was this actually closed" than + * lastchange, which moves forward on ANY later edit — a note added + * after closing, for instance. Falls back to lastchange for the rare + * closed ticket with no closedat recorded (seen on very old rows). + */ + private function heskClosedAt(object $heskTicket): ?string + { + return $heskTicket->closedat ?: $heskTicket->lastchange; + } + + /** + * Backfill mode (--assign-operators): sets assignee_id (from Hesk's + * ticket owner, resolved to a local operator by e-mail — same + * resolveOperator() used for reply/note authorship) on already-imported + * tickets that don't have one yet. importOneTicket() does this + * automatically for new imports — see there. Never overwrites an + * assignee_id an operator may have since set manually in servicedesk + * (only touches tickets where it's still null), and never invents an + * assignment: a Hesk owner of 0 (unassigned) or one whose e-mail + * doesn't match any local operator (e.g. Hesk staff since deleted — + * seen in practice on this data) is left unassigned rather than guessed. + */ + private function runAssignOperators(bool $commit): int + { + $tickets = DB::table('tickets') + ->where('source', 'hesk_import') + ->whereNotNull('hesk_ticket_id') + ->whereNull('assignee_id') + ->get(['id', 'hesk_ticket_id']); + + if ($tickets->isEmpty()) { + $this->warn('Brak zaimportowanych zgłoszeń bez przypisanego operatora do sprawdzenia.'); + + return self::SUCCESS; + } + + $this->loadHeskStaffNames(); + + $heskOwners = DB::connection('hesk')->table('help_tickets') + ->whereIn('id', $tickets->pluck('hesk_ticket_id')) + ->pluck('owner', 'id'); + + $assigned = 0; + $noHeskOwner = 0; + $ownerUnmatched = 0; + + foreach ($tickets as $ticket) { + $ownerId = (int) ($heskOwners[$ticket->hesk_ticket_id] ?? 0); + + if ($ownerId <= 0) { + $noHeskOwner++; + + continue; + } + + $operator = $this->resolveAssignee($ownerId); + + if (! $operator) { + $ownerUnmatched++; + + continue; + } + + $assigned++; + + if ($commit) { + DB::table('tickets')->where('id', $ticket->id)->update(['assignee_id' => $operator->id]); + } + } + + $this->table(['Miara', 'Wartość'], [ + ['Sprawdzone zgłoszenia bez operatora', $tickets->count()], + ['Przypisano operatora', $assigned], + ['Brak właściciela w Hesku (nieprzypisane)', $noHeskOwner], + ['Właściciel w Hesku bez konta w servicedesk', $ownerUnmatched], + ]); + + if (! $commit) { + $this->newLine(); + $this->comment('To był dry-run — uruchom ponownie z --assign-operators --commit, żeby faktycznie zapisać.'); + } + + return self::SUCCESS; + } + private function loadHeskStaffNames(): void { - $this->heskStaffNames = DB::connection('hesk')->table('help_users')->pluck('name', 'id')->all(); + $rows = DB::connection('hesk')->table('help_users')->select('id', 'name', 'email')->get(); + $this->heskStaffNames = $rows->pluck('name', 'id')->all(); + $this->heskStaffEmails = $rows->pluck('email', 'id')->all(); + } + + /** + * Matches a Hesk staff member to a real local User by e-mail, so + * imported replies/notes are linked to an actual clickable operator + * account rather than only carrying the right name via author_name. + * Falls back to null (unchanged prior behavior) when no local account + * has that e-mail — this never creates operator accounts. + */ + private function resolveOperator(int $heskStaffId): ?User + { + if (! array_key_exists($heskStaffId, $this->operatorCache)) { + $email = $this->heskStaffEmails[$heskStaffId] ?? null; + $this->operatorCache[$heskStaffId] = $email ? User::query()->where('email', $email)->first() : null; + } + + return $this->operatorCache[$heskStaffId]; + } + + /** + * Hesk staff ids whose account has since been fully deleted from Hesk + * (no help_users row left at all — resolveOperator() has no e-mail to + * even look up for these) mapped to the servicedesk operator who should + * own their historical tickets now, per explicit instruction from the + * app owner. staffid 6 was Katarzyna Piewiszkis, 7 was Agnieszka + * Konopka — identified from ticket-history text remnants, not from any + * structured Hesk data (there wasn't any left). + */ + private const DELETED_STAFF_REASSIGNMENT = [ + 6 => 'agolebiowska@polagent.com', + 7 => 'agolebiowska@polagent.com', + ]; + + /** + * Ticket-*ownership*-specific resolution: falls back to + * DELETED_STAFF_REASSIGNMENT when resolveOperator() can't do anything + * (no e-mail left in Hesk to look up). Deliberately not folded into + * resolveOperator() itself — reassigning who now owns a ticket is + * reasonable, but reply/note *authorship* should keep reflecting who + * actually wrote it rather than being silently rewritten to whoever + * ticket ownership was reassigned to. + */ + private function resolveAssignee(int $heskOwnerId): ?User + { + return $this->resolveOperator($heskOwnerId) + ?? (isset(self::DELETED_STAFF_REASSIGNMENT[$heskOwnerId]) + ? User::query()->where('email', self::DELETED_STAFF_REASSIGNMENT[$heskOwnerId])->first() + : null); } private function importOneTicket(object $heskTicket, array &$stats): void { - $customer = $this->resolveCustomer($heskTicket->email, $heskTicket->name, $stats); + $customer = $this->resolveCustomer($heskTicket->email); + + if (! $customer) { + // Guarded against in handle() before this is ever called — kept + // here as a hard stop rather than silently creating an + // orphaned/incorrect ticket if that guard is ever bypassed. + throw new \RuntimeException("Brak konta klienta dla {$heskTicket->email} — nie powinno się zdarzyć, sprawdzono wcześniej w handle()."); + } + $categoryId = $this->categoryMap[$heskTicket->category] ?? null; + $statusKey = self::STATUS_MAP[(int) $heskTicket->status] ?? 'open'; + $closedAt = $statusKey === 'closed' ? $this->heskClosedAt($heskTicket) : null; + $assignee = ((int) $heskTicket->owner) > 0 ? $this->resolveAssignee((int) $heskTicket->owner) : null; $ticket = Ticket::query()->create([ 'number' => Ticket::nextNumber(), @@ -345,21 +728,23 @@ class ImportHeskTickets extends Command 'name' => $heskTicket->name ?: $heskTicket->email, 'category_id' => $categoryId, 'team_id' => $categoryId ? ($this->teamByCategory[$categoryId] ?? null) : null, + 'assignee_id' => $assignee?->id, 'subject' => $this->cleanText($heskTicket->subject) ?: '(bez tematu)', 'body' => $this->cleanText($heskTicket->message), - 'status_key' => self::STATUS_MAP[(int) $heskTicket->status] ?? 'open', + 'status_key' => $statusKey, 'priority_key' => self::PRIORITY_MAP[(int) $heskTicket->priority] ?? 'medium', 'source' => 'hesk_import', + 'hesk_ticket_id' => $heskTicket->id, 'last_customer_activity_at' => $heskTicket->lastchange, 'created_at' => $heskTicket->dt, - 'updated_at' => $heskTicket->lastchange, + 'updated_at' => $closedAt ?? $heskTicket->lastchange, ]); // Ticket::booted() re-saves the row right after create() to stamp a // checksum, which — being a normal Eloquent save() — stomps // updated_at back to "now". Restore the historical value via the // query builder so it bypasses Eloquent's timestamp handling. - DB::table('tickets')->where('id', $ticket->id)->update(['updated_at' => $heskTicket->lastchange]); + DB::table('tickets')->where('id', $ticket->id)->update(['updated_at' => $closedAt ?? $heskTicket->lastchange]); $opening = $ticket->messages()->create([ 'author_name' => $heskTicket->name ?: $heskTicket->email, @@ -378,30 +763,43 @@ class ImportHeskTickets extends Command $this->importNote($ticket, $note, $stats); } + // Mirrors what a real in-app closure leaves behind (see + // TicketService::setStatus()) so an imported closed ticket's + // "Historia zmian" tab isn't empty and shows an accurate closure + // date — without this, nothing else records when/that it closed. + if ($closedAt) { + $ticket->histories()->create([ + 'text' => 'Status zmieniony na: '.Status::labelFor('closed'), + 'created_at' => $closedAt, + ]); + } + $stats['created']++; } + /** + * Both pulled from repliesByTicket/notesByTicket, bulk-preloaded once + * for the whole run in handle() rather than queried per ticket here — + * on a multi-thousand-ticket import that was 2 extra DB round-trips per + * ticket for no reason, since the domain filter already bounds the + * result set to something worth loading in one shot. + */ private function heskReplies(int $heskTicketId): Collection { - return DB::connection('hesk')->table('help_replies') - ->where('replyto', $heskTicketId) - ->orderBy('dt') - ->get(); + return $this->repliesByTicket->get($heskTicketId, collect()); } private function heskNotes(int $heskTicketId): Collection { - return DB::connection('hesk')->table('help_notes') - ->where('ticket', $heskTicketId) - ->orderBy('dt') - ->get(); + return $this->notesByTicket->get($heskTicketId, collect()); } private function importReply(Ticket $ticket, object $reply, User $customer, array &$stats): void { $isStaff = (int) $reply->staffid > 0; + $operator = $isStaff ? $this->resolveOperator((int) $reply->staffid) : null; $authorName = $isStaff - ? ($this->heskStaffNames[$reply->staffid] ?? 'Personel') + ? ($operator->name ?? $this->heskStaffNames[$reply->staffid] ?? 'Personel') : ($reply->name ?: $customer->name); $message = $ticket->messages()->create([ @@ -411,48 +809,54 @@ class ImportHeskTickets extends Command 'updated_at' => $reply->dt, ]); - // Staff replies aren't linked to a real User (we deliberately don't - // create operator accounts for imported Hesk staff — see the - // migration script's design questions) — attachAuthor(null, - // 'operator') still tags the role/badge correctly via author_name. - $message->attachAuthor($isStaff ? null : $customer->id, $isStaff ? 'operator' : 'client'); + // Linked to a real operator User when the Hesk staff e-mail matches + // one (resolveOperator()); otherwise falls back to the previous + // behavior — attachAuthor(null, 'operator') still tags the + // role/badge correctly via author_name, just without a clickable + // user behind it. + $message->attachAuthor($isStaff ? $operator?->id : $customer->id, $isStaff ? 'operator' : 'client'); $stats['messages']++; + + if ($isStaff && $operator) { + $stats['operator_messages_linked']++; + } } private function importNote(Ticket $ticket, object $note, array &$stats): void { + $operator = $this->resolveOperator((int) $note->who); + $message = $ticket->messages()->create([ - 'author_name' => $this->heskStaffNames[$note->who] ?? 'Personel', + 'author_name' => $operator->name ?? $this->heskStaffNames[$note->who] ?? 'Personel', 'internal' => true, 'body' => $this->cleanText($note->message), 'created_at' => $note->dt, 'updated_at' => $note->dt, ]); - $message->attachAuthor(null, 'operator'); + $message->attachAuthor($operator?->id, 'operator'); $stats['messages']++; + + if ($operator) { + $stats['operator_messages_linked']++; + } } /** - * Finds or creates the local client account for a Hesk requester e-mail. - * Reuses an existing account (e.g. the one real admin account, or one - * already created by an earlier ticket from the same person) rather than - * duplicating, and only ever adds the 'client' role — never removes - * whatever roles the account already had. + * Finds the local client account for a Hesk requester e-mail. As of the + * 2026-08 re-import the servicedesk user base is considered + * authoritative/complete — this deliberately never creates a User + * anymore (unlike the original 2026-08-04 run); handle() checks + * existence before a ticket ever reaches here, so returning null is not + * expected in normal operation (see importOneTicket()'s hard-stop guard). + * Only ever adds the 'client' role to a match — never removes whatever + * roles the account already had. */ - private function resolveCustomer(string $email, ?string $name, array &$stats): User + private function resolveCustomer(string $email): ?User { - $email = trim($email); - $user = User::query()->where('email', $email)->first(); + $user = User::query()->where('email', trim($email))->first(); if (! $user) { - $user = User::query()->create([ - 'name' => $name ?: $email, - 'email' => $email, - 'roles' => ['client'], - ]); - $stats['customers_created']++; - - return $user; + return null; } if (! in_array('client', $user->roles, true)) { diff --git a/src/app/Livewire/Admin/Logs.php b/src/app/Livewire/Admin/Logs.php new file mode 100644 index 0000000..6ef8eb7 --- /dev/null +++ b/src/app/Livewire/Admin/Logs.php @@ -0,0 +1,171 @@ +files()->pluck('name'); + $this->selectedFile = $names->first(fn (string $n) => $n === 'laravel.log') ?? $names->first() ?? ''; + } + + public static function availableLevels(): array + { + return self::LEVELS; + } + + /** + * @return array{class: string, style: string} CSS for the level badge — + * reuses the existing .tag-* palette (accent/accent-2/neutral) where + * it fits, and falls back to inline color-mix() (matching the + * danger/warning treatment already used elsewhere, e.g. + * admin/api-keys.blade.php's status tags) for severities with no + * existing tag class. + */ + public static function levelBadge(?string $level): array + { + return match ($level) { + 'DEBUG' => ['class' => 'tag tag-neutral', 'style' => ''], + 'INFO' => ['class' => 'tag tag-accent-2', 'style' => ''], + 'NOTICE' => ['class' => 'tag tag-accent', 'style' => ''], + 'WARNING' => ['class' => 'tag', 'style' => 'background:color-mix(in srgb, var(--color-warning) 20%, transparent);color:var(--color-warning)'], + 'ERROR' => ['class' => 'tag', 'style' => 'background:color-mix(in srgb, var(--color-danger) 18%, transparent);color:var(--color-danger)'], + 'CRITICAL', 'ALERT', 'EMERGENCY' => ['class' => 'tag', 'style' => 'background:var(--color-danger);color:#fff'], + default => ['class' => 'tag tag-outline', 'style' => ''], + }; + } + + #[Computed] + public function files(): Collection + { + $paths = glob(storage_path('logs/*.log')) ?: []; + + return collect($paths) + ->map(fn (string $path) => [ + 'name' => basename($path), + 'size' => filesize($path) ?: 0, + 'modified' => Carbon::createFromTimestamp(filemtime($path) ?: time()), + ]) + ->sortByDesc('modified') + ->values(); + } + + #[Computed] + public function entries(): Collection + { + if ($this->selectedFile === '' || ! $this->files()->pluck('name')->contains($this->selectedFile)) { + return collect(); + } + + $path = storage_path('logs/'.$this->selectedFile); + + if (! is_file($path)) { + return collect(); + } + + $size = filesize($path); + $handle = fopen($path, 'r'); + $truncated = $size > self::MAX_BYTES; + + if ($truncated) { + fseek($handle, -self::MAX_BYTES, SEEK_END); + } + + $content = stream_get_contents($handle); + fclose($handle); + + // A new log entry starts at a "[YYYY-MM-DD HH:MM:SS]" line; anything + // after it (stack traces, multi-line messages) belongs to that entry. + $chunks = preg_split('/(?=^\[\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2})/m', (string) $content); + $chunks = array_values(array_filter($chunks, fn (string $c) => trim($c) !== '')); + + if ($truncated && count($chunks) > 1) { + // First chunk was very likely cut mid-entry by the seek above. + array_shift($chunks); + } + + $entries = collect($chunks)->map(function (string $chunk) { + preg_match('/^\[[^\]]+\]\s+\S+\.(\w+):/', $chunk, $m); + + return [ + 'level' => isset($m[1]) ? strtoupper($m[1]) : null, + 'text' => rtrim($chunk), + ]; + }); + + if ($this->levelFilter !== '') { + $entries = $entries->filter(fn (array $e) => $e['level'] === $this->levelFilter); + } + + if (trim($this->search) !== '') { + $needle = mb_strtolower($this->search); + $entries = $entries->filter(fn (array $e) => str_contains(mb_strtolower($e['text']), $needle)); + } + + return $entries->values()->slice(-$this->limit)->values(); + } + + public function selectFile(string $name): void + { + if ($this->files()->pluck('name')->contains($name)) { + $this->selectedFile = $name; + } + } + + public function formatBytes(int $bytes): string + { + if ($bytes < 1024) { + return "{$bytes} B"; + } + + $units = ['KB', 'MB', 'GB']; + $value = $bytes / 1024; + + foreach ($units as $unit) { + if ($value < 1024 || $unit === end($units)) { + return number_format($value, 1).' '.$unit; + } + $value /= 1024; + } + + return "{$bytes} B"; + } + + public function render() + { + return view('livewire.admin.logs'); + } +} diff --git a/src/app/Livewire/Admin/Panel.php b/src/app/Livewire/Admin/Panel.php index b0ac349..ee3189e 100644 --- a/src/app/Livewire/Admin/Panel.php +++ b/src/app/Livewire/Admin/Panel.php @@ -1840,6 +1840,7 @@ class Panel extends Component 'config' => 'Konfiguracja', 'integrations' => 'Integracje', 'api-keys' => 'Klucze API', + 'logs' => 'Logi', 'about' => 'O aplikacji', ]; diff --git a/src/app/Livewire/Operator/Queue.php b/src/app/Livewire/Operator/Queue.php index 307f9ac..b945cbc 100644 --- a/src/app/Livewire/Operator/Queue.php +++ b/src/app/Livewire/Operator/Queue.php @@ -354,7 +354,9 @@ class Queue extends Component if ($this->filterPriority !== 'all') { $query->where('priority_key', $this->filterPriority); } - if ($this->filterCategory !== 'all') { + if ($this->filterCategory === 'none') { + $query->whereNull('subcategory_id')->whereNull('category_id'); + } elseif ($this->filterCategory !== 'all') { // A ticket carries a category either via its subcategory or, // when routed to a whole category with no subcategory (e.g. an // IMAP mailbox), directly on tickets.category_id. diff --git a/src/app/Livewire/Operator/Stats.php b/src/app/Livewire/Operator/Stats.php index 4157050..fa1eacc 100644 --- a/src/app/Livewire/Operator/Stats.php +++ b/src/app/Livewire/Operator/Stats.php @@ -128,7 +128,17 @@ class Stats extends Component $query->where('tickets.priority_key', $this->filterPriority); } if ($this->filterCategory !== 'all') { - $query->whereHas('subcategory', fn ($q) => $q->where('category_id', $this->filterCategory)); + // A ticket can carry its category two ways — via a subcategory + // (whose own category_id we check through) or, when routed to a + // bare category with no subcategory, via tickets.category_id + // directly (see Ticket::category()). Only checking the + // subcategory relation silently dropped every bare-category + // ticket from the filter. + $categoryId = $this->filterCategory; + $query->where(function ($q) use ($categoryId) { + $q->where('tickets.category_id', $categoryId) + ->orWhereHas('subcategory', fn ($sq) => $sq->where('category_id', $categoryId)); + }); } if ($this->filterAssignee === 'unassigned') { $query->whereNull('tickets.assignee_id'); @@ -299,37 +309,70 @@ class Stats extends Component ])->values(); } + /** + * A ticket carries its category two ways: via a subcategory (whose + * category_id we join through), or — when routed to a bare category with + * no subcategory — via tickets.category_id directly (see + * Ticket::category()). Counting only the subcategory join silently + * dropped every bare-category ticket, so this sums both paths per + * category id before joining to categories for the label. + */ #[Computed] public function byCategory() { - return (clone $this->baseQuery) + $viaSubcategory = (clone $this->baseQuery) ->whereNotNull('tickets.subcategory_id') ->join('subcategories', 'subcategories.id', '=', 'tickets.subcategory_id') - ->join('categories', 'categories.id', '=', 'subcategories.category_id') - ->select('categories.name as label', DB::raw('count(*) as count')) - ->groupBy('categories.id', 'categories.name') - ->orderByDesc('count') - ->get() - ->map(fn ($row) => ['label' => $row->label, 'count' => (int) $row->count]); + ->select('subcategories.category_id', DB::raw('count(*) as count')) + ->groupBy('subcategories.category_id') + ->pluck('count', 'category_id'); + + $viaCategory = (clone $this->baseQuery) + ->whereNull('tickets.subcategory_id') + ->whereNotNull('tickets.category_id') + ->select('tickets.category_id', DB::raw('count(*) as count')) + ->groupBy('tickets.category_id') + ->pluck('count', 'category_id'); + + $counts = $viaSubcategory->keys()->merge($viaCategory->keys())->unique() + ->mapWithKeys(fn ($id) => [$id => ($viaSubcategory[$id] ?? 0) + ($viaCategory[$id] ?? 0)]); + + return Category::query()->whereIn('id', $counts->keys())->get() + ->map(fn (Category $c) => ['label' => $c->name, 'count' => (int) $counts[$c->id]]) + ->sortByDesc('count') + ->values(); } /** - * One level deeper than byCategory() — same shape, but grouped by the - * actual subcategory, labeled "Category / Subcategory" to disambiguate - * subcategories that share a name across different parent categories. + * One level deeper than byCategory() — grouped by the actual subcategory, + * labeled "Category / Subcategory" to disambiguate subcategories that + * share a name across different parent categories. Tickets routed to a + * bare category (no subcategory — see byCategory()'s docblock) have no + * subcategory to group by, so they get their own "Category (bez + * podkategorii)" row instead of being silently dropped. */ #[Computed] public function bySubcategory() { - return (clone $this->baseQuery) + $withSubcategory = (clone $this->baseQuery) ->whereNotNull('tickets.subcategory_id') ->join('subcategories', 'subcategories.id', '=', 'tickets.subcategory_id') ->join('categories', 'categories.id', '=', 'subcategories.category_id') ->select('subcategories.id', 'categories.name as category_name', 'subcategories.name as sub_name', DB::raw('count(*) as count')) ->groupBy('subcategories.id', 'categories.name', 'subcategories.name') - ->orderByDesc('count') ->get() ->map(fn ($row) => ['label' => $row->category_name.' / '.$row->sub_name, 'count' => (int) $row->count]); + + $bareCategory = (clone $this->baseQuery) + ->whereNull('tickets.subcategory_id') + ->whereNotNull('tickets.category_id') + ->join('categories', 'categories.id', '=', 'tickets.category_id') + ->select('categories.name as category_name', DB::raw('count(*) as count')) + ->groupBy('categories.id', 'categories.name') + ->get() + ->map(fn ($row) => ['label' => $row->category_name.' (bez podkategorii)', 'count' => (int) $row->count]); + + return $withSubcategory->concat($bareCategory)->sortByDesc('count')->values(); } #[Computed] diff --git a/src/app/Models/EmailTemplate.php b/src/app/Models/EmailTemplate.php index 39caccc..dfceda9 100644 --- a/src/app/Models/EmailTemplate.php +++ b/src/app/Models/EmailTemplate.php @@ -5,7 +5,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Model; -#[Fillable(['key', 'name', 'trigger_label', 'subject', 'body'])] +#[Fillable(['key', 'name', 'subject', 'body'])] class EmailTemplate extends Model { public function render(array $placeholders): array diff --git a/src/app/Models/Ticket.php b/src/app/Models/Ticket.php index 151c27f..085a4f7 100644 --- a/src/app/Models/Ticket.php +++ b/src/app/Models/Ticket.php @@ -9,12 +9,13 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; #[Fillable([ 'number', 'checksum', 'customer_id', 'email', 'name', 'subcategory_id', 'category_id', 'subject', 'body', - 'status_key', 'priority_key', 'team_id', 'assignee_id', 'custom_fields', 'api_client_id', 'source', + 'status_key', 'priority_key', 'team_id', 'assignee_id', 'custom_fields', 'api_client_id', 'source', 'hesk_ticket_id', 'sla_notified_at', 'last_customer_activity_at', 'time_spent_seconds', 'timer_started_at', 'created_at', 'updated_at', 'csat_rating', 'csat_comment', 'csat_rated_at', 'ai_triaged_at', 'ai_summary', 'ai_suggested_action', 'ai_summary_generated_at', @@ -22,6 +23,43 @@ use Illuminate\Support\Facades\DB; ])] class Ticket extends Model { + /** + * Every value ever written to tickets.source across the app — web + * submission (the default), IMAP-fetched e-mail, and the Hesk import + * command. Enforced on save (see booted() below) so a typo'd literal + * fails loudly instead of silently sticking in the column. + */ + public const SOURCES = ['web', 'email', 'hesk_import']; + + /** + * ai_* and snipeit_* fields are no longer real columns on `tickets` — + * they live in aiSummary()/snipeitAsset(), one-to-one extension tables (see the + * 2026_08_05_000164/000165 migrations for why: both blocks are wide and + * null on most tickets). These maps back the getAttribute()/ + * setAttribute() overrides below, which keep every existing + * `$ticket->ai_summary`/`$ticket->snipeit_asset_id` read/write working + * unchanged against the new tables, so callers never had to change. + */ + private const AI_SUMMARY_FIELD_MAP = [ + 'ai_triaged_at' => 'triaged_at', + 'ai_summary' => 'summary', + 'ai_suggested_action' => 'suggested_action', + 'ai_summary_generated_at' => 'summary_generated_at', + ]; + + private const SNIPEIT_FIELD_MAP = [ + 'snipeit_asset_id' => 'asset_id', + 'snipeit_asset_name' => 'asset_name', + ]; + + /** + * Queued writes to the virtual ai_* and snipeit_* fields above, flushed into + * the related row once the ticket itself is saved (see booted()) rather + * than applied immediately — a brand-new ticket has no id yet to key the + * related row on. + */ + protected array $pendingVirtualAttributes = []; + /** * Every ticket gets a stable, unique checksum the moment its id is known * — it never needs to change afterward, and having it always populated @@ -34,6 +72,87 @@ class Ticket extends Model $ticket->checksum = static::generateUniqueChecksum($ticket->id); $ticket->saveQuietly(); }); + + static::saving(function (Ticket $ticket) { + if ($ticket->source !== null && ! in_array($ticket->source, self::SOURCES, true)) { + throw new \InvalidArgumentException("Invalid ticket source: {$ticket->source}"); + } + }); + + static::saved(function (Ticket $ticket) { + $ticket->flushPendingVirtualAttributes(); + + // Keeps ticket_field_values (queryable EAV rows) in sync with the + // freeform custom_fields JSON blob — see syncFieldValues(). + if ($ticket->wasChanged('custom_fields') || $ticket->wasRecentlyCreated) { + $ticket->syncFieldValues(); + } + }); + } + + /** + * @see AI_SUMMARY_FIELD_MAP, SNIPEIT_FIELD_MAP + */ + public function getAttribute($key) + { + if (isset(self::AI_SUMMARY_FIELD_MAP[$key])) { + return array_key_exists($key, $this->pendingVirtualAttributes) + ? $this->pendingVirtualAttributes[$key] + : $this->aiSummary?->{self::AI_SUMMARY_FIELD_MAP[$key]}; + } + + if (isset(self::SNIPEIT_FIELD_MAP[$key])) { + return array_key_exists($key, $this->pendingVirtualAttributes) + ? $this->pendingVirtualAttributes[$key] + : $this->snipeitAsset?->{self::SNIPEIT_FIELD_MAP[$key]}; + } + + return parent::getAttribute($key); + } + + /** + * @see AI_SUMMARY_FIELD_MAP, SNIPEIT_FIELD_MAP + */ + public function setAttribute($key, $value) + { + if (isset(self::AI_SUMMARY_FIELD_MAP[$key]) || isset(self::SNIPEIT_FIELD_MAP[$key])) { + $this->pendingVirtualAttributes[$key] = $value; + + return $this; + } + + return parent::setAttribute($key, $value); + } + + protected function flushPendingVirtualAttributes(): void + { + if (! $this->pendingVirtualAttributes) { + return; + } + + $ai = array_intersect_key($this->pendingVirtualAttributes, self::AI_SUMMARY_FIELD_MAP); + $snipeit = array_intersect_key($this->pendingVirtualAttributes, self::SNIPEIT_FIELD_MAP); + $this->pendingVirtualAttributes = []; + + if ($ai) { + $this->aiSummary()->updateOrCreate([], collect($ai) + ->mapWithKeys(fn ($value, $key) => [self::AI_SUMMARY_FIELD_MAP[$key] => $value])->all()); + } + + if ($snipeit) { + $this->snipeitAsset()->updateOrCreate([], collect($snipeit) + ->mapWithKeys(fn ($value, $key) => [self::SNIPEIT_FIELD_MAP[$key] => $value])->all()); + } + } + + public function aiSummary(): HasOne + { + return $this->hasOne(TicketAiSummary::class); + } + + public function snipeitAsset(): HasOne + { + return $this->hasOne(TicketSnipeitAsset::class); } protected function casts(): array @@ -46,9 +165,6 @@ class Ticket extends Model 'timer_started_at' => 'datetime', 'csat_rating' => 'integer', 'csat_rated_at' => 'datetime', - 'ai_triaged_at' => 'datetime', - 'ai_summary_generated_at' => 'datetime', - 'snipeit_asset_id' => 'integer', ]; } @@ -87,6 +203,16 @@ class Ticket extends Model return $this->belongsTo(Category::class); } + /** + * Queryable counterpart to the custom_fields JSON blob — see + * syncFieldValues(). Read-only from the app's perspective; write custom + * field values via the custom_fields attribute as before. + */ + public function fieldValues(): HasMany + { + return $this->hasMany(TicketFieldValue::class); + } + public function watchers(): BelongsToMany { return $this->belongsToMany(User::class, 'ticket_watchers'); @@ -157,9 +283,18 @@ class Ticket extends Model return $this->hasMany(AutomationRuleTicketLog::class); } + /** + * Numeric-safe "max + 1" without pulling every ticket's number into PHP + * memory (`number` is a plain string column, so a DB-level MAX() would + * sort lexicographically — "999" > "1000" — hence ordering by length + * first). LENGTH()/ORDER BY/LIMIT are portable across MySQL and the + * sqlite connection tests run against, unlike a driver-specific CAST. + */ public static function nextNumber(): string { - $max = static::query()->pluck('number')->map(fn ($n) => (int) $n)->max(); + $max = (int) static::query() + ->orderByRaw('LENGTH(number) DESC, number DESC') + ->value('number'); return (string) (($max ?: 1000) + 1); } @@ -553,4 +688,33 @@ class Ticket extends Model ->values() ->all(); } + + /** + * Mirrors the custom_fields JSON blob into ticket_field_values, one row + * per non-blank entry — called automatically on save (see booted()). + * Deleted/blanked entries are removed rather than left stale, and + * unrecognized field ids (e.g. a value left over after its custom_fields + * definition was deleted) are skipped, matching the migration's + * backfill. + */ + public function syncFieldValues(): void + { + $values = $this->custom_fields ?? []; + $validFieldIds = CustomField::query()->pluck('id')->all(); + + $this->fieldValues()->whereNotIn('custom_field_id', array_keys($values))->delete(); + + foreach ($values as $fieldId => $value) { + if ($value === '' || $value === null || ! in_array((int) $fieldId, $validFieldIds, true)) { + $this->fieldValues()->where('custom_field_id', $fieldId)->delete(); + + continue; + } + + $this->fieldValues()->updateOrCreate( + ['custom_field_id' => $fieldId], + ['value' => is_bool($value) ? ($value ? '1' : '0') : (string) $value], + ); + } + } } diff --git a/src/app/Models/TicketAiSummary.php b/src/app/Models/TicketAiSummary.php new file mode 100644 index 0000000..fb51fc3 --- /dev/null +++ b/src/app/Models/TicketAiSummary.php @@ -0,0 +1,24 @@ + 'datetime', + 'summary_generated_at' => 'datetime', + ]; + } + + public function ticket(): BelongsTo + { + return $this->belongsTo(Ticket::class); + } +} diff --git a/src/app/Models/TicketFieldValue.php b/src/app/Models/TicketFieldValue.php new file mode 100644 index 0000000..f86aa58 --- /dev/null +++ b/src/app/Models/TicketFieldValue.php @@ -0,0 +1,21 @@ +belongsTo(Ticket::class); + } + + public function field(): BelongsTo + { + return $this->belongsTo(CustomField::class, 'custom_field_id'); + } +} diff --git a/src/app/Models/TicketMessage.php b/src/app/Models/TicketMessage.php index 944f7fd..b959299 100644 --- a/src/app/Models/TicketMessage.php +++ b/src/app/Models/TicketMessage.php @@ -12,6 +12,23 @@ use Illuminate\Database\Eloquent\Relations\HasOneThrough; #[Fillable(['ticket_id', 'author_name', 'internal', 'body', 'edited', 'api_client_id', 'source', 'created_at', 'updated_at'])] class TicketMessage extends Model { + /** + * Non-null values ever written to ticket_messages.source — null means + * "web" (see the migration that added this column); only IMAP-fetched + * replies set it to 'email'. Enforced on save (see booted() below) so a + * typo'd literal fails loudly instead of silently sticking. + */ + public const SOURCES = ['email']; + + protected static function booted(): void + { + static::saving(function (TicketMessage $message) { + if ($message->source !== null && ! in_array($message->source, self::SOURCES, true)) { + throw new \InvalidArgumentException("Invalid ticket message source: {$message->source}"); + } + }); + } + protected function casts(): array { return [ diff --git a/src/app/Models/TicketSnipeitAsset.php b/src/app/Models/TicketSnipeitAsset.php new file mode 100644 index 0000000..d93cc13 --- /dev/null +++ b/src/app/Models/TicketSnipeitAsset.php @@ -0,0 +1,23 @@ + 'integer', + ]; + } + + public function ticket(): BelongsTo + { + return $this->belongsTo(Ticket::class); + } +} diff --git a/src/app/Models/User.php b/src/app/Models/User.php index f88d9dc..847f538 100644 --- a/src/app/Models/User.php +++ b/src/app/Models/User.php @@ -2,7 +2,6 @@ namespace App\Models; -// use Illuminate\Contracts\Auth\MustVerifyEmail; use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; @@ -16,7 +15,7 @@ use LdapRecord\Laravel\Auth\AuthenticatesWithLdap; use LdapRecord\Laravel\Auth\LdapAuthenticatable; #[Fillable(['name', 'email', 'password', 'roles', 'custom_field_values'])] -#[Hidden(['password', 'remember_token'])] +#[Hidden(['password'])] class User extends Authenticatable implements LdapAuthenticatable { /** @use HasFactory */ @@ -109,7 +108,6 @@ class User extends Authenticatable implements LdapAuthenticatable protected function casts(): array { return [ - 'email_verified_at' => 'datetime', 'password' => 'hashed', ]; } diff --git a/src/app/Services/AiClient.php b/src/app/Services/AiClient.php index f4c21c3..cabc286 100644 --- a/src/app/Services/AiClient.php +++ b/src/app/Services/AiClient.php @@ -4,6 +4,8 @@ namespace App\Services; use App\Support\Settings; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; /** * Generic OpenAI-compatible chat-completions client — works against Groq, @@ -32,19 +34,38 @@ class AiClient return null; } + $model = Settings::get('ai_model'); + try { $response = $this->client()->post('/chat/completions', [ - 'model' => Settings::get('ai_model'), + 'model' => $model, 'messages' => $messages, ...$options, ]); if (! $response->successful()) { + Log::channel('ai')->warning(sprintf( + 'Zapytanie do modelu %s zakończone błędem HTTP %d: %s', + $model, + $response->status(), + $response->json('error.message') ?? Str::limit($response->body(), 300), + )); + return null; } - return $response->json('choices.0.message.content'); - } catch (\Throwable) { + $content = $response->json('choices.0.message.content'); + Log::channel('ai')->debug(sprintf( + 'Zapytanie do modelu %s: %d wiadomości wejściowych, odpowiedź %d znaków.', + $model, + count($messages), + mb_strlen((string) $content), + )); + + return $content; + } catch (\Throwable $e) { + Log::channel('ai')->error("Zapytanie do modelu {$model} nie powiodło się: {$e->getMessage()}"); + return null; } } diff --git a/src/app/Services/TicketAiSummaryService.php b/src/app/Services/TicketAiSummaryService.php index f620504..0a7acce 100644 --- a/src/app/Services/TicketAiSummaryService.php +++ b/src/app/Services/TicketAiSummaryService.php @@ -6,6 +6,7 @@ use App\Models\Ticket; use App\Support\Settings; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; /** @@ -48,6 +49,13 @@ class TicketAiSummaryService $this->summarizeOne($ticket) ? $totals['updated']++ : $totals['failed']++; }); + Log::channel('ai')->info(sprintf( + 'Podsumowania: przeskanowano %d, zaktualizowano %d, błędów %d.', + $totals['scanned'], + $totals['updated'], + $totals['failed'], + )); + return $totals; } @@ -58,18 +66,28 @@ class TicketAiSummaryService * latter also changes on unrelated actions (status/priority/timer * edits), which would otherwise trigger spurious re-summarization on * every scheduler tick for an active ticket. + * + * ai_summary_generated_at now lives on the related ticket_ai_summaries + * row (see Ticket::aiSummary()), so this joins to it directly rather + * than going through the model relation — a plain whereNull() on the + * left-joined column covers "no row yet" the same way it used to cover + * "column is null" when it lived on tickets itself. */ protected function staleQuery(): Builder { - return Ticket::query()->where(function (Builder $q) { - $q->whereNull('ai_summary_generated_at') - ->orWhere(function (Builder $q2) { - $q2->whereNotNull('ai_summary_generated_at') - ->whereColumn('ai_summary_generated_at', '<', DB::raw( - '(select max(ticket_messages.created_at) from ticket_messages where ticket_messages.ticket_id = tickets.id)' - )); - }); - })->orderBy('id'); + return Ticket::query() + ->leftJoin('ticket_ai_summaries', 'ticket_ai_summaries.ticket_id', '=', 'tickets.id') + ->where(function (Builder $q) { + $q->whereNull('ticket_ai_summaries.summary_generated_at') + ->orWhere(function (Builder $q2) { + $q2->whereNotNull('ticket_ai_summaries.summary_generated_at') + ->whereColumn('ticket_ai_summaries.summary_generated_at', '<', DB::raw( + '(select max(ticket_messages.created_at) from ticket_messages where ticket_messages.ticket_id = tickets.id)' + )); + }); + }) + ->select('tickets.*') + ->orderBy('tickets.id'); } /** @@ -99,6 +117,8 @@ class TicketAiSummaryService // Leaves any prior summary untouched and generated_at unchanged, // so the ticket stays in the stale set and gets retried next run // rather than silently losing a working summary. + Log::channel('ai')->warning("Podsumowanie zgłoszenia #{$ticket->number}: nie udało się wygenerować (brak lub niepoprawna odpowiedź modelu)."); + return false; } @@ -108,6 +128,8 @@ class TicketAiSummaryService 'ai_summary_generated_at' => now(), ]); + Log::channel('ai')->debug("Podsumowanie zgłoszenia #{$ticket->number}: zaktualizowane."); + return true; } diff --git a/src/app/Services/TicketAiTriageService.php b/src/app/Services/TicketAiTriageService.php index 9da59c8..b65f33f 100644 --- a/src/app/Services/TicketAiTriageService.php +++ b/src/app/Services/TicketAiTriageService.php @@ -6,6 +6,7 @@ use App\Models\Category; use App\Models\Priority; use App\Models\Ticket; use App\Support\Settings; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; /** @@ -43,7 +44,12 @@ class TicketAiTriageService $vocabulary = $this->buildVocabulary(); $priorities = Priority::query()->orderBy('sort_order')->pluck('label', 'key')->all(); - Ticket::query()->whereNull('ai_triaged_at') + // ai_triaged_at now lives on the related ticket_ai_summaries row (see + // Ticket::aiSummary()) — whereDoesntHave() matches both "no row yet" + // and "row exists but triaged_at is still null", same as the plain + // whereNull() this replaces did when the column lived on tickets. + Ticket::query() + ->whereDoesntHave('aiSummary', fn ($q) => $q->whereNotNull('triaged_at')) ->orderBy('id') ->limit($limit ?? self::BATCH_LIMIT) ->get() @@ -52,6 +58,13 @@ class TicketAiTriageService $this->triageOne($ticket, $vocabulary, $priorities, $totals); }); + Log::channel('ai')->info(sprintf( + 'Triage: przeskanowano %d, zmieniono %d, błędów %d.', + $totals['scanned'], + $totals['changed'], + $totals['failed'], + )); + return $totals; } @@ -92,6 +105,7 @@ class TicketAiTriageService // response doesn't get retried forever. if ($raw !== null && $parsed === null) { $totals['failed']++; + Log::channel('ai')->warning("Triage zgłoszenia #{$ticket->number}: odpowiedź modelu nie dała się zinterpretować jako JSON."); } [$changes, $historyLines] = $parsed @@ -101,6 +115,9 @@ class TicketAiTriageService if ($changes) { $this->tickets->applyAiTriage($ticket, $changes, $historyLines); $totals['changed']++; + Log::channel('ai')->info("Triage zgłoszenia #{$ticket->number}: ".implode('; ', $historyLines)); + } else { + Log::channel('ai')->debug("Triage zgłoszenia #{$ticket->number}: bez zmian."); } $ticket->update(['ai_triaged_at' => now()]); diff --git a/src/config/logging.php b/src/config/logging.php index 5feefc9..562dff6 100644 --- a/src/config/logging.php +++ b/src/config/logging.php @@ -86,6 +86,29 @@ return [ 'replace_placeholders' => true, ], + // Dedicated, always-verbose channel for the scheduled AI ticket + // automation (ai:run-ticket-automation — triage + summaries) — same + // rationale as 'imap' below: full visibility into what the AI + // integration did on every run without depending on LOG_LEVEL. + 'ai' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/ai.log'), + 'level' => 'debug', + 'days' => 14, + 'replace_placeholders' => true, + ], + + // Dedicated channel for the one-off Hesk import command (hesk:import) + // — console output alone is lost once the terminal is closed, so + // failures/summary go here too. + 'hesk_import' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/hesk-import.log'), + 'level' => 'debug', + 'days' => 14, + 'replace_placeholders' => true, + ], + 'slack' => [ 'driver' => 'slack', 'url' => env('LOG_SLACK_WEBHOOK_URL'), diff --git a/src/database/factories/UserFactory.php b/src/database/factories/UserFactory.php index ab7ff09..1bd1f92 100644 --- a/src/database/factories/UserFactory.php +++ b/src/database/factories/UserFactory.php @@ -5,7 +5,6 @@ namespace Database\Factories; use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Facades\Hash; -use Illuminate\Support\Str; /** * @extends Factory @@ -27,20 +26,8 @@ class UserFactory extends Factory return [ 'name' => fake()->name(), 'email' => fake()->unique()->safeEmail(), - 'email_verified_at' => now(), 'password' => static::$password ??= Hash::make('password'), 'roles' => ['client'], - 'remember_token' => Str::random(10), ]; } - - /** - * Indicate that the model's email address should be unverified. - */ - public function unverified(): static - { - return $this->state(fn (array $attributes) => [ - 'email_verified_at' => null, - ]); - } } diff --git a/src/database/migrations/2026_08_05_000160_add_hesk_ticket_id_to_tickets.php b/src/database/migrations/2026_08_05_000160_add_hesk_ticket_id_to_tickets.php new file mode 100644 index 0000000..282a929 --- /dev/null +++ b/src/database/migrations/2026_08_05_000160_add_hesk_ticket_id_to_tickets.php @@ -0,0 +1,30 @@ +unsignedInteger('hesk_ticket_id')->nullable()->unique()->after('source'); + }); + } + + public function down(): void + { + Schema::table('tickets', function (Blueprint $table) { + $table->dropColumn('hesk_ticket_id'); + }); + } +}; diff --git a/src/database/migrations/2026_08_05_000161_drop_unused_columns.php b/src/database/migrations/2026_08_05_000161_drop_unused_columns.php new file mode 100644 index 0000000..925a456 --- /dev/null +++ b/src/database/migrations/2026_08_05_000161_drop_unused_columns.php @@ -0,0 +1,51 @@ +dropColumn(['remember_token', 'email_verified_at']); + }); + + Schema::table('email_templates', function (Blueprint $table) { + $table->dropColumn('trigger_label'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->rememberToken(); + $table->timestamp('email_verified_at')->nullable(); + }); + + Schema::table('email_templates', function (Blueprint $table) { + $table->string('trigger_label')->default(''); + }); + } +}; diff --git a/src/database/migrations/2026_08_05_000162_create_ticket_field_values_table.php b/src/database/migrations/2026_08_05_000162_create_ticket_field_values_table.php new file mode 100644 index 0000000..f14fb00 --- /dev/null +++ b/src/database/migrations/2026_08_05_000162_create_ticket_field_values_table.php @@ -0,0 +1,77 @@ +id(); + $table->foreignId('ticket_id')->constrained()->cascadeOnDelete(); + $table->foreignId('custom_field_id')->constrained()->cascadeOnDelete(); + $table->text('value')->nullable(); + $table->timestamps(); + $table->unique(['ticket_id', 'custom_field_id']); + }); + + $this->backfill(); + } + + /** + * One-time backfill from the existing custom_fields JSON blob, so + * reporting against ticket_field_values also covers tickets created + * before this table existed. Skips any field id no longer present in + * custom_fields (a deleted field definition would otherwise violate the + * FK constraint) and any blank/null value, matching + * Ticket::syncFieldValues()'s own filtering. + */ + private function backfill(): void + { + $validFieldIds = DB::table('custom_fields')->pluck('id')->all(); + $now = now(); + + DB::table('tickets')->whereNotNull('custom_fields')->orderBy('id') + ->chunkById(500, function ($tickets) use ($validFieldIds, $now) { + $rows = []; + + foreach ($tickets as $ticket) { + $values = json_decode($ticket->custom_fields, true) ?? []; + + foreach ($values as $fieldId => $value) { + if ($value === '' || $value === null || ! in_array((int) $fieldId, $validFieldIds, true)) { + continue; + } + + $rows[] = [ + 'ticket_id' => $ticket->id, + 'custom_field_id' => (int) $fieldId, + 'value' => is_bool($value) ? ($value ? '1' : '0') : (string) $value, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + } + + if ($rows) { + DB::table('ticket_field_values')->insert($rows); + } + }); + } + + public function down(): void + { + Schema::dropIfExists('ticket_field_values'); + } +}; diff --git a/src/database/migrations/2026_08_05_000163_add_reverse_indexes_to_pivot_tables.php b/src/database/migrations/2026_08_05_000163_add_reverse_indexes_to_pivot_tables.php new file mode 100644 index 0000000..1735c54 --- /dev/null +++ b/src/database/migrations/2026_08_05_000163_add_reverse_indexes_to_pivot_tables.php @@ -0,0 +1,54 @@ +index('subcategory_id'); + }); + + Schema::table('role_user', function (Blueprint $table) { + $table->index('user_id'); + }); + + Schema::table('team_user', function (Blueprint $table) { + $table->index('user_id'); + }); + + Schema::table('custom_field_subcategory', function (Blueprint $table) { + $table->index('subcategory_id'); + }); + } + + public function down(): void + { + Schema::table('team_subcategory', function (Blueprint $table) { + $table->dropIndex(['subcategory_id']); + }); + + Schema::table('role_user', function (Blueprint $table) { + $table->dropIndex(['user_id']); + }); + + Schema::table('team_user', function (Blueprint $table) { + $table->dropIndex(['user_id']); + }); + + Schema::table('custom_field_subcategory', function (Blueprint $table) { + $table->dropIndex(['subcategory_id']); + }); + } +}; diff --git a/src/database/migrations/2026_08_05_000164_create_ticket_ai_summaries_table.php b/src/database/migrations/2026_08_05_000164_create_ticket_ai_summaries_table.php new file mode 100644 index 0000000..f7dc658 --- /dev/null +++ b/src/database/migrations/2026_08_05_000164_create_ticket_ai_summaries_table.php @@ -0,0 +1,74 @@ +ai_summary` etc. read/write working unchanged against this + * table (see Ticket::AI_SUMMARY_FIELD_MAP). + */ + public function up(): void + { + Schema::create('ticket_ai_summaries', function (Blueprint $table) { + $table->id(); + $table->foreignId('ticket_id')->unique()->constrained()->cascadeOnDelete(); + $table->timestamp('triaged_at')->nullable(); + $table->text('summary')->nullable(); + $table->text('suggested_action')->nullable(); + $table->timestamp('summary_generated_at')->nullable(); + $table->timestamps(); + }); + + $this->backfill(); + + Schema::table('tickets', function (Blueprint $table) { + $table->dropColumn(['ai_triaged_at', 'ai_summary', 'ai_suggested_action', 'ai_summary_generated_at']); + }); + } + + private function backfill(): void + { + $now = now(); + + DB::table('tickets') + ->where(function ($q) { + $q->whereNotNull('ai_triaged_at') + ->orWhereNotNull('ai_summary') + ->orWhereNotNull('ai_suggested_action') + ->orWhereNotNull('ai_summary_generated_at'); + }) + ->orderBy('id') + ->chunkById(500, function ($tickets) use ($now) { + DB::table('ticket_ai_summaries')->insert($tickets->map(fn ($t) => [ + 'ticket_id' => $t->id, + 'triaged_at' => $t->ai_triaged_at, + 'summary' => $t->ai_summary, + 'suggested_action' => $t->ai_suggested_action, + 'summary_generated_at' => $t->ai_summary_generated_at, + 'created_at' => $now, + 'updated_at' => $now, + ])->all()); + }); + } + + public function down(): void + { + Schema::table('tickets', function (Blueprint $table) { + $table->timestamp('ai_triaged_at')->nullable()->after('source'); + $table->text('ai_summary')->nullable()->after('ai_triaged_at'); + $table->text('ai_suggested_action')->nullable()->after('ai_summary'); + $table->timestamp('ai_summary_generated_at')->nullable()->after('ai_suggested_action'); + }); + + Schema::dropIfExists('ticket_ai_summaries'); + } +}; diff --git a/src/database/migrations/2026_08_05_000165_create_ticket_snipeit_assets_table.php b/src/database/migrations/2026_08_05_000165_create_ticket_snipeit_assets_table.php new file mode 100644 index 0000000..f43ab4c --- /dev/null +++ b/src/database/migrations/2026_08_05_000165_create_ticket_snipeit_assets_table.php @@ -0,0 +1,64 @@ +snipeit_asset_id`/`snipeit_asset_name` read/write + * working unchanged against this table (see Ticket::SNIPEIT_FIELD_MAP). + */ + public function up(): void + { + Schema::create('ticket_snipeit_assets', function (Blueprint $table) { + $table->id(); + $table->foreignId('ticket_id')->unique()->constrained()->cascadeOnDelete(); + $table->unsignedInteger('asset_id')->nullable(); + $table->string('asset_name')->nullable(); + $table->timestamps(); + }); + + $this->backfill(); + + Schema::table('tickets', function (Blueprint $table) { + $table->dropColumn(['snipeit_asset_id', 'snipeit_asset_name']); + }); + } + + private function backfill(): void + { + $now = now(); + + DB::table('tickets') + ->where(function ($q) { + $q->whereNotNull('snipeit_asset_id')->orWhereNotNull('snipeit_asset_name'); + }) + ->orderBy('id') + ->chunkById(500, function ($tickets) use ($now) { + DB::table('ticket_snipeit_assets')->insert($tickets->map(fn ($t) => [ + 'ticket_id' => $t->id, + 'asset_id' => $t->snipeit_asset_id, + 'asset_name' => $t->snipeit_asset_name, + 'created_at' => $now, + 'updated_at' => $now, + ])->all()); + }); + } + + public function down(): void + { + Schema::table('tickets', function (Blueprint $table) { + $table->unsignedInteger('snipeit_asset_id')->nullable()->after('source'); + $table->string('snipeit_asset_name')->nullable()->after('snipeit_asset_id'); + }); + + Schema::dropIfExists('ticket_snipeit_assets'); + } +}; diff --git a/src/database/seeders/DatabaseSeeder.php b/src/database/seeders/DatabaseSeeder.php index 541c728..5d8c4dc 100644 --- a/src/database/seeders/DatabaseSeeder.php +++ b/src/database/seeders/DatabaseSeeder.php @@ -296,52 +296,52 @@ class DatabaseSeeder extends Seeder $templates = [ 'tpl-new' => [ - 'name' => 'Nowe zgłoszenie przyjęte', 'trigger_label' => 'Zgłoszenie utworzone', + 'name' => 'Nowe zgłoszenie przyjęte', 'subject' => 'Otrzymaliśmy Twoje zgłoszenie #{numer}', 'body' => '

Cześć {imie},

Otrzymaliśmy Twoje zgłoszenie „{temat}”. Nasz zespół zajmie się nim najszybciej jak to możliwe.

'.$link.$footer, ], 'tpl-status' => [ - 'name' => 'Zmiana statusu', 'trigger_label' => 'Status zgłoszenia zmieniony', + 'name' => 'Zmiana statusu', 'subject' => 'Aktualizacja zgłoszenia #{numer}', 'body' => '

Cześć {imie},

Status Twojego zgłoszenia „{temat}” zmienił się na: {status}.

'.$link.$footer, ], 'tpl-category' => [ - 'name' => 'Zmiana kategorii', 'trigger_label' => 'Kategoria zgłoszenia zmieniona', + 'name' => 'Zmiana kategorii', 'subject' => 'Zmieniono kategorię zgłoszenia #{numer}', 'body' => '

Cześć {imie},

Kategoria Twojego zgłoszenia „{temat}” została zmieniona na: {kategoria}.

'.$link.$footer, ], 'tpl-assignee' => [ - 'name' => 'Zmiana przypisanego operatora', 'trigger_label' => 'Przypisany operator zmieniony', + 'name' => 'Zmiana przypisanego operatora', 'subject' => 'Zmieniono osobę obsługującą zgłoszenie #{numer}', 'body' => '

Cześć {imie},

Twoim zgłoszeniem „{temat}” zajmie się teraz: {operator}.

'.$link.$footer, ], 'tpl-priority' => [ - 'name' => 'Zmiana priorytetu', 'trigger_label' => 'Priorytet zgłoszenia zmieniony', + 'name' => 'Zmiana priorytetu', 'subject' => 'Zmieniono priorytet zgłoszenia #{numer}', 'body' => '

Cześć {imie},

Priorytet Twojego zgłoszenia „{temat}” zmienił się na: {priorytet}.

'.$link.$footer, ], 'tpl-team' => [ - 'name' => 'Zmiana zespołu', 'trigger_label' => 'Zespół obsługujący zmieniony', + 'name' => 'Zmiana zespołu', 'subject' => 'Zmieniono zespół obsługujący zgłoszenie #{numer}', 'body' => '

Cześć {imie},

Twoim zgłoszeniem „{temat}” zajmuje się teraz zespół: {zespol}.

'.$link.$footer, ], 'tpl-closed' => [ - 'name' => 'Zgłoszenie zamknięte', 'trigger_label' => 'Status = Zamknięte', + 'name' => 'Zgłoszenie zamknięte', 'subject' => 'Zgłoszenie #{numer} zostało zamknięte', 'body' => '

Cześć {imie},

Twoje zgłoszenie „{temat}” zostało zamknięte. Jeśli temat nie został rozwiązany, odpowiedz na tego maila lub zgłoś sprawę ponownie.

'.$link.$csatLink.$footer, ], 'tpl-reply' => [ - 'name' => 'Nowa odpowiedź operatora', 'trigger_label' => 'Operator odpowiedział', + 'name' => 'Nowa odpowiedź operatora', 'subject' => 'Nowa odpowiedź w zgłoszeniu #{numer}', 'body' => '

Cześć {imie},

Otrzymałeś/aś nową odpowiedź w zgłoszeniu „{temat}”.

'.$link.$footer, ], 'tpl-sla-breach' => [ - 'name' => 'Przekroczenie SLA', 'trigger_label' => 'SLA przekroczone — operator', + 'name' => 'Przekroczenie SLA', 'subject' => 'Przekroczono SLA zgłoszenia #{numer}', 'body' => '

Cześć {operator},

Zgłoszenie „{temat}” (#{numer}) przekroczyło ustalony czas rozwiązania SLA.

'.$link.$footer, ], 'tpl-team-new-ticket' => [ - 'name' => 'Nowe zgłoszenie w zespole', 'trigger_label' => 'Nowe zgłoszenie w zespole — operator', + 'name' => 'Nowe zgłoszenie w zespole', 'subject' => 'Nowe zgłoszenie w Twoim zespole (#{numer})', 'body' => '

Cześć,

Nowe zgłoszenie „{temat}” (#{numer}, kategoria: {kategoria}) trafiło do zespołu {zespol}.

'.$link.$footer, ], @@ -352,7 +352,6 @@ class DatabaseSeeder extends Seeder foreach ($templates as $key => $tpl) { $ids[$key] = EmailTemplate::query()->firstOrCreate(['key' => $key], [ 'name' => $tpl['name'], - 'trigger_label' => $tpl['trigger_label'], 'subject' => $tpl['subject'], 'body' => $tpl['body'], ])->id; diff --git a/src/resources/views/components/theme-toggle.blade.php b/src/resources/views/components/theme-toggle.blade.php index 2c080d8..d569df1 100644 --- a/src/resources/views/components/theme-toggle.blade.php +++ b/src/resources/views/components/theme-toggle.blade.php @@ -16,10 +16,10 @@ style="position:relative;display:inline-block" > diff --git a/src/resources/views/livewire/admin/panel.blade.php b/src/resources/views/livewire/admin/panel.blade.php index 937ce05..20d94c1 100644 --- a/src/resources/views/livewire/admin/panel.blade.php +++ b/src/resources/views/livewire/admin/panel.blade.php @@ -22,6 +22,7 @@ $tabGroups = [ ['key' => 'config', 'label' => 'Konfiguracja', 'icon' => 'settings'], ['key' => 'integrations', 'label' => 'Integracje', 'icon' => 'hub'], ['key' => 'api-keys', 'label' => 'Klucze API', 'icon' => 'vpn_key'], + ['key' => 'logs', 'label' => 'Logi', 'icon' => 'terminal'], ['key' => 'about', 'label' => 'O aplikacji', 'icon' => 'info'], ], ]; @@ -620,9 +621,12 @@ $tabGroups = [

Sesja i strefa czasowa

+ init() { + this.tick(); + this.tickHandle = setInterval(() => this.tick(), 1000); + this.navigatingHandler = () => clearInterval(this.tickHandle); + document.addEventListener('livewire:navigating', this.navigatingHandler); + }, + }">
Aktualny czas: - +
@@ -983,6 +992,11 @@ $tabGroups = [ @endif + {{-- ================= LOGS ================= --}} + @if ($tab === 'logs') + + @endif + @if ($tab === 'about')

O aplikacji

diff --git a/src/resources/views/livewire/client/new-ticket.blade.php b/src/resources/views/livewire/client/new-ticket.blade.php index 6b4690c..fb1c5dc 100644 --- a/src/resources/views/livewire/client/new-ticket.blade.php +++ b/src/resources/views/livewire/client/new-ticket.blade.php @@ -95,7 +95,7 @@ @dragover.prevent="dragging = true" @dragleave.prevent="dragging = false" @drop.prevent="dragging = false; const input = $el.querySelector('input[type=file]'); input.files = $event.dataTransfer.files; input.dispatchEvent(new Event('change'))" - :style="{ borderColor: dragging ? 'var(--color-accent)' : undefined, background: dragging ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }" + :style="{ borderColor: (typeof dragging !== 'undefined' && dragging) ? 'var(--color-accent)' : undefined, background: (typeof dragging !== 'undefined' && dragging) ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }" style="border:1px dashed var(--color-divider);border-radius:8px;padding:14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px" > diff --git a/src/resources/views/livewire/client/ticket-show.blade.php b/src/resources/views/livewire/client/ticket-show.blade.php index bd68a84..f6092e8 100644 --- a/src/resources/views/livewire/client/ticket-show.blade.php +++ b/src/resources/views/livewire/client/ticket-show.blade.php @@ -13,15 +13,27 @@ $refreshTicketSeconds = max(1, (int) \App\Support\Settings::get('refresh_ticket_view_seconds')); @endphp
schedule - +
@@ -89,7 +101,7 @@ @dragover.prevent="dragging = true" @dragleave.prevent="dragging = false" @drop.prevent="dragging = false; const input = $el.querySelector('input[type=file]'); input.files = $event.dataTransfer.files; input.dispatchEvent(new Event('change'))" - :style="{ borderColor: dragging ? 'var(--color-accent)' : undefined, background: dragging ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }" + :style="{ borderColor: (typeof dragging !== 'undefined' && dragging) ? 'var(--color-accent)' : undefined, background: (typeof dragging !== 'undefined' && dragging) ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }" style="flex:1;min-width:0;border:1px dashed var(--color-divider);border-radius:8px;padding:10px 14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px" > diff --git a/src/resources/views/livewire/operator/new-ticket.blade.php b/src/resources/views/livewire/operator/new-ticket.blade.php index 6def039..9f7171e 100644 --- a/src/resources/views/livewire/operator/new-ticket.blade.php +++ b/src/resources/views/livewire/operator/new-ticket.blade.php @@ -92,7 +92,7 @@ @dragover.prevent="dragging = true" @dragleave.prevent="dragging = false" @drop.prevent="dragging = false; const input = $el.querySelector('input[type=file]'); input.files = $event.dataTransfer.files; input.dispatchEvent(new Event('change'))" - :style="{ borderColor: dragging ? 'var(--color-accent)' : undefined, background: dragging ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }" + :style="{ borderColor: (typeof dragging !== 'undefined' && dragging) ? 'var(--color-accent)' : undefined, background: (typeof dragging !== 'undefined' && dragging) ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }" style="border:1px dashed var(--color-divider);border-radius:8px;padding:14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px" > diff --git a/src/resources/views/livewire/operator/queue.blade.php b/src/resources/views/livewire/operator/queue.blade.php index 90a85d4..01038a8 100644 --- a/src/resources/views/livewire/operator/queue.blade.php +++ b/src/resources/views/livewire/operator/queue.blade.php @@ -6,12 +6,12 @@ class="side-rail" style="padding:10px;gap:6px;background:color-mix(in srgb, var(--color-text) 5%, var(--color-bg))" x-data="{ collapsed: localStorage.getItem('operatorSidebarCollapsed') === '1' }" - x-effect="localStorage.setItem('operatorSidebarCollapsed', collapsed ? '1' : '0')" - :class="collapsed ? 'side-rail-collapsed' : ''" + x-effect="typeof collapsed !== 'undefined' && localStorage.setItem('operatorSidebarCollapsed', collapsed ? '1' : '0')" + :class="(typeof collapsed !== 'undefined' && collapsed) ? 'side-rail-collapsed' : ''" > - @@ -128,7 +129,7 @@ view_column Kolumny -
+
@foreach ($columnDefs as $key => $label)
diff --git a/src/resources/views/livewire/operator/ticket-show.blade.php b/src/resources/views/livewire/operator/ticket-show.blade.php index fe37d20..63a28cb 100644 --- a/src/resources/views/livewire/operator/ticket-show.blade.php +++ b/src/resources/views/livewire/operator/ticket-show.blade.php @@ -25,15 +25,27 @@ $refreshTicketSeconds = max(1, (int) \App\Support\Settings::get('refresh_ticket_view_seconds')); @endphp
schedule - +
@@ -140,7 +152,7 @@ @dragover.prevent="dragging = true" @dragleave.prevent="dragging = false" @drop.prevent="dragging = false; const input = $el.querySelector('input[type=file]'); input.files = $event.dataTransfer.files; input.dispatchEvent(new Event('change'))" - :style="{ borderColor: dragging ? 'var(--color-accent)' : undefined, background: dragging ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }" + :style="{ borderColor: (typeof dragging !== 'undefined' && dragging) ? 'var(--color-accent)' : undefined, background: (typeof dragging !== 'undefined' && dragging) ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }" style="flex:1;min-width:0;border:1px dashed var(--color-divider);border-radius:8px;padding:10px 14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px" > @@ -216,7 +228,7 @@ @dragover.prevent="dragging = true" @dragleave.prevent="dragging = false" @drop.prevent="dragging = false; const input = $el.querySelector('input[type=file]'); input.files = $event.dataTransfer.files; input.dispatchEvent(new Event('change'))" - :style="{ borderColor: dragging ? 'var(--color-accent)' : undefined, background: dragging ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }" + :style="{ borderColor: (typeof dragging !== 'undefined' && dragging) ? 'var(--color-accent)' : undefined, background: (typeof dragging !== 'undefined' && dragging) ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }" style="flex:1;min-width:0;border:1px dashed var(--color-divider);border-radius:8px;padding:10px 14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px" > @@ -235,7 +247,7 @@ -
+
@forelse ($this->replyQuickActions as $qa) @empty @@ -439,6 +451,7 @@
MONITOR CZASU PRACY
- Czas w zgłoszeniu: + {{-- Guarded call: even with the interval/listener cleanup above, a + refreshTicketData() response already in flight when the operator + navigates away can still land afterward and get morphed against + a scope that's mid-teardown — typeof-guarding here means that + race can no longer throw, whatever exactly triggers it. --}} + Czas w zgłoszeniu: edit
@if ($ticket->isClosed()) diff --git a/src/tests/Feature/AdminLogsViewerTest.php b/src/tests/Feature/AdminLogsViewerTest.php new file mode 100644 index 0000000..7493b21 --- /dev/null +++ b/src/tests/Feature/AdminLogsViewerTest.php @@ -0,0 +1,78 @@ +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(); +}); diff --git a/src/tests/Feature/StatsCategoryBreakdownTest.php b/src/tests/Feature/StatsCategoryBreakdownTest.php new file mode 100644 index 0000000..4ec069d --- /dev/null +++ b/src/tests/Feature/StatsCategoryBreakdownTest.php @@ -0,0 +1,59 @@ +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); +}); diff --git a/src/tests/Feature/StatsSubcategoryBreakdownTest.php b/src/tests/Feature/StatsSubcategoryBreakdownTest.php index ffc593a..3bfb58e 100644 --- a/src/tests/Feature/StatsSubcategoryBreakdownTest.php +++ b/src/tests/Feature/StatsSubcategoryBreakdownTest.php @@ -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); +}); diff --git a/src/tests/Feature/TicketBusinessRulesTest.php b/src/tests/Feature/TicketBusinessRulesTest.php index 67c6eee..b71acb4 100644 --- a/src/tests/Feature/TicketBusinessRulesTest.php +++ b/src/tests/Feature/TicketBusinessRulesTest.php @@ -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(); diff --git a/src/tests/Feature/TicketFieldValuesSyncTest.php b/src/tests/Feature/TicketFieldValuesSyncTest.php new file mode 100644 index 0000000..e39b8cb --- /dev/null +++ b/src/tests/Feature/TicketFieldValuesSyncTest.php @@ -0,0 +1,50 @@ +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); +}); diff --git a/src/tests/Feature/TicketSourceValidationTest.php b/src/tests/Feature/TicketSourceValidationTest.php new file mode 100644 index 0000000..c7cc19a --- /dev/null +++ b/src/tests/Feature/TicketSourceValidationTest.php @@ -0,0 +1,38 @@ + 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); +}); diff --git a/wiki/admin/README.md b/wiki/admin/README.md index 879b2b0..144339c 100644 --- a/wiki/admin/README.md +++ b/wiki/admin/README.md @@ -353,6 +353,17 @@ razem, zamiast być rozrzucone po różnych zakładkach. - **Prompt systemowy podsumowania** — edytowalne pole tekstowe z gotową wartością domyślną i przyciskiem **„Resetuj”**. +## Logi + +Zakładka **„Logi”** pokazuje zawartość plików `storage/logs/*.log` (aplikacja, +IMAP, automatyzacja AI, import z Heska, itd.) bez potrzeby dostępu do +kontenera przez SSH/shell. Lista plików po lewej pokazuje rozmiar i datę +ostatniej modyfikacji (najnowsze na górze) — kliknięcie ładuje zawartość. +Filtry: poziom (ERROR/WARNING/INFO/...), wyszukiwanie tekstowe i liczba +pokazywanych wpisów (100–3000, pokazuje ostatnie N pasujących), plus +opcjonalne automatyczne odświeżanie co 5 s. Widok jest tylko do odczytu i +dostępny wyłącznie dla administratorów. + ## API Panel `/admin/api-docs` udostępnia interaktywną dokumentację (Swagger) REST API diff --git a/wiki/operator/README.md b/wiki/operator/README.md index 0a14942..331eac5 100644 --- a/wiki/operator/README.md +++ b/wiki/operator/README.md @@ -47,7 +47,9 @@ otworzyłeś, najnowsze na górze. e-mailu dowolnego konta (nie tylko klientów), pokazuje liczbę jego zgłoszeń i prowadzi od razu do kolejki przefiltrowanej do tego klienta. -**Filtry** nad tabelą: status, priorytet, kategoria, wyszukiwanie po numerze/ +**Filtry** nad tabelą: status, priorytet, kategoria (opcja **„Bez kategorii”** +pokazuje zgłoszenia bez przypisanej kategorii i podkategorii — np. z poczty +IMAP, która nie trafiła w żadną), wyszukiwanie po numerze/ temacie/kliencie/treści zgłoszenia i odpowiedzi w wątku. **Kolumny** można dowolnie włączać/wyłączać przyciskiem „Kolumny” (numer, temat, klient, kategoria, podkategoria, priorytet, status, SLA, przypisany, zespół, utworzono — kilka z