# 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 ├──< TicketFieldValue (queryable custom_fields values, kept in sync) ├── aiSummary → TicketAiSummary (1:1, triage+summary state) ├── snipeitAsset → TicketSnipeitAsset (1:1, linked asset) ├── 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. **Virtual `ai_*`/`snipeit_*` attributes.** The AI triage/summary fields (`ai_triaged_at`, `ai_summary`, `ai_suggested_action`, `ai_summary_generated_at`) and the Snipe-IT link (`snipeit_asset_id`, `snipeit_asset_name`) are **not** real columns on `tickets` — they live on the related `TicketAiSummary`/ `TicketSnipeitAsset` rows shown in the diagram above (each table's own columns drop the prefix, e.g. `ticket_ai_summaries.summary`). `Ticket` overrides `getAttribute()`/`setAttribute()` (see `AI_SUMMARY_FIELD_MAP`/ `SNIPEIT_FIELD_MAP`) so every existing `$ticket->ai_summary`/ `$ticket->update(['snipeit_asset_id' => ...])` call site keeps working unchanged against the new tables — the same pattern `TicketMessage` already uses for its own virtual `role`/`author_id`. A write is queued (`$pendingVirtualAttributes`) and flushed into the related row's `updateOrCreate()` on the model's `saved` event, since a brand-new ticket has no id yet to key the related row on until that point. If you add a new `ai_*`/`snipeit_*` field, add it to the relevant `FIELD_MAP` rather than to `tickets` directly. **Custom field values.** `tickets.custom_fields` (a JSON blob, `field.id => value`) stays the source of truth for reads/writes — `TicketFieldValue` (`ticket_field_values`, one row per non-blank entry) is a queryable mirror kept in sync automatically by `Ticket::syncFieldValues()` (called from the same `saved` hook whenever `custom_fields` changes), so reporting can filter/join on "tickets where custom field X = Y" without scanning JSON. Nothing else needs to write to `ticket_field_values` directly. **`source` validation.** `Ticket::SOURCES`/`TicketMessage::SOURCES` are the only values ever allowed in `tickets.source`/`ticket_messages.source` (`'web'`/`'email'`/`'hesk_import'`; `null` still means "web" for messages) — enforced by a `saving` listener that throws `InvalidArgumentException` on anything else, so a typo'd literal fails loudly instead of sticking silently. Add new values to the constant before writing them anywhere. ## 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. 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](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](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. `app/Ldap/` has two directory-schema models — `LldapUser` (LLDAP/OpenLDAP, the default) and `AdUser` (Active Directory, `LdapRecord\Models\ActiveDirectory\User` under the hood). `Settings::ldapUserModelClass()` picks between them based on the `ldap_directory_type` setting, and `AppServiceProvider::applyLdapSettingsOverride()` wires the chosen class into `config('auth.providers.users.model')` on every request — same live-override mechanism as the connection host/base DN below. `LdapUserProvisioner` (used for sync + guest auto-provisioning) resolves the same setting at call time rather than caching the class, so switching directory type takes effect without a redeploy. ## 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](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 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 `` 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 `