Files
servicedesk/src/app/Services/BookStackClient.php
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

280 lines
10 KiB
PHP

<?php
namespace App\Services;
use App\Support\Settings;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
class BookStackClient
{
/**
* Two independent allow-lists, since which shelves make sense to
* surface differs by where suggestions show up: 'creation' gates the
* ticket-wizard suggestions (client/operator/guest), 'ticket_view'
* gates the separate sidebar shown to an operator on an existing ticket.
*/
public const CONTEXT_CREATION = 'creation';
public const CONTEXT_TICKET_VIEW = 'ticket_view';
protected const CONTEXT_SETTINGS_KEYS = [
self::CONTEXT_CREATION => 'bookstack_allowed_shelf_ids_creation',
self::CONTEXT_TICKET_VIEW => 'bookstack_allowed_shelf_ids_ticket_view',
];
public function enabled(): bool
{
return Settings::bool('bookstack_enabled')
&& Settings::get('bookstack_base_url')
&& Settings::get('bookstack_token_id');
}
/**
* Suggested-article lookup, shared by the ticket-creation wizard and the
* operator ticket-view sidebar — returns [] whenever the integration is
* off/unconfigured, the query is empty, or no shelf has been allow-listed
* yet for the given $context (an empty allow-list means "search nothing",
* not "search everything" — an admin has to opt specific shelves in
* before any content is ever suggested, independently per context).
* Cached briefly since the same category/subcategory query repeats
* across every ticket created/viewed with that combination. Respects the
* admin-configured bookstack_search_types setting ('both'|'page'|'book')
* via BookStack's own `{type:x}` query syntax. The cache key folds in the
* allowed-shelf list so changing it in Admin > Konfiguracja is reflected
* immediately, instead of possibly serving a pre-change result for up to
* 10 minutes.
*
* @return array<int, array{name: string, url: ?string, type: string, book: ?string, shelf: ?string}>
*/
public function search(string $query, int $limit = 5, string $context = self::CONTEXT_CREATION): array
{
$query = trim($query);
$allowedShelfIds = $this->allowedShelfIds($context);
if (! $this->enabled() || $query === '' || ! $allowedShelfIds) {
return [];
}
$typeFilter = Settings::get('bookstack_search_types', 'both');
if (in_array($typeFilter, ['page', 'book'], true)) {
$query .= " {type:{$typeFilter}}";
}
$cacheKey = 'bookstack:search:'.md5($query.'|'.$limit.'|'.implode(',', $allowedShelfIds));
return Cache::remember($cacheKey, now()->addMinutes(10), function () use ($query, $limit, $allowedShelfIds) {
try {
$response = $this->client()->get('/api/search', ['query' => $query, 'count' => $limit]);
if (! $response->successful()) {
return [];
}
$shelfMap = $this->shelfBookMap();
$allowedBookIds = $this->bookIdsForShelves($shelfMap, $allowedShelfIds);
$bookShelfNames = $this->bookShelfNames($shelfMap);
return collect($response->json('data', []))
->filter(function (array $item) use ($allowedShelfIds, $allowedBookIds) {
$type = $item['type'] ?? null;
if ($type === 'bookshelf') {
return in_array($item['id'] ?? null, $allowedShelfIds, true);
}
if ($type === 'book') {
return in_array($item['id'] ?? null, $allowedBookIds, true);
}
// pages/chapters carry the id of the book they live in
return isset($item['book_id']) && in_array($item['book_id'], $allowedBookIds, true);
})
->map(function (array $item) use ($bookShelfNames) {
$bookId = $item['book_id'] ?? (($item['type'] ?? null) === 'book' ? $item['id'] : null);
return [
'name' => $item['name'] ?? '',
'url' => $item['url'] ?? null,
'type' => $item['type'] ?? 'page',
'book' => $item['book']['name'] ?? null,
'shelf' => $bookId ? ($bookShelfNames[$bookId] ?? null) : null,
];
})
->filter(fn (array $item) => $item['name'] !== '')
->values()
->all();
} catch (\Throwable) {
return [];
}
});
}
/**
* Drops the cached shelf list and shelf>book membership map — used by
* the admin's "Odśwież listę półek" button so a shelf renamed/added/
* removed in BookStack shows up immediately instead of after up to 30
* minutes. Doesn't touch the per-query search-result cache (10 min TTL,
* self-invalidates on the next config save via the allow-list in its key).
*/
public function clearShelfCache(): void
{
Cache::forget('bookstack:shelves');
Cache::forget('bookstack:shelf-book-map');
}
/**
* Bookshelves for the admin's two "dozwolone półki" checklists — cached
* since shelf structure changes rarely and this is fetched on every
* Admin > Konfiguracja page load while the BookStack section is expanded.
*
* @return array<int, array{id: int, name: string}>
*/
public function shelves(): array
{
if (! $this->enabled()) {
return [];
}
return Cache::remember('bookstack:shelves', now()->addMinutes(30), function () {
try {
$response = $this->client()->get('/api/shelves', ['count' => 200]);
if (! $response->successful()) {
return [];
}
return collect($response->json('data', []))
->map(fn (array $s) => ['id' => $s['id'], 'name' => $s['name']])
->values()
->all();
} catch (\Throwable) {
return [];
}
});
}
/**
* @return int[]
*/
protected function allowedShelfIds(string $context): array
{
$key = self::CONTEXT_SETTINGS_KEYS[$context] ?? self::CONTEXT_SETTINGS_KEYS[self::CONTEXT_CREATION];
$raw = Settings::get($key, '');
return collect(explode(',', (string) $raw))
->map(fn ($v) => (int) trim($v))
->filter()
->values()
->all();
}
/**
* Every shelf's book membership, fetched once and cached — the single
* source both shelf-exclusion and the "Shelf > Book" breadcrumb are
* derived from, so there's only one place that talks to /api/shelves/{id}.
*
* @return array<int, array{name: string, bookIds: int[]}>
*/
protected function shelfBookMap(): array
{
return Cache::remember('bookstack:shelf-book-map', now()->addMinutes(30), function () {
$map = [];
foreach ($this->shelves() as $shelf) {
$bookIds = [];
try {
$response = $this->client()->get("/api/shelves/{$shelf['id']}");
if ($response->successful()) {
$bookIds = collect($response->json('books', []))->pluck('id')->all();
}
} catch (\Throwable) {
// Skip an unreachable/deleted shelf rather than failing the whole search.
}
$map[$shelf['id']] = ['name' => $shelf['name'], 'bookIds' => $bookIds];
}
return $map;
});
}
/**
* @param array<int, array{name: string, bookIds: int[]}> $shelfMap
* @param int[] $shelfIds
* @return int[]
*/
protected function bookIdsForShelves(array $shelfMap, array $shelfIds): array
{
$ids = [];
foreach ($shelfIds as $shelfId) {
$ids = [...$ids, ...($shelfMap[$shelfId]['bookIds'] ?? [])];
}
return $ids;
}
/**
* Book id -> owning shelf name, for the suggestion list's breadcrumb. A
* book that sits on more than one shelf just shows whichever is last in
* the map — there's no single "correct" shelf to prefer in that case.
*
* @param array<int, array{name: string, bookIds: int[]}> $shelfMap
* @return array<int, string>
*/
protected function bookShelfNames(array $shelfMap): array
{
$names = [];
foreach ($shelfMap as $shelf) {
foreach ($shelf['bookIds'] as $bookId) {
$names[$bookId] = $shelf['name'];
}
}
return $names;
}
/**
* Tests unsaved admin-form values directly, rather than whatever's
* currently stored — mirrors testLdapConnection()/testMailConnection()
* in Admin\Panel. Returns a message alongside the ok/error flag (BookStack's
* API returns a specific, useful reason — e.g. missing "Access System API"
* role permission — that a plain boolean would hide from the admin.
*
* @return array{ok: bool, message: ?string}
*/
public function testConnection(string $baseUrl, string $tokenId, string $tokenSecret, bool $verifySsl = true): array
{
try {
$response = Http::withHeaders(['Authorization' => "Token {$tokenId}:{$tokenSecret}"])
->withOptions(['verify' => $verifySsl])
->timeout(6)
->get(rtrim($baseUrl, '/').'/api/search', ['query' => 'test', 'count' => 1]);
if ($response->successful()) {
return ['ok' => true, 'message' => null];
}
return ['ok' => false, 'message' => $response->json('error.message') ?? ('HTTP '.$response->status())];
} catch (\Throwable $e) {
return ['ok' => false, 'message' => $e->getMessage()];
}
}
protected function client()
{
$tokenId = Settings::get('bookstack_token_id');
$tokenSecret = Settings::get('bookstack_token_secret');
return Http::withHeaders(['Authorization' => "Token {$tokenId}:{$tokenSecret}"])
->withOptions(['verify' => Settings::bool('bookstack_verify_ssl')])
->timeout(4)
->baseUrl(rtrim(Settings::get('bookstack_base_url'), '/'));
}
}