- 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>
This commit is contained in:
2026-07-22 15:18:09 +02:00
parent 4e8f17189a
commit 90fae0a4de
49 changed files with 1649 additions and 79 deletions

View File

@@ -38,14 +38,23 @@ Category ─< Subcategory ─< CustomField (per-subcategory custom fields
├──< TicketHistory
├── customer/assignee → User
├── status → Status (fixed stages: new/open/closed)
── priority → Priority → SlaRule (response/resolution minutes)
── 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
@@ -91,6 +100,24 @@ 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
@@ -110,3 +137,27 @@ 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.