- Real-time updates (Laravel Reverb): live operator queue, live ticket
  chat/detail updates for operator and client, periodic fallback refresh
  with a visible countdown as a backstop for dropped websocket connections.
- SLA automation rules (Admin > Automatyzacja SLA): act on a ticket after
  N minutes of customer silence (change priority/status/team/assignee),
  evaluated every 15 minutes, reusing TicketService's own setters so
  automated changes get the same history/notification/broadcast a manual
  change would.
- New notification: every operator on a matching team gets notified when
  a new ticket lands in one of their subcategories.
- BookStack knowledge-base sidebar now also shown on the client's own
  ticket view (previously operator-only); suggestions everywhere now load
  in after first paint instead of blocking it.
- Client ticket view: shows assigned operator + team; page widened to
  match the operator's.
- Notification bell shows unread only; read notifications disappear
  instead of just dimming.
- Stats dashboard: sectioned layout, new breakdowns (by subcategory, CSAT
  by team/operator, top clients, client x subcategory cross-tab).
- Mobile: nav dropdowns (theme/notifications/profile) now expand full
  width instead of overflowing off-screen below 640px.
- Fixed two bugs that silently disabled all real-time updates (missing
  CSRF header on Echo's private-channel auth; a script-load-order race
  that could miss the livewire:init event) and the mariadb healthcheck
  (world-writable credentials file on this stack's NFS mount).
- Assorted test-suite fixes (roles virtual attribute needs the roles
  table seeded; a few missing seeds/wrong assertions found along the way).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 20:43:05 +02:00
parent def7c70887
commit 0b06687ea1
64 changed files with 3613 additions and 168 deletions

View File

@@ -65,11 +65,19 @@ rather than spreading it across Livewire components.
## Roles & permissions
Roles are a plain array on the user (`$user->roles`), not a separate pivot-backed
package — 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.
`$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
@@ -118,6 +126,72 @@ 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 3060 seconds via a small Alpine countdown calling
`$wire.refreshQueue()` / `$wire.refreshTicketData()` — broadcasting is
best-effort, not the only way these views ever update.
## SLA
`SlaRule` holds per-priority response/resolution targets in minutes. The
@@ -127,6 +201,28 @@ tickets and can notify the assigned operator — see [install.md](install.md) fo
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