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>
This commit is contained in:
279
src/app/Services/BookStackClient.php
Normal file
279
src/app/Services/BookStackClient.php
Normal file
@@ -0,0 +1,279 @@
|
||||
<?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'), '/'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user