5 Commits

Author SHA1 Message Date
0d116dfd98 v1.2.0
- E-mail intake (IMAP), optional and off by default: clients can create a
  ticket or reply to an existing one just by sending/replying to an e-mail.
  Configure any number of mailboxes in the new Admin > Poczta page (SMTP +
  IMAP together, replacing the old "E-MAIL" tab), each routed to a specific
  subcategory or a whole category (new tickets.category_id column). Replies
  are matched to their ticket via the number/checksum already in every
  notification subject; autoresponders/bounces are detected and rejected;
  "restrict tickets to LDAP" is enforced for e-mail like the guest web form.
  Manual "Pobierz teraz" per-mailbox fetch button; dedicated
  storage/logs/imap-*.log regardless of the app's log level; mail-icon badges
  on e-mail-originated tickets/messages in the operator queue and ticket view.
- Operator queue: "select all" checkbox in the table header for every
  currently visible ticket under the active filter/tab.
- Fixed: scheduled commands (SLA breach check, automation rules, and now IMAP
  fetch) always sent notifications through .env's default mailer instead of
  the configured SMTP server, because AppServiceProvider's Settings override
  used to skip itself for any console command, not just migrate.
- Fixed: visiting a ticket that no longer exists (deleted mid-session, or a
  stale background refresh) showed a raw 404 instead of redirecting back to
  the operator queue / client dashboard.
- Docs: README/ARCHITECTURE/CLAUDE/install/wiki updated for all of the above,
  including the previously-missing host crontab entry for schedule:run.

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 23:43:01 +02:00
0b06687ea1 v1.1.2
- Real-time updates (Laravel Reverb): live operator queue, live ticket
  chat/detail updates for operator and client, periodic fallback refresh
  with a visible countdown as a backstop for dropped websocket connections.
- SLA automation rules (Admin > Automatyzacja SLA): act on a ticket after
  N minutes of customer silence (change priority/status/team/assignee),
  evaluated every 15 minutes, reusing TicketService's own setters so
  automated changes get the same history/notification/broadcast a manual
  change would.
- New notification: every operator on a matching team gets notified when
  a new ticket lands in one of their subcategories.
- BookStack knowledge-base sidebar now also shown on the client's own
  ticket view (previously operator-only); suggestions everywhere now load
  in after first paint instead of blocking it.
- Client ticket view: shows assigned operator + team; page widened to
  match the operator's.
- Notification bell shows unread only; read notifications disappear
  instead of just dimming.
- Stats dashboard: sectioned layout, new breakdowns (by subcategory, CSAT
  by team/operator, top clients, client x subcategory cross-tab).
- Mobile: nav dropdowns (theme/notifications/profile) now expand full
  width instead of overflowing off-screen below 640px.
- Fixed two bugs that silently disabled all real-time updates (missing
  CSRF header on Echo's private-channel auth; a script-load-order race
  that could miss the livewire:init event) and the mariadb healthcheck
  (world-writable credentials file on this stack's NFS mount).
- Assorted test-suite fixes (roles virtual attribute needs the roles
  table seeded; a few missing seeds/wrong assertions found along the way).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 20:43:05 +02:00
def7c70887 Add pcntl/posix PHP extensions for Reverb signal handling
All checks were successful
Build and push image / build (push) Successful in 1m25s
The Reverb websocket server (artisan reverb:start) needs pcntl for
SIGINT/SIGTERM/SIGTSTP signal handling on shutdown; the base php:apache
image doesn't enable it by default.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 16:33:08 +02:00
116 changed files with 8650 additions and 412 deletions

View File

@@ -63,13 +63,71 @@ queue + unassigned + anything assigned to them, an admin sees everything), and
work-timer tracking (`timerElapsedSeconds()`). Keep ticket-shaped logic here
rather than spreading it across Livewire components.
## Ticket numbering & URLs
A ticket carries three distinct identifiers, each with a different job:
- **`id`** — the DB primary key. Never shown to users; the REST API
(`routes/api.php`) is deliberately pinned to it (`{ticket:id}` explicit
binding on every `{ticket}` route) so external integrations have a stable
contract regardless of the numbering settings below.
- **`number`** — a plain sequential string (`Ticket::nextNumber()`, max+1
starting at 1001), unique but otherwise unremarkable. Backs `scopeSearch()`
and the numeric sort in `Operator/Queue.php` regardless of display mode.
- **`checksum`** — a 6-digit HMAC-derived value (salted with `app.key`,
keyed off `id`), assigned once in a `Ticket::booted()` `created` listener
and never changed afterward. Collisions are handled for real, not just
assumed away: `Ticket::generateUniqueChecksum()` walks a nonce forward
until the candidate is free (checked against the DB), and the column has a
`unique()` constraint as a hard backstop.
`Ticket::displayNumber()`/`formattedNumber()` pick between `number` (zero-padded
to `Settings::get('ticket_number_min_length')`) and `checksum` based on
`Settings::bool('ticket_number_obfuscate')` — the "Ukryj kolejność zgłoszeń"
toggle in Admin > Konfiguracja. `Ticket` also overrides `getRouteKey()` and
`resolveRouteBinding()` to mirror that same choice, so **the web routes**
(`routes/web.php`, all plain `{ticket}` implicit bindings — no explicit field)
resolve and generate URLs against whichever column is currently the display
number: flip the setting and both the visible number *and* every link
(`route('client.ticket', $ticket)` etc.) switch together, and a bookmarked URL
built under the old mode stops resolving. This is why the API routes need the
explicit `{ticket:id}` override — without it, the same global `getRouteKey()`
change would silently start requiring `number`/`checksum` in API path params
too, breaking the documented `integer` "Ticket id" contract.
The `{numer}` placeholder available in admin-editable e-mail templates
(Admin > Szablony e-mail / Wyzwalacze) resolves to `formattedNumber()`
*without* `displayNumber()`'s prefix — those templates already hardcode their
own `#{numer}`, so adding the prefix there too would double it up or clash
with a non-default prefix.
A ticket route binding that resolves to nothing (most commonly: the ticket
was deleted while someone had it open, and a later request — typically
Livewire's own "model missing during hydration" recovery, which does a full
`window.location.reload()` of the same page — hits `{ticket}` again) no
longer surfaces Laravel's default 404 page. `bootstrap/app.php` registers a
`NotFoundHttpException` render callback (note: `Handler::prepareException()`
already converts `ModelNotFoundException` into `NotFoundHttpException`,
wrapped as `getPrevious()`, *before* any render callback runs — a callback
typed against `ModelNotFoundException` itself would never match) that
redirects to `operator.queue`/`client.dashboard` instead, for any
authenticated request under `operator/*`/`client/*`.
## Roles & permissions
Roles are a plain array on the user (`$user->roles`), not a separate pivot-backed
package — 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.
`$user->roles` reads/writes as a plain array (`['client', 'operator']`), but
it's a **virtual attribute** (`User::getAttribute()`/`setAttribute()`
overrides) backed by a real `roles` lookup table + `role_user` pivot, not an
actual column — assigning `'roles' => [...]` on create/update stashes the keys
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
@@ -91,7 +149,8 @@ attributes.
`App\Support\Settings` (`app/Support/Settings.php`) is a cached key/value reader
over the `settings` table, with hardcoded defaults for every key (company name,
LDAP/SMTP connection details, attachment limits, session lifetime, timezone,
branding/email HTML, etc.). Admin > Konfiguracja writes to this table, and
branding/email HTML, etc.). Admin > Konfiguracja (general/attachments/session),
Poczta (SMTP) and Integracje (LDAP, BookStack) all write to this same table, and
`AppServiceProvider::boot()` re-applies the relevant subset of it over
`config()` on every request — meaning **`Setting` rows win over `.env`** for
LDAP, mail, session lifetime and timezone once they're non-empty. This is by
@@ -100,6 +159,19 @@ source of the "seeded placeholder overrides real `.env` values" gotcha
documented in [install.md](install.md) — anything touching LDAP/mail/session/
timezone config should go through `Settings`, not raw `config()`/`.env` reads.
`settingsTableUsable()` gates all four overrides on whether the `settings`
table is safe to query yet — but is deliberately scoped to just the `migrate`
command family (`runningConsoleCommand('migrate', 'migrate:fresh', ...)`), not
"any console command". It used to blanket-skip for every console invocation
(exempting only unit tests), which silently broke every scheduled command's
outbound mail: `AppServiceProvider::boot()` runs on each process including
`schedule:run`-invoked commands, so `tickets:check-sla-breaches`,
`automation:run-rules` and `emails:fetch-imap` (below) all sent notifications
through whatever `.env`'s `MAIL_MAILER` happened to be (`log`, i.e. nowhere)
instead of the admin-configured SMTP server — with no error, since the `log`
mailer never throws. If a scheduled command's notification/lookup ever again
seems to silently use `.env` defaults instead of `Settings`, check here first.
## Notifications
`TicketService::notify(Ticket $ticket, string $triggerKey)` is the single
@@ -118,6 +190,86 @@ 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/...`
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 every 3060 seconds via a small Alpine countdown calling
`$wire.refreshQueue()` / `$wire.refreshTicketData()` — broadcasting is
best-effort, not the only way these views ever update.
A third private channel, **`App.Models.User.{id}`** (Laravel's default
per-notifiable convention, kept verbatim rather than a shorter alias),
carries realtime bell delivery: `AppServiceProvider::broadcastBellNotifications()`
listens for the framework's own `NotificationSent` event, and — only for the
`database` channel of a `TicketNotification` — dispatches `NotificationCreated`
on the recipient's own channel. This is a single choke point rather than
threading a broadcast call into every `TicketService` notification call site
(including the Trigger engine's `send_notification` action, below).
`resources/js/echo.js` bridges it into a `bell-notification-received` Livewire
event (refreshing `NotificationBell` instantly) and, if the viewer opted in via
the toggle on `/settings/notifications`, also raises a native in-tab
`Notification` popup — no service worker or push subscription, so this only
fires while the tab is open, same limitation as the other Echo listeners here.
## SLA
`SlaRule` holds per-priority response/resolution targets in minutes. The
@@ -127,6 +279,108 @@ tickets and can notify the assigned operator — see [install.md](install.md) fo
why this requires an external cron entry (the Docker image ships no
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` (also every 15 minutes) 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.
## 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` as
`Schedule::command('emails:fetch-imap')->everyFiveMinutes()->withoutOverlapping()`
— the one scheduled command in this app that opts into
`withoutOverlapping()` (SLA/automation don't), given IMAP I/O latency.
Early-returns if no `ImapMailbox` is enabled. Also callable directly per
mailbox from Admin > Poczta's "Pobierz teraz" button
(`ImapMailboxFetcher::fetchMailbox()`, bypassing the enabled-only
`fetchAll()` used by the schedule) for on-demand fetching/diagnosis
without shell access.
Requires the same external `schedule:run` cron entry as SLA/automation (see
[install.md](install.md) and the crontab note in
[CLAUDE.md](CLAUDE.md)) — without it, only the manual "Pobierz teraz" button
does anything.
## API
`routes/api.php` + `app/Http/Controllers/Api/` expose a small ability-scoped REST

View File

@@ -3,6 +3,221 @@
All notable changes to this project are documented in this file. Format loosely
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [1.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 3060 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
### Added

View File

@@ -60,6 +60,30 @@ with no rebuild or restart:
view:clear` to flush any root-owned compiled views before ending the
session — don't wait for a report of a broken page to catch it.
## Scheduled commands need a host crontab entry
The Docker image ships no cron/supervisor of its own (see [install.md](install.md)),
so `tickets:check-sla-breaches`, `automation:run-rules`, and `emails:fetch-imap`
(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 three scheduled commands above had ever run
automatically; ask before changing this again, since removing it silently
breaks SLA checks, automation rules and IMAP fetching, 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.
## Apache `/icons/` alias trap
The stock `php:apache` image enables `mods-enabled/alias.conf`, which defines

View File

@@ -8,7 +8,7 @@ RUN apt-get update && apt-get install -y \
unzip \
git \
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
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer

114
README.md
View File

@@ -13,7 +13,7 @@ The app has three areas, gated by role (a user can hold more than one at once):
|---|---|---|---|
| Client | `/client` | `client` | Submit tickets, track status, reply, see resolution |
| Operator | `/operator` | `operator` | Work the ticket queue, reply/resolve, see team statistics |
| Admin | `/admin` | `admin` | Configure categories, users, teams, SLA, templates, branding, LDAP/SMTP |
| Admin | `/admin` | `admin` | Configure categories, users, teams, SLA, templates, triggers, branding, LDAP/SMTP/BookStack |
Every account gets the `client` role by default (see `AssignDefaultRole` for LDAP-provisioned
accounts), and always lands on `/client` first after login regardless of what other
@@ -29,6 +29,18 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
- **SLA** — per-priority response/resolution time targets; a scheduled command
(`tickets:check-sla-breaches`, every 15 min) flags overdue tickets and can notify
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 countdown) covers a dropped websocket connection.
- **Categories & custom fields** — admin-defined categories/subcategories, each with
its own set of custom fields (text/textarea/select/checkbox/date/number) and an
optional default priority.
@@ -41,15 +53,35 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
team changed, closed, operator replied, SLA breached), each independently
enable/disable-able.
- **Operator statistics** (`/operator/stats`) — filterable dashboard (date range,
team, priority, category, assignee) with KPI tiles (volume, SLA breach rate,
average first-response/resolution time) and breakdowns by status, priority,
category, team and operator workload, plus a daily created-vs-closed trend.
team, priority, category, assignee) organized into sections: KPI tiles (volume,
SLA breach rate, average first-response/resolution time, CSAT); breakdowns by
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,
e-mail layout/footer, LDAP connection + user sync, SMTP connection, attachment
limits, session lifetime, timezone — all editable from Admin > Konfiguracja.
e-mail layout/footer, SMTP connection (Admin > E-MAIL), attachment limits,
session lifetime, timezone (Admin > Konfiguracja), and LDAP connection + user
sync + BookStack (Admin > Integracje).
- **LDAP auth** — logins bind against an LDAP/LLDAP directory (`config/auth.php`,
`config/ldap.php`); local accounts (e.g. the emergency `admin` account) fall back
to e-mail + local password when the LDAP bind doesn't match.
- **Triggers** (Admin > Wyzwalacze) — event-driven business rules that fire
immediately on a ticket lifecycle event (created, any field updated, status/
priority/assignee/team/category changed, new public reply): AND-combined
conditions gate a sequence of actions (set status/priority/team/assignee, or
send an e-mail using a dedicated set of freely add/edit/delete-able trigger
e-mail templates, kept separate from the fixed per-event system templates).
Complements the time-based SLA automation rules above rather than replacing
them.
- **Ticket watching** — operators can star/"Obserwuj" any ticket to follow it
regardless of assignment/team, which feeds the "Obserwowane zgłoszenia" scope
in their notification preferences.
- **Per-user notification preferences** (`/settings/notifications`) — each
operator/admin chooses, per event category (new ticket, ticket update,
escalation), which scope of tickets (mine, unassigned, watched, all) notifies
them via the in-app bell, and whether that also sends an e-mail; plus an
opt-in toggle for native in-tab browser push notifications.
- **REST API** (`/api/v1/...`, Sanctum token auth, ability-scoped: `tickets:read`,
`tickets:write`, `dictionaries:read`, `users:read`) for tickets/messages/users/
categories/statuses/priorities/teams — issued via admin-managed API clients.
@@ -57,9 +89,34 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
- **PWA** — installable manifest + icons for the client-facing area.
- **In-app notifications** — a bell in the top bar (client/operator/admin areas)
backed by Laravel's database notification channel, alongside the existing
e-mail notifications (same per-trigger enable toggle drives both).
- **Attachments** — drag-and-drop upload (in addition to the file picker) and
inline image thumbnails in the message thread instead of a plain download link.
e-mail notifications (same per-trigger enable toggle drives both); shows
unread notifications only — reading one removes it from the list. Updates
live over WebSockets the moment a notification is created (with a 30s
fallback poll), and can optionally raise a native browser push notification
while the tab is open (see per-user notification preferences above).
Includes a dedicated trigger notifying every operator on a team whose
subcategories match a newly created ticket.
- **Attachments** — drag-and-drop upload (in addition to the file picker); every
attachment shows in the message thread as just its filename, opening in a new
tab on click (no inline image preview).
- **E-mail intake (IMAP)** *(optional, off by default)* — clients can create
tickets or reply to an existing one just by sending/replying to an e-mail;
configure any number of mailboxes in Admin > Poczta (e.g. one address per
team), each routed to a specific subcategory or a whole category. A reply
is matched back to its ticket via the number/checksum already present in
every notification's subject; automatic replies (autoresponders, bounces)
are detected and rejected instead of creating junk tickets, and the
"restrict tickets to LDAP" setting is enforced for e-mail exactly like the
guest web form. A manual "Pobierz teraz" button fetches immediately
outside the 5-minute schedule; all activity is logged separately to
`storage/logs/imap-*.log`. Tickets/messages that came in by e-mail show a
small mail-icon badge in the operator queue and ticket view.
- **Configurable ticket numbering** (Admin > Konfiguracja) — a custom prefix and
minimum zero-padded length for the ticket number, plus an optional "hide
ticket order" mode that displays a stable per-ticket checksum instead of the
sequential number. When enabled, ticket URLs switch to the same checksum too,
so the number in the link always matches the one on the page; the REST API is
unaffected and always addresses tickets by `id`.
- **Customer satisfaction (CSAT)** — clients rate a ticket 15 stars (+ optional
comment) once it's closed; average/response-rate surfaced as a KPI on the
operator stats dashboard, with a link in the "ticket closed" e-mail.
@@ -73,29 +130,35 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
- **BookStack knowledge-base integration** *(optional, off by default)*
suggests relevant BookStack articles by category/subcategory while a ticket
is being created, and in a separate sidebar panel on an existing ticket for
operators (with a copy-link button). Configured entirely from Admin >
Konfiguracja: connection + API token, optional SSL-verification bypass for
self-signed instances, page/book search-type filter, and two independent
per-shelf allow-lists (nothing is searched until an admin opts specific
shelves in, separately for ticket-creation suggestions vs. the operator
sidebar).
both operators and clients (with a copy-link button for operators). Loads in
after the page's first paint rather than blocking it. Configured entirely
from Admin > Integracje: connection + API token, optional SSL-verification
bypass for self-signed instances, page/book search-type filter, and two
independent per-shelf allow-lists (nothing is searched until an admin opts
specific shelves in, separately for ticket-creation suggestions vs. the
operator/client ticket-view sidebar).
## Tech stack
- **Backend**: Laravel, Livewire (server-driven UI, no SPA build beyond Tailwind/Vite
for CSS), LdapRecord for directory auth, Sanctum for API tokens, L5-Swagger for
API docs.
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
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
build step beyond the CSS bundle.
- **Database**: MariaDB.
- **Deployment**: `compose.yaml``servicedesk` (source bind-mounted from `./src`,
no image rebuild needed for PHP/Blade/route changes) + `mariadb`, fronted by
Traefik with a private-CA TLS cert. The `servicedesk` image 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.
no image rebuild needed for PHP/Blade/route changes) + `mariadb` + `reverb`
(same image, `php artisan reverb:start`), fronted by Traefik with a private-CA
TLS cert (the websocket path is routed to `reverb` by a higher-priority
Traefik rule; everything else goes to `servicedesk`). The `servicedesk` image
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 —
both via Docker Compose (this stack) and directly on a server with Apache/Nginx,
@@ -128,14 +191,19 @@ Compose-level and Laravel-level) and the LDAP/SMTP gotcha after a fresh seed.
src/ Laravel application
app/Livewire/ Client/Operator/Admin Livewire components
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)
app/Services/ TicketService (ticket lifecycle + notifications), BookStackClient,
ImapMailboxFetcher (I/O) + ImapMessageClassifier (pure logic)
app/Ldap/ LDAP user model + sync handlers
database/migrations/ Schema (one file per table group, final shape)
database/seeders/ DatabaseSeeder — reference data, no ticket data
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
routes/web.php Client/Operator/Admin routes (role-gated)
routes/api.php REST API (Sanctum, ability-gated)
routes/channels.php Broadcasting channel authorization (operator.queue, ticket.{id})
wiki/
client/ How-to guide for the Client role
operator/ How-to guide for the Operator role

View File

@@ -74,7 +74,7 @@ APP_LOCALE=pl
APP_FALLBACK_LOCALE=pl
AUTHOR_CONTACT=helpdesk@twoja-domena.pl # widoczne w Admin > O aplikacji
VERSION=1.1.0 # rezerwa na przyszłość, jeszcze nigdzie nie wyświetlane
VERSION=1.1.3 # widoczne w Admin > O aplikacji
DB_CONNECTION=mysql
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
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
```bash
@@ -201,11 +265,14 @@ docker run --rm -v "$(pwd)/src":/app -w /app node:22 npm run build
Powtarzaj drugi krok po każdej zmianie w `resources/css/` lub `resources/js/`.
### 1.6. Zadanie cykliczne (SLA) i kolejka
### 1.6. Zadanie cykliczne (SLA, automatyzacje, poczta IMAP) i kolejka
`routes/console.php` planuje `tickets:check-sla-breaches` co 15 minut, ale **obraz
Dockera nie ma wbudowanego cron/supervisora** — bez dodatkowego kroku to zadanie
nigdy się nie uruchomi. Najprościej dodać wpis crona **na hoście**:
`routes/console.php` planuje `tickets:check-sla-breaches` i `automation:run-rules`
co 15 minut oraz `emails:fetch-imap` (odbieranie zgłoszeń/odpowiedzi e-mailem —
patrz Admin > Poczta) 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). Najprościej dodać wpis crona **na hoście**:
```cron
* * * * * cd /ścieżka/do/repo && docker compose exec -T servicedesk php artisan schedule:run >> /dev/null 2>&1
@@ -285,7 +352,7 @@ APP_LOCALE=pl
APP_FALLBACK_LOCALE=pl
AUTHOR_CONTACT=helpdesk@twoja-domena.pl
VERSION=1.1.0
VERSION=1.1.3
DB_CONNECTION=mysql
DB_HOST=127.0.0.1 # albo adres IP/hostname prawdziwego serwera DB
@@ -393,9 +460,10 @@ server {
}
```
### 2.6. Zadanie cykliczne (SLA) i kolejka
### 2.6. Zadanie cykliczne (SLA, automatyzacje, poczta IMAP) 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` i `emails:fetch-imap` (patrz 1.6 wyżej):
```cron
* * * * * cd /var/www/servicedesk/src && php artisan schedule:run >> /dev/null 2>&1
@@ -405,6 +473,36 @@ Jak w wersji Docker — kolejka (`php artisan queue:work`) nie jest obowiązkowa
skoro powiadomienia wysyłają się synchronicznie; zostaw `QUEUE_CONNECTION=database`
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
Identycznie jak w kroku 1.6 — zaloguj się `admin@example.com` / `admin`, zmień

View File

@@ -1,11 +1,11 @@
APP_NAME=Laravel
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_DEBUG=false
APP_URL=http://localhost
AUTHOR_CONTACT=helpdesk@kzbikowski.pl
VERSION=1.1.0
VERSION=1.1.4
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
@@ -40,6 +40,24 @@ BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
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_PREFIX=

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Console\Commands;
use App\Models\ImapMailbox;
use App\Services\ImapMailboxFetcher;
use Illuminate\Console\Command;
class FetchImapEmails extends Command
{
protected $signature = 'emails:fetch-imap';
protected $description = 'Poll every enabled IMAP mailbox and turn new messages into tickets/replies';
public function handle(ImapMailboxFetcher $fetcher): int
{
if (! ImapMailbox::query()->where('enabled', true)->exists()) {
return self::SUCCESS;
}
$totals = $fetcher->fetchAll();
$this->info(sprintf(
'IMAP fetch: %d nowych, %d odpowiedzi, %d odrzuconych, %d błędów.',
$totals['created'],
$totals['replied'],
$totals['rejected'],
$totals['errors'],
));
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,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(),
]);
}
}

View File

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

View File

@@ -0,0 +1,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,
];
}
}

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

View File

@@ -0,0 +1,335 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Category;
use App\Models\ImapMailbox;
use App\Services\ImapMailboxFetcher;
use App\Support\Settings;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Mail;
use Livewire\Attributes\Computed;
use Livewire\Component;
/**
* SMTP (outbound) + IMAP mailboxes (inbound turns e-mails into tickets or
* replies) on their own dedicated admin page, split out of the generic
* "Integracje" grab-bag since IMAP is a repeatable list (N mailboxes) rather
* than a singleton config, and both halves of "reply by e-mail" belong
* together rather than split across tabs.
*/
class MailSettings extends Component
{
public array $mailConfig = [];
public ?string $mailTestResult = null;
public bool $mailboxFormOpen = false;
public array $mailboxForm = [
'id' => null,
'name' => '',
'enabled' => true,
'host' => '',
'port' => 993,
'encryption' => 'ssl',
'validateCert' => true,
'username' => '',
'password' => '',
'folder' => 'INBOX',
'processedFolder' => '',
'rejectedFolder' => '',
'target' => '',
'blocklistSenders' => 'mailer-daemon,postmaster,no-reply,noreply',
];
public ?int $mailboxTestResultId = null;
public ?string $mailboxTestResult = null;
public ?string $mailboxTestMessage = null;
public ?int $mailboxFetchResultId = null;
public ?string $mailboxFetchSummary = null;
public function mount(): void
{
$this->mailConfig = [
'smtpEnabled' => Settings::bool('mail_smtp_enabled'),
'smtpHost' => Settings::get('mail_smtp_host'),
'smtpPort' => Settings::get('mail_smtp_port'),
'smtpUsername' => Settings::get('mail_smtp_username'),
'smtpPassword' => Settings::get('mail_smtp_password'),
'smtpEncryption' => Settings::get('mail_smtp_encryption'),
'fromAddress' => Settings::get('mail_from_address'),
'fromName' => Settings::get('mail_from_name'),
];
}
#[Computed]
public function mailboxes(): Collection
{
return ImapMailbox::query()->with(['defaultSubcategory.category', 'defaultCategory'])->orderBy('name')->get();
}
/**
* Categories with their subcategories nested, for the mailbox form's
* single combined "cała kategoria albo konkretna podkategoria" selector.
*/
#[Computed]
public function categoryOptions(): Collection
{
return Category::query()->with('subcategories')->orderBy('name')->get()
->map(fn (Category $c) => [
'id' => $c->id,
'name' => $c->name,
'subcategories' => $c->subcategories->map(fn ($s) => ['id' => $s->id, 'name' => $s->name])->values(),
])
->values();
}
// ===================== SMTP =====================
public function saveMailConfig(): void
{
Settings::set('mail_smtp_enabled', $this->mailConfig['smtpEnabled'] ? '1' : '0');
Settings::set('mail_smtp_host', $this->mailConfig['smtpHost']);
Settings::set('mail_smtp_port', (string) $this->mailConfig['smtpPort']);
Settings::set('mail_smtp_username', $this->mailConfig['smtpUsername']);
if ($this->mailConfig['smtpPassword']) {
Settings::set('mail_smtp_password', $this->mailConfig['smtpPassword']);
}
Settings::set('mail_smtp_encryption', $this->mailConfig['smtpEncryption']);
Settings::set('mail_from_address', $this->mailConfig['fromAddress']);
Settings::set('mail_from_name', $this->mailConfig['fromName']);
$this->mailTestResult = null;
}
/**
* Sends a real test e-mail to the logged-in admin using the form's
* current (unsaved) values, temporarily overriding the mail config the
* same way AppServiceProvider does for real once saved.
*/
public function testMailConnection(): void
{
$cfg = $this->mailConfig;
if (empty($cfg['smtpHost']) || empty($cfg['fromAddress'])) {
$this->mailTestResult = 'error';
return;
}
$original = Config::get('mail');
try {
Config::set('mail.default', 'smtp');
Config::set('mail.mailers.smtp.host', $cfg['smtpHost']);
Config::set('mail.mailers.smtp.port', (int) $cfg['smtpPort']);
Config::set('mail.mailers.smtp.username', $cfg['smtpUsername'] ?: null);
Config::set('mail.mailers.smtp.password', $cfg['smtpPassword'] ?: Settings::get('mail_smtp_password'));
Config::set('mail.mailers.smtp.scheme', match ($cfg['smtpEncryption']) {
'ssl' => 'smtps',
'tls' => 'smtp',
default => null,
});
Config::set('mail.from.address', $cfg['fromAddress']);
Config::set('mail.from.name', $cfg['fromName'] ?: Settings::get('company_name'));
app()->forgetInstance('mail.manager');
app()->forgetInstance('mailer');
Mail::raw('To jest testowa wiadomość wysłana z panelu administratora Servicedesk.', function ($message) {
$message->to(Auth::user()->email)->subject('Test konfiguracji SMTP');
});
$this->mailTestResult = 'ok';
} catch (\Throwable) {
$this->mailTestResult = 'error';
} finally {
Config::set('mail', $original);
app()->forgetInstance('mail.manager');
app()->forgetInstance('mailer');
}
}
// ===================== IMAP MAILBOXES =====================
public function openMailboxForm(): void
{
$this->reset('mailboxForm');
$this->mailboxForm = [
'id' => null,
'name' => '',
'enabled' => true,
'host' => '',
'port' => 993,
'encryption' => 'ssl',
'validateCert' => true,
'username' => '',
'password' => '',
'folder' => 'INBOX',
'processedFolder' => '',
'rejectedFolder' => '',
'target' => '',
'blocklistSenders' => 'mailer-daemon,postmaster,no-reply,noreply',
];
$this->mailboxTestResultId = null;
$this->resetErrorBag();
$this->mailboxFormOpen = true;
}
public function editMailbox(int $id): void
{
$mailbox = ImapMailbox::query()->findOrFail($id);
$target = match (true) {
(bool) $mailbox->default_subcategory_id => "subcategory:{$mailbox->default_subcategory_id}",
(bool) $mailbox->default_category_id => "category:{$mailbox->default_category_id}",
default => '',
};
$this->mailboxForm = [
'id' => $mailbox->id,
'name' => $mailbox->name,
'enabled' => $mailbox->enabled,
'host' => $mailbox->host,
'port' => $mailbox->port,
'encryption' => $mailbox->encryption,
'validateCert' => $mailbox->validate_cert,
'username' => $mailbox->username,
'password' => $mailbox->password,
'folder' => $mailbox->folder,
'processedFolder' => $mailbox->processed_folder,
'rejectedFolder' => $mailbox->rejected_folder,
'target' => $target,
'blocklistSenders' => $mailbox->blocklist_senders,
];
$this->mailboxTestResultId = null;
$this->resetErrorBag();
$this->mailboxFormOpen = true;
}
public function closeMailboxForm(): void
{
$this->mailboxFormOpen = false;
}
public function submitMailboxForm(): void
{
$this->validate([
'mailboxForm.name' => ['required', 'string', 'max:255'],
'mailboxForm.host' => ['required', 'string', 'max:255'],
'mailboxForm.port' => ['required', 'integer', 'min:1', 'max:65535'],
'mailboxForm.encryption' => ['required', 'in:ssl,tls,none'],
'mailboxForm.username' => ['required', 'string', 'max:255'],
'mailboxForm.folder' => ['required', 'string', 'max:255'],
]);
[$targetType, $targetId] = str_contains((string) $this->mailboxForm['target'], ':')
? explode(':', $this->mailboxForm['target'], 2)
: [null, null];
$data = [
'name' => $this->mailboxForm['name'],
'enabled' => (bool) $this->mailboxForm['enabled'],
'host' => $this->mailboxForm['host'],
'port' => (int) $this->mailboxForm['port'],
'encryption' => $this->mailboxForm['encryption'],
'validate_cert' => (bool) $this->mailboxForm['validateCert'],
'username' => $this->mailboxForm['username'],
'folder' => $this->mailboxForm['folder'],
'processed_folder' => $this->mailboxForm['processedFolder'] ?: null,
'rejected_folder' => $this->mailboxForm['rejectedFolder'] ?: null,
// Exactly one of these (or neither) — never both — driven by the
// form's single "cała kategoria albo konkretna podkategoria" selector.
'default_subcategory_id' => $targetType === 'subcategory' ? $targetId : null,
'default_category_id' => $targetType === 'category' ? $targetId : null,
'blocklist_senders' => $this->mailboxForm['blocklistSenders'],
];
$mailbox = ImapMailbox::query()->find($this->mailboxForm['id']);
if ($mailbox) {
if ($this->mailboxForm['password']) {
$data['password'] = $this->mailboxForm['password'];
}
$mailbox->update($data);
} else {
$data['password'] = $this->mailboxForm['password'];
ImapMailbox::query()->create($data);
}
$this->mailboxFormOpen = false;
unset($this->mailboxes);
}
public function toggleMailboxEnabled(int $id): void
{
$mailbox = ImapMailbox::query()->findOrFail($id);
$mailbox->update(['enabled' => ! $mailbox->enabled]);
unset($this->mailboxes);
}
public function removeMailbox(int $id): void
{
ImapMailbox::query()->findOrFail($id)->delete();
unset($this->mailboxes);
}
/**
* Runs a real fetch against one mailbox right now, outside the 5-minute
* schedule for checking a freshly-configured mailbox without waiting,
* and for diagnosing "why didn't my e-mail turn into a ticket" without
* needing shell access. Allowed even while the mailbox is disabled
* (fetchAll(), used by the scheduled command, is the one that respects
* the enabled flag this is an explicit admin action).
*/
public function fetchMailboxNow(int $id): void
{
$mailbox = ImapMailbox::query()->findOrFail($id);
$result = app(ImapMailboxFetcher::class)->fetchMailbox($mailbox);
$this->mailboxFetchResultId = $id;
$this->mailboxFetchSummary = "Nowe: {$result['created']}, odpowiedzi: {$result['replied']}, odrzucone: {$result['rejected']}, błędy: {$result['errors']}.";
unset($this->mailboxes);
}
/**
* Tests the form's current (unsaved) values against a throwaway
* ImapMailbox instance mirrors testMailConnection()'s "don't require a
* save first" behavior. Falls back to the stored password when editing
* an existing mailbox and the password field was left blank.
*/
public function testMailboxConnection(): void
{
$mailbox = new ImapMailbox([
'host' => $this->mailboxForm['host'],
'port' => (int) $this->mailboxForm['port'],
'encryption' => $this->mailboxForm['encryption'],
'validate_cert' => (bool) $this->mailboxForm['validateCert'],
'username' => $this->mailboxForm['username'],
'folder' => $this->mailboxForm['folder'] ?: 'INBOX',
]);
$mailbox->password = $this->mailboxForm['password']
?: ($this->mailboxForm['id'] ? ImapMailbox::query()->find($this->mailboxForm['id'])?->password : null);
$error = app(ImapMailboxFetcher::class)->testConnection($mailbox);
$this->mailboxTestResultId = (int) ($this->mailboxForm['id'] ?? 0);
$this->mailboxTestResult = $error === null ? 'ok' : 'error';
$this->mailboxTestMessage = $error;
}
public function render()
{
return view('livewire.admin.mail-settings');
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Livewire\Admin;
use App\Models\AutomationRule;
use App\Models\Category;
use App\Models\CustomField;
use App\Models\EmailTemplate;
@@ -13,6 +14,7 @@ use App\Models\SlaRule;
use App\Models\Status;
use App\Models\Subcategory;
use App\Models\Team;
use App\Models\Ticket;
use App\Models\User;
use App\Models\UserField;
use App\Services\BookStackClient;
@@ -20,11 +22,10 @@ use App\Services\LdapUserProvisioner;
use App\Support\Settings;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use LdapRecord\Connection;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithFileUploads;
@@ -32,6 +33,7 @@ class Panel extends Component
{
use WithFileUploads;
#[Url]
public string $tab = 'categories';
// ---- categories ----
@@ -100,6 +102,15 @@ class Panel extends Component
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 ----
public ?int $editingTemplateId = null;
@@ -135,10 +146,6 @@ class Panel extends Component
public ?string $ldapTestResult = null;
public array $mailConfig = [];
public ?string $mailTestResult = null;
public array $bookstackConfig = [];
public ?string $bookstackTestResult = null;
@@ -169,6 +176,9 @@ class Panel extends Component
'attachmentAllowedTypes' => Settings::get('attachment_allowed_types'),
'sessionLifetimeMinutes' => Settings::get('session_lifetime_minutes'),
'timezone' => Settings::timezone(),
'ticketNumberPrefix' => Settings::get('ticket_number_prefix'),
'ticketNumberObfuscate' => Settings::bool('ticket_number_obfuscate'),
'ticketNumberMinLength' => Settings::get('ticket_number_min_length'),
];
$this->ldapConfig = [
@@ -185,17 +195,6 @@ class Panel extends Component
'restrictTicketsToLdap' => Settings::bool('restrict_tickets_to_ldap'),
];
$this->mailConfig = [
'smtpEnabled' => Settings::bool('mail_smtp_enabled'),
'smtpHost' => Settings::get('mail_smtp_host'),
'smtpPort' => Settings::get('mail_smtp_port'),
'smtpUsername' => Settings::get('mail_smtp_username'),
'smtpPassword' => Settings::get('mail_smtp_password'),
'smtpEncryption' => Settings::get('mail_smtp_encryption'),
'fromAddress' => Settings::get('mail_from_address'),
'fromName' => Settings::get('mail_from_name'),
];
$this->bookstackConfig = [
'enabled' => Settings::bool('bookstack_enabled'),
'baseUrl' => Settings::get('bookstack_base_url'),
@@ -854,6 +853,96 @@ class Panel extends Component
$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 =====================
#[Computed]
@@ -1255,6 +1344,33 @@ class Panel extends Component
if (in_array($this->systemConfig['timezone'], \DateTimeZone::listIdentifiers(), true)) {
Settings::set('timezone', $this->systemConfig['timezone']);
}
Settings::set('ticket_number_prefix', trim((string) $this->systemConfig['ticketNumberPrefix']));
Settings::set('ticket_number_obfuscate', $this->systemConfig['ticketNumberObfuscate'] ? '1' : '0');
Settings::set('ticket_number_min_length', (string) max(1, (int) $this->systemConfig['ticketNumberMinLength']));
}
/**
* 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 =====================
@@ -1392,75 +1508,6 @@ class Panel extends Component
$this->bookstackTestMessage = $result['message'];
}
// ===================== MAIL / SMTP CONFIG =====================
public function saveMailConfig(): void
{
Settings::set('mail_smtp_enabled', $this->mailConfig['smtpEnabled'] ? '1' : '0');
Settings::set('mail_smtp_host', $this->mailConfig['smtpHost']);
Settings::set('mail_smtp_port', (string) $this->mailConfig['smtpPort']);
Settings::set('mail_smtp_username', $this->mailConfig['smtpUsername']);
if ($this->mailConfig['smtpPassword']) {
Settings::set('mail_smtp_password', $this->mailConfig['smtpPassword']);
}
Settings::set('mail_smtp_encryption', $this->mailConfig['smtpEncryption']);
Settings::set('mail_from_address', $this->mailConfig['fromAddress']);
Settings::set('mail_from_name', $this->mailConfig['fromName']);
$this->mailTestResult = null;
}
/**
* Sends a real test e-mail to the logged-in admin using the form's
* current (unsaved) values, temporarily overriding the mail config the
* same way AppServiceProvider does for real once saved so this test
* exercises the exact path production notifications will use.
*/
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');
}
}
// ===================== GENERIC DELETE CONFIRM =====================
public function requestDelete(string $type, mixed $id, string $message): void
@@ -1491,10 +1538,11 @@ class Panel extends Component
'user-field' => UserField::query()->find($this->pendingDeleteId)?->delete(),
'reply-quick-action' => ReplyQuickAction::query()->find($this->pendingDeleteId)?->delete(),
'response-template' => ResponseTemplate::query()->find($this->pendingDeleteId)?->delete(),
'automation-rule' => AutomationRule::query()->find($this->pendingDeleteId)?->delete(),
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();
}

View File

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

View File

@@ -30,6 +30,16 @@ class NewTicket extends Component
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]
public function categories()
{
@@ -67,6 +77,10 @@ class NewTicket extends Component
#[Computed]
public function suggestedArticles(): array
{
if (! $this->suggestedArticlesLoaded) {
return [];
}
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? ''));
return app(BookStackClient::class)->search($query);

View File

@@ -4,10 +4,12 @@ namespace App\Livewire\Client;
use App\Models\Ticket;
use App\Models\TicketMessage;
use App\Services\BookStackClient;
use App\Services\TicketService;
use App\Support\Settings;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed;
use Livewire\Attributes\On;
use Livewire\Component;
use Livewire\WithFileUploads;
@@ -31,6 +33,16 @@ class TicketShow extends Component
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
{
abort_unless($ticket->customer_id === Auth::id(), 403);
@@ -44,12 +56,76 @@ class TicketShow extends Component
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]
public function otherTickets()
{
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 ?? ''));
return app(BookStackClient::class)->search($query);
}
public function updatedAttachments(): void
{
if (! $this->attachments) {

View File

@@ -36,6 +36,16 @@ class Landing extends Component
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]
public function categories()
{
@@ -85,7 +95,7 @@ class Landing extends Component
#[Computed]
public function suggestedArticles(): array
{
if (! Settings::bool('bookstack_show_to_guests')) {
if (! $this->suggestedArticlesLoaded || ! Settings::bool('bookstack_show_to_guests')) {
return [];
}

View File

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

View File

@@ -33,6 +33,16 @@ class NewTicket extends Component
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]
public function clients()
{
@@ -76,6 +86,10 @@ class NewTicket extends Component
#[Computed]
public function suggestedArticles(): array
{
if (! $this->suggestedArticlesLoaded) {
return [];
}
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? ''));
return app(BookStackClient::class)->search($query);

View File

@@ -2,6 +2,7 @@
namespace App\Livewire\Operator;
use App\Events\TicketQueueChanged;
use App\Models\Category;
use App\Models\Priority;
use App\Models\Status;
@@ -11,11 +12,13 @@ use App\Models\User;
use App\Services\TicketService;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed;
use Livewire\Attributes\On;
use Livewire\Attributes\Url;
use Livewire\Component;
class Queue extends Component
{
#[Url]
public string $queue = '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]
public function savedViews()
{
@@ -268,7 +298,13 @@ class Queue extends Component
$query->where('priority_key', $this->filterPriority);
}
if ($this->filterCategory !== 'all') {
$query->whereHas('subcategory', fn ($q) => $q->where('category_id', $this->filterCategory));
// A ticket carries a category either via its subcategory or,
// when routed to a whole category with no subcategory (e.g. an
// IMAP mailbox), directly on tickets.category_id.
$query->where(function ($q) {
$q->whereHas('subcategory', fn ($sq) => $sq->where('category_id', $this->filterCategory))
->orWhere('category_id', $this->filterCategory);
});
}
if ($this->filterCustomerId) {
$query->where('customer_id', $this->filterCustomerId);
@@ -277,7 +313,7 @@ class Queue extends Component
$query->search($this->search);
}
$tickets = $query->with(['subcategory.category', 'assignee', 'priority', 'status'])->get();
$tickets = $query->with(['subcategory.category', 'category', 'assignee', 'priority', 'status', 'team'])->get();
return $this->sortTickets($tickets);
}
@@ -301,6 +337,9 @@ class Queue extends Component
'priority' => $tickets->sortBy(fn (Ticket $t) => $t->priority?->sort_order ?? PHP_INT_MAX, SORT_REGULAR, $desc),
'status' => $tickets->sortBy(fn (Ticket $t) => $t->status?->sort_order ?? PHP_INT_MAX, SORT_REGULAR, $desc),
'assignee' => $tickets->sortBy(fn (Ticket $t) => $t->assignee?->name ?? '', SORT_NATURAL | SORT_FLAG_CASE, $desc),
'team' => $tickets->sortBy(fn (Ticket $t) => $t->team?->name ?? '', SORT_NATURAL | SORT_FLAG_CASE, $desc),
'subcategory' => $tickets->sortBy(fn (Ticket $t) => $t->subcategory?->name ?? '', SORT_NATURAL | SORT_FLAG_CASE, $desc),
'created' => $tickets->sortBy(fn (Ticket $t) => $t->created_at, SORT_REGULAR, $desc),
default => $tickets->sortBy('updated_at', SORT_REGULAR, $desc),
};
@@ -317,10 +356,13 @@ class Queue extends Component
'subject' => 'Temat',
'customer' => 'Klient',
'category' => 'Kategoria',
'subcategory' => 'Podkategoria',
'priority' => 'Priorytet',
'status' => 'Status',
'sla' => 'SLA',
'assignee' => 'Przypisany',
'team' => 'Zespół',
'created' => 'Utworzono',
];
}
@@ -331,7 +373,7 @@ class Queue extends Component
*/
public function sortableColumns(): array
{
return ['number', 'subject', 'customer', 'category', 'priority', 'status', 'assignee'];
return ['number', 'subject', 'customer', 'category', 'subcategory', 'priority', 'status', 'assignee', 'team', 'created'];
}
public function sortByColumn(string $column): void
@@ -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
{
$ids = $this->selectedIdsInScope();
@@ -417,7 +473,13 @@ class Queue extends Component
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->pendingDeleteSelected = false;
}

View File

@@ -312,6 +312,25 @@ class Stats extends Component
->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]
public function byTeam()
{
@@ -351,6 +370,170 @@ class Stats extends Component
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
* wide range (or "Cały okres") never renders an unreadably thin column
@@ -422,7 +605,7 @@ class Stats extends Component
foreach ($tickets as $ticket) {
fputcsv($out, [
$ticket->number,
$ticket->displayNumber(),
$ticket->subject,
$ticket->statusLabel(),
$ticket->priorityLabel(),

View File

@@ -2,6 +2,7 @@
namespace App\Livewire\Operator;
use App\Events\TicketQueueChanged;
use App\Models\Category;
use App\Models\Priority;
use App\Models\ReplyQuickAction;
@@ -17,6 +18,7 @@ use App\Services\TicketService;
use App\Support\Settings;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed;
use Livewire\Attributes\On;
use Livewire\Component;
use Livewire\WithFileUploads;
@@ -66,6 +68,16 @@ class TicketShow extends Component
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;
}
public function mount(Ticket $ticket): void
{
abort_unless($ticket->isVisibleToOperator(Auth::user()), 403);
@@ -79,6 +91,18 @@ class TicketShow extends Component
$this->ticket->resumeTimer();
}
#[Computed]
public function isWatching(): bool
{
return $this->ticket->isWatchedBy(Auth::user());
}
public function toggleWatch(): void
{
app(TicketService::class)->toggleWatch($this->ticket, Auth::user());
unset($this->isWatching);
}
// -------- time tracking --------
public function stopTimer(): void
@@ -168,6 +192,50 @@ class TicketShow extends Component
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);
}
/**
* 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->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->publicMessages, $this->internalMessages);
$this->ticket->refresh();
}
#[Computed]
public function statuses()
{
@@ -192,6 +260,10 @@ class TicketShow extends Component
#[Computed]
public function suggestedArticles(): array
{
if (! $this->suggestedArticlesLoaded) {
return [];
}
$subcategory = $this->ticket->subcategory;
$query = trim(($subcategory?->category?->name ?? '').' '.($subcategory?->name ?? ''));
@@ -500,7 +572,9 @@ class TicketShow extends Component
public function confirmDeleteTicket(): void
{
$ticketId = $this->ticket->id;
$this->ticket->delete();
TicketQueueChanged::dispatch($ticketId, 'deleted', Auth::id());
$this->redirect(route('operator.queue'), navigate: true);
}

View File

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

View File

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

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

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* One inbound mailbox polled by `emails:fetch-imap` an admin can configure
* several (e.g. zgloszenia-it@ vs zgloszenia-delegacje@), each landing new
* tickets in its own default subcategory. Unlike LDAP/SMTP/BookStack, this is
* a list of N configs rather than a Settings singleton, so it's a real model
* rather than key/value rows.
*/
#[Fillable([
'name', 'enabled', 'host', 'port', 'encryption', 'validate_cert',
'username', 'password', 'folder', 'processed_folder', 'rejected_folder',
'default_subcategory_id', 'default_category_id', 'blocklist_senders', 'last_checked_at', 'last_error',
])]
class ImapMailbox extends Model
{
protected function casts(): array
{
return [
'enabled' => 'boolean',
'validate_cert' => 'boolean',
'password' => 'encrypted',
'last_checked_at' => 'datetime',
];
}
public function defaultSubcategory(): BelongsTo
{
return $this->belongsTo(Subcategory::class, 'default_subcategory_id');
}
/**
* Only meaningful when default_subcategory_id is null a mailbox is
* routed to either a specific subcategory or a whole category, never
* both (enforced by the admin form's single combined selector).
*/
public function defaultCategory(): BelongsTo
{
return $this->belongsTo(Category::class, 'default_category_id');
}
public function blocklistedSenders(): array
{
return array_filter(array_map('trim', explode(',', (string) $this->blocklist_senders)));
}
public function targetLabel(): string
{
if ($this->defaultSubcategory) {
return $this->defaultSubcategory->category->name.' / '.$this->defaultSubcategory->name;
}
if ($this->defaultCategory) {
return 'Cała kategoria: '.$this->defaultCategory->name;
}
return '—';
}
}

View File

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

View File

@@ -2,27 +2,44 @@
namespace App\Models;
use App\Support\Settings;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
#[Fillable([
'number', 'customer_id', 'email', 'name', 'subcategory_id', 'subject', 'body',
'status_key', 'priority_key', 'team_id', 'assignee_id', 'custom_fields', 'api_client_id',
'sla_notified_at', 'time_spent_seconds', 'timer_started_at', 'created_at', 'updated_at',
'csat_rating', 'csat_comment', 'csat_rated_at',
'number', 'checksum', 'customer_id', 'email', 'name', 'subcategory_id', 'category_id', 'subject', 'body',
'status_key', 'priority_key', 'team_id', 'assignee_id', 'custom_fields', 'api_client_id', 'source',
'sla_notified_at', 'last_customer_activity_at', 'time_spent_seconds', 'timer_started_at',
'created_at', 'updated_at', 'csat_rating', 'csat_comment', 'csat_rated_at',
])]
class Ticket extends Model
{
/**
* Every ticket gets a stable, unique checksum the moment its id is known
* it never needs to change afterward, and having it always populated
* (regardless of whether obfuscation is currently on) means toggling the
* "Ukryj kolejność zgłoszeń" setting doesn't need a backfill pass.
*/
protected static function booted(): void
{
static::created(function (Ticket $ticket) {
$ticket->checksum = static::generateUniqueChecksum($ticket->id);
$ticket->saveQuietly();
});
}
protected function casts(): array
{
return [
'custom_fields' => 'array',
'sla_notified_at' => 'datetime',
'last_customer_activity_at' => 'datetime',
'time_spent_seconds' => 'integer',
'timer_started_at' => 'datetime',
'csat_rating' => 'integer',
@@ -55,6 +72,26 @@ class Ticket extends Model
return $this->belongsTo(Subcategory::class);
}
/**
* Only ever set when there's no subcategory to derive a category from
* (subcategory_id already implies one via Subcategory::category()) see
* categoryLabel() and the migration that introduced this column.
*/
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
public function watchers(): BelongsToMany
{
return $this->belongsToMany(User::class, 'ticket_watchers');
}
public function isWatchedBy(User $user): bool
{
return $this->watchers()->where('users.id', $user->id)->exists();
}
public function status(): BelongsTo
{
return $this->belongsTo(Status::class, 'status_key');
@@ -90,6 +127,11 @@ class Ticket extends Model
return $this->hasMany(TicketHistory::class)->orderByDesc('created_at');
}
public function automationRuleLogs(): HasMany
{
return $this->hasMany(AutomationRuleTicketLog::class);
}
public static function nextNumber(): string
{
$max = static::query()->pluck('number')->map(fn ($n) => (int) $n)->max();
@@ -97,9 +139,92 @@ class Ticket extends Model
return (string) (($max ?: 1000) + 1);
}
/**
* The number shown to users: the admin-configured prefix in front of
* formattedNumber(). Kept separate from formattedNumber() because the
* `{numer}` placeholder in admin-editable e-mail templates historically
* carries no prefix (templates hardcode their own, e.g. "Zgłoszenie
* #{numer}") — changing that would double up or mismatch a
* non-default prefix in every existing template.
*/
public function displayNumber(): string
{
return Settings::get('ticket_number_prefix', '#').$this->formattedNumber();
}
/**
* The ticket number without any prefix: either the raw sequential
* `number` (zero-padded to the admin-configured minimum length), or
* when obfuscation is enabled this ticket's stored checksum. The
* checksum is a fixed-width HMAC output, so minimum-length padding
* doesn't apply to it (padding a checksum has no real meaning — it's
* only meant to make a short *sequential* number look consistent).
* This is also the value getRouteKey()/resolveRouteBinding() use, so
* the number shown on the page and the one in the URL always match.
* The underlying `number` column itself is left alone, since it still
* backs the numeric sort in Operator/Queue.php.
*/
public function formattedNumber(): string
{
if (Settings::bool('ticket_number_obfuscate')) {
return $this->checksum ?? $this->number;
}
$minLength = max(1, (int) Settings::get('ticket_number_min_length', '4'));
return str_pad($this->number, $minLength, '0', STR_PAD_LEFT);
}
/**
* The value used when generating a URL for this ticket (route($name,
* $ticket)) mirrors formattedNumber() minus the prefix, so a link
* never shows the raw sequential number while the page itself shows an
* obfuscated one (or vice versa).
*/
public function getRouteKey()
{
return Settings::bool('ticket_number_obfuscate') ? ($this->checksum ?? $this->number) : $this->number;
}
/**
* Inbound counterpart to getRouteKey() resolves a URL segment back to
* a ticket via whichever column matches the current numbering mode.
*/
public function resolveRouteBinding($value, $field = null)
{
if ($field) {
return $this->where($field, $value)->first();
}
$column = Settings::bool('ticket_number_obfuscate') ? 'checksum' : 'number';
return $this->where($column, $value)->first();
}
/**
* A short, HMAC-derived checksum for this ticket, carrying no relation
* to creation order salted with the app key so it can't be predicted
* or reversed back into id/creation order without server-side secrets.
* Collisions are rare but not astronomically so at 6 digits, so this
* walks a nonce forward until it lands on a value no other ticket
* already has (enforced for real by the column's unique constraint).
*/
public static function generateUniqueChecksum(int $id): string
{
$nonce = 0;
do {
$hash = hash_hmac('sha256', $id.'|'.$nonce, (string) config('app.key'));
$candidate = (string) (hexdec(substr($hash, 0, 8)) % 900000 + 100000);
$nonce++;
} while (static::query()->where('checksum', $candidate)->exists());
return $candidate;
}
public function categoryLabel(): string
{
return $this->subcategory?->label() ?? '';
return $this->subcategory?->label() ?? $this->category?->name ?? '';
}
/**
@@ -167,6 +292,7 @@ class Ticket extends Model
}
$q->orWhere('number', 'like', $like)
->orWhere('checksum', 'like', $like)
->orWhere('name', 'like', $like)
->orWhere('email', 'like', $like)
->orWhereIn('id', $messageTicketIds);

View File

@@ -9,7 +9,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\HasOneThrough;
#[Fillable(['ticket_id', 'author_name', 'internal', 'body', 'edited', 'api_client_id', 'created_at', 'updated_at'])]
#[Fillable(['ticket_id', 'author_name', 'internal', 'body', 'edited', 'api_client_id', 'source', 'created_at', 'updated_at'])]
class TicketMessage extends Model
{
protected function casts(): array

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,13 +2,17 @@
namespace App\Providers;
use App\Events\NotificationCreated;
use App\Models\ApiClient;
use App\Models\User;
use App\Notifications\TicketNotification;
use App\Support\Settings;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Http\Request;
use Illuminate\Notifications\Events\NotificationSent;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\ServiceProvider;
@@ -35,12 +39,35 @@ class AppServiceProvider extends ServiceProvider
$this->applySessionSettingsOverride();
$this->applyTimezoneSettingsOverride();
$this->configureApiRateLimiting();
$this->broadcastBellNotifications();
// 'user' backs the polymorphic notifiable_type column on the
// database-notifications table (in-app notification bell).
Relation::enforceMorphMap(['api_client' => ApiClient::class, 'user' => User::class]);
}
/**
* A single choke point for realtime bell delivery hooks Laravel's own
* post-send event instead of threading a broadcast dispatch into every
* TicketService call site that creates a "database" notification
* (client leg, staff fan-out, and eventually the Trigger engine's
* send_notification action). $event->response is the DatabaseChannel's
* return value: the DatabaseNotification row that was just created,
* whose id is the same one the bell already reads.
*/
protected function broadcastBellNotifications(): void
{
Event::listen(NotificationSent::class, function (NotificationSent $event) {
if ($event->channel !== 'database' || ! $event->notification instanceof TicketNotification) {
return;
}
$data = $event->notification->toDatabase($event->notifiable);
NotificationCreated::dispatch($event->notifiable->id, $event->response->id, $data['message'], $data['url']);
});
}
/**
* API keys get a generous per-key budget; unauthenticated requests (which
* only ever hit the guard before rejecting with 401) get a much smaller
@@ -58,13 +85,21 @@ class AppServiceProvider extends ServiceProvider
}
/**
* Avoid touching the DB during artisan commands that run before the
* `settings` table exists (e.g. `migrate` itself), or before it can be
* queried at all shared by every settings-driven config override below.
* Avoid touching the DB during the specific artisan commands that run
* before the `settings` table exists or could be mid-schema-change (the
* migrate family) shared by every settings-driven config override
* below. Deliberately scoped to just those commands rather than "any
* console command": scheduled commands (`schedule:run` → e.g.
* `emails:fetch-imap`, `tickets:check-sla-breaches`) also run in the
* console and need the real SMTP/LDAP/timezone overrides exactly like a
* web request does, or their notifications/lookups silently fall back
* to whatever's in `.env` (this was a real bug: scheduled-command
* notifications were always going out via the `.env` `log` mailer
* instead of the configured SMTP server).
*/
protected function settingsTableUsable(): bool
{
if ($this->app->runningInConsole() && ! $this->app->runningUnitTests()) {
if ($this->app->runningConsoleCommand('migrate', 'migrate:fresh', 'migrate:refresh', 'migrate:reset', 'migrate:rollback', 'migrate:install')) {
return false;
}

View File

@@ -0,0 +1,282 @@
<?php
namespace App\Services;
use App\Models\ImapMailbox;
use App\Support\Imap\InboundEmail;
use App\Support\Settings;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Log;
use Psr\Log\LoggerInterface;
use Throwable;
use Webklex\PHPIMAP\Client;
use Webklex\PHPIMAP\ClientManager;
use Webklex\PHPIMAP\Message;
/**
* I/O layer for the "reply/create ticket by e-mail" feature connects to
* every enabled ImapMailbox, fetches unseen messages and delegates every
* decision to ImapMessageClassifier (pure logic) + TicketService (the
* existing ticket-mutation API). Kept thin and mostly untested directly;
* ImapMessageClassifier carries the actual test coverage.
*/
class ImapMailboxFetcher
{
private const HEADER_FIELDS = ['auto-submitted', 'x-autoreply', 'x-autorespond', 'precedence'];
public function __construct(
private readonly ImapMessageClassifier $classifier,
private readonly TicketService $tickets,
) {}
/**
* @return array{created: int, replied: int, rejected: int, errors: int}
*/
public function fetchAll(): array
{
$totals = ['created' => 0, 'replied' => 0, 'rejected' => 0, 'errors' => 0];
foreach (ImapMailbox::query()->where('enabled', true)->get() as $mailbox) {
foreach ($this->fetchMailbox($mailbox) as $key => $value) {
$totals[$key] += $value;
}
}
return $totals;
}
/**
* @return array{created: int, replied: int, rejected: int, errors: int}
*/
public function fetchMailbox(ImapMailbox $mailbox): array
{
$result = ['created' => 0, 'replied' => 0, 'rejected' => 0, 'errors' => 0];
$log = Log::channel('imap');
$log->info("[{$mailbox->name}] łączenie z {$mailbox->host}:{$mailbox->port} (folder: {$mailbox->folder})");
try {
$client = $this->connect($mailbox);
$folder = $client->getFolder($mailbox->folder ?: 'INBOX');
$messages = $folder->messages()->whereUnseen()->get();
$log->info("[{$mailbox->name}] {$messages->count()} nieprzeczytanych wiadomości");
foreach ($messages as $message) {
try {
$this->processMessage($mailbox, $message, $result, $log);
} catch (Throwable $e) {
$result['errors']++;
$log->error("[{$mailbox->name}] błąd przetwarzania wiadomości (uid={$message->getUid()}) — {$e->getMessage()}");
}
}
$client->disconnect();
$mailbox->update(['last_checked_at' => now(), 'last_error' => null]);
$log->info("[{$mailbox->name}] zakończono: {$result['created']} nowych, {$result['replied']} odpowiedzi, {$result['rejected']} odrzuconych, {$result['errors']} błędów");
} catch (Throwable $e) {
$result['errors']++;
$mailbox->update(['last_checked_at' => now(), 'last_error' => $e->getMessage()]);
$log->error("[{$mailbox->name}] połączenie nieudane — {$e->getMessage()}");
}
return $result;
}
/**
* Opens a connection and lists the configured folder, without fetching
* or touching any message used by the admin "Testuj połączenie" button.
* Returns null on success, the exception message on failure.
*/
public function testConnection(ImapMailbox $mailbox): ?string
{
try {
$client = $this->connect($mailbox);
$client->getFolder($mailbox->folder ?: 'INBOX');
$client->disconnect();
return null;
} catch (Throwable $e) {
return $e->getMessage();
}
}
private function connect(ImapMailbox $mailbox): Client
{
$manager = new ClientManager;
$client = $manager->make([
'host' => $mailbox->host,
'port' => $mailbox->port,
'protocol' => 'imap',
'encryption' => $mailbox->encryption === 'none' ? false : $mailbox->encryption,
'validate_cert' => $mailbox->validate_cert,
'username' => $mailbox->username,
'password' => $mailbox->password,
]);
$client->connect();
return $client;
}
/**
* @param array{created: int, replied: int, rejected: int, errors: int} $result
*/
private function processMessage(ImapMailbox $mailbox, Message $message, array &$result, LoggerInterface $log): void
{
$email = $this->toInboundEmail($message);
$uid = $message->getUid();
$log->debug("[{$mailbox->name}] uid={$uid} od={$email->fromEmail} temat=\"{$email->subject}\" nagłówki=".json_encode($email->headers, JSON_UNESCAPED_UNICODE));
$rejectReason = $this->classifier->rejectionReason($email, $mailbox->blocklistedSenders());
if ($rejectReason === null && ! $this->classifier->isSenderAllowed($email->fromEmail)) {
$rejectReason = "nadawca spoza LDAP ({$email->fromEmail}), a restrict_tickets_to_ldap jest włączone";
}
if ($rejectReason !== null) {
$this->finish($message, $mailbox->rejected_folder);
$result['rejected']++;
$log->info("[{$mailbox->name}] uid={$uid} ODRZUCONO od {$email->fromEmail} \"{$email->subject}\"{$rejectReason}");
return;
}
// Oznacz/przenieś PRZED utworzeniem ticketu: awaria w tym miejscu
// zostawia co najwyżej "przetworzoną" wiadomość bez ticketu (widoczne,
// łatwe do naprawienia ręcznie) zamiast duplikatu ticketu przy
// ponownym uruchomieniu.
$this->finish($message, $mailbox->processed_folder);
$ticket = $this->classifier->matchTicket($email->subject);
$sender = $this->classifier->resolveSender($email->fromEmail);
$attachments = $this->buildAttachments($email, $mailbox, $log);
$authorName = $email->fromName !== '' ? $email->fromName : $email->fromEmail;
if ($ticket) {
if ($sender) {
$this->tickets->clientReply($ticket, $sender, $email->body(), $attachments, source: 'email');
} else {
$this->tickets->guestReply($ticket, $authorName, $email->body(), $attachments, source: 'email');
}
$result['replied']++;
$log->info("[{$mailbox->name}] uid={$uid} ODPOWIEDŹ od {$email->fromEmail} dopisana do zgłoszenia #{$ticket->id} ({$ticket->displayNumber()})");
return;
}
$newTicket = $this->tickets->create([
'email' => $email->fromEmail,
'name' => $authorName,
'subcategory_id' => $mailbox->default_subcategory_id,
'category_id' => $mailbox->default_category_id,
'subject' => $email->subject !== '' ? $email->subject : '(bez tematu)',
'body' => $email->body(),
'source' => 'email',
], $sender, $authorName);
$result['created']++;
$log->info("[{$mailbox->name}] uid={$uid} NOWE zgłoszenie #{$newTicket->id} ({$newTicket->displayNumber()}) od {$email->fromEmail}");
}
private function finish(Message $message, ?string $moveToFolder): void
{
try {
$message->setFlag('Seen');
} catch (Throwable $e) {
Log::channel('imap')->warning("IMAP: nie udało się oznaczyć wiadomości jako przeczytanej — {$e->getMessage()}");
}
if ($moveToFolder) {
$message->move($moveToFolder);
}
}
private function toInboundEmail(Message $message): InboundEmail
{
$fromAddress = $message->getFrom()->first();
$header = $message->getHeader();
// Webklex's Header::get() returns an *empty* Attribute (not null)
// for a header that isn't present at all, and Attribute::first() on
// that empty instance comes back as '' rather than null — so a
// plain "!== null" check on the resulting value is always true,
// making every message look like it carries every one of these
// headers. Only keep a header that actually has content.
$headers = [];
foreach (self::HEADER_FIELDS as $name) {
$value = $header?->get($name)->first();
if ($value !== null && $value !== '') {
$headers[$name] = (string) $value;
}
}
return new InboundEmail(
fromEmail: $fromAddress?->mail ?? '',
fromName: $this->decodeHeaderText(trim((string) ($fromAddress?->personal ?? ''), '"')),
subject: $this->decodeHeaderText((string) $message->getSubject()),
textBody: (string) $message->getTextBody(),
htmlBody: (string) $message->getHTMLBody(),
headers: $headers,
attachments: $this->extractAttachments($message),
);
}
/**
* Some senders' mail clients leave the Subject/From display-name as raw
* RFC 2047 encoded-words (e.g. "=?utf-8?Q?...?=") instead of the
* decoded UTF-8 webklex's own config claims to produce decode
* defensively rather than showing garbled text on the ticket.
*/
private function decodeHeaderText(string $value): string
{
return $value !== '' ? mb_decode_mimeheader($value) : $value;
}
/**
* @return array<int, array{filename: string, mime: string, content: string}>
*/
private function extractAttachments(Message $message): array
{
$attachments = [];
foreach ($message->getAttachments() as $attachment) {
$attachments[] = [
'filename' => $attachment->getName() ?: 'attachment',
'mime' => $attachment->getMimeType() ?: 'application/octet-stream',
'content' => $attachment->getContent(),
];
}
return $attachments;
}
/**
* Converts raw attachment bytes into UploadedFile instances (via a temp
* file + the $test=true flag, which lets Symfony's UploadedFile skip the
* is_uploaded_file() check outside of a real HTTP request) so they flow
* through TicketService::attachFiles() unchanged. Validated the same way
* every other caller validates before calling attachFiles() a mail
* carrying an oversized/disallowed attachment still creates the
* ticket/reply, just without that attachment, rather than being dropped
* entirely or silently bypassing the admin's attachment policy.
*
* @return UploadedFile[]
*/
private function buildAttachments(InboundEmail $email, ImapMailbox $mailbox, LoggerInterface $log): array
{
$files = [];
foreach ($email->attachments as $attachment) {
$path = tempnam(sys_get_temp_dir(), 'imap_');
file_put_contents($path, $attachment['content']);
$files[] = new UploadedFile($path, $attachment['filename'], $attachment['mime'], null, true);
}
if ($files && ($error = Settings::validateAttachments($files))) {
$log->warning("[{$mailbox->name}] pominięto załączniki wiadomości od {$email->fromEmail}{$error}");
return [];
}
return $files;
}
}

View File

@@ -0,0 +1,137 @@
<?php
namespace App\Services;
use App\Models\Ticket;
use App\Models\User;
use App\Support\Imap\InboundEmail;
use App\Support\Settings;
/**
* Pure decision logic for the IMAP fetcher no IMAP connection, no
* side effects, so it's fully Pest-testable against hand-built
* InboundEmail instances. ImapMailboxFetcher does all the I/O and calls
* into this for every decision.
*/
class ImapMessageClassifier
{
/**
* RFC 3834 (Auto-Submitted) + common vendor headers, plus EN/PL subject
* phrasing for autoresponders/bounces that don't set those headers at
* all the two layers catch most real-world autoresponders/mailer-daemons.
*/
private const AUTO_REPLY_SUBJECT_PATTERNS = [
'/\bout of office\b/i',
'/\bautomatic reply\b/i',
'/\bautomatyczna odpowiedz\b/iu',
'/\bautoresponder\b/i',
'/\bundeliverable\b/i',
'/\bundelivered\b/i',
'/\bmail delivery failed\b/i',
'/\bdelivery status notification\b/i',
'/\bnieobecnosc\b.*\bbiurze\b/iu',
];
/**
* Returns a human-readable rejection reason, or null if the message
* should be processed as a genuine ticket/reply.
*
* @param string[] $extraBlocklist additional blocked sender local-parts/addresses (per-mailbox)
*/
public function rejectionReason(InboundEmail $email, array $extraBlocklist = []): ?string
{
$autoSubmitted = strtolower((string) $email->header('auto-submitted'));
if ($autoSubmitted !== '' && $autoSubmitted !== 'no') {
return "Auto-Submitted: {$autoSubmitted}";
}
if ($email->header('x-autoreply') !== null || $email->header('x-autorespond') !== null) {
return 'X-Autoreply/X-Autorespond header present';
}
$precedence = strtolower((string) $email->header('precedence'));
if (in_array($precedence, ['bulk', 'junk', 'list'], true)) {
return "Precedence: {$precedence}";
}
$senderLocalPart = strtolower(explode('@', $email->fromEmail)[0] ?? '');
$blocked = array_map('strtolower', $extraBlocklist);
if ($senderLocalPart !== '' && in_array($senderLocalPart, $blocked, true)) {
return "Blocked sender: {$email->fromEmail}";
}
if (in_array(strtolower($email->fromEmail), $blocked, true)) {
return "Blocked sender: {$email->fromEmail}";
}
foreach (self::AUTO_REPLY_SUBJECT_PATTERNS as $pattern) {
if (preg_match($pattern, $email->subject) === 1) {
return "Subject matched auto-reply pattern ({$pattern})";
}
}
return null;
}
/**
* Same gate Landing::submit() applies to web/guest ticket creation
* (Settings::bool('restrict_tickets_to_ldap')) must apply identically
* to mail-originated tickets/replies, or the restriction has a hole.
*/
public function isSenderAllowed(string $email): bool
{
if (! Settings::bool('restrict_tickets_to_ldap')) {
return true;
}
return User::query()->where('email', $email)->exists()
|| app(LdapUserProvisioner::class)->existsInLdap($email);
}
/**
* Existing local user, or an LDAP-provisioned one if enabled mirrors
* TicketService::create()'s own guest-resolution branch. Returns null
* for a genuine, unprovisionable guest.
*/
public function resolveSender(string $email): ?User
{
if ($user = User::query()->where('email', $email)->first()) {
return $user;
}
if (Settings::bool('ldap_auto_provision_guests')) {
return app(LdapUserProvisioner::class)->findOrCreateByEmail($email);
}
return null;
}
/**
* Strips common reply/forward prefixes, then tries every digit run of
* length >= 4 (longest first) against Ticket::resolveRouteBinding()
* covers both the plain sequential number and the obfuscated checksum,
* since both are plain digit strings and every outbound notification
* subject already carries one (see database/seeders/DatabaseSeeder.php).
* Prefix-aware matching was considered and rejected: {numer} email
* templates hardcode their own literal '#', independent of the
* admin-configurable ticket_number_prefix setting, and templates are
* themselves admin-editable.
*/
public function matchTicket(string $subject): ?Ticket
{
$cleaned = preg_replace('/^\s*(re|odp|fwd|fw|aw)\s*:\s*/i', '', $subject) ?? $subject;
$cleaned = preg_replace('/^\s*(re|odp|fwd|fw|aw)\s*:\s*/i', '', $cleaned) ?? $cleaned;
preg_match_all('/\d{4,}/', $cleaned, $matches);
$tokens = $matches[0] ?? [];
usort($tokens, fn ($a, $b) => strlen($b) <=> strlen($a));
foreach ($tokens as $token) {
$ticket = (new Ticket)->resolveRouteBinding($token);
if ($ticket) {
return $ticket;
}
}
return null;
}
}

View File

@@ -2,7 +2,10 @@
namespace App\Services;
use App\Events\TicketMessagePosted;
use App\Events\TicketQueueChanged;
use App\Models\ApiClient;
use App\Models\NotificationPreference;
use App\Models\NotificationSetting;
use App\Models\Priority;
use App\Models\Status;
@@ -14,6 +17,7 @@ use App\Models\User;
use App\Notifications\TicketNotification;
use App\Support\Settings;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Storage;
@@ -38,6 +42,10 @@ class TicketService
'email' => $customer?->email ?? $data['email'],
'name' => $customer?->name ?? ($data['name'] ?? $data['email']),
'subcategory_id' => $subcategory?->id,
// category_id only ever carries a value when there's no
// subcategory to derive one from (e.g. an IMAP mailbox routed to
// a whole category rather than a specific subcategory).
'category_id' => $subcategory ? null : ($data['category_id'] ?? null),
'subject' => $data['subject'],
'body' => $data['body'],
'status_key' => Settings::get('default_status', 'new'),
@@ -45,6 +53,8 @@ class TicketService
'team_id' => $this->autoAssignTeam($subcategory),
'assignee_id' => $data['assignee_id'] ?? null,
'custom_fields' => $data['custom_values'] ?? [],
'last_customer_activity_at' => now(),
'source' => $data['source'] ?? 'web',
]);
$message = $ticket->messages()->create([
@@ -54,6 +64,9 @@ class TicketService
$message->attachAuthor($customer?->id, 'client');
$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;
}
@@ -87,9 +100,17 @@ class TicketService
// the running segment (if any) the moment a ticket is closed,
// regardless of which flow triggered the status change.
$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 {
$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
@@ -113,6 +134,9 @@ class TicketService
$ticket->update(['priority_key' => $priorityKey]);
$ticket->addHistory('Priorytet zmieniony na: '.Priority::labelFor($priorityKey));
$this->notify($ticket, 'priority_changed');
app(TriggerEngine::class)->handle($ticket, 'priority_changed');
app(TriggerEngine::class)->handle($ticket, 'ticket_updated');
TicketQueueChanged::dispatch($ticket->id, 'priority_changed', Auth::id());
}
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->addHistory('Przypisano do: '.($assignee?->name ?? 'Nieprzypisane'));
$this->notify($ticket, 'assignee_changed');
app(TriggerEngine::class)->handle($ticket, 'assignee_changed');
app(TriggerEngine::class)->handle($ticket, 'ticket_updated');
TicketQueueChanged::dispatch($ticket->id, 'assignee_changed', Auth::id());
}
public function setTeam(Ticket $ticket, ?Team $team): void
@@ -129,6 +156,9 @@ class TicketService
$ticket->update(['team_id' => $team?->id]);
$ticket->addHistory('Zespół zmieniony na: '.($team?->name ?? 'Brak'));
$this->notify($ticket, 'team_changed');
app(TriggerEngine::class)->handle($ticket, 'team_changed');
app(TriggerEngine::class)->handle($ticket, 'ticket_updated');
TicketQueueChanged::dispatch($ticket->id, 'team_changed', Auth::id());
}
public function setReporter(Ticket $ticket, User $customer): void
@@ -151,7 +181,10 @@ class TicketService
if ($categoryChanged) {
$this->notify($ticket, 'category_changed');
app(TriggerEngine::class)->handle($ticket, 'category_changed');
}
app(TriggerEngine::class)->handle($ticket, 'ticket_updated');
}
public function operatorReply(Ticket $ticket, User $operator, string $body, ?string $statusAfter = null, array $attachments = []): void
@@ -164,6 +197,9 @@ class TicketService
$ticket->touch();
$this->attachFiles($ticket, $message, $attachments);
$this->notify($ticket, 'operator_replied');
app(TriggerEngine::class)->handle($ticket, 'comment_added');
TicketMessagePosted::dispatch($ticket->id, $message->id, false, $operator->id);
TicketQueueChanged::dispatch($ticket->id, 'message_posted', $operator->id);
if ($statusAfter) {
$this->setStatus($ticket, $statusAfter);
@@ -179,17 +215,76 @@ class TicketService
]);
$message->attachAuthor($operator->id, 'operator');
$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([
'author_name' => $client->name,
'body' => $body,
'source' => $source === 'web' ? null : $source,
]);
$message->attachAuthor($client->id, 'client');
$ticket->touch();
$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 +307,12 @@ class TicketService
if (! $internal) {
$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;
}
@@ -265,7 +364,7 @@ class TicketService
$primary->messages()->create([
'author_name' => 'System',
'body' => 'Scalono zgłoszenia: '.$others->map(fn (Ticket $o) => '#'.$o->number)->implode(', '),
'body' => 'Scalono zgłoszenia: '.$others->map(fn (Ticket $o) => $o->displayNumber())->implode(', '),
]);
foreach ($others as $other) {
@@ -283,23 +382,50 @@ class TicketService
$note = $other->messages()->create([
'author_name' => 'System',
'internal' => true,
'body' => 'Scalone ze zgłoszeniem #'.$primary->number,
'body' => 'Scalone ze zgłoszeniem '.$primary->displayNumber(),
]);
$note->attachAuthor(null, 'operator');
TicketQueueChanged::dispatch($other->id, 'merged', Auth::id());
}
$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
* event raised from within this service) can trigger the same way.
*
* Routes through the recipient's own User model (so it lands in the
* in-app notification bell in addition to e-mail) whenever one exists;
* falls back to an anonymous mail-only route for a guest customer with
* no account. One shared NotificationSetting.enabled flag gates both
* channels there's no separate in-app on/off switch.
* NotificationSetting.enabled is the global kill switch, layered above
* every per-user preference below disabling a trigger here silences
* both legs regardless of what any individual staff member configured;
* the personal matrix can only narrow within an enabled trigger, never
* widen past it.
*
* Sends exactly one notification to the trigger's fixed
* NotificationSetting.recipient (a client, or the ticket's single
* assignee) exactly as before, then for triggers mapped in
* STAFF_EVENT_MAP additionally fans out to every other operator/admin
* whose own notification preferences put this ticket in scope.
*/
public function notify(Ticket $ticket, string $triggerKey): void
{
@@ -310,20 +436,93 @@ class TicketService
}
$notifiable = $setting->recipient === 'operator' ? $ticket->assignee : $ticket->customer;
$fallbackEmail = $setting->recipient === 'operator' ? $ticket->assignee?->email : $ticket->email;
$this->deliverTicketNotification($ticket, $setting->recipient, $setting->email_template_id, notifiable: $notifiable, fallbackEmail: $fallbackEmail);
if ($category = self::STAFF_EVENT_MAP[$triggerKey] ?? null) {
$this->notifyStaffForCategory($ticket, $category, $setting->email_template_id, skip: $notifiable);
}
}
/**
* Notifies every operator/admin whose personal notification preferences
* (see NotificationPreference) put this ticket into one of their chosen
* scopes for $category "Wszystkie zgłoszenia" deliberately reuses the
* existing Ticket::isVisibleToOperator() ACL rather than meaning
* literally every ticket, so it naturally stays within a non-admin
* operator's own team(s) + unrouted tickets. $skip excludes whoever
* notify() already notified directly via the fixed recipient (so an
* assignee with scope_mine enabled doesn't get the same event twice),
* and the acting user is always excluded so nobody gets notified about
* their own action.
*/
protected function notifyStaffForCategory(Ticket $ticket, string $category, int $templateId, ?User $skip = null): void
{
$staff = User::query()->whereHas('roleAssignments', fn ($q) => $q->whereIn('key', ['operator', 'admin']))->get();
foreach ($staff as $user) {
if ($user->id === Auth::id() || ($skip && $user->id === $skip->id)) {
continue;
}
$pref = NotificationPreference::rowFor($user, $category);
$inScope = ($pref['scope_mine'] && $ticket->assignee_id === $user->id)
|| ($pref['scope_unassigned'] && $ticket->assignee_id === null)
|| ($pref['scope_watched'] && $ticket->isWatchedBy($user))
|| ($pref['scope_all'] && $ticket->isVisibleToOperator($user));
if (! $inScope) {
continue;
}
$this->deliverTicketNotification($ticket, 'operator', $templateId, $pref['email'] ? ['mail', 'database'] : ['database'], notifiable: $user);
}
}
/**
* Entry point for the Trigger engine's send_notification action (see
* TriggerEngine) an admin-authored, explicit business action, not one
* of the fixed system lifecycle events, so unlike notify() it doesn't
* consult NotificationSetting or any per-user preference; it always
* sends both mail and bell, same as the original unconditional
* TicketNotification behaviour.
*/
public function sendCustomNotification(Ticket $ticket, string $recipient, int $templateId): void
{
$notifiable = $recipient === 'operator' ? $ticket->assignee : $ticket->customer;
$fallbackEmail = $recipient === 'operator' ? $ticket->assignee?->email : $ticket->email;
$this->deliverTicketNotification($ticket, $recipient, $templateId, notifiable: $notifiable, fallbackEmail: $fallbackEmail, templateSource: 'trigger_email_template');
}
/**
* Shared by notify()'s fixed-recipient leg, notifyStaffForCategory()'s
* per-user fan-out, and sendCustomNotification(). $notifiable, when
* given a real User, always wins over $fallbackEmail the fallback
* only exists for a guest customer with no account, where the
* "database" (bell) channel has nothing to attach to, so
* TicketNotification::via() drops it to mail-only anyway.
*/
private function deliverTicketNotification(
Ticket $ticket,
string $recipientRole,
int $templateId,
array $channels = ['mail', 'database'],
?User $notifiable = null,
?string $fallbackEmail = null,
string $templateSource = 'email_template',
): void {
if ($notifiable) {
$notifiable->notify(new TicketNotification($ticket, $setting->email_template_id, $setting->recipient));
$notifiable->notify(new TicketNotification($ticket, $templateId, $recipientRole, $channels, $templateSource));
return;
}
$email = $setting->recipient === 'operator' ? $ticket->assignee?->email : $ticket->email;
if (! $email) {
return;
}
Notification::route('mail', $email)
->notify(new TicketNotification($ticket, $setting->email_template_id, $setting->recipient));
if ($fallbackEmail) {
Notification::route('mail', $fallbackEmail)
->notify(new TicketNotification($ticket, $templateId, $recipientRole, $channels, $templateSource));
}
}
}

View File

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

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Support\Imap;
/**
* Normalized view of one inbound message, independent of the IMAP client
* library the seam between ImapMailboxFetcher (I/O, effectively
* untestable without a real mailbox) and ImapMessageClassifier (pure
* decision logic, fully Pest-testable against hand-built instances).
*/
class InboundEmail
{
/**
* @param array<string, string> $headers lower-cased header names
* @param array<int, array{filename: string, mime: string, content: string}> $attachments
*/
public function __construct(
public readonly string $fromEmail,
public readonly string $fromName,
public readonly string $subject,
public readonly string $textBody,
public readonly string $htmlBody,
public readonly array $headers,
public readonly array $attachments = [],
) {}
/**
* Treats an empty string the same as an absent header the IMAP
* library backing ImapMailboxFetcher represents "header not present" as
* an empty value rather than a missing array key in some cases, so
* callers checking `header($x) !== null` alone would otherwise
* misdetect every message as carrying every header.
*/
public function header(string $name): ?string
{
$value = $this->headers[strtolower($name)] ?? null;
return $value !== null && $value !== '' ? $value : null;
}
public function body(): string
{
if (trim($this->textBody) !== '') {
return $this->textBody;
}
return trim(html_entity_decode(strip_tags($this->htmlBody)));
}
}

View File

@@ -22,6 +22,9 @@ class Settings
'attachment_allowed_types' => 'jpg,jpeg,png,pdf,doc,docx,xls,xlsx,zip,txt',
'session_lifetime_minutes' => '120',
'timezone' => 'UTC',
'ticket_number_prefix' => '#',
'ticket_number_obfuscate' => '0',
'ticket_number_min_length' => '4',
'ldap_enabled' => '1',
'ldap_host' => '',
'ldap_port' => '389',

View File

@@ -1,18 +1,21 @@
<?php
use App\Http\Middleware\EnsureRole;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
use Laravel\Sanctum\Http\Middleware\CheckAbilities;
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
channels: __DIR__.'/../routes/channels.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
@@ -43,4 +46,29 @@ return Application::configure(basePath: dirname(__DIR__))
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*'),
);
// A ticket deleted mid-session (typically by the operator/client
// currently viewing it) leaves any later request for that same
// {ticket} route binding 404ing — most commonly Livewire's own
// "model missing during hydration" recovery, which does a full
// window.location.reload() of the very page whose ticket just
// disappeared (e.g. the ticket-show view's periodic fallback
// refresh polling a few seconds after a delete+redirect). Land back
// on that area's own list page instead of a raw 404.
//
// Handler::prepareException() already converts ModelNotFoundException
// into NotFoundHttpException (wrapping the original as getPrevious())
// before any render() callback is dispatched — a callback typed
// against ModelNotFoundException itself would simply never match.
$exceptions->render(function (NotFoundHttpException $e, Request $request) {
if (! $e->getPrevious() instanceof ModelNotFoundException || ! $request->user()) {
return null;
}
return match (true) {
$request->is('operator/*') => redirect()->route('operator.queue'),
$request->is('client/*') => redirect()->route('client.dashboard'),
default => null,
};
});
})->create();

View File

@@ -13,9 +13,11 @@
"darkaonline/l5-swagger": "*",
"directorytree/ldaprecord-laravel": "*",
"laravel/framework": "^13.8",
"laravel/reverb": "*",
"laravel/sanctum": "*",
"laravel/tinker": "^3.0",
"livewire/livewire": "*"
"livewire/livewire": "*",
"webklex/php-imap": "*"
},
"require-dev": {
"fakerphp/faker": "^1.23",

988
src/composer.lock generated

File diff suppressed because it is too large Load Diff

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

View File

@@ -73,6 +73,19 @@ return [
'replace_placeholders' => true,
],
// Dedicated, always-verbose channel for the IMAP fetcher
// (emails:fetch-imap) — kept separate from 'single'/LOG_LEVEL so a
// production app typically running at LOG_LEVEL=error still gets
// full visibility into what the fetcher did on every run, without
// that verbosity going into the main laravel.log.
'imap' => [
'driver' => 'daily',
'path' => storage_path('logs/imap.log'),
'level' => 'debug',
'days' => 14,
'replace_placeholders' => true,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),

102
src/config/reverb.php Normal file
View 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),
],
],
],
],
];

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 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');
}
};

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 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');
}
};

View File

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

View File

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

View File

@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('ticket_watchers', function (Blueprint $table) {
$table->id();
$table->foreignId('ticket_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->timestamps();
$table->unique(['ticket_id', 'user_id']);
});
}
public function down(): void
{
Schema::dropIfExists('ticket_watchers');
}
};

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('tickets', function (Blueprint $table) {
$table->string('checksum', 20)->nullable()->unique()->after('number');
});
// Backfill: every existing ticket gets a stable, HMAC-derived
// checksum (mirrors Ticket::generateUniqueChecksum()) so the
// "hide ticket order" numbering mode has a real, unique, indexed
// column to resolve ticket URLs against instead of only being a
// display-time computation.
$assigned = [];
DB::table('tickets')->orderBy('id')->select('id')->chunkById(500, function ($tickets) use (&$assigned) {
foreach ($tickets as $ticket) {
$nonce = 0;
do {
$hash = hash_hmac('sha256', $ticket->id.'|'.$nonce, (string) config('app.key'));
$candidate = (string) (hexdec(substr($hash, 0, 8)) % 900000 + 100000);
$nonce++;
} while (isset($assigned[$candidate]));
$assigned[$candidate] = true;
DB::table('tickets')->where('id', $ticket->id)->update(['checksum' => $candidate]);
}
});
}
public function down(): void
{
Schema::table('tickets', function (Blueprint $table) {
$table->dropColumn('checksum');
});
}
};

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('imap_mailboxes', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->boolean('enabled')->default(false);
$table->string('host');
$table->unsignedSmallInteger('port')->default(993);
$table->string('encryption')->default('ssl');
$table->boolean('validate_cert')->default(true);
$table->string('username');
$table->text('password')->nullable();
$table->string('folder')->default('INBOX');
$table->string('processed_folder')->nullable();
$table->string('rejected_folder')->nullable();
$table->foreignId('default_subcategory_id')->nullable()->constrained('subcategories')->nullOnDelete();
$table->string('blocklist_senders')->default('mailer-daemon,postmaster,no-reply,noreply');
$table->timestamp('last_checked_at')->nullable();
$table->text('last_error')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('imap_mailboxes');
}
};

View File

@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* category_id lets a ticket carry just a Category with no specific
* Subcategory (e.g. an IMAP mailbox routed to "całą kategorię" rather
* than one subcategory) subcategory_id already implies a category via
* its own relation, so category_id is only ever populated when there's
* no subcategory to derive it from (see Ticket::categoryLabel()).
*
* source records how the ticket was created (web/e-mail/...), surfaced
* as a badge in the operator queue/ticket view.
*/
public function up(): void
{
Schema::table('tickets', function (Blueprint $table) {
$table->foreignId('category_id')->nullable()->after('subcategory_id')->constrained('categories')->nullOnDelete();
$table->string('source')->default('web')->after('api_client_id');
});
Schema::table('imap_mailboxes', function (Blueprint $table) {
$table->foreignId('default_category_id')->nullable()->after('default_subcategory_id')->constrained('categories')->nullOnDelete();
});
}
public function down(): void
{
Schema::table('imap_mailboxes', function (Blueprint $table) {
$table->dropConstrainedForeignId('default_category_id');
});
Schema::table('tickets', function (Blueprint $table) {
$table->dropConstrainedForeignId('category_id');
$table->dropColumn('source');
});
}
};

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Mirrors tickets.source at the individual-message level a ticket
* created on the web can still later receive a reply by e-mail (or vice
* versa), so this needs tracking per message, not just per ticket.
* Null means "web" (the original/default channel); only IMAP-originated
* messages ever set it to 'email'.
*/
public function up(): void
{
Schema::table('ticket_messages', function (Blueprint $table) {
$table->string('source')->nullable()->after('api_client_id');
});
}
public function down(): void
{
Schema::table('ticket_messages', function (Blueprint $table) {
$table->dropColumn('source');
});
}
};

View File

@@ -55,7 +55,7 @@ class DatabaseSeeder extends Seeder
['key' => 'operator', 'label' => 'Operator'],
['key' => 'admin', 'label' => 'Administrator'],
] as $role) {
Role::query()->create($role);
Role::query()->firstOrCreate(['key' => $role['key']], $role);
}
}
@@ -259,6 +259,11 @@ class DatabaseSeeder extends Seeder
{
foreach ([
['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],
] as $action) {
ReplyQuickAction::query()->create($action);
@@ -335,13 +340,17 @@ class DatabaseSeeder extends Seeder
'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,
],
'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 = [];
foreach ($templates as $key => $tpl) {
$ids[$key] = EmailTemplate::query()->create([
'key' => $key,
$ids[$key] = EmailTemplate::query()->firstOrCreate(['key' => $key], [
'name' => $tpl['name'],
'trigger_label' => $tpl['trigger_label'],
'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' => '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' => 'ticket_created_team', 'trigger_label' => 'Nowe zgłoszenie w zespole (powiadom operatorów)', 'enabled' => true, 'recipient' => 'operator', 'template' => 'tpl-team-new-ticket'],
] as $setting) {
NotificationSetting::query()->create([
'trigger_key' => $setting['trigger_key'],
NotificationSetting::query()->firstOrCreate(['trigger_key' => $setting['trigger_key']], [
'trigger_label' => $setting['trigger_label'],
'enabled' => $setting['enabled'],
'recipient' => $setting['recipient'],

40
src/package-lock.json generated
View File

@@ -4,6 +4,10 @@
"requires": true,
"packages": {
"": {
"dependencies": {
"laravel-echo": "^2.1.0",
"pusher-js": "^8.4.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"concurrently": "^9.0.1",
@@ -909,6 +913,27 @@
"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": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.1.3.tgz",
@@ -1275,6 +1300,15 @@
"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": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -1451,6 +1485,12 @@
"dev": true,
"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": {
"version": "8.1.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",

View File

@@ -6,6 +6,10 @@
"build": "vite build",
"dev": "vite"
},
"dependencies": {
"laravel-echo": "^2.1.0",
"pusher-js": "^8.4.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"concurrently": "^9.0.1",

View File

@@ -343,6 +343,22 @@ body {
.page-pad { padding: 16px !important; }
.nav { padding-left: 14px !important; padding-right: 14px !important; gap: 10px; }
.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; }
.main-col { min-width: 0; }
.aside-col { width: 100%; }

View File

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

View File

@@ -2,19 +2,8 @@
@php
$url = \Illuminate\Support\Facades\Storage::disk('public')->url($attachment->path);
$isImage = \Illuminate\Support\Str::startsWith($attachment->mime ?? '', 'image/');
@endphp
@if ($isImage)
<a href="{{ $url }}" target="_blank" style="display:block;margin-top:8px">
<img
src="{{ $url }}"
alt="{{ $attachment->original_name }}"
loading="lazy"
style="max-width:220px;max-height:160px;border-radius:8px;border:1px solid var(--color-divider);object-fit:cover;cursor:zoom-in;display:block"
>
</a>
@else
<a
href="{{ $url }}"
target="_blank"
@@ -22,4 +11,3 @@
>
<span class="material-symbols-outlined" style="font-size:15px">attach_file</span>{{ $attachment->original_name }}
</a>
@endif

View File

@@ -16,7 +16,7 @@
@endphp
@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">
<span class="material-symbols-outlined" style="font-size:18px">account_circle</span>
<span class="profile-menu-name">{{ $user->name }}</span>
@@ -25,6 +25,7 @@
<div
x-show="open"
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"
>
@foreach ($areas as $area)
@@ -41,6 +42,18 @@
<div style="border-top:1px solid var(--color-divider)"></div>
@endif
@if ($user && ($user->isOperator() || $user->isAdmin()))
<a
href="{{ route('settings.notifications') }}"
wire:navigate
@click="open = false"
class="theme-toggle-option"
style="text-decoration:none;color:{{ request()->routeIs('settings.*') ? 'var(--color-accent)' : 'var(--color-text)' }};font-size:12.5px"
>Powiadomienia</a>
<div style="border-top:1px solid var(--color-divider)"></div>
@endif
<a
href="{{ route('logout') }}"
onclick="event.preventDefault(); document.getElementById('profile-menu-logout-form').submit();"

View File

@@ -12,6 +12,7 @@
}
}"
@click.outside="open = false"
class="nav-dropdown-wrap"
style="position:relative;display:inline-block"
>
<button type="button" class="btn btn-secondary btn-icon" @click="open = !open">
@@ -20,6 +21,7 @@
<div
x-show="open"
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"
>
<button type="button" class="theme-toggle-option" @click="apply('light')">

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8">
<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>
<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; }
</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'])
<style>:root{--color-accent: {{ \App\Support\Settings::accentColor() }};}</style>
@livewireStyles

View File

@@ -0,0 +1,182 @@
<div>
<h3 style="margin:0 0 14px">E-mail (SMTP)</h3>
<form wire:submit="saveMailConfig" class="card" style="padding:20px;gap:14px;max-width:480px;margin-bottom:32px">
<div class="field"><label>Adres nadawcy</label><input class="input" type="email" placeholder="wsparcie@firma.pl" wire:model="mailConfig.fromAddress"></div>
<div class="field"><label>Nazwa nadawcy</label><input class="input" placeholder="Zespół Wsparcia" wire:model="mailConfig.fromName"></div>
<div class="hr"></div>
<label class="radio"><input type="checkbox" wire:model="mailConfig.smtpEnabled" style="position:static;opacity:1;width:auto;height:auto"><strong>Włącz wysyłkę przez własny serwer SMTP</strong></label>
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Bez włączenia aplikacja wysyła pocztę zgodnie z konfiguracją środowiska (.env).</span>
@if ($mailConfig['smtpEnabled'])
<div class="field"><label>Host SMTP</label><input class="input" placeholder="smtp.example.com" wire:model="mailConfig.smtpHost"></div>
<div style="display:flex;gap:10px">
<div class="field" style="flex:1"><label>Port</label><input class="input" type="number" placeholder="587" wire:model="mailConfig.smtpPort"></div>
<div class="field" style="flex:1">
<label>Szyfrowanie</label>
<select class="input" wire:model="mailConfig.smtpEncryption">
<option value="none">Brak</option>
<option value="tls">STARTTLS</option>
<option value="ssl">SSL/TLS</option>
</select>
</div>
</div>
<div class="field"><label>Użytkownik</label><input class="input" wire:model="mailConfig.smtpUsername"></div>
<div class="field"><label>Hasło</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="mailConfig.smtpPassword"></div>
<div style="display:flex;gap:10px;margin-top:8px;align-items:center;flex-wrap:wrap">
<button type="button" class="btn btn-secondary" wire:click="testMailConnection">Wyślij testową wiadomość</button>
<button type="submit" class="btn btn-primary">Zapisz</button>
@if ($mailTestResult === 'ok')
<div style="display:flex;align-items:center;gap:6px;color:var(--color-success)"><span class="material-symbols-outlined" style="font-size:18px">check_circle</span>Wysłano na Twój adres</div>
@elseif ($mailTestResult === 'error')
<div style="display:flex;align-items:center;gap:6px;color:var(--color-danger)"><span class="material-symbols-outlined" style="font-size:18px">error</span>Błąd wysyłki</div>
@endif
</div>
@else
<button type="submit" class="btn btn-primary" style="align-self:flex-start">Zapisz</button>
@endif
</form>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
<h3 style="margin:0">Skrzynki IMAP (zgłoszenia i odpowiedzi przez e-mail)</h3>
<button class="btn btn-primary" type="button" wire:click="openMailboxForm">+ Nowa skrzynka</button>
</div>
<p class="text-muted" style="font-size:12.5px;margin:0 0 14px">
Każda skrzynka jest sprawdzana co kilka minut nowa wiadomość zakłada zgłoszenie w wybranej podkategorii (np. zgloszenia-it@firma.pl IT), a odpowiedź na powiadomienie e-mail (temat zawiera numer zgłoszenia) trafia jako odpowiedź do istniejącego zgłoszenia. Automatyczne odpowiedzi (autorespondery, „poza biurem”, bounce) odrzucane.
</p>
@if ($this->mailboxes->isNotEmpty())
<div class="table-wrap" style="margin-bottom:20px">
<table class="table">
<thead>
<tr>
<th>Nazwa</th>
<th>Serwer</th>
<th>Użytkownik</th>
<th>Kategoria / podkategoria</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach ($this->mailboxes as $mailbox)
<tr>
<td>{{ $mailbox->name }}</td>
<td class="text-muted" style="white-space:nowrap">{{ $mailbox->host }}:{{ $mailbox->port }}</td>
<td class="text-muted">{{ $mailbox->username }}</td>
<td class="text-muted">{{ $mailbox->targetLabel() }}</td>
<td>
<button type="button" class="tag" style="border:none;cursor:pointer;background:color-mix(in srgb, var(--color-{{ $mailbox->enabled ? 'success' : 'danger' }}) 18%, transparent);color:var(--color-{{ $mailbox->enabled ? 'success' : 'danger' }})"
wire:click="toggleMailboxEnabled({{ $mailbox->id }})">
{{ $mailbox->enabled ? 'Włączona' : 'Wyłączona' }}
</button>
@if ($mailbox->last_error)
<div style="color:var(--color-danger);font-size:11px;margin-top:4px">{{ $mailbox->last_error }}</div>
@elseif ($mailbox->last_checked_at)
<div class="text-muted" style="font-size:11px;margin-top:4px">Sprawdzono: {{ $mailbox->last_checked_at->format('Y-m-d H:i') }}</div>
@endif
@if ($mailboxFetchResultId === $mailbox->id)
<div class="text-muted" style="font-size:11px;margin-top:4px">{{ $mailboxFetchSummary }}</div>
@endif
</td>
<td>
<div style="display:flex;gap:6px;justify-content:flex-end">
<button class="btn btn-ghost" type="button" wire:click="fetchMailboxNow({{ $mailbox->id }})" wire:loading.attr="disabled" wire:target="fetchMailboxNow({{ $mailbox->id }})">Pobierz teraz</button>
<button class="btn btn-ghost" type="button" wire:click="editMailbox({{ $mailbox->id }})">Edytuj</button>
<button class="btn btn-ghost" type="button" wire:click="removeMailbox({{ $mailbox->id }})" wire:confirm="Usunąć tę skrzynkę IMAP?">Usuń</button>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<p class="text-muted" style="font-size:13px">Brak skonfigurowanych skrzynek IMAP. Dodaj pierwszą używając przycisku wyżej.</p>
@endif
@if ($mailboxFormOpen)
<div class="dialog-backdrop">
<form wire:submit="submitMailboxForm" class="dialog" style="max-width:520px;max-height:90vh;overflow-y:auto">
<div class="dialog-title">{{ $mailboxForm['id'] ? 'Edytuj skrzynkę IMAP' : 'Nowa skrzynka IMAP' }}</div>
<div class="field">
<label>Nazwa (etykieta)</label>
<input class="input" placeholder="np. Zgłoszenia IT" wire:model="mailboxForm.name">
</div>
@error('mailboxForm.name') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
<label class="radio"><input type="checkbox" wire:model="mailboxForm.enabled" style="position:static;opacity:1;width:auto;height:auto">Włączona</label>
<div class="hr"></div>
<div class="field"><label>Host IMAP</label><input class="input" placeholder="imap.firma.pl" wire:model="mailboxForm.host"></div>
@error('mailboxForm.host') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
<div style="display:flex;gap:10px">
<div class="field" style="flex:1"><label>Port</label><input class="input" type="number" wire:model="mailboxForm.port"></div>
<div class="field" style="flex:1">
<label>Szyfrowanie</label>
<select class="input" wire:model="mailboxForm.encryption">
<option value="ssl">SSL/TLS</option>
<option value="tls">STARTTLS</option>
<option value="none">Brak</option>
</select>
</div>
</div>
<label class="radio"><input type="checkbox" wire:model="mailboxForm.validateCert" style="position:static;opacity:1;width:auto;height:auto">Weryfikuj certyfikat TLS</label>
<div class="field"><label>Adres skrzynki (login)</label><input class="input" placeholder="zgloszenia-it@firma.pl" wire:model="mailboxForm.username"></div>
@error('mailboxForm.username') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
<div class="field"><label>Hasło</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="mailboxForm.password"></div>
<div class="hr"></div>
<div class="field">
<label>Kategoria / podkategoria nowych zgłoszeń</label>
<select class="input" wire:model="mailboxForm.target">
<option value="">Brak (zgłoszenie nieprzypisane)</option>
@foreach ($this->categoryOptions as $category)
<optgroup label="{{ $category['name'] }}">
<option value="category:{{ $category['id'] }}">Cała kategoria: {{ $category['name'] }}</option>
@foreach ($category['subcategories'] as $sub)
<option value="subcategory:{{ $sub['id'] }}">{{ $sub['name'] }}</option>
@endforeach
</optgroup>
@endforeach
</select>
<span class="text-muted" style="font-size:11.5px">Wybierz konkretną podkategorię (trafi też do jej zespołu) albo całą kategorię, jeśli nie chcesz przypisywać konkretnej podkategorii.</span>
</div>
<div class="field"><label>Folder</label><input class="input" wire:model="mailboxForm.folder"></div>
<div style="display:flex;gap:10px">
<div class="field" style="flex:1"><label>Folder po przetworzeniu (opcjonalnie)</label><input class="input" placeholder="pozostaw puste = oznacz jako przeczytane" wire:model="mailboxForm.processedFolder"></div>
<div class="field" style="flex:1"><label>Folder odrzuconych (opcjonalnie)</label><input class="input" placeholder="pozostaw puste = oznacz jako przeczytane" wire:model="mailboxForm.rejectedFolder"></div>
</div>
<div class="field">
<label>Zablokowani nadawcy (dodatkowo do filtrów autoresponderów)</label>
<input class="input" wire:model="mailboxForm.blocklistSenders">
</div>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:4px">
<button type="button" class="btn btn-secondary" wire:click="testMailboxConnection">Testuj połączenie</button>
@if ($mailboxTestResult === 'ok')
<div style="display:flex;align-items:center;gap:6px;color:var(--color-success)"><span class="material-symbols-outlined" style="font-size:18px">check_circle</span>Połączono</div>
@elseif ($mailboxTestResult === 'error')
<div style="display:flex;align-items:center;gap:6px;color:var(--color-danger);font-size:12.5px"><span class="material-symbols-outlined" style="font-size:18px">error</span>{{ $mailboxTestMessage }}</div>
@endif
</div>
<div class="dialog-actions">
<button class="btn btn-secondary" type="button" wire:click="closeMailboxForm">Anuluj</button>
<button class="btn btn-primary" type="submit">Zapisz</button>
</div>
</form>
</div>
@endif
</div>

View File

@@ -7,6 +7,8 @@ $tabGroups = [
['key' => 'priorities', 'label' => 'Priorytety i SLA', 'icon' => 'priority_high'],
['key' => 'reply-quick-actions', 'label' => 'Szybkie akcje odpowiedzi', 'icon' => 'bolt'],
['key' => 'response-templates', 'label' => 'Szablony odpowiedzi', 'icon' => 'chat'],
['key' => 'automation-rules', 'label' => 'Automatyzacja SLA', 'icon' => 'bolt'],
['key' => 'triggers', 'label' => 'Wyzwalacze', 'icon' => 'rule'],
],
'Zespół' => [
['key' => 'users', 'label' => 'Użytkownicy', 'icon' => 'group'],
@@ -15,8 +17,10 @@ $tabGroups = [
],
'Ustawienia' => [
['key' => 'templates', 'label' => 'Szablony e-mail', 'icon' => 'mail'],
['key' => 'email', 'label' => 'Poczta', 'icon' => 'forward_to_inbox'],
['key' => 'branding', 'label' => 'Wygląd i branding', 'icon' => 'palette'],
['key' => 'config', 'label' => 'Konfiguracja', 'icon' => 'settings'],
['key' => 'integrations', 'label' => 'Integracje', 'icon' => 'hub'],
['key' => 'api-keys', 'label' => 'Klucze API', 'icon' => 'vpn_key'],
['key' => 'about', 'label' => 'O aplikacji', 'icon' => 'info'],
],
@@ -322,6 +326,44 @@ $tabGroups = [
@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 &ge; {{ $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 ================= --}}
@if ($tab === 'statuses')
<h3 style="margin:0 0 6px">Statusy</h3>
@@ -401,6 +443,27 @@ $tabGroups = [
{{-- ================= EMAIL TEMPLATES ================= --}}
@if ($tab === 'templates')
<h3 style="margin:0 0 6px">Powiadomienia e-mail</h3>
<p class="text-muted" style="font-size:12.5px;margin:0 0 14px">Każde zdarzenie ma stały, przypisany na stałe szablon możesz go dowolnie edytować, ale nie zmienić na inny. Wyłącz przełącznik, żeby dana wiadomość nigdy nie była wysyłana.</p>
<div class="table-wrap">
<table class="table">
<thead><tr><th>Zdarzenie</th><th>Odbiorca</th><th>Wysyłane</th><th></th></tr></thead>
<tbody>
@foreach ($this->notificationSettings as $ns)
<tr>
<td style="white-space:nowrap">{{ $ns->trigger_label }}</td>
<td><span class="tag tag-outline">{{ $ns->recipient === 'operator' ? 'Operator' : 'Zgłaszający' }}</span></td>
<td><input type="checkbox" @checked($ns->enabled) wire:click="toggleNotificationEnabled({{ $ns->id }})"></td>
<td><button type="button" class="btn btn-ghost" wire:click="editEmailTemplate({{ $ns->email_template_id }})">Edytuj szablon</button></td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
{{-- ================= E-MAIL ================= --}}
@if ($tab === 'email')
<h3 style="margin:0 0 6px">Wygląd wiadomości e-mail</h3>
<p class="text-muted" style="font-size:12.5px;margin:0 0 14px">Każde powiadomienie wysyłane jest w stałym „pudełku” (nazwa firmy, ramka, treść zdarzenia) tu edytujesz tylko jego stopkę. Po prawej podgląd na żywo na przykładowym zgłoszeniu.</p>
@@ -420,23 +483,7 @@ $tabGroups = [
</div>
</div>
<h3 style="margin:0 0 6px">Powiadomienia e-mail</h3>
<p class="text-muted" style="font-size:12.5px;margin:0 0 14px">Każde zdarzenie ma stały, przypisany na stałe szablon możesz go dowolnie edytować, ale nie zmienić na inny. Wyłącz przełącznik, żeby dana wiadomość nigdy nie była wysyłana.</p>
<div class="table-wrap">
<table class="table">
<thead><tr><th>Zdarzenie</th><th>Odbiorca</th><th>Wysyłane</th><th></th></tr></thead>
<tbody>
@foreach ($this->notificationSettings as $ns)
<tr>
<td style="white-space:nowrap">{{ $ns->trigger_label }}</td>
<td><span class="tag tag-outline">{{ $ns->recipient === 'operator' ? 'Operator' : 'Zgłaszający' }}</span></td>
<td><input type="checkbox" @checked($ns->enabled) wire:click="toggleNotificationEnabled({{ $ns->id }})"></td>
<td><button type="button" class="btn btn-ghost" wire:click="editEmailTemplate({{ $ns->email_template_id }})">Edytuj szablon</button></td>
</tr>
@endforeach
</tbody>
</table>
</div>
<livewire:admin.mail-settings />
@endif
{{-- ================= BRANDING ================= --}}
@@ -532,6 +579,21 @@ $tabGroups = [
</select>
</div>
<label class="radio"><input type="checkbox" wire:model="systemConfig.autoAssignByCategory" style="position:static;opacity:1;width:auto;height:auto">Automatyczne przypisywanie do zespołu wg kategorii</label>
<div class="hr"></div>
<div class="field">
<label>Prefiks numeru zgłoszenia</label>
<input class="input" maxlength="20" placeholder="#" wire:model.live="systemConfig.ticketNumberPrefix">
</div>
<div class="field">
<label>Minimalna długość numeru (uzupełniana zerami z przodu)</label>
<input class="input" type="number" min="1" max="10" wire:model.live="systemConfig.ticketNumberMinLength">
</div>
<label class="radio"><input type="checkbox" wire:model.live="systemConfig.ticketNumberObfuscate" style="position:static;opacity:1;width:auto;height:auto">Ukryj kolejność zgłoszeń (numer wyświetlany jako suma kontrolna zamiast kolejnego numeru)</label>
<div class="text-muted" style="font-size:12px">
ID z bazy: {{ $this->ticketNumberPreview['id'] }} &rarr; podgląd numeru: {{ $this->ticketNumberPreview['formatted'] }}
</div>
</div>
<div class="card" style="padding:20px;gap:14px">
@@ -581,46 +643,14 @@ $tabGroups = [
</div>
</form>
<form wire:submit="saveMailConfig" class="card" style="padding:20px;gap:14px">
<h4 style="margin:0">E-mail (SMTP)</h4>
<div class="field"><label>Adres nadawcy</label><input class="input" type="email" placeholder="wsparcie@firma.pl" wire:model="mailConfig.fromAddress"></div>
<div class="field"><label>Nazwa nadawcy</label><input class="input" placeholder="Zespół Wsparcia" wire:model="mailConfig.fromName"></div>
<p class="text-muted" style="font-size:11.5px;margin:-4px 0 0">Stopka wiadomości e-mail edytowana jest w zakładce „Szablony e-mail”.</p>
<div 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>
{{-- ================= INTEGRATIONS ================= --}}
@if ($tab === 'integrations')
<h3 style="margin:0 0 16px">Integracje</h3>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:20px;align-items:start">
<form wire:submit="saveLdapConfig" class="card" style="padding:20px;gap:14px">
<h4 style="margin:0">LDAP / Active Directory</h4>
@@ -803,6 +833,96 @@ $tabGroups = [
</div>
@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)
<div class="dialog-backdrop">
<div class="dialog" style="max-width:560px">

View File

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

View File

@@ -19,7 +19,7 @@
@foreach (($tab === 'current' ? $this->currentTickets : $this->archiveTickets) as $ticket)
<a href="{{ route('client.ticket', $ticket) }}" wire:navigate class="card elev-sm" style="padding:16px;cursor:pointer;flex-direction:row;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;text-decoration:none;color:inherit">
<div>
<div style="font-weight:500">#{{ $ticket->number }} — {{ $ticket->subject }}</div>
<div style="font-weight:500">{{ $ticket->displayNumber() }} {{ $ticket->subject }}</div>
<div class="card-meta">{{ $ticket->categoryLabel() }} &middot; {{ \App\Support\Rel::format($ticket->updated_at) }}</div>
</div>
<div style="display:flex;gap:6px">

View File

@@ -60,7 +60,9 @@
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="backToSubcategory">Zmień</button>
</div>
<div wire:init="loadSuggestedArticles">
<x-bookstack-suggestions :articles="$this->suggestedArticles" />
</div>
<div class="field">
<label>Temat</label>

View File

@@ -1,8 +1,24 @@
<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:20px;max-width:920px;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">&larr; Wróć do listy</a>
<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">
<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">&larr; 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. --}}
<div
class="btn btn-secondary"
style="cursor:default;gap:6px"
x-data="{ remaining: 30, total: 30 }"
x-init="setInterval(() => { remaining = remaining <= 1 ? total : remaining - 1; if (remaining === total) $wire.refreshTicketData(); }, 1000)"
title="Zgłoszenie odświeża się automatycznie"
>
<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 class="main-col" style="display:flex;flex-direction:column;gap:16px">
@@ -12,7 +28,7 @@
@endphp
<div class="card" style="padding:22px;gap:10px">
<div class="card-kicker">Zgłoszenie #{{ $ticket->number }}</div>
<div class="card-kicker">Zgłoszenie {{ $ticket->displayNumber() }}</div>
<h2 style="margin:2px 0 0">{{ $ticket->subject }}</h2>
<div class="card-meta">{{ $ticket->categoryLabel() }} &middot; utworzono {{ \App\Support\Rel::format($ticket->created_at) }}</div>
<div style="white-space:pre-wrap;font-size:14px;margin-top:4px">{{ $ticket->body }}</div>
@@ -31,7 +47,7 @@
<div style="display:flex;flex-direction:column;gap:10px">
@foreach ($threadMessages as $m)
@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="display:flex;justify-content:space-between;align-items:flex-start;gap:10px">
<div style="font-size:11px;opacity:0.65;margin-bottom:4px">{{ $m->author_name }} &middot; {{ \App\Support\Rel::format($m->created_at) }}{{ $m->edited ? ' · edytowano' : '' }}</div>
@@ -94,12 +110,18 @@
<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>
<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-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->statusStyle() }}">{{ $ticket->statusLabel() }}</span>
</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())
<button type="button" class="btn btn-secondary btn-block" wire:click="close">Zamknij zgłoszenie</button>
@elseif ($ticket->isClosed())
@@ -137,7 +159,7 @@
<div class="card" style="padding:16px;gap:8px">
<div class="card-kicker">Historia zmian</div>
@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
<p class="text-muted" style="font-size:12px;margin:0">Brak historii zmian.</p>
@endforelse
@@ -146,7 +168,7 @@
<div class="card-kicker">Inne Twoje zgłoszenia</div>
@forelse ($this->otherTickets as $ot)
<a href="{{ route('client.ticket', $ot) }}" wire:navigate style="display:flex;justify-content:space-between;align-items:center;gap:8px;cursor:pointer;text-decoration:none;color:inherit">
<span style="font-size:13px">#{{ $ot->number }} — {{ $ot->subject }}</span>
<span style="font-size:13px">{{ $ot->displayNumber() }} {{ $ot->subject }}</span>
<span style="{{ $ot->statusStyle() }};flex:none">{{ $ot->statusLabel() }}</span>
</a>
@empty
@@ -169,4 +191,19 @@
</div>
</div>
@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>

View File

@@ -9,11 +9,11 @@
@if ($this->submittedTicket)
<div class="card elev-md" style="padding:32px;gap:14px;text-align:left">
<span class="tag tag-accent" style="align-self:flex-start">Zgłoszenie przyjęte</span>
<h2 style="margin:0">Zgłoszenie #{{ $this->submittedTicket->number }} zostało utworzone</h2>
<h2 style="margin:0">Zgłoszenie {{ $this->submittedTicket->displayNumber() }} zostało utworzone</h2>
<p class="text-muted" style="margin:0">Zapisz numer zgłoszenia i adres e-mail będziesz mógł/mogła sprawdzić status, kontaktując się z zespołem wsparcia. Aktualizacje będziemy wysyłać na Twój adres e-mail.</p>
<div class="hr"></div>
<div style="display:flex;flex-direction:column;gap:4px;font-size:14px">
<div><strong>Numer zgłoszenia:</strong> #{{ $this->submittedTicket->number }}</div>
<div><strong>Numer zgłoszenia:</strong> {{ $this->submittedTicket->displayNumber() }}</div>
<div><strong>Temat:</strong> {{ $this->submittedTicket->subject }}</div>
<div><strong>Kategoria:</strong> {{ $this->submittedTicket->categoryLabel() }}</div>
<div><strong>Zgłaszający:</strong> {{ $this->submittedTicket->email }}</div>
@@ -90,7 +90,9 @@
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="backToSubcategory">Zmień</button>
</div>
<div wire:init="loadSuggestedArticles">
<x-bookstack-suggestions :articles="$this->suggestedArticles" />
</div>
<div class="field">
<label>Temat</label>

View File

@@ -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.30s="$refresh">
<button type="button" class="btn btn-secondary" @click="open = !open" style="position:relative;display:flex;align-items:center;gap:0;padding:8px">
<span class="material-symbols-outlined" style="font-size:18px">notifications</span>
@if ($this->unreadCount)
@@ -9,6 +9,7 @@
<div
x-show="open"
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"
>
<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)
<a
wire:key="notification-{{ $notification->id }}"
href="{{ $notification->data['url'] ?? '#' }}"
wire:navigate
wire:click="markAsRead('{{ $notification->id }}')"
@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 style="font-size:11px;color:color-mix(in srgb, var(--color-text) 55%, transparent);margin-top:2px">{{ $notification->created_at->diffForHumans() }}</div>

View File

@@ -66,7 +66,9 @@
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="backToSubcategory">Zmień</button>
</div>
<div wire:init="loadSuggestedArticles">
<x-bookstack-suggestions :articles="$this->suggestedArticles" />
</div>
<div class="field">
<label>Temat</label>

View File

@@ -116,13 +116,28 @@
@endforeach
</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. --}}
<div
class="btn btn-secondary"
style="cursor:default;gap:6px"
x-data="{ remaining: 60, total: 60 }"
x-init="setInterval(() => { remaining = remaining <= 1 ? total : remaining - 1; if (remaining === total) $wire.refreshQueue(); }, 1000)"
title="Kolejka odświeża się automatycznie co minutę"
>
<span class="material-symbols-outlined" style="font-size:18px">schedule</span>
<span x-text="remaining + 's'"></span>
</div>
</div>
<div class="table-wrap">
<table class="table table-cards-mobile">
<thead>
<tr>
<th></th>
<th><input type="checkbox" @checked($this->filteredTickets->isNotEmpty() && empty($this->filteredTickets->pluck('id')->diff($selectedIds)->all())) wire:click="toggleSelectAll" title="Zaznacz wszystkie"></th>
@foreach ($columnDefs as $key => $label)
@continue(! in_array($key, $visibleColumns))
<th>
@@ -143,10 +158,15 @@
<tbody>
@foreach ($this->filteredTickets as $t)
@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>
@if (in_array('number', $visibleColumns))
<td data-label="Numer" class="td-title"><a href="{{ route('operator.ticket', $t) }}" wire:navigate style="color:inherit;text-decoration:none;cursor:pointer">{{ $t->number }}</a></td>
<td data-label="Numer" class="td-title">
<a href="{{ route('operator.ticket', $t) }}" wire:navigate style="color:inherit;text-decoration:none;cursor:pointer">{{ $t->displayNumber() }}</a>
@if ($t->source === 'email')
<span class="material-symbols-outlined" style="font-size:15px;vertical-align:-3px;opacity:0.7" title="Utworzone przez e-mail">mail</span>
@endif
</td>
@endif
@if (in_array('subject', $visibleColumns))
<td data-label="Temat" class="td-title"><a href="{{ route('operator.ticket', $t) }}" wire:navigate style="color:inherit;text-decoration:none;cursor:pointer;white-space:nowrap">{{ $t->subject }}</a></td>
@@ -157,6 +177,9 @@
@if (in_array('category', $visibleColumns))
<td data-label="Kategoria" style="white-space:nowrap">{{ $t->categoryLabel() }}</td>
@endif
@if (in_array('subcategory', $visibleColumns))
<td data-label="Podkategoria" style="white-space:nowrap">{{ $t->subcategory?->name ?? '—' }}</td>
@endif
@if (in_array('priority', $visibleColumns))
<td data-label="Priorytet"><span style="{{ $t->priorityStyle() }}">{{ $t->priorityLabel() }}</span></td>
@endif
@@ -169,6 +192,12 @@
@if (in_array('assignee', $visibleColumns))
<td data-label="Przypisany" style="white-space:nowrap">{{ $t->assignee?->name ?? 'Nieprzypisane' }}</td>
@endif
@if (in_array('team', $visibleColumns))
<td data-label="Zespół" style="white-space:nowrap">{{ $t->team?->name ?? '—' }}</td>
@endif
@if (in_array('created', $visibleColumns))
<td data-label="Utworzono" style="white-space:nowrap">{{ \App\Support\Rel::format($t->created_at) }}</td>
@endif
</tr>
@endforeach
</tbody>

View File

@@ -61,6 +61,8 @@
{{-- KPI tiles --}}
@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 class="stat-tile">
<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>
</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">
{{-- By status --}}
<div class="card" style="padding:16px">
<div class="card-title" style="margin-bottom:12px">Zgłoszenia wg statusu</div>
@php($max = max($this->byStatus->max('count'), 1))
@@ -113,7 +118,6 @@
@endforelse
</div>
{{-- By priority --}}
<div class="card" style="padding:16px">
<div class="card-title" style="margin-bottom:12px">Zgłoszenia wg priorytetu</div>
@php($max = max($this->byPriority->max('count'), 1))
@@ -128,7 +132,6 @@
@endforelse
</div>
{{-- By category --}}
<div class="card" style="padding:16px">
<div class="card-title" style="margin-bottom:12px">Zgłoszenia wg kategorii</div>
@php($cats = $this->byCategory)
@@ -144,7 +147,27 @@
@endforelse
</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-title" style="margin-bottom:12px">Obciążenie zespołów</div>
@php($teamRows = $this->byTeam)
@@ -160,7 +183,6 @@
@endforelse
</div>
{{-- By assignee --}}
<div class="card" style="padding:16px">
<div class="card-title" style="margin-bottom:12px">Obciążenie operatorów</div>
@php($opRows = $this->byAssignee)
@@ -176,11 +198,109 @@
@endforelse
</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 --}}
@php($trend = $this->trend)
@php($trendMax = max(collect($trend)->max('created'), collect($trend)->max('closed'), 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-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>
@@ -225,3 +345,4 @@
</div>
</div>
</div>
</div>

View File

@@ -3,13 +3,47 @@
<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">
<a href="{{ route('operator.queue') }}" wire:navigate class="btn btn-ghost" style="align-self:flex-start;padding:0">&larr; 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">&larr; Wróć do listy</a>
<button
type="button"
class="btn btn-secondary"
style="gap:6px"
wire:click="toggleWatch"
title="{{ $this->isWatching ? 'Przestań obserwować to zgłoszenie' : 'Obserwuj to zgłoszenie' }}"
>
<span class="material-symbols-outlined" style="font-size:18px">{{ $this->isWatching ? 'star' : 'star_outline' }}</span>
{{ $this->isWatching ? 'Obserwowane' : 'Obserwuj' }}
</button>
{{-- Live updates arrive via broadcasting, but websocket connections can
drop silently this is a periodic fallback refresh, with a visible
countdown so it's clear the thread is still refreshing on its own. --}}
<div
class="btn btn-secondary"
style="cursor:default;gap:6px"
x-data="{ remaining: 30, total: 30 }"
x-init="setInterval(() => { remaining = remaining <= 1 ? total : remaining - 1; if (remaining === total) $wire.refreshTicketData(); }, 1000)"
title="Zgłoszenie odświeża się automatycznie"
>
<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 class="main-col" style="display:flex;flex-direction:column;gap:16px">
<div class="card" style="padding:20px;gap:8px">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">
<div class="card-kicker">Zgłoszenie #{{ $ticket->number }}</div>
<div style="display:flex;align-items:center;gap:8px">
<div class="card-kicker">Zgłoszenie {{ $ticket->displayNumber() }}</div>
@if ($ticket->source === 'email')
<span class="tag tag-outline" style="display:inline-flex;align-items:center;gap:3px;font-size:10.5px" title="Utworzone przez e-mail">
<span class="material-symbols-outlined" style="font-size:13px">mail</span>E-mail
</span>
@endif
</div>
@unless ($editingDetails)
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="toggleEditDetails">Edytuj</button>
@endunless
@@ -67,7 +101,7 @@
<div class="card" style="padding:16px;gap:10px">
<div class="card-kicker">Notatki wewnętrzne</div>
@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="font-size:11px;opacity:0.65;margin-bottom:4px">{{ $m->author_name }} &middot; {{ \App\Support\Rel::format($m->created_at) }}</div>
@if ($m->role === 'operator')
@@ -130,10 +164,15 @@
<div style="display:flex;flex-direction:column;gap:10px">
@foreach ($threadMessages as $m)
@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="display:flex;justify-content:space-between;align-items:flex-start;gap:10px">
<div style="font-size:11px;opacity:0.65;margin-bottom:4px">{{ $m->author_name }} &middot; {{ \App\Support\Rel::format($m->created_at) }}{{ $m->edited ? ' · edytowano' : '' }}</div>
<div style="font-size:11px;opacity:0.65;margin-bottom:4px;display:flex;align-items:center;gap:4px">
@if ($m->source === 'email')
<span class="material-symbols-outlined" style="font-size:13px" title="Odebrane e-mailem">mail</span>
@endif
{{ $m->author_name }} &middot; {{ \App\Support\Rel::format($m->created_at) }}{{ $m->edited ? ' · edytowano' : '' }}
</div>
@if ($m->role === 'operator')
<div style="display:flex;gap:6px;flex:none">
<span class="material-symbols-outlined" style="font-size:15px;cursor:pointer;opacity:0.7" wire:click="startEditMessage({{ $m->id }}, @js($m->body))">edit</span>
@@ -286,7 +325,9 @@
</div>
</div>
<div wire:init="loadSuggestedArticles">
<x-bookstack-suggestions :articles="$this->suggestedArticles" variant="sidebar" title="Baza wiedzy" :show-copy="true" />
</div>
<div class="card" style="padding:16px;gap:8px">
<div class="card-kicker">SLA</div>
@@ -427,7 +468,7 @@
<div class="dialog-backdrop">
<div class="dialog" style="max-width:400px">
<div class="dialog-title">Potwierdź usunięcie</div>
<div class="dialog-body">Czy na pewno usunąć zgłoszenie #{{ $ticket->number }}?</div>
<div class="dialog-body">Czy na pewno usunąć zgłoszenie {{ $ticket->displayNumber() }}?</div>
<div class="dialog-actions">
<button type="button" class="btn btn-secondary" wire:click="cancelDeleteTicket">Anuluj</button>
<button type="button" class="btn btn-primary" wire:click="confirmDeleteTicket">Usuń</button>
@@ -435,4 +476,19 @@
</div>
</div>
@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>

View File

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

View File

@@ -12,14 +12,14 @@ use Illuminate\Support\Facades\Route;
Route::prefix('v1')->middleware('throttle:api')->group(function () {
Route::middleware(['auth:sanctum', 'abilities:tickets:read'])->group(function () {
Route::get('/tickets', [TicketController::class, 'index']);
Route::get('/tickets/{ticket}', [TicketController::class, 'show']);
Route::get('/tickets/{ticket}/messages', [TicketMessageController::class, 'index']);
Route::get('/tickets/{ticket:id}', [TicketController::class, 'show']);
Route::get('/tickets/{ticket:id}/messages', [TicketMessageController::class, 'index']);
});
Route::middleware(['auth:sanctum', 'abilities:tickets:write'])->group(function () {
Route::post('/tickets', [TicketController::class, 'store']);
Route::patch('/tickets/{ticket}', [TicketController::class, 'update']);
Route::post('/tickets/{ticket}/messages', [TicketMessageController::class, 'store']);
Route::patch('/tickets/{ticket:id}', [TicketController::class, 'update']);
Route::post('/tickets/{ticket:id}/messages', [TicketMessageController::class, 'store']);
});
Route::middleware(['auth:sanctum', 'abilities:dictionaries:read'])->group(function () {

47
src/routes/channels.php Normal file
View 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;
});

View File

@@ -9,3 +9,5 @@ Artisan::command('inspire', function () {
})->purpose('Display an inspiring quote');
Schedule::command('tickets:check-sla-breaches')->everyFifteenMinutes();
Schedule::command('automation:run-rules')->everyFifteenMinutes();
Schedule::command('emails:fetch-imap')->everyFiveMinutes()->withoutOverlapping();

View File

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

View File

@@ -0,0 +1,172 @@
<?php
use App\Models\AutomationRule;
use App\Models\Priority;
use App\Models\Team;
use App\Models\User;
use App\Services\TicketService;
use Illuminate\Support\Facades\Artisan;
test('a rule fires once a ticket has been silent past its threshold and applies its action', function () {
seedStatusesAndPriorities();
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
$ticket = makeTicket(['created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
$rule = AutomationRule::query()->create([
'label' => 'Podnieś priorytet po 60 min',
'enabled' => true,
'condition_minutes' => 60,
'action_type' => 'change_priority',
'action_value' => 'low',
]);
Artisan::call('automation:run-rules');
expect($ticket->fresh()->priority_key)->toBe('low');
expect($rule->fresh()->hasFiredFor($ticket->fresh()))->toBeTrue();
// TicketService::setPriority() writes its own "Priorytet zmieniony na: ..."
// history line; RunAutomationRules adds a second, clearly-attributed one.
expect($ticket->fresh()->histories()->count())->toBe(2);
});
test('a rule does not fire again on the same ticket once already latched', function () {
seedStatusesAndPriorities();
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
$ticket = makeTicket(['created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
AutomationRule::query()->create([
'label' => 'Podnieś priorytet po 60 min',
'enabled' => true,
'condition_minutes' => 60,
'action_type' => 'change_priority',
'action_value' => 'low',
]);
Artisan::call('automation:run-rules');
$ticket->refresh()->update(['priority_key' => 'high']); // simulate an operator manually reverting it
Artisan::call('automation:run-rules');
expect($ticket->fresh()->priority_key)->toBe('high');
});
test('a fresh client reply resets the latch so the rule can fire again after new silence', function () {
seedStatusesAndPriorities();
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
$ticket = makeTicket(['created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
$client = User::query()->create(['name' => 'Klient', 'email' => 'client-auto@example.com', 'roles' => ['client']]);
AutomationRule::query()->create([
'label' => 'Podnieś priorytet po 60 min',
'enabled' => true,
'condition_minutes' => 60,
'action_type' => 'change_priority',
'action_value' => 'low',
]);
Artisan::call('automation:run-rules');
expect($ticket->fresh()->priority_key)->toBe('low');
app(TicketService::class)->clientReply($ticket->fresh(), $client, 'Nadal potrzebuję pomocy');
$ticket->refresh()->update(['last_customer_activity_at' => now()->subMinutes(90), 'priority_key' => 'high']);
Artisan::call('automation:run-rules');
expect($ticket->fresh()->priority_key)->toBe('low');
});
test('a rule scoped to a priority ignores tickets with a different priority', function () {
seedStatusesAndPriorities();
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
$ticket = makeTicket(['priority_key' => 'low', 'created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
AutomationRule::query()->create([
'label' => 'Tylko dla wysokiego priorytetu',
'enabled' => true,
'condition_minutes' => 60,
'scope_priority_key' => 'high',
'action_type' => 'change_priority',
'action_value' => 'low',
]);
Artisan::call('automation:run-rules');
expect($ticket->fresh()->priority_key)->toBe('low');
expect($ticket->fresh()->histories()->count())->toBe(0);
});
test('a disabled rule never fires', function () {
seedStatusesAndPriorities();
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
$ticket = makeTicket(['created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
AutomationRule::query()->create([
'label' => 'Wyłączona reguła',
'enabled' => false,
'condition_minutes' => 60,
'action_type' => 'change_priority',
'action_value' => 'low',
]);
Artisan::call('automation:run-rules');
expect($ticket->fresh()->priority_key)->toBe('high');
});
test('a closed ticket is never matched even if it has been silent long enough', function () {
seedStatusesAndPriorities();
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
$ticket = makeTicket(['status_key' => 'closed', 'created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
AutomationRule::query()->create([
'label' => 'Podnieś priorytet po 60 min',
'enabled' => true,
'condition_minutes' => 60,
'action_type' => 'change_priority',
'action_value' => 'low',
]);
Artisan::call('automation:run-rules');
expect($ticket->fresh()->priority_key)->toBe('high');
});
test('two independent rules can both fire on the same ticket in one run', function () {
seedStatusesAndPriorities();
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
$team = Team::query()->create(['name' => 'Wsparcie L2']);
$ticket = makeTicket(['created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
AutomationRule::query()->create([
'label' => 'Podnieś priorytet',
'enabled' => true,
'condition_minutes' => 60,
'action_type' => 'change_priority',
'action_value' => 'low',
]);
AutomationRule::query()->create([
'label' => 'Przekaż do L2',
'enabled' => true,
'condition_minutes' => 60,
'action_type' => 'change_team',
'action_value' => (string) $team->id,
]);
Artisan::call('automation:run-rules');
$ticket->refresh();
expect($ticket->priority_key)->toBe('low');
expect($ticket->team_id)->toBe($team->id);
expect($ticket->histories()->count())->toBe(4);
});
test('reopening a closed ticket clears its automation logs so rules can fire again', function () {
seedStatusesAndPriorities();
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
$ticket = makeTicket(['created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
$rule = AutomationRule::query()->create([
'label' => 'Podnieś priorytet',
'enabled' => true,
'condition_minutes' => 60,
'action_type' => 'change_priority',
'action_value' => 'low',
]);
Artisan::call('automation:run-rules');
expect($rule->logs()->count())->toBe(1);
app(TicketService::class)->setStatus($ticket->fresh(), 'closed');
expect($rule->logs()->count())->toBe(0);
});

View File

@@ -0,0 +1,31 @@
<?php
use App\Models\User;
test('visiting a deleted ticket as an operator redirects to the operator queue instead of 404ing', function () {
seedStatusesAndPriorities();
$operator = User::query()->create(['name' => 'Op', 'email' => 'op@example.com', 'roles' => ['operator']]);
$ticket = makeTicket();
$id = $ticket->id;
$ticket->delete();
$this->actingAs($operator)
->get("/operator/tickets/{$id}")
->assertRedirect(route('operator.queue'));
});
test('visiting a deleted ticket as a client redirects to the client dashboard instead of 404ing', function () {
seedStatusesAndPriorities();
$client = User::query()->create(['name' => 'Klient', 'email' => 'klient@example.com', 'roles' => ['client']]);
$ticket = makeTicket(['customer_id' => $client->id]);
$id = $ticket->id;
$ticket->delete();
$this->actingAs($client)
->get("/client/tickets/{$id}")
->assertRedirect(route('client.dashboard'));
});
test('a guest hitting a non-existent ticket route still gets the normal (non-redirected) handling', function () {
$this->get('/operator/tickets/999999')->assertRedirect(route('login'));
});

View File

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

View File

@@ -3,13 +3,15 @@
use App\Livewire\Admin\Panel;
use App\Models\EmailTemplate;
use App\Models\NotificationSetting;
use App\Models\User;
use App\Notifications\TicketNotification;
use App\Services\TicketService;
use Illuminate\Support\Facades\Notification;
use Livewire\Livewire;
test('admin can toggle a notification on/off but cannot add, delete, or reassign templates', function () {
$admin = adminUser();
$this->seed();
$admin = User::query()->withRole('admin')->firstOrFail();
$statusChanged = NotificationSetting::query()->where('trigger_key', 'status_changed')->firstOrFail();
Livewire::actingAs($admin)->test(Panel::class)
@@ -26,10 +28,11 @@ test('admin can toggle a notification on/off but cannot add, delete, or reassign
});
test('admin can edit the subject and body of a trigger\'s fixed template', function () {
$admin = adminUser();
$this->seed();
$admin = User::query()->withRole('admin')->firstOrFail();
// A bare migrated (unseeded) database has no email_templates rows yet, so
// ticket_created's binding is still null at this point — give it one.
// Repoint ticket_created's NotificationSetting at a template we control,
// instead of the real seeded one, so this test's assertions are its own.
$template = EmailTemplate::query()->create(['key' => 'tpl-created-test', 'name' => 'Nowe', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'B']);
$ticketCreated = NotificationSetting::query()->where('trigger_key', 'ticket_created')->firstOrFail();
$ticketCreated->update(['email_template_id' => $template->id]);
@@ -50,7 +53,7 @@ test('admin can edit the subject and body of a trigger\'s fixed template', funct
test('a disabled trigger sends no notification, an enabled one sends the assigned template with a working ticket link', function () {
Notification::fake();
seedStatusesAndPriorities();
$this->seed();
$template = EmailTemplate::query()->create([
'key' => 'tpl-new-test', 'name' => 'Nowe', 'trigger_label' => 'x',

View File

@@ -2,6 +2,7 @@
use App\Models\Category;
use App\Models\EmailTemplate;
use App\Models\NotificationPreference;
use App\Models\NotificationSetting;
use App\Models\Team;
use App\Models\User;
@@ -9,19 +10,21 @@ use App\Notifications\TicketNotification;
use App\Services\TicketService;
use Illuminate\Support\Facades\Notification;
test('the 6 extended triggers exist and are disabled by default, alongside the 2 enabled originals', function () {
test('the 10 notification triggers exist, with the customer-facing lifecycle ones (and the new-ticket-for-team one) enabled by default and the rest opt-in', function () {
$this->seed();
$settings = NotificationSetting::query()->get()->keyBy('trigger_key');
expect($settings->keys()->sort()->values()->all())->toBe([
'assignee_changed', 'category_changed', 'operator_replied', 'priority_changed',
'sla_breached', 'status_changed', 'team_changed', 'ticket_closed', 'ticket_created',
'ticket_created_team',
]);
foreach (['ticket_created', 'status_changed'] as $key) {
foreach (['ticket_created', 'status_changed', 'ticket_closed', 'operator_replied', 'ticket_created_team'] as $key) {
expect($settings[$key]->enabled)->toBeTrue();
}
foreach (['category_changed', 'assignee_changed', 'priority_changed', 'team_changed', 'ticket_closed', 'operator_replied', 'sla_breached'] as $key) {
foreach (['category_changed', 'assignee_changed', 'priority_changed', 'team_changed', 'sla_breached'] as $key) {
expect($settings[$key]->enabled)->toBeFalse()
->and($settings[$key]->email_template_id)->not->toBeNull();
}
@@ -29,7 +32,7 @@ test('the 6 extended triggers exist and are disabled by default, alongside the 2
test('a disabled-by-default trigger sends nothing until an admin turns it on', function () {
Notification::fake();
seedStatusesAndPriorities();
$this->seed();
$ticket = makeTicket();
app(TicketService::class)->setPriority($ticket, 'high');
@@ -42,7 +45,7 @@ test('a disabled-by-default trigger sends nothing until an admin turns it on', f
test('changing the assignee fires assignee_changed with the {operator} placeholder once enabled', function () {
Notification::fake();
seedStatusesAndPriorities();
$this->seed();
NotificationSetting::query()->where('trigger_key', 'assignee_changed')->update(['enabled' => true]);
$ticket = makeTicket();
@@ -57,7 +60,7 @@ test('changing the assignee fires assignee_changed with the {operator} placehold
test('changing the team fires team_changed with the {zespol} placeholder once enabled', function () {
Notification::fake();
seedStatusesAndPriorities();
$this->seed();
NotificationSetting::query()->where('trigger_key', 'team_changed')->update(['enabled' => true]);
$ticket = makeTicket();
@@ -72,7 +75,7 @@ test('changing the team fires team_changed with the {zespol} placeholder once en
test('changing the subcategory fires category_changed, but re-saving details without changing it does not', function () {
Notification::fake();
seedStatusesAndPriorities();
$this->seed();
NotificationSetting::query()->where('trigger_key', 'category_changed')->update(['enabled' => true]);
$category = Category::query()->create(['name' => 'IT-Pomoc']);
@@ -92,12 +95,12 @@ test('changing the subcategory fires category_changed, but re-saving details wit
test('closing a ticket fires only ticket_closed, not status_changed, so it does not double-notify', function () {
Notification::fake();
seedStatusesAndPriorities();
$this->seed();
NotificationSetting::query()->where('trigger_key', 'ticket_closed')->update(['enabled' => true]);
// status_changed ships enabled by default, but in a bare migrated (unseeded)
// database it has no template assigned yet — give it one so it would have
// something to send if it (wrongly) fired, isolating this test from seeding order.
// status_changed ships enabled by default — repoint it at a template we
// control so this test's "did it wrongly fire" assertion isn't tied to
// whatever content the real seeded template happens to have.
$statusTemplate = EmailTemplate::query()->create([
'key' => 'tpl-status-test', 'name' => 'Status', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'B',
]);
@@ -112,7 +115,7 @@ test('closing a ticket fires only ticket_closed, not status_changed, so it does
test('a non-closing status change still fires status_changed as usual', function () {
Notification::fake();
seedStatusesAndPriorities();
$this->seed();
$statusTemplate = EmailTemplate::query()->create([
'key' => 'tpl-status-test-2', 'name' => 'Status', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'B',
@@ -128,7 +131,7 @@ test('a non-closing status change still fires status_changed as usual', function
test('an operator reply fires operator_replied once enabled, independent of any status change', function () {
Notification::fake();
seedStatusesAndPriorities();
$this->seed();
NotificationSetting::query()->where('trigger_key', 'operator_replied')->update(['enabled' => true]);
$ticket = makeTicket();
$operator = User::query()->create(['name' => 'Op', 'email' => 'op-reply@example.com', 'roles' => ['operator']]);
@@ -137,3 +140,86 @@ test('an operator reply fires operator_replied once enabled, independent of any
Notification::assertSentOnDemandTimes(TicketNotification::class, 1);
});
test('every operator/admin whose new_ticket preference puts a routed ticket in scope gets notified once, no duplicates', function () {
Notification::fake();
$this->seed();
$category = Category::query()->create(['name' => 'IT-Pomoc']);
$sub = $category->subcategories()->create(['name' => 'VPN']);
$team = Team::query()->create(['name' => 'Zespół VPN']);
$team->subcategories()->attach($sub->id);
$memberA = User::query()->create(['name' => 'Ola', 'email' => 'team-notif-a@example.com', 'roles' => ['operator']]);
$memberB = User::query()->create(['name' => 'Jan', 'email' => 'team-notif-b@example.com', 'roles' => ['operator']]);
$team->members()->attach([$memberA->id, $memberB->id]);
// auto_assign_by_category is on by default (seeded), so the ticket's
// team_id actually becomes the VPN team's id — that's what now drives
// who's "in scope" for the default scope_all preference, replacing the
// old separate team-subcategory-routing fan-out.
app(TicketService::class)->create([
'email' => 'client-team-notif@example.com',
'subject' => 'Problem z VPN',
'body' => 'Nie mogę się połączyć.',
'subcategory_id' => $sub->id,
], null);
Notification::assertSentTo($memberA, TicketNotification::class);
Notification::assertSentTo($memberB, TicketNotification::class);
// The seeded admin also qualifies: Ticket::isVisibleToOperator() returns
// true unconditionally for admins, and scope_all is the default — this
// is intentional, it's what keeps the one real admin account notified
// about every new ticket without any setup.
Notification::assertSentTo(User::query()->where('email', 'admin@example.com')->firstOrFail(), TicketNotification::class);
// Plus one more: TicketService::create() also fires the pre-existing
// 'ticket_created' trigger, routed anonymously to the guest's e-mail
// since this ticket has no real customer account.
Notification::assertSentTimes(TicketNotification::class, 4);
});
test('an operator outside the ticket\'s team is not notified, even with scope_all left at its default', function () {
Notification::fake();
$this->seed();
$category = Category::query()->create(['name' => 'IT-Pomoc']);
$sub = $category->subcategories()->create(['name' => 'VPN']);
$team = Team::query()->create(['name' => 'Zespół VPN']);
$team->subcategories()->attach($sub->id);
User::query()->create(['name' => 'Ola', 'email' => 'team-notif-a@example.com', 'roles' => ['operator']])
->teams()->attach($team->id);
$outsider = User::query()->create(['name' => 'Niepowiązany', 'email' => 'unrelated-op@example.com', 'roles' => ['operator']]);
app(TicketService::class)->create([
'email' => 'client-team-notif-2@example.com',
'subject' => 'Problem z VPN',
'body' => 'Nie mogę się połączyć.',
'subcategory_id' => $sub->id,
], null);
// The ticket routed to "Zespół VPN"; $outsider belongs to no team, so
// Ticket::isVisibleToOperator() (which scope_all delegates to) is false
// for them even though their preference defaults to scope_all=true.
Notification::assertNotSentTo($outsider, TicketNotification::class);
});
test('an operator who turns off scope_all for new tickets stops receiving them, even for a ticket they could otherwise see', function () {
Notification::fake();
$this->seed();
$category = Category::query()->create(['name' => 'Bez zespołu']);
$sub = $category->subcategories()->create(['name' => 'Inne']);
$operator = User::query()->create(['name' => 'Cichy', 'email' => 'opted-out-op@example.com', 'roles' => ['operator']]);
NotificationPreference::query()->create(array_merge(
['user_id' => $operator->id, 'event_category' => 'new_ticket'],
array_merge(NotificationPreference::DEFAULTS['new_ticket'], ['scope_all' => false])
));
app(TicketService::class)->create([
'email' => 'client-no-team@example.com',
'subject' => 'Coś innego',
'body' => 'Treść.',
'subcategory_id' => $sub->id,
], null);
Notification::assertNotSentTo($operator, TicketNotification::class);
});

View File

@@ -0,0 +1,203 @@
<?php
use App\Livewire\Admin\MailSettings;
use App\Livewire\Operator\Queue;
use App\Livewire\Operator\TicketShow;
use App\Models\Category;
use App\Models\ImapMailbox;
use App\Models\User;
use App\Services\TicketService;
use Livewire\Livewire;
// ===================== TicketService::create() category-only routing =====================
test('create() sets category_id when only a category is given (no subcategory)', function () {
seedStatusesAndPriorities();
$category = Category::query()->create(['name' => 'Delegacje']);
$ticket = app(TicketService::class)->create([
'email' => 'gosc@example.com',
'category_id' => $category->id,
'subject' => 'Sprawa delegacji',
'body' => 'Treść',
], null);
expect($ticket->category_id)->toBe($category->id)
->and($ticket->subcategory_id)->toBeNull()
->and($ticket->categoryLabel())->toBe('Delegacje');
});
test('create() leaves category_id null when a subcategory is given (category is derived from it)', function () {
seedStatusesAndPriorities();
$subcategory = subcategoryFixture();
$ticket = app(TicketService::class)->create([
'email' => 'gosc@example.com',
'subcategory_id' => $subcategory->id,
'subject' => 'Sprawa VPN',
'body' => 'Treść',
], null);
expect($ticket->category_id)->toBeNull()
->and($ticket->categoryLabel())->toBe('IT / VPN');
});
test('create() defaults source to web, and accepts an explicit source', function () {
seedStatusesAndPriorities();
$webTicket = app(TicketService::class)->create([
'email' => 'a@example.com', 'subject' => 'S', 'body' => 'B',
], null);
$mailTicket = app(TicketService::class)->create([
'email' => 'b@example.com', 'subject' => 'S', 'body' => 'B', 'source' => 'email',
], null);
expect($webTicket->source)->toBe('web')
->and($mailTicket->source)->toBe('email');
});
// ===================== ImapMailbox::targetLabel() =====================
test('targetLabel reflects subcategory, whole-category, or neither', function () {
$subcategory = subcategoryFixture();
$category = Category::query()->create(['name' => 'Delegacje']);
$bySubcategory = ImapMailbox::query()->create(mailboxFixtureData(['default_subcategory_id' => $subcategory->id]));
$byCategory = ImapMailbox::query()->create(mailboxFixtureData(['default_category_id' => $category->id]));
$unrouted = ImapMailbox::query()->create(mailboxFixtureData());
expect($bySubcategory->targetLabel())->toBe('IT / VPN')
->and($byCategory->targetLabel())->toBe('Cała kategoria: Delegacje')
->and($unrouted->targetLabel())->toBe('—');
});
function mailboxFixtureData(array $overrides = []): array
{
return array_merge([
'name' => 'Test',
'enabled' => true,
'host' => 'imap.example.com',
'port' => 993,
'encryption' => 'ssl',
'validate_cert' => true,
'username' => 'test@example.com',
'password' => 'secret',
'folder' => 'INBOX',
], $overrides);
}
// ===================== Admin: MailSettings mailbox form =====================
test('admin can route a mailbox to a whole category via the combined selector', function () {
$admin = adminUser();
$category = Category::query()->create(['name' => 'Delegacje']);
Livewire::actingAs($admin)->test(MailSettings::class)
->call('openMailboxForm')
->set('mailboxForm.name', 'Zgłoszenia delegacji')
->set('mailboxForm.host', 'imap.example.com')
->set('mailboxForm.username', 'zgloszenia-delegacje@example.com')
->set('mailboxForm.password', 'secret')
->set('mailboxForm.target', "category:{$category->id}")
->call('submitMailboxForm')
->assertOk();
$mailbox = ImapMailbox::query()->where('name', 'Zgłoszenia delegacji')->firstOrFail();
expect($mailbox->default_category_id)->toBe($category->id)
->and($mailbox->default_subcategory_id)->toBeNull();
});
test('admin can route a mailbox to a specific subcategory via the combined selector', function () {
$admin = adminUser();
$subcategory = subcategoryFixture();
Livewire::actingAs($admin)->test(MailSettings::class)
->call('openMailboxForm')
->set('mailboxForm.name', 'Zgłoszenia IT')
->set('mailboxForm.host', 'imap.example.com')
->set('mailboxForm.username', 'zgloszenia-it@example.com')
->set('mailboxForm.password', 'secret')
->set('mailboxForm.target', "subcategory:{$subcategory->id}")
->call('submitMailboxForm')
->assertOk();
$mailbox = ImapMailbox::query()->where('name', 'Zgłoszenia IT')->firstOrFail();
expect($mailbox->default_subcategory_id)->toBe($subcategory->id)
->and($mailbox->default_category_id)->toBeNull();
});
test('switching an existing mailbox from a subcategory to a whole category clears the old target', function () {
$admin = adminUser();
$subcategory = subcategoryFixture();
$category = Category::query()->create(['name' => 'Delegacje']);
$mailbox = ImapMailbox::query()->create(mailboxFixtureData(['default_subcategory_id' => $subcategory->id]));
Livewire::actingAs($admin)->test(MailSettings::class)
->call('editMailbox', $mailbox->id)
->assertSet('mailboxForm.target', "subcategory:{$subcategory->id}")
->set('mailboxForm.target', "category:{$category->id}")
->call('submitMailboxForm')
->assertOk();
$mailbox->refresh();
expect($mailbox->default_category_id)->toBe($category->id)
->and($mailbox->default_subcategory_id)->toBeNull();
});
// ===================== Operator UI: e-mail source badge =====================
test('the operator queue shows a mail icon next to an e-mail-originated ticket but not a web one', function () {
seedStatusesAndPriorities();
$operator = operatorUser();
$webTicket = makeTicket(['number' => '2001', 'source' => 'web']);
$mailTicket = makeTicket(['number' => '2002', 'source' => 'email']);
Livewire::actingAs($operator)->test(Queue::class)
->assertSeeHtml('title="Utworzone przez e-mail"');
});
test('the ticket detail header shows an e-mail badge only for e-mail-originated tickets', function () {
seedStatusesAndPriorities();
$operator = operatorUser();
$mailTicket = makeTicket(['number' => '2003', 'source' => 'email']);
$webTicket = makeTicket(['number' => '2004', 'source' => 'web']);
Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $mailTicket])
->assertSee('E-mail');
Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $webTicket])
->assertDontSee('E-mail');
});
// ===================== Operator queue: category-only tickets are filterable =====================
test('filtering the queue by category includes a ticket routed to that whole category with no subcategory', function () {
seedStatusesAndPriorities();
$operator = operatorUser();
$category = Category::query()->create(['name' => 'Delegacje']);
$ticket = makeTicket(['number' => '2005', 'category_id' => $category->id]);
Livewire::actingAs($operator)->test(Queue::class)
->set('filterCategory', $category->id)
->assertSee($ticket->displayNumber());
});
// ===================== Operator UI: per-message e-mail source badge =====================
test('a reply fetched by e-mail shows a mail badge in the thread, a normal client reply does not', function () {
seedStatusesAndPriorities();
$operator = operatorUser();
$client = User::query()->create(['name' => 'Klient', 'email' => 'klient@example.com', 'roles' => ['client']]);
$ticket = makeTicket(['number' => '2006']);
app(TicketService::class)->clientReply($ticket, $client, 'Odpowiedź z portalu.');
app(TicketService::class)->clientReply($ticket, $client, 'Odpowiedź e-mailem.', source: 'email');
Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket])
->assertSeeHtml('title="Odebrane e-mailem"');
});

View File

@@ -0,0 +1,215 @@
<?php
use App\Ldap\LldapUser;
use App\Models\User;
use App\Services\ImapMessageClassifier;
use App\Support\Imap\InboundEmail;
use App\Support\Settings;
use Illuminate\Support\Str;
use LdapRecord\Laravel\Testing\DirectoryEmulator;
afterEach(function () {
DirectoryEmulator::tearDown();
});
function makeInboundEmail(array $overrides = []): InboundEmail
{
return new InboundEmail(
fromEmail: $overrides['fromEmail'] ?? 'klient@example.com',
fromName: $overrides['fromName'] ?? 'Jan Kowalski',
subject: $overrides['subject'] ?? 'Zwykła wiadomość',
textBody: $overrides['textBody'] ?? 'Treść wiadomości.',
htmlBody: $overrides['htmlBody'] ?? '',
headers: $overrides['headers'] ?? [],
attachments: $overrides['attachments'] ?? [],
);
}
// ===================== rejectionReason() =====================
test('a normal reply is not rejected', function () {
$classifier = new ImapMessageClassifier;
expect($classifier->rejectionReason(makeInboundEmail()))->toBeNull();
});
test('Auto-Submitted header other than "no" is rejected', function () {
$classifier = new ImapMessageClassifier;
$email = makeInboundEmail(['headers' => ['auto-submitted' => 'auto-replied']]);
expect($classifier->rejectionReason($email))->not->toBeNull();
});
test('Auto-Submitted: no is not rejected', function () {
$classifier = new ImapMessageClassifier;
$email = makeInboundEmail(['headers' => ['auto-submitted' => 'no']]);
expect($classifier->rejectionReason($email))->toBeNull();
});
test('X-Autoreply header is rejected', function () {
$classifier = new ImapMessageClassifier;
$email = makeInboundEmail(['headers' => ['x-autoreply' => '1']]);
expect($classifier->rejectionReason($email))->not->toBeNull();
});
test('regression: an empty-string header value (present key, no content) is treated as absent, not rejected', function () {
// Reproduces the real production bug: Webklex's Header::get() returns
// an empty (non-null) Attribute for a header that isn't on the message
// at all, so a naive "!== null" check on x-autoreply/x-autorespond
// rejected every single inbound e-mail.
$classifier = new ImapMessageClassifier;
$email = makeInboundEmail(['headers' => [
'auto-submitted' => '', 'x-autoreply' => '', 'x-autorespond' => '', 'precedence' => '',
]]);
expect($classifier->rejectionReason($email))->toBeNull();
});
test('Precedence: bulk is rejected', function () {
$classifier = new ImapMessageClassifier;
$email = makeInboundEmail(['headers' => ['precedence' => 'bulk']]);
expect($classifier->rejectionReason($email))->not->toBeNull();
});
test('a blocklisted sender is rejected', function () {
$classifier = new ImapMessageClassifier;
$email = makeInboundEmail(['fromEmail' => 'mailer-daemon@example.com']);
expect($classifier->rejectionReason($email, ['mailer-daemon']))->not->toBeNull();
});
test('an out-of-office subject is rejected', function () {
$classifier = new ImapMessageClassifier;
$email = makeInboundEmail(['subject' => 'Automatic reply: Out of Office']);
expect($classifier->rejectionReason($email))->not->toBeNull();
});
test('a Polish autoresponder subject is rejected', function () {
$classifier = new ImapMessageClassifier;
$email = makeInboundEmail(['subject' => 'Automatyczna odpowiedz: nieobecnosc w biurze']);
expect($classifier->rejectionReason($email))->not->toBeNull();
});
// ===================== matchTicket() =====================
test('matches an existing ticket by its plain sequential number in the subject', function () {
seedStatusesAndPriorities();
$ticket = makeTicket(['number' => '1042']);
$classifier = new ImapMessageClassifier;
expect($classifier->matchTicket('Re: Aktualizacja zgłoszenia #1042')->id)->toBe($ticket->id);
});
test('matches an existing ticket by its checksum when obfuscation is enabled', function () {
seedStatusesAndPriorities();
$ticket = makeTicket(['number' => '1042']);
Settings::set('ticket_number_obfuscate', '1');
$classifier = new ImapMessageClassifier;
expect($classifier->matchTicket("Re: Aktualizacja zgłoszenia #{$ticket->checksum}")->id)->toBe($ticket->id);
});
test('returns null when no digit run in the subject matches any ticket', function () {
seedStatusesAndPriorities();
makeTicket(['number' => '1042']);
$classifier = new ImapMessageClassifier;
expect($classifier->matchTicket('Nowa sprawa bez numeru'))->toBeNull();
});
test('strips common reply/forward prefixes before matching', function () {
seedStatusesAndPriorities();
$ticket = makeTicket(['number' => '1042']);
$classifier = new ImapMessageClassifier;
foreach (['Re:', 'RE:', 'Odp:', 'Fwd:', 'FW:', 'Aw:'] as $prefix) {
expect($classifier->matchTicket("{$prefix} Zgłoszenie #1042")->id)->toBe($ticket->id);
}
});
test('when the subject has multiple digit runs, the one that actually resolves wins', function () {
seedStatusesAndPriorities();
$ticket = makeTicket(['number' => '1042']);
$classifier = new ImapMessageClassifier;
// "2026" (a year, 4 digits) doesn't resolve to any ticket; "1042" does.
expect($classifier->matchTicket('Zgłoszenie #1042 z dnia 2026-07-23')->id)->toBe($ticket->id);
});
// ===================== isSenderAllowed() / resolveSender() =====================
test('isSenderAllowed allows any e-mail when restrict_tickets_to_ldap is off (the default)', function () {
$classifier = new ImapMessageClassifier;
expect($classifier->isSenderAllowed('ktokolwiek@example.com'))->toBeTrue();
});
test('isSenderAllowed rejects an unknown e-mail when restrict_tickets_to_ldap is on', function () {
DirectoryEmulator::setup();
Settings::set('restrict_tickets_to_ldap', '1');
$classifier = new ImapMessageClassifier;
expect($classifier->isSenderAllowed('nieznany@firma.pl'))->toBeFalse();
});
test('isSenderAllowed allows an e-mail that exists in LDAP when restrict_tickets_to_ldap is on', function () {
DirectoryEmulator::setup();
Settings::set('restrict_tickets_to_ldap', '1');
LldapUser::create([
'uid' => 'znany.gosc',
'cn' => 'Znany Gość',
'mail' => 'znany.gosc@firma.pl',
'entryuuid' => (string) Str::uuid(),
]);
$classifier = new ImapMessageClassifier;
expect($classifier->isSenderAllowed('znany.gosc@firma.pl'))->toBeTrue();
});
test('isSenderAllowed allows an already-known local account even when restrict_tickets_to_ldap is on', function () {
DirectoryEmulator::setup();
Settings::set('restrict_tickets_to_ldap', '1');
User::query()->create(['name' => 'Istniejący Klient', 'email' => 'istniejacy@firma.pl', 'roles' => ['client']]);
$classifier = new ImapMessageClassifier;
expect($classifier->isSenderAllowed('istniejacy@firma.pl'))->toBeTrue();
});
test('resolveSender returns an existing local user without touching LDAP', function () {
$user = User::query()->create(['name' => 'Istniejący', 'email' => 'istniejacy@firma.pl', 'roles' => ['client']]);
$classifier = new ImapMessageClassifier;
expect($classifier->resolveSender('istniejacy@firma.pl')->id)->toBe($user->id);
});
test('resolveSender returns null for an unprovisionable guest', function () {
Settings::set('ldap_auto_provision_guests', '0');
$classifier = new ImapMessageClassifier;
expect($classifier->resolveSender('nikt@example.com'))->toBeNull();
});

View File

@@ -1,18 +1,18 @@
<?php
use App\Livewire\Admin\Panel;
use App\Livewire\Admin\MailSettings;
use App\Models\EmailTemplate;
use App\Notifications\TicketNotification;
use App\Providers\AppServiceProvider;
use App\Support\Settings;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Mail;
use Livewire\Livewire;
test('admin can save the SMTP/from settings, and the password is only overwritten when provided', function () {
$admin = adminUser();
Livewire::actingAs($admin)->test(Panel::class)
->call('setTab', 'config')
Livewire::actingAs($admin)->test(MailSettings::class)
->set('mailConfig.fromAddress', 'wsparcie@firma.pl')
->set('mailConfig.fromName', 'Zespół Wsparcia')
->set('mailConfig.smtpEnabled', true)
@@ -31,7 +31,7 @@ test('admin can save the SMTP/from settings, and the password is only overwritte
->and(Settings::get('mail_smtp_password'))->toBe('sekret123');
// Saving again with a blank password field must not wipe the stored one.
Livewire::actingAs($admin)->test(Panel::class)
Livewire::actingAs($admin)->test(MailSettings::class)
->set('mailConfig.smtpHost', 'smtp.firma.pl')
->set('mailConfig.smtpPassword', '')
->call('saveMailConfig')
@@ -44,14 +44,14 @@ test('the SMTP test button reports an error without a host/from address, and suc
Mail::fake();
$admin = adminUser();
Livewire::actingAs($admin)->test(Panel::class)
Livewire::actingAs($admin)->test(MailSettings::class)
->call('testMailConnection')
->assertSet('mailTestResult', 'error');
// Mail::fake()'s raw() is a no-op that never throws, so a valid config
// reports success — this exercises the same config-override/restore path
// real sends use, without needing a reachable SMTP server in tests.
Livewire::actingAs($admin)->test(Panel::class)
Livewire::actingAs($admin)->test(MailSettings::class)
->set('mailConfig.fromAddress', 'wsparcie@firma.pl')
->set('mailConfig.smtpHost', 'smtp.firma.pl')
->call('testMailConnection')
@@ -109,3 +109,30 @@ test('AppServiceProvider always applies the from-address override regardless of
expect(config('mail.from.address'))->toBe('wsparcie@firma.pl')
->and(config('mail.from.name'))->toBe('Wsparcie');
});
test('regression: the mail override still applies for a console command other than migrate (e.g. schedule:run/tinker)', function () {
// Reproduces the real production bug: settingsTableUsable() used to
// blanket-skip for *any* console command, which meant scheduled
// commands (emails:fetch-imap, tickets:check-sla-breaches) always sent
// mail via the .env "log" mailer instead of the configured SMTP server,
// since AppServiceProvider::boot() runs on every process including
// console ones. Only the migrate family should still be excluded.
$originalArgv = $_SERVER['argv'] ?? null;
Settings::set('mail_smtp_enabled', '1');
Settings::set('mail_smtp_host', 'smtp.enabled.example');
try {
$_SERVER['argv'] = ['artisan', 'emails:fetch-imap'];
(new AppServiceProvider(app()))->boot();
expect(config('mail.default'))->toBe('smtp');
Config::set('mail.default', 'log');
$_SERVER['argv'] = ['artisan', 'migrate'];
(new AppServiceProvider(app()))->boot();
expect(config('mail.default'))->not->toBe('smtp');
} finally {
$_SERVER['argv'] = $originalArgv;
}
});

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,39 @@
<?php
use App\Livewire\Operator\Queue;
use Livewire\Livewire;
test('toggleSelectAll selects every currently visible ticket', function () {
seedStatusesAndPriorities();
$operator = operatorUser('select-all-1@example.com');
$a = makeTicket(['number' => '1001']);
$b = makeTicket(['number' => '1002']);
Livewire::actingAs($operator)->test(Queue::class)
->call('toggleSelectAll')
->assertSet('selectedIds', [$a->id, $b->id]);
});
test('toggleSelectAll deselects everything when all visible tickets are already selected', function () {
seedStatusesAndPriorities();
$operator = operatorUser('select-all-2@example.com');
makeTicket(['number' => '1001']);
makeTicket(['number' => '1002']);
Livewire::actingAs($operator)->test(Queue::class)
->call('toggleSelectAll')
->call('toggleSelectAll')
->assertSet('selectedIds', []);
});
test('toggleSelectAll only affects tickets visible under the active filter, not every ticket', function () {
seedStatusesAndPriorities();
$operator = operatorUser('select-all-3@example.com');
makeTicket(['number' => '1001', 'status_key' => 'open']);
$closed = makeTicket(['number' => '1002', 'status_key' => 'closed']);
Livewire::actingAs($operator)->test(Queue::class)
->set('queue', 'closed')
->call('toggleSelectAll')
->assertSet('selectedIds', [$closed->id]);
});

View File

@@ -0,0 +1,60 @@
<?php
use App\Events\NotificationCreated;
use App\Models\EmailTemplate;
use App\Notifications\TicketNotification;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Notification;
test('sending a bell notification to a real user dispatches NotificationCreated on their private channel', function () {
Mail::fake();
Event::fake([NotificationCreated::class]);
seedStatusesAndPriorities();
$operator = operatorUser('realtime-bell@example.com');
$template = EmailTemplate::query()->create([
'key' => 'tpl-realtime-test', 'name' => 'x', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'B',
]);
$ticket = makeTicket();
$operator->notify(new TicketNotification($ticket, $template->id, 'operator'));
Event::assertDispatched(NotificationCreated::class, function (NotificationCreated $event) use ($operator, $ticket) {
return $event->userId === $operator->id
&& str_contains($event->message, $ticket->number)
&& $event->url === route('operator.ticket', $ticket);
});
});
test('a bell-only notification (no mail channel) still dispatches NotificationCreated', function () {
Mail::fake();
Event::fake([NotificationCreated::class]);
seedStatusesAndPriorities();
$operator = operatorUser('bell-only-realtime@example.com');
$template = EmailTemplate::query()->create([
'key' => 'tpl-realtime-bell-only', 'name' => 'x', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'B',
]);
$ticket = makeTicket();
$operator->notify(new TicketNotification($ticket, $template->id, 'operator', ['database']));
Event::assertDispatched(NotificationCreated::class, fn (NotificationCreated $event) => $event->userId === $operator->id);
});
test('a guest customer notified by mail only never broadcasts a bell event', function () {
Mail::fake();
Event::fake([NotificationCreated::class]);
seedStatusesAndPriorities();
$template = EmailTemplate::query()->create([
'key' => 'tpl-realtime-guest', 'name' => 'x', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'B',
]);
$ticket = makeTicket();
Notification::route('mail', $ticket->email)
->notify(new TicketNotification($ticket, $template->id));
Event::assertNotDispatched(NotificationCreated::class);
});

View File

@@ -6,6 +6,8 @@ use App\Models\ReplyQuickAction;
use Livewire\Livewire;
test('the 3 default reply quick actions exist out of the box, matching the old hardcoded menu', function () {
$this->seed();
// "Wyślij i oznacz jako rozwiązane" used to point at the now-removed
// "resolved" status (folded into "closed" — see the status restructure
// migration), so it points at "closed" today, same as "Wyślij i zamknij".
@@ -72,7 +74,7 @@ test('admin can edit and delete a reply quick action', function () {
});
test('the operator ticket view lists the configured reply quick actions in the send menu', function () {
seedStatusesAndPriorities();
$this->seed();
$operator = operatorUser('quickaction-view@example.com');
$ticket = makeTicket(['number' => '1001']);
@@ -83,7 +85,7 @@ test('the operator ticket view lists the configured reply quick actions in the s
});
test('sending via a status-changing quick action updates the ticket status', function () {
seedStatusesAndPriorities();
$this->seed();
$operator = operatorUser('quickaction-send@example.com');
$ticket = makeTicket(['number' => '1001', 'status_key' => 'new']);
$action = ReplyQuickAction::query()->where('label', 'Wyślij i oznacz jako rozwiązane')->firstOrFail();

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