Files
servicedesk/ARCHITECTURE.md
Kacper 313e01ad24 v1.2.1
- Generic AI integration (Admin > Integracje > "Integracja AI"), optional and
  off by default: an OpenAI-compatible /chat/completions client (Groq, OpenAI,
  or a self-hosted Ollama instance) configured by base URL, optional API key,
  model, and an SSL-verification toggle. Foundation for the two AI features
  below and anything else that wants an LLM call in the future.
- BookStack automatic content tagging (AI): "Otaguj nową treść"/"Otaguj
  wszystko ponownie" buttons plus `php artisan bookstack:tag-content`
  (--dry-run/--force/--limit=N) tag every book/chapter/page with matching
  helpdesk subcategory names, idempotent by default.
- BookStack search refinement: "Przeszukuj" is now three independent
  checkboxes (Książki/Strony/Rozdziały) instead of a single dropdown, plus a
  new "Szukaj po" setting (nazwa/tagi/oba) — tag matching uses the bare
  subcategory name, matching what auto-tagging writes.
- AI-driven ticket triage + summary (Admin > Integracje > "Automatyzacja AI
  dla zgłoszeń", via new scheduled ai:run-ticket-automation): five toggles
  auto-assign/correct category+subcategory, rewrite an unclear subject, and
  set priority from content, once per ticket in the background; every change
  is logged in the ticket's history. Separately, an AI summary + suggested
  action for every ticket, shown to operators only, with an admin-editable
  prompt.
- Operators can now reassign a ticket to any team, not just one they belong
  to.
- The auto-refresh countdown badges (ticket view, operator queue) are now
  clickable — fetch immediately and reset the countdown.
- All 7 "cyclical" intervals (3 browser refresh countdowns, the notification
  bell poll, and the 4 background scheduled commands) are now configurable
  from Admin > Konfiguracja instead of fixed in code.
- Fixed: an operator viewing a ticket that's deleted or moved outside their
  team scope mid-session is now redirected to the operator queue instead of
  hitting an error.
- Docs: README/ARCHITECTURE/CLAUDE/install/wiki updated for all of the above.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 13:38:39 +02:00

590 lines
36 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 two outbound HTTP clients in the
codebase (Laravel's `Http` facade), alongside `AiClient` above — 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.
## 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 — never live-called from the ticket
page itself, only ever displaying whatever the scheduled command last
computed). 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). 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.
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.