Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1df697afce | |||
| 313e01ad24 | |||
| 0d116dfd98 | |||
| 63178b366e | |||
| ab90abcaa3 | |||
| 0b06687ea1 | |||
| def7c70887 |
484
ARCHITECTURE.md
484
ARCHITECTURE.md
@@ -63,13 +63,94 @@ queue + unassigned + anything assigned to them, an admin sees everything), and
|
|||||||
work-timer tracking (`timerElapsedSeconds()`). Keep ticket-shaped logic here
|
work-timer tracking (`timerElapsedSeconds()`). Keep ticket-shaped logic here
|
||||||
rather than spreading it across Livewire components.
|
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
|
## Roles & permissions
|
||||||
|
|
||||||
Roles are a plain array on the user (`$user->roles`), not a separate pivot-backed
|
`$user->roles` reads/writes as a plain array (`['client', 'operator']`), but
|
||||||
package — checked via `EnsureRole` at the route level. Every account gets
|
it's a **virtual attribute** (`User::getAttribute()`/`setAttribute()`
|
||||||
`client` by default (`App\Ldap\Handlers\AssignDefaultRole` for LDAP-provisioned
|
overrides) backed by a real `roles` lookup table + `role_user` pivot, not an
|
||||||
accounts); staff switch areas via the header role switcher, but always land on
|
actual column — assigning `'roles' => [...]` on create/update stashes the keys
|
||||||
`/client` first after login.
|
until the model's `saved` hook resolves them against `roles.key` and syncs the
|
||||||
|
pivot. This matters for tests/seeders: a role key must exist in the `roles`
|
||||||
|
table *before* it can be assigned this way, or the assignment silently becomes
|
||||||
|
a no-op (`Tests\TestCase::setUp()` seeds the 3 fixed roles for exactly this
|
||||||
|
reason, since almost every test creates a role-bearing user). Checked via
|
||||||
|
`EnsureRole` at the route level. Every account gets `client` by default
|
||||||
|
(`App\Ldap\Handlers\AssignDefaultRole` for LDAP-provisioned accounts); staff
|
||||||
|
switch areas via the header role switcher, but always land on `/client` first
|
||||||
|
after login.
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
@@ -91,7 +172,8 @@ attributes.
|
|||||||
`App\Support\Settings` (`app/Support/Settings.php`) is a cached key/value reader
|
`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,
|
over the `settings` table, with hardcoded defaults for every key (company name,
|
||||||
LDAP/SMTP connection details, attachment limits, session lifetime, timezone,
|
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
|
`AppServiceProvider::boot()` re-applies the relevant subset of it over
|
||||||
`config()` on every request — meaning **`Setting` rows win over `.env`** for
|
`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
|
LDAP, mail, session lifetime and timezone once they're non-empty. This is by
|
||||||
@@ -100,6 +182,19 @@ source of the "seeded placeholder overrides real `.env` values" gotcha
|
|||||||
documented in [install.md](install.md) — anything touching LDAP/mail/session/
|
documented in [install.md](install.md) — anything touching LDAP/mail/session/
|
||||||
timezone config should go through `Settings`, not raw `config()`/`.env` reads.
|
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
|
## Notifications
|
||||||
|
|
||||||
`TicketService::notify(Ticket $ticket, string $triggerKey)` is the single
|
`TicketService::notify(Ticket $ticket, string $triggerKey)` is the single
|
||||||
@@ -118,15 +213,232 @@ since one account can hold both — this decides whether the ticket link (both
|
|||||||
the e-mail body and the in-app notification's `url`) points into `/client/...`
|
the e-mail body and the in-app notification's `url`) points into `/client/...`
|
||||||
or `/operator/...`.
|
or `/operator/...`.
|
||||||
|
|
||||||
|
## Real-time broadcasting (Reverb)
|
||||||
|
|
||||||
|
Two private channels, authorized in `routes/channels.php`:
|
||||||
|
|
||||||
|
- **`operator.queue`** — one shared channel for every operator/admin (not
|
||||||
|
scoped per team/ticket), so the receiving `Operator\Queue` component just
|
||||||
|
re-queries through its own already-correct `Ticket::scopeVisibleToOperator()`
|
||||||
|
on any event instead of the channel-auth callback needing to duplicate that
|
||||||
|
ACL logic. Payloads stay minimal (ticket id + reason + actor id) for the
|
||||||
|
same reason.
|
||||||
|
- **`ticket.{id}`** — per-ticket channel for the message thread and detail
|
||||||
|
changes, authorized for an operator with `isVisibleToOperator()` **or** the
|
||||||
|
ticket's own customer (OR, not else-if — the one real account in this app
|
||||||
|
holds both roles at once). An internal note broadcasts on the same channel
|
||||||
|
a client can subscribe to, but the payload never carries the message body —
|
||||||
|
each side's Livewire component only ever re-queries whatever its own
|
||||||
|
already-authorized computed property returns, so there's nothing to leak.
|
||||||
|
|
||||||
|
Two events, both `App\Events\TicketQueueChanged` (broadcasts on **both**
|
||||||
|
channels above — a status/priority/team/assignee change needs to reach a
|
||||||
|
client watching their own ticket too) and `App\Events\TicketMessagePosted`
|
||||||
|
(broadcasts on `ticket.{id}` only). Both implement `ShouldBroadcastNow`, not
|
||||||
|
`ShouldBroadcast` — this app runs with no queue worker by design (see
|
||||||
|
`TicketNotification`), so broadcasting happens synchronously within the
|
||||||
|
request like everything else here. `TicketService` dispatches both from every
|
||||||
|
ticket-mutating method (create/setStatus/setPriority/setAssignee/setTeam/
|
||||||
|
operatorReply/operatorNote/clientReply/apiMessage/merge); the two ad hoc
|
||||||
|
delete call sites (`Operator\Queue::confirmDeleteSelected()`,
|
||||||
|
`Operator\TicketShow::confirmDeleteTicket()`) dispatch `TicketQueueChanged`
|
||||||
|
directly since there's no `TicketService::delete()` to hook into.
|
||||||
|
|
||||||
|
Browser side, `resources/js/echo.js` bridges Reverb events into plain
|
||||||
|
Livewire events (`Livewire.dispatch('queue-changed', ...)` /
|
||||||
|
`'ticket-message-posted'`) rather than using the `#[On('echo-private:...')]`
|
||||||
|
attribute directly — version-agnostic, and each Livewire component just
|
||||||
|
declares a plain `#[On(...)]` listener that no-ops if the payload's `actorId`
|
||||||
|
matches the viewer's own id (self-echo suppression) or the ticket id doesn't
|
||||||
|
match the component's own ticket. Two easy-to-reintroduce bugs to know about
|
||||||
|
if "nothing updates live" ever comes back:
|
||||||
|
|
||||||
|
1. **CSRF on `/broadcasting/auth`.** Echo's private-channel subscription
|
||||||
|
POSTs there under the app's normal CSRF middleware; the `Echo` constructor
|
||||||
|
must pass `auth.headers['X-CSRF-TOKEN']` (read from the `<meta
|
||||||
|
name="csrf-token">` tag in `layouts/app.blade.php`) or every subscription
|
||||||
|
attempt is silently rejected.
|
||||||
|
2. **Script load order.** `resources/js/app.js` (which imports `echo.js`)
|
||||||
|
loads via `@vite` as `type="module"`, which the HTML spec defers until
|
||||||
|
after the document is parsed — meaning Livewire's own bootstrap script
|
||||||
|
(`@livewireScripts`, a plain synchronous `<script>` near the end of
|
||||||
|
`<body>`) has already run by the time `echo.js` executes. Don't gate
|
||||||
|
anything in `echo.js` behind `document.addEventListener('livewire:init',
|
||||||
|
...)` — that event fires as part of Livewire's own (earlier) script, so a
|
||||||
|
listener registered this late permanently misses it. The one place this
|
||||||
|
still matters is the per-ticket subscription triggered from a Livewire
|
||||||
|
`@script` block in the ticket-show views, which can run before or after
|
||||||
|
`echo.js` depending on exactly when Livewire processes it — it queues the
|
||||||
|
ticket id onto `window.__pendingTicketChannelIds` if `echo.js` hasn't
|
||||||
|
defined `window.subscribeToTicketChannel` yet, and `echo.js` flushes that
|
||||||
|
queue once it has.
|
||||||
|
|
||||||
|
As a defense against a dropped websocket connection (backgrounded tab,
|
||||||
|
network blip), the operator queue and both ticket-detail views also poll
|
||||||
|
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
|
## SLA
|
||||||
|
|
||||||
`SlaRule` holds per-priority response/resolution targets in minutes. The
|
`SlaRule` holds per-priority response/resolution targets in minutes. The
|
||||||
scheduled command `tickets:check-sla-breaches` (registered in
|
scheduled command `tickets:check-sla-breaches` (registered in
|
||||||
`routes/console.php`, run every 15 minutes via `schedule:run`) flags overdue
|
`routes/console.php`, default every 15 minutes, interval admin-configurable —
|
||||||
tickets and can notify the assigned operator — see [install.md](install.md) for
|
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
|
why this requires an external cron entry (the Docker image ships no
|
||||||
cron/supervisor of its own).
|
cron/supervisor of its own).
|
||||||
|
|
||||||
|
## SLA automation rules
|
||||||
|
|
||||||
|
`AutomationRule` (label, `condition_minutes`, optional `scope_priority_key`/
|
||||||
|
`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` (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
|
||||||
|
use, so the automated change gets the same history entry, notification, and
|
||||||
|
broadcast for free. Idempotency is a per-(rule, ticket) row in
|
||||||
|
`automation_rule_ticket_logs`, cleared by `TicketService` whenever the silence
|
||||||
|
that triggered it is broken (a fresh `clientReply()`) or the ticket
|
||||||
|
closes/reopens (`setStatus()`) — so a rule can fire again after a new period
|
||||||
|
of silence instead of being permanently latched. Multiple matching rules on
|
||||||
|
the same ticket in the same run all fire independently, in `id` order; a rule
|
||||||
|
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
|
## API
|
||||||
|
|
||||||
`routes/api.php` + `app/Http/Controllers/Api/` expose a small ability-scoped REST
|
`routes/api.php` + `app/Http/Controllers/Api/` expose a small ability-scoped REST
|
||||||
@@ -138,26 +450,140 @@ tighter per-IP limit for unauthenticated requests
|
|||||||
generated by L5-Swagger at `/admin/api-docs`; there is no static Markdown API
|
generated by L5-Swagger at `/admin/api-docs`; there is no static Markdown API
|
||||||
reference in-repo.
|
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
|
## BookStack integration
|
||||||
|
|
||||||
`App\Services\BookStackClient` is the only outbound HTTP client in the
|
`App\Services\BookStackClient` is one of two outbound HTTP clients in the
|
||||||
codebase (Laravel's `Http` facade) — everything else here only ever receives
|
codebase (Laravel's `Http` facade), alongside `AiClient` above — everything
|
||||||
requests. It's entirely `Settings`-driven, no `.env`/`config()` involved:
|
else here only ever receives requests. It's entirely `Settings`-driven, no
|
||||||
`bookstack_enabled`, `bookstack_base_url`, `bookstack_token_id`/
|
`.env`/`config()` involved: `bookstack_enabled`, `bookstack_base_url`,
|
||||||
`bookstack_token_secret` (encrypted, same as the LDAP/SMTP passwords),
|
`bookstack_token_id`/`bookstack_token_secret` (encrypted, same as the
|
||||||
`bookstack_verify_ssl`, `bookstack_search_types` ('both'|'page'|'book'), and
|
LDAP/SMTP passwords), `bookstack_verify_ssl`, and **two independent**
|
||||||
**two independent** allow-lists of BookStack shelf IDs —
|
allow-lists of BookStack shelf IDs — `bookstack_allowed_shelf_ids_creation`
|
||||||
`bookstack_allowed_shelf_ids_creation` (ticket-wizard suggestions) and
|
(ticket-wizard suggestions) and `bookstack_allowed_shelf_ids_ticket_view`
|
||||||
`bookstack_allowed_shelf_ids_ticket_view` (the operator's sidebar on an
|
(the operator's sidebar on an existing ticket) — `search()` takes a
|
||||||
existing ticket) — `search()` takes a `$context` (`CONTEXT_CREATION` /
|
`$context` (`CONTEXT_CREATION` / `CONTEXT_TICKET_VIEW`) that selects which
|
||||||
`CONTEXT_TICKET_VIEW`) that selects which one applies. **An empty allow-list
|
one applies. **An empty allow-list means "search nothing"**, not "search
|
||||||
means "search nothing"**, not "search everything" — nothing is ever
|
everything" — nothing is ever suggested until an admin explicitly opts
|
||||||
suggested until an admin explicitly opts shelves in, independently per
|
shelves in, independently per context. BookStack has no "which shelf is this
|
||||||
context. BookStack has no "which shelf is this book on" field in its own
|
book on" field in its own search response, so `BookStackClient` fetches
|
||||||
search response, so `BookStackClient` fetches `/api/shelves` +
|
`/api/shelves` + `/api/shelves/{id}` once (cached 30 min) into a
|
||||||
`/api/shelves/{id}` once (cached 30 min) into a shelf→book-ids map, used both
|
shelf→book-ids map, used both to resolve the allow-list to book IDs and to
|
||||||
to resolve the allow-list to book IDs and to build the "Shelf > Book"
|
build the "Shelf > Book" breadcrumb shown next to each suggestion. Per-query
|
||||||
breadcrumb shown next to each suggestion. Per-query search results are cached
|
search results are cached 10 minutes, keyed on the query text **and** the
|
||||||
10 minutes, keyed on the query text **and** the active allow-list, so toggling
|
active allow-list, so toggling which shelves are allowed is reflected
|
||||||
which shelves are allowed is reflected immediately instead of serving a
|
immediately instead of serving a pre-change result for up to 10 minutes.
|
||||||
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.
|
||||||
|
|
||||||
|
## 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 — never live-called from the ticket
|
||||||
|
page itself, only ever displaying whatever the scheduled command last
|
||||||
|
computed). Regenerates whenever a ticket's latest message postdates its
|
||||||
|
last summary — deliberately compared against `ticket_messages.created_at`,
|
||||||
|
not `tickets.updated_at` (which also changes on unrelated actions like a
|
||||||
|
status/priority edit, which would otherwise trigger spurious
|
||||||
|
re-summarization on every tick for an active ticket). Unlike the triage
|
||||||
|
service, a malformed AI response here leaves the previous summary
|
||||||
|
untouched rather than stamping "done" — the ticket stays in the "stale"
|
||||||
|
set and gets retried next run, since this feature is meant to keep
|
||||||
|
refreshing indefinitely, not run once. The system prompt is
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|||||||
284
CHANGELOG.md
284
CHANGELOG.md
@@ -3,6 +3,290 @@
|
|||||||
All notable changes to this project are documented in this file. Format loosely
|
All notable changes to this project are documented in this file. Format loosely
|
||||||
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
|
- **Real-time updates (Laravel Reverb)** — the operator ticket queue now
|
||||||
|
updates live: new tickets appear, status/priority/team/assignee changes
|
||||||
|
and new replies re-sort/refresh the affected row, and closed/deleted/
|
||||||
|
reassigned-away tickets disappear, all without a manual refresh. The
|
||||||
|
ticket message thread is now "live chat": a reply from either side
|
||||||
|
appears for the other party instantly. Clients also see status/priority/
|
||||||
|
team/assignee changes and new history entries on their own ticket live.
|
||||||
|
Backed by two private channels (`operator.queue`, `ticket.{id}`) and two
|
||||||
|
broadcast events (`TicketQueueChanged`, `TicketMessagePosted`), sent
|
||||||
|
synchronously (no queue worker needed, consistent with how e-mail
|
||||||
|
notifications already work in this app).
|
||||||
|
- **Periodic fallback refresh** — since a websocket connection can drop
|
||||||
|
silently (backgrounded tab, network blip), the operator queue and both
|
||||||
|
ticket-detail views also poll themselves every 30–60 seconds regardless
|
||||||
|
of broadcasting, with a small visible countdown badge so it's clear the
|
||||||
|
page is still refreshing on its own.
|
||||||
|
- **SLA automation rules** (Admin > Automatyzacja SLA) — configurable rules
|
||||||
|
that act on a ticket after N minutes of customer silence (optionally
|
||||||
|
scoped to a priority/category/team): change priority, status, team, or
|
||||||
|
assignee. Reuses the same `TicketService` setters a manual operator
|
||||||
|
action would, so automated changes get the same history entry,
|
||||||
|
notification, and (now) live broadcast as a human doing it. A new
|
||||||
|
scheduled command (`automation:run-rules`, every 15 minutes) evaluates
|
||||||
|
all enabled rules; each rule only fires once per ticket until a fresh
|
||||||
|
reply or a close/reopen resets it.
|
||||||
|
- **New notification: ticket landed in your team** — every operator on a
|
||||||
|
team whose subcategories match a newly created ticket now gets notified
|
||||||
|
(enabled by default; toggle like any other trigger in Admin > Szablony
|
||||||
|
e-mail).
|
||||||
|
- **BookStack knowledge-base sidebar on the client's own ticket view** —
|
||||||
|
previously only shown to operators; clients now see the same
|
||||||
|
category/subcategory-matched suggestions on their ticket page that they
|
||||||
|
saw while creating it.
|
||||||
|
- **Operator ticket view / client ticket view now show the assigned
|
||||||
|
operator and team** in the "Status i priorytet" card (client side).
|
||||||
|
- **Stats dashboard**: new breakdowns — tickets by subcategory, CSAT
|
||||||
|
average by team and by operator, top 10 clients by ticket volume (+ one
|
||||||
|
"Goście" bucket for guest submissions), and a client × subcategory
|
||||||
|
cross-tab (top 10 clients × top 5 subcategories, rest folded into
|
||||||
|
"Inne"). The whole dashboard is now organized into labeled sections
|
||||||
|
(Podsumowanie / Rozkład zgłoszeń / Obciążenie / Klienci / Ocena obsługi /
|
||||||
|
Trend) instead of one flat wall of cards.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- BookStack suggestions (ticket-creation wizards, guest landing page,
|
||||||
|
operator/client ticket views) now load in a beat after the page's first
|
||||||
|
paint (`wire:init`) instead of blocking the initial render on BookStack's
|
||||||
|
API response.
|
||||||
|
- The notification bell shows **unread notifications only** — reading one
|
||||||
|
(by clicking it or "mark all as read") now removes it from the list
|
||||||
|
instead of just dimming it.
|
||||||
|
- The client ticket-view page is now the same width as the operator's
|
||||||
|
(1180px, up from 920px) — the main message-thread column is unaffected.
|
||||||
|
- Mobile: the theme/notifications/profile-menu dropdowns in the top nav
|
||||||
|
now expand to the full screen width below 640px instead of a fixed
|
||||||
|
narrow width that could overflow off-screen.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Two bugs that silently disabled all real-time updates from the moment
|
||||||
|
Reverb was first wired in: (1) the Echo client never sent a CSRF token
|
||||||
|
when authorizing private channels, so every subscription attempt was
|
||||||
|
rejected by the app's own CSRF middleware; (2) the Echo setup script
|
||||||
|
(loaded as a deferred ES module) raced against Livewire's own
|
||||||
|
synchronously-loaded bootstrap script and could miss the `livewire:init`
|
||||||
|
event entirely, silently skipping the operator-queue subscription.
|
||||||
|
- The `mariadb` container's healthcheck was failing (`Access denied ...
|
||||||
|
using password: NO`) because its auto-generated credentials file had
|
||||||
|
world-writable permissions (an artifact of this stack's NFS-backed bind
|
||||||
|
mount) and MariaDB's client refuses to read credentials from a file
|
||||||
|
anyone can modify — the healthcheck now passes.
|
||||||
|
- Several pre-existing test-suite gaps found while working in this area:
|
||||||
|
`User::roles` (a virtual attribute backed by the `roles`/`role_user`
|
||||||
|
pivot, not a plain column) needs the `roles` table seeded before
|
||||||
|
assigning a role by key — now seeded automatically for every test in
|
||||||
|
`Tests\TestCase`. A handful of other tests were missing
|
||||||
|
`NotificationSetting`/`ReplyQuickAction` seed data, asserting a stale set
|
||||||
|
of default-enabled notification triggers, or using the wrong Notification
|
||||||
|
Fake assertion for a real (non-guest) recipient.
|
||||||
|
|
||||||
## [1.1.0] - 2026-07-22
|
## [1.1.0] - 2026-07-22
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
41
CLAUDE.md
41
CLAUDE.md
@@ -60,6 +60,47 @@ with no rebuild or restart:
|
|||||||
view:clear` to flush any root-owned compiled views before ending the
|
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.
|
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
|
## Apache `/icons/` alias trap
|
||||||
|
|
||||||
The stock `php:apache` image enables `mods-enabled/alias.conf`, which defines
|
The stock `php:apache` image enables `mods-enabled/alias.conf`, which defines
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ RUN apt-get update && apt-get install -y \
|
|||||||
unzip \
|
unzip \
|
||||||
git \
|
git \
|
||||||
libldap2-dev \
|
libldap2-dev \
|
||||||
&& docker-php-ext-install curl mysqli pdo pdo_mysql ldap zip
|
&& docker-php-ext-install curl mysqli pdo pdo_mysql ldap zip pcntl posix
|
||||||
|
|
||||||
# Kopiowanie Composera z oficjalnego obrazu
|
# Kopiowanie Composera z oficjalnego obrazu
|
||||||
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||||
|
|||||||
150
README.md
150
README.md
@@ -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 |
|
| Client | `/client` | `client` | Submit tickets, track status, reply, see resolution |
|
||||||
| Operator | `/operator` | `operator` | Work the ticket queue, reply/resolve, see team statistics |
|
| 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
|
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
|
accounts), and always lands on `/client` first after login regardless of what other
|
||||||
@@ -29,27 +29,65 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
|
|||||||
- **SLA** — per-priority response/resolution time targets; a scheduled command
|
- **SLA** — per-priority response/resolution time targets; a scheduled command
|
||||||
(`tickets:check-sla-breaches`, every 15 min) flags overdue tickets and can notify
|
(`tickets:check-sla-breaches`, every 15 min) flags overdue tickets and can notify
|
||||||
the assigned operator.
|
the assigned operator.
|
||||||
|
- **SLA automation rules** (Admin > Automatyzacja SLA) — configurable rules that
|
||||||
|
change a ticket's priority/status/team/assignee after N minutes of customer
|
||||||
|
silence (optionally scoped to a priority/category/team), evaluated every 15
|
||||||
|
minutes (`automation:run-rules`). Reuses the same `TicketService` setters a
|
||||||
|
manual operator action would, so an automated change gets the same history
|
||||||
|
entry, notification, and live broadcast as a human doing it; each rule fires
|
||||||
|
once per ticket until a fresh customer reply or a close/reopen resets it.
|
||||||
|
- **Real-time updates** — the operator ticket queue and both ticket-detail views
|
||||||
|
(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, 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
|
- **Categories & custom fields** — admin-defined categories/subcategories, each with
|
||||||
its own set of custom fields (text/textarea/select/checkbox/date/number) and an
|
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
|
- **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
|
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
|
- **Templates** — canned response snippets for the reply box, admin-configurable
|
||||||
"quick actions" (send + transition status in one click), and HTML e-mail
|
"quick actions" (send + transition status in one click), and HTML e-mail
|
||||||
templates for every ticket lifecycle event (created, status/priority/category/
|
templates for every ticket lifecycle event (created, status/priority/category/
|
||||||
team changed, closed, operator replied, SLA breached), each independently
|
team changed, closed, operator replied, SLA breached), each independently
|
||||||
enable/disable-able.
|
enable/disable-able.
|
||||||
- **Operator statistics** (`/operator/stats`) — filterable dashboard (date range,
|
- **Operator statistics** (`/operator/stats`) — filterable dashboard (date range,
|
||||||
team, priority, category, assignee) with KPI tiles (volume, SLA breach rate,
|
team, priority, category, assignee) organized into sections: KPI tiles (volume,
|
||||||
average first-response/resolution time) and breakdowns by status, priority,
|
SLA breach rate, average first-response/resolution time, CSAT); breakdowns by
|
||||||
category, team and operator workload, plus a daily created-vs-closed trend.
|
status, priority, category and subcategory; team/operator workload; top 10
|
||||||
|
clients by ticket volume plus a client × subcategory cross-tab (top 5
|
||||||
|
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,
|
- **Branding & config** — company name/logo/favicon/accent color, login notice,
|
||||||
e-mail layout/footer, LDAP connection + user sync, SMTP connection, attachment
|
e-mail layout/footer, SMTP connection (Admin > E-MAIL), attachment limits,
|
||||||
limits, session lifetime, timezone — all editable from Admin > Konfiguracja.
|
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`,
|
- **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
|
`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.
|
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`,
|
- **REST API** (`/api/v1/...`, Sanctum token auth, ability-scoped: `tickets:read`,
|
||||||
`tickets:write`, `dictionaries:read`, `users:read`) for tickets/messages/users/
|
`tickets:write`, `dictionaries:read`, `users:read`) for tickets/messages/users/
|
||||||
categories/statuses/priorities/teams — issued via admin-managed API clients.
|
categories/statuses/priorities/teams — issued via admin-managed API clients.
|
||||||
@@ -57,9 +95,34 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
|
|||||||
- **PWA** — installable manifest + icons for the client-facing area.
|
- **PWA** — installable manifest + icons for the client-facing area.
|
||||||
- **In-app notifications** — a bell in the top bar (client/operator/admin areas)
|
- **In-app notifications** — a bell in the top bar (client/operator/admin areas)
|
||||||
backed by Laravel's database notification channel, alongside the existing
|
backed by Laravel's database notification channel, alongside the existing
|
||||||
e-mail notifications (same per-trigger enable toggle drives both).
|
e-mail notifications (same per-trigger enable toggle drives both); shows
|
||||||
- **Attachments** — drag-and-drop upload (in addition to the file picker) and
|
unread notifications only — reading one removes it from the list. Updates
|
||||||
inline image thumbnails in the message thread instead of a plain download link.
|
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 1–5 stars (+ optional
|
- **Customer satisfaction (CSAT)** — clients rate a ticket 1–5 stars (+ optional
|
||||||
comment) once it's closed; average/response-rate surfaced as a KPI on the
|
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.
|
operator stats dashboard, with a link in the "ticket closed" e-mail.
|
||||||
@@ -73,29 +136,58 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
|
|||||||
- **BookStack knowledge-base integration** *(optional, off by default)* —
|
- **BookStack knowledge-base integration** *(optional, off by default)* —
|
||||||
suggests relevant BookStack articles by category/subcategory while a ticket
|
suggests relevant BookStack articles by category/subcategory while a ticket
|
||||||
is being created, and in a separate sidebar panel on an existing ticket for
|
is being created, and in a separate sidebar panel on an existing ticket for
|
||||||
operators (with a copy-link button). Configured entirely from Admin >
|
both operators and clients (with a copy-link button for operators). Loads in
|
||||||
Konfiguracja: connection + API token, optional SSL-verification bypass for
|
after the page's first paint rather than blocking it. Configured entirely
|
||||||
self-signed instances, page/book search-type filter, and two independent
|
from Admin > Integracje: connection + API token, optional SSL-verification
|
||||||
per-shelf allow-lists (nothing is searched until an admin opts specific
|
bypass for self-signed instances, a content-type filter (books/pages/
|
||||||
shelves in, separately for ticket-creation suggestions vs. the operator
|
chapters, independently toggleable) and a "search by" mode (name / tags /
|
||||||
sidebar).
|
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, refreshed as the thread grows, with
|
||||||
|
an admin-editable prompt (reset-to-default button included).
|
||||||
|
|
||||||
## Tech stack
|
## Tech stack
|
||||||
|
|
||||||
- **Backend**: Laravel, Livewire (server-driven UI, no SPA build beyond Tailwind/Vite
|
- **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
|
for CSS), LdapRecord for directory auth, Sanctum for API tokens, L5-Swagger for
|
||||||
API docs.
|
API docs, Laravel Reverb for WebSocket broadcasting (real-time queue/chat
|
||||||
|
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
|
- **Frontend**: Blade + Livewire + a little Alpine.js for local UI state; Tailwind
|
||||||
v4 via Vite for `resources/css/app.css`. No JS charting library — the statistics
|
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
|
||||||
dashboard is hand-rolled inline-styled bar/column charts, so it needs no client
|
dashboard is hand-rolled inline-styled bar/column charts, so it needs no client
|
||||||
build step beyond the CSS bundle.
|
build step beyond the CSS bundle.
|
||||||
- **Database**: MariaDB.
|
- **Database**: MariaDB.
|
||||||
- **Deployment**: `compose.yaml` — `servicedesk` (source bind-mounted from `./src`,
|
- **Deployment**: `compose.yaml` — `servicedesk` (source bind-mounted from `./src`,
|
||||||
no image rebuild needed for PHP/Blade/route changes) + `mariadb`, fronted by
|
no image rebuild needed for PHP/Blade/route changes) + `mariadb` + `reverb`
|
||||||
Traefik with a private-CA TLS cert. The `servicedesk` image itself is built and
|
(same image, `php artisan reverb:start`), fronted by Traefik with a private-CA
|
||||||
pushed by Gitea Actions (`.gitea/workflows/build.yml`) to the Gitea container
|
TLS cert (the websocket path is routed to `reverb` by a higher-priority
|
||||||
registry whenever `Dockerfile` changes — `compose.yaml` just pulls a tag, it
|
Traefik rule; everything else goes to `servicedesk`). The `servicedesk` image
|
||||||
never builds locally.
|
itself is built and pushed by Gitea Actions (`.gitea/workflows/build.yml`) to
|
||||||
|
the Gitea container registry whenever `Dockerfile` changes — `compose.yaml`
|
||||||
|
just pulls a tag, it never builds locally.
|
||||||
|
|
||||||
See **[install.md](install.md)** for full step-by-step deployment instructions —
|
See **[install.md](install.md)** for full step-by-step deployment instructions —
|
||||||
both via Docker Compose (this stack) and directly on a server with Apache/Nginx,
|
both via Docker Compose (this stack) and directly on a server with Apache/Nginx,
|
||||||
@@ -128,14 +220,22 @@ Compose-level and Laravel-level) and the LDAP/SMTP gotcha after a fresh seed.
|
|||||||
src/ Laravel application
|
src/ Laravel application
|
||||||
app/Livewire/ Client/Operator/Admin Livewire components
|
app/Livewire/ Client/Operator/Admin Livewire components
|
||||||
app/Models/ Eloquent models
|
app/Models/ Eloquent models
|
||||||
app/Services/ TicketService (ticket lifecycle + notifications), BookStackClient
|
app/Events/ Broadcast events (TicketQueueChanged, TicketMessagePosted)
|
||||||
|
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
|
||||||
app/Ldap/ LDAP user model + sync handlers
|
app/Ldap/ LDAP user model + sync handlers
|
||||||
database/migrations/ Schema (one file per table group, final shape)
|
database/migrations/ Schema (one file per table group, final shape)
|
||||||
database/seeders/ DatabaseSeeder — reference data, no ticket data
|
database/seeders/ DatabaseSeeder — reference data, no ticket data
|
||||||
resources/css/ Tailwind entrypoint (needs `npm run build` after edits)
|
resources/css/ Tailwind entrypoint (needs `npm run build` after edits)
|
||||||
|
resources/js/echo.js Laravel Echo/Reverb client + broadcast → Livewire event bridge
|
||||||
resources/views/ Blade templates
|
resources/views/ Blade templates
|
||||||
routes/web.php Client/Operator/Admin routes (role-gated)
|
routes/web.php Client/Operator/Admin routes (role-gated)
|
||||||
routes/api.php REST API (Sanctum, ability-gated)
|
routes/api.php REST API (Sanctum, ability-gated)
|
||||||
|
routes/channels.php Broadcasting channel authorization (operator.queue, ticket.{id})
|
||||||
wiki/
|
wiki/
|
||||||
client/ How-to guide for the Client role
|
client/ How-to guide for the Client role
|
||||||
operator/ How-to guide for the Operator role
|
operator/ How-to guide for the Operator role
|
||||||
|
|||||||
142
install.md
142
install.md
@@ -74,7 +74,7 @@ APP_LOCALE=pl
|
|||||||
APP_FALLBACK_LOCALE=pl
|
APP_FALLBACK_LOCALE=pl
|
||||||
|
|
||||||
AUTHOR_CONTACT=helpdesk@twoja-domena.pl # widoczne w Admin > O aplikacji
|
AUTHOR_CONTACT=helpdesk@twoja-domena.pl # widoczne w Admin > O aplikacji
|
||||||
VERSION=1.1.0 # rezerwa na przyszłość, jeszcze nigdzie nie wyświetlane
|
VERSION=1.2.1 # widoczne w Admin > O aplikacji
|
||||||
|
|
||||||
DB_CONNECTION=mysql
|
DB_CONNECTION=mysql
|
||||||
DB_HOST=mariadb # nazwa serwisu z compose.yaml, NIE 127.0.0.1
|
DB_HOST=mariadb # nazwa serwisu z compose.yaml, NIE 127.0.0.1
|
||||||
@@ -173,6 +173,70 @@ obrazu `servicedesk`) — poczekaj, aż workflow CI przejdzie choć raz (np. prz
|
|||||||
push/PR zmieniający `Dockerfile`, albo ręczne odpalenie z zakładki Actions w
|
push/PR zmieniający `Dockerfile`, albo ręczne odpalenie z zakładki Actions w
|
||||||
Gitea), zanim spróbujesz `docker compose pull` na serwerze.
|
Gitea), zanim spróbujesz `docker compose pull` na serwerze.
|
||||||
|
|
||||||
|
### 1.3b. Real-time (Laravel Reverb)
|
||||||
|
|
||||||
|
Realtime (kolejka operatora, czat na żywo) wymaga trzeciej usługi w
|
||||||
|
`compose.yaml`, `reverb` — tego samego obrazu `servicedesk`, tylko z innym
|
||||||
|
`command: php artisan reverb:start --host=0.0.0.0 --port=8080`. Obraz musi
|
||||||
|
mieć rozszerzenia PHP `pcntl`/`posix` (potrzebne serwerowi Reverb do obsługi
|
||||||
|
sygnałów) — jeśli `Dockerfile` ich nie instaluje, `reverb` będzie się zapętlać
|
||||||
|
w restartach z błędem `Undefined constant "...SIGINT"`; dodaj `pcntl posix` do
|
||||||
|
listy w `docker-php-ext-install` i poczekaj na przebudowanie obrazu przez CI.
|
||||||
|
|
||||||
|
Traefik musi kierować ścieżkę websocketu (`/app*`) do `reverb`, a resztę do
|
||||||
|
`servicedesk` — na tej samej domenie, więc bez dodatkowego wpisu DNS/certyfikatu:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
reverb:
|
||||||
|
image: gitea.kzbikowski.pl/kzbkowski/servicedesk:${IMAGE_TAG:-latest}
|
||||||
|
command: php artisan reverb:start --host=0.0.0.0 --port=8080
|
||||||
|
volumes:
|
||||||
|
- ./src:/var/www/html
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
mariadb:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
- traefik_public
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.docker.network=traefik_public
|
||||||
|
- traefik.http.routers.reverb.rule=Host(`${HOSTNAME}`) && PathPrefix(`/app`)
|
||||||
|
- traefik.http.routers.reverb.priority=1000
|
||||||
|
- traefik.http.routers.reverb.entrypoints=websecure
|
||||||
|
- traefik.http.routers.reverb.tls=true
|
||||||
|
- traefik.http.services.reverb.loadbalancer.server.port=8080
|
||||||
|
```
|
||||||
|
|
||||||
|
`priority=1000` jest ważne — Traefik domyślnie liczy priorytet reguły na
|
||||||
|
podstawie długości jej zapisu, co może dać samemu `Host(...)` z `servicedesk`
|
||||||
|
wyższy priorytet niż oczekiwano, przez co ścieżkowa reguła `reverb` przegrywa i
|
||||||
|
nic nie działa mimo poprawnej konfiguracji.
|
||||||
|
|
||||||
|
W `src/.env` — `BROADCAST_CONNECTION=reverb` plus:
|
||||||
|
|
||||||
|
```env
|
||||||
|
REVERB_APP_ID=wygeneruj-losowy-id
|
||||||
|
REVERB_APP_KEY=wygeneruj-losowy-klucz
|
||||||
|
REVERB_APP_SECRET=wygeneruj-losowy-sekret
|
||||||
|
REVERB_HOST=reverb # nazwa usługi compose — ruch serwer-serwer po sieci internal
|
||||||
|
REVERB_PORT=8080
|
||||||
|
REVERB_SCHEME=http
|
||||||
|
|
||||||
|
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
|
||||||
|
VITE_REVERB_HOST=servicedesk.twoja-domena.pl # publiczna domena — to, z czym łączy się przeglądarka
|
||||||
|
VITE_REVERB_PORT=443
|
||||||
|
VITE_REVERB_SCHEME=https
|
||||||
|
```
|
||||||
|
|
||||||
|
`REVERB_HOST` (serwer→serwer, wewnętrzna sieć Docker) i `VITE_REVERB_HOST`
|
||||||
|
(przeglądarka→Traefik, publiczna domena) to celowo dwie różne wartości —
|
||||||
|
pomylenie ich to najczęstszy błąd przy pierwszym wdrożeniu tej funkcji.
|
||||||
|
|
||||||
|
Po zmianie zmiennych `VITE_REVERB_*` trzeba przebudować front-end (krok 1.5) —
|
||||||
|
te wartości są wypiekane w zbudowany bundle JS, nie czytane w runtime.
|
||||||
|
|
||||||
### 1.4. Instalacja aplikacji wewnątrz kontenera
|
### 1.4. Instalacja aplikacji wewnątrz kontenera
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -201,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/`.
|
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
|
`routes/console.php` planuje `tickets:check-sla-breaches` i `automation:run-rules`
|
||||||
Dockera nie ma wbudowanego cron/supervisora** — bez dodatkowego kroku to zadanie
|
co 15 minut, oraz `emails:fetch-imap` (odbieranie zgłoszeń/odpowiedzi e-mailem —
|
||||||
nigdy się nie uruchomi. Najprościej dodać wpis crona **na hoście**:
|
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
|
```cron
|
||||||
* * * * * cd /ścieżka/do/repo && docker compose exec -T servicedesk php artisan schedule:run >> /dev/null 2>&1
|
* * * * * cd /ścieżka/do/repo && docker compose exec -T servicedesk php artisan schedule:run >> /dev/null 2>&1
|
||||||
@@ -234,15 +306,27 @@ użytku:
|
|||||||
3. Użyj przycisków **„Testuj połączenie”** przy obu sekcjach, zanim zaczniesz
|
3. Użyj przycisków **„Testuj połączenie”** przy obu sekcjach, zanim zaczniesz
|
||||||
polegać na logowaniu przez katalog.
|
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
|
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
|
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
|
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
|
uprawnienie „Access System API”), filtr typu treści (książki/strony/rozdziały,
|
||||||
podpowiedzi przy tworzeniu zgłoszenia i dla panelu operatora — dopóki żadna
|
niezależne checkboxy), tryb wyszukiwania (po nazwie / po tagach / oba), oraz
|
||||||
półka nie jest zaznaczona, wyszukiwanie nic nie zwraca.
|
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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -285,7 +369,7 @@ APP_LOCALE=pl
|
|||||||
APP_FALLBACK_LOCALE=pl
|
APP_FALLBACK_LOCALE=pl
|
||||||
|
|
||||||
AUTHOR_CONTACT=helpdesk@twoja-domena.pl
|
AUTHOR_CONTACT=helpdesk@twoja-domena.pl
|
||||||
VERSION=1.1.0
|
VERSION=1.2.1
|
||||||
|
|
||||||
DB_CONNECTION=mysql
|
DB_CONNECTION=mysql
|
||||||
DB_HOST=127.0.0.1 # albo adres IP/hostname prawdziwego serwera DB
|
DB_HOST=127.0.0.1 # albo adres IP/hostname prawdziwego serwera DB
|
||||||
@@ -393,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
|
```cron
|
||||||
* * * * * cd /var/www/servicedesk/src && php artisan schedule:run >> /dev/null 2>&1
|
* * * * * cd /var/www/servicedesk/src && php artisan schedule:run >> /dev/null 2>&1
|
||||||
@@ -405,6 +491,36 @@ Jak w wersji Docker — kolejka (`php artisan queue:work`) nie jest obowiązkowa
|
|||||||
skoro powiadomienia wysyłają się synchronicznie; zostaw `QUEUE_CONNECTION=database`
|
skoro powiadomienia wysyłają się synchronicznie; zostaw `QUEUE_CONNECTION=database`
|
||||||
jako bezpieczny domyślny driver na przyszłość.
|
jako bezpieczny domyślny driver na przyszłość.
|
||||||
|
|
||||||
|
Realtime (patrz 1.3b) potrzebuje tu **długo działającego procesu**
|
||||||
|
`php artisan reverb:start` — PHP musi mieć rozszerzenia `pcntl`/`posix`
|
||||||
|
(standardowo dostępne, ale sprawdź `php -m`). Najprościej pod systemd:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# /etc/systemd/system/servicedesk-reverb.service
|
||||||
|
[Unit]
|
||||||
|
Description=Servicedesk Reverb websocket server
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=www-data
|
||||||
|
WorkingDirectory=/var/www/servicedesk/src
|
||||||
|
ExecStart=/usr/bin/php artisan reverb:start --host=0.0.0.0 --port=8080
|
||||||
|
Restart=always
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl enable --now servicedesk-reverb
|
||||||
|
```
|
||||||
|
|
||||||
|
Ustaw też te same zmienne `.env` co w 1.3b (`BROADCAST_CONNECTION=reverb`,
|
||||||
|
`REVERB_*`, `VITE_REVERB_*` — tu `REVERB_HOST` to po prostu `127.0.0.1`, nie
|
||||||
|
nazwa usługi compose) i skieruj serwer WWW tak, by ścieżka `/app*` trafiała do
|
||||||
|
portu 8080 zamiast do PHP-FPM/Apache (osobny `location`/`VirtualHost` dla tej
|
||||||
|
jednej ścieżki, analogicznie do reguły Traefika w 1.3b).
|
||||||
|
|
||||||
### 2.7. Pierwsze logowanie i dalsza konfiguracja
|
### 2.7. Pierwsze logowanie i dalsza konfiguracja
|
||||||
|
|
||||||
Identycznie jak w kroku 1.6 — zaloguj się `admin@example.com` / `admin`, zmień
|
Identycznie jak w kroku 1.6 — zaloguj się `admin@example.com` / `admin`, zmień
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
APP_NAME=Laravel
|
APP_NAME=Laravel
|
||||||
APP_ENV=local
|
APP_ENV=local
|
||||||
APP_KEY=
|
APP_KEY=
|
||||||
APP_DEBUG=true
|
APP_DEBUG=false
|
||||||
APP_URL=http://localhost
|
APP_URL=http://localhost
|
||||||
|
|
||||||
AUTHOR_CONTACT=helpdesk@kzbikowski.pl
|
AUTHOR_CONTACT=helpdesk@kzbikowski.pl
|
||||||
VERSION=1.1.0
|
VERSION=1.2.2
|
||||||
|
|
||||||
APP_LOCALE=en
|
APP_LOCALE=en
|
||||||
APP_FALLBACK_LOCALE=en
|
APP_FALLBACK_LOCALE=en
|
||||||
@@ -40,6 +40,24 @@ BROADCAST_CONNECTION=log
|
|||||||
FILESYSTEM_DISK=local
|
FILESYSTEM_DISK=local
|
||||||
QUEUE_CONNECTION=database
|
QUEUE_CONNECTION=database
|
||||||
|
|
||||||
|
# Reverb (real-time broadcasting) — set BROADCAST_CONNECTION=reverb to enable.
|
||||||
|
# REVERB_HOST/PORT/SCHEME are server-to-server (this container calling the
|
||||||
|
# "reverb" compose service over the internal docker network) — REVERB_HOST
|
||||||
|
# should be the compose service name ("reverb"), not a public hostname.
|
||||||
|
# VITE_REVERB_* is what gets baked into the built frontend bundle and is what
|
||||||
|
# the browser connects to — same public hostname/TLS as the rest of the app.
|
||||||
|
REVERB_APP_ID=wygeneruj-losowy-id
|
||||||
|
REVERB_APP_KEY=wygeneruj-losowy-klucz
|
||||||
|
REVERB_APP_SECRET=wygeneruj-losowy-sekret
|
||||||
|
REVERB_HOST=reverb
|
||||||
|
REVERB_PORT=8080
|
||||||
|
REVERB_SCHEME=http
|
||||||
|
|
||||||
|
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
|
||||||
|
VITE_REVERB_HOST=servicedesk.twoja-domena.pl
|
||||||
|
VITE_REVERB_PORT=443
|
||||||
|
VITE_REVERB_SCHEME=https
|
||||||
|
|
||||||
CACHE_STORE=database
|
CACHE_STORE=database
|
||||||
# CACHE_PREFIX=
|
# CACHE_PREFIX=
|
||||||
|
|
||||||
|
|||||||
35
src/app/Console/Commands/BookstackTagContent.php
Normal file
35
src/app/Console/Commands/BookstackTagContent.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\BookStackContentTagger;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class BookstackTagContent extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'bookstack:tag-content
|
||||||
|
{--dry-run : Klasyfikuj i pokaż wynik bez zapisu tagów do BookStack}
|
||||||
|
{--force : Klasyfikuj ponownie elementy, które już mają tag podkategorii}
|
||||||
|
{--limit= : Zatrzymaj się po przetworzeniu N elementów}';
|
||||||
|
|
||||||
|
protected $description = 'Otaguj każdą książkę/rozdział/stronę w BookStack pasującymi nazwami podkategorii helpdesku, z pomocą skonfigurowanego dostawcy AI';
|
||||||
|
|
||||||
|
public function handle(BookStackContentTagger $tagger): int
|
||||||
|
{
|
||||||
|
$totals = $tagger->run(
|
||||||
|
dryRun: (bool) $this->option('dry-run'),
|
||||||
|
force: (bool) $this->option('force'),
|
||||||
|
limit: $this->option('limit') !== null ? (int) $this->option('limit') : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'BookStack tagging: przeskanowano %d, otagowano %d, pominięto %d, nieudanych paczek %d.',
|
||||||
|
$totals['scanned'],
|
||||||
|
$totals['tagged'],
|
||||||
|
$totals['skipped'],
|
||||||
|
$totals['failed_batches'],
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/app/Console/Commands/FetchImapEmails.php
Normal file
33
src/app/Console/Commands/FetchImapEmails.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\ImapMailbox;
|
||||||
|
use App\Services\ImapMailboxFetcher;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class FetchImapEmails extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'emails:fetch-imap';
|
||||||
|
|
||||||
|
protected $description = 'Poll every enabled IMAP mailbox and turn new messages into tickets/replies';
|
||||||
|
|
||||||
|
public function handle(ImapMailboxFetcher $fetcher): int
|
||||||
|
{
|
||||||
|
if (! ImapMailbox::query()->where('enabled', true)->exists()) {
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$totals = $fetcher->fetchAll();
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'IMAP fetch: %d nowych, %d odpowiedzi, %d odrzuconych, %d błędów.',
|
||||||
|
$totals['created'],
|
||||||
|
$totals['replied'],
|
||||||
|
$totals['rejected'],
|
||||||
|
$totals['errors'],
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/app/Console/Commands/RunAiTicketAutomation.php
Normal file
35
src/app/Console/Commands/RunAiTicketAutomation.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\TicketAiSummaryService;
|
||||||
|
use App\Services\TicketAiTriageService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class RunAiTicketAutomation extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'ai:run-ticket-automation';
|
||||||
|
|
||||||
|
protected $description = 'Run AI-driven ticket triage (categorize/prioritize new tickets) and refresh AI ticket summaries for the operator view';
|
||||||
|
|
||||||
|
public function handle(TicketAiTriageService $triage, TicketAiSummaryService $summary): int
|
||||||
|
{
|
||||||
|
$triageTotals = $triage->run();
|
||||||
|
$summaryTotals = $summary->run();
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'AI triage: scanned %d, changed %d, failed %d.',
|
||||||
|
$triageTotals['scanned'],
|
||||||
|
$triageTotals['changed'],
|
||||||
|
$triageTotals['failed'],
|
||||||
|
));
|
||||||
|
$this->info(sprintf(
|
||||||
|
'AI summaries: scanned %d, updated %d, failed %d.',
|
||||||
|
$summaryTotals['scanned'],
|
||||||
|
$summaryTotals['updated'],
|
||||||
|
$summaryTotals['failed'],
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/app/Console/Commands/RunAutomationRules.php
Normal file
80
src/app/Console/Commands/RunAutomationRules.php
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\AutomationRule;
|
||||||
|
use App\Models\AutomationRuleTicketLog;
|
||||||
|
use App\Models\Status;
|
||||||
|
use App\Models\Team;
|
||||||
|
use App\Models\Ticket;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\TicketService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class RunAutomationRules extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'automation:run-rules';
|
||||||
|
|
||||||
|
protected $description = 'Apply enabled SLA automation rules to tickets that have been silent past their condition threshold';
|
||||||
|
|
||||||
|
public function handle(TicketService $tickets): int
|
||||||
|
{
|
||||||
|
$rules = AutomationRule::query()->where('enabled', true)->orderBy('id')->get();
|
||||||
|
$fired = 0;
|
||||||
|
|
||||||
|
foreach ($rules as $rule) {
|
||||||
|
$query = Ticket::query()->whereNotIn('status_key', Status::closedKeys());
|
||||||
|
|
||||||
|
if ($rule->scope_priority_key) {
|
||||||
|
$query->where('priority_key', $rule->scope_priority_key);
|
||||||
|
}
|
||||||
|
if ($rule->scope_subcategory_id) {
|
||||||
|
$query->where('subcategory_id', $rule->scope_subcategory_id);
|
||||||
|
}
|
||||||
|
if ($rule->scope_team_id) {
|
||||||
|
$query->where('team_id', $rule->scope_team_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidates = $query->get()->filter(function (Ticket $ticket) use ($rule) {
|
||||||
|
if ($rule->hasFiredFor($ticket)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$since = $ticket->last_customer_activity_at ?? $ticket->created_at;
|
||||||
|
|
||||||
|
// Carbon 3 defaults diffInMinutes() to a signed result (negative
|
||||||
|
// when $since is in the past) rather than always-absolute — be
|
||||||
|
// explicit, same trap as Ticket::secondsSinceTimerStarted().
|
||||||
|
return now()->diffInMinutes($since, absolute: true) >= $rule->condition_minutes;
|
||||||
|
});
|
||||||
|
|
||||||
|
foreach ($candidates as $ticket) {
|
||||||
|
$this->apply($tickets, $rule, $ticket);
|
||||||
|
$fired++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Ran automation rules: {$fired} action(s) applied.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function apply(TicketService $tickets, AutomationRule $rule, Ticket $ticket): void
|
||||||
|
{
|
||||||
|
match ($rule->action_type) {
|
||||||
|
'change_priority' => $tickets->setPriority($ticket, $rule->action_value),
|
||||||
|
'change_status' => $tickets->setStatus($ticket, $rule->action_value),
|
||||||
|
'change_team' => $tickets->setTeam($ticket, Team::query()->find($rule->action_value)),
|
||||||
|
'change_assignee' => $tickets->setAssignee($ticket, User::query()->find($rule->action_value)),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
$ticket->addHistory("Automatyzacja: {$rule->label}");
|
||||||
|
|
||||||
|
AutomationRuleTicketLog::query()->create([
|
||||||
|
'automation_rule_id' => $rule->id,
|
||||||
|
'ticket_id' => $ticket->id,
|
||||||
|
'triggered_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/app/Events/NotificationCreated.php
Normal file
47
src/app/Events/NotificationCreated.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/app/Events/TicketMessagePosted.php
Normal file
52
src/app/Events/TicketMessagePosted.php
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?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 on ticket.{id} whenever a new message (public reply, internal note,
|
||||||
|
* or API message) is posted — drives the live message thread ("live chat")
|
||||||
|
* for both operator and client views of the same ticket.
|
||||||
|
*
|
||||||
|
* The payload deliberately never carries the message body, only metadata —
|
||||||
|
* both public and internal messages broadcast on the same per-ticket
|
||||||
|
* channel, and a client subscriber must never be able to read an internal
|
||||||
|
* note's content from the socket frame itself. Each side's Livewire
|
||||||
|
* component only ever re-queries whatever its own already-authorized
|
||||||
|
* computed property returns (the client's never touches internalMessages()
|
||||||
|
* regardless of which event arrived), so this is safe by construction.
|
||||||
|
*/
|
||||||
|
class TicketMessagePosted implements ShouldBroadcastNow
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithSockets;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public int $ticketId,
|
||||||
|
public int $messageId,
|
||||||
|
public bool $internal,
|
||||||
|
public ?int $actorId,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, Channel>
|
||||||
|
*/
|
||||||
|
public function broadcastOn(): array
|
||||||
|
{
|
||||||
|
return [new PrivateChannel('ticket.'.$this->ticketId)];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastWith(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'ticketId' => $this->ticketId,
|
||||||
|
'messageId' => $this->messageId,
|
||||||
|
'internal' => $this->internal,
|
||||||
|
'actorId' => $this->actorId,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/app/Events/TicketQueueChanged.php
Normal file
56
src/app/Events/TicketQueueChanged.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?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 on operator.queue whenever a ticket is created, changes in a way
|
||||||
|
* that affects the operator queue table (status/priority/team/assignee, or
|
||||||
|
* a new message bumping its updated_at, which the queue sorts by), or is
|
||||||
|
* deleted. Payload stays minimal — the receiving Queue component just
|
||||||
|
* re-queries through its own already-correct visibleToOperator() scope
|
||||||
|
* rather than this event needing to encode per-viewer visibility itself.
|
||||||
|
*
|
||||||
|
* Also broadcast on the ticket's own channel, so a client (who can't
|
||||||
|
* subscribe to operator.queue at all — see routes/channels.php) still sees
|
||||||
|
* status/priority/team/assignee changes live while viewing that one ticket.
|
||||||
|
*
|
||||||
|
* ShouldBroadcastNow (not ShouldBroadcast) — this app runs with no queue
|
||||||
|
* worker by design (see TicketNotification), so broadcasts happen
|
||||||
|
* synchronously within the request like everything else here.
|
||||||
|
*/
|
||||||
|
class TicketQueueChanged implements ShouldBroadcastNow
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithSockets;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public int $ticketId,
|
||||||
|
public string $reason,
|
||||||
|
public ?int $actorId,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, Channel>
|
||||||
|
*/
|
||||||
|
public function broadcastOn(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
new PrivateChannel('operator.queue'),
|
||||||
|
new PrivateChannel('ticket.'.$this->ticketId),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastWith(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'ticketId' => $this->ticketId,
|
||||||
|
'reason' => $this->reason,
|
||||||
|
'actorId' => $this->actorId,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
335
src/app/Livewire/Admin/MailSettings.php
Normal file
335
src/app/Livewire/Admin/MailSettings.php
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Admin;
|
||||||
|
|
||||||
|
use App\Models\Category;
|
||||||
|
use App\Models\ImapMailbox;
|
||||||
|
use App\Services\ImapMailboxFetcher;
|
||||||
|
use App\Support\Settings;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Config;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SMTP (outbound) + IMAP mailboxes (inbound — turns e-mails into tickets or
|
||||||
|
* replies) on their own dedicated admin page, split out of the generic
|
||||||
|
* "Integracje" grab-bag since IMAP is a repeatable list (N mailboxes) rather
|
||||||
|
* than a singleton config, and both halves of "reply by e-mail" belong
|
||||||
|
* together rather than split across tabs.
|
||||||
|
*/
|
||||||
|
class MailSettings extends Component
|
||||||
|
{
|
||||||
|
public array $mailConfig = [];
|
||||||
|
|
||||||
|
public ?string $mailTestResult = null;
|
||||||
|
|
||||||
|
public bool $mailboxFormOpen = false;
|
||||||
|
|
||||||
|
public array $mailboxForm = [
|
||||||
|
'id' => null,
|
||||||
|
'name' => '',
|
||||||
|
'enabled' => true,
|
||||||
|
'host' => '',
|
||||||
|
'port' => 993,
|
||||||
|
'encryption' => 'ssl',
|
||||||
|
'validateCert' => true,
|
||||||
|
'username' => '',
|
||||||
|
'password' => '',
|
||||||
|
'folder' => 'INBOX',
|
||||||
|
'processedFolder' => '',
|
||||||
|
'rejectedFolder' => '',
|
||||||
|
'target' => '',
|
||||||
|
'blocklistSenders' => 'mailer-daemon,postmaster,no-reply,noreply',
|
||||||
|
];
|
||||||
|
|
||||||
|
public ?int $mailboxTestResultId = null;
|
||||||
|
|
||||||
|
public ?string $mailboxTestResult = null;
|
||||||
|
|
||||||
|
public ?string $mailboxTestMessage = null;
|
||||||
|
|
||||||
|
public ?int $mailboxFetchResultId = null;
|
||||||
|
|
||||||
|
public ?string $mailboxFetchSummary = null;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->mailConfig = [
|
||||||
|
'smtpEnabled' => Settings::bool('mail_smtp_enabled'),
|
||||||
|
'smtpHost' => Settings::get('mail_smtp_host'),
|
||||||
|
'smtpPort' => Settings::get('mail_smtp_port'),
|
||||||
|
'smtpUsername' => Settings::get('mail_smtp_username'),
|
||||||
|
'smtpPassword' => Settings::get('mail_smtp_password'),
|
||||||
|
'smtpEncryption' => Settings::get('mail_smtp_encryption'),
|
||||||
|
'fromAddress' => Settings::get('mail_from_address'),
|
||||||
|
'fromName' => Settings::get('mail_from_name'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function mailboxes(): Collection
|
||||||
|
{
|
||||||
|
return ImapMailbox::query()->with(['defaultSubcategory.category', 'defaultCategory'])->orderBy('name')->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Categories with their subcategories nested, for the mailbox form's
|
||||||
|
* single combined "cała kategoria albo konkretna podkategoria" selector.
|
||||||
|
*/
|
||||||
|
#[Computed]
|
||||||
|
public function categoryOptions(): Collection
|
||||||
|
{
|
||||||
|
return Category::query()->with('subcategories')->orderBy('name')->get()
|
||||||
|
->map(fn (Category $c) => [
|
||||||
|
'id' => $c->id,
|
||||||
|
'name' => $c->name,
|
||||||
|
'subcategories' => $c->subcategories->map(fn ($s) => ['id' => $s->id, 'name' => $s->name])->values(),
|
||||||
|
])
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== SMTP =====================
|
||||||
|
|
||||||
|
public function saveMailConfig(): void
|
||||||
|
{
|
||||||
|
Settings::set('mail_smtp_enabled', $this->mailConfig['smtpEnabled'] ? '1' : '0');
|
||||||
|
Settings::set('mail_smtp_host', $this->mailConfig['smtpHost']);
|
||||||
|
Settings::set('mail_smtp_port', (string) $this->mailConfig['smtpPort']);
|
||||||
|
Settings::set('mail_smtp_username', $this->mailConfig['smtpUsername']);
|
||||||
|
|
||||||
|
if ($this->mailConfig['smtpPassword']) {
|
||||||
|
Settings::set('mail_smtp_password', $this->mailConfig['smtpPassword']);
|
||||||
|
}
|
||||||
|
|
||||||
|
Settings::set('mail_smtp_encryption', $this->mailConfig['smtpEncryption']);
|
||||||
|
Settings::set('mail_from_address', $this->mailConfig['fromAddress']);
|
||||||
|
Settings::set('mail_from_name', $this->mailConfig['fromName']);
|
||||||
|
|
||||||
|
$this->mailTestResult = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a real test e-mail to the logged-in admin using the form's
|
||||||
|
* current (unsaved) values, temporarily overriding the mail config the
|
||||||
|
* same way AppServiceProvider does for real once saved.
|
||||||
|
*/
|
||||||
|
public function testMailConnection(): void
|
||||||
|
{
|
||||||
|
$cfg = $this->mailConfig;
|
||||||
|
|
||||||
|
if (empty($cfg['smtpHost']) || empty($cfg['fromAddress'])) {
|
||||||
|
$this->mailTestResult = 'error';
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$original = Config::get('mail');
|
||||||
|
|
||||||
|
try {
|
||||||
|
Config::set('mail.default', 'smtp');
|
||||||
|
Config::set('mail.mailers.smtp.host', $cfg['smtpHost']);
|
||||||
|
Config::set('mail.mailers.smtp.port', (int) $cfg['smtpPort']);
|
||||||
|
Config::set('mail.mailers.smtp.username', $cfg['smtpUsername'] ?: null);
|
||||||
|
Config::set('mail.mailers.smtp.password', $cfg['smtpPassword'] ?: Settings::get('mail_smtp_password'));
|
||||||
|
Config::set('mail.mailers.smtp.scheme', match ($cfg['smtpEncryption']) {
|
||||||
|
'ssl' => 'smtps',
|
||||||
|
'tls' => 'smtp',
|
||||||
|
default => null,
|
||||||
|
});
|
||||||
|
Config::set('mail.from.address', $cfg['fromAddress']);
|
||||||
|
Config::set('mail.from.name', $cfg['fromName'] ?: Settings::get('company_name'));
|
||||||
|
|
||||||
|
app()->forgetInstance('mail.manager');
|
||||||
|
app()->forgetInstance('mailer');
|
||||||
|
|
||||||
|
Mail::raw('To jest testowa wiadomość wysłana z panelu administratora Servicedesk.', function ($message) {
|
||||||
|
$message->to(Auth::user()->email)->subject('Test konfiguracji SMTP');
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->mailTestResult = 'ok';
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$this->mailTestResult = 'error';
|
||||||
|
} finally {
|
||||||
|
Config::set('mail', $original);
|
||||||
|
app()->forgetInstance('mail.manager');
|
||||||
|
app()->forgetInstance('mailer');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== IMAP MAILBOXES =====================
|
||||||
|
|
||||||
|
public function openMailboxForm(): void
|
||||||
|
{
|
||||||
|
$this->reset('mailboxForm');
|
||||||
|
$this->mailboxForm = [
|
||||||
|
'id' => null,
|
||||||
|
'name' => '',
|
||||||
|
'enabled' => true,
|
||||||
|
'host' => '',
|
||||||
|
'port' => 993,
|
||||||
|
'encryption' => 'ssl',
|
||||||
|
'validateCert' => true,
|
||||||
|
'username' => '',
|
||||||
|
'password' => '',
|
||||||
|
'folder' => 'INBOX',
|
||||||
|
'processedFolder' => '',
|
||||||
|
'rejectedFolder' => '',
|
||||||
|
'target' => '',
|
||||||
|
'blocklistSenders' => 'mailer-daemon,postmaster,no-reply,noreply',
|
||||||
|
];
|
||||||
|
$this->mailboxTestResultId = null;
|
||||||
|
$this->resetErrorBag();
|
||||||
|
$this->mailboxFormOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function editMailbox(int $id): void
|
||||||
|
{
|
||||||
|
$mailbox = ImapMailbox::query()->findOrFail($id);
|
||||||
|
|
||||||
|
$target = match (true) {
|
||||||
|
(bool) $mailbox->default_subcategory_id => "subcategory:{$mailbox->default_subcategory_id}",
|
||||||
|
(bool) $mailbox->default_category_id => "category:{$mailbox->default_category_id}",
|
||||||
|
default => '',
|
||||||
|
};
|
||||||
|
|
||||||
|
$this->mailboxForm = [
|
||||||
|
'id' => $mailbox->id,
|
||||||
|
'name' => $mailbox->name,
|
||||||
|
'enabled' => $mailbox->enabled,
|
||||||
|
'host' => $mailbox->host,
|
||||||
|
'port' => $mailbox->port,
|
||||||
|
'encryption' => $mailbox->encryption,
|
||||||
|
'validateCert' => $mailbox->validate_cert,
|
||||||
|
'username' => $mailbox->username,
|
||||||
|
'password' => $mailbox->password,
|
||||||
|
'folder' => $mailbox->folder,
|
||||||
|
'processedFolder' => $mailbox->processed_folder,
|
||||||
|
'rejectedFolder' => $mailbox->rejected_folder,
|
||||||
|
'target' => $target,
|
||||||
|
'blocklistSenders' => $mailbox->blocklist_senders,
|
||||||
|
];
|
||||||
|
$this->mailboxTestResultId = null;
|
||||||
|
$this->resetErrorBag();
|
||||||
|
$this->mailboxFormOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closeMailboxForm(): void
|
||||||
|
{
|
||||||
|
$this->mailboxFormOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function submitMailboxForm(): void
|
||||||
|
{
|
||||||
|
$this->validate([
|
||||||
|
'mailboxForm.name' => ['required', 'string', 'max:255'],
|
||||||
|
'mailboxForm.host' => ['required', 'string', 'max:255'],
|
||||||
|
'mailboxForm.port' => ['required', 'integer', 'min:1', 'max:65535'],
|
||||||
|
'mailboxForm.encryption' => ['required', 'in:ssl,tls,none'],
|
||||||
|
'mailboxForm.username' => ['required', 'string', 'max:255'],
|
||||||
|
'mailboxForm.folder' => ['required', 'string', 'max:255'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
[$targetType, $targetId] = str_contains((string) $this->mailboxForm['target'], ':')
|
||||||
|
? explode(':', $this->mailboxForm['target'], 2)
|
||||||
|
: [null, null];
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'name' => $this->mailboxForm['name'],
|
||||||
|
'enabled' => (bool) $this->mailboxForm['enabled'],
|
||||||
|
'host' => $this->mailboxForm['host'],
|
||||||
|
'port' => (int) $this->mailboxForm['port'],
|
||||||
|
'encryption' => $this->mailboxForm['encryption'],
|
||||||
|
'validate_cert' => (bool) $this->mailboxForm['validateCert'],
|
||||||
|
'username' => $this->mailboxForm['username'],
|
||||||
|
'folder' => $this->mailboxForm['folder'],
|
||||||
|
'processed_folder' => $this->mailboxForm['processedFolder'] ?: null,
|
||||||
|
'rejected_folder' => $this->mailboxForm['rejectedFolder'] ?: null,
|
||||||
|
// Exactly one of these (or neither) — never both — driven by the
|
||||||
|
// form's single "cała kategoria albo konkretna podkategoria" selector.
|
||||||
|
'default_subcategory_id' => $targetType === 'subcategory' ? $targetId : null,
|
||||||
|
'default_category_id' => $targetType === 'category' ? $targetId : null,
|
||||||
|
'blocklist_senders' => $this->mailboxForm['blocklistSenders'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$mailbox = ImapMailbox::query()->find($this->mailboxForm['id']);
|
||||||
|
|
||||||
|
if ($mailbox) {
|
||||||
|
if ($this->mailboxForm['password']) {
|
||||||
|
$data['password'] = $this->mailboxForm['password'];
|
||||||
|
}
|
||||||
|
$mailbox->update($data);
|
||||||
|
} else {
|
||||||
|
$data['password'] = $this->mailboxForm['password'];
|
||||||
|
ImapMailbox::query()->create($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->mailboxFormOpen = false;
|
||||||
|
unset($this->mailboxes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggleMailboxEnabled(int $id): void
|
||||||
|
{
|
||||||
|
$mailbox = ImapMailbox::query()->findOrFail($id);
|
||||||
|
$mailbox->update(['enabled' => ! $mailbox->enabled]);
|
||||||
|
unset($this->mailboxes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeMailbox(int $id): void
|
||||||
|
{
|
||||||
|
ImapMailbox::query()->findOrFail($id)->delete();
|
||||||
|
unset($this->mailboxes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a real fetch against one mailbox right now, outside the 5-minute
|
||||||
|
* schedule — for checking a freshly-configured mailbox without waiting,
|
||||||
|
* and for diagnosing "why didn't my e-mail turn into a ticket" without
|
||||||
|
* needing shell access. Allowed even while the mailbox is disabled
|
||||||
|
* (fetchAll(), used by the scheduled command, is the one that respects
|
||||||
|
* the enabled flag — this is an explicit admin action).
|
||||||
|
*/
|
||||||
|
public function fetchMailboxNow(int $id): void
|
||||||
|
{
|
||||||
|
$mailbox = ImapMailbox::query()->findOrFail($id);
|
||||||
|
$result = app(ImapMailboxFetcher::class)->fetchMailbox($mailbox);
|
||||||
|
|
||||||
|
$this->mailboxFetchResultId = $id;
|
||||||
|
$this->mailboxFetchSummary = "Nowe: {$result['created']}, odpowiedzi: {$result['replied']}, odrzucone: {$result['rejected']}, błędy: {$result['errors']}.";
|
||||||
|
unset($this->mailboxes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests the form's current (unsaved) values against a throwaway
|
||||||
|
* ImapMailbox instance — mirrors testMailConnection()'s "don't require a
|
||||||
|
* save first" behavior. Falls back to the stored password when editing
|
||||||
|
* an existing mailbox and the password field was left blank.
|
||||||
|
*/
|
||||||
|
public function testMailboxConnection(): void
|
||||||
|
{
|
||||||
|
$mailbox = new ImapMailbox([
|
||||||
|
'host' => $this->mailboxForm['host'],
|
||||||
|
'port' => (int) $this->mailboxForm['port'],
|
||||||
|
'encryption' => $this->mailboxForm['encryption'],
|
||||||
|
'validate_cert' => (bool) $this->mailboxForm['validateCert'],
|
||||||
|
'username' => $this->mailboxForm['username'],
|
||||||
|
'folder' => $this->mailboxForm['folder'] ?: 'INBOX',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$mailbox->password = $this->mailboxForm['password']
|
||||||
|
?: ($this->mailboxForm['id'] ? ImapMailbox::query()->find($this->mailboxForm['id'])?->password : null);
|
||||||
|
|
||||||
|
$error = app(ImapMailboxFetcher::class)->testConnection($mailbox);
|
||||||
|
|
||||||
|
$this->mailboxTestResultId = (int) ($this->mailboxForm['id'] ?? 0);
|
||||||
|
$this->mailboxTestResult = $error === null ? 'ok' : 'error';
|
||||||
|
$this->mailboxTestMessage = $error;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.admin.mail-settings');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Livewire\Admin;
|
namespace App\Livewire\Admin;
|
||||||
|
|
||||||
|
use App\Models\AutomationRule;
|
||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
use App\Models\CustomField;
|
use App\Models\CustomField;
|
||||||
use App\Models\EmailTemplate;
|
use App\Models\EmailTemplate;
|
||||||
@@ -13,18 +14,20 @@ use App\Models\SlaRule;
|
|||||||
use App\Models\Status;
|
use App\Models\Status;
|
||||||
use App\Models\Subcategory;
|
use App\Models\Subcategory;
|
||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
|
use App\Models\Ticket;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\UserField;
|
use App\Models\UserField;
|
||||||
|
use App\Services\AiClient;
|
||||||
use App\Services\BookStackClient;
|
use App\Services\BookStackClient;
|
||||||
|
use App\Services\BookStackContentTagger;
|
||||||
use App\Services\LdapUserProvisioner;
|
use App\Services\LdapUserProvisioner;
|
||||||
use App\Support\Settings;
|
use App\Support\Settings;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Config;
|
|
||||||
use Illuminate\Support\Facades\Mail;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use LdapRecord\Connection;
|
use LdapRecord\Connection;
|
||||||
use Livewire\Attributes\Computed;
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Attributes\Url;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Livewire\WithFileUploads;
|
use Livewire\WithFileUploads;
|
||||||
|
|
||||||
@@ -32,6 +35,7 @@ class Panel extends Component
|
|||||||
{
|
{
|
||||||
use WithFileUploads;
|
use WithFileUploads;
|
||||||
|
|
||||||
|
#[Url]
|
||||||
public string $tab = 'categories';
|
public string $tab = 'categories';
|
||||||
|
|
||||||
// ---- categories ----
|
// ---- categories ----
|
||||||
@@ -100,6 +104,15 @@ class Panel extends Component
|
|||||||
|
|
||||||
public array $responseTemplateForm = ['id' => null, 'label' => '', 'body' => ''];
|
public array $responseTemplateForm = ['id' => null, 'label' => '', 'body' => ''];
|
||||||
|
|
||||||
|
// ---- automation rules ----
|
||||||
|
public bool $automationRuleFormOpen = false;
|
||||||
|
|
||||||
|
public array $automationRuleForm = [
|
||||||
|
'id' => null, 'label' => '', 'enabled' => true, 'condition_minutes' => 60,
|
||||||
|
'scope_priority_key' => '', 'scope_subcategory_id' => '', 'scope_team_id' => '',
|
||||||
|
'action_type' => 'change_priority', 'action_value' => '',
|
||||||
|
];
|
||||||
|
|
||||||
// ---- templates ----
|
// ---- templates ----
|
||||||
public ?int $editingTemplateId = null;
|
public ?int $editingTemplateId = null;
|
||||||
|
|
||||||
@@ -135,16 +148,30 @@ class Panel extends Component
|
|||||||
|
|
||||||
public ?string $ldapTestResult = null;
|
public ?string $ldapTestResult = null;
|
||||||
|
|
||||||
public array $mailConfig = [];
|
|
||||||
|
|
||||||
public ?string $mailTestResult = null;
|
|
||||||
|
|
||||||
public array $bookstackConfig = [];
|
public array $bookstackConfig = [];
|
||||||
|
|
||||||
public ?string $bookstackTestResult = null;
|
public ?string $bookstackTestResult = null;
|
||||||
|
|
||||||
public ?string $bookstackTestMessage = null;
|
public ?string $bookstackTestMessage = null;
|
||||||
|
|
||||||
|
public ?array $bookstackTagResult = null;
|
||||||
|
|
||||||
|
public ?string $bookstackTagError = null;
|
||||||
|
|
||||||
|
public array $aiConfig = [];
|
||||||
|
|
||||||
|
public ?string $aiTestResult = null;
|
||||||
|
|
||||||
|
public ?string $aiTestMessage = null;
|
||||||
|
|
||||||
|
public array $aiTriageConfig = [];
|
||||||
|
|
||||||
|
public bool $aiSummaryEnabled = false;
|
||||||
|
|
||||||
|
public string $aiSummaryPrompt = '';
|
||||||
|
|
||||||
|
public int $aiSummaryPromptVersion = 0;
|
||||||
|
|
||||||
// ---- generic pending-delete confirm ----
|
// ---- generic pending-delete confirm ----
|
||||||
public ?string $pendingDeleteType = null;
|
public ?string $pendingDeleteType = null;
|
||||||
|
|
||||||
@@ -169,6 +196,16 @@ class Panel extends Component
|
|||||||
'attachmentAllowedTypes' => Settings::get('attachment_allowed_types'),
|
'attachmentAllowedTypes' => Settings::get('attachment_allowed_types'),
|
||||||
'sessionLifetimeMinutes' => Settings::get('session_lifetime_minutes'),
|
'sessionLifetimeMinutes' => Settings::get('session_lifetime_minutes'),
|
||||||
'timezone' => Settings::timezone(),
|
'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 = [
|
$this->ldapConfig = [
|
||||||
@@ -185,17 +222,6 @@ class Panel extends Component
|
|||||||
'restrictTicketsToLdap' => Settings::bool('restrict_tickets_to_ldap'),
|
'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 = [
|
$this->bookstackConfig = [
|
||||||
'enabled' => Settings::bool('bookstack_enabled'),
|
'enabled' => Settings::bool('bookstack_enabled'),
|
||||||
'baseUrl' => Settings::get('bookstack_base_url'),
|
'baseUrl' => Settings::get('bookstack_base_url'),
|
||||||
@@ -203,10 +229,29 @@ class Panel extends Component
|
|||||||
'tokenSecret' => Settings::get('bookstack_token_secret'),
|
'tokenSecret' => Settings::get('bookstack_token_secret'),
|
||||||
'verifySsl' => Settings::bool('bookstack_verify_ssl'),
|
'verifySsl' => Settings::bool('bookstack_verify_ssl'),
|
||||||
'showToGuests' => Settings::bool('bookstack_show_to_guests'),
|
'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', '')),
|
'allowedShelfIdsCreation' => $this->parseShelfIds(Settings::get('bookstack_allowed_shelf_ids_creation', '')),
|
||||||
'allowedShelfIdsTicketView' => $this->parseShelfIds(Settings::get('bookstack_allowed_shelf_ids_ticket_view', '')),
|
'allowedShelfIdsTicketView' => $this->parseShelfIds(Settings::get('bookstack_allowed_shelf_ids_ticket_view', '')),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
$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->aiSummaryPrompt = Settings::get('ai_summary_prompt');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setTab(string $tab): void
|
public function setTab(string $tab): void
|
||||||
@@ -271,10 +316,55 @@ class Panel extends Component
|
|||||||
return;
|
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] = '';
|
$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
|
public function openSubcategoryEditForm(int $subcategoryId): void
|
||||||
{
|
{
|
||||||
$sub = Subcategory::query()->with('customFields')->findOrFail($subcategoryId);
|
$sub = Subcategory::query()->with('customFields')->findOrFail($subcategoryId);
|
||||||
@@ -854,6 +944,96 @@ class Panel extends Component
|
|||||||
$this->requestDelete('response-template', $id, 'Szablon odpowiedzi zostanie usunięty z listy dostępnej operatorom.');
|
$this->requestDelete('response-template', $id, 'Szablon odpowiedzi zostanie usunięty z listy dostępnej operatorom.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===================== AUTOMATION RULES =====================
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function automationRules()
|
||||||
|
{
|
||||||
|
return AutomationRule::query()->orderBy('id')->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function openAutomationRuleForm(): void
|
||||||
|
{
|
||||||
|
$this->automationRuleForm = [
|
||||||
|
'id' => null, 'label' => '', 'enabled' => true, 'condition_minutes' => 60,
|
||||||
|
'scope_priority_key' => '', 'scope_subcategory_id' => '', 'scope_team_id' => '',
|
||||||
|
'action_type' => 'change_priority', 'action_value' => '',
|
||||||
|
];
|
||||||
|
$this->automationRuleFormOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function editAutomationRule(int $id): void
|
||||||
|
{
|
||||||
|
$rule = AutomationRule::query()->findOrFail($id);
|
||||||
|
|
||||||
|
$this->automationRuleForm = [
|
||||||
|
'id' => $rule->id,
|
||||||
|
'label' => $rule->label,
|
||||||
|
'enabled' => $rule->enabled,
|
||||||
|
'condition_minutes' => $rule->condition_minutes,
|
||||||
|
'scope_priority_key' => $rule->scope_priority_key ?? '',
|
||||||
|
'scope_subcategory_id' => $rule->scope_subcategory_id ?? '',
|
||||||
|
'scope_team_id' => $rule->scope_team_id ?? '',
|
||||||
|
'action_type' => $rule->action_type,
|
||||||
|
'action_value' => $rule->action_value,
|
||||||
|
];
|
||||||
|
$this->automationRuleFormOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closeAutomationRuleForm(): void
|
||||||
|
{
|
||||||
|
$this->automationRuleFormOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switching action_type invalidates whichever action_value was picked for
|
||||||
|
// the previous type (e.g. a priority key isn't a valid team id).
|
||||||
|
public function updatedAutomationRuleFormActionType(): void
|
||||||
|
{
|
||||||
|
$this->automationRuleForm['action_value'] = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function submitAutomationRule(): void
|
||||||
|
{
|
||||||
|
$this->validate([
|
||||||
|
'automationRuleForm.label' => 'required|string|max:255',
|
||||||
|
'automationRuleForm.condition_minutes' => 'required|integer|min:1',
|
||||||
|
'automationRuleForm.action_type' => 'required|in:'.implode(',', AutomationRule::ACTION_TYPES),
|
||||||
|
'automationRuleForm.action_value' => 'required|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'label' => $this->automationRuleForm['label'],
|
||||||
|
'enabled' => (bool) $this->automationRuleForm['enabled'],
|
||||||
|
'condition_minutes' => (int) $this->automationRuleForm['condition_minutes'],
|
||||||
|
'scope_priority_key' => $this->automationRuleForm['scope_priority_key'] ?: null,
|
||||||
|
'scope_subcategory_id' => $this->automationRuleForm['scope_subcategory_id'] ?: null,
|
||||||
|
'scope_team_id' => $this->automationRuleForm['scope_team_id'] ?: null,
|
||||||
|
'action_type' => $this->automationRuleForm['action_type'],
|
||||||
|
'action_value' => $this->automationRuleForm['action_value'],
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($this->automationRuleForm['id']) {
|
||||||
|
AutomationRule::query()->find($this->automationRuleForm['id'])?->update($data);
|
||||||
|
} else {
|
||||||
|
AutomationRule::query()->create($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->automationRuleFormOpen = false;
|
||||||
|
unset($this->automationRules);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggleAutomationRuleEnabled(int $id): void
|
||||||
|
{
|
||||||
|
$rule = AutomationRule::query()->find($id);
|
||||||
|
$rule?->update(['enabled' => ! $rule->enabled]);
|
||||||
|
unset($this->automationRules);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeAutomationRule(int $id): void
|
||||||
|
{
|
||||||
|
$this->requestDelete('automation-rule', $id, 'Reguła automatyzacji zostanie usunięta.');
|
||||||
|
}
|
||||||
|
|
||||||
// ===================== STATUSES / PRIORITIES =====================
|
// ===================== STATUSES / PRIORITIES =====================
|
||||||
|
|
||||||
#[Computed]
|
#[Computed]
|
||||||
@@ -1255,6 +1435,41 @@ class Panel extends Component
|
|||||||
if (in_array($this->systemConfig['timezone'], \DateTimeZone::listIdentifiers(), true)) {
|
if (in_array($this->systemConfig['timezone'], \DateTimeZone::listIdentifiers(), true)) {
|
||||||
Settings::set('timezone', $this->systemConfig['timezone']);
|
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 =====================
|
// ===================== LDAP CONFIG =====================
|
||||||
@@ -1323,8 +1538,10 @@ class Panel extends Component
|
|||||||
Settings::set('bookstack_verify_ssl', $this->bookstackConfig['verifySsl'] ? '1' : '0');
|
Settings::set('bookstack_verify_ssl', $this->bookstackConfig['verifySsl'] ? '1' : '0');
|
||||||
Settings::set('bookstack_show_to_guests', $this->bookstackConfig['showToGuests'] ? '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', implode(',', BookStackClient::normalizeSearchTypes($this->bookstackConfig['searchTypes'])));
|
||||||
Settings::set('bookstack_search_types', $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']));
|
Settings::set('bookstack_allowed_shelf_ids_creation', implode(',', $this->bookstackConfig['allowedShelfIdsCreation']));
|
||||||
@@ -1374,6 +1591,15 @@ class Panel extends Component
|
|||||||
: [...$ids, $id];
|
: [...$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
|
public function testBookstackConnection(): void
|
||||||
{
|
{
|
||||||
$cfg = $this->bookstackConfig;
|
$cfg = $this->bookstackConfig;
|
||||||
@@ -1392,73 +1618,91 @@ class Panel extends Component
|
|||||||
$this->bookstackTestMessage = $result['message'];
|
$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
|
* Runs synchronously in the request (no queue worker runs in this
|
||||||
* current (unsaved) values, temporarily overriding the mail config the
|
* deployment — see CLAUDE.md — so a dispatched job would just sit in the
|
||||||
* same way AppServiceProvider does for real once saved — so this test
|
* `jobs` table). Safe to click again if it times out on a large wiki:
|
||||||
* exercises the exact path production notifications will use.
|
* 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 (! app(BookStackClient::class)->enabled() || ! app(AiClient::class)->enabled()) {
|
||||||
|
$this->bookstackTagResult = null;
|
||||||
if (empty($cfg['smtpHost']) || empty($cfg['fromAddress'])) {
|
$this->bookstackTagError = 'Włącz i skonfiguruj obie integracje — BookStack oraz AI — przed uruchomieniem tagowania.';
|
||||||
$this->mailTestResult = 'error';
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$original = Config::get('mail');
|
$this->bookstackTagError = null;
|
||||||
|
|
||||||
try {
|
set_time_limit(0);
|
||||||
Config::set('mail.default', 'smtp');
|
$this->bookstackTagResult = app(BookStackContentTagger::class)->run(force: $force);
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function runBookstackTaggingForce(): void
|
||||||
|
{
|
||||||
|
$this->runBookstackTagging(force: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 =====================
|
// ===================== GENERIC DELETE CONFIRM =====================
|
||||||
@@ -1491,10 +1735,11 @@ class Panel extends Component
|
|||||||
'user-field' => UserField::query()->find($this->pendingDeleteId)?->delete(),
|
'user-field' => UserField::query()->find($this->pendingDeleteId)?->delete(),
|
||||||
'reply-quick-action' => ReplyQuickAction::query()->find($this->pendingDeleteId)?->delete(),
|
'reply-quick-action' => ReplyQuickAction::query()->find($this->pendingDeleteId)?->delete(),
|
||||||
'response-template' => ResponseTemplate::query()->find($this->pendingDeleteId)?->delete(),
|
'response-template' => ResponseTemplate::query()->find($this->pendingDeleteId)?->delete(),
|
||||||
|
'automation-rule' => AutomationRule::query()->find($this->pendingDeleteId)?->delete(),
|
||||||
default => null,
|
default => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
unset($this->categories, $this->customFields, $this->statuses, $this->priorities, $this->teams, $this->users, $this->userFields, $this->replyQuickActions, $this->responseTemplates, $this->notificationSettings);
|
unset($this->categories, $this->customFields, $this->statuses, $this->priorities, $this->teams, $this->users, $this->userFields, $this->replyQuickActions, $this->responseTemplates, $this->notificationSettings, $this->automationRules);
|
||||||
|
|
||||||
$this->cancelPendingDelete();
|
$this->cancelPendingDelete();
|
||||||
}
|
}
|
||||||
|
|||||||
342
src/app/Livewire/Admin/Triggers.php
Normal file
342
src/app/Livewire/Admin/Triggers.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,16 @@ class NewTicket extends Component
|
|||||||
|
|
||||||
public array $attachments = [];
|
public array $attachments = [];
|
||||||
|
|
||||||
|
// Set via wire:init (see the blade view) rather than on the initial
|
||||||
|
// render, so the BookStack HTTP call in suggestedArticles() never
|
||||||
|
// delays the page's first paint — it loads in a beat later instead.
|
||||||
|
public bool $suggestedArticlesLoaded = false;
|
||||||
|
|
||||||
|
public function loadSuggestedArticles(): void
|
||||||
|
{
|
||||||
|
$this->suggestedArticlesLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
#[Computed]
|
#[Computed]
|
||||||
public function categories()
|
public function categories()
|
||||||
{
|
{
|
||||||
@@ -67,9 +77,14 @@ class NewTicket extends Component
|
|||||||
#[Computed]
|
#[Computed]
|
||||||
public function suggestedArticles(): array
|
public function suggestedArticles(): array
|
||||||
{
|
{
|
||||||
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? ''));
|
if (! $this->suggestedArticlesLoaded) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
return app(BookStackClient::class)->search($query);
|
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? ''));
|
||||||
|
$tagQuery = trim($this->selectedSubcategory?->name ?? '');
|
||||||
|
|
||||||
|
return app(BookStackClient::class)->search($query, tagQuery: $tagQuery);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function backToCategory(): void
|
public function backToCategory(): void
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ namespace App\Livewire\Client;
|
|||||||
|
|
||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
use App\Models\TicketMessage;
|
use App\Models\TicketMessage;
|
||||||
|
use App\Services\BookStackClient;
|
||||||
use App\Services\TicketService;
|
use App\Services\TicketService;
|
||||||
use App\Support\Settings;
|
use App\Support\Settings;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Livewire\Attributes\Computed;
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Attributes\On;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Livewire\WithFileUploads;
|
use Livewire\WithFileUploads;
|
||||||
|
|
||||||
@@ -31,6 +33,16 @@ class TicketShow extends Component
|
|||||||
|
|
||||||
public string $csatComment = '';
|
public string $csatComment = '';
|
||||||
|
|
||||||
|
// Set via wire:init (see the blade view) rather than on the initial
|
||||||
|
// render, so the BookStack HTTP call in suggestedArticles() never
|
||||||
|
// delays the ticket page's first paint — it loads in a beat later instead.
|
||||||
|
public bool $suggestedArticlesLoaded = false;
|
||||||
|
|
||||||
|
public function loadSuggestedArticles(): void
|
||||||
|
{
|
||||||
|
$this->suggestedArticlesLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
public function mount(Ticket $ticket): void
|
public function mount(Ticket $ticket): void
|
||||||
{
|
{
|
||||||
abort_unless($ticket->customer_id === Auth::id(), 403);
|
abort_unless($ticket->customer_id === Auth::id(), 403);
|
||||||
@@ -44,12 +56,77 @@ class TicketShow extends Component
|
|||||||
return $this->ticket->publicMessages()->with(['author', 'authorLink.role', 'attachments'])->get();
|
return $this->ticket->publicMessages()->with(['author', 'authorLink.role', 'attachments'])->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridged from a TicketMessagePosted broadcast on this ticket's own
|
||||||
|
* channel (see resources/js/echo.js) — an operator's reply appears
|
||||||
|
* without the client refreshing, the "live chat" effect. Ignores events
|
||||||
|
* for any other ticket id since Livewire dispatches are page-wide.
|
||||||
|
*/
|
||||||
|
#[On('ticket-message-posted')]
|
||||||
|
public function onTicketMessagePosted(int $ticketId): void
|
||||||
|
{
|
||||||
|
if ($ticketId !== $this->ticket->id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($this->ticketMessages);
|
||||||
|
$this->ticket->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridged from a TicketQueueChanged broadcast on this ticket's own
|
||||||
|
* channel — lets the client see a status/priority/team/assignee change
|
||||||
|
* (and the resulting history entry) made by an operator, or by an
|
||||||
|
* automation rule firing in the background, without refreshing.
|
||||||
|
*/
|
||||||
|
#[On('queue-changed')]
|
||||||
|
public function onQueueChanged(int $ticketId): void
|
||||||
|
{
|
||||||
|
if ($ticketId !== $this->ticket->id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->ticket->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Periodic fallback refresh (see the countdown badge in the blade view)
|
||||||
|
* — broadcasting is best-effort, a dropped websocket connection
|
||||||
|
* shouldn't mean the thread/ticket silently stops updating.
|
||||||
|
*/
|
||||||
|
public function refreshTicketData(): void
|
||||||
|
{
|
||||||
|
unset($this->ticketMessages);
|
||||||
|
$this->ticket->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
#[Computed]
|
#[Computed]
|
||||||
public function otherTickets()
|
public function otherTickets()
|
||||||
{
|
{
|
||||||
return Auth::user()->ticketsAsCustomer()->where('id', '!=', $this->ticket->id)->get();
|
return Auth::user()->ticketsAsCustomer()->where('id', '!=', $this->ticket->id)->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same allow-listed shelves (CONTEXT_CREATION) and category/subcategory
|
||||||
|
* query the ticket-creation wizard used, so the client sees the same
|
||||||
|
* suggestions here as they did while writing the ticket.
|
||||||
|
*
|
||||||
|
* @return array<int, array{name: string, url: ?string, type: string, book: ?string, shelf: ?string}>
|
||||||
|
*/
|
||||||
|
#[Computed]
|
||||||
|
public function suggestedArticles(): array
|
||||||
|
{
|
||||||
|
if (! $this->suggestedArticlesLoaded) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$subcategory = $this->ticket->subcategory;
|
||||||
|
$query = trim(($subcategory?->category?->name ?? '').' '.($subcategory?->name ?? ''));
|
||||||
|
$tagQuery = trim($subcategory?->name ?? '');
|
||||||
|
|
||||||
|
return app(BookStackClient::class)->search($query, tagQuery: $tagQuery);
|
||||||
|
}
|
||||||
|
|
||||||
public function updatedAttachments(): void
|
public function updatedAttachments(): void
|
||||||
{
|
{
|
||||||
if (! $this->attachments) {
|
if (! $this->attachments) {
|
||||||
|
|||||||
@@ -36,6 +36,16 @@ class Landing extends Component
|
|||||||
|
|
||||||
public ?int $submittedTicketId = null;
|
public ?int $submittedTicketId = null;
|
||||||
|
|
||||||
|
// Set via wire:init (see the blade view) rather than on the initial
|
||||||
|
// render, so the BookStack HTTP call in suggestedArticles() never
|
||||||
|
// delays the page's first paint — it loads in a beat later instead.
|
||||||
|
public bool $suggestedArticlesLoaded = false;
|
||||||
|
|
||||||
|
public function loadSuggestedArticles(): void
|
||||||
|
{
|
||||||
|
$this->suggestedArticlesLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
#[Computed]
|
#[Computed]
|
||||||
public function categories()
|
public function categories()
|
||||||
{
|
{
|
||||||
@@ -85,13 +95,14 @@ class Landing extends Component
|
|||||||
#[Computed]
|
#[Computed]
|
||||||
public function suggestedArticles(): array
|
public function suggestedArticles(): array
|
||||||
{
|
{
|
||||||
if (! Settings::bool('bookstack_show_to_guests')) {
|
if (! $this->suggestedArticlesLoaded || ! Settings::bool('bookstack_show_to_guests')) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? ''));
|
$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
|
public function backToCategory(): void
|
||||||
|
|||||||
@@ -4,14 +4,33 @@ namespace App\Livewire;
|
|||||||
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Livewire\Attributes\Computed;
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Attributes\On;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class NotificationBell extends 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
|
||||||
|
* listed dimmed. The full history still lives in the notifications
|
||||||
|
* table for anyone querying it directly, just not surfaced here.
|
||||||
|
*/
|
||||||
#[Computed]
|
#[Computed]
|
||||||
public function notifications()
|
public function notifications()
|
||||||
{
|
{
|
||||||
return Auth::user()->notifications()->latest()->limit(20)->get();
|
return Auth::user()->unreadNotifications()->latest()->limit(20)->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Computed]
|
#[Computed]
|
||||||
|
|||||||
@@ -33,6 +33,16 @@ class NewTicket extends Component
|
|||||||
|
|
||||||
public array $attachments = [];
|
public array $attachments = [];
|
||||||
|
|
||||||
|
// Set via wire:init (see the blade view) rather than on the initial
|
||||||
|
// render, so the BookStack HTTP call in suggestedArticles() never
|
||||||
|
// delays the page's first paint — it loads in a beat later instead.
|
||||||
|
public bool $suggestedArticlesLoaded = false;
|
||||||
|
|
||||||
|
public function loadSuggestedArticles(): void
|
||||||
|
{
|
||||||
|
$this->suggestedArticlesLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
#[Computed]
|
#[Computed]
|
||||||
public function clients()
|
public function clients()
|
||||||
{
|
{
|
||||||
@@ -76,9 +86,14 @@ class NewTicket extends Component
|
|||||||
#[Computed]
|
#[Computed]
|
||||||
public function suggestedArticles(): array
|
public function suggestedArticles(): array
|
||||||
{
|
{
|
||||||
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? ''));
|
if (! $this->suggestedArticlesLoaded) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
return app(BookStackClient::class)->search($query);
|
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? ''));
|
||||||
|
$tagQuery = trim($this->selectedSubcategory?->name ?? '');
|
||||||
|
|
||||||
|
return app(BookStackClient::class)->search($query, tagQuery: $tagQuery);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function backToCategory(): void
|
public function backToCategory(): void
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Livewire\Operator;
|
namespace App\Livewire\Operator;
|
||||||
|
|
||||||
|
use App\Events\TicketQueueChanged;
|
||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
use App\Models\Priority;
|
use App\Models\Priority;
|
||||||
use App\Models\Status;
|
use App\Models\Status;
|
||||||
@@ -11,11 +12,13 @@ use App\Models\User;
|
|||||||
use App\Services\TicketService;
|
use App\Services\TicketService;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Livewire\Attributes\Computed;
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Attributes\On;
|
||||||
use Livewire\Attributes\Url;
|
use Livewire\Attributes\Url;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class Queue extends Component
|
class Queue extends Component
|
||||||
{
|
{
|
||||||
|
#[Url]
|
||||||
public string $queue = 'all';
|
public string $queue = 'all';
|
||||||
|
|
||||||
public string $filterStatus = 'all';
|
public string $filterStatus = 'all';
|
||||||
@@ -65,6 +68,33 @@ class Queue extends Component
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridged from a TicketQueueChanged broadcast via resources/js/echo.js —
|
||||||
|
* see that file for why this is a plain Livewire event rather than an
|
||||||
|
* `#[On('echo-private:...')]` attribute. Re-queries through the same
|
||||||
|
* visibleToOperator()-scoped computed property a manual refresh would
|
||||||
|
* use, so a ticket that just became invisible to this operator (closed,
|
||||||
|
* reassigned away, moved to another team) simply won't come back, and
|
||||||
|
* its id is pruned from the current selection so a stale checkbox
|
||||||
|
* doesn't linger for a row that's no longer on screen.
|
||||||
|
*/
|
||||||
|
#[On('queue-changed')]
|
||||||
|
public function onQueueChanged(int $ticketId = 0): void
|
||||||
|
{
|
||||||
|
$this->refreshQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Periodic fallback refresh (see the countdown badge next to "Kolumny"
|
||||||
|
* in the blade view) — broadcasting is best-effort, a dropped websocket
|
||||||
|
* connection shouldn't mean the queue silently stops updating.
|
||||||
|
*/
|
||||||
|
public function refreshQueue(): void
|
||||||
|
{
|
||||||
|
unset($this->filteredTickets);
|
||||||
|
$this->selectedIds = array_values(array_intersect($this->selectedIds, $this->filteredTickets->pluck('id')->all()));
|
||||||
|
}
|
||||||
|
|
||||||
#[Computed]
|
#[Computed]
|
||||||
public function savedViews()
|
public function savedViews()
|
||||||
{
|
{
|
||||||
@@ -268,7 +298,13 @@ class Queue extends Component
|
|||||||
$query->where('priority_key', $this->filterPriority);
|
$query->where('priority_key', $this->filterPriority);
|
||||||
}
|
}
|
||||||
if ($this->filterCategory !== 'all') {
|
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) {
|
if ($this->filterCustomerId) {
|
||||||
$query->where('customer_id', $this->filterCustomerId);
|
$query->where('customer_id', $this->filterCustomerId);
|
||||||
@@ -277,7 +313,7 @@ class Queue extends Component
|
|||||||
$query->search($this->search);
|
$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);
|
return $this->sortTickets($tickets);
|
||||||
}
|
}
|
||||||
@@ -301,6 +337,9 @@ class Queue extends Component
|
|||||||
'priority' => $tickets->sortBy(fn (Ticket $t) => $t->priority?->sort_order ?? PHP_INT_MAX, SORT_REGULAR, $desc),
|
'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),
|
'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),
|
'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),
|
default => $tickets->sortBy('updated_at', SORT_REGULAR, $desc),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -317,10 +356,13 @@ class Queue extends Component
|
|||||||
'subject' => 'Temat',
|
'subject' => 'Temat',
|
||||||
'customer' => 'Klient',
|
'customer' => 'Klient',
|
||||||
'category' => 'Kategoria',
|
'category' => 'Kategoria',
|
||||||
|
'subcategory' => 'Podkategoria',
|
||||||
'priority' => 'Priorytet',
|
'priority' => 'Priorytet',
|
||||||
'status' => 'Status',
|
'status' => 'Status',
|
||||||
'sla' => 'SLA',
|
'sla' => 'SLA',
|
||||||
'assignee' => 'Przypisany',
|
'assignee' => 'Przypisany',
|
||||||
|
'team' => 'Zespół',
|
||||||
|
'created' => 'Utworzono',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,7 +373,7 @@ class Queue extends Component
|
|||||||
*/
|
*/
|
||||||
public function sortableColumns(): array
|
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
|
public function sortByColumn(string $column): void
|
||||||
@@ -389,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
|
public function mergeSelected(): void
|
||||||
{
|
{
|
||||||
$ids = $this->selectedIdsInScope();
|
$ids = $this->selectedIdsInScope();
|
||||||
@@ -417,7 +473,13 @@ class Queue extends Component
|
|||||||
|
|
||||||
public function confirmDeleteSelected(): void
|
public function confirmDeleteSelected(): void
|
||||||
{
|
{
|
||||||
Ticket::query()->visibleToOperator(Auth::user())->whereIn('id', $this->selectedIds)->delete();
|
$ids = Ticket::query()->visibleToOperator(Auth::user())->whereIn('id', $this->selectedIds)->pluck('id');
|
||||||
|
Ticket::query()->whereIn('id', $ids)->delete();
|
||||||
|
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
TicketQueueChanged::dispatch($id, 'deleted', Auth::id());
|
||||||
|
}
|
||||||
|
|
||||||
$this->selectedIds = [];
|
$this->selectedIds = [];
|
||||||
$this->pendingDeleteSelected = false;
|
$this->pendingDeleteSelected = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -312,6 +312,25 @@ class Stats extends Component
|
|||||||
->map(fn ($row) => ['label' => $row->label, 'count' => (int) $row->count]);
|
->map(fn ($row) => ['label' => $row->label, 'count' => (int) $row->count]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One level deeper than byCategory() — same shape, but grouped by the
|
||||||
|
* actual subcategory, labeled "Category / Subcategory" to disambiguate
|
||||||
|
* subcategories that share a name across different parent categories.
|
||||||
|
*/
|
||||||
|
#[Computed]
|
||||||
|
public function bySubcategory()
|
||||||
|
{
|
||||||
|
return (clone $this->baseQuery)
|
||||||
|
->whereNotNull('tickets.subcategory_id')
|
||||||
|
->join('subcategories', 'subcategories.id', '=', 'tickets.subcategory_id')
|
||||||
|
->join('categories', 'categories.id', '=', 'subcategories.category_id')
|
||||||
|
->select('subcategories.id', 'categories.name as category_name', 'subcategories.name as sub_name', DB::raw('count(*) as count'))
|
||||||
|
->groupBy('subcategories.id', 'categories.name', 'subcategories.name')
|
||||||
|
->orderByDesc('count')
|
||||||
|
->get()
|
||||||
|
->map(fn ($row) => ['label' => $row->category_name.' / '.$row->sub_name, 'count' => (int) $row->count]);
|
||||||
|
}
|
||||||
|
|
||||||
#[Computed]
|
#[Computed]
|
||||||
public function byTeam()
|
public function byTeam()
|
||||||
{
|
{
|
||||||
@@ -351,6 +370,170 @@ class Stats extends Component
|
|||||||
return $rows;
|
return $rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlike teams/operators (small, fixed sets), the customer list is
|
||||||
|
* unbounded — capped to the top 10 by ticket volume in the current
|
||||||
|
* filtered range rather than listing every client who ever wrote in.
|
||||||
|
* Guest submissions (no account) are summed into one "Goście" bucket
|
||||||
|
* rather than grouped by e-mail, since a guest has no stable identity
|
||||||
|
* to rank against registered clients.
|
||||||
|
*/
|
||||||
|
#[Computed]
|
||||||
|
public function byCustomer()
|
||||||
|
{
|
||||||
|
$rows = (clone $this->baseQuery)
|
||||||
|
->whereNotNull('tickets.customer_id')
|
||||||
|
->join('users', 'users.id', '=', 'tickets.customer_id')
|
||||||
|
->select('users.id', 'users.name as label', DB::raw('count(*) as count'))
|
||||||
|
->groupBy('users.id', 'users.name')
|
||||||
|
->orderByDesc('count')
|
||||||
|
->limit(10)
|
||||||
|
->get()
|
||||||
|
->map(fn ($row) => ['label' => $row->label, 'count' => (int) $row->count]);
|
||||||
|
|
||||||
|
$guestCount = (clone $this->baseQuery)->whereNull('tickets.customer_id')->count();
|
||||||
|
|
||||||
|
if ($guestCount > 0) {
|
||||||
|
$rows->push(['label' => 'Goście (bez konta)', 'count' => $guestCount]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows->sortByDesc('count')->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client × subcategory cross-tab — which clients' tickets fall into
|
||||||
|
* which kind of subcategory. Both dimensions are unbounded (unlike
|
||||||
|
* teams/operators), so this caps to the top 10 clients by overall
|
||||||
|
* volume (rows, mirroring byCustomer()) and the top 5 subcategories by
|
||||||
|
* overall volume (columns, mirroring assigneeSubcategoryMatrix()'s
|
||||||
|
* "Inne" folding) — otherwise the table could grow arbitrarily in both
|
||||||
|
* directions. Guest tickets (no customer_id) are excluded entirely
|
||||||
|
* rather than folded into one "guest" row, since mixing a real client's
|
||||||
|
* per-subcategory pattern with an anonymous aggregate wouldn't mean
|
||||||
|
* anything.
|
||||||
|
*
|
||||||
|
* @return array{columns: array<int, string>, hasOther: bool, rows: array<int, array{label: string, cells: array<int, int>, other: ?int, total: int}>}
|
||||||
|
*/
|
||||||
|
#[Computed]
|
||||||
|
public function customerSubcategoryMatrix(): array
|
||||||
|
{
|
||||||
|
$raw = (clone $this->baseQuery)
|
||||||
|
->whereNotNull('tickets.customer_id')
|
||||||
|
->whereNotNull('tickets.subcategory_id')
|
||||||
|
->join('subcategories', 'subcategories.id', '=', 'tickets.subcategory_id')
|
||||||
|
->join('categories', 'categories.id', '=', 'subcategories.category_id')
|
||||||
|
->join('users', 'users.id', '=', 'tickets.customer_id')
|
||||||
|
->select(
|
||||||
|
'tickets.customer_id',
|
||||||
|
'users.name as customer_name',
|
||||||
|
'subcategories.id as subcategory_id',
|
||||||
|
'categories.name as category_name',
|
||||||
|
'subcategories.name as subcategory_name',
|
||||||
|
DB::raw('count(*) as total'),
|
||||||
|
)
|
||||||
|
->groupBy('tickets.customer_id', 'users.name', 'subcategories.id', 'categories.name', 'subcategories.name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($raw->isEmpty()) {
|
||||||
|
return ['columns' => [], 'hasOther' => false, 'rows' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
$subcategoryTotals = $raw->groupBy('subcategory_id')->map(fn ($g) => $g->sum('total'));
|
||||||
|
$topSubcategoryIds = $subcategoryTotals->sortDesc()->keys()->take(5);
|
||||||
|
|
||||||
|
$subcategoryLabels = $raw->unique('subcategory_id')->keyBy('subcategory_id')
|
||||||
|
->map(fn ($r) => $r->category_name.' / '.$r->subcategory_name);
|
||||||
|
|
||||||
|
$columns = $topSubcategoryIds->map(fn ($id) => $subcategoryLabels[$id])->values()->all();
|
||||||
|
$hasOther = $subcategoryTotals->keys()->diff($topSubcategoryIds)->isNotEmpty();
|
||||||
|
|
||||||
|
$byCustomer = $raw->groupBy('customer_id');
|
||||||
|
$customerNames = $raw->unique('customer_id')->keyBy('customer_id')->map(fn ($r) => $r->customer_name);
|
||||||
|
$topCustomerIds = $byCustomer->map(fn ($g) => $g->sum('total'))->sortDesc()->keys()->take(10);
|
||||||
|
|
||||||
|
$rows = $topCustomerIds
|
||||||
|
->map(function ($customerId) use ($byCustomer, $customerNames, $topSubcategoryIds, $hasOther) {
|
||||||
|
$entries = $byCustomer->get($customerId, collect());
|
||||||
|
$bySubcategory = $entries->keyBy('subcategory_id');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'label' => $customerNames[$customerId],
|
||||||
|
'cells' => $topSubcategoryIds->map(fn ($id) => (int) ($bySubcategory[$id]->total ?? 0))->values()->all(),
|
||||||
|
'other' => $hasOther ? (int) $entries->whereNotIn('subcategory_id', $topSubcategoryIds->all())->sum('total') : null,
|
||||||
|
'total' => (int) $entries->sum('total'),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return ['columns' => $columns, 'hasOther' => $hasOther, 'rows' => $rows];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Average CSAT rating per team, only among rated tickets in the current
|
||||||
|
* filtered range — mirrors byTeam()'s "Bez zespołu" bucket handling, but
|
||||||
|
* teams/buckets with zero ratings are dropped entirely (an average of
|
||||||
|
* nothing isn't a meaningful bar to draw).
|
||||||
|
*/
|
||||||
|
#[Computed]
|
||||||
|
public function csatByTeam()
|
||||||
|
{
|
||||||
|
$stats = (clone $this->baseQuery)
|
||||||
|
->whereNotNull('csat_rating')
|
||||||
|
->select('team_id', DB::raw('avg(csat_rating) as avg_rating'), DB::raw('count(*) as rated_count'))
|
||||||
|
->groupBy('team_id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$avgs = $stats->pluck('avg_rating', 'team_id');
|
||||||
|
$counts = $stats->pluck('rated_count', 'team_id');
|
||||||
|
|
||||||
|
$rows = $this->teams
|
||||||
|
->map(fn (Team $t) => [
|
||||||
|
'label' => $t->name,
|
||||||
|
'avg' => isset($avgs[$t->id]) ? round((float) $avgs[$t->id], 2) : null,
|
||||||
|
'count' => (int) ($counts[$t->id] ?? 0),
|
||||||
|
])
|
||||||
|
->filter(fn ($row) => $row['count'] > 0)
|
||||||
|
->values();
|
||||||
|
|
||||||
|
if ($counts->get(null, 0)) {
|
||||||
|
$rows->push(['label' => 'Bez zespołu', 'avg' => round((float) $avgs->get(null), 2), 'count' => (int) $counts->get(null)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows->sortByDesc('avg')->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Average CSAT rating per assignee, same shape/semantics as csatByTeam().
|
||||||
|
*/
|
||||||
|
#[Computed]
|
||||||
|
public function csatByAssignee()
|
||||||
|
{
|
||||||
|
$stats = (clone $this->baseQuery)
|
||||||
|
->whereNotNull('csat_rating')
|
||||||
|
->select('assignee_id', DB::raw('avg(csat_rating) as avg_rating'), DB::raw('count(*) as rated_count'))
|
||||||
|
->groupBy('assignee_id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$avgs = $stats->pluck('avg_rating', 'assignee_id');
|
||||||
|
$counts = $stats->pluck('rated_count', 'assignee_id');
|
||||||
|
|
||||||
|
$rows = $this->operators
|
||||||
|
->map(fn (User $u) => [
|
||||||
|
'label' => $u->name,
|
||||||
|
'avg' => isset($avgs[$u->id]) ? round((float) $avgs[$u->id], 2) : null,
|
||||||
|
'count' => (int) ($counts[$u->id] ?? 0),
|
||||||
|
])
|
||||||
|
->filter(fn ($row) => $row['count'] > 0)
|
||||||
|
->values();
|
||||||
|
|
||||||
|
if ($counts->get(null, 0)) {
|
||||||
|
$rows->push(['label' => 'Nieprzypisane', 'avg' => round((float) $avgs->get(null), 2), 'count' => (int) $counts->get(null)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows->sortByDesc('avg')->values();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Daily created-vs-closed volume, capped at the most recent 60 days so a
|
* Daily created-vs-closed volume, capped at the most recent 60 days so a
|
||||||
* wide range (or "Cały okres") never renders an unreadably thin column
|
* wide range (or "Cały okres") never renders an unreadably thin column
|
||||||
@@ -422,7 +605,7 @@ class Stats extends Component
|
|||||||
|
|
||||||
foreach ($tickets as $ticket) {
|
foreach ($tickets as $ticket) {
|
||||||
fputcsv($out, [
|
fputcsv($out, [
|
||||||
$ticket->number,
|
$ticket->displayNumber(),
|
||||||
$ticket->subject,
|
$ticket->subject,
|
||||||
$ticket->statusLabel(),
|
$ticket->statusLabel(),
|
||||||
$ticket->priorityLabel(),
|
$ticket->priorityLabel(),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Livewire\Operator;
|
namespace App\Livewire\Operator;
|
||||||
|
|
||||||
|
use App\Events\TicketQueueChanged;
|
||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
use App\Models\Priority;
|
use App\Models\Priority;
|
||||||
use App\Models\ReplyQuickAction;
|
use App\Models\ReplyQuickAction;
|
||||||
@@ -15,8 +16,10 @@ use App\Models\User;
|
|||||||
use App\Services\BookStackClient;
|
use App\Services\BookStackClient;
|
||||||
use App\Services\TicketService;
|
use App\Services\TicketService;
|
||||||
use App\Support\Settings;
|
use App\Support\Settings;
|
||||||
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Livewire\Attributes\Computed;
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Attributes\On;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Livewire\WithFileUploads;
|
use Livewire\WithFileUploads;
|
||||||
|
|
||||||
@@ -66,6 +69,28 @@ class TicketShow extends Component
|
|||||||
|
|
||||||
public string $editTimerSeconds = '0';
|
public string $editTimerSeconds = '0';
|
||||||
|
|
||||||
|
// Set via wire:init (see the blade view) rather than on the initial
|
||||||
|
// render, so the BookStack HTTP call in suggestedArticles() never
|
||||||
|
// delays the ticket page's first paint — it loads in a beat later instead.
|
||||||
|
public bool $suggestedArticlesLoaded = false;
|
||||||
|
|
||||||
|
public function loadSuggestedArticles(): void
|
||||||
|
{
|
||||||
|
$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 function mount(Ticket $ticket): void
|
public function mount(Ticket $ticket): void
|
||||||
{
|
{
|
||||||
abort_unless($ticket->isVisibleToOperator(Auth::user()), 403);
|
abort_unless($ticket->isVisibleToOperator(Auth::user()), 403);
|
||||||
@@ -79,6 +104,36 @@ class TicketShow extends Component
|
|||||||
$this->ticket->resumeTimer();
|
$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 --------
|
// -------- time tracking --------
|
||||||
|
|
||||||
public function stopTimer(): void
|
public function stopTimer(): void
|
||||||
@@ -168,6 +223,83 @@ class TicketShow extends Component
|
|||||||
return $this->ticket->internalMessages()->with(['author', 'authorLink.role', 'attachments'])->get();
|
return $this->ticket->internalMessages()->with(['author', 'authorLink.role', 'attachments'])->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridged from a TicketMessagePosted broadcast on this ticket's own
|
||||||
|
* channel (see resources/js/echo.js) — a new reply/note from the other
|
||||||
|
* party appears without the viewer refreshing, the "live chat" effect.
|
||||||
|
* Ignores events for any other ticket id, since Livewire dispatches are
|
||||||
|
* page-wide and this component only cares about its own ticket.
|
||||||
|
*/
|
||||||
|
#[On('ticket-message-posted')]
|
||||||
|
public function onTicketMessagePosted(int $ticketId): void
|
||||||
|
{
|
||||||
|
if ($ticketId !== $this->ticket->id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
* change made by another operator, or by an automation rule firing in
|
||||||
|
* the background, show up live on a ticket someone currently has open.
|
||||||
|
*/
|
||||||
|
#[On('queue-changed')]
|
||||||
|
public function onQueueChanged(int $ticketId): void
|
||||||
|
{
|
||||||
|
if ($ticketId !== $this->ticket->id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->refreshOrRedirectAway();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Periodic fallback refresh (see the countdown badge in the blade view)
|
||||||
|
* — broadcasting is best-effort, a dropped websocket connection
|
||||||
|
* shouldn't mean the thread/ticket silently stops updating.
|
||||||
|
*/
|
||||||
|
public function refreshTicketData(): void
|
||||||
|
{
|
||||||
|
if (! $this->refreshOrRedirectAway()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($this->publicMessages, $this->internalMessages);
|
||||||
|
}
|
||||||
|
|
||||||
#[Computed]
|
#[Computed]
|
||||||
public function statuses()
|
public function statuses()
|
||||||
{
|
{
|
||||||
@@ -192,26 +324,28 @@ class TicketShow extends Component
|
|||||||
#[Computed]
|
#[Computed]
|
||||||
public function suggestedArticles(): array
|
public function suggestedArticles(): array
|
||||||
{
|
{
|
||||||
|
if (! $this->suggestedArticlesLoaded) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
$subcategory = $this->ticket->subcategory;
|
$subcategory = $this->ticket->subcategory;
|
||||||
$query = trim(($subcategory?->category?->name ?? '').' '.($subcategory?->name ?? ''));
|
$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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A non-admin operator can only reassign a ticket to one of their own
|
* Every team, regardless of the viewing operator's own membership —
|
||||||
* teams (mirrors the visibility scoping in Operator\Queue).
|
* 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]
|
#[Computed]
|
||||||
public function teams()
|
public function teams()
|
||||||
{
|
{
|
||||||
$query = Team::query();
|
return Team::query()->get();
|
||||||
|
|
||||||
if (! Auth::user()->isAdmin()) {
|
|
||||||
$query->whereHas('members', fn ($q) => $q->where('users.id', Auth::id()));
|
|
||||||
}
|
|
||||||
|
|
||||||
return $query->get();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Computed]
|
#[Computed]
|
||||||
@@ -285,7 +419,12 @@ class TicketShow extends Component
|
|||||||
public function setTeam(string $id): void
|
public function setTeam(string $id): void
|
||||||
{
|
{
|
||||||
app(TicketService::class)->setTeam($this->ticket, $id ? Team::query()->find($id) : null);
|
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 --------
|
// -------- reporter --------
|
||||||
@@ -500,7 +639,9 @@ class TicketShow extends Component
|
|||||||
|
|
||||||
public function confirmDeleteTicket(): void
|
public function confirmDeleteTicket(): void
|
||||||
{
|
{
|
||||||
|
$ticketId = $this->ticket->id;
|
||||||
$this->ticket->delete();
|
$this->ticket->delete();
|
||||||
|
TicketQueueChanged::dispatch($ticketId, 'deleted', Auth::id());
|
||||||
$this->redirect(route('operator.queue'), navigate: true);
|
$this->redirect(route('operator.queue'), navigate: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
44
src/app/Livewire/Settings/NotificationPreferences.php
Normal file
44
src/app/Livewire/Settings/NotificationPreferences.php
Normal 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()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/app/Models/AutomationRule.php
Normal file
35
src/app/Models/AutomationRule.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
#[Fillable([
|
||||||
|
'label', 'enabled', 'condition_minutes',
|
||||||
|
'scope_priority_key', 'scope_subcategory_id', 'scope_team_id',
|
||||||
|
'action_type', 'action_value',
|
||||||
|
])]
|
||||||
|
class AutomationRule extends Model
|
||||||
|
{
|
||||||
|
public const ACTION_TYPES = ['change_priority', 'change_status', 'change_team', 'change_assignee'];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'enabled' => 'boolean',
|
||||||
|
'condition_minutes' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logs(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(AutomationRuleTicketLog::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasFiredFor(Ticket $ticket): bool
|
||||||
|
{
|
||||||
|
return $this->logs()->where('ticket_id', $ticket->id)->exists();
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/app/Models/AutomationRuleTicketLog.php
Normal file
28
src/app/Models/AutomationRuleTicketLog.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
#[Fillable(['automation_rule_id', 'ticket_id', 'triggered_at'])]
|
||||||
|
class AutomationRuleTicketLog extends Model
|
||||||
|
{
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['triggered_at' => 'datetime'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rule(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(AutomationRule::class, 'automation_rule_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ticket(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Ticket::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,6 @@ class Category extends Model
|
|||||||
{
|
{
|
||||||
public function subcategories(): HasMany
|
public function subcategories(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Subcategory::class);
|
return $this->hasMany(Subcategory::class)->orderBy('sort_order');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
65
src/app/Models/ImapMailbox.php
Normal file
65
src/app/Models/ImapMailbox.php
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One inbound mailbox polled by `emails:fetch-imap` — an admin can configure
|
||||||
|
* several (e.g. zgloszenia-it@ vs zgloszenia-delegacje@), each landing new
|
||||||
|
* tickets in its own default subcategory. Unlike LDAP/SMTP/BookStack, this is
|
||||||
|
* a list of N configs rather than a Settings singleton, so it's a real model
|
||||||
|
* rather than key/value rows.
|
||||||
|
*/
|
||||||
|
#[Fillable([
|
||||||
|
'name', 'enabled', 'host', 'port', 'encryption', 'validate_cert',
|
||||||
|
'username', 'password', 'folder', 'processed_folder', 'rejected_folder',
|
||||||
|
'default_subcategory_id', 'default_category_id', 'blocklist_senders', 'last_checked_at', 'last_error',
|
||||||
|
])]
|
||||||
|
class ImapMailbox extends Model
|
||||||
|
{
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'enabled' => 'boolean',
|
||||||
|
'validate_cert' => 'boolean',
|
||||||
|
'password' => 'encrypted',
|
||||||
|
'last_checked_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function defaultSubcategory(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Subcategory::class, 'default_subcategory_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only meaningful when default_subcategory_id is null — a mailbox is
|
||||||
|
* routed to either a specific subcategory or a whole category, never
|
||||||
|
* both (enforced by the admin form's single combined selector).
|
||||||
|
*/
|
||||||
|
public function defaultCategory(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Category::class, 'default_category_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function blocklistedSenders(): array
|
||||||
|
{
|
||||||
|
return array_filter(array_map('trim', explode(',', (string) $this->blocklist_senders)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function targetLabel(): string
|
||||||
|
{
|
||||||
|
if ($this->defaultSubcategory) {
|
||||||
|
return $this->defaultSubcategory->category->name.' / '.$this->defaultSubcategory->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->defaultCategory) {
|
||||||
|
return 'Cała kategoria: '.$this->defaultCategory->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src/app/Models/NotificationPreference.php
Normal file
68
src/app/Models/NotificationPreference.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
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
|
class Subcategory extends Model
|
||||||
{
|
{
|
||||||
public function category(): BelongsTo
|
public function category(): BelongsTo
|
||||||
|
|||||||
@@ -2,31 +2,51 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Support\Settings;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'number', 'customer_id', 'email', 'name', 'subcategory_id', 'subject', 'body',
|
'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',
|
'status_key', 'priority_key', 'team_id', 'assignee_id', 'custom_fields', 'api_client_id', 'source',
|
||||||
'sla_notified_at', 'time_spent_seconds', 'timer_started_at', 'created_at', 'updated_at',
|
'sla_notified_at', 'last_customer_activity_at', 'time_spent_seconds', 'timer_started_at',
|
||||||
'csat_rating', 'csat_comment', 'csat_rated_at',
|
'created_at', 'updated_at', 'csat_rating', 'csat_comment', 'csat_rated_at',
|
||||||
|
'ai_triaged_at', 'ai_summary', 'ai_suggested_action', 'ai_summary_generated_at',
|
||||||
])]
|
])]
|
||||||
class Ticket extends Model
|
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
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'custom_fields' => 'array',
|
'custom_fields' => 'array',
|
||||||
'sla_notified_at' => 'datetime',
|
'sla_notified_at' => 'datetime',
|
||||||
|
'last_customer_activity_at' => 'datetime',
|
||||||
'time_spent_seconds' => 'integer',
|
'time_spent_seconds' => 'integer',
|
||||||
'timer_started_at' => 'datetime',
|
'timer_started_at' => 'datetime',
|
||||||
'csat_rating' => 'integer',
|
'csat_rating' => 'integer',
|
||||||
'csat_rated_at' => 'datetime',
|
'csat_rated_at' => 'datetime',
|
||||||
|
'ai_triaged_at' => 'datetime',
|
||||||
|
'ai_summary_generated_at' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +75,26 @@ class Ticket extends Model
|
|||||||
return $this->belongsTo(Subcategory::class);
|
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
|
public function status(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Status::class, 'status_key');
|
return $this->belongsTo(Status::class, 'status_key');
|
||||||
@@ -90,6 +130,11 @@ class Ticket extends Model
|
|||||||
return $this->hasMany(TicketHistory::class)->orderByDesc('created_at');
|
return $this->hasMany(TicketHistory::class)->orderByDesc('created_at');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function automationRuleLogs(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(AutomationRuleTicketLog::class);
|
||||||
|
}
|
||||||
|
|
||||||
public static function nextNumber(): string
|
public static function nextNumber(): string
|
||||||
{
|
{
|
||||||
$max = static::query()->pluck('number')->map(fn ($n) => (int) $n)->max();
|
$max = static::query()->pluck('number')->map(fn ($n) => (int) $n)->max();
|
||||||
@@ -97,9 +142,92 @@ class Ticket extends Model
|
|||||||
return (string) (($max ?: 1000) + 1);
|
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
|
public function categoryLabel(): string
|
||||||
{
|
{
|
||||||
return $this->subcategory?->label() ?? '';
|
return $this->subcategory?->label() ?? $this->category?->name ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -167,6 +295,7 @@ class Ticket extends Model
|
|||||||
}
|
}
|
||||||
|
|
||||||
$q->orWhere('number', 'like', $like)
|
$q->orWhere('number', 'like', $like)
|
||||||
|
->orWhere('checksum', 'like', $like)
|
||||||
->orWhere('name', 'like', $like)
|
->orWhere('name', 'like', $like)
|
||||||
->orWhere('email', 'like', $like)
|
->orWhere('email', 'like', $like)
|
||||||
->orWhereIn('id', $messageTicketIds);
|
->orWhereIn('id', $messageTicketIds);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasOneThrough;
|
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
|
class TicketMessage extends Model
|
||||||
{
|
{
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
|
|||||||
33
src/app/Models/Trigger.php
Normal file
33
src/app/Models/Trigger.php
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/app/Models/TriggerEmailTemplate.php
Normal file
26
src/app/Models/TriggerEmailTemplate.php
Normal 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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -184,6 +184,11 @@ class User extends Authenticatable implements LdapAuthenticatable
|
|||||||
return $this->hasMany(Ticket::class, 'customer_id');
|
return $this->hasMany(Ticket::class, 'customer_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function watchedTickets(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Ticket::class, 'ticket_watchers');
|
||||||
|
}
|
||||||
|
|
||||||
public function ticketsAssigned(): HasMany
|
public function ticketsAssigned(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Ticket::class, 'assignee_id');
|
return $this->hasMany(Ticket::class, 'assignee_id');
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Notifications;
|
|||||||
|
|
||||||
use App\Models\EmailTemplate;
|
use App\Models\EmailTemplate;
|
||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
|
use App\Models\TriggerEmailTemplate;
|
||||||
use App\Support\Settings;
|
use App\Support\Settings;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Notifications\AnonymousNotifiable;
|
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
|
* 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
|
* notifiable itself; it decides which ticket URL (client vs operator
|
||||||
* area) both the e-mail link and the in-app notification point to.
|
* 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
|
* A guest customer with no account is routed anonymously (see
|
||||||
@@ -30,7 +48,7 @@ class TicketNotification extends Notification
|
|||||||
*/
|
*/
|
||||||
public function via(object $notifiable): array
|
public function via(object $notifiable): array
|
||||||
{
|
{
|
||||||
return $notifiable instanceof AnonymousNotifiable ? ['mail'] : ['mail', 'database'];
|
return $notifiable instanceof AnonymousNotifiable ? ['mail'] : $this->channels;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function ticketUrl(): string
|
protected function ticketUrl(): string
|
||||||
@@ -44,19 +62,21 @@ class TicketNotification extends Notification
|
|||||||
'ticket_id' => $this->ticket->id,
|
'ticket_id' => $this->ticket->id,
|
||||||
'number' => $this->ticket->number,
|
'number' => $this->ticket->number,
|
||||||
'subject' => $this->ticket->subject,
|
'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(),
|
'url' => $this->ticketUrl(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function toMail(object $notifiable): MailMessage
|
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);
|
$firstName = trim(explode(' ', $this->ticket->name)[0] ?? $this->ticket->name);
|
||||||
|
|
||||||
$rendered = $template?->render([
|
$rendered = $template?->render([
|
||||||
'numer' => $this->ticket->number,
|
'numer' => $this->ticket->formattedNumber(),
|
||||||
'imie' => $firstName,
|
'imie' => $firstName,
|
||||||
'temat' => $this->ticket->subject,
|
'temat' => $this->ticket->subject,
|
||||||
'status' => $this->ticket->statusLabel(),
|
'status' => $this->ticket->statusLabel(),
|
||||||
@@ -67,7 +87,7 @@ class TicketNotification extends Notification
|
|||||||
'link' => $this->ticketUrl(),
|
'link' => $this->ticketUrl(),
|
||||||
'ocena' => route('client.ticket', $this->ticket).'#csat',
|
'ocena' => route('client.ticket', $this->ticket).'#csat',
|
||||||
]) ?? [
|
]) ?? [
|
||||||
'subject' => 'Zgłoszenie #'.$this->ticket->number,
|
'subject' => 'Zgłoszenie '.$this->ticket->displayNumber(),
|
||||||
'body' => $this->ticket->subject,
|
'body' => $this->ticket->subject,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,17 @@
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Events\NotificationCreated;
|
||||||
use App\Models\ApiClient;
|
use App\Models\ApiClient;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Notifications\TicketNotification;
|
||||||
use App\Support\Settings;
|
use App\Support\Settings;
|
||||||
use Illuminate\Cache\RateLimiting\Limit;
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Notifications\Events\NotificationSent;
|
||||||
use Illuminate\Support\Facades\Config;
|
use Illuminate\Support\Facades\Config;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
@@ -35,12 +39,35 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
$this->applySessionSettingsOverride();
|
$this->applySessionSettingsOverride();
|
||||||
$this->applyTimezoneSettingsOverride();
|
$this->applyTimezoneSettingsOverride();
|
||||||
$this->configureApiRateLimiting();
|
$this->configureApiRateLimiting();
|
||||||
|
$this->broadcastBellNotifications();
|
||||||
|
|
||||||
// 'user' backs the polymorphic notifiable_type column on the
|
// 'user' backs the polymorphic notifiable_type column on the
|
||||||
// database-notifications table (in-app notification bell).
|
// database-notifications table (in-app notification bell).
|
||||||
Relation::enforceMorphMap(['api_client' => ApiClient::class, 'user' => User::class]);
|
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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API keys get a generous per-key budget; unauthenticated requests (which
|
* API keys get a generous per-key budget; unauthenticated requests (which
|
||||||
* only ever hit the guard before rejecting with 401) get a much smaller
|
* only ever hit the guard before rejecting with 401) get a much smaller
|
||||||
@@ -58,13 +85,21 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Avoid touching the DB during artisan commands that run before the
|
* Avoid touching the DB during the specific artisan commands that run
|
||||||
* `settings` table exists (e.g. `migrate` itself), or before it can be
|
* before the `settings` table exists or could be mid-schema-change (the
|
||||||
* queried at all — shared by every settings-driven config override below.
|
* 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
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
95
src/app/Services/AiClient.php
Normal file
95
src/app/Services/AiClient.php
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Support\Settings;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic OpenAI-compatible chat-completions client — works against Groq,
|
||||||
|
* OpenAI itself, or a self-hosted Ollama instance's OpenAI-compat endpoint,
|
||||||
|
* whichever the admin points ai_base_url at. Not BookStack-specific; the
|
||||||
|
* BookStack content tagger is just the first consumer.
|
||||||
|
*/
|
||||||
|
class AiClient
|
||||||
|
{
|
||||||
|
public function enabled(): bool
|
||||||
|
{
|
||||||
|
// Deliberately no api-key requirement here — a self-hosted Ollama
|
||||||
|
// instance typically has no auth at all.
|
||||||
|
return Settings::bool('ai_enabled')
|
||||||
|
&& Settings::get('ai_base_url')
|
||||||
|
&& Settings::get('ai_model');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{role: string, content: string}> $messages
|
||||||
|
* @return string|null the assistant message content, or null on any failure
|
||||||
|
*/
|
||||||
|
public function chat(array $messages, array $options = []): ?string
|
||||||
|
{
|
||||||
|
if (! $this->enabled()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $this->client()->post('/chat/completions', [
|
||||||
|
'model' => Settings::get('ai_model'),
|
||||||
|
'messages' => $messages,
|
||||||
|
...$options,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response->json('choices.0.message.content');
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests unsaved admin-form values directly, rather than whatever's
|
||||||
|
* currently stored — mirrors BookStackClient::testConnection().
|
||||||
|
*
|
||||||
|
* @return array{ok: bool, message: ?string}
|
||||||
|
*/
|
||||||
|
public function testConnection(string $baseUrl, string $apiKey, string $model, bool $verifySsl = true): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$http = Http::withOptions(['verify' => $verifySsl])
|
||||||
|
->timeout(10)
|
||||||
|
->baseUrl(rtrim($baseUrl, '/'));
|
||||||
|
|
||||||
|
if ($apiKey !== '') {
|
||||||
|
$http = $http->withToken($apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $http->post('/chat/completions', [
|
||||||
|
'model' => $model,
|
||||||
|
'messages' => [['role' => 'user', 'content' => 'ping']],
|
||||||
|
'max_tokens' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
return ['ok' => true, 'message' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['ok' => false, 'message' => $response->json('error.message') ?? ('HTTP '.$response->status())];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return ['ok' => false, 'message' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function client()
|
||||||
|
{
|
||||||
|
$http = Http::withOptions(['verify' => Settings::bool('ai_verify_ssl')])
|
||||||
|
->timeout(60)
|
||||||
|
->baseUrl(rtrim(Settings::get('ai_base_url'), '/'));
|
||||||
|
|
||||||
|
$apiKey = Settings::get('ai_api_key');
|
||||||
|
|
||||||
|
return $apiKey ? $http->withToken($apiKey) : $http;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,31 @@ class BookStackClient
|
|||||||
self::CONTEXT_TICKET_VIEW => 'bookstack_allowed_shelf_ids_ticket_view',
|
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
|
public function enabled(): bool
|
||||||
{
|
{
|
||||||
return Settings::bool('bookstack_enabled')
|
return Settings::bool('bookstack_enabled')
|
||||||
@@ -39,44 +64,56 @@ class BookStackClient
|
|||||||
* before any content is ever suggested, independently per context).
|
* before any content is ever suggested, independently per context).
|
||||||
* Cached briefly since the same category/subcategory query repeats
|
* Cached briefly since the same category/subcategory query repeats
|
||||||
* across every ticket created/viewed with that combination. Respects the
|
* across every ticket created/viewed with that combination. Respects the
|
||||||
* admin-configured bookstack_search_types setting ('both'|'page'|'book')
|
* admin-configured bookstack_search_types (subset of SEARCH_TYPES, via
|
||||||
* via BookStack's own `{type:x}` query syntax. The cache key folds in the
|
* BookStack's `{type:a|b}` syntax) and bookstack_search_by ('name'|
|
||||||
* allowed-shelf list so changing it in Admin > Konfiguracja is reflected
|
* 'tags'|'both', via `{in_name:...}`/`[...]`) settings. The cache key
|
||||||
* immediately, instead of possibly serving a pre-change result for up to
|
* folds in the allowed-shelf list so changing it in Admin > Konfiguracja
|
||||||
* 10 minutes.
|
* 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}>
|
* @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);
|
$query = trim($query);
|
||||||
|
$tagQuery = trim($tagQuery ?? $query);
|
||||||
$allowedShelfIds = $this->allowedShelfIds($context);
|
$allowedShelfIds = $this->allowedShelfIds($context);
|
||||||
|
|
||||||
if (! $this->enabled() || $query === '' || ! $allowedShelfIds) {
|
if (! $this->enabled() || $query === '' || ! $allowedShelfIds) {
|
||||||
return [];
|
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)) {
|
$cacheKey = 'bookstack:search:'.md5(implode('||', $bookstackQueries).'|'.$limit.'|'.implode(',', $allowedShelfIds));
|
||||||
$query .= " {type:{$typeFilter}}";
|
|
||||||
}
|
|
||||||
|
|
||||||
$cacheKey = 'bookstack:search:'.md5($query.'|'.$limit.'|'.implode(',', $allowedShelfIds));
|
return Cache::remember($cacheKey, now()->addMinutes(10), function () use ($bookstackQueries, $limit, $allowedShelfIds) {
|
||||||
|
|
||||||
return Cache::remember($cacheKey, now()->addMinutes(10), function () use ($query, $limit, $allowedShelfIds) {
|
|
||||||
try {
|
try {
|
||||||
$response = $this->client()->get('/api/search', ['query' => $query, 'count' => $limit]);
|
|
||||||
|
|
||||||
if (! $response->successful()) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$shelfMap = $this->shelfBookMap();
|
$shelfMap = $this->shelfBookMap();
|
||||||
$allowedBookIds = $this->bookIdsForShelves($shelfMap, $allowedShelfIds);
|
$allowedBookIds = $this->bookIdsForShelves($shelfMap, $allowedShelfIds);
|
||||||
$bookShelfNames = $this->bookShelfNames($shelfMap);
|
$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) {
|
->filter(function (array $item) use ($allowedShelfIds, $allowedBookIds) {
|
||||||
$type = $item['type'] ?? null;
|
$type = $item['type'] ?? null;
|
||||||
|
|
||||||
@@ -103,6 +140,8 @@ class BookStackClient
|
|||||||
];
|
];
|
||||||
})
|
})
|
||||||
->filter(fn (array $item) => $item['name'] !== '')
|
->filter(fn (array $item) => $item['name'] !== '')
|
||||||
|
->unique(fn (array $item) => $item['url'] ?? $item['name'])
|
||||||
|
->take($limit)
|
||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
} catch (\Throwable) {
|
} 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
|
* 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/
|
* 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[]
|
* @return int[]
|
||||||
*/
|
*/
|
||||||
|
|||||||
260
src/app/Services/BookStackContentTagger.php
Normal file
260
src/app/Services/BookStackContentTagger.php
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Subcategory;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk-assigns BookStack tags to every book/chapter/page, using the
|
||||||
|
* configured AiClient to classify each item's title+content against the
|
||||||
|
* helpdesk's current subcategory vocabulary — so BookStackClient::search()'s
|
||||||
|
* 'tags'/'both' mode has something to actually match against. Idempotent by
|
||||||
|
* default: an item already carrying a tag matching a current subcategory
|
||||||
|
* name is skipped, so re-running after adding a handful of new pages is
|
||||||
|
* cheap; $force re-classifies everything.
|
||||||
|
*/
|
||||||
|
class BookStackContentTagger
|
||||||
|
{
|
||||||
|
protected const BATCH_SIZE = 20;
|
||||||
|
|
||||||
|
protected const CONTENT_EXCERPT_CHARS = 2000;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected BookStackClient $bookstack,
|
||||||
|
protected AiClient $ai,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{scanned: int, tagged: int, skipped: int, failed_batches: int}
|
||||||
|
*/
|
||||||
|
public function run(bool $dryRun = false, bool $force = false, ?int $limit = null): array
|
||||||
|
{
|
||||||
|
$totals = ['scanned' => 0, 'tagged' => 0, 'skipped' => 0, 'failed_batches' => 0];
|
||||||
|
|
||||||
|
if (! $this->bookstack->enabled() || ! $this->ai->enabled()) {
|
||||||
|
return $totals;
|
||||||
|
}
|
||||||
|
|
||||||
|
$vocabulary = $this->subcategoryVocabulary();
|
||||||
|
|
||||||
|
if (! $vocabulary) {
|
||||||
|
return $totals;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (BookStackClient::CONTENT_TYPES as $type) {
|
||||||
|
foreach ($this->itemsToProcess($type, $vocabulary, $force, $limit, $totals) as $batch) {
|
||||||
|
$this->processBatch($type, $batch, $vocabulary, $dryRun, $totals);
|
||||||
|
|
||||||
|
if ($limit !== null && $totals['scanned'] >= $limit) {
|
||||||
|
break 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $totals;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string[] current subcategory names, deduped case-insensitively
|
||||||
|
*/
|
||||||
|
protected function subcategoryVocabulary(): array
|
||||||
|
{
|
||||||
|
return Subcategory::query()
|
||||||
|
->pluck('name')
|
||||||
|
->filter()
|
||||||
|
->unique(fn (string $name) => Str::lower($name))
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Yields item detail records (id, name, tags, content) in chunks of
|
||||||
|
* BATCH_SIZE, skipping already-tagged items unless $force. Detail is
|
||||||
|
* fetched per item since BookStack's list endpoints don't include
|
||||||
|
* tags/content — acceptable here since this is an offline batch job, not
|
||||||
|
* a request-path call.
|
||||||
|
*
|
||||||
|
* @param string[] $vocabulary
|
||||||
|
* @param array{scanned: int, tagged: int, skipped: int, failed_batches: int} $totals
|
||||||
|
* @return \Generator<int, array<int, array{id: int, name: string, tags: array, content: string}>>
|
||||||
|
*/
|
||||||
|
protected function itemsToProcess(string $type, array $vocabulary, bool $force, ?int $limit, array &$totals): \Generator
|
||||||
|
{
|
||||||
|
$batch = [];
|
||||||
|
|
||||||
|
foreach ($this->bookstack->listAll($type) as $summary) {
|
||||||
|
if ($limit !== null && $totals['scanned'] >= $limit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$detail = $this->bookstack->detail($type, $summary['id']);
|
||||||
|
|
||||||
|
if (! $detail) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$totals['scanned']++;
|
||||||
|
|
||||||
|
if (! $force && $this->alreadyTagged($detail['tags'], $vocabulary)) {
|
||||||
|
$totals['skipped']++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$batch[] = $detail;
|
||||||
|
|
||||||
|
if (count($batch) >= self::BATCH_SIZE) {
|
||||||
|
yield $batch;
|
||||||
|
$batch = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($batch) {
|
||||||
|
yield $batch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{name: string, value: string}> $tags
|
||||||
|
* @param string[] $vocabulary
|
||||||
|
*/
|
||||||
|
protected function alreadyTagged(array $tags, array $vocabulary): bool
|
||||||
|
{
|
||||||
|
$existing = collect($tags)->map(fn (array $t) => Str::lower($t['name'] ?? ''));
|
||||||
|
$vocabLower = collect($vocabulary)->map(fn (string $v) => Str::lower($v));
|
||||||
|
|
||||||
|
return $existing->intersect($vocabLower)->isNotEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{id: int, name: string, tags: array, content: string}> $batch
|
||||||
|
* @param string[] $vocabulary
|
||||||
|
* @param array{scanned: int, tagged: int, skipped: int, failed_batches: int} $totals
|
||||||
|
*/
|
||||||
|
protected function processBatch(string $type, array $batch, array $vocabulary, bool $dryRun, array &$totals): void
|
||||||
|
{
|
||||||
|
$prompt = $this->buildPrompt($batch, $vocabulary);
|
||||||
|
|
||||||
|
$raw = $this->ai->chat([
|
||||||
|
['role' => 'system', 'content' => $prompt['system']],
|
||||||
|
['role' => 'user', 'content' => $prompt['user']],
|
||||||
|
], ['temperature' => 0]);
|
||||||
|
|
||||||
|
$assignments = $this->parseAssignments($raw, $vocabulary);
|
||||||
|
|
||||||
|
if ($assignments === null) {
|
||||||
|
$totals['failed_batches']++;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($batch as $item) {
|
||||||
|
$labels = $assignments[(string) $item['id']] ?? [];
|
||||||
|
|
||||||
|
if (! $labels) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$totals['tagged']++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mergedTags = $this->mergeTags($item['tags'], $labels);
|
||||||
|
|
||||||
|
if ($this->bookstack->updateTags($type, $item['id'], $mergedTags)) {
|
||||||
|
$totals['tagged']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{id: int, name: string, tags: array, content: string}> $batch
|
||||||
|
* @param string[] $vocabulary
|
||||||
|
* @return array{system: string, user: string}
|
||||||
|
*/
|
||||||
|
protected function buildPrompt(array $batch, array $vocabulary): array
|
||||||
|
{
|
||||||
|
$system = 'Klasyfikujesz artykuły bazy wiedzy do kategorii zgłoszeń helpdesk. '
|
||||||
|
.'Dostępne kategorie (użyj DOKŁADNIE tej pisowni): '.implode(', ', $vocabulary).'. '
|
||||||
|
.'Dla każdego elementu przypisz 0, 1 lub więcej pasujących kategorii — nie zgaduj '
|
||||||
|
.'kategorii, jeśli żadna sensownie nie pasuje, zwróć pustą tablicę. '
|
||||||
|
.'Odpowiedz WYŁĄCZNIE obiektem JSON, bez żadnego innego tekstu ani formatowania, '
|
||||||
|
.'gdzie klucz to id elementu (jako string), a wartość to tablica dopasowanych nazw '
|
||||||
|
.'kategorii. Przykład: {"12": ["Drukarki i skanery"], "13": []}';
|
||||||
|
|
||||||
|
$user = collect($batch)->map(function (array $item) {
|
||||||
|
$excerpt = Str::limit(strip_tags($item['content']), self::CONTENT_EXCERPT_CHARS, '');
|
||||||
|
|
||||||
|
return "id={$item['id']} nazwa=\"{$item['name']}\"\n{$excerpt}";
|
||||||
|
})->implode("\n---\n");
|
||||||
|
|
||||||
|
return ['system' => $system, 'user' => $user];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defensively parses the LLM's JSON response — not every OpenAI-
|
||||||
|
* compatible provider (esp. self-hosted Ollama models) reliably honors a
|
||||||
|
* "respond with only JSON" instruction, so this first tries to pull out
|
||||||
|
* the first {...} block (in case the model wrapped it in prose or a
|
||||||
|
* markdown code fence) before decoding. A malformed/non-JSON response
|
||||||
|
* fails just this one batch (caller counts it in failed_batches) rather
|
||||||
|
* than aborting the whole run. Only vocabulary-matching labels survive
|
||||||
|
* (case-insensitive); anything else the model returns is discarded.
|
||||||
|
*
|
||||||
|
* @param string[] $vocabulary
|
||||||
|
* @return array<string, string[]>|null
|
||||||
|
*/
|
||||||
|
protected function parseAssignments(?string $raw, array $vocabulary): ?array
|
||||||
|
{
|
||||||
|
if (! $raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! preg_match('/\{.*\}/s', $raw, $matches)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($matches[0], true);
|
||||||
|
|
||||||
|
if (! is_array($decoded)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$vocabByLower = collect($vocabulary)->mapWithKeys(fn (string $v) => [Str::lower($v) => $v]);
|
||||||
|
$result = [];
|
||||||
|
|
||||||
|
foreach ($decoded as $id => $labels) {
|
||||||
|
if (! is_array($labels)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result[(string) $id] = collect($labels)
|
||||||
|
->map(fn ($l) => $vocabByLower[Str::lower((string) $l)] ?? null)
|
||||||
|
->filter()
|
||||||
|
->unique()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{name: string, value: string}> $existing
|
||||||
|
* @param string[] $newLabels
|
||||||
|
* @return array<int, array{name: string, value: string}>
|
||||||
|
*/
|
||||||
|
protected function mergeTags(array $existing, array $newLabels): array
|
||||||
|
{
|
||||||
|
$existingLower = collect($existing)->map(fn (array $t) => Str::lower($t['name'] ?? ''));
|
||||||
|
|
||||||
|
$additions = collect($newLabels)
|
||||||
|
->reject(fn (string $label) => $existingLower->contains(Str::lower($label)))
|
||||||
|
->map(fn (string $label) => ['name' => $label, 'value' => '']);
|
||||||
|
|
||||||
|
return [...$existing, ...$additions->values()->all()];
|
||||||
|
}
|
||||||
|
}
|
||||||
282
src/app/Services/ImapMailboxFetcher.php
Normal file
282
src/app/Services/ImapMailboxFetcher.php
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\ImapMailbox;
|
||||||
|
use App\Support\Imap\InboundEmail;
|
||||||
|
use App\Support\Settings;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Throwable;
|
||||||
|
use Webklex\PHPIMAP\Client;
|
||||||
|
use Webklex\PHPIMAP\ClientManager;
|
||||||
|
use Webklex\PHPIMAP\Message;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* I/O layer for the "reply/create ticket by e-mail" feature — connects to
|
||||||
|
* every enabled ImapMailbox, fetches unseen messages and delegates every
|
||||||
|
* decision to ImapMessageClassifier (pure logic) + TicketService (the
|
||||||
|
* existing ticket-mutation API). Kept thin and mostly untested directly;
|
||||||
|
* ImapMessageClassifier carries the actual test coverage.
|
||||||
|
*/
|
||||||
|
class ImapMailboxFetcher
|
||||||
|
{
|
||||||
|
private const HEADER_FIELDS = ['auto-submitted', 'x-autoreply', 'x-autorespond', 'precedence'];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ImapMessageClassifier $classifier,
|
||||||
|
private readonly TicketService $tickets,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{created: int, replied: int, rejected: int, errors: int}
|
||||||
|
*/
|
||||||
|
public function fetchAll(): array
|
||||||
|
{
|
||||||
|
$totals = ['created' => 0, 'replied' => 0, 'rejected' => 0, 'errors' => 0];
|
||||||
|
|
||||||
|
foreach (ImapMailbox::query()->where('enabled', true)->get() as $mailbox) {
|
||||||
|
foreach ($this->fetchMailbox($mailbox) as $key => $value) {
|
||||||
|
$totals[$key] += $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $totals;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{created: int, replied: int, rejected: int, errors: int}
|
||||||
|
*/
|
||||||
|
public function fetchMailbox(ImapMailbox $mailbox): array
|
||||||
|
{
|
||||||
|
$result = ['created' => 0, 'replied' => 0, 'rejected' => 0, 'errors' => 0];
|
||||||
|
$log = Log::channel('imap');
|
||||||
|
|
||||||
|
$log->info("[{$mailbox->name}] łączenie z {$mailbox->host}:{$mailbox->port} (folder: {$mailbox->folder})");
|
||||||
|
|
||||||
|
try {
|
||||||
|
$client = $this->connect($mailbox);
|
||||||
|
$folder = $client->getFolder($mailbox->folder ?: 'INBOX');
|
||||||
|
$messages = $folder->messages()->whereUnseen()->get();
|
||||||
|
|
||||||
|
$log->info("[{$mailbox->name}] {$messages->count()} nieprzeczytanych wiadomości");
|
||||||
|
|
||||||
|
foreach ($messages as $message) {
|
||||||
|
try {
|
||||||
|
$this->processMessage($mailbox, $message, $result, $log);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$result['errors']++;
|
||||||
|
$log->error("[{$mailbox->name}] błąd przetwarzania wiadomości (uid={$message->getUid()}) — {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$client->disconnect();
|
||||||
|
$mailbox->update(['last_checked_at' => now(), 'last_error' => null]);
|
||||||
|
$log->info("[{$mailbox->name}] zakończono: {$result['created']} nowych, {$result['replied']} odpowiedzi, {$result['rejected']} odrzuconych, {$result['errors']} błędów");
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$result['errors']++;
|
||||||
|
$mailbox->update(['last_checked_at' => now(), 'last_error' => $e->getMessage()]);
|
||||||
|
$log->error("[{$mailbox->name}] połączenie nieudane — {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a connection and lists the configured folder, without fetching
|
||||||
|
* or touching any message — used by the admin "Testuj połączenie" button.
|
||||||
|
* Returns null on success, the exception message on failure.
|
||||||
|
*/
|
||||||
|
public function testConnection(ImapMailbox $mailbox): ?string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$client = $this->connect($mailbox);
|
||||||
|
$client->getFolder($mailbox->folder ?: 'INBOX');
|
||||||
|
$client->disconnect();
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function connect(ImapMailbox $mailbox): Client
|
||||||
|
{
|
||||||
|
$manager = new ClientManager;
|
||||||
|
$client = $manager->make([
|
||||||
|
'host' => $mailbox->host,
|
||||||
|
'port' => $mailbox->port,
|
||||||
|
'protocol' => 'imap',
|
||||||
|
'encryption' => $mailbox->encryption === 'none' ? false : $mailbox->encryption,
|
||||||
|
'validate_cert' => $mailbox->validate_cert,
|
||||||
|
'username' => $mailbox->username,
|
||||||
|
'password' => $mailbox->password,
|
||||||
|
]);
|
||||||
|
$client->connect();
|
||||||
|
|
||||||
|
return $client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{created: int, replied: int, rejected: int, errors: int} $result
|
||||||
|
*/
|
||||||
|
private function processMessage(ImapMailbox $mailbox, Message $message, array &$result, LoggerInterface $log): void
|
||||||
|
{
|
||||||
|
$email = $this->toInboundEmail($message);
|
||||||
|
$uid = $message->getUid();
|
||||||
|
|
||||||
|
$log->debug("[{$mailbox->name}] uid={$uid} od={$email->fromEmail} temat=\"{$email->subject}\" nagłówki=".json_encode($email->headers, JSON_UNESCAPED_UNICODE));
|
||||||
|
|
||||||
|
$rejectReason = $this->classifier->rejectionReason($email, $mailbox->blocklistedSenders());
|
||||||
|
if ($rejectReason === null && ! $this->classifier->isSenderAllowed($email->fromEmail)) {
|
||||||
|
$rejectReason = "nadawca spoza LDAP ({$email->fromEmail}), a restrict_tickets_to_ldap jest włączone";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($rejectReason !== null) {
|
||||||
|
$this->finish($message, $mailbox->rejected_folder);
|
||||||
|
$result['rejected']++;
|
||||||
|
$log->info("[{$mailbox->name}] uid={$uid} ODRZUCONO od {$email->fromEmail} \"{$email->subject}\" — {$rejectReason}");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oznacz/przenieś PRZED utworzeniem ticketu: awaria w tym miejscu
|
||||||
|
// zostawia co najwyżej "przetworzoną" wiadomość bez ticketu (widoczne,
|
||||||
|
// łatwe do naprawienia ręcznie) zamiast duplikatu ticketu przy
|
||||||
|
// ponownym uruchomieniu.
|
||||||
|
$this->finish($message, $mailbox->processed_folder);
|
||||||
|
|
||||||
|
$ticket = $this->classifier->matchTicket($email->subject);
|
||||||
|
$sender = $this->classifier->resolveSender($email->fromEmail);
|
||||||
|
$attachments = $this->buildAttachments($email, $mailbox, $log);
|
||||||
|
$authorName = $email->fromName !== '' ? $email->fromName : $email->fromEmail;
|
||||||
|
|
||||||
|
if ($ticket) {
|
||||||
|
if ($sender) {
|
||||||
|
$this->tickets->clientReply($ticket, $sender, $email->body(), $attachments, source: 'email');
|
||||||
|
} else {
|
||||||
|
$this->tickets->guestReply($ticket, $authorName, $email->body(), $attachments, source: 'email');
|
||||||
|
}
|
||||||
|
$result['replied']++;
|
||||||
|
$log->info("[{$mailbox->name}] uid={$uid} ODPOWIEDŹ od {$email->fromEmail} dopisana do zgłoszenia #{$ticket->id} ({$ticket->displayNumber()})");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$newTicket = $this->tickets->create([
|
||||||
|
'email' => $email->fromEmail,
|
||||||
|
'name' => $authorName,
|
||||||
|
'subcategory_id' => $mailbox->default_subcategory_id,
|
||||||
|
'category_id' => $mailbox->default_category_id,
|
||||||
|
'subject' => $email->subject !== '' ? $email->subject : '(bez tematu)',
|
||||||
|
'body' => $email->body(),
|
||||||
|
'source' => 'email',
|
||||||
|
], $sender, $authorName);
|
||||||
|
$result['created']++;
|
||||||
|
$log->info("[{$mailbox->name}] uid={$uid} NOWE zgłoszenie #{$newTicket->id} ({$newTicket->displayNumber()}) od {$email->fromEmail}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function finish(Message $message, ?string $moveToFolder): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$message->setFlag('Seen');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::channel('imap')->warning("IMAP: nie udało się oznaczyć wiadomości jako przeczytanej — {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($moveToFolder) {
|
||||||
|
$message->move($moveToFolder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function toInboundEmail(Message $message): InboundEmail
|
||||||
|
{
|
||||||
|
$fromAddress = $message->getFrom()->first();
|
||||||
|
$header = $message->getHeader();
|
||||||
|
|
||||||
|
// Webklex's Header::get() returns an *empty* Attribute (not null)
|
||||||
|
// for a header that isn't present at all, and Attribute::first() on
|
||||||
|
// that empty instance comes back as '' rather than null — so a
|
||||||
|
// plain "!== null" check on the resulting value is always true,
|
||||||
|
// making every message look like it carries every one of these
|
||||||
|
// headers. Only keep a header that actually has content.
|
||||||
|
$headers = [];
|
||||||
|
foreach (self::HEADER_FIELDS as $name) {
|
||||||
|
$value = $header?->get($name)->first();
|
||||||
|
if ($value !== null && $value !== '') {
|
||||||
|
$headers[$name] = (string) $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new InboundEmail(
|
||||||
|
fromEmail: $fromAddress?->mail ?? '',
|
||||||
|
fromName: $this->decodeHeaderText(trim((string) ($fromAddress?->personal ?? ''), '"')),
|
||||||
|
subject: $this->decodeHeaderText((string) $message->getSubject()),
|
||||||
|
textBody: (string) $message->getTextBody(),
|
||||||
|
htmlBody: (string) $message->getHTMLBody(),
|
||||||
|
headers: $headers,
|
||||||
|
attachments: $this->extractAttachments($message),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Some senders' mail clients leave the Subject/From display-name as raw
|
||||||
|
* RFC 2047 encoded-words (e.g. "=?utf-8?Q?...?=") instead of the
|
||||||
|
* decoded UTF-8 webklex's own config claims to produce — decode
|
||||||
|
* defensively rather than showing garbled text on the ticket.
|
||||||
|
*/
|
||||||
|
private function decodeHeaderText(string $value): string
|
||||||
|
{
|
||||||
|
return $value !== '' ? mb_decode_mimeheader($value) : $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{filename: string, mime: string, content: string}>
|
||||||
|
*/
|
||||||
|
private function extractAttachments(Message $message): array
|
||||||
|
{
|
||||||
|
$attachments = [];
|
||||||
|
|
||||||
|
foreach ($message->getAttachments() as $attachment) {
|
||||||
|
$attachments[] = [
|
||||||
|
'filename' => $attachment->getName() ?: 'attachment',
|
||||||
|
'mime' => $attachment->getMimeType() ?: 'application/octet-stream',
|
||||||
|
'content' => $attachment->getContent(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $attachments;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts raw attachment bytes into UploadedFile instances (via a temp
|
||||||
|
* file + the $test=true flag, which lets Symfony's UploadedFile skip the
|
||||||
|
* is_uploaded_file() check outside of a real HTTP request) so they flow
|
||||||
|
* through TicketService::attachFiles() unchanged. Validated the same way
|
||||||
|
* every other caller validates before calling attachFiles() — a mail
|
||||||
|
* carrying an oversized/disallowed attachment still creates the
|
||||||
|
* ticket/reply, just without that attachment, rather than being dropped
|
||||||
|
* entirely or silently bypassing the admin's attachment policy.
|
||||||
|
*
|
||||||
|
* @return UploadedFile[]
|
||||||
|
*/
|
||||||
|
private function buildAttachments(InboundEmail $email, ImapMailbox $mailbox, LoggerInterface $log): array
|
||||||
|
{
|
||||||
|
$files = [];
|
||||||
|
|
||||||
|
foreach ($email->attachments as $attachment) {
|
||||||
|
$path = tempnam(sys_get_temp_dir(), 'imap_');
|
||||||
|
file_put_contents($path, $attachment['content']);
|
||||||
|
$files[] = new UploadedFile($path, $attachment['filename'], $attachment['mime'], null, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($files && ($error = Settings::validateAttachments($files))) {
|
||||||
|
$log->warning("[{$mailbox->name}] pominięto załączniki wiadomości od {$email->fromEmail} — {$error}");
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $files;
|
||||||
|
}
|
||||||
|
}
|
||||||
137
src/app/Services/ImapMessageClassifier.php
Normal file
137
src/app/Services/ImapMessageClassifier.php
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Ticket;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\Imap\InboundEmail;
|
||||||
|
use App\Support\Settings;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure decision logic for the IMAP fetcher — no IMAP connection, no
|
||||||
|
* side effects, so it's fully Pest-testable against hand-built
|
||||||
|
* InboundEmail instances. ImapMailboxFetcher does all the I/O and calls
|
||||||
|
* into this for every decision.
|
||||||
|
*/
|
||||||
|
class ImapMessageClassifier
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* RFC 3834 (Auto-Submitted) + common vendor headers, plus EN/PL subject
|
||||||
|
* phrasing for autoresponders/bounces that don't set those headers at
|
||||||
|
* all — the two layers catch most real-world autoresponders/mailer-daemons.
|
||||||
|
*/
|
||||||
|
private const AUTO_REPLY_SUBJECT_PATTERNS = [
|
||||||
|
'/\bout of office\b/i',
|
||||||
|
'/\bautomatic reply\b/i',
|
||||||
|
'/\bautomatyczna odpowiedz\b/iu',
|
||||||
|
'/\bautoresponder\b/i',
|
||||||
|
'/\bundeliverable\b/i',
|
||||||
|
'/\bundelivered\b/i',
|
||||||
|
'/\bmail delivery failed\b/i',
|
||||||
|
'/\bdelivery status notification\b/i',
|
||||||
|
'/\bnieobecnosc\b.*\bbiurze\b/iu',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a human-readable rejection reason, or null if the message
|
||||||
|
* should be processed as a genuine ticket/reply.
|
||||||
|
*
|
||||||
|
* @param string[] $extraBlocklist additional blocked sender local-parts/addresses (per-mailbox)
|
||||||
|
*/
|
||||||
|
public function rejectionReason(InboundEmail $email, array $extraBlocklist = []): ?string
|
||||||
|
{
|
||||||
|
$autoSubmitted = strtolower((string) $email->header('auto-submitted'));
|
||||||
|
if ($autoSubmitted !== '' && $autoSubmitted !== 'no') {
|
||||||
|
return "Auto-Submitted: {$autoSubmitted}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($email->header('x-autoreply') !== null || $email->header('x-autorespond') !== null) {
|
||||||
|
return 'X-Autoreply/X-Autorespond header present';
|
||||||
|
}
|
||||||
|
|
||||||
|
$precedence = strtolower((string) $email->header('precedence'));
|
||||||
|
if (in_array($precedence, ['bulk', 'junk', 'list'], true)) {
|
||||||
|
return "Precedence: {$precedence}";
|
||||||
|
}
|
||||||
|
|
||||||
|
$senderLocalPart = strtolower(explode('@', $email->fromEmail)[0] ?? '');
|
||||||
|
$blocked = array_map('strtolower', $extraBlocklist);
|
||||||
|
if ($senderLocalPart !== '' && in_array($senderLocalPart, $blocked, true)) {
|
||||||
|
return "Blocked sender: {$email->fromEmail}";
|
||||||
|
}
|
||||||
|
if (in_array(strtolower($email->fromEmail), $blocked, true)) {
|
||||||
|
return "Blocked sender: {$email->fromEmail}";
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (self::AUTO_REPLY_SUBJECT_PATTERNS as $pattern) {
|
||||||
|
if (preg_match($pattern, $email->subject) === 1) {
|
||||||
|
return "Subject matched auto-reply pattern ({$pattern})";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same gate Landing::submit() applies to web/guest ticket creation
|
||||||
|
* (Settings::bool('restrict_tickets_to_ldap')) — must apply identically
|
||||||
|
* to mail-originated tickets/replies, or the restriction has a hole.
|
||||||
|
*/
|
||||||
|
public function isSenderAllowed(string $email): bool
|
||||||
|
{
|
||||||
|
if (! Settings::bool('restrict_tickets_to_ldap')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return User::query()->where('email', $email)->exists()
|
||||||
|
|| app(LdapUserProvisioner::class)->existsInLdap($email);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Existing local user, or an LDAP-provisioned one if enabled — mirrors
|
||||||
|
* TicketService::create()'s own guest-resolution branch. Returns null
|
||||||
|
* for a genuine, unprovisionable guest.
|
||||||
|
*/
|
||||||
|
public function resolveSender(string $email): ?User
|
||||||
|
{
|
||||||
|
if ($user = User::query()->where('email', $email)->first()) {
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Settings::bool('ldap_auto_provision_guests')) {
|
||||||
|
return app(LdapUserProvisioner::class)->findOrCreateByEmail($email);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strips common reply/forward prefixes, then tries every digit run of
|
||||||
|
* length >= 4 (longest first) against Ticket::resolveRouteBinding() —
|
||||||
|
* covers both the plain sequential number and the obfuscated checksum,
|
||||||
|
* since both are plain digit strings and every outbound notification
|
||||||
|
* subject already carries one (see database/seeders/DatabaseSeeder.php).
|
||||||
|
* Prefix-aware matching was considered and rejected: {numer} email
|
||||||
|
* templates hardcode their own literal '#', independent of the
|
||||||
|
* admin-configurable ticket_number_prefix setting, and templates are
|
||||||
|
* themselves admin-editable.
|
||||||
|
*/
|
||||||
|
public function matchTicket(string $subject): ?Ticket
|
||||||
|
{
|
||||||
|
$cleaned = preg_replace('/^\s*(re|odp|fwd|fw|aw)\s*:\s*/i', '', $subject) ?? $subject;
|
||||||
|
$cleaned = preg_replace('/^\s*(re|odp|fwd|fw|aw)\s*:\s*/i', '', $cleaned) ?? $cleaned;
|
||||||
|
|
||||||
|
preg_match_all('/\d{4,}/', $cleaned, $matches);
|
||||||
|
$tokens = $matches[0] ?? [];
|
||||||
|
usort($tokens, fn ($a, $b) => strlen($b) <=> strlen($a));
|
||||||
|
|
||||||
|
foreach ($tokens as $token) {
|
||||||
|
$ticket = (new Ticket)->resolveRouteBinding($token);
|
||||||
|
if ($ticket) {
|
||||||
|
return $ticket;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
139
src/app/Services/TicketAiSummaryService.php
Normal file
139
src/app/Services/TicketAiSummaryService.php
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
<?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 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function buildTranscript(Ticket $ticket): string
|
||||||
|
{
|
||||||
|
$lines = ["Temat: {$ticket->subject}"];
|
||||||
|
|
||||||
|
$ticket->messages()->latest('created_at')->limit(self::TRANSCRIPT_MESSAGE_LIMIT)->get()
|
||||||
|
->sortBy('created_at')
|
||||||
|
->each(function ($message) use (&$lines) {
|
||||||
|
$role = $message->internal ? 'notatka wewnętrzna' : ($message->role === 'client' ? 'klient' : 'operator');
|
||||||
|
$body = Str::limit(strip_tags($message->body), self::MESSAGE_EXCERPT_CHARS, '');
|
||||||
|
$lines[] = "[{$role}] {$message->author_name}: {$body}";
|
||||||
|
});
|
||||||
|
|
||||||
|
return implode("\n\n", $lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same defensive-parsing shape used elsewhere in this app's AI services
|
||||||
|
* (BookStackContentTagger, TicketAiTriageService) — extracts the first
|
||||||
|
* {...} block before decoding.
|
||||||
|
*
|
||||||
|
* @return array{summary: string, suggested_action: ?string}|null
|
||||||
|
*/
|
||||||
|
protected function parseResponse(?string $raw): ?array
|
||||||
|
{
|
||||||
|
if (! $raw || ! preg_match('/\{.*\}/s', $raw, $matches)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($matches[0], true);
|
||||||
|
|
||||||
|
if (! is_array($decoded) || empty($decoded['summary']) || ! is_string($decoded['summary'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'summary' => trim($decoded['summary']),
|
||||||
|
'suggested_action' => ! empty($decoded['suggested_action']) && is_string($decoded['suggested_action'])
|
||||||
|
? trim($decoded['suggested_action'])
|
||||||
|
: null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
311
src/app/Services/TicketAiTriageService.php
Normal file
311
src/app/Services/TicketAiTriageService.php
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Category;
|
||||||
|
use App\Models\Priority;
|
||||||
|
use App\Models\Ticket;
|
||||||
|
use App\Support\Settings;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot AI classification of new tickets, gated by 5 independent
|
||||||
|
* settings toggles (ai_triage_category_when_missing/
|
||||||
|
* subcategory_when_category_only/recheck_categorized/fix_subject/
|
||||||
|
* set_priority). Runs from the scheduled ai:run-ticket-automation command,
|
||||||
|
* never synchronously at ticket creation, so it never adds LLM latency to a
|
||||||
|
* live customer submitting a ticket. Every scanned ticket gets
|
||||||
|
* ai_triaged_at stamped exactly once — this is a one-shot pass per ticket,
|
||||||
|
* not a continuous recheck, and there's no manual re-trigger by design.
|
||||||
|
*/
|
||||||
|
class TicketAiTriageService
|
||||||
|
{
|
||||||
|
protected const BATCH_LIMIT = 25;
|
||||||
|
|
||||||
|
protected const BODY_EXCERPT_CHARS = 4000;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected AiClient $ai,
|
||||||
|
protected TicketService $tickets,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{scanned: int, changed: int, failed: int}
|
||||||
|
*/
|
||||||
|
public function run(?int $limit = null): array
|
||||||
|
{
|
||||||
|
$totals = ['scanned' => 0, 'changed' => 0, 'failed' => 0];
|
||||||
|
|
||||||
|
if (! $this->ai->enabled() || ! $this->anyToggleEnabled()) {
|
||||||
|
return $totals;
|
||||||
|
}
|
||||||
|
|
||||||
|
$vocabulary = $this->buildVocabulary();
|
||||||
|
$priorities = Priority::query()->orderBy('sort_order')->pluck('label', 'key')->all();
|
||||||
|
|
||||||
|
Ticket::query()->whereNull('ai_triaged_at')
|
||||||
|
->orderBy('id')
|
||||||
|
->limit($limit ?? self::BATCH_LIMIT)
|
||||||
|
->get()
|
||||||
|
->each(function (Ticket $ticket) use ($vocabulary, $priorities, &$totals) {
|
||||||
|
$totals['scanned']++;
|
||||||
|
$this->triageOne($ticket, $vocabulary, $priorities, $totals);
|
||||||
|
});
|
||||||
|
|
||||||
|
return $totals;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function anyToggleEnabled(): bool
|
||||||
|
{
|
||||||
|
return Settings::bool('ai_triage_category_when_missing')
|
||||||
|
|| Settings::bool('ai_triage_subcategory_when_category_only')
|
||||||
|
|| Settings::bool('ai_triage_recheck_categorized')
|
||||||
|
|| Settings::bool('ai_triage_fix_subject')
|
||||||
|
|| Settings::bool('ai_triage_set_priority');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{id: int, name: string, subcategories: array<int, array{id: int, name: string}>}> $vocabulary
|
||||||
|
* @param array<string, string> $priorities
|
||||||
|
* @param array{scanned: int, changed: int, failed: int} $totals
|
||||||
|
*/
|
||||||
|
protected function triageOne(Ticket $ticket, array $vocabulary, array $priorities, array &$totals): void
|
||||||
|
{
|
||||||
|
$prompt = $this->buildPrompt($ticket, $vocabulary, $priorities);
|
||||||
|
|
||||||
|
if ($prompt === null) {
|
||||||
|
$ticket->update(['ai_triaged_at' => now()]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = $this->ai->chat([
|
||||||
|
['role' => 'system', 'content' => $prompt['system']],
|
||||||
|
['role' => 'user', 'content' => $prompt['user']],
|
||||||
|
], ['temperature' => 0]);
|
||||||
|
|
||||||
|
$parsed = $this->parseResponse($raw);
|
||||||
|
|
||||||
|
// A response came back but couldn't be parsed — not fatal to the
|
||||||
|
// run, just means this ticket wasn't classified this time.
|
||||||
|
// ai_triaged_at is still stamped below so a persistently-bad
|
||||||
|
// response doesn't get retried forever.
|
||||||
|
if ($raw !== null && $parsed === null) {
|
||||||
|
$totals['failed']++;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$changes, $historyLines] = $parsed
|
||||||
|
? $this->resolveChanges($ticket, $parsed, $vocabulary, $priorities, $prompt['scope'])
|
||||||
|
: [[], []];
|
||||||
|
|
||||||
|
if ($changes) {
|
||||||
|
$this->tickets->applyAiTriage($ticket, $changes, $historyLines);
|
||||||
|
$totals['changed']++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticket->update(['ai_triaged_at' => now()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{id: int, name: string, subcategories: array<int, array{id: int, name: string}>}>
|
||||||
|
*/
|
||||||
|
protected function buildVocabulary(): array
|
||||||
|
{
|
||||||
|
return Category::query()->with('subcategories')->get()
|
||||||
|
->map(fn (Category $c) => [
|
||||||
|
'id' => $c->id,
|
||||||
|
'name' => $c->name,
|
||||||
|
'subcategories' => $c->subcategories->map(fn ($s) => ['id' => $s->id, 'name' => $s->name])->all(),
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines which of the 3 mutually-exclusive category scenarios (if
|
||||||
|
* any) applies to $ticket's current state, and whether subject/priority
|
||||||
|
* are also in scope, then builds the prompt around exactly that. Returns
|
||||||
|
* null when nothing is applicable/enabled for this ticket, so the caller
|
||||||
|
* can skip straight to stamping ai_triaged_at without an AI call.
|
||||||
|
*
|
||||||
|
* @param array<int, array{id: int, name: string, subcategories: array}> $vocabulary
|
||||||
|
* @param array<string, string> $priorities
|
||||||
|
* @return array{system: string, user: string, scope: ?string}|null
|
||||||
|
*/
|
||||||
|
protected function buildPrompt(Ticket $ticket, array $vocabulary, array $priorities): ?array
|
||||||
|
{
|
||||||
|
$scope = null;
|
||||||
|
|
||||||
|
if (! $ticket->category_id && ! $ticket->subcategory_id && Settings::bool('ai_triage_category_when_missing')) {
|
||||||
|
$scope = 'missing';
|
||||||
|
} elseif ($ticket->category_id && ! $ticket->subcategory_id && Settings::bool('ai_triage_subcategory_when_category_only')) {
|
||||||
|
$scope = 'category_only';
|
||||||
|
} elseif ($ticket->subcategory_id && Settings::bool('ai_triage_recheck_categorized')) {
|
||||||
|
$scope = 'recheck';
|
||||||
|
}
|
||||||
|
|
||||||
|
$wantSubject = Settings::bool('ai_triage_fix_subject');
|
||||||
|
$wantPriority = Settings::bool('ai_triage_set_priority');
|
||||||
|
|
||||||
|
if ($scope === null && ! $wantSubject && ! $wantPriority) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = ['Klasyfikujesz zgłoszenia helpdesku na podstawie tematu i treści.'];
|
||||||
|
|
||||||
|
if ($scope === 'missing') {
|
||||||
|
$parts[] = 'To zgłoszenie nie ma jeszcze przypisanej kategorii ani podkategorii. Wybierz najlepiej '
|
||||||
|
.'pasującą kategorię z listy poniżej (użyj DOKŁADNIE tej pisowni) i, jeśli to możliwe, także '
|
||||||
|
.'konkretną podkategorię w jej ramach. Jeśli żadna kategoria sensownie nie pasuje, zwróć null dla obu pól.';
|
||||||
|
} elseif ($scope === 'category_only') {
|
||||||
|
$parts[] = "To zgłoszenie ma już przypisaną kategorię \"{$ticket->category->name}\", ale brak konkretnej "
|
||||||
|
.'podkategorii. Wybierz najlepiej pasującą podkategorię z listy poniżej (należącą do tej kategorii, '
|
||||||
|
.'użyj DOKŁADNIE tej pisowni). Jeśli żadna nie pasuje dobrze, zwróć null.';
|
||||||
|
} elseif ($scope === 'recheck') {
|
||||||
|
$parts[] = "To zgłoszenie ma już przypisaną podkategorię \"{$ticket->subcategory->label()}\". Sprawdź, "
|
||||||
|
.'czy to nadal najlepsze dopasowanie na podstawie treści. Jeśli tak — zwróć null (nic nie zmieniaj). '
|
||||||
|
.'Jeśli lepiej pasuje inna kategoria/podkategoria z listy poniżej, zwróć ją.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($scope !== null) {
|
||||||
|
$parts[] = "Dostępne kategorie i podkategorie:\n".$this->vocabularyText($vocabulary, $scope, $ticket);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($wantSubject) {
|
||||||
|
$parts[] = 'Jeśli obecny temat zgłoszenia jest niejasny lub mylący, zaproponuj lepszy, zwięzły temat po '
|
||||||
|
.'polsku w polu "subject" (w przeciwnym razie null).';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($wantPriority) {
|
||||||
|
$priorityList = collect($priorities)->map(fn ($label, $key) => "{$key} ({$label})")->implode(', ');
|
||||||
|
$parts[] = 'Na podstawie treści oceń priorytet zgłoszenia i zwróć jego klucz w polu "priority" — '
|
||||||
|
."dostępne klucze: {$priorityList}.";
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts[] = 'Odpowiedz WYŁĄCZNIE obiektem JSON, bez żadnego innego tekstu ani formatowania: '
|
||||||
|
.'{"category": "...", "subcategory": "...", "subject": "...", "priority": "..."} — pola, o które nie '
|
||||||
|
.'proszono powyżej, ustaw na null.';
|
||||||
|
|
||||||
|
$user = "Temat: {$ticket->subject}\n\nTreść:\n".Str::limit(strip_tags($ticket->body), self::BODY_EXCERPT_CHARS);
|
||||||
|
|
||||||
|
return ['system' => implode("\n\n", $parts), 'user' => $user, 'scope' => $scope];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{id: int, name: string, subcategories: array<int, array{id: int, name: string}>}> $vocabulary
|
||||||
|
*/
|
||||||
|
protected function vocabularyText(array $vocabulary, string $scope, Ticket $ticket): string
|
||||||
|
{
|
||||||
|
$categories = $scope === 'category_only'
|
||||||
|
? collect($vocabulary)->filter(fn (array $c) => $c['id'] === $ticket->category_id)
|
||||||
|
: collect($vocabulary);
|
||||||
|
|
||||||
|
return $categories
|
||||||
|
->map(fn (array $c) => "- {$c['name']}: ".collect($c['subcategories'])->pluck('name')->implode(', '))
|
||||||
|
->implode("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same defensive-parsing shape as BookStackContentTagger::parseAssignments()
|
||||||
|
* — extracts the first {...} block before decoding, so prose-wrapped or
|
||||||
|
* malformed responses fail gracefully instead of crashing the run.
|
||||||
|
*/
|
||||||
|
protected function parseResponse(?string $raw): ?array
|
||||||
|
{
|
||||||
|
if (! $raw || ! preg_match('/\{.*\}/s', $raw, $matches)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($matches[0], true);
|
||||||
|
|
||||||
|
return is_array($decoded) ? $decoded : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fail-closed resolution: every value from the model is matched against
|
||||||
|
* the real vocabulary/priority list before being trusted — an
|
||||||
|
* unmatched/hallucinated category, a subcategory outside its claimed
|
||||||
|
* category, or an unknown priority key is silently dropped rather than
|
||||||
|
* written to the ticket. Only fields whose resolved value actually
|
||||||
|
* differs from the ticket's current value produce a change + history
|
||||||
|
* line, so e.g. a "recheck" that confirms the existing subcategory
|
||||||
|
* leaves no trace.
|
||||||
|
*
|
||||||
|
* @param array<int, array{id: int, name: string, subcategories: array<int, array{id: int, name: string}>}> $vocabulary
|
||||||
|
* @param array<string, string> $priorities
|
||||||
|
* @return array{0: array<string, mixed>, 1: string[]}
|
||||||
|
*/
|
||||||
|
protected function resolveChanges(Ticket $ticket, array $parsed, array $vocabulary, array $priorities, ?string $scope): array
|
||||||
|
{
|
||||||
|
$changes = [];
|
||||||
|
$historyLines = [];
|
||||||
|
|
||||||
|
if ($scope !== null) {
|
||||||
|
$resolved = $this->resolveCategory($parsed, $vocabulary, $scope, $ticket);
|
||||||
|
|
||||||
|
if ($resolved) {
|
||||||
|
[$categoryId, $subcategoryId, $label] = $resolved;
|
||||||
|
|
||||||
|
if ($categoryId !== $ticket->category_id || $subcategoryId !== $ticket->subcategory_id) {
|
||||||
|
$changes['category_id'] = $categoryId;
|
||||||
|
$changes['subcategory_id'] = $subcategoryId;
|
||||||
|
$historyLines[] = "Kategoria zmieniona na: {$label}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Settings::bool('ai_triage_fix_subject') && ! empty($parsed['subject']) && is_string($parsed['subject'])) {
|
||||||
|
$newSubject = Str::limit(trim($parsed['subject']), 255, '');
|
||||||
|
|
||||||
|
if ($newSubject !== '' && $newSubject !== $ticket->subject) {
|
||||||
|
$changes['subject'] = $newSubject;
|
||||||
|
$historyLines[] = "Temat zmieniony na: „{$newSubject}”";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Settings::bool('ai_triage_set_priority') && ! empty($parsed['priority']) && is_string($parsed['priority'])) {
|
||||||
|
$key = collect($priorities)->keys()->first(fn ($k) => Str::lower($k) === Str::lower($parsed['priority']));
|
||||||
|
|
||||||
|
if ($key !== null && $key !== $ticket->priority_key) {
|
||||||
|
$changes['priority_key'] = $key;
|
||||||
|
$historyLines[] = 'Priorytet zmieniony na: '.Priority::labelFor($key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$changes, $historyLines];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{id: int, name: string, subcategories: array<int, array{id: int, name: string}>}> $vocabulary
|
||||||
|
* @return array{0: ?int, 1: ?int, 2: string}|null [category_id, subcategory_id, display label]
|
||||||
|
*/
|
||||||
|
protected function resolveCategory(array $parsed, array $vocabulary, string $scope, Ticket $ticket): ?array
|
||||||
|
{
|
||||||
|
$categories = $scope === 'category_only'
|
||||||
|
? collect($vocabulary)->filter(fn (array $c) => $c['id'] === $ticket->category_id)
|
||||||
|
: collect($vocabulary);
|
||||||
|
|
||||||
|
$subName = $parsed['subcategory'] ?? null;
|
||||||
|
|
||||||
|
if (is_string($subName) && $subName !== '') {
|
||||||
|
foreach ($categories as $category) {
|
||||||
|
foreach ($category['subcategories'] as $sub) {
|
||||||
|
if (Str::lower($sub['name']) === Str::lower($subName)) {
|
||||||
|
return [null, $sub['id'], "{$category['name']} / {$sub['name']}"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$catName = $parsed['category'] ?? null;
|
||||||
|
|
||||||
|
if (is_string($catName) && $catName !== '') {
|
||||||
|
foreach ($categories as $category) {
|
||||||
|
if (Str::lower($category['name']) === Str::lower($catName)) {
|
||||||
|
return [$category['id'], null, $category['name']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Events\TicketMessagePosted;
|
||||||
|
use App\Events\TicketQueueChanged;
|
||||||
use App\Models\ApiClient;
|
use App\Models\ApiClient;
|
||||||
|
use App\Models\NotificationPreference;
|
||||||
use App\Models\NotificationSetting;
|
use App\Models\NotificationSetting;
|
||||||
use App\Models\Priority;
|
use App\Models\Priority;
|
||||||
use App\Models\Status;
|
use App\Models\Status;
|
||||||
@@ -14,6 +17,7 @@ use App\Models\User;
|
|||||||
use App\Notifications\TicketNotification;
|
use App\Notifications\TicketNotification;
|
||||||
use App\Support\Settings;
|
use App\Support\Settings;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Notification;
|
use Illuminate\Support\Facades\Notification;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
@@ -38,6 +42,10 @@ class TicketService
|
|||||||
'email' => $customer?->email ?? $data['email'],
|
'email' => $customer?->email ?? $data['email'],
|
||||||
'name' => $customer?->name ?? ($data['name'] ?? $data['email']),
|
'name' => $customer?->name ?? ($data['name'] ?? $data['email']),
|
||||||
'subcategory_id' => $subcategory?->id,
|
'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'],
|
'subject' => $data['subject'],
|
||||||
'body' => $data['body'],
|
'body' => $data['body'],
|
||||||
'status_key' => Settings::get('default_status', 'new'),
|
'status_key' => Settings::get('default_status', 'new'),
|
||||||
@@ -45,6 +53,8 @@ class TicketService
|
|||||||
'team_id' => $this->autoAssignTeam($subcategory),
|
'team_id' => $this->autoAssignTeam($subcategory),
|
||||||
'assignee_id' => $data['assignee_id'] ?? null,
|
'assignee_id' => $data['assignee_id'] ?? null,
|
||||||
'custom_fields' => $data['custom_values'] ?? [],
|
'custom_fields' => $data['custom_values'] ?? [],
|
||||||
|
'last_customer_activity_at' => now(),
|
||||||
|
'source' => $data['source'] ?? 'web',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$message = $ticket->messages()->create([
|
$message = $ticket->messages()->create([
|
||||||
@@ -54,6 +64,9 @@ class TicketService
|
|||||||
$message->attachAuthor($customer?->id, 'client');
|
$message->attachAuthor($customer?->id, 'client');
|
||||||
|
|
||||||
$this->notify($ticket, 'ticket_created');
|
$this->notify($ticket, 'ticket_created');
|
||||||
|
$this->notify($ticket, 'ticket_created_team');
|
||||||
|
app(TriggerEngine::class)->handle($ticket, 'ticket_created');
|
||||||
|
TicketQueueChanged::dispatch($ticket->id, 'created', Auth::id());
|
||||||
|
|
||||||
return $ticket;
|
return $ticket;
|
||||||
}
|
}
|
||||||
@@ -87,9 +100,17 @@ class TicketService
|
|||||||
// the running segment (if any) the moment a ticket is closed,
|
// the running segment (if any) the moment a ticket is closed,
|
||||||
// regardless of which flow triggered the status change.
|
// regardless of which flow triggered the status change.
|
||||||
$ticket->stopTimer();
|
$ticket->stopTimer();
|
||||||
|
|
||||||
|
// A closed ticket that reopens later should give every automation
|
||||||
|
// rule a clean slate rather than staying latched from before.
|
||||||
|
$ticket->automationRuleLogs()->delete();
|
||||||
} else {
|
} else {
|
||||||
$this->notify($ticket, 'status_changed');
|
$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());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function submitCsat(Ticket $ticket, int $rating, ?string $comment = null): void
|
public function submitCsat(Ticket $ticket, int $rating, ?string $comment = null): void
|
||||||
@@ -113,6 +134,9 @@ class TicketService
|
|||||||
$ticket->update(['priority_key' => $priorityKey]);
|
$ticket->update(['priority_key' => $priorityKey]);
|
||||||
$ticket->addHistory('Priorytet zmieniony na: '.Priority::labelFor($priorityKey));
|
$ticket->addHistory('Priorytet zmieniony na: '.Priority::labelFor($priorityKey));
|
||||||
$this->notify($ticket, 'priority_changed');
|
$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());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setAssignee(Ticket $ticket, ?User $assignee): void
|
public function setAssignee(Ticket $ticket, ?User $assignee): void
|
||||||
@@ -122,6 +146,9 @@ class TicketService
|
|||||||
$ticket->update(['assignee_id' => $assignee?->id, 'sla_notified_at' => null]);
|
$ticket->update(['assignee_id' => $assignee?->id, 'sla_notified_at' => null]);
|
||||||
$ticket->addHistory('Przypisano do: '.($assignee?->name ?? 'Nieprzypisane'));
|
$ticket->addHistory('Przypisano do: '.($assignee?->name ?? 'Nieprzypisane'));
|
||||||
$this->notify($ticket, 'assignee_changed');
|
$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());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setTeam(Ticket $ticket, ?Team $team): void
|
public function setTeam(Ticket $ticket, ?Team $team): void
|
||||||
@@ -129,6 +156,55 @@ class TicketService
|
|||||||
$ticket->update(['team_id' => $team?->id]);
|
$ticket->update(['team_id' => $team?->id]);
|
||||||
$ticket->addHistory('Zespół zmieniony na: '.($team?->name ?? 'Brak'));
|
$ticket->addHistory('Zespół zmieniony na: '.($team?->name ?? 'Brak'));
|
||||||
$this->notify($ticket, 'team_changed');
|
$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
|
public function setReporter(Ticket $ticket, User $customer): void
|
||||||
@@ -151,7 +227,10 @@ class TicketService
|
|||||||
|
|
||||||
if ($categoryChanged) {
|
if ($categoryChanged) {
|
||||||
$this->notify($ticket, 'category_changed');
|
$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
|
public function operatorReply(Ticket $ticket, User $operator, string $body, ?string $statusAfter = null, array $attachments = []): void
|
||||||
@@ -164,6 +243,9 @@ class TicketService
|
|||||||
$ticket->touch();
|
$ticket->touch();
|
||||||
$this->attachFiles($ticket, $message, $attachments);
|
$this->attachFiles($ticket, $message, $attachments);
|
||||||
$this->notify($ticket, 'operator_replied');
|
$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);
|
||||||
|
|
||||||
if ($statusAfter) {
|
if ($statusAfter) {
|
||||||
$this->setStatus($ticket, $statusAfter);
|
$this->setStatus($ticket, $statusAfter);
|
||||||
@@ -179,17 +261,76 @@ class TicketService
|
|||||||
]);
|
]);
|
||||||
$message->attachAuthor($operator->id, 'operator');
|
$message->attachAuthor($operator->id, 'operator');
|
||||||
$this->attachFiles($ticket, $message, $attachments);
|
$this->attachFiles($ticket, $message, $attachments);
|
||||||
|
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([
|
$message = $ticket->messages()->create([
|
||||||
'author_name' => $client->name,
|
'author_name' => $client->name,
|
||||||
'body' => $body,
|
'body' => $body,
|
||||||
|
'source' => $source === 'web' ? null : $source,
|
||||||
]);
|
]);
|
||||||
$message->attachAuthor($client->id, 'client');
|
$message->attachAuthor($client->id, 'client');
|
||||||
$ticket->touch();
|
$ticket->touch();
|
||||||
$this->attachFiles($ticket, $message, $attachments);
|
$this->attachFiles($ticket, $message, $attachments);
|
||||||
|
|
||||||
|
// A fresh customer reply breaks whatever silence an automation rule
|
||||||
|
// fired on, so it should be able to fire again after a new period of
|
||||||
|
// silence rather than staying latched from before.
|
||||||
|
$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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -212,8 +353,12 @@ class TicketService
|
|||||||
|
|
||||||
if (! $internal) {
|
if (! $internal) {
|
||||||
$this->notify($ticket, 'operator_replied');
|
$this->notify($ticket, 'operator_replied');
|
||||||
|
app(TriggerEngine::class)->handle($ticket, 'comment_added');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TicketMessagePosted::dispatch($ticket->id, $message->id, $internal, null);
|
||||||
|
TicketQueueChanged::dispatch($ticket->id, 'message_posted', null);
|
||||||
|
|
||||||
return $message;
|
return $message;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,7 +410,7 @@ class TicketService
|
|||||||
|
|
||||||
$primary->messages()->create([
|
$primary->messages()->create([
|
||||||
'author_name' => 'System',
|
'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) {
|
foreach ($others as $other) {
|
||||||
@@ -283,23 +428,50 @@ class TicketService
|
|||||||
$note = $other->messages()->create([
|
$note = $other->messages()->create([
|
||||||
'author_name' => 'System',
|
'author_name' => 'System',
|
||||||
'internal' => true,
|
'internal' => true,
|
||||||
'body' => 'Scalone ze zgłoszeniem #'.$primary->number,
|
'body' => 'Scalone ze zgłoszeniem '.$primary->displayNumber(),
|
||||||
]);
|
]);
|
||||||
$note->attachAuthor(null, 'operator');
|
$note->attachAuthor(null, 'operator');
|
||||||
|
TicketQueueChanged::dispatch($other->id, 'merged', Auth::id());
|
||||||
}
|
}
|
||||||
|
|
||||||
$primary->touch();
|
$primary->touch();
|
||||||
|
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
|
* Public so the scheduled SLA-breach check (which isn't a ticket lifecycle
|
||||||
* event raised from within this service) can trigger the same way.
|
* event raised from within this service) can trigger the same way.
|
||||||
*
|
*
|
||||||
* Routes through the recipient's own User model (so it lands in the
|
* NotificationSetting.enabled is the global kill switch, layered above
|
||||||
* in-app notification bell in addition to e-mail) whenever one exists;
|
* every per-user preference below — disabling a trigger here silences
|
||||||
* falls back to an anonymous mail-only route for a guest customer with
|
* both legs regardless of what any individual staff member configured;
|
||||||
* no account. One shared NotificationSetting.enabled flag gates both
|
* the personal matrix can only narrow within an enabled trigger, never
|
||||||
* channels — there's no separate in-app on/off switch.
|
* 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
|
public function notify(Ticket $ticket, string $triggerKey): void
|
||||||
{
|
{
|
||||||
@@ -310,20 +482,93 @@ class TicketService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$notifiable = $setting->recipient === 'operator' ? $ticket->assignee : $ticket->customer;
|
$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) {
|
if ($notifiable) {
|
||||||
$notifiable->notify(new TicketNotification($ticket, $setting->email_template_id, $setting->recipient));
|
$notifiable->notify(new TicketNotification($ticket, $templateId, $recipientRole, $channels, $templateSource));
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$email = $setting->recipient === 'operator' ? $ticket->assignee?->email : $ticket->email;
|
if ($fallbackEmail) {
|
||||||
|
Notification::route('mail', $fallbackEmail)
|
||||||
if (! $email) {
|
->notify(new TicketNotification($ticket, $templateId, $recipientRole, $channels, $templateSource));
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Notification::route('mail', $email)
|
|
||||||
->notify(new TicketNotification($ticket, $setting->email_template_id, $setting->recipient));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
164
src/app/Services/TriggerEngine.php
Normal file
164
src/app/Services/TriggerEngine.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/app/Support/Imap/InboundEmail.php
Normal file
49
src/app/Support/Imap/InboundEmail.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Imap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalized view of one inbound message, independent of the IMAP client
|
||||||
|
* library — the seam between ImapMailboxFetcher (I/O, effectively
|
||||||
|
* untestable without a real mailbox) and ImapMessageClassifier (pure
|
||||||
|
* decision logic, fully Pest-testable against hand-built instances).
|
||||||
|
*/
|
||||||
|
class InboundEmail
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $headers lower-cased header names
|
||||||
|
* @param array<int, array{filename: string, mime: string, content: string}> $attachments
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $fromEmail,
|
||||||
|
public readonly string $fromName,
|
||||||
|
public readonly string $subject,
|
||||||
|
public readonly string $textBody,
|
||||||
|
public readonly string $htmlBody,
|
||||||
|
public readonly array $headers,
|
||||||
|
public readonly array $attachments = [],
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Treats an empty string the same as an absent header — the IMAP
|
||||||
|
* library backing ImapMailboxFetcher represents "header not present" as
|
||||||
|
* an empty value rather than a missing array key in some cases, so
|
||||||
|
* callers checking `header($x) !== null` alone would otherwise
|
||||||
|
* misdetect every message as carrying every header.
|
||||||
|
*/
|
||||||
|
public function header(string $name): ?string
|
||||||
|
{
|
||||||
|
$value = $this->headers[strtolower($name)] ?? null;
|
||||||
|
|
||||||
|
return $value !== null && $value !== '' ? $value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function body(): string
|
||||||
|
{
|
||||||
|
if (trim($this->textBody) !== '') {
|
||||||
|
return $this->textBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim(html_entity_decode(strip_tags($this->htmlBody)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,16 @@ class Settings
|
|||||||
'attachment_allowed_types' => 'jpg,jpeg,png,pdf,doc,docx,xls,xlsx,zip,txt',
|
'attachment_allowed_types' => 'jpg,jpeg,png,pdf,doc,docx,xls,xlsx,zip,txt',
|
||||||
'session_lifetime_minutes' => '120',
|
'session_lifetime_minutes' => '120',
|
||||||
'timezone' => 'UTC',
|
'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_enabled' => '1',
|
||||||
'ldap_host' => '',
|
'ldap_host' => '',
|
||||||
'ldap_port' => '389',
|
'ldap_port' => '389',
|
||||||
@@ -45,9 +55,28 @@ class Settings
|
|||||||
'bookstack_token_secret' => '',
|
'bookstack_token_secret' => '',
|
||||||
'bookstack_verify_ssl' => '1',
|
'bookstack_verify_ssl' => '1',
|
||||||
'bookstack_show_to_guests' => '0',
|
'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_creation' => '',
|
||||||
'bookstack_allowed_shelf_ids_ticket_view' => '',
|
'bookstack_allowed_shelf_ids_ticket_view' => '',
|
||||||
|
'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_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>',
|
'email_footer' => '<p>Ta wiadomość została wygenerowana automatycznie przez system {firma} — prosimy na nią nie odpowiadać.</p>',
|
||||||
'accent_color' => '#7c6fd6',
|
'accent_color' => '#7c6fd6',
|
||||||
'login_notice_type' => 'info',
|
'login_notice_type' => 'info',
|
||||||
@@ -60,7 +89,7 @@ class Settings
|
|||||||
.'</div>',
|
.'</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'];
|
||||||
|
|
||||||
public static function get(string $key, ?string $default = null): ?string
|
public static function get(string $key, ?string $default = null): ?string
|
||||||
{
|
{
|
||||||
@@ -206,6 +235,24 @@ class Settings
|
|||||||
: static::$defaults['timezone'];
|
: 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
|
* Wraps a single e-mail template's rendered HTML body in the fixed
|
||||||
* "box" layout — company name, ticket content and footer — so every
|
* "box" layout — company name, ticket content and footer — so every
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Middleware\EnsureRole;
|
use App\Http\Middleware\EnsureRole;
|
||||||
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Laravel\Sanctum\Http\Middleware\CheckAbilities;
|
use Laravel\Sanctum\Http\Middleware\CheckAbilities;
|
||||||
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
|
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
return Application::configure(basePath: dirname(__DIR__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withRouting(
|
->withRouting(
|
||||||
web: __DIR__.'/../routes/web.php',
|
web: __DIR__.'/../routes/web.php',
|
||||||
api: __DIR__.'/../routes/api.php',
|
api: __DIR__.'/../routes/api.php',
|
||||||
commands: __DIR__.'/../routes/console.php',
|
commands: __DIR__.'/../routes/console.php',
|
||||||
|
channels: __DIR__.'/../routes/channels.php',
|
||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
@@ -43,4 +46,29 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
$exceptions->shouldRenderJsonWhen(
|
$exceptions->shouldRenderJsonWhen(
|
||||||
fn (Request $request) => $request->is('api/*'),
|
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();
|
})->create();
|
||||||
|
|||||||
@@ -13,9 +13,11 @@
|
|||||||
"darkaonline/l5-swagger": "*",
|
"darkaonline/l5-swagger": "*",
|
||||||
"directorytree/ldaprecord-laravel": "*",
|
"directorytree/ldaprecord-laravel": "*",
|
||||||
"laravel/framework": "^13.8",
|
"laravel/framework": "^13.8",
|
||||||
|
"laravel/reverb": "*",
|
||||||
"laravel/sanctum": "*",
|
"laravel/sanctum": "*",
|
||||||
"laravel/tinker": "^3.0",
|
"laravel/tinker": "^3.0",
|
||||||
"livewire/livewire": "*"
|
"livewire/livewire": "*",
|
||||||
|
"webklex/php-imap": "*"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
|
|||||||
988
src/composer.lock
generated
988
src/composer.lock
generated
File diff suppressed because it is too large
Load Diff
82
src/config/broadcasting.php
Normal file
82
src/config/broadcasting.php
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Broadcaster
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option controls the default broadcaster that will be used by the
|
||||||
|
| framework when an event needs to be broadcast. You may set this to
|
||||||
|
| any of the connections defined in the "connections" array below.
|
||||||
|
|
|
||||||
|
| Supported: "reverb", "pusher", "ably", "redis", "log", "null"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('BROADCAST_CONNECTION', 'null'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Broadcast Connections
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may define all of the broadcast connections that will be used
|
||||||
|
| to broadcast events to other systems or over WebSockets. Samples of
|
||||||
|
| each available type of connection are provided inside this array.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'connections' => [
|
||||||
|
|
||||||
|
'reverb' => [
|
||||||
|
'driver' => 'reverb',
|
||||||
|
'key' => env('REVERB_APP_KEY'),
|
||||||
|
'secret' => env('REVERB_APP_SECRET'),
|
||||||
|
'app_id' => env('REVERB_APP_ID'),
|
||||||
|
'options' => [
|
||||||
|
'host' => env('REVERB_HOST'),
|
||||||
|
'port' => env('REVERB_PORT', 443),
|
||||||
|
'scheme' => env('REVERB_SCHEME', 'https'),
|
||||||
|
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
|
||||||
|
],
|
||||||
|
'client_options' => [
|
||||||
|
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'pusher' => [
|
||||||
|
'driver' => 'pusher',
|
||||||
|
'key' => env('PUSHER_APP_KEY'),
|
||||||
|
'secret' => env('PUSHER_APP_SECRET'),
|
||||||
|
'app_id' => env('PUSHER_APP_ID'),
|
||||||
|
'options' => [
|
||||||
|
'cluster' => env('PUSHER_APP_CLUSTER'),
|
||||||
|
'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com',
|
||||||
|
'port' => env('PUSHER_PORT', 443),
|
||||||
|
'scheme' => env('PUSHER_SCHEME', 'https'),
|
||||||
|
'encrypted' => true,
|
||||||
|
'useTLS' => env('PUSHER_SCHEME', 'https') === 'https',
|
||||||
|
],
|
||||||
|
'client_options' => [
|
||||||
|
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'ably' => [
|
||||||
|
'driver' => 'ably',
|
||||||
|
'key' => env('ABLY_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'log' => [
|
||||||
|
'driver' => 'log',
|
||||||
|
],
|
||||||
|
|
||||||
|
'null' => [
|
||||||
|
'driver' => 'null',
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -73,6 +73,19 @@ return [
|
|||||||
'replace_placeholders' => true,
|
'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' => [
|
'slack' => [
|
||||||
'driver' => 'slack',
|
'driver' => 'slack',
|
||||||
'url' => env('LOG_SLACK_WEBHOOK_URL'),
|
'url' => env('LOG_SLACK_WEBHOOK_URL'),
|
||||||
|
|||||||
102
src/config/reverb.php
Normal file
102
src/config/reverb.php
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Reverb Server
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option controls the default server used by Reverb to handle
|
||||||
|
| incoming messages as well as broadcasting message to all your
|
||||||
|
| connected clients. At this time only "reverb" is supported.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('REVERB_SERVER', 'reverb'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Reverb Servers
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may define details for each of the supported Reverb servers.
|
||||||
|
| Each server has its own configuration options that are defined in
|
||||||
|
| the array below. You should ensure all the options are present.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'servers' => [
|
||||||
|
|
||||||
|
'reverb' => [
|
||||||
|
'host' => env('REVERB_SERVER_HOST', '0.0.0.0'),
|
||||||
|
'port' => env('REVERB_SERVER_PORT', 8080),
|
||||||
|
'path' => env('REVERB_SERVER_PATH', ''),
|
||||||
|
'hostname' => env('REVERB_HOST'),
|
||||||
|
'options' => [
|
||||||
|
'tls' => [],
|
||||||
|
],
|
||||||
|
'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000),
|
||||||
|
'scaling' => [
|
||||||
|
'enabled' => env('REVERB_SCALING_ENABLED', false),
|
||||||
|
'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'),
|
||||||
|
'server' => [
|
||||||
|
'url' => env('REDIS_URL'),
|
||||||
|
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('REDIS_PORT', '6379'),
|
||||||
|
'username' => env('REDIS_USERNAME'),
|
||||||
|
'password' => env('REDIS_PASSWORD'),
|
||||||
|
'database' => env('REDIS_DB', '0'),
|
||||||
|
'timeout' => env('REDIS_TIMEOUT', 60),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15),
|
||||||
|
'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15),
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Reverb Applications
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may define how Reverb applications are managed. If you choose
|
||||||
|
| to use the "config" provider, you may define an array of apps which
|
||||||
|
| your server will support, including their connection credentials.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'apps' => [
|
||||||
|
|
||||||
|
'provider' => 'config',
|
||||||
|
|
||||||
|
'apps' => [
|
||||||
|
[
|
||||||
|
'key' => env('REVERB_APP_KEY'),
|
||||||
|
'secret' => env('REVERB_APP_SECRET'),
|
||||||
|
'app_id' => env('REVERB_APP_ID'),
|
||||||
|
'options' => [
|
||||||
|
'host' => env('REVERB_HOST'),
|
||||||
|
'port' => env('REVERB_PORT', 443),
|
||||||
|
'scheme' => env('REVERB_SCHEME', 'https'),
|
||||||
|
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
|
||||||
|
],
|
||||||
|
'allowed_origins' => ['*'],
|
||||||
|
'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
|
||||||
|
'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
|
||||||
|
'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'),
|
||||||
|
'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000),
|
||||||
|
'accept_client_events_from' => env('REVERB_APP_ACCEPT_CLIENT_EVENTS_FROM', 'members'),
|
||||||
|
'rate_limiting' => [
|
||||||
|
'enabled' => env('REVERB_APP_RATE_LIMITING_ENABLED', false),
|
||||||
|
'max_attempts' => env('REVERB_APP_RATE_LIMIT_MAX_ATTEMPTS', 60),
|
||||||
|
'decay_seconds' => env('REVERB_APP_RATE_LIMIT_DECAY_SECONDS', 60),
|
||||||
|
'terminate_on_limit' => env('REVERB_APP_RATE_LIMIT_TERMINATE', false),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -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
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* scope_* columns are soft references (like tickets.status_key) — no FK,
|
||||||
|
* so deleting a priority/subcategory/team in Admin never blocks or
|
||||||
|
* cascades into a rule; a null scope column means "any" for that filter.
|
||||||
|
* action_value likewise soft-holds whichever kind of key/id action_type
|
||||||
|
* needs (priority_key/status_key/team_id/user_id).
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('automation_rules', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('label');
|
||||||
|
$table->boolean('enabled')->default(true);
|
||||||
|
$table->unsignedInteger('condition_minutes');
|
||||||
|
$table->string('scope_priority_key')->nullable();
|
||||||
|
$table->unsignedBigInteger('scope_subcategory_id')->nullable();
|
||||||
|
$table->unsignedBigInteger('scope_team_id')->nullable();
|
||||||
|
$table->string('action_type');
|
||||||
|
$table->string('action_value');
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('automation_rules');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* One row per (rule, ticket) firing — the idempotency latch that stops
|
||||||
|
* RunAutomationRules from re-applying the same rule to the same ticket
|
||||||
|
* every scheduler tick. Rows are deleted (not flagged) by TicketService
|
||||||
|
* whenever the underlying silence is broken, so the rule can fire again.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('automation_rule_ticket_logs', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('automation_rule_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('ticket_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->timestamp('triggered_at');
|
||||||
|
|
||||||
|
$table->unique(['automation_rule_id', 'ticket_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('automation_rule_ticket_logs');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Nullable, no backfill of existing rows — RunAutomationRules falls back
|
||||||
|
* to created_at when this is null, mirroring how resolutionDeadline()
|
||||||
|
* treats a missing SlaRule as "no SLA" rather than backfilling one.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('tickets', function (Blueprint $table) {
|
||||||
|
$table->timestamp('last_customer_activity_at')->nullable()->after('sla_notified_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('tickets', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('last_customer_activity_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Backfills the new 'ticket_created_team' trigger + its email template
|
||||||
|
* for an already-seeded database (mirrors DatabaseSeeder::
|
||||||
|
* seedEmailTemplatesAndNotifications(), which only runs on a fresh
|
||||||
|
* install) — guarded so re-running, or a fresh seed that already has
|
||||||
|
* both rows, is a no-op.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$templateId = DB::table('email_templates')->where('key', 'tpl-team-new-ticket')->value('id');
|
||||||
|
|
||||||
|
if (! $templateId) {
|
||||||
|
$templateId = DB::table('email_templates')->insertGetId([
|
||||||
|
'key' => 'tpl-team-new-ticket',
|
||||||
|
'name' => 'Nowe zgłoszenie w zespole',
|
||||||
|
'trigger_label' => 'Nowe zgłoszenie w zespole — operator',
|
||||||
|
'subject' => 'Nowe zgłoszenie w Twoim zespole (#{numer})',
|
||||||
|
'body' => '<p>Cześć,</p><p>Nowe zgłoszenie „{temat}” (#{numer}, kategoria: {kategoria}) trafiło do zespołu {zespol}.</p><p>Podgląd zgłoszenia: <a href="{link}" rel="noopener noreferrer" target="_blank">Kliknij tu</a></p><p>Pozdrawiamy,<br>Zespół Wsparcia</p>',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DB::table('notification_settings')->where('trigger_key', 'ticket_created_team')->exists()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('notification_settings')->insert([
|
||||||
|
'trigger_key' => 'ticket_created_team',
|
||||||
|
'trigger_label' => 'Nowe zgłoszenie w zespole (powiadom operatorów)',
|
||||||
|
'enabled' => true,
|
||||||
|
'recipient' => 'operator',
|
||||||
|
'email_template_id' => $templateId,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
DB::table('notification_settings')->where('trigger_key', 'ticket_created_team')->delete();
|
||||||
|
DB::table('email_templates')->where('key', 'tpl-team-new-ticket')->delete();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('tickets', function (Blueprint $table) {
|
||||||
|
$table->string('checksum', 20)->nullable()->unique()->after('number');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Backfill: every existing ticket gets a stable, HMAC-derived
|
||||||
|
// checksum (mirrors Ticket::generateUniqueChecksum()) so the
|
||||||
|
// "hide ticket order" numbering mode has a real, unique, indexed
|
||||||
|
// column to resolve ticket URLs against instead of only being a
|
||||||
|
// display-time computation.
|
||||||
|
$assigned = [];
|
||||||
|
|
||||||
|
DB::table('tickets')->orderBy('id')->select('id')->chunkById(500, function ($tickets) use (&$assigned) {
|
||||||
|
foreach ($tickets as $ticket) {
|
||||||
|
$nonce = 0;
|
||||||
|
|
||||||
|
do {
|
||||||
|
$hash = hash_hmac('sha256', $ticket->id.'|'.$nonce, (string) config('app.key'));
|
||||||
|
$candidate = (string) (hexdec(substr($hash, 0, 8)) % 900000 + 100000);
|
||||||
|
$nonce++;
|
||||||
|
} while (isset($assigned[$candidate]));
|
||||||
|
|
||||||
|
$assigned[$candidate] = true;
|
||||||
|
|
||||||
|
DB::table('tickets')->where('id', $ticket->id)->update(['checksum' => $candidate]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('tickets', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('checksum');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('imap_mailboxes', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name');
|
||||||
|
$table->boolean('enabled')->default(false);
|
||||||
|
$table->string('host');
|
||||||
|
$table->unsignedSmallInteger('port')->default(993);
|
||||||
|
$table->string('encryption')->default('ssl');
|
||||||
|
$table->boolean('validate_cert')->default(true);
|
||||||
|
$table->string('username');
|
||||||
|
$table->text('password')->nullable();
|
||||||
|
$table->string('folder')->default('INBOX');
|
||||||
|
$table->string('processed_folder')->nullable();
|
||||||
|
$table->string('rejected_folder')->nullable();
|
||||||
|
$table->foreignId('default_subcategory_id')->nullable()->constrained('subcategories')->nullOnDelete();
|
||||||
|
$table->string('blocklist_senders')->default('mailer-daemon,postmaster,no-reply,noreply');
|
||||||
|
$table->timestamp('last_checked_at')->nullable();
|
||||||
|
$table->text('last_error')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('imap_mailboxes');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* category_id lets a ticket carry just a Category with no specific
|
||||||
|
* Subcategory (e.g. an IMAP mailbox routed to "całą kategorię" rather
|
||||||
|
* than one subcategory) — subcategory_id already implies a category via
|
||||||
|
* its own relation, so category_id is only ever populated when there's
|
||||||
|
* no subcategory to derive it from (see Ticket::categoryLabel()).
|
||||||
|
*
|
||||||
|
* source records how the ticket was created (web/e-mail/...), surfaced
|
||||||
|
* as a badge in the operator queue/ticket view.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('tickets', function (Blueprint $table) {
|
||||||
|
$table->foreignId('category_id')->nullable()->after('subcategory_id')->constrained('categories')->nullOnDelete();
|
||||||
|
$table->string('source')->default('web')->after('api_client_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('imap_mailboxes', function (Blueprint $table) {
|
||||||
|
$table->foreignId('default_category_id')->nullable()->after('default_subcategory_id')->constrained('categories')->nullOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('imap_mailboxes', function (Blueprint $table) {
|
||||||
|
$table->dropConstrainedForeignId('default_category_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('tickets', function (Blueprint $table) {
|
||||||
|
$table->dropConstrainedForeignId('category_id');
|
||||||
|
$table->dropColumn('source');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Mirrors tickets.source at the individual-message level — a ticket
|
||||||
|
* created on the web can still later receive a reply by e-mail (or vice
|
||||||
|
* versa), so this needs tracking per message, not just per ticket.
|
||||||
|
* Null means "web" (the original/default channel); only IMAP-originated
|
||||||
|
* messages ever set it to 'email'.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('ticket_messages', function (Blueprint $table) {
|
||||||
|
$table->string('source')->nullable()->after('api_client_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('ticket_messages', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('source');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* ai_triaged_at marks that the AI auto-triage pass has run for this
|
||||||
|
* ticket (regardless of whether it changed anything) — never reset, so
|
||||||
|
* the scheduled command's query is just "tickets where this is null".
|
||||||
|
*
|
||||||
|
* ai_summary/ai_suggested_action/ai_summary_generated_at cache the AI
|
||||||
|
* ticket summary shown to operators; generated_at lets the summary
|
||||||
|
* command cheaply tell whether a ticket's summary is stale relative to
|
||||||
|
* its latest message, without re-summarizing every ticket every run.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('tickets', function (Blueprint $table) {
|
||||||
|
$table->timestamp('ai_triaged_at')->nullable()->after('source');
|
||||||
|
$table->text('ai_summary')->nullable()->after('ai_triaged_at');
|
||||||
|
$table->text('ai_suggested_action')->nullable()->after('ai_summary');
|
||||||
|
$table->timestamp('ai_summary_generated_at')->nullable()->after('ai_suggested_action');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('tickets', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['ai_triaged_at', 'ai_summary', 'ai_suggested_action', 'ai_summary_generated_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('subcategories', function (Blueprint $table) {
|
||||||
|
$table->unsignedInteger('sort_order')->default(0)->after('default_priority_key');
|
||||||
|
});
|
||||||
|
|
||||||
|
foreach (DB::table('subcategories')->orderBy('category_id')->orderBy('id')->get() as $position => $sub) {
|
||||||
|
DB::table('subcategories')->where('id', $sub->id)->update(['sort_order' => $position]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('subcategories', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('sort_order');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -55,7 +55,7 @@ class DatabaseSeeder extends Seeder
|
|||||||
['key' => 'operator', 'label' => 'Operator'],
|
['key' => 'operator', 'label' => 'Operator'],
|
||||||
['key' => 'admin', 'label' => 'Administrator'],
|
['key' => 'admin', 'label' => 'Administrator'],
|
||||||
] as $role) {
|
] as $role) {
|
||||||
Role::query()->create($role);
|
Role::query()->firstOrCreate(['key' => $role['key']], $role);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,6 +259,11 @@ class DatabaseSeeder extends Seeder
|
|||||||
{
|
{
|
||||||
foreach ([
|
foreach ([
|
||||||
['label' => 'Wyślij i „Oczekuje na klienta”', 'status_key' => 'waiting_customer', 'sort_order' => 1],
|
['label' => 'Wyślij i „Oczekuje na klienta”', 'status_key' => 'waiting_customer', 'sort_order' => 1],
|
||||||
|
// Used to point at the now-removed "resolved" status, folded into
|
||||||
|
// "closed" by the status restructure — same target as "Wyślij i
|
||||||
|
// zamknij" today, kept as a separate quick action for continuity
|
||||||
|
// with the old hardcoded menu (see ReplyQuickActionsTest).
|
||||||
|
['label' => 'Wyślij i oznacz jako rozwiązane', 'status_key' => 'closed', 'sort_order' => 2],
|
||||||
['label' => 'Wyślij i zamknij', 'status_key' => 'closed', 'sort_order' => 3],
|
['label' => 'Wyślij i zamknij', 'status_key' => 'closed', 'sort_order' => 3],
|
||||||
] as $action) {
|
] as $action) {
|
||||||
ReplyQuickAction::query()->create($action);
|
ReplyQuickAction::query()->create($action);
|
||||||
@@ -335,13 +340,17 @@ class DatabaseSeeder extends Seeder
|
|||||||
'subject' => 'Przekroczono SLA zgłoszenia #{numer}',
|
'subject' => 'Przekroczono SLA zgłoszenia #{numer}',
|
||||||
'body' => '<p>Cześć {operator},</p><p>Zgłoszenie „{temat}” (#{numer}) przekroczyło ustalony czas rozwiązania SLA.</p>'.$link.$footer,
|
'body' => '<p>Cześć {operator},</p><p>Zgłoszenie „{temat}” (#{numer}) przekroczyło ustalony czas rozwiązania SLA.</p>'.$link.$footer,
|
||||||
],
|
],
|
||||||
|
'tpl-team-new-ticket' => [
|
||||||
|
'name' => 'Nowe zgłoszenie w zespole', 'trigger_label' => 'Nowe zgłoszenie w zespole — operator',
|
||||||
|
'subject' => 'Nowe zgłoszenie w Twoim zespole (#{numer})',
|
||||||
|
'body' => '<p>Cześć,</p><p>Nowe zgłoszenie „{temat}” (#{numer}, kategoria: {kategoria}) trafiło do zespołu {zespol}.</p>'.$link.$footer,
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
$ids = [];
|
$ids = [];
|
||||||
|
|
||||||
foreach ($templates as $key => $tpl) {
|
foreach ($templates as $key => $tpl) {
|
||||||
$ids[$key] = EmailTemplate::query()->create([
|
$ids[$key] = EmailTemplate::query()->firstOrCreate(['key' => $key], [
|
||||||
'key' => $key,
|
|
||||||
'name' => $tpl['name'],
|
'name' => $tpl['name'],
|
||||||
'trigger_label' => $tpl['trigger_label'],
|
'trigger_label' => $tpl['trigger_label'],
|
||||||
'subject' => $tpl['subject'],
|
'subject' => $tpl['subject'],
|
||||||
@@ -359,9 +368,9 @@ class DatabaseSeeder extends Seeder
|
|||||||
['trigger_key' => 'ticket_closed', 'trigger_label' => 'Zgłoszenie zamknięte', 'enabled' => true, 'recipient' => 'client', 'template' => 'tpl-closed'],
|
['trigger_key' => 'ticket_closed', 'trigger_label' => 'Zgłoszenie zamknięte', 'enabled' => true, 'recipient' => 'client', 'template' => 'tpl-closed'],
|
||||||
['trigger_key' => 'operator_replied', 'trigger_label' => 'Nowa odpowiedź operatora', 'enabled' => true, 'recipient' => 'client', 'template' => 'tpl-reply'],
|
['trigger_key' => 'operator_replied', 'trigger_label' => 'Nowa odpowiedź operatora', 'enabled' => true, 'recipient' => 'client', 'template' => 'tpl-reply'],
|
||||||
['trigger_key' => 'sla_breached', 'trigger_label' => 'Przekroczono SLA (powiadom operatora)', 'enabled' => false, 'recipient' => 'operator', 'template' => 'tpl-sla-breach'],
|
['trigger_key' => 'sla_breached', 'trigger_label' => 'Przekroczono SLA (powiadom operatora)', 'enabled' => false, 'recipient' => 'operator', 'template' => 'tpl-sla-breach'],
|
||||||
|
['trigger_key' => 'ticket_created_team', 'trigger_label' => 'Nowe zgłoszenie w zespole (powiadom operatorów)', 'enabled' => true, 'recipient' => 'operator', 'template' => 'tpl-team-new-ticket'],
|
||||||
] as $setting) {
|
] as $setting) {
|
||||||
NotificationSetting::query()->create([
|
NotificationSetting::query()->firstOrCreate(['trigger_key' => $setting['trigger_key']], [
|
||||||
'trigger_key' => $setting['trigger_key'],
|
|
||||||
'trigger_label' => $setting['trigger_label'],
|
'trigger_label' => $setting['trigger_label'],
|
||||||
'enabled' => $setting['enabled'],
|
'enabled' => $setting['enabled'],
|
||||||
'recipient' => $setting['recipient'],
|
'recipient' => $setting['recipient'],
|
||||||
|
|||||||
40
src/package-lock.json
generated
40
src/package-lock.json
generated
@@ -4,6 +4,10 @@
|
|||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
|
"dependencies": {
|
||||||
|
"laravel-echo": "^2.1.0",
|
||||||
|
"pusher-js": "^8.4.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"concurrently": "^9.0.1",
|
"concurrently": "^9.0.1",
|
||||||
@@ -909,6 +913,27 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/laravel-echo": {
|
||||||
|
"version": "2.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.4.0.tgz",
|
||||||
|
"integrity": "sha512-8w0fAGSNt6THfbNyqdKc29bhfeNpJg13CGx2fcLgoX0/f0mTJm/AIkYTTakmcr9pc42ZB68cSoE00j4/xNaFGQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pusher-js": "*",
|
||||||
|
"socket.io-client": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"pusher-js": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"socket.io-client": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/laravel-vite-plugin": {
|
"node_modules/laravel-vite-plugin": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.1.3.tgz",
|
||||||
@@ -1275,6 +1300,15 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pusher-js": {
|
||||||
|
"version": "8.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.5.0.tgz",
|
||||||
|
"integrity": "sha512-V7uzGi9bqOOOyM/6IkJdpFyjGZj7llz1v0oWnYkZKcYLvbz6VcHVLmzKqkvegjuMumpfIEKGLmWHwFb39XFCpw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tweetnacl": "^1.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/require-directory": {
|
"node_modules/require-directory": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
@@ -1451,6 +1485,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
|
"node_modules/tweetnacl": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
|
||||||
|
"license": "Unlicense"
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.1.5",
|
"version": "8.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"dev": "vite"
|
"dev": "vite"
|
||||||
},
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"laravel-echo": "^2.1.0",
|
||||||
|
"pusher-js": "^8.4.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"concurrently": "^9.0.1",
|
"concurrently": "^9.0.1",
|
||||||
|
|||||||
@@ -343,6 +343,22 @@ body {
|
|||||||
.page-pad { padding: 16px !important; }
|
.page-pad { padding: 16px !important; }
|
||||||
.nav { padding-left: 14px !important; padding-right: 14px !important; gap: 10px; }
|
.nav { padding-left: 14px !important; padding-right: 14px !important; gap: 10px; }
|
||||||
.nav-panel-label { display: none; }
|
.nav-panel-label { display: none; }
|
||||||
|
|
||||||
|
/* Theme/notifications/profile dropdowns are anchored (position:absolute)
|
||||||
|
to their own small trigger button by default, which overflows off the
|
||||||
|
edge of narrow screens once their fixed width no longer fits between
|
||||||
|
the button and the viewport edge. Dropping the wrapper's own
|
||||||
|
positioning context makes .nav itself (already position:relative) the
|
||||||
|
containing block instead, so left/right:0 spans the whole navbar
|
||||||
|
width rather than the button's. */
|
||||||
|
.nav-dropdown-wrap { position: static !important; }
|
||||||
|
.nav-dropdown {
|
||||||
|
left: 0 !important;
|
||||||
|
right: 0 !important;
|
||||||
|
width: auto !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
margin-top: 8px !important;
|
||||||
|
}
|
||||||
.profile-menu-name { display: none; }
|
.profile-menu-name { display: none; }
|
||||||
.main-col { min-width: 0; }
|
.main-col { min-width: 0; }
|
||||||
.aside-col { width: 100%; }
|
.aside-col { width: 100%; }
|
||||||
|
|||||||
@@ -1 +1,9 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Echo exposes an expressive API for subscribing to channels and listening
|
||||||
|
* for events that are broadcast by Laravel. Echo and event broadcasting
|
||||||
|
* allow your team to quickly build robust real-time web applications.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import './echo';
|
||||||
|
|||||||
112
src/resources/js/echo.js
Normal file
112
src/resources/js/echo.js
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import Echo from 'laravel-echo';
|
||||||
|
|
||||||
|
import Pusher from 'pusher-js';
|
||||||
|
window.Pusher = Pusher;
|
||||||
|
|
||||||
|
// Private channel subscriptions POST to /broadcasting/auth, which sits
|
||||||
|
// behind the app's normal CSRF middleware like any other POST route —
|
||||||
|
// without this header every private-channel auth request 419s silently
|
||||||
|
// (pusher-js swallows it as a subscription error), so nothing broadcast
|
||||||
|
// ever reaches the browser even though the socket connection itself works.
|
||||||
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||||
|
|
||||||
|
window.Echo = new Echo({
|
||||||
|
broadcaster: 'reverb',
|
||||||
|
key: import.meta.env.VITE_REVERB_APP_KEY,
|
||||||
|
wsHost: import.meta.env.VITE_REVERB_HOST,
|
||||||
|
wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,
|
||||||
|
wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
|
||||||
|
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
|
||||||
|
enabledTransports: ['ws', 'wss'],
|
||||||
|
auth: {
|
||||||
|
headers: {
|
||||||
|
'X-CSRF-TOKEN': csrfToken,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridges Reverb broadcast events into plain Livewire events rather than
|
||||||
|
* using the `#[On('echo-private:...')]` attribute directly on components —
|
||||||
|
* this indirection is deliberately version-agnostic and easy to verify from
|
||||||
|
* the browser console regardless of Livewire's internals.
|
||||||
|
*
|
||||||
|
* This file is loaded via @vite as `type="module"`, which the HTML spec
|
||||||
|
* defers until after the document is parsed — meaning any plain
|
||||||
|
* (non-deferred) <script> earlier in the page, including Livewire's own
|
||||||
|
* bootstrap script from @livewireScripts, has ALREADY run by the time this
|
||||||
|
* executes. So `window.Livewire` is already available here; there's no
|
||||||
|
* reason to wait for the 'livewire:init' event. Waiting for it was actually
|
||||||
|
* a bug: Livewire dispatches that event synchronously as part of its own
|
||||||
|
* (earlier-running) script, so a listener registered this late permanently
|
||||||
|
* missed it — silently disabling this whole subscription, every time.
|
||||||
|
*/
|
||||||
|
if (window.currentUserId) {
|
||||||
|
window.Echo.private('operator.queue')
|
||||||
|
.listen('.TicketQueueChanged', (e) => {
|
||||||
|
if (e.actorId !== window.currentUserId) {
|
||||||
|
// Only ticketId is passed through — Livewire calls #[On] methods
|
||||||
|
// with the payload as named arguments, so keeping this to a
|
||||||
|
// single well-known key avoids every listener having to declare
|
||||||
|
// (and ignore) every field this event might ever carry.
|
||||||
|
Livewire.dispatch('queue-changed', { ticketId: e.ticketId });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.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,
|
||||||
|
* since the channel name needs the ticket id that only the page knows.
|
||||||
|
*/
|
||||||
|
window.subscribeToTicketChannel = function (ticketId) {
|
||||||
|
window.Echo.private('ticket.' + ticketId)
|
||||||
|
.listen('.TicketMessagePosted', (e) => {
|
||||||
|
if (e.actorId !== window.currentUserId) {
|
||||||
|
Livewire.dispatch('ticket-message-posted', { ticketId: e.ticketId });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.listen('.TicketQueueChanged', (e) => {
|
||||||
|
if (e.actorId !== window.currentUserId) {
|
||||||
|
Livewire.dispatch('queue-changed', { ticketId: e.ticketId });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.error((error) => console.error('ticket.' + ticketId + ' subscription error', error));
|
||||||
|
};
|
||||||
|
|
||||||
|
// The @script block in ticket-show.blade.php calls subscribeToTicketChannel()
|
||||||
|
// as soon as Livewire processes that component — which can happen either
|
||||||
|
// before or after this deferred module has run, depending on exactly when
|
||||||
|
// Livewire gets to it. If it ran first, it queued the ticket id here instead
|
||||||
|
// of finding the function undefined; flush that queue now that we're ready.
|
||||||
|
(window.__pendingTicketChannelIds || []).forEach((id) => window.subscribeToTicketChannel(id));
|
||||||
|
window.__pendingTicketChannelIds = null;
|
||||||
@@ -2,24 +2,12 @@
|
|||||||
|
|
||||||
@php
|
@php
|
||||||
$url = \Illuminate\Support\Facades\Storage::disk('public')->url($attachment->path);
|
$url = \Illuminate\Support\Facades\Storage::disk('public')->url($attachment->path);
|
||||||
$isImage = \Illuminate\Support\Str::startsWith($attachment->mime ?? '', 'image/');
|
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@if ($isImage)
|
<a
|
||||||
<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 }}"
|
href="{{ $url }}"
|
||||||
target="_blank"
|
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)"
|
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 }}
|
<span class="material-symbols-outlined" style="font-size:15px">attach_file</span>{{ $attachment->original_name }}
|
||||||
</a>
|
</a>
|
||||||
@endif
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@if ($user)
|
@if ($user)
|
||||||
<div x-data="{ open: false }" @click.outside="open = false" style="position:relative;display:inline-block">
|
<div x-data="{ open: false }" @click.outside="open = false" class="nav-dropdown-wrap" style="position:relative;display:inline-block">
|
||||||
<button type="button" class="btn btn-secondary" @click="open = !open" style="display:flex;align-items:center;gap:6px">
|
<button type="button" class="btn btn-secondary" @click="open = !open" style="display:flex;align-items:center;gap:6px">
|
||||||
<span class="material-symbols-outlined" style="font-size:18px">account_circle</span>
|
<span class="material-symbols-outlined" style="font-size:18px">account_circle</span>
|
||||||
<span class="profile-menu-name">{{ $user->name }}</span>
|
<span class="profile-menu-name">{{ $user->name }}</span>
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
<div
|
<div
|
||||||
x-show="open"
|
x-show="open"
|
||||||
x-cloak
|
x-cloak
|
||||||
|
class="nav-dropdown"
|
||||||
style="position:absolute;top:100%;right:0;margin-top:6px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);min-width:200px;overflow:hidden;z-index:30"
|
style="position:absolute;top:100%;right:0;margin-top:6px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);min-width:200px;overflow:hidden;z-index:30"
|
||||||
>
|
>
|
||||||
@foreach ($areas as $area)
|
@foreach ($areas as $area)
|
||||||
@@ -41,6 +42,18 @@
|
|||||||
<div style="border-top:1px solid var(--color-divider)"></div>
|
<div style="border-top:1px solid var(--color-divider)"></div>
|
||||||
@endif
|
@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
|
<a
|
||||||
href="{{ route('logout') }}"
|
href="{{ route('logout') }}"
|
||||||
onclick="event.preventDefault(); document.getElementById('profile-menu-logout-form').submit();"
|
onclick="event.preventDefault(); document.getElementById('profile-menu-logout-form').submit();"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
}
|
}
|
||||||
}"
|
}"
|
||||||
@click.outside="open = false"
|
@click.outside="open = false"
|
||||||
|
class="nav-dropdown-wrap"
|
||||||
style="position:relative;display:inline-block"
|
style="position:relative;display:inline-block"
|
||||||
>
|
>
|
||||||
<button type="button" class="btn btn-secondary btn-icon" @click="open = !open">
|
<button type="button" class="btn btn-secondary btn-icon" @click="open = !open">
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
<div
|
<div
|
||||||
x-show="open"
|
x-show="open"
|
||||||
x-cloak
|
x-cloak
|
||||||
|
class="nav-dropdown"
|
||||||
style="position:absolute;top:100%;right:0;margin-top:6px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);min-width:150px;overflow:hidden;z-index:20"
|
style="position:absolute;top:100%;right:0;margin-top:6px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);min-width:150px;overflow:hidden;z-index:20"
|
||||||
>
|
>
|
||||||
<button type="button" class="theme-toggle-option" @click="apply('light')">
|
<button type="button" class="theme-toggle-option" @click="apply('light')">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||||
<title>{{ $title ?? \App\Support\Settings::get('company_name') }}</title>
|
<title>{{ $title ?? \App\Support\Settings::get('company_name') }}</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="{{ \App\Support\Settings::faviconUrl() }}">
|
<link rel="icon" type="image/svg+xml" href="{{ \App\Support\Settings::faviconUrl() }}">
|
||||||
|
|
||||||
@@ -70,6 +71,12 @@
|
|||||||
.ql-editor hr { border: none; border-top: 1px solid var(--color-divider); margin: 10px 0; }
|
.ql-editor hr { border: none; border-top: 1px solid var(--color-divider); margin: 10px 0; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Lets the Echo listeners in resources/js/echo.js tell "my own action
|
||||||
|
// echoed back" apart from "someone else changed this" without a
|
||||||
|
// roundtrip — broadcast event payloads carry the same actorId shape.
|
||||||
|
window.currentUserId = @json(auth()->id());
|
||||||
|
</script>
|
||||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||||
<style>:root{--color-accent: {{ \App\Support\Settings::accentColor() }};}</style>
|
<style>:root{--color-accent: {{ \App\Support\Settings::accentColor() }};}</style>
|
||||||
@livewireStyles
|
@livewireStyles
|
||||||
|
|||||||
182
src/resources/views/livewire/admin/mail-settings.blade.php
Normal file
182
src/resources/views/livewire/admin/mail-settings.blade.php
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
<div>
|
||||||
|
<h3 style="margin:0 0 14px">E-mail (SMTP)</h3>
|
||||||
|
<form wire:submit="saveMailConfig" class="card" style="padding:20px;gap:14px;max-width:480px;margin-bottom:32px">
|
||||||
|
<div class="field"><label>Adres nadawcy</label><input class="input" type="email" placeholder="wsparcie@firma.pl" wire:model="mailConfig.fromAddress"></div>
|
||||||
|
<div class="field"><label>Nazwa nadawcy</label><input class="input" placeholder="Zespół Wsparcia" wire:model="mailConfig.fromName"></div>
|
||||||
|
|
||||||
|
<div class="hr"></div>
|
||||||
|
|
||||||
|
<label class="radio"><input type="checkbox" wire:model="mailConfig.smtpEnabled" style="position:static;opacity:1;width:auto;height:auto"><strong>Włącz wysyłkę przez własny serwer SMTP</strong></label>
|
||||||
|
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Bez włączenia aplikacja wysyła pocztę zgodnie z konfiguracją środowiska (.env).</span>
|
||||||
|
|
||||||
|
@if ($mailConfig['smtpEnabled'])
|
||||||
|
<div class="field"><label>Host SMTP</label><input class="input" placeholder="smtp.example.com" wire:model="mailConfig.smtpHost"></div>
|
||||||
|
<div style="display:flex;gap:10px">
|
||||||
|
<div class="field" style="flex:1"><label>Port</label><input class="input" type="number" placeholder="587" wire:model="mailConfig.smtpPort"></div>
|
||||||
|
<div class="field" style="flex:1">
|
||||||
|
<label>Szyfrowanie</label>
|
||||||
|
<select class="input" wire:model="mailConfig.smtpEncryption">
|
||||||
|
<option value="none">Brak</option>
|
||||||
|
<option value="tls">STARTTLS</option>
|
||||||
|
<option value="ssl">SSL/TLS</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Użytkownik</label><input class="input" wire:model="mailConfig.smtpUsername"></div>
|
||||||
|
<div class="field"><label>Hasło</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="mailConfig.smtpPassword"></div>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:10px;margin-top:8px;align-items:center;flex-wrap:wrap">
|
||||||
|
<button type="button" class="btn btn-secondary" wire:click="testMailConnection">Wyślij testową wiadomość</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Zapisz</button>
|
||||||
|
@if ($mailTestResult === 'ok')
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;color:var(--color-success)"><span class="material-symbols-outlined" style="font-size:18px">check_circle</span>Wysłano na Twój adres</div>
|
||||||
|
@elseif ($mailTestResult === 'error')
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;color:var(--color-danger)"><span class="material-symbols-outlined" style="font-size:18px">error</span>Błąd wysyłki</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<button type="submit" class="btn btn-primary" style="align-self:flex-start">Zapisz</button>
|
||||||
|
@endif
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
|
||||||
|
<h3 style="margin:0">Skrzynki IMAP (zgłoszenia i odpowiedzi przez e-mail)</h3>
|
||||||
|
<button class="btn btn-primary" type="button" wire:click="openMailboxForm">+ Nowa skrzynka</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-muted" style="font-size:12.5px;margin:0 0 14px">
|
||||||
|
Każda skrzynka jest sprawdzana co kilka minut — nowa wiadomość zakłada zgłoszenie w wybranej podkategorii (np. zgloszenia-it@firma.pl → IT), a odpowiedź na powiadomienie e-mail (temat zawiera numer zgłoszenia) trafia jako odpowiedź do istniejącego zgłoszenia. Automatyczne odpowiedzi (autorespondery, „poza biurem”, bounce) są odrzucane.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@if ($this->mailboxes->isNotEmpty())
|
||||||
|
<div class="table-wrap" style="margin-bottom:20px">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nazwa</th>
|
||||||
|
<th>Serwer</th>
|
||||||
|
<th>Użytkownik</th>
|
||||||
|
<th>Kategoria / podkategoria</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach ($this->mailboxes as $mailbox)
|
||||||
|
<tr>
|
||||||
|
<td>{{ $mailbox->name }}</td>
|
||||||
|
<td class="text-muted" style="white-space:nowrap">{{ $mailbox->host }}:{{ $mailbox->port }}</td>
|
||||||
|
<td class="text-muted">{{ $mailbox->username }}</td>
|
||||||
|
<td class="text-muted">{{ $mailbox->targetLabel() }}</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" class="tag" style="border:none;cursor:pointer;background:color-mix(in srgb, var(--color-{{ $mailbox->enabled ? 'success' : 'danger' }}) 18%, transparent);color:var(--color-{{ $mailbox->enabled ? 'success' : 'danger' }})"
|
||||||
|
wire:click="toggleMailboxEnabled({{ $mailbox->id }})">
|
||||||
|
{{ $mailbox->enabled ? 'Włączona' : 'Wyłączona' }}
|
||||||
|
</button>
|
||||||
|
@if ($mailbox->last_error)
|
||||||
|
<div style="color:var(--color-danger);font-size:11px;margin-top:4px">{{ $mailbox->last_error }}</div>
|
||||||
|
@elseif ($mailbox->last_checked_at)
|
||||||
|
<div class="text-muted" style="font-size:11px;margin-top:4px">Sprawdzono: {{ $mailbox->last_checked_at->format('Y-m-d H:i') }}</div>
|
||||||
|
@endif
|
||||||
|
@if ($mailboxFetchResultId === $mailbox->id)
|
||||||
|
<div class="text-muted" style="font-size:11px;margin-top:4px">{{ $mailboxFetchSummary }}</div>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div style="display:flex;gap:6px;justify-content:flex-end">
|
||||||
|
<button class="btn btn-ghost" type="button" wire:click="fetchMailboxNow({{ $mailbox->id }})" wire:loading.attr="disabled" wire:target="fetchMailboxNow({{ $mailbox->id }})">Pobierz teraz</button>
|
||||||
|
<button class="btn btn-ghost" type="button" wire:click="editMailbox({{ $mailbox->id }})">Edytuj</button>
|
||||||
|
<button class="btn btn-ghost" type="button" wire:click="removeMailbox({{ $mailbox->id }})" wire:confirm="Usunąć tę skrzynkę IMAP?">Usuń</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<p class="text-muted" style="font-size:13px">Brak skonfigurowanych skrzynek IMAP. Dodaj pierwszą używając przycisku wyżej.</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($mailboxFormOpen)
|
||||||
|
<div class="dialog-backdrop">
|
||||||
|
<form wire:submit="submitMailboxForm" class="dialog" style="max-width:520px;max-height:90vh;overflow-y:auto">
|
||||||
|
<div class="dialog-title">{{ $mailboxForm['id'] ? 'Edytuj skrzynkę IMAP' : 'Nowa skrzynka IMAP' }}</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Nazwa (etykieta)</label>
|
||||||
|
<input class="input" placeholder="np. Zgłoszenia IT" wire:model="mailboxForm.name">
|
||||||
|
</div>
|
||||||
|
@error('mailboxForm.name') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
|
||||||
|
|
||||||
|
<label class="radio"><input type="checkbox" wire:model="mailboxForm.enabled" style="position:static;opacity:1;width:auto;height:auto">Włączona</label>
|
||||||
|
|
||||||
|
<div class="hr"></div>
|
||||||
|
|
||||||
|
<div class="field"><label>Host IMAP</label><input class="input" placeholder="imap.firma.pl" wire:model="mailboxForm.host"></div>
|
||||||
|
@error('mailboxForm.host') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
|
||||||
|
|
||||||
|
<div style="display:flex;gap:10px">
|
||||||
|
<div class="field" style="flex:1"><label>Port</label><input class="input" type="number" wire:model="mailboxForm.port"></div>
|
||||||
|
<div class="field" style="flex:1">
|
||||||
|
<label>Szyfrowanie</label>
|
||||||
|
<select class="input" wire:model="mailboxForm.encryption">
|
||||||
|
<option value="ssl">SSL/TLS</option>
|
||||||
|
<option value="tls">STARTTLS</option>
|
||||||
|
<option value="none">Brak</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="radio"><input type="checkbox" wire:model="mailboxForm.validateCert" style="position:static;opacity:1;width:auto;height:auto">Weryfikuj certyfikat TLS</label>
|
||||||
|
|
||||||
|
<div class="field"><label>Adres skrzynki (login)</label><input class="input" placeholder="zgloszenia-it@firma.pl" wire:model="mailboxForm.username"></div>
|
||||||
|
@error('mailboxForm.username') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
|
||||||
|
<div class="field"><label>Hasło</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="mailboxForm.password"></div>
|
||||||
|
|
||||||
|
<div class="hr"></div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Kategoria / podkategoria nowych zgłoszeń</label>
|
||||||
|
<select class="input" wire:model="mailboxForm.target">
|
||||||
|
<option value="">Brak (zgłoszenie nieprzypisane)</option>
|
||||||
|
@foreach ($this->categoryOptions as $category)
|
||||||
|
<optgroup label="{{ $category['name'] }}">
|
||||||
|
<option value="category:{{ $category['id'] }}">Cała kategoria: {{ $category['name'] }}</option>
|
||||||
|
@foreach ($category['subcategories'] as $sub)
|
||||||
|
<option value="subcategory:{{ $sub['id'] }}">{{ $sub['name'] }}</option>
|
||||||
|
@endforeach
|
||||||
|
</optgroup>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
<span class="text-muted" style="font-size:11.5px">Wybierz konkretną podkategorię (trafi też do jej zespołu) albo całą kategorię, jeśli nie chcesz przypisywać konkretnej podkategorii.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field"><label>Folder</label><input class="input" wire:model="mailboxForm.folder"></div>
|
||||||
|
<div style="display:flex;gap:10px">
|
||||||
|
<div class="field" style="flex:1"><label>Folder po przetworzeniu (opcjonalnie)</label><input class="input" placeholder="pozostaw puste = oznacz jako przeczytane" wire:model="mailboxForm.processedFolder"></div>
|
||||||
|
<div class="field" style="flex:1"><label>Folder odrzuconych (opcjonalnie)</label><input class="input" placeholder="pozostaw puste = oznacz jako przeczytane" wire:model="mailboxForm.rejectedFolder"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Zablokowani nadawcy (dodatkowo do filtrów autoresponderów)</label>
|
||||||
|
<input class="input" wire:model="mailboxForm.blocklistSenders">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:4px">
|
||||||
|
<button type="button" class="btn btn-secondary" wire:click="testMailboxConnection">Testuj połączenie</button>
|
||||||
|
@if ($mailboxTestResult === 'ok')
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;color:var(--color-success)"><span class="material-symbols-outlined" style="font-size:18px">check_circle</span>Połączono</div>
|
||||||
|
@elseif ($mailboxTestResult === 'error')
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;color:var(--color-danger);font-size:12.5px"><span class="material-symbols-outlined" style="font-size:18px">error</span>{{ $mailboxTestMessage }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button class="btn btn-secondary" type="button" wire:click="closeMailboxForm">Anuluj</button>
|
||||||
|
<button class="btn btn-primary" type="submit">Zapisz</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
@@ -7,6 +7,8 @@ $tabGroups = [
|
|||||||
['key' => 'priorities', 'label' => 'Priorytety i SLA', 'icon' => 'priority_high'],
|
['key' => 'priorities', 'label' => 'Priorytety i SLA', 'icon' => 'priority_high'],
|
||||||
['key' => 'reply-quick-actions', 'label' => 'Szybkie akcje odpowiedzi', 'icon' => 'bolt'],
|
['key' => 'reply-quick-actions', 'label' => 'Szybkie akcje odpowiedzi', 'icon' => 'bolt'],
|
||||||
['key' => 'response-templates', 'label' => 'Szablony odpowiedzi', 'icon' => 'chat'],
|
['key' => 'response-templates', 'label' => 'Szablony odpowiedzi', 'icon' => 'chat'],
|
||||||
|
['key' => 'automation-rules', 'label' => 'Automatyzacja SLA', 'icon' => 'bolt'],
|
||||||
|
['key' => 'triggers', 'label' => 'Wyzwalacze', 'icon' => 'rule'],
|
||||||
],
|
],
|
||||||
'Zespół' => [
|
'Zespół' => [
|
||||||
['key' => 'users', 'label' => 'Użytkownicy', 'icon' => 'group'],
|
['key' => 'users', 'label' => 'Użytkownicy', 'icon' => 'group'],
|
||||||
@@ -15,8 +17,10 @@ $tabGroups = [
|
|||||||
],
|
],
|
||||||
'Ustawienia' => [
|
'Ustawienia' => [
|
||||||
['key' => 'templates', 'label' => 'Szablony e-mail', 'icon' => 'mail'],
|
['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' => 'branding', 'label' => 'Wygląd i branding', 'icon' => 'palette'],
|
||||||
['key' => 'config', 'label' => 'Konfiguracja', 'icon' => 'settings'],
|
['key' => 'config', 'label' => 'Konfiguracja', 'icon' => 'settings'],
|
||||||
|
['key' => 'integrations', 'label' => 'Integracje', 'icon' => 'hub'],
|
||||||
['key' => 'api-keys', 'label' => 'Klucze API', 'icon' => 'vpn_key'],
|
['key' => 'api-keys', 'label' => 'Klucze API', 'icon' => 'vpn_key'],
|
||||||
['key' => 'about', 'label' => 'O aplikacji', 'icon' => 'info'],
|
['key' => 'about', 'label' => 'O aplikacji', 'icon' => 'info'],
|
||||||
],
|
],
|
||||||
@@ -81,7 +85,7 @@ $tabGroups = [
|
|||||||
<table class="table" style="margin:0;border-top:none">
|
<table class="table" style="margin:0;border-top:none">
|
||||||
<thead><tr><th>Podkategoria</th><th></th></tr></thead>
|
<thead><tr><th>Podkategoria</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@foreach ($cat->subcategories as $sub)
|
@foreach ($cat->subcategories as $i => $sub)
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<div style="display:flex;flex-direction:column;gap:2px">
|
<div style="display:flex;flex-direction:column;gap:2px">
|
||||||
@@ -93,7 +97,9 @@ $tabGroups = [
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<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="openSubcategoryEditForm({{ $sub->id }})">Edytuj</button>
|
||||||
<button class="btn btn-ghost" type="button" wire:click="removeSubcategory({{ $sub->id }})">Usuń</button>
|
<button class="btn btn-ghost" type="button" wire:click="removeSubcategory({{ $sub->id }})">Usuń</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -322,6 +328,44 @@ $tabGroups = [
|
|||||||
@endif
|
@endif
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
{{-- ================= AUTOMATION RULES ================= --}}
|
||||||
|
@if ($tab === 'automation-rules')
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
|
||||||
|
<h3 style="margin:0">Automatyzacja SLA</h3>
|
||||||
|
<button class="btn btn-primary" wire:click="openAutomationRuleForm">+ Dodaj regułę</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted" style="font-size:12.5px;margin:0 0 14px">Co 15 minut sprawdzane jest, czy zgłoszenie milczy (brak odpowiedzi klienta) dłużej niż próg reguły — jeśli tak (i pasuje do opcjonalnego zawężenia), wykonywana jest wybrana akcja. Reguła nie powtarza się dla tego samego zgłoszenia, dopóki klient znów nie napisze albo zgłoszenie nie zostanie zamknięte i otwarte ponownie.</p>
|
||||||
|
@if ($this->automationRules->isNotEmpty())
|
||||||
|
<div style="display:flex;flex-direction:column;gap:10px">
|
||||||
|
@foreach ($this->automationRules as $rule)
|
||||||
|
<div class="card" style="padding:14px 16px;gap:6px">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">
|
||||||
|
<div>
|
||||||
|
<span class="card-title">{{ $rule->label }}</span>
|
||||||
|
<span class="text-muted" style="font-size:12px;display:block">Brak odpowiedzi klienta ≥ {{ $rule->condition_minutes }} min. → {{ match($rule->action_type) { 'change_priority' => 'zmień priorytet', 'change_status' => 'zmień status', 'change_team' => 'zmień zespół', 'change_assignee' => 'zmień przypisanie', default => $rule->action_type } }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:6px;flex:none;align-items:center">
|
||||||
|
<label style="display:flex;align-items:center;gap:4px;font-size:12px">
|
||||||
|
<input type="checkbox" wire:click="toggleAutomationRuleEnabled({{ $rule->id }})" @checked($rule->enabled)>
|
||||||
|
Aktywna
|
||||||
|
</label>
|
||||||
|
<button class="btn btn-ghost" type="button" wire:click="editAutomationRule({{ $rule->id }})">Edytuj</button>
|
||||||
|
<button class="btn btn-ghost" type="button" wire:click="removeAutomationRule({{ $rule->id }})">Usuń</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<p class="text-muted" style="font-size:13px">Brak reguł automatyzacji. Dodaj pierwszą używając przycisku wyżej.</p>
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- ================= TRIGGERS ================= --}}
|
||||||
|
@if ($tab === 'triggers')
|
||||||
|
<livewire:admin.triggers />
|
||||||
|
@endif
|
||||||
|
|
||||||
{{-- ================= STATUSES ================= --}}
|
{{-- ================= STATUSES ================= --}}
|
||||||
@if ($tab === 'statuses')
|
@if ($tab === 'statuses')
|
||||||
<h3 style="margin:0 0 6px">Statusy</h3>
|
<h3 style="margin:0 0 6px">Statusy</h3>
|
||||||
@@ -401,6 +445,27 @@ $tabGroups = [
|
|||||||
|
|
||||||
{{-- ================= EMAIL TEMPLATES ================= --}}
|
{{-- ================= EMAIL TEMPLATES ================= --}}
|
||||||
@if ($tab === '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>
|
<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>
|
<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>
|
||||||
|
|
||||||
@@ -420,23 +485,7 @@ $tabGroups = [
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 style="margin:0 0 6px">Powiadomienia e-mail</h3>
|
<livewire:admin.mail-settings />
|
||||||
<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
|
@endif
|
||||||
|
|
||||||
{{-- ================= BRANDING ================= --}}
|
{{-- ================= BRANDING ================= --}}
|
||||||
@@ -532,6 +581,21 @@ $tabGroups = [
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
<label class="radio"><input type="checkbox" wire:model="systemConfig.autoAssignByCategory" style="position:static;opacity:1;width:auto;height:auto">Automatyczne przypisywanie do zespołu wg kategorii</label>
|
||||||
|
|
||||||
|
<div class="hr"></div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Prefiks numeru zgłoszenia</label>
|
||||||
|
<input class="input" maxlength="20" placeholder="#" wire:model.live="systemConfig.ticketNumberPrefix">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Minimalna długość numeru (uzupełniana zerami z przodu)</label>
|
||||||
|
<input class="input" type="number" min="1" max="10" wire:model.live="systemConfig.ticketNumberMinLength">
|
||||||
|
</div>
|
||||||
|
<label class="radio"><input type="checkbox" wire:model.live="systemConfig.ticketNumberObfuscate" style="position:static;opacity:1;width:auto;height:auto">Ukryj kolejność zgłoszeń (numer wyświetlany jako suma kontrolna zamiast kolejnego numeru)</label>
|
||||||
|
<div class="text-muted" style="font-size:12px">
|
||||||
|
ID z bazy: {{ $this->ticketNumberPreview['id'] }} → podgląd numeru: {{ $this->ticketNumberPreview['formatted'] }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" style="padding:20px;gap:14px">
|
<div class="card" style="padding:20px;gap:14px">
|
||||||
@@ -576,51 +640,44 @@ $tabGroups = [
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div style="grid-column:1/-1;display:flex">
|
||||||
<button type="submit" class="btn btn-primary">Zapisz</button>
|
<button type="submit" class="btn btn-primary">Zapisz</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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 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>
|
|
||||||
<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
|
@endif
|
||||||
</div>
|
|
||||||
@else
|
{{-- ================= INTEGRATIONS ================= --}}
|
||||||
<button type="submit" class="btn btn-primary" style="align-self:flex-start">Zapisz</button>
|
@if ($tab === 'integrations')
|
||||||
@endif
|
<h3 style="margin:0 0 16px">Integracje</h3>
|
||||||
</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">
|
<form wire:submit="saveLdapConfig" class="card" style="padding:20px;gap:14px">
|
||||||
<h4 style="margin:0">LDAP / Active Directory</h4>
|
<h4 style="margin:0">LDAP / Active Directory</h4>
|
||||||
@@ -665,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>
|
<div class="field"><label>Token Secret</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="bookstackConfig.tokenSecret"></div>
|
||||||
<p class="text-muted" style="font-size:11.5px;margin:-4px 0 0">Token API generuje się w BookStack: Profil → Ustawienia API. Użytkownik/rola właściciela tokenu musi mieć uprawnienie „Access System API”.</p>
|
<p class="text-muted" style="font-size:11.5px;margin:-4px 0 0">Token API generuje się w BookStack: Profil → Ustawienia API. Użytkownik/rola właściciela tokenu musi mieć uprawnienie „Access System API”.</p>
|
||||||
|
|
||||||
<div class="field"><label>Przeszukuj</label>
|
<div class="field">
|
||||||
<select class="input" style="width:auto" wire:model="bookstackConfig.searchTypes">
|
<label>Przeszukuj</label>
|
||||||
<option value="both">Strony i książki</option>
|
<div style="display:flex;gap:16px;flex-wrap:wrap">
|
||||||
<option value="page">Tylko strony</option>
|
<label style="display:flex;align-items:center;gap:6px;font-size:13px;font-weight:400">
|
||||||
<option value="book">Tylko książki</option>
|
<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>
|
</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>
|
||||||
|
|
||||||
<div style="display:flex;justify-content:flex-end">
|
<div style="display:flex;justify-content:flex-end">
|
||||||
@@ -720,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>
|
<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>
|
<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">
|
<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="button" class="btn btn-secondary" wire:click="testBookstackConnection">Testuj połączenie</button>
|
||||||
<button type="submit" class="btn btn-primary">Zapisz</button>
|
<button type="submit" class="btn btn-primary">Zapisz</button>
|
||||||
@@ -734,6 +834,68 @@ $tabGroups = [
|
|||||||
@endif
|
@endif
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<form wire:submit="saveAiConfig" class="card" style="padding:20px;gap:14px">
|
||||||
|
<h4 style="margin:0">Integracja AI</h4>
|
||||||
|
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Ogólne połączenie z dostawcą modelu językowego (API kompatybilne z OpenAI — Groq, OpenAI, lokalny Ollama itp.), wykorzystywane m.in. do automatycznego tagowania treści w BookStack.</span>
|
||||||
|
<label class="radio"><input type="checkbox" wire:model="aiConfig.enabled" style="position:static;opacity:1;width:auto;height:auto"><strong>Włącz integrację AI</strong></label>
|
||||||
|
|
||||||
|
@if ($aiConfig['enabled'])
|
||||||
|
<div class="field"><label>Adres API (Base URL)</label><input class="input" placeholder="https://api.groq.com/openai/v1" wire:model="aiConfig.baseUrl"></div>
|
||||||
|
<div class="field"><label>Klucz API</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="aiConfig.apiKey"></div>
|
||||||
|
<p class="text-muted" style="font-size:11.5px;margin:-4px 0 0">Zostaw puste dla lokalnych instancji bez autoryzacji (np. Ollama).</p>
|
||||||
|
<div class="field"><label>Model</label><input class="input" placeholder="np. llama-3.3-70b-versatile" wire:model="aiConfig.model"></div>
|
||||||
|
|
||||||
|
<label class="radio"><input type="checkbox" wire:model="aiConfig.verifySsl" style="position:static;opacity:1;width:auto;height:auto">Weryfikuj certyfikat SSL</label>
|
||||||
|
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Wyłącz tylko jeśli instancja (np. lokalny Ollama) korzysta z certyfikatu self-signed / z prywatnego CA.</span>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:10px;margin-top:8px;align-items:center;flex-wrap:wrap">
|
||||||
|
<button type="button" class="btn btn-secondary" wire:click="testAiConnection">Testuj połączenie</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Zapisz</button>
|
||||||
|
@if ($aiTestResult === 'ok')
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;color:var(--color-success)"><span class="material-symbols-outlined" style="font-size:18px">check_circle</span>Połączenie OK</div>
|
||||||
|
@elseif ($aiTestResult === 'error')
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;color:var(--color-danger)"><span class="material-symbols-outlined" style="font-size:18px">error</span>Błąd połączenia{{ $aiTestMessage ? ': '.$aiTestMessage : '' }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<button type="submit" class="btn btn-primary" style="align-self:flex-start">Zapisz</button>
|
||||||
|
@endif
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form wire:submit="saveAiTriageConfig" class="card" style="padding:20px;gap:14px">
|
||||||
|
<h4 style="margin:0">Automatyzacja AI dla zgłoszeń</h4>
|
||||||
|
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Wymaga włączonej integracji AI (karta obok). Zgłoszenia są przetwarzane w tle, cyklicznie co kilka minut — nie spowalnia to tworzenia zgłoszenia przez klienta.</span>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Automatyczna kategoryzacja nowych zgłoszeń</label>
|
||||||
|
<label class="radio"><input type="checkbox" wire:model="aiTriageConfig.categoryWhenMissing" style="position:static;opacity:1;width:auto;height:auto">Przypisz kategorię/podkategorię, gdy zgłoszenie nie ma żadnej</label>
|
||||||
|
<label class="radio"><input type="checkbox" wire:model="aiTriageConfig.subcategoryWhenCategoryOnly" style="position:static;opacity:1;width:auto;height:auto">Dobierz podkategorię, gdy zgłoszenie ma tylko kategorię</label>
|
||||||
|
<label class="radio"><input type="checkbox" wire:model="aiTriageConfig.recheckCategorized" style="position:static;opacity:1;width:auto;height:auto">Zweryfikuj i ewentualnie popraw już przypisaną podkategorię</label>
|
||||||
|
<label class="radio"><input type="checkbox" wire:model="aiTriageConfig.fixSubject" style="position:static;opacity:1;width:auto;height:auto">Popraw temat zgłoszenia, jeśli jest niejasny</label>
|
||||||
|
<label class="radio"><input type="checkbox" wire:model="aiTriageConfig.setPriority" style="position:static;opacity:1;width:auto;height:auto">Ustaw priorytet na podstawie treści</label>
|
||||||
|
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Każde zgłoszenie jest sprawdzane tylko raz — zastosowane zmiany trafiają do historii zgłoszenia z adnotacją „Automatyzacja: klasyfikacja AI”.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="border-top:1px solid var(--color-divider);margin:4px 0"></div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Podsumowanie AI dla operatora</label>
|
||||||
|
<label class="radio"><input type="checkbox" wire:model="aiSummaryEnabled" style="position:static;opacity:1;width:auto;height:auto"><strong>Generuj podsumowanie i sugerowaną akcję dla każdego zgłoszenia</strong></label>
|
||||||
|
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Widoczne wyłącznie w panelu operatora, w bocznym panelu zgłoszenia. Odświeżane automatycznie, gdy w wątku pojawi się nowa wiadomość.</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>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@@ -803,6 +965,96 @@ $tabGroups = [
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
@if ($automationRuleFormOpen)
|
||||||
|
<div class="dialog-backdrop">
|
||||||
|
<form wire:submit="submitAutomationRule" class="dialog" style="max-width:520px">
|
||||||
|
<div class="dialog-title">{{ $automationRuleForm['id'] ? 'Edytuj regułę automatyzacji' : 'Nowa reguła automatyzacji' }}</div>
|
||||||
|
<div class="field"><label>Nazwa reguły</label><input class="input" wire:model="automationRuleForm.label" placeholder="np. Eskalacja przy braku odpowiedzi"></div>
|
||||||
|
@error('automationRuleForm.label') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
|
||||||
|
<div class="field">
|
||||||
|
<label>Brak odpowiedzi klienta przez (minuty)</label>
|
||||||
|
<input class="input" type="number" min="1" wire:model="automationRuleForm.condition_minutes">
|
||||||
|
</div>
|
||||||
|
@error('automationRuleForm.condition_minutes') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Zawężenie (opcjonalne — puste = dowolne)</label>
|
||||||
|
<select class="input" wire:model="automationRuleForm.scope_priority_key" style="margin-bottom:6px">
|
||||||
|
<option value="">Dowolny priorytet</option>
|
||||||
|
@foreach ($this->priorities as $p)
|
||||||
|
<option value="{{ $p->key }}">{{ $p->label }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
<select class="input" wire:model="automationRuleForm.scope_subcategory_id" style="margin-bottom:6px">
|
||||||
|
<option value="">Dowolna kategoria</option>
|
||||||
|
@foreach ($this->subcategoriesForTeamForm as $s)
|
||||||
|
<option value="{{ $s['id'] }}">{{ $s['label'] }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
<select class="input" wire:model="automationRuleForm.scope_team_id">
|
||||||
|
<option value="">Dowolny zespół</option>
|
||||||
|
@foreach ($this->teams as $t)
|
||||||
|
<option value="{{ $t->id }}">{{ $t->name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Akcja</label>
|
||||||
|
<select class="input" wire:model.live="automationRuleForm.action_type">
|
||||||
|
<option value="change_priority">Zmień priorytet</option>
|
||||||
|
<option value="change_status">Zmień status</option>
|
||||||
|
<option value="change_team">Zmień zespół</option>
|
||||||
|
<option value="change_assignee">Zmień przypisanie</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Nowa wartość</label>
|
||||||
|
@switch ($automationRuleForm['action_type'])
|
||||||
|
@case ('change_priority')
|
||||||
|
<select class="input" wire:model="automationRuleForm.action_value">
|
||||||
|
<option value="">— wybierz —</option>
|
||||||
|
@foreach ($this->priorities as $p)
|
||||||
|
<option value="{{ $p->key }}">{{ $p->label }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
@break
|
||||||
|
@case ('change_status')
|
||||||
|
<select class="input" wire:model="automationRuleForm.action_value">
|
||||||
|
<option value="">— wybierz —</option>
|
||||||
|
@foreach ($this->statuses as $s)
|
||||||
|
<option value="{{ $s->key }}">{{ $s->label }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
@break
|
||||||
|
@case ('change_team')
|
||||||
|
<select class="input" wire:model="automationRuleForm.action_value">
|
||||||
|
<option value="">— wybierz —</option>
|
||||||
|
@foreach ($this->teams as $t)
|
||||||
|
<option value="{{ $t->id }}">{{ $t->name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
@break
|
||||||
|
@case ('change_assignee')
|
||||||
|
<select class="input" wire:model="automationRuleForm.action_value">
|
||||||
|
<option value="">— wybierz —</option>
|
||||||
|
@foreach ($this->operatorsForTeamForm as $o)
|
||||||
|
<option value="{{ $o['id'] }}">{{ $o['label'] }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
@break
|
||||||
|
@endswitch
|
||||||
|
</div>
|
||||||
|
@error('automationRuleForm.action_value') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
|
||||||
|
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button class="btn btn-secondary" type="button" wire:click="closeAutomationRuleForm">Anuluj</button>
|
||||||
|
<button class="btn btn-primary" type="submit">{{ $automationRuleForm['id'] ? 'Zapisz' : 'Dodaj regułę' }}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
@if ($this->editingTemplate)
|
@if ($this->editingTemplate)
|
||||||
<div class="dialog-backdrop">
|
<div class="dialog-backdrop">
|
||||||
<div class="dialog" style="max-width:560px">
|
<div class="dialog" style="max-width:560px">
|
||||||
|
|||||||
303
src/resources/views/livewire/admin/triggers.blade.php
Normal file
303
src/resources/views/livewire/admin/triggers.blade.php
Normal 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 są 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>
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
@foreach (($tab === 'current' ? $this->currentTickets : $this->archiveTickets) as $ticket)
|
@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">
|
<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>
|
||||||
<div style="font-weight:500">#{{ $ticket->number }} — {{ $ticket->subject }}</div>
|
<div style="font-weight:500">{{ $ticket->displayNumber() }} — {{ $ticket->subject }}</div>
|
||||||
<div class="card-meta">{{ $ticket->categoryLabel() }} · {{ \App\Support\Rel::format($ticket->updated_at) }}</div>
|
<div class="card-meta">{{ $ticket->categoryLabel() }} · {{ \App\Support\Rel::format($ticket->updated_at) }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:6px">
|
<div style="display:flex;gap:6px">
|
||||||
|
|||||||
@@ -60,7 +60,9 @@
|
|||||||
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="backToSubcategory">Zmień</button>
|
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="backToSubcategory">Zmień</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div wire:init="loadSuggestedArticles">
|
||||||
<x-bookstack-suggestions :articles="$this->suggestedArticles" />
|
<x-bookstack-suggestions :articles="$this->suggestedArticles" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Temat</label>
|
<label>Temat</label>
|
||||||
|
|||||||
@@ -1,8 +1,29 @@
|
|||||||
<div style="flex:1;display:flex;flex-direction:column">
|
<div style="flex:1;display:flex;flex-direction:column">
|
||||||
<x-topbar />
|
<x-topbar />
|
||||||
|
|
||||||
<div class="page-pad" style="flex:1;padding:28px;display:flex;flex-direction:column;gap:20px;max-width:920px;width:100%;margin:0 auto;box-sizing:border-box">
|
<div class="page-pad" style="flex:1;padding:28px;display:flex;flex-direction:column;gap:20px;max-width:1180px;width:100%;margin:0 auto;box-sizing:border-box">
|
||||||
<a href="{{ route('client.dashboard') }}" wire:navigate class="btn btn-ghost" style="align-self:flex-start;padding:0">← Wróć do listy</a>
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
|
||||||
|
<a href="{{ route('client.dashboard') }}" wire:navigate class="btn btn-ghost" style="padding:0">← Wróć do listy</a>
|
||||||
|
|
||||||
|
{{-- 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.
|
||||||
|
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:pointer;gap:6px"
|
||||||
|
x-data="{ remaining: {{ $refreshTicketSeconds }}, total: {{ $refreshTicketSeconds }} }"
|
||||||
|
x-init="setInterval(() => { remaining = remaining <= 1 ? total : remaining - 1; if (remaining === total) $wire.refreshTicketData(); }, 1000)"
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style="display:flex;gap:20px;align-items:flex-start;flex-wrap:wrap">
|
<div style="display:flex;gap:20px;align-items:flex-start;flex-wrap:wrap">
|
||||||
<div class="main-col" style="display:flex;flex-direction:column;gap:16px">
|
<div class="main-col" style="display:flex;flex-direction:column;gap:16px">
|
||||||
@@ -12,7 +33,7 @@
|
|||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div class="card" style="padding:22px;gap:10px">
|
<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>
|
<h2 style="margin:2px 0 0">{{ $ticket->subject }}</h2>
|
||||||
<div class="card-meta">{{ $ticket->categoryLabel() }} · utworzono {{ \App\Support\Rel::format($ticket->created_at) }}</div>
|
<div class="card-meta">{{ $ticket->categoryLabel() }} · utworzono {{ \App\Support\Rel::format($ticket->created_at) }}</div>
|
||||||
<div style="white-space:pre-wrap;font-size:14px;margin-top:4px">{{ $ticket->body }}</div>
|
<div style="white-space:pre-wrap;font-size:14px;margin-top:4px">{{ $ticket->body }}</div>
|
||||||
@@ -31,7 +52,7 @@
|
|||||||
<div style="display:flex;flex-direction:column;gap:10px">
|
<div style="display:flex;flex-direction:column;gap:10px">
|
||||||
@foreach ($threadMessages as $m)
|
@foreach ($threadMessages as $m)
|
||||||
@php $mine = $m->role === 'client' && $m->author_id === auth()->id(); @endphp
|
@php $mine = $m->role === 'client' && $m->author_id === auth()->id(); @endphp
|
||||||
<div style="display:flex;justify-content:{{ $mine ? 'flex-end' : 'flex-start' }}">
|
<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="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="display:flex;justify-content:space-between;align-items:flex-start;gap:10px">
|
||||||
<div style="font-size:11px;opacity:0.65;margin-bottom:4px">{{ $m->author_name }} · {{ \App\Support\Rel::format($m->created_at) }}{{ $m->edited ? ' · edytowano' : '' }}</div>
|
<div style="font-size:11px;opacity:0.65;margin-bottom:4px">{{ $m->author_name }} · {{ \App\Support\Rel::format($m->created_at) }}{{ $m->edited ? ' · edytowano' : '' }}</div>
|
||||||
@@ -94,12 +115,18 @@
|
|||||||
<div style="font-size:14px;font-weight:500">{{ auth()->user()->name }}</div>
|
<div style="font-size:14px;font-weight:500">{{ auth()->user()->name }}</div>
|
||||||
<div class="text-muted" style="font-size:13px">{{ auth()->user()->email }}</div>
|
<div class="text-muted" style="font-size:13px">{{ auth()->user()->email }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div wire:init="loadSuggestedArticles">
|
||||||
|
<x-bookstack-suggestions :articles="$this->suggestedArticles" variant="sidebar" title="Baza wiedzy" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card" style="padding:16px;gap:10px">
|
<div class="card" style="padding:16px;gap:10px">
|
||||||
<div class="card-kicker">Status i priorytet</div>
|
<div class="card-kicker">Status i priorytet</div>
|
||||||
<div style="display:flex;gap:6px">
|
<div style="display:flex;gap:6px;flex-wrap:wrap">
|
||||||
<span style="{{ $ticket->priorityStyle() }}">{{ $ticket->priorityLabel() }}</span>
|
<span style="{{ $ticket->priorityStyle() }}">{{ $ticket->priorityLabel() }}</span>
|
||||||
<span style="{{ $ticket->statusStyle() }}">{{ $ticket->statusLabel() }}</span>
|
<span style="{{ $ticket->statusStyle() }}">{{ $ticket->statusLabel() }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="font-size:13px" class="text-muted">Przypisany operator: <span style="color:var(--color-text)">{{ $ticket->assignee?->name ?? 'Nieprzypisane' }}</span></div>
|
||||||
|
<div style="font-size:13px" class="text-muted">Zespół: <span style="color:var(--color-text)">{{ $ticket->team?->name ?? 'Brak' }}</span></div>
|
||||||
@if (! $ticket->isClosed())
|
@if (! $ticket->isClosed())
|
||||||
<button type="button" class="btn btn-secondary btn-block" wire:click="close">Zamknij zgłoszenie</button>
|
<button type="button" class="btn btn-secondary btn-block" wire:click="close">Zamknij zgłoszenie</button>
|
||||||
@elseif ($ticket->isClosed())
|
@elseif ($ticket->isClosed())
|
||||||
@@ -137,7 +164,7 @@
|
|||||||
<div class="card" style="padding:16px;gap:8px">
|
<div class="card" style="padding:16px;gap:8px">
|
||||||
<div class="card-kicker">Historia zmian</div>
|
<div class="card-kicker">Historia zmian</div>
|
||||||
@forelse ($ticket->histories as $h)
|
@forelse ($ticket->histories as $h)
|
||||||
<div style="font-size:12.5px"><span>{{ $h->text }}</span><div class="text-muted" style="font-size:11px">{{ \App\Support\Rel::format($h->created_at) }}</div></div>
|
<div wire:key="history-{{ $h->id }}" style="font-size:12.5px"><span>{{ $h->text }}</span><div class="text-muted" style="font-size:11px">{{ \App\Support\Rel::format($h->created_at) }}</div></div>
|
||||||
@empty
|
@empty
|
||||||
<p class="text-muted" style="font-size:12px;margin:0">Brak historii zmian.</p>
|
<p class="text-muted" style="font-size:12px;margin:0">Brak historii zmian.</p>
|
||||||
@endforelse
|
@endforelse
|
||||||
@@ -146,7 +173,7 @@
|
|||||||
<div class="card-kicker">Inne Twoje zgłoszenia</div>
|
<div class="card-kicker">Inne Twoje zgłoszenia</div>
|
||||||
@forelse ($this->otherTickets as $ot)
|
@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">
|
<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>
|
<span style="{{ $ot->statusStyle() }};flex:none">{{ $ot->statusLabel() }}</span>
|
||||||
</a>
|
</a>
|
||||||
@empty
|
@empty
|
||||||
@@ -169,4 +196,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
@script
|
||||||
|
<script>
|
||||||
|
// Re-runs on every mount of this component, including after a
|
||||||
|
// wire:navigate to a different ticket, so the socket subscription
|
||||||
|
// always matches whichever ticket is currently on screen.
|
||||||
|
if (window.subscribeToTicketChannel) {
|
||||||
|
window.subscribeToTicketChannel({{ $ticket->id }});
|
||||||
|
} else {
|
||||||
|
// echo.js (a deferred module) hasn't run yet — queue the id so it
|
||||||
|
// subscribes as soon as it does, instead of silently doing nothing.
|
||||||
|
(window.__pendingTicketChannelIds = window.__pendingTicketChannelIds || []).push({{ $ticket->id }});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
@endscript
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,11 +9,11 @@
|
|||||||
@if ($this->submittedTicket)
|
@if ($this->submittedTicket)
|
||||||
<div class="card elev-md" style="padding:32px;gap:14px;text-align:left">
|
<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>
|
<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>
|
<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 class="hr"></div>
|
||||||
<div style="display:flex;flex-direction:column;gap:4px;font-size:14px">
|
<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>Temat:</strong> {{ $this->submittedTicket->subject }}</div>
|
||||||
<div><strong>Kategoria:</strong> {{ $this->submittedTicket->categoryLabel() }}</div>
|
<div><strong>Kategoria:</strong> {{ $this->submittedTicket->categoryLabel() }}</div>
|
||||||
<div><strong>Zgłaszający:</strong> {{ $this->submittedTicket->email }}</div>
|
<div><strong>Zgłaszający:</strong> {{ $this->submittedTicket->email }}</div>
|
||||||
@@ -90,7 +90,9 @@
|
|||||||
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="backToSubcategory">Zmień</button>
|
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="backToSubcategory">Zmień</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div wire:init="loadSuggestedArticles">
|
||||||
<x-bookstack-suggestions :articles="$this->suggestedArticles" />
|
<x-bookstack-suggestions :articles="$this->suggestedArticles" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Temat</label>
|
<label>Temat</label>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<div x-data="{ open: false }" @click.outside="open = false" 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">
|
<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>
|
<span class="material-symbols-outlined" style="font-size:18px">notifications</span>
|
||||||
@if ($this->unreadCount)
|
@if ($this->unreadCount)
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
<div
|
<div
|
||||||
x-show="open"
|
x-show="open"
|
||||||
x-cloak
|
x-cloak
|
||||||
|
class="nav-dropdown"
|
||||||
style="position:absolute;top:100%;right:0;margin-top:6px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);width:320px;max-height:420px;overflow-y:auto;z-index:30"
|
style="position:absolute;top:100%;right:0;margin-top:6px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);width:320px;max-height:420px;overflow-y:auto;z-index:30"
|
||||||
>
|
>
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 14px;border-bottom:1px solid var(--color-divider)">
|
<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 14px;border-bottom:1px solid var(--color-divider)">
|
||||||
@@ -20,11 +21,12 @@
|
|||||||
|
|
||||||
@forelse ($this->notifications as $notification)
|
@forelse ($this->notifications as $notification)
|
||||||
<a
|
<a
|
||||||
|
wire:key="notification-{{ $notification->id }}"
|
||||||
href="{{ $notification->data['url'] ?? '#' }}"
|
href="{{ $notification->data['url'] ?? '#' }}"
|
||||||
wire:navigate
|
wire:navigate
|
||||||
wire:click="markAsRead('{{ $notification->id }}')"
|
wire:click="markAsRead('{{ $notification->id }}')"
|
||||||
@click="open = false"
|
@click="open = false"
|
||||||
style="display:block;padding:10px 14px;text-decoration:none;color:var(--color-text);border-bottom:1px solid var(--color-divider);font-size:12.5px;{{ $notification->read_at ? 'opacity:0.6' : 'background:color-mix(in srgb, var(--color-accent) 6%, transparent)' }}"
|
style="display:block;padding:10px 14px;text-decoration:none;color:var(--color-text);border-bottom:1px solid var(--color-divider);font-size:12.5px;background:color-mix(in srgb, var(--color-accent) 6%, transparent)"
|
||||||
>
|
>
|
||||||
<div>{{ $notification->data['message'] ?? '' }}</div>
|
<div>{{ $notification->data['message'] ?? '' }}</div>
|
||||||
<div style="font-size:11px;color:color-mix(in srgb, var(--color-text) 55%, transparent);margin-top:2px">{{ $notification->created_at->diffForHumans() }}</div>
|
<div style="font-size:11px;color:color-mix(in srgb, var(--color-text) 55%, transparent);margin-top:2px">{{ $notification->created_at->diffForHumans() }}</div>
|
||||||
|
|||||||
@@ -66,7 +66,9 @@
|
|||||||
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="backToSubcategory">Zmień</button>
|
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="backToSubcategory">Zmień</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div wire:init="loadSuggestedArticles">
|
||||||
<x-bookstack-suggestions :articles="$this->suggestedArticles" />
|
<x-bookstack-suggestions :articles="$this->suggestedArticles" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Temat</label>
|
<label>Temat</label>
|
||||||
|
|||||||
@@ -116,13 +116,33 @@
|
|||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{-- 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. 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:pointer;gap:6px"
|
||||||
|
x-data="{ remaining: {{ $refreshQueueSeconds }}, total: {{ $refreshQueueSeconds }} }"
|
||||||
|
x-init="setInterval(() => { remaining = remaining <= 1 ? total : remaining - 1; if (remaining === total) $wire.refreshQueue(); }, 1000)"
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="table table-cards-mobile">
|
<table class="table table-cards-mobile">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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)
|
@foreach ($columnDefs as $key => $label)
|
||||||
@continue(! in_array($key, $visibleColumns))
|
@continue(! in_array($key, $visibleColumns))
|
||||||
<th>
|
<th>
|
||||||
@@ -143,10 +163,15 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
@foreach ($this->filteredTickets as $t)
|
@foreach ($this->filteredTickets as $t)
|
||||||
@php $sla = $t->slaInfo(); @endphp
|
@php $sla = $t->slaInfo(); @endphp
|
||||||
<tr>
|
<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>
|
<td class="td-select"><input type="checkbox" @checked(in_array($t->id, $selectedIds)) wire:click="toggleSelect({{ $t->id }})"></td>
|
||||||
@if (in_array('number', $visibleColumns))
|
@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
|
@endif
|
||||||
@if (in_array('subject', $visibleColumns))
|
@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>
|
<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>
|
||||||
@@ -157,6 +182,9 @@
|
|||||||
@if (in_array('category', $visibleColumns))
|
@if (in_array('category', $visibleColumns))
|
||||||
<td data-label="Kategoria" style="white-space:nowrap">{{ $t->categoryLabel() }}</td>
|
<td data-label="Kategoria" style="white-space:nowrap">{{ $t->categoryLabel() }}</td>
|
||||||
@endif
|
@endif
|
||||||
|
@if (in_array('subcategory', $visibleColumns))
|
||||||
|
<td data-label="Podkategoria" style="white-space:nowrap">{{ $t->subcategory?->name ?? '—' }}</td>
|
||||||
|
@endif
|
||||||
@if (in_array('priority', $visibleColumns))
|
@if (in_array('priority', $visibleColumns))
|
||||||
<td data-label="Priorytet"><span style="{{ $t->priorityStyle() }}">{{ $t->priorityLabel() }}</span></td>
|
<td data-label="Priorytet"><span style="{{ $t->priorityStyle() }}">{{ $t->priorityLabel() }}</span></td>
|
||||||
@endif
|
@endif
|
||||||
@@ -169,6 +197,12 @@
|
|||||||
@if (in_array('assignee', $visibleColumns))
|
@if (in_array('assignee', $visibleColumns))
|
||||||
<td data-label="Przypisany" style="white-space:nowrap">{{ $t->assignee?->name ?? 'Nieprzypisane' }}</td>
|
<td data-label="Przypisany" style="white-space:nowrap">{{ $t->assignee?->name ?? 'Nieprzypisane' }}</td>
|
||||||
@endif
|
@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>
|
</tr>
|
||||||
@endforeach
|
@endforeach
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -61,6 +61,8 @@
|
|||||||
|
|
||||||
{{-- KPI tiles --}}
|
{{-- KPI tiles --}}
|
||||||
@php($kpis = $this->kpis)
|
@php($kpis = $this->kpis)
|
||||||
|
<div style="display:flex;flex-direction:column;gap:12px">
|
||||||
|
<h3 style="margin:0">Podsumowanie</h3>
|
||||||
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(160px, 1fr));gap:12px">
|
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(160px, 1fr));gap:12px">
|
||||||
<div class="stat-tile">
|
<div class="stat-tile">
|
||||||
<div class="stat-tile-label">Łącznie zgłoszeń</div>
|
<div class="stat-tile-label">Łącznie zgłoszeń</div>
|
||||||
@@ -96,9 +98,12 @@
|
|||||||
<div class="stat-tile-meta">{{ $kpis['csat']['count'] }} ocen{{ $kpis['csat']['responseRate'] !== null ? ' · '.$kpis['csat']['responseRate'].'% odpowiedzi' : '' }}</div>
|
<div class="stat-tile-meta">{{ $kpis['csat']['count'] }} ocen{{ $kpis['csat']['responseRate'] !== null ? ' · '.$kpis['csat']['responseRate'].'% odpowiedzi' : '' }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Rozkład zgłoszeń --}}
|
||||||
|
<div style="display:flex;flex-direction:column;gap:12px">
|
||||||
|
<h3 style="margin:0">Rozkład zgłoszeń</h3>
|
||||||
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(340px, 1fr));gap:16px;align-items:start">
|
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(340px, 1fr));gap:16px;align-items:start">
|
||||||
{{-- By status --}}
|
|
||||||
<div class="card" style="padding:16px">
|
<div class="card" style="padding:16px">
|
||||||
<div class="card-title" style="margin-bottom:12px">Zgłoszenia wg statusu</div>
|
<div class="card-title" style="margin-bottom:12px">Zgłoszenia wg statusu</div>
|
||||||
@php($max = max($this->byStatus->max('count'), 1))
|
@php($max = max($this->byStatus->max('count'), 1))
|
||||||
@@ -113,7 +118,6 @@
|
|||||||
@endforelse
|
@endforelse
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- By priority --}}
|
|
||||||
<div class="card" style="padding:16px">
|
<div class="card" style="padding:16px">
|
||||||
<div class="card-title" style="margin-bottom:12px">Zgłoszenia wg priorytetu</div>
|
<div class="card-title" style="margin-bottom:12px">Zgłoszenia wg priorytetu</div>
|
||||||
@php($max = max($this->byPriority->max('count'), 1))
|
@php($max = max($this->byPriority->max('count'), 1))
|
||||||
@@ -128,7 +132,6 @@
|
|||||||
@endforelse
|
@endforelse
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- By category --}}
|
|
||||||
<div class="card" style="padding:16px">
|
<div class="card" style="padding:16px">
|
||||||
<div class="card-title" style="margin-bottom:12px">Zgłoszenia wg kategorii</div>
|
<div class="card-title" style="margin-bottom:12px">Zgłoszenia wg kategorii</div>
|
||||||
@php($cats = $this->byCategory)
|
@php($cats = $this->byCategory)
|
||||||
@@ -144,7 +147,27 @@
|
|||||||
@endforelse
|
@endforelse
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- By team --}}
|
<div class="card" style="padding:16px">
|
||||||
|
<div class="card-title" style="margin-bottom:12px">Zgłoszenia wg podkategorii</div>
|
||||||
|
@php($subs = $this->bySubcategory)
|
||||||
|
@php($max = max($subs->max('count') ?? 0, 1))
|
||||||
|
@forelse ($subs as $row)
|
||||||
|
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['count'] }}">
|
||||||
|
<div class="bar-row-label">{{ $row['label'] }}</div>
|
||||||
|
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['count'] / $max * 100 }}%;background:var(--color-accent)"></div></div>
|
||||||
|
<div class="bar-row-value">{{ $row['count'] }}</div>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<div class="card-meta">Brak danych w wybranym okresie.</div>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Obciążenie --}}
|
||||||
|
<div style="display:flex;flex-direction:column;gap:12px">
|
||||||
|
<h3 style="margin:0">Obciążenie</h3>
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(340px, 1fr));gap:16px;align-items:start">
|
||||||
<div class="card" style="padding:16px">
|
<div class="card" style="padding:16px">
|
||||||
<div class="card-title" style="margin-bottom:12px">Obciążenie zespołów</div>
|
<div class="card-title" style="margin-bottom:12px">Obciążenie zespołów</div>
|
||||||
@php($teamRows = $this->byTeam)
|
@php($teamRows = $this->byTeam)
|
||||||
@@ -160,7 +183,6 @@
|
|||||||
@endforelse
|
@endforelse
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- By assignee --}}
|
|
||||||
<div class="card" style="padding:16px">
|
<div class="card" style="padding:16px">
|
||||||
<div class="card-title" style="margin-bottom:12px">Obciążenie operatorów</div>
|
<div class="card-title" style="margin-bottom:12px">Obciążenie operatorów</div>
|
||||||
@php($opRows = $this->byAssignee)
|
@php($opRows = $this->byAssignee)
|
||||||
@@ -176,11 +198,109 @@
|
|||||||
@endforelse
|
@endforelse
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Klienci --}}
|
||||||
|
<div style="display:flex;flex-direction:column;gap:12px">
|
||||||
|
<h3 style="margin:0">Klienci</h3>
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(340px, 1fr));gap:16px;align-items:start">
|
||||||
|
<div class="card" style="padding:16px">
|
||||||
|
<div class="card-title" style="margin-bottom:12px">Najaktywniejsi klienci (Top 10)</div>
|
||||||
|
@php($customerRows = $this->byCustomer)
|
||||||
|
@php($max = max($customerRows->max('count') ?? 0, 1))
|
||||||
|
@forelse ($customerRows as $row)
|
||||||
|
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['count'] }}">
|
||||||
|
<div class="bar-row-label">{{ $row['label'] }}</div>
|
||||||
|
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['count'] / $max * 100 }}%;background:var(--color-accent-2)"></div></div>
|
||||||
|
<div class="bar-row-value">{{ $row['count'] }}</div>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<div class="card-meta">Brak danych w wybranym okresie.</div>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="padding:16px">
|
||||||
|
<div class="card-title" style="margin-bottom:4px">Klienci wg podkategorii</div>
|
||||||
|
<div class="card-meta" style="margin-bottom:12px">Top 10 klientów × top 5 podkategorii wg wolumenu w wybranym okresie; reszta zbiorczo w kolumnie „Inne”.</div>
|
||||||
|
@php($matrix = $this->customerSubcategoryMatrix)
|
||||||
|
@if (count($matrix['rows']))
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Klient</th>
|
||||||
|
@foreach ($matrix['columns'] as $col)
|
||||||
|
<th>{{ $col }}</th>
|
||||||
|
@endforeach
|
||||||
|
@if ($matrix['hasOther'])
|
||||||
|
<th>Inne</th>
|
||||||
|
@endif
|
||||||
|
<th>Razem</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach ($matrix['rows'] as $row)
|
||||||
|
<tr>
|
||||||
|
<td style="white-space:nowrap">{{ $row['label'] }}</td>
|
||||||
|
@foreach ($row['cells'] as $cell)
|
||||||
|
<td>{{ $cell ?: '—' }}</td>
|
||||||
|
@endforeach
|
||||||
|
@if ($matrix['hasOther'])
|
||||||
|
<td>{{ $row['other'] ?: '—' }}</td>
|
||||||
|
@endif
|
||||||
|
<td style="font-weight:600">{{ $row['total'] }}</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="card-meta">Brak danych w wybranym okresie.</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Ocena obsługi (CSAT) --}}
|
||||||
|
<div style="display:flex;flex-direction:column;gap:12px">
|
||||||
|
<h3 style="margin:0">Ocena obsługi (CSAT)</h3>
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(340px, 1fr));gap:16px;align-items:start">
|
||||||
|
<div class="card" style="padding:16px">
|
||||||
|
<div class="card-title" style="margin-bottom:12px">CSAT wg zespołu</div>
|
||||||
|
@php($csatTeamRows = $this->csatByTeam)
|
||||||
|
@forelse ($csatTeamRows as $row)
|
||||||
|
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['avg'] }} / 5 ({{ $row['count'] }} ocen)">
|
||||||
|
<div class="bar-row-label">{{ $row['label'] }}</div>
|
||||||
|
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['avg'] / 5 * 100 }}%;background:var(--color-accent)"></div></div>
|
||||||
|
<div class="bar-row-value">{{ $row['avg'] }} / 5</div>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<div class="card-meta">Brak ocen w wybranym okresie.</div>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="padding:16px">
|
||||||
|
<div class="card-title" style="margin-bottom:12px">CSAT wg operatora</div>
|
||||||
|
@php($csatOpRows = $this->csatByAssignee)
|
||||||
|
@forelse ($csatOpRows as $row)
|
||||||
|
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['avg'] }} / 5 ({{ $row['count'] }} ocen)">
|
||||||
|
<div class="bar-row-label">{{ $row['label'] }}</div>
|
||||||
|
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['avg'] / 5 * 100 }}%;background:var(--color-accent)"></div></div>
|
||||||
|
<div class="bar-row-value">{{ $row['avg'] }} / 5</div>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<div class="card-meta">Brak ocen w wybranym okresie.</div>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{-- Trend --}}
|
{{-- Trend --}}
|
||||||
@php($trend = $this->trend)
|
@php($trend = $this->trend)
|
||||||
@php($trendMax = max(collect($trend)->max('created'), collect($trend)->max('closed'), 1))
|
@php($trendMax = max(collect($trend)->max('created'), collect($trend)->max('closed'), 1))
|
||||||
@php($labelStep = max((int) ceil(count($trend) / 12), 1))
|
@php($labelStep = max((int) ceil(count($trend) / 12), 1))
|
||||||
|
<div style="display:flex;flex-direction:column;gap:12px">
|
||||||
|
<h3 style="margin:0">Trend w czasie</h3>
|
||||||
<div class="card" style="padding:16px">
|
<div class="card" style="padding:16px">
|
||||||
<div class="card-title">Trend zgłoszeń</div>
|
<div class="card-title">Trend zgłoszeń</div>
|
||||||
<div class="card-meta" style="margin-bottom:12px">Utworzone i zamknięte w czasie (maks. ostatnie 60 dni okresu).</div>
|
<div class="card-meta" style="margin-bottom:12px">Utworzone i zamknięte w czasie (maks. ostatnie 60 dni okresu).</div>
|
||||||
@@ -224,4 +344,5 @@
|
|||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,13 +3,52 @@
|
|||||||
|
|
||||||
<div class="page-pad" style="flex:1;padding:20px 24px;overflow:auto">
|
<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;flex-direction:column;gap:16px;max-width:1180px;margin:0 auto">
|
||||||
<a href="{{ route('operator.queue') }}" wire:navigate class="btn btn-ghost" style="align-self:flex-start;padding:0">← Wróć do listy</a>
|
<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;margin-right:auto">← 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.
|
||||||
|
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:pointer;gap:6px"
|
||||||
|
x-data="{ remaining: {{ $refreshTicketSeconds }}, total: {{ $refreshTicketSeconds }} }"
|
||||||
|
x-init="setInterval(() => { remaining = remaining <= 1 ? total : remaining - 1; if (remaining === total) $wire.refreshTicketData(); }, 1000)"
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style="display:flex;gap:20px;align-items:flex-start;flex-wrap:wrap">
|
<div style="display:flex;gap:20px;align-items:flex-start;flex-wrap:wrap">
|
||||||
<div class="main-col" style="display:flex;flex-direction:column;gap:16px">
|
<div class="main-col" style="display:flex;flex-direction:column;gap:16px">
|
||||||
<div class="card" style="padding:20px;gap:8px">
|
<div class="card" style="padding:20px;gap:8px">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;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)
|
@unless ($editingDetails)
|
||||||
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="toggleEditDetails">Edytuj</button>
|
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="toggleEditDetails">Edytuj</button>
|
||||||
@endunless
|
@endunless
|
||||||
@@ -67,7 +106,7 @@
|
|||||||
<div class="card" style="padding:16px;gap:10px">
|
<div class="card" style="padding:16px;gap:10px">
|
||||||
<div class="card-kicker">Notatki wewnętrzne</div>
|
<div class="card-kicker">Notatki wewnętrzne</div>
|
||||||
@forelse ($this->internalMessages as $m)
|
@forelse ($this->internalMessages as $m)
|
||||||
<div style="padding:10px 12px;border-left:3px solid var(--color-accent);background:var(--color-surface);border-radius:4px">
|
<div wire:key="note-{{ $m->id }}" style="padding:10px 12px;border-left:3px solid var(--color-accent);background:var(--color-surface);border-radius:4px">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">
|
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">
|
||||||
<div style="font-size:11px;opacity:0.65;margin-bottom:4px">{{ $m->author_name }} · {{ \App\Support\Rel::format($m->created_at) }}</div>
|
<div style="font-size:11px;opacity:0.65;margin-bottom:4px">{{ $m->author_name }} · {{ \App\Support\Rel::format($m->created_at) }}</div>
|
||||||
@if ($m->role === 'operator')
|
@if ($m->role === 'operator')
|
||||||
@@ -130,10 +169,15 @@
|
|||||||
<div style="display:flex;flex-direction:column;gap:10px">
|
<div style="display:flex;flex-direction:column;gap:10px">
|
||||||
@foreach ($threadMessages as $m)
|
@foreach ($threadMessages as $m)
|
||||||
@php $mine = $m->role === 'operator'; @endphp
|
@php $mine = $m->role === 'operator'; @endphp
|
||||||
<div style="display:flex;justify-content:{{ $mine ? 'flex-end' : 'flex-start' }}">
|
<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="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="display:flex;justify-content:space-between;align-items:flex-start;gap:10px">
|
||||||
<div style="font-size:11px;opacity:0.65;margin-bottom:4px">{{ $m->author_name }} · {{ \App\Support\Rel::format($m->created_at) }}{{ $m->edited ? ' · edytowano' : '' }}</div>
|
<div style="font-size:11px;opacity:0.65;margin-bottom:4px;display:flex;align-items:center;gap:4px">
|
||||||
|
@if ($m->source === 'email')
|
||||||
|
<span class="material-symbols-outlined" style="font-size:13px" title="Odebrane e-mailem">mail</span>
|
||||||
|
@endif
|
||||||
|
{{ $m->author_name }} · {{ \App\Support\Rel::format($m->created_at) }}{{ $m->edited ? ' · edytowano' : '' }}
|
||||||
|
</div>
|
||||||
@if ($m->role === 'operator')
|
@if ($m->role === 'operator')
|
||||||
<div style="display:flex;gap:6px;flex:none">
|
<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>
|
<span class="material-symbols-outlined" style="font-size:15px;cursor:pointer;opacity:0.7" wire:click="startEditMessage({{ $m->id }}, @js($m->body))">edit</span>
|
||||||
@@ -286,7 +330,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div wire:init="loadSuggestedArticles">
|
||||||
<x-bookstack-suggestions :articles="$this->suggestedArticles" variant="sidebar" title="Baza wiedzy" :show-copy="true" />
|
<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 class="card-kicker">Podsumowanie AI</div>
|
||||||
|
@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.</p>
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
<div class="card" style="padding:16px;gap:8px">
|
<div class="card" style="padding:16px;gap:8px">
|
||||||
<div class="card-kicker">SLA</div>
|
<div class="card-kicker">SLA</div>
|
||||||
@@ -427,7 +492,7 @@
|
|||||||
<div class="dialog-backdrop">
|
<div class="dialog-backdrop">
|
||||||
<div class="dialog" style="max-width:400px">
|
<div class="dialog" style="max-width:400px">
|
||||||
<div class="dialog-title">Potwierdź usunięcie</div>
|
<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">
|
<div class="dialog-actions">
|
||||||
<button type="button" class="btn btn-secondary" wire:click="cancelDeleteTicket">Anuluj</button>
|
<button type="button" class="btn btn-secondary" wire:click="cancelDeleteTicket">Anuluj</button>
|
||||||
<button type="button" class="btn btn-primary" wire:click="confirmDeleteTicket">Usuń</button>
|
<button type="button" class="btn btn-primary" wire:click="confirmDeleteTicket">Usuń</button>
|
||||||
@@ -435,4 +500,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
@script
|
||||||
|
<script>
|
||||||
|
// Re-runs on every mount of this component, including after a
|
||||||
|
// wire:navigate to a different ticket, so the socket subscription
|
||||||
|
// always matches whichever ticket is currently on screen.
|
||||||
|
if (window.subscribeToTicketChannel) {
|
||||||
|
window.subscribeToTicketChannel({{ $ticket->id }});
|
||||||
|
} else {
|
||||||
|
// echo.js (a deferred module) hasn't run yet — queue the id so it
|
||||||
|
// subscribes as soon as it does, instead of silently doing nothing.
|
||||||
|
(window.__pendingTicketChannelIds = window.__pendingTicketChannelIds || []).push({{ $ticket->id }});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
@endscript
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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">← 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>
|
||||||
@@ -12,14 +12,14 @@ use Illuminate\Support\Facades\Route;
|
|||||||
Route::prefix('v1')->middleware('throttle:api')->group(function () {
|
Route::prefix('v1')->middleware('throttle:api')->group(function () {
|
||||||
Route::middleware(['auth:sanctum', 'abilities:tickets:read'])->group(function () {
|
Route::middleware(['auth:sanctum', 'abilities:tickets:read'])->group(function () {
|
||||||
Route::get('/tickets', [TicketController::class, 'index']);
|
Route::get('/tickets', [TicketController::class, 'index']);
|
||||||
Route::get('/tickets/{ticket}', [TicketController::class, 'show']);
|
Route::get('/tickets/{ticket:id}', [TicketController::class, 'show']);
|
||||||
Route::get('/tickets/{ticket}/messages', [TicketMessageController::class, 'index']);
|
Route::get('/tickets/{ticket:id}/messages', [TicketMessageController::class, 'index']);
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::middleware(['auth:sanctum', 'abilities:tickets:write'])->group(function () {
|
Route::middleware(['auth:sanctum', 'abilities:tickets:write'])->group(function () {
|
||||||
Route::post('/tickets', [TicketController::class, 'store']);
|
Route::post('/tickets', [TicketController::class, 'store']);
|
||||||
Route::patch('/tickets/{ticket}', [TicketController::class, 'update']);
|
Route::patch('/tickets/{ticket:id}', [TicketController::class, 'update']);
|
||||||
Route::post('/tickets/{ticket}/messages', [TicketMessageController::class, 'store']);
|
Route::post('/tickets/{ticket:id}/messages', [TicketMessageController::class, 'store']);
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::middleware(['auth:sanctum', 'abilities:dictionaries:read'])->group(function () {
|
Route::middleware(['auth:sanctum', 'abilities:dictionaries:read'])->group(function () {
|
||||||
|
|||||||
47
src/routes/channels.php
Normal file
47
src/routes/channels.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Ticket;
|
||||||
|
use Illuminate\Support\Facades\Broadcast;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One shared channel for anything queue-relevant (new/changed/closed
|
||||||
|
* tickets) rather than per-team channels — payloads carry only minimal
|
||||||
|
* metadata, so the receiving Livewire component just re-queries through its
|
||||||
|
* own already-correct Ticket::scopeVisibleToOperator() instead of this
|
||||||
|
* callback needing to duplicate that ACL logic.
|
||||||
|
*/
|
||||||
|
Broadcast::channel('operator.queue', function ($user) {
|
||||||
|
return in_array('operator', $user->roles ?? []) || $user->isAdmin();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-ticket channel for message/detail changes (drives both the live
|
||||||
|
* message thread and live ticket-header updates). OR, not else-if — the
|
||||||
|
* owner's account holds both client and operator roles at once, so both
|
||||||
|
* branches must be checked rather than picking one based on role alone.
|
||||||
|
* Payloads here also stay minimal (never the message body itself), so an
|
||||||
|
* internal note can safely broadcast on the same channel a client is
|
||||||
|
* subscribed to — the client's own computed properties never touch
|
||||||
|
* internal messages regardless of which event arrived.
|
||||||
|
*/
|
||||||
|
Broadcast::channel('ticket.{ticketId}', function ($user, int $ticketId) {
|
||||||
|
$ticket = Ticket::query()->find($ticketId);
|
||||||
|
|
||||||
|
if (! $ticket) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Support\Settings;
|
||||||
use Illuminate\Foundation\Inspiring;
|
use Illuminate\Foundation\Inspiring;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
use Illuminate\Support\Facades\Schedule;
|
use Illuminate\Support\Facades\Schedule;
|
||||||
@@ -8,4 +9,16 @@ Artisan::command('inspire', function () {
|
|||||||
$this->comment(Inspiring::quote());
|
$this->comment(Inspiring::quote());
|
||||||
})->purpose('Display an inspiring quote');
|
})->purpose('Display an inspiring quote');
|
||||||
|
|
||||||
Schedule::command('tickets:check-sla-breaches')->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));
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use App\Livewire\Operator\NewTicket as OperatorNewTicket;
|
|||||||
use App\Livewire\Operator\Queue as OperatorQueue;
|
use App\Livewire\Operator\Queue as OperatorQueue;
|
||||||
use App\Livewire\Operator\Stats as OperatorStats;
|
use App\Livewire\Operator\Stats as OperatorStats;
|
||||||
use App\Livewire\Operator\TicketShow as OperatorTicketShow;
|
use App\Livewire\Operator\TicketShow as OperatorTicketShow;
|
||||||
|
use App\Livewire\Settings\NotificationPreferences;
|
||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
use App\Support\Settings;
|
use App\Support\Settings;
|
||||||
use Illuminate\Support\Facades\Auth;
|
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::middleware(['auth', 'role:admin'])->prefix('admin')->name('admin.')->group(function () {
|
||||||
Route::get('/', AdminPanel::class)->name('panel');
|
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');
|
||||||
|
});
|
||||||
|
|||||||
61
src/tests/Feature/AdminAiIntegrationConfigTest.php
Normal file
61
src/tests/Feature/AdminAiIntegrationConfigTest.php
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Livewire\Admin\Panel;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Support\Settings;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
test('the Integracje tab shows the Integracja AI card', function () {
|
||||||
|
$admin = adminUser();
|
||||||
|
|
||||||
|
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||||
|
->assertSee('Integracja AI');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saving the AI config persists settings and encrypts the api key at rest', function () {
|
||||||
|
$admin = adminUser();
|
||||||
|
|
||||||
|
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||||
|
->set('aiConfig.enabled', true)
|
||||||
|
->set('aiConfig.baseUrl', 'https://api.groq.test/openai/v1')
|
||||||
|
->set('aiConfig.apiKey', 'super-secret-key')
|
||||||
|
->set('aiConfig.model', 'llama-3.3-70b-versatile')
|
||||||
|
->set('aiConfig.verifySsl', true)
|
||||||
|
->call('saveAiConfig')
|
||||||
|
->assertOk();
|
||||||
|
|
||||||
|
expect(Settings::get('ai_base_url'))->toBe('https://api.groq.test/openai/v1');
|
||||||
|
expect(Settings::get('ai_model'))->toBe('llama-3.3-70b-versatile');
|
||||||
|
expect(Settings::get('ai_api_key'))->toBe('super-secret-key');
|
||||||
|
|
||||||
|
$stored = Setting::query()->where('key', 'ai_api_key')->value('value');
|
||||||
|
expect($stored)->not->toBe('super-secret-key');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaving the api key field blank on save keeps the previously stored key', function () {
|
||||||
|
Settings::set('ai_api_key', 'already-stored-key');
|
||||||
|
$admin = adminUser();
|
||||||
|
|
||||||
|
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||||
|
->set('aiConfig.enabled', true)
|
||||||
|
->set('aiConfig.baseUrl', 'https://api.groq.test/openai/v1')
|
||||||
|
->set('aiConfig.model', 'llama-3.3-70b-versatile')
|
||||||
|
->call('saveAiConfig')
|
||||||
|
->assertOk();
|
||||||
|
|
||||||
|
expect(Settings::get('ai_api_key'))->toBe('already-stored-key');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('testAiConnection reports the result of a live probe using unsaved form values', function () {
|
||||||
|
Http::fake(['api.groq.test/*' => Http::response(['choices' => [['message' => ['content' => 'pong']]]])]);
|
||||||
|
$admin = adminUser();
|
||||||
|
|
||||||
|
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||||
|
->set('aiConfig.enabled', true)
|
||||||
|
->set('aiConfig.baseUrl', 'https://api.groq.test/openai/v1')
|
||||||
|
->set('aiConfig.apiKey', 'key')
|
||||||
|
->set('aiConfig.model', 'llama-3.3-70b-versatile')
|
||||||
|
->call('testAiConnection')
|
||||||
|
->assertSet('aiTestResult', 'ok');
|
||||||
|
});
|
||||||
74
src/tests/Feature/AdminAiTriageConfigTest.php
Normal file
74
src/tests/Feature/AdminAiTriageConfigTest.php
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<?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('saveAiSummaryPrompt persists custom prompt text', function () {
|
||||||
|
$admin = adminUser();
|
||||||
|
|
||||||
|
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||||
|
->call('saveAiSummaryPrompt', 'Mój niestandardowy prompt.')
|
||||||
|
->assertOk();
|
||||||
|
|
||||||
|
expect(Settings::get('ai_summary_prompt'))->toBe('Mój niestandardowy prompt.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resetAiSummaryPrompt restores the default prompt after it was customized', function () {
|
||||||
|
$default = Settings::default('ai_summary_prompt');
|
||||||
|
$admin = adminUser();
|
||||||
|
|
||||||
|
$component = Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||||
|
->call('saveAiSummaryPrompt', 'Coś zupełnie innego.')
|
||||||
|
->assertSet('aiSummaryPrompt', 'Coś zupełnie innego.');
|
||||||
|
|
||||||
|
expect(Settings::get('ai_summary_prompt'))->toBe('Coś zupełnie innego.');
|
||||||
|
|
||||||
|
$component->call('resetAiSummaryPrompt')
|
||||||
|
->assertSet('aiSummaryPrompt', $default);
|
||||||
|
|
||||||
|
expect(Settings::get('ai_summary_prompt'))->toBe($default);
|
||||||
|
});
|
||||||
54
src/tests/Feature/AdminBookstackTaggingButtonsTest.php
Normal file
54
src/tests/Feature/AdminBookstackTaggingButtonsTest.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Livewire\Admin\Panel;
|
||||||
|
use App\Support\Settings;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
test('the tagging buttons are visible once BookStack is enabled', function () {
|
||||||
|
$admin = adminUser();
|
||||||
|
|
||||||
|
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||||
|
->set('bookstackConfig.enabled', true)
|
||||||
|
->assertSee('Otaguj nową treść')
|
||||||
|
->assertSee('Otaguj wszystko ponownie');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking the normal button runs a non-force tagging pass and shows the summary', function () {
|
||||||
|
seedTaggerSubcategory();
|
||||||
|
enableBookstackAndAiForTagging();
|
||||||
|
fakeTaggerBookstackAndAi(
|
||||||
|
pageTags: [10 => [], 11 => [['name' => 'Drukarki i skanery', 'value' => '']]],
|
||||||
|
aiContent: '{"10": ["Drukarki i skanery"], "11": ["Drukarki i skanery"]}',
|
||||||
|
);
|
||||||
|
$admin = adminUser();
|
||||||
|
|
||||||
|
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||||
|
->call('runBookstackTagging')
|
||||||
|
->assertSet('bookstackTagResult', ['scanned' => 2, 'tagged' => 1, 'skipped' => 1, 'failed_batches' => 0])
|
||||||
|
->assertSee('Przeskanowano 2, otagowano 1, pominięto 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking the force button reclassifies already-tagged content too', function () {
|
||||||
|
seedTaggerSubcategory();
|
||||||
|
enableBookstackAndAiForTagging();
|
||||||
|
fakeTaggerBookstackAndAi(
|
||||||
|
pageTags: [11 => [['name' => 'Drukarki i skanery', 'value' => '']]],
|
||||||
|
aiContent: '{"11": ["Drukarki i skanery"]}',
|
||||||
|
);
|
||||||
|
$admin = adminUser();
|
||||||
|
|
||||||
|
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||||
|
->call('runBookstackTaggingForce')
|
||||||
|
->assertSet('bookstackTagResult', ['scanned' => 1, 'tagged' => 1, 'skipped' => 0, 'failed_batches' => 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('running tagging without a configured AI integration shows a helpful error instead of a silent no-op', function () {
|
||||||
|
enableBookstackAndAiForTagging();
|
||||||
|
Settings::set('ai_enabled', '0');
|
||||||
|
$admin = adminUser();
|
||||||
|
|
||||||
|
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||||
|
->call('runBookstackTagging')
|
||||||
|
->assertSet('bookstackTagResult', null)
|
||||||
|
->assertSee('Włącz i skonfiguruj obie integracje');
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user