- In-app notifications: a bell in the top bar backed by Laravel's database
notification channel, alongside existing e-mail notifications (same
per-trigger toggle drives both; ticket links now correctly point into the
recipient's own area instead of always linking to the client view).
- Drag-and-drop attachments on every upload form, plus inline image
thumbnails in the message thread instead of a plain download link.
- Customer satisfaction (CSAT) rating: clients rate a closed ticket 1-5 stars
with an optional comment; shown read-only to operators, surfaced as a KPI
on the stats dashboard, and linked from the "ticket closed" e-mail.
- Saved queue views: operators can save/apply/delete named filter+sort+
column presets in the ticket queue and mark one as their default.
- Full-text search (MySQL FULLTEXT, portable LIKE fallback) across ticket
subject/body and reply message bodies, now also on the client's own ticket
list.
- Stats CSV export for the currently filtered ticket set.
- Optional BookStack knowledge-base integration (off by default): suggests
articles by category/subcategory while creating a ticket and in a separate
sidebar for operators on an existing ticket (with a copy-link button).
Configurable connection/SSL bypass/search-type filter, plus two
independent per-shelf allow-lists so nothing is ever searched until an
admin opts specific shelves in.
- Closed tickets no longer show in "Moje zgłoszenia"/"Nieprzypisane"/team
queue tabs, only under "Zamknięte" (matching how "Otwarte" already worked).
- Wired up the Admin > About "Wersja" field to config('app.version')/VERSION
in .env instead of a stale hardcoded string.
- Fixed: TicketService::setStatus() now checks a status's stage rather than
the literal key 'closed' to decide whether to fire the "ticket closed"
notification/stop the timer.
- Updated README/ARCHITECTURE/CHANGELOG/install/SECURITY docs and all three
wiki/ role guides for the above; documented a root-vs-www-data file
ownership gotcha in CLAUDE.md (running artisan commands via a plain
`docker exec` can leave root-owned Blade cache files that later break
recompilation for the www-data Apache process).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
9.0 KiB
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 for the feature list and tech stack;
this doc covers how the pieces fit together.
Request flow
routes/web.phpgates every area behindauth+ 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.- 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 inroutes/api.phpis the exception, backed byapp/Http/Controllers/Api/). - Livewire components call into
app/Services/TicketService.phpfor 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. 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).
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 — 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 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.