v1.1.2
- 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:
106
ARCHITECTURE.md
106
ARCHITECTURE.md
@@ -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 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.
|
||||
|
||||
## 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
|
||||
|
||||
Reference in New Issue
Block a user