- Configurable ticket numbering (Admin > Konfiguracja > Ogólne): admin-set prefix and minimum zero-padded length for the ticket number. - "Ukryj kolejność zgłoszeń": an opt-in mode that displays a stable, HMAC-derived checksum instead of the sequential ticket number, so it gives no indication of ticket volume or creation order. Ticket URLs switch to the same checksum when this is on, so a link and the number on the page it points to always match. The REST API is unaffected — pinned to `id` regardless of this setting. Search now also matches by checksum. - Fixed: attachments no longer show an inline image thumbnail in the message thread — every attachment (images included) shows as just its filename, opening in a new tab on click. - Docs: README/ARCHITECTURE/wiki updated for all of the above. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
313 lines
18 KiB
Markdown
313 lines
18 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.
|
||
|
||
## 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),
|
||
E-MAIL (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.
|
||
|
||
## 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 every 30–60 seconds via a small Alpine countdown calling
|
||
`$wire.refreshQueue()` / `$wire.refreshTicketData()` — broadcasting is
|
||
best-effort, not the only way these views ever update.
|
||
|
||
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`, run every 15 minutes via `schedule:run`) 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` (also every 15 minutes) 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.
|
||
|
||
## 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.
|
||
|
||
## BookStack integration
|
||
|
||
`App\Services\BookStackClient` is the only outbound HTTP client in the
|
||
codebase (Laravel's `Http` facade) — 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`, `bookstack_search_types` ('both'|'page'|'book'), 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.
|