Co nowego: - Wsparcie Active Directory dla LDAP (obok LLDAP/OpenLDAP), przełącznik typu katalogu w Admin > Integracje. - Wyszukiwarka klientów dla operatora (Operator > Klienci). - Stronicowanie kolejki operatora (50/stronę) i dashboardu klienta (20/stronę). - Globalna wyszukiwarka zgłoszeń (Ctrl+K/Cmd+K) z operatorami w stylu Gmaila (od:, temat:, treść:, numer:), plus przycisk "Szukaj" w panelu bocznym. - Ostatnio przeglądane zgłoszenia w panelu bocznym operatora. - Przeprojektowany pasek nawigacji: suwak Klient/Operator/Administrator zamiast rozwijanego menu, bogatsze menu profilu (nazwa/e-mail/role), dynamiczne tytuły kart przeglądarki na każdej podstronie. - Narzędzie do jednorazowego importu historii zgłoszeń z Heska 3.x (scripts/hesk-import/). - Poprawka: paginacja pokazywała surowe klucze tłumaczeń zamiast tekstu (brakujący lang/pl/pagination.php). Zaktualizowana dokumentacja: README, CLAUDE.md, install.md, ARCHITECTURE.md, CHANGELOG.md, wiki/*. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
684 lines
42 KiB
Markdown
684 lines
42 KiB
Markdown
# 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](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:admin` → `App\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
|
|
├── 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 saved queue filter/sort/column presets)
|
|
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.
|
|
|
|
## 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](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](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](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](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](install.md) and the crontab note in
|
|
[CLAUDE.md](CLAUDE.md)) — without it, only the manual "Pobierz teraz" button
|
|
does anything.
|
|
|
|
## 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`,
|
|
a comma-separated allow-list) — gates `Client\NewTicket`'s asset picker.
|
|
Mirrors BookStack's shelf allow-lists: an **empty** subcategory list means
|
|
the picker never shows for any subcategory, not "every subcategory" —
|
|
`NewTicket::snipeitAssets()` checks both the toggle and that the currently
|
|
selected `subcategoryId` is in the list before calling
|
|
`assetsForEmail()`. `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 `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.
|
|
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 `tickets.ai_triaged_at` stamped exactly once — 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 `tickets.ai_summary`/`ai_suggested_action`/
|
|
`ai_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.
|