- Snipe-IT asset inventory integration (Admin > Integracje), optional and off by default: connect by API address + personal token (+ SSL-verification bypass). Three independent toggles: client can pick which of their own Snipe-IT assets a ticket concerns (scoped to admin-selected subcategories, empty = never shows), operator sees the requester's assets in a ticket-view sidebar, operator can search the whole inventory from that same sidebar (not a separate page) for shared equipment. Assets shown as "numer środka - numer seryjny - producent model" + category; a linked asset's live status is fetched fresh on the ticket page, and unlinking stays available to an operator even with both view/search toggles off. - AI summary: a "Wygeneruj teraz" button for an immediate on-demand refresh, plus a new admin toggle to regenerate right after every new reply/note instead of only on the next scheduled sweep. The transcript sent to the model now also includes the ticket's own opening body, fixing summaries missing the original request on long threads. - Fixed: the status dropdown in the operator ticket view could keep showing the pre-change status after a status-changing quick action until the next page load (Livewire/Alpine-morph quirk for wire:change-bound selects). - Docs: README/ARCHITECTURE/CHANGELOG/install/wiki updated for all of the above, including correcting the AI-summary refresh description left over from the 1.2.1 release notes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
674 lines
41 KiB
Markdown
674 lines
41 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.
|
|
|
|
## 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.
|