- Triggers (Admin > Wyzwalacze): event-driven rules that fire immediately on a ticket lifecycle event (created/updated/status/priority/assignee/team/ category changed, new reply), with AND-conditions and ordered actions (set status/priority/team/assignee, send e-mail). Ships its own dedicated, freely add/edit/delete-able e-mail templates, kept separate from the fixed system templates. - Ticket watching: operators can star/"Obserwuj" any ticket to follow it regardless of assignment/team. - Real-time notification bell (private per-user broadcast channel, 30s fallback poll) with an opt-in in-tab browser push notification. - Per-user notification preferences (/settings/notifications): scope (mine/unassigned/watched/all) and e-mail toggle per event category. - Admin > Integracje: new tab for LDAP/AD + BookStack config, split out of Konfiguracja. - Operator queue: Podkategoria/Zespół/Utworzono columns (off by default). - Obserwuj button moved next to the auto-refresh countdown; trigger condition builder shows subcategory/zgłaszający as name dropdowns instead of raw IDs; /settings/notifications got a back link, full-width push card, and a bordered table container; admin panel tab and operator queue view now persist across a plain page refresh. - Docs: README/ARCHITECTURE/wiki updated for all of the above. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
275 lines
16 KiB
Markdown
275 lines
16 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.
|
||
|
||
## 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.
|