6 Commits

Author SHA1 Message Date
7a8cf2037c v1.3.0
- Snipe-IT asset inventory integration (Admin > Integracje), optional and off
  by default: connect by API address + personal token (+ SSL-verification
  bypass). Three independent toggles: client can pick which of their own
  Snipe-IT assets a ticket concerns (scoped to admin-selected subcategories,
  empty = never shows), operator sees the requester's assets in a ticket-view
  sidebar, operator can search the whole inventory from that same sidebar (not
  a separate page) for shared equipment. Assets shown as "numer środka - numer
  seryjny - producent model" + category; a linked asset's live status is
  fetched fresh on the ticket page, and unlinking stays available to an
  operator even with both view/search toggles off.
- AI summary: a "Wygeneruj teraz" button for an immediate on-demand refresh,
  plus a new admin toggle to regenerate right after every new reply/note
  instead of only on the next scheduled sweep. The transcript sent to the
  model now also includes the ticket's own opening body, fixing summaries
  missing the original request on long threads.
- Fixed: the status dropdown in the operator ticket view could keep showing
  the pre-change status after a status-changing quick action until the next
  page load (Livewire/Alpine-morph quirk for wire:change-bound selects).
- Docs: README/ARCHITECTURE/CHANGELOG/install/wiki updated for all of the
  above, including correcting the AI-summary refresh description left over
  from the 1.2.1 release notes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 21:11:21 +02:00
1df697afce v1.2.2
- Subcategories can now be reordered within their category — up/down arrow
  buttons next to "Edytuj" in Admin > Kategorie. The order set there is used
  everywhere a subcategory list is shown (pickers, admin listings, etc.).
2026-07-24 13:58:18 +02:00
313e01ad24 v1.2.1
- Generic AI integration (Admin > Integracje > "Integracja AI"), optional and
  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. Foundation for the two AI features
  below and anything else that wants an LLM call in the future.
- BookStack automatic content tagging (AI): "Otaguj nową treść"/"Otaguj
  wszystko ponownie" buttons plus `php artisan bookstack:tag-content`
  (--dry-run/--force/--limit=N) tag every book/chapter/page with matching
  helpdesk subcategory names, idempotent by default.
- BookStack search refinement: "Przeszukuj" is now three independent
  checkboxes (Książki/Strony/Rozdziały) instead of a single dropdown, plus a
  new "Szukaj po" setting (nazwa/tagi/oba) — tag matching uses the bare
  subcategory name, matching what auto-tagging writes.
- AI-driven ticket triage + summary (Admin > Integracje > "Automatyzacja AI
  dla zgłoszeń", via new scheduled ai:run-ticket-automation): five toggles
  auto-assign/correct category+subcategory, rewrite an unclear subject, and
  set priority from content, once per ticket in the background; every change
  is logged in the ticket's history. Separately, an AI summary + suggested
  action for every ticket, shown to operators only, with an admin-editable
  prompt.
- Operators can now reassign a ticket to any team, not just one they belong
  to.
- The auto-refresh countdown badges (ticket view, operator queue) are now
  clickable — fetch immediately and reset the countdown.
- All 7 "cyclical" intervals (3 browser refresh countdowns, the notification
  bell poll, and the 4 background scheduled commands) are now configurable
  from Admin > Konfiguracja instead of fixed in code.
- Fixed: an operator viewing a ticket that's deleted or moved outside their
  team scope mid-session is now redirected to the operator queue instead of
  hitting an error.
- Docs: README/ARCHITECTURE/CLAUDE/install/wiki updated for all of the above.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 13:38:39 +02:00
0d116dfd98 v1.2.0
- 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 (SMTP +
  IMAP together, replacing the old "E-MAIL" tab), each routed to a specific
  subcategory or a whole category (new tickets.category_id column). Replies
  are matched to their ticket via the number/checksum already in every
  notification subject; autoresponders/bounces are detected and rejected;
  "restrict tickets to LDAP" is enforced for e-mail like the guest web form.
  Manual "Pobierz teraz" per-mailbox fetch button; dedicated
  storage/logs/imap-*.log regardless of the app's log level; mail-icon badges
  on e-mail-originated tickets/messages in the operator queue and ticket view.
- Operator queue: "select all" checkbox in the table header for every
  currently visible ticket under the active filter/tab.
- Fixed: scheduled commands (SLA breach check, automation rules, and now IMAP
  fetch) always sent notifications through .env's default mailer instead of
  the configured SMTP server, because AppServiceProvider's Settings override
  used to skip itself for any console command, not just migrate.
- Fixed: visiting a ticket that no longer exists (deleted mid-session, or a
  stale background refresh) showed a raw 404 instead of redirecting back to
  the operator queue / client dashboard.
- Docs: README/ARCHITECTURE/CLAUDE/install/wiki updated for all of the above,
  including the previously-missing host crontab entry for schedule:run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 12:44:30 +02:00
63178b366e v1.1.4
- Configurable ticket numbering (Admin > Konfiguracja > Ogólne): admin-set
  prefix and 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 it gives
  no indication of ticket volume or creation order. Ticket URLs switch to
  the same checksum when this is on, so a link and the number on the page it
  points to always match. The REST API is unaffected — pinned to `id`
  regardless of this setting. Search now also matches by checksum.
- Fixed: attachments no longer show an inline image thumbnail in the
  message thread — every attachment (images included) shows as just its
  filename, opening in a new tab on click.
- Docs: README/ARCHITECTURE/wiki updated for all of the above.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 09:57:39 +02:00
ab90abcaa3 v1.1.3
- Triggers (Admin > Wyzwalacze): event-driven rules that fire immediately on
  a ticket lifecycle event (created/updated/status/priority/assignee/team/
  category changed, new reply), with AND-conditions and ordered actions
  (set status/priority/team/assignee, send e-mail). Ships its own dedicated,
  freely add/edit/delete-able e-mail templates, kept separate from the fixed
  system templates.
- Ticket watching: operators can star/"Obserwuj" any ticket to follow it
  regardless of assignment/team.
- Real-time notification bell (private per-user broadcast channel, 30s
  fallback poll) with an opt-in in-tab browser push notification.
- Per-user notification preferences (/settings/notifications): scope
  (mine/unassigned/watched/all) and e-mail toggle per event category.
- Admin > Integracje: new tab for LDAP/AD + BookStack config, split out of
  Konfiguracja.
- Operator queue: Podkategoria/Zespół/Utworzono columns (off by default).
- Obserwuj button moved next to the auto-refresh countdown; trigger
  condition builder shows subcategory/zgłaszający as name dropdowns instead
  of raw IDs; /settings/notifications got a back link, full-width push
  card, and a bordered table container; admin panel tab and operator queue
  view now persist across a plain page refresh.
- Docs: README/ARCHITECTURE/wiki updated for all of the above.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 23:43:01 +02:00
121 changed files with 10118 additions and 405 deletions

View File

@@ -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
@@ -99,7 +172,8 @@ attributes.
`App\Support\Settings` (`app/Support/Settings.php`) is a cached key/value reader
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 writes to this table, and
branding/email HTML, etc.). Admin > Konfiguracja (general/attachments/session),
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
@@ -108,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
@@ -188,16 +275,41 @@ 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 3060 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),
carries realtime bell delivery: `AppServiceProvider::broadcastBellNotifications()`
listens for the framework's own `NotificationSent` event, and — only for the
`database` channel of a `TicketNotification` — dispatches `NotificationCreated`
on the recipient's own channel. This is a single choke point rather than
threading a broadcast call into every `TicketService` notification call site
(including the Trigger engine's `send_notification` action, below).
`resources/js/echo.js` bridges it into a `bell-notification-received` Livewire
event (refreshing `NotificationBell` instantly) and, if the viewer opted in via
the toggle on `/settings/notifications`, also raises a native in-tab
`Notification` popup — no service worker or push subscription, so this only
fires while the tab is open, same limitation as the other Echo listeners here.
## SLA
`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).
@@ -207,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
@@ -223,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
@@ -234,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.

View File

@@ -3,6 +3,253 @@
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
- **Triggers** (Admin > Wyzwalacze) — event-driven business rules that fire
immediately on a ticket lifecycle event (created, any field updated, status/
priority/assignee/team/category changed, new public reply). AND-combined
conditions gate a sequence of ordered actions (set status/priority/team/
assignee, or send an e-mail). Ships with its own dedicated, freely
add/edit/delete-able trigger e-mail templates — kept separate from the
fixed, per-event system templates, which stay exactly as fixed as before.
Complements the time-based SLA automation rules rather than replacing them,
guarded against runaway loops (a depth limit plus a same-value no-op check).
- **Ticket watching** — operators can star/"Obserwuj" any ticket to follow it
regardless of assignment or team.
- **Real-time notification bell** — the bell now updates the instant a
notification is created (broadcast on a new private per-user channel),
with the existing 30s poll kept as a fallback for a dropped websocket.
Optionally also raises a native in-tab browser push notification.
- **Per-user notification preferences** (`/settings/notifications`) — each
operator/admin chooses, per event category (new ticket, ticket update,
escalation), which scope of tickets (mine, unassigned, watched, all)
notifies them via the bell and whether that also sends an e-mail, plus an
opt-in toggle for the browser push notifications above.
- **Admin > Integracje** — new tab hosting LDAP/AD and BookStack
configuration, split out of Konfiguracja so that tab is just general
system settings (attachments, session, timezone).
- Operator queue: three more optional columns (off by default, toggle via
"Kolumny") — Podkategoria, Zespół, Utworzono.
### Changed
- The "Obserwuj" button on the operator ticket view moved next to the
auto-refresh countdown badge, both now grouped on the right.
- `/settings/notifications`: added a "← Wróć" link back to the operator/admin
area, the browser-push card now spans the full page width, and the
preferences table sits in a bordered card like the rest of the app.
- Trigger conditions on Podkategoria/Zgłaszający now show a name dropdown
instead of a raw ID field.
- The admin panel's active tab and the operator queue's active view now
persist across a plain page refresh (bound to the URL query string), so
reloading no longer bounces back to the first tab.
## [1.1.2] - 2026-07-22
### Added

View File

@@ -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

132
README.md
View File

@@ -13,7 +13,7 @@ The app has three areas, gated by role (a user can hold more than one at once):
|---|---|---|---|
| Client | `/client` | `client` | Submit tickets, track status, reply, see resolution |
| Operator | `/operator` | `operator` | Work the ticket queue, reply/resolve, see team statistics |
| Admin | `/admin` | `admin` | Configure categories, users, teams, SLA, templates, branding, LDAP/SMTP |
| Admin | `/admin` | `admin` | Configure categories, users, teams, SLA, templates, triggers, branding, LDAP/SMTP/BookStack |
Every account gets the `client` role by default (see `AssignDefaultRole` for LDAP-provisioned
accounts), and always lands on `/client` first after login regardless of what other
@@ -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/
@@ -60,11 +66,28 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
subcategories, rest folded into "Inne"); CSAT average by team and by operator;
and a daily created-vs-closed trend.
- **Branding & config** — company name/logo/favicon/accent color, login notice,
e-mail layout/footer, LDAP connection + user sync, SMTP connection, attachment
limits, session lifetime, timezone — all editable from Admin > Konfiguracja.
e-mail layout/footer, SMTP connection (Admin > E-MAIL), attachment limits,
session lifetime, timezone (Admin > Konfiguracja), and LDAP connection + user
sync + BookStack (Admin > Integracje).
- **LDAP auth** — logins bind against an LDAP/LLDAP directory (`config/auth.php`,
`config/ldap.php`); local accounts (e.g. the emergency `admin` account) fall back
to e-mail + local password when the LDAP bind doesn't match.
- **Triggers** (Admin > Wyzwalacze) — event-driven business rules that fire
immediately on a ticket lifecycle event (created, any field updated, status/
priority/assignee/team/category changed, new public reply): AND-combined
conditions gate a sequence of actions (set status/priority/team/assignee, or
send an e-mail using a dedicated set of freely add/edit/delete-able trigger
e-mail templates, kept separate from the fixed per-event system templates).
Complements the time-based SLA automation rules above rather than replacing
them.
- **Ticket watching** — operators can star/"Obserwuj" any ticket to follow it
regardless of assignment/team, which feeds the "Obserwowane zgłoszenia" scope
in their notification preferences.
- **Per-user notification preferences** (`/settings/notifications`) — each
operator/admin chooses, per event category (new ticket, ticket update,
escalation), which scope of tickets (mine, unassigned, watched, all) notifies
them via the in-app bell, and whether that also sends an e-mail; plus an
opt-in toggle for native in-tab browser push notifications.
- **REST API** (`/api/v1/...`, Sanctum token auth, ability-scoped: `tickets:read`,
`tickets:write`, `dictionaries:read`, `users:read`) for tickets/messages/users/
categories/statuses/priorities/teams — issued via admin-managed API clients.
@@ -73,11 +96,33 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
- **In-app notifications** — a bell in the top bar (client/operator/admin areas)
backed by Laravel's database notification channel, alongside the existing
e-mail notifications (same per-trigger enable toggle drives both); shows
unread notifications only — reading one removes it from the list. 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.
unread notifications only — reading one removes it from the list. Updates
live over WebSockets the moment a notification is created (with a 30s
fallback poll), and can optionally raise a native browser push notification
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); 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 15 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.
@@ -93,18 +138,63 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
is being created, and in a separate sidebar panel on an existing ticket for
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 > Konfiguracja: 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).
from Admin > Integracje: connection + API token, optional SSL-verification
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
@@ -152,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

View File

@@ -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.2 # 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.2
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

View File

@@ -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.2
VERSION=1.3.0
APP_LOCALE=en
APP_FALLBACK_LOCALE=en

View 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;
}
}

View 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;
}
}

View 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;
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
/**
* Fired once per database (bell) notification actually created for a real
* user see the NotificationSent listener in AppServiceProvider::boot(),
* which is the single choke point that dispatches this regardless of which
* of the several TicketService call sites created the underlying
* notification. Drives both the realtime bell badge (NotificationBell) and,
* when the viewing browser has granted permission, an in-tab
* `Notification` API popup.
*/
class NotificationCreated implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets;
public function __construct(
public int $userId,
public string $notificationId,
public string $message,
public string $url,
) {}
/**
* @return array<int, Channel>
*/
public function broadcastOn(): array
{
return [new PrivateChannel('App.Models.User.'.$this->userId)];
}
public function broadcastWith(): array
{
return [
'notificationId' => $this->notificationId,
'message' => $this->message,
'url' => $this->url,
];
}
}

View 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);
}
}
}

View 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');
}
}

View File

@@ -14,18 +14,21 @@ 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;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithFileUploads;
@@ -33,6 +36,7 @@ class Panel extends Component
{
use WithFileUploads;
#[Url]
public string $tab = 'categories';
// ---- categories ----
@@ -145,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;
@@ -179,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 = [
@@ -195,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'),
@@ -213,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
@@ -281,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);
@@ -1355,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 =====================
@@ -1423,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']));
@@ -1474,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;
@@ -1492,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'));
set_time_limit(0);
$this->bookstackTagResult = app(BookStackContentTagger::class)->run(force: $force);
}
app()->forgetInstance('mail.manager');
app()->forgetInstance('mailer');
public function runBookstackTaggingForce(): void
{
$this->runBookstackTagging(force: true);
}
Mail::raw('To jest testowa wiadomość wysłana z panelu administratora Servicedesk.', function ($message) {
$message->to(Auth::user()->email)->subject('Test konfiguracji SMTP');
});
// ===================== SNIPE-IT CONFIG =====================
$this->mailTestResult = 'ok';
} catch (\Throwable) {
$this->mailTestResult = 'error';
} finally {
Config::set('mail', $original);
app()->forgetInstance('mail.manager');
app()->forgetInstance('mailer');
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 =====================

View File

@@ -0,0 +1,342 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Priority;
use App\Models\Status;
use App\Models\Subcategory;
use App\Models\Team;
use App\Models\Trigger;
use App\Models\TriggerEmailTemplate;
use App\Models\User;
use Illuminate\Support\Collection;
use Livewire\Attributes\Computed;
use Livewire\Component;
class Triggers extends Component
{
public bool $formOpen = false;
public ?int $editingId = null;
public array $form = [
'name' => '',
'enabled' => true,
'event' => 'ticket_created',
'conditions' => [],
'actions' => [],
];
public bool $templateFormOpen = false;
public ?int $editingTemplateId = null;
public array $templateForm = ['name' => '', 'subject' => '', 'body' => ''];
public static function eventLabels(): array
{
return [
'ticket_created' => 'Zgłoszenie utworzone',
'ticket_updated' => 'Zgłoszenie zaktualizowane (dowolne pole)',
'status_changed' => 'Zmiana statusu',
'priority_changed' => 'Zmiana priorytetu',
'assignee_changed' => 'Zmiana przypisanego operatora',
'team_changed' => 'Zmiana zespołu',
'category_changed' => 'Zmiana kategorii',
'comment_added' => 'Nowa wiadomość (publiczna)',
];
}
public static function fieldLabels(): array
{
return [
'status_key' => 'Status',
'priority_key' => 'Priorytet',
'team_id' => 'Zespół',
'subcategory_id' => 'Podkategoria',
'assignee_id' => 'Operator przypisany',
'customer_id' => 'Zgłaszający',
'subject' => 'Temat',
'body' => 'Treść',
];
}
public static function operatorLabels(): array
{
return [
'equals' => 'jest równe',
'not_equals' => 'jest różne od',
'is_empty' => 'jest puste',
'is_not_empty' => 'nie jest puste',
'contains' => 'zawiera',
];
}
public static function actionTypeLabels(): array
{
return [
'set_status' => 'Ustaw status',
'set_priority' => 'Ustaw priorytet',
'set_team' => 'Ustaw zespół',
'set_assignee' => 'Ustaw operatora',
'send_notification' => 'Wyślij powiadomienie e-mail',
];
}
#[Computed]
public function triggers(): Collection
{
return Trigger::query()->orderBy('sort_order')->get();
}
#[Computed]
public function statuses(): Collection
{
return Status::query()->orderBy('sort_order')->get();
}
#[Computed]
public function priorities(): Collection
{
return Priority::query()->orderBy('sort_order')->get();
}
#[Computed]
public function teams(): Collection
{
return Team::query()->orderBy('name')->get();
}
#[Computed]
public function operators(): Collection
{
return User::query()->whereHas('roleAssignments', fn ($q) => $q->whereIn('key', ['operator', 'admin']))->orderBy('name')->get();
}
#[Computed]
public function subcategories(): Collection
{
return Subcategory::query()->with('category')->get()
->sortBy(fn (Subcategory $s) => $s->category->name.' / '.$s->name, SORT_NATURAL | SORT_FLAG_CASE)
->values();
}
#[Computed]
public function customers(): Collection
{
return User::query()->whereHas('roleAssignments', fn ($q) => $q->where('key', 'client'))->orderBy('name')->get();
}
#[Computed]
public function emailTemplates(): Collection
{
return TriggerEmailTemplate::query()->orderBy('name')->get();
}
public function openForm(): void
{
$this->editingId = null;
$this->form = ['name' => '', 'enabled' => true, 'event' => 'ticket_created', 'conditions' => [], 'actions' => []];
$this->resetErrorBag();
$this->formOpen = true;
}
public function editTrigger(int $id): void
{
$trigger = Trigger::query()->findOrFail($id);
$this->editingId = $trigger->id;
$this->form = [
'name' => $trigger->name,
'enabled' => $trigger->enabled,
'event' => $trigger->event,
'conditions' => $trigger->conditions,
'actions' => $trigger->actions,
];
$this->resetErrorBag();
$this->formOpen = true;
}
public function closeForm(): void
{
$this->formOpen = false;
}
public function addCondition(): void
{
$this->form['conditions'][] = ['field' => Trigger::CONDITION_FIELDS[0], 'operator' => 'equals', 'value' => ''];
}
public function removeCondition(int $index): void
{
unset($this->form['conditions'][$index]);
$this->form['conditions'] = array_values($this->form['conditions']);
}
public function addAction(): void
{
$this->form['actions'][] = ['type' => Trigger::ACTION_TYPES[0], 'value' => '', 'recipient' => 'client', 'email_template_id' => ''];
}
public function removeAction(int $index): void
{
unset($this->form['actions'][$index]);
$this->form['actions'] = array_values($this->form['actions']);
}
public function moveActionUp(int $index): void
{
$this->swapFormActions($index, $index - 1);
}
public function moveActionDown(int $index): void
{
$this->swapFormActions($index, $index + 1);
}
protected function swapFormActions(int $a, int $b): void
{
if ($b < 0 || $b >= count($this->form['actions'])) {
return;
}
[$this->form['actions'][$a], $this->form['actions'][$b]] = [$this->form['actions'][$b], $this->form['actions'][$a]];
}
public function submit(): void
{
$this->validate([
'form.name' => ['required', 'string', 'max:255'],
'form.event' => ['required', 'string', 'in:'.implode(',', Trigger::EVENTS)],
'form.conditions' => ['array'],
'form.conditions.*.field' => ['required', 'string', 'in:'.implode(',', Trigger::CONDITION_FIELDS)],
'form.conditions.*.operator' => ['required', 'string', 'in:'.implode(',', Trigger::CONDITION_OPERATORS)],
'form.actions' => ['required', 'array', 'min:1'],
'form.actions.*.type' => ['required', 'string', 'in:'.implode(',', Trigger::ACTION_TYPES)],
]);
$data = [
'name' => $this->form['name'],
'enabled' => (bool) $this->form['enabled'],
'event' => $this->form['event'],
'conditions' => array_values($this->form['conditions']),
'actions' => array_values($this->form['actions']),
];
if ($this->editingId) {
Trigger::query()->findOrFail($this->editingId)->update($data);
} else {
$data['sort_order'] = (Trigger::query()->max('sort_order') ?? 0) + 1;
Trigger::query()->create($data);
}
$this->formOpen = false;
unset($this->triggers);
}
public function toggleEnabled(int $id): void
{
$trigger = Trigger::query()->findOrFail($id);
$trigger->update(['enabled' => ! $trigger->enabled]);
unset($this->triggers);
}
public function removeTrigger(int $id): void
{
Trigger::query()->findOrFail($id)->delete();
unset($this->triggers);
}
public function moveUp(int $id): void
{
$this->swapAdjacentSortOrder($id, -1);
}
public function moveDown(int $id): void
{
$this->swapAdjacentSortOrder($id, 1);
}
protected function swapAdjacentSortOrder(int $id, int $direction): void
{
$ordered = $this->triggers;
$index = $ordered->search(fn ($row) => $row->id === $id);
$swapIndex = $index + $direction;
if ($index === false || $swapIndex < 0 || $swapIndex >= $ordered->count()) {
return;
}
$row = $ordered[$index];
$neighbor = $ordered[$swapIndex];
[$rowOrder, $neighborOrder] = [$row->sort_order, $neighbor->sort_order];
$row->update(['sort_order' => $neighborOrder]);
$neighbor->update(['sort_order' => $rowOrder]);
unset($this->triggers);
}
// ===================== TEMPLATES (wyzwalaczy) =====================
public function openTemplateForm(): void
{
$this->editingTemplateId = null;
$this->templateForm = ['name' => '', 'subject' => '', 'body' => ''];
$this->resetErrorBag();
$this->templateFormOpen = true;
}
public function editTemplate(int $id): void
{
$template = TriggerEmailTemplate::query()->findOrFail($id);
$this->editingTemplateId = $template->id;
$this->templateForm = [
'name' => $template->name,
'subject' => $template->subject,
'body' => $template->body,
];
$this->resetErrorBag();
$this->templateFormOpen = true;
}
public function closeTemplateForm(): void
{
$this->templateFormOpen = false;
}
public function setTemplateBodyDraft(string $value): void
{
$this->templateForm['body'] = $value;
}
public function submitTemplate(): void
{
$this->validate([
'templateForm.name' => ['required', 'string', 'max:255'],
'templateForm.subject' => ['required', 'string', 'max:255'],
'templateForm.body' => ['required', 'string'],
]);
if ($this->editingTemplateId) {
TriggerEmailTemplate::query()->findOrFail($this->editingTemplateId)->update($this->templateForm);
} else {
TriggerEmailTemplate::query()->create($this->templateForm);
}
$this->templateFormOpen = false;
unset($this->emailTemplates);
}
public function removeTemplate(int $id): void
{
TriggerEmailTemplate::query()->findOrFail($id)->delete();
unset($this->emailTemplates);
}
public function render()
{
return view('livewire.admin.triggers');
}
}

View File

@@ -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);

View File

@@ -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

View File

@@ -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

View File

@@ -4,10 +4,23 @@ namespace App\Livewire;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed;
use Livewire\Attributes\On;
use Livewire\Component;
class NotificationBell extends Component
{
/**
* Fired by echo.js the moment a NotificationCreated broadcast arrives on
* this user's private channel refreshes the badge/list instantly
* instead of waiting for the next 30s poll, which stays in place below
* as a fallback for dropped websocket connections.
*/
#[On('bell-notification-received')]
public function onBellNotification(): void
{
unset($this->notifications, $this->unreadCount);
}
/**
* Only unread once a notification is read (clicked through, or via
* "mark all as read"), it disappears from the bell rather than staying

View File

@@ -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

View File

@@ -18,6 +18,7 @@ use Livewire\Component;
class Queue extends Component
{
#[Url]
public string $queue = 'all';
public string $filterStatus = 'all';
@@ -297,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);
@@ -306,7 +313,7 @@ class Queue extends Component
$query->search($this->search);
}
$tickets = $query->with(['subcategory.category', 'assignee', 'priority', 'status'])->get();
$tickets = $query->with(['subcategory.category', 'category', 'assignee', 'priority', 'status', 'team'])->get();
return $this->sortTickets($tickets);
}
@@ -330,6 +337,9 @@ class Queue extends Component
'priority' => $tickets->sortBy(fn (Ticket $t) => $t->priority?->sort_order ?? PHP_INT_MAX, SORT_REGULAR, $desc),
'status' => $tickets->sortBy(fn (Ticket $t) => $t->status?->sort_order ?? PHP_INT_MAX, SORT_REGULAR, $desc),
'assignee' => $tickets->sortBy(fn (Ticket $t) => $t->assignee?->name ?? '', SORT_NATURAL | SORT_FLAG_CASE, $desc),
'team' => $tickets->sortBy(fn (Ticket $t) => $t->team?->name ?? '', SORT_NATURAL | SORT_FLAG_CASE, $desc),
'subcategory' => $tickets->sortBy(fn (Ticket $t) => $t->subcategory?->name ?? '', SORT_NATURAL | SORT_FLAG_CASE, $desc),
'created' => $tickets->sortBy(fn (Ticket $t) => $t->created_at, SORT_REGULAR, $desc),
default => $tickets->sortBy('updated_at', SORT_REGULAR, $desc),
};
@@ -346,10 +356,13 @@ class Queue extends Component
'subject' => 'Temat',
'customer' => 'Klient',
'category' => 'Kategoria',
'subcategory' => 'Podkategoria',
'priority' => 'Priorytet',
'status' => 'Status',
'sla' => 'SLA',
'assignee' => 'Przypisany',
'team' => 'Zespół',
'created' => 'Utworzono',
];
}
@@ -360,7 +373,7 @@ class Queue extends Component
*/
public function sortableColumns(): array
{
return ['number', 'subject', 'customer', 'category', 'priority', 'status', 'assignee'];
return ['number', 'subject', 'customer', 'category', 'subcategory', 'priority', 'status', 'assignee', 'team', 'created'];
}
public function sortByColumn(string $column): void
@@ -418,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();

View File

@@ -605,7 +605,7 @@ class Stats extends Component
foreach ($tickets as $ticket) {
fputcsv($out, [
$ticket->number,
$ticket->displayNumber(),
$ticket->subject,
$ticket->statusLabel(),
$ticket->priorityLabel(),

View File

@@ -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,36 @@ 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
{
return $this->ticket->isWatchedBy(Auth::user());
}
public function toggleWatch(): void
{
app(TicketService::class)->toggleWatch($this->ticket, Auth::user());
unset($this->isWatching);
}
// -------- time tracking --------
public function stopTimer(): void
@@ -197,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
@@ -210,7 +305,7 @@ class TicketShow extends Component
return;
}
$this->ticket->refresh();
$this->refreshOrRedirectAway();
}
/**
@@ -220,8 +315,11 @@ class TicketShow extends Component
*/
public function refreshTicketData(): void
{
if (! $this->refreshOrRedirectAway()) {
return;
}
unset($this->publicMessages, $this->internalMessages);
$this->ticket->refresh();
}
#[Computed]
@@ -254,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]
@@ -345,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 --------

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Livewire\Settings;
use App\Models\NotificationPreference;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class NotificationPreferences extends Component
{
protected const SCOPE_FIELDS = ['scope_mine', 'scope_unassigned', 'scope_watched', 'scope_all', 'email'];
public function mount(): void
{
abort_unless(Auth::user()->isOperator() || Auth::user()->isAdmin(), 403);
}
public function rows(): array
{
$user = Auth::user();
return collect(NotificationPreference::CATEGORIES)
->mapWithKeys(fn (string $category) => [$category => NotificationPreference::rowFor($user, $category)])
->all();
}
public function toggle(string $category, string $field): void
{
abort_unless(in_array($category, NotificationPreference::CATEGORIES, true), 404);
abort_unless(in_array($field, self::SCOPE_FIELDS, true), 404);
$preference = NotificationPreference::query()->firstOrCreate(
['user_id' => Auth::id(), 'event_category' => $category],
array_merge(['user_id' => Auth::id(), 'event_category' => $category], NotificationPreference::DEFAULTS[$category])
);
$preference->update([$field => ! $preference->$field]);
}
public function render()
{
return view('livewire.settings.notification-preferences', ['rows' => $this->rows()]);
}
}

View File

@@ -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');
}
}

View 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 '—';
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['user_id', 'event_category', 'scope_mine', 'scope_unassigned', 'scope_watched', 'scope_all', 'email'])]
class NotificationPreference extends Model
{
public const CATEGORIES = ['new_ticket', 'ticket_update', 'escalation'];
/**
* Applied whenever a user has never touched a given row including
* every user created after this feature ships (new hires, LDAP JIT
* provisioning). new_ticket defaults to exactly what
* TicketService::notifyOperatorsForNewTicket() used to do
* unconditionally (notify every relevant operator, by mail and bell),
* so shipping this feature doesn't silently change what the existing
* admin account already receives. ticket_update/escalation have no
* current staff-facing equivalent, so any default there is purely
* additive rather than a behavior change.
*/
public const DEFAULTS = [
'new_ticket' => ['scope_mine' => false, 'scope_unassigned' => false, 'scope_watched' => false, 'scope_all' => true, 'email' => true],
'ticket_update' => ['scope_mine' => true, 'scope_unassigned' => false, 'scope_watched' => true, 'scope_all' => false, 'email' => false],
'escalation' => ['scope_mine' => true, 'scope_unassigned' => false, 'scope_watched' => true, 'scope_all' => false, 'email' => true],
];
protected function casts(): array
{
return [
'scope_mine' => 'boolean',
'scope_unassigned' => 'boolean',
'scope_watched' => 'boolean',
'scope_all' => 'boolean',
'email' => 'boolean',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Always returns a usable row the persisted one if the user has ever
* toggled this category, DEFAULTS[$category] otherwise so callers
* never need to null-check.
*/
public static function rowFor(User $user, string $category): array
{
$row = static::query()->where('user_id', $user->id)->where('event_category', $category)->first();
if (! $row) {
return static::DEFAULTS[$category];
}
return [
'scope_mine' => $row->scope_mine,
'scope_unassigned' => $row->scope_unassigned,
'scope_watched' => $row->scope_watched,
'scope_all' => $row->scope_all,
'email' => $row->email,
];
}
}

View File

@@ -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

View File

@@ -2,22 +2,40 @@
namespace App\Models;
use App\Support\Settings;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
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 [
@@ -28,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',
];
}
@@ -56,6 +77,26 @@ 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');
}
public function isWatchedBy(User $user): bool
{
return $this->watchers()->where('users.id', $user->id)->exists();
}
public function status(): BelongsTo
{
return $this->belongsTo(Status::class, 'status_key');
@@ -103,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 ?? '';
}
/**
@@ -173,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);

View File

@@ -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

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['name', 'enabled', 'event', 'conditions', 'actions', 'sort_order'])]
class Trigger extends Model
{
public const EVENTS = [
'ticket_created', 'ticket_updated', 'status_changed', 'priority_changed',
'assignee_changed', 'team_changed', 'category_changed', 'comment_added',
];
public const CONDITION_FIELDS = [
'status_key', 'priority_key', 'team_id', 'subcategory_id', 'assignee_id', 'customer_id', 'subject', 'body',
];
public const CONDITION_OPERATORS = ['equals', 'not_equals', 'is_empty', 'is_not_empty', 'contains'];
public const ACTION_TYPES = ['set_status', 'set_priority', 'set_team', 'set_assignee', 'send_notification'];
protected function casts(): array
{
return [
'enabled' => 'boolean',
'conditions' => 'array',
'actions' => 'array',
'sort_order' => 'integer',
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['name', 'subject', 'body'])]
class TriggerEmailTemplate extends Model
{
public function render(array $placeholders): array
{
$replace = function (string $text) use ($placeholders): string {
foreach ($placeholders as $key => $value) {
$text = str_replace('{'.$key.'}', (string) $value, $text);
}
return $text;
};
return [
'subject' => $replace($this->subject),
'body' => $replace($this->body),
];
}
}

View File

@@ -184,6 +184,11 @@ class User extends Authenticatable implements LdapAuthenticatable
return $this->hasMany(Ticket::class, 'customer_id');
}
public function watchedTickets(): BelongsToMany
{
return $this->belongsToMany(Ticket::class, 'ticket_watchers');
}
public function ticketsAssigned(): HasMany
{
return $this->hasMany(Ticket::class, 'assignee_id');

View File

@@ -4,6 +4,7 @@ namespace App\Notifications;
use App\Models\EmailTemplate;
use App\Models\Ticket;
use App\Models\TriggerEmailTemplate;
use App\Support\Settings;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\AnonymousNotifiable;
@@ -20,8 +21,25 @@ class TicketNotification extends Notification
* user can hold both roles at once, so this can't be inferred from the
* notifiable itself; it decides which ticket URL (client vs operator
* area) both the e-mail link and the in-app notification point to.
*
* $channels lets a caller with a real per-recipient preference (see
* TicketService::notifyStaffForCategory()) send only 'database' (bell,
* no e-mail) for a given recipient defaults to the original
* unconditional "both" behaviour so every existing call site is
* unaffected.
*
* $templateSource picks which table $emailTemplateId is looked up in:
* 'email_template' (the fixed, built-in templates) or
* 'trigger_email_template' (the freely add/edit/delete-able templates
* used by trigger "send_notification" actions see TriggerEngine).
*/
public function __construct(protected Ticket $ticket, protected int $emailTemplateId, protected string $recipientRole = 'client') {}
public function __construct(
protected Ticket $ticket,
protected int $emailTemplateId,
protected string $recipientRole = 'client',
protected array $channels = ['mail', 'database'],
protected string $templateSource = 'email_template',
) {}
/**
* A guest customer with no account is routed anonymously (see
@@ -30,7 +48,7 @@ class TicketNotification extends Notification
*/
public function via(object $notifiable): array
{
return $notifiable instanceof AnonymousNotifiable ? ['mail'] : ['mail', 'database'];
return $notifiable instanceof AnonymousNotifiable ? ['mail'] : $this->channels;
}
protected function ticketUrl(): string
@@ -44,19 +62,21 @@ 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(),
];
}
public function toMail(object $notifiable): MailMessage
{
$template = EmailTemplate::query()->find($this->emailTemplateId);
$template = $this->templateSource === 'trigger_email_template'
? TriggerEmailTemplate::query()->find($this->emailTemplateId)
: EmailTemplate::query()->find($this->emailTemplateId);
$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(),
@@ -67,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,
];

View File

@@ -2,13 +2,19 @@
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;
use App\Support\Settings;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Http\Request;
use Illuminate\Notifications\Events\NotificationSent;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\ServiceProvider;
@@ -35,12 +41,55 @@ class AppServiceProvider extends ServiceProvider
$this->applySessionSettingsOverride();
$this->applyTimezoneSettingsOverride();
$this->configureApiRateLimiting();
$this->broadcastBellNotifications();
$this->regenerateAiSummaryOnNewMessage();
// 'user' backs the polymorphic notifiable_type column on the
// database-notifications table (in-app notification bell).
Relation::enforceMorphMap(['api_client' => ApiClient::class, 'user' => User::class]);
}
/**
* A single choke point for realtime bell delivery hooks Laravel's own
* post-send event instead of threading a broadcast dispatch into every
* TicketService call site that creates a "database" notification
* (client leg, staff fan-out, and eventually the Trigger engine's
* send_notification action). $event->response is the DatabaseChannel's
* return value: the DatabaseNotification row that was just created,
* whose id is the same one the bell already reads.
*/
protected function broadcastBellNotifications(): void
{
Event::listen(NotificationSent::class, function (NotificationSent $event) {
if ($event->channel !== 'database' || ! $event->notification instanceof TicketNotification) {
return;
}
$data = $event->notification->toDatabase($event->notifiable);
NotificationCreated::dispatch($event->notifiable->id, $event->response->id, $data['message'], $data['url']);
});
}
/**
* 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
@@ -58,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;
}

View 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;
}
}

View File

@@ -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[]
*/

View 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()];
}
}

View 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;
}
}

View 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;
}
}

View 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');
}
}

View 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,
];
}
}

View 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;
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Services;
use App\Events\TicketMessagePosted;
use App\Events\TicketQueueChanged;
use App\Models\ApiClient;
use App\Models\NotificationPreference;
use App\Models\NotificationSetting;
use App\Models\Priority;
use App\Models\Status;
@@ -41,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'),
@@ -49,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([
@@ -58,7 +66,8 @@ class TicketService
$message->attachAuthor($customer?->id, 'client');
$this->notify($ticket, 'ticket_created');
$this->notifyOperatorsForNewTicket($ticket, $subcategory);
$this->notify($ticket, 'ticket_created_team');
app(TriggerEngine::class)->handle($ticket, 'ticket_created');
TicketQueueChanged::dispatch($ticket->id, 'created', Auth::id());
return $ticket;
@@ -74,38 +83,6 @@ class TicketService
->value('id');
}
/**
* Notifies every member of every team the new ticket's subcategory
* routes to independent of whether "auto_assign_by_category" actually
* assigned the ticket's team_id, since the point here is "a ticket
* matching your team's specialty came in", not the routing feature
* itself. Unlike notify(), this fans out to potentially many
* notifiables at once, so it can't reuse that single-recipient method.
*/
protected function notifyOperatorsForNewTicket(Ticket $ticket, ?Subcategory $subcategory): void
{
if (! $subcategory) {
return;
}
$setting = NotificationSetting::query()->where('trigger_key', 'ticket_created_team')->first();
if (! $setting || ! $setting->enabled || ! $setting->email_template_id) {
return;
}
$operators = Team::query()
->whereHas('subcategories', fn ($q) => $q->where('subcategories.id', $subcategory->id))
->with('members')
->get()
->flatMap(fn (Team $team) => $team->members)
->unique('id');
foreach ($operators as $operator) {
$operator->notify(new TicketNotification($ticket, $setting->email_template_id, 'operator'));
}
}
public function setStatus(Ticket $ticket, string $statusKey): void
{
// Any status change (closing, reopening, moving between open sub-statuses)
@@ -133,6 +110,8 @@ class TicketService
$this->notify($ticket, 'status_changed');
}
app(TriggerEngine::class)->handle($ticket, 'status_changed');
app(TriggerEngine::class)->handle($ticket, 'ticket_updated');
TicketQueueChanged::dispatch($ticket->id, 'status_changed', Auth::id());
}
@@ -157,6 +136,8 @@ class TicketService
$ticket->update(['priority_key' => $priorityKey]);
$ticket->addHistory('Priorytet zmieniony na: '.Priority::labelFor($priorityKey));
$this->notify($ticket, 'priority_changed');
app(TriggerEngine::class)->handle($ticket, 'priority_changed');
app(TriggerEngine::class)->handle($ticket, 'ticket_updated');
TicketQueueChanged::dispatch($ticket->id, 'priority_changed', Auth::id());
}
@@ -167,6 +148,8 @@ class TicketService
$ticket->update(['assignee_id' => $assignee?->id, 'sla_notified_at' => null]);
$ticket->addHistory('Przypisano do: '.($assignee?->name ?? 'Nieprzypisane'));
$this->notify($ticket, 'assignee_changed');
app(TriggerEngine::class)->handle($ticket, 'assignee_changed');
app(TriggerEngine::class)->handle($ticket, 'ticket_updated');
TicketQueueChanged::dispatch($ticket->id, 'assignee_changed', Auth::id());
}
@@ -175,15 +158,84 @@ class TicketService
$ticket->update(['team_id' => $team?->id]);
$ticket->addHistory('Zespół zmieniony na: '.($team?->name ?? 'Brak'));
$this->notify($ticket, 'team_changed');
app(TriggerEngine::class)->handle($ticket, 'team_changed');
app(TriggerEngine::class)->handle($ticket, 'ticket_updated');
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;
@@ -198,7 +250,10 @@ class TicketService
if ($categoryChanged) {
$this->notify($ticket, 'category_changed');
app(TriggerEngine::class)->handle($ticket, 'category_changed');
}
app(TriggerEngine::class)->handle($ticket, 'ticket_updated');
}
public function operatorReply(Ticket $ticket, User $operator, string $body, ?string $statusAfter = null, array $attachments = []): void
@@ -211,6 +266,7 @@ class TicketService
$ticket->touch();
$this->attachFiles($ticket, $message, $attachments);
$this->notify($ticket, 'operator_replied');
app(TriggerEngine::class)->handle($ticket, 'comment_added');
TicketMessagePosted::dispatch($ticket->id, $message->id, false, $operator->id);
TicketQueueChanged::dispatch($ticket->id, 'message_posted', $operator->id);
@@ -231,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();
@@ -247,10 +304,58 @@ class TicketService
$ticket->update(['last_customer_activity_at' => now()]);
$ticket->automationRuleLogs()->delete();
// Unlike notify(), clientReply() never had a NotificationSetting
// trigger_key of its own — comment_added is a Trigger-engine-only
// hook, e.g. for a rule that reopens a closed ticket on a fresh
// customer reply.
app(TriggerEngine::class)->handle($ticket, 'comment_added');
TicketMessagePosted::dispatch($ticket->id, $message->id, false, $client->id);
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)) {
$ticket->watchers()->detach($user->id);
return false;
}
$ticket->watchers()->attach($user->id);
return true;
}
/**
* A message posted by an API integration rather than a logged-in person
* no User to attach as author, so it lands as a "system" message (mirrors
@@ -271,6 +376,7 @@ class TicketService
if (! $internal) {
$this->notify($ticket, 'operator_replied');
app(TriggerEngine::class)->handle($ticket, 'comment_added');
}
TicketMessagePosted::dispatch($ticket->id, $message->id, $internal, null);
@@ -327,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) {
@@ -345,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());
@@ -355,15 +461,40 @@ class TicketService
TicketQueueChanged::dispatch($primary->id, 'message_posted', Auth::id());
}
/**
* Maps the fixed NotificationSetting trigger_keys onto the 3 event
* categories a staff member can tune on their personal notification
* preferences page (see NotificationPreference::CATEGORIES). Triggers
* absent from this map (currently just the client-only 'ticket_created'
* ack) have no staff-facing leg at all.
*/
private const STAFF_EVENT_MAP = [
'ticket_created_team' => 'new_ticket',
'status_changed' => 'ticket_update',
'priority_changed' => 'ticket_update',
'assignee_changed' => 'ticket_update',
'team_changed' => 'ticket_update',
'category_changed' => 'ticket_update',
'operator_replied' => 'ticket_update',
'ticket_closed' => 'ticket_update',
'sla_breached' => 'escalation',
];
/**
* Public so the scheduled SLA-breach check (which isn't a ticket lifecycle
* event raised from within this service) can trigger the same way.
*
* Routes through the recipient's own User model (so it lands in the
* in-app notification bell in addition to e-mail) whenever one exists;
* falls back to an anonymous mail-only route for a guest customer with
* no account. One shared NotificationSetting.enabled flag gates both
* channels there's no separate in-app on/off switch.
* NotificationSetting.enabled is the global kill switch, layered above
* every per-user preference below disabling a trigger here silences
* both legs regardless of what any individual staff member configured;
* the personal matrix can only narrow within an enabled trigger, never
* widen past it.
*
* Sends exactly one notification to the trigger's fixed
* NotificationSetting.recipient (a client, or the ticket's single
* assignee) exactly as before, then for triggers mapped in
* STAFF_EVENT_MAP additionally fans out to every other operator/admin
* whose own notification preferences put this ticket in scope.
*/
public function notify(Ticket $ticket, string $triggerKey): void
{
@@ -374,20 +505,93 @@ class TicketService
}
$notifiable = $setting->recipient === 'operator' ? $ticket->assignee : $ticket->customer;
$fallbackEmail = $setting->recipient === 'operator' ? $ticket->assignee?->email : $ticket->email;
$this->deliverTicketNotification($ticket, $setting->recipient, $setting->email_template_id, notifiable: $notifiable, fallbackEmail: $fallbackEmail);
if ($category = self::STAFF_EVENT_MAP[$triggerKey] ?? null) {
$this->notifyStaffForCategory($ticket, $category, $setting->email_template_id, skip: $notifiable);
}
}
/**
* Notifies every operator/admin whose personal notification preferences
* (see NotificationPreference) put this ticket into one of their chosen
* scopes for $category "Wszystkie zgłoszenia" deliberately reuses the
* existing Ticket::isVisibleToOperator() ACL rather than meaning
* literally every ticket, so it naturally stays within a non-admin
* operator's own team(s) + unrouted tickets. $skip excludes whoever
* notify() already notified directly via the fixed recipient (so an
* assignee with scope_mine enabled doesn't get the same event twice),
* and the acting user is always excluded so nobody gets notified about
* their own action.
*/
protected function notifyStaffForCategory(Ticket $ticket, string $category, int $templateId, ?User $skip = null): void
{
$staff = User::query()->whereHas('roleAssignments', fn ($q) => $q->whereIn('key', ['operator', 'admin']))->get();
foreach ($staff as $user) {
if ($user->id === Auth::id() || ($skip && $user->id === $skip->id)) {
continue;
}
$pref = NotificationPreference::rowFor($user, $category);
$inScope = ($pref['scope_mine'] && $ticket->assignee_id === $user->id)
|| ($pref['scope_unassigned'] && $ticket->assignee_id === null)
|| ($pref['scope_watched'] && $ticket->isWatchedBy($user))
|| ($pref['scope_all'] && $ticket->isVisibleToOperator($user));
if (! $inScope) {
continue;
}
$this->deliverTicketNotification($ticket, 'operator', $templateId, $pref['email'] ? ['mail', 'database'] : ['database'], notifiable: $user);
}
}
/**
* Entry point for the Trigger engine's send_notification action (see
* TriggerEngine) an admin-authored, explicit business action, not one
* of the fixed system lifecycle events, so unlike notify() it doesn't
* consult NotificationSetting or any per-user preference; it always
* sends both mail and bell, same as the original unconditional
* TicketNotification behaviour.
*/
public function sendCustomNotification(Ticket $ticket, string $recipient, int $templateId): void
{
$notifiable = $recipient === 'operator' ? $ticket->assignee : $ticket->customer;
$fallbackEmail = $recipient === 'operator' ? $ticket->assignee?->email : $ticket->email;
$this->deliverTicketNotification($ticket, $recipient, $templateId, notifiable: $notifiable, fallbackEmail: $fallbackEmail, templateSource: 'trigger_email_template');
}
/**
* Shared by notify()'s fixed-recipient leg, notifyStaffForCategory()'s
* per-user fan-out, and sendCustomNotification(). $notifiable, when
* given a real User, always wins over $fallbackEmail the fallback
* only exists for a guest customer with no account, where the
* "database" (bell) channel has nothing to attach to, so
* TicketNotification::via() drops it to mail-only anyway.
*/
private function deliverTicketNotification(
Ticket $ticket,
string $recipientRole,
int $templateId,
array $channels = ['mail', 'database'],
?User $notifiable = null,
?string $fallbackEmail = null,
string $templateSource = 'email_template',
): void {
if ($notifiable) {
$notifiable->notify(new TicketNotification($ticket, $setting->email_template_id, $setting->recipient));
$notifiable->notify(new TicketNotification($ticket, $templateId, $recipientRole, $channels, $templateSource));
return;
}
$email = $setting->recipient === 'operator' ? $ticket->assignee?->email : $ticket->email;
if (! $email) {
return;
if ($fallbackEmail) {
Notification::route('mail', $fallbackEmail)
->notify(new TicketNotification($ticket, $templateId, $recipientRole, $channels, $templateSource));
}
Notification::route('mail', $email)
->notify(new TicketNotification($ticket, $setting->email_template_id, $setting->recipient));
}
}

View File

@@ -0,0 +1,164 @@
<?php
namespace App\Services;
use App\Models\Priority;
use App\Models\Status;
use App\Models\Team;
use App\Models\Ticket;
use App\Models\Trigger;
use App\Models\User;
use Illuminate\Support\Facades\Log;
/**
* Event-based business rules, configured entirely by admins through the
* Wyzwalacze tab additive and independent from AutomationRule (which is
* time/silence-based and evaluated by a scheduled command instead). Fires
* synchronously on every matching ticket-lifecycle event (see the
* TicketService call sites), same as the app's other side effects there
* is no queue worker in this stack to defer work to.
*/
class TriggerEngine
{
private static int $depth = 0;
private const MAX_DEPTH = 5;
public function __construct(protected TicketService $tickets) {}
/**
* Guarded two ways against runaway loops: an action that would only
* reassert the ticket's current value is a no-op before it ever gets
* here (see the apply* methods below), which kills the common case of a
* trigger re-matching its own result; the depth counter below is the
* hard backstop for genuine cycles between two or more different
* triggers.
*/
public function handle(Ticket $ticket, string $event): void
{
if (self::$depth >= self::MAX_DEPTH) {
Log::warning('TriggerEngine: max depth reached, aborting further evaluation', [
'ticket_id' => $ticket->id,
'event' => $event,
]);
return;
}
self::$depth++;
try {
$triggers = Trigger::query()->where('enabled', true)->where('event', $event)->orderBy('sort_order')->get();
foreach ($triggers as $trigger) {
$current = $ticket->fresh();
if ($current && $this->matches($trigger, $current)) {
$this->applyActions($trigger, $current);
}
}
} finally {
self::$depth--;
}
}
protected function matches(Trigger $trigger, Ticket $ticket): bool
{
foreach ($trigger->conditions as $condition) {
$field = $condition['field'] ?? null;
if (! in_array($field, Trigger::CONDITION_FIELDS, true)) {
return false;
}
if (! $this->conditionMatches($condition, $ticket->{$field})) {
return false;
}
}
return true;
}
protected function conditionMatches(array $condition, mixed $actual): bool
{
$value = $condition['value'] ?? null;
return match ($condition['operator'] ?? null) {
'equals' => (string) $actual === (string) $value,
'not_equals' => (string) $actual !== (string) $value,
'is_empty' => $actual === null || $actual === '',
'is_not_empty' => $actual !== null && $actual !== '',
'contains' => is_string($actual) && $value !== null && str_contains(mb_strtolower($actual), mb_strtolower((string) $value)),
default => false,
};
}
protected function applyActions(Trigger $trigger, Ticket $ticket): void
{
foreach ($trigger->actions as $action) {
match ($action['type'] ?? null) {
'set_status' => $this->applySetStatus($ticket, $action['value'] ?? null),
'set_priority' => $this->applySetPriority($ticket, $action['value'] ?? null),
'set_team' => $this->applySetTeam($ticket, $action['value'] ?? null),
'set_assignee' => $this->applySetAssignee($ticket, $action['value'] ?? null),
'send_notification' => $this->applySendNotification($ticket, $action),
default => null,
};
}
}
protected function applySetStatus(Ticket $ticket, ?string $value): void
{
if (! $value || ! Status::query()->where('key', $value)->exists() || $ticket->status_key === $value) {
return;
}
$this->tickets->setStatus($ticket, $value);
}
protected function applySetPriority(Ticket $ticket, ?string $value): void
{
if (! $value || ! Priority::query()->where('key', $value)->exists() || $ticket->priority_key === $value) {
return;
}
$this->tickets->setPriority($ticket, $value);
}
protected function applySetTeam(Ticket $ticket, null|string|int $value): void
{
if ($value === null || (int) $ticket->team_id === (int) $value) {
return;
}
$team = Team::query()->find($value);
if ($team) {
$this->tickets->setTeam($ticket, $team);
}
}
protected function applySetAssignee(Ticket $ticket, null|string|int $value): void
{
if ($value === null || (int) $ticket->assignee_id === (int) $value) {
return;
}
$assignee = User::query()->find($value);
if ($assignee) {
$this->tickets->setAssignee($ticket, $assignee);
}
}
protected function applySendNotification(Ticket $ticket, array $action): void
{
$templateId = $action['email_template_id'] ?? null;
if (! $templateId) {
return;
}
$this->tickets->sendCustomNotification($ticket, $action['recipient'] ?? 'client', (int) $templateId);
}
}

View 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)));
}
}

View File

@@ -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

View File

@@ -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();

View File

@@ -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
View File

@@ -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",

View File

@@ -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'),

View File

@@ -0,0 +1,25 @@
<?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('ticket_watchers', function (Blueprint $table) {
$table->id();
$table->foreignId('ticket_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->timestamps();
$table->unique(['ticket_id', 'user_id']);
});
}
public function down(): void
{
Schema::dropIfExists('ticket_watchers');
}
};

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* One row per (user, event_category) only written the first time a
* user actually toggles a checkbox on their notification-preferences
* page. A missing row is not "notifications off"; callers must fall
* back to NotificationPreference::DEFAULTS, never treat absence as
* all-false (see NotificationPreference::rowFor()).
*/
public function up(): void
{
Schema::create('notification_preferences', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('event_category');
$table->boolean('scope_mine')->default(false);
$table->boolean('scope_unassigned')->default(false);
$table->boolean('scope_watched')->default(false);
$table->boolean('scope_all')->default(false);
$table->boolean('email')->default(false);
$table->timestamps();
$table->unique(['user_id', 'event_category']);
});
}
public function down(): void
{
Schema::dropIfExists('notification_preferences');
}
};

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Event-driven business rules (see App\Services\TriggerEngine)
* conditions/actions are JSON so an admin can add/edit rules entirely
* through the UI, with no migration needed per rule. Deliberately no
* dedup/log table here (unlike automation_rule_ticket_logs): a trigger
* is meant to re-fire on every matching event, not latch until reset.
*/
public function up(): void
{
Schema::create('triggers', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->boolean('enabled')->default(true);
$table->string('event');
$table->json('conditions');
$table->json('actions');
$table->unsignedInteger('sort_order')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('triggers');
}
};

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Separate from email_templates on purpose: those are fixed 1:1 to a
* built-in notification trigger (no add/delete/reassign see
* Admin\Panel::editingTemplate()), while these are freely add/edit/
* delete-able by admins for use in the "Wyślij powiadomienie e-mail"
* trigger action (App\Services\TriggerEngine::applySendNotification).
*/
public function up(): void
{
Schema::create('trigger_email_templates', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('subject');
$table->text('body');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('trigger_email_templates');
}
};

View File

@@ -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');
});
}
};

View File

@@ -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');
}
};

View File

@@ -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');
});
}
};

View File

@@ -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');
});
}
};

View File

@@ -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']);
});
}
};

View File

@@ -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');
});
}
};

View File

@@ -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']);
});
}
};

View File

@@ -55,6 +55,34 @@ if (window.currentUserId) {
.error((error) => console.error('operator.queue subscription error', error));
}
/**
* Every logged-in user's own private notification stream — refreshes the
* bell instantly (see NotificationBell::onBellNotification()) and, when the
* viewer has opted in via the toggle on the notification-preferences page,
* also raises an in-tab browser Notification. Deliberately lightweight: no
* service worker, no push subscription — this only fires while the tab
* calling it is open, same limitation as the operator.queue block above.
*/
if (window.currentUserId) {
window.Echo.private('App.Models.User.' + window.currentUserId)
.listen('.NotificationCreated', (e) => {
Livewire.dispatch('bell-notification-received', { notificationId: e.notificationId });
if (
localStorage.getItem('browserNotificationsEnabled') === '1'
&& typeof Notification !== 'undefined'
&& Notification.permission === 'granted'
) {
const popup = new Notification(e.message, { tag: e.notificationId });
popup.onclick = () => {
window.focus();
window.location.href = e.url;
};
}
})
.error((error) => console.error('user notification channel subscription error', error));
}
/**
* Subscribes to a single ticket's channel — called by the Blade view of
* whichever TicketShow component (operator or client) is currently mounted,

View File

@@ -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
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
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>

View File

@@ -42,6 +42,18 @@
<div style="border-top:1px solid var(--color-divider)"></div>
@endif
@if ($user && ($user->isOperator() || $user->isAdmin()))
<a
href="{{ route('settings.notifications') }}"
wire:navigate
@click="open = false"
class="theme-toggle-option"
style="text-decoration:none;color:{{ request()->routeIs('settings.*') ? 'var(--color-accent)' : 'var(--color-text)' }};font-size:12.5px"
>Powiadomienia</a>
<div style="border-top:1px solid var(--color-divider)"></div>
@endif
<a
href="{{ route('logout') }}"
onclick="event.preventDefault(); document.getElementById('profile-menu-logout-form').submit();"

View 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

View 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) 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>

View File

@@ -8,6 +8,7 @@ $tabGroups = [
['key' => 'reply-quick-actions', 'label' => 'Szybkie akcje odpowiedzi', 'icon' => 'bolt'],
['key' => 'response-templates', 'label' => 'Szablony odpowiedzi', 'icon' => 'chat'],
['key' => 'automation-rules', 'label' => 'Automatyzacja SLA', 'icon' => 'bolt'],
['key' => 'triggers', 'label' => 'Wyzwalacze', 'icon' => 'rule'],
],
'Zespół' => [
['key' => 'users', 'label' => 'Użytkownicy', 'icon' => 'group'],
@@ -16,8 +17,10 @@ $tabGroups = [
],
'Ustawienia' => [
['key' => 'templates', 'label' => 'Szablony e-mail', 'icon' => 'mail'],
['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'],
['key' => 'api-keys', 'label' => 'Klucze API', 'icon' => 'vpn_key'],
['key' => 'about', 'label' => 'O aplikacji', 'icon' => 'info'],
],
@@ -82,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">
@@ -94,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>
@@ -356,6 +361,11 @@ $tabGroups = [
@endif
@endif
{{-- ================= TRIGGERS ================= --}}
@if ($tab === 'triggers')
<livewire:admin.triggers />
@endif
{{-- ================= STATUSES ================= --}}
@if ($tab === 'statuses')
<h3 style="margin:0 0 6px">Statusy</h3>
@@ -435,6 +445,27 @@ $tabGroups = [
{{-- ================= EMAIL TEMPLATES ================= --}}
@if ($tab === 'templates')
<h3 style="margin:0 0 6px">Powiadomienia e-mail</h3>
<p class="text-muted" style="font-size:12.5px;margin:0 0 14px">Każde zdarzenie ma stały, przypisany na stałe szablon możesz go dowolnie edytować, ale nie zmienić na inny. Wyłącz przełącznik, żeby dana wiadomość nigdy nie była wysyłana.</p>
<div class="table-wrap">
<table class="table">
<thead><tr><th>Zdarzenie</th><th>Odbiorca</th><th>Wysyłane</th><th></th></tr></thead>
<tbody>
@foreach ($this->notificationSettings as $ns)
<tr>
<td style="white-space:nowrap">{{ $ns->trigger_label }}</td>
<td><span class="tag tag-outline">{{ $ns->recipient === 'operator' ? 'Operator' : 'Zgłaszający' }}</span></td>
<td><input type="checkbox" @checked($ns->enabled) wire:click="toggleNotificationEnabled({{ $ns->id }})"></td>
<td><button type="button" class="btn btn-ghost" wire:click="editEmailTemplate({{ $ns->email_template_id }})">Edytuj szablon</button></td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
{{-- ================= E-MAIL ================= --}}
@if ($tab === 'email')
<h3 style="margin:0 0 6px">Wygląd wiadomości e-mail</h3>
<p class="text-muted" style="font-size:12.5px;margin:0 0 14px">Każde powiadomienie wysyłane jest w stałym „pudełku” (nazwa firmy, ramka, treść zdarzenia) tu edytujesz tylko jego stopkę. Po prawej podgląd na żywo na przykładowym zgłoszeniu.</p>
@@ -454,23 +485,7 @@ $tabGroups = [
</div>
</div>
<h3 style="margin:0 0 6px">Powiadomienia e-mail</h3>
<p class="text-muted" style="font-size:12.5px;margin:0 0 14px">Każde zdarzenie ma stały, przypisany na stałe szablon możesz go dowolnie edytować, ale nie zmienić na inny. Wyłącz przełącznik, żeby dana wiadomość nigdy nie była wysyłana.</p>
<div class="table-wrap">
<table class="table">
<thead><tr><th>Zdarzenie</th><th>Odbiorca</th><th>Wysyłane</th><th></th></tr></thead>
<tbody>
@foreach ($this->notificationSettings as $ns)
<tr>
<td style="white-space:nowrap">{{ $ns->trigger_label }}</td>
<td><span class="tag tag-outline">{{ $ns->recipient === 'operator' ? 'Operator' : 'Zgłaszający' }}</span></td>
<td><input type="checkbox" @checked($ns->enabled) wire:click="toggleNotificationEnabled({{ $ns->id }})"></td>
<td><button type="button" class="btn btn-ghost" wire:click="editEmailTemplate({{ $ns->email_template_id }})">Edytuj szablon</button></td>
</tr>
@endforeach
</tbody>
</table>
</div>
<livewire:admin.mail-settings />
@endif
{{-- ================= BRANDING ================= --}}
@@ -566,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'] }} &rarr; podgląd numeru: {{ $this->ticketNumberPreview['formatted'] }}
</div>
</div>
<div class="card" style="padding:20px;gap:14px">
@@ -610,51 +640,44 @@ $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>
</form>
<form wire:submit="saveMailConfig" class="card" style="padding:20px;gap:14px">
<h4 style="margin:0">E-mail (SMTP)</h4>
<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>
<p class="text-muted" style="font-size:11.5px;margin:-4px 0 0">Stopka wiadomości e-mail edytowana jest w zakładce „Szablony e-mail”.</p>
</div>
@endif
<div class="hr"></div>
{{-- ================= INTEGRATIONS ================= --}}
@if ($tab === 'integrations')
<h3 style="margin:0 0 16px">Integracje</h3>
<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:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:20px;align-items:start">
<form wire:submit="saveLdapConfig" class="card" style="padding:20px;gap:14px">
<h4 style="margin:0">LDAP / Active Directory</h4>
@@ -699,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 &rarr; 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">
@@ -754,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>
@@ -768,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 &rarr; „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 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

View File

@@ -0,0 +1,303 @@
@php
$eventLabels = \App\Livewire\Admin\Triggers::eventLabels();
$fieldLabels = \App\Livewire\Admin\Triggers::fieldLabels();
$operatorLabels = \App\Livewire\Admin\Triggers::operatorLabels();
$actionTypeLabels = \App\Livewire\Admin\Triggers::actionTypeLabels();
@endphp
<div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
<h3 style="margin:0">Wyzwalacze</h3>
<button class="btn btn-primary" type="button" wire:click="openForm">+ Nowy wyzwalacz</button>
</div>
<p class="text-muted" style="font-size:12.5px;margin:0 0 14px">
Wyzwalacze reagują natychmiast na zdarzenie w zgłoszeniu (utworzenie, zmiana pola, nowy komentarz) w odróżnieniu od Automatyzacji SLA (zakładka obok), która działa na podstawie czasu milczenia klienta. Warunki wyzwalacza muszą być spełnione wszystkie naraz (ORAZ); akcje wykonują się w podanej kolejności.
</p>
@if ($this->triggers->isNotEmpty())
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th></th>
<th>Nazwa</th>
<th>Zdarzenie</th>
<th>Warunki</th>
<th>Akcje</th>
<th>Aktywny</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach ($this->triggers as $trigger)
<tr wire:key="trigger-{{ $trigger->id }}">
<td style="white-space:nowrap">
<button type="button" class="btn btn-ghost" style="padding:0 4px" wire:click="moveUp({{ $trigger->id }})" title="Przenieś wyżej">
<span class="material-symbols-outlined" style="font-size:16px">arrow_upward</span>
</button>
<button type="button" class="btn btn-ghost" style="padding:0 4px" wire:click="moveDown({{ $trigger->id }})" title="Przenieś niżej">
<span class="material-symbols-outlined" style="font-size:16px">arrow_downward</span>
</button>
</td>
<td style="white-space:nowrap">{{ $trigger->name }}</td>
<td><span class="tag tag-outline">{{ $eventLabels[$trigger->event] ?? $trigger->event }}</span></td>
<td class="text-muted" style="font-size:12px">
{{ count($trigger->conditions) }} {{ count($trigger->conditions) === 1 ? 'warunek' : 'warunków' }}
</td>
<td class="text-muted" style="font-size:12px">
{{ count($trigger->actions) }} {{ count($trigger->actions) === 1 ? 'akcja' : 'akcji' }}
</td>
<td><input type="checkbox" @checked($trigger->enabled) wire:click="toggleEnabled({{ $trigger->id }})"></td>
<td>
<div style="display:flex;gap:6px;justify-content:flex-end">
<button class="btn btn-ghost" type="button" wire:click="editTrigger({{ $trigger->id }})">Edytuj</button>
<button class="btn btn-ghost" type="button" wire:click="removeTrigger({{ $trigger->id }})" wire:confirm="Usunąć wyzwalacz „{{ $trigger->name }}”?">Usuń</button>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<p class="text-muted" style="font-size:13px">Brak wyzwalaczy. Utwórz pierwszy używając przycisku wyżej.</p>
@endif
<div class="hr" style="margin:22px 0"></div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
<div>
<h3 style="margin:0">Szablony e-mail wyzwalaczy</h3>
<p class="text-muted" style="font-size:12.5px;margin:4px 0 0">
Osobne od szablonów w zakładce „Szablony e-mail” (te przypisane na stałe do zdarzeń systemowych) te
tutaj możesz dowolnie dodawać, edytować i usuwać, do wykorzystania w akcji „Wyślij powiadomienie e-mail” wyzwalacza.
</p>
</div>
<button class="btn btn-primary" type="button" wire:click="openTemplateForm" style="flex:none">+ Nowy szablon</button>
</div>
@if ($this->emailTemplates->isNotEmpty())
<div class="table-wrap">
<table class="table">
<thead><tr><th>Nazwa</th><th>Temat</th><th></th></tr></thead>
<tbody>
@foreach ($this->emailTemplates as $template)
<tr wire:key="trigger-template-{{ $template->id }}">
<td style="white-space:nowrap">{{ $template->name }}</td>
<td>{{ $template->subject }}</td>
<td>
<div style="display:flex;gap:6px;justify-content:flex-end">
<button class="btn btn-ghost" type="button" wire:click="editTemplate({{ $template->id }})">Edytuj</button>
<button class="btn btn-ghost" type="button" wire:click="removeTemplate({{ $template->id }})" wire:confirm="Usunąć szablon „{{ $template->name }}”?">Usuń</button>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<p class="text-muted" style="font-size:13px">Brak szablonów. Dodaj pierwszy używając przycisku wyżej.</p>
@endif
@if ($templateFormOpen)
<div class="dialog-backdrop">
<form wire:submit="submitTemplate" class="dialog" style="max-width:560px;max-height:88vh;overflow:auto">
<div class="dialog-title">{{ $editingTemplateId ? 'Edytuj szablon' : 'Nowy szablon' }}</div>
<div class="field">
<label>Nazwa szablonu</label>
<input class="input" wire:model="templateForm.name" placeholder="np. Przypomnienie o braku odpowiedzi">
@error('templateForm.name') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
</div>
<div class="field">
<label>Temat wiadomości</label>
<input class="input" wire:model="templateForm.subject">
@error('templateForm.subject') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
</div>
<div class="field">
<label>Treść</label>
<x-quill-editor wire:key="trigger-template-body-{{ $editingTemplateId ?? 'new' }}" :value="$templateForm['body']" on-change="setTemplateBodyDraft" min-height="160px" />
@error('templateForm.body') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
</div>
<p class="text-muted" style="font-size:12px;margin:0">Dostępne zmienne: {numer}, {imie}, {temat}, {status}, {kategoria}, {priorytet}, {zespol}, {operator}, {link}. Ta treść trafia do wspólnego szablonu-pudełka (zakładka „E-MAIL”) w miejscu {tresc}.</p>
<div class="dialog-actions">
<button type="button" class="btn btn-secondary" wire:click="closeTemplateForm">Anuluj</button>
<button type="submit" class="btn btn-primary">{{ $editingTemplateId ? 'Zapisz' : 'Utwórz' }}</button>
</div>
</form>
</div>
@endif
@if ($formOpen)
<div class="dialog-backdrop">
<form wire:submit="submit" class="dialog" style="max-width:640px;max-height:88vh;overflow:auto">
<div class="dialog-title">{{ $editingId ? 'Edytuj wyzwalacz' : 'Nowy wyzwalacz' }}</div>
<div class="field">
<label>Nazwa</label>
<input class="input" wire:model="form.name" placeholder="np. Priorytet krytyczny → zespół VIP">
@error('form.name') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
</div>
<div class="field">
<label>Zdarzenie</label>
<select class="input" wire:model="form.event">
@foreach ($eventLabels as $key => $label)
<option value="{{ $key }}">{{ $label }}</option>
@endforeach
</select>
</div>
<label class="radio"><input type="checkbox" wire:model="form.enabled" style="position:static;opacity:1;width:auto;height:auto">Aktywny</label>
<div class="hr"></div>
<div style="display:flex;justify-content:space-between;align-items:center">
<label style="font-weight:500">Warunki (wszystkie muszą być spełnione)</label>
<button type="button" class="btn btn-ghost" wire:click="addCondition">+ Dodaj warunek</button>
</div>
@foreach ($form['conditions'] as $i => $condition)
<div wire:key="condition-{{ $i }}" style="display:flex;gap:6px;align-items:flex-start">
<select class="input" style="flex:1" wire:model.live="form.conditions.{{ $i }}.field">
@foreach ($fieldLabels as $key => $label)
<option value="{{ $key }}">{{ $label }}</option>
@endforeach
</select>
<select class="input" style="flex:1" wire:model.live="form.conditions.{{ $i }}.operator">
@foreach ($operatorLabels as $key => $label)
<option value="{{ $key }}">{{ $label }}</option>
@endforeach
</select>
@if (! in_array($condition['operator'], ['is_empty', 'is_not_empty']))
@if (($condition['field'] ?? null) === 'status_key')
<select class="input" style="flex:1" wire:model="form.conditions.{{ $i }}.value">
@foreach ($this->statuses as $status)
<option value="{{ $status->key }}">{{ $status->label }}</option>
@endforeach
</select>
@elseif (($condition['field'] ?? null) === 'priority_key')
<select class="input" style="flex:1" wire:model="form.conditions.{{ $i }}.value">
@foreach ($this->priorities as $priority)
<option value="{{ $priority->key }}">{{ $priority->label }}</option>
@endforeach
</select>
@elseif (($condition['field'] ?? null) === 'team_id')
<select class="input" style="flex:1" wire:model="form.conditions.{{ $i }}.value">
@foreach ($this->teams as $team)
<option value="{{ $team->id }}">{{ $team->name }}</option>
@endforeach
</select>
@elseif (($condition['field'] ?? null) === 'assignee_id')
<select class="input" style="flex:1" wire:model="form.conditions.{{ $i }}.value">
@foreach ($this->operators as $operator)
<option value="{{ $operator->id }}">{{ $operator->name }}</option>
@endforeach
</select>
@elseif (($condition['field'] ?? null) === 'subcategory_id')
<select class="input" style="flex:1" wire:model="form.conditions.{{ $i }}.value">
@foreach ($this->subcategories as $sub)
<option value="{{ $sub->id }}">{{ $sub->category->name }} / {{ $sub->name }}</option>
@endforeach
</select>
@elseif (($condition['field'] ?? null) === 'customer_id')
<select class="input" style="flex:1" wire:model="form.conditions.{{ $i }}.value">
@foreach ($this->customers as $customer)
<option value="{{ $customer->id }}">{{ $customer->name }}</option>
@endforeach
</select>
@else
<input class="input" style="flex:1" wire:model="form.conditions.{{ $i }}.value" placeholder="wartość">
@endif
@else
<div style="flex:1"></div>
@endif
<button type="button" class="btn btn-ghost" style="padding:0 6px" wire:click="removeCondition({{ $i }})" title="Usuń warunek">
<span class="material-symbols-outlined" style="font-size:16px">close</span>
</button>
</div>
@endforeach
@if (empty($form['conditions']))
<p class="text-muted" style="font-size:12px;margin:0">Brak warunków wyzwalacz zadziała za każdym razem, gdy wybrane zdarzenie wystąpi.</p>
@endif
<div class="hr"></div>
<div style="display:flex;justify-content:space-between;align-items:center">
<label style="font-weight:500">Akcje (wykonywane po kolei)</label>
<button type="button" class="btn btn-ghost" wire:click="addAction">+ Dodaj akcję</button>
</div>
@error('form.actions') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
@foreach ($form['actions'] as $i => $action)
<div wire:key="action-{{ $i }}" class="card" style="padding:10px;gap:6px">
<div style="display:flex;gap:6px;align-items:center">
<select class="input" style="flex:1" wire:model.live="form.actions.{{ $i }}.type">
@foreach ($actionTypeLabels as $key => $label)
<option value="{{ $key }}">{{ $label }}</option>
@endforeach
</select>
<button type="button" class="btn btn-ghost" style="padding:0 4px" wire:click="moveActionUp({{ $i }})" title="Przenieś wyżej">
<span class="material-symbols-outlined" style="font-size:16px">arrow_upward</span>
</button>
<button type="button" class="btn btn-ghost" style="padding:0 4px" wire:click="moveActionDown({{ $i }})" title="Przenieś niżej">
<span class="material-symbols-outlined" style="font-size:16px">arrow_downward</span>
</button>
<button type="button" class="btn btn-ghost" style="padding:0 6px" wire:click="removeAction({{ $i }})" title="Usuń akcję">
<span class="material-symbols-outlined" style="font-size:16px">close</span>
</button>
</div>
@if (($action['type'] ?? null) === 'set_status')
<select class="input" wire:model="form.actions.{{ $i }}.value">
@foreach ($this->statuses as $status)
<option value="{{ $status->key }}">{{ $status->label }}</option>
@endforeach
</select>
@elseif (($action['type'] ?? null) === 'set_priority')
<select class="input" wire:model="form.actions.{{ $i }}.value">
@foreach ($this->priorities as $priority)
<option value="{{ $priority->key }}">{{ $priority->label }}</option>
@endforeach
</select>
@elseif (($action['type'] ?? null) === 'set_team')
<select class="input" wire:model="form.actions.{{ $i }}.value">
@foreach ($this->teams as $team)
<option value="{{ $team->id }}">{{ $team->name }}</option>
@endforeach
</select>
@elseif (($action['type'] ?? null) === 'set_assignee')
<select class="input" wire:model="form.actions.{{ $i }}.value">
@foreach ($this->operators as $operator)
<option value="{{ $operator->id }}">{{ $operator->name }}</option>
@endforeach
</select>
@elseif (($action['type'] ?? null) === 'send_notification')
<div style="display:flex;gap:6px">
<select class="input" style="flex:1" wire:model="form.actions.{{ $i }}.recipient">
<option value="client">Zgłaszający</option>
<option value="operator">Przypisany operator</option>
</select>
<select class="input" style="flex:1" wire:model="form.actions.{{ $i }}.email_template_id">
<option value=""> wybierz szablon </option>
@foreach ($this->emailTemplates as $template)
<option value="{{ $template->id }}">{{ $template->name }}</option>
@endforeach
</select>
</div>
@endif
</div>
@endforeach
<div class="dialog-actions">
<button type="button" class="btn btn-secondary" wire:click="closeForm">Anuluj</button>
<button type="submit" class="btn btn-primary">{{ $editingId ? 'Zapisz' : 'Utwórz' }}</button>
</div>
</form>
</div>
@endif
</div>

View File

@@ -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() }} &middot; {{ \App\Support\Rel::format($ticket->updated_at) }}</div>
</div>
<div style="display:flex;gap:6px">

View File

@@ -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">

View File

@@ -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() }} &middot; 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

View File

@@ -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>

View File

@@ -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)

View File

@@ -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>
@@ -172,6 +182,9 @@
@if (in_array('category', $visibleColumns))
<td data-label="Kategoria" style="white-space:nowrap">{{ $t->categoryLabel() }}</td>
@endif
@if (in_array('subcategory', $visibleColumns))
<td data-label="Podkategoria" style="white-space:nowrap">{{ $t->subcategory?->name ?? '—' }}</td>
@endif
@if (in_array('priority', $visibleColumns))
<td data-label="Priorytet"><span style="{{ $t->priorityStyle() }}">{{ $t->priorityLabel() }}</span></td>
@endif
@@ -184,6 +197,12 @@
@if (in_array('assignee', $visibleColumns))
<td data-label="Przypisany" style="white-space:nowrap">{{ $t->assignee?->name ?? 'Nieprzypisane' }}</td>
@endif
@if (in_array('team', $visibleColumns))
<td data-label="Zespół" style="white-space:nowrap">{{ $t->team?->name ?? '—' }}</td>
@endif
@if (in_array('created', $visibleColumns))
<td data-label="Utworzono" style="white-space:nowrap">{{ \App\Support\Rel::format($t->created_at) }}</td>
@endif
</tr>
@endforeach
</tbody>

View File

@@ -4,17 +4,33 @@
<div class="page-pad" style="flex:1;padding:20px 24px;overflow:auto">
<div style="display:flex;flex-direction:column;gap:16px;max-width:1180px;margin:0 auto">
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
<a href="{{ route('operator.queue') }}" wire:navigate class="btn btn-ghost" style="padding:0">&larr; Wróć do listy</a>
<a href="{{ route('operator.queue') }}" wire:navigate class="btn btn-ghost" style="padding:0;margin-right:auto">&larr; Wróć do listy</a>
<button
type="button"
class="btn btn-secondary"
style="gap:6px"
wire:click="toggleWatch"
title="{{ $this->isWatching ? 'Przestań obserwować to zgłoszenie' : 'Obserwuj to zgłoszenie' }}"
>
<span class="material-symbols-outlined" style="font-size:18px">{{ $this->isWatching ? 'star' : 'star_outline' }}</span>
{{ $this->isWatching ? 'Obserwowane' : 'Obserwuj' }}
</button>
{{-- 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>
@@ -25,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
@@ -149,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 }} &middot; {{ \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 }} &middot; {{ \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>
@@ -263,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
@@ -302,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>
@@ -445,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>

View File

@@ -0,0 +1,97 @@
@php
$categoryLabels = [
'new_ticket' => 'Nowe zgłoszenie',
'ticket_update' => 'Aktualizacja zgłoszenia',
'escalation' => 'Zgłoszenie eskalowane',
];
$scopeColumns = [
'scope_mine' => 'Moje zgłoszenia',
'scope_unassigned' => 'Nie przypisany',
'scope_watched' => 'Obserwowane zgłoszenia',
'scope_all' => 'Wszystkie zgłoszenia',
];
@endphp
<div style="flex:1;display:flex;flex-direction:column">
<x-topbar />
<div class="page-pad" style="flex:1;padding:28px;display:flex;flex-direction:column;gap:24px;max-width:920px;width:100%;margin:0 auto;box-sizing:border-box">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap">
<div>
<h3 style="margin:0 0 6px">Powiadomienia</h3>
<p class="text-muted" style="font-size:12.5px;margin:0">Wybierz, o których zgłoszeniach chcesz być informowany dzwoneczkiem w aplikacji, i przy których zdarzeniach dodatkowo wysłać Ci e-mail.</p>
</div>
<a href="{{ auth()->user()->isAdmin() ? route('admin.panel') : route('operator.queue') }}" wire:navigate class="btn btn-ghost" style="padding:0;flex:none">&larr; Wróć</a>
</div>
<div class="card" style="padding:0;overflow:hidden">
<div class="table-wrap">
<table class="table" style="margin:0">
<thead>
<tr>
<th></th>
@foreach ($scopeColumns as $label)
<th style="text-align:center">{{ $label }}</th>
@endforeach
<th style="text-align:center">Informuj również przez e-mail</th>
</tr>
</thead>
<tbody>
@foreach ($categoryLabels as $category => $label)
<tr wire:key="pref-row-{{ $category }}">
<td style="white-space:nowrap">{{ $label }}</td>
@foreach ($scopeColumns as $field => $ignored)
<td style="text-align:center">
<input type="checkbox" @checked($rows[$category][$field]) wire:click="toggle('{{ $category }}', '{{ $field }}')">
</td>
@endforeach
<td style="text-align:center">
<input type="checkbox" @checked($rows[$category]['email']) wire:click="toggle('{{ $category }}', 'email')">
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
<div class="card" style="padding:18px;gap:10px" x-data="{
supported: typeof Notification !== 'undefined',
permission: typeof Notification !== 'undefined' ? Notification.permission : 'unsupported',
enabled: localStorage.getItem('browserNotificationsEnabled') === '1',
async enable() {
if (!this.supported) return;
this.permission = await Notification.requestPermission();
this.enabled = this.permission === 'granted';
localStorage.setItem('browserNotificationsEnabled', this.enabled ? '1' : '0');
},
disable() {
this.enabled = false;
localStorage.setItem('browserNotificationsEnabled', '0');
},
}">
<h4 style="margin:0">Powiadomienia push w przeglądarce</h4>
<p class="text-muted" style="font-size:12px;margin:0">Gdy ta karta jest otwarta, nowe zdarzenia z dzwoneczka mogą dodatkowo pojawić się jako natywne powiadomienie przeglądarki.</p>
<template x-if="!supported">
<span class="text-muted" style="font-size:12px">Ta przeglądarka nie obsługuje powiadomień push.</span>
</template>
<template x-if="supported && permission === 'denied'">
<span style="font-size:12px;color:var(--color-danger)">Powiadomienia zostały zablokowane w ustawieniach przeglądarki.</span>
</template>
<template x-if="supported && permission !== 'denied' && !enabled">
<button type="button" class="btn btn-secondary" style="align-self:flex-start" x-on:click="enable">Włącz powiadomienia push</button>
</template>
<template x-if="supported && enabled">
<div style="display:flex;align-items:center;gap:10px">
<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>Włączone
</div>
<button type="button" class="btn btn-ghost" x-on:click="disable">Wyłącz</button>
</div>
</template>
</div>
</div>
</div>

View File

@@ -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 () {

View File

@@ -34,3 +34,14 @@ Broadcast::channel('ticket.{ticketId}', function ($user, int $ticketId) {
return (in_array('operator', $user->roles ?? []) && $ticket->isVisibleToOperator($user))
|| $ticket->customer_id === $user->id;
});
/**
* Every logged-in user's own private notification stream (bell realtime
* updates + in-tab browser push, see NotificationCreated). Laravel's
* default `App.Models.User.{id}` naming convention is kept verbatim so it
* matches what `$notifiable->notify()` already implies, rather than
* inventing a shorter alias.
*/
Broadcast::channel('App.Models.User.{id}', function ($user, int $id) {
return $user->id === $id;
});

View File

@@ -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));

View File

@@ -10,6 +10,7 @@ use App\Livewire\Operator\NewTicket as OperatorNewTicket;
use App\Livewire\Operator\Queue as OperatorQueue;
use App\Livewire\Operator\Stats as OperatorStats;
use App\Livewire\Operator\TicketShow as OperatorTicketShow;
use App\Livewire\Settings\NotificationPreferences;
use App\Models\Ticket;
use App\Support\Settings;
use Illuminate\Support\Facades\Auth;
@@ -73,3 +74,7 @@ Route::middleware(['auth', 'role:operator'])->prefix('operator')->name('operator
Route::middleware(['auth', 'role:admin'])->prefix('admin')->name('admin.')->group(function () {
Route::get('/', AdminPanel::class)->name('panel');
});
Route::middleware(['auth', 'role:operator,admin'])->prefix('settings')->name('settings.')->group(function () {
Route::get('/notifications', NotificationPreferences::class)->name('notifications');
});

View 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');
});

View 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);
});

View 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');
});

View File

@@ -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']);
});

View 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');
});

View 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');
});

View 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);
});

View 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'));
});

View 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'));
});

View 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'));
});

View File

@@ -37,7 +37,7 @@ test('admin can reset the email footer back to its default, remounting the edito
$admin = adminUser();
$component = Livewire::actingAs($admin)->test(Panel::class)
->call('setTab', 'templates')
->call('setTab', 'email')
->call('saveEmailFooter', 'Coś innego')
->assertSet('emailFooterVersion', 0);
@@ -50,11 +50,11 @@ test('admin can reset the email footer back to its default, remounting the edito
expect(Settings::get('email_footer'))->toBe(Settings::default('email_footer'));
});
test('admin can save the email footer from the Szablony e-mail tab (moved out of Konfiguracja)', function () {
test('admin can save the email footer from the E-MAIL tab', function () {
$admin = adminUser();
Livewire::actingAs($admin)->test(Panel::class)
->call('setTab', 'templates')
->call('setTab', 'email')
->call('saveEmailFooter', '<p>Pozdrawiamy, Zespół Wsparcia</p>')
->assertOk()
->assertSet('emailFooterHtml', '<p>Pozdrawiamy, Zespół Wsparcia</p>');
@@ -66,7 +66,7 @@ test('the live example preview reflects the currently saved footer', function ()
$admin = adminUser();
$component = Livewire::actingAs($admin)->test(Panel::class)
->call('setTab', 'templates')
->call('setTab', 'email')
->call('saveEmailFooter', 'Stopka na żywo');
expect($component->instance()->emailPreviewHtml)->toContain('Stopka na żywo')

View File

@@ -2,6 +2,7 @@
use App\Models\Category;
use App\Models\EmailTemplate;
use App\Models\NotificationPreference;
use App\Models\NotificationSetting;
use App\Models\Team;
use App\Models\User;
@@ -140,7 +141,7 @@ test('an operator reply fires operator_replied once enabled, independent of any
Notification::assertSentOnDemandTimes(TicketNotification::class, 1);
});
test('every member of a team whose subcategory matches a new ticket gets notified once, no duplicates', function () {
test('every operator/admin whose new_ticket preference puts a routed ticket in scope gets notified once, no duplicates', function () {
Notification::fake();
$this->seed();
@@ -152,6 +153,10 @@ test('every member of a team whose subcategory matches a new ticket gets notifie
$memberB = User::query()->create(['name' => 'Jan', 'email' => 'team-notif-b@example.com', 'roles' => ['operator']]);
$team->members()->attach([$memberA->id, $memberB->id]);
// auto_assign_by_category is on by default (seeded), so the ticket's
// team_id actually becomes the VPN team's id — that's what now drives
// who's "in scope" for the default scope_all preference, replacing the
// old separate team-subcategory-routing fan-out.
app(TicketService::class)->create([
'email' => 'client-team-notif@example.com',
'subject' => 'Problem z VPN',
@@ -161,19 +166,53 @@ test('every member of a team whose subcategory matches a new ticket gets notifie
Notification::assertSentTo($memberA, TicketNotification::class);
Notification::assertSentTo($memberB, TicketNotification::class);
// The seeded admin also qualifies: Ticket::isVisibleToOperator() returns
// true unconditionally for admins, and scope_all is the default — this
// is intentional, it's what keeps the one real admin account notified
// about every new ticket without any setup.
Notification::assertSentTo(User::query()->where('email', 'admin@example.com')->firstOrFail(), TicketNotification::class);
// Plus one more: TicketService::create() also fires the pre-existing
// 'ticket_created' trigger, routed anonymously to the guest's e-mail
// since this ticket has no real customer account.
Notification::assertSentTimes(TicketNotification::class, 3);
Notification::assertSentTimes(TicketNotification::class, 4);
});
test('a new ticket with no matching team notifies no operator', function () {
test('an operator outside the ticket\'s team is not notified, even with scope_all left at its default', function () {
Notification::fake();
$this->seed();
$category = Category::query()->create(['name' => 'IT-Pomoc']);
$sub = $category->subcategories()->create(['name' => 'VPN']);
$team = Team::query()->create(['name' => 'Zespół VPN']);
$team->subcategories()->attach($sub->id);
User::query()->create(['name' => 'Ola', 'email' => 'team-notif-a@example.com', 'roles' => ['operator']])
->teams()->attach($team->id);
$outsider = User::query()->create(['name' => 'Niepowiązany', 'email' => 'unrelated-op@example.com', 'roles' => ['operator']]);
app(TicketService::class)->create([
'email' => 'client-team-notif-2@example.com',
'subject' => 'Problem z VPN',
'body' => 'Nie mogę się połączyć.',
'subcategory_id' => $sub->id,
], null);
// The ticket routed to "Zespół VPN"; $outsider belongs to no team, so
// Ticket::isVisibleToOperator() (which scope_all delegates to) is false
// for them even though their preference defaults to scope_all=true.
Notification::assertNotSentTo($outsider, TicketNotification::class);
});
test('an operator who turns off scope_all for new tickets stops receiving them, even for a ticket they could otherwise see', function () {
Notification::fake();
$this->seed();
$category = Category::query()->create(['name' => 'Bez zespołu']);
$sub = $category->subcategories()->create(['name' => 'Inne']);
$operator = User::query()->create(['name' => 'Niepowiązany', 'email' => 'unrelated-op@example.com', 'roles' => ['operator']]);
$operator = User::query()->create(['name' => 'Cichy', 'email' => 'opted-out-op@example.com', 'roles' => ['operator']]);
NotificationPreference::query()->create(array_merge(
['user_id' => $operator->id, 'event_category' => 'new_ticket'],
array_merge(NotificationPreference::DEFAULTS['new_ticket'], ['scope_all' => false])
));
app(TicketService::class)->create([
'email' => 'client-no-team@example.com',

View 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"');
});

View 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();
});

View File

@@ -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', 'config')
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;
}
});

View File

@@ -0,0 +1,95 @@
<?php
use App\Models\NotificationPreference;
use App\Models\NotificationSetting;
use App\Notifications\TicketNotification;
use App\Services\TicketService;
use Illuminate\Support\Facades\Notification;
test('an assignee with scope_mine enabled for escalations is not double-notified on top of the direct sla_breached send', function () {
Notification::fake();
$this->seed();
NotificationSetting::query()->where('trigger_key', 'sla_breached')->update(['enabled' => true]);
$ticket = makeTicket();
$assignee = operatorUser('assignee-sla@example.com');
$ticket->update(['assignee_id' => $assignee->id]);
app(TicketService::class)->notify($ticket->fresh(), 'sla_breached');
// NotificationPreference::DEFAULTS['escalation'] has scope_mine=true, so
// without the notify()->notifyStaffForCategory() dedup, the assignee
// would receive this twice: once as the fixed NotificationSetting
// recipient, once again from the scope_mine fan-out.
Notification::assertSentToTimes($assignee, TicketNotification::class, 1);
});
test('a staff member with the e-mail column off for an event still gets the bell but not a mail', function () {
Notification::fake();
$this->seed();
$operator = operatorUser('bell-only@example.com');
NotificationPreference::query()->create(array_merge(
['user_id' => $operator->id, 'event_category' => 'ticket_update'],
array_merge(NotificationPreference::DEFAULTS['ticket_update'], ['scope_all' => true, 'email' => false])
));
NotificationSetting::query()->where('trigger_key', 'priority_changed')->update(['enabled' => true]);
$ticket = makeTicket();
app(TicketService::class)->setPriority($ticket, 'high');
Notification::assertSentTo($operator, TicketNotification::class, function ($notification, $channels) {
return $channels === ['database'];
});
});
test('a staff member with the e-mail column on for an event gets both the bell and a mail', function () {
Notification::fake();
$this->seed();
$operator = operatorUser('bell-and-mail@example.com');
NotificationPreference::query()->create(array_merge(
['user_id' => $operator->id, 'event_category' => 'ticket_update'],
array_merge(NotificationPreference::DEFAULTS['ticket_update'], ['scope_all' => true, 'email' => true])
));
NotificationSetting::query()->where('trigger_key', 'priority_changed')->update(['enabled' => true]);
$ticket = makeTicket();
app(TicketService::class)->setPriority($ticket, 'high');
Notification::assertSentTo($operator, TicketNotification::class, function ($notification, $channels) {
return $channels === ['mail', 'database'];
});
});
test('the operator performing the action is never notified about their own change', function () {
Notification::fake();
$this->seed();
NotificationSetting::query()->where('trigger_key', 'priority_changed')->update(['enabled' => true]);
$actor = operatorUser('actor@example.com');
$this->actingAs($actor);
$ticket = makeTicket();
app(TicketService::class)->setPriority($ticket, 'high');
Notification::assertNotSentTo($actor, TicketNotification::class);
});
test('disabling a trigger instance-wide silences the staff fan-out too, regardless of any individual preference', function () {
Notification::fake();
$this->seed();
$operator = operatorUser('kill-switch@example.com');
NotificationPreference::query()->create(array_merge(
['user_id' => $operator->id, 'event_category' => 'ticket_update'],
array_merge(NotificationPreference::DEFAULTS['ticket_update'], ['scope_all' => true, 'email' => true])
));
NotificationSetting::query()->where('trigger_key', 'priority_changed')->update(['enabled' => false]);
$ticket = makeTicket();
app(TicketService::class)->setPriority($ticket, 'high');
Notification::assertNotSentTo($operator, TicketNotification::class);
});

View File

@@ -0,0 +1,70 @@
<?php
use App\Livewire\Settings\NotificationPreferences;
use App\Models\NotificationPreference;
use App\Models\User;
use Livewire\Livewire;
test('a client cannot open the notification preferences page', function () {
$client = User::query()->create(['name' => 'Client', 'email' => 'client-np@example.com', 'roles' => ['client']]);
Livewire::actingAs($client)->test(NotificationPreferences::class)->assertStatus(403);
});
test('an operator with no saved preferences sees the built-in defaults', function () {
$operator = operatorUser();
Livewire::actingAs($operator)->test(NotificationPreferences::class)
->assertViewHas('rows', [
'new_ticket' => NotificationPreference::DEFAULTS['new_ticket'],
'ticket_update' => NotificationPreference::DEFAULTS['ticket_update'],
'escalation' => NotificationPreference::DEFAULTS['escalation'],
]);
});
test('toggling a checkbox persists just that one field and leaves the rest at their defaults', function () {
$operator = operatorUser();
Livewire::actingAs($operator)->test(NotificationPreferences::class)
->call('toggle', 'ticket_update', 'scope_all')
->assertOk();
$row = NotificationPreference::query()->where('user_id', $operator->id)->where('event_category', 'ticket_update')->firstOrFail();
expect($row->scope_all)->toBeTrue()
->and($row->scope_mine)->toBe(NotificationPreference::DEFAULTS['ticket_update']['scope_mine'])
->and($row->email)->toBe(NotificationPreference::DEFAULTS['ticket_update']['email']);
});
test('toggling twice flips the field back off', function () {
$operator = operatorUser();
Livewire::actingAs($operator)->test(NotificationPreferences::class)
->call('toggle', 'escalation', 'email')
->call('toggle', 'escalation', 'email');
$row = NotificationPreference::query()->where('user_id', $operator->id)->where('event_category', 'escalation')->firstOrFail();
expect($row->email)->toBe(NotificationPreference::DEFAULTS['escalation']['email']);
});
test('an unknown category or field is rejected', function () {
$operator = operatorUser();
Livewire::actingAs($operator)->test(NotificationPreferences::class)
->call('toggle', 'not_a_category', 'scope_all')
->assertStatus(404);
});
test('NotificationPreference::rowFor falls back to defaults when nothing is saved, and to the saved row once toggled', function () {
$operator = operatorUser();
expect(NotificationPreference::rowFor($operator, 'new_ticket'))->toBe(NotificationPreference::DEFAULTS['new_ticket']);
NotificationPreference::query()->create(array_merge(
['user_id' => $operator->id, 'event_category' => 'new_ticket'],
array_merge(NotificationPreference::DEFAULTS['new_ticket'], ['scope_all' => false])
));
expect(NotificationPreference::rowFor($operator, 'new_ticket')['scope_all'])->toBeFalse();
});

View 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');
});

View File

@@ -58,8 +58,8 @@ test('columns can be hidden and shown again, but at least one must stay visible'
$component->call('toggleColumn', 'sla')
->assertSet('visibleColumns', fn ($cols) => in_array('sla', $cols, true));
// Hide every column except one, then try to hide the last one too.
foreach (array_keys((new \App\Livewire\Operator\Queue)->columnDefs()) as $key) {
// Hide every visible-by-default column except one, then try to hide the last one too.
foreach ((new Queue)->visibleColumns as $key) {
if ($key !== 'number') {
$component->call('toggleColumn', $key);
}

View 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]);
});

Some files were not shown because too many files have changed in this diff Show More