# 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 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. ## 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 writes to this 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/...`. ## 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). ## 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.