diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 3e49064..3497e02 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -470,9 +470,10 @@ layered on top rather than baked into the client itself.
## BookStack integration
-`App\Services\BookStackClient` is one of two outbound HTTP clients in the
-codebase (Laravel's `Http` facade), alongside `AiClient` above — everything
-else here only ever receives requests. It's entirely `Settings`-driven, no
+`App\Services\BookStackClient` is one of three outbound HTTP clients in the
+codebase (Laravel's `Http` facade), alongside `AiClient` above and
+`SnipeItClient` below — 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`, and **two independent**
@@ -527,6 +528,70 @@ unless `--force`/the "wszystko ponownie" button is used — and new tags are
merged into an item's existing tags (`updateTags()` PUTs the whole array;
BookStack has no "append a tag" endpoint), never overwriting unrelated ones.
+## Snipe-IT asset inventory integration
+
+`App\Services\SnipeItClient` talks to a Snipe-IT instance's REST API
+(`/api/v1/...`, bearer token auth), entirely `Settings`-driven like
+`BookStackClient`: `snipeit_enabled`, `snipeit_base_url`,
+`snipeit_api_token` (encrypted), `snipeit_verify_ssl`. Every call is wrapped
+in `try/catch(\Throwable)` returning `[]`/`null` on failure, same
+safe-default convention as `AiClient`/`BookStackClient`. Three independently
+toggleable settings gate what a client/operator can actually do with it —
+none of them affect `SnipeItClient` itself, only which Livewire methods are
+willing to call it:
+
+- `snipeit_client_can_select_asset` (+ `snipeit_client_asset_subcategory_ids`,
+ a comma-separated allow-list) — gates `Client\NewTicket`'s asset picker.
+ Mirrors BookStack's shelf allow-lists: an **empty** subcategory list means
+ the picker never shows for any subcategory, not "every subcategory" —
+ `NewTicket::snipeitAssets()` checks both the toggle and that the currently
+ selected `subcategoryId` is in the list before calling
+ `assetsForEmail()`. `selectCategory()`/`selectSubcategory()` reset any
+ already-picked asset, so switching to an out-of-scope subcategory can't
+ silently carry a stale selection through to `submit()`.
+- `snipeit_operator_view_requester_assets` — gates the same
+ `assetsForEmail()` lookup (by the ticket's own `email`, not the viewing
+ operator's) in `Operator\TicketShow`'s sidebar.
+- `snipeit_operator_search_inventory` — gates `searchAssets()`, a free-text
+ `/hardware?search=` lookup across the *whole* inventory, for linking
+ equipment the requester doesn't personally own (e.g. a shared printer).
+ Rendered inline in the same sidebar card as the requester-assets list, not
+ a separate route/page.
+
+`Operator\TicketShow::linkSnipeitAsset(int $id)` deliberately does **not**
+fall back to a direct `SnipeItClient::asset($id)` lookup by id — it only
+accepts an id present in `snipeitRequesterAssets`/`snipeitSearchResults`,
+and each of those is itself empty unless its own setting above is on. This
+means an operator can't link an arbitrary asset through a source the admin
+has switched off for them, even by tampering with the Livewire request
+payload. `unlinkSnipeitAsset()` has no such gate — clearing an existing link
+is a correction, not a new way to browse Snipe-IT, so it stays available
+even with both toggles off.
+
+`SnipeItClient::assetsForEmail()` has to resolve an e-mail to a Snipe-IT user
+first (`GET /users?search=`, no "assets by e-mail" endpoint exists), then
+lists what's checked out to them (`GET /users/{id}/assets`) — cached 5
+minutes per e-mail. `normalizeAsset()` is the single place that turns a raw
+Snipe-IT hardware row into the shape every caller/view uses (`id`, `label`,
+`serial`, `manufacturer`, `model`, `category`, `status`, `url`); `label`
+joins whichever of asset tag / serial / "manufacturer model" are actually
+present with `" - "`, falling back to `Zasób #{id}` if all three are blank —
+Snipe-IT doesn't guarantee any of them are filled in. The `x-snipeit-assets`
+Blade component renders that shape everywhere an asset list shows up
+(client picker, requester sidebar, search results), with a `card` prop that
+skips its own wrapping `
` when embedded inside a
+caller-provided one (the inventory-search box + its results share one card).
+
+A linked ticket only stores `tickets.snipeit_asset_id` + a cached
+`snipeit_asset_name` label (`TicketService::setSnipeitAsset()`, which also
+writes a ticket-history line) — no other Snipe-IT fields are persisted.
+Anywhere a linked asset's live detail is shown (the "Powiązany sprzęt" card),
+it's re-fetched fresh via `SnipeItClient::asset($id)` rather than trusted
+from the cache, so a status/reassignment change made directly in Snipe-IT is
+reflected immediately; the cached label is only ever the fallback shown when
+that live fetch fails (instance unreachable, or the asset was deleted
+there).
+
## AI ticket triage & summary
Two independent services, both consuming `AiClient` above, both run from a
@@ -565,17 +630,20 @@ live customer submitting a ticket:
toggle), cached on `tickets.ai_summary`/`ai_suggested_action`/
`ai_summary_generated_at` and shown only in the operator ticket view (a
"Podsumowanie AI" sidebar card, lazy-loaded via `wire:init` like the
- BookStack suggestions card next to it — never live-called from the ticket
- page itself, only ever displaying whatever the scheduled command last
- computed). Regenerates whenever a ticket's latest message postdates its
- last summary — deliberately compared against `ticket_messages.created_at`,
- not `tickets.updated_at` (which also changes on unrelated actions like a
+ BookStack suggestions card next to it). `run()` (the scheduled sweep)
+ regenerates whenever a ticket's latest message postdates its last summary
+ — deliberately compared against `ticket_messages.created_at`, not
+ `tickets.updated_at` (which also changes on unrelated actions like a
status/priority edit, which would otherwise trigger spurious
- re-summarization on every tick for an active ticket). Unlike the triage
- service, a malformed AI response here leaves the previous summary
- untouched rather than stamping "done" — the ticket stays in the "stale"
- set and gets retried next run, since this feature is meant to keep
- refreshing indefinitely, not run once. The system prompt is
+ re-summarization on every tick for an active ticket). `buildTranscript()`
+ includes the ticket's own `body` (the opening description, outside
+ `ticket_messages`) ahead of the message transcript — needed because that
+ row would otherwise fall outside `TRANSCRIPT_MESSAGE_LIMIT` (30) on any
+ thread longer than that, silently dropping the original request from the
+ prompt. Unlike the triage service, a malformed AI response here leaves the
+ previous summary untouched rather than stamping "done" — the ticket stays
+ in the "stale" set and gets retried next run, since this feature is meant
+ to keep refreshing indefinitely, not run once. The system prompt is
admin-editable (`ai_summary_prompt` setting, plain textarea with a
"Resetuj" button restoring `Settings::default('ai_summary_prompt')` —
same pattern as the e-mail footer editor) and asks the model for a small
@@ -583,6 +651,22 @@ live customer submitting a ticket:
the same defensive regex-extract-then-decode approach used throughout
these AI services.
+Besides `run()`'s scheduled sweep, two paths call `generateFor(Ticket
+$ticket): bool` directly, bypassing the staleness check entirely:
+`Operator\TicketShow::regenerateAiSummary()` (the sidebar's "Wygeneruj
+teraz" button, a synchronous Livewire call — its `wire:loading` state covers
+the wait, no need to dispatch anything in the background) and a
+`TicketMessagePosted` listener registered in
+`AppServiceProvider::regenerateAiSummaryOnNewMessage()`, active only when
+both `ai_summary_enabled` and `ai_summary_regenerate_on_message` (off by
+default) are on. That listener dispatches `App\Jobs\GenerateTicketAiSummaryJob`
+via `::dispatchAfterResponse()` rather than the normal queue — deliberately
+**not** `ShouldQueue`, since this deployment's queue worker is optional
+infrastructure (see install.md) and anything pushed onto the `jobs` table
+has no guarantee of ever being picked up; `dispatchAfterResponse()` instead
+runs the job in-process right after the triggering HTTP/console response is
+sent, needing no worker at all.
+
Its own interval (`ai:run-ticket-automation`) is admin-configurable the same
way the other 3 scheduled commands are — see "Configurable scheduled-command
intervals" above for the mechanism and a boot-time trap worth knowing about
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1ab787d..c979df5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,54 @@
All notable changes to this project are documented in this file. Format loosely
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
+## [1.3.0] - 2026-07-27
+
+### Added
+
+- **Snipe-IT asset inventory integration** (Admin > Integracje), optional and
+ off by default — connects to a Snipe-IT instance by API address + personal
+ API token (plus a "Nie sprawdzaj SSL" toggle for self-signed instances) and
+ surfaces three independently switchable capabilities:
+ - **Klient może wybrać sprzęt, którego dotyczy zgłoszenie** — while
+ creating a ticket, a client sees the devices checked out to them in
+ Snipe-IT (matched by e-mail) and can pick the one the ticket is about.
+ Scoped to admin-selected subcategories via a multi-select picker that
+ only appears once this is turned on — same "nothing shows until
+ explicitly opted in" convention as BookStack's shelf allow-lists.
+ - **Operator może zobaczyć sprzęt zgłaszającego w widoku zgłoszenia** — the
+ same per-requester asset list, shown in a sidebar card on the ticket
+ view, with a "Powiąż" button per item.
+ - **Zezwól operatorowi na przeszukiwanie całego inwentarza** — a search
+ box + button in the same sidebar (not a separate page) letting an
+ operator link any asset in Snipe-IT, not just the requester's own — for
+ shared equipment like printers.
+ - A linked asset shows live status/category/current assignment (fetched
+ fresh from Snipe-IT, not just the cached label) with an "Odepnij" button
+ that stays available to the operator regardless of the two toggles above
+ — clearing an existing link is a correction, not new Snipe-IT access.
+ Every asset is displayed as "numer środka - numer seryjny - producent
+ model" plus its Snipe-IT category, joining whichever of those pieces are
+ actually present.
+- **AI summary: manual regenerate + regenerate on new message.** A
+ "Wygeneruj teraz" button now sits on the operator's "Podsumowanie AI" card
+ for an immediate, on-demand refresh. Separately, a new admin toggle
+ ("Regeneruj podsumowanie od razu po każdej nowej wiadomości", off by
+ default) re-runs the summary right after any reply/note lands on a ticket,
+ instead of only ever picking it up on the next scheduled
+ `ai:run-ticket-automation` sweep. The transcript sent to the model now also
+ includes the ticket's own opening body text (previously only the reply
+ thread), fixing summaries silently missing the original request on long
+ tickets whose first message had scrolled out of the transcript window.
+
+### Fixed
+
+- The status dropdown in the operator ticket view could keep showing the
+ pre-change status after sending a reply via a status-changing quick action
+ (e.g. "Wyślij i oznacz jako rozwiązane") until the next full page load — a
+ Livewire/Alpine-morph quirk for `
',
];
- protected static array $encrypted = ['ldap_bind_password', 'mail_smtp_password', 'bookstack_token_secret', 'ai_api_key'];
+ protected static array $encrypted = ['ldap_bind_password', 'mail_smtp_password', 'bookstack_token_secret', 'ai_api_key', 'snipeit_api_token'];
public static function get(string $key, ?string $default = null): ?string
{
diff --git a/src/database/migrations/2026_07_27_000158_add_snipeit_asset_to_tickets_table.php b/src/database/migrations/2026_07_27_000158_add_snipeit_asset_to_tickets_table.php
new file mode 100644
index 0000000..44eee5d
--- /dev/null
+++ b/src/database/migrations/2026_07_27_000158_add_snipeit_asset_to_tickets_table.php
@@ -0,0 +1,29 @@
+unsignedInteger('snipeit_asset_id')->nullable()->after('ai_summary_generated_at');
+ $table->string('snipeit_asset_name')->nullable()->after('snipeit_asset_id');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('tickets', function (Blueprint $table) {
+ $table->dropColumn(['snipeit_asset_id', 'snipeit_asset_name']);
+ });
+ }
+};
diff --git a/src/resources/views/components/snipeit-assets.blade.php b/src/resources/views/components/snipeit-assets.blade.php
new file mode 100644
index 0000000..70d370d
--- /dev/null
+++ b/src/resources/views/components/snipeit-assets.blade.php
@@ -0,0 +1,64 @@
+@props([
+ 'assets',
+ 'variant' => 'banner',
+ 'title' => 'Twój sprzęt (inwentarz)',
+ 'selectable' => false,
+ 'selectAction' => 'selectSnipeitAsset',
+ 'selectedId' => null,
+ // false when embedded inside a caller-provided card (e.g. the operator's
+ // "Przeszukaj inwentarz" search box + results in one container) — skips
+ // this component's own wrapping card/title so the two don't nest.
+ 'card' => true,
+])
+
+@php
+ $isSidebar = $variant === 'sidebar';
+@endphp
+
+@if (count($assets))
+ @if ($card)
+