Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a8cf2037c | |||
| 1df697afce | |||
| 313e01ad24 | |||
| 0d116dfd98 | |||
| 63178b366e |
457
ARCHITECTURE.md
457
ARCHITECTURE.md
@@ -63,6 +63,79 @@ 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.
|
||||
|
||||
## Ticket numbering & URLs
|
||||
|
||||
A ticket carries three distinct identifiers, each with a different job:
|
||||
|
||||
- **`id`** — the DB primary key. Never shown to users; the REST API
|
||||
(`routes/api.php`) is deliberately pinned to it (`{ticket:id}` explicit
|
||||
binding on every `{ticket}` route) so external integrations have a stable
|
||||
contract regardless of the numbering settings below.
|
||||
- **`number`** — a plain sequential string (`Ticket::nextNumber()`, max+1
|
||||
starting at 1001), unique but otherwise unremarkable. Backs `scopeSearch()`
|
||||
and the numeric sort in `Operator/Queue.php` regardless of display mode.
|
||||
- **`checksum`** — a 6-digit HMAC-derived value (salted with `app.key`,
|
||||
keyed off `id`), assigned once in a `Ticket::booted()` `created` listener
|
||||
and never changed afterward. Collisions are handled for real, not just
|
||||
assumed away: `Ticket::generateUniqueChecksum()` walks a nonce forward
|
||||
until the candidate is free (checked against the DB), and the column has a
|
||||
`unique()` constraint as a hard backstop.
|
||||
|
||||
`Ticket::displayNumber()`/`formattedNumber()` pick between `number` (zero-padded
|
||||
to `Settings::get('ticket_number_min_length')`) and `checksum` based on
|
||||
`Settings::bool('ticket_number_obfuscate')` — the "Ukryj kolejność zgłoszeń"
|
||||
toggle in Admin > Konfiguracja. `Ticket` also overrides `getRouteKey()` and
|
||||
`resolveRouteBinding()` to mirror that same choice, so **the web routes**
|
||||
(`routes/web.php`, all plain `{ticket}` implicit bindings — no explicit field)
|
||||
resolve and generate URLs against whichever column is currently the display
|
||||
number: flip the setting and both the visible number *and* every link
|
||||
(`route('client.ticket', $ticket)` etc.) switch together, and a bookmarked URL
|
||||
built under the old mode stops resolving. This is why the API routes need the
|
||||
explicit `{ticket:id}` override — without it, the same global `getRouteKey()`
|
||||
change would silently start requiring `number`/`checksum` in API path params
|
||||
too, breaking the documented `integer` "Ticket id" contract.
|
||||
|
||||
The `{numer}` placeholder available in admin-editable e-mail templates
|
||||
(Admin > Szablony e-mail / Wyzwalacze) resolves to `formattedNumber()`
|
||||
*without* `displayNumber()`'s prefix — those templates already hardcode their
|
||||
own `#{numer}`, so adding the prefix there too would double it up or clash
|
||||
with a non-default prefix.
|
||||
|
||||
A ticket route binding that resolves to nothing (most commonly: the ticket
|
||||
was deleted while someone had it open, and a later request — typically
|
||||
Livewire's own "model missing during hydration" recovery, which does a full
|
||||
`window.location.reload()` of the same page — hits `{ticket}` again) no
|
||||
longer surfaces Laravel's default 404 page. `bootstrap/app.php` registers a
|
||||
`NotFoundHttpException` render callback (note: `Handler::prepareException()`
|
||||
already converts `ModelNotFoundException` into `NotFoundHttpException`,
|
||||
wrapped as `getPrevious()`, *before* any render callback runs — a callback
|
||||
typed against `ModelNotFoundException` itself would never match) that
|
||||
redirects to `operator.queue`/`client.dashboard` instead, for any
|
||||
authenticated request under `operator/*`/`client/*`.
|
||||
|
||||
That global handler only ever sees a full HTTP request (a page load/reload),
|
||||
not Livewire's own AJAX update endpoint (`/livewire/update`, which doesn't
|
||||
match the `operator/*`/`client/*` path check) — so it doesn't cover an
|
||||
operator who already has a ticket open when it's deleted, or whose team gets
|
||||
reassigned (by anyone, including via their own action — see "Teams" in
|
||||
[README.md](README.md)) to one outside their visible scope
|
||||
(`Ticket::isVisibleToOperator()`) mid-session. `Operator\TicketShow` handles
|
||||
that case itself: a Livewire component's typed public model property
|
||||
(`public Ticket $ticket`) is re-fetched by id on every subsequent request via
|
||||
`firstOrFail()` (`Livewire\Features\SupportModels\ModelSynth::hydrate()`),
|
||||
which throws `ModelNotFoundException` *before* any of the component's own
|
||||
method code runs if the row is gone — too early for an ordinary try/catch
|
||||
inside an action method to ever catch. The component instead defines
|
||||
Livewire's `exception($e, $stopPropagation)` lifecycle hook (called for any
|
||||
exception raised anywhere in the component's request lifecycle, hydration
|
||||
included) to catch that case and redirect. The narrower case — ticket still
|
||||
exists but is no longer visible, e.g. after a team reassignment — doesn't
|
||||
throw at all, so it's caught separately: `refreshOrRedirectAway()` re-checks
|
||||
`isVisibleToOperator()` after every live-update refresh
|
||||
(`onQueueChanged()`/`refreshTicketData()`) and after the operator's own
|
||||
`setTeam()` call, redirecting immediately rather than leaving them on a
|
||||
ticket they can no longer legitimately keep viewing.
|
||||
|
||||
## Roles & permissions
|
||||
|
||||
`$user->roles` reads/writes as a plain array (`['client', 'operator']`), but
|
||||
@@ -100,7 +173,7 @@ attributes.
|
||||
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 (general/attachments/session),
|
||||
E-MAIL (SMTP) and Integracje (LDAP, BookStack) all write to this same table, and
|
||||
Poczta (SMTP) and Integracje (LDAP, BookStack) all write to this same 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
|
||||
@@ -109,6 +182,19 @@ 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.
|
||||
|
||||
`settingsTableUsable()` gates all four overrides on whether the `settings`
|
||||
table is safe to query yet — but is deliberately scoped to just the `migrate`
|
||||
command family (`runningConsoleCommand('migrate', 'migrate:fresh', ...)`), not
|
||||
"any console command". It used to blanket-skip for every console invocation
|
||||
(exempting only unit tests), which silently broke every scheduled command's
|
||||
outbound mail: `AppServiceProvider::boot()` runs on each process including
|
||||
`schedule:run`-invoked commands, so `tickets:check-sla-breaches`,
|
||||
`automation:run-rules` and `emails:fetch-imap` (below) all sent notifications
|
||||
through whatever `.env`'s `MAIL_MAILER` happened to be (`log`, i.e. nowhere)
|
||||
instead of the admin-configured SMTP server — with no error, since the `log`
|
||||
mailer never throws. If a scheduled command's notification/lookup ever again
|
||||
seems to silently use `.env` defaults instead of `Settings`, check here first.
|
||||
|
||||
## Notifications
|
||||
|
||||
`TicketService::notify(Ticket $ticket, string $triggerKey)` is the single
|
||||
@@ -189,9 +275,19 @@ if "nothing updates live" ever comes back:
|
||||
|
||||
As a defense against a dropped websocket connection (backgrounded tab,
|
||||
network blip), the operator queue and both ticket-detail views also poll
|
||||
themselves every 30–60 seconds via a small Alpine countdown calling
|
||||
`$wire.refreshQueue()` / `$wire.refreshTicketData()` — broadcasting is
|
||||
best-effort, not the only way these views ever update.
|
||||
themselves via a small Alpine countdown calling `$wire.refreshQueue()` /
|
||||
`$wire.refreshTicketData()` — broadcasting is best-effort, not the only way
|
||||
these views ever update. The countdown badge is also clickable
|
||||
(`x-on:click="remaining = total; $wire.refresh...()"` on the same element
|
||||
the `x-init="setInterval(...)"` already lives on) to fetch immediately and
|
||||
reset the countdown, rather than only ever firing on its own schedule. Its
|
||||
interval — like the notification bell's `wire:poll` and the 4 scheduled
|
||||
commands below — reads from `Settings` (`refresh_queue_seconds`/
|
||||
`refresh_ticket_view_seconds`/`refresh_notifications_seconds`, admin-editable
|
||||
in Konfiguracja) rather than a hardcoded number: `wire:poll.{{ $seconds }}s`
|
||||
and Alpine's `x-data="{ remaining: {{ $seconds }}, ... }"` both just
|
||||
interpolate to plain text in the rendered HTML, so a `Settings`-sourced value
|
||||
works exactly like a literal one would.
|
||||
|
||||
A third private channel, **`App.Models.User.{id}`** (Laravel's default
|
||||
per-notifiable convention, kept verbatim rather than a shorter alias),
|
||||
@@ -211,8 +307,9 @@ fires while the tab is open, same limitation as the other Echo listeners here.
|
||||
|
||||
`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](install.md) for
|
||||
`routes/console.php`, default every 15 minutes, interval admin-configurable —
|
||||
see "Configurable scheduled-command intervals" below) flags overdue tickets
|
||||
and can notify the assigned operator — see [install.md](install.md) for
|
||||
why this requires an external cron entry (the Docker image ships no
|
||||
cron/supervisor of its own).
|
||||
|
||||
@@ -222,8 +319,9 @@ cron/supervisor of its own).
|
||||
`scope_subcategory_id`/`scope_team_id`, `action_type` + `action_value`) lets an
|
||||
admin configure "if a ticket has been silent for N minutes, change its
|
||||
priority/status/team/assignee" without code — Admin > Automatyzacja SLA. The
|
||||
scheduled command `automation:run-rules` (also every 15 minutes) evaluates
|
||||
every enabled rule against `Ticket.last_customer_activity_at` (falling back to
|
||||
scheduled command `automation:run-rules` (default also every 15 minutes,
|
||||
independently configurable) evaluates every enabled rule against
|
||||
`Ticket.last_customer_activity_at` (falling back to
|
||||
`created_at` if never set — mirrors how `resolutionDeadline()` treats a
|
||||
missing `SlaRule` as "no SLA" rather than backfilling one), and applies a
|
||||
match through the same `TicketService` setters a manual operator action would
|
||||
@@ -238,6 +336,109 @@ that closes the ticket doesn't block earlier-ordered rules already applied
|
||||
this run, but a later rule's own query naturally excludes an already-closed
|
||||
ticket.
|
||||
|
||||
## Configurable scheduled-command intervals
|
||||
|
||||
All 4 scheduled commands (`tickets:check-sla-breaches`, `automation:run-rules`,
|
||||
`emails:fetch-imap`, `ai:run-ticket-automation`) have an admin-configurable
|
||||
interval (Admin > Konfiguracja — `schedule_sla_check_minutes`/
|
||||
`schedule_automation_rules_minutes`/`schedule_imap_fetch_minutes`/
|
||||
`schedule_ai_automation_minutes`), defaulting to their previous hardcoded
|
||||
values (15/15/5/5 minutes). `routes/console.php` registers all 4 as
|
||||
`->everyMinute()->when(fn () => Settings::dueEveryMinutes($key, $default))`
|
||||
rather than an eagerly-built `->cron('*/N * * * *')` string — this is a
|
||||
deliberate choice, not just a style preference: `routes/console.php` is
|
||||
`require`'d on **every** artisan boot (`migrate`, `tinker`, `php artisan
|
||||
test`, not just `schedule:run`, since it's wired in via `bootstrap/app.php`'s
|
||||
`commands:` key), so anything at its *top level* that queries the database
|
||||
would run before a fresh/test database necessarily has the `settings` table
|
||||
yet — an early version of this feature that built the cron string eagerly at
|
||||
the top level broke exactly this way. A closure passed to `->when()` is only
|
||||
ever evaluated later, when `schedule:run` actually processes due events, so
|
||||
`Settings::dueEveryMinutes()` never runs at boot. One visible side effect:
|
||||
`php artisan schedule:list` shows `* * * * *` for all four regardless of
|
||||
their actual configured interval, since the real interval only exists inside
|
||||
the closure — expected, not a bug.
|
||||
|
||||
## IMAP e-mail intake
|
||||
|
||||
Optional, off by default (`ImapMailbox.enabled` per row — there is no single
|
||||
global toggle since this is a list of N mailboxes, not a `Settings`
|
||||
singleton). Split across three layers, mirroring the plan that shipped it:
|
||||
|
||||
- **`App\Models\ImapMailbox`** — one row per polled mailbox (host/port/
|
||||
encryption/username, `password` cast `'encrypted'` — the first model in
|
||||
this codebase to use Laravel's native encrypted cast rather than the
|
||||
manual `Crypt::` pattern `Settings` uses, since this is a list of records
|
||||
rather than key/value config). `default_subcategory_id` XOR
|
||||
`default_category_id` (enforced by the admin form's single combined
|
||||
selector, not a DB constraint) route new tickets; `category_id` only ever
|
||||
gets populated when there's no subcategory to derive one from (see
|
||||
`Ticket::categoryLabel()`/`TicketService::create()`).
|
||||
- **`App\Services\ImapMessageClassifier`** — pure decision logic, no IMAP
|
||||
connection, fully Pest-testable: `rejectionReason()` (auto-reply/bounce
|
||||
detection via `Auto-Submitted`/`Precedence`/`X-Autoreply` headers + EN/PL
|
||||
subject phrases + a per-mailbox sender blocklist), `matchTicket()`
|
||||
(extracts every digit run ≥4 chars from the subject — after stripping
|
||||
`Re:`/`Odp:`/`Fwd:`/`FW:`/`Aw:` — and tries each through
|
||||
`Ticket::resolveRouteBinding()`, so it transparently matches either the
|
||||
plain sequential number or the obfuscated checksum, whichever mode is
|
||||
active; no changes to outbound mail were needed since every notification
|
||||
subject already carries `{numer}`), `isSenderAllowed()` (mirrors
|
||||
`Landing::emailIsKnown()` — enforces `restrict_tickets_to_ldap` for e-mail
|
||||
exactly like the guest web form), `resolveSender()` (existing local user,
|
||||
or `LdapUserProvisioner::findOrCreateByEmail()` if enabled).
|
||||
- **`App\Services\ImapMailboxFetcher`** — the I/O layer (`webklex/php-imap`,
|
||||
a pure-PHP IMAP client with no `ext-imap` dependency — confirmed available
|
||||
extensions were sufficient, no Dockerfile change needed). Fetches
|
||||
`whereUnseen()` per mailbox, flags/moves a message **before** creating the
|
||||
ticket (a crash mid-batch then risks a "processed but no ticket" message —
|
||||
visible and easy to fix manually — rather than a duplicate ticket on the
|
||||
next run), converts attachments to `UploadedFile` via a temp file (`$test
|
||||
= true` bypasses the `is_uploaded_file()` check outside a real HTTP
|
||||
request) so they flow through the existing `Settings::validateAttachments()`
|
||||
+ `TicketService::attachFiles()` unchanged. Logs every connection attempt
|
||||
and per-message decision to a dedicated `imap` log channel
|
||||
(`storage/logs/imap-*.log`, always `debug` level regardless of the app's
|
||||
own `LOG_LEVEL` — see `config/logging.php`) since this app commonly runs
|
||||
at `LOG_LEVEL=error`, which would otherwise silently swallow this
|
||||
activity entirely.
|
||||
- One real bug worth remembering if IMAP rejection logic ever seems too
|
||||
aggressive again: Webklex's `Header::get($name)` returns an *empty*
|
||||
`Attribute` (not `null`) for a header that isn't present at all, and
|
||||
`Attribute::first()` on that empty instance is `''`, not `null` — a
|
||||
naive `$header !== null` check therefore treats *every* message as
|
||||
carrying *every* header. Guarded in two places: `ImapMailboxFetcher`
|
||||
only keeps a header value that's non-empty, and
|
||||
`InboundEmail::header()` itself also treats `''` as absent, so the bug
|
||||
can't resurface even if some other header source stops filtering.
|
||||
- **`TicketService::guestReply()`** — the one new method added to the
|
||||
existing service: a customer reply with no `User` account (mirrors
|
||||
`clientReply()` — real customer activity, resets SLA silence, fires
|
||||
`comment_added` so an admin-configured Trigger can reopen a closed ticket
|
||||
— rather than `apiMessage()`, which tags a system/integration note, not
|
||||
client content). Both `clientReply()` and `guestReply()` take an optional
|
||||
trailing `string $source = 'web'`, stored as `TicketMessage.source`
|
||||
(`null` for `'web'`) — the per-message counterpart to `Ticket.source`,
|
||||
since a ticket opened on the web can later get an e-mail reply or vice
|
||||
versa. Both surface as a small mail-icon badge (operator queue: next to
|
||||
the ticket number; ticket view: per-message in the thread, plus a tag next
|
||||
to the ticket number in the header).
|
||||
- **`emails:fetch-imap`** (`app/Console/Commands/FetchImapEmails.php`),
|
||||
registered in `routes/console.php` with `->withoutOverlapping()` (like
|
||||
`ai:run-ticket-automation`, unlike the SLA-check/automation-rules
|
||||
commands — both make real outbound HTTP/IMAP calls per record, so a slow
|
||||
run risks overlapping the next tick in a way a pure-DB command doesn't).
|
||||
Early-returns if no `ImapMailbox` is enabled. Also callable directly per
|
||||
mailbox from Admin > Poczta's "Pobierz teraz" button
|
||||
(`ImapMailboxFetcher::fetchMailbox()`, bypassing the enabled-only
|
||||
`fetchAll()` used by the schedule) for on-demand fetching/diagnosis
|
||||
without shell access.
|
||||
|
||||
Requires the same external `schedule:run` cron entry as SLA/automation (see
|
||||
[install.md](install.md) and the crontab note in
|
||||
[CLAUDE.md](CLAUDE.md)) — without it, only the manual "Pobierz teraz" button
|
||||
does anything.
|
||||
|
||||
## API
|
||||
|
||||
`routes/api.php` + `app/Http/Controllers/Api/` expose a small ability-scoped REST
|
||||
@@ -249,26 +450,224 @@ tighter per-IP limit for unauthenticated requests
|
||||
generated by L5-Swagger at `/admin/api-docs`; there is no static Markdown API
|
||||
reference in-repo.
|
||||
|
||||
## Generic AI integration
|
||||
|
||||
`App\Services\AiClient` is a small, feature-agnostic wrapper around an
|
||||
OpenAI-compatible `/chat/completions` endpoint (`chat(array $messages, array
|
||||
$options = []): ?string`) — works against Groq, OpenAI itself, or a
|
||||
self-hosted Ollama instance, whichever `ai_base_url` points at.
|
||||
`Settings`-driven like everything else here: `ai_enabled`, `ai_base_url`,
|
||||
`ai_api_key` (encrypted, optional — deliberately not required by `enabled()`,
|
||||
since a self-hosted Ollama instance typically has no auth at all),
|
||||
`ai_model`, `ai_verify_ssl`. Every call is wrapped in `try/catch(\Throwable)`
|
||||
and returns `null` on any failure (network, non-2xx, unexpected shape),
|
||||
matching `BookStackClient`'s safe-default convention — callers are expected
|
||||
to treat `null` as "AI unavailable" and degrade gracefully rather than throw.
|
||||
Not tied to any single feature: `BookStackContentTagger`,
|
||||
`TicketAiTriageService` and `TicketAiSummaryService` (below) are just its
|
||||
first three consumers, each with their own prompt-building/parsing logic
|
||||
layered on top rather than baked into the client itself.
|
||||
|
||||
## 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.
|
||||
`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**
|
||||
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.
|
||||
|
||||
**Content-type filter and "search by" mode**: `bookstack_search_types` is a
|
||||
comma-separated subset of `BookStackClient::SEARCH_TYPES` (`book`, `page`,
|
||||
`chapter` — checkboxes in the admin UI, no more single-select "both/page/book"
|
||||
dropdown), combined into BookStack's own `{type:a|b}` query syntax.
|
||||
`bookstack_search_by` (`'name'`/`'tags'`/`'both'`) picks between matching the
|
||||
title (`{in_name:...}`) and matching a tag whose name equals the query
|
||||
(`[...]` — see BookStack content auto-tagging below for what actually writes
|
||||
those tags); `'both'` runs one request per mode and merges/dedupes the
|
||||
results, since BookStack's own query syntax ANDs filters together rather than
|
||||
OR-ing them, so there's no single-request way to ask for "name OR tag".
|
||||
`search()` takes both a `$query` (full "Category Subcategory" text, used for
|
||||
the name-match variant) and an optional `$tagQuery` (bare subcategory name,
|
||||
used for the tag-match variant) — the two differ because a tag is expected to
|
||||
hold just the subcategory name, not the combined category+subcategory text.
|
||||
|
||||
## BookStack content auto-tagging
|
||||
|
||||
`App\Services\BookStackContentTagger` (used by the "Otaguj nową
|
||||
treść"/"Otaguj wszystko ponownie" buttons on the BookStack admin card and by
|
||||
`php artisan bookstack:tag-content`) is the reason the tag-based search mode
|
||||
above has anything to match: it walks every book/chapter/page via
|
||||
`BookStackClient::listAll()`/`detail()`, builds a Polish prompt naming the
|
||||
current, live `Subcategory` list as the only allowed vocabulary, and asks
|
||||
`AiClient` (above) to return which subcategory name(s) fit each item — a
|
||||
single response per batch of 20 items, to keep prompt size/cost down.
|
||||
Defensive JSON parsing (`parseAssignments()`) regex-extracts the first
|
||||
`{...}` block before decoding, so a chatty or malformed response fails just
|
||||
that one batch (`failed_batches` in the run summary) instead of crashing the
|
||||
whole pass; every returned label is matched case-insensitively against the
|
||||
real subcategory list before being trusted, so a hallucinated name is
|
||||
silently dropped rather than written as a tag. Idempotent by default — an
|
||||
item already carrying a tag matching a current subcategory name is skipped
|
||||
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 `<div class="card">` 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
|
||||
single scheduled command (`ai:run-ticket-automation`) — **never
|
||||
synchronously at ticket creation**, so an LLM call never adds latency to a
|
||||
live customer submitting a ticket:
|
||||
|
||||
- **`App\Services\TicketAiTriageService`** — a one-shot classification pass
|
||||
per ticket, gated by 5 independent toggles
|
||||
(`ai_triage_category_when_missing`/`subcategory_when_category_only`/
|
||||
`recheck_categorized`/`fix_subject`/`set_priority`). `buildPrompt()` picks
|
||||
one of 3 mutually-exclusive category scenarios from the ticket's *current*
|
||||
state (no category/subcategory at all → assign both; category but no
|
||||
subcategory → pick one within it; already has a subcategory → recheck and
|
||||
possibly correct), independently of the subject/priority toggles. Every
|
||||
scanned ticket gets `tickets.ai_triaged_at` stamped exactly once — this is
|
||||
a one-shot pass, not a continuous recheck, and there's deliberately no
|
||||
manual per-ticket re-trigger. Resolution is fail-closed the same way as the
|
||||
BookStack tagger: every value the model returns is matched against the
|
||||
real category/subcategory/priority vocabulary before being trusted: a
|
||||
hallucinated or out-of-scope value (e.g. a subcategory claimed under the
|
||||
wrong category) is silently dropped. Applying changes goes through a new
|
||||
`TicketService::applyAiTriage(Ticket $ticket, array $changes, array
|
||||
$historyLines)` — a single `$ticket->update()` for whichever
|
||||
category/subcategory/subject/priority fields actually changed, one
|
||||
specific history line per changed field plus a final "Automatyzacja:
|
||||
klasyfikacja AI" attribution line (mirrors how `RunAutomationRules` logs
|
||||
its own SLA-automation changes), and `notify()`/`TriggerEngine::handle()`
|
||||
fired only for the fields that actually changed — deliberately not
|
||||
composed from the existing `setPriority()`/`updateDetails()` setters, since
|
||||
one AI pass can touch several fields at once and those would each write
|
||||
their own generic line and fire notifications per-field instead of once
|
||||
per pass.
|
||||
- **`App\Services\TicketAiSummaryService`** — a summary + suggested next
|
||||
action for **every** ticket (gated by a single `ai_summary_enabled`
|
||||
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). `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). `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
|
||||
JSON object (`{"summary": "...", "suggested_action": "..."}`), parsed with
|
||||
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
|
||||
before touching `routes/console.php` again.
|
||||
|
||||
204
CHANGELOG.md
204
CHANGELOG.md
@@ -3,6 +3,210 @@
|
||||
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 `<select>` elements bound via `wire:change`
|
||||
rather than `wire:model`. Fixed by keying the element to the status value
|
||||
so the DOM node is force-replaced instead of morphed.
|
||||
|
||||
## [1.2.2] - 2026-07-24
|
||||
|
||||
### Added
|
||||
|
||||
- Subcategories can now be reordered within their category — up/down arrow
|
||||
buttons next to "Edytuj" in Admin > Kategorie, right beside each
|
||||
subcategory's edit/delete buttons. The order set there is used everywhere a
|
||||
subcategory list is shown (subcategory pickers, admin listings, etc.), not
|
||||
just the admin panel itself.
|
||||
|
||||
## [1.2.1] - 2026-07-24
|
||||
|
||||
### Added
|
||||
|
||||
- **Generic AI integration** (Admin > Integracje > "Integracja AI"), optional
|
||||
and off by default — an OpenAI-compatible `/chat/completions` client (works
|
||||
against Groq, OpenAI itself, or a self-hosted Ollama instance) configured by
|
||||
base URL, optional API key, model name, and an SSL-verification toggle for
|
||||
self-signed local endpoints. Not tied to any one feature — it's the shared
|
||||
foundation for the two AI-driven features below, and for anything else that
|
||||
wants an LLM call in the future.
|
||||
- **BookStack automatic content tagging (AI)** — a "Otaguj nową treść"/"Otaguj
|
||||
wszystko ponownie (force)" button pair in the BookStack card, plus
|
||||
`php artisan bookstack:tag-content` (`--dry-run`/`--force`/`--limit=N`) for
|
||||
the command line. Uses the AI integration above to classify every
|
||||
book/chapter/page's title+content against the current list of helpdesk
|
||||
subcategories and tags matching ones by name — idempotent by default
|
||||
(skips already-tagged content), so re-running after adding a few pages is
|
||||
cheap. This is what gives the BookStack "search by tags" option below
|
||||
something to actually match against.
|
||||
- **BookStack search refinement** — "Przeszukuj" is now three independent
|
||||
checkboxes (Książki / Strony / Rozdziały) instead of a single dropdown with
|
||||
no chapter option, plus a new "Szukaj po" setting: słowa kluczowe w nazwie /
|
||||
tagi / oba. Tag matching uses the bare subcategory name (e.g. "Drukarki i
|
||||
skanery"), matching what the auto-tagging feature above writes.
|
||||
- **AI-driven ticket triage + summary** (Admin > Integracje >
|
||||
"Automatyzacja AI dla zgłoszeń", runs via a new scheduled
|
||||
`ai:run-ticket-automation`) — five independent toggles: assign a
|
||||
category/subcategory when a ticket has neither, pick a subcategory when it
|
||||
only has a category, recheck and possibly correct an already-categorized
|
||||
ticket, rewrite an unclear subject, and set a priority based on content.
|
||||
Runs once per ticket in the background (never synchronously at submission,
|
||||
so it adds no latency for a client), and every applied change leaves a
|
||||
specific line plus a "Automatyzacja: klasyfikacja AI" entry in the ticket's
|
||||
history, same convention as the existing SLA automation rules. Separately,
|
||||
an AI-generated summary + suggested next action for **every** ticket, shown
|
||||
only to operators in a new "Podsumowanie AI" sidebar card, refreshed
|
||||
whenever the thread gets a new message — its system prompt is admin-editable
|
||||
as a plain-text field with a "Resetuj" button back to the shipped default.
|
||||
- Operators can now reassign a ticket to **any** team, not just one they
|
||||
belong to (previously the dropdown only ever offered the operator's own
|
||||
teams).
|
||||
- The auto-refresh countdown badges (ticket view, operator queue) are now
|
||||
**clickable** — fetch immediately and reset the countdown, instead of only
|
||||
ever refreshing on their own fixed schedule.
|
||||
- **All 7 "cyclical" intervals** in the app are now configurable from
|
||||
Admin > Konfiguracja instead of fixed in code: the 3 browser auto-refresh
|
||||
countdowns (ticket view, operator queue), the notification bell's poll,
|
||||
and the 4 background scheduled commands (SLA breach check, SLA automation
|
||||
rules, IMAP fetch, AI ticket automation). Defaults match the previous
|
||||
hardcoded values, so nothing changes until an admin edits them.
|
||||
|
||||
### Fixed
|
||||
|
||||
- An operator viewing a ticket that gets deleted by someone else, or whose
|
||||
team changes to one outside the operator's own scope (including via their
|
||||
own reassignment above), is now redirected back to the operator queue
|
||||
instead of hitting an error mid-session.
|
||||
|
||||
## [1.2.0] - 2026-07-23
|
||||
|
||||
### Added
|
||||
|
||||
- **E-mail intake (IMAP)**, optional and off by default — clients can create a
|
||||
ticket or reply to an existing one just by sending/replying to an e-mail.
|
||||
Configure any number of mailboxes in the new **Admin > Poczta** page (which
|
||||
now hosts SMTP alongside IMAP, replacing the old "E-MAIL" tab), each with
|
||||
its own host/port/encryption/credentials/folder and routed to either a
|
||||
specific subcategory (routes to that subcategory's team, same as a web
|
||||
ticket) or a whole category with no subcategory (a new `tickets.category_id`
|
||||
column covers this case — previously a ticket's category only ever came
|
||||
through a subcategory).
|
||||
- A reply is matched back to its ticket via the number/checksum already
|
||||
present in every notification e-mail's subject — works with either the
|
||||
plain sequential number or the obfuscated checksum, whichever numbering
|
||||
mode is active, no changes to outbound templates needed.
|
||||
- Automatic replies (autoresponders, "out of office", bounces/mailer-daemon)
|
||||
are detected via headers and common EN/PL subject phrasing and rejected
|
||||
instead of creating a ticket; a per-mailbox sender blocklist covers the
|
||||
rest. The "tylko użytkownicy z LDAP" restriction is enforced for e-mail
|
||||
exactly like the guest web form.
|
||||
- A "Pobierz teraz" button per mailbox fetches immediately, outside the
|
||||
5-minute schedule — useful for testing a freshly-configured mailbox or
|
||||
diagnosing why a specific e-mail didn't turn into a ticket.
|
||||
- Every connection attempt and per-message decision (accepted/rejected/
|
||||
matched to which ticket) is logged to a dedicated `storage/logs/imap-*.log`
|
||||
file, independent of the app's own log level.
|
||||
- Tickets and individual messages that came in by e-mail show a small
|
||||
mail-icon badge in the operator queue and ticket view, distinguishing them
|
||||
from ones created/replied to on the web.
|
||||
- **Operator queue**: a "select all" checkbox in the table header
|
||||
selects/deselects every ticket currently visible under the active
|
||||
filter/tab in one click, instead of clicking each row's checkbox.
|
||||
|
||||
### Changed
|
||||
|
||||
- Admin's old **"E-MAIL"** tab is now **"Poczta"** and also lists/manages the
|
||||
IMAP mailboxes above — the two halves of "reply by e-mail" (send/receive)
|
||||
now live together instead of SMTP being off on its own.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Scheduled-command notifications were silently going nowhere.**
|
||||
`AppServiceProvider`'s Settings-based config override (SMTP/LDAP/session/
|
||||
timezone) used to skip itself for *any* console command, not just
|
||||
`migrate` — meaning `tickets:check-sla-breaches` and `automation:run-rules`
|
||||
(and now `emails:fetch-imap`) always sent their e-mails through whatever
|
||||
`.env`'s `MAIL_MAILER` happened to be (`log`, i.e. nowhere) instead of the
|
||||
admin-configured SMTP server, with no visible error. Now scoped to just the
|
||||
`migrate` command family, so every scheduled command gets the same live
|
||||
config a web request would.
|
||||
- Visiting a ticket that no longer exists (most commonly: it was deleted
|
||||
while the viewer had it open, and a later background refresh hit the same
|
||||
URL) no longer shows Laravel's default 404 page — redirects back to the
|
||||
operator queue or client dashboard instead.
|
||||
- This host had no crontab entry at all for `php artisan schedule:run` —
|
||||
meaning SLA breach checks and automation rules had never actually run on
|
||||
their own, only ever on request. Documented and configured (see
|
||||
[CLAUDE.md](CLAUDE.md)).
|
||||
|
||||
## [1.1.4] - 2026-07-23
|
||||
|
||||
### Added
|
||||
|
||||
- **Configurable ticket numbering** (Admin > Konfiguracja > Ogólne) — an
|
||||
admin-set prefix (default `#`) and a minimum zero-padded length for the
|
||||
ticket number.
|
||||
- **"Ukryj kolejność zgłoszeń"** — an opt-in mode that displays a stable,
|
||||
HMAC-derived checksum instead of the sequential ticket number, so the
|
||||
number shown gives no indication of ticket volume or creation order. Every
|
||||
ticket gets its checksum assigned once, on creation, guaranteed unique.
|
||||
When this mode is on, ticket URLs switch to the same checksum too (custom
|
||||
`Ticket::getRouteKey()`/`resolveRouteBinding()`), so a link and the number
|
||||
on the page it points to always match — and a URL built under the other
|
||||
mode stops resolving. The REST API is unaffected; it's pinned to `id`
|
||||
regardless of this setting. Search (queue/dashboard) now also matches
|
||||
against the checksum. A live preview against a real ticket from the
|
||||
database shows exactly how the number will look before saving.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Attachments**: dropped the inline image thumbnail preview in the message
|
||||
thread — every attachment (images included) now shows as just its
|
||||
filename, opening in a new tab on click, consistent with how non-image
|
||||
attachments already worked.
|
||||
|
||||
## [1.1.3] - 2026-07-22
|
||||
|
||||
### Added
|
||||
|
||||
41
CLAUDE.md
41
CLAUDE.md
@@ -60,6 +60,47 @@ with no rebuild or restart:
|
||||
view:clear` to flush any root-owned compiled views before ending the
|
||||
session — don't wait for a report of a broken page to catch it.
|
||||
|
||||
## Scheduled commands need a host crontab entry
|
||||
|
||||
The Docker image ships no cron/supervisor of its own (see [install.md](install.md)),
|
||||
so `tickets:check-sla-breaches`, `automation:run-rules`, `emails:fetch-imap`,
|
||||
and `ai:run-ticket-automation` (all registered in `routes/console.php` via
|
||||
`Schedule::command(...)`) only ever run if something outside the container
|
||||
calls `php artisan schedule:run` on a timer. **As of 2026-07-23 this is
|
||||
configured** — root's crontab on the host runs, every minute:
|
||||
|
||||
```cron
|
||||
* * * * * cd /mnt/rabbit-containers/servicedesk && docker compose exec -T servicedesk php artisan schedule:run >> /dev/null 2>&1
|
||||
```
|
||||
|
||||
(`sudo crontab -l -u root` to inspect/edit — it previously did not exist at all,
|
||||
which meant none of the four scheduled commands above had ever run
|
||||
automatically; ask before changing this again, since removing it silently
|
||||
breaks SLA checks, automation rules, IMAP fetching and AI ticket automation,
|
||||
and confusingly not the IMAP feature alone if you're only debugging that one.)
|
||||
IMAP-specific activity (connect attempts, per-message accept/reject decisions,
|
||||
created/replied ticket ids) is logged separately from the app's normal
|
||||
`LOG_LEVEL` to `storage/logs/imap-*.log` (see the `imap` channel in
|
||||
`config/logging.php`) — check there first when a mailbox isn't behaving as
|
||||
expected, before assuming the scheduler itself isn't firing.
|
||||
|
||||
All four commands' intervals are admin-configurable (Admin > Konfiguracja —
|
||||
`schedule_sla_check_minutes`/`schedule_automation_rules_minutes`/
|
||||
`schedule_imap_fetch_minutes`/`schedule_ai_automation_minutes`), which is why
|
||||
`routes/console.php` registers them as `->everyMinute()->when(fn () =>
|
||||
Settings::dueEveryMinutes(...))` instead of a plain `->everyFifteenMinutes()`/
|
||||
`->cron(...)` call — **never build a cron expression (or otherwise read
|
||||
`Settings`) at that file's top level**. `routes/console.php` is `require`'d on
|
||||
every artisan boot (`migrate`, `tinker`, `php artisan test`, not just
|
||||
`schedule:run` — it's wired in via `bootstrap/app.php`'s `commands:` key), so
|
||||
a top-level `Settings::get(...)` call runs before a fresh/test database
|
||||
necessarily has the `settings` table, and crashes every single artisan
|
||||
invocation, not just the scheduler. A `->when($closure)` guard is the fix —
|
||||
the closure is only ever evaluated later, when `schedule:run` processes due
|
||||
events. One visible side effect: `php artisan schedule:list` shows
|
||||
`* * * * *` for all four regardless of the actual configured interval, since
|
||||
that only exists inside the closure — expected, not a bug worth chasing.
|
||||
|
||||
## Apache `/icons/` alias trap
|
||||
|
||||
The stock `php:apache` image enables `mods-enabled/alias.conf`, which defines
|
||||
|
||||
98
README.md
98
README.md
@@ -40,13 +40,19 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
|
||||
(operator and client) update live over WebSockets (Laravel Reverb): new
|
||||
tickets, status/priority/team/assignee changes, and new replies ("live chat")
|
||||
all show up without a manual refresh. A periodic fallback refresh (with a
|
||||
visible countdown) covers a dropped websocket connection.
|
||||
visible, clickable countdown badge — click it to fetch immediately and reset
|
||||
the countdown) covers a dropped websocket connection. All of the refresh/poll
|
||||
intervals in the app, browser-side and the background scheduled commands
|
||||
alike, are configurable from Admin > Konfiguracja (see below).
|
||||
- **Categories & custom fields** — admin-defined categories/subcategories, each with
|
||||
its own set of custom fields (text/textarea/select/checkbox/date/number) and an
|
||||
optional default priority.
|
||||
optional default priority. Subcategories within a category can be reordered with
|
||||
up/down arrows in Admin > Kategorie; the order set there is what clients/operators
|
||||
see everywhere a subcategory picker is shown.
|
||||
- **Teams** — subcategories auto-route to a team; operators only see their own
|
||||
team's queue (plus unrouted tickets and anything assigned to them) unless they're
|
||||
an admin.
|
||||
an admin. Reassigning a ticket to a team, though, is unrestricted — an operator
|
||||
can route a ticket to any team, not just one they belong to.
|
||||
- **Templates** — canned response snippets for the reply box, admin-configurable
|
||||
"quick actions" (send + transition status in one click), and HTML e-mail
|
||||
templates for every ticket lifecycle event (created, status/priority/category/
|
||||
@@ -96,8 +102,27 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
|
||||
while the tab is open (see per-user notification preferences above).
|
||||
Includes a dedicated trigger notifying every operator on a team whose
|
||||
subcategories match a newly created ticket.
|
||||
- **Attachments** — drag-and-drop upload (in addition to the file picker) and
|
||||
inline image thumbnails in the message thread instead of a plain download link.
|
||||
- **Attachments** — drag-and-drop upload (in addition to the file picker); every
|
||||
attachment shows in the message thread as just its filename, opening in a new
|
||||
tab on click (no inline image preview).
|
||||
- **E-mail intake (IMAP)** *(optional, off by default)* — clients can create
|
||||
tickets or reply to an existing one just by sending/replying to an e-mail;
|
||||
configure any number of mailboxes in Admin > Poczta (e.g. one address per
|
||||
team), each routed to a specific subcategory or a whole category. A reply
|
||||
is matched back to its ticket via the number/checksum already present in
|
||||
every notification's subject; automatic replies (autoresponders, bounces)
|
||||
are detected and rejected instead of creating junk tickets, and the
|
||||
"restrict tickets to LDAP" setting is enforced for e-mail exactly like the
|
||||
guest web form. A manual "Pobierz teraz" button fetches immediately
|
||||
outside the 5-minute schedule; all activity is logged separately to
|
||||
`storage/logs/imap-*.log`. Tickets/messages that came in by e-mail show a
|
||||
small mail-icon badge in the operator queue and ticket view.
|
||||
- **Configurable ticket numbering** (Admin > Konfiguracja) — a custom prefix and
|
||||
minimum zero-padded length for the ticket number, plus an optional "hide
|
||||
ticket order" mode that displays a stable per-ticket checksum instead of the
|
||||
sequential number. When enabled, ticket URLs switch to the same checksum too,
|
||||
so the number in the link always matches the one on the page; the REST API is
|
||||
unaffected and always addresses tickets by `id`.
|
||||
- **Customer satisfaction (CSAT)** — clients rate a ticket 1–5 stars (+ optional
|
||||
comment) once it's closed; average/response-rate surfaced as a KPI on the
|
||||
operator stats dashboard, with a link in the "ticket closed" e-mail.
|
||||
@@ -114,17 +139,62 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
|
||||
both operators and clients (with a copy-link button for operators). Loads in
|
||||
after the page's first paint rather than blocking it. Configured entirely
|
||||
from Admin > Integracje: connection + API token, optional SSL-verification
|
||||
bypass for self-signed instances, page/book search-type filter, and two
|
||||
independent per-shelf allow-lists (nothing is searched until an admin opts
|
||||
specific shelves in, separately for ticket-creation suggestions vs. the
|
||||
operator/client ticket-view sidebar).
|
||||
bypass for self-signed instances, a content-type filter (books/pages/
|
||||
chapters, independently toggleable) and a "search by" mode (name / tags /
|
||||
both), and two independent per-shelf allow-lists (nothing is searched until
|
||||
an admin opts specific shelves in, separately for ticket-creation
|
||||
suggestions vs. the operator/client ticket-view sidebar). A pair of
|
||||
"Otaguj nową treść"/"Otaguj wszystko ponownie" buttons (also available as
|
||||
`php artisan bookstack:tag-content`) use the AI integration below to
|
||||
auto-tag every book/chapter/page with matching subcategory names, so the
|
||||
tag-based search mode has something to find.
|
||||
- **Generic AI integration** (Admin > Integracje > "Integracja AI") *(optional,
|
||||
off by default)* — an OpenAI-compatible chat-completions client (Groq,
|
||||
OpenAI, or a self-hosted Ollama instance) configured by base URL, optional
|
||||
API key, model, and an SSL-verification toggle. Not tied to a single
|
||||
feature — it backs the BookStack auto-tagging above and the AI ticket
|
||||
triage/summary below, and is meant to be reused by anything that needs an
|
||||
LLM call in the future.
|
||||
- **AI-driven ticket triage + summary** (Admin > Integracje >
|
||||
"Automatyzacja AI dla zgłoszeń") *(optional, off by default, requires the AI
|
||||
integration above)* — five independent toggles run once per new ticket, in
|
||||
the background (`ai:run-ticket-automation`, never synchronously at
|
||||
submission): assign a category/subcategory when missing, pick a
|
||||
subcategory when only a category is set, recheck/correct an
|
||||
already-categorized ticket, rewrite an unclear subject, and set a priority
|
||||
from the ticket's content. Every applied change is logged in the ticket's
|
||||
history. Separately, an AI-generated summary + suggested next action for
|
||||
every ticket, shown to operators only in a "Podsumowanie AI" sidebar card
|
||||
with a manual "Wygeneruj teraz" button, an admin-editable prompt
|
||||
(reset-to-default button included), and a per-transcript excerpt of the
|
||||
ticket's own opening body alongside the reply thread (so long tickets
|
||||
don't lose the original request once it scrolls out of the message
|
||||
window). Refreshed by the same periodic sweep by default; an optional
|
||||
admin toggle regenerates it immediately after every new reply/note
|
||||
instead of waiting for the next scheduled run.
|
||||
- **Snipe-IT asset inventory integration** *(optional, off by default)* —
|
||||
connects to a Snipe-IT instance (API address + personal API token, plus an
|
||||
SSL-verification bypass for self-signed instances) and adds three
|
||||
independently toggleable capabilities from Admin > Integracje: a client
|
||||
can pick which of their own Snipe-IT assets a ticket concerns while
|
||||
creating it (scoped to admin-selected subcategories, empty selection means
|
||||
it never shows — same convention as BookStack's shelf allow-lists), an
|
||||
operator sees the requester's own assets in a ticket-view sidebar card,
|
||||
and an operator can search the entire Snipe-IT inventory from that same
|
||||
sidebar (not a separate page) to link shared equipment the requester isn't
|
||||
the current owner of. Every asset is shown as "numer środka - numer
|
||||
seryjny - producent model" plus its Snipe-IT category; a linked asset's
|
||||
live status/assignment is fetched fresh on the ticket page, and unlinking
|
||||
stays available to an operator even if both view/search toggles are later
|
||||
turned off.
|
||||
|
||||
## Tech stack
|
||||
|
||||
- **Backend**: Laravel, Livewire (server-driven UI, no SPA build beyond Tailwind/Vite
|
||||
for CSS), LdapRecord for directory auth, Sanctum for API tokens, L5-Swagger for
|
||||
API docs, Laravel Reverb for WebSocket broadcasting (real-time queue/chat
|
||||
updates — see [ARCHITECTURE.md](ARCHITECTURE.md)).
|
||||
updates — see [ARCHITECTURE.md](ARCHITECTURE.md)), webklex/php-imap for the
|
||||
optional e-mail intake fetcher (pure-PHP IMAP client, no `ext-imap` needed).
|
||||
- **Frontend**: Blade + Livewire + a little Alpine.js for local UI state; Tailwind
|
||||
v4 via Vite for `resources/css/app.css`; Laravel Echo + Pusher-protocol client
|
||||
(`resources/js/echo.js`) for Reverb. No JS charting library — the statistics
|
||||
@@ -172,8 +242,12 @@ src/ Laravel application
|
||||
app/Livewire/ Client/Operator/Admin Livewire components
|
||||
app/Models/ Eloquent models
|
||||
app/Events/ Broadcast events (TicketQueueChanged, TicketMessagePosted)
|
||||
app/Console/Commands/ Scheduled commands (SLA breach check, automation rules)
|
||||
app/Services/ TicketService (ticket lifecycle + notifications), BookStackClient
|
||||
app/Console/Commands/ Scheduled commands (SLA breach check, automation rules, IMAP fetch,
|
||||
AI ticket triage/summary) + bookstack:tag-content
|
||||
app/Services/ TicketService (ticket lifecycle + notifications), BookStackClient,
|
||||
ImapMailboxFetcher (I/O) + ImapMessageClassifier (pure logic),
|
||||
AiClient (generic LLM client), BookStackContentTagger,
|
||||
TicketAiTriageService, TicketAiSummaryService, SnipeItClient
|
||||
app/Ldap/ LDAP user model + sync handlers
|
||||
database/migrations/ Schema (one file per table group, final shape)
|
||||
database/seeders/ DatabaseSeeder — reference data, no ticket data
|
||||
|
||||
48
install.md
48
install.md
@@ -74,7 +74,7 @@ APP_LOCALE=pl
|
||||
APP_FALLBACK_LOCALE=pl
|
||||
|
||||
AUTHOR_CONTACT=helpdesk@twoja-domena.pl # widoczne w Admin > O aplikacji
|
||||
VERSION=1.1.3 # widoczne w Admin > O aplikacji
|
||||
VERSION=1.3.0 # widoczne w Admin > O aplikacji
|
||||
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=mariadb # nazwa serwisu z compose.yaml, NIE 127.0.0.1
|
||||
@@ -265,11 +265,19 @@ docker run --rm -v "$(pwd)/src":/app -w /app node:22 npm run build
|
||||
|
||||
Powtarzaj drugi krok po każdej zmianie w `resources/css/` lub `resources/js/`.
|
||||
|
||||
### 1.6. Zadanie cykliczne (SLA) i kolejka
|
||||
### 1.6. Zadanie cykliczne (SLA, automatyzacje, poczta IMAP, AI) i kolejka
|
||||
|
||||
`routes/console.php` planuje `tickets:check-sla-breaches` co 15 minut, ale **obraz
|
||||
Dockera nie ma wbudowanego cron/supervisora** — bez dodatkowego kroku to zadanie
|
||||
nigdy się nie uruchomi. Najprościej dodać wpis crona **na hoście**:
|
||||
`routes/console.php` planuje `tickets:check-sla-breaches` i `automation:run-rules`
|
||||
co 15 minut, oraz `emails:fetch-imap` (odbieranie zgłoszeń/odpowiedzi e-mailem —
|
||||
patrz Admin > Poczta) i `ai:run-ticket-automation` (opcjonalna automatyczna
|
||||
kategoryzacja/podsumowania AI zgłoszeń — patrz Admin > Integracje) co 5 minut,
|
||||
ale **obraz Dockera nie ma wbudowanego cron/supervisora** — bez dodatkowego
|
||||
kroku żadne z tych zadań nigdy się nie uruchomi (poczta IMAP nadal da się
|
||||
sprawdzić ręcznie przyciskiem „Pobierz teraz”, ale bez crona nic nie dzieje się
|
||||
samo). Wszystkie cztery interwały są też konfigurowalne z poziomu **Admin >
|
||||
Konfiguracja** (bez potrzeby edycji kodu czy restartu — nowa wartość obowiązuje
|
||||
od najbliższego tyknięcia harmonogramu). Najprościej dodać wpis crona **na
|
||||
hoście**:
|
||||
|
||||
```cron
|
||||
* * * * * cd /ścieżka/do/repo && docker compose exec -T servicedesk php artisan schedule:run >> /dev/null 2>&1
|
||||
@@ -298,15 +306,27 @@ użytku:
|
||||
3. Użyj przycisków **„Testuj połączenie”** przy obu sekcjach, zanim zaczniesz
|
||||
polegać na logowaniu przez katalog.
|
||||
|
||||
### Integracje opcjonalne (BookStack)
|
||||
### Integracje opcjonalne (BookStack, AI)
|
||||
|
||||
Podpowiedzi artykułów z bazy wiedzy BookStack (przy tworzeniu zgłoszenia i w
|
||||
panelu operatora) są **domyślnie wyłączone** i nie wymagają żadnej zmiennej w
|
||||
`.env` — całość konfiguruje się w **Admin > Konfiguracja**: adres instancji,
|
||||
`.env` — całość konfiguruje się w **Admin > Integracje**: adres instancji,
|
||||
Token ID/Secret (rola/użytkownik właściciela tokenu musi mieć w BookStacku
|
||||
uprawnienie „Access System API”), oraz osobne listy dozwolonych półek dla
|
||||
podpowiedzi przy tworzeniu zgłoszenia i dla panelu operatora — dopóki żadna
|
||||
półka nie jest zaznaczona, wyszukiwanie nic nie zwraca.
|
||||
uprawnienie „Access System API”), filtr typu treści (książki/strony/rozdziały,
|
||||
niezależne checkboxy), tryb wyszukiwania (po nazwie / po tagach / oba), oraz
|
||||
osobne listy dozwolonych półek dla podpowiedzi przy tworzeniu zgłoszenia i dla
|
||||
panelu operatora — dopóki żadna półka nie jest zaznaczona, wyszukiwanie nic
|
||||
nie zwraca.
|
||||
|
||||
Integracja AI (Admin > Integracje > „Integracja AI”) jest **domyślnie
|
||||
wyłączona** i tak samo nie wymaga żadnej zmiennej w `.env` — adres API
|
||||
(dowolny dostawca kompatybilny z OpenAI: Groq, OpenAI, lokalny Ollama),
|
||||
opcjonalny klucz API, nazwa modelu i przełącznik weryfikacji SSL. Sama w
|
||||
sobie nic nie robi — dopiero po jej włączeniu można włączyć automatyczne
|
||||
tagowanie treści BookStack (przyciski przy integracji BookStack) oraz
|
||||
automatyczną kategoryzację/podsumowania AI zgłoszeń (Admin > Integracje >
|
||||
„Automatyzacja AI dla zgłoszeń”, wymaga też wpisu crona z kroku 1.6/2.6
|
||||
powyżej — to ten sam harmonogram co SLA/automatyzacje/IMAP).
|
||||
|
||||
---
|
||||
|
||||
@@ -349,7 +369,7 @@ APP_LOCALE=pl
|
||||
APP_FALLBACK_LOCALE=pl
|
||||
|
||||
AUTHOR_CONTACT=helpdesk@twoja-domena.pl
|
||||
VERSION=1.1.3
|
||||
VERSION=1.3.0
|
||||
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=127.0.0.1 # albo adres IP/hostname prawdziwego serwera DB
|
||||
@@ -457,9 +477,11 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
### 2.6. Zadanie cykliczne (SLA) i kolejka
|
||||
### 2.6. Zadanie cykliczne (SLA, automatyzacje, poczta IMAP, AI) i kolejka
|
||||
|
||||
Crontab użytkownika, pod którym stoi aplikacja (np. `www-data`):
|
||||
Crontab użytkownika, pod którym stoi aplikacja (np. `www-data`) — obsługuje też
|
||||
`automation:run-rules`, `emails:fetch-imap` i `ai:run-ticket-automation`
|
||||
(patrz 1.6 wyżej, w tym konfigurowalne interwały w Admin > Konfiguracja):
|
||||
|
||||
```cron
|
||||
* * * * * cd /var/www/servicedesk/src && php artisan schedule:run >> /dev/null 2>&1
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
APP_NAME=Laravel
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
|
||||
AUTHOR_CONTACT=helpdesk@kzbikowski.pl
|
||||
VERSION=1.1.3
|
||||
VERSION=1.3.0
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
|
||||
35
src/app/Console/Commands/BookstackTagContent.php
Normal file
35
src/app/Console/Commands/BookstackTagContent.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\BookStackContentTagger;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class BookstackTagContent extends Command
|
||||
{
|
||||
protected $signature = 'bookstack:tag-content
|
||||
{--dry-run : Klasyfikuj i pokaż wynik bez zapisu tagów do BookStack}
|
||||
{--force : Klasyfikuj ponownie elementy, które już mają tag podkategorii}
|
||||
{--limit= : Zatrzymaj się po przetworzeniu N elementów}';
|
||||
|
||||
protected $description = 'Otaguj każdą książkę/rozdział/stronę w BookStack pasującymi nazwami podkategorii helpdesku, z pomocą skonfigurowanego dostawcy AI';
|
||||
|
||||
public function handle(BookStackContentTagger $tagger): int
|
||||
{
|
||||
$totals = $tagger->run(
|
||||
dryRun: (bool) $this->option('dry-run'),
|
||||
force: (bool) $this->option('force'),
|
||||
limit: $this->option('limit') !== null ? (int) $this->option('limit') : null,
|
||||
);
|
||||
|
||||
$this->info(sprintf(
|
||||
'BookStack tagging: przeskanowano %d, otagowano %d, pominięto %d, nieudanych paczek %d.',
|
||||
$totals['scanned'],
|
||||
$totals['tagged'],
|
||||
$totals['skipped'],
|
||||
$totals['failed_batches'],
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
33
src/app/Console/Commands/FetchImapEmails.php
Normal file
33
src/app/Console/Commands/FetchImapEmails.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\ImapMailbox;
|
||||
use App\Services\ImapMailboxFetcher;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class FetchImapEmails extends Command
|
||||
{
|
||||
protected $signature = 'emails:fetch-imap';
|
||||
|
||||
protected $description = 'Poll every enabled IMAP mailbox and turn new messages into tickets/replies';
|
||||
|
||||
public function handle(ImapMailboxFetcher $fetcher): int
|
||||
{
|
||||
if (! ImapMailbox::query()->where('enabled', true)->exists()) {
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$totals = $fetcher->fetchAll();
|
||||
|
||||
$this->info(sprintf(
|
||||
'IMAP fetch: %d nowych, %d odpowiedzi, %d odrzuconych, %d błędów.',
|
||||
$totals['created'],
|
||||
$totals['replied'],
|
||||
$totals['rejected'],
|
||||
$totals['errors'],
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
35
src/app/Console/Commands/RunAiTicketAutomation.php
Normal file
35
src/app/Console/Commands/RunAiTicketAutomation.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\TicketAiSummaryService;
|
||||
use App\Services\TicketAiTriageService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RunAiTicketAutomation extends Command
|
||||
{
|
||||
protected $signature = 'ai:run-ticket-automation';
|
||||
|
||||
protected $description = 'Run AI-driven ticket triage (categorize/prioritize new tickets) and refresh AI ticket summaries for the operator view';
|
||||
|
||||
public function handle(TicketAiTriageService $triage, TicketAiSummaryService $summary): int
|
||||
{
|
||||
$triageTotals = $triage->run();
|
||||
$summaryTotals = $summary->run();
|
||||
|
||||
$this->info(sprintf(
|
||||
'AI triage: scanned %d, changed %d, failed %d.',
|
||||
$triageTotals['scanned'],
|
||||
$triageTotals['changed'],
|
||||
$triageTotals['failed'],
|
||||
));
|
||||
$this->info(sprintf(
|
||||
'AI summaries: scanned %d, updated %d, failed %d.',
|
||||
$summaryTotals['scanned'],
|
||||
$summaryTotals['updated'],
|
||||
$summaryTotals['failed'],
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
30
src/app/Jobs/GenerateTicketAiSummaryJob.php
Normal file
30
src/app/Jobs/GenerateTicketAiSummaryJob.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Ticket;
|
||||
use App\Services\TicketAiSummaryService;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
|
||||
/**
|
||||
* Deliberately NOT a queued job (no ShouldQueue) — this app's queue worker
|
||||
* is optional infrastructure (see install.md), so anything pushed onto the
|
||||
* `jobs` table has no guarantee of ever being picked up. Dispatched with
|
||||
* ::dispatchAfterResponse() instead, which runs it in-process right after
|
||||
* the triggering HTTP/console response is sent, needing no worker at all.
|
||||
*/
|
||||
class GenerateTicketAiSummaryJob
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct(protected int $ticketId) {}
|
||||
|
||||
public function handle(TicketAiSummaryService $summary): void
|
||||
{
|
||||
$ticket = Ticket::find($this->ticketId);
|
||||
|
||||
if ($ticket) {
|
||||
$summary->generateFor($ticket);
|
||||
}
|
||||
}
|
||||
}
|
||||
335
src/app/Livewire/Admin/MailSettings.php
Normal file
335
src/app/Livewire/Admin/MailSettings.php
Normal file
@@ -0,0 +1,335 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\ImapMailbox;
|
||||
use App\Services\ImapMailboxFetcher;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* SMTP (outbound) + IMAP mailboxes (inbound — turns e-mails into tickets or
|
||||
* replies) on their own dedicated admin page, split out of the generic
|
||||
* "Integracje" grab-bag since IMAP is a repeatable list (N mailboxes) rather
|
||||
* than a singleton config, and both halves of "reply by e-mail" belong
|
||||
* together rather than split across tabs.
|
||||
*/
|
||||
class MailSettings extends Component
|
||||
{
|
||||
public array $mailConfig = [];
|
||||
|
||||
public ?string $mailTestResult = null;
|
||||
|
||||
public bool $mailboxFormOpen = false;
|
||||
|
||||
public array $mailboxForm = [
|
||||
'id' => null,
|
||||
'name' => '',
|
||||
'enabled' => true,
|
||||
'host' => '',
|
||||
'port' => 993,
|
||||
'encryption' => 'ssl',
|
||||
'validateCert' => true,
|
||||
'username' => '',
|
||||
'password' => '',
|
||||
'folder' => 'INBOX',
|
||||
'processedFolder' => '',
|
||||
'rejectedFolder' => '',
|
||||
'target' => '',
|
||||
'blocklistSenders' => 'mailer-daemon,postmaster,no-reply,noreply',
|
||||
];
|
||||
|
||||
public ?int $mailboxTestResultId = null;
|
||||
|
||||
public ?string $mailboxTestResult = null;
|
||||
|
||||
public ?string $mailboxTestMessage = null;
|
||||
|
||||
public ?int $mailboxFetchResultId = null;
|
||||
|
||||
public ?string $mailboxFetchSummary = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->mailConfig = [
|
||||
'smtpEnabled' => Settings::bool('mail_smtp_enabled'),
|
||||
'smtpHost' => Settings::get('mail_smtp_host'),
|
||||
'smtpPort' => Settings::get('mail_smtp_port'),
|
||||
'smtpUsername' => Settings::get('mail_smtp_username'),
|
||||
'smtpPassword' => Settings::get('mail_smtp_password'),
|
||||
'smtpEncryption' => Settings::get('mail_smtp_encryption'),
|
||||
'fromAddress' => Settings::get('mail_from_address'),
|
||||
'fromName' => Settings::get('mail_from_name'),
|
||||
];
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function mailboxes(): Collection
|
||||
{
|
||||
return ImapMailbox::query()->with(['defaultSubcategory.category', 'defaultCategory'])->orderBy('name')->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Categories with their subcategories nested, for the mailbox form's
|
||||
* single combined "cała kategoria albo konkretna podkategoria" selector.
|
||||
*/
|
||||
#[Computed]
|
||||
public function categoryOptions(): Collection
|
||||
{
|
||||
return Category::query()->with('subcategories')->orderBy('name')->get()
|
||||
->map(fn (Category $c) => [
|
||||
'id' => $c->id,
|
||||
'name' => $c->name,
|
||||
'subcategories' => $c->subcategories->map(fn ($s) => ['id' => $s->id, 'name' => $s->name])->values(),
|
||||
])
|
||||
->values();
|
||||
}
|
||||
|
||||
// ===================== SMTP =====================
|
||||
|
||||
public function saveMailConfig(): void
|
||||
{
|
||||
Settings::set('mail_smtp_enabled', $this->mailConfig['smtpEnabled'] ? '1' : '0');
|
||||
Settings::set('mail_smtp_host', $this->mailConfig['smtpHost']);
|
||||
Settings::set('mail_smtp_port', (string) $this->mailConfig['smtpPort']);
|
||||
Settings::set('mail_smtp_username', $this->mailConfig['smtpUsername']);
|
||||
|
||||
if ($this->mailConfig['smtpPassword']) {
|
||||
Settings::set('mail_smtp_password', $this->mailConfig['smtpPassword']);
|
||||
}
|
||||
|
||||
Settings::set('mail_smtp_encryption', $this->mailConfig['smtpEncryption']);
|
||||
Settings::set('mail_from_address', $this->mailConfig['fromAddress']);
|
||||
Settings::set('mail_from_name', $this->mailConfig['fromName']);
|
||||
|
||||
$this->mailTestResult = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a real test e-mail to the logged-in admin using the form's
|
||||
* current (unsaved) values, temporarily overriding the mail config the
|
||||
* same way AppServiceProvider does for real once saved.
|
||||
*/
|
||||
public function testMailConnection(): void
|
||||
{
|
||||
$cfg = $this->mailConfig;
|
||||
|
||||
if (empty($cfg['smtpHost']) || empty($cfg['fromAddress'])) {
|
||||
$this->mailTestResult = 'error';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$original = Config::get('mail');
|
||||
|
||||
try {
|
||||
Config::set('mail.default', 'smtp');
|
||||
Config::set('mail.mailers.smtp.host', $cfg['smtpHost']);
|
||||
Config::set('mail.mailers.smtp.port', (int) $cfg['smtpPort']);
|
||||
Config::set('mail.mailers.smtp.username', $cfg['smtpUsername'] ?: null);
|
||||
Config::set('mail.mailers.smtp.password', $cfg['smtpPassword'] ?: Settings::get('mail_smtp_password'));
|
||||
Config::set('mail.mailers.smtp.scheme', match ($cfg['smtpEncryption']) {
|
||||
'ssl' => 'smtps',
|
||||
'tls' => 'smtp',
|
||||
default => null,
|
||||
});
|
||||
Config::set('mail.from.address', $cfg['fromAddress']);
|
||||
Config::set('mail.from.name', $cfg['fromName'] ?: Settings::get('company_name'));
|
||||
|
||||
app()->forgetInstance('mail.manager');
|
||||
app()->forgetInstance('mailer');
|
||||
|
||||
Mail::raw('To jest testowa wiadomość wysłana z panelu administratora Servicedesk.', function ($message) {
|
||||
$message->to(Auth::user()->email)->subject('Test konfiguracji SMTP');
|
||||
});
|
||||
|
||||
$this->mailTestResult = 'ok';
|
||||
} catch (\Throwable) {
|
||||
$this->mailTestResult = 'error';
|
||||
} finally {
|
||||
Config::set('mail', $original);
|
||||
app()->forgetInstance('mail.manager');
|
||||
app()->forgetInstance('mailer');
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== IMAP MAILBOXES =====================
|
||||
|
||||
public function openMailboxForm(): void
|
||||
{
|
||||
$this->reset('mailboxForm');
|
||||
$this->mailboxForm = [
|
||||
'id' => null,
|
||||
'name' => '',
|
||||
'enabled' => true,
|
||||
'host' => '',
|
||||
'port' => 993,
|
||||
'encryption' => 'ssl',
|
||||
'validateCert' => true,
|
||||
'username' => '',
|
||||
'password' => '',
|
||||
'folder' => 'INBOX',
|
||||
'processedFolder' => '',
|
||||
'rejectedFolder' => '',
|
||||
'target' => '',
|
||||
'blocklistSenders' => 'mailer-daemon,postmaster,no-reply,noreply',
|
||||
];
|
||||
$this->mailboxTestResultId = null;
|
||||
$this->resetErrorBag();
|
||||
$this->mailboxFormOpen = true;
|
||||
}
|
||||
|
||||
public function editMailbox(int $id): void
|
||||
{
|
||||
$mailbox = ImapMailbox::query()->findOrFail($id);
|
||||
|
||||
$target = match (true) {
|
||||
(bool) $mailbox->default_subcategory_id => "subcategory:{$mailbox->default_subcategory_id}",
|
||||
(bool) $mailbox->default_category_id => "category:{$mailbox->default_category_id}",
|
||||
default => '',
|
||||
};
|
||||
|
||||
$this->mailboxForm = [
|
||||
'id' => $mailbox->id,
|
||||
'name' => $mailbox->name,
|
||||
'enabled' => $mailbox->enabled,
|
||||
'host' => $mailbox->host,
|
||||
'port' => $mailbox->port,
|
||||
'encryption' => $mailbox->encryption,
|
||||
'validateCert' => $mailbox->validate_cert,
|
||||
'username' => $mailbox->username,
|
||||
'password' => $mailbox->password,
|
||||
'folder' => $mailbox->folder,
|
||||
'processedFolder' => $mailbox->processed_folder,
|
||||
'rejectedFolder' => $mailbox->rejected_folder,
|
||||
'target' => $target,
|
||||
'blocklistSenders' => $mailbox->blocklist_senders,
|
||||
];
|
||||
$this->mailboxTestResultId = null;
|
||||
$this->resetErrorBag();
|
||||
$this->mailboxFormOpen = true;
|
||||
}
|
||||
|
||||
public function closeMailboxForm(): void
|
||||
{
|
||||
$this->mailboxFormOpen = false;
|
||||
}
|
||||
|
||||
public function submitMailboxForm(): void
|
||||
{
|
||||
$this->validate([
|
||||
'mailboxForm.name' => ['required', 'string', 'max:255'],
|
||||
'mailboxForm.host' => ['required', 'string', 'max:255'],
|
||||
'mailboxForm.port' => ['required', 'integer', 'min:1', 'max:65535'],
|
||||
'mailboxForm.encryption' => ['required', 'in:ssl,tls,none'],
|
||||
'mailboxForm.username' => ['required', 'string', 'max:255'],
|
||||
'mailboxForm.folder' => ['required', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
[$targetType, $targetId] = str_contains((string) $this->mailboxForm['target'], ':')
|
||||
? explode(':', $this->mailboxForm['target'], 2)
|
||||
: [null, null];
|
||||
|
||||
$data = [
|
||||
'name' => $this->mailboxForm['name'],
|
||||
'enabled' => (bool) $this->mailboxForm['enabled'],
|
||||
'host' => $this->mailboxForm['host'],
|
||||
'port' => (int) $this->mailboxForm['port'],
|
||||
'encryption' => $this->mailboxForm['encryption'],
|
||||
'validate_cert' => (bool) $this->mailboxForm['validateCert'],
|
||||
'username' => $this->mailboxForm['username'],
|
||||
'folder' => $this->mailboxForm['folder'],
|
||||
'processed_folder' => $this->mailboxForm['processedFolder'] ?: null,
|
||||
'rejected_folder' => $this->mailboxForm['rejectedFolder'] ?: null,
|
||||
// Exactly one of these (or neither) — never both — driven by the
|
||||
// form's single "cała kategoria albo konkretna podkategoria" selector.
|
||||
'default_subcategory_id' => $targetType === 'subcategory' ? $targetId : null,
|
||||
'default_category_id' => $targetType === 'category' ? $targetId : null,
|
||||
'blocklist_senders' => $this->mailboxForm['blocklistSenders'],
|
||||
];
|
||||
|
||||
$mailbox = ImapMailbox::query()->find($this->mailboxForm['id']);
|
||||
|
||||
if ($mailbox) {
|
||||
if ($this->mailboxForm['password']) {
|
||||
$data['password'] = $this->mailboxForm['password'];
|
||||
}
|
||||
$mailbox->update($data);
|
||||
} else {
|
||||
$data['password'] = $this->mailboxForm['password'];
|
||||
ImapMailbox::query()->create($data);
|
||||
}
|
||||
|
||||
$this->mailboxFormOpen = false;
|
||||
unset($this->mailboxes);
|
||||
}
|
||||
|
||||
public function toggleMailboxEnabled(int $id): void
|
||||
{
|
||||
$mailbox = ImapMailbox::query()->findOrFail($id);
|
||||
$mailbox->update(['enabled' => ! $mailbox->enabled]);
|
||||
unset($this->mailboxes);
|
||||
}
|
||||
|
||||
public function removeMailbox(int $id): void
|
||||
{
|
||||
ImapMailbox::query()->findOrFail($id)->delete();
|
||||
unset($this->mailboxes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a real fetch against one mailbox right now, outside the 5-minute
|
||||
* schedule — for checking a freshly-configured mailbox without waiting,
|
||||
* and for diagnosing "why didn't my e-mail turn into a ticket" without
|
||||
* needing shell access. Allowed even while the mailbox is disabled
|
||||
* (fetchAll(), used by the scheduled command, is the one that respects
|
||||
* the enabled flag — this is an explicit admin action).
|
||||
*/
|
||||
public function fetchMailboxNow(int $id): void
|
||||
{
|
||||
$mailbox = ImapMailbox::query()->findOrFail($id);
|
||||
$result = app(ImapMailboxFetcher::class)->fetchMailbox($mailbox);
|
||||
|
||||
$this->mailboxFetchResultId = $id;
|
||||
$this->mailboxFetchSummary = "Nowe: {$result['created']}, odpowiedzi: {$result['replied']}, odrzucone: {$result['rejected']}, błędy: {$result['errors']}.";
|
||||
unset($this->mailboxes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the form's current (unsaved) values against a throwaway
|
||||
* ImapMailbox instance — mirrors testMailConnection()'s "don't require a
|
||||
* save first" behavior. Falls back to the stored password when editing
|
||||
* an existing mailbox and the password field was left blank.
|
||||
*/
|
||||
public function testMailboxConnection(): void
|
||||
{
|
||||
$mailbox = new ImapMailbox([
|
||||
'host' => $this->mailboxForm['host'],
|
||||
'port' => (int) $this->mailboxForm['port'],
|
||||
'encryption' => $this->mailboxForm['encryption'],
|
||||
'validate_cert' => (bool) $this->mailboxForm['validateCert'],
|
||||
'username' => $this->mailboxForm['username'],
|
||||
'folder' => $this->mailboxForm['folder'] ?: 'INBOX',
|
||||
]);
|
||||
|
||||
$mailbox->password = $this->mailboxForm['password']
|
||||
?: ($this->mailboxForm['id'] ? ImapMailbox::query()->find($this->mailboxForm['id'])?->password : null);
|
||||
|
||||
$error = app(ImapMailboxFetcher::class)->testConnection($mailbox);
|
||||
|
||||
$this->mailboxTestResultId = (int) ($this->mailboxForm['id'] ?? 0);
|
||||
$this->mailboxTestResult = $error === null ? 'ok' : 'error';
|
||||
$this->mailboxTestMessage = $error;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin.mail-settings');
|
||||
}
|
||||
}
|
||||
@@ -14,15 +14,17 @@ use App\Models\SlaRule;
|
||||
use App\Models\Status;
|
||||
use App\Models\Subcategory;
|
||||
use App\Models\Team;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\User;
|
||||
use App\Models\UserField;
|
||||
use App\Services\AiClient;
|
||||
use App\Services\BookStackClient;
|
||||
use App\Services\BookStackContentTagger;
|
||||
use App\Services\LdapUserProvisioner;
|
||||
use App\Services\SnipeItClient;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use LdapRecord\Connection;
|
||||
use Livewire\Attributes\Computed;
|
||||
@@ -147,16 +149,38 @@ class Panel extends Component
|
||||
|
||||
public ?string $ldapTestResult = null;
|
||||
|
||||
public array $mailConfig = [];
|
||||
|
||||
public ?string $mailTestResult = null;
|
||||
|
||||
public array $bookstackConfig = [];
|
||||
|
||||
public ?string $bookstackTestResult = null;
|
||||
|
||||
public ?string $bookstackTestMessage = null;
|
||||
|
||||
public ?array $bookstackTagResult = null;
|
||||
|
||||
public ?string $bookstackTagError = null;
|
||||
|
||||
public array $snipeitConfig = [];
|
||||
|
||||
public ?string $snipeitTestResult = null;
|
||||
|
||||
public ?string $snipeitTestMessage = null;
|
||||
|
||||
public array $aiConfig = [];
|
||||
|
||||
public ?string $aiTestResult = null;
|
||||
|
||||
public ?string $aiTestMessage = null;
|
||||
|
||||
public array $aiTriageConfig = [];
|
||||
|
||||
public bool $aiSummaryEnabled = false;
|
||||
|
||||
public bool $aiSummaryRegenerateOnMessage = false;
|
||||
|
||||
public string $aiSummaryPrompt = '';
|
||||
|
||||
public int $aiSummaryPromptVersion = 0;
|
||||
|
||||
// ---- generic pending-delete confirm ----
|
||||
public ?string $pendingDeleteType = null;
|
||||
|
||||
@@ -181,6 +205,16 @@ class Panel extends Component
|
||||
'attachmentAllowedTypes' => Settings::get('attachment_allowed_types'),
|
||||
'sessionLifetimeMinutes' => Settings::get('session_lifetime_minutes'),
|
||||
'timezone' => Settings::timezone(),
|
||||
'ticketNumberPrefix' => Settings::get('ticket_number_prefix'),
|
||||
'ticketNumberObfuscate' => Settings::bool('ticket_number_obfuscate'),
|
||||
'ticketNumberMinLength' => Settings::get('ticket_number_min_length'),
|
||||
'refreshTicketViewSeconds' => Settings::get('refresh_ticket_view_seconds'),
|
||||
'refreshQueueSeconds' => Settings::get('refresh_queue_seconds'),
|
||||
'refreshNotificationsSeconds' => Settings::get('refresh_notifications_seconds'),
|
||||
'scheduleSlaCheckMinutes' => Settings::get('schedule_sla_check_minutes'),
|
||||
'scheduleAutomationRulesMinutes' => Settings::get('schedule_automation_rules_minutes'),
|
||||
'scheduleImapFetchMinutes' => Settings::get('schedule_imap_fetch_minutes'),
|
||||
'scheduleAiAutomationMinutes' => Settings::get('schedule_ai_automation_minutes'),
|
||||
];
|
||||
|
||||
$this->ldapConfig = [
|
||||
@@ -197,17 +231,6 @@ class Panel extends Component
|
||||
'restrictTicketsToLdap' => Settings::bool('restrict_tickets_to_ldap'),
|
||||
];
|
||||
|
||||
$this->mailConfig = [
|
||||
'smtpEnabled' => Settings::bool('mail_smtp_enabled'),
|
||||
'smtpHost' => Settings::get('mail_smtp_host'),
|
||||
'smtpPort' => Settings::get('mail_smtp_port'),
|
||||
'smtpUsername' => Settings::get('mail_smtp_username'),
|
||||
'smtpPassword' => Settings::get('mail_smtp_password'),
|
||||
'smtpEncryption' => Settings::get('mail_smtp_encryption'),
|
||||
'fromAddress' => Settings::get('mail_from_address'),
|
||||
'fromName' => Settings::get('mail_from_name'),
|
||||
];
|
||||
|
||||
$this->bookstackConfig = [
|
||||
'enabled' => Settings::bool('bookstack_enabled'),
|
||||
'baseUrl' => Settings::get('bookstack_base_url'),
|
||||
@@ -215,10 +238,41 @@ class Panel extends Component
|
||||
'tokenSecret' => Settings::get('bookstack_token_secret'),
|
||||
'verifySsl' => Settings::bool('bookstack_verify_ssl'),
|
||||
'showToGuests' => Settings::bool('bookstack_show_to_guests'),
|
||||
'searchTypes' => Settings::get('bookstack_search_types', 'both'),
|
||||
'searchTypes' => BookStackClient::normalizeSearchTypes(Settings::get('bookstack_search_types', '')),
|
||||
'searchBy' => in_array($searchBy = Settings::get('bookstack_search_by', 'both'), BookStackClient::SEARCH_BY_OPTIONS, true) ? $searchBy : 'both',
|
||||
'allowedShelfIdsCreation' => $this->parseShelfIds(Settings::get('bookstack_allowed_shelf_ids_creation', '')),
|
||||
'allowedShelfIdsTicketView' => $this->parseShelfIds(Settings::get('bookstack_allowed_shelf_ids_ticket_view', '')),
|
||||
];
|
||||
|
||||
$this->snipeitConfig = [
|
||||
'enabled' => Settings::bool('snipeit_enabled'),
|
||||
'baseUrl' => Settings::get('snipeit_base_url'),
|
||||
'apiToken' => Settings::get('snipeit_api_token'),
|
||||
'skipSslVerification' => ! Settings::bool('snipeit_verify_ssl'),
|
||||
'clientCanSelectAsset' => Settings::bool('snipeit_client_can_select_asset'),
|
||||
'clientAssetSubcategoryIds' => $this->parseShelfIds(Settings::get('snipeit_client_asset_subcategory_ids', '')),
|
||||
'operatorViewRequesterAssets' => Settings::bool('snipeit_operator_view_requester_assets'),
|
||||
'operatorSearchInventory' => Settings::bool('snipeit_operator_search_inventory'),
|
||||
];
|
||||
|
||||
$this->aiConfig = [
|
||||
'enabled' => Settings::bool('ai_enabled'),
|
||||
'baseUrl' => Settings::get('ai_base_url'),
|
||||
'apiKey' => Settings::get('ai_api_key'),
|
||||
'model' => Settings::get('ai_model'),
|
||||
'verifySsl' => Settings::bool('ai_verify_ssl'),
|
||||
];
|
||||
|
||||
$this->aiTriageConfig = [
|
||||
'categoryWhenMissing' => Settings::bool('ai_triage_category_when_missing'),
|
||||
'subcategoryWhenCategoryOnly' => Settings::bool('ai_triage_subcategory_when_category_only'),
|
||||
'recheckCategorized' => Settings::bool('ai_triage_recheck_categorized'),
|
||||
'fixSubject' => Settings::bool('ai_triage_fix_subject'),
|
||||
'setPriority' => Settings::bool('ai_triage_set_priority'),
|
||||
];
|
||||
$this->aiSummaryEnabled = Settings::bool('ai_summary_enabled');
|
||||
$this->aiSummaryRegenerateOnMessage = Settings::bool('ai_summary_regenerate_on_message');
|
||||
$this->aiSummaryPrompt = Settings::get('ai_summary_prompt');
|
||||
}
|
||||
|
||||
public function setTab(string $tab): void
|
||||
@@ -283,10 +337,55 @@ class Panel extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
Category::query()->find($categoryId)?->subcategories()->create(['name' => $name]);
|
||||
$category = Category::query()->find($categoryId);
|
||||
|
||||
if (! $category) {
|
||||
return;
|
||||
}
|
||||
|
||||
$nextPosition = ((int) $category->subcategories()->max('sort_order')) + 1;
|
||||
$category->subcategories()->create(['name' => $name, 'sort_order' => $nextPosition]);
|
||||
$this->newSubNames[$categoryId] = '';
|
||||
}
|
||||
|
||||
public function moveSubcategoryUp(int $subcategoryId): void
|
||||
{
|
||||
$this->swapSubcategoryOrder($subcategoryId, -1);
|
||||
}
|
||||
|
||||
public function moveSubcategoryDown(int $subcategoryId): void
|
||||
{
|
||||
$this->swapSubcategoryOrder($subcategoryId, 1);
|
||||
}
|
||||
|
||||
protected function swapSubcategoryOrder(int $subcategoryId, int $direction): void
|
||||
{
|
||||
$sub = Subcategory::query()->find($subcategoryId);
|
||||
|
||||
if (! $sub) {
|
||||
return;
|
||||
}
|
||||
|
||||
$siblings = Subcategory::query()
|
||||
->where('category_id', $sub->category_id)
|
||||
->orderBy('sort_order')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$idx = $siblings->search(fn (Subcategory $s) => $s->id === $sub->id);
|
||||
$swapIdx = $idx + $direction;
|
||||
|
||||
if ($idx === false || $swapIdx < 0 || $swapIdx >= $siblings->count()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$other = $siblings[$swapIdx];
|
||||
[$order, $otherOrder] = [$sub->sort_order, $other->sort_order];
|
||||
|
||||
$sub->update(['sort_order' => $otherOrder]);
|
||||
$other->update(['sort_order' => $order]);
|
||||
}
|
||||
|
||||
public function openSubcategoryEditForm(int $subcategoryId): void
|
||||
{
|
||||
$sub = Subcategory::query()->with('customFields')->findOrFail($subcategoryId);
|
||||
@@ -1357,6 +1456,41 @@ class Panel extends Component
|
||||
if (in_array($this->systemConfig['timezone'], \DateTimeZone::listIdentifiers(), true)) {
|
||||
Settings::set('timezone', $this->systemConfig['timezone']);
|
||||
}
|
||||
|
||||
Settings::set('ticket_number_prefix', trim((string) $this->systemConfig['ticketNumberPrefix']));
|
||||
Settings::set('ticket_number_obfuscate', $this->systemConfig['ticketNumberObfuscate'] ? '1' : '0');
|
||||
Settings::set('ticket_number_min_length', (string) max(1, (int) $this->systemConfig['ticketNumberMinLength']));
|
||||
|
||||
Settings::set('refresh_ticket_view_seconds', (string) max(1, (int) $this->systemConfig['refreshTicketViewSeconds']));
|
||||
Settings::set('refresh_queue_seconds', (string) max(1, (int) $this->systemConfig['refreshQueueSeconds']));
|
||||
Settings::set('refresh_notifications_seconds', (string) max(1, (int) $this->systemConfig['refreshNotificationsSeconds']));
|
||||
Settings::set('schedule_sla_check_minutes', (string) max(1, (int) $this->systemConfig['scheduleSlaCheckMinutes']));
|
||||
Settings::set('schedule_automation_rules_minutes', (string) max(1, (int) $this->systemConfig['scheduleAutomationRulesMinutes']));
|
||||
Settings::set('schedule_imap_fetch_minutes', (string) max(1, (int) $this->systemConfig['scheduleImapFetchMinutes']));
|
||||
Settings::set('schedule_ai_automation_minutes', (string) max(1, (int) $this->systemConfig['scheduleAiAutomationMinutes']));
|
||||
}
|
||||
|
||||
/**
|
||||
* Live preview for the "Numeracja zgłoszeń" settings — renders a real
|
||||
* ticket's id/number against the form's current (not-yet-saved) values,
|
||||
* so the admin sees exactly how numbers will look before hitting Zapisz.
|
||||
*/
|
||||
#[Computed]
|
||||
public function ticketNumberPreview(): array
|
||||
{
|
||||
$ticket = Ticket::query()->latest('id')->first();
|
||||
$id = $ticket->id ?? 1;
|
||||
$raw = $ticket->number ?? '1001';
|
||||
$checksum = $ticket->checksum ?? Ticket::generateUniqueChecksum($id);
|
||||
$obfuscate = (bool) ($this->systemConfig['ticketNumberObfuscate'] ?? false);
|
||||
$minLength = max(1, (int) ($this->systemConfig['ticketNumberMinLength'] ?? 4));
|
||||
|
||||
$number = $obfuscate ? $checksum : str_pad($raw, $minLength, '0', STR_PAD_LEFT);
|
||||
|
||||
return [
|
||||
'id' => $id,
|
||||
'formatted' => trim((string) ($this->systemConfig['ticketNumberPrefix'] ?? '')).$number,
|
||||
];
|
||||
}
|
||||
|
||||
// ===================== LDAP CONFIG =====================
|
||||
@@ -1425,8 +1559,10 @@ class Panel extends Component
|
||||
Settings::set('bookstack_verify_ssl', $this->bookstackConfig['verifySsl'] ? '1' : '0');
|
||||
Settings::set('bookstack_show_to_guests', $this->bookstackConfig['showToGuests'] ? '1' : '0');
|
||||
|
||||
if (in_array($this->bookstackConfig['searchTypes'], ['both', 'page', 'book'], true)) {
|
||||
Settings::set('bookstack_search_types', $this->bookstackConfig['searchTypes']);
|
||||
Settings::set('bookstack_search_types', implode(',', BookStackClient::normalizeSearchTypes($this->bookstackConfig['searchTypes'])));
|
||||
|
||||
if (in_array($this->bookstackConfig['searchBy'], BookStackClient::SEARCH_BY_OPTIONS, true)) {
|
||||
Settings::set('bookstack_search_by', $this->bookstackConfig['searchBy']);
|
||||
}
|
||||
|
||||
Settings::set('bookstack_allowed_shelf_ids_creation', implode(',', $this->bookstackConfig['allowedShelfIdsCreation']));
|
||||
@@ -1476,6 +1612,15 @@ class Panel extends Component
|
||||
: [...$ids, $id];
|
||||
}
|
||||
|
||||
public function toggleBookstackSearchType(string $type): void
|
||||
{
|
||||
$types = $this->bookstackConfig['searchTypes'];
|
||||
|
||||
$this->bookstackConfig['searchTypes'] = in_array($type, $types, true)
|
||||
? array_values(array_diff($types, [$type]))
|
||||
: [...$types, $type];
|
||||
}
|
||||
|
||||
public function testBookstackConnection(): void
|
||||
{
|
||||
$cfg = $this->bookstackConfig;
|
||||
@@ -1494,73 +1639,140 @@ class Panel extends Component
|
||||
$this->bookstackTestMessage = $result['message'];
|
||||
}
|
||||
|
||||
// ===================== MAIL / SMTP CONFIG =====================
|
||||
|
||||
public function saveMailConfig(): void
|
||||
{
|
||||
Settings::set('mail_smtp_enabled', $this->mailConfig['smtpEnabled'] ? '1' : '0');
|
||||
Settings::set('mail_smtp_host', $this->mailConfig['smtpHost']);
|
||||
Settings::set('mail_smtp_port', (string) $this->mailConfig['smtpPort']);
|
||||
Settings::set('mail_smtp_username', $this->mailConfig['smtpUsername']);
|
||||
|
||||
if ($this->mailConfig['smtpPassword']) {
|
||||
Settings::set('mail_smtp_password', $this->mailConfig['smtpPassword']);
|
||||
}
|
||||
|
||||
Settings::set('mail_smtp_encryption', $this->mailConfig['smtpEncryption']);
|
||||
Settings::set('mail_from_address', $this->mailConfig['fromAddress']);
|
||||
Settings::set('mail_from_name', $this->mailConfig['fromName']);
|
||||
|
||||
$this->mailTestResult = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a real test e-mail to the logged-in admin using the form's
|
||||
* current (unsaved) values, temporarily overriding the mail config the
|
||||
* same way AppServiceProvider does for real once saved — so this test
|
||||
* exercises the exact path production notifications will use.
|
||||
* Runs synchronously in the request (no queue worker runs in this
|
||||
* deployment — see CLAUDE.md — so a dispatched job would just sit in the
|
||||
* `jobs` table). Safe to click again if it times out on a large wiki:
|
||||
* every write is idempotent, so a re-run just skips whatever already got
|
||||
* tagged (or, for the --force variant, re-classifies from scratch).
|
||||
*/
|
||||
public function testMailConnection(): void
|
||||
public function runBookstackTagging(bool $force = false): void
|
||||
{
|
||||
$cfg = $this->mailConfig;
|
||||
|
||||
if (empty($cfg['smtpHost']) || empty($cfg['fromAddress'])) {
|
||||
$this->mailTestResult = 'error';
|
||||
if (! app(BookStackClient::class)->enabled() || ! app(AiClient::class)->enabled()) {
|
||||
$this->bookstackTagResult = null;
|
||||
$this->bookstackTagError = 'Włącz i skonfiguruj obie integracje — BookStack oraz AI — przed uruchomieniem tagowania.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$original = Config::get('mail');
|
||||
$this->bookstackTagError = null;
|
||||
|
||||
try {
|
||||
Config::set('mail.default', 'smtp');
|
||||
Config::set('mail.mailers.smtp.host', $cfg['smtpHost']);
|
||||
Config::set('mail.mailers.smtp.port', (int) $cfg['smtpPort']);
|
||||
Config::set('mail.mailers.smtp.username', $cfg['smtpUsername'] ?: null);
|
||||
Config::set('mail.mailers.smtp.password', $cfg['smtpPassword'] ?: Settings::get('mail_smtp_password'));
|
||||
Config::set('mail.mailers.smtp.scheme', match ($cfg['smtpEncryption']) {
|
||||
'ssl' => 'smtps',
|
||||
'tls' => 'smtp',
|
||||
default => null,
|
||||
});
|
||||
Config::set('mail.from.address', $cfg['fromAddress']);
|
||||
Config::set('mail.from.name', $cfg['fromName'] ?: Settings::get('company_name'));
|
||||
|
||||
app()->forgetInstance('mail.manager');
|
||||
app()->forgetInstance('mailer');
|
||||
|
||||
Mail::raw('To jest testowa wiadomość wysłana z panelu administratora Servicedesk.', function ($message) {
|
||||
$message->to(Auth::user()->email)->subject('Test konfiguracji SMTP');
|
||||
});
|
||||
|
||||
$this->mailTestResult = 'ok';
|
||||
} catch (\Throwable) {
|
||||
$this->mailTestResult = 'error';
|
||||
} finally {
|
||||
Config::set('mail', $original);
|
||||
app()->forgetInstance('mail.manager');
|
||||
app()->forgetInstance('mailer');
|
||||
set_time_limit(0);
|
||||
$this->bookstackTagResult = app(BookStackContentTagger::class)->run(force: $force);
|
||||
}
|
||||
|
||||
public function runBookstackTaggingForce(): void
|
||||
{
|
||||
$this->runBookstackTagging(force: true);
|
||||
}
|
||||
|
||||
// ===================== SNIPE-IT CONFIG =====================
|
||||
|
||||
public function saveSnipeitConfig(): void
|
||||
{
|
||||
Settings::set('snipeit_enabled', $this->snipeitConfig['enabled'] ? '1' : '0');
|
||||
Settings::set('snipeit_base_url', $this->snipeitConfig['baseUrl']);
|
||||
|
||||
if ($this->snipeitConfig['apiToken']) {
|
||||
Settings::set('snipeit_api_token', $this->snipeitConfig['apiToken']);
|
||||
}
|
||||
|
||||
Settings::set('snipeit_verify_ssl', $this->snipeitConfig['skipSslVerification'] ? '0' : '1');
|
||||
Settings::set('snipeit_client_can_select_asset', $this->snipeitConfig['clientCanSelectAsset'] ? '1' : '0');
|
||||
Settings::set('snipeit_client_asset_subcategory_ids', implode(',', $this->snipeitConfig['clientAssetSubcategoryIds']));
|
||||
Settings::set('snipeit_operator_view_requester_assets', $this->snipeitConfig['operatorViewRequesterAssets'] ? '1' : '0');
|
||||
Settings::set('snipeit_operator_search_inventory', $this->snipeitConfig['operatorSearchInventory'] ? '1' : '0');
|
||||
|
||||
$this->snipeitTestResult = null;
|
||||
$this->snipeitTestMessage = null;
|
||||
}
|
||||
|
||||
public function toggleSnipeitClientSubcategory(int $id): void
|
||||
{
|
||||
$ids = $this->snipeitConfig['clientAssetSubcategoryIds'];
|
||||
|
||||
$this->snipeitConfig['clientAssetSubcategoryIds'] = in_array($id, $ids, true)
|
||||
? array_values(array_diff($ids, [$id]))
|
||||
: [...$ids, $id];
|
||||
}
|
||||
|
||||
public function testSnipeitConnection(): void
|
||||
{
|
||||
$cfg = $this->snipeitConfig;
|
||||
|
||||
if (empty($cfg['baseUrl'])) {
|
||||
$this->snipeitTestResult = 'error';
|
||||
$this->snipeitTestMessage = 'Uzupełnij adres API.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$token = $cfg['apiToken'] ?: Settings::get('snipeit_api_token');
|
||||
$result = app(SnipeItClient::class)->testConnection($cfg['baseUrl'], $token ?? '', ! $cfg['skipSslVerification']);
|
||||
|
||||
$this->snipeitTestResult = $result['ok'] ? 'ok' : 'error';
|
||||
$this->snipeitTestMessage = $result['message'];
|
||||
}
|
||||
|
||||
// ===================== AI CONFIG =====================
|
||||
|
||||
public function saveAiConfig(): void
|
||||
{
|
||||
Settings::set('ai_enabled', $this->aiConfig['enabled'] ? '1' : '0');
|
||||
Settings::set('ai_base_url', $this->aiConfig['baseUrl']);
|
||||
|
||||
if ($this->aiConfig['apiKey']) {
|
||||
Settings::set('ai_api_key', $this->aiConfig['apiKey']);
|
||||
}
|
||||
|
||||
Settings::set('ai_model', $this->aiConfig['model']);
|
||||
Settings::set('ai_verify_ssl', $this->aiConfig['verifySsl'] ? '1' : '0');
|
||||
|
||||
$this->aiTestResult = null;
|
||||
$this->aiTestMessage = null;
|
||||
}
|
||||
|
||||
public function testAiConnection(): void
|
||||
{
|
||||
$cfg = $this->aiConfig;
|
||||
|
||||
if (empty($cfg['baseUrl']) || empty($cfg['model'])) {
|
||||
$this->aiTestResult = 'error';
|
||||
$this->aiTestMessage = 'Uzupełnij adres API i nazwę modelu.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$apiKey = $cfg['apiKey'] ?: Settings::get('ai_api_key');
|
||||
$result = app(AiClient::class)->testConnection($cfg['baseUrl'], $apiKey ?? '', $cfg['model'], (bool) $cfg['verifySsl']);
|
||||
|
||||
$this->aiTestResult = $result['ok'] ? 'ok' : 'error';
|
||||
$this->aiTestMessage = $result['message'];
|
||||
}
|
||||
|
||||
public function saveAiTriageConfig(): void
|
||||
{
|
||||
Settings::set('ai_triage_category_when_missing', $this->aiTriageConfig['categoryWhenMissing'] ? '1' : '0');
|
||||
Settings::set('ai_triage_subcategory_when_category_only', $this->aiTriageConfig['subcategoryWhenCategoryOnly'] ? '1' : '0');
|
||||
Settings::set('ai_triage_recheck_categorized', $this->aiTriageConfig['recheckCategorized'] ? '1' : '0');
|
||||
Settings::set('ai_triage_fix_subject', $this->aiTriageConfig['fixSubject'] ? '1' : '0');
|
||||
Settings::set('ai_triage_set_priority', $this->aiTriageConfig['setPriority'] ? '1' : '0');
|
||||
Settings::set('ai_summary_enabled', $this->aiSummaryEnabled ? '1' : '0');
|
||||
Settings::set('ai_summary_regenerate_on_message', $this->aiSummaryRegenerateOnMessage ? '1' : '0');
|
||||
}
|
||||
|
||||
public function saveAiSummaryPrompt(string $value): void
|
||||
{
|
||||
Settings::set('ai_summary_prompt', $value);
|
||||
$this->aiSummaryPrompt = $value;
|
||||
}
|
||||
|
||||
public function resetAiSummaryPrompt(): void
|
||||
{
|
||||
$default = Settings::default('ai_summary_prompt');
|
||||
Settings::set('ai_summary_prompt', $default);
|
||||
$this->aiSummaryPrompt = $default;
|
||||
$this->aiSummaryPromptVersion++;
|
||||
}
|
||||
|
||||
// ===================== GENERIC DELETE CONFIRM =====================
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Livewire\Client;
|
||||
use App\Models\Category;
|
||||
use App\Models\Subcategory;
|
||||
use App\Services\BookStackClient;
|
||||
use App\Services\SnipeItClient;
|
||||
use App\Services\TicketService;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -40,6 +41,61 @@ class NewTicket extends Component
|
||||
$this->suggestedArticlesLoaded = true;
|
||||
}
|
||||
|
||||
// Same wire:init-deferred pattern as suggestedArticlesLoaded above,
|
||||
// for the Snipe-IT "Twój sprzęt" picker.
|
||||
public bool $snipeitAssetsLoaded = false;
|
||||
|
||||
public ?int $selectedSnipeitAssetId = null;
|
||||
|
||||
public function loadSnipeitAssets(): void
|
||||
{
|
||||
$this->snipeitAssetsLoaded = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty unless the admin turned the picker on AND allow-listed the
|
||||
* currently selected subcategory for it (see
|
||||
* snipeit_client_asset_subcategory_ids) — an empty allow-list means
|
||||
* "no subcategory", not "every subcategory", mirroring how BookStack's
|
||||
* shelf allow-lists work.
|
||||
*
|
||||
* @return array<int, array{id: int, label: string, serial: ?string, manufacturer: ?string, model: ?string, category: ?string, status: ?string, url: string}>
|
||||
*/
|
||||
#[Computed]
|
||||
public function snipeitAssets(): array
|
||||
{
|
||||
if (! $this->snipeitAssetsLoaded || ! Settings::bool('snipeit_client_can_select_asset') || ! $this->subcategoryId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (! in_array($this->subcategoryId, $this->snipeitAllowedSubcategoryIds(), true)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return app(SnipeItClient::class)->assetsForEmail(Auth::user()->email);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int[]
|
||||
*/
|
||||
protected function snipeitAllowedSubcategoryIds(): array
|
||||
{
|
||||
return collect(explode(',', Settings::get('snipeit_client_asset_subcategory_ids', '')))
|
||||
->map(fn ($v) => (int) trim($v))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function selectSnipeitAsset(int $id): void
|
||||
{
|
||||
if (! Settings::bool('snipeit_client_can_select_asset')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->selectedSnipeitAssetId = $this->selectedSnipeitAssetId === $id ? null : $id;
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function categories()
|
||||
{
|
||||
@@ -62,12 +118,14 @@ class NewTicket extends Component
|
||||
{
|
||||
$this->categoryId = $id;
|
||||
$this->subcategoryId = null;
|
||||
$this->selectedSnipeitAssetId = null;
|
||||
$this->step = 2;
|
||||
}
|
||||
|
||||
public function selectSubcategory(int $id): void
|
||||
{
|
||||
$this->subcategoryId = $id;
|
||||
$this->selectedSnipeitAssetId = null;
|
||||
$this->step = 3;
|
||||
}
|
||||
|
||||
@@ -82,8 +140,9 @@ class NewTicket extends Component
|
||||
}
|
||||
|
||||
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? ''));
|
||||
$tagQuery = trim($this->selectedSubcategory?->name ?? '');
|
||||
|
||||
return app(BookStackClient::class)->search($query);
|
||||
return app(BookStackClient::class)->search($query, tagQuery: $tagQuery);
|
||||
}
|
||||
|
||||
public function backToCategory(): void
|
||||
@@ -129,12 +188,18 @@ class NewTicket extends Component
|
||||
|
||||
$user = Auth::user();
|
||||
|
||||
$selectedAsset = $this->selectedSnipeitAssetId
|
||||
? collect($this->snipeitAssets)->firstWhere('id', $this->selectedSnipeitAssetId)
|
||||
: null;
|
||||
|
||||
$ticket = app(TicketService::class)->create([
|
||||
'email' => $user->email,
|
||||
'subcategory_id' => $this->subcategoryId,
|
||||
'subject' => $this->subject,
|
||||
'body' => $this->body,
|
||||
'custom_values' => $this->customValues,
|
||||
'snipeit_asset_id' => $selectedAsset['id'] ?? null,
|
||||
'snipeit_asset_name' => $selectedAsset['label'] ?? null,
|
||||
], $user);
|
||||
|
||||
app(TicketService::class)->attachFiles($ticket, $ticket->messages()->first(), $this->attachments);
|
||||
|
||||
@@ -122,8 +122,9 @@ class TicketShow extends Component
|
||||
|
||||
$subcategory = $this->ticket->subcategory;
|
||||
$query = trim(($subcategory?->category?->name ?? '').' '.($subcategory?->name ?? ''));
|
||||
$tagQuery = trim($subcategory?->name ?? '');
|
||||
|
||||
return app(BookStackClient::class)->search($query);
|
||||
return app(BookStackClient::class)->search($query, tagQuery: $tagQuery);
|
||||
}
|
||||
|
||||
public function updatedAttachments(): void
|
||||
|
||||
@@ -100,8 +100,9 @@ class Landing extends Component
|
||||
}
|
||||
|
||||
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? ''));
|
||||
$tagQuery = trim($this->selectedSubcategory?->name ?? '');
|
||||
|
||||
return app(BookStackClient::class)->search($query);
|
||||
return app(BookStackClient::class)->search($query, tagQuery: $tagQuery);
|
||||
}
|
||||
|
||||
public function backToCategory(): void
|
||||
|
||||
@@ -91,8 +91,9 @@ class NewTicket extends Component
|
||||
}
|
||||
|
||||
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? ''));
|
||||
$tagQuery = trim($this->selectedSubcategory?->name ?? '');
|
||||
|
||||
return app(BookStackClient::class)->search($query);
|
||||
return app(BookStackClient::class)->search($query, tagQuery: $tagQuery);
|
||||
}
|
||||
|
||||
public function backToCategory(): void
|
||||
|
||||
@@ -298,7 +298,13 @@ class Queue extends Component
|
||||
$query->where('priority_key', $this->filterPriority);
|
||||
}
|
||||
if ($this->filterCategory !== 'all') {
|
||||
$query->whereHas('subcategory', fn ($q) => $q->where('category_id', $this->filterCategory));
|
||||
// A ticket carries a category either via its subcategory or,
|
||||
// when routed to a whole category with no subcategory (e.g. an
|
||||
// IMAP mailbox), directly on tickets.category_id.
|
||||
$query->where(function ($q) {
|
||||
$q->whereHas('subcategory', fn ($sq) => $sq->where('category_id', $this->filterCategory))
|
||||
->orWhere('category_id', $this->filterCategory);
|
||||
});
|
||||
}
|
||||
if ($this->filterCustomerId) {
|
||||
$query->where('customer_id', $this->filterCustomerId);
|
||||
@@ -307,7 +313,7 @@ class Queue extends Component
|
||||
$query->search($this->search);
|
||||
}
|
||||
|
||||
$tickets = $query->with(['subcategory.category', 'assignee', 'priority', 'status', 'team'])->get();
|
||||
$tickets = $query->with(['subcategory.category', 'category', 'assignee', 'priority', 'status', 'team'])->get();
|
||||
|
||||
return $this->sortTickets($tickets);
|
||||
}
|
||||
@@ -425,6 +431,20 @@ class Queue extends Component
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects every ticket currently visible under the active filters/queue
|
||||
* (not every ticket in the system) — toggles off if all of them are
|
||||
* already selected, matching the usual "header checkbox" convention.
|
||||
*/
|
||||
public function toggleSelectAll(): void
|
||||
{
|
||||
$visibleIds = $this->filteredTickets->pluck('id')->all();
|
||||
|
||||
$this->selectedIds = empty(array_diff($visibleIds, $this->selectedIds))
|
||||
? array_values(array_diff($this->selectedIds, $visibleIds))
|
||||
: array_values(array_unique(array_merge($this->selectedIds, $visibleIds)));
|
||||
}
|
||||
|
||||
public function mergeSelected(): void
|
||||
{
|
||||
$ids = $this->selectedIdsInScope();
|
||||
|
||||
@@ -605,7 +605,7 @@ class Stats extends Component
|
||||
|
||||
foreach ($tickets as $ticket) {
|
||||
fputcsv($out, [
|
||||
$ticket->number,
|
||||
$ticket->displayNumber(),
|
||||
$ticket->subject,
|
||||
$ticket->statusLabel(),
|
||||
$ticket->priorityLabel(),
|
||||
|
||||
@@ -14,8 +14,11 @@ use App\Models\Ticket;
|
||||
use App\Models\TicketMessage;
|
||||
use App\Models\User;
|
||||
use App\Services\BookStackClient;
|
||||
use App\Services\SnipeItClient;
|
||||
use App\Services\TicketAiSummaryService;
|
||||
use App\Services\TicketService;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\On;
|
||||
@@ -78,6 +81,38 @@ class TicketShow extends Component
|
||||
$this->suggestedArticlesLoaded = true;
|
||||
}
|
||||
|
||||
// Same wire:init-deferred pattern — the AI summary card just displays
|
||||
// whatever the scheduled ai:run-ticket-automation command last computed
|
||||
// (no live AI call from the ticket page), but refreshing the ticket here
|
||||
// picks up a summary the command generated after this page's initial load.
|
||||
public bool $aiSummaryLoaded = false;
|
||||
|
||||
public function loadAiSummary(): void
|
||||
{
|
||||
$this->aiSummaryLoaded = true;
|
||||
$this->ticket->refresh();
|
||||
}
|
||||
|
||||
public ?string $aiSummaryRegenerateError = null;
|
||||
|
||||
// Manual regeneration is an explicit operator action (unlike the
|
||||
// wire:init-deferred load above), so it's fine to block on the AI call
|
||||
// here rather than deferring it — the button's wire:loading state covers
|
||||
// the wait.
|
||||
public function regenerateAiSummary(): void
|
||||
{
|
||||
$this->aiSummaryRegenerateError = null;
|
||||
set_time_limit(0);
|
||||
|
||||
if (! app(TicketAiSummaryService::class)->generateFor($this->ticket)) {
|
||||
$this->aiSummaryRegenerateError = 'Nie udało się wygenerować podsumowania. Sprawdź konfigurację integracji AI.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->ticket->refresh();
|
||||
}
|
||||
|
||||
public function mount(Ticket $ticket): void
|
||||
{
|
||||
abort_unless($ticket->isVisibleToOperator(Auth::user()), 403);
|
||||
@@ -91,6 +126,24 @@ class TicketShow extends Component
|
||||
$this->ticket->resumeTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Livewire lifecycle hook, called for any exception raised while
|
||||
* handling a request for this component — including one thrown while
|
||||
* re-hydrating the typed $ticket property itself (Livewire re-fetches
|
||||
* it by id on every request), which happens before any of this
|
||||
* component's own methods run and so can't be caught locally the way
|
||||
* refreshOrRedirectAway() catches it during an explicit refresh().
|
||||
* Covers a ticket deleted by someone else while an operator still has
|
||||
* it open — sends them back to their queue instead of a hard error.
|
||||
*/
|
||||
public function exception(\Throwable $e, $stopPropagation): void
|
||||
{
|
||||
if ($e instanceof ModelNotFoundException) {
|
||||
$this->redirect(route('operator.queue'), navigate: true);
|
||||
$stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function isWatching(): bool
|
||||
{
|
||||
@@ -209,6 +262,36 @@ class TicketShow extends Component
|
||||
unset($this->publicMessages, $this->internalMessages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-fetches the ticket and, for a non-admin operator, sends them back
|
||||
* to their queue instead of leaving them stuck on a page that can no
|
||||
* longer legitimately show anything — either because the ticket was
|
||||
* deleted (refresh() throws ModelNotFoundException, same as
|
||||
* Model::findOrFail() internally) or because a team/assignee change
|
||||
* (by this operator or anyone else) moved it out of their visible
|
||||
* scope. Returns false when it redirected, so callers can bail out of
|
||||
* whatever they were doing instead of continuing to operate on a
|
||||
* ticket that's about to disappear from under them.
|
||||
*/
|
||||
protected function refreshOrRedirectAway(): bool
|
||||
{
|
||||
try {
|
||||
$this->ticket->refresh();
|
||||
} catch (ModelNotFoundException) {
|
||||
$this->redirect(route('operator.queue'), navigate: true);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->ticket->isVisibleToOperator(Auth::user())) {
|
||||
$this->redirect(route('operator.queue'), navigate: true);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridged from a TicketQueueChanged broadcast (see resources/js/echo.js
|
||||
* and Queue::onQueueChanged()) — lets a status/priority/team/assignee
|
||||
@@ -222,7 +305,7 @@ class TicketShow extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
$this->ticket->refresh();
|
||||
$this->refreshOrRedirectAway();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -232,8 +315,11 @@ class TicketShow extends Component
|
||||
*/
|
||||
public function refreshTicketData(): void
|
||||
{
|
||||
if (! $this->refreshOrRedirectAway()) {
|
||||
return;
|
||||
}
|
||||
|
||||
unset($this->publicMessages, $this->internalMessages);
|
||||
$this->ticket->refresh();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
@@ -266,24 +352,121 @@ class TicketShow extends Component
|
||||
|
||||
$subcategory = $this->ticket->subcategory;
|
||||
$query = trim(($subcategory?->category?->name ?? '').' '.($subcategory?->name ?? ''));
|
||||
$tagQuery = trim($subcategory?->name ?? '');
|
||||
|
||||
return app(BookStackClient::class)->search($query, 5, BookStackClient::CONTEXT_TICKET_VIEW);
|
||||
return app(BookStackClient::class)->search($query, 5, BookStackClient::CONTEXT_TICKET_VIEW, $tagQuery);
|
||||
}
|
||||
|
||||
// -------- Snipe-IT --------
|
||||
|
||||
// Same wire:init-deferred pattern as suggestedArticlesLoaded above — the
|
||||
// requester's asset list is a Snipe-IT HTTP call, deferred so it never
|
||||
// delays the ticket page's first paint.
|
||||
public bool $snipeitAssetsLoaded = false;
|
||||
|
||||
public function loadSnipeitAssets(): void
|
||||
{
|
||||
$this->snipeitAssetsLoaded = true;
|
||||
}
|
||||
|
||||
public string $snipeitSearchQuery = '';
|
||||
|
||||
public array $snipeitSearchResults = [];
|
||||
|
||||
/**
|
||||
* @return array<int, array{id: int, label: string, serial: ?string, manufacturer: ?string, model: ?string, category: ?string, status: ?string, url: string}>
|
||||
*/
|
||||
#[Computed]
|
||||
public function snipeitRequesterAssets(): array
|
||||
{
|
||||
if (! $this->snipeitAssetsLoaded || ! Settings::bool('snipeit_operator_view_requester_assets') || ! $this->ticket->email) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return app(SnipeItClient::class)->assetsForEmail($this->ticket->email);
|
||||
}
|
||||
|
||||
/**
|
||||
* A non-admin operator can only reassign a ticket to one of their own
|
||||
* teams (mirrors the visibility scoping in Operator\Queue).
|
||||
* Live detail for the ticket's linked asset (if any) — always fetched
|
||||
* fresh so a status/assignment change made directly in Snipe-IT shows up
|
||||
* without an operator having to re-link anything. Not gated behind
|
||||
* snipeit_operator_view_requester_assets/snipeit_operator_search_inventory:
|
||||
* showing what's already on the ticket isn't the same permission as
|
||||
* browsing the rest of Snipe-IT. Falls back to the ticket's own cached
|
||||
* snipeit_asset_name in the view when this comes back null (unreachable
|
||||
* instance or the asset was deleted there).
|
||||
*
|
||||
* @return array{id: int, label: string, serial: ?string, manufacturer: ?string, model: ?string, category: ?string, status: ?string, assignedTo: ?string, url: string}|null
|
||||
*/
|
||||
#[Computed]
|
||||
public function snipeitLinkedAsset(): ?array
|
||||
{
|
||||
return $this->ticket->snipeit_asset_id
|
||||
? app(SnipeItClient::class)->asset($this->ticket->snipeit_asset_id)
|
||||
: null;
|
||||
}
|
||||
|
||||
public function searchSnipeitAssets(): void
|
||||
{
|
||||
if (! Settings::bool('snipeit_operator_search_inventory')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->snipeitSearchResults = app(SnipeItClient::class)->searchAssets($this->snipeitSearchQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* $id must come from whichever list it was clicked from — the requester's
|
||||
* assets (gated on snipeit_operator_view_requester_assets) or an
|
||||
* inventory search result (gated on snipeit_operator_search_inventory) —
|
||||
* rather than a direct Snipe-IT lookup by id, so an operator can't link
|
||||
* an arbitrary asset via a source that's admin-disabled for them.
|
||||
*/
|
||||
public function linkSnipeitAsset(int $id): void
|
||||
{
|
||||
$fromRequesterAssets = Settings::bool('snipeit_operator_view_requester_assets')
|
||||
? collect($this->snipeitRequesterAssets)->firstWhere('id', $id)
|
||||
: null;
|
||||
|
||||
$fromSearchResults = Settings::bool('snipeit_operator_search_inventory')
|
||||
? collect($this->snipeitSearchResults)->firstWhere('id', $id)
|
||||
: null;
|
||||
|
||||
$asset = $fromRequesterAssets ?? $fromSearchResults;
|
||||
|
||||
if (! $asset) {
|
||||
return;
|
||||
}
|
||||
|
||||
app(TicketService::class)->setSnipeitAsset($this->ticket, ['id' => $asset['id'], 'label' => $asset['label']]);
|
||||
$this->ticket->refresh();
|
||||
unset($this->snipeitLinkedAsset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlike linkSnipeitAsset(), not gated behind either visibility setting
|
||||
* — clearing a link a ticket already has is a correction, not a new way
|
||||
* to browse Snipe-IT, so it stays available even if an admin later turns
|
||||
* both of those off.
|
||||
*/
|
||||
public function unlinkSnipeitAsset(): void
|
||||
{
|
||||
app(TicketService::class)->setSnipeitAsset($this->ticket, null);
|
||||
$this->ticket->refresh();
|
||||
unset($this->snipeitLinkedAsset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every team, regardless of the viewing operator's own membership —
|
||||
* unlike ticket *visibility* (Operator\Queue, scoped to an operator's
|
||||
* own teams), reassignment isn't restricted: an operator working a
|
||||
* ticket needs to be able to route it to whichever team actually owns
|
||||
* the problem, even one they don't personally belong to.
|
||||
*/
|
||||
#[Computed]
|
||||
public function teams()
|
||||
{
|
||||
$query = Team::query();
|
||||
|
||||
if (! Auth::user()->isAdmin()) {
|
||||
$query->whereHas('members', fn ($q) => $q->where('users.id', Auth::id()));
|
||||
}
|
||||
|
||||
return $query->get();
|
||||
return Team::query()->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
@@ -357,7 +540,12 @@ class TicketShow extends Component
|
||||
public function setTeam(string $id): void
|
||||
{
|
||||
app(TicketService::class)->setTeam($this->ticket, $id ? Team::query()->find($id) : null);
|
||||
$this->ticket->refresh();
|
||||
|
||||
// Reassigning to a team the operator doesn't belong to can move the
|
||||
// ticket out of their own visible scope (see Ticket::isVisibleToOperator())
|
||||
// — send them back to their queue rather than leaving them on a
|
||||
// ticket they can no longer legitimately keep viewing.
|
||||
$this->refreshOrRedirectAway();
|
||||
}
|
||||
|
||||
// -------- reporter --------
|
||||
|
||||
@@ -11,6 +11,6 @@ class Category extends Model
|
||||
{
|
||||
public function subcategories(): HasMany
|
||||
{
|
||||
return $this->hasMany(Subcategory::class);
|
||||
return $this->hasMany(Subcategory::class)->orderBy('sort_order');
|
||||
}
|
||||
}
|
||||
|
||||
65
src/app/Models/ImapMailbox.php
Normal file
65
src/app/Models/ImapMailbox.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* One inbound mailbox polled by `emails:fetch-imap` — an admin can configure
|
||||
* several (e.g. zgloszenia-it@ vs zgloszenia-delegacje@), each landing new
|
||||
* tickets in its own default subcategory. Unlike LDAP/SMTP/BookStack, this is
|
||||
* a list of N configs rather than a Settings singleton, so it's a real model
|
||||
* rather than key/value rows.
|
||||
*/
|
||||
#[Fillable([
|
||||
'name', 'enabled', 'host', 'port', 'encryption', 'validate_cert',
|
||||
'username', 'password', 'folder', 'processed_folder', 'rejected_folder',
|
||||
'default_subcategory_id', 'default_category_id', 'blocklist_senders', 'last_checked_at', 'last_error',
|
||||
])]
|
||||
class ImapMailbox extends Model
|
||||
{
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => 'boolean',
|
||||
'validate_cert' => 'boolean',
|
||||
'password' => 'encrypted',
|
||||
'last_checked_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function defaultSubcategory(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Subcategory::class, 'default_subcategory_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Only meaningful when default_subcategory_id is null — a mailbox is
|
||||
* routed to either a specific subcategory or a whole category, never
|
||||
* both (enforced by the admin form's single combined selector).
|
||||
*/
|
||||
public function defaultCategory(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class, 'default_category_id');
|
||||
}
|
||||
|
||||
public function blocklistedSenders(): array
|
||||
{
|
||||
return array_filter(array_map('trim', explode(',', (string) $this->blocklist_senders)));
|
||||
}
|
||||
|
||||
public function targetLabel(): string
|
||||
{
|
||||
if ($this->defaultSubcategory) {
|
||||
return $this->defaultSubcategory->category->name.' / '.$this->defaultSubcategory->name;
|
||||
}
|
||||
|
||||
if ($this->defaultCategory) {
|
||||
return 'Cała kategoria: '.$this->defaultCategory->name;
|
||||
}
|
||||
|
||||
return '—';
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable(['category_id', 'name', 'description', 'default_priority_key'])]
|
||||
#[Fillable(['category_id', 'name', 'description', 'default_priority_key', 'sort_order'])]
|
||||
class Subcategory extends Model
|
||||
{
|
||||
public function category(): BelongsTo
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -12,13 +13,29 @@ use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
#[Fillable([
|
||||
'number', 'customer_id', 'email', 'name', 'subcategory_id', 'subject', 'body',
|
||||
'status_key', 'priority_key', 'team_id', 'assignee_id', 'custom_fields', 'api_client_id',
|
||||
'number', 'checksum', 'customer_id', 'email', 'name', 'subcategory_id', 'category_id', 'subject', 'body',
|
||||
'status_key', 'priority_key', 'team_id', 'assignee_id', 'custom_fields', 'api_client_id', 'source',
|
||||
'sla_notified_at', 'last_customer_activity_at', 'time_spent_seconds', 'timer_started_at',
|
||||
'created_at', 'updated_at', 'csat_rating', 'csat_comment', 'csat_rated_at',
|
||||
'ai_triaged_at', 'ai_summary', 'ai_suggested_action', 'ai_summary_generated_at',
|
||||
'snipeit_asset_id', 'snipeit_asset_name',
|
||||
])]
|
||||
class Ticket extends Model
|
||||
{
|
||||
/**
|
||||
* Every ticket gets a stable, unique checksum the moment its id is known
|
||||
* — it never needs to change afterward, and having it always populated
|
||||
* (regardless of whether obfuscation is currently on) means toggling the
|
||||
* "Ukryj kolejność zgłoszeń" setting doesn't need a backfill pass.
|
||||
*/
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::created(function (Ticket $ticket) {
|
||||
$ticket->checksum = static::generateUniqueChecksum($ticket->id);
|
||||
$ticket->saveQuietly();
|
||||
});
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
@@ -29,6 +46,9 @@ class Ticket extends Model
|
||||
'timer_started_at' => 'datetime',
|
||||
'csat_rating' => 'integer',
|
||||
'csat_rated_at' => 'datetime',
|
||||
'ai_triaged_at' => 'datetime',
|
||||
'ai_summary_generated_at' => 'datetime',
|
||||
'snipeit_asset_id' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -57,6 +77,16 @@ class Ticket extends Model
|
||||
return $this->belongsTo(Subcategory::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only ever set when there's no subcategory to derive a category from
|
||||
* (subcategory_id already implies one via Subcategory::category()) — see
|
||||
* categoryLabel() and the migration that introduced this column.
|
||||
*/
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class);
|
||||
}
|
||||
|
||||
public function watchers(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'ticket_watchers');
|
||||
@@ -114,9 +144,92 @@ class Ticket extends Model
|
||||
return (string) (($max ?: 1000) + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* The number shown to users: the admin-configured prefix in front of
|
||||
* formattedNumber(). Kept separate from formattedNumber() because the
|
||||
* `{numer}` placeholder in admin-editable e-mail templates historically
|
||||
* carries no prefix (templates hardcode their own, e.g. "Zgłoszenie
|
||||
* #{numer}") — changing that would double up or mismatch a
|
||||
* non-default prefix in every existing template.
|
||||
*/
|
||||
public function displayNumber(): string
|
||||
{
|
||||
return Settings::get('ticket_number_prefix', '#').$this->formattedNumber();
|
||||
}
|
||||
|
||||
/**
|
||||
* The ticket number without any prefix: either the raw sequential
|
||||
* `number` (zero-padded to the admin-configured minimum length), or —
|
||||
* when obfuscation is enabled — this ticket's stored checksum. The
|
||||
* checksum is a fixed-width HMAC output, so minimum-length padding
|
||||
* doesn't apply to it (padding a checksum has no real meaning — it's
|
||||
* only meant to make a short *sequential* number look consistent).
|
||||
* This is also the value getRouteKey()/resolveRouteBinding() use, so
|
||||
* the number shown on the page and the one in the URL always match.
|
||||
* The underlying `number` column itself is left alone, since it still
|
||||
* backs the numeric sort in Operator/Queue.php.
|
||||
*/
|
||||
public function formattedNumber(): string
|
||||
{
|
||||
if (Settings::bool('ticket_number_obfuscate')) {
|
||||
return $this->checksum ?? $this->number;
|
||||
}
|
||||
|
||||
$minLength = max(1, (int) Settings::get('ticket_number_min_length', '4'));
|
||||
|
||||
return str_pad($this->number, $minLength, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* The value used when generating a URL for this ticket (route($name,
|
||||
* $ticket)) — mirrors formattedNumber() minus the prefix, so a link
|
||||
* never shows the raw sequential number while the page itself shows an
|
||||
* obfuscated one (or vice versa).
|
||||
*/
|
||||
public function getRouteKey()
|
||||
{
|
||||
return Settings::bool('ticket_number_obfuscate') ? ($this->checksum ?? $this->number) : $this->number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inbound counterpart to getRouteKey() — resolves a URL segment back to
|
||||
* a ticket via whichever column matches the current numbering mode.
|
||||
*/
|
||||
public function resolveRouteBinding($value, $field = null)
|
||||
{
|
||||
if ($field) {
|
||||
return $this->where($field, $value)->first();
|
||||
}
|
||||
|
||||
$column = Settings::bool('ticket_number_obfuscate') ? 'checksum' : 'number';
|
||||
|
||||
return $this->where($column, $value)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* A short, HMAC-derived checksum for this ticket, carrying no relation
|
||||
* to creation order — salted with the app key so it can't be predicted
|
||||
* or reversed back into id/creation order without server-side secrets.
|
||||
* Collisions are rare but not astronomically so at 6 digits, so this
|
||||
* walks a nonce forward until it lands on a value no other ticket
|
||||
* already has (enforced for real by the column's unique constraint).
|
||||
*/
|
||||
public static function generateUniqueChecksum(int $id): string
|
||||
{
|
||||
$nonce = 0;
|
||||
|
||||
do {
|
||||
$hash = hash_hmac('sha256', $id.'|'.$nonce, (string) config('app.key'));
|
||||
$candidate = (string) (hexdec(substr($hash, 0, 8)) % 900000 + 100000);
|
||||
$nonce++;
|
||||
} while (static::query()->where('checksum', $candidate)->exists());
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
public function categoryLabel(): string
|
||||
{
|
||||
return $this->subcategory?->label() ?? '';
|
||||
return $this->subcategory?->label() ?? $this->category?->name ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,6 +297,7 @@ class Ticket extends Model
|
||||
}
|
||||
|
||||
$q->orWhere('number', 'like', $like)
|
||||
->orWhere('checksum', 'like', $like)
|
||||
->orWhere('name', 'like', $like)
|
||||
->orWhere('email', 'like', $like)
|
||||
->orWhereIn('id', $messageTicketIds);
|
||||
|
||||
@@ -9,7 +9,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOneThrough;
|
||||
|
||||
#[Fillable(['ticket_id', 'author_name', 'internal', 'body', 'edited', 'api_client_id', 'created_at', 'updated_at'])]
|
||||
#[Fillable(['ticket_id', 'author_name', 'internal', 'body', 'edited', 'api_client_id', 'source', 'created_at', 'updated_at'])]
|
||||
class TicketMessage extends Model
|
||||
{
|
||||
protected function casts(): array
|
||||
|
||||
@@ -62,7 +62,7 @@ class TicketNotification extends Notification
|
||||
'ticket_id' => $this->ticket->id,
|
||||
'number' => $this->ticket->number,
|
||||
'subject' => $this->ticket->subject,
|
||||
'message' => 'Zgłoszenie #'.$this->ticket->number.' — '.$this->ticket->subject,
|
||||
'message' => 'Zgłoszenie '.$this->ticket->displayNumber().' — '.$this->ticket->subject,
|
||||
'url' => $this->ticketUrl(),
|
||||
];
|
||||
}
|
||||
@@ -76,7 +76,7 @@ class TicketNotification extends Notification
|
||||
$firstName = trim(explode(' ', $this->ticket->name)[0] ?? $this->ticket->name);
|
||||
|
||||
$rendered = $template?->render([
|
||||
'numer' => $this->ticket->number,
|
||||
'numer' => $this->ticket->formattedNumber(),
|
||||
'imie' => $firstName,
|
||||
'temat' => $this->ticket->subject,
|
||||
'status' => $this->ticket->statusLabel(),
|
||||
@@ -87,7 +87,7 @@ class TicketNotification extends Notification
|
||||
'link' => $this->ticketUrl(),
|
||||
'ocena' => route('client.ticket', $this->ticket).'#csat',
|
||||
]) ?? [
|
||||
'subject' => 'Zgłoszenie #'.$this->ticket->number,
|
||||
'subject' => 'Zgłoszenie '.$this->ticket->displayNumber(),
|
||||
'body' => $this->ticket->subject,
|
||||
];
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Events\NotificationCreated;
|
||||
use App\Events\TicketMessagePosted;
|
||||
use App\Jobs\GenerateTicketAiSummaryJob;
|
||||
use App\Models\ApiClient;
|
||||
use App\Models\User;
|
||||
use App\Notifications\TicketNotification;
|
||||
@@ -40,6 +42,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
$this->applyTimezoneSettingsOverride();
|
||||
$this->configureApiRateLimiting();
|
||||
$this->broadcastBellNotifications();
|
||||
$this->regenerateAiSummaryOnNewMessage();
|
||||
|
||||
// 'user' backs the polymorphic notifiable_type column on the
|
||||
// database-notifications table (in-app notification bell).
|
||||
@@ -68,6 +71,25 @@ class AppServiceProvider extends ServiceProvider
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-optional: when enabled, every reply/note/API message re-runs the
|
||||
* AI summary for its ticket right away instead of waiting for the next
|
||||
* ai:run-ticket-automation sweep (up to schedule_ai_automation_minutes
|
||||
* stale). dispatchAfterResponse() runs in-process after the triggering
|
||||
* request finishes rather than going through the queue table — see
|
||||
* GenerateTicketAiSummaryJob's docblock for why.
|
||||
*/
|
||||
protected function regenerateAiSummaryOnNewMessage(): void
|
||||
{
|
||||
Event::listen(TicketMessagePosted::class, function (TicketMessagePosted $event) {
|
||||
if (! Settings::bool('ai_summary_enabled') || ! Settings::bool('ai_summary_regenerate_on_message')) {
|
||||
return;
|
||||
}
|
||||
|
||||
GenerateTicketAiSummaryJob::dispatchAfterResponse($event->ticketId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* API keys get a generous per-key budget; unauthenticated requests (which
|
||||
* only ever hit the guard before rejecting with 401) get a much smaller
|
||||
@@ -85,13 +107,21 @@ class AppServiceProvider extends ServiceProvider
|
||||
}
|
||||
|
||||
/**
|
||||
* Avoid touching the DB during artisan commands that run before the
|
||||
* `settings` table exists (e.g. `migrate` itself), or before it can be
|
||||
* queried at all — shared by every settings-driven config override below.
|
||||
* Avoid touching the DB during the specific artisan commands that run
|
||||
* before the `settings` table exists or could be mid-schema-change (the
|
||||
* migrate family) — shared by every settings-driven config override
|
||||
* below. Deliberately scoped to just those commands rather than "any
|
||||
* console command": scheduled commands (`schedule:run` → e.g.
|
||||
* `emails:fetch-imap`, `tickets:check-sla-breaches`) also run in the
|
||||
* console and need the real SMTP/LDAP/timezone overrides exactly like a
|
||||
* web request does, or their notifications/lookups silently fall back
|
||||
* to whatever's in `.env` (this was a real bug: scheduled-command
|
||||
* notifications were always going out via the `.env` `log` mailer
|
||||
* instead of the configured SMTP server).
|
||||
*/
|
||||
protected function settingsTableUsable(): bool
|
||||
{
|
||||
if ($this->app->runningInConsole() && ! $this->app->runningUnitTests()) {
|
||||
if ($this->app->runningConsoleCommand('migrate', 'migrate:fresh', 'migrate:refresh', 'migrate:reset', 'migrate:rollback', 'migrate:install')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
95
src/app/Services/AiClient.php
Normal file
95
src/app/Services/AiClient.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* Generic OpenAI-compatible chat-completions client — works against Groq,
|
||||
* OpenAI itself, or a self-hosted Ollama instance's OpenAI-compat endpoint,
|
||||
* whichever the admin points ai_base_url at. Not BookStack-specific; the
|
||||
* BookStack content tagger is just the first consumer.
|
||||
*/
|
||||
class AiClient
|
||||
{
|
||||
public function enabled(): bool
|
||||
{
|
||||
// Deliberately no api-key requirement here — a self-hosted Ollama
|
||||
// instance typically has no auth at all.
|
||||
return Settings::bool('ai_enabled')
|
||||
&& Settings::get('ai_base_url')
|
||||
&& Settings::get('ai_model');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{role: string, content: string}> $messages
|
||||
* @return string|null the assistant message content, or null on any failure
|
||||
*/
|
||||
public function chat(array $messages, array $options = []): ?string
|
||||
{
|
||||
if (! $this->enabled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->client()->post('/chat/completions', [
|
||||
'model' => Settings::get('ai_model'),
|
||||
'messages' => $messages,
|
||||
...$options,
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $response->json('choices.0.message.content');
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests unsaved admin-form values directly, rather than whatever's
|
||||
* currently stored — mirrors BookStackClient::testConnection().
|
||||
*
|
||||
* @return array{ok: bool, message: ?string}
|
||||
*/
|
||||
public function testConnection(string $baseUrl, string $apiKey, string $model, bool $verifySsl = true): array
|
||||
{
|
||||
try {
|
||||
$http = Http::withOptions(['verify' => $verifySsl])
|
||||
->timeout(10)
|
||||
->baseUrl(rtrim($baseUrl, '/'));
|
||||
|
||||
if ($apiKey !== '') {
|
||||
$http = $http->withToken($apiKey);
|
||||
}
|
||||
|
||||
$response = $http->post('/chat/completions', [
|
||||
'model' => $model,
|
||||
'messages' => [['role' => 'user', 'content' => 'ping']],
|
||||
'max_tokens' => 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()
|
||||
{
|
||||
$http = Http::withOptions(['verify' => Settings::bool('ai_verify_ssl')])
|
||||
->timeout(60)
|
||||
->baseUrl(rtrim(Settings::get('ai_base_url'), '/'));
|
||||
|
||||
$apiKey = Settings::get('ai_api_key');
|
||||
|
||||
return $apiKey ? $http->withToken($apiKey) : $http;
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,31 @@ class BookStackClient
|
||||
self::CONTEXT_TICKET_VIEW => 'bookstack_allowed_shelf_ids_ticket_view',
|
||||
];
|
||||
|
||||
/**
|
||||
* Content types selectable via the admin's "Przeszukuj" checkboxes.
|
||||
* Deliberately excludes 'bookshelf' — shelves are only ever a filter
|
||||
* (dozwolone półki), never a suggestion result in their own right.
|
||||
*/
|
||||
public const SEARCH_TYPES = ['book', 'page', 'chapter'];
|
||||
|
||||
/**
|
||||
* How the query text is matched, via the admin's "Szukaj po" option —
|
||||
* 'name' restricts to the title ({in_name:...}), 'tags' matches a tag
|
||||
* whose name equals the query (expected to hold the helpdesk
|
||||
* category/subcategory name, e.g. a "Drukarki" tag on the relevant
|
||||
* BookStack pages), 'both' runs both and merges the results (BookStack's
|
||||
* query syntax ANDs filters together, so there's no single-query way to
|
||||
* express "name OR tag").
|
||||
*/
|
||||
public const SEARCH_BY_OPTIONS = ['name', 'tags', 'both'];
|
||||
|
||||
/**
|
||||
* Content types the bulk-tagging command operates over — same set as
|
||||
* SEARCH_TYPES (book/page/chapter, no bookshelf), named separately since
|
||||
* the two consts serve different features that happen to share a domain.
|
||||
*/
|
||||
public const CONTENT_TYPES = self::SEARCH_TYPES;
|
||||
|
||||
public function enabled(): bool
|
||||
{
|
||||
return Settings::bool('bookstack_enabled')
|
||||
@@ -39,44 +64,56 @@ class BookStackClient
|
||||
* 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.
|
||||
* admin-configured bookstack_search_types (subset of SEARCH_TYPES, via
|
||||
* BookStack's `{type:a|b}` syntax) and bookstack_search_by ('name'|
|
||||
* 'tags'|'both', via `{in_name:...}`/`[...]`) settings. 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.
|
||||
*
|
||||
* $tagQuery is the text matched by the 'tags' variant, separate from
|
||||
* $query (matched by the 'name' variant) — callers pass the bare
|
||||
* subcategory name here (what bookstack:tag-content actually writes as
|
||||
* a tag), while $query stays the fuller "Category Subcategory" text
|
||||
* that's more useful for a plain title/body search. Defaults to $query
|
||||
* so existing call sites that don't pass it keep working.
|
||||
*
|
||||
* @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
|
||||
public function search(string $query, int $limit = 5, string $context = self::CONTEXT_CREATION, ?string $tagQuery = null): array
|
||||
{
|
||||
$query = trim($query);
|
||||
$tagQuery = trim($tagQuery ?? $query);
|
||||
$allowedShelfIds = $this->allowedShelfIds($context);
|
||||
|
||||
if (! $this->enabled() || $query === '' || ! $allowedShelfIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$typeFilter = Settings::get('bookstack_search_types', 'both');
|
||||
$searchBy = $this->searchBy();
|
||||
$bookstackQueries = $searchBy === 'both'
|
||||
? [$this->buildQuery($query, 'name'), $this->buildQuery($tagQuery, 'tags')]
|
||||
: [$this->buildQuery($searchBy === 'tags' ? $tagQuery : $query, $searchBy)];
|
||||
|
||||
if (in_array($typeFilter, ['page', 'book'], true)) {
|
||||
$query .= " {type:{$typeFilter}}";
|
||||
}
|
||||
$cacheKey = 'bookstack:search:'.md5(implode('||', $bookstackQueries).'|'.$limit.'|'.implode(',', $allowedShelfIds));
|
||||
|
||||
$cacheKey = 'bookstack:search:'.md5($query.'|'.$limit.'|'.implode(',', $allowedShelfIds));
|
||||
|
||||
return Cache::remember($cacheKey, now()->addMinutes(10), function () use ($query, $limit, $allowedShelfIds) {
|
||||
return Cache::remember($cacheKey, now()->addMinutes(10), function () use ($bookstackQueries, $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', []))
|
||||
$items = collect();
|
||||
|
||||
foreach ($bookstackQueries as $bookstackQuery) {
|
||||
$response = $this->client()->get('/api/search', ['query' => $bookstackQuery, 'count' => $limit]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$items = $items->concat($response->json('data', []));
|
||||
}
|
||||
}
|
||||
|
||||
return $items
|
||||
->filter(function (array $item) use ($allowedShelfIds, $allowedBookIds) {
|
||||
$type = $item['type'] ?? null;
|
||||
|
||||
@@ -103,6 +140,8 @@ class BookStackClient
|
||||
];
|
||||
})
|
||||
->filter(fn (array $item) => $item['name'] !== '')
|
||||
->unique(fn (array $item) => $item['url'] ?? $item['name'])
|
||||
->take($limit)
|
||||
->values()
|
||||
->all();
|
||||
} catch (\Throwable) {
|
||||
@@ -111,6 +150,68 @@ class BookStackClient
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds one BookStack search-syntax query string for $query, restricted
|
||||
* to $by ('name' -> `{in_name:...}`, 'tags' -> `[...]`) and to the
|
||||
* configured content types (`{type:a|b}`, omitted if all types are
|
||||
* allowed since that's equivalent to no filter).
|
||||
*/
|
||||
protected function buildQuery(string $query, string $by): string
|
||||
{
|
||||
$parts = [$by === 'tags' ? "[{$query}]" : "{in_name:{$query}}"];
|
||||
|
||||
$types = $this->searchTypes();
|
||||
|
||||
if (array_diff(self::SEARCH_TYPES, $types)) {
|
||||
$parts[] = '{type:'.implode('|', $types).'}';
|
||||
}
|
||||
|
||||
return implode(' ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[] non-empty subset of SEARCH_TYPES
|
||||
*/
|
||||
protected function searchTypes(): array
|
||||
{
|
||||
return self::normalizeSearchTypes(Settings::get('bookstack_search_types', ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes bookstack_search_types storage into a non-empty subset of
|
||||
* SEARCH_TYPES — shared with Admin\Panel so the checkbox UI and the
|
||||
* actual search agree on the same format. Also understands the legacy
|
||||
* single-value 'both'/'page'/'book' storage from before the setting
|
||||
* became a checkbox list, so existing configuration keeps working.
|
||||
*
|
||||
* @param string[]|string $raw
|
||||
* @return string[]
|
||||
*/
|
||||
public static function normalizeSearchTypes(array|string $raw): array
|
||||
{
|
||||
$legacy = ['both' => self::SEARCH_TYPES, 'page' => ['page'], 'book' => ['book']];
|
||||
|
||||
if (is_string($raw) && isset($legacy[$raw])) {
|
||||
return $legacy[$raw];
|
||||
}
|
||||
|
||||
$types = collect(is_array($raw) ? $raw : explode(',', $raw))
|
||||
->map(fn ($v) => trim((string) $v))
|
||||
->filter(fn ($v) => in_array($v, self::SEARCH_TYPES, true))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return $types ?: self::SEARCH_TYPES;
|
||||
}
|
||||
|
||||
protected function searchBy(): string
|
||||
{
|
||||
$raw = Settings::get('bookstack_search_by', 'both');
|
||||
|
||||
return in_array($raw, self::SEARCH_BY_OPTIONS, true) ? $raw : 'both';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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/
|
||||
@@ -155,6 +256,103 @@ class BookStackClient
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Every book/chapter/page of $type across the whole BookStack instance —
|
||||
* NOT filtered by the allowed-shelf settings, unlike search(). Those only
|
||||
* gate which suggestions are ever shown to a client/operator; the bulk
|
||||
* tagger is meant to cover every piece of content regardless. Paginates
|
||||
* through BookStack's count/offset list endpoints (count capped at 500,
|
||||
* the API's own per-page maximum). Uncached — this is a one-shot batch
|
||||
* read, not a repeated request-path lookup.
|
||||
*
|
||||
* @return array<int, array{id: int, name: string}>
|
||||
*/
|
||||
public function listAll(string $type): array
|
||||
{
|
||||
if (! $this->enabled() || ! in_array($type, self::CONTENT_TYPES, true)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$items = [];
|
||||
$offset = 0;
|
||||
|
||||
try {
|
||||
do {
|
||||
$response = $this->client()->get("/api/{$type}s", ['count' => 500, 'offset' => $offset]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
break;
|
||||
}
|
||||
|
||||
$page = $response->json('data', []);
|
||||
$items = [...$items, ...$page];
|
||||
$offset += 500;
|
||||
$total = $response->json('total', 0);
|
||||
} while (count($page) > 0 && count($items) < $total);
|
||||
} catch (\Throwable) {
|
||||
return $items;
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full detail for a single book/chapter/page — its current tags (needed
|
||||
* to merge rather than clobber when the tagger writes new ones) and the
|
||||
* text used to classify it (a page's markdown source, or a book/
|
||||
* chapter's description). Null if $type is invalid or the item can't be
|
||||
* fetched.
|
||||
*
|
||||
* @return array{id: int, name: string, tags: array<int, array{name: string, value: string}>, content: string}|null
|
||||
*/
|
||||
public function detail(string $type, int $id): ?array
|
||||
{
|
||||
if (! $this->enabled() || ! in_array($type, self::CONTENT_TYPES, true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->client()->get("/api/{$type}s/{$id}");
|
||||
|
||||
if (! $response->successful()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
return [
|
||||
'id' => $data['id'],
|
||||
'name' => $data['name'] ?? '',
|
||||
'tags' => $data['tags'] ?? [],
|
||||
'content' => $data['markdown'] ?? $data['description'] ?? '',
|
||||
];
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrites just the tags field on a book/chapter/page — BookStack
|
||||
* treats every field on its update endpoints as optional, so this never
|
||||
* touches the item's name/content/other attributes. Callers are
|
||||
* responsible for merging in any tags they want to keep (this replaces
|
||||
* the whole array, it doesn't append).
|
||||
*
|
||||
* @param array<int, array{name: string, value: string}> $tags
|
||||
*/
|
||||
public function updateTags(string $type, int $id, array $tags): bool
|
||||
{
|
||||
if (! $this->enabled() || ! in_array($type, self::CONTENT_TYPES, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->client()->put("/api/{$type}s/{$id}", ['tags' => $tags])->successful();
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int[]
|
||||
*/
|
||||
|
||||
260
src/app/Services/BookStackContentTagger.php
Normal file
260
src/app/Services/BookStackContentTagger.php
Normal file
@@ -0,0 +1,260 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Subcategory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Bulk-assigns BookStack tags to every book/chapter/page, using the
|
||||
* configured AiClient to classify each item's title+content against the
|
||||
* helpdesk's current subcategory vocabulary — so BookStackClient::search()'s
|
||||
* 'tags'/'both' mode has something to actually match against. Idempotent by
|
||||
* default: an item already carrying a tag matching a current subcategory
|
||||
* name is skipped, so re-running after adding a handful of new pages is
|
||||
* cheap; $force re-classifies everything.
|
||||
*/
|
||||
class BookStackContentTagger
|
||||
{
|
||||
protected const BATCH_SIZE = 20;
|
||||
|
||||
protected const CONTENT_EXCERPT_CHARS = 2000;
|
||||
|
||||
public function __construct(
|
||||
protected BookStackClient $bookstack,
|
||||
protected AiClient $ai,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{scanned: int, tagged: int, skipped: int, failed_batches: int}
|
||||
*/
|
||||
public function run(bool $dryRun = false, bool $force = false, ?int $limit = null): array
|
||||
{
|
||||
$totals = ['scanned' => 0, 'tagged' => 0, 'skipped' => 0, 'failed_batches' => 0];
|
||||
|
||||
if (! $this->bookstack->enabled() || ! $this->ai->enabled()) {
|
||||
return $totals;
|
||||
}
|
||||
|
||||
$vocabulary = $this->subcategoryVocabulary();
|
||||
|
||||
if (! $vocabulary) {
|
||||
return $totals;
|
||||
}
|
||||
|
||||
foreach (BookStackClient::CONTENT_TYPES as $type) {
|
||||
foreach ($this->itemsToProcess($type, $vocabulary, $force, $limit, $totals) as $batch) {
|
||||
$this->processBatch($type, $batch, $vocabulary, $dryRun, $totals);
|
||||
|
||||
if ($limit !== null && $totals['scanned'] >= $limit) {
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $totals;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[] current subcategory names, deduped case-insensitively
|
||||
*/
|
||||
protected function subcategoryVocabulary(): array
|
||||
{
|
||||
return Subcategory::query()
|
||||
->pluck('name')
|
||||
->filter()
|
||||
->unique(fn (string $name) => Str::lower($name))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Yields item detail records (id, name, tags, content) in chunks of
|
||||
* BATCH_SIZE, skipping already-tagged items unless $force. Detail is
|
||||
* fetched per item since BookStack's list endpoints don't include
|
||||
* tags/content — acceptable here since this is an offline batch job, not
|
||||
* a request-path call.
|
||||
*
|
||||
* @param string[] $vocabulary
|
||||
* @param array{scanned: int, tagged: int, skipped: int, failed_batches: int} $totals
|
||||
* @return \Generator<int, array<int, array{id: int, name: string, tags: array, content: string}>>
|
||||
*/
|
||||
protected function itemsToProcess(string $type, array $vocabulary, bool $force, ?int $limit, array &$totals): \Generator
|
||||
{
|
||||
$batch = [];
|
||||
|
||||
foreach ($this->bookstack->listAll($type) as $summary) {
|
||||
if ($limit !== null && $totals['scanned'] >= $limit) {
|
||||
return;
|
||||
}
|
||||
|
||||
$detail = $this->bookstack->detail($type, $summary['id']);
|
||||
|
||||
if (! $detail) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$totals['scanned']++;
|
||||
|
||||
if (! $force && $this->alreadyTagged($detail['tags'], $vocabulary)) {
|
||||
$totals['skipped']++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$batch[] = $detail;
|
||||
|
||||
if (count($batch) >= self::BATCH_SIZE) {
|
||||
yield $batch;
|
||||
$batch = [];
|
||||
}
|
||||
}
|
||||
|
||||
if ($batch) {
|
||||
yield $batch;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{name: string, value: string}> $tags
|
||||
* @param string[] $vocabulary
|
||||
*/
|
||||
protected function alreadyTagged(array $tags, array $vocabulary): bool
|
||||
{
|
||||
$existing = collect($tags)->map(fn (array $t) => Str::lower($t['name'] ?? ''));
|
||||
$vocabLower = collect($vocabulary)->map(fn (string $v) => Str::lower($v));
|
||||
|
||||
return $existing->intersect($vocabLower)->isNotEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{id: int, name: string, tags: array, content: string}> $batch
|
||||
* @param string[] $vocabulary
|
||||
* @param array{scanned: int, tagged: int, skipped: int, failed_batches: int} $totals
|
||||
*/
|
||||
protected function processBatch(string $type, array $batch, array $vocabulary, bool $dryRun, array &$totals): void
|
||||
{
|
||||
$prompt = $this->buildPrompt($batch, $vocabulary);
|
||||
|
||||
$raw = $this->ai->chat([
|
||||
['role' => 'system', 'content' => $prompt['system']],
|
||||
['role' => 'user', 'content' => $prompt['user']],
|
||||
], ['temperature' => 0]);
|
||||
|
||||
$assignments = $this->parseAssignments($raw, $vocabulary);
|
||||
|
||||
if ($assignments === null) {
|
||||
$totals['failed_batches']++;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($batch as $item) {
|
||||
$labels = $assignments[(string) $item['id']] ?? [];
|
||||
|
||||
if (! $labels) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$totals['tagged']++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$mergedTags = $this->mergeTags($item['tags'], $labels);
|
||||
|
||||
if ($this->bookstack->updateTags($type, $item['id'], $mergedTags)) {
|
||||
$totals['tagged']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{id: int, name: string, tags: array, content: string}> $batch
|
||||
* @param string[] $vocabulary
|
||||
* @return array{system: string, user: string}
|
||||
*/
|
||||
protected function buildPrompt(array $batch, array $vocabulary): array
|
||||
{
|
||||
$system = 'Klasyfikujesz artykuły bazy wiedzy do kategorii zgłoszeń helpdesk. '
|
||||
.'Dostępne kategorie (użyj DOKŁADNIE tej pisowni): '.implode(', ', $vocabulary).'. '
|
||||
.'Dla każdego elementu przypisz 0, 1 lub więcej pasujących kategorii — nie zgaduj '
|
||||
.'kategorii, jeśli żadna sensownie nie pasuje, zwróć pustą tablicę. '
|
||||
.'Odpowiedz WYŁĄCZNIE obiektem JSON, bez żadnego innego tekstu ani formatowania, '
|
||||
.'gdzie klucz to id elementu (jako string), a wartość to tablica dopasowanych nazw '
|
||||
.'kategorii. Przykład: {"12": ["Drukarki i skanery"], "13": []}';
|
||||
|
||||
$user = collect($batch)->map(function (array $item) {
|
||||
$excerpt = Str::limit(strip_tags($item['content']), self::CONTENT_EXCERPT_CHARS, '');
|
||||
|
||||
return "id={$item['id']} nazwa=\"{$item['name']}\"\n{$excerpt}";
|
||||
})->implode("\n---\n");
|
||||
|
||||
return ['system' => $system, 'user' => $user];
|
||||
}
|
||||
|
||||
/**
|
||||
* Defensively parses the LLM's JSON response — not every OpenAI-
|
||||
* compatible provider (esp. self-hosted Ollama models) reliably honors a
|
||||
* "respond with only JSON" instruction, so this first tries to pull out
|
||||
* the first {...} block (in case the model wrapped it in prose or a
|
||||
* markdown code fence) before decoding. A malformed/non-JSON response
|
||||
* fails just this one batch (caller counts it in failed_batches) rather
|
||||
* than aborting the whole run. Only vocabulary-matching labels survive
|
||||
* (case-insensitive); anything else the model returns is discarded.
|
||||
*
|
||||
* @param string[] $vocabulary
|
||||
* @return array<string, string[]>|null
|
||||
*/
|
||||
protected function parseAssignments(?string $raw, array $vocabulary): ?array
|
||||
{
|
||||
if (! $raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! preg_match('/\{.*\}/s', $raw, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode($matches[0], true);
|
||||
|
||||
if (! is_array($decoded)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$vocabByLower = collect($vocabulary)->mapWithKeys(fn (string $v) => [Str::lower($v) => $v]);
|
||||
$result = [];
|
||||
|
||||
foreach ($decoded as $id => $labels) {
|
||||
if (! is_array($labels)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result[(string) $id] = collect($labels)
|
||||
->map(fn ($l) => $vocabByLower[Str::lower((string) $l)] ?? null)
|
||||
->filter()
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{name: string, value: string}> $existing
|
||||
* @param string[] $newLabels
|
||||
* @return array<int, array{name: string, value: string}>
|
||||
*/
|
||||
protected function mergeTags(array $existing, array $newLabels): array
|
||||
{
|
||||
$existingLower = collect($existing)->map(fn (array $t) => Str::lower($t['name'] ?? ''));
|
||||
|
||||
$additions = collect($newLabels)
|
||||
->reject(fn (string $label) => $existingLower->contains(Str::lower($label)))
|
||||
->map(fn (string $label) => ['name' => $label, 'value' => '']);
|
||||
|
||||
return [...$existing, ...$additions->values()->all()];
|
||||
}
|
||||
}
|
||||
282
src/app/Services/ImapMailboxFetcher.php
Normal file
282
src/app/Services/ImapMailboxFetcher.php
Normal file
@@ -0,0 +1,282 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ImapMailbox;
|
||||
use App\Support\Imap\InboundEmail;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Throwable;
|
||||
use Webklex\PHPIMAP\Client;
|
||||
use Webklex\PHPIMAP\ClientManager;
|
||||
use Webklex\PHPIMAP\Message;
|
||||
|
||||
/**
|
||||
* I/O layer for the "reply/create ticket by e-mail" feature — connects to
|
||||
* every enabled ImapMailbox, fetches unseen messages and delegates every
|
||||
* decision to ImapMessageClassifier (pure logic) + TicketService (the
|
||||
* existing ticket-mutation API). Kept thin and mostly untested directly;
|
||||
* ImapMessageClassifier carries the actual test coverage.
|
||||
*/
|
||||
class ImapMailboxFetcher
|
||||
{
|
||||
private const HEADER_FIELDS = ['auto-submitted', 'x-autoreply', 'x-autorespond', 'precedence'];
|
||||
|
||||
public function __construct(
|
||||
private readonly ImapMessageClassifier $classifier,
|
||||
private readonly TicketService $tickets,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{created: int, replied: int, rejected: int, errors: int}
|
||||
*/
|
||||
public function fetchAll(): array
|
||||
{
|
||||
$totals = ['created' => 0, 'replied' => 0, 'rejected' => 0, 'errors' => 0];
|
||||
|
||||
foreach (ImapMailbox::query()->where('enabled', true)->get() as $mailbox) {
|
||||
foreach ($this->fetchMailbox($mailbox) as $key => $value) {
|
||||
$totals[$key] += $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $totals;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{created: int, replied: int, rejected: int, errors: int}
|
||||
*/
|
||||
public function fetchMailbox(ImapMailbox $mailbox): array
|
||||
{
|
||||
$result = ['created' => 0, 'replied' => 0, 'rejected' => 0, 'errors' => 0];
|
||||
$log = Log::channel('imap');
|
||||
|
||||
$log->info("[{$mailbox->name}] łączenie z {$mailbox->host}:{$mailbox->port} (folder: {$mailbox->folder})");
|
||||
|
||||
try {
|
||||
$client = $this->connect($mailbox);
|
||||
$folder = $client->getFolder($mailbox->folder ?: 'INBOX');
|
||||
$messages = $folder->messages()->whereUnseen()->get();
|
||||
|
||||
$log->info("[{$mailbox->name}] {$messages->count()} nieprzeczytanych wiadomości");
|
||||
|
||||
foreach ($messages as $message) {
|
||||
try {
|
||||
$this->processMessage($mailbox, $message, $result, $log);
|
||||
} catch (Throwable $e) {
|
||||
$result['errors']++;
|
||||
$log->error("[{$mailbox->name}] błąd przetwarzania wiadomości (uid={$message->getUid()}) — {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
$client->disconnect();
|
||||
$mailbox->update(['last_checked_at' => now(), 'last_error' => null]);
|
||||
$log->info("[{$mailbox->name}] zakończono: {$result['created']} nowych, {$result['replied']} odpowiedzi, {$result['rejected']} odrzuconych, {$result['errors']} błędów");
|
||||
} catch (Throwable $e) {
|
||||
$result['errors']++;
|
||||
$mailbox->update(['last_checked_at' => now(), 'last_error' => $e->getMessage()]);
|
||||
$log->error("[{$mailbox->name}] połączenie nieudane — {$e->getMessage()}");
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a connection and lists the configured folder, without fetching
|
||||
* or touching any message — used by the admin "Testuj połączenie" button.
|
||||
* Returns null on success, the exception message on failure.
|
||||
*/
|
||||
public function testConnection(ImapMailbox $mailbox): ?string
|
||||
{
|
||||
try {
|
||||
$client = $this->connect($mailbox);
|
||||
$client->getFolder($mailbox->folder ?: 'INBOX');
|
||||
$client->disconnect();
|
||||
|
||||
return null;
|
||||
} catch (Throwable $e) {
|
||||
return $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
private function connect(ImapMailbox $mailbox): Client
|
||||
{
|
||||
$manager = new ClientManager;
|
||||
$client = $manager->make([
|
||||
'host' => $mailbox->host,
|
||||
'port' => $mailbox->port,
|
||||
'protocol' => 'imap',
|
||||
'encryption' => $mailbox->encryption === 'none' ? false : $mailbox->encryption,
|
||||
'validate_cert' => $mailbox->validate_cert,
|
||||
'username' => $mailbox->username,
|
||||
'password' => $mailbox->password,
|
||||
]);
|
||||
$client->connect();
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{created: int, replied: int, rejected: int, errors: int} $result
|
||||
*/
|
||||
private function processMessage(ImapMailbox $mailbox, Message $message, array &$result, LoggerInterface $log): void
|
||||
{
|
||||
$email = $this->toInboundEmail($message);
|
||||
$uid = $message->getUid();
|
||||
|
||||
$log->debug("[{$mailbox->name}] uid={$uid} od={$email->fromEmail} temat=\"{$email->subject}\" nagłówki=".json_encode($email->headers, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$rejectReason = $this->classifier->rejectionReason($email, $mailbox->blocklistedSenders());
|
||||
if ($rejectReason === null && ! $this->classifier->isSenderAllowed($email->fromEmail)) {
|
||||
$rejectReason = "nadawca spoza LDAP ({$email->fromEmail}), a restrict_tickets_to_ldap jest włączone";
|
||||
}
|
||||
|
||||
if ($rejectReason !== null) {
|
||||
$this->finish($message, $mailbox->rejected_folder);
|
||||
$result['rejected']++;
|
||||
$log->info("[{$mailbox->name}] uid={$uid} ODRZUCONO od {$email->fromEmail} \"{$email->subject}\" — {$rejectReason}");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Oznacz/przenieś PRZED utworzeniem ticketu: awaria w tym miejscu
|
||||
// zostawia co najwyżej "przetworzoną" wiadomość bez ticketu (widoczne,
|
||||
// łatwe do naprawienia ręcznie) zamiast duplikatu ticketu przy
|
||||
// ponownym uruchomieniu.
|
||||
$this->finish($message, $mailbox->processed_folder);
|
||||
|
||||
$ticket = $this->classifier->matchTicket($email->subject);
|
||||
$sender = $this->classifier->resolveSender($email->fromEmail);
|
||||
$attachments = $this->buildAttachments($email, $mailbox, $log);
|
||||
$authorName = $email->fromName !== '' ? $email->fromName : $email->fromEmail;
|
||||
|
||||
if ($ticket) {
|
||||
if ($sender) {
|
||||
$this->tickets->clientReply($ticket, $sender, $email->body(), $attachments, source: 'email');
|
||||
} else {
|
||||
$this->tickets->guestReply($ticket, $authorName, $email->body(), $attachments, source: 'email');
|
||||
}
|
||||
$result['replied']++;
|
||||
$log->info("[{$mailbox->name}] uid={$uid} ODPOWIEDŹ od {$email->fromEmail} dopisana do zgłoszenia #{$ticket->id} ({$ticket->displayNumber()})");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$newTicket = $this->tickets->create([
|
||||
'email' => $email->fromEmail,
|
||||
'name' => $authorName,
|
||||
'subcategory_id' => $mailbox->default_subcategory_id,
|
||||
'category_id' => $mailbox->default_category_id,
|
||||
'subject' => $email->subject !== '' ? $email->subject : '(bez tematu)',
|
||||
'body' => $email->body(),
|
||||
'source' => 'email',
|
||||
], $sender, $authorName);
|
||||
$result['created']++;
|
||||
$log->info("[{$mailbox->name}] uid={$uid} NOWE zgłoszenie #{$newTicket->id} ({$newTicket->displayNumber()}) od {$email->fromEmail}");
|
||||
}
|
||||
|
||||
private function finish(Message $message, ?string $moveToFolder): void
|
||||
{
|
||||
try {
|
||||
$message->setFlag('Seen');
|
||||
} catch (Throwable $e) {
|
||||
Log::channel('imap')->warning("IMAP: nie udało się oznaczyć wiadomości jako przeczytanej — {$e->getMessage()}");
|
||||
}
|
||||
|
||||
if ($moveToFolder) {
|
||||
$message->move($moveToFolder);
|
||||
}
|
||||
}
|
||||
|
||||
private function toInboundEmail(Message $message): InboundEmail
|
||||
{
|
||||
$fromAddress = $message->getFrom()->first();
|
||||
$header = $message->getHeader();
|
||||
|
||||
// Webklex's Header::get() returns an *empty* Attribute (not null)
|
||||
// for a header that isn't present at all, and Attribute::first() on
|
||||
// that empty instance comes back as '' rather than null — so a
|
||||
// plain "!== null" check on the resulting value is always true,
|
||||
// making every message look like it carries every one of these
|
||||
// headers. Only keep a header that actually has content.
|
||||
$headers = [];
|
||||
foreach (self::HEADER_FIELDS as $name) {
|
||||
$value = $header?->get($name)->first();
|
||||
if ($value !== null && $value !== '') {
|
||||
$headers[$name] = (string) $value;
|
||||
}
|
||||
}
|
||||
|
||||
return new InboundEmail(
|
||||
fromEmail: $fromAddress?->mail ?? '',
|
||||
fromName: $this->decodeHeaderText(trim((string) ($fromAddress?->personal ?? ''), '"')),
|
||||
subject: $this->decodeHeaderText((string) $message->getSubject()),
|
||||
textBody: (string) $message->getTextBody(),
|
||||
htmlBody: (string) $message->getHTMLBody(),
|
||||
headers: $headers,
|
||||
attachments: $this->extractAttachments($message),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Some senders' mail clients leave the Subject/From display-name as raw
|
||||
* RFC 2047 encoded-words (e.g. "=?utf-8?Q?...?=") instead of the
|
||||
* decoded UTF-8 webklex's own config claims to produce — decode
|
||||
* defensively rather than showing garbled text on the ticket.
|
||||
*/
|
||||
private function decodeHeaderText(string $value): string
|
||||
{
|
||||
return $value !== '' ? mb_decode_mimeheader($value) : $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{filename: string, mime: string, content: string}>
|
||||
*/
|
||||
private function extractAttachments(Message $message): array
|
||||
{
|
||||
$attachments = [];
|
||||
|
||||
foreach ($message->getAttachments() as $attachment) {
|
||||
$attachments[] = [
|
||||
'filename' => $attachment->getName() ?: 'attachment',
|
||||
'mime' => $attachment->getMimeType() ?: 'application/octet-stream',
|
||||
'content' => $attachment->getContent(),
|
||||
];
|
||||
}
|
||||
|
||||
return $attachments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts raw attachment bytes into UploadedFile instances (via a temp
|
||||
* file + the $test=true flag, which lets Symfony's UploadedFile skip the
|
||||
* is_uploaded_file() check outside of a real HTTP request) so they flow
|
||||
* through TicketService::attachFiles() unchanged. Validated the same way
|
||||
* every other caller validates before calling attachFiles() — a mail
|
||||
* carrying an oversized/disallowed attachment still creates the
|
||||
* ticket/reply, just without that attachment, rather than being dropped
|
||||
* entirely or silently bypassing the admin's attachment policy.
|
||||
*
|
||||
* @return UploadedFile[]
|
||||
*/
|
||||
private function buildAttachments(InboundEmail $email, ImapMailbox $mailbox, LoggerInterface $log): array
|
||||
{
|
||||
$files = [];
|
||||
|
||||
foreach ($email->attachments as $attachment) {
|
||||
$path = tempnam(sys_get_temp_dir(), 'imap_');
|
||||
file_put_contents($path, $attachment['content']);
|
||||
$files[] = new UploadedFile($path, $attachment['filename'], $attachment['mime'], null, true);
|
||||
}
|
||||
|
||||
if ($files && ($error = Settings::validateAttachments($files))) {
|
||||
$log->warning("[{$mailbox->name}] pominięto załączniki wiadomości od {$email->fromEmail} — {$error}");
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
}
|
||||
137
src/app/Services/ImapMessageClassifier.php
Normal file
137
src/app/Services/ImapMessageClassifier.php
Normal file
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Ticket;
|
||||
use App\Models\User;
|
||||
use App\Support\Imap\InboundEmail;
|
||||
use App\Support\Settings;
|
||||
|
||||
/**
|
||||
* Pure decision logic for the IMAP fetcher — no IMAP connection, no
|
||||
* side effects, so it's fully Pest-testable against hand-built
|
||||
* InboundEmail instances. ImapMailboxFetcher does all the I/O and calls
|
||||
* into this for every decision.
|
||||
*/
|
||||
class ImapMessageClassifier
|
||||
{
|
||||
/**
|
||||
* RFC 3834 (Auto-Submitted) + common vendor headers, plus EN/PL subject
|
||||
* phrasing for autoresponders/bounces that don't set those headers at
|
||||
* all — the two layers catch most real-world autoresponders/mailer-daemons.
|
||||
*/
|
||||
private const AUTO_REPLY_SUBJECT_PATTERNS = [
|
||||
'/\bout of office\b/i',
|
||||
'/\bautomatic reply\b/i',
|
||||
'/\bautomatyczna odpowiedz\b/iu',
|
||||
'/\bautoresponder\b/i',
|
||||
'/\bundeliverable\b/i',
|
||||
'/\bundelivered\b/i',
|
||||
'/\bmail delivery failed\b/i',
|
||||
'/\bdelivery status notification\b/i',
|
||||
'/\bnieobecnosc\b.*\bbiurze\b/iu',
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns a human-readable rejection reason, or null if the message
|
||||
* should be processed as a genuine ticket/reply.
|
||||
*
|
||||
* @param string[] $extraBlocklist additional blocked sender local-parts/addresses (per-mailbox)
|
||||
*/
|
||||
public function rejectionReason(InboundEmail $email, array $extraBlocklist = []): ?string
|
||||
{
|
||||
$autoSubmitted = strtolower((string) $email->header('auto-submitted'));
|
||||
if ($autoSubmitted !== '' && $autoSubmitted !== 'no') {
|
||||
return "Auto-Submitted: {$autoSubmitted}";
|
||||
}
|
||||
|
||||
if ($email->header('x-autoreply') !== null || $email->header('x-autorespond') !== null) {
|
||||
return 'X-Autoreply/X-Autorespond header present';
|
||||
}
|
||||
|
||||
$precedence = strtolower((string) $email->header('precedence'));
|
||||
if (in_array($precedence, ['bulk', 'junk', 'list'], true)) {
|
||||
return "Precedence: {$precedence}";
|
||||
}
|
||||
|
||||
$senderLocalPart = strtolower(explode('@', $email->fromEmail)[0] ?? '');
|
||||
$blocked = array_map('strtolower', $extraBlocklist);
|
||||
if ($senderLocalPart !== '' && in_array($senderLocalPart, $blocked, true)) {
|
||||
return "Blocked sender: {$email->fromEmail}";
|
||||
}
|
||||
if (in_array(strtolower($email->fromEmail), $blocked, true)) {
|
||||
return "Blocked sender: {$email->fromEmail}";
|
||||
}
|
||||
|
||||
foreach (self::AUTO_REPLY_SUBJECT_PATTERNS as $pattern) {
|
||||
if (preg_match($pattern, $email->subject) === 1) {
|
||||
return "Subject matched auto-reply pattern ({$pattern})";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same gate Landing::submit() applies to web/guest ticket creation
|
||||
* (Settings::bool('restrict_tickets_to_ldap')) — must apply identically
|
||||
* to mail-originated tickets/replies, or the restriction has a hole.
|
||||
*/
|
||||
public function isSenderAllowed(string $email): bool
|
||||
{
|
||||
if (! Settings::bool('restrict_tickets_to_ldap')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return User::query()->where('email', $email)->exists()
|
||||
|| app(LdapUserProvisioner::class)->existsInLdap($email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Existing local user, or an LDAP-provisioned one if enabled — mirrors
|
||||
* TicketService::create()'s own guest-resolution branch. Returns null
|
||||
* for a genuine, unprovisionable guest.
|
||||
*/
|
||||
public function resolveSender(string $email): ?User
|
||||
{
|
||||
if ($user = User::query()->where('email', $email)->first()) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
if (Settings::bool('ldap_auto_provision_guests')) {
|
||||
return app(LdapUserProvisioner::class)->findOrCreateByEmail($email);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips common reply/forward prefixes, then tries every digit run of
|
||||
* length >= 4 (longest first) against Ticket::resolveRouteBinding() —
|
||||
* covers both the plain sequential number and the obfuscated checksum,
|
||||
* since both are plain digit strings and every outbound notification
|
||||
* subject already carries one (see database/seeders/DatabaseSeeder.php).
|
||||
* Prefix-aware matching was considered and rejected: {numer} email
|
||||
* templates hardcode their own literal '#', independent of the
|
||||
* admin-configurable ticket_number_prefix setting, and templates are
|
||||
* themselves admin-editable.
|
||||
*/
|
||||
public function matchTicket(string $subject): ?Ticket
|
||||
{
|
||||
$cleaned = preg_replace('/^\s*(re|odp|fwd|fw|aw)\s*:\s*/i', '', $subject) ?? $subject;
|
||||
$cleaned = preg_replace('/^\s*(re|odp|fwd|fw|aw)\s*:\s*/i', '', $cleaned) ?? $cleaned;
|
||||
|
||||
preg_match_all('/\d{4,}/', $cleaned, $matches);
|
||||
$tokens = $matches[0] ?? [];
|
||||
usort($tokens, fn ($a, $b) => strlen($b) <=> strlen($a));
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
$ticket = (new Ticket)->resolveRouteBinding($token);
|
||||
if ($ticket) {
|
||||
return $ticket;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
212
src/app/Services/SnipeItClient.php
Normal file
212
src/app/Services/SnipeItClient.php
Normal file
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class SnipeItClient
|
||||
{
|
||||
public function enabled(): bool
|
||||
{
|
||||
return Settings::bool('snipeit_enabled')
|
||||
&& Settings::get('snipeit_base_url')
|
||||
&& Settings::get('snipeit_api_token');
|
||||
}
|
||||
|
||||
/**
|
||||
* Assets Snipe-IT has checked out to $email — feeds the "Sprzęt
|
||||
* zgłaszającego" sidebar shown to a client creating a ticket and to an
|
||||
* operator viewing one. Snipe-IT has no "assets by e-mail" endpoint, so
|
||||
* this looks the requester up as a Snipe-IT user first, then lists what's
|
||||
* assigned to them. Cached briefly per e-mail since the ticket-creation
|
||||
* form and ticket-view page both re-render this on every interaction.
|
||||
*
|
||||
* @return array<int, array{id: int, label: string, serial: ?string, manufacturer: ?string, model: ?string, category: ?string, status: ?string, url: string}>
|
||||
*/
|
||||
public function assetsForEmail(string $email): array
|
||||
{
|
||||
$email = trim($email);
|
||||
|
||||
if (! $this->enabled() || $email === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Cache::remember('snipeit:user-assets:'.md5(strtolower($email)), now()->addMinutes(5), function () use ($email) {
|
||||
try {
|
||||
$user = $this->findUserByEmail($email);
|
||||
|
||||
if (! $user) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$response = $this->client()->get("/users/{$user['id']}/assets");
|
||||
|
||||
if (! $response->successful()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return collect($response->json('rows', []))
|
||||
->map(fn (array $a) => $this->normalizeAsset($a))
|
||||
->values()
|
||||
->all();
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-inventory search behind the operator's "przeszukaj cały
|
||||
* inwentarz" picker — unlike assetsForEmail() this isn't scoped to any
|
||||
* one requester. Uncached: it's a live, as-you-type lookup.
|
||||
*
|
||||
* @return array<int, array{id: int, label: string, serial: ?string, manufacturer: ?string, model: ?string, category: ?string, status: ?string, url: string}>
|
||||
*/
|
||||
public function searchAssets(string $query, int $limit = 10): array
|
||||
{
|
||||
$query = trim($query);
|
||||
|
||||
if (! $this->enabled() || $query === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->client()->get('/hardware', ['search' => $query, 'limit' => $limit]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return collect($response->json('rows', []))
|
||||
->map(fn (array $a) => $this->normalizeAsset($a))
|
||||
->values()
|
||||
->all();
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Live detail for a ticket's linked asset — fetched fresh rather than
|
||||
* trusting the ticket's cached snipeit_asset_name, so a status/
|
||||
* reassignment change in Snipe-IT is reflected immediately. Null if
|
||||
* unreachable or the asset was deleted there; callers fall back to the
|
||||
* cached label in that case.
|
||||
*
|
||||
* @return array{id: int, label: string, serial: ?string, manufacturer: ?string, model: ?string, category: ?string, status: ?string, assignedTo: ?string, url: string}|null
|
||||
*/
|
||||
public function asset(int $id): ?array
|
||||
{
|
||||
if (! $this->enabled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->client()->get("/hardware/{$id}");
|
||||
|
||||
if (! $response->successful()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
return [
|
||||
...$this->normalizeAsset($data),
|
||||
'assignedTo' => $data['assigned_to']['name'] ?? null,
|
||||
];
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests unsaved admin-form values directly, rather than whatever's
|
||||
* currently stored — mirrors BookStackClient::testConnection(). Hits a
|
||||
* plain list endpoint (rather than e.g. /users/me, which isn't present
|
||||
* on every Snipe-IT version) so this works as a version-agnostic
|
||||
* auth+reachability check.
|
||||
*
|
||||
* @return array{ok: bool, message: ?string}
|
||||
*/
|
||||
public function testConnection(string $baseUrl, string $token, bool $verifySsl = true): array
|
||||
{
|
||||
try {
|
||||
$response = Http::withToken($token)
|
||||
->acceptJson()
|
||||
->withOptions(['verify' => $verifySsl])
|
||||
->timeout(6)
|
||||
->get(rtrim($baseUrl, '/').'/api/v1/hardware', ['limit' => 1]);
|
||||
|
||||
if ($response->successful()) {
|
||||
return ['ok' => true, 'message' => null];
|
||||
}
|
||||
|
||||
$message = $response->json('messages') ?? $response->json('message');
|
||||
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => is_string($message) ? $message : ($message ? json_encode($message) : ('HTTP '.$response->status())),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return ['ok' => false, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
protected function findUserByEmail(string $email): ?array
|
||||
{
|
||||
$response = $this->client()->get('/users', ['search' => $email, 'limit' => 5]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return collect($response->json('rows', []))
|
||||
->first(fn (array $u) => isset($u['email']) && strcasecmp($u['email'], $email) === 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* label is "numer środka - numer seryjny - producent model" — joining
|
||||
* whichever of those three pieces is actually present (Snipe-IT doesn't
|
||||
* guarantee any of them), falling back to the bare asset id if all three
|
||||
* are blank. The display format requested for the "Sprzęt zgłaszającego"
|
||||
* picker and sidebar, everywhere an asset is listed.
|
||||
*
|
||||
* @return array{id: int, label: string, serial: ?string, manufacturer: ?string, model: ?string, category: ?string, status: ?string, url: string}
|
||||
*/
|
||||
protected function normalizeAsset(array $a): array
|
||||
{
|
||||
$assetTag = $a['asset_tag'] ?? null;
|
||||
$serial = $a['serial'] ?? null;
|
||||
$manufacturer = $a['manufacturer']['name'] ?? null;
|
||||
$model = $a['model']['name'] ?? null;
|
||||
$modelDisplay = trim(($manufacturer ? "{$manufacturer} " : '').($model ?? ''));
|
||||
|
||||
$labelParts = collect([$assetTag, $serial, $modelDisplay])
|
||||
->map(fn ($v) => trim((string) $v))
|
||||
->filter(fn ($v) => $v !== '');
|
||||
|
||||
$label = $labelParts->isNotEmpty() ? $labelParts->implode(' - ') : 'Zasób #'.$a['id'];
|
||||
|
||||
return [
|
||||
'id' => $a['id'],
|
||||
'label' => $label,
|
||||
'serial' => $serial,
|
||||
'manufacturer' => $manufacturer,
|
||||
'model' => $model,
|
||||
'category' => $a['category']['name'] ?? null,
|
||||
'status' => $a['status_label']['name'] ?? null,
|
||||
'url' => rtrim(Settings::get('snipeit_base_url'), '/').'/hardware/'.$a['id'],
|
||||
];
|
||||
}
|
||||
|
||||
protected function client()
|
||||
{
|
||||
return Http::withToken(Settings::get('snipeit_api_token'))
|
||||
->acceptJson()
|
||||
->withOptions(['verify' => Settings::bool('snipeit_verify_ssl')])
|
||||
->timeout(6)
|
||||
->baseUrl(rtrim(Settings::get('snipeit_base_url'), '/').'/api/v1');
|
||||
}
|
||||
}
|
||||
166
src/app/Services/TicketAiSummaryService.php
Normal file
166
src/app/Services/TicketAiSummaryService.php
Normal file
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Ticket;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Generates an AI summary + suggested next action for every ticket, cached
|
||||
* on the ticket row and shown only in the operator view (see
|
||||
* TicketAiTriageService's docblock for why this runs from the scheduled
|
||||
* ai:run-ticket-automation command rather than live on page load). Stays
|
||||
* reasonably fresh by regenerating whenever a ticket's latest message
|
||||
* postdates its last summary, not on every scheduler tick for every ticket.
|
||||
*/
|
||||
class TicketAiSummaryService
|
||||
{
|
||||
protected const BATCH_LIMIT = 25;
|
||||
|
||||
protected const MESSAGE_EXCERPT_CHARS = 1500;
|
||||
|
||||
protected const BODY_EXCERPT_CHARS = 4000;
|
||||
|
||||
protected const TRANSCRIPT_MESSAGE_LIMIT = 30;
|
||||
|
||||
public function __construct(protected AiClient $ai) {}
|
||||
|
||||
/**
|
||||
* @return array{scanned: int, updated: int, failed: int}
|
||||
*/
|
||||
public function run(?int $limit = null): array
|
||||
{
|
||||
$totals = ['scanned' => 0, 'updated' => 0, 'failed' => 0];
|
||||
|
||||
if (! $this->ai->enabled() || ! Settings::bool('ai_summary_enabled')) {
|
||||
return $totals;
|
||||
}
|
||||
|
||||
$this->staleQuery()
|
||||
->limit($limit ?? self::BATCH_LIMIT)
|
||||
->get()
|
||||
->each(function (Ticket $ticket) use (&$totals) {
|
||||
$totals['scanned']++;
|
||||
|
||||
$this->summarizeOne($ticket) ? $totals['updated']++ : $totals['failed']++;
|
||||
});
|
||||
|
||||
return $totals;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tickets with no summary yet, or whose latest message postdates the
|
||||
* last summary generation. Deliberately compares against
|
||||
* ticket_messages.created_at rather than tickets.updated_at — the
|
||||
* latter also changes on unrelated actions (status/priority/timer
|
||||
* edits), which would otherwise trigger spurious re-summarization on
|
||||
* every scheduler tick for an active ticket.
|
||||
*/
|
||||
protected function staleQuery(): Builder
|
||||
{
|
||||
return Ticket::query()->where(function (Builder $q) {
|
||||
$q->whereNull('ai_summary_generated_at')
|
||||
->orWhere(function (Builder $q2) {
|
||||
$q2->whereNotNull('ai_summary_generated_at')
|
||||
->whereColumn('ai_summary_generated_at', '<', DB::raw(
|
||||
'(select max(ticket_messages.created_at) from ticket_messages where ticket_messages.ticket_id = tickets.id)'
|
||||
));
|
||||
});
|
||||
})->orderBy('id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerates the summary for a single ticket right now, bypassing the
|
||||
* staleness check — used by the manual "regenerate" button and the
|
||||
* on-new-message hook, as opposed to run()'s scheduled batch sweep.
|
||||
*/
|
||||
public function generateFor(Ticket $ticket): bool
|
||||
{
|
||||
if (! $this->ai->enabled() || ! Settings::bool('ai_summary_enabled')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->summarizeOne($ticket);
|
||||
}
|
||||
|
||||
protected function summarizeOne(Ticket $ticket): bool
|
||||
{
|
||||
$raw = $this->ai->chat([
|
||||
['role' => 'system', 'content' => Settings::get('ai_summary_prompt')],
|
||||
['role' => 'user', 'content' => $this->buildTranscript($ticket)],
|
||||
], ['temperature' => 0.2]);
|
||||
|
||||
$parsed = $this->parseResponse($raw);
|
||||
|
||||
if ($parsed === null) {
|
||||
// Leaves any prior summary untouched and generated_at unchanged,
|
||||
// so the ticket stays in the stale set and gets retried next run
|
||||
// rather than silently losing a working summary.
|
||||
return false;
|
||||
}
|
||||
|
||||
$ticket->update([
|
||||
'ai_summary' => $parsed['summary'],
|
||||
'ai_suggested_action' => $parsed['suggested_action'],
|
||||
'ai_summary_generated_at' => now(),
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Includes tickets.body explicitly (the opening description, separate
|
||||
* from ticket_messages) rather than relying on it showing up as the
|
||||
* thread's first message — that row falls outside the last-N transcript
|
||||
* window on any ticket with more than TRANSCRIPT_MESSAGE_LIMIT messages,
|
||||
* which would otherwise silently drop the original request from long
|
||||
* threads. Mirrors TicketAiTriageService's own subject+body framing.
|
||||
*/
|
||||
protected function buildTranscript(Ticket $ticket): string
|
||||
{
|
||||
$lines = [
|
||||
"Temat: {$ticket->subject}",
|
||||
"Treść:\n".Str::limit(strip_tags($ticket->body), self::BODY_EXCERPT_CHARS),
|
||||
];
|
||||
|
||||
$ticket->messages()->latest('created_at')->limit(self::TRANSCRIPT_MESSAGE_LIMIT)->get()
|
||||
->sortBy('created_at')
|
||||
->each(function ($message) use (&$lines) {
|
||||
$role = $message->internal ? 'notatka wewnętrzna' : ($message->role === 'client' ? 'klient' : 'operator');
|
||||
$body = Str::limit(strip_tags($message->body), self::MESSAGE_EXCERPT_CHARS, '');
|
||||
$lines[] = "[{$role}] {$message->author_name}: {$body}";
|
||||
});
|
||||
|
||||
return implode("\n\n", $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same defensive-parsing shape used elsewhere in this app's AI services
|
||||
* (BookStackContentTagger, TicketAiTriageService) — extracts the first
|
||||
* {...} block before decoding.
|
||||
*
|
||||
* @return array{summary: string, suggested_action: ?string}|null
|
||||
*/
|
||||
protected function parseResponse(?string $raw): ?array
|
||||
{
|
||||
if (! $raw || ! preg_match('/\{.*\}/s', $raw, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode($matches[0], true);
|
||||
|
||||
if (! is_array($decoded) || empty($decoded['summary']) || ! is_string($decoded['summary'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'summary' => trim($decoded['summary']),
|
||||
'suggested_action' => ! empty($decoded['suggested_action']) && is_string($decoded['suggested_action'])
|
||||
? trim($decoded['suggested_action'])
|
||||
: null,
|
||||
];
|
||||
}
|
||||
}
|
||||
311
src/app/Services/TicketAiTriageService.php
Normal file
311
src/app/Services/TicketAiTriageService.php
Normal file
@@ -0,0 +1,311 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Priority;
|
||||
use App\Models\Ticket;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* One-shot AI classification of new tickets, gated by 5 independent
|
||||
* settings toggles (ai_triage_category_when_missing/
|
||||
* subcategory_when_category_only/recheck_categorized/fix_subject/
|
||||
* set_priority). Runs from the scheduled ai:run-ticket-automation command,
|
||||
* never synchronously at ticket creation, so it never adds LLM latency to a
|
||||
* live customer submitting a ticket. Every scanned ticket gets
|
||||
* ai_triaged_at stamped exactly once — this is a one-shot pass per ticket,
|
||||
* not a continuous recheck, and there's no manual re-trigger by design.
|
||||
*/
|
||||
class TicketAiTriageService
|
||||
{
|
||||
protected const BATCH_LIMIT = 25;
|
||||
|
||||
protected const BODY_EXCERPT_CHARS = 4000;
|
||||
|
||||
public function __construct(
|
||||
protected AiClient $ai,
|
||||
protected TicketService $tickets,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{scanned: int, changed: int, failed: int}
|
||||
*/
|
||||
public function run(?int $limit = null): array
|
||||
{
|
||||
$totals = ['scanned' => 0, 'changed' => 0, 'failed' => 0];
|
||||
|
||||
if (! $this->ai->enabled() || ! $this->anyToggleEnabled()) {
|
||||
return $totals;
|
||||
}
|
||||
|
||||
$vocabulary = $this->buildVocabulary();
|
||||
$priorities = Priority::query()->orderBy('sort_order')->pluck('label', 'key')->all();
|
||||
|
||||
Ticket::query()->whereNull('ai_triaged_at')
|
||||
->orderBy('id')
|
||||
->limit($limit ?? self::BATCH_LIMIT)
|
||||
->get()
|
||||
->each(function (Ticket $ticket) use ($vocabulary, $priorities, &$totals) {
|
||||
$totals['scanned']++;
|
||||
$this->triageOne($ticket, $vocabulary, $priorities, $totals);
|
||||
});
|
||||
|
||||
return $totals;
|
||||
}
|
||||
|
||||
protected function anyToggleEnabled(): bool
|
||||
{
|
||||
return Settings::bool('ai_triage_category_when_missing')
|
||||
|| Settings::bool('ai_triage_subcategory_when_category_only')
|
||||
|| Settings::bool('ai_triage_recheck_categorized')
|
||||
|| Settings::bool('ai_triage_fix_subject')
|
||||
|| Settings::bool('ai_triage_set_priority');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{id: int, name: string, subcategories: array<int, array{id: int, name: string}>}> $vocabulary
|
||||
* @param array<string, string> $priorities
|
||||
* @param array{scanned: int, changed: int, failed: int} $totals
|
||||
*/
|
||||
protected function triageOne(Ticket $ticket, array $vocabulary, array $priorities, array &$totals): void
|
||||
{
|
||||
$prompt = $this->buildPrompt($ticket, $vocabulary, $priorities);
|
||||
|
||||
if ($prompt === null) {
|
||||
$ticket->update(['ai_triaged_at' => now()]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$raw = $this->ai->chat([
|
||||
['role' => 'system', 'content' => $prompt['system']],
|
||||
['role' => 'user', 'content' => $prompt['user']],
|
||||
], ['temperature' => 0]);
|
||||
|
||||
$parsed = $this->parseResponse($raw);
|
||||
|
||||
// A response came back but couldn't be parsed — not fatal to the
|
||||
// run, just means this ticket wasn't classified this time.
|
||||
// ai_triaged_at is still stamped below so a persistently-bad
|
||||
// response doesn't get retried forever.
|
||||
if ($raw !== null && $parsed === null) {
|
||||
$totals['failed']++;
|
||||
}
|
||||
|
||||
[$changes, $historyLines] = $parsed
|
||||
? $this->resolveChanges($ticket, $parsed, $vocabulary, $priorities, $prompt['scope'])
|
||||
: [[], []];
|
||||
|
||||
if ($changes) {
|
||||
$this->tickets->applyAiTriage($ticket, $changes, $historyLines);
|
||||
$totals['changed']++;
|
||||
}
|
||||
|
||||
$ticket->update(['ai_triaged_at' => now()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{id: int, name: string, subcategories: array<int, array{id: int, name: string}>}>
|
||||
*/
|
||||
protected function buildVocabulary(): array
|
||||
{
|
||||
return Category::query()->with('subcategories')->get()
|
||||
->map(fn (Category $c) => [
|
||||
'id' => $c->id,
|
||||
'name' => $c->name,
|
||||
'subcategories' => $c->subcategories->map(fn ($s) => ['id' => $s->id, 'name' => $s->name])->all(),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines which of the 3 mutually-exclusive category scenarios (if
|
||||
* any) applies to $ticket's current state, and whether subject/priority
|
||||
* are also in scope, then builds the prompt around exactly that. Returns
|
||||
* null when nothing is applicable/enabled for this ticket, so the caller
|
||||
* can skip straight to stamping ai_triaged_at without an AI call.
|
||||
*
|
||||
* @param array<int, array{id: int, name: string, subcategories: array}> $vocabulary
|
||||
* @param array<string, string> $priorities
|
||||
* @return array{system: string, user: string, scope: ?string}|null
|
||||
*/
|
||||
protected function buildPrompt(Ticket $ticket, array $vocabulary, array $priorities): ?array
|
||||
{
|
||||
$scope = null;
|
||||
|
||||
if (! $ticket->category_id && ! $ticket->subcategory_id && Settings::bool('ai_triage_category_when_missing')) {
|
||||
$scope = 'missing';
|
||||
} elseif ($ticket->category_id && ! $ticket->subcategory_id && Settings::bool('ai_triage_subcategory_when_category_only')) {
|
||||
$scope = 'category_only';
|
||||
} elseif ($ticket->subcategory_id && Settings::bool('ai_triage_recheck_categorized')) {
|
||||
$scope = 'recheck';
|
||||
}
|
||||
|
||||
$wantSubject = Settings::bool('ai_triage_fix_subject');
|
||||
$wantPriority = Settings::bool('ai_triage_set_priority');
|
||||
|
||||
if ($scope === null && ! $wantSubject && ! $wantPriority) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parts = ['Klasyfikujesz zgłoszenia helpdesku na podstawie tematu i treści.'];
|
||||
|
||||
if ($scope === 'missing') {
|
||||
$parts[] = 'To zgłoszenie nie ma jeszcze przypisanej kategorii ani podkategorii. Wybierz najlepiej '
|
||||
.'pasującą kategorię z listy poniżej (użyj DOKŁADNIE tej pisowni) i, jeśli to możliwe, także '
|
||||
.'konkretną podkategorię w jej ramach. Jeśli żadna kategoria sensownie nie pasuje, zwróć null dla obu pól.';
|
||||
} elseif ($scope === 'category_only') {
|
||||
$parts[] = "To zgłoszenie ma już przypisaną kategorię \"{$ticket->category->name}\", ale brak konkretnej "
|
||||
.'podkategorii. Wybierz najlepiej pasującą podkategorię z listy poniżej (należącą do tej kategorii, '
|
||||
.'użyj DOKŁADNIE tej pisowni). Jeśli żadna nie pasuje dobrze, zwróć null.';
|
||||
} elseif ($scope === 'recheck') {
|
||||
$parts[] = "To zgłoszenie ma już przypisaną podkategorię \"{$ticket->subcategory->label()}\". Sprawdź, "
|
||||
.'czy to nadal najlepsze dopasowanie na podstawie treści. Jeśli tak — zwróć null (nic nie zmieniaj). '
|
||||
.'Jeśli lepiej pasuje inna kategoria/podkategoria z listy poniżej, zwróć ją.';
|
||||
}
|
||||
|
||||
if ($scope !== null) {
|
||||
$parts[] = "Dostępne kategorie i podkategorie:\n".$this->vocabularyText($vocabulary, $scope, $ticket);
|
||||
}
|
||||
|
||||
if ($wantSubject) {
|
||||
$parts[] = 'Jeśli obecny temat zgłoszenia jest niejasny lub mylący, zaproponuj lepszy, zwięzły temat po '
|
||||
.'polsku w polu "subject" (w przeciwnym razie null).';
|
||||
}
|
||||
|
||||
if ($wantPriority) {
|
||||
$priorityList = collect($priorities)->map(fn ($label, $key) => "{$key} ({$label})")->implode(', ');
|
||||
$parts[] = 'Na podstawie treści oceń priorytet zgłoszenia i zwróć jego klucz w polu "priority" — '
|
||||
."dostępne klucze: {$priorityList}.";
|
||||
}
|
||||
|
||||
$parts[] = 'Odpowiedz WYŁĄCZNIE obiektem JSON, bez żadnego innego tekstu ani formatowania: '
|
||||
.'{"category": "...", "subcategory": "...", "subject": "...", "priority": "..."} — pola, o które nie '
|
||||
.'proszono powyżej, ustaw na null.';
|
||||
|
||||
$user = "Temat: {$ticket->subject}\n\nTreść:\n".Str::limit(strip_tags($ticket->body), self::BODY_EXCERPT_CHARS);
|
||||
|
||||
return ['system' => implode("\n\n", $parts), 'user' => $user, 'scope' => $scope];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{id: int, name: string, subcategories: array<int, array{id: int, name: string}>}> $vocabulary
|
||||
*/
|
||||
protected function vocabularyText(array $vocabulary, string $scope, Ticket $ticket): string
|
||||
{
|
||||
$categories = $scope === 'category_only'
|
||||
? collect($vocabulary)->filter(fn (array $c) => $c['id'] === $ticket->category_id)
|
||||
: collect($vocabulary);
|
||||
|
||||
return $categories
|
||||
->map(fn (array $c) => "- {$c['name']}: ".collect($c['subcategories'])->pluck('name')->implode(', '))
|
||||
->implode("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Same defensive-parsing shape as BookStackContentTagger::parseAssignments()
|
||||
* — extracts the first {...} block before decoding, so prose-wrapped or
|
||||
* malformed responses fail gracefully instead of crashing the run.
|
||||
*/
|
||||
protected function parseResponse(?string $raw): ?array
|
||||
{
|
||||
if (! $raw || ! preg_match('/\{.*\}/s', $raw, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode($matches[0], true);
|
||||
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail-closed resolution: every value from the model is matched against
|
||||
* the real vocabulary/priority list before being trusted — an
|
||||
* unmatched/hallucinated category, a subcategory outside its claimed
|
||||
* category, or an unknown priority key is silently dropped rather than
|
||||
* written to the ticket. Only fields whose resolved value actually
|
||||
* differs from the ticket's current value produce a change + history
|
||||
* line, so e.g. a "recheck" that confirms the existing subcategory
|
||||
* leaves no trace.
|
||||
*
|
||||
* @param array<int, array{id: int, name: string, subcategories: array<int, array{id: int, name: string}>}> $vocabulary
|
||||
* @param array<string, string> $priorities
|
||||
* @return array{0: array<string, mixed>, 1: string[]}
|
||||
*/
|
||||
protected function resolveChanges(Ticket $ticket, array $parsed, array $vocabulary, array $priorities, ?string $scope): array
|
||||
{
|
||||
$changes = [];
|
||||
$historyLines = [];
|
||||
|
||||
if ($scope !== null) {
|
||||
$resolved = $this->resolveCategory($parsed, $vocabulary, $scope, $ticket);
|
||||
|
||||
if ($resolved) {
|
||||
[$categoryId, $subcategoryId, $label] = $resolved;
|
||||
|
||||
if ($categoryId !== $ticket->category_id || $subcategoryId !== $ticket->subcategory_id) {
|
||||
$changes['category_id'] = $categoryId;
|
||||
$changes['subcategory_id'] = $subcategoryId;
|
||||
$historyLines[] = "Kategoria zmieniona na: {$label}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Settings::bool('ai_triage_fix_subject') && ! empty($parsed['subject']) && is_string($parsed['subject'])) {
|
||||
$newSubject = Str::limit(trim($parsed['subject']), 255, '');
|
||||
|
||||
if ($newSubject !== '' && $newSubject !== $ticket->subject) {
|
||||
$changes['subject'] = $newSubject;
|
||||
$historyLines[] = "Temat zmieniony na: „{$newSubject}”";
|
||||
}
|
||||
}
|
||||
|
||||
if (Settings::bool('ai_triage_set_priority') && ! empty($parsed['priority']) && is_string($parsed['priority'])) {
|
||||
$key = collect($priorities)->keys()->first(fn ($k) => Str::lower($k) === Str::lower($parsed['priority']));
|
||||
|
||||
if ($key !== null && $key !== $ticket->priority_key) {
|
||||
$changes['priority_key'] = $key;
|
||||
$historyLines[] = 'Priorytet zmieniony na: '.Priority::labelFor($key);
|
||||
}
|
||||
}
|
||||
|
||||
return [$changes, $historyLines];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{id: int, name: string, subcategories: array<int, array{id: int, name: string}>}> $vocabulary
|
||||
* @return array{0: ?int, 1: ?int, 2: string}|null [category_id, subcategory_id, display label]
|
||||
*/
|
||||
protected function resolveCategory(array $parsed, array $vocabulary, string $scope, Ticket $ticket): ?array
|
||||
{
|
||||
$categories = $scope === 'category_only'
|
||||
? collect($vocabulary)->filter(fn (array $c) => $c['id'] === $ticket->category_id)
|
||||
: collect($vocabulary);
|
||||
|
||||
$subName = $parsed['subcategory'] ?? null;
|
||||
|
||||
if (is_string($subName) && $subName !== '') {
|
||||
foreach ($categories as $category) {
|
||||
foreach ($category['subcategories'] as $sub) {
|
||||
if (Str::lower($sub['name']) === Str::lower($subName)) {
|
||||
return [null, $sub['id'], "{$category['name']} / {$sub['name']}"];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$catName = $parsed['category'] ?? null;
|
||||
|
||||
if (is_string($catName) && $catName !== '') {
|
||||
foreach ($categories as $category) {
|
||||
if (Str::lower($category['name']) === Str::lower($catName)) {
|
||||
return [$category['id'], null, $category['name']];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,10 @@ class TicketService
|
||||
'email' => $customer?->email ?? $data['email'],
|
||||
'name' => $customer?->name ?? ($data['name'] ?? $data['email']),
|
||||
'subcategory_id' => $subcategory?->id,
|
||||
// category_id only ever carries a value when there's no
|
||||
// subcategory to derive one from (e.g. an IMAP mailbox routed to
|
||||
// a whole category rather than a specific subcategory).
|
||||
'category_id' => $subcategory ? null : ($data['category_id'] ?? null),
|
||||
'subject' => $data['subject'],
|
||||
'body' => $data['body'],
|
||||
'status_key' => Settings::get('default_status', 'new'),
|
||||
@@ -50,6 +54,9 @@ class TicketService
|
||||
'assignee_id' => $data['assignee_id'] ?? null,
|
||||
'custom_fields' => $data['custom_values'] ?? [],
|
||||
'last_customer_activity_at' => now(),
|
||||
'source' => $data['source'] ?? 'web',
|
||||
'snipeit_asset_id' => $data['snipeit_asset_id'] ?? null,
|
||||
'snipeit_asset_name' => $data['snipeit_asset_name'] ?? null,
|
||||
]);
|
||||
|
||||
$message = $ticket->messages()->create([
|
||||
@@ -156,12 +163,79 @@ class TicketService
|
||||
TicketQueueChanged::dispatch($ticket->id, 'team_changed', Auth::id());
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies one AI-triage pass's changes (see TicketAiTriageService) in a
|
||||
* single update, rather than composing setPriority()/updateDetails() —
|
||||
* a pass can touch category, subcategory, subject and priority
|
||||
* together, and those would each write their own generic history line
|
||||
* and fire notify()/TriggerEngine::handle() per field instead of once
|
||||
* per pass, fragmenting one semantic AI decision into several
|
||||
* unrelated-looking edits. $historyLines carries one mechanical
|
||||
* "X changed to: Y" line per changed field (built by the caller, since
|
||||
* it already knows the human-readable labels); this always appends one
|
||||
* more attribution line on top, mirroring how RunAutomationRules logs
|
||||
* "Automatyzacja: {label}" after its own field-change lines.
|
||||
*
|
||||
* @param array<string, mixed> $changes column => value, only the fields that actually changed
|
||||
* @param string[] $historyLines
|
||||
*/
|
||||
public function applyAiTriage(Ticket $ticket, array $changes, array $historyLines): void
|
||||
{
|
||||
if (! $changes) {
|
||||
return;
|
||||
}
|
||||
|
||||
$categoryChanged = array_key_exists('category_id', $changes) || array_key_exists('subcategory_id', $changes);
|
||||
$priorityChanged = array_key_exists('priority_key', $changes);
|
||||
|
||||
$ticket->update($changes);
|
||||
|
||||
foreach ($historyLines as $line) {
|
||||
$ticket->addHistory($line);
|
||||
}
|
||||
$ticket->addHistory('Automatyzacja: klasyfikacja AI');
|
||||
|
||||
if ($categoryChanged) {
|
||||
$this->notify($ticket, 'category_changed');
|
||||
app(TriggerEngine::class)->handle($ticket, 'category_changed');
|
||||
}
|
||||
|
||||
if ($priorityChanged) {
|
||||
$this->notify($ticket, 'priority_changed');
|
||||
app(TriggerEngine::class)->handle($ticket, 'priority_changed');
|
||||
}
|
||||
|
||||
app(TriggerEngine::class)->handle($ticket, 'ticket_updated');
|
||||
TicketQueueChanged::dispatch($ticket->id, 'ai_triage', Auth::id());
|
||||
}
|
||||
|
||||
public function setReporter(Ticket $ticket, User $customer): void
|
||||
{
|
||||
$ticket->update(['customer_id' => $customer->id, 'email' => $customer->email, 'name' => $customer->name]);
|
||||
$ticket->addHistory('Zgłaszający zmieniony na: '.$customer->name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Links/unlinks the Snipe-IT asset attached to a ticket — $asset null
|
||||
* unlinks. Only the label (not live status/assignment) is cached on the
|
||||
* ticket row, so it still shows something if Snipe-IT later becomes
|
||||
* unreachable or the asset is deleted there, without a live API call on
|
||||
* every ticket list render (see SnipeItClient::asset() for the live
|
||||
* fetch used on the ticket-detail page itself).
|
||||
*
|
||||
* @param array{id: int, label: string}|null $asset
|
||||
*/
|
||||
public function setSnipeitAsset(Ticket $ticket, ?array $asset): void
|
||||
{
|
||||
$ticket->update([
|
||||
'snipeit_asset_id' => $asset['id'] ?? null,
|
||||
'snipeit_asset_name' => $asset['label'] ?? null,
|
||||
]);
|
||||
$ticket->addHistory($asset
|
||||
? 'Powiązano sprzęt (inwentarz): '.$asset['label']
|
||||
: 'Odpięto powiązany sprzęt (inwentarz)');
|
||||
}
|
||||
|
||||
public function updateDetails(Ticket $ticket, array $data): void
|
||||
{
|
||||
$categoryChanged = ($data['subcategory_id'] ?? null) !== $ticket->subcategory_id;
|
||||
@@ -213,11 +287,12 @@ class TicketService
|
||||
TicketMessagePosted::dispatch($ticket->id, $message->id, true, $operator->id);
|
||||
}
|
||||
|
||||
public function clientReply(Ticket $ticket, User $client, string $body, array $attachments = []): void
|
||||
public function clientReply(Ticket $ticket, User $client, string $body, array $attachments = [], string $source = 'web'): void
|
||||
{
|
||||
$message = $ticket->messages()->create([
|
||||
'author_name' => $client->name,
|
||||
'body' => $body,
|
||||
'source' => $source === 'web' ? null : $source,
|
||||
]);
|
||||
$message->attachAuthor($client->id, 'client');
|
||||
$ticket->touch();
|
||||
@@ -238,6 +313,36 @@ class TicketService
|
||||
TicketQueueChanged::dispatch($ticket->id, 'message_posted', $client->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* A reply from a customer with no User account — e.g. an e-mail reply
|
||||
* from an address the IMAP fetcher couldn't resolve to a local/LDAP
|
||||
* user. Mirrors clientReply() (real customer activity: resets SLA
|
||||
* silence, fires comment_added so an admin-configured Trigger can reopen
|
||||
* a closed ticket) rather than apiMessage() (attachAuthor(null, null) —
|
||||
* a system/integration note, not client content). attachAuthor(null,
|
||||
* 'client') matches how create() already tags a guest's opening message.
|
||||
*/
|
||||
public function guestReply(Ticket $ticket, string $authorName, string $body, array $attachments = [], string $source = 'web'): TicketMessage
|
||||
{
|
||||
$message = $ticket->messages()->create([
|
||||
'author_name' => $authorName,
|
||||
'body' => $body,
|
||||
'source' => $source === 'web' ? null : $source,
|
||||
]);
|
||||
$message->attachAuthor(null, 'client');
|
||||
$ticket->touch();
|
||||
$this->attachFiles($ticket, $message, $attachments);
|
||||
|
||||
$ticket->update(['last_customer_activity_at' => now()]);
|
||||
$ticket->automationRuleLogs()->delete();
|
||||
|
||||
app(TriggerEngine::class)->handle($ticket, 'comment_added');
|
||||
TicketMessagePosted::dispatch($ticket->id, $message->id, false, null);
|
||||
TicketQueueChanged::dispatch($ticket->id, 'message_posted', null);
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
public function toggleWatch(Ticket $ticket, User $user): bool
|
||||
{
|
||||
if ($ticket->isWatchedBy($user)) {
|
||||
@@ -328,7 +433,7 @@ class TicketService
|
||||
|
||||
$primary->messages()->create([
|
||||
'author_name' => 'System',
|
||||
'body' => 'Scalono zgłoszenia: '.$others->map(fn (Ticket $o) => '#'.$o->number)->implode(', '),
|
||||
'body' => 'Scalono zgłoszenia: '.$others->map(fn (Ticket $o) => $o->displayNumber())->implode(', '),
|
||||
]);
|
||||
|
||||
foreach ($others as $other) {
|
||||
@@ -346,7 +451,7 @@ class TicketService
|
||||
$note = $other->messages()->create([
|
||||
'author_name' => 'System',
|
||||
'internal' => true,
|
||||
'body' => 'Scalone ze zgłoszeniem #'.$primary->number,
|
||||
'body' => 'Scalone ze zgłoszeniem '.$primary->displayNumber(),
|
||||
]);
|
||||
$note->attachAuthor(null, 'operator');
|
||||
TicketQueueChanged::dispatch($other->id, 'merged', Auth::id());
|
||||
|
||||
49
src/app/Support/Imap/InboundEmail.php
Normal file
49
src/app/Support/Imap/InboundEmail.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Imap;
|
||||
|
||||
/**
|
||||
* Normalized view of one inbound message, independent of the IMAP client
|
||||
* library — the seam between ImapMailboxFetcher (I/O, effectively
|
||||
* untestable without a real mailbox) and ImapMessageClassifier (pure
|
||||
* decision logic, fully Pest-testable against hand-built instances).
|
||||
*/
|
||||
class InboundEmail
|
||||
{
|
||||
/**
|
||||
* @param array<string, string> $headers lower-cased header names
|
||||
* @param array<int, array{filename: string, mime: string, content: string}> $attachments
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $fromEmail,
|
||||
public readonly string $fromName,
|
||||
public readonly string $subject,
|
||||
public readonly string $textBody,
|
||||
public readonly string $htmlBody,
|
||||
public readonly array $headers,
|
||||
public readonly array $attachments = [],
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Treats an empty string the same as an absent header — the IMAP
|
||||
* library backing ImapMailboxFetcher represents "header not present" as
|
||||
* an empty value rather than a missing array key in some cases, so
|
||||
* callers checking `header($x) !== null` alone would otherwise
|
||||
* misdetect every message as carrying every header.
|
||||
*/
|
||||
public function header(string $name): ?string
|
||||
{
|
||||
$value = $this->headers[strtolower($name)] ?? null;
|
||||
|
||||
return $value !== null && $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
public function body(): string
|
||||
{
|
||||
if (trim($this->textBody) !== '') {
|
||||
return $this->textBody;
|
||||
}
|
||||
|
||||
return trim(html_entity_decode(strip_tags($this->htmlBody)));
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,16 @@ class Settings
|
||||
'attachment_allowed_types' => 'jpg,jpeg,png,pdf,doc,docx,xls,xlsx,zip,txt',
|
||||
'session_lifetime_minutes' => '120',
|
||||
'timezone' => 'UTC',
|
||||
'ticket_number_prefix' => '#',
|
||||
'ticket_number_obfuscate' => '0',
|
||||
'ticket_number_min_length' => '4',
|
||||
'refresh_ticket_view_seconds' => '30',
|
||||
'refresh_queue_seconds' => '60',
|
||||
'refresh_notifications_seconds' => '30',
|
||||
'schedule_sla_check_minutes' => '15',
|
||||
'schedule_automation_rules_minutes' => '15',
|
||||
'schedule_imap_fetch_minutes' => '5',
|
||||
'schedule_ai_automation_minutes' => '5',
|
||||
'ldap_enabled' => '1',
|
||||
'ldap_host' => '',
|
||||
'ldap_port' => '389',
|
||||
@@ -45,9 +55,37 @@ class Settings
|
||||
'bookstack_token_secret' => '',
|
||||
'bookstack_verify_ssl' => '1',
|
||||
'bookstack_show_to_guests' => '0',
|
||||
'bookstack_search_types' => 'both',
|
||||
'bookstack_search_types' => 'book,page,chapter',
|
||||
'bookstack_search_by' => 'both',
|
||||
'bookstack_allowed_shelf_ids_creation' => '',
|
||||
'bookstack_allowed_shelf_ids_ticket_view' => '',
|
||||
'snipeit_enabled' => '0',
|
||||
'snipeit_base_url' => '',
|
||||
'snipeit_api_token' => '',
|
||||
'snipeit_verify_ssl' => '1',
|
||||
'snipeit_client_can_select_asset' => '0',
|
||||
'snipeit_client_asset_subcategory_ids' => '',
|
||||
'snipeit_operator_view_requester_assets' => '1',
|
||||
'snipeit_operator_search_inventory' => '1',
|
||||
'ai_enabled' => '0',
|
||||
'ai_base_url' => '',
|
||||
'ai_api_key' => '',
|
||||
'ai_model' => '',
|
||||
'ai_verify_ssl' => '1',
|
||||
'ai_triage_category_when_missing' => '0',
|
||||
'ai_triage_subcategory_when_category_only' => '0',
|
||||
'ai_triage_recheck_categorized' => '0',
|
||||
'ai_triage_fix_subject' => '0',
|
||||
'ai_triage_set_priority' => '0',
|
||||
'ai_summary_enabled' => '0',
|
||||
'ai_summary_regenerate_on_message' => '0',
|
||||
'ai_summary_prompt' => 'Jesteś asystentem operatora helpdesku. Otrzymujesz temat, treść oraz historię '
|
||||
.'wiadomości zgłoszenia. Podsumuj sprawę rzeczowo po polsku (2-3 zdania, czego dotyczy problem i na '
|
||||
.'jakim jest etapie — np. czeka na odpowiedź klienta czy na działanie operatora) i zaproponuj krótką, '
|
||||
.'konkretną kolejną akcję (jedno zdanie), np. "Poproś klienta o zrzut ekranu błędu" albo "Zamknij '
|
||||
.'zgłoszenie — klient potwierdził rozwiązanie". Odpowiedz WYŁĄCZNIE obiektem JSON, bez innego tekstu: '
|
||||
.'{"summary": "...", "suggested_action": "..."}. Jeśli nie da się ocenić kolejnego kroku, ustaw '
|
||||
.'"suggested_action" na pusty string.',
|
||||
'email_footer' => '<p>Ta wiadomość została wygenerowana automatycznie przez system {firma} — prosimy na nią nie odpowiadać.</p>',
|
||||
'accent_color' => '#7c6fd6',
|
||||
'login_notice_type' => 'info',
|
||||
@@ -60,7 +98,7 @@ class Settings
|
||||
.'</div>',
|
||||
];
|
||||
|
||||
protected static array $encrypted = ['ldap_bind_password', 'mail_smtp_password', 'bookstack_token_secret'];
|
||||
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
|
||||
{
|
||||
@@ -206,6 +244,24 @@ class Settings
|
||||
: static::$defaults['timezone'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an admin-configurable "every N minutes" scheduled command
|
||||
* (schedule_*_minutes, routes/console.php) is due to run this minute.
|
||||
* Used inside a Schedule::command(...)->when(...) closure rather than
|
||||
* building a cron string up front — a closure is only evaluated when
|
||||
* schedule:run actually processes due events, whereas eagerly reading
|
||||
* Settings (which queries the DB) at routes/console.php's top level
|
||||
* would run on every artisan boot (migrate, tinker, tests, ...), before
|
||||
* the settings table necessarily even exists. Clamped to a minimum of 1
|
||||
* so a blank/zero/negative stored value can never busy-loop.
|
||||
*/
|
||||
public static function dueEveryMinutes(string $key, int $default): bool
|
||||
{
|
||||
$minutes = max(1, (int) static::get($key, (string) $default));
|
||||
|
||||
return now()->minute % $minutes === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a single e-mail template's rendered HTML body in the fixed
|
||||
* "box" layout — company name, ticket content and footer — so every
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\EnsureRole;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Http\Request;
|
||||
use Laravel\Sanctum\Http\Middleware\CheckAbilities;
|
||||
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
@@ -44,4 +46,29 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
$exceptions->shouldRenderJsonWhen(
|
||||
fn (Request $request) => $request->is('api/*'),
|
||||
);
|
||||
|
||||
// A ticket deleted mid-session (typically by the operator/client
|
||||
// currently viewing it) leaves any later request for that same
|
||||
// {ticket} route binding 404ing — most commonly Livewire's own
|
||||
// "model missing during hydration" recovery, which does a full
|
||||
// window.location.reload() of the very page whose ticket just
|
||||
// disappeared (e.g. the ticket-show view's periodic fallback
|
||||
// refresh polling a few seconds after a delete+redirect). Land back
|
||||
// on that area's own list page instead of a raw 404.
|
||||
//
|
||||
// Handler::prepareException() already converts ModelNotFoundException
|
||||
// into NotFoundHttpException (wrapping the original as getPrevious())
|
||||
// before any render() callback is dispatched — a callback typed
|
||||
// against ModelNotFoundException itself would simply never match.
|
||||
$exceptions->render(function (NotFoundHttpException $e, Request $request) {
|
||||
if (! $e->getPrevious() instanceof ModelNotFoundException || ! $request->user()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match (true) {
|
||||
$request->is('operator/*') => redirect()->route('operator.queue'),
|
||||
$request->is('client/*') => redirect()->route('client.dashboard'),
|
||||
default => null,
|
||||
};
|
||||
});
|
||||
})->create();
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
"laravel/reverb": "*",
|
||||
"laravel/sanctum": "*",
|
||||
"laravel/tinker": "^3.0",
|
||||
"livewire/livewire": "*"
|
||||
"livewire/livewire": "*",
|
||||
"webklex/php-imap": "*"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
|
||||
83
src/composer.lock
generated
83
src/composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "321add40614eb8751e0c8dbda55016eb",
|
||||
"content-hash": "abe8bd31e8d8849ae593e562f73a39df",
|
||||
"packages": [
|
||||
{
|
||||
"name": "brick/math",
|
||||
@@ -7593,6 +7593,87 @@
|
||||
],
|
||||
"time": "2026-04-26T05:33:54+00:00"
|
||||
},
|
||||
{
|
||||
"name": "webklex/php-imap",
|
||||
"version": "6.2.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Webklex/php-imap.git",
|
||||
"reference": "6b8ef85d621bbbaf52741b00cca8e9237e2b2e05"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Webklex/php-imap/zipball/6b8ef85d621bbbaf52741b00cca8e9237e2b2e05",
|
||||
"reference": "6b8ef85d621bbbaf52741b00cca8e9237e2b2e05",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-fileinfo": "*",
|
||||
"ext-iconv": "*",
|
||||
"ext-json": "*",
|
||||
"ext-libxml": "*",
|
||||
"ext-mbstring": "*",
|
||||
"ext-openssl": "*",
|
||||
"ext-zip": "*",
|
||||
"illuminate/pagination": ">=5.0.0",
|
||||
"nesbot/carbon": "^2.62.1|^3.2.4",
|
||||
"php": "^8.0.2",
|
||||
"symfony/http-foundation": ">=2.8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^9.5.10"
|
||||
},
|
||||
"suggest": {
|
||||
"symfony/mime": "Recomended for better extension support",
|
||||
"symfony/var-dumper": "Usefull tool for debugging"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "6.0-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Webklex\\PHPIMAP\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Malte Goldenbaum",
|
||||
"email": "github@webklex.com",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "PHP IMAP client",
|
||||
"homepage": "https://github.com/webklex/php-imap",
|
||||
"keywords": [
|
||||
"imap",
|
||||
"mail",
|
||||
"php-imap",
|
||||
"pop3",
|
||||
"webklex"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Webklex/php-imap/issues",
|
||||
"source": "https://github.com/Webklex/php-imap/tree/6.2.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://www.buymeacoffee.com/webklex",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://ko-fi.com/webklex",
|
||||
"type": "ko_fi"
|
||||
}
|
||||
],
|
||||
"time": "2025-04-25T06:02:37+00:00"
|
||||
},
|
||||
{
|
||||
"name": "zircote/swagger-php",
|
||||
"version": "6.4.0",
|
||||
|
||||
@@ -73,6 +73,19 @@ return [
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
// Dedicated, always-verbose channel for the IMAP fetcher
|
||||
// (emails:fetch-imap) — kept separate from 'single'/LOG_LEVEL so a
|
||||
// production app typically running at LOG_LEVEL=error still gets
|
||||
// full visibility into what the fetcher did on every run, without
|
||||
// that verbosity going into the main laravel.log.
|
||||
'imap' => [
|
||||
'driver' => 'daily',
|
||||
'path' => storage_path('logs/imap.log'),
|
||||
'level' => 'debug',
|
||||
'days' => 14,
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'slack' => [
|
||||
'driver' => 'slack',
|
||||
'url' => env('LOG_SLACK_WEBHOOK_URL'),
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('tickets', function (Blueprint $table) {
|
||||
$table->string('checksum', 20)->nullable()->unique()->after('number');
|
||||
});
|
||||
|
||||
// Backfill: every existing ticket gets a stable, HMAC-derived
|
||||
// checksum (mirrors Ticket::generateUniqueChecksum()) so the
|
||||
// "hide ticket order" numbering mode has a real, unique, indexed
|
||||
// column to resolve ticket URLs against instead of only being a
|
||||
// display-time computation.
|
||||
$assigned = [];
|
||||
|
||||
DB::table('tickets')->orderBy('id')->select('id')->chunkById(500, function ($tickets) use (&$assigned) {
|
||||
foreach ($tickets as $ticket) {
|
||||
$nonce = 0;
|
||||
|
||||
do {
|
||||
$hash = hash_hmac('sha256', $ticket->id.'|'.$nonce, (string) config('app.key'));
|
||||
$candidate = (string) (hexdec(substr($hash, 0, 8)) % 900000 + 100000);
|
||||
$nonce++;
|
||||
} while (isset($assigned[$candidate]));
|
||||
|
||||
$assigned[$candidate] = true;
|
||||
|
||||
DB::table('tickets')->where('id', $ticket->id)->update(['checksum' => $candidate]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('tickets', function (Blueprint $table) {
|
||||
$table->dropColumn('checksum');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('imap_mailboxes', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->boolean('enabled')->default(false);
|
||||
$table->string('host');
|
||||
$table->unsignedSmallInteger('port')->default(993);
|
||||
$table->string('encryption')->default('ssl');
|
||||
$table->boolean('validate_cert')->default(true);
|
||||
$table->string('username');
|
||||
$table->text('password')->nullable();
|
||||
$table->string('folder')->default('INBOX');
|
||||
$table->string('processed_folder')->nullable();
|
||||
$table->string('rejected_folder')->nullable();
|
||||
$table->foreignId('default_subcategory_id')->nullable()->constrained('subcategories')->nullOnDelete();
|
||||
$table->string('blocklist_senders')->default('mailer-daemon,postmaster,no-reply,noreply');
|
||||
$table->timestamp('last_checked_at')->nullable();
|
||||
$table->text('last_error')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('imap_mailboxes');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* category_id lets a ticket carry just a Category with no specific
|
||||
* Subcategory (e.g. an IMAP mailbox routed to "całą kategorię" rather
|
||||
* than one subcategory) — subcategory_id already implies a category via
|
||||
* its own relation, so category_id is only ever populated when there's
|
||||
* no subcategory to derive it from (see Ticket::categoryLabel()).
|
||||
*
|
||||
* source records how the ticket was created (web/e-mail/...), surfaced
|
||||
* as a badge in the operator queue/ticket view.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('tickets', function (Blueprint $table) {
|
||||
$table->foreignId('category_id')->nullable()->after('subcategory_id')->constrained('categories')->nullOnDelete();
|
||||
$table->string('source')->default('web')->after('api_client_id');
|
||||
});
|
||||
|
||||
Schema::table('imap_mailboxes', function (Blueprint $table) {
|
||||
$table->foreignId('default_category_id')->nullable()->after('default_subcategory_id')->constrained('categories')->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('imap_mailboxes', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('default_category_id');
|
||||
});
|
||||
|
||||
Schema::table('tickets', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('category_id');
|
||||
$table->dropColumn('source');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Mirrors tickets.source at the individual-message level — a ticket
|
||||
* created on the web can still later receive a reply by e-mail (or vice
|
||||
* versa), so this needs tracking per message, not just per ticket.
|
||||
* Null means "web" (the original/default channel); only IMAP-originated
|
||||
* messages ever set it to 'email'.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('ticket_messages', function (Blueprint $table) {
|
||||
$table->string('source')->nullable()->after('api_client_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('ticket_messages', function (Blueprint $table) {
|
||||
$table->dropColumn('source');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* ai_triaged_at marks that the AI auto-triage pass has run for this
|
||||
* ticket (regardless of whether it changed anything) — never reset, so
|
||||
* the scheduled command's query is just "tickets where this is null".
|
||||
*
|
||||
* ai_summary/ai_suggested_action/ai_summary_generated_at cache the AI
|
||||
* ticket summary shown to operators; generated_at lets the summary
|
||||
* command cheaply tell whether a ticket's summary is stale relative to
|
||||
* its latest message, without re-summarizing every ticket every run.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('tickets', function (Blueprint $table) {
|
||||
$table->timestamp('ai_triaged_at')->nullable()->after('source');
|
||||
$table->text('ai_summary')->nullable()->after('ai_triaged_at');
|
||||
$table->text('ai_suggested_action')->nullable()->after('ai_summary');
|
||||
$table->timestamp('ai_summary_generated_at')->nullable()->after('ai_suggested_action');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('tickets', function (Blueprint $table) {
|
||||
$table->dropColumn(['ai_triaged_at', 'ai_summary', 'ai_suggested_action', 'ai_summary_generated_at']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('subcategories', function (Blueprint $table) {
|
||||
$table->unsignedInteger('sort_order')->default(0)->after('default_priority_key');
|
||||
});
|
||||
|
||||
foreach (DB::table('subcategories')->orderBy('category_id')->orderBy('id')->get() as $position => $sub) {
|
||||
DB::table('subcategories')->where('id', $sub->id)->update(['sort_order' => $position]);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('subcategories', function (Blueprint $table) {
|
||||
$table->dropColumn('sort_order');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* snipeit_asset_name is a cached label (asset tag + name/model) captured
|
||||
* at link time — kept alongside the id so the ticket list/header still
|
||||
* shows something meaningful if Snipe-IT is unreachable or the asset was
|
||||
* later deleted there, without depending on a live API call.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('tickets', function (Blueprint $table) {
|
||||
$table->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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -2,24 +2,12 @@
|
||||
|
||||
@php
|
||||
$url = \Illuminate\Support\Facades\Storage::disk('public')->url($attachment->path);
|
||||
$isImage = \Illuminate\Support\Str::startsWith($attachment->mime ?? '', 'image/');
|
||||
@endphp
|
||||
|
||||
@if ($isImage)
|
||||
<a href="{{ $url }}" target="_blank" style="display:block;margin-top:8px">
|
||||
<img
|
||||
src="{{ $url }}"
|
||||
alt="{{ $attachment->original_name }}"
|
||||
loading="lazy"
|
||||
style="max-width:220px;max-height:160px;border-radius:8px;border:1px solid var(--color-divider);object-fit:cover;cursor:zoom-in;display:block"
|
||||
>
|
||||
</a>
|
||||
@else
|
||||
<a
|
||||
<a
|
||||
href="{{ $url }}"
|
||||
target="_blank"
|
||||
style="display:inline-flex;align-items:center;gap:6px;margin-top:8px;padding:5px 10px;border:1px solid var(--color-divider);border-radius:6px;font-size:12.5px;color:inherit;text-decoration:none;background:color-mix(in srgb, var(--color-text) 5%, transparent)"
|
||||
>
|
||||
>
|
||||
<span class="material-symbols-outlined" style="font-size:15px">attach_file</span>{{ $attachment->original_name }}
|
||||
</a>
|
||||
@endif
|
||||
</a>
|
||||
|
||||
64
src/resources/views/components/snipeit-assets.blade.php
Normal file
64
src/resources/views/components/snipeit-assets.blade.php
Normal file
@@ -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)
|
||||
<div class="card" style="{{ $isSidebar ? 'padding:16px;gap:8px' : 'padding:14px;gap:10px;background:color-mix(in srgb, var(--color-accent) 6%, transparent);border-color:color-mix(in srgb, var(--color-accent) 25%, var(--color-divider))' }}">
|
||||
@if ($isSidebar)
|
||||
<div class="card-kicker">{{ $title }}</div>
|
||||
@else
|
||||
<div style="display:flex;align-items:center;gap:6px;font-size:12.5px;font-weight:600">
|
||||
<span class="material-symbols-outlined" style="font-size:16px">devices</span>
|
||||
{{ $title }}
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
<div style="display:flex;flex-direction:column;gap:2px">
|
||||
@foreach ($assets as $a)
|
||||
@php $isSelected = $selectedId === $a['id']; @endphp
|
||||
<div style="display:flex;gap:8px;align-items:center;padding:8px;border-radius:6px;{{ $isSelected ? 'background:color-mix(in srgb, var(--color-accent) 10%, transparent)' : '' }}">
|
||||
<a
|
||||
href="{{ $a['url'] }}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style="display:flex;gap:10px;align-items:flex-start;flex:1;min-width:0;text-decoration:none;color:inherit"
|
||||
>
|
||||
<span class="material-symbols-outlined" style="font-size:18px;flex:none;margin-top:1px;color:var(--color-accent)">devices</span>
|
||||
<span style="min-width:0;flex:1">
|
||||
<span style="display:block;font-size:13px;font-weight:500;{{ $isSelected ? 'color:var(--color-accent)' : '' }}">{{ $a['label'] }}</span>
|
||||
@if (! empty($a['category']))
|
||||
<span style="display:block;font-size:11px;color:color-mix(in srgb, var(--color-text) 55%, transparent);margin-top:1px">{{ $a['category'] }}</span>
|
||||
@endif
|
||||
</span>
|
||||
</a>
|
||||
@if ($selectable)
|
||||
@if ($isSelected)
|
||||
<span style="flex:none;display:flex;align-items:center;gap:4px;font-size:11px;color:var(--color-accent);white-space:nowrap">
|
||||
<span class="material-symbols-outlined" style="font-size:16px">check_circle</span>
|
||||
Powiązano
|
||||
</span>
|
||||
@else
|
||||
<button type="button" class="btn btn-secondary" style="flex:none;font-size:11px;padding:4px 8px;white-space:nowrap" wire:click="{{ $selectAction }}({{ $a['id'] }})">Powiąż</button>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@if ($card)
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
182
src/resources/views/livewire/admin/mail-settings.blade.php
Normal file
182
src/resources/views/livewire/admin/mail-settings.blade.php
Normal file
@@ -0,0 +1,182 @@
|
||||
<div>
|
||||
<h3 style="margin:0 0 14px">E-mail (SMTP)</h3>
|
||||
<form wire:submit="saveMailConfig" class="card" style="padding:20px;gap:14px;max-width:480px;margin-bottom:32px">
|
||||
<div class="field"><label>Adres nadawcy</label><input class="input" type="email" placeholder="wsparcie@firma.pl" wire:model="mailConfig.fromAddress"></div>
|
||||
<div class="field"><label>Nazwa nadawcy</label><input class="input" placeholder="Zespół Wsparcia" wire:model="mailConfig.fromName"></div>
|
||||
|
||||
<div class="hr"></div>
|
||||
|
||||
<label class="radio"><input type="checkbox" wire:model="mailConfig.smtpEnabled" style="position:static;opacity:1;width:auto;height:auto"><strong>Włącz wysyłkę przez własny serwer SMTP</strong></label>
|
||||
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Bez włączenia aplikacja wysyła pocztę zgodnie z konfiguracją środowiska (.env).</span>
|
||||
|
||||
@if ($mailConfig['smtpEnabled'])
|
||||
<div class="field"><label>Host SMTP</label><input class="input" placeholder="smtp.example.com" wire:model="mailConfig.smtpHost"></div>
|
||||
<div style="display:flex;gap:10px">
|
||||
<div class="field" style="flex:1"><label>Port</label><input class="input" type="number" placeholder="587" wire:model="mailConfig.smtpPort"></div>
|
||||
<div class="field" style="flex:1">
|
||||
<label>Szyfrowanie</label>
|
||||
<select class="input" wire:model="mailConfig.smtpEncryption">
|
||||
<option value="none">Brak</option>
|
||||
<option value="tls">STARTTLS</option>
|
||||
<option value="ssl">SSL/TLS</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field"><label>Użytkownik</label><input class="input" wire:model="mailConfig.smtpUsername"></div>
|
||||
<div class="field"><label>Hasło</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="mailConfig.smtpPassword"></div>
|
||||
|
||||
<div style="display:flex;gap:10px;margin-top:8px;align-items:center;flex-wrap:wrap">
|
||||
<button type="button" class="btn btn-secondary" wire:click="testMailConnection">Wyślij testową wiadomość</button>
|
||||
<button type="submit" class="btn btn-primary">Zapisz</button>
|
||||
@if ($mailTestResult === 'ok')
|
||||
<div style="display:flex;align-items:center;gap:6px;color:var(--color-success)"><span class="material-symbols-outlined" style="font-size:18px">check_circle</span>Wysłano na Twój adres</div>
|
||||
@elseif ($mailTestResult === 'error')
|
||||
<div style="display:flex;align-items:center;gap:6px;color:var(--color-danger)"><span class="material-symbols-outlined" style="font-size:18px">error</span>Błąd wysyłki</div>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<button type="submit" class="btn btn-primary" style="align-self:flex-start">Zapisz</button>
|
||||
@endif
|
||||
</form>
|
||||
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
|
||||
<h3 style="margin:0">Skrzynki IMAP (zgłoszenia i odpowiedzi przez e-mail)</h3>
|
||||
<button class="btn btn-primary" type="button" wire:click="openMailboxForm">+ Nowa skrzynka</button>
|
||||
</div>
|
||||
|
||||
<p class="text-muted" style="font-size:12.5px;margin:0 0 14px">
|
||||
Każda skrzynka jest sprawdzana co kilka minut — nowa wiadomość zakłada zgłoszenie w wybranej podkategorii (np. zgloszenia-it@firma.pl → IT), a odpowiedź na powiadomienie e-mail (temat zawiera numer zgłoszenia) trafia jako odpowiedź do istniejącego zgłoszenia. Automatyczne odpowiedzi (autorespondery, „poza biurem”, bounce) są odrzucane.
|
||||
</p>
|
||||
|
||||
@if ($this->mailboxes->isNotEmpty())
|
||||
<div class="table-wrap" style="margin-bottom:20px">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nazwa</th>
|
||||
<th>Serwer</th>
|
||||
<th>Użytkownik</th>
|
||||
<th>Kategoria / podkategoria</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($this->mailboxes as $mailbox)
|
||||
<tr>
|
||||
<td>{{ $mailbox->name }}</td>
|
||||
<td class="text-muted" style="white-space:nowrap">{{ $mailbox->host }}:{{ $mailbox->port }}</td>
|
||||
<td class="text-muted">{{ $mailbox->username }}</td>
|
||||
<td class="text-muted">{{ $mailbox->targetLabel() }}</td>
|
||||
<td>
|
||||
<button type="button" class="tag" style="border:none;cursor:pointer;background:color-mix(in srgb, var(--color-{{ $mailbox->enabled ? 'success' : 'danger' }}) 18%, transparent);color:var(--color-{{ $mailbox->enabled ? 'success' : 'danger' }})"
|
||||
wire:click="toggleMailboxEnabled({{ $mailbox->id }})">
|
||||
{{ $mailbox->enabled ? 'Włączona' : 'Wyłączona' }}
|
||||
</button>
|
||||
@if ($mailbox->last_error)
|
||||
<div style="color:var(--color-danger);font-size:11px;margin-top:4px">{{ $mailbox->last_error }}</div>
|
||||
@elseif ($mailbox->last_checked_at)
|
||||
<div class="text-muted" style="font-size:11px;margin-top:4px">Sprawdzono: {{ $mailbox->last_checked_at->format('Y-m-d H:i') }}</div>
|
||||
@endif
|
||||
@if ($mailboxFetchResultId === $mailbox->id)
|
||||
<div class="text-muted" style="font-size:11px;margin-top:4px">{{ $mailboxFetchSummary }}</div>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
<div style="display:flex;gap:6px;justify-content:flex-end">
|
||||
<button class="btn btn-ghost" type="button" wire:click="fetchMailboxNow({{ $mailbox->id }})" wire:loading.attr="disabled" wire:target="fetchMailboxNow({{ $mailbox->id }})">Pobierz teraz</button>
|
||||
<button class="btn btn-ghost" type="button" wire:click="editMailbox({{ $mailbox->id }})">Edytuj</button>
|
||||
<button class="btn btn-ghost" type="button" wire:click="removeMailbox({{ $mailbox->id }})" wire:confirm="Usunąć tę skrzynkę IMAP?">Usuń</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@else
|
||||
<p class="text-muted" style="font-size:13px">Brak skonfigurowanych skrzynek IMAP. Dodaj pierwszą używając przycisku wyżej.</p>
|
||||
@endif
|
||||
|
||||
@if ($mailboxFormOpen)
|
||||
<div class="dialog-backdrop">
|
||||
<form wire:submit="submitMailboxForm" class="dialog" style="max-width:520px;max-height:90vh;overflow-y:auto">
|
||||
<div class="dialog-title">{{ $mailboxForm['id'] ? 'Edytuj skrzynkę IMAP' : 'Nowa skrzynka IMAP' }}</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Nazwa (etykieta)</label>
|
||||
<input class="input" placeholder="np. Zgłoszenia IT" wire:model="mailboxForm.name">
|
||||
</div>
|
||||
@error('mailboxForm.name') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
|
||||
|
||||
<label class="radio"><input type="checkbox" wire:model="mailboxForm.enabled" style="position:static;opacity:1;width:auto;height:auto">Włączona</label>
|
||||
|
||||
<div class="hr"></div>
|
||||
|
||||
<div class="field"><label>Host IMAP</label><input class="input" placeholder="imap.firma.pl" wire:model="mailboxForm.host"></div>
|
||||
@error('mailboxForm.host') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
|
||||
|
||||
<div style="display:flex;gap:10px">
|
||||
<div class="field" style="flex:1"><label>Port</label><input class="input" type="number" wire:model="mailboxForm.port"></div>
|
||||
<div class="field" style="flex:1">
|
||||
<label>Szyfrowanie</label>
|
||||
<select class="input" wire:model="mailboxForm.encryption">
|
||||
<option value="ssl">SSL/TLS</option>
|
||||
<option value="tls">STARTTLS</option>
|
||||
<option value="none">Brak</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="radio"><input type="checkbox" wire:model="mailboxForm.validateCert" style="position:static;opacity:1;width:auto;height:auto">Weryfikuj certyfikat TLS</label>
|
||||
|
||||
<div class="field"><label>Adres skrzynki (login)</label><input class="input" placeholder="zgloszenia-it@firma.pl" wire:model="mailboxForm.username"></div>
|
||||
@error('mailboxForm.username') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
|
||||
<div class="field"><label>Hasło</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="mailboxForm.password"></div>
|
||||
|
||||
<div class="hr"></div>
|
||||
|
||||
<div class="field">
|
||||
<label>Kategoria / podkategoria nowych zgłoszeń</label>
|
||||
<select class="input" wire:model="mailboxForm.target">
|
||||
<option value="">Brak (zgłoszenie nieprzypisane)</option>
|
||||
@foreach ($this->categoryOptions as $category)
|
||||
<optgroup label="{{ $category['name'] }}">
|
||||
<option value="category:{{ $category['id'] }}">Cała kategoria: {{ $category['name'] }}</option>
|
||||
@foreach ($category['subcategories'] as $sub)
|
||||
<option value="subcategory:{{ $sub['id'] }}">{{ $sub['name'] }}</option>
|
||||
@endforeach
|
||||
</optgroup>
|
||||
@endforeach
|
||||
</select>
|
||||
<span class="text-muted" style="font-size:11.5px">Wybierz konkretną podkategorię (trafi też do jej zespołu) albo całą kategorię, jeśli nie chcesz przypisywać konkretnej podkategorii.</span>
|
||||
</div>
|
||||
|
||||
<div class="field"><label>Folder</label><input class="input" wire:model="mailboxForm.folder"></div>
|
||||
<div style="display:flex;gap:10px">
|
||||
<div class="field" style="flex:1"><label>Folder po przetworzeniu (opcjonalnie)</label><input class="input" placeholder="pozostaw puste = oznacz jako przeczytane" wire:model="mailboxForm.processedFolder"></div>
|
||||
<div class="field" style="flex:1"><label>Folder odrzuconych (opcjonalnie)</label><input class="input" placeholder="pozostaw puste = oznacz jako przeczytane" wire:model="mailboxForm.rejectedFolder"></div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Zablokowani nadawcy (dodatkowo do filtrów autoresponderów)</label>
|
||||
<input class="input" wire:model="mailboxForm.blocklistSenders">
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:4px">
|
||||
<button type="button" class="btn btn-secondary" wire:click="testMailboxConnection">Testuj połączenie</button>
|
||||
@if ($mailboxTestResult === 'ok')
|
||||
<div style="display:flex;align-items:center;gap:6px;color:var(--color-success)"><span class="material-symbols-outlined" style="font-size:18px">check_circle</span>Połączono</div>
|
||||
@elseif ($mailboxTestResult === 'error')
|
||||
<div style="display:flex;align-items:center;gap:6px;color:var(--color-danger);font-size:12.5px"><span class="material-symbols-outlined" style="font-size:18px">error</span>{{ $mailboxTestMessage }}</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="dialog-actions">
|
||||
<button class="btn btn-secondary" type="button" wire:click="closeMailboxForm">Anuluj</button>
|
||||
<button class="btn btn-primary" type="submit">Zapisz</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -17,7 +17,7 @@ $tabGroups = [
|
||||
],
|
||||
'Ustawienia' => [
|
||||
['key' => 'templates', 'label' => 'Szablony e-mail', 'icon' => 'mail'],
|
||||
['key' => 'email', 'label' => 'E-MAIL', 'icon' => 'forward_to_inbox'],
|
||||
['key' => 'email', 'label' => 'Poczta', 'icon' => 'forward_to_inbox'],
|
||||
['key' => 'branding', 'label' => 'Wygląd i branding', 'icon' => 'palette'],
|
||||
['key' => 'config', 'label' => 'Konfiguracja', 'icon' => 'settings'],
|
||||
['key' => 'integrations', 'label' => 'Integracje', 'icon' => 'hub'],
|
||||
@@ -85,7 +85,7 @@ $tabGroups = [
|
||||
<table class="table" style="margin:0;border-top:none">
|
||||
<thead><tr><th>Podkategoria</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
@foreach ($cat->subcategories as $sub)
|
||||
@foreach ($cat->subcategories as $i => $sub)
|
||||
<tr>
|
||||
<td>
|
||||
<div style="display:flex;flex-direction:column;gap:2px">
|
||||
@@ -97,7 +97,9 @@ $tabGroups = [
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div style="display:flex;gap:6px;justify-content:flex-end">
|
||||
<div style="display:flex;gap:6px;justify-content:flex-end;align-items:center">
|
||||
<button class="btn btn-icon" type="button" wire:click="moveSubcategoryUp({{ $sub->id }})" @disabled($i === 0) style="padding:2px;width:24px;height:24px" title="Przesuń wyżej"><span class="material-symbols-outlined" style="font-size:16px">arrow_upward</span></button>
|
||||
<button class="btn btn-icon" type="button" wire:click="moveSubcategoryDown({{ $sub->id }})" @disabled($i === $cat->subcategories->count() - 1) style="padding:2px;width:24px;height:24px" title="Przesuń niżej"><span class="material-symbols-outlined" style="font-size:16px">arrow_downward</span></button>
|
||||
<button class="btn btn-ghost" type="button" wire:click="openSubcategoryEditForm({{ $sub->id }})">Edytuj</button>
|
||||
<button class="btn btn-ghost" type="button" wire:click="removeSubcategory({{ $sub->id }})">Usuń</button>
|
||||
</div>
|
||||
@@ -483,45 +485,7 @@ $tabGroups = [
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 style="margin:0 0 14px">E-mail (SMTP)</h3>
|
||||
<form wire:submit="saveMailConfig" class="card" style="padding:20px;gap:14px;max-width:480px">
|
||||
<div class="field"><label>Adres nadawcy</label><input class="input" type="email" placeholder="wsparcie@firma.pl" wire:model="mailConfig.fromAddress"></div>
|
||||
<div class="field"><label>Nazwa nadawcy</label><input class="input" placeholder="Zespół Wsparcia" wire:model="mailConfig.fromName"></div>
|
||||
|
||||
<div class="hr"></div>
|
||||
|
||||
<label class="radio"><input type="checkbox" wire:model="mailConfig.smtpEnabled" style="position:static;opacity:1;width:auto;height:auto"><strong>Włącz wysyłkę przez własny serwer SMTP</strong></label>
|
||||
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Bez włączenia aplikacja wysyła pocztę zgodnie z konfiguracją środowiska (.env).</span>
|
||||
|
||||
@if ($mailConfig['smtpEnabled'])
|
||||
<div class="field"><label>Host SMTP</label><input class="input" placeholder="smtp.example.com" wire:model="mailConfig.smtpHost"></div>
|
||||
<div style="display:flex;gap:10px">
|
||||
<div class="field" style="flex:1"><label>Port</label><input class="input" type="number" placeholder="587" wire:model="mailConfig.smtpPort"></div>
|
||||
<div class="field" style="flex:1">
|
||||
<label>Szyfrowanie</label>
|
||||
<select class="input" wire:model="mailConfig.smtpEncryption">
|
||||
<option value="none">Brak</option>
|
||||
<option value="tls">STARTTLS</option>
|
||||
<option value="ssl">SSL/TLS</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field"><label>Użytkownik</label><input class="input" wire:model="mailConfig.smtpUsername"></div>
|
||||
<div class="field"><label>Hasło</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="mailConfig.smtpPassword"></div>
|
||||
|
||||
<div style="display:flex;gap:10px;margin-top:8px;align-items:center;flex-wrap:wrap">
|
||||
<button type="button" class="btn btn-secondary" wire:click="testMailConnection">Wyślij testową wiadomość</button>
|
||||
<button type="submit" class="btn btn-primary">Zapisz</button>
|
||||
@if ($mailTestResult === 'ok')
|
||||
<div style="display:flex;align-items:center;gap:6px;color:var(--color-success)"><span class="material-symbols-outlined" style="font-size:18px">check_circle</span>Wysłano na Twój adres</div>
|
||||
@elseif ($mailTestResult === 'error')
|
||||
<div style="display:flex;align-items:center;gap:6px;color:var(--color-danger)"><span class="material-symbols-outlined" style="font-size:18px">error</span>Błąd wysyłki</div>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<button type="submit" class="btn btn-primary" style="align-self:flex-start">Zapisz</button>
|
||||
@endif
|
||||
</form>
|
||||
<livewire:admin.mail-settings />
|
||||
@endif
|
||||
|
||||
{{-- ================= BRANDING ================= --}}
|
||||
@@ -617,6 +581,21 @@ $tabGroups = [
|
||||
</select>
|
||||
</div>
|
||||
<label class="radio"><input type="checkbox" wire:model="systemConfig.autoAssignByCategory" style="position:static;opacity:1;width:auto;height:auto">Automatyczne przypisywanie do zespołu wg kategorii</label>
|
||||
|
||||
<div class="hr"></div>
|
||||
|
||||
<div class="field">
|
||||
<label>Prefiks numeru zgłoszenia</label>
|
||||
<input class="input" maxlength="20" placeholder="#" wire:model.live="systemConfig.ticketNumberPrefix">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Minimalna długość numeru (uzupełniana zerami z przodu)</label>
|
||||
<input class="input" type="number" min="1" max="10" wire:model.live="systemConfig.ticketNumberMinLength">
|
||||
</div>
|
||||
<label class="radio"><input type="checkbox" wire:model.live="systemConfig.ticketNumberObfuscate" style="position:static;opacity:1;width:auto;height:auto">Ukryj kolejność zgłoszeń (numer wyświetlany jako suma kontrolna zamiast kolejnego numeru)</label>
|
||||
<div class="text-muted" style="font-size:12px">
|
||||
ID z bazy: {{ $this->ticketNumberPreview['id'] }} → podgląd numeru: {{ $this->ticketNumberPreview['formatted'] }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding:20px;gap:14px">
|
||||
@@ -661,6 +640,31 @@ $tabGroups = [
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding:20px;gap:14px">
|
||||
<h4 style="margin:0">Częstotliwość odświeżania i harmonogramu</h4>
|
||||
|
||||
<div class="card-kicker">Odświeżanie w przeglądarce</div>
|
||||
<div style="display:flex;gap:10px">
|
||||
<div class="field" style="flex:1"><label>Widok zgłoszenia (klient i operator)</label><input class="input" type="number" min="1" wire:model="systemConfig.refreshTicketViewSeconds"></div>
|
||||
<div class="field" style="flex:1"><label>Lista zgłoszeń operatora</label><input class="input" type="number" min="1" wire:model="systemConfig.refreshQueueSeconds"></div>
|
||||
</div>
|
||||
<div class="field"><label>Dzwonek powiadomień</label><input class="input" type="number" min="1" wire:model="systemConfig.refreshNotificationsSeconds"></div>
|
||||
<p class="text-muted" style="font-size:12px;margin:0">Sekundy między automatycznymi odświeżeniami w przeglądarce (niezależnie od odświeżenia na żądanie po kliknięciu w licznik).</p>
|
||||
|
||||
<div class="hr"></div>
|
||||
|
||||
<div class="card-kicker">Zadania w tle</div>
|
||||
<div style="display:flex;gap:10px">
|
||||
<div class="field" style="flex:1"><label>Sprawdzanie naruszeń SLA</label><input class="input" type="number" min="1" wire:model="systemConfig.scheduleSlaCheckMinutes"></div>
|
||||
<div class="field" style="flex:1"><label>Reguły automatyzacji</label><input class="input" type="number" min="1" wire:model="systemConfig.scheduleAutomationRulesMinutes"></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:10px">
|
||||
<div class="field" style="flex:1"><label>Pobieranie e-maili (IMAP)</label><input class="input" type="number" min="1" wire:model="systemConfig.scheduleImapFetchMinutes"></div>
|
||||
<div class="field" style="flex:1"><label>Automatyczna kategoryzacja i podsumowania AI</label><input class="input" type="number" min="1" wire:model="systemConfig.scheduleAiAutomationMinutes"></div>
|
||||
</div>
|
||||
<p class="text-muted" style="font-size:12px;margin:0">Minuty między uruchomieniami zadań w tle. Zmiana obowiązuje od najbliższego uruchomienia harmonogramu (co minutę), bez potrzeby restartu.</p>
|
||||
</div>
|
||||
|
||||
<div style="grid-column:1/-1;display:flex">
|
||||
<button type="submit" class="btn btn-primary">Zapisz</button>
|
||||
</div>
|
||||
@@ -718,12 +722,35 @@ $tabGroups = [
|
||||
<div class="field"><label>Token Secret</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="bookstackConfig.tokenSecret"></div>
|
||||
<p class="text-muted" style="font-size:11.5px;margin:-4px 0 0">Token API generuje się w BookStack: Profil → Ustawienia API. Użytkownik/rola właściciela tokenu musi mieć uprawnienie „Access System API”.</p>
|
||||
|
||||
<div class="field"><label>Przeszukuj</label>
|
||||
<select class="input" style="width:auto" wire:model="bookstackConfig.searchTypes">
|
||||
<option value="both">Strony i książki</option>
|
||||
<option value="page">Tylko strony</option>
|
||||
<option value="book">Tylko książki</option>
|
||||
<div class="field">
|
||||
<label>Przeszukuj</label>
|
||||
<div style="display:flex;gap:16px;flex-wrap:wrap">
|
||||
<label style="display:flex;align-items:center;gap:6px;font-size:13px;font-weight:400">
|
||||
<input type="checkbox" style="position:static;opacity:1;width:auto;height:auto" @checked(in_array('book', $bookstackConfig['searchTypes'])) wire:click="toggleBookstackSearchType('book')">
|
||||
Książki
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:6px;font-size:13px;font-weight:400">
|
||||
<input type="checkbox" style="position:static;opacity:1;width:auto;height:auto" @checked(in_array('page', $bookstackConfig['searchTypes'])) wire:click="toggleBookstackSearchType('page')">
|
||||
Strony
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:6px;font-size:13px;font-weight:400">
|
||||
<input type="checkbox" style="position:static;opacity:1;width:auto;height:auto" @checked(in_array('chapter', $bookstackConfig['searchTypes'])) wire:click="toggleBookstackSearchType('chapter')">
|
||||
Rozdziały
|
||||
</label>
|
||||
</div>
|
||||
@if (empty($bookstackConfig['searchTypes']))
|
||||
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Nic nie zaznaczono — przy zapisie zostaną użyte wszystkie typy.</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Szukaj po</label>
|
||||
<select class="input" style="width:auto" wire:model="bookstackConfig.searchBy">
|
||||
<option value="both">Słowa kluczowe w nazwie lub tagi</option>
|
||||
<option value="name">Tylko słowa kluczowe w nazwie</option>
|
||||
<option value="tags">Tylko tagi</option>
|
||||
</select>
|
||||
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">„Tagi” dopasowują artykuły oznaczone w BookStack tagiem o nazwie zgodnej z kategorią/podkategorią zgłoszenia (np. tag „Drukarki” dla podkategorii „Drukarki”).</p>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;justify-content:flex-end">
|
||||
@@ -773,6 +800,26 @@ $tabGroups = [
|
||||
<label class="radio"><input type="checkbox" wire:model="bookstackConfig.verifySsl" style="position:static;opacity:1;width:auto;height:auto">Weryfikuj certyfikat SSL instancji BookStack</label>
|
||||
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Wyłącz tylko jeśli instancja BookStack korzysta z certyfikatu self-signed / z prywatnego CA.</span>
|
||||
|
||||
<div class="field">
|
||||
<label>Automatyczne tagowanie treści (AI)</label>
|
||||
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Używa integracji AI (skonfigurowanej w karcie „Integracja AI” obok) do otagowania książek/stron/rozdziałów nazwami pasujących podkategorii helpdesku — bez tego wyszukiwanie „po tagach” nic nie znajdzie. „Otaguj nową treść” pomija już otagowane pozycje; „Otaguj wszystko ponownie” klasyfikuje od nowa całą wiki (dłużej, więcej zapytań do AI).</p>
|
||||
<div style="display:flex;gap:10px;margin-top:6px;align-items:center;flex-wrap:wrap">
|
||||
<button type="button" class="btn btn-secondary" wire:click="runBookstackTagging" wire:loading.attr="disabled" wire:target="runBookstackTagging,runBookstackTaggingForce">
|
||||
<span class="material-symbols-outlined" style="font-size:16px;vertical-align:middle" wire:loading.class="spin" wire:target="runBookstackTagging">sell</span>
|
||||
Otaguj nową treść
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" wire:click="runBookstackTaggingForce" wire:loading.attr="disabled" wire:target="runBookstackTagging,runBookstackTaggingForce">
|
||||
<span class="material-symbols-outlined" style="font-size:16px;vertical-align:middle" wire:loading.class="spin" wire:target="runBookstackTaggingForce">refresh</span>
|
||||
Otaguj wszystko ponownie (force)
|
||||
</button>
|
||||
</div>
|
||||
@if ($bookstackTagResult !== null)
|
||||
<div class="text-muted" style="font-size:11.5px;margin-top:6px">Przeskanowano {{ $bookstackTagResult['scanned'] }}, otagowano {{ $bookstackTagResult['tagged'] }}, pominięto {{ $bookstackTagResult['skipped'] }}, nieudanych paczek {{ $bookstackTagResult['failed_batches'] }}.</div>
|
||||
@elseif ($bookstackTagError)
|
||||
<div style="display:flex;align-items:center;gap:6px;color:var(--color-danger);font-size:11.5px;margin-top:6px"><span class="material-symbols-outlined" style="font-size:16px">error</span>{{ $bookstackTagError }}</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:10px;margin-top:8px;align-items:center;flex-wrap:wrap">
|
||||
<button type="button" class="btn btn-secondary" wire:click="testBookstackConnection">Testuj połączenie</button>
|
||||
<button type="submit" class="btn btn-primary">Zapisz</button>
|
||||
@@ -787,6 +834,128 @@ $tabGroups = [
|
||||
@endif
|
||||
</form>
|
||||
|
||||
<form wire:submit="saveSnipeitConfig" class="card" style="padding:20px;gap:14px">
|
||||
<h4 style="margin:0">Snipe-IT (ewidencja sprzętu)</h4>
|
||||
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Pokazuje sprzęt przypisany do zgłaszającego przy tworzeniu i przeglądaniu zgłoszenia oraz pozwala powiązać zgłoszenie z konkretnym urządzeniem z ewidencji Snipe-IT.</span>
|
||||
<label class="radio"><input type="checkbox" wire:model="snipeitConfig.enabled" style="position:static;opacity:1;width:auto;height:auto"><strong>Włącz integrację z Snipe-IT</strong></label>
|
||||
|
||||
@if ($snipeitConfig['enabled'])
|
||||
<div class="field"><label>Adres API</label><input class="input" placeholder="https://assets.firma.pl" wire:model="snipeitConfig.baseUrl"></div>
|
||||
<div class="field"><label>Klucz API</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="snipeitConfig.apiToken"></div>
|
||||
<p class="text-muted" style="font-size:11.5px;margin:-4px 0 0">Osobisty token API generuje się w Snipe-IT: profil użytkownika → „Create New Token”.</p>
|
||||
|
||||
<label class="radio"><input type="checkbox" wire:model="snipeitConfig.skipSslVerification" style="position:static;opacity:1;width:auto;height:auto">Nie sprawdzaj SSL</label>
|
||||
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Zaznacz tylko, jeśli instancja Snipe-IT korzysta z certyfikatu self-signed / z prywatnego CA.</span>
|
||||
|
||||
<div style="border-top:1px solid var(--color-divider);margin:4px 0"></div>
|
||||
|
||||
<div class="field">
|
||||
<label>Klient</label>
|
||||
<label class="radio"><input type="checkbox" wire:model="snipeitConfig.clientCanSelectAsset" style="position:static;opacity:1;width:auto;height:auto">Klient może wybrać sprzęt, którego dotyczy zgłoszenie</label>
|
||||
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Przy tworzeniu zgłoszenia klient zobaczy listę swojego sprzętu z Snipe-IT (dopasowanego po adresie e-mail) i będzie mógł je powiązać ze zgłoszeniem.</p>
|
||||
</div>
|
||||
|
||||
@if ($snipeitConfig['clientCanSelectAsset'])
|
||||
<div class="field">
|
||||
<label>Ogranicz do podkategorii</label>
|
||||
<x-multiselect
|
||||
:options="$this->subcategoriesForTeamForm"
|
||||
:selected-ids="$snipeitConfig['clientAssetSubcategoryIds']"
|
||||
toggle-action="toggleSnipeitClientSubcategory"
|
||||
placeholder="Brak wybranych podkategorii"
|
||||
/>
|
||||
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Wybór sprzętu pojawi się klientowi tylko przy tworzeniu zgłoszenia w zaznaczonych tu podkategoriach. Jeśli nic nie jest zaznaczone, opcja nie pojawi się w żadnej podkategorii.</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div style="border-top:1px solid var(--color-divider);margin:4px 0"></div>
|
||||
|
||||
<div class="field">
|
||||
<label>Operator</label>
|
||||
<label class="radio"><input type="checkbox" wire:model="snipeitConfig.operatorViewRequesterAssets" style="position:static;opacity:1;width:auto;height:auto">Operator może zobaczyć sprzęt zgłaszającego w widoku zgłoszenia</label>
|
||||
<label class="radio"><input type="checkbox" wire:model="snipeitConfig.operatorSearchInventory" style="position:static;opacity:1;width:auto;height:auto">Zezwól operatorowi na przeszukiwanie całego inwentarza (nie tylko sprzętu zgłaszającego)</label>
|
||||
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Obie funkcje pojawiają się w bocznym panelu widoku zgłoszenia operatora — przeszukiwanie inwentarza jako pole wyszukiwania z przyciskiem „Szukaj”, nie osobna podstrona. Odpięcie już powiązanego urządzenia jest zawsze dostępne dla operatora, niezależnie od tych dwóch ustawień.</p>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:10px;margin-top:8px;align-items:center;flex-wrap:wrap">
|
||||
<button type="button" class="btn btn-secondary" wire:click="testSnipeitConnection">Testuj połączenie</button>
|
||||
<button type="submit" class="btn btn-primary">Zapisz</button>
|
||||
@if ($snipeitTestResult === 'ok')
|
||||
<div style="display:flex;align-items:center;gap:6px;color:var(--color-success)"><span class="material-symbols-outlined" style="font-size:18px">check_circle</span>Połączenie OK</div>
|
||||
@elseif ($snipeitTestResult === 'error')
|
||||
<div style="display:flex;align-items:center;gap:6px;color:var(--color-danger)"><span class="material-symbols-outlined" style="font-size:18px">error</span>Błąd połączenia{{ $snipeitTestMessage ? ': '.$snipeitTestMessage : '' }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<button type="submit" class="btn btn-primary" style="align-self:flex-start">Zapisz</button>
|
||||
@endif
|
||||
</form>
|
||||
|
||||
<form wire:submit="saveAiConfig" class="card" style="padding:20px;gap:14px">
|
||||
<h4 style="margin:0">Integracja AI</h4>
|
||||
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Ogólne połączenie z dostawcą modelu językowego (API kompatybilne z OpenAI — Groq, OpenAI, lokalny Ollama itp.), wykorzystywane m.in. do automatycznego tagowania treści w BookStack.</span>
|
||||
<label class="radio"><input type="checkbox" wire:model="aiConfig.enabled" style="position:static;opacity:1;width:auto;height:auto"><strong>Włącz integrację AI</strong></label>
|
||||
|
||||
@if ($aiConfig['enabled'])
|
||||
<div class="field"><label>Adres API (Base URL)</label><input class="input" placeholder="https://api.groq.com/openai/v1" wire:model="aiConfig.baseUrl"></div>
|
||||
<div class="field"><label>Klucz API</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="aiConfig.apiKey"></div>
|
||||
<p class="text-muted" style="font-size:11.5px;margin:-4px 0 0">Zostaw puste dla lokalnych instancji bez autoryzacji (np. Ollama).</p>
|
||||
<div class="field"><label>Model</label><input class="input" placeholder="np. llama-3.3-70b-versatile" wire:model="aiConfig.model"></div>
|
||||
|
||||
<label class="radio"><input type="checkbox" wire:model="aiConfig.verifySsl" style="position:static;opacity:1;width:auto;height:auto">Weryfikuj certyfikat SSL</label>
|
||||
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Wyłącz tylko jeśli instancja (np. lokalny Ollama) korzysta z certyfikatu self-signed / z prywatnego CA.</span>
|
||||
|
||||
<div style="display:flex;gap:10px;margin-top:8px;align-items:center;flex-wrap:wrap">
|
||||
<button type="button" class="btn btn-secondary" wire:click="testAiConnection">Testuj połączenie</button>
|
||||
<button type="submit" class="btn btn-primary">Zapisz</button>
|
||||
@if ($aiTestResult === 'ok')
|
||||
<div style="display:flex;align-items:center;gap:6px;color:var(--color-success)"><span class="material-symbols-outlined" style="font-size:18px">check_circle</span>Połączenie OK</div>
|
||||
@elseif ($aiTestResult === 'error')
|
||||
<div style="display:flex;align-items:center;gap:6px;color:var(--color-danger)"><span class="material-symbols-outlined" style="font-size:18px">error</span>Błąd połączenia{{ $aiTestMessage ? ': '.$aiTestMessage : '' }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<button type="submit" class="btn btn-primary" style="align-self:flex-start">Zapisz</button>
|
||||
@endif
|
||||
</form>
|
||||
|
||||
<form wire:submit="saveAiTriageConfig" class="card" style="padding:20px;gap:14px">
|
||||
<h4 style="margin:0">Automatyzacja AI dla zgłoszeń</h4>
|
||||
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Wymaga włączonej integracji AI (karta obok). Zgłoszenia są przetwarzane w tle, cyklicznie co kilka minut — nie spowalnia to tworzenia zgłoszenia przez klienta.</span>
|
||||
|
||||
<div class="field">
|
||||
<label>Automatyczna kategoryzacja nowych zgłoszeń</label>
|
||||
<label class="radio"><input type="checkbox" wire:model="aiTriageConfig.categoryWhenMissing" style="position:static;opacity:1;width:auto;height:auto">Przypisz kategorię/podkategorię, gdy zgłoszenie nie ma żadnej</label>
|
||||
<label class="radio"><input type="checkbox" wire:model="aiTriageConfig.subcategoryWhenCategoryOnly" style="position:static;opacity:1;width:auto;height:auto">Dobierz podkategorię, gdy zgłoszenie ma tylko kategorię</label>
|
||||
<label class="radio"><input type="checkbox" wire:model="aiTriageConfig.recheckCategorized" style="position:static;opacity:1;width:auto;height:auto">Zweryfikuj i ewentualnie popraw już przypisaną podkategorię</label>
|
||||
<label class="radio"><input type="checkbox" wire:model="aiTriageConfig.fixSubject" style="position:static;opacity:1;width:auto;height:auto">Popraw temat zgłoszenia, jeśli jest niejasny</label>
|
||||
<label class="radio"><input type="checkbox" wire:model="aiTriageConfig.setPriority" style="position:static;opacity:1;width:auto;height:auto">Ustaw priorytet na podstawie treści</label>
|
||||
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Każde zgłoszenie jest sprawdzane tylko raz — zastosowane zmiany trafiają do historii zgłoszenia z adnotacją „Automatyzacja: klasyfikacja AI”.</p>
|
||||
</div>
|
||||
|
||||
<div style="border-top:1px solid var(--color-divider);margin:4px 0"></div>
|
||||
|
||||
<div class="field">
|
||||
<label>Podsumowanie AI dla operatora</label>
|
||||
<label class="radio"><input type="checkbox" wire:model="aiSummaryEnabled" style="position:static;opacity:1;width:auto;height:auto"><strong>Generuj podsumowanie i sugerowaną akcję dla każdego zgłoszenia</strong></label>
|
||||
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Widoczne wyłącznie w panelu operatora, w bocznym panelu zgłoszenia. Domyślnie odświeżane cyklicznie (co kilka minut, wraz z pozostałą automatyzacją AI powyżej).</p>
|
||||
|
||||
<label class="radio"><input type="checkbox" wire:model="aiSummaryRegenerateOnMessage" style="position:static;opacity:1;width:auto;height:auto">Regeneruj podsumowanie od razu po każdej nowej wiadomości</label>
|
||||
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Zamiast czekać na najbliższy cykl automatyzacji — dotyczy odpowiedzi operatora, klienta i notatek wewnętrznych.</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<label>Prompt systemowy podsumowania</label>
|
||||
<button type="button" class="btn btn-ghost" style="padding:2px 8px;font-size:12px" wire:click="resetAiSummaryPrompt" wire:confirm="Przywrócić domyślny prompt? Obecna treść zostanie zastąpiona.">Resetuj</button>
|
||||
</div>
|
||||
<textarea wire:key="ai-summary-prompt-{{ $aiSummaryPromptVersion }}" class="input" rows="6" wire:change="saveAiSummaryPrompt($event.target.value)">{{ $aiSummaryPrompt }}</textarea>
|
||||
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Model musi zwrócić obiekt JSON z kluczami "summary" i "suggested_action" — nie zmieniaj tego wymogu, chyba że wiadomo, że nowy dostawca/model obsłuży to inaczej.</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" style="align-self:flex-start">Zapisz</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
@endif
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
@foreach (($tab === 'current' ? $this->currentTickets : $this->archiveTickets) as $ticket)
|
||||
<a href="{{ route('client.ticket', $ticket) }}" wire:navigate class="card elev-sm" style="padding:16px;cursor:pointer;flex-direction:row;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;text-decoration:none;color:inherit">
|
||||
<div>
|
||||
<div style="font-weight:500">#{{ $ticket->number }} — {{ $ticket->subject }}</div>
|
||||
<div style="font-weight:500">{{ $ticket->displayNumber() }} — {{ $ticket->subject }}</div>
|
||||
<div class="card-meta">{{ $ticket->categoryLabel() }} · {{ \App\Support\Rel::format($ticket->updated_at) }}</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px">
|
||||
|
||||
@@ -64,6 +64,15 @@
|
||||
<x-bookstack-suggestions :articles="$this->suggestedArticles" />
|
||||
</div>
|
||||
|
||||
<div wire:init="loadSnipeitAssets">
|
||||
<x-snipeit-assets
|
||||
:assets="$this->snipeitAssets"
|
||||
title="Twój sprzęt (inwentarz) — powiąż, jeśli zgłoszenie go dotyczy"
|
||||
:selectable="\App\Support\Settings::bool('snipeit_client_can_select_asset')"
|
||||
:selected-id="$selectedSnipeitAssetId"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Temat</label>
|
||||
<input class="input" wire:model="subject">
|
||||
|
||||
@@ -7,13 +7,18 @@
|
||||
|
||||
{{-- Live updates arrive via broadcasting, but websocket connections can
|
||||
drop silently — this is a periodic fallback refresh, with a visible
|
||||
countdown so it's clear the thread is still refreshing on its own. --}}
|
||||
countdown so it's clear the thread is still refreshing on its own.
|
||||
Also clickable, to fetch immediately and reset the countdown. --}}
|
||||
@php
|
||||
$refreshTicketSeconds = max(1, (int) \App\Support\Settings::get('refresh_ticket_view_seconds'));
|
||||
@endphp
|
||||
<div
|
||||
class="btn btn-secondary"
|
||||
style="cursor:default;gap:6px"
|
||||
x-data="{ remaining: 30, total: 30 }"
|
||||
style="cursor:pointer;gap:6px"
|
||||
x-data="{ remaining: {{ $refreshTicketSeconds }}, total: {{ $refreshTicketSeconds }} }"
|
||||
x-init="setInterval(() => { remaining = remaining <= 1 ? total : remaining - 1; if (remaining === total) $wire.refreshTicketData(); }, 1000)"
|
||||
title="Zgłoszenie odświeża się automatycznie"
|
||||
x-on:click="remaining = total; $wire.refreshTicketData()"
|
||||
title="Zgłoszenie odświeża się automatycznie — kliknij, aby odświeżyć teraz"
|
||||
>
|
||||
<span class="material-symbols-outlined" style="font-size:18px">schedule</span>
|
||||
<span x-text="remaining + 's'"></span>
|
||||
@@ -28,7 +33,7 @@
|
||||
@endphp
|
||||
|
||||
<div class="card" style="padding:22px;gap:10px">
|
||||
<div class="card-kicker">Zgłoszenie #{{ $ticket->number }}</div>
|
||||
<div class="card-kicker">Zgłoszenie {{ $ticket->displayNumber() }}</div>
|
||||
<h2 style="margin:2px 0 0">{{ $ticket->subject }}</h2>
|
||||
<div class="card-meta">{{ $ticket->categoryLabel() }} · utworzono {{ \App\Support\Rel::format($ticket->created_at) }}</div>
|
||||
<div style="white-space:pre-wrap;font-size:14px;margin-top:4px">{{ $ticket->body }}</div>
|
||||
@@ -114,6 +119,16 @@
|
||||
<x-bookstack-suggestions :articles="$this->suggestedArticles" variant="sidebar" title="Baza wiedzy" />
|
||||
</div>
|
||||
|
||||
@if ($ticket->snipeit_asset_name)
|
||||
<div class="card" style="padding:16px;gap:6px">
|
||||
<div class="card-kicker">Powiązany sprzęt</div>
|
||||
<div style="display:flex;align-items:center;gap:8px;font-size:13px;font-weight:500">
|
||||
<span class="material-symbols-outlined" style="font-size:18px;color:var(--color-accent)">devices</span>
|
||||
{{ $ticket->snipeit_asset_name }}
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="card" style="padding:16px;gap:10px">
|
||||
<div class="card-kicker">Status i priorytet</div>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap">
|
||||
@@ -168,7 +183,7 @@
|
||||
<div class="card-kicker">Inne Twoje zgłoszenia</div>
|
||||
@forelse ($this->otherTickets as $ot)
|
||||
<a href="{{ route('client.ticket', $ot) }}" wire:navigate style="display:flex;justify-content:space-between;align-items:center;gap:8px;cursor:pointer;text-decoration:none;color:inherit">
|
||||
<span style="font-size:13px">#{{ $ot->number }} — {{ $ot->subject }}</span>
|
||||
<span style="font-size:13px">{{ $ot->displayNumber() }} — {{ $ot->subject }}</span>
|
||||
<span style="{{ $ot->statusStyle() }};flex:none">{{ $ot->statusLabel() }}</span>
|
||||
</a>
|
||||
@empty
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
@if ($this->submittedTicket)
|
||||
<div class="card elev-md" style="padding:32px;gap:14px;text-align:left">
|
||||
<span class="tag tag-accent" style="align-self:flex-start">Zgłoszenie przyjęte</span>
|
||||
<h2 style="margin:0">Zgłoszenie #{{ $this->submittedTicket->number }} zostało utworzone</h2>
|
||||
<h2 style="margin:0">Zgłoszenie {{ $this->submittedTicket->displayNumber() }} zostało utworzone</h2>
|
||||
<p class="text-muted" style="margin:0">Zapisz numer zgłoszenia i adres e-mail — będziesz mógł/mogła sprawdzić status, kontaktując się z zespołem wsparcia. Aktualizacje będziemy wysyłać na Twój adres e-mail.</p>
|
||||
<div class="hr"></div>
|
||||
<div style="display:flex;flex-direction:column;gap:4px;font-size:14px">
|
||||
<div><strong>Numer zgłoszenia:</strong> #{{ $this->submittedTicket->number }}</div>
|
||||
<div><strong>Numer zgłoszenia:</strong> {{ $this->submittedTicket->displayNumber() }}</div>
|
||||
<div><strong>Temat:</strong> {{ $this->submittedTicket->subject }}</div>
|
||||
<div><strong>Kategoria:</strong> {{ $this->submittedTicket->categoryLabel() }}</div>
|
||||
<div><strong>Zgłaszający:</strong> {{ $this->submittedTicket->email }}</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div x-data="{ open: false }" @click.outside="open = false" class="nav-dropdown-wrap" style="position:relative;display:inline-block" wire:poll.30s="$refresh">
|
||||
<div x-data="{ open: false }" @click.outside="open = false" class="nav-dropdown-wrap" style="position:relative;display:inline-block" wire:poll.{{ max(1, (int) \App\Support\Settings::get('refresh_notifications_seconds')) }}s="$refresh">
|
||||
<button type="button" class="btn btn-secondary" @click="open = !open" style="position:relative;display:flex;align-items:center;gap:0;padding:8px">
|
||||
<span class="material-symbols-outlined" style="font-size:18px">notifications</span>
|
||||
@if ($this->unreadCount)
|
||||
|
||||
@@ -120,13 +120,18 @@
|
||||
{{-- Live updates arrive via broadcasting, but websocket connections can
|
||||
drop silently (backgrounded tab, network blip) — this is a periodic
|
||||
fallback refresh, with a visible countdown so it's clear the queue
|
||||
is still refreshing itself rather than just stuck. --}}
|
||||
is still refreshing itself rather than just stuck. Also clickable,
|
||||
to fetch immediately and reset the countdown. --}}
|
||||
@php
|
||||
$refreshQueueSeconds = max(1, (int) \App\Support\Settings::get('refresh_queue_seconds'));
|
||||
@endphp
|
||||
<div
|
||||
class="btn btn-secondary"
|
||||
style="cursor:default;gap:6px"
|
||||
x-data="{ remaining: 60, total: 60 }"
|
||||
style="cursor:pointer;gap:6px"
|
||||
x-data="{ remaining: {{ $refreshQueueSeconds }}, total: {{ $refreshQueueSeconds }} }"
|
||||
x-init="setInterval(() => { remaining = remaining <= 1 ? total : remaining - 1; if (remaining === total) $wire.refreshQueue(); }, 1000)"
|
||||
title="Kolejka odświeża się automatycznie co minutę"
|
||||
x-on:click="remaining = total; $wire.refreshQueue()"
|
||||
title="Kolejka odświeża się automatycznie — kliknij, aby odświeżyć teraz"
|
||||
>
|
||||
<span class="material-symbols-outlined" style="font-size:18px">schedule</span>
|
||||
<span x-text="remaining + 's'"></span>
|
||||
@@ -137,7 +142,7 @@
|
||||
<table class="table table-cards-mobile">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th><input type="checkbox" @checked($this->filteredTickets->isNotEmpty() && empty($this->filteredTickets->pluck('id')->diff($selectedIds)->all())) wire:click="toggleSelectAll" title="Zaznacz wszystkie"></th>
|
||||
@foreach ($columnDefs as $key => $label)
|
||||
@continue(! in_array($key, $visibleColumns))
|
||||
<th>
|
||||
@@ -161,7 +166,12 @@
|
||||
<tr wire:key="ticket-{{ $t->id }}">
|
||||
<td class="td-select"><input type="checkbox" @checked(in_array($t->id, $selectedIds)) wire:click="toggleSelect({{ $t->id }})"></td>
|
||||
@if (in_array('number', $visibleColumns))
|
||||
<td data-label="Numer" class="td-title"><a href="{{ route('operator.ticket', $t) }}" wire:navigate style="color:inherit;text-decoration:none;cursor:pointer">{{ $t->number }}</a></td>
|
||||
<td data-label="Numer" class="td-title">
|
||||
<a href="{{ route('operator.ticket', $t) }}" wire:navigate style="color:inherit;text-decoration:none;cursor:pointer">{{ $t->displayNumber() }}</a>
|
||||
@if ($t->source === 'email')
|
||||
<span class="material-symbols-outlined" style="font-size:15px;vertical-align:-3px;opacity:0.7" title="Utworzone przez e-mail">mail</span>
|
||||
@endif
|
||||
</td>
|
||||
@endif
|
||||
@if (in_array('subject', $visibleColumns))
|
||||
<td data-label="Temat" class="td-title"><a href="{{ route('operator.ticket', $t) }}" wire:navigate style="color:inherit;text-decoration:none;cursor:pointer;white-space:nowrap">{{ $t->subject }}</a></td>
|
||||
|
||||
@@ -19,13 +19,18 @@
|
||||
|
||||
{{-- Live updates arrive via broadcasting, but websocket connections can
|
||||
drop silently — this is a periodic fallback refresh, with a visible
|
||||
countdown so it's clear the thread is still refreshing on its own. --}}
|
||||
countdown so it's clear the thread is still refreshing on its own.
|
||||
Also clickable, to fetch immediately and reset the countdown. --}}
|
||||
@php
|
||||
$refreshTicketSeconds = max(1, (int) \App\Support\Settings::get('refresh_ticket_view_seconds'));
|
||||
@endphp
|
||||
<div
|
||||
class="btn btn-secondary"
|
||||
style="cursor:default;gap:6px"
|
||||
x-data="{ remaining: 30, total: 30 }"
|
||||
style="cursor:pointer;gap:6px"
|
||||
x-data="{ remaining: {{ $refreshTicketSeconds }}, total: {{ $refreshTicketSeconds }} }"
|
||||
x-init="setInterval(() => { remaining = remaining <= 1 ? total : remaining - 1; if (remaining === total) $wire.refreshTicketData(); }, 1000)"
|
||||
title="Zgłoszenie odświeża się automatycznie"
|
||||
x-on:click="remaining = total; $wire.refreshTicketData()"
|
||||
title="Zgłoszenie odświeża się automatycznie — kliknij, aby odświeżyć teraz"
|
||||
>
|
||||
<span class="material-symbols-outlined" style="font-size:18px">schedule</span>
|
||||
<span x-text="remaining + 's'"></span>
|
||||
@@ -36,7 +41,14 @@
|
||||
<div class="main-col" style="display:flex;flex-direction:column;gap:16px">
|
||||
<div class="card" style="padding:20px;gap:8px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">
|
||||
<div class="card-kicker">Zgłoszenie #{{ $ticket->number }}</div>
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<div class="card-kicker">Zgłoszenie {{ $ticket->displayNumber() }}</div>
|
||||
@if ($ticket->source === 'email')
|
||||
<span class="tag tag-outline" style="display:inline-flex;align-items:center;gap:3px;font-size:10.5px" title="Utworzone przez e-mail">
|
||||
<span class="material-symbols-outlined" style="font-size:13px">mail</span>E-mail
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
@unless ($editingDetails)
|
||||
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="toggleEditDetails">Edytuj</button>
|
||||
@endunless
|
||||
@@ -160,7 +172,12 @@
|
||||
<div wire:key="msg-{{ $m->id }}" style="display:flex;justify-content:{{ $mine ? 'flex-end' : 'flex-start' }}">
|
||||
<div style="max-width:75%;padding:10px 14px;border-radius:12px;font-size:14px;background:{{ $mine ? 'var(--color-accent-800)' : 'var(--color-surface)' }};color:{{ $mine ? 'var(--color-accent-100)' : 'var(--color-text)' }};border:1px solid var(--color-divider)">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px">
|
||||
<div style="font-size:11px;opacity:0.65;margin-bottom:4px">{{ $m->author_name }} · {{ \App\Support\Rel::format($m->created_at) }}{{ $m->edited ? ' · edytowano' : '' }}</div>
|
||||
<div style="font-size:11px;opacity:0.65;margin-bottom:4px;display:flex;align-items:center;gap:4px">
|
||||
@if ($m->source === 'email')
|
||||
<span class="material-symbols-outlined" style="font-size:13px" title="Odebrane e-mailem">mail</span>
|
||||
@endif
|
||||
{{ $m->author_name }} · {{ \App\Support\Rel::format($m->created_at) }}{{ $m->edited ? ' · edytowano' : '' }}
|
||||
</div>
|
||||
@if ($m->role === 'operator')
|
||||
<div style="display:flex;gap:6px;flex:none">
|
||||
<span class="material-symbols-outlined" style="font-size:15px;cursor:pointer;opacity:0.7" wire:click="startEditMessage({{ $m->id }}, @js($m->body))">edit</span>
|
||||
@@ -274,7 +291,7 @@
|
||||
<div class="card-kicker">Status i przypisanie</div>
|
||||
<div class="field">
|
||||
<label>Status</label>
|
||||
<select class="input" wire:change="setStatus($event.target.value)">
|
||||
<select class="input" wire:key="ticket-status-select-{{ $ticket->status_key }}" wire:change="setStatus($event.target.value)">
|
||||
@foreach ($this->statuses as $s)
|
||||
<option value="{{ $s->key }}" @selected($ticket->status_key === $s->key)>{{ $s->label }}</option>
|
||||
@endforeach
|
||||
@@ -313,10 +330,93 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (\App\Support\Settings::bool('snipeit_enabled'))
|
||||
@if ($ticket->snipeit_asset_id)
|
||||
@php $linkedAsset = $this->snipeitLinkedAsset; @endphp
|
||||
<div class="card" style="padding:16px;gap:6px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">
|
||||
<div class="card-kicker">Powiązany sprzęt</div>
|
||||
<button type="button" class="btn btn-ghost" style="font-size:11px;padding:2px 6px" wire:click="unlinkSnipeitAsset">Odepnij</button>
|
||||
</div>
|
||||
<a href="{{ $linkedAsset['url'] ?? '#' }}" target="_blank" rel="noopener noreferrer" style="display:flex;gap:8px;align-items:flex-start;min-width:0;text-decoration:none;color:inherit">
|
||||
<span class="material-symbols-outlined" style="font-size:18px;flex:none;margin-top:1px;color:var(--color-accent)">devices</span>
|
||||
<span style="min-width:0">
|
||||
<span style="display:block;font-size:13px;font-weight:500;color:var(--color-accent)">{{ $linkedAsset['label'] ?? $ticket->snipeit_asset_name }}</span>
|
||||
@if ($linkedAsset)
|
||||
<span style="display:block;font-size:11px;color:color-mix(in srgb, var(--color-text) 55%, transparent)">{{ collect([$linkedAsset['category'] ?? null, $linkedAsset['status'] ?? null, $linkedAsset['assignedTo'] ?? null])->filter()->implode(' · ') }}</span>
|
||||
@else
|
||||
<span style="display:block;font-size:11px;color:color-mix(in srgb, var(--color-text) 55%, transparent)">Niedostępne w Snipe-IT (brak połączenia lub usunięto)</span>
|
||||
@endif
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (\App\Support\Settings::bool('snipeit_operator_view_requester_assets'))
|
||||
<div wire:init="loadSnipeitAssets">
|
||||
<x-snipeit-assets
|
||||
:assets="$this->snipeitRequesterAssets"
|
||||
variant="sidebar"
|
||||
title="Sprzęt zgłaszającego"
|
||||
:selectable="true"
|
||||
select-action="linkSnipeitAsset"
|
||||
:selected-id="$ticket->snipeit_asset_id"
|
||||
/>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (\App\Support\Settings::bool('snipeit_operator_search_inventory'))
|
||||
<div class="card" style="padding:16px;gap:8px">
|
||||
<div class="card-kicker">Przeszukaj inwentarz</div>
|
||||
<div style="display:flex;gap:6px">
|
||||
<input class="input" style="flex:1" placeholder="Nr inwentarzowy, model, nazwa..." wire:model="snipeitSearchQuery" wire:keydown.enter.prevent="searchSnipeitAssets">
|
||||
<button type="button" class="btn btn-secondary" style="flex:none" wire:click="searchSnipeitAssets">Szukaj</button>
|
||||
</div>
|
||||
|
||||
<x-snipeit-assets
|
||||
:assets="$snipeitSearchResults"
|
||||
variant="sidebar"
|
||||
:card="false"
|
||||
:selectable="true"
|
||||
select-action="linkSnipeitAsset"
|
||||
:selected-id="$ticket->snipeit_asset_id"
|
||||
/>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
<div wire:init="loadSuggestedArticles">
|
||||
<x-bookstack-suggestions :articles="$this->suggestedArticles" variant="sidebar" title="Baza wiedzy" :show-copy="true" />
|
||||
</div>
|
||||
|
||||
@if (\App\Support\Settings::bool('ai_summary_enabled'))
|
||||
<div wire:init="loadAiSummary" class="card" style="padding:16px;gap:8px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px">
|
||||
<div class="card-kicker">Podsumowanie AI</div>
|
||||
<button type="button" class="btn btn-ghost" style="padding:2px 8px;font-size:12px;flex:none" wire:click="regenerateAiSummary" wire:loading.attr="disabled" wire:target="regenerateAiSummary">
|
||||
<span wire:loading.remove wire:target="regenerateAiSummary">Wygeneruj teraz</span>
|
||||
<span wire:loading wire:target="regenerateAiSummary">Generowanie…</span>
|
||||
</button>
|
||||
</div>
|
||||
@if ($aiSummaryRegenerateError)
|
||||
<div style="font-size:12px;color:var(--color-danger)">{{ $aiSummaryRegenerateError }}</div>
|
||||
@endif
|
||||
@if ($aiSummaryLoaded)
|
||||
@if ($ticket->ai_summary)
|
||||
<div style="font-size:13px;line-height:1.5">{{ $ticket->ai_summary }}</div>
|
||||
@if ($ticket->ai_suggested_action)
|
||||
<div style="margin-top:6px;padding-top:8px;border-top:1px solid var(--color-divider);font-size:13px">
|
||||
<strong>Sugerowana akcja:</strong> {{ $ticket->ai_suggested_action }}
|
||||
</div>
|
||||
@endif
|
||||
<div class="text-muted" style="font-size:11px;margin-top:4px">Zaktualizowano: {{ $ticket->ai_summary_generated_at?->diffForHumans() }}</div>
|
||||
@else
|
||||
<p class="text-muted" style="font-size:12.5px;margin:0">Podsumowanie pojawi się po najbliższym cyklu automatyzacji AI, albo od razu po kliknięciu „Wygeneruj teraz”.</p>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="card" style="padding:16px;gap:8px">
|
||||
<div class="card-kicker">SLA</div>
|
||||
<div style="font-size:12.5px">{{ $ticket->slaInfo()['text'] }}</div>
|
||||
@@ -456,7 +556,7 @@
|
||||
<div class="dialog-backdrop">
|
||||
<div class="dialog" style="max-width:400px">
|
||||
<div class="dialog-title">Potwierdź usunięcie</div>
|
||||
<div class="dialog-body">Czy na pewno usunąć zgłoszenie #{{ $ticket->number }}?</div>
|
||||
<div class="dialog-body">Czy na pewno usunąć zgłoszenie {{ $ticket->displayNumber() }}?</div>
|
||||
<div class="dialog-actions">
|
||||
<button type="button" class="btn btn-secondary" wire:click="cancelDeleteTicket">Anuluj</button>
|
||||
<button type="button" class="btn btn-primary" wire:click="confirmDeleteTicket">Usuń</button>
|
||||
|
||||
@@ -12,14 +12,14 @@ use Illuminate\Support\Facades\Route;
|
||||
Route::prefix('v1')->middleware('throttle:api')->group(function () {
|
||||
Route::middleware(['auth:sanctum', 'abilities:tickets:read'])->group(function () {
|
||||
Route::get('/tickets', [TicketController::class, 'index']);
|
||||
Route::get('/tickets/{ticket}', [TicketController::class, 'show']);
|
||||
Route::get('/tickets/{ticket}/messages', [TicketMessageController::class, 'index']);
|
||||
Route::get('/tickets/{ticket:id}', [TicketController::class, 'show']);
|
||||
Route::get('/tickets/{ticket:id}/messages', [TicketMessageController::class, 'index']);
|
||||
});
|
||||
|
||||
Route::middleware(['auth:sanctum', 'abilities:tickets:write'])->group(function () {
|
||||
Route::post('/tickets', [TicketController::class, 'store']);
|
||||
Route::patch('/tickets/{ticket}', [TicketController::class, 'update']);
|
||||
Route::post('/tickets/{ticket}/messages', [TicketMessageController::class, 'store']);
|
||||
Route::patch('/tickets/{ticket:id}', [TicketController::class, 'update']);
|
||||
Route::post('/tickets/{ticket:id}/messages', [TicketMessageController::class, 'store']);
|
||||
});
|
||||
|
||||
Route::middleware(['auth:sanctum', 'abilities:dictionaries:read'])->group(function () {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
@@ -8,5 +9,16 @@ Artisan::command('inspire', function () {
|
||||
$this->comment(Inspiring::quote());
|
||||
})->purpose('Display an inspiring quote');
|
||||
|
||||
Schedule::command('tickets:check-sla-breaches')->everyFifteenMinutes();
|
||||
Schedule::command('automation:run-rules')->everyFifteenMinutes();
|
||||
// Intervals are admin-configurable (Admin > Konfiguracja). Each command is
|
||||
// considered every minute but the ->when() closure (evaluated lazily by
|
||||
// schedule:run, never at boot) decides whether the configured interval has
|
||||
// actually elapsed — see Settings::dueEveryMinutes() for why this can't be
|
||||
// an eagerly-built cron string instead.
|
||||
Schedule::command('tickets:check-sla-breaches')->everyMinute()
|
||||
->when(fn () => Settings::dueEveryMinutes('schedule_sla_check_minutes', 15));
|
||||
Schedule::command('automation:run-rules')->everyMinute()
|
||||
->when(fn () => Settings::dueEveryMinutes('schedule_automation_rules_minutes', 15));
|
||||
Schedule::command('emails:fetch-imap')->everyMinute()->withoutOverlapping()
|
||||
->when(fn () => Settings::dueEveryMinutes('schedule_imap_fetch_minutes', 5));
|
||||
Schedule::command('ai:run-ticket-automation')->everyMinute()->withoutOverlapping()
|
||||
->when(fn () => Settings::dueEveryMinutes('schedule_ai_automation_minutes', 5));
|
||||
|
||||
61
src/tests/Feature/AdminAiIntegrationConfigTest.php
Normal file
61
src/tests/Feature/AdminAiIntegrationConfigTest.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\Panel;
|
||||
use App\Models\Setting;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('the Integracje tab shows the Integracja AI card', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||
->assertSee('Integracja AI');
|
||||
});
|
||||
|
||||
test('saving the AI config persists settings and encrypts the api key at rest', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||
->set('aiConfig.enabled', true)
|
||||
->set('aiConfig.baseUrl', 'https://api.groq.test/openai/v1')
|
||||
->set('aiConfig.apiKey', 'super-secret-key')
|
||||
->set('aiConfig.model', 'llama-3.3-70b-versatile')
|
||||
->set('aiConfig.verifySsl', true)
|
||||
->call('saveAiConfig')
|
||||
->assertOk();
|
||||
|
||||
expect(Settings::get('ai_base_url'))->toBe('https://api.groq.test/openai/v1');
|
||||
expect(Settings::get('ai_model'))->toBe('llama-3.3-70b-versatile');
|
||||
expect(Settings::get('ai_api_key'))->toBe('super-secret-key');
|
||||
|
||||
$stored = Setting::query()->where('key', 'ai_api_key')->value('value');
|
||||
expect($stored)->not->toBe('super-secret-key');
|
||||
});
|
||||
|
||||
test('leaving the api key field blank on save keeps the previously stored key', function () {
|
||||
Settings::set('ai_api_key', 'already-stored-key');
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||
->set('aiConfig.enabled', true)
|
||||
->set('aiConfig.baseUrl', 'https://api.groq.test/openai/v1')
|
||||
->set('aiConfig.model', 'llama-3.3-70b-versatile')
|
||||
->call('saveAiConfig')
|
||||
->assertOk();
|
||||
|
||||
expect(Settings::get('ai_api_key'))->toBe('already-stored-key');
|
||||
});
|
||||
|
||||
test('testAiConnection reports the result of a live probe using unsaved form values', function () {
|
||||
Http::fake(['api.groq.test/*' => Http::response(['choices' => [['message' => ['content' => 'pong']]]])]);
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||
->set('aiConfig.enabled', true)
|
||||
->set('aiConfig.baseUrl', 'https://api.groq.test/openai/v1')
|
||||
->set('aiConfig.apiKey', 'key')
|
||||
->set('aiConfig.model', 'llama-3.3-70b-versatile')
|
||||
->call('testAiConnection')
|
||||
->assertSet('aiTestResult', 'ok');
|
||||
});
|
||||
85
src/tests/Feature/AdminAiTriageConfigTest.php
Normal file
85
src/tests/Feature/AdminAiTriageConfigTest.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\Panel;
|
||||
use App\Support\Settings;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('the Integracje tab shows the Automatyzacja AI dla zgłoszeń card', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||
->assertSee('Automatyzacja AI dla zgłoszeń');
|
||||
});
|
||||
|
||||
test('saving the triage config persists all 5 toggles plus the summary toggle', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||
->set('aiTriageConfig.categoryWhenMissing', true)
|
||||
->set('aiTriageConfig.subcategoryWhenCategoryOnly', true)
|
||||
->set('aiTriageConfig.recheckCategorized', true)
|
||||
->set('aiTriageConfig.fixSubject', true)
|
||||
->set('aiTriageConfig.setPriority', true)
|
||||
->set('aiSummaryEnabled', true)
|
||||
->call('saveAiTriageConfig')
|
||||
->assertOk();
|
||||
|
||||
expect(Settings::bool('ai_triage_category_when_missing'))->toBeTrue();
|
||||
expect(Settings::bool('ai_triage_subcategory_when_category_only'))->toBeTrue();
|
||||
expect(Settings::bool('ai_triage_recheck_categorized'))->toBeTrue();
|
||||
expect(Settings::bool('ai_triage_fix_subject'))->toBeTrue();
|
||||
expect(Settings::bool('ai_triage_set_priority'))->toBeTrue();
|
||||
expect(Settings::bool('ai_summary_enabled'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('unchecking every toggle and saving turns them all back off', function () {
|
||||
Settings::set('ai_triage_category_when_missing', '1');
|
||||
Settings::set('ai_summary_enabled', '1');
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||
->set('aiTriageConfig.categoryWhenMissing', false)
|
||||
->set('aiSummaryEnabled', false)
|
||||
->call('saveAiTriageConfig')
|
||||
->assertOk();
|
||||
|
||||
expect(Settings::bool('ai_triage_category_when_missing'))->toBeFalse();
|
||||
expect(Settings::bool('ai_summary_enabled'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('saving the triage config also persists the regenerate-on-message toggle', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||
->set('aiSummaryRegenerateOnMessage', true)
|
||||
->call('saveAiTriageConfig')
|
||||
->assertOk();
|
||||
|
||||
expect(Settings::bool('ai_summary_regenerate_on_message'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('saveAiSummaryPrompt persists custom prompt text', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||
->call('saveAiSummaryPrompt', 'Mój niestandardowy prompt.')
|
||||
->assertOk();
|
||||
|
||||
expect(Settings::get('ai_summary_prompt'))->toBe('Mój niestandardowy prompt.');
|
||||
});
|
||||
|
||||
test('resetAiSummaryPrompt restores the default prompt after it was customized', function () {
|
||||
$default = Settings::default('ai_summary_prompt');
|
||||
$admin = adminUser();
|
||||
|
||||
$component = Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||
->call('saveAiSummaryPrompt', 'Coś zupełnie innego.')
|
||||
->assertSet('aiSummaryPrompt', 'Coś zupełnie innego.');
|
||||
|
||||
expect(Settings::get('ai_summary_prompt'))->toBe('Coś zupełnie innego.');
|
||||
|
||||
$component->call('resetAiSummaryPrompt')
|
||||
->assertSet('aiSummaryPrompt', $default);
|
||||
|
||||
expect(Settings::get('ai_summary_prompt'))->toBe($default);
|
||||
});
|
||||
54
src/tests/Feature/AdminBookstackTaggingButtonsTest.php
Normal file
54
src/tests/Feature/AdminBookstackTaggingButtonsTest.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\Panel;
|
||||
use App\Support\Settings;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('the tagging buttons are visible once BookStack is enabled', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||
->set('bookstackConfig.enabled', true)
|
||||
->assertSee('Otaguj nową treść')
|
||||
->assertSee('Otaguj wszystko ponownie');
|
||||
});
|
||||
|
||||
test('clicking the normal button runs a non-force tagging pass and shows the summary', function () {
|
||||
seedTaggerSubcategory();
|
||||
enableBookstackAndAiForTagging();
|
||||
fakeTaggerBookstackAndAi(
|
||||
pageTags: [10 => [], 11 => [['name' => 'Drukarki i skanery', 'value' => '']]],
|
||||
aiContent: '{"10": ["Drukarki i skanery"], "11": ["Drukarki i skanery"]}',
|
||||
);
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||
->call('runBookstackTagging')
|
||||
->assertSet('bookstackTagResult', ['scanned' => 2, 'tagged' => 1, 'skipped' => 1, 'failed_batches' => 0])
|
||||
->assertSee('Przeskanowano 2, otagowano 1, pominięto 1');
|
||||
});
|
||||
|
||||
test('clicking the force button reclassifies already-tagged content too', function () {
|
||||
seedTaggerSubcategory();
|
||||
enableBookstackAndAiForTagging();
|
||||
fakeTaggerBookstackAndAi(
|
||||
pageTags: [11 => [['name' => 'Drukarki i skanery', 'value' => '']]],
|
||||
aiContent: '{"11": ["Drukarki i skanery"]}',
|
||||
);
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||
->call('runBookstackTaggingForce')
|
||||
->assertSet('bookstackTagResult', ['scanned' => 1, 'tagged' => 1, 'skipped' => 0, 'failed_batches' => 0]);
|
||||
});
|
||||
|
||||
test('running tagging without a configured AI integration shows a helpful error instead of a silent no-op', function () {
|
||||
enableBookstackAndAiForTagging();
|
||||
Settings::set('ai_enabled', '0');
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||
->call('runBookstackTagging')
|
||||
->assertSet('bookstackTagResult', null)
|
||||
->assertSee('Włącz i skonfiguruj obie integracje');
|
||||
});
|
||||
@@ -45,3 +45,59 @@ test('admin can add a description to a subcategory via the edit dialog', functio
|
||||
|
||||
expect($sub->fresh()->description)->toBe('Problemy z połączeniem VPN.');
|
||||
});
|
||||
|
||||
test('admin can reorder subcategories with up/down arrows', function () {
|
||||
$admin = adminUser();
|
||||
$category = Category::query()->create(['name' => 'IT-Pomoc']);
|
||||
$vpn = $category->subcategories()->create(['name' => 'VPN', 'sort_order' => 0]);
|
||||
$wifi = $category->subcategories()->create(['name' => 'WiFi', 'sort_order' => 1]);
|
||||
$drukarki = $category->subcategories()->create(['name' => 'Drukarki', 'sort_order' => 2]);
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'categories')
|
||||
->call('moveSubcategoryUp', $drukarki->id)
|
||||
->assertOk();
|
||||
|
||||
expect($category->fresh()->subcategories->pluck('name')->all())
|
||||
->toBe(['VPN', 'Drukarki', 'WiFi']);
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'categories')
|
||||
->call('moveSubcategoryDown', $vpn->id)
|
||||
->assertOk();
|
||||
|
||||
expect($category->fresh()->subcategories->pluck('name')->all())
|
||||
->toBe(['Drukarki', 'VPN', 'WiFi']);
|
||||
});
|
||||
|
||||
test('moving the top subcategory up or the bottom one down is a no-op', function () {
|
||||
$admin = adminUser();
|
||||
$category = Category::query()->create(['name' => 'IT-Pomoc']);
|
||||
$vpn = $category->subcategories()->create(['name' => 'VPN', 'sort_order' => 0]);
|
||||
$wifi = $category->subcategories()->create(['name' => 'WiFi', 'sort_order' => 1]);
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'categories')
|
||||
->call('moveSubcategoryUp', $vpn->id)
|
||||
->call('moveSubcategoryDown', $wifi->id)
|
||||
->assertOk();
|
||||
|
||||
expect($category->fresh()->subcategories->pluck('name')->all())
|
||||
->toBe(['VPN', 'WiFi']);
|
||||
});
|
||||
|
||||
test('a new subcategory is appended to the end of the display order', function () {
|
||||
$admin = adminUser();
|
||||
$category = Category::query()->create(['name' => 'IT-Pomoc']);
|
||||
$category->subcategories()->create(['name' => 'VPN', 'sort_order' => 0]);
|
||||
$category->subcategories()->create(['name' => 'WiFi', 'sort_order' => 1]);
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'categories')
|
||||
->set('newSubNames.'.$category->id, 'Drukarki')
|
||||
->call('addSubcategory', $category->id)
|
||||
->assertOk();
|
||||
|
||||
expect($category->fresh()->subcategories->pluck('name')->all())
|
||||
->toBe(['VPN', 'WiFi', 'Drukarki']);
|
||||
});
|
||||
|
||||
106
src/tests/Feature/AdminSnipeitIntegrationConfigTest.php
Normal file
106
src/tests/Feature/AdminSnipeitIntegrationConfigTest.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\Panel;
|
||||
use App\Models\Category;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
|
||||
function adminUserForSnipeitTest(): User
|
||||
{
|
||||
return User::query()->create(['name' => 'Admin', 'email' => 'admin-snipeit@example.com', 'roles' => ['admin']]);
|
||||
}
|
||||
|
||||
test('the Integracje tab shows the Snipe-IT card with the requested fields', function () {
|
||||
$admin = adminUserForSnipeitTest();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||
->set('snipeitConfig.enabled', true)
|
||||
->assertSee('Snipe-IT')
|
||||
->assertSee('Adres API')
|
||||
->assertSee('Klucz API')
|
||||
->assertSee('Nie sprawdzaj SSL')
|
||||
->assertSee('Klient może wybrać sprzęt, którego dotyczy zgłoszenie')
|
||||
->assertSee('Operator może zobaczyć sprzęt zgłaszającego w widoku zgłoszenia')
|
||||
->assertSee('Zezwól operatorowi na przeszukiwanie całego inwentarza');
|
||||
});
|
||||
|
||||
test('the subcategory scope picker only appears once "klient może wybrać sprzęt" is checked', function () {
|
||||
$admin = adminUserForSnipeitTest();
|
||||
$category = Category::query()->create(['name' => 'Sprzęt']);
|
||||
$category->subcategories()->create(['name' => 'Laptop']);
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||
->set('snipeitConfig.enabled', true)
|
||||
->assertDontSee('Ogranicz do podkategorii')
|
||||
->set('snipeitConfig.clientCanSelectAsset', true)
|
||||
->assertSee('Ogranicz do podkategorii')
|
||||
->assertSee('Sprzęt / Laptop');
|
||||
});
|
||||
|
||||
test('saving the Snipe-IT config persists settings, encrypts the token at rest, and inverts the SSL checkbox', function () {
|
||||
$admin = adminUserForSnipeitTest();
|
||||
$category = Category::query()->create(['name' => 'Sprzęt']);
|
||||
$sub = $category->subcategories()->create(['name' => 'Laptop']);
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||
->set('snipeitConfig.enabled', true)
|
||||
->set('snipeitConfig.baseUrl', 'https://assets.firma.test')
|
||||
->set('snipeitConfig.apiToken', 'super-secret-token')
|
||||
->set('snipeitConfig.skipSslVerification', true)
|
||||
->set('snipeitConfig.clientCanSelectAsset', true)
|
||||
->call('toggleSnipeitClientSubcategory', $sub->id)
|
||||
->set('snipeitConfig.operatorViewRequesterAssets', true)
|
||||
->set('snipeitConfig.operatorSearchInventory', false)
|
||||
->call('saveSnipeitConfig')
|
||||
->assertOk();
|
||||
|
||||
expect(Settings::get('snipeit_enabled'))->toBe('1');
|
||||
expect(Settings::get('snipeit_base_url'))->toBe('https://assets.firma.test');
|
||||
expect(Settings::get('snipeit_api_token'))->toBe('super-secret-token');
|
||||
// "Nie sprawdzaj SSL" checked means verify_ssl is stored as off.
|
||||
expect(Settings::bool('snipeit_verify_ssl'))->toBeFalse();
|
||||
expect(Settings::bool('snipeit_client_can_select_asset'))->toBeTrue();
|
||||
expect(Settings::get('snipeit_client_asset_subcategory_ids'))->toBe((string) $sub->id);
|
||||
expect(Settings::bool('snipeit_operator_view_requester_assets'))->toBeTrue();
|
||||
expect(Settings::bool('snipeit_operator_search_inventory'))->toBeFalse();
|
||||
|
||||
$stored = Setting::query()->where('key', 'snipeit_api_token')->value('value');
|
||||
expect($stored)->not->toBe('super-secret-token');
|
||||
});
|
||||
|
||||
test('leaving the api token field blank on save keeps the previously stored token', function () {
|
||||
Settings::set('snipeit_api_token', 'already-stored-token');
|
||||
$admin = adminUserForSnipeitTest();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||
->set('snipeitConfig.enabled', true)
|
||||
->set('snipeitConfig.baseUrl', 'https://assets.firma.test')
|
||||
->call('saveSnipeitConfig')
|
||||
->assertOk();
|
||||
|
||||
expect(Settings::get('snipeit_api_token'))->toBe('already-stored-token');
|
||||
});
|
||||
|
||||
test('testSnipeitConnection reports the result of a live probe using unsaved form values', function () {
|
||||
Http::fake(['assets.firma.test/*' => Http::response(['rows' => []])]);
|
||||
$admin = adminUserForSnipeitTest();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||
->set('snipeitConfig.enabled', true)
|
||||
->set('snipeitConfig.baseUrl', 'https://assets.firma.test')
|
||||
->set('snipeitConfig.apiToken', 'tok')
|
||||
->call('testSnipeitConnection')
|
||||
->assertSet('snipeitTestResult', 'ok');
|
||||
});
|
||||
|
||||
test('testSnipeitConnection requires a base URL before probing', function () {
|
||||
$admin = adminUserForSnipeitTest();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||
->set('snipeitConfig.enabled', true)
|
||||
->call('testSnipeitConnection')
|
||||
->assertSet('snipeitTestResult', 'error');
|
||||
});
|
||||
66
src/tests/Feature/AiClientTest.php
Normal file
66
src/tests/Feature/AiClientTest.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
use App\Services\AiClient;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
test('enabled is false unless ai_enabled, ai_base_url and ai_model are all set — api key is optional', function () {
|
||||
expect(app(AiClient::class)->enabled())->toBeFalse();
|
||||
|
||||
Settings::set('ai_enabled', '1');
|
||||
Settings::set('ai_base_url', 'https://api.groq.test/openai/v1');
|
||||
Settings::set('ai_model', 'llama-3.3-70b-versatile');
|
||||
|
||||
expect(app(AiClient::class)->enabled())->toBeTrue();
|
||||
});
|
||||
|
||||
test('chat returns the assistant message content on success', function () {
|
||||
Settings::set('ai_enabled', '1');
|
||||
Settings::set('ai_base_url', 'https://api.groq.test/openai/v1');
|
||||
Settings::set('ai_model', 'llama-3.3-70b-versatile');
|
||||
|
||||
Http::fake([
|
||||
'api.groq.test/*' => Http::response(['choices' => [['message' => ['content' => 'hello']]]]),
|
||||
]);
|
||||
|
||||
expect(app(AiClient::class)->chat([['role' => 'user', 'content' => 'hi']]))->toBe('hello');
|
||||
});
|
||||
|
||||
test('chat returns null on a non-successful response instead of throwing', function () {
|
||||
Settings::set('ai_enabled', '1');
|
||||
Settings::set('ai_base_url', 'https://api.groq.test/openai/v1');
|
||||
Settings::set('ai_model', 'llama-3.3-70b-versatile');
|
||||
|
||||
Http::fake(['api.groq.test/*' => Http::response(['error' => 'nope'], 500)]);
|
||||
|
||||
expect(app(AiClient::class)->chat([['role' => 'user', 'content' => 'hi']]))->toBeNull();
|
||||
});
|
||||
|
||||
test('chat sends no Authorization header when no api key is configured (self-hosted Ollama style)', function () {
|
||||
Settings::set('ai_enabled', '1');
|
||||
Settings::set('ai_base_url', 'http://ollama.test/v1');
|
||||
Settings::set('ai_model', 'llama3');
|
||||
|
||||
Http::fake(['ollama.test/*' => Http::response(['choices' => [['message' => ['content' => 'ok']]]])]);
|
||||
|
||||
app(AiClient::class)->chat([['role' => 'user', 'content' => 'hi']]);
|
||||
|
||||
Http::assertSent(fn ($request) => ! $request->hasHeader('Authorization'));
|
||||
});
|
||||
|
||||
test('testConnection reports ok on a successful response', function () {
|
||||
Http::fake(['api.groq.test/*' => Http::response(['choices' => [['message' => ['content' => 'pong']]]])]);
|
||||
|
||||
$ok = app(AiClient::class)->testConnection('https://api.groq.test/openai/v1', 'key', 'llama3', true);
|
||||
|
||||
expect($ok)->toBe(['ok' => true, 'message' => null]);
|
||||
});
|
||||
|
||||
test('testConnection reports the provider error message on failure', function () {
|
||||
Http::fake(['api.groq.test/*' => Http::response(['error' => ['message' => 'bad model']], 400)]);
|
||||
|
||||
$error = app(AiClient::class)->testConnection('https://api.groq.test/openai/v1', 'key', 'bad-model', true);
|
||||
|
||||
expect($error['ok'])->toBeFalse();
|
||||
expect($error['message'])->toBe('bad model');
|
||||
});
|
||||
68
src/tests/Feature/AiSummaryRegenerateOnMessageTest.php
Normal file
68
src/tests/Feature/AiSummaryRegenerateOnMessageTest.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\GenerateTicketAiSummaryJob;
|
||||
use App\Models\User;
|
||||
use App\Services\TicketService;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
|
||||
function enableAiSummaryRegenerateOnMessage(bool $enabled): void
|
||||
{
|
||||
Settings::set('ai_enabled', '1');
|
||||
Settings::set('ai_summary_enabled', '1');
|
||||
Settings::set('ai_summary_regenerate_on_message', $enabled ? '1' : '0');
|
||||
}
|
||||
|
||||
test('an operator reply dispatches an immediate summary regeneration when the setting is enabled', function () {
|
||||
seedStatusesAndPriorities();
|
||||
enableAiSummaryRegenerateOnMessage(true);
|
||||
Bus::fake();
|
||||
|
||||
$ticket = makeTicket();
|
||||
$operator = operatorUser();
|
||||
|
||||
app(TicketService::class)->operatorReply($ticket, $operator, 'Odpowiedź operatora.');
|
||||
|
||||
Bus::assertDispatchedAfterResponse(GenerateTicketAiSummaryJob::class);
|
||||
});
|
||||
|
||||
test('a client reply does not dispatch regeneration when the setting is disabled', function () {
|
||||
seedStatusesAndPriorities();
|
||||
enableAiSummaryRegenerateOnMessage(false);
|
||||
Bus::fake();
|
||||
|
||||
$ticket = makeTicket();
|
||||
$client = User::factory()->create();
|
||||
|
||||
app(TicketService::class)->clientReply($ticket, $client, 'Odpowiedź klienta.');
|
||||
|
||||
Bus::assertNotDispatched(GenerateTicketAiSummaryJob::class);
|
||||
});
|
||||
|
||||
test('an internal operator note also triggers regeneration, matching the transcript including internal notes', function () {
|
||||
seedStatusesAndPriorities();
|
||||
enableAiSummaryRegenerateOnMessage(true);
|
||||
Bus::fake();
|
||||
|
||||
$ticket = makeTicket();
|
||||
$operator = operatorUser();
|
||||
|
||||
app(TicketService::class)->operatorNote($ticket, $operator, 'Notatka wewnętrzna.');
|
||||
|
||||
Bus::assertDispatchedAfterResponse(GenerateTicketAiSummaryJob::class);
|
||||
});
|
||||
|
||||
test('no regeneration is dispatched when the AI summary feature itself is off, even with the toggle on', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Settings::set('ai_enabled', '1');
|
||||
Settings::set('ai_summary_enabled', '0');
|
||||
Settings::set('ai_summary_regenerate_on_message', '1');
|
||||
Bus::fake();
|
||||
|
||||
$ticket = makeTicket();
|
||||
$operator = operatorUser();
|
||||
|
||||
app(TicketService::class)->operatorReply($ticket, $operator, 'Odpowiedź operatora.');
|
||||
|
||||
Bus::assertNotDispatched(GenerateTicketAiSummaryJob::class);
|
||||
});
|
||||
154
src/tests/Feature/BookStackContentTaggerTest.php
Normal file
154
src/tests/Feature/BookStackContentTaggerTest.php
Normal file
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Services\BookStackContentTagger;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
function seedTaggerSubcategory(): void
|
||||
{
|
||||
$category = Category::query()->create(['name' => 'IT-Pomoc']);
|
||||
$category->subcategories()->create(['name' => 'Drukarki i skanery']);
|
||||
}
|
||||
|
||||
function enableBookstackAndAiForTagging(): void
|
||||
{
|
||||
Settings::set('bookstack_enabled', '1');
|
||||
Settings::set('bookstack_base_url', 'https://wiki.test');
|
||||
Settings::set('bookstack_token_id', 'id');
|
||||
Settings::set('bookstack_token_secret', 'secret');
|
||||
|
||||
Settings::set('ai_enabled', '1');
|
||||
Settings::set('ai_base_url', 'https://ai.test');
|
||||
Settings::set('ai_model', 'llama-3.3-70b-versatile');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array> $pageTags page id => existing tags array
|
||||
*/
|
||||
function fakeTaggerBookstackAndAi(array $pageTags, string $aiContent): void
|
||||
{
|
||||
Http::fake([
|
||||
'wiki.test/api/books*' => Http::response(['data' => [], 'total' => 0]),
|
||||
'wiki.test/api/chapters*' => Http::response(['data' => [], 'total' => 0]),
|
||||
'wiki.test/api/pages*' => function ($request) use ($pageTags) {
|
||||
$path = parse_url($request->url(), PHP_URL_PATH);
|
||||
|
||||
if ($request->method() === 'GET' && Str::endsWith($path, '/api/pages')) {
|
||||
return Http::response([
|
||||
'data' => collect($pageTags)->keys()->map(fn ($id) => ['id' => $id, 'name' => "Page {$id}"])->values()->all(),
|
||||
'total' => count($pageTags),
|
||||
]);
|
||||
}
|
||||
|
||||
$id = (int) basename($path);
|
||||
|
||||
if ($request->method() === 'GET') {
|
||||
return Http::response([
|
||||
'id' => $id,
|
||||
'name' => "Page {$id}",
|
||||
'tags' => $pageTags[$id] ?? [],
|
||||
'markdown' => 'Treść o drukarkach i skanerach.',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($request->method() === 'PUT') {
|
||||
return Http::response(['id' => $id]);
|
||||
}
|
||||
|
||||
return Http::response([], 404);
|
||||
},
|
||||
'ai.test/*' => Http::response(['choices' => [['message' => ['content' => $aiContent]]]]),
|
||||
]);
|
||||
}
|
||||
|
||||
test('normal run tags untagged content and skips already-tagged content', function () {
|
||||
seedTaggerSubcategory();
|
||||
enableBookstackAndAiForTagging();
|
||||
fakeTaggerBookstackAndAi(
|
||||
pageTags: [10 => [], 11 => [['name' => 'Drukarki i skanery', 'value' => '']]],
|
||||
aiContent: '{"10": ["Drukarki i skanery"], "11": ["Drukarki i skanery"]}',
|
||||
);
|
||||
|
||||
$totals = app(BookStackContentTagger::class)->run();
|
||||
|
||||
expect($totals)->toBe(['scanned' => 2, 'tagged' => 1, 'skipped' => 1, 'failed_batches' => 0]);
|
||||
|
||||
Http::assertSent(fn ($r) => $r->method() === 'PUT'
|
||||
&& str_contains((string) $r->url(), '/api/pages/10')
|
||||
&& collect($r->data()['tags'])->contains(fn ($t) => $t['name'] === 'Drukarki i skanery'));
|
||||
|
||||
Http::assertNotSent(fn ($r) => $r->method() === 'PUT' && str_contains((string) $r->url(), '/api/pages/11'));
|
||||
|
||||
// the already-tagged page never even makes it into the AI prompt
|
||||
Http::assertSent(fn ($r) => str_contains((string) $r->url(), 'ai.test')
|
||||
? (str_contains($r['messages'][1]['content'], 'id=10') && ! str_contains($r['messages'][1]['content'], 'id=11'))
|
||||
: true);
|
||||
});
|
||||
|
||||
test('--dry-run classifies but never calls PUT', function () {
|
||||
seedTaggerSubcategory();
|
||||
enableBookstackAndAiForTagging();
|
||||
fakeTaggerBookstackAndAi(
|
||||
pageTags: [10 => []],
|
||||
aiContent: '{"10": ["Drukarki i skanery"]}',
|
||||
);
|
||||
|
||||
$totals = app(BookStackContentTagger::class)->run(dryRun: true);
|
||||
|
||||
expect($totals['tagged'])->toBe(1);
|
||||
Http::assertNotSent(fn ($r) => $r->method() === 'PUT');
|
||||
});
|
||||
|
||||
test('--force re-classifies and re-writes already-tagged content', function () {
|
||||
seedTaggerSubcategory();
|
||||
enableBookstackAndAiForTagging();
|
||||
fakeTaggerBookstackAndAi(
|
||||
pageTags: [11 => [['name' => 'Drukarki i skanery', 'value' => '']]],
|
||||
aiContent: '{"11": ["Drukarki i skanery"]}',
|
||||
);
|
||||
|
||||
$totals = app(BookStackContentTagger::class)->run(force: true);
|
||||
|
||||
expect($totals)->toBe(['scanned' => 1, 'tagged' => 1, 'skipped' => 0, 'failed_batches' => 0]);
|
||||
Http::assertSent(fn ($r) => $r->method() === 'PUT' && str_contains((string) $r->url(), '/api/pages/11'));
|
||||
});
|
||||
|
||||
test('tag matching is case-insensitive when deciding whether content is already tagged', function () {
|
||||
seedTaggerSubcategory();
|
||||
enableBookstackAndAiForTagging();
|
||||
fakeTaggerBookstackAndAi(
|
||||
pageTags: [11 => [['name' => 'drukarki i skanery', 'value' => '']]],
|
||||
aiContent: '{"11": []}',
|
||||
);
|
||||
|
||||
$totals = app(BookStackContentTagger::class)->run();
|
||||
|
||||
expect($totals['skipped'])->toBe(1);
|
||||
expect($totals['scanned'])->toBe(1);
|
||||
});
|
||||
|
||||
test('a malformed AI response fails only that batch, without writing any tags', function () {
|
||||
seedTaggerSubcategory();
|
||||
enableBookstackAndAiForTagging();
|
||||
fakeTaggerBookstackAndAi(
|
||||
pageTags: [10 => []],
|
||||
aiContent: 'this is not json at all',
|
||||
);
|
||||
|
||||
$totals = app(BookStackContentTagger::class)->run();
|
||||
|
||||
expect($totals)->toBe(['scanned' => 1, 'tagged' => 0, 'skipped' => 0, 'failed_batches' => 1]);
|
||||
Http::assertNotSent(fn ($r) => $r->method() === 'PUT');
|
||||
});
|
||||
|
||||
test('the tagger never touches bookshelves', function () {
|
||||
seedTaggerSubcategory();
|
||||
enableBookstackAndAiForTagging();
|
||||
fakeTaggerBookstackAndAi(pageTags: [], aiContent: '{}');
|
||||
|
||||
app(BookStackContentTagger::class)->run();
|
||||
|
||||
Http::assertNotSent(fn ($r) => str_contains((string) $r->url(), '/api/shelves'));
|
||||
});
|
||||
54
src/tests/Feature/BookStackSearchTagQueryTest.php
Normal file
54
src/tests/Feature/BookStackSearchTagQueryTest.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
use App\Services\BookStackClient;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
function enableBookstackForSearch(string $searchBy = 'both'): void
|
||||
{
|
||||
Settings::set('bookstack_enabled', '1');
|
||||
Settings::set('bookstack_base_url', 'https://wiki.test');
|
||||
Settings::set('bookstack_token_id', 'id');
|
||||
Settings::set('bookstack_token_secret', 'secret');
|
||||
Settings::set('bookstack_search_by', $searchBy);
|
||||
Settings::set('bookstack_allowed_shelf_ids_creation', '1');
|
||||
|
||||
Http::fake([
|
||||
'wiki.test/api/shelves/1' => Http::response(['books' => [['id' => 5]]]),
|
||||
'wiki.test/api/shelves*' => Http::response(['data' => [['id' => 1, 'name' => 'IT']]]),
|
||||
'wiki.test/api/search*' => Http::response(['data' => []]),
|
||||
]);
|
||||
}
|
||||
|
||||
test('with search_by=both, the tags-variant query uses tagQuery while the name-variant keeps the full query', function () {
|
||||
enableBookstackForSearch('both');
|
||||
|
||||
app(BookStackClient::class)->search('IT-Pomoc Drukarki i skanery', 5, BookStackClient::CONTEXT_CREATION, 'Drukarki i skanery');
|
||||
|
||||
Http::assertSent(fn ($request) => str_contains((string) $request->url(), '/api/search')
|
||||
&& ($request['query'] ?? '') === '{in_name:IT-Pomoc Drukarki i skanery}');
|
||||
|
||||
Http::assertSent(fn ($request) => str_contains((string) $request->url(), '/api/search')
|
||||
&& ($request['query'] ?? '') === '[Drukarki i skanery]');
|
||||
});
|
||||
|
||||
test('omitting tagQuery falls back to the full query for backward compatibility', function () {
|
||||
enableBookstackForSearch('tags');
|
||||
|
||||
app(BookStackClient::class)->search('IT-Pomoc Drukarki i skanery');
|
||||
|
||||
Http::assertSent(fn ($request) => str_contains((string) $request->url(), '/api/search')
|
||||
&& ($request['query'] ?? '') === '[IT-Pomoc Drukarki i skanery]');
|
||||
});
|
||||
|
||||
test('search_by=tags alone sends only the tag-form query built from tagQuery, never the plain category+subcategory text', function () {
|
||||
enableBookstackForSearch('tags');
|
||||
|
||||
app(BookStackClient::class)->search('IT-Pomoc Drukarki i skanery', 5, BookStackClient::CONTEXT_CREATION, 'Drukarki i skanery');
|
||||
|
||||
Http::assertSent(fn ($request) => str_contains((string) $request->url(), '/api/search')
|
||||
&& ($request['query'] ?? '') === '[Drukarki i skanery]');
|
||||
|
||||
Http::assertNotSent(fn ($request) => str_contains((string) $request->url(), '/api/search')
|
||||
&& str_contains($request['query'] ?? '', 'IT-Pomoc'));
|
||||
});
|
||||
31
src/tests/Feature/DeletedTicketRedirectsInsteadOf404Test.php
Normal file
31
src/tests/Feature/DeletedTicketRedirectsInsteadOf404Test.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
test('visiting a deleted ticket as an operator redirects to the operator queue instead of 404ing', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = User::query()->create(['name' => 'Op', 'email' => 'op@example.com', 'roles' => ['operator']]);
|
||||
$ticket = makeTicket();
|
||||
$id = $ticket->id;
|
||||
$ticket->delete();
|
||||
|
||||
$this->actingAs($operator)
|
||||
->get("/operator/tickets/{$id}")
|
||||
->assertRedirect(route('operator.queue'));
|
||||
});
|
||||
|
||||
test('visiting a deleted ticket as a client redirects to the client dashboard instead of 404ing', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$client = User::query()->create(['name' => 'Klient', 'email' => 'klient@example.com', 'roles' => ['client']]);
|
||||
$ticket = makeTicket(['customer_id' => $client->id]);
|
||||
$id = $ticket->id;
|
||||
$ticket->delete();
|
||||
|
||||
$this->actingAs($client)
|
||||
->get("/client/tickets/{$id}")
|
||||
->assertRedirect(route('client.dashboard'));
|
||||
});
|
||||
|
||||
test('a guest hitting a non-existent ticket route still gets the normal (non-redirected) handling', function () {
|
||||
$this->get('/operator/tickets/999999')->assertRedirect(route('login'));
|
||||
});
|
||||
203
src/tests/Feature/ImapCategoryRoutingAndSourceBadgeTest.php
Normal file
203
src/tests/Feature/ImapCategoryRoutingAndSourceBadgeTest.php
Normal file
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\MailSettings;
|
||||
use App\Livewire\Operator\Queue;
|
||||
use App\Livewire\Operator\TicketShow;
|
||||
use App\Models\Category;
|
||||
use App\Models\ImapMailbox;
|
||||
use App\Models\User;
|
||||
use App\Services\TicketService;
|
||||
use Livewire\Livewire;
|
||||
|
||||
// ===================== TicketService::create() category-only routing =====================
|
||||
|
||||
test('create() sets category_id when only a category is given (no subcategory)', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$category = Category::query()->create(['name' => 'Delegacje']);
|
||||
|
||||
$ticket = app(TicketService::class)->create([
|
||||
'email' => 'gosc@example.com',
|
||||
'category_id' => $category->id,
|
||||
'subject' => 'Sprawa delegacji',
|
||||
'body' => 'Treść',
|
||||
], null);
|
||||
|
||||
expect($ticket->category_id)->toBe($category->id)
|
||||
->and($ticket->subcategory_id)->toBeNull()
|
||||
->and($ticket->categoryLabel())->toBe('Delegacje');
|
||||
});
|
||||
|
||||
test('create() leaves category_id null when a subcategory is given (category is derived from it)', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$subcategory = subcategoryFixture();
|
||||
|
||||
$ticket = app(TicketService::class)->create([
|
||||
'email' => 'gosc@example.com',
|
||||
'subcategory_id' => $subcategory->id,
|
||||
'subject' => 'Sprawa VPN',
|
||||
'body' => 'Treść',
|
||||
], null);
|
||||
|
||||
expect($ticket->category_id)->toBeNull()
|
||||
->and($ticket->categoryLabel())->toBe('IT / VPN');
|
||||
});
|
||||
|
||||
test('create() defaults source to web, and accepts an explicit source', function () {
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
$webTicket = app(TicketService::class)->create([
|
||||
'email' => 'a@example.com', 'subject' => 'S', 'body' => 'B',
|
||||
], null);
|
||||
|
||||
$mailTicket = app(TicketService::class)->create([
|
||||
'email' => 'b@example.com', 'subject' => 'S', 'body' => 'B', 'source' => 'email',
|
||||
], null);
|
||||
|
||||
expect($webTicket->source)->toBe('web')
|
||||
->and($mailTicket->source)->toBe('email');
|
||||
});
|
||||
|
||||
// ===================== ImapMailbox::targetLabel() =====================
|
||||
|
||||
test('targetLabel reflects subcategory, whole-category, or neither', function () {
|
||||
$subcategory = subcategoryFixture();
|
||||
$category = Category::query()->create(['name' => 'Delegacje']);
|
||||
|
||||
$bySubcategory = ImapMailbox::query()->create(mailboxFixtureData(['default_subcategory_id' => $subcategory->id]));
|
||||
$byCategory = ImapMailbox::query()->create(mailboxFixtureData(['default_category_id' => $category->id]));
|
||||
$unrouted = ImapMailbox::query()->create(mailboxFixtureData());
|
||||
|
||||
expect($bySubcategory->targetLabel())->toBe('IT / VPN')
|
||||
->and($byCategory->targetLabel())->toBe('Cała kategoria: Delegacje')
|
||||
->and($unrouted->targetLabel())->toBe('—');
|
||||
});
|
||||
|
||||
function mailboxFixtureData(array $overrides = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'name' => 'Test',
|
||||
'enabled' => true,
|
||||
'host' => 'imap.example.com',
|
||||
'port' => 993,
|
||||
'encryption' => 'ssl',
|
||||
'validate_cert' => true,
|
||||
'username' => 'test@example.com',
|
||||
'password' => 'secret',
|
||||
'folder' => 'INBOX',
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
// ===================== Admin: MailSettings mailbox form =====================
|
||||
|
||||
test('admin can route a mailbox to a whole category via the combined selector', function () {
|
||||
$admin = adminUser();
|
||||
$category = Category::query()->create(['name' => 'Delegacje']);
|
||||
|
||||
Livewire::actingAs($admin)->test(MailSettings::class)
|
||||
->call('openMailboxForm')
|
||||
->set('mailboxForm.name', 'Zgłoszenia delegacji')
|
||||
->set('mailboxForm.host', 'imap.example.com')
|
||||
->set('mailboxForm.username', 'zgloszenia-delegacje@example.com')
|
||||
->set('mailboxForm.password', 'secret')
|
||||
->set('mailboxForm.target', "category:{$category->id}")
|
||||
->call('submitMailboxForm')
|
||||
->assertOk();
|
||||
|
||||
$mailbox = ImapMailbox::query()->where('name', 'Zgłoszenia delegacji')->firstOrFail();
|
||||
|
||||
expect($mailbox->default_category_id)->toBe($category->id)
|
||||
->and($mailbox->default_subcategory_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('admin can route a mailbox to a specific subcategory via the combined selector', function () {
|
||||
$admin = adminUser();
|
||||
$subcategory = subcategoryFixture();
|
||||
|
||||
Livewire::actingAs($admin)->test(MailSettings::class)
|
||||
->call('openMailboxForm')
|
||||
->set('mailboxForm.name', 'Zgłoszenia IT')
|
||||
->set('mailboxForm.host', 'imap.example.com')
|
||||
->set('mailboxForm.username', 'zgloszenia-it@example.com')
|
||||
->set('mailboxForm.password', 'secret')
|
||||
->set('mailboxForm.target', "subcategory:{$subcategory->id}")
|
||||
->call('submitMailboxForm')
|
||||
->assertOk();
|
||||
|
||||
$mailbox = ImapMailbox::query()->where('name', 'Zgłoszenia IT')->firstOrFail();
|
||||
|
||||
expect($mailbox->default_subcategory_id)->toBe($subcategory->id)
|
||||
->and($mailbox->default_category_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('switching an existing mailbox from a subcategory to a whole category clears the old target', function () {
|
||||
$admin = adminUser();
|
||||
$subcategory = subcategoryFixture();
|
||||
$category = Category::query()->create(['name' => 'Delegacje']);
|
||||
|
||||
$mailbox = ImapMailbox::query()->create(mailboxFixtureData(['default_subcategory_id' => $subcategory->id]));
|
||||
|
||||
Livewire::actingAs($admin)->test(MailSettings::class)
|
||||
->call('editMailbox', $mailbox->id)
|
||||
->assertSet('mailboxForm.target', "subcategory:{$subcategory->id}")
|
||||
->set('mailboxForm.target', "category:{$category->id}")
|
||||
->call('submitMailboxForm')
|
||||
->assertOk();
|
||||
|
||||
$mailbox->refresh();
|
||||
|
||||
expect($mailbox->default_category_id)->toBe($category->id)
|
||||
->and($mailbox->default_subcategory_id)->toBeNull();
|
||||
});
|
||||
|
||||
// ===================== Operator UI: e-mail source badge =====================
|
||||
|
||||
test('the operator queue shows a mail icon next to an e-mail-originated ticket but not a web one', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser();
|
||||
$webTicket = makeTicket(['number' => '2001', 'source' => 'web']);
|
||||
$mailTicket = makeTicket(['number' => '2002', 'source' => 'email']);
|
||||
|
||||
Livewire::actingAs($operator)->test(Queue::class)
|
||||
->assertSeeHtml('title="Utworzone przez e-mail"');
|
||||
});
|
||||
|
||||
test('the ticket detail header shows an e-mail badge only for e-mail-originated tickets', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser();
|
||||
$mailTicket = makeTicket(['number' => '2003', 'source' => 'email']);
|
||||
$webTicket = makeTicket(['number' => '2004', 'source' => 'web']);
|
||||
|
||||
Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $mailTicket])
|
||||
->assertSee('E-mail');
|
||||
|
||||
Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $webTicket])
|
||||
->assertDontSee('E-mail');
|
||||
});
|
||||
|
||||
// ===================== Operator queue: category-only tickets are filterable =====================
|
||||
|
||||
test('filtering the queue by category includes a ticket routed to that whole category with no subcategory', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser();
|
||||
$category = Category::query()->create(['name' => 'Delegacje']);
|
||||
$ticket = makeTicket(['number' => '2005', 'category_id' => $category->id]);
|
||||
|
||||
Livewire::actingAs($operator)->test(Queue::class)
|
||||
->set('filterCategory', $category->id)
|
||||
->assertSee($ticket->displayNumber());
|
||||
});
|
||||
|
||||
// ===================== Operator UI: per-message e-mail source badge =====================
|
||||
|
||||
test('a reply fetched by e-mail shows a mail badge in the thread, a normal client reply does not', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser();
|
||||
$client = User::query()->create(['name' => 'Klient', 'email' => 'klient@example.com', 'roles' => ['client']]);
|
||||
$ticket = makeTicket(['number' => '2006']);
|
||||
|
||||
app(TicketService::class)->clientReply($ticket, $client, 'Odpowiedź z portalu.');
|
||||
app(TicketService::class)->clientReply($ticket, $client, 'Odpowiedź e-mailem.', source: 'email');
|
||||
|
||||
Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket])
|
||||
->assertSeeHtml('title="Odebrane e-mailem"');
|
||||
});
|
||||
215
src/tests/Feature/ImapMessageClassifierTest.php
Normal file
215
src/tests/Feature/ImapMessageClassifierTest.php
Normal file
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
use App\Ldap\LldapUser;
|
||||
use App\Models\User;
|
||||
use App\Services\ImapMessageClassifier;
|
||||
use App\Support\Imap\InboundEmail;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Str;
|
||||
use LdapRecord\Laravel\Testing\DirectoryEmulator;
|
||||
|
||||
afterEach(function () {
|
||||
DirectoryEmulator::tearDown();
|
||||
});
|
||||
|
||||
function makeInboundEmail(array $overrides = []): InboundEmail
|
||||
{
|
||||
return new InboundEmail(
|
||||
fromEmail: $overrides['fromEmail'] ?? 'klient@example.com',
|
||||
fromName: $overrides['fromName'] ?? 'Jan Kowalski',
|
||||
subject: $overrides['subject'] ?? 'Zwykła wiadomość',
|
||||
textBody: $overrides['textBody'] ?? 'Treść wiadomości.',
|
||||
htmlBody: $overrides['htmlBody'] ?? '',
|
||||
headers: $overrides['headers'] ?? [],
|
||||
attachments: $overrides['attachments'] ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
// ===================== rejectionReason() =====================
|
||||
|
||||
test('a normal reply is not rejected', function () {
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
expect($classifier->rejectionReason(makeInboundEmail()))->toBeNull();
|
||||
});
|
||||
|
||||
test('Auto-Submitted header other than "no" is rejected', function () {
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
$email = makeInboundEmail(['headers' => ['auto-submitted' => 'auto-replied']]);
|
||||
|
||||
expect($classifier->rejectionReason($email))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('Auto-Submitted: no is not rejected', function () {
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
$email = makeInboundEmail(['headers' => ['auto-submitted' => 'no']]);
|
||||
|
||||
expect($classifier->rejectionReason($email))->toBeNull();
|
||||
});
|
||||
|
||||
test('X-Autoreply header is rejected', function () {
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
$email = makeInboundEmail(['headers' => ['x-autoreply' => '1']]);
|
||||
|
||||
expect($classifier->rejectionReason($email))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('regression: an empty-string header value (present key, no content) is treated as absent, not rejected', function () {
|
||||
// Reproduces the real production bug: Webklex's Header::get() returns
|
||||
// an empty (non-null) Attribute for a header that isn't on the message
|
||||
// at all, so a naive "!== null" check on x-autoreply/x-autorespond
|
||||
// rejected every single inbound e-mail.
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
$email = makeInboundEmail(['headers' => [
|
||||
'auto-submitted' => '', 'x-autoreply' => '', 'x-autorespond' => '', 'precedence' => '',
|
||||
]]);
|
||||
|
||||
expect($classifier->rejectionReason($email))->toBeNull();
|
||||
});
|
||||
|
||||
test('Precedence: bulk is rejected', function () {
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
$email = makeInboundEmail(['headers' => ['precedence' => 'bulk']]);
|
||||
|
||||
expect($classifier->rejectionReason($email))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('a blocklisted sender is rejected', function () {
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
$email = makeInboundEmail(['fromEmail' => 'mailer-daemon@example.com']);
|
||||
|
||||
expect($classifier->rejectionReason($email, ['mailer-daemon']))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('an out-of-office subject is rejected', function () {
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
$email = makeInboundEmail(['subject' => 'Automatic reply: Out of Office']);
|
||||
|
||||
expect($classifier->rejectionReason($email))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('a Polish autoresponder subject is rejected', function () {
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
$email = makeInboundEmail(['subject' => 'Automatyczna odpowiedz: nieobecnosc w biurze']);
|
||||
|
||||
expect($classifier->rejectionReason($email))->not->toBeNull();
|
||||
});
|
||||
|
||||
// ===================== matchTicket() =====================
|
||||
|
||||
test('matches an existing ticket by its plain sequential number in the subject', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$ticket = makeTicket(['number' => '1042']);
|
||||
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
expect($classifier->matchTicket('Re: Aktualizacja zgłoszenia #1042')->id)->toBe($ticket->id);
|
||||
});
|
||||
|
||||
test('matches an existing ticket by its checksum when obfuscation is enabled', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$ticket = makeTicket(['number' => '1042']);
|
||||
Settings::set('ticket_number_obfuscate', '1');
|
||||
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
expect($classifier->matchTicket("Re: Aktualizacja zgłoszenia #{$ticket->checksum}")->id)->toBe($ticket->id);
|
||||
});
|
||||
|
||||
test('returns null when no digit run in the subject matches any ticket', function () {
|
||||
seedStatusesAndPriorities();
|
||||
makeTicket(['number' => '1042']);
|
||||
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
expect($classifier->matchTicket('Nowa sprawa bez numeru'))->toBeNull();
|
||||
});
|
||||
|
||||
test('strips common reply/forward prefixes before matching', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$ticket = makeTicket(['number' => '1042']);
|
||||
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
foreach (['Re:', 'RE:', 'Odp:', 'Fwd:', 'FW:', 'Aw:'] as $prefix) {
|
||||
expect($classifier->matchTicket("{$prefix} Zgłoszenie #1042")->id)->toBe($ticket->id);
|
||||
}
|
||||
});
|
||||
|
||||
test('when the subject has multiple digit runs, the one that actually resolves wins', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$ticket = makeTicket(['number' => '1042']);
|
||||
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
// "2026" (a year, 4 digits) doesn't resolve to any ticket; "1042" does.
|
||||
expect($classifier->matchTicket('Zgłoszenie #1042 z dnia 2026-07-23')->id)->toBe($ticket->id);
|
||||
});
|
||||
|
||||
// ===================== isSenderAllowed() / resolveSender() =====================
|
||||
|
||||
test('isSenderAllowed allows any e-mail when restrict_tickets_to_ldap is off (the default)', function () {
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
expect($classifier->isSenderAllowed('ktokolwiek@example.com'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('isSenderAllowed rejects an unknown e-mail when restrict_tickets_to_ldap is on', function () {
|
||||
DirectoryEmulator::setup();
|
||||
Settings::set('restrict_tickets_to_ldap', '1');
|
||||
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
expect($classifier->isSenderAllowed('nieznany@firma.pl'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('isSenderAllowed allows an e-mail that exists in LDAP when restrict_tickets_to_ldap is on', function () {
|
||||
DirectoryEmulator::setup();
|
||||
Settings::set('restrict_tickets_to_ldap', '1');
|
||||
|
||||
LldapUser::create([
|
||||
'uid' => 'znany.gosc',
|
||||
'cn' => 'Znany Gość',
|
||||
'mail' => 'znany.gosc@firma.pl',
|
||||
'entryuuid' => (string) Str::uuid(),
|
||||
]);
|
||||
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
expect($classifier->isSenderAllowed('znany.gosc@firma.pl'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('isSenderAllowed allows an already-known local account even when restrict_tickets_to_ldap is on', function () {
|
||||
DirectoryEmulator::setup();
|
||||
Settings::set('restrict_tickets_to_ldap', '1');
|
||||
|
||||
User::query()->create(['name' => 'Istniejący Klient', 'email' => 'istniejacy@firma.pl', 'roles' => ['client']]);
|
||||
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
expect($classifier->isSenderAllowed('istniejacy@firma.pl'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('resolveSender returns an existing local user without touching LDAP', function () {
|
||||
$user = User::query()->create(['name' => 'Istniejący', 'email' => 'istniejacy@firma.pl', 'roles' => ['client']]);
|
||||
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
expect($classifier->resolveSender('istniejacy@firma.pl')->id)->toBe($user->id);
|
||||
});
|
||||
|
||||
test('resolveSender returns null for an unprovisionable guest', function () {
|
||||
Settings::set('ldap_auto_provision_guests', '0');
|
||||
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
expect($classifier->resolveSender('nikt@example.com'))->toBeNull();
|
||||
});
|
||||
@@ -1,18 +1,18 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\Panel;
|
||||
use App\Livewire\Admin\MailSettings;
|
||||
use App\Models\EmailTemplate;
|
||||
use App\Notifications\TicketNotification;
|
||||
use App\Providers\AppServiceProvider;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('admin can save the SMTP/from settings, and the password is only overwritten when provided', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'email')
|
||||
Livewire::actingAs($admin)->test(MailSettings::class)
|
||||
->set('mailConfig.fromAddress', 'wsparcie@firma.pl')
|
||||
->set('mailConfig.fromName', 'Zespół Wsparcia')
|
||||
->set('mailConfig.smtpEnabled', true)
|
||||
@@ -31,7 +31,7 @@ test('admin can save the SMTP/from settings, and the password is only overwritte
|
||||
->and(Settings::get('mail_smtp_password'))->toBe('sekret123');
|
||||
|
||||
// Saving again with a blank password field must not wipe the stored one.
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
Livewire::actingAs($admin)->test(MailSettings::class)
|
||||
->set('mailConfig.smtpHost', 'smtp.firma.pl')
|
||||
->set('mailConfig.smtpPassword', '')
|
||||
->call('saveMailConfig')
|
||||
@@ -44,14 +44,14 @@ test('the SMTP test button reports an error without a host/from address, and suc
|
||||
Mail::fake();
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
Livewire::actingAs($admin)->test(MailSettings::class)
|
||||
->call('testMailConnection')
|
||||
->assertSet('mailTestResult', 'error');
|
||||
|
||||
// Mail::fake()'s raw() is a no-op that never throws, so a valid config
|
||||
// reports success — this exercises the same config-override/restore path
|
||||
// real sends use, without needing a reachable SMTP server in tests.
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
Livewire::actingAs($admin)->test(MailSettings::class)
|
||||
->set('mailConfig.fromAddress', 'wsparcie@firma.pl')
|
||||
->set('mailConfig.smtpHost', 'smtp.firma.pl')
|
||||
->call('testMailConnection')
|
||||
@@ -109,3 +109,30 @@ test('AppServiceProvider always applies the from-address override regardless of
|
||||
expect(config('mail.from.address'))->toBe('wsparcie@firma.pl')
|
||||
->and(config('mail.from.name'))->toBe('Wsparcie');
|
||||
});
|
||||
|
||||
test('regression: the mail override still applies for a console command other than migrate (e.g. schedule:run/tinker)', function () {
|
||||
// Reproduces the real production bug: settingsTableUsable() used to
|
||||
// blanket-skip for *any* console command, which meant scheduled
|
||||
// commands (emails:fetch-imap, tickets:check-sla-breaches) always sent
|
||||
// mail via the .env "log" mailer instead of the configured SMTP server,
|
||||
// since AppServiceProvider::boot() runs on every process including
|
||||
// console ones. Only the migrate family should still be excluded.
|
||||
$originalArgv = $_SERVER['argv'] ?? null;
|
||||
|
||||
Settings::set('mail_smtp_enabled', '1');
|
||||
Settings::set('mail_smtp_host', 'smtp.enabled.example');
|
||||
|
||||
try {
|
||||
$_SERVER['argv'] = ['artisan', 'emails:fetch-imap'];
|
||||
(new AppServiceProvider(app()))->boot();
|
||||
expect(config('mail.default'))->toBe('smtp');
|
||||
|
||||
Config::set('mail.default', 'log');
|
||||
|
||||
$_SERVER['argv'] = ['artisan', 'migrate'];
|
||||
(new AppServiceProvider(app()))->boot();
|
||||
expect(config('mail.default'))->not->toBe('smtp');
|
||||
} finally {
|
||||
$_SERVER['argv'] = $originalArgv;
|
||||
}
|
||||
});
|
||||
|
||||
43
src/tests/Feature/OperatorAiSummaryRegenerateTest.php
Normal file
43
src/tests/Feature/OperatorAiSummaryRegenerateTest.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Operator\TicketShow;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('operator can manually trigger AI summary regeneration from the ticket sidebar', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Settings::set('ai_enabled', '1');
|
||||
Settings::set('ai_base_url', 'https://ai.test');
|
||||
Settings::set('ai_model', 'llama-3.3-70b-versatile');
|
||||
Settings::set('ai_summary_enabled', '1');
|
||||
Http::fake(['ai.test/*' => Http::response([
|
||||
'choices' => [['message' => ['content' => '{"summary": "Ręcznie wygenerowane.", "suggested_action": null}']]],
|
||||
])]);
|
||||
|
||||
$operator = operatorUser();
|
||||
$ticket = makeTicket();
|
||||
|
||||
Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket])
|
||||
->call('regenerateAiSummary')
|
||||
->call('loadAiSummary')
|
||||
->assertOk()
|
||||
->assertSee('Ręcznie wygenerowane.');
|
||||
|
||||
expect($ticket->refresh()->ai_summary)->toBe('Ręcznie wygenerowane.');
|
||||
});
|
||||
|
||||
test('a failed manual regeneration shows an error and leaves the previous summary intact', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Settings::set('ai_enabled', '0');
|
||||
|
||||
$operator = operatorUser();
|
||||
$ticket = makeTicket();
|
||||
$ticket->update(['ai_summary' => 'stare podsumowanie']);
|
||||
|
||||
Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket])
|
||||
->call('regenerateAiSummary')
|
||||
->assertSet('aiSummaryRegenerateError', 'Nie udało się wygenerować podsumowania. Sprawdź konfigurację integracji AI.');
|
||||
|
||||
expect($ticket->refresh()->ai_summary)->toBe('stare podsumowanie');
|
||||
});
|
||||
39
src/tests/Feature/OperatorQueueSelectAllTest.php
Normal file
39
src/tests/Feature/OperatorQueueSelectAllTest.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Operator\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('toggleSelectAll selects every currently visible ticket', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('select-all-1@example.com');
|
||||
$a = makeTicket(['number' => '1001']);
|
||||
$b = makeTicket(['number' => '1002']);
|
||||
|
||||
Livewire::actingAs($operator)->test(Queue::class)
|
||||
->call('toggleSelectAll')
|
||||
->assertSet('selectedIds', [$a->id, $b->id]);
|
||||
});
|
||||
|
||||
test('toggleSelectAll deselects everything when all visible tickets are already selected', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('select-all-2@example.com');
|
||||
makeTicket(['number' => '1001']);
|
||||
makeTicket(['number' => '1002']);
|
||||
|
||||
Livewire::actingAs($operator)->test(Queue::class)
|
||||
->call('toggleSelectAll')
|
||||
->call('toggleSelectAll')
|
||||
->assertSet('selectedIds', []);
|
||||
});
|
||||
|
||||
test('toggleSelectAll only affects tickets visible under the active filter, not every ticket', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('select-all-3@example.com');
|
||||
makeTicket(['number' => '1001', 'status_key' => 'open']);
|
||||
$closed = makeTicket(['number' => '1002', 'status_key' => 'closed']);
|
||||
|
||||
Livewire::actingAs($operator)->test(Queue::class)
|
||||
->set('queue', 'closed')
|
||||
->call('toggleSelectAll')
|
||||
->assertSet('selectedIds', [$closed->id]);
|
||||
});
|
||||
@@ -91,7 +91,7 @@ test('a non-admin operator can still open a ticket outside their team if it is p
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
test('the team reassignment dropdown on a ticket only offers a non-admin operator their own teams', function () {
|
||||
test('the team reassignment dropdown on a ticket offers a non-admin operator every team, not just their own', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('scoped-5@example.com');
|
||||
$myTeam = Team::query()->create(['name' => 'Infrastruktura']);
|
||||
@@ -101,9 +101,25 @@ test('the team reassignment dropdown on a ticket only offers a non-admin operato
|
||||
$ticket = makeTicket(['number' => '5001', 'team_id' => $myTeam->id]);
|
||||
|
||||
$teamNames = Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket])
|
||||
->instance()->teams->pluck('name')->all();
|
||||
->instance()->teams->pluck('name')->sort()->values()->all();
|
||||
|
||||
expect($teamNames)->toBe(['Infrastruktura']);
|
||||
expect($teamNames)->toBe(['Aplikacje', 'Infrastruktura']);
|
||||
});
|
||||
|
||||
test('a non-admin operator can reassign a ticket to a team they do not belong to', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('scoped-7@example.com');
|
||||
$myTeam = Team::query()->create(['name' => 'Infrastruktura']);
|
||||
$otherTeam = Team::query()->create(['name' => 'Aplikacje']);
|
||||
$operator->teams()->attach($myTeam->id);
|
||||
|
||||
$ticket = makeTicket(['number' => '5002', 'team_id' => $myTeam->id]);
|
||||
|
||||
Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket])
|
||||
->call('setTeam', (string) $otherTeam->id)
|
||||
->assertOk();
|
||||
|
||||
expect($ticket->fresh()->team_id)->toBe($otherTeam->id);
|
||||
});
|
||||
|
||||
test('merging cannot pull in a ticket outside the operators scope via a crafted selection', function () {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Operator\TicketShow;
|
||||
use App\Models\Team;
|
||||
use App\Models\Ticket;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('an operator viewing a ticket is sent to the queue instead of erroring when it is deleted by someone else', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('redirect-1@example.com');
|
||||
$ticket = makeTicket(['number' => '6001', 'team_id' => null]);
|
||||
|
||||
$component = Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket]);
|
||||
|
||||
Ticket::query()->whereKey($ticket->id)->delete();
|
||||
|
||||
$component->call('onQueueChanged', $ticket->id)
|
||||
->assertRedirect(route('operator.queue'));
|
||||
});
|
||||
|
||||
test('an operator is sent to the queue when a live update moves the ticket to a team outside their scope', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('redirect-2@example.com');
|
||||
$myTeam = Team::query()->create(['name' => 'Infrastruktura']);
|
||||
$otherTeam = Team::query()->create(['name' => 'Aplikacje']);
|
||||
$operator->teams()->attach($myTeam->id);
|
||||
|
||||
$ticket = makeTicket(['number' => '6002', 'team_id' => $myTeam->id]);
|
||||
|
||||
$component = Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket]);
|
||||
|
||||
$ticket->update(['team_id' => $otherTeam->id]);
|
||||
|
||||
$component->call('onQueueChanged', $ticket->id)
|
||||
->assertRedirect(route('operator.queue'));
|
||||
});
|
||||
|
||||
test('an operator is not redirected by a live update for a ticket still personally assigned to them', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('redirect-3@example.com');
|
||||
$myTeam = Team::query()->create(['name' => 'Infrastruktura']);
|
||||
$otherTeam = Team::query()->create(['name' => 'Aplikacje']);
|
||||
$operator->teams()->attach($myTeam->id);
|
||||
|
||||
$ticket = makeTicket(['number' => '6003', 'team_id' => $myTeam->id, 'assignee_id' => $operator->id]);
|
||||
|
||||
$component = Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket]);
|
||||
|
||||
$ticket->update(['team_id' => $otherTeam->id]);
|
||||
|
||||
$component->call('onQueueChanged', $ticket->id)
|
||||
->assertNoRedirect();
|
||||
});
|
||||
|
||||
test('an operator who reassigns a ticket to a team outside their own scope is redirected immediately', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('redirect-4@example.com');
|
||||
$myTeam = Team::query()->create(['name' => 'Infrastruktura']);
|
||||
$otherTeam = Team::query()->create(['name' => 'Aplikacje']);
|
||||
$operator->teams()->attach($myTeam->id);
|
||||
|
||||
$ticket = makeTicket(['number' => '6004', 'team_id' => $myTeam->id]);
|
||||
|
||||
Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket])
|
||||
->call('setTeam', (string) $otherTeam->id)
|
||||
->assertRedirect(route('operator.queue'));
|
||||
|
||||
expect($ticket->fresh()->team_id)->toBe($otherTeam->id);
|
||||
});
|
||||
|
||||
test('an operator who reassigns a ticket to their own team is not redirected', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('redirect-5@example.com');
|
||||
$myTeam = Team::query()->create(['name' => 'Infrastruktura']);
|
||||
$operator->teams()->attach($myTeam->id);
|
||||
|
||||
$ticket = makeTicket(['number' => '6005', 'team_id' => null]);
|
||||
|
||||
Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket])
|
||||
->call('setTeam', (string) $myTeam->id)
|
||||
->assertNoRedirect();
|
||||
|
||||
expect($ticket->fresh()->team_id)->toBe($myTeam->id);
|
||||
});
|
||||
|
||||
test('a direct link to a since-deleted ticket redirects an operator to their queue instead of a 404', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('redirect-6@example.com');
|
||||
$ticket = makeTicket(['number' => '6006']);
|
||||
$url = route('operator.ticket', $ticket);
|
||||
$ticket->delete();
|
||||
|
||||
$this->actingAs($operator)->get($url)->assertRedirect(route('operator.queue'));
|
||||
});
|
||||
45
src/tests/Feature/RefreshBadgesConfiguredIntervalTest.php
Normal file
45
src/tests/Feature/RefreshBadgesConfiguredIntervalTest.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Client\TicketShow as ClientTicketShow;
|
||||
use App\Livewire\NotificationBell;
|
||||
use App\Livewire\Operator\Queue;
|
||||
use App\Livewire\Operator\TicketShow as OperatorTicketShow;
|
||||
use App\Models\User;
|
||||
use App\Support\Settings;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('operator ticket view renders the configured interval and a working click-to-refresh handler', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Settings::set('refresh_ticket_view_seconds', '45');
|
||||
$ticket = makeTicket(['number' => '7001']);
|
||||
|
||||
Livewire::actingAs(operatorUser())->test(OperatorTicketShow::class, ['ticket' => $ticket])
|
||||
->assertSeeHtml('remaining: 45, total: 45')
|
||||
->assertSeeHtml('remaining = total; $wire.refreshTicketData()');
|
||||
});
|
||||
|
||||
test('client ticket view renders the configured interval and a working click-to-refresh handler', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Settings::set('refresh_ticket_view_seconds', '50');
|
||||
$client = User::query()->create(['name' => 'Anna Kowalska', 'email' => 'client-refresh@example.com', 'roles' => ['client']]);
|
||||
$ticket = makeTicket(['number' => '7002', 'customer_id' => $client->id]);
|
||||
|
||||
Livewire::actingAs($client)->test(ClientTicketShow::class, ['ticket' => $ticket])
|
||||
->assertSeeHtml('remaining: 50, total: 50')
|
||||
->assertSeeHtml('remaining = total; $wire.refreshTicketData()');
|
||||
});
|
||||
|
||||
test('operator queue renders the configured interval and a working click-to-refresh handler', function () {
|
||||
Settings::set('refresh_queue_seconds', '75');
|
||||
|
||||
Livewire::actingAs(operatorUser())->test(Queue::class)
|
||||
->assertSeeHtml('remaining: 75, total: 75')
|
||||
->assertSeeHtml('remaining = total; $wire.refreshQueue()');
|
||||
});
|
||||
|
||||
test('notification bell polls at the configured interval', function () {
|
||||
Settings::set('refresh_notifications_seconds', '15');
|
||||
|
||||
Livewire::actingAs(operatorUser())->test(NotificationBell::class)
|
||||
->assertSeeHtml('wire:poll.15s="$refresh"');
|
||||
});
|
||||
43
src/tests/Feature/RefreshIntervalsConfigTest.php
Normal file
43
src/tests/Feature/RefreshIntervalsConfigTest.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\Panel;
|
||||
use App\Support\Settings;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('admin can save custom refresh and schedule intervals from the Konfiguracja tab', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'config')
|
||||
->set('systemConfig.refreshTicketViewSeconds', '45')
|
||||
->set('systemConfig.refreshQueueSeconds', '90')
|
||||
->set('systemConfig.refreshNotificationsSeconds', '20')
|
||||
->set('systemConfig.scheduleSlaCheckMinutes', '10')
|
||||
->set('systemConfig.scheduleAutomationRulesMinutes', '10')
|
||||
->set('systemConfig.scheduleImapFetchMinutes', '2')
|
||||
->set('systemConfig.scheduleAiAutomationMinutes', '2')
|
||||
->call('saveSystemConfig')
|
||||
->assertOk();
|
||||
|
||||
expect(Settings::get('refresh_ticket_view_seconds'))->toBe('45');
|
||||
expect(Settings::get('refresh_queue_seconds'))->toBe('90');
|
||||
expect(Settings::get('refresh_notifications_seconds'))->toBe('20');
|
||||
expect(Settings::get('schedule_sla_check_minutes'))->toBe('10');
|
||||
expect(Settings::get('schedule_automation_rules_minutes'))->toBe('10');
|
||||
expect(Settings::get('schedule_imap_fetch_minutes'))->toBe('2');
|
||||
expect(Settings::get('schedule_ai_automation_minutes'))->toBe('2');
|
||||
});
|
||||
|
||||
test('saving a zero or negative interval clamps it to 1', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'config')
|
||||
->set('systemConfig.refreshQueueSeconds', '0')
|
||||
->set('systemConfig.scheduleImapFetchMinutes', '-3')
|
||||
->call('saveSystemConfig')
|
||||
->assertOk();
|
||||
|
||||
expect(Settings::get('refresh_queue_seconds'))->toBe('1');
|
||||
expect(Settings::get('schedule_imap_fetch_minutes'))->toBe('1');
|
||||
});
|
||||
@@ -98,6 +98,24 @@ test('sending via a status-changing quick action updates the ticket status', fun
|
||||
->and($ticket->fresh()->messages()->where('body', 'Naprawione, proszę potwierdzić.')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('sending via a status-changing quick action changes the status select\'s wire:key so the browser is forced to redraw it', function () {
|
||||
// The <select> is bound via wire:change, not wire:model, so Livewire's
|
||||
// morph step otherwise preserves whatever the browser already has
|
||||
// selected instead of applying the freshly rendered "selected" option —
|
||||
// a documented Livewire/Alpine-morph quirk for uncontrolled form
|
||||
// elements. Keying the element to status_key forces a real replace.
|
||||
$this->seed();
|
||||
$operator = operatorUser('quickaction-wirekey@example.com');
|
||||
$ticket = makeTicket(['number' => '1002', 'status_key' => 'new']);
|
||||
$action = ReplyQuickAction::query()->where('label', 'Wyślij i oznacz jako rozwiązane')->firstOrFail();
|
||||
|
||||
Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket])
|
||||
->assertSeeHtml('wire:key="ticket-status-select-new"')
|
||||
->set('reply', 'Naprawione, proszę potwierdzić.')
|
||||
->call('sendAndTransition', $action->id)
|
||||
->assertSeeHtml('wire:key="ticket-status-select-closed"');
|
||||
});
|
||||
|
||||
test('sending via a "nie zmieniaj" quick action posts the reply without changing the status', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('quickaction-nochange@example.com');
|
||||
|
||||
36
src/tests/Feature/RunAiTicketAutomationCommandTest.php
Normal file
36
src/tests/Feature/RunAiTicketAutomationCommandTest.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Priority;
|
||||
use App\Models\Ticket;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
test('the scheduled command runs both triage and summary in one pass', function () {
|
||||
Settings::set('ai_enabled', '1');
|
||||
Settings::set('ai_base_url', 'https://ai.test');
|
||||
Settings::set('ai_model', 'llama-3.3-70b-versatile');
|
||||
Settings::set('ai_triage_set_priority', '1');
|
||||
Settings::set('ai_summary_enabled', '1');
|
||||
|
||||
Priority::query()->create(['key' => 'high', 'label' => 'Wysoki', 'color' => '#000', 'sort_order' => 1]);
|
||||
|
||||
Ticket::query()->create([
|
||||
'number' => '900100',
|
||||
'email' => 'client@example.com',
|
||||
'name' => 'Test Client',
|
||||
'subject' => 'Test',
|
||||
'body' => 'Treść zgłoszenia.',
|
||||
'status_key' => 'new',
|
||||
'priority_key' => 'medium',
|
||||
'custom_fields' => [],
|
||||
]);
|
||||
|
||||
Http::fake(['ai.test/*' => Http::response(['choices' => [
|
||||
['message' => ['content' => '{"category": null, "subcategory": null, "subject": null, "priority": "high"}']],
|
||||
]])]);
|
||||
|
||||
$this->artisan('ai:run-ticket-automation')
|
||||
->assertSuccessful()
|
||||
->expectsOutputToContain('AI triage: scanned 1, changed 1, failed 0.')
|
||||
->expectsOutputToContain('AI summaries: scanned 1,');
|
||||
});
|
||||
42
src/tests/Feature/SettingsCronExpressionTest.php
Normal file
42
src/tests/Feature/SettingsCronExpressionTest.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
test('dueEveryMinutes is true on a minute that is a multiple of the configured interval', function () {
|
||||
Carbon::setTestNow(Carbon::parse('2026-01-01 12:14:00'));
|
||||
Settings::set('schedule_imap_fetch_minutes', '7');
|
||||
|
||||
expect(Settings::dueEveryMinutes('schedule_imap_fetch_minutes', 5))->toBeTrue();
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('dueEveryMinutes is false on a minute that is not a multiple of the configured interval', function () {
|
||||
Carbon::setTestNow(Carbon::parse('2026-01-01 12:15:00'));
|
||||
Settings::set('schedule_imap_fetch_minutes', '7');
|
||||
|
||||
expect(Settings::dueEveryMinutes('schedule_imap_fetch_minutes', 5))->toBeFalse();
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('dueEveryMinutes falls back to the given default when unset', function () {
|
||||
Carbon::setTestNow(Carbon::parse('2026-01-01 12:15:00'));
|
||||
|
||||
expect(Settings::dueEveryMinutes('schedule_ai_automation_minutes', 5))->toBeTrue();
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('dueEveryMinutes clamps a zero or negative stored value to 1, so it is always due', function () {
|
||||
Carbon::setTestNow(Carbon::parse('2026-01-01 12:13:00'));
|
||||
|
||||
Settings::set('schedule_sla_check_minutes', '0');
|
||||
expect(Settings::dueEveryMinutes('schedule_sla_check_minutes', 15))->toBeTrue();
|
||||
|
||||
Settings::set('schedule_sla_check_minutes', '-4');
|
||||
expect(Settings::dueEveryMinutes('schedule_sla_check_minutes', 15))->toBeTrue();
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
146
src/tests/Feature/SnipeItClientTest.php
Normal file
146
src/tests/Feature/SnipeItClientTest.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
use App\Services\SnipeItClient;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
function enableSnipeit(): void
|
||||
{
|
||||
Settings::set('snipeit_enabled', '1');
|
||||
Settings::set('snipeit_base_url', 'https://assets.test');
|
||||
Settings::set('snipeit_api_token', 'tok');
|
||||
}
|
||||
|
||||
test('enabled requires enabled flag, base url and api token', function () {
|
||||
expect(app(SnipeItClient::class)->enabled())->toBeFalse();
|
||||
|
||||
enableSnipeit();
|
||||
|
||||
expect(app(SnipeItClient::class)->enabled())->toBeTrue();
|
||||
});
|
||||
|
||||
test('assetsForEmail looks up the Snipe-IT user by e-mail, then lists what is checked out to them', function () {
|
||||
enableSnipeit();
|
||||
|
||||
Http::fake([
|
||||
'assets.test/api/v1/users?*' => Http::response(['rows' => [
|
||||
['id' => 7, 'email' => 'jan@example.com', 'name' => 'Jan Kowalski'],
|
||||
]]),
|
||||
'assets.test/api/v1/users/7/assets*' => Http::response(['rows' => [
|
||||
[
|
||||
'id' => 100, 'asset_tag' => 'SI-001', 'name' => 'Laptop Jana', 'serial' => 'SN12345',
|
||||
'manufacturer' => ['name' => 'Dell'], 'model' => ['name' => 'Latitude 5420'],
|
||||
'category' => ['name' => 'Laptopy'], 'status_label' => ['name' => 'Deployed'],
|
||||
],
|
||||
]]),
|
||||
]);
|
||||
|
||||
$assets = app(SnipeItClient::class)->assetsForEmail('jan@example.com');
|
||||
|
||||
expect($assets)->toHaveCount(1);
|
||||
expect($assets[0]['id'])->toBe(100);
|
||||
expect($assets[0]['label'])->toBe('SI-001 - SN12345 - Dell Latitude 5420');
|
||||
expect($assets[0]['category'])->toBe('Laptopy');
|
||||
expect($assets[0]['status'])->toBe('Deployed');
|
||||
expect($assets[0]['url'])->toBe('https://assets.test/hardware/100');
|
||||
});
|
||||
|
||||
test('normalizeAsset joins only whichever of asset tag / serial / manufacturer+model are present, falling back to the asset id', function () {
|
||||
enableSnipeit();
|
||||
|
||||
Http::fake(['assets.test/api/v1/hardware/1' => Http::response([
|
||||
'id' => 1, 'asset_tag' => 'SI-100', 'serial' => null, 'manufacturer' => null, 'model' => null,
|
||||
])]);
|
||||
expect(app(SnipeItClient::class)->asset(1)['label'])->toBe('SI-100');
|
||||
|
||||
Http::fake(['assets.test/api/v1/hardware/2' => Http::response([
|
||||
'id' => 2, 'asset_tag' => null, 'serial' => null, 'manufacturer' => null, 'model' => null,
|
||||
])]);
|
||||
expect(app(SnipeItClient::class)->asset(2)['label'])->toBe('Zasób #2');
|
||||
});
|
||||
|
||||
test('assetsForEmail returns nothing when no Snipe-IT user matches the e-mail', function () {
|
||||
enableSnipeit();
|
||||
|
||||
Http::fake(['assets.test/api/v1/users?*' => Http::response(['rows' => []])]);
|
||||
|
||||
expect(app(SnipeItClient::class)->assetsForEmail('nobody@example.com'))->toBe([]);
|
||||
});
|
||||
|
||||
test('assetsForEmail returns an empty list without an HTTP call when the integration is disabled', function () {
|
||||
Http::fake();
|
||||
|
||||
expect(app(SnipeItClient::class)->assetsForEmail('jan@example.com'))->toBe([]);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('searchAssets hits the hardware list endpoint with the search query', function () {
|
||||
enableSnipeit();
|
||||
|
||||
Http::fake(['assets.test/api/v1/hardware?*' => Http::response(['rows' => [
|
||||
[
|
||||
'id' => 55, 'asset_tag' => 'SI-055', 'name' => null, 'serial' => null,
|
||||
'manufacturer' => ['name' => 'HP'], 'model' => ['name' => 'LaserJet Pro'],
|
||||
'category' => ['name' => 'Drukarki'], 'status_label' => ['name' => 'Ready to Deploy'],
|
||||
],
|
||||
]])]);
|
||||
|
||||
$results = app(SnipeItClient::class)->searchAssets('drukarka');
|
||||
|
||||
expect($results)->toHaveCount(1);
|
||||
expect($results[0]['label'])->toBe('SI-055 - HP LaserJet Pro');
|
||||
expect($results[0]['category'])->toBe('Drukarki');
|
||||
|
||||
Http::assertSent(fn ($request) => str_contains((string) $request->url(), 'search=drukarka'));
|
||||
});
|
||||
|
||||
test('searchAssets returns nothing for a blank query without calling out', function () {
|
||||
enableSnipeit();
|
||||
Http::fake();
|
||||
|
||||
expect(app(SnipeItClient::class)->searchAssets(' '))->toBe([]);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('asset fetches live detail including serial, category and current assignment', function () {
|
||||
enableSnipeit();
|
||||
|
||||
Http::fake(['assets.test/api/v1/hardware/100' => Http::response([
|
||||
'id' => 100, 'asset_tag' => 'SI-001', 'name' => 'Laptop Jana', 'serial' => 'ABC123',
|
||||
'manufacturer' => ['name' => 'Dell'], 'model' => ['name' => 'Latitude 5420'],
|
||||
'category' => ['name' => 'Laptopy'], 'status_label' => ['name' => 'Deployed'],
|
||||
'assigned_to' => ['name' => 'Jan Kowalski'],
|
||||
])]);
|
||||
|
||||
$asset = app(SnipeItClient::class)->asset(100);
|
||||
|
||||
expect($asset['label'])->toBe('SI-001 - ABC123 - Dell Latitude 5420');
|
||||
expect($asset['serial'])->toBe('ABC123');
|
||||
expect($asset['category'])->toBe('Laptopy');
|
||||
expect($asset['assignedTo'])->toBe('Jan Kowalski');
|
||||
});
|
||||
|
||||
test('asset returns null when the asset no longer exists in Snipe-IT', function () {
|
||||
enableSnipeit();
|
||||
|
||||
Http::fake(['assets.test/api/v1/hardware/999' => Http::response(['status' => 'error'], 404)]);
|
||||
|
||||
expect(app(SnipeItClient::class)->asset(999))->toBeNull();
|
||||
});
|
||||
|
||||
test('testConnection reports ok on a successful response', function () {
|
||||
Http::fake(['assets.test/api/v1/hardware?*' => Http::response(['rows' => []])]);
|
||||
|
||||
$result = app(SnipeItClient::class)->testConnection('https://assets.test', 'tok', true);
|
||||
|
||||
expect($result)->toBe(['ok' => true, 'message' => null]);
|
||||
});
|
||||
|
||||
test('testConnection reports the API error message on failure', function () {
|
||||
Http::fake(['assets.test/api/v1/hardware?*' => Http::response(['status' => 'error', 'messages' => 'Unauthenticated.'], 401)]);
|
||||
|
||||
$result = app(SnipeItClient::class)->testConnection('https://assets.test', 'bad-tok', true);
|
||||
|
||||
expect($result['ok'])->toBeFalse();
|
||||
expect($result['message'])->toBe('Unauthenticated.');
|
||||
});
|
||||
241
src/tests/Feature/SnipeitAssetLinkingTest.php
Normal file
241
src/tests/Feature/SnipeitAssetLinkingTest.php
Normal file
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Client\NewTicket as ClientNewTicket;
|
||||
use App\Livewire\Operator\TicketShow as OperatorTicketShow;
|
||||
use App\Models\Category;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\User;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
|
||||
function enableSnipeitForLinkingTest(array $overrides = []): void
|
||||
{
|
||||
Settings::set('snipeit_enabled', '1');
|
||||
Settings::set('snipeit_base_url', 'https://assets.test');
|
||||
Settings::set('snipeit_api_token', 'tok');
|
||||
Settings::set('snipeit_client_can_select_asset', $overrides['client_can_select_asset'] ?? '1');
|
||||
Settings::set('snipeit_operator_view_requester_assets', $overrides['operator_view_requester_assets'] ?? '1');
|
||||
Settings::set('snipeit_operator_search_inventory', $overrides['operator_search_inventory'] ?? '1');
|
||||
}
|
||||
|
||||
function fakeSnipeitUserAsset(string $email = 'client-snipeit@example.com'): void
|
||||
{
|
||||
Http::fake([
|
||||
'assets.test/api/v1/users?*' => Http::response(['rows' => [
|
||||
['id' => 7, 'email' => $email, 'name' => 'Test Client'],
|
||||
]]),
|
||||
'assets.test/api/v1/users/7/assets*' => Http::response(['rows' => [
|
||||
[
|
||||
'id' => 100, 'asset_tag' => 'SI-001', 'name' => 'Laptop klienta', 'serial' => 'SN123',
|
||||
'manufacturer' => ['name' => 'Dell'], 'model' => ['name' => 'Latitude 5420'],
|
||||
'category' => ['name' => 'Laptopy'], 'status_label' => ['name' => 'Deployed'],
|
||||
],
|
||||
]]),
|
||||
'assets.test/api/v1/hardware/100' => Http::response([
|
||||
'id' => 100, 'asset_tag' => 'SI-001', 'name' => 'Laptop klienta', 'serial' => 'SN123',
|
||||
'manufacturer' => ['name' => 'Dell'], 'model' => ['name' => 'Latitude 5420'],
|
||||
'category' => ['name' => 'Laptopy'], 'status_label' => ['name' => 'Deployed'],
|
||||
'assigned_to' => ['name' => 'Test Client'],
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
test('a client can link one of their own Snipe-IT assets while creating a ticket, shown as serial - manufacturer model / category', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$email = 'client-snipeit@example.com';
|
||||
fakeSnipeitUserAsset($email);
|
||||
|
||||
$client = User::query()->create(['name' => 'Test Client', 'email' => $email, 'roles' => ['client']]);
|
||||
$category = Category::query()->create(['name' => 'Sprzęt']);
|
||||
$sub = $category->subcategories()->create(['name' => 'Laptop']);
|
||||
|
||||
enableSnipeitForLinkingTest();
|
||||
Settings::set('snipeit_client_asset_subcategory_ids', (string) $sub->id);
|
||||
|
||||
Livewire::actingAs($client)->test(ClientNewTicket::class)
|
||||
->call('selectCategory', $category->id)
|
||||
->call('selectSubcategory', $sub->id)
|
||||
->call('loadSnipeitAssets')
|
||||
->assertSee('SI-001 - SN123 - Dell Latitude 5420')
|
||||
->assertSee('Laptopy')
|
||||
->call('selectSnipeitAsset', 100)
|
||||
->assertSet('selectedSnipeitAssetId', 100)
|
||||
->set('subject', 'Nie działa laptop')
|
||||
->set('body', 'Opis problemu')
|
||||
->call('submit');
|
||||
|
||||
$ticket = Ticket::query()->where('subject', 'Nie działa laptop')->firstOrFail();
|
||||
expect($ticket->snipeit_asset_id)->toBe(100);
|
||||
expect($ticket->snipeit_asset_name)->toBe('SI-001 - SN123 - Dell Latitude 5420');
|
||||
});
|
||||
|
||||
test('selecting the same asset twice deselects it', function () {
|
||||
seedStatusesAndPriorities();
|
||||
enableSnipeitForLinkingTest();
|
||||
$email = 'client-snipeit2@example.com';
|
||||
fakeSnipeitUserAsset($email);
|
||||
|
||||
$client = User::query()->create(['name' => 'Test Client', 'email' => $email, 'roles' => ['client']]);
|
||||
|
||||
Livewire::actingAs($client)->test(ClientNewTicket::class)
|
||||
->call('loadSnipeitAssets')
|
||||
->call('selectSnipeitAsset', 100)
|
||||
->assertSet('selectedSnipeitAssetId', 100)
|
||||
->call('selectSnipeitAsset', 100)
|
||||
->assertSet('selectedSnipeitAssetId', null);
|
||||
});
|
||||
|
||||
test('the client asset picker is empty when "klient może wybrać sprzęt" is off, even for an allow-listed subcategory', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$email = 'client-snipeit3@example.com';
|
||||
fakeSnipeitUserAsset($email);
|
||||
|
||||
$client = User::query()->create(['name' => 'Test Client', 'email' => $email, 'roles' => ['client']]);
|
||||
$category = Category::query()->create(['name' => 'Sprzęt']);
|
||||
$sub = $category->subcategories()->create(['name' => 'Laptop']);
|
||||
|
||||
enableSnipeitForLinkingTest(['client_can_select_asset' => '0']);
|
||||
Settings::set('snipeit_client_asset_subcategory_ids', (string) $sub->id);
|
||||
|
||||
Livewire::actingAs($client)->test(ClientNewTicket::class)
|
||||
->call('selectCategory', $category->id)
|
||||
->call('selectSubcategory', $sub->id)
|
||||
->call('loadSnipeitAssets')
|
||||
->assertDontSee('SN123');
|
||||
});
|
||||
|
||||
test('the client asset picker only shows for subcategories the admin allow-listed', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$email = 'client-snipeit4@example.com';
|
||||
fakeSnipeitUserAsset($email);
|
||||
|
||||
$client = User::query()->create(['name' => 'Test Client', 'email' => $email, 'roles' => ['client']]);
|
||||
$category = Category::query()->create(['name' => 'Sprzęt']);
|
||||
$allowedSub = $category->subcategories()->create(['name' => 'Laptop']);
|
||||
$otherSub = $category->subcategories()->create(['name' => 'Telefon']);
|
||||
|
||||
enableSnipeitForLinkingTest();
|
||||
Settings::set('snipeit_client_asset_subcategory_ids', (string) $allowedSub->id);
|
||||
|
||||
// Allow-listed subcategory: the picker shows.
|
||||
Livewire::actingAs($client)->test(ClientNewTicket::class)
|
||||
->call('selectCategory', $category->id)
|
||||
->call('selectSubcategory', $allowedSub->id)
|
||||
->call('loadSnipeitAssets')
|
||||
->assertSee('SI-001 - SN123 - Dell Latitude 5420');
|
||||
|
||||
// A subcategory not in the allow-list: the picker stays hidden.
|
||||
Livewire::actingAs($client)->test(ClientNewTicket::class)
|
||||
->call('selectCategory', $category->id)
|
||||
->call('selectSubcategory', $otherSub->id)
|
||||
->call('loadSnipeitAssets')
|
||||
->assertDontSee('SN123');
|
||||
});
|
||||
|
||||
test('changing subcategory clears a previously selected asset', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$email = 'client-snipeit5@example.com';
|
||||
fakeSnipeitUserAsset($email);
|
||||
|
||||
$client = User::query()->create(['name' => 'Test Client', 'email' => $email, 'roles' => ['client']]);
|
||||
$category = Category::query()->create(['name' => 'Sprzęt']);
|
||||
$sub = $category->subcategories()->create(['name' => 'Laptop']);
|
||||
|
||||
enableSnipeitForLinkingTest();
|
||||
Settings::set('snipeit_client_asset_subcategory_ids', (string) $sub->id);
|
||||
|
||||
Livewire::actingAs($client)->test(ClientNewTicket::class)
|
||||
->call('selectCategory', $category->id)
|
||||
->call('selectSubcategory', $sub->id)
|
||||
->call('selectSnipeitAsset', 100)
|
||||
->assertSet('selectedSnipeitAssetId', 100)
|
||||
->call('selectSubcategory', $sub->id)
|
||||
->assertSet('selectedSnipeitAssetId', null);
|
||||
});
|
||||
|
||||
test('an operator can link a Snipe-IT asset found via inventory search to an existing ticket', function () {
|
||||
seedStatusesAndPriorities();
|
||||
enableSnipeitForLinkingTest();
|
||||
Http::fake([
|
||||
'assets.test/api/v1/users?*' => Http::response(['rows' => []]),
|
||||
'assets.test/api/v1/hardware?*' => Http::response(['rows' => [
|
||||
[
|
||||
'id' => 55, 'asset_tag' => 'SI-055', 'name' => null, 'serial' => null,
|
||||
'manufacturer' => ['name' => 'HP'], 'model' => ['name' => 'LaserJet Pro'],
|
||||
'category' => ['name' => 'Drukarki'], 'status_label' => ['name' => 'Ready to Deploy'],
|
||||
],
|
||||
]]),
|
||||
'assets.test/api/v1/hardware/55' => Http::response([
|
||||
'id' => 55, 'asset_tag' => 'SI-055', 'name' => null, 'serial' => null,
|
||||
'manufacturer' => ['name' => 'HP'], 'model' => ['name' => 'LaserJet Pro'],
|
||||
'category' => ['name' => 'Drukarki'], 'status_label' => ['name' => 'Ready to Deploy'],
|
||||
]),
|
||||
]);
|
||||
|
||||
$operator = operatorUser();
|
||||
$ticket = makeTicket();
|
||||
|
||||
$component = Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket])
|
||||
->set('snipeitSearchQuery', 'drukarka')
|
||||
->call('searchSnipeitAssets')
|
||||
->assertSee('SI-055 - HP LaserJet Pro')
|
||||
->call('linkSnipeitAsset', 55);
|
||||
|
||||
$ticket->refresh();
|
||||
expect($ticket->snipeit_asset_id)->toBe(55);
|
||||
expect($ticket->snipeit_asset_name)->toBe('SI-055 - HP LaserJet Pro');
|
||||
expect($ticket->histories()->latest()->first()->text)->toContain('Powiązano sprzęt');
|
||||
|
||||
// Unlinking works regardless of the two view/search toggles (see next test).
|
||||
$component->call('unlinkSnipeitAsset');
|
||||
$ticket->refresh();
|
||||
expect($ticket->snipeit_asset_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('linking is a no-op when neither requester-assets view nor inventory search is enabled for the operator', function () {
|
||||
seedStatusesAndPriorities();
|
||||
enableSnipeitForLinkingTest(['operator_view_requester_assets' => '0', 'operator_search_inventory' => '0']);
|
||||
Http::fake(['assets.test/*' => Http::response(['rows' => []])]);
|
||||
|
||||
$operator = operatorUser();
|
||||
$ticket = makeTicket();
|
||||
|
||||
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket])
|
||||
->call('linkSnipeitAsset', 55);
|
||||
|
||||
$ticket->refresh();
|
||||
expect($ticket->snipeit_asset_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('unlinking stays available even when both operator view/search toggles are off', function () {
|
||||
seedStatusesAndPriorities();
|
||||
enableSnipeitForLinkingTest(['operator_view_requester_assets' => '0', 'operator_search_inventory' => '0']);
|
||||
|
||||
$operator = operatorUser();
|
||||
$ticket = makeTicket(['snipeit_asset_id' => 100, 'snipeit_asset_name' => 'SI-001 - SN123 - Dell Latitude 5420']);
|
||||
|
||||
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket])
|
||||
->call('unlinkSnipeitAsset');
|
||||
|
||||
$ticket->refresh();
|
||||
expect($ticket->snipeit_asset_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('an operator cannot link an asset found via search when inventory search is disabled, even if the id is valid', function () {
|
||||
seedStatusesAndPriorities();
|
||||
enableSnipeitForLinkingTest(['operator_search_inventory' => '0']);
|
||||
Http::fake(['assets.test/api/v1/hardware/55' => Http::response([
|
||||
'id' => 55, 'asset_tag' => 'SI-055', 'serial' => null, 'manufacturer' => ['name' => 'HP'], 'model' => ['name' => 'LaserJet Pro'],
|
||||
])]);
|
||||
|
||||
$operator = operatorUser();
|
||||
$ticket = makeTicket();
|
||||
|
||||
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket])
|
||||
->set('snipeitSearchResults', [['id' => 55, 'label' => 'HP LaserJet Pro']])
|
||||
->call('linkSnipeitAsset', 55);
|
||||
|
||||
$ticket->refresh();
|
||||
expect($ticket->snipeit_asset_id)->toBeNull();
|
||||
});
|
||||
170
src/tests/Feature/TicketAiSummaryServiceTest.php
Normal file
170
src/tests/Feature/TicketAiSummaryServiceTest.php
Normal file
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Ticket;
|
||||
use App\Services\TicketAiSummaryService;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
function enableAiSummary(): void
|
||||
{
|
||||
Settings::set('ai_enabled', '1');
|
||||
Settings::set('ai_base_url', 'https://ai.test');
|
||||
Settings::set('ai_model', 'llama-3.3-70b-versatile');
|
||||
Settings::set('ai_summary_enabled', '1');
|
||||
}
|
||||
|
||||
function summaryTicket(array $overrides = []): Ticket
|
||||
{
|
||||
return Ticket::query()->create(array_merge([
|
||||
'number' => (string) random_int(100000, 999999),
|
||||
'email' => 'client@example.com',
|
||||
'name' => 'Test Client',
|
||||
'subject' => 'Problem z drukarką',
|
||||
'body' => 'Drukarka nie działa od rana.',
|
||||
'status_key' => 'new',
|
||||
'priority_key' => 'medium',
|
||||
'custom_fields' => [],
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
function fakeAiSummaryChat(string $content): void
|
||||
{
|
||||
Http::fake(['ai.test/*' => Http::response(['choices' => [['message' => ['content' => $content]]]])]);
|
||||
}
|
||||
|
||||
test('generates and stores a summary + suggested action for a fresh ticket', function () {
|
||||
enableAiSummary();
|
||||
fakeAiSummaryChat('{"summary": "Klient zgłasza awarię drukarki.", "suggested_action": "Poproś o model drukarki."}');
|
||||
|
||||
$ticket = summaryTicket();
|
||||
|
||||
$totals = app(TicketAiSummaryService::class)->run();
|
||||
|
||||
expect($totals)->toBe(['scanned' => 1, 'updated' => 1, 'failed' => 0]);
|
||||
$ticket->refresh();
|
||||
expect($ticket->ai_summary)->toBe('Klient zgłasza awarię drukarki.');
|
||||
expect($ticket->ai_suggested_action)->toBe('Poproś o model drukarki.');
|
||||
expect($ticket->ai_summary_generated_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('a ticket with a newer message than its last summary is picked up again', function () {
|
||||
enableAiSummary();
|
||||
$ticket = summaryTicket();
|
||||
$ticket->messages()->create(['author_name' => 'Test Client', 'body' => 'Pierwsza wiadomość.']);
|
||||
$ticket->update(['ai_summary' => 'stare podsumowanie', 'ai_summary_generated_at' => now()->subDay()]);
|
||||
|
||||
// A message created "now" postdates the day-old summary.
|
||||
$ticket->messages()->create(['author_name' => 'Operator', 'body' => 'Nowa odpowiedź operatora.']);
|
||||
|
||||
fakeAiSummaryChat('{"summary": "Zaktualizowane podsumowanie.", "suggested_action": null}');
|
||||
|
||||
$totals = app(TicketAiSummaryService::class)->run();
|
||||
|
||||
expect($totals)->toBe(['scanned' => 1, 'updated' => 1, 'failed' => 0]);
|
||||
expect($ticket->refresh()->ai_summary)->toBe('Zaktualizowane podsumowanie.');
|
||||
});
|
||||
|
||||
test('a ticket whose summary is already newer than its latest message is not reprocessed', function () {
|
||||
enableAiSummary();
|
||||
$ticket = summaryTicket();
|
||||
$ticket->messages()->create(['author_name' => 'Test Client', 'body' => 'Jedyna wiadomość.']);
|
||||
$ticket->update(['ai_summary' => 'aktualne podsumowanie', 'ai_summary_generated_at' => now()]);
|
||||
|
||||
Http::fake();
|
||||
|
||||
$totals = app(TicketAiSummaryService::class)->run();
|
||||
|
||||
expect($totals)->toBe(['scanned' => 0, 'updated' => 0, 'failed' => 0]);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('an unrelated update() that only touches updated_at does not trigger re-summarization', function () {
|
||||
enableAiSummary();
|
||||
$ticket = summaryTicket();
|
||||
$ticket->messages()->create(['author_name' => 'Test Client', 'body' => 'Jedyna wiadomość.']);
|
||||
$ticket->update(['ai_summary' => 'aktualne podsumowanie', 'ai_summary_generated_at' => now()]);
|
||||
|
||||
// Simulates e.g. a priority/status change touching tickets.updated_at
|
||||
// without any new ticket_messages row.
|
||||
$ticket->update(['priority_key' => 'high']);
|
||||
|
||||
Http::fake();
|
||||
|
||||
$totals = app(TicketAiSummaryService::class)->run();
|
||||
|
||||
expect($totals)->toBe(['scanned' => 0, 'updated' => 0, 'failed' => 0]);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('a malformed AI response leaves the previous summary untouched and keeps the ticket stale', function () {
|
||||
enableAiSummary();
|
||||
$ticket = summaryTicket();
|
||||
$ticket->update(['ai_summary' => 'stare podsumowanie', 'ai_summary_generated_at' => null]);
|
||||
fakeAiSummaryChat('to nie jest JSON');
|
||||
|
||||
$totals = app(TicketAiSummaryService::class)->run();
|
||||
|
||||
expect($totals)->toBe(['scanned' => 1, 'updated' => 0, 'failed' => 1]);
|
||||
$ticket->refresh();
|
||||
expect($ticket->ai_summary)->toBe('stare podsumowanie');
|
||||
expect($ticket->ai_summary_generated_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('the transcript sent to the AI includes the ticket subject, its own body, and every message tagged by role', function () {
|
||||
enableAiSummary();
|
||||
$ticket = summaryTicket(['subject' => 'Problem z drukarką', 'body' => 'Drukarka nie działa od rana.']);
|
||||
$ticket->messages()->create(['author_name' => 'Test Client', 'body' => 'Dodatkowy szczegół.'])->attachAuthor(null, 'client');
|
||||
$ticket->messages()->create(['author_name' => 'Operator', 'body' => 'Sprawdzam sprawę.'])->attachAuthor(null, 'operator');
|
||||
|
||||
fakeAiSummaryChat('{"summary": "ok", "suggested_action": null}');
|
||||
|
||||
app(TicketAiSummaryService::class)->run();
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
$userMessage = collect($request->data()['messages'])->firstWhere('role', 'user')['content'];
|
||||
|
||||
return str_contains($userMessage, 'Temat: Problem z drukarką')
|
||||
&& str_contains($userMessage, 'Treść:')
|
||||
&& str_contains($userMessage, 'Drukarka nie działa od rana.')
|
||||
&& str_contains($userMessage, '[klient] Test Client: Dodatkowy szczegół.')
|
||||
&& str_contains($userMessage, '[operator] Operator: Sprawdzam sprawę.');
|
||||
});
|
||||
});
|
||||
|
||||
test('generateFor() regenerates a single ticket immediately, ignoring the staleness check', function () {
|
||||
enableAiSummary();
|
||||
$ticket = summaryTicket();
|
||||
$ticket->update(['ai_summary' => 'aktualne podsumowanie', 'ai_summary_generated_at' => now()]);
|
||||
|
||||
fakeAiSummaryChat('{"summary": "Świeże podsumowanie.", "suggested_action": null}');
|
||||
|
||||
$result = app(TicketAiSummaryService::class)->generateFor($ticket);
|
||||
|
||||
expect($result)->toBeTrue();
|
||||
expect($ticket->refresh()->ai_summary)->toBe('Świeże podsumowanie.');
|
||||
});
|
||||
|
||||
test('generateFor() returns false without calling the AI when the integration is disabled', function () {
|
||||
Settings::set('ai_enabled', '0');
|
||||
$ticket = summaryTicket();
|
||||
|
||||
Http::fake();
|
||||
|
||||
expect(app(TicketAiSummaryService::class)->generateFor($ticket))->toBeFalse();
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('ai_summary_enabled=0 makes no AI calls', function () {
|
||||
Settings::set('ai_enabled', '1');
|
||||
Settings::set('ai_base_url', 'https://ai.test');
|
||||
Settings::set('ai_model', 'llama-3.3-70b-versatile');
|
||||
Settings::set('ai_summary_enabled', '0');
|
||||
summaryTicket();
|
||||
|
||||
Http::fake();
|
||||
|
||||
$totals = app(TicketAiSummaryService::class)->run();
|
||||
|
||||
expect($totals)->toBe(['scanned' => 0, 'updated' => 0, 'failed' => 0]);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
245
src/tests/Feature/TicketAiTriageServiceTest.php
Normal file
245
src/tests/Feature/TicketAiTriageServiceTest.php
Normal file
@@ -0,0 +1,245 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Priority;
|
||||
use App\Models\Subcategory;
|
||||
use App\Models\Ticket;
|
||||
use App\Services\TicketAiTriageService;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
function enableAiForTriage(): void
|
||||
{
|
||||
Settings::set('ai_enabled', '1');
|
||||
Settings::set('ai_base_url', 'https://ai.test');
|
||||
Settings::set('ai_model', 'llama-3.3-70b-versatile');
|
||||
}
|
||||
|
||||
/** @return array{cat1: Category, sub1: Subcategory, sub2: Subcategory, cat2: Category} */
|
||||
function seedCategoriesForTriage(): array
|
||||
{
|
||||
$cat1 = Category::query()->create(['name' => 'IT-Pomoc']);
|
||||
$sub1 = $cat1->subcategories()->create(['name' => 'Drukarki i skanery']);
|
||||
$sub2 = $cat1->subcategories()->create(['name' => 'VPN']);
|
||||
$cat2 = Category::query()->create(['name' => 'Zamówienia']);
|
||||
$cat2->subcategories()->create(['name' => 'Nowe zamówienie']);
|
||||
|
||||
return compact('cat1', 'sub1', 'sub2', 'cat2');
|
||||
}
|
||||
|
||||
function seedPrioritiesForTriage(): void
|
||||
{
|
||||
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#000', 'sort_order' => 4]);
|
||||
Priority::query()->create(['key' => 'medium', 'label' => 'Średni', 'color' => '#000', 'sort_order' => 3]);
|
||||
Priority::query()->create(['key' => 'high', 'label' => 'Wysoki', 'color' => '#000', 'sort_order' => 2]);
|
||||
Priority::query()->create(['key' => 'critical', 'label' => 'Krytyczny', 'color' => '#000', 'sort_order' => 1]);
|
||||
}
|
||||
|
||||
function triageTicket(array $overrides = []): Ticket
|
||||
{
|
||||
return Ticket::query()->create(array_merge([
|
||||
'number' => (string) random_int(100000, 999999),
|
||||
'email' => 'client@example.com',
|
||||
'name' => 'Test Client',
|
||||
'subject' => 'Problem z drukarką',
|
||||
'body' => 'Drukarka HP w biurze nie drukuje od rana, pokazuje błąd papieru mimo że jest papier.',
|
||||
'status_key' => 'new',
|
||||
'priority_key' => 'medium',
|
||||
'custom_fields' => [],
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
function fakeAiChat(string $content): void
|
||||
{
|
||||
Http::fake(['ai.test/*' => Http::response(['choices' => [['message' => ['content' => $content]]]])]);
|
||||
}
|
||||
|
||||
test('category_when_missing assigns category+subcategory to a fully unclassified ticket', function () {
|
||||
enableAiForTriage();
|
||||
['sub1' => $sub1] = seedCategoriesForTriage();
|
||||
Settings::set('ai_triage_category_when_missing', '1');
|
||||
fakeAiChat('{"category": "IT-Pomoc", "subcategory": "Drukarki i skanery", "subject": null, "priority": null}');
|
||||
|
||||
$ticket = triageTicket(['category_id' => null, 'subcategory_id' => null]);
|
||||
|
||||
$totals = app(TicketAiTriageService::class)->run();
|
||||
|
||||
expect($totals)->toBe(['scanned' => 1, 'changed' => 1, 'failed' => 0]);
|
||||
$ticket->refresh();
|
||||
expect($ticket->subcategory_id)->toBe($sub1->id);
|
||||
expect($ticket->category_id)->toBeNull();
|
||||
expect($ticket->ai_triaged_at)->not->toBeNull();
|
||||
expect($ticket->histories()->pluck('text')->all())->toBe([
|
||||
'Kategoria zmieniona na: IT-Pomoc / Drukarki i skanery',
|
||||
'Automatyzacja: klasyfikacja AI',
|
||||
]);
|
||||
});
|
||||
|
||||
test('subcategory_when_category_only restricts the prompt to the ticket\'s existing category and picks within it', function () {
|
||||
enableAiForTriage();
|
||||
['cat1' => $cat1, 'sub2' => $sub2] = seedCategoriesForTriage();
|
||||
Settings::set('ai_triage_subcategory_when_category_only', '1');
|
||||
fakeAiChat('{"category": null, "subcategory": "VPN", "subject": null, "priority": null}');
|
||||
|
||||
$ticket = triageTicket(['category_id' => $cat1->id, 'subcategory_id' => null]);
|
||||
|
||||
app(TicketAiTriageService::class)->run();
|
||||
|
||||
$ticket->refresh();
|
||||
expect($ticket->subcategory_id)->toBe($sub2->id);
|
||||
expect($ticket->category_id)->toBeNull();
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
$content = $request['messages'][0]['content'] ?? '';
|
||||
|
||||
return str_contains($content, 'Drukarki i skanery')
|
||||
&& str_contains($content, 'VPN')
|
||||
&& ! str_contains($content, 'Nowe zamówienie');
|
||||
});
|
||||
});
|
||||
|
||||
test('recheck_categorized moves an already-categorized ticket to a better-matching subcategory', function () {
|
||||
enableAiForTriage();
|
||||
['sub1' => $sub1, 'sub2' => $sub2] = seedCategoriesForTriage();
|
||||
Settings::set('ai_triage_recheck_categorized', '1');
|
||||
fakeAiChat('{"category": null, "subcategory": "VPN", "subject": null, "priority": null}');
|
||||
|
||||
$ticket = triageTicket(['category_id' => null, 'subcategory_id' => $sub1->id]);
|
||||
|
||||
app(TicketAiTriageService::class)->run();
|
||||
|
||||
expect($ticket->refresh()->subcategory_id)->toBe($sub2->id);
|
||||
});
|
||||
|
||||
test('recheck_categorized confirming the existing subcategory leaves no history and no changed count', function () {
|
||||
enableAiForTriage();
|
||||
['sub1' => $sub1] = seedCategoriesForTriage();
|
||||
Settings::set('ai_triage_recheck_categorized', '1');
|
||||
fakeAiChat('{"category": null, "subcategory": "Drukarki i skanery", "subject": null, "priority": null}');
|
||||
|
||||
$ticket = triageTicket(['category_id' => null, 'subcategory_id' => $sub1->id]);
|
||||
|
||||
$totals = app(TicketAiTriageService::class)->run();
|
||||
|
||||
expect($totals)->toBe(['scanned' => 1, 'changed' => 0, 'failed' => 0]);
|
||||
expect($ticket->refresh()->subcategory_id)->toBe($sub1->id);
|
||||
expect($ticket->histories()->count())->toBe(0);
|
||||
expect($ticket->ai_triaged_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('fix_subject rewrites an unclear subject', function () {
|
||||
enableAiForTriage();
|
||||
Settings::set('ai_triage_fix_subject', '1');
|
||||
fakeAiChat('{"category": null, "subcategory": null, "subject": "Awaria drukarki HP w biurze", "priority": null}');
|
||||
|
||||
$ticket = triageTicket(['subject' => 'pomocy!!!']);
|
||||
|
||||
app(TicketAiTriageService::class)->run();
|
||||
|
||||
$ticket->refresh();
|
||||
expect($ticket->subject)->toBe('Awaria drukarki HP w biurze');
|
||||
expect($ticket->histories()->pluck('text')->all())->toBe([
|
||||
'Temat zmieniony na: „Awaria drukarki HP w biurze”',
|
||||
'Automatyzacja: klasyfikacja AI',
|
||||
]);
|
||||
});
|
||||
|
||||
test('set_priority assigns a priority based on content', function () {
|
||||
enableAiForTriage();
|
||||
seedPrioritiesForTriage();
|
||||
Settings::set('ai_triage_set_priority', '1');
|
||||
fakeAiChat('{"category": null, "subcategory": null, "subject": null, "priority": "high"}');
|
||||
|
||||
$ticket = triageTicket(['priority_key' => 'medium']);
|
||||
|
||||
app(TicketAiTriageService::class)->run();
|
||||
|
||||
$ticket->refresh();
|
||||
expect($ticket->priority_key)->toBe('high');
|
||||
expect($ticket->histories()->pluck('text')->all())->toBe([
|
||||
'Priorytet zmieniony na: Wysoki',
|
||||
'Automatyzacja: klasyfikacja AI',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a multi-field change writes one mechanical line per changed field plus a single attribution line', function () {
|
||||
enableAiForTriage();
|
||||
seedPrioritiesForTriage();
|
||||
['sub1' => $sub1, 'sub2' => $sub2] = seedCategoriesForTriage();
|
||||
Settings::set('ai_triage_recheck_categorized', '1');
|
||||
Settings::set('ai_triage_set_priority', '1');
|
||||
// ticket currently sits under sub2 (VPN); AI moves it to sub1 (Drukarki i
|
||||
// skanery) AND bumps priority — both fields change in the same pass.
|
||||
fakeAiChat('{"category": null, "subcategory": "Drukarki i skanery", "subject": null, "priority": "critical"}');
|
||||
|
||||
$ticket = triageTicket(['category_id' => null, 'subcategory_id' => $sub2->id, 'priority_key' => 'medium']);
|
||||
|
||||
app(TicketAiTriageService::class)->run();
|
||||
|
||||
$ticket->refresh();
|
||||
expect($ticket->subcategory_id)->toBe($sub1->id);
|
||||
expect($ticket->priority_key)->toBe('critical');
|
||||
expect($ticket->histories()->pluck('text')->all())->toBe([
|
||||
'Kategoria zmieniona na: IT-Pomoc / Drukarki i skanery',
|
||||
'Priorytet zmieniony na: Krytyczny',
|
||||
'Automatyzacja: klasyfikacja AI',
|
||||
]);
|
||||
});
|
||||
|
||||
test('idempotency: a second run does not rescan an already-triaged ticket', function () {
|
||||
enableAiForTriage();
|
||||
Settings::set('ai_triage_set_priority', '1');
|
||||
seedPrioritiesForTriage();
|
||||
fakeAiChat('{"category": null, "subcategory": null, "subject": null, "priority": "high"}');
|
||||
|
||||
triageTicket();
|
||||
|
||||
app(TicketAiTriageService::class)->run();
|
||||
$second = app(TicketAiTriageService::class)->run();
|
||||
|
||||
expect($second)->toBe(['scanned' => 0, 'changed' => 0, 'failed' => 0]);
|
||||
});
|
||||
|
||||
test('a malformed AI response changes nothing but still stamps ai_triaged_at and counts as failed', function () {
|
||||
enableAiForTriage();
|
||||
Settings::set('ai_triage_set_priority', '1');
|
||||
seedPrioritiesForTriage();
|
||||
fakeAiChat('to nie jest JSON');
|
||||
|
||||
$ticket = triageTicket(['priority_key' => 'medium']);
|
||||
|
||||
$totals = app(TicketAiTriageService::class)->run();
|
||||
|
||||
expect($totals)->toBe(['scanned' => 1, 'changed' => 0, 'failed' => 1]);
|
||||
$ticket->refresh();
|
||||
expect($ticket->priority_key)->toBe('medium');
|
||||
expect($ticket->ai_triaged_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('a hallucinated category name is silently dropped rather than applied', function () {
|
||||
enableAiForTriage();
|
||||
seedCategoriesForTriage();
|
||||
Settings::set('ai_triage_category_when_missing', '1');
|
||||
fakeAiChat('{"category": "Kategoria Zmyślona Przez Model", "subcategory": null, "subject": null, "priority": null}');
|
||||
|
||||
$ticket = triageTicket(['category_id' => null, 'subcategory_id' => null]);
|
||||
|
||||
$totals = app(TicketAiTriageService::class)->run();
|
||||
|
||||
expect($totals['changed'])->toBe(0);
|
||||
$ticket->refresh();
|
||||
expect($ticket->category_id)->toBeNull();
|
||||
expect($ticket->subcategory_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('with every triage toggle off, run makes no AI calls at all', function () {
|
||||
enableAiForTriage();
|
||||
triageTicket(['category_id' => null, 'subcategory_id' => null]);
|
||||
|
||||
Http::fake();
|
||||
|
||||
$totals = app(TicketAiTriageService::class)->run();
|
||||
|
||||
expect($totals)->toBe(['scanned' => 0, 'changed' => 0, 'failed' => 0]);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
65
src/tests/Feature/TicketNumberObfuscationTest.php
Normal file
65
src/tests/Feature/TicketNumberObfuscationTest.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
use App\Models\ApiClient;
|
||||
use App\Models\User;
|
||||
use App\Support\Settings;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
test('a ticket is assigned a stable, unique checksum on creation', function () {
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
$ticket = makeTicket();
|
||||
|
||||
expect($ticket->checksum)->not->toBeNull()
|
||||
->and($ticket->checksum)->toMatch('/^\d{6}$/')
|
||||
->and($ticket->fresh()->checksum)->toBe($ticket->checksum);
|
||||
});
|
||||
|
||||
test('with obfuscation off, the ticket URL and the displayed number both use the raw sequential number', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Settings::set('ticket_number_obfuscate', '0');
|
||||
|
||||
$operator = User::query()->create(['name' => 'Op', 'email' => 'op@example.com', 'roles' => ['operator']]);
|
||||
$ticket = makeTicket(['number' => '1042']);
|
||||
|
||||
$url = route('operator.ticket', $ticket);
|
||||
|
||||
expect($url)->toContain('/1042')
|
||||
->and($ticket->displayNumber())->toBe('#1042');
|
||||
|
||||
$this->actingAs($operator)->get($url)->assertOk();
|
||||
});
|
||||
|
||||
test('with obfuscation on, the ticket URL and the displayed number both use the checksum, and the raw number no longer resolves', function () {
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
$operator = User::query()->create(['name' => 'Op', 'email' => 'op@example.com', 'roles' => ['operator']]);
|
||||
$ticket = makeTicket(['number' => '1042']);
|
||||
|
||||
Settings::set('ticket_number_obfuscate', '1');
|
||||
|
||||
$url = route('operator.ticket', $ticket);
|
||||
|
||||
expect($url)->toContain($ticket->checksum)
|
||||
->and($url)->not->toContain('/1042')
|
||||
->and($ticket->displayNumber())->toBe('#'.$ticket->checksum);
|
||||
|
||||
$this->actingAs($operator)->get($url)->assertOk();
|
||||
|
||||
// A "no ticket matches this identifier" route-binding failure now
|
||||
// redirects to the area's own queue instead of a bare 404 (see
|
||||
// bootstrap/app.php) — the raw sequential number still doesn't resolve
|
||||
// to the ticket, it just no longer surfaces as a dead-end error page.
|
||||
$this->actingAs($operator)->get('/operator/tickets/1042')->assertRedirect(route('operator.queue'));
|
||||
});
|
||||
|
||||
test('the API still binds tickets by numeric id regardless of the obfuscation setting', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Settings::set('ticket_number_obfuscate', '1');
|
||||
|
||||
$ticket = makeTicket();
|
||||
$client = ApiClient::factory()->create();
|
||||
Sanctum::actingAs($client, ['tickets:read']);
|
||||
|
||||
$this->getJson("/api/v1/tickets/{$ticket->id}")->assertOk()->assertJsonPath('data.id', $ticket->id);
|
||||
});
|
||||
61
src/tests/Feature/TicketServiceGuestReplyTest.php
Normal file
61
src/tests/Feature/TicketServiceGuestReplyTest.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AutomationRule;
|
||||
use App\Models\User;
|
||||
use App\Services\TicketService;
|
||||
|
||||
test('guestReply records a client-role message with no author id', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$ticket = makeTicket();
|
||||
|
||||
$message = app(TicketService::class)->guestReply($ticket, 'Anonimowy Gość', 'Odpowiedź gościa e-mailem.');
|
||||
|
||||
expect($message->author_name)->toBe('Anonimowy Gość')
|
||||
->and($message->body)->toBe('Odpowiedź gościa e-mailem.')
|
||||
->and($message->author_id)->toBeNull()
|
||||
->and($message->role)->toBe('client')
|
||||
->and($message->source)->toBeNull();
|
||||
});
|
||||
|
||||
test('guestReply tags the message source as email when told to', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$ticket = makeTicket();
|
||||
|
||||
$message = app(TicketService::class)->guestReply($ticket, 'Gość', 'Treść', source: 'email');
|
||||
|
||||
expect($message->source)->toBe('email');
|
||||
});
|
||||
|
||||
test('clientReply defaults to a null (web) source, and can be tagged as email', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$ticket = makeTicket();
|
||||
$client = User::query()->create(['name' => 'Klient', 'email' => 'klient@example.com', 'roles' => ['client']]);
|
||||
|
||||
app(TicketService::class)->clientReply($ticket, $client, 'Odpowiedź z portalu.');
|
||||
$webMessage = $ticket->messages()->latest('id')->first();
|
||||
|
||||
app(TicketService::class)->clientReply($ticket, $client, 'Odpowiedź e-mailem.', source: 'email');
|
||||
$emailMessage = $ticket->messages()->latest('id')->first();
|
||||
|
||||
expect($webMessage->source)->toBeNull()
|
||||
->and($emailMessage->source)->toBe('email');
|
||||
});
|
||||
|
||||
test('guestReply updates last_customer_activity_at and clears automation logs, like clientReply', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$ticket = makeTicket(['last_customer_activity_at' => now()->subDays(1)]);
|
||||
|
||||
$rule = AutomationRule::query()->create([
|
||||
'label' => 'Test rule',
|
||||
'condition_minutes' => 60,
|
||||
'action_type' => 'set_priority',
|
||||
'action_value' => 'high',
|
||||
'enabled' => true,
|
||||
]);
|
||||
$ticket->automationRuleLogs()->create(['automation_rule_id' => $rule->id, 'triggered_at' => now()]);
|
||||
|
||||
app(TicketService::class)->guestReply($ticket, 'Gość', 'Treść');
|
||||
|
||||
expect($ticket->fresh()->last_customer_activity_at->diffInSeconds(now()))->toBeLessThan(5)
|
||||
->and($ticket->automationRuleLogs()->count())->toBe(0);
|
||||
});
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
Panel administratora (`/admin`) to jedno miejsce do konfiguracji całego systemu:
|
||||
struktura zgłoszeń (kategorie, pola, statusy, priorytety, SLA), automatyzacje
|
||||
(reguły SLA, wyzwalacze), użytkownicy i zespoły, treści (szablony, szybkie
|
||||
akcje, e-maile), wygląd/branding oraz integracje (LDAP, SMTP, BookStack, API).
|
||||
(reguły SLA, wyzwalacze, automatyzacja AI zgłoszeń), użytkownicy i zespoły,
|
||||
treści (szablony, szybkie akcje, e-maile), wygląd/branding oraz integracje
|
||||
(LDAP, poczta SMTP/IMAP, BookStack, AI, API).
|
||||
|
||||
Domyślnie każde konto ląduje po zalogowaniu w panelu Klienta; przełącz się do
|
||||
panelu Administratora przez menu profilu (prawy górny róg).
|
||||
@@ -159,10 +160,81 @@ ważne + treść HTML).
|
||||
- **Ogólne** — domyślny status nowego zgłoszenia, automatyczne przypisywanie wg
|
||||
kategorii, limity załączników (rozmiar/liczba/typy), czas życia sesji, strefa
|
||||
czasowa.
|
||||
- **Numeracja zgłoszeń** — dowolny **prefiks** numeru (domyślnie `#`) i
|
||||
**minimalna długość** (dopełniana zerami z przodu, dotyczy tylko trybu
|
||||
sekwencyjnego). Checkbox **„Ukryj kolejność zgłoszeń”** przełącza
|
||||
wyświetlany numer z kolejnego (np. `#1042`) na stałą, losowo wyglądającą
|
||||
**sumę kontrolną** (np. `#559122`) przypisaną zgłoszeniu raz, na zawsze —
|
||||
tak, by po samym numerze nie dało się odgadnąć, ile jest zgłoszeń ani w
|
||||
jakiej kolejności powstały. Podgląd pod polami pokazuje na żywo, jak
|
||||
będzie wyglądał numer dla realnego zgłoszenia z bazy, zanim się zapisze
|
||||
zmiany. Gdy ta opcja jest włączona, **linki do zgłoszeń też** posługują
|
||||
się sumą kontrolną zamiast kolejnego numeru — stary link ze zwykłym
|
||||
numerem przestaje działać. REST API (`/api/v1/...`) tego nie dotyczy —
|
||||
tam zgłoszenia zawsze identyfikuje się po `id`, niezależnie od tego
|
||||
ustawienia.
|
||||
- **Częstotliwość odświeżania i harmonogramu** — dwie grupy pól:
|
||||
- **Odświeżanie w przeglądarce** — co ile sekund odświeża się (poza
|
||||
aktualizacjami na żywo) widok zgłoszenia (klient i operator), lista
|
||||
zgłoszeń operatora, i dzwonek powiadomień.
|
||||
- **Zadania w tle** — co ile minut uruchamiają się sprawdzanie naruszeń
|
||||
SLA, reguły automatyzacji, pobieranie e-maili (IMAP) i automatyzacja AI
|
||||
zgłoszeń. Zmiana obowiązuje od najbliższego tyknięcia harmonogramu (co
|
||||
minutę), bez potrzeby restartu czy redeployu.
|
||||
|
||||
SMTP (host, port, szyfrowanie, użytkownik/hasło, adres/nazwa nadawcy, z
|
||||
przyciskiem **„Testuj połączenie”**) konfiguruje się w zakładce **E-MAIL**,
|
||||
razem z layoutem/stopką wiadomości — patrz sekcja wyżej.
|
||||
przyciskiem **„Testuj połączenie”**) konfiguruje się w zakładce **Poczta**,
|
||||
razem z layoutem/stopką wiadomości i skrzynkami IMAP (patrz niżej).
|
||||
|
||||
## Poczta — odbieranie zgłoszeń i odpowiedzi e-mailem (IMAP)
|
||||
|
||||
Zakładka **Poczta** (dawniej „E-MAIL") łączy konfigurację SMTP (wysyłka) ze
|
||||
skrzynkami IMAP (odbiór) — obie strony wymiany e-mailowej z klientem żyją
|
||||
razem, zamiast być rozrzucone po różnych zakładkach.
|
||||
|
||||
- **Wiele skrzynek IMAP jednocześnie** — np. `zgloszenia-it@firma.pl` i
|
||||
`zgloszenia-hr@firma.pl` jako dwie osobne, niezależnie włączane skrzynki,
|
||||
każda z własnym hostem/portem/szyfrowaniem/loginem/hasłem i folderem.
|
||||
- **Cel nowych zgłoszeń** — jeden wspólny selektor pozwala wybrać albo
|
||||
**konkretną podkategorię** (trafi też do jej zespołu, tak jak zgłoszenie
|
||||
założone przez formularz web), albo **całą kategorię** bez wskazywania
|
||||
podkategorii (zgłoszenie zostaje nieprzypisane do zespołu, ale kategoria
|
||||
jest widoczna i można po niej filtrować kolejkę operatora), albo zostawić
|
||||
puste (zgłoszenie całkiem nieprzypisane).
|
||||
- **Dopasowywanie odpowiedzi** — odpowiedź na powiadomienie e-mail (temat
|
||||
zawiera numer/sumę kontrolną zgłoszenia) trafia jako kolejna wiadomość do
|
||||
tego samego wątku, nie jako nowe zgłoszenie — widoczna na żywo u operatora,
|
||||
tak jak każda inna odpowiedź.
|
||||
- **Filtry przed śmieciowymi zgłoszeniami** — automatyczne odpowiedzi
|
||||
(autorespondery, „poza biurem”, bounce/mailer-daemon) są rozpoznawane po
|
||||
nagłówkach (`Auto-Submitted`, `Precedence`) i typowych frazach w temacie
|
||||
(PL i EN) i **odrzucane bez tworzenia zgłoszenia**; dodatkowa lista
|
||||
zablokowanych nadawców per skrzynka (domyślnie `mailer-daemon, postmaster,
|
||||
no-reply, noreply`).
|
||||
- **„Tylko użytkownicy z LDAP”** (Integracje → LDAP) działa identycznie dla
|
||||
poczty jak dla formularza gościa na stronie głównej — jeśli włączone, e-mail
|
||||
od nieznanego nadawcy (spoza LDAP i bez lokalnego konta) jest odrzucany, nie
|
||||
tworzy zgłoszenia.
|
||||
- **Folder po przetworzeniu / folder odrzuconych** (opcjonalnie) — jeśli
|
||||
puste, wiadomość zostaje na miejscu tylko oznaczona jako przeczytana.
|
||||
- **Przycisk „Pobierz teraz”** przy każdej skrzynce — ręczne, natychmiastowe
|
||||
sprawdzenie poczty bez czekania na harmonogram (co 5 minut), działa też dla
|
||||
wyłączonej skrzynki; pokazuje od razu liczbę nowych/odpowiedzi/odrzuconych/
|
||||
błędów.
|
||||
- **Przycisk „Testuj połączenie”** sprawdza niezapisane wartości formularza,
|
||||
bez zapisywania.
|
||||
- **Log** — cała aktywność (połączenia, każda decyzja per wiadomość, błędy)
|
||||
trafia do osobnego pliku `storage/logs/imap-*.log`, niezależnie od
|
||||
ogólnego poziomu logowania aplikacji — najlepsze miejsce do sprawdzenia,
|
||||
dlaczego dany e-mail się nie przetworzył.
|
||||
- **Znacznik „e-mail"** — zgłoszenie i pojedyncze wiadomości utworzone z
|
||||
poczty mają widoczną ikonę koperty w kolejce operatora i w widoku
|
||||
zgłoszenia, odróżniając je od zgłoszeń/odpowiedzi z formularza web.
|
||||
|
||||
> Sprawdzanie skrzynek działa cyklicznie tylko wtedy, gdy na serwerze jest
|
||||
> skonfigurowany zewnętrzny cron wywołujący `php artisan schedule:run` (patrz
|
||||
> [install.md](../../install.md)) — bez tego działa wyłącznie przycisk
|
||||
> „Pobierz teraz”.
|
||||
|
||||
## Integracje
|
||||
|
||||
@@ -184,7 +256,12 @@ razem z layoutem/stopką wiadomości — patrz sekcja wyżej.
|
||||
zapytania kończą się błędem 403 mimo poprawnych danych logowania.
|
||||
- **Weryfikuj certyfikat SSL** — włączone domyślnie; wyłącz tylko jeśli
|
||||
instancja BookStack korzysta z certyfikatu self-signed/prywatnego CA.
|
||||
- **Przeszukuj** — strony i książki / tylko strony / tylko książki.
|
||||
- **Przeszukuj** — trzy niezależne checkboxy: Książki / Strony / Rozdziały
|
||||
(dowolna kombinacja).
|
||||
- **Szukaj po** — słowa kluczowe w nazwie / tagi / oba. Wyszukiwanie po
|
||||
tagach dopasowuje artykuły oznaczone w BookStacku tagiem o nazwie zgodnej
|
||||
z podkategorią zgłoszenia (np. tag „Drukarki i skanery”) — patrz
|
||||
automatyczne tagowanie niżej, żeby nie robić tego ręcznie dla całej wiki.
|
||||
- **Dozwolone półki** — dwie **niezależne** checklisty: jedna dla podpowiedzi
|
||||
przy tworzeniu zgłoszenia (klient, operator, formularz gościa na stronie
|
||||
głównej), druga dla panelu bocznego operatora na widoku istniejącego
|
||||
@@ -199,6 +276,79 @@ razem z layoutem/stopką wiadomości — patrz sekcja wyżej.
|
||||
- Przycisk **„Testuj połączenie”** sprawdza niezapisane wartości formularza
|
||||
(analogicznie do LDAP/SMTP) i pokazuje dokładny komunikat błędu z
|
||||
BookStacka, jeśli połączenie się nie powiedzie.
|
||||
- **Automatyczne tagowanie treści (AI)** — przyciski **„Otaguj nową
|
||||
treść”** (pomija już otagowane pozycje) i **„Otaguj wszystko ponownie”**
|
||||
(klasyfikuje od nowa całą wiki) używają integracji AI (niżej) do
|
||||
otagowania każdej książki/strony/rozdziału nazwami pasujących podkategorii
|
||||
helpdesku — bez tego wyszukiwanie „po tagach” wyżej nic nie znajdzie.
|
||||
Wymaga wcześniej skonfigurowanej i włączonej integracji AI. Dostępne też
|
||||
z linii poleceń: `php artisan bookstack:tag-content` (`--dry-run`,
|
||||
`--force`, `--limit=N`).
|
||||
|
||||
- **Snipe-IT (ewidencja sprzętu)** — opcjonalna integracja, **domyślnie
|
||||
wyłączona**. Po włączeniu:
|
||||
- **Adres API, Klucz API** — osobisty token API generuje się w Snipe-IT:
|
||||
profil użytkownika → „Create New Token”.
|
||||
- **Nie sprawdzaj SSL** — zaznacz tylko, jeśli instancja Snipe-IT korzysta
|
||||
z certyfikatu self-signed/prywatnego CA.
|
||||
- **Klient może wybrać sprzęt, którego dotyczy zgłoszenie** — przy
|
||||
tworzeniu zgłoszenia klient widzi listę swojego sprzętu z Snipe-IT
|
||||
(dopasowanego po adresie e-mail) i może je powiązać ze zgłoszeniem. Po
|
||||
zaznaczeniu pojawia się dodatkowa lista wielokrotnego wyboru **„Ogranicz
|
||||
do podkategorii”** — wybór sprzętu pokaże się klientowi **tylko** dla
|
||||
zaznaczonych tam podkategorii; jeśli nic nie jest zaznaczone, opcja nie
|
||||
pojawi się w żadnej podkategorii (tak samo jak dozwolone półki BookStack
|
||||
wyżej — trzeba świadomie wskazać zakres).
|
||||
- **Operator może zobaczyć sprzęt zgłaszającego w widoku zgłoszenia** — ta
|
||||
sama lista sprzętu zgłaszającego, tym razem w panelu bocznym operatora
|
||||
na widoku zgłoszenia, z przyciskiem „Powiąż” przy każdej pozycji.
|
||||
- **Zezwól operatorowi na przeszukiwanie całego inwentarza** — pole
|
||||
wyszukiwania z przyciskiem „Szukaj” w tym samym panelu bocznym (nie
|
||||
osobna podstrona), pozwalające powiązać dowolny sprzęt z Snipe-IT, nie
|
||||
tylko sprzęt zgłaszającego — przydatne dla współdzielonego sprzętu, np.
|
||||
drukarek.
|
||||
- Powiązany sprzęt pokazuje się na widoku zgłoszenia jako „numer środka -
|
||||
numer seryjny - producent model” oraz kategoria, z bieżącym statusem
|
||||
pobieranym na żywo z Snipe-IT. Przycisk „Odepnij” jest dostępny dla
|
||||
operatora zawsze, niezależnie od dwóch powyższych przełączników —
|
||||
odpięcie już powiązanego sprzętu to korekta, nie nowy dostęp do
|
||||
Snipe-IT.
|
||||
- Przycisk **„Testuj połączenie”** działa tak samo jak przy pozostałych
|
||||
integracjach.
|
||||
|
||||
- **Integracja AI** — opcjonalna, **domyślnie wyłączona**, ogólne połączenie z
|
||||
dostawcą modelu językowego (nie tylko dla BookStacka — patrz
|
||||
„Automatyzacja AI dla zgłoszeń” niżej). Pola: **adres API** (dowolny
|
||||
dostawca kompatybilny z OpenAI — np. Groq, OpenAI, lokalny Ollama),
|
||||
**klucz API** (opcjonalny — zostaw puste dla lokalnych instancji bez
|
||||
autoryzacji), **model**, **weryfikacja SSL** (wyłącz tylko dla instancji z
|
||||
certyfikatem self-signed, np. lokalny Ollama). Przycisk **„Testuj
|
||||
połączenie”** jak przy pozostałych integracjach.
|
||||
|
||||
- **Automatyzacja AI dla zgłoszeń** — wymaga włączonej integracji AI powyżej.
|
||||
Zgłoszenia przetwarzane są w tle, cyklicznie (`ai:run-ticket-automation`,
|
||||
interwał konfigurowalny w Konfiguracji) — nie synchronicznie przy
|
||||
składaniu zgłoszenia, więc nie spowalnia to klienta.
|
||||
- **Automatyczna kategoryzacja** — pięć niezależnych przełączników: przypisz
|
||||
kategorię/podkategorię, gdy zgłoszenie nie ma żadnej; dobierz podkategorię,
|
||||
gdy ma tylko kategorię; zweryfikuj i ewentualnie popraw już przypisaną
|
||||
podkategorię; popraw temat zgłoszenia, jeśli jest niejasny; ustaw
|
||||
priorytet na podstawie treści. Każde zgłoszenie jest sprawdzane **tylko
|
||||
raz** — zmiany trafiają do historii zgłoszenia z adnotacją
|
||||
„Automatyzacja: klasyfikacja AI” (patrz „Historia” w przewodniku
|
||||
operatora).
|
||||
- **Podsumowanie AI dla operatora** — osobny przełącznik generuje krótkie
|
||||
podsumowanie + sugerowaną kolejną akcję dla **każdego** zgłoszenia,
|
||||
widoczne tylko operatorowi (panel boczny „Podsumowanie AI” w widoku
|
||||
zgłoszenia). Domyślnie odświeża się cyklicznie, wraz z pozostałą
|
||||
automatyzacją AI powyżej (interwał w Konfiguracji) — operator może też
|
||||
w każdej chwili kliknąć **„Wygeneruj teraz”** przy podsumowaniu, żeby
|
||||
odświeżyć je natychmiast. Osobny przełącznik **„Regeneruj podsumowanie
|
||||
od razu po każdej nowej wiadomości”** (domyślnie wyłączony) sprawia, że
|
||||
podsumowanie odświeża się samo zaraz po każdej odpowiedzi/notatce, bez
|
||||
czekania na najbliższy cykl automatyzacji.
|
||||
- **Prompt systemowy podsumowania** — edytowalne pole tekstowe z gotową
|
||||
wartością domyślną i przyciskiem **„Resetuj”**.
|
||||
|
||||
## API
|
||||
|
||||
|
||||
@@ -18,10 +18,15 @@ tylko **nieprzeczytane** powiadomienia — kliknięcie usuwa je z listy.
|
||||
4. Opcjonalnie dodaj **załączniki** — przeciągnij pliki na pole załączników albo
|
||||
kliknij, żeby wybrać je z dysku (limit rozmiaru/liczby/plików ustala admin —
|
||||
komunikat o błędzie poinformuje, jeśli coś przekracza limit).
|
||||
5. Jeśli administrator włączył podpowiedzi z bazy wiedzy, przy wyborze
|
||||
5. Jeśli administrator włączył wybór sprzętu dla wybranej podkategorii,
|
||||
zobaczysz listę Twojego sprzętu z ewidencji (Snipe-IT/inwentarza),
|
||||
dopasowaną po Twoim adresie e-mail — kliknij „Powiąż” przy urządzeniu,
|
||||
którego dotyczy zgłoszenie (opcjonalne, ponowne kliknięcie odznacza wybór).
|
||||
Dostępne tylko dla podkategorii, które administrator do tego dopuścił.
|
||||
6. Jeśli administrator włączył podpowiedzi z bazy wiedzy, przy wyborze
|
||||
kategorii/podkategorii może pojawić się lista pasujących artykułów — warto
|
||||
je sprawdzić, zanim wyślesz zgłoszenie.
|
||||
6. Wyślij zgłoszenie. Otrzymasz e-mail potwierdzający (jeśli powiadomienia są
|
||||
7. Wyślij zgłoszenie. Otrzymasz e-mail potwierdzający (jeśli powiadomienia są
|
||||
włączone) z linkiem do podglądu, oraz — jeśli jesteś zalogowany — powiadomienie
|
||||
w dzwoneczku w górnym pasku.
|
||||
|
||||
@@ -48,14 +53,18 @@ Otwórz dowolne zgłoszenie, by zobaczyć:
|
||||
nie są widoczne dla klienta); załączone obrazy pokazują się jako miniatury,
|
||||
- **historię zmian** — log statusu/priorytetu/zespołu/przypisania z datą,
|
||||
- **SLA** — orientacyjny czas do rozwiązania wg priorytetu sprawy,
|
||||
- jeśli zgłoszenie jest powiązane z konkretnym sprzętem (przez Ciebie przy
|
||||
tworzeniu zgłoszenia albo przez operatora później) — jego nazwa w panelu
|
||||
bocznym „Powiązany sprzęt”,
|
||||
- jeśli administrator włączył integrację z bazą wiedzy — panel z artykułami
|
||||
dopasowanymi do kategorii/podkategorii sprawy (te same podpowiedzi, co przy
|
||||
tworzeniu zgłoszenia).
|
||||
|
||||
Wszystko na tej stronie aktualizuje się **na żywo** — jeśli operator odpowie
|
||||
albo zmieni status/przypisanie, zobaczysz to bez odświeżania strony. Mały
|
||||
licznik przy przycisku „Wróć do listy” to niezależny, okresowy fallback (co
|
||||
ok. 30 s), na wypadek gdyby połączenie w tle się zerwało.
|
||||
licznik przy przycisku „Wróć do listy” to niezależny, okresowy fallback
|
||||
(domyślnie co ok. 30 s), na wypadek gdyby połączenie w tle się zerwało —
|
||||
kliknięcie licznika od razu odświeża stronę i resetuje odliczanie.
|
||||
|
||||
## Odpowiadanie
|
||||
|
||||
|
||||
@@ -49,21 +49,35 @@ swoje.
|
||||
|
||||
**Akcje zbiorcze**: zaznacz kilka zgłoszeń checkboxami, by je **scalić** (pierwsze
|
||||
zaznaczone staje się główne, reszta trafia do niego jako wiadomości i zostaje
|
||||
zamknięta) albo **usunąć**.
|
||||
zamknięta) albo **usunąć**. Checkbox w nagłówku tabeli zaznacza/odznacza od
|
||||
razu wszystkie zgłoszenia aktualnie widoczne pod bieżącym filtrem/zakładką
|
||||
(nie wszystkie w systemie).
|
||||
|
||||
Zgłoszenie założone albo odpowiedziane przez e-mail (patrz konfiguracja w
|
||||
Admin > Poczta) ma widoczną ikonę koperty obok numeru w kolejce oraz przy
|
||||
konkretnej wiadomości w wątku zgłoszenia.
|
||||
|
||||
Kolejka aktualizuje się **na żywo** — nowe zgłoszenie, zmiana statusu/priorytetu/
|
||||
przypisania czy nowa odpowiedź pojawiają się bez odświeżania strony. Obok
|
||||
przycisku „Kolumny” widać mały licznik odliczający do zera — to niezależny od
|
||||
połączenia na żywo, okresowy fallback (co ok. 60 s), na wypadek gdyby
|
||||
połączenie sieciowe w tle się zerwało.
|
||||
połączenia na żywo, okresowy fallback (interwał ustawia administrator w
|
||||
Konfiguracji, domyślnie co ok. 60 s), na wypadek gdyby połączenie sieciowe w
|
||||
tle się zerwało. **Licznik jest też klikalny** — kliknięcie od razu odświeża
|
||||
listę i resetuje odliczanie, zamiast czekać na naturalny koniec.
|
||||
|
||||
## Praca ze zgłoszeniem
|
||||
|
||||
Widok zgłoszenia też aktualizuje się na żywo — nowa wiadomość klienta pojawia
|
||||
się od razu (bez odświeżania), podobnie jak zmiana statusu/priorytetu/zespołu
|
||||
zrobiona przez innego operatora albo przez regułę automatyzacji SLA. Licznik
|
||||
przy przycisku „Wróć do listy” to taki sam fallbackowy zegar jak w kolejce
|
||||
(co ok. 30 s).
|
||||
zrobiona przez innego operatora, przez regułę automatyzacji SLA albo przez
|
||||
automatyzację AI (patrz „Historia” niżej). Licznik przy przycisku „Wróć do
|
||||
listy” to taki sam fallbackowy, klikalny zegar jak w kolejce (domyślnie co
|
||||
ok. 30 s, konfigurowalny przez administratora).
|
||||
|
||||
Jeśli zgłoszenie, które akurat oglądasz, zostanie usunięte przez kogoś
|
||||
innego, albo trafi (przez zmianę zespołu) poza Twój zakres widoczności —
|
||||
zostaniesz automatycznie przeniesiony z powrotem do swojej kolejki, zamiast
|
||||
zobaczyć błąd.
|
||||
|
||||
W widoku pojedynczego zgłoszenia:
|
||||
|
||||
@@ -72,7 +86,10 @@ W widoku pojedynczego zgłoszenia:
|
||||
„Obserwowane zgłoszenia” w Twoich preferencjach powiadomień
|
||||
(`/settings/notifications`).
|
||||
- **Zmiana statusu / priorytetu / zespołu / przypisanego operatora** — z listy
|
||||
rozwijanej; „Przypisz do mnie” to skrót jednym kliknięciem.
|
||||
rozwijanej; „Przypisz do mnie” to skrót jednym kliknięciem. Zespół można
|
||||
zmienić na **dowolny**, nie tylko taki, do którego sam należysz — jeśli
|
||||
przeniesiesz zgłoszenie do zespołu spoza swojego zakresu widoczności,
|
||||
zostaniesz automatycznie przeniesiony do swojej kolejki.
|
||||
- **Odpowiedź publiczna** — widoczna dla klienta; można wybrać **szablon
|
||||
odpowiedzi** (wstawia gotowy tekst do edycji) i wysłać razem ze zmianą statusu
|
||||
jedną **szybką akcją** (np. „Wyślij i zamknij”) zamiast dwóch osobnych kroków.
|
||||
@@ -85,6 +102,24 @@ W widoku pojedynczego zgłoszenia:
|
||||
boczny podpowiada artykuły pasujące do kategorii/podkategorii zgłoszenia;
|
||||
kliknięcie otwiera artykuł, przycisk „Kopiuj link" kopiuje adres bez
|
||||
wychodzenia ze zgłoszenia (np. do wklejenia w odpowiedzi).
|
||||
- **Sprzęt (jeśli administrator włączył integrację z Snipe-IT)** — panel
|
||||
boczny może pokazywać do trzech rzeczy, zależnie od tego, co administrator
|
||||
włączył: sprzęt już powiązany ze zgłoszeniem (nazwa jako „numer środka -
|
||||
numer seryjny - producent model” + kategoria, status na żywo z Snipe-IT,
|
||||
przycisk „Odepnij”), listę sprzętu przypisanego zgłaszającemu z przyciskiem
|
||||
„Powiąż” przy każdej pozycji, oraz pole wyszukiwania „Przeszukaj inwentarz”
|
||||
z przyciskiem „Szukaj”, pozwalające powiązać dowolny sprzęt z Snipe-IT (nie
|
||||
tylko sprzęt zgłaszającego) — przydatne np. dla drukarki współdzielonej
|
||||
przez kilka osób. „Odepnij” działa zawsze, nawet gdy administrator wyłączył
|
||||
obie powyższe listy.
|
||||
- **Podsumowanie AI** (jeśli administrator włączył automatyzację AI
|
||||
zgłoszeń) — panel boczny widoczny tylko w panelu operatora, pokazuje krótkie
|
||||
podsumowanie sprawy i sugerowaną kolejną akcję. Domyślnie odświeża się samo
|
||||
cyklicznie (razem z pozostałą automatyzacją AI); przycisk **„Wygeneruj
|
||||
teraz”** przy podsumowaniu odświeża je natychmiast na żądanie, a jeśli
|
||||
administrator włączył „Regeneruj podsumowanie od razu po każdej nowej
|
||||
wiadomości”, odświeży się samo zaraz po każdej nowej odpowiedzi/notatce,
|
||||
bez czekania na cykl automatyzacji.
|
||||
- **Ocena obsługi** — jeśli klient już ocenił zgłoszenie, ocena (gwiazdki +
|
||||
ewentualny komentarz) pokazuje się w panelu bocznym, tylko do odczytu.
|
||||
- **Licznik czasu pracy** — start/stop/reset przy zgłoszeniu; czas zapisuje się
|
||||
@@ -94,9 +129,12 @@ W widoku pojedynczego zgłoszenia:
|
||||
po jego zamknięciu; wcześniej naliczony czas można wciąż ręcznie skorygować.
|
||||
- **Edycja danych zgłoszenia** — temat, opis, podkategoria, pola dodatkowe;
|
||||
zmiana kategorii może wysłać powiadomienie do klienta.
|
||||
- **Historia** — log każdej zmiany (status, priorytet, zespół, przypisanie) z
|
||||
datą; wpis zaczynający się od „Automatyzacja: …” oznacza, że zmianę wykonała
|
||||
reguła automatyzacji SLA (Admin > Automatyzacja SLA), nie operator ręcznie.
|
||||
- **Historia** — log każdej zmiany (status, priorytet, zespół, przypisanie,
|
||||
kategoria/podkategoria, temat) z datą; wpis „Automatyzacja: nazwa reguły”
|
||||
oznacza regułę automatyzacji SLA (Admin > Automatyzacja SLA), a
|
||||
„Automatyzacja: klasyfikacja AI” — automatyczną kategoryzację/priorytet
|
||||
ustawione przez integrację AI (Admin > Integracje) — w obu przypadkach nie
|
||||
operator ręcznie.
|
||||
|
||||
## Statystyki (`/operator/stats`)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user