Files
servicedesk/ARCHITECTURE.md
Kacper 0943829331 v1.5.1
Co nowego:
- Kolumny kolejki operatora: dwie nowe (ID, e-mail) obok istniejących, oraz
  możliwość zmiany kolejności widocznych kolumn strzałkami ↑/↓ w picker
  „Kolumny” — nie tylko włączanie/wyłączanie. Kolejność zapamiętywana jest
  per operator tak samo jak dotąd widoczność.
- Wybór sprzętu klienta (Snipe-IT): druga lista dozwolonych kategorii obok
  istniejącej listy podkategorii — pozwala objąć od razu wszystkie
  podkategorie danej kategorii jednym zaznaczeniem.
- Pulpit klienta i widok zgłoszenia operatora pamiętają teraz aktywną
  zakładkę, więc „Wróć do listy” wraca do tej samej, a nie zawsze do
  domyślnej.

Poprawki:
- Paginacja (kolejka operatora, pulpit klienta) używała domyślnego,
  szarego stylu Laravela reagującego na motyw systemu/przeglądarki, a nie
  przełącznik jasny/ciemny w aplikacji — stąd ciemne przyciski nawet w
  trybie jasnym. Podmieniony na własny widok zgodny z kolorami aplikacji
  (w tym własne tło/border każdego przycisku i wyśrodkowanie na telefonie).
- Kolorystyka boksu z informacją o logowaniu nie zmienia już odcienia
  między trybem jasnym i ciemnym (wcześniej pochodziła z --color-accent) —
  teraz stałe, ciemne tło w obu trybach, więc kolory tekstu ustawione przez
  admina (np. biały) zostają czytelne niezależnie od motywu. Poszerzona
  karta logowania (380px → 480px).
- Lista zgłoszeń klienta: etykiety priorytetu/statusu nie zawijają się już
  do osobnej linii przy długim temacie na wąskich ekranach — zostają
  przypięte do prawej, a temat zawija się we własnej kolumnie.
- Liczniki czasu pracy (resumeTimer/stopTimer/...) nie dotykają już
  updated_at — samo otwarcie zgłoszenia nie liczy się jako aktualizacja.
  Kolejka operatora i pulpit klienta domyślnie sortują po dacie utworzenia
  z tego samego powodu.

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 09:01:34 +02:00

47 KiB

Architecture

Server-rendered Laravel + Livewire app (no SPA/API-driven frontend for the app itself — the REST API in routes/api.php exists purely for external integrations). See README.md for the feature list and tech stack; this doc covers how the pieces fit together.

Request flow

  1. routes/web.php gates every area behind auth + a role middleware (role:client, role:operator, role:adminApp\Http\Middleware\EnsureRole), which checks the role against $user->roles. A user can hold multiple roles at once; the router just requires one of the listed roles per route group.
  2. Each route resolves to a full-page Livewire component under app/Livewire/{Client,Operator,Admin,Auth}/ — there are no traditional controllers rendering Blade views for these areas (the REST API in routes/api.php is the exception, backed by app/Http/Controllers/Api/).
  3. Livewire components call into app/Services/TicketService.php for anything that mutates ticket state (create/transition/reply/notify) rather than mutating models directly — keep that convention when adding new mutations so notification/history/SLA side effects stay in one place.
  4. App\Providers\AppServiceProvider::boot() runs a settings override pass on every request (applyLdapSettingsOverride, applyMailSettingsOverride, applySessionSettingsOverride, applyTimezoneSettingsOverride) — see "Settings override" below.

Data model

Core tables/models (app/Models/):

Category ─< Subcategory ─< CustomField        (per-subcategory custom fields)
                 │
                 └──< Ticket >── Team          (subcategory routes to a team)
                          │
                          ├──< 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)
                          └── csat_rating/csat_comment/csat_rated_at (nullable — set once, on close)

User ─< UserFieldValue >─ UserField
User ─< SavedQueueView                          (operator's own named/default saved queue filter/sort/column presets)
User.operator_queue_columns                     (JSON, auto-remembers shown/hidden queue columns + their order, independent of SavedQueueView)
User ─< notifications (Laravel's database channel — polymorphic, morph-mapped as 'user')
ApiClient                                       (Sanctum token owner, ability-scoped)
Setting                                          (single-row-per-key config store, see below)
ReplyQuickAction, ResponseTemplate, EmailTemplate, NotificationSetting

tickets.subject/tickets.body and ticket_messages.body carry a MySQL/MariaDB FULLTEXT index (added in a later migration, MySQL-only — absent on the sqlite connection the test suite runs on) — Ticket::scopeSearch() uses whereFullText() when the active connection is mysql and falls back to a portable LIKE otherwise, so the same call site works in both places.

Ticket (app/Models/Ticket.php) is the largest model — it owns SLA math (slaInfo(), isOverdue(), resolutionDeadline()), status/priority display helpers (statusLabel(), tagStyleFromColor()), operator-visibility scoping (scopeVisibleToOperator, isVisibleToOperator — a team member sees their team's 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.

Timer bookkeeping never touches updated_at. flushTimer()/stopTimer()/ resumeTimer()/resetTimer()/setTimeSpent() all route their writes through the private updateTimerFields(), which toggles $this->timestamps = false around the update() call. resumeTimer() runs on every single ticket open (TicketShow::mount()) and stopTimer() on every navigate-away/tab-close — without this, merely viewing a ticket (no reply, no status change) would bump updated_at, which used to drown out genuinely stale tickets in any list sorted by that column (operator queue, client dashboard — both now default to sorting by created_at instead, for the same reason). Real content changes still touch updated_at normally, via their own separate update()/save() calls elsewhere. If you add another timer-only field, write it through updateTimerFields() too rather than a plain update().

Ticket numbering & URLs

A ticket carries three distinct identifiers, each with a different job:

  • id — the DB primary key. Never shown to users; the REST API (routes/api.php) is deliberately pinned to it ({ticket:id} explicit binding on every {ticket} route) so external integrations have a stable contract regardless of the numbering settings below.
  • number — a plain sequential string (Ticket::nextNumber(), max+1 starting at 1001), unique but otherwise unremarkable. Backs scopeSearch() and the numeric sort in Operator/Queue.php regardless of display mode.
  • checksum — a 6-digit HMAC-derived value (salted with app.key, keyed off id), assigned once in a Ticket::booted() created listener and never changed afterward. Collisions are handled for real, not just assumed away: Ticket::generateUniqueChecksum() walks a nonce forward until the candidate is free (checked against the DB), and the column has a unique() constraint as a hard backstop.

Ticket::displayNumber()/formattedNumber() pick between number (zero-padded to Settings::get('ticket_number_min_length')) and checksum based on Settings::bool('ticket_number_obfuscate') — the "Ukryj kolejność zgłoszeń" toggle in Admin > Konfiguracja. Ticket also overrides getRouteKey() and resolveRouteBinding() to mirror that same choice, so the web routes (routes/web.php, all plain {ticket} implicit bindings — no explicit field) resolve and generate URLs against whichever column is currently the display number: flip the setting and both the visible number and every link (route('client.ticket', $ticket) etc.) switch together, and a bookmarked URL built under the old mode stops resolving. This is why the API routes need the explicit {ticket:id} override — without it, the same global getRouteKey() change would silently start requiring number/checksum in API path params too, breaking the documented integer "Ticket id" contract.

The {numer} placeholder available in admin-editable e-mail templates (Admin > Szablony e-mail / Wyzwalacze) resolves to formattedNumber() without displayNumber()'s prefix — those templates already hardcode their own #{numer}, so adding the prefix there too would double it up or clash with a non-default prefix.

A ticket route binding that resolves to nothing (most commonly: the ticket was deleted while someone had it open, and a later request — typically Livewire's own "model missing during hydration" recovery, which does a full window.location.reload() of the same page — hits {ticket} again) no longer surfaces Laravel's default 404 page. bootstrap/app.php registers a NotFoundHttpException render callback (note: Handler::prepareException() already converts ModelNotFoundException into NotFoundHttpException, wrapped as getPrevious(), before any render callback runs — a callback typed against ModelNotFoundException itself would never match) that redirects to operator.queue/client.dashboard instead, for any authenticated request under operator/*/client/*.

That global handler only ever sees a full HTTP request (a page load/reload), not Livewire's own AJAX update endpoint (/livewire/update, which doesn't match the operator/*/client/* path check) — so it doesn't cover an operator who already has a ticket open when it's deleted, or whose team gets reassigned (by anyone, including via their own action — see "Teams" in README.md) to one outside their visible scope (Ticket::isVisibleToOperator()) mid-session. Operator\TicketShow handles that case itself: a Livewire component's typed public model property (public Ticket $ticket) is re-fetched by id on every subsequent request via firstOrFail() (Livewire\Features\SupportModels\ModelSynth::hydrate()), which throws ModelNotFoundException before any of the component's own method code runs if the row is gone — too early for an ordinary try/catch inside an action method to ever catch. The component instead defines Livewire's exception($e, $stopPropagation) lifecycle hook (called for any exception raised anywhere in the component's request lifecycle, hydration included) to catch that case and redirect. The narrower case — ticket still exists but is no longer visible, e.g. after a team reassignment — doesn't throw at all, so it's caught separately: refreshOrRedirectAway() re-checks isVisibleToOperator() after every live-update refresh (onQueueChanged()/refreshTicketData()) and after the operator's own setTeam() call, redirecting immediately rather than leaving them on a ticket they can no longer legitimately keep viewing.

Roles & permissions

$user->roles reads/writes as a plain array (['client', 'operator']), but it's a virtual attribute (User::getAttribute()/setAttribute() overrides) backed by a real roles lookup table + role_user pivot, not an actual column — assigning 'roles' => [...] on create/update stashes the keys until the model's saved hook resolves them against roles.key and syncs the pivot. This matters for tests/seeders: a role key must exist in the roles table before it can be assigned this way, or the assignment silently becomes a no-op (Tests\TestCase::setUp() seeds the 3 fixed roles for exactly this reason, since almost every test creates a role-bearing user). Checked via EnsureRole at the route level. Every account gets client by default (App\Ldap\Handlers\AssignDefaultRole for LDAP-provisioned accounts); staff switch areas via the header role switcher, but always land on /client first after login.

Authentication

config/auth.php defines the default web guard against an LDAP-backed user provider (LdapRecord); a plain Eloquent provider is kept alongside it only for local tooling/tests that don't hit a directory. In production, LDAP bind is the primary path; the local fallback account (admin@example.com from the seeder) authenticates against a local password when the LDAP bind doesn't match — this is the account used for first login after a fresh install (see install.md).

app/Ldap/Handlers/ hooks into LdapRecord's import/sync events: AssignDefaultRole grants the client role to new LDAP-provisioned accounts, SyncUserFieldsFromLdap keeps UserFieldValue rows in sync with directory attributes.

app/Ldap/ has two directory-schema models — LldapUser (LLDAP/OpenLDAP, the default) and AdUser (Active Directory, LdapRecord\Models\ActiveDirectory\User under the hood). Settings::ldapUserModelClass() picks between them based on the ldap_directory_type setting, and AppServiceProvider::applyLdapSettingsOverride() wires the chosen class into config('auth.providers.users.model') on every request — same live-override mechanism as the connection host/base DN below. LdapUserProvisioner (used for sync + guest auto-provisioning) resolves the same setting at call time rather than caching the class, so switching directory type takes effect without a redeploy.

Settings override ("live config")

App\Support\Settings (app/Support/Settings.php) is a cached key/value reader over the settings table, with hardcoded defaults for every key (company name, LDAP/SMTP connection details, attachment limits, session lifetime, timezone, branding/email HTML, etc.). Admin > Konfiguracja (general/attachments/session), Poczta (SMTP) and Integracje (LDAP, BookStack) all write to this same table, and AppServiceProvider::boot() re-applies the relevant subset of it over config() on every request — meaning Setting rows win over .env for LDAP, mail, session lifetime and timezone once they're non-empty. This is by design (lets an admin reconfigure LDAP/SMTP without a redeploy) but is also the source of the "seeded placeholder overrides real .env values" gotcha documented in install.md — anything touching LDAP/mail/session/ timezone config should go through Settings, not raw config()/.env reads.

settingsTableUsable() gates all four overrides on whether the settings table is safe to query yet — but is deliberately scoped to just the migrate command family (runningConsoleCommand('migrate', 'migrate:fresh', ...)), not "any console command". It used to blanket-skip for every console invocation (exempting only unit tests), which silently broke every scheduled command's outbound mail: AppServiceProvider::boot() runs on each process including schedule:run-invoked commands, so tickets:check-sla-breaches, automation:run-rules and emails:fetch-imap (below) all sent notifications through whatever .env's MAIL_MAILER happened to be (log, i.e. nowhere) instead of the admin-configured SMTP server — with no error, since the log mailer never throws. If a scheduled command's notification/lookup ever again seems to silently use .env defaults instead of Settings, check here first.

Notifications

TicketService::notify(Ticket $ticket, string $triggerKey) is the single fan-out point for every ticket lifecycle event (see the NotificationSetting rows seeded per trigger key) — it resolves the configured recipient ($ticket->assignee or $ticket->customer) to a real User when one exists and calls $user->notify(new TicketNotification(...)), which fires both the mail and database channels (App\Notifications\TicketNotification) — there's no separate on/off switch for in-app vs. e-mail, the same NotificationSetting.enabled flag gates both. A guest customer with no account still gets routed anonymously (Notification::route('mail', $email), mail-only — the database channel needs a real notifiable to attach the row to). TicketNotification is constructed with an explicit $recipientRole ('client'|'operator') rather than inferring it from the notifiable's roles, since one account can hold both — this decides whether the ticket link (both the e-mail body and the in-app notification's url) points into /client/... or /operator/....

Real-time broadcasting (Reverb)

Two private channels, authorized in routes/channels.php:

  • operator.queue — one shared channel for every operator/admin (not scoped per team/ticket), so the receiving Operator\Queue component just re-queries through its own already-correct Ticket::scopeVisibleToOperator() on any event instead of the channel-auth callback needing to duplicate that ACL logic. Payloads stay minimal (ticket id + reason + actor id) for the same reason.
  • ticket.{id} — per-ticket channel for the message thread and detail changes, authorized for an operator with isVisibleToOperator() or the ticket's own customer (OR, not else-if — the one real account in this app holds both roles at once). An internal note broadcasts on the same channel a client can subscribe to, but the payload never carries the message body — each side's Livewire component only ever re-queries whatever its own already-authorized computed property returns, so there's nothing to leak.

Two events, both App\Events\TicketQueueChanged (broadcasts on both channels above — a status/priority/team/assignee change needs to reach a client watching their own ticket too) and App\Events\TicketMessagePosted (broadcasts on ticket.{id} only). Both implement ShouldBroadcastNow, not ShouldBroadcast — this app runs with no queue worker by design (see TicketNotification), so broadcasting happens synchronously within the request like everything else here. TicketService dispatches both from every ticket-mutating method (create/setStatus/setPriority/setAssignee/setTeam/ operatorReply/operatorNote/clientReply/apiMessage/merge); the two ad hoc delete call sites (Operator\Queue::confirmDeleteSelected(), Operator\TicketShow::confirmDeleteTicket()) dispatch TicketQueueChanged directly since there's no TicketService::delete() to hook into.

Browser side, resources/js/echo.js bridges Reverb events into plain Livewire events (Livewire.dispatch('queue-changed', ...) / 'ticket-message-posted') rather than using the #[On('echo-private:...')] attribute directly — version-agnostic, and each Livewire component just declares a plain #[On(...)] listener that no-ops if the payload's actorId matches the viewer's own id (self-echo suppression) or the ticket id doesn't match the component's own ticket. Two easy-to-reintroduce bugs to know about if "nothing updates live" ever comes back:

  1. CSRF on /broadcasting/auth. Echo's private-channel subscription POSTs there under the app's normal CSRF middleware; the Echo constructor must pass auth.headers['X-CSRF-TOKEN'] (read from the <meta name="csrf-token"> tag in layouts/app.blade.php) or every subscription attempt is silently rejected.
  2. Script load order. resources/js/app.js (which imports echo.js) loads via @vite as type="module", which the HTML spec defers until after the document is parsed — meaning Livewire's own bootstrap script (@livewireScripts, a plain synchronous <script> near the end of <body>) has already run by the time echo.js executes. Don't gate anything in echo.js behind document.addEventListener('livewire:init', ...) — that event fires as part of Livewire's own (earlier) script, so a listener registered this late permanently misses it. The one place this still matters is the per-ticket subscription triggered from a Livewire @script block in the ticket-show views, which can run before or after echo.js depending on exactly when Livewire processes it — it queues the ticket id onto window.__pendingTicketChannelIds if echo.js hasn't defined window.subscribeToTicketChannel yet, and echo.js flushes that queue once it has.

As a defense against a dropped websocket connection (backgrounded tab, network blip), the operator queue and both ticket-detail views also poll themselves via a small Alpine countdown calling $wire.refreshQueue() / $wire.refreshTicketData() — broadcasting is best-effort, not the only way these views ever update. The countdown badge is also clickable (x-on:click="remaining = total; $wire.refresh...()" on the same element the x-init="setInterval(...)" already lives on) to fetch immediately and reset the countdown, rather than only ever firing on its own schedule. Its interval — like the notification bell's wire:poll and the 4 scheduled commands below — reads from Settings (refresh_queue_seconds/ refresh_ticket_view_seconds/refresh_notifications_seconds, admin-editable in Konfiguracja) rather than a hardcoded number: wire:poll.{{ $seconds }}s and Alpine's x-data="{ remaining: {{ $seconds }}, ... }" both just interpolate to plain text in the rendered HTML, so a Settings-sourced value works exactly like a literal one would.

A third private channel, App.Models.User.{id} (Laravel's default per-notifiable convention, kept verbatim rather than a shorter alias), carries realtime bell delivery: AppServiceProvider::broadcastBellNotifications() listens for the framework's own NotificationSent event, and — only for the database channel of a TicketNotification — dispatches NotificationCreated on the recipient's own channel. This is a single choke point rather than threading a broadcast call into every TicketService notification call site (including the Trigger engine's send_notification action, below). resources/js/echo.js bridges it into a bell-notification-received Livewire event (refreshing NotificationBell instantly) and, if the viewer opted in via the toggle on /settings/notifications, also raises a native in-tab Notification popup — no service worker or push subscription, so this only fires while the tab is open, same limitation as the other Echo listeners here.

SLA

SlaRule holds per-priority response/resolution targets in minutes. The scheduled command tickets:check-sla-breaches (registered in routes/console.php, default every 15 minutes, interval admin-configurable — see "Configurable scheduled-command intervals" below) flags overdue tickets and can notify the assigned operator — see install.md for why this requires an external cron entry (the Docker image ships no cron/supervisor of its own).

SLA automation rules

AutomationRule (label, condition_minutes, optional scope_priority_key/ scope_subcategory_id/scope_team_id, action_type + action_value) lets an admin configure "if a ticket has been silent for N minutes, change its priority/status/team/assignee" without code — Admin > Automatyzacja SLA. The scheduled command automation:run-rules (default also every 15 minutes, independently configurable) evaluates every enabled rule against Ticket.last_customer_activity_at (falling back to created_at if never set — mirrors how resolutionDeadline() treats a missing SlaRule as "no SLA" rather than backfilling one), and applies a match through the same TicketService setters a manual operator action would use, so the automated change gets the same history entry, notification, and broadcast for free. Idempotency is a per-(rule, ticket) row in automation_rule_ticket_logs, cleared by TicketService whenever the silence that triggered it is broken (a fresh clientReply()) or the ticket closes/reopens (setStatus()) — so a rule can fire again after a new period of silence instead of being permanently latched. Multiple matching rules on the same ticket in the same run all fire independently, in id order; a rule that closes the ticket doesn't block earlier-ordered rules already applied this run, but a later rule's own query naturally excludes an already-closed ticket.

Configurable scheduled-command intervals

All 4 scheduled commands (tickets:check-sla-breaches, automation:run-rules, emails:fetch-imap, ai:run-ticket-automation) have an admin-configurable interval (Admin > Konfiguracja — schedule_sla_check_minutes/ schedule_automation_rules_minutes/schedule_imap_fetch_minutes/ schedule_ai_automation_minutes), defaulting to their previous hardcoded values (15/15/5/5 minutes). routes/console.php registers all 4 as ->everyMinute()->when(fn () => Settings::dueEveryMinutes($key, $default)) rather than an eagerly-built ->cron('*/N * * * *') string — this is a deliberate choice, not just a style preference: routes/console.php is require'd on every artisan boot (migrate, tinker, php artisan test, not just schedule:run, since it's wired in via bootstrap/app.php's commands: key), so anything at its top level that queries the database would run before a fresh/test database necessarily has the settings table yet — an early version of this feature that built the cron string eagerly at the top level broke exactly this way. A closure passed to ->when() is only ever evaluated later, when schedule:run actually processes due events, so Settings::dueEveryMinutes() never runs at boot. One visible side effect: php artisan schedule:list shows * * * * * for all four regardless of their actual configured interval, since the real interval only exists inside the closure — expected, not a bug.

IMAP e-mail intake

Optional, off by default (ImapMailbox.enabled per row — there is no single global toggle since this is a list of N mailboxes, not a Settings singleton). Split across three layers, mirroring the plan that shipped it:

  • App\Models\ImapMailbox — one row per polled mailbox (host/port/ encryption/username, password cast 'encrypted' — the first model in this codebase to use Laravel's native encrypted cast rather than the manual Crypt:: pattern Settings uses, since this is a list of records rather than key/value config). default_subcategory_id XOR default_category_id (enforced by the admin form's single combined selector, not a DB constraint) route new tickets; category_id only ever gets populated when there's no subcategory to derive one from (see Ticket::categoryLabel()/TicketService::create()).
  • App\Services\ImapMessageClassifier — pure decision logic, no IMAP connection, fully Pest-testable: rejectionReason() (auto-reply/bounce detection via Auto-Submitted/Precedence/X-Autoreply headers + EN/PL subject phrases + a per-mailbox sender blocklist), matchTicket() (extracts every digit run ≥4 chars from the subject — after stripping Re:/Odp:/Fwd:/FW:/Aw: — and tries each through Ticket::resolveRouteBinding(), so it transparently matches either the plain sequential number or the obfuscated checksum, whichever mode is active; no changes to outbound mail were needed since every notification subject already carries {numer}), isSenderAllowed() (mirrors Landing::emailIsKnown() — enforces restrict_tickets_to_ldap for e-mail exactly like the guest web form), resolveSender() (existing local user, or LdapUserProvisioner::findOrCreateByEmail() if enabled).
  • App\Services\ImapMailboxFetcher — the I/O layer (webklex/php-imap, a pure-PHP IMAP client with no ext-imap dependency — confirmed available extensions were sufficient, no Dockerfile change needed). Fetches whereUnseen() per mailbox, flags/moves a message before creating the ticket (a crash mid-batch then risks a "processed but no ticket" message — visible and easy to fix manually — rather than a duplicate ticket on the next run), converts attachments to UploadedFile via a temp file ($test = true bypasses the is_uploaded_file() check outside a real HTTP request) so they flow through the existing Settings::validateAttachments()
    • TicketService::attachFiles() unchanged. Logs every connection attempt and per-message decision to a dedicated imap log channel (storage/logs/imap-*.log, always debug level regardless of the app's own LOG_LEVEL — see config/logging.php) since this app commonly runs at LOG_LEVEL=error, which would otherwise silently swallow this activity entirely.
    • One real bug worth remembering if IMAP rejection logic ever seems too aggressive again: Webklex's Header::get($name) returns an empty Attribute (not null) for a header that isn't present at all, and Attribute::first() on that empty instance is '', not null — a naive $header !== null check therefore treats every message as carrying every header. Guarded in two places: ImapMailboxFetcher only keeps a header value that's non-empty, and InboundEmail::header() itself also treats '' as absent, so the bug can't resurface even if some other header source stops filtering.
  • TicketService::guestReply() — the one new method added to the existing service: a customer reply with no User account (mirrors clientReply() — real customer activity, resets SLA silence, fires comment_added so an admin-configured Trigger can reopen a closed ticket — rather than apiMessage(), which tags a system/integration note, not client content). Both clientReply() and guestReply() take an optional trailing string $source = 'web', stored as TicketMessage.source (null for 'web') — the per-message counterpart to Ticket.source, since a ticket opened on the web can later get an e-mail reply or vice versa. Both surface as a small mail-icon badge (operator queue: next to the ticket number; ticket view: per-message in the thread, plus a tag next to the ticket number in the header).
  • emails:fetch-imap (app/Console/Commands/FetchImapEmails.php), registered in routes/console.php with ->withoutOverlapping() (like ai:run-ticket-automation, unlike the SLA-check/automation-rules commands — both make real outbound HTTP/IMAP calls per record, so a slow run risks overlapping the next tick in a way a pure-DB command doesn't). Early-returns if no ImapMailbox is enabled. Also callable directly per mailbox from Admin > Poczta's "Pobierz teraz" button (ImapMailboxFetcher::fetchMailbox(), bypassing the enabled-only fetchAll() used by the schedule) for on-demand fetching/diagnosis without shell access.

Requires the same external schedule:run cron entry as SLA/automation (see install.md and the crontab note in 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 surface over Sanctum tokens (tickets:read, tickets:write, dictionaries:read, users:read), issued via admin-managed ApiClient records. Rate limiting is configured per-client (120 req/min keyed by client ID) vs. a tighter per-IP limit for unauthenticated requests (AppServiceProvider::configureApiRateLimiting()). Interactive docs are generated by L5-Swagger at /admin/api-docs; there is no static Markdown API reference in-repo.

Generic AI integration

App\Services\AiClient is a small, feature-agnostic wrapper around an OpenAI-compatible /chat/completions endpoint (chat(array $messages, array $options = []): ?string) — works against Groq, OpenAI itself, or a self-hosted Ollama instance, whichever ai_base_url points at. Settings-driven like everything else here: ai_enabled, ai_base_url, ai_api_key (encrypted, optional — deliberately not required by enabled(), since a self-hosted Ollama instance typically has no auth at all), ai_model, ai_verify_ssl. Every call is wrapped in try/catch(\Throwable) and returns null on any failure (network, non-2xx, unexpected shape), matching BookStackClient's safe-default convention — callers are expected to treat null as "AI unavailable" and degrade gracefully rather than throw. Not tied to any single feature: BookStackContentTagger, TicketAiTriageService and TicketAiSummaryService (below) are just its first three consumers, each with their own prompt-building/parsing logic layered on top rather than baked into the client itself.

BookStack integration

App\Services\BookStackClient is one of three outbound HTTP clients in the codebase (Laravel's Http facade), alongside AiClient above and SnipeItClient below — everything else here only ever receives requests. It's entirely Settings-driven, no .env/config() involved: bookstack_enabled, bookstack_base_url, bookstack_token_id/bookstack_token_secret (encrypted, same as the LDAP/SMTP passwords), bookstack_verify_ssl, and two independent allow-lists of BookStack shelf IDs — bookstack_allowed_shelf_ids_creation (ticket-wizard suggestions) and bookstack_allowed_shelf_ids_ticket_view (the operator's sidebar on an existing ticket) — search() takes a $context (CONTEXT_CREATION / CONTEXT_TICKET_VIEW) that selects which one applies. An empty allow-list means "search nothing", not "search everything" — nothing is ever suggested until an admin explicitly opts shelves in, independently per context. BookStack has no "which shelf is this book on" field in its own search response, so BookStackClient fetches /api/shelves + /api/shelves/{id} once (cached 30 min) into a shelf→book-ids map, used both to resolve the allow-list to book IDs and to build the "Shelf > Book" breadcrumb shown next to each suggestion. Per-query search results are cached 10 minutes, keyed on the query text and the active allow-list, so toggling which shelves are allowed is reflected immediately instead of serving a pre-change result for up to 10 minutes.

Content-type filter and "search by" mode: bookstack_search_types is a comma-separated subset of BookStackClient::SEARCH_TYPES (book, page, chapter — checkboxes in the admin UI, no more single-select "both/page/book" dropdown), combined into BookStack's own {type:a|b} query syntax. bookstack_search_by ('name'/'tags'/'both') picks between matching the title ({in_name:...}) and matching a tag whose name equals the query ([...] — see BookStack content auto-tagging below for what actually writes those tags); 'both' runs one request per mode and merges/dedupes the results, since BookStack's own query syntax ANDs filters together rather than OR-ing them, so there's no single-request way to ask for "name OR tag". search() takes both a $query (full "Category Subcategory" text, used for the name-match variant) and an optional $tagQuery (bare subcategory name, used for the tag-match variant) — the two differ because a tag is expected to hold just the subcategory name, not the combined category+subcategory text.

BookStack content auto-tagging

App\Services\BookStackContentTagger (used by the "Otaguj nową treść"/"Otaguj wszystko ponownie" buttons on the BookStack admin card and by php artisan bookstack:tag-content) is the reason the tag-based search mode above has anything to match: it walks every book/chapter/page via BookStackClient::listAll()/detail(), builds a Polish prompt naming the current, live Subcategory list as the only allowed vocabulary, and asks AiClient (above) to return which subcategory name(s) fit each item — a single response per batch of 20 items, to keep prompt size/cost down. Defensive JSON parsing (parseAssignments()) regex-extracts the first {...} block before decoding, so a chatty or malformed response fails just that one batch (failed_batches in the run summary) instead of crashing the whole pass; every returned label is matched case-insensitively against the real subcategory list before being trusted, so a hallucinated name is silently dropped rather than written as a tag. Idempotent by default — an item already carrying a tag matching a current subcategory name is skipped unless --force/the "wszystko ponownie" button is used — and new tags are merged into an item's existing tags (updateTags() PUTs the whole array; BookStack has no "append a tag" endpoint), never overwriting unrelated ones.

Snipe-IT asset inventory integration

App\Services\SnipeItClient talks to a Snipe-IT instance's REST API (/api/v1/..., bearer token auth), entirely Settings-driven like BookStackClient: snipeit_enabled, snipeit_base_url, snipeit_api_token (encrypted), snipeit_verify_ssl. Every call is wrapped in try/catch(\Throwable) returning []/null on failure, same safe-default convention as AiClient/BookStackClient. Three independently toggleable settings gate what a client/operator can actually do with it — none of them affect SnipeItClient itself, only which Livewire methods are willing to call it:

  • snipeit_client_can_select_asset (+ snipeit_client_asset_subcategory_ids and snipeit_client_asset_category_ids, two independent comma-separated allow-lists) — gates Client\NewTicket's asset picker. Mirrors BookStack's shelf allow-lists: empty lists mean the picker never shows for any subcategory, not "every subcategory" — NewTicket::snipeitAssets() checks the toggle and that either the currently selected subcategoryId is in the subcategory list or categoryId is in the (coarser) category list before calling assetsForEmail(). The category list exists so an admin can cover every subcategory of a category in one click instead of ticking each one individually; the two lists are additive, not exclusive. selectCategory()/selectSubcategory() reset any already-picked asset, so switching to an out-of-scope subcategory can't silently carry a stale selection through to submit().
  • snipeit_operator_view_requester_assets — gates the same assetsForEmail() lookup (by the ticket's own email, not the viewing operator's) in Operator\TicketShow's sidebar.
  • snipeit_operator_search_inventory — gates searchAssets(), a free-text /hardware?search= lookup across the whole inventory, for linking equipment the requester doesn't personally own (e.g. a shared printer). Rendered inline in the same sidebar card as the requester-assets list, not a separate route/page.

Operator\TicketShow::linkSnipeitAsset(int $id) deliberately does not fall back to a direct SnipeItClient::asset($id) lookup by id — it only accepts an id present in snipeitRequesterAssets/snipeitSearchResults, and each of those is itself empty unless its own setting above is on. This means an operator can't link an arbitrary asset through a source the admin has switched off for them, even by tampering with the Livewire request payload. unlinkSnipeitAsset() has no such gate — clearing an existing link is a correction, not a new way to browse Snipe-IT, so it stays available even with both toggles off.

SnipeItClient::assetsForEmail() has to resolve an e-mail to a Snipe-IT user first (GET /users?search=, no "assets by e-mail" endpoint exists), then lists what's checked out to them (GET /users/{id}/assets) — cached 5 minutes per e-mail. normalizeAsset() is the single place that turns a raw Snipe-IT hardware row into the shape every caller/view uses (id, label, serial, manufacturer, model, category, status, url); label joins whichever of asset tag / serial / "manufacturer model" are actually present with " - ", falling back to Zasób #{id} if all three are blank — Snipe-IT doesn't guarantee any of them are filled in. The x-snipeit-assets Blade component renders that shape everywhere an asset list shows up (client picker, requester sidebar, search results), with a card prop that skips its own wrapping <div class="card"> when embedded inside a caller-provided one (the inventory-search box + its results share one card).

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 reflected immediately; the cached label is only ever the fallback shown when that live fetch fails (instance unreachable, or the asset was deleted there).

AI ticket triage & summary

Two independent services, both consuming AiClient above, both run from a single scheduled command (ai:run-ticket-automation) — never synchronously at ticket creation, so an LLM call never adds latency to a live customer submitting a ticket:

  • App\Services\TicketAiTriageService — a one-shot classification pass per ticket, gated by 5 independent toggles (ai_triage_category_when_missing/subcategory_when_category_only/ recheck_categorized/fix_subject/set_priority). buildPrompt() picks one of 3 mutually-exclusive category scenarios from the ticket's current 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 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 real category/subcategory/priority vocabulary before being trusted: a hallucinated or out-of-scope value (e.g. a subcategory claimed under the wrong category) is silently dropped. Applying changes goes through a new TicketService::applyAiTriage(Ticket $ticket, array $changes, array $historyLines) — a single $ticket->update() for whichever category/subcategory/subject/priority fields actually changed, one specific history line per changed field plus a final "Automatyzacja: klasyfikacja AI" attribution line (mirrors how RunAutomationRules logs its own SLA-automation changes), and notify()/TriggerEngine::handle() fired only for the fields that actually changed — deliberately not composed from the existing setPriority()/updateDetails() setters, since one AI pass can touch several fields at once and those would each write their own generic line and fire notifications per-field instead of once per pass.
  • App\Services\TicketAiSummaryService — a summary + suggested next action for every ticket (gated by a single ai_summary_enabled 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 — deliberately compared against ticket_messages.created_at, not tickets.updated_at (which also changes on unrelated actions like a status/priority edit, which would otherwise trigger spurious re-summarization on every tick for an active ticket). buildTranscript() includes the ticket's own body (the opening description, outside ticket_messages) ahead of the message transcript — needed because that row would otherwise fall outside TRANSCRIPT_MESSAGE_LIMIT (30) on any thread longer than that, silently dropping the original request from the prompt. Unlike the triage service, a malformed AI response here leaves the previous summary untouched rather than stamping "done" — the ticket stays in the "stale" set and gets retried next run, since this feature is meant to keep refreshing indefinitely, not run once. The system prompt is admin-editable (ai_summary_prompt setting, plain textarea with a "Resetuj" button restoring Settings::default('ai_summary_prompt') — same pattern as the e-mail footer editor) and asks the model for a small JSON object ({"summary": "...", "suggested_action": "..."}), parsed with the same defensive regex-extract-then-decode approach used throughout these AI services.

Besides run()'s scheduled sweep, two paths call generateFor(Ticket $ticket): bool directly, bypassing the staleness check entirely: Operator\TicketShow::regenerateAiSummary() (the sidebar's "Wygeneruj teraz" button, a synchronous Livewire call — its wire:loading state covers the wait, no need to dispatch anything in the background) and a TicketMessagePosted listener registered in AppServiceProvider::regenerateAiSummaryOnNewMessage(), active only when both ai_summary_enabled and ai_summary_regenerate_on_message (off by default) are on. That listener dispatches App\Jobs\GenerateTicketAiSummaryJob via ::dispatchAfterResponse() rather than the normal queue — deliberately not ShouldQueue, since this deployment's queue worker is optional infrastructure (see install.md) and anything pushed onto the jobs table has no guarantee of ever being picked up; dispatchAfterResponse() instead runs the job in-process right after the triggering HTTP/console response is sent, needing no worker at all.

Its own interval (ai:run-ticket-automation) is admin-configurable the same way the other 3 scheduled commands are — see "Configurable scheduled-command intervals" above for the mechanism and a boot-time trap worth knowing about before touching routes/console.php again.