- 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>
36 KiB
Architecture
Server-rendered Laravel + Livewire app (no SPA/API-driven frontend for the app
itself — the REST API in routes/api.php exists purely for external
integrations). See README.md for the feature list and tech stack;
this doc covers how the pieces fit together.
Request flow
routes/web.phpgates every area behindauth+ 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.- 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 inroutes/api.phpis the exception, backed byapp/Http/Controllers/Api/). - Livewire components call into
app/Services/TicketService.phpfor 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. 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. BacksscopeSearch()and the numeric sort inOperator/Queue.phpregardless of display mode.checksum— a 6-digit HMAC-derived value (salted withapp.key, keyed offid), assigned once in aTicket::booted()createdlistener 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 aunique()constraint as a hard backstop.
Ticket::displayNumber()/formattedNumber() pick between number (zero-padded
to Settings::get('ticket_number_min_length')) and checksum based on
Settings::bool('ticket_number_obfuscate') — the "Ukryj kolejność zgłoszeń"
toggle in Admin > Konfiguracja. Ticket also overrides getRouteKey() and
resolveRouteBinding() to mirror that same choice, so the web routes
(routes/web.php, all plain {ticket} implicit bindings — no explicit field)
resolve and generate URLs against whichever column is currently the display
number: flip the setting and both the visible number and every link
(route('client.ticket', $ticket) etc.) switch together, and a bookmarked URL
built under the old mode stops resolving. This is why the API routes need the
explicit {ticket:id} override — without it, the same global getRouteKey()
change would silently start requiring number/checksum in API path params
too, breaking the documented integer "Ticket id" contract.
The {numer} placeholder available in admin-editable e-mail templates
(Admin > Szablony e-mail / Wyzwalacze) resolves to formattedNumber()
without displayNumber()'s prefix — those templates already hardcode their
own #{numer}, so adding the prefix there too would double it up or clash
with a non-default prefix.
A ticket route binding that resolves to nothing (most commonly: the ticket
was deleted while someone had it open, and a later request — typically
Livewire's own "model missing during hydration" recovery, which does a full
window.location.reload() of the same page — hits {ticket} again) no
longer surfaces Laravel's default 404 page. bootstrap/app.php registers a
NotFoundHttpException render callback (note: Handler::prepareException()
already converts ModelNotFoundException into NotFoundHttpException,
wrapped as getPrevious(), before any render callback runs — a callback
typed against ModelNotFoundException itself would never match) that
redirects to operator.queue/client.dashboard instead, for any
authenticated request under operator/*/client/*.
That global handler only ever sees a full HTTP request (a page load/reload),
not Livewire's own AJAX update endpoint (/livewire/update, which doesn't
match the operator/*/client/* path check) — so it doesn't cover an
operator who already has a ticket open when it's deleted, or whose team gets
reassigned (by anyone, including via their own action — see "Teams" in
README.md) to one outside their visible scope
(Ticket::isVisibleToOperator()) mid-session. Operator\TicketShow handles
that case itself: a Livewire component's typed public model property
(public Ticket $ticket) is re-fetched by id on every subsequent request via
firstOrFail() (Livewire\Features\SupportModels\ModelSynth::hydrate()),
which throws ModelNotFoundException before any of the component's own
method code runs if the row is gone — too early for an ordinary try/catch
inside an action method to ever catch. The component instead defines
Livewire's exception($e, $stopPropagation) lifecycle hook (called for any
exception raised anywhere in the component's request lifecycle, hydration
included) to catch that case and redirect. The narrower case — ticket still
exists but is no longer visible, e.g. after a team reassignment — doesn't
throw at all, so it's caught separately: refreshOrRedirectAway() re-checks
isVisibleToOperator() after every live-update refresh
(onQueueChanged()/refreshTicketData()) and after the operator's own
setTeam() call, redirecting immediately rather than leaving them on a
ticket they can no longer legitimately keep viewing.
Roles & permissions
$user->roles reads/writes as a plain array (['client', 'operator']), but
it's a virtual attribute (User::getAttribute()/setAttribute()
overrides) backed by a real roles lookup table + role_user pivot, not an
actual column — assigning 'roles' => [...] on create/update stashes the keys
until the model's saved hook resolves them against roles.key and syncs the
pivot. This matters for tests/seeders: a role key must exist in the roles
table before it can be assigned this way, or the assignment silently becomes
a no-op (Tests\TestCase::setUp() seeds the 3 fixed roles for exactly this
reason, since almost every test creates a role-bearing user). Checked via
EnsureRole at the route level. Every account gets client by default
(App\Ldap\Handlers\AssignDefaultRole for LDAP-provisioned accounts); staff
switch areas via the header role switcher, but always land on /client first
after login.
Authentication
config/auth.php defines the default web guard against an LDAP-backed user
provider (LdapRecord); a plain Eloquent provider is kept alongside it only for
local tooling/tests that don't hit a directory. In production, LDAP bind is the
primary path; the local fallback account (admin@example.com from the seeder)
authenticates against a local password when the LDAP bind doesn't match — this is
the account used for first login after a fresh install (see
install.md).
app/Ldap/Handlers/ hooks into LdapRecord's import/sync events:
AssignDefaultRole grants the client role to new LDAP-provisioned accounts,
SyncUserFieldsFromLdap keeps UserFieldValue rows in sync with directory
attributes.
Settings override ("live config")
App\Support\Settings (app/Support/Settings.php) is a cached key/value reader
over the settings table, with hardcoded defaults for every key (company name,
LDAP/SMTP connection details, attachment limits, session lifetime, timezone,
branding/email HTML, etc.). Admin > Konfiguracja (general/attachments/session),
Poczta (SMTP) and Integracje (LDAP, BookStack) all write to this same table, and
AppServiceProvider::boot() re-applies the relevant subset of it over
config() on every request — meaning Setting rows win over .env for
LDAP, mail, session lifetime and timezone once they're non-empty. This is by
design (lets an admin reconfigure LDAP/SMTP without a redeploy) but is also the
source of the "seeded placeholder overrides real .env values" gotcha
documented in install.md — anything touching LDAP/mail/session/
timezone config should go through Settings, not raw config()/.env reads.
settingsTableUsable() gates all four overrides on whether the settings
table is safe to query yet — but is deliberately scoped to just the migrate
command family (runningConsoleCommand('migrate', 'migrate:fresh', ...)), not
"any console command". It used to blanket-skip for every console invocation
(exempting only unit tests), which silently broke every scheduled command's
outbound mail: AppServiceProvider::boot() runs on each process including
schedule:run-invoked commands, so tickets:check-sla-breaches,
automation:run-rules and emails:fetch-imap (below) all sent notifications
through whatever .env's MAIL_MAILER happened to be (log, i.e. nowhere)
instead of the admin-configured SMTP server — with no error, since the log
mailer never throws. If a scheduled command's notification/lookup ever again
seems to silently use .env defaults instead of Settings, check here first.
Notifications
TicketService::notify(Ticket $ticket, string $triggerKey) is the single
fan-out point for every ticket lifecycle event (see the NotificationSetting
rows seeded per trigger key) — it resolves the configured recipient
($ticket->assignee or $ticket->customer) to a real User when one exists
and calls $user->notify(new TicketNotification(...)), which fires both
the mail and database channels (App\Notifications\TicketNotification) —
there's no separate on/off switch for in-app vs. e-mail, the same
NotificationSetting.enabled flag gates both. A guest customer with no
account still gets routed anonymously (Notification::route('mail', $email),
mail-only — the database channel needs a real notifiable to attach the row
to). TicketNotification is constructed with an explicit $recipientRole
('client'|'operator') rather than inferring it from the notifiable's roles,
since one account can hold both — this decides whether the ticket link (both
the e-mail body and the in-app notification's url) points into /client/...
or /operator/....
Real-time broadcasting (Reverb)
Two private channels, authorized in routes/channels.php:
operator.queue— one shared channel for every operator/admin (not scoped per team/ticket), so the receivingOperator\Queuecomponent just re-queries through its own already-correctTicket::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 withisVisibleToOperator()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:
- CSRF on
/broadcasting/auth. Echo's private-channel subscription POSTs there under the app's normal CSRF middleware; theEchoconstructor must passauth.headers['X-CSRF-TOKEN'](read from the<meta name="csrf-token">tag inlayouts/app.blade.php) or every subscription attempt is silently rejected. - Script load order.
resources/js/app.js(which importsecho.js) loads via@viteastype="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 timeecho.jsexecutes. Don't gate anything inecho.jsbehinddocument.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@scriptblock in the ticket-show views, which can run before or afterecho.jsdepending on exactly when Livewire processes it — it queues the ticket id ontowindow.__pendingTicketChannelIdsifecho.jshasn't definedwindow.subscribeToTicketChannelyet, andecho.jsflushes that queue once it has.
As a defense against a dropped websocket connection (backgrounded tab,
network blip), the operator queue and both ticket-detail views also poll
themselves via a small Alpine countdown calling $wire.refreshQueue() /
$wire.refreshTicketData() — broadcasting is best-effort, not the only way
these views ever update. The countdown badge is also clickable
(x-on:click="remaining = total; $wire.refresh...()" on the same element
the x-init="setInterval(...)" already lives on) to fetch immediately and
reset the countdown, rather than only ever firing on its own schedule. Its
interval — like the notification bell's wire:poll and the 4 scheduled
commands below — reads from Settings (refresh_queue_seconds/
refresh_ticket_view_seconds/refresh_notifications_seconds, admin-editable
in Konfiguracja) rather than a hardcoded number: wire:poll.{{ $seconds }}s
and Alpine's x-data="{ remaining: {{ $seconds }}, ... }" both just
interpolate to plain text in the rendered HTML, so a Settings-sourced value
works exactly like a literal one would.
A third private channel, App.Models.User.{id} (Laravel's default
per-notifiable convention, kept verbatim rather than a shorter alias),
carries realtime bell delivery: AppServiceProvider::broadcastBellNotifications()
listens for the framework's own NotificationSent event, and — only for the
database channel of a TicketNotification — dispatches NotificationCreated
on the recipient's own channel. This is a single choke point rather than
threading a broadcast call into every TicketService notification call site
(including the Trigger engine's send_notification action, below).
resources/js/echo.js bridges it into a bell-notification-received Livewire
event (refreshing NotificationBell instantly) and, if the viewer opted in via
the toggle on /settings/notifications, also raises a native in-tab
Notification popup — no service worker or push subscription, so this only
fires while the tab is open, same limitation as the other Echo listeners here.
SLA
SlaRule holds per-priority response/resolution targets in minutes. The
scheduled command tickets:check-sla-breaches (registered in
routes/console.php, default every 15 minutes, interval admin-configurable —
see "Configurable scheduled-command intervals" below) flags overdue tickets
and can notify the assigned operator — see install.md for
why this requires an external cron entry (the Docker image ships no
cron/supervisor of its own).
SLA automation rules
AutomationRule (label, condition_minutes, optional scope_priority_key/
scope_subcategory_id/scope_team_id, action_type + action_value) lets an
admin configure "if a ticket has been silent for N minutes, change its
priority/status/team/assignee" without code — Admin > Automatyzacja SLA. The
scheduled command automation:run-rules (default also every 15 minutes,
independently configurable) evaluates every enabled rule against
Ticket.last_customer_activity_at (falling back to
created_at if never set — mirrors how resolutionDeadline() treats a
missing SlaRule as "no SLA" rather than backfilling one), and applies a
match through the same TicketService setters a manual operator action would
use, so the automated change gets the same history entry, notification, and
broadcast for free. Idempotency is a per-(rule, ticket) row in
automation_rule_ticket_logs, cleared by TicketService whenever the silence
that triggered it is broken (a fresh clientReply()) or the ticket
closes/reopens (setStatus()) — so a rule can fire again after a new period
of silence instead of being permanently latched. Multiple matching rules on
the same ticket in the same run all fire independently, in id order; a rule
that closes the ticket doesn't block earlier-ordered rules already applied
this run, but a later rule's own query naturally excludes an already-closed
ticket.
Configurable scheduled-command intervals
All 4 scheduled commands (tickets:check-sla-breaches, automation:run-rules,
emails:fetch-imap, ai:run-ticket-automation) have an admin-configurable
interval (Admin > Konfiguracja — schedule_sla_check_minutes/
schedule_automation_rules_minutes/schedule_imap_fetch_minutes/
schedule_ai_automation_minutes), defaulting to their previous hardcoded
values (15/15/5/5 minutes). routes/console.php registers all 4 as
->everyMinute()->when(fn () => Settings::dueEveryMinutes($key, $default))
rather than an eagerly-built ->cron('*/N * * * *') string — this is a
deliberate choice, not just a style preference: routes/console.php is
require'd on every artisan boot (migrate, tinker, php artisan test, not just schedule:run, since it's wired in via bootstrap/app.php's
commands: key), so anything at its top level that queries the database
would run before a fresh/test database necessarily has the settings table
yet — an early version of this feature that built the cron string eagerly at
the top level broke exactly this way. A closure passed to ->when() is only
ever evaluated later, when schedule:run actually processes due events, so
Settings::dueEveryMinutes() never runs at boot. One visible side effect:
php artisan schedule:list shows * * * * * for all four regardless of
their actual configured interval, since the real interval only exists inside
the closure — expected, not a bug.
IMAP e-mail intake
Optional, off by default (ImapMailbox.enabled per row — there is no single
global toggle since this is a list of N mailboxes, not a Settings
singleton). Split across three layers, mirroring the plan that shipped it:
App\Models\ImapMailbox— one row per polled mailbox (host/port/ encryption/username,passwordcast'encrypted'— the first model in this codebase to use Laravel's native encrypted cast rather than the manualCrypt::patternSettingsuses, since this is a list of records rather than key/value config).default_subcategory_idXORdefault_category_id(enforced by the admin form's single combined selector, not a DB constraint) route new tickets;category_idonly ever gets populated when there's no subcategory to derive one from (seeTicket::categoryLabel()/TicketService::create()).App\Services\ImapMessageClassifier— pure decision logic, no IMAP connection, fully Pest-testable:rejectionReason()(auto-reply/bounce detection viaAuto-Submitted/Precedence/X-Autoreplyheaders + EN/PL subject phrases + a per-mailbox sender blocklist),matchTicket()(extracts every digit run ≥4 chars from the subject — after strippingRe:/Odp:/Fwd:/FW:/Aw:— and tries each throughTicket::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()(mirrorsLanding::emailIsKnown()— enforcesrestrict_tickets_to_ldapfor e-mail exactly like the guest web form),resolveSender()(existing local user, orLdapUserProvisioner::findOrCreateByEmail()if enabled).App\Services\ImapMailboxFetcher— the I/O layer (webklex/php-imap, a pure-PHP IMAP client with noext-imapdependency — confirmed available extensions were sufficient, no Dockerfile change needed). FetcheswhereUnseen()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 toUploadedFilevia a temp file ($test = truebypasses theis_uploaded_file()check outside a real HTTP request) so they flow through the existingSettings::validateAttachments()TicketService::attachFiles()unchanged. Logs every connection attempt and per-message decision to a dedicatedimaplog channel (storage/logs/imap-*.log, alwaysdebuglevel regardless of the app's ownLOG_LEVEL— seeconfig/logging.php) since this app commonly runs atLOG_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 emptyAttribute(notnull) for a header that isn't present at all, andAttribute::first()on that empty instance is'', notnull— a naive$header !== nullcheck therefore treats every message as carrying every header. Guarded in two places:ImapMailboxFetcheronly keeps a header value that's non-empty, andInboundEmail::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 noUseraccount (mirrorsclientReply()— real customer activity, resets SLA silence, firescomment_addedso an admin-configured Trigger can reopen a closed ticket — rather thanapiMessage(), which tags a system/integration note, not client content). BothclientReply()andguestReply()take an optional trailingstring $source = 'web', stored asTicketMessage.source(nullfor'web') — the per-message counterpart toTicket.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 inroutes/console.phpwith->withoutOverlapping()(likeai: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 noImapMailboxis enabled. Also callable directly per mailbox from Admin > Poczta's "Pobierz teraz" button (ImapMailboxFetcher::fetchMailbox(), bypassing the enabled-onlyfetchAll()used by the schedule) for on-demand fetching/diagnosis without shell access.
Requires the same external schedule:run cron entry as SLA/automation (see
install.md and the crontab note in
CLAUDE.md) — without it, only the manual "Pobierz teraz" button
does anything.
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 getstickets.ai_triaged_atstamped 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 newTicketService::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 howRunAutomationRuleslogs its own SLA-automation changes), andnotify()/TriggerEngine::handle()fired only for the fields that actually changed — deliberately not composed from the existingsetPriority()/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 singleai_summary_enabledtoggle), cached ontickets.ai_summary/ai_suggested_action/ai_summary_generated_atand shown only in the operator ticket view (a "Podsumowanie AI" sidebar card, lazy-loaded viawire:initlike 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 againstticket_messages.created_at, nottickets.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_promptsetting, plain textarea with a "Resetuj" button restoringSettings::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.