Files
servicedesk/ARCHITECTURE.md
Kacper 90fae0a4de v1.1.0
- 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>
2026-07-22 15:18:09 +02:00

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

  1. routes/web.php gates every area behind auth + a role middleware (role:client, role:operator, role:adminApp\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).

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.