Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d116dfd98 | |||
| 63178b366e | |||
| ab90abcaa3 |
160
ARCHITECTURE.md
160
ARCHITECTURE.md
@@ -63,6 +63,56 @@ 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
|
||||
|
||||
`$user->roles` reads/writes as a plain array (`['client', 'operator']`), but
|
||||
@@ -99,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
|
||||
@@ -108,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
|
||||
@@ -192,6 +256,20 @@ themselves every 30–60 seconds via a small Alpine countdown calling
|
||||
`$wire.refreshQueue()` / `$wire.refreshTicketData()` — broadcasting is
|
||||
best-effort, not the only way these views ever update.
|
||||
|
||||
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
|
||||
@@ -223,6 +301,86 @@ 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
|
||||
|
||||
130
CHANGELOG.md
130
CHANGELOG.md
@@ -3,6 +3,136 @@
|
||||
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
|
||||
|
||||
24
CLAUDE.md
24
CLAUDE.md
@@ -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
|
||||
|
||||
65
README.md
65
README.md
@@ -13,7 +13,7 @@ The app has three areas, gated by role (a user can hold more than one at once):
|
||||
|---|---|---|---|
|
||||
| Client | `/client` | `client` | Submit tickets, track status, reply, see resolution |
|
||||
| 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
|
||||
@@ -60,11 +60,28 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
|
||||
subcategories, rest folded into "Inne"); CSAT average by team and by operator;
|
||||
and a daily created-vs-closed trend.
|
||||
- **Branding & config** — company name/logo/favicon/accent color, login notice,
|
||||
e-mail layout/footer, LDAP connection + user sync, SMTP connection, attachment
|
||||
limits, session lifetime, timezone — all editable from Admin > Konfiguracja.
|
||||
e-mail layout/footer, SMTP connection (Admin > E-MAIL), attachment limits,
|
||||
session lifetime, timezone (Admin > Konfiguracja), and LDAP connection + user
|
||||
sync + BookStack (Admin > Integracje).
|
||||
- **LDAP auth** — logins bind against an LDAP/LLDAP directory (`config/auth.php`,
|
||||
`config/ldap.php`); local accounts (e.g. the emergency `admin` account) fall back
|
||||
to e-mail + local password when the LDAP bind doesn't match.
|
||||
- **Triggers** (Admin > Wyzwalacze) — event-driven business rules that fire
|
||||
immediately on a ticket lifecycle event (created, any field updated, status/
|
||||
priority/assignee/team/category changed, new public reply): AND-combined
|
||||
conditions gate a sequence of actions (set status/priority/team/assignee, or
|
||||
send an e-mail using a dedicated set of freely add/edit/delete-able trigger
|
||||
e-mail templates, kept separate from the fixed per-event system templates).
|
||||
Complements the time-based SLA automation rules above rather than replacing
|
||||
them.
|
||||
- **Ticket watching** — operators can star/"Obserwuj" any ticket to follow it
|
||||
regardless of assignment/team, which feeds the "Obserwowane zgłoszenia" scope
|
||||
in their notification preferences.
|
||||
- **Per-user notification preferences** (`/settings/notifications`) — each
|
||||
operator/admin chooses, per event category (new ticket, ticket update,
|
||||
escalation), which scope of tickets (mine, unassigned, watched, all) notifies
|
||||
them via the in-app bell, and whether that also sends an e-mail; plus an
|
||||
opt-in toggle for native in-tab browser push notifications.
|
||||
- **REST API** (`/api/v1/...`, Sanctum token auth, ability-scoped: `tickets:read`,
|
||||
`tickets:write`, `dictionaries:read`, `users:read`) for tickets/messages/users/
|
||||
categories/statuses/priorities/teams — issued via admin-managed API clients.
|
||||
@@ -73,11 +90,33 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
|
||||
- **In-app notifications** — a bell in the top bar (client/operator/admin areas)
|
||||
backed by Laravel's database notification channel, alongside the existing
|
||||
e-mail notifications (same per-trigger enable toggle drives both); shows
|
||||
unread notifications only — reading one removes it from the list. Includes a
|
||||
dedicated trigger notifying every operator on a team whose subcategories
|
||||
match a newly created ticket.
|
||||
- **Attachments** — drag-and-drop upload (in addition to the file picker) and
|
||||
inline image thumbnails in the message thread instead of a plain download link.
|
||||
unread notifications only — reading one removes it from the list. Updates
|
||||
live over WebSockets the moment a notification is created (with a 30s
|
||||
fallback poll), and can optionally raise a native browser push notification
|
||||
while the tab is open (see per-user notification preferences above).
|
||||
Includes a dedicated trigger notifying every operator on a team whose
|
||||
subcategories match a newly created ticket.
|
||||
- **Attachments** — drag-and-drop upload (in addition to the file picker); every
|
||||
attachment shows in the message thread as just its filename, opening in a new
|
||||
tab on click (no inline image preview).
|
||||
- **E-mail intake (IMAP)** *(optional, off by default)* — clients can create
|
||||
tickets or reply to an existing one just by sending/replying to an e-mail;
|
||||
configure any number of mailboxes in Admin > Poczta (e.g. one address per
|
||||
team), each routed to a specific subcategory or a whole category. A reply
|
||||
is matched back to its ticket via the number/checksum already present in
|
||||
every notification's subject; automatic replies (autoresponders, bounces)
|
||||
are detected and rejected instead of creating junk tickets, and the
|
||||
"restrict tickets to LDAP" setting is enforced for e-mail exactly like the
|
||||
guest web form. A manual "Pobierz teraz" button fetches immediately
|
||||
outside the 5-minute schedule; all activity is logged separately to
|
||||
`storage/logs/imap-*.log`. Tickets/messages that came in by e-mail show a
|
||||
small mail-icon badge in the operator queue and ticket view.
|
||||
- **Configurable ticket numbering** (Admin > Konfiguracja) — a custom prefix and
|
||||
minimum zero-padded length for the ticket number, plus an optional "hide
|
||||
ticket order" mode that displays a stable per-ticket checksum instead of the
|
||||
sequential number. When enabled, ticket URLs switch to the same checksum too,
|
||||
so the number in the link always matches the one on the page; the REST API is
|
||||
unaffected and always addresses tickets by `id`.
|
||||
- **Customer satisfaction (CSAT)** — clients rate a ticket 1–5 stars (+ optional
|
||||
comment) once it's closed; average/response-rate surfaced as a KPI on the
|
||||
operator stats dashboard, with a link in the "ticket closed" e-mail.
|
||||
@@ -93,7 +132,7 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
|
||||
is being created, and in a separate sidebar panel on an existing ticket for
|
||||
both operators and clients (with a copy-link button for operators). Loads in
|
||||
after the page's first paint rather than blocking it. Configured entirely
|
||||
from Admin > Konfiguracja: connection + API token, optional SSL-verification
|
||||
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
|
||||
@@ -104,7 +143,8 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
|
||||
- **Backend**: Laravel, Livewire (server-driven UI, no SPA build beyond Tailwind/Vite
|
||||
for CSS), LdapRecord for directory auth, Sanctum for API tokens, L5-Swagger for
|
||||
API docs, Laravel Reverb for WebSocket broadcasting (real-time queue/chat
|
||||
updates — see [ARCHITECTURE.md](ARCHITECTURE.md)).
|
||||
updates — see [ARCHITECTURE.md](ARCHITECTURE.md)), webklex/php-imap for the
|
||||
optional e-mail intake fetcher (pure-PHP IMAP client, no `ext-imap` needed).
|
||||
- **Frontend**: Blade + Livewire + a little Alpine.js for local UI state; Tailwind
|
||||
v4 via Vite for `resources/css/app.css`; Laravel Echo + Pusher-protocol client
|
||||
(`resources/js/echo.js`) for Reverb. No JS charting library — the statistics
|
||||
@@ -152,8 +192,9 @@ src/ Laravel application
|
||||
app/Livewire/ Client/Operator/Admin Livewire components
|
||||
app/Models/ Eloquent models
|
||||
app/Events/ Broadcast events (TicketQueueChanged, TicketMessagePosted)
|
||||
app/Console/Commands/ Scheduled commands (SLA breach check, automation rules)
|
||||
app/Services/ TicketService (ticket lifecycle + notifications), BookStackClient
|
||||
app/Console/Commands/ Scheduled commands (SLA breach check, automation rules, IMAP fetch)
|
||||
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
|
||||
|
||||
20
install.md
20
install.md
@@ -74,7 +74,7 @@ APP_LOCALE=pl
|
||||
APP_FALLBACK_LOCALE=pl
|
||||
|
||||
AUTHOR_CONTACT=helpdesk@twoja-domena.pl # widoczne w Admin > O aplikacji
|
||||
VERSION=1.1.2 # widoczne w Admin > O aplikacji
|
||||
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
|
||||
@@ -265,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
|
||||
@@ -349,7 +352,7 @@ APP_LOCALE=pl
|
||||
APP_FALLBACK_LOCALE=pl
|
||||
|
||||
AUTHOR_CONTACT=helpdesk@twoja-domena.pl
|
||||
VERSION=1.1.2
|
||||
VERSION=1.1.3
|
||||
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=127.0.0.1 # albo adres IP/hostname prawdziwego serwera DB
|
||||
@@ -457,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
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
APP_NAME=Laravel
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
|
||||
AUTHOR_CONTACT=helpdesk@kzbikowski.pl
|
||||
VERSION=1.1.2
|
||||
VERSION=1.1.4
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
|
||||
33
src/app/Console/Commands/FetchImapEmails.php
Normal file
33
src/app/Console/Commands/FetchImapEmails.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\ImapMailbox;
|
||||
use App\Services\ImapMailboxFetcher;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class FetchImapEmails extends Command
|
||||
{
|
||||
protected $signature = 'emails:fetch-imap';
|
||||
|
||||
protected $description = 'Poll every enabled IMAP mailbox and turn new messages into tickets/replies';
|
||||
|
||||
public function handle(ImapMailboxFetcher $fetcher): int
|
||||
{
|
||||
if (! ImapMailbox::query()->where('enabled', true)->exists()) {
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$totals = $fetcher->fetchAll();
|
||||
|
||||
$this->info(sprintf(
|
||||
'IMAP fetch: %d nowych, %d odpowiedzi, %d odrzuconych, %d błędów.',
|
||||
$totals['created'],
|
||||
$totals['replied'],
|
||||
$totals['rejected'],
|
||||
$totals['errors'],
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
47
src/app/Events/NotificationCreated.php
Normal file
47
src/app/Events/NotificationCreated.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\Channel;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
|
||||
/**
|
||||
* Fired once per database (bell) notification actually created for a real
|
||||
* user — see the NotificationSent listener in AppServiceProvider::boot(),
|
||||
* which is the single choke point that dispatches this regardless of which
|
||||
* of the several TicketService call sites created the underlying
|
||||
* notification. Drives both the realtime bell badge (NotificationBell) and,
|
||||
* when the viewing browser has granted permission, an in-tab
|
||||
* `Notification` API popup.
|
||||
*/
|
||||
class NotificationCreated implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets;
|
||||
|
||||
public function __construct(
|
||||
public int $userId,
|
||||
public string $notificationId,
|
||||
public string $message,
|
||||
public string $url,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<int, Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PrivateChannel('App.Models.User.'.$this->userId)];
|
||||
}
|
||||
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'notificationId' => $this->notificationId,
|
||||
'message' => $this->message,
|
||||
'url' => $this->url,
|
||||
];
|
||||
}
|
||||
}
|
||||
335
src/app/Livewire/Admin/MailSettings.php
Normal file
335
src/app/Livewire/Admin/MailSettings.php
Normal file
@@ -0,0 +1,335 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\ImapMailbox;
|
||||
use App\Services\ImapMailboxFetcher;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* SMTP (outbound) + IMAP mailboxes (inbound — turns e-mails into tickets or
|
||||
* replies) on their own dedicated admin page, split out of the generic
|
||||
* "Integracje" grab-bag since IMAP is a repeatable list (N mailboxes) rather
|
||||
* than a singleton config, and both halves of "reply by e-mail" belong
|
||||
* together rather than split across tabs.
|
||||
*/
|
||||
class MailSettings extends Component
|
||||
{
|
||||
public array $mailConfig = [];
|
||||
|
||||
public ?string $mailTestResult = null;
|
||||
|
||||
public bool $mailboxFormOpen = false;
|
||||
|
||||
public array $mailboxForm = [
|
||||
'id' => null,
|
||||
'name' => '',
|
||||
'enabled' => true,
|
||||
'host' => '',
|
||||
'port' => 993,
|
||||
'encryption' => 'ssl',
|
||||
'validateCert' => true,
|
||||
'username' => '',
|
||||
'password' => '',
|
||||
'folder' => 'INBOX',
|
||||
'processedFolder' => '',
|
||||
'rejectedFolder' => '',
|
||||
'target' => '',
|
||||
'blocklistSenders' => 'mailer-daemon,postmaster,no-reply,noreply',
|
||||
];
|
||||
|
||||
public ?int $mailboxTestResultId = null;
|
||||
|
||||
public ?string $mailboxTestResult = null;
|
||||
|
||||
public ?string $mailboxTestMessage = null;
|
||||
|
||||
public ?int $mailboxFetchResultId = null;
|
||||
|
||||
public ?string $mailboxFetchSummary = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->mailConfig = [
|
||||
'smtpEnabled' => Settings::bool('mail_smtp_enabled'),
|
||||
'smtpHost' => Settings::get('mail_smtp_host'),
|
||||
'smtpPort' => Settings::get('mail_smtp_port'),
|
||||
'smtpUsername' => Settings::get('mail_smtp_username'),
|
||||
'smtpPassword' => Settings::get('mail_smtp_password'),
|
||||
'smtpEncryption' => Settings::get('mail_smtp_encryption'),
|
||||
'fromAddress' => Settings::get('mail_from_address'),
|
||||
'fromName' => Settings::get('mail_from_name'),
|
||||
];
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function mailboxes(): Collection
|
||||
{
|
||||
return ImapMailbox::query()->with(['defaultSubcategory.category', 'defaultCategory'])->orderBy('name')->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Categories with their subcategories nested, for the mailbox form's
|
||||
* single combined "cała kategoria albo konkretna podkategoria" selector.
|
||||
*/
|
||||
#[Computed]
|
||||
public function categoryOptions(): Collection
|
||||
{
|
||||
return Category::query()->with('subcategories')->orderBy('name')->get()
|
||||
->map(fn (Category $c) => [
|
||||
'id' => $c->id,
|
||||
'name' => $c->name,
|
||||
'subcategories' => $c->subcategories->map(fn ($s) => ['id' => $s->id, 'name' => $s->name])->values(),
|
||||
])
|
||||
->values();
|
||||
}
|
||||
|
||||
// ===================== SMTP =====================
|
||||
|
||||
public function saveMailConfig(): void
|
||||
{
|
||||
Settings::set('mail_smtp_enabled', $this->mailConfig['smtpEnabled'] ? '1' : '0');
|
||||
Settings::set('mail_smtp_host', $this->mailConfig['smtpHost']);
|
||||
Settings::set('mail_smtp_port', (string) $this->mailConfig['smtpPort']);
|
||||
Settings::set('mail_smtp_username', $this->mailConfig['smtpUsername']);
|
||||
|
||||
if ($this->mailConfig['smtpPassword']) {
|
||||
Settings::set('mail_smtp_password', $this->mailConfig['smtpPassword']);
|
||||
}
|
||||
|
||||
Settings::set('mail_smtp_encryption', $this->mailConfig['smtpEncryption']);
|
||||
Settings::set('mail_from_address', $this->mailConfig['fromAddress']);
|
||||
Settings::set('mail_from_name', $this->mailConfig['fromName']);
|
||||
|
||||
$this->mailTestResult = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a real test e-mail to the logged-in admin using the form's
|
||||
* current (unsaved) values, temporarily overriding the mail config the
|
||||
* same way AppServiceProvider does for real once saved.
|
||||
*/
|
||||
public function testMailConnection(): void
|
||||
{
|
||||
$cfg = $this->mailConfig;
|
||||
|
||||
if (empty($cfg['smtpHost']) || empty($cfg['fromAddress'])) {
|
||||
$this->mailTestResult = 'error';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$original = Config::get('mail');
|
||||
|
||||
try {
|
||||
Config::set('mail.default', 'smtp');
|
||||
Config::set('mail.mailers.smtp.host', $cfg['smtpHost']);
|
||||
Config::set('mail.mailers.smtp.port', (int) $cfg['smtpPort']);
|
||||
Config::set('mail.mailers.smtp.username', $cfg['smtpUsername'] ?: null);
|
||||
Config::set('mail.mailers.smtp.password', $cfg['smtpPassword'] ?: Settings::get('mail_smtp_password'));
|
||||
Config::set('mail.mailers.smtp.scheme', match ($cfg['smtpEncryption']) {
|
||||
'ssl' => 'smtps',
|
||||
'tls' => 'smtp',
|
||||
default => null,
|
||||
});
|
||||
Config::set('mail.from.address', $cfg['fromAddress']);
|
||||
Config::set('mail.from.name', $cfg['fromName'] ?: Settings::get('company_name'));
|
||||
|
||||
app()->forgetInstance('mail.manager');
|
||||
app()->forgetInstance('mailer');
|
||||
|
||||
Mail::raw('To jest testowa wiadomość wysłana z panelu administratora Servicedesk.', function ($message) {
|
||||
$message->to(Auth::user()->email)->subject('Test konfiguracji SMTP');
|
||||
});
|
||||
|
||||
$this->mailTestResult = 'ok';
|
||||
} catch (\Throwable) {
|
||||
$this->mailTestResult = 'error';
|
||||
} finally {
|
||||
Config::set('mail', $original);
|
||||
app()->forgetInstance('mail.manager');
|
||||
app()->forgetInstance('mailer');
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== IMAP MAILBOXES =====================
|
||||
|
||||
public function openMailboxForm(): void
|
||||
{
|
||||
$this->reset('mailboxForm');
|
||||
$this->mailboxForm = [
|
||||
'id' => null,
|
||||
'name' => '',
|
||||
'enabled' => true,
|
||||
'host' => '',
|
||||
'port' => 993,
|
||||
'encryption' => 'ssl',
|
||||
'validateCert' => true,
|
||||
'username' => '',
|
||||
'password' => '',
|
||||
'folder' => 'INBOX',
|
||||
'processedFolder' => '',
|
||||
'rejectedFolder' => '',
|
||||
'target' => '',
|
||||
'blocklistSenders' => 'mailer-daemon,postmaster,no-reply,noreply',
|
||||
];
|
||||
$this->mailboxTestResultId = null;
|
||||
$this->resetErrorBag();
|
||||
$this->mailboxFormOpen = true;
|
||||
}
|
||||
|
||||
public function editMailbox(int $id): void
|
||||
{
|
||||
$mailbox = ImapMailbox::query()->findOrFail($id);
|
||||
|
||||
$target = match (true) {
|
||||
(bool) $mailbox->default_subcategory_id => "subcategory:{$mailbox->default_subcategory_id}",
|
||||
(bool) $mailbox->default_category_id => "category:{$mailbox->default_category_id}",
|
||||
default => '',
|
||||
};
|
||||
|
||||
$this->mailboxForm = [
|
||||
'id' => $mailbox->id,
|
||||
'name' => $mailbox->name,
|
||||
'enabled' => $mailbox->enabled,
|
||||
'host' => $mailbox->host,
|
||||
'port' => $mailbox->port,
|
||||
'encryption' => $mailbox->encryption,
|
||||
'validateCert' => $mailbox->validate_cert,
|
||||
'username' => $mailbox->username,
|
||||
'password' => $mailbox->password,
|
||||
'folder' => $mailbox->folder,
|
||||
'processedFolder' => $mailbox->processed_folder,
|
||||
'rejectedFolder' => $mailbox->rejected_folder,
|
||||
'target' => $target,
|
||||
'blocklistSenders' => $mailbox->blocklist_senders,
|
||||
];
|
||||
$this->mailboxTestResultId = null;
|
||||
$this->resetErrorBag();
|
||||
$this->mailboxFormOpen = true;
|
||||
}
|
||||
|
||||
public function closeMailboxForm(): void
|
||||
{
|
||||
$this->mailboxFormOpen = false;
|
||||
}
|
||||
|
||||
public function submitMailboxForm(): void
|
||||
{
|
||||
$this->validate([
|
||||
'mailboxForm.name' => ['required', 'string', 'max:255'],
|
||||
'mailboxForm.host' => ['required', 'string', 'max:255'],
|
||||
'mailboxForm.port' => ['required', 'integer', 'min:1', 'max:65535'],
|
||||
'mailboxForm.encryption' => ['required', 'in:ssl,tls,none'],
|
||||
'mailboxForm.username' => ['required', 'string', 'max:255'],
|
||||
'mailboxForm.folder' => ['required', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
[$targetType, $targetId] = str_contains((string) $this->mailboxForm['target'], ':')
|
||||
? explode(':', $this->mailboxForm['target'], 2)
|
||||
: [null, null];
|
||||
|
||||
$data = [
|
||||
'name' => $this->mailboxForm['name'],
|
||||
'enabled' => (bool) $this->mailboxForm['enabled'],
|
||||
'host' => $this->mailboxForm['host'],
|
||||
'port' => (int) $this->mailboxForm['port'],
|
||||
'encryption' => $this->mailboxForm['encryption'],
|
||||
'validate_cert' => (bool) $this->mailboxForm['validateCert'],
|
||||
'username' => $this->mailboxForm['username'],
|
||||
'folder' => $this->mailboxForm['folder'],
|
||||
'processed_folder' => $this->mailboxForm['processedFolder'] ?: null,
|
||||
'rejected_folder' => $this->mailboxForm['rejectedFolder'] ?: null,
|
||||
// Exactly one of these (or neither) — never both — driven by the
|
||||
// form's single "cała kategoria albo konkretna podkategoria" selector.
|
||||
'default_subcategory_id' => $targetType === 'subcategory' ? $targetId : null,
|
||||
'default_category_id' => $targetType === 'category' ? $targetId : null,
|
||||
'blocklist_senders' => $this->mailboxForm['blocklistSenders'],
|
||||
];
|
||||
|
||||
$mailbox = ImapMailbox::query()->find($this->mailboxForm['id']);
|
||||
|
||||
if ($mailbox) {
|
||||
if ($this->mailboxForm['password']) {
|
||||
$data['password'] = $this->mailboxForm['password'];
|
||||
}
|
||||
$mailbox->update($data);
|
||||
} else {
|
||||
$data['password'] = $this->mailboxForm['password'];
|
||||
ImapMailbox::query()->create($data);
|
||||
}
|
||||
|
||||
$this->mailboxFormOpen = false;
|
||||
unset($this->mailboxes);
|
||||
}
|
||||
|
||||
public function toggleMailboxEnabled(int $id): void
|
||||
{
|
||||
$mailbox = ImapMailbox::query()->findOrFail($id);
|
||||
$mailbox->update(['enabled' => ! $mailbox->enabled]);
|
||||
unset($this->mailboxes);
|
||||
}
|
||||
|
||||
public function removeMailbox(int $id): void
|
||||
{
|
||||
ImapMailbox::query()->findOrFail($id)->delete();
|
||||
unset($this->mailboxes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a real fetch against one mailbox right now, outside the 5-minute
|
||||
* schedule — for checking a freshly-configured mailbox without waiting,
|
||||
* and for diagnosing "why didn't my e-mail turn into a ticket" without
|
||||
* needing shell access. Allowed even while the mailbox is disabled
|
||||
* (fetchAll(), used by the scheduled command, is the one that respects
|
||||
* the enabled flag — this is an explicit admin action).
|
||||
*/
|
||||
public function fetchMailboxNow(int $id): void
|
||||
{
|
||||
$mailbox = ImapMailbox::query()->findOrFail($id);
|
||||
$result = app(ImapMailboxFetcher::class)->fetchMailbox($mailbox);
|
||||
|
||||
$this->mailboxFetchResultId = $id;
|
||||
$this->mailboxFetchSummary = "Nowe: {$result['created']}, odpowiedzi: {$result['replied']}, odrzucone: {$result['rejected']}, błędy: {$result['errors']}.";
|
||||
unset($this->mailboxes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the form's current (unsaved) values against a throwaway
|
||||
* ImapMailbox instance — mirrors testMailConnection()'s "don't require a
|
||||
* save first" behavior. Falls back to the stored password when editing
|
||||
* an existing mailbox and the password field was left blank.
|
||||
*/
|
||||
public function testMailboxConnection(): void
|
||||
{
|
||||
$mailbox = new ImapMailbox([
|
||||
'host' => $this->mailboxForm['host'],
|
||||
'port' => (int) $this->mailboxForm['port'],
|
||||
'encryption' => $this->mailboxForm['encryption'],
|
||||
'validate_cert' => (bool) $this->mailboxForm['validateCert'],
|
||||
'username' => $this->mailboxForm['username'],
|
||||
'folder' => $this->mailboxForm['folder'] ?: 'INBOX',
|
||||
]);
|
||||
|
||||
$mailbox->password = $this->mailboxForm['password']
|
||||
?: ($this->mailboxForm['id'] ? ImapMailbox::query()->find($this->mailboxForm['id'])?->password : null);
|
||||
|
||||
$error = app(ImapMailboxFetcher::class)->testConnection($mailbox);
|
||||
|
||||
$this->mailboxTestResultId = (int) ($this->mailboxForm['id'] ?? 0);
|
||||
$this->mailboxTestResult = $error === null ? 'ok' : 'error';
|
||||
$this->mailboxTestMessage = $error;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin.mail-settings');
|
||||
}
|
||||
}
|
||||
@@ -14,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;
|
||||
@@ -21,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;
|
||||
|
||||
@@ -33,6 +33,7 @@ class Panel extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
#[Url]
|
||||
public string $tab = 'categories';
|
||||
|
||||
// ---- categories ----
|
||||
@@ -145,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;
|
||||
@@ -179,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 = [
|
||||
@@ -195,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'),
|
||||
@@ -1355,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 =====================
|
||||
@@ -1492,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
|
||||
|
||||
342
src/app/Livewire/Admin/Triggers.php
Normal file
342
src/app/Livewire/Admin/Triggers.php
Normal file
@@ -0,0 +1,342 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Models\Priority;
|
||||
use App\Models\Status;
|
||||
use App\Models\Subcategory;
|
||||
use App\Models\Team;
|
||||
use App\Models\Trigger;
|
||||
use App\Models\TriggerEmailTemplate;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
|
||||
class Triggers extends Component
|
||||
{
|
||||
public bool $formOpen = false;
|
||||
|
||||
public ?int $editingId = null;
|
||||
|
||||
public array $form = [
|
||||
'name' => '',
|
||||
'enabled' => true,
|
||||
'event' => 'ticket_created',
|
||||
'conditions' => [],
|
||||
'actions' => [],
|
||||
];
|
||||
|
||||
public bool $templateFormOpen = false;
|
||||
|
||||
public ?int $editingTemplateId = null;
|
||||
|
||||
public array $templateForm = ['name' => '', 'subject' => '', 'body' => ''];
|
||||
|
||||
public static function eventLabels(): array
|
||||
{
|
||||
return [
|
||||
'ticket_created' => 'Zgłoszenie utworzone',
|
||||
'ticket_updated' => 'Zgłoszenie zaktualizowane (dowolne pole)',
|
||||
'status_changed' => 'Zmiana statusu',
|
||||
'priority_changed' => 'Zmiana priorytetu',
|
||||
'assignee_changed' => 'Zmiana przypisanego operatora',
|
||||
'team_changed' => 'Zmiana zespołu',
|
||||
'category_changed' => 'Zmiana kategorii',
|
||||
'comment_added' => 'Nowa wiadomość (publiczna)',
|
||||
];
|
||||
}
|
||||
|
||||
public static function fieldLabels(): array
|
||||
{
|
||||
return [
|
||||
'status_key' => 'Status',
|
||||
'priority_key' => 'Priorytet',
|
||||
'team_id' => 'Zespół',
|
||||
'subcategory_id' => 'Podkategoria',
|
||||
'assignee_id' => 'Operator przypisany',
|
||||
'customer_id' => 'Zgłaszający',
|
||||
'subject' => 'Temat',
|
||||
'body' => 'Treść',
|
||||
];
|
||||
}
|
||||
|
||||
public static function operatorLabels(): array
|
||||
{
|
||||
return [
|
||||
'equals' => 'jest równe',
|
||||
'not_equals' => 'jest różne od',
|
||||
'is_empty' => 'jest puste',
|
||||
'is_not_empty' => 'nie jest puste',
|
||||
'contains' => 'zawiera',
|
||||
];
|
||||
}
|
||||
|
||||
public static function actionTypeLabels(): array
|
||||
{
|
||||
return [
|
||||
'set_status' => 'Ustaw status',
|
||||
'set_priority' => 'Ustaw priorytet',
|
||||
'set_team' => 'Ustaw zespół',
|
||||
'set_assignee' => 'Ustaw operatora',
|
||||
'send_notification' => 'Wyślij powiadomienie e-mail',
|
||||
];
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function triggers(): Collection
|
||||
{
|
||||
return Trigger::query()->orderBy('sort_order')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function statuses(): Collection
|
||||
{
|
||||
return Status::query()->orderBy('sort_order')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function priorities(): Collection
|
||||
{
|
||||
return Priority::query()->orderBy('sort_order')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function teams(): Collection
|
||||
{
|
||||
return Team::query()->orderBy('name')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function operators(): Collection
|
||||
{
|
||||
return User::query()->whereHas('roleAssignments', fn ($q) => $q->whereIn('key', ['operator', 'admin']))->orderBy('name')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function subcategories(): Collection
|
||||
{
|
||||
return Subcategory::query()->with('category')->get()
|
||||
->sortBy(fn (Subcategory $s) => $s->category->name.' / '.$s->name, SORT_NATURAL | SORT_FLAG_CASE)
|
||||
->values();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function customers(): Collection
|
||||
{
|
||||
return User::query()->whereHas('roleAssignments', fn ($q) => $q->where('key', 'client'))->orderBy('name')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function emailTemplates(): Collection
|
||||
{
|
||||
return TriggerEmailTemplate::query()->orderBy('name')->get();
|
||||
}
|
||||
|
||||
public function openForm(): void
|
||||
{
|
||||
$this->editingId = null;
|
||||
$this->form = ['name' => '', 'enabled' => true, 'event' => 'ticket_created', 'conditions' => [], 'actions' => []];
|
||||
$this->resetErrorBag();
|
||||
$this->formOpen = true;
|
||||
}
|
||||
|
||||
public function editTrigger(int $id): void
|
||||
{
|
||||
$trigger = Trigger::query()->findOrFail($id);
|
||||
|
||||
$this->editingId = $trigger->id;
|
||||
$this->form = [
|
||||
'name' => $trigger->name,
|
||||
'enabled' => $trigger->enabled,
|
||||
'event' => $trigger->event,
|
||||
'conditions' => $trigger->conditions,
|
||||
'actions' => $trigger->actions,
|
||||
];
|
||||
$this->resetErrorBag();
|
||||
$this->formOpen = true;
|
||||
}
|
||||
|
||||
public function closeForm(): void
|
||||
{
|
||||
$this->formOpen = false;
|
||||
}
|
||||
|
||||
public function addCondition(): void
|
||||
{
|
||||
$this->form['conditions'][] = ['field' => Trigger::CONDITION_FIELDS[0], 'operator' => 'equals', 'value' => ''];
|
||||
}
|
||||
|
||||
public function removeCondition(int $index): void
|
||||
{
|
||||
unset($this->form['conditions'][$index]);
|
||||
$this->form['conditions'] = array_values($this->form['conditions']);
|
||||
}
|
||||
|
||||
public function addAction(): void
|
||||
{
|
||||
$this->form['actions'][] = ['type' => Trigger::ACTION_TYPES[0], 'value' => '', 'recipient' => 'client', 'email_template_id' => ''];
|
||||
}
|
||||
|
||||
public function removeAction(int $index): void
|
||||
{
|
||||
unset($this->form['actions'][$index]);
|
||||
$this->form['actions'] = array_values($this->form['actions']);
|
||||
}
|
||||
|
||||
public function moveActionUp(int $index): void
|
||||
{
|
||||
$this->swapFormActions($index, $index - 1);
|
||||
}
|
||||
|
||||
public function moveActionDown(int $index): void
|
||||
{
|
||||
$this->swapFormActions($index, $index + 1);
|
||||
}
|
||||
|
||||
protected function swapFormActions(int $a, int $b): void
|
||||
{
|
||||
if ($b < 0 || $b >= count($this->form['actions'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
[$this->form['actions'][$a], $this->form['actions'][$b]] = [$this->form['actions'][$b], $this->form['actions'][$a]];
|
||||
}
|
||||
|
||||
public function submit(): void
|
||||
{
|
||||
$this->validate([
|
||||
'form.name' => ['required', 'string', 'max:255'],
|
||||
'form.event' => ['required', 'string', 'in:'.implode(',', Trigger::EVENTS)],
|
||||
'form.conditions' => ['array'],
|
||||
'form.conditions.*.field' => ['required', 'string', 'in:'.implode(',', Trigger::CONDITION_FIELDS)],
|
||||
'form.conditions.*.operator' => ['required', 'string', 'in:'.implode(',', Trigger::CONDITION_OPERATORS)],
|
||||
'form.actions' => ['required', 'array', 'min:1'],
|
||||
'form.actions.*.type' => ['required', 'string', 'in:'.implode(',', Trigger::ACTION_TYPES)],
|
||||
]);
|
||||
|
||||
$data = [
|
||||
'name' => $this->form['name'],
|
||||
'enabled' => (bool) $this->form['enabled'],
|
||||
'event' => $this->form['event'],
|
||||
'conditions' => array_values($this->form['conditions']),
|
||||
'actions' => array_values($this->form['actions']),
|
||||
];
|
||||
|
||||
if ($this->editingId) {
|
||||
Trigger::query()->findOrFail($this->editingId)->update($data);
|
||||
} else {
|
||||
$data['sort_order'] = (Trigger::query()->max('sort_order') ?? 0) + 1;
|
||||
Trigger::query()->create($data);
|
||||
}
|
||||
|
||||
$this->formOpen = false;
|
||||
unset($this->triggers);
|
||||
}
|
||||
|
||||
public function toggleEnabled(int $id): void
|
||||
{
|
||||
$trigger = Trigger::query()->findOrFail($id);
|
||||
$trigger->update(['enabled' => ! $trigger->enabled]);
|
||||
unset($this->triggers);
|
||||
}
|
||||
|
||||
public function removeTrigger(int $id): void
|
||||
{
|
||||
Trigger::query()->findOrFail($id)->delete();
|
||||
unset($this->triggers);
|
||||
}
|
||||
|
||||
public function moveUp(int $id): void
|
||||
{
|
||||
$this->swapAdjacentSortOrder($id, -1);
|
||||
}
|
||||
|
||||
public function moveDown(int $id): void
|
||||
{
|
||||
$this->swapAdjacentSortOrder($id, 1);
|
||||
}
|
||||
|
||||
protected function swapAdjacentSortOrder(int $id, int $direction): void
|
||||
{
|
||||
$ordered = $this->triggers;
|
||||
$index = $ordered->search(fn ($row) => $row->id === $id);
|
||||
$swapIndex = $index + $direction;
|
||||
|
||||
if ($index === false || $swapIndex < 0 || $swapIndex >= $ordered->count()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$row = $ordered[$index];
|
||||
$neighbor = $ordered[$swapIndex];
|
||||
|
||||
[$rowOrder, $neighborOrder] = [$row->sort_order, $neighbor->sort_order];
|
||||
$row->update(['sort_order' => $neighborOrder]);
|
||||
$neighbor->update(['sort_order' => $rowOrder]);
|
||||
|
||||
unset($this->triggers);
|
||||
}
|
||||
|
||||
// ===================== TEMPLATES (wyzwalaczy) =====================
|
||||
|
||||
public function openTemplateForm(): void
|
||||
{
|
||||
$this->editingTemplateId = null;
|
||||
$this->templateForm = ['name' => '', 'subject' => '', 'body' => ''];
|
||||
$this->resetErrorBag();
|
||||
$this->templateFormOpen = true;
|
||||
}
|
||||
|
||||
public function editTemplate(int $id): void
|
||||
{
|
||||
$template = TriggerEmailTemplate::query()->findOrFail($id);
|
||||
|
||||
$this->editingTemplateId = $template->id;
|
||||
$this->templateForm = [
|
||||
'name' => $template->name,
|
||||
'subject' => $template->subject,
|
||||
'body' => $template->body,
|
||||
];
|
||||
$this->resetErrorBag();
|
||||
$this->templateFormOpen = true;
|
||||
}
|
||||
|
||||
public function closeTemplateForm(): void
|
||||
{
|
||||
$this->templateFormOpen = false;
|
||||
}
|
||||
|
||||
public function setTemplateBodyDraft(string $value): void
|
||||
{
|
||||
$this->templateForm['body'] = $value;
|
||||
}
|
||||
|
||||
public function submitTemplate(): void
|
||||
{
|
||||
$this->validate([
|
||||
'templateForm.name' => ['required', 'string', 'max:255'],
|
||||
'templateForm.subject' => ['required', 'string', 'max:255'],
|
||||
'templateForm.body' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
if ($this->editingTemplateId) {
|
||||
TriggerEmailTemplate::query()->findOrFail($this->editingTemplateId)->update($this->templateForm);
|
||||
} else {
|
||||
TriggerEmailTemplate::query()->create($this->templateForm);
|
||||
}
|
||||
|
||||
$this->templateFormOpen = false;
|
||||
unset($this->emailTemplates);
|
||||
}
|
||||
|
||||
public function removeTemplate(int $id): void
|
||||
{
|
||||
TriggerEmailTemplate::query()->findOrFail($id)->delete();
|
||||
unset($this->emailTemplates);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin.triggers');
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,23 @@ namespace App\Livewire;
|
||||
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
class NotificationBell extends Component
|
||||
{
|
||||
/**
|
||||
* Fired by echo.js the moment a NotificationCreated broadcast arrives on
|
||||
* this user's private channel — refreshes the badge/list instantly
|
||||
* instead of waiting for the next 30s poll, which stays in place below
|
||||
* as a fallback for dropped websocket connections.
|
||||
*/
|
||||
#[On('bell-notification-received')]
|
||||
public function onBellNotification(): void
|
||||
{
|
||||
unset($this->notifications, $this->unreadCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only unread — once a notification is read (clicked through, or via
|
||||
* "mark all as read"), it disappears from the bell rather than staying
|
||||
|
||||
@@ -18,6 +18,7 @@ use Livewire\Component;
|
||||
|
||||
class Queue extends Component
|
||||
{
|
||||
#[Url]
|
||||
public string $queue = 'all';
|
||||
|
||||
public string $filterStatus = 'all';
|
||||
@@ -297,7 +298,13 @@ class Queue extends Component
|
||||
$query->where('priority_key', $this->filterPriority);
|
||||
}
|
||||
if ($this->filterCategory !== 'all') {
|
||||
$query->whereHas('subcategory', fn ($q) => $q->where('category_id', $this->filterCategory));
|
||||
// A ticket carries a category either via its subcategory or,
|
||||
// when routed to a whole category with no subcategory (e.g. an
|
||||
// IMAP mailbox), directly on tickets.category_id.
|
||||
$query->where(function ($q) {
|
||||
$q->whereHas('subcategory', fn ($sq) => $sq->where('category_id', $this->filterCategory))
|
||||
->orWhere('category_id', $this->filterCategory);
|
||||
});
|
||||
}
|
||||
if ($this->filterCustomerId) {
|
||||
$query->where('customer_id', $this->filterCustomerId);
|
||||
@@ -306,7 +313,7 @@ class Queue extends Component
|
||||
$query->search($this->search);
|
||||
}
|
||||
|
||||
$tickets = $query->with(['subcategory.category', 'assignee', 'priority', 'status'])->get();
|
||||
$tickets = $query->with(['subcategory.category', 'category', 'assignee', 'priority', 'status', 'team'])->get();
|
||||
|
||||
return $this->sortTickets($tickets);
|
||||
}
|
||||
@@ -330,6 +337,9 @@ class Queue extends Component
|
||||
'priority' => $tickets->sortBy(fn (Ticket $t) => $t->priority?->sort_order ?? PHP_INT_MAX, SORT_REGULAR, $desc),
|
||||
'status' => $tickets->sortBy(fn (Ticket $t) => $t->status?->sort_order ?? PHP_INT_MAX, SORT_REGULAR, $desc),
|
||||
'assignee' => $tickets->sortBy(fn (Ticket $t) => $t->assignee?->name ?? '', SORT_NATURAL | SORT_FLAG_CASE, $desc),
|
||||
'team' => $tickets->sortBy(fn (Ticket $t) => $t->team?->name ?? '', SORT_NATURAL | SORT_FLAG_CASE, $desc),
|
||||
'subcategory' => $tickets->sortBy(fn (Ticket $t) => $t->subcategory?->name ?? '', SORT_NATURAL | SORT_FLAG_CASE, $desc),
|
||||
'created' => $tickets->sortBy(fn (Ticket $t) => $t->created_at, SORT_REGULAR, $desc),
|
||||
default => $tickets->sortBy('updated_at', SORT_REGULAR, $desc),
|
||||
};
|
||||
|
||||
@@ -346,10 +356,13 @@ class Queue extends Component
|
||||
'subject' => 'Temat',
|
||||
'customer' => 'Klient',
|
||||
'category' => 'Kategoria',
|
||||
'subcategory' => 'Podkategoria',
|
||||
'priority' => 'Priorytet',
|
||||
'status' => 'Status',
|
||||
'sla' => 'SLA',
|
||||
'assignee' => 'Przypisany',
|
||||
'team' => 'Zespół',
|
||||
'created' => 'Utworzono',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -360,7 +373,7 @@ class Queue extends Component
|
||||
*/
|
||||
public function sortableColumns(): array
|
||||
{
|
||||
return ['number', 'subject', 'customer', 'category', 'priority', 'status', 'assignee'];
|
||||
return ['number', 'subject', 'customer', 'category', 'subcategory', 'priority', 'status', 'assignee', 'team', 'created'];
|
||||
}
|
||||
|
||||
public function sortByColumn(string $column): void
|
||||
@@ -418,6 +431,20 @@ class Queue extends Component
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects every ticket currently visible under the active filters/queue
|
||||
* (not every ticket in the system) — toggles off if all of them are
|
||||
* already selected, matching the usual "header checkbox" convention.
|
||||
*/
|
||||
public function toggleSelectAll(): void
|
||||
{
|
||||
$visibleIds = $this->filteredTickets->pluck('id')->all();
|
||||
|
||||
$this->selectedIds = empty(array_diff($visibleIds, $this->selectedIds))
|
||||
? array_values(array_diff($this->selectedIds, $visibleIds))
|
||||
: array_values(array_unique(array_merge($this->selectedIds, $visibleIds)));
|
||||
}
|
||||
|
||||
public function mergeSelected(): void
|
||||
{
|
||||
$ids = $this->selectedIdsInScope();
|
||||
|
||||
@@ -605,7 +605,7 @@ class Stats extends Component
|
||||
|
||||
foreach ($tickets as $ticket) {
|
||||
fputcsv($out, [
|
||||
$ticket->number,
|
||||
$ticket->displayNumber(),
|
||||
$ticket->subject,
|
||||
$ticket->statusLabel(),
|
||||
$ticket->priorityLabel(),
|
||||
|
||||
@@ -91,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
|
||||
|
||||
44
src/app/Livewire/Settings/NotificationPreferences.php
Normal file
44
src/app/Livewire/Settings/NotificationPreferences.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Models\NotificationPreference;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Component;
|
||||
|
||||
class NotificationPreferences extends Component
|
||||
{
|
||||
protected const SCOPE_FIELDS = ['scope_mine', 'scope_unassigned', 'scope_watched', 'scope_all', 'email'];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
abort_unless(Auth::user()->isOperator() || Auth::user()->isAdmin(), 403);
|
||||
}
|
||||
|
||||
public function rows(): array
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
return collect(NotificationPreference::CATEGORIES)
|
||||
->mapWithKeys(fn (string $category) => [$category => NotificationPreference::rowFor($user, $category)])
|
||||
->all();
|
||||
}
|
||||
|
||||
public function toggle(string $category, string $field): void
|
||||
{
|
||||
abort_unless(in_array($category, NotificationPreference::CATEGORIES, true), 404);
|
||||
abort_unless(in_array($field, self::SCOPE_FIELDS, true), 404);
|
||||
|
||||
$preference = NotificationPreference::query()->firstOrCreate(
|
||||
['user_id' => Auth::id(), 'event_category' => $category],
|
||||
array_merge(['user_id' => Auth::id(), 'event_category' => $category], NotificationPreference::DEFAULTS[$category])
|
||||
);
|
||||
|
||||
$preference->update([$field => ! $preference->$field]);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.settings.notification-preferences', ['rows' => $this->rows()]);
|
||||
}
|
||||
}
|
||||
65
src/app/Models/ImapMailbox.php
Normal file
65
src/app/Models/ImapMailbox.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* One inbound mailbox polled by `emails:fetch-imap` — an admin can configure
|
||||
* several (e.g. zgloszenia-it@ vs zgloszenia-delegacje@), each landing new
|
||||
* tickets in its own default subcategory. Unlike LDAP/SMTP/BookStack, this is
|
||||
* a list of N configs rather than a Settings singleton, so it's a real model
|
||||
* rather than key/value rows.
|
||||
*/
|
||||
#[Fillable([
|
||||
'name', 'enabled', 'host', 'port', 'encryption', 'validate_cert',
|
||||
'username', 'password', 'folder', 'processed_folder', 'rejected_folder',
|
||||
'default_subcategory_id', 'default_category_id', 'blocklist_senders', 'last_checked_at', 'last_error',
|
||||
])]
|
||||
class ImapMailbox extends Model
|
||||
{
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => 'boolean',
|
||||
'validate_cert' => 'boolean',
|
||||
'password' => 'encrypted',
|
||||
'last_checked_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function defaultSubcategory(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Subcategory::class, 'default_subcategory_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Only meaningful when default_subcategory_id is null — a mailbox is
|
||||
* routed to either a specific subcategory or a whole category, never
|
||||
* both (enforced by the admin form's single combined selector).
|
||||
*/
|
||||
public function defaultCategory(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class, 'default_category_id');
|
||||
}
|
||||
|
||||
public function blocklistedSenders(): array
|
||||
{
|
||||
return array_filter(array_map('trim', explode(',', (string) $this->blocklist_senders)));
|
||||
}
|
||||
|
||||
public function targetLabel(): string
|
||||
{
|
||||
if ($this->defaultSubcategory) {
|
||||
return $this->defaultSubcategory->category->name.' / '.$this->defaultSubcategory->name;
|
||||
}
|
||||
|
||||
if ($this->defaultCategory) {
|
||||
return 'Cała kategoria: '.$this->defaultCategory->name;
|
||||
}
|
||||
|
||||
return '—';
|
||||
}
|
||||
}
|
||||
68
src/app/Models/NotificationPreference.php
Normal file
68
src/app/Models/NotificationPreference.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable(['user_id', 'event_category', 'scope_mine', 'scope_unassigned', 'scope_watched', 'scope_all', 'email'])]
|
||||
class NotificationPreference extends Model
|
||||
{
|
||||
public const CATEGORIES = ['new_ticket', 'ticket_update', 'escalation'];
|
||||
|
||||
/**
|
||||
* Applied whenever a user has never touched a given row — including
|
||||
* every user created after this feature ships (new hires, LDAP JIT
|
||||
* provisioning). new_ticket defaults to exactly what
|
||||
* TicketService::notifyOperatorsForNewTicket() used to do
|
||||
* unconditionally (notify every relevant operator, by mail and bell),
|
||||
* so shipping this feature doesn't silently change what the existing
|
||||
* admin account already receives. ticket_update/escalation have no
|
||||
* current staff-facing equivalent, so any default there is purely
|
||||
* additive rather than a behavior change.
|
||||
*/
|
||||
public const DEFAULTS = [
|
||||
'new_ticket' => ['scope_mine' => false, 'scope_unassigned' => false, 'scope_watched' => false, 'scope_all' => true, 'email' => true],
|
||||
'ticket_update' => ['scope_mine' => true, 'scope_unassigned' => false, 'scope_watched' => true, 'scope_all' => false, 'email' => false],
|
||||
'escalation' => ['scope_mine' => true, 'scope_unassigned' => false, 'scope_watched' => true, 'scope_all' => false, 'email' => true],
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'scope_mine' => 'boolean',
|
||||
'scope_unassigned' => 'boolean',
|
||||
'scope_watched' => 'boolean',
|
||||
'scope_all' => 'boolean',
|
||||
'email' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Always returns a usable row — the persisted one if the user has ever
|
||||
* toggled this category, DEFAULTS[$category] otherwise — so callers
|
||||
* never need to null-check.
|
||||
*/
|
||||
public static function rowFor(User $user, string $category): array
|
||||
{
|
||||
$row = static::query()->where('user_id', $user->id)->where('event_category', $category)->first();
|
||||
|
||||
if (! $row) {
|
||||
return static::DEFAULTS[$category];
|
||||
}
|
||||
|
||||
return [
|
||||
'scope_mine' => $row->scope_mine,
|
||||
'scope_unassigned' => $row->scope_unassigned,
|
||||
'scope_watched' => $row->scope_watched,
|
||||
'scope_all' => $row->scope_all,
|
||||
'email' => $row->email,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,22 +2,38 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
#[Fillable([
|
||||
'number', 'customer_id', 'email', 'name', 'subcategory_id', 'subject', 'body',
|
||||
'status_key', 'priority_key', 'team_id', 'assignee_id', 'custom_fields', 'api_client_id',
|
||||
'number', 'checksum', 'customer_id', 'email', 'name', 'subcategory_id', 'category_id', 'subject', 'body',
|
||||
'status_key', 'priority_key', 'team_id', 'assignee_id', 'custom_fields', 'api_client_id', 'source',
|
||||
'sla_notified_at', 'last_customer_activity_at', 'time_spent_seconds', 'timer_started_at',
|
||||
'created_at', 'updated_at', 'csat_rating', 'csat_comment', 'csat_rated_at',
|
||||
])]
|
||||
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 [
|
||||
@@ -56,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');
|
||||
@@ -103,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 ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,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);
|
||||
|
||||
@@ -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
|
||||
|
||||
33
src/app/Models/Trigger.php
Normal file
33
src/app/Models/Trigger.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
#[Fillable(['name', 'enabled', 'event', 'conditions', 'actions', 'sort_order'])]
|
||||
class Trigger extends Model
|
||||
{
|
||||
public const EVENTS = [
|
||||
'ticket_created', 'ticket_updated', 'status_changed', 'priority_changed',
|
||||
'assignee_changed', 'team_changed', 'category_changed', 'comment_added',
|
||||
];
|
||||
|
||||
public const CONDITION_FIELDS = [
|
||||
'status_key', 'priority_key', 'team_id', 'subcategory_id', 'assignee_id', 'customer_id', 'subject', 'body',
|
||||
];
|
||||
|
||||
public const CONDITION_OPERATORS = ['equals', 'not_equals', 'is_empty', 'is_not_empty', 'contains'];
|
||||
|
||||
public const ACTION_TYPES = ['set_status', 'set_priority', 'set_team', 'set_assignee', 'send_notification'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => 'boolean',
|
||||
'conditions' => 'array',
|
||||
'actions' => 'array',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
}
|
||||
}
|
||||
26
src/app/Models/TriggerEmailTemplate.php
Normal file
26
src/app/Models/TriggerEmailTemplate.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
#[Fillable(['name', 'subject', 'body'])]
|
||||
class TriggerEmailTemplate extends Model
|
||||
{
|
||||
public function render(array $placeholders): array
|
||||
{
|
||||
$replace = function (string $text) use ($placeholders): string {
|
||||
foreach ($placeholders as $key => $value) {
|
||||
$text = str_replace('{'.$key.'}', (string) $value, $text);
|
||||
}
|
||||
|
||||
return $text;
|
||||
};
|
||||
|
||||
return [
|
||||
'subject' => $replace($this->subject),
|
||||
'body' => $replace($this->body),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -184,6 +184,11 @@ class User extends Authenticatable implements LdapAuthenticatable
|
||||
return $this->hasMany(Ticket::class, 'customer_id');
|
||||
}
|
||||
|
||||
public function watchedTickets(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Ticket::class, 'ticket_watchers');
|
||||
}
|
||||
|
||||
public function ticketsAssigned(): HasMany
|
||||
{
|
||||
return $this->hasMany(Ticket::class, 'assignee_id');
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
282
src/app/Services/ImapMailboxFetcher.php
Normal file
282
src/app/Services/ImapMailboxFetcher.php
Normal file
@@ -0,0 +1,282 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ImapMailbox;
|
||||
use App\Support\Imap\InboundEmail;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Throwable;
|
||||
use Webklex\PHPIMAP\Client;
|
||||
use Webklex\PHPIMAP\ClientManager;
|
||||
use Webklex\PHPIMAP\Message;
|
||||
|
||||
/**
|
||||
* I/O layer for the "reply/create ticket by e-mail" feature — connects to
|
||||
* every enabled ImapMailbox, fetches unseen messages and delegates every
|
||||
* decision to ImapMessageClassifier (pure logic) + TicketService (the
|
||||
* existing ticket-mutation API). Kept thin and mostly untested directly;
|
||||
* ImapMessageClassifier carries the actual test coverage.
|
||||
*/
|
||||
class ImapMailboxFetcher
|
||||
{
|
||||
private const HEADER_FIELDS = ['auto-submitted', 'x-autoreply', 'x-autorespond', 'precedence'];
|
||||
|
||||
public function __construct(
|
||||
private readonly ImapMessageClassifier $classifier,
|
||||
private readonly TicketService $tickets,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{created: int, replied: int, rejected: int, errors: int}
|
||||
*/
|
||||
public function fetchAll(): array
|
||||
{
|
||||
$totals = ['created' => 0, 'replied' => 0, 'rejected' => 0, 'errors' => 0];
|
||||
|
||||
foreach (ImapMailbox::query()->where('enabled', true)->get() as $mailbox) {
|
||||
foreach ($this->fetchMailbox($mailbox) as $key => $value) {
|
||||
$totals[$key] += $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $totals;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{created: int, replied: int, rejected: int, errors: int}
|
||||
*/
|
||||
public function fetchMailbox(ImapMailbox $mailbox): array
|
||||
{
|
||||
$result = ['created' => 0, 'replied' => 0, 'rejected' => 0, 'errors' => 0];
|
||||
$log = Log::channel('imap');
|
||||
|
||||
$log->info("[{$mailbox->name}] łączenie z {$mailbox->host}:{$mailbox->port} (folder: {$mailbox->folder})");
|
||||
|
||||
try {
|
||||
$client = $this->connect($mailbox);
|
||||
$folder = $client->getFolder($mailbox->folder ?: 'INBOX');
|
||||
$messages = $folder->messages()->whereUnseen()->get();
|
||||
|
||||
$log->info("[{$mailbox->name}] {$messages->count()} nieprzeczytanych wiadomości");
|
||||
|
||||
foreach ($messages as $message) {
|
||||
try {
|
||||
$this->processMessage($mailbox, $message, $result, $log);
|
||||
} catch (Throwable $e) {
|
||||
$result['errors']++;
|
||||
$log->error("[{$mailbox->name}] błąd przetwarzania wiadomości (uid={$message->getUid()}) — {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
$client->disconnect();
|
||||
$mailbox->update(['last_checked_at' => now(), 'last_error' => null]);
|
||||
$log->info("[{$mailbox->name}] zakończono: {$result['created']} nowych, {$result['replied']} odpowiedzi, {$result['rejected']} odrzuconych, {$result['errors']} błędów");
|
||||
} catch (Throwable $e) {
|
||||
$result['errors']++;
|
||||
$mailbox->update(['last_checked_at' => now(), 'last_error' => $e->getMessage()]);
|
||||
$log->error("[{$mailbox->name}] połączenie nieudane — {$e->getMessage()}");
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a connection and lists the configured folder, without fetching
|
||||
* or touching any message — used by the admin "Testuj połączenie" button.
|
||||
* Returns null on success, the exception message on failure.
|
||||
*/
|
||||
public function testConnection(ImapMailbox $mailbox): ?string
|
||||
{
|
||||
try {
|
||||
$client = $this->connect($mailbox);
|
||||
$client->getFolder($mailbox->folder ?: 'INBOX');
|
||||
$client->disconnect();
|
||||
|
||||
return null;
|
||||
} catch (Throwable $e) {
|
||||
return $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
private function connect(ImapMailbox $mailbox): Client
|
||||
{
|
||||
$manager = new ClientManager;
|
||||
$client = $manager->make([
|
||||
'host' => $mailbox->host,
|
||||
'port' => $mailbox->port,
|
||||
'protocol' => 'imap',
|
||||
'encryption' => $mailbox->encryption === 'none' ? false : $mailbox->encryption,
|
||||
'validate_cert' => $mailbox->validate_cert,
|
||||
'username' => $mailbox->username,
|
||||
'password' => $mailbox->password,
|
||||
]);
|
||||
$client->connect();
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{created: int, replied: int, rejected: int, errors: int} $result
|
||||
*/
|
||||
private function processMessage(ImapMailbox $mailbox, Message $message, array &$result, LoggerInterface $log): void
|
||||
{
|
||||
$email = $this->toInboundEmail($message);
|
||||
$uid = $message->getUid();
|
||||
|
||||
$log->debug("[{$mailbox->name}] uid={$uid} od={$email->fromEmail} temat=\"{$email->subject}\" nagłówki=".json_encode($email->headers, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$rejectReason = $this->classifier->rejectionReason($email, $mailbox->blocklistedSenders());
|
||||
if ($rejectReason === null && ! $this->classifier->isSenderAllowed($email->fromEmail)) {
|
||||
$rejectReason = "nadawca spoza LDAP ({$email->fromEmail}), a restrict_tickets_to_ldap jest włączone";
|
||||
}
|
||||
|
||||
if ($rejectReason !== null) {
|
||||
$this->finish($message, $mailbox->rejected_folder);
|
||||
$result['rejected']++;
|
||||
$log->info("[{$mailbox->name}] uid={$uid} ODRZUCONO od {$email->fromEmail} \"{$email->subject}\" — {$rejectReason}");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Oznacz/przenieś PRZED utworzeniem ticketu: awaria w tym miejscu
|
||||
// zostawia co najwyżej "przetworzoną" wiadomość bez ticketu (widoczne,
|
||||
// łatwe do naprawienia ręcznie) zamiast duplikatu ticketu przy
|
||||
// ponownym uruchomieniu.
|
||||
$this->finish($message, $mailbox->processed_folder);
|
||||
|
||||
$ticket = $this->classifier->matchTicket($email->subject);
|
||||
$sender = $this->classifier->resolveSender($email->fromEmail);
|
||||
$attachments = $this->buildAttachments($email, $mailbox, $log);
|
||||
$authorName = $email->fromName !== '' ? $email->fromName : $email->fromEmail;
|
||||
|
||||
if ($ticket) {
|
||||
if ($sender) {
|
||||
$this->tickets->clientReply($ticket, $sender, $email->body(), $attachments, source: 'email');
|
||||
} else {
|
||||
$this->tickets->guestReply($ticket, $authorName, $email->body(), $attachments, source: 'email');
|
||||
}
|
||||
$result['replied']++;
|
||||
$log->info("[{$mailbox->name}] uid={$uid} ODPOWIEDŹ od {$email->fromEmail} dopisana do zgłoszenia #{$ticket->id} ({$ticket->displayNumber()})");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$newTicket = $this->tickets->create([
|
||||
'email' => $email->fromEmail,
|
||||
'name' => $authorName,
|
||||
'subcategory_id' => $mailbox->default_subcategory_id,
|
||||
'category_id' => $mailbox->default_category_id,
|
||||
'subject' => $email->subject !== '' ? $email->subject : '(bez tematu)',
|
||||
'body' => $email->body(),
|
||||
'source' => 'email',
|
||||
], $sender, $authorName);
|
||||
$result['created']++;
|
||||
$log->info("[{$mailbox->name}] uid={$uid} NOWE zgłoszenie #{$newTicket->id} ({$newTicket->displayNumber()}) od {$email->fromEmail}");
|
||||
}
|
||||
|
||||
private function finish(Message $message, ?string $moveToFolder): void
|
||||
{
|
||||
try {
|
||||
$message->setFlag('Seen');
|
||||
} catch (Throwable $e) {
|
||||
Log::channel('imap')->warning("IMAP: nie udało się oznaczyć wiadomości jako przeczytanej — {$e->getMessage()}");
|
||||
}
|
||||
|
||||
if ($moveToFolder) {
|
||||
$message->move($moveToFolder);
|
||||
}
|
||||
}
|
||||
|
||||
private function toInboundEmail(Message $message): InboundEmail
|
||||
{
|
||||
$fromAddress = $message->getFrom()->first();
|
||||
$header = $message->getHeader();
|
||||
|
||||
// Webklex's Header::get() returns an *empty* Attribute (not null)
|
||||
// for a header that isn't present at all, and Attribute::first() on
|
||||
// that empty instance comes back as '' rather than null — so a
|
||||
// plain "!== null" check on the resulting value is always true,
|
||||
// making every message look like it carries every one of these
|
||||
// headers. Only keep a header that actually has content.
|
||||
$headers = [];
|
||||
foreach (self::HEADER_FIELDS as $name) {
|
||||
$value = $header?->get($name)->first();
|
||||
if ($value !== null && $value !== '') {
|
||||
$headers[$name] = (string) $value;
|
||||
}
|
||||
}
|
||||
|
||||
return new InboundEmail(
|
||||
fromEmail: $fromAddress?->mail ?? '',
|
||||
fromName: $this->decodeHeaderText(trim((string) ($fromAddress?->personal ?? ''), '"')),
|
||||
subject: $this->decodeHeaderText((string) $message->getSubject()),
|
||||
textBody: (string) $message->getTextBody(),
|
||||
htmlBody: (string) $message->getHTMLBody(),
|
||||
headers: $headers,
|
||||
attachments: $this->extractAttachments($message),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Some senders' mail clients leave the Subject/From display-name as raw
|
||||
* RFC 2047 encoded-words (e.g. "=?utf-8?Q?...?=") instead of the
|
||||
* decoded UTF-8 webklex's own config claims to produce — decode
|
||||
* defensively rather than showing garbled text on the ticket.
|
||||
*/
|
||||
private function decodeHeaderText(string $value): string
|
||||
{
|
||||
return $value !== '' ? mb_decode_mimeheader($value) : $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{filename: string, mime: string, content: string}>
|
||||
*/
|
||||
private function extractAttachments(Message $message): array
|
||||
{
|
||||
$attachments = [];
|
||||
|
||||
foreach ($message->getAttachments() as $attachment) {
|
||||
$attachments[] = [
|
||||
'filename' => $attachment->getName() ?: 'attachment',
|
||||
'mime' => $attachment->getMimeType() ?: 'application/octet-stream',
|
||||
'content' => $attachment->getContent(),
|
||||
];
|
||||
}
|
||||
|
||||
return $attachments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts raw attachment bytes into UploadedFile instances (via a temp
|
||||
* file + the $test=true flag, which lets Symfony's UploadedFile skip the
|
||||
* is_uploaded_file() check outside of a real HTTP request) so they flow
|
||||
* through TicketService::attachFiles() unchanged. Validated the same way
|
||||
* every other caller validates before calling attachFiles() — a mail
|
||||
* carrying an oversized/disallowed attachment still creates the
|
||||
* ticket/reply, just without that attachment, rather than being dropped
|
||||
* entirely or silently bypassing the admin's attachment policy.
|
||||
*
|
||||
* @return UploadedFile[]
|
||||
*/
|
||||
private function buildAttachments(InboundEmail $email, ImapMailbox $mailbox, LoggerInterface $log): array
|
||||
{
|
||||
$files = [];
|
||||
|
||||
foreach ($email->attachments as $attachment) {
|
||||
$path = tempnam(sys_get_temp_dir(), 'imap_');
|
||||
file_put_contents($path, $attachment['content']);
|
||||
$files[] = new UploadedFile($path, $attachment['filename'], $attachment['mime'], null, true);
|
||||
}
|
||||
|
||||
if ($files && ($error = Settings::validateAttachments($files))) {
|
||||
$log->warning("[{$mailbox->name}] pominięto załączniki wiadomości od {$email->fromEmail} — {$error}");
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
}
|
||||
137
src/app/Services/ImapMessageClassifier.php
Normal file
137
src/app/Services/ImapMessageClassifier.php
Normal file
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Ticket;
|
||||
use App\Models\User;
|
||||
use App\Support\Imap\InboundEmail;
|
||||
use App\Support\Settings;
|
||||
|
||||
/**
|
||||
* Pure decision logic for the IMAP fetcher — no IMAP connection, no
|
||||
* side effects, so it's fully Pest-testable against hand-built
|
||||
* InboundEmail instances. ImapMailboxFetcher does all the I/O and calls
|
||||
* into this for every decision.
|
||||
*/
|
||||
class ImapMessageClassifier
|
||||
{
|
||||
/**
|
||||
* RFC 3834 (Auto-Submitted) + common vendor headers, plus EN/PL subject
|
||||
* phrasing for autoresponders/bounces that don't set those headers at
|
||||
* all — the two layers catch most real-world autoresponders/mailer-daemons.
|
||||
*/
|
||||
private const AUTO_REPLY_SUBJECT_PATTERNS = [
|
||||
'/\bout of office\b/i',
|
||||
'/\bautomatic reply\b/i',
|
||||
'/\bautomatyczna odpowiedz\b/iu',
|
||||
'/\bautoresponder\b/i',
|
||||
'/\bundeliverable\b/i',
|
||||
'/\bundelivered\b/i',
|
||||
'/\bmail delivery failed\b/i',
|
||||
'/\bdelivery status notification\b/i',
|
||||
'/\bnieobecnosc\b.*\bbiurze\b/iu',
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns a human-readable rejection reason, or null if the message
|
||||
* should be processed as a genuine ticket/reply.
|
||||
*
|
||||
* @param string[] $extraBlocklist additional blocked sender local-parts/addresses (per-mailbox)
|
||||
*/
|
||||
public function rejectionReason(InboundEmail $email, array $extraBlocklist = []): ?string
|
||||
{
|
||||
$autoSubmitted = strtolower((string) $email->header('auto-submitted'));
|
||||
if ($autoSubmitted !== '' && $autoSubmitted !== 'no') {
|
||||
return "Auto-Submitted: {$autoSubmitted}";
|
||||
}
|
||||
|
||||
if ($email->header('x-autoreply') !== null || $email->header('x-autorespond') !== null) {
|
||||
return 'X-Autoreply/X-Autorespond header present';
|
||||
}
|
||||
|
||||
$precedence = strtolower((string) $email->header('precedence'));
|
||||
if (in_array($precedence, ['bulk', 'junk', 'list'], true)) {
|
||||
return "Precedence: {$precedence}";
|
||||
}
|
||||
|
||||
$senderLocalPart = strtolower(explode('@', $email->fromEmail)[0] ?? '');
|
||||
$blocked = array_map('strtolower', $extraBlocklist);
|
||||
if ($senderLocalPart !== '' && in_array($senderLocalPart, $blocked, true)) {
|
||||
return "Blocked sender: {$email->fromEmail}";
|
||||
}
|
||||
if (in_array(strtolower($email->fromEmail), $blocked, true)) {
|
||||
return "Blocked sender: {$email->fromEmail}";
|
||||
}
|
||||
|
||||
foreach (self::AUTO_REPLY_SUBJECT_PATTERNS as $pattern) {
|
||||
if (preg_match($pattern, $email->subject) === 1) {
|
||||
return "Subject matched auto-reply pattern ({$pattern})";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same gate Landing::submit() applies to web/guest ticket creation
|
||||
* (Settings::bool('restrict_tickets_to_ldap')) — must apply identically
|
||||
* to mail-originated tickets/replies, or the restriction has a hole.
|
||||
*/
|
||||
public function isSenderAllowed(string $email): bool
|
||||
{
|
||||
if (! Settings::bool('restrict_tickets_to_ldap')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return User::query()->where('email', $email)->exists()
|
||||
|| app(LdapUserProvisioner::class)->existsInLdap($email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Existing local user, or an LDAP-provisioned one if enabled — mirrors
|
||||
* TicketService::create()'s own guest-resolution branch. Returns null
|
||||
* for a genuine, unprovisionable guest.
|
||||
*/
|
||||
public function resolveSender(string $email): ?User
|
||||
{
|
||||
if ($user = User::query()->where('email', $email)->first()) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
if (Settings::bool('ldap_auto_provision_guests')) {
|
||||
return app(LdapUserProvisioner::class)->findOrCreateByEmail($email);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips common reply/forward prefixes, then tries every digit run of
|
||||
* length >= 4 (longest first) against Ticket::resolveRouteBinding() —
|
||||
* covers both the plain sequential number and the obfuscated checksum,
|
||||
* since both are plain digit strings and every outbound notification
|
||||
* subject already carries one (see database/seeders/DatabaseSeeder.php).
|
||||
* Prefix-aware matching was considered and rejected: {numer} email
|
||||
* templates hardcode their own literal '#', independent of the
|
||||
* admin-configurable ticket_number_prefix setting, and templates are
|
||||
* themselves admin-editable.
|
||||
*/
|
||||
public function matchTicket(string $subject): ?Ticket
|
||||
{
|
||||
$cleaned = preg_replace('/^\s*(re|odp|fwd|fw|aw)\s*:\s*/i', '', $subject) ?? $subject;
|
||||
$cleaned = preg_replace('/^\s*(re|odp|fwd|fw|aw)\s*:\s*/i', '', $cleaned) ?? $cleaned;
|
||||
|
||||
preg_match_all('/\d{4,}/', $cleaned, $matches);
|
||||
$tokens = $matches[0] ?? [];
|
||||
usort($tokens, fn ($a, $b) => strlen($b) <=> strlen($a));
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
$ticket = (new Ticket)->resolveRouteBinding($token);
|
||||
if ($ticket) {
|
||||
return $ticket;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Services;
|
||||
use App\Events\TicketMessagePosted;
|
||||
use App\Events\TicketQueueChanged;
|
||||
use App\Models\ApiClient;
|
||||
use App\Models\NotificationPreference;
|
||||
use App\Models\NotificationSetting;
|
||||
use App\Models\Priority;
|
||||
use App\Models\Status;
|
||||
@@ -41,6 +42,10 @@ class TicketService
|
||||
'email' => $customer?->email ?? $data['email'],
|
||||
'name' => $customer?->name ?? ($data['name'] ?? $data['email']),
|
||||
'subcategory_id' => $subcategory?->id,
|
||||
// category_id only ever carries a value when there's no
|
||||
// subcategory to derive one from (e.g. an IMAP mailbox routed to
|
||||
// a whole category rather than a specific subcategory).
|
||||
'category_id' => $subcategory ? null : ($data['category_id'] ?? null),
|
||||
'subject' => $data['subject'],
|
||||
'body' => $data['body'],
|
||||
'status_key' => Settings::get('default_status', 'new'),
|
||||
@@ -49,6 +54,7 @@ class TicketService
|
||||
'assignee_id' => $data['assignee_id'] ?? null,
|
||||
'custom_fields' => $data['custom_values'] ?? [],
|
||||
'last_customer_activity_at' => now(),
|
||||
'source' => $data['source'] ?? 'web',
|
||||
]);
|
||||
|
||||
$message = $ticket->messages()->create([
|
||||
@@ -58,7 +64,8 @@ class TicketService
|
||||
$message->attachAuthor($customer?->id, 'client');
|
||||
|
||||
$this->notify($ticket, 'ticket_created');
|
||||
$this->notifyOperatorsForNewTicket($ticket, $subcategory);
|
||||
$this->notify($ticket, 'ticket_created_team');
|
||||
app(TriggerEngine::class)->handle($ticket, 'ticket_created');
|
||||
TicketQueueChanged::dispatch($ticket->id, 'created', Auth::id());
|
||||
|
||||
return $ticket;
|
||||
@@ -74,38 +81,6 @@ class TicketService
|
||||
->value('id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies every member of every team the new ticket's subcategory
|
||||
* routes to — independent of whether "auto_assign_by_category" actually
|
||||
* assigned the ticket's team_id, since the point here is "a ticket
|
||||
* matching your team's specialty came in", not the routing feature
|
||||
* itself. Unlike notify(), this fans out to potentially many
|
||||
* notifiables at once, so it can't reuse that single-recipient method.
|
||||
*/
|
||||
protected function notifyOperatorsForNewTicket(Ticket $ticket, ?Subcategory $subcategory): void
|
||||
{
|
||||
if (! $subcategory) {
|
||||
return;
|
||||
}
|
||||
|
||||
$setting = NotificationSetting::query()->where('trigger_key', 'ticket_created_team')->first();
|
||||
|
||||
if (! $setting || ! $setting->enabled || ! $setting->email_template_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$operators = Team::query()
|
||||
->whereHas('subcategories', fn ($q) => $q->where('subcategories.id', $subcategory->id))
|
||||
->with('members')
|
||||
->get()
|
||||
->flatMap(fn (Team $team) => $team->members)
|
||||
->unique('id');
|
||||
|
||||
foreach ($operators as $operator) {
|
||||
$operator->notify(new TicketNotification($ticket, $setting->email_template_id, 'operator'));
|
||||
}
|
||||
}
|
||||
|
||||
public function setStatus(Ticket $ticket, string $statusKey): void
|
||||
{
|
||||
// Any status change (closing, reopening, moving between open sub-statuses)
|
||||
@@ -133,6 +108,8 @@ class TicketService
|
||||
$this->notify($ticket, 'status_changed');
|
||||
}
|
||||
|
||||
app(TriggerEngine::class)->handle($ticket, 'status_changed');
|
||||
app(TriggerEngine::class)->handle($ticket, 'ticket_updated');
|
||||
TicketQueueChanged::dispatch($ticket->id, 'status_changed', Auth::id());
|
||||
}
|
||||
|
||||
@@ -157,6 +134,8 @@ class TicketService
|
||||
$ticket->update(['priority_key' => $priorityKey]);
|
||||
$ticket->addHistory('Priorytet zmieniony na: '.Priority::labelFor($priorityKey));
|
||||
$this->notify($ticket, 'priority_changed');
|
||||
app(TriggerEngine::class)->handle($ticket, 'priority_changed');
|
||||
app(TriggerEngine::class)->handle($ticket, 'ticket_updated');
|
||||
TicketQueueChanged::dispatch($ticket->id, 'priority_changed', Auth::id());
|
||||
}
|
||||
|
||||
@@ -167,6 +146,8 @@ class TicketService
|
||||
$ticket->update(['assignee_id' => $assignee?->id, 'sla_notified_at' => null]);
|
||||
$ticket->addHistory('Przypisano do: '.($assignee?->name ?? 'Nieprzypisane'));
|
||||
$this->notify($ticket, 'assignee_changed');
|
||||
app(TriggerEngine::class)->handle($ticket, 'assignee_changed');
|
||||
app(TriggerEngine::class)->handle($ticket, 'ticket_updated');
|
||||
TicketQueueChanged::dispatch($ticket->id, 'assignee_changed', Auth::id());
|
||||
}
|
||||
|
||||
@@ -175,6 +156,8 @@ 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());
|
||||
}
|
||||
|
||||
@@ -198,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
|
||||
@@ -211,6 +197,7 @@ class TicketService
|
||||
$ticket->touch();
|
||||
$this->attachFiles($ticket, $message, $attachments);
|
||||
$this->notify($ticket, 'operator_replied');
|
||||
app(TriggerEngine::class)->handle($ticket, 'comment_added');
|
||||
TicketMessagePosted::dispatch($ticket->id, $message->id, false, $operator->id);
|
||||
TicketQueueChanged::dispatch($ticket->id, 'message_posted', $operator->id);
|
||||
|
||||
@@ -231,11 +218,12 @@ class TicketService
|
||||
TicketMessagePosted::dispatch($ticket->id, $message->id, true, $operator->id);
|
||||
}
|
||||
|
||||
public function clientReply(Ticket $ticket, User $client, string $body, array $attachments = []): void
|
||||
public function clientReply(Ticket $ticket, User $client, string $body, array $attachments = [], string $source = 'web'): void
|
||||
{
|
||||
$message = $ticket->messages()->create([
|
||||
'author_name' => $client->name,
|
||||
'body' => $body,
|
||||
'source' => $source === 'web' ? null : $source,
|
||||
]);
|
||||
$message->attachAuthor($client->id, 'client');
|
||||
$ticket->touch();
|
||||
@@ -247,10 +235,58 @@ class TicketService
|
||||
$ticket->update(['last_customer_activity_at' => now()]);
|
||||
$ticket->automationRuleLogs()->delete();
|
||||
|
||||
// Unlike notify(), clientReply() never had a NotificationSetting
|
||||
// trigger_key of its own — comment_added is a Trigger-engine-only
|
||||
// hook, e.g. for a rule that reopens a closed ticket on a fresh
|
||||
// customer reply.
|
||||
app(TriggerEngine::class)->handle($ticket, 'comment_added');
|
||||
TicketMessagePosted::dispatch($ticket->id, $message->id, false, $client->id);
|
||||
TicketQueueChanged::dispatch($ticket->id, 'message_posted', $client->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* A reply from a customer with no User account — e.g. an e-mail reply
|
||||
* from an address the IMAP fetcher couldn't resolve to a local/LDAP
|
||||
* user. Mirrors clientReply() (real customer activity: resets SLA
|
||||
* silence, fires comment_added so an admin-configured Trigger can reopen
|
||||
* a closed ticket) rather than apiMessage() (attachAuthor(null, null) —
|
||||
* a system/integration note, not client content). attachAuthor(null,
|
||||
* 'client') matches how create() already tags a guest's opening message.
|
||||
*/
|
||||
public function guestReply(Ticket $ticket, string $authorName, string $body, array $attachments = [], string $source = 'web'): TicketMessage
|
||||
{
|
||||
$message = $ticket->messages()->create([
|
||||
'author_name' => $authorName,
|
||||
'body' => $body,
|
||||
'source' => $source === 'web' ? null : $source,
|
||||
]);
|
||||
$message->attachAuthor(null, 'client');
|
||||
$ticket->touch();
|
||||
$this->attachFiles($ticket, $message, $attachments);
|
||||
|
||||
$ticket->update(['last_customer_activity_at' => now()]);
|
||||
$ticket->automationRuleLogs()->delete();
|
||||
|
||||
app(TriggerEngine::class)->handle($ticket, 'comment_added');
|
||||
TicketMessagePosted::dispatch($ticket->id, $message->id, false, null);
|
||||
TicketQueueChanged::dispatch($ticket->id, 'message_posted', null);
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
public function toggleWatch(Ticket $ticket, User $user): bool
|
||||
{
|
||||
if ($ticket->isWatchedBy($user)) {
|
||||
$ticket->watchers()->detach($user->id);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$ticket->watchers()->attach($user->id);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* A message posted by an API integration rather than a logged-in person —
|
||||
* no User to attach as author, so it lands as a "system" message (mirrors
|
||||
@@ -271,6 +307,7 @@ class TicketService
|
||||
|
||||
if (! $internal) {
|
||||
$this->notify($ticket, 'operator_replied');
|
||||
app(TriggerEngine::class)->handle($ticket, 'comment_added');
|
||||
}
|
||||
|
||||
TicketMessagePosted::dispatch($ticket->id, $message->id, $internal, null);
|
||||
@@ -327,7 +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) {
|
||||
@@ -345,7 +382,7 @@ class TicketService
|
||||
$note = $other->messages()->create([
|
||||
'author_name' => 'System',
|
||||
'internal' => true,
|
||||
'body' => 'Scalone ze zgłoszeniem #'.$primary->number,
|
||||
'body' => 'Scalone ze zgłoszeniem '.$primary->displayNumber(),
|
||||
]);
|
||||
$note->attachAuthor(null, 'operator');
|
||||
TicketQueueChanged::dispatch($other->id, 'merged', Auth::id());
|
||||
@@ -355,15 +392,40 @@ class TicketService
|
||||
TicketQueueChanged::dispatch($primary->id, 'message_posted', Auth::id());
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the fixed NotificationSetting trigger_keys onto the 3 event
|
||||
* categories a staff member can tune on their personal notification
|
||||
* preferences page (see NotificationPreference::CATEGORIES). Triggers
|
||||
* absent from this map (currently just the client-only 'ticket_created'
|
||||
* ack) have no staff-facing leg at all.
|
||||
*/
|
||||
private const STAFF_EVENT_MAP = [
|
||||
'ticket_created_team' => 'new_ticket',
|
||||
'status_changed' => 'ticket_update',
|
||||
'priority_changed' => 'ticket_update',
|
||||
'assignee_changed' => 'ticket_update',
|
||||
'team_changed' => 'ticket_update',
|
||||
'category_changed' => 'ticket_update',
|
||||
'operator_replied' => 'ticket_update',
|
||||
'ticket_closed' => 'ticket_update',
|
||||
'sla_breached' => 'escalation',
|
||||
];
|
||||
|
||||
/**
|
||||
* Public so the scheduled SLA-breach check (which isn't a ticket lifecycle
|
||||
* event raised from within this service) can trigger the same way.
|
||||
*
|
||||
* Routes through the recipient's own User model (so it lands in the
|
||||
* in-app notification bell in addition to e-mail) whenever one exists;
|
||||
* falls back to an anonymous mail-only route for a guest customer with
|
||||
* no account. One shared NotificationSetting.enabled flag gates both
|
||||
* channels — there's no separate in-app on/off switch.
|
||||
* NotificationSetting.enabled is the global kill switch, layered above
|
||||
* every per-user preference below — disabling a trigger here silences
|
||||
* both legs regardless of what any individual staff member configured;
|
||||
* the personal matrix can only narrow within an enabled trigger, never
|
||||
* widen past it.
|
||||
*
|
||||
* Sends exactly one notification to the trigger's fixed
|
||||
* NotificationSetting.recipient (a client, or the ticket's single
|
||||
* assignee) exactly as before, then — for triggers mapped in
|
||||
* STAFF_EVENT_MAP — additionally fans out to every other operator/admin
|
||||
* whose own notification preferences put this ticket in scope.
|
||||
*/
|
||||
public function notify(Ticket $ticket, string $triggerKey): void
|
||||
{
|
||||
@@ -374,20 +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;
|
||||
if ($fallbackEmail) {
|
||||
Notification::route('mail', $fallbackEmail)
|
||||
->notify(new TicketNotification($ticket, $templateId, $recipientRole, $channels, $templateSource));
|
||||
}
|
||||
|
||||
Notification::route('mail', $email)
|
||||
->notify(new TicketNotification($ticket, $setting->email_template_id, $setting->recipient));
|
||||
}
|
||||
}
|
||||
|
||||
164
src/app/Services/TriggerEngine.php
Normal file
164
src/app/Services/TriggerEngine.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Priority;
|
||||
use App\Models\Status;
|
||||
use App\Models\Team;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\Trigger;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Event-based business rules, configured entirely by admins through the
|
||||
* Wyzwalacze tab — additive and independent from AutomationRule (which is
|
||||
* time/silence-based and evaluated by a scheduled command instead). Fires
|
||||
* synchronously on every matching ticket-lifecycle event (see the
|
||||
* TicketService call sites), same as the app's other side effects — there
|
||||
* is no queue worker in this stack to defer work to.
|
||||
*/
|
||||
class TriggerEngine
|
||||
{
|
||||
private static int $depth = 0;
|
||||
|
||||
private const MAX_DEPTH = 5;
|
||||
|
||||
public function __construct(protected TicketService $tickets) {}
|
||||
|
||||
/**
|
||||
* Guarded two ways against runaway loops: an action that would only
|
||||
* reassert the ticket's current value is a no-op before it ever gets
|
||||
* here (see the apply* methods below), which kills the common case of a
|
||||
* trigger re-matching its own result; the depth counter below is the
|
||||
* hard backstop for genuine cycles between two or more different
|
||||
* triggers.
|
||||
*/
|
||||
public function handle(Ticket $ticket, string $event): void
|
||||
{
|
||||
if (self::$depth >= self::MAX_DEPTH) {
|
||||
Log::warning('TriggerEngine: max depth reached, aborting further evaluation', [
|
||||
'ticket_id' => $ticket->id,
|
||||
'event' => $event,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
self::$depth++;
|
||||
|
||||
try {
|
||||
$triggers = Trigger::query()->where('enabled', true)->where('event', $event)->orderBy('sort_order')->get();
|
||||
|
||||
foreach ($triggers as $trigger) {
|
||||
$current = $ticket->fresh();
|
||||
|
||||
if ($current && $this->matches($trigger, $current)) {
|
||||
$this->applyActions($trigger, $current);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
self::$depth--;
|
||||
}
|
||||
}
|
||||
|
||||
protected function matches(Trigger $trigger, Ticket $ticket): bool
|
||||
{
|
||||
foreach ($trigger->conditions as $condition) {
|
||||
$field = $condition['field'] ?? null;
|
||||
|
||||
if (! in_array($field, Trigger::CONDITION_FIELDS, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->conditionMatches($condition, $ticket->{$field})) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function conditionMatches(array $condition, mixed $actual): bool
|
||||
{
|
||||
$value = $condition['value'] ?? null;
|
||||
|
||||
return match ($condition['operator'] ?? null) {
|
||||
'equals' => (string) $actual === (string) $value,
|
||||
'not_equals' => (string) $actual !== (string) $value,
|
||||
'is_empty' => $actual === null || $actual === '',
|
||||
'is_not_empty' => $actual !== null && $actual !== '',
|
||||
'contains' => is_string($actual) && $value !== null && str_contains(mb_strtolower($actual), mb_strtolower((string) $value)),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
protected function applyActions(Trigger $trigger, Ticket $ticket): void
|
||||
{
|
||||
foreach ($trigger->actions as $action) {
|
||||
match ($action['type'] ?? null) {
|
||||
'set_status' => $this->applySetStatus($ticket, $action['value'] ?? null),
|
||||
'set_priority' => $this->applySetPriority($ticket, $action['value'] ?? null),
|
||||
'set_team' => $this->applySetTeam($ticket, $action['value'] ?? null),
|
||||
'set_assignee' => $this->applySetAssignee($ticket, $action['value'] ?? null),
|
||||
'send_notification' => $this->applySendNotification($ticket, $action),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
protected function applySetStatus(Ticket $ticket, ?string $value): void
|
||||
{
|
||||
if (! $value || ! Status::query()->where('key', $value)->exists() || $ticket->status_key === $value) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->tickets->setStatus($ticket, $value);
|
||||
}
|
||||
|
||||
protected function applySetPriority(Ticket $ticket, ?string $value): void
|
||||
{
|
||||
if (! $value || ! Priority::query()->where('key', $value)->exists() || $ticket->priority_key === $value) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->tickets->setPriority($ticket, $value);
|
||||
}
|
||||
|
||||
protected function applySetTeam(Ticket $ticket, null|string|int $value): void
|
||||
{
|
||||
if ($value === null || (int) $ticket->team_id === (int) $value) {
|
||||
return;
|
||||
}
|
||||
|
||||
$team = Team::query()->find($value);
|
||||
|
||||
if ($team) {
|
||||
$this->tickets->setTeam($ticket, $team);
|
||||
}
|
||||
}
|
||||
|
||||
protected function applySetAssignee(Ticket $ticket, null|string|int $value): void
|
||||
{
|
||||
if ($value === null || (int) $ticket->assignee_id === (int) $value) {
|
||||
return;
|
||||
}
|
||||
|
||||
$assignee = User::query()->find($value);
|
||||
|
||||
if ($assignee) {
|
||||
$this->tickets->setAssignee($ticket, $assignee);
|
||||
}
|
||||
}
|
||||
|
||||
protected function applySendNotification(Ticket $ticket, array $action): void
|
||||
{
|
||||
$templateId = $action['email_template_id'] ?? null;
|
||||
|
||||
if (! $templateId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->tickets->sendCustomNotification($ticket, $action['recipient'] ?? 'client', (int) $templateId);
|
||||
}
|
||||
}
|
||||
49
src/app/Support/Imap/InboundEmail.php
Normal file
49
src/app/Support/Imap/InboundEmail.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Imap;
|
||||
|
||||
/**
|
||||
* Normalized view of one inbound message, independent of the IMAP client
|
||||
* library — the seam between ImapMailboxFetcher (I/O, effectively
|
||||
* untestable without a real mailbox) and ImapMessageClassifier (pure
|
||||
* decision logic, fully Pest-testable against hand-built instances).
|
||||
*/
|
||||
class InboundEmail
|
||||
{
|
||||
/**
|
||||
* @param array<string, string> $headers lower-cased header names
|
||||
* @param array<int, array{filename: string, mime: string, content: string}> $attachments
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $fromEmail,
|
||||
public readonly string $fromName,
|
||||
public readonly string $subject,
|
||||
public readonly string $textBody,
|
||||
public readonly string $htmlBody,
|
||||
public readonly array $headers,
|
||||
public readonly array $attachments = [],
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Treats an empty string the same as an absent header — the IMAP
|
||||
* library backing ImapMailboxFetcher represents "header not present" as
|
||||
* an empty value rather than a missing array key in some cases, so
|
||||
* callers checking `header($x) !== null` alone would otherwise
|
||||
* misdetect every message as carrying every header.
|
||||
*/
|
||||
public function header(string $name): ?string
|
||||
{
|
||||
$value = $this->headers[strtolower($name)] ?? null;
|
||||
|
||||
return $value !== null && $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
public function body(): string
|
||||
{
|
||||
if (trim($this->textBody) !== '') {
|
||||
return $this->textBody;
|
||||
}
|
||||
|
||||
return trim(html_entity_decode(strip_tags($this->htmlBody)));
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,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',
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\EnsureRole;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Http\Request;
|
||||
use Laravel\Sanctum\Http\Middleware\CheckAbilities;
|
||||
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
@@ -44,4 +46,29 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
$exceptions->shouldRenderJsonWhen(
|
||||
fn (Request $request) => $request->is('api/*'),
|
||||
);
|
||||
|
||||
// A ticket deleted mid-session (typically by the operator/client
|
||||
// currently viewing it) leaves any later request for that same
|
||||
// {ticket} route binding 404ing — most commonly Livewire's own
|
||||
// "model missing during hydration" recovery, which does a full
|
||||
// window.location.reload() of the very page whose ticket just
|
||||
// disappeared (e.g. the ticket-show view's periodic fallback
|
||||
// refresh polling a few seconds after a delete+redirect). Land back
|
||||
// on that area's own list page instead of a raw 404.
|
||||
//
|
||||
// Handler::prepareException() already converts ModelNotFoundException
|
||||
// into NotFoundHttpException (wrapping the original as getPrevious())
|
||||
// before any render() callback is dispatched — a callback typed
|
||||
// against ModelNotFoundException itself would simply never match.
|
||||
$exceptions->render(function (NotFoundHttpException $e, Request $request) {
|
||||
if (! $e->getPrevious() instanceof ModelNotFoundException || ! $request->user()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match (true) {
|
||||
$request->is('operator/*') => redirect()->route('operator.queue'),
|
||||
$request->is('client/*') => redirect()->route('client.dashboard'),
|
||||
default => null,
|
||||
};
|
||||
});
|
||||
})->create();
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
"laravel/reverb": "*",
|
||||
"laravel/sanctum": "*",
|
||||
"laravel/tinker": "^3.0",
|
||||
"livewire/livewire": "*"
|
||||
"livewire/livewire": "*",
|
||||
"webklex/php-imap": "*"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
|
||||
83
src/composer.lock
generated
83
src/composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "321add40614eb8751e0c8dbda55016eb",
|
||||
"content-hash": "abe8bd31e8d8849ae593e562f73a39df",
|
||||
"packages": [
|
||||
{
|
||||
"name": "brick/math",
|
||||
@@ -7593,6 +7593,87 @@
|
||||
],
|
||||
"time": "2026-04-26T05:33:54+00:00"
|
||||
},
|
||||
{
|
||||
"name": "webklex/php-imap",
|
||||
"version": "6.2.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Webklex/php-imap.git",
|
||||
"reference": "6b8ef85d621bbbaf52741b00cca8e9237e2b2e05"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Webklex/php-imap/zipball/6b8ef85d621bbbaf52741b00cca8e9237e2b2e05",
|
||||
"reference": "6b8ef85d621bbbaf52741b00cca8e9237e2b2e05",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-fileinfo": "*",
|
||||
"ext-iconv": "*",
|
||||
"ext-json": "*",
|
||||
"ext-libxml": "*",
|
||||
"ext-mbstring": "*",
|
||||
"ext-openssl": "*",
|
||||
"ext-zip": "*",
|
||||
"illuminate/pagination": ">=5.0.0",
|
||||
"nesbot/carbon": "^2.62.1|^3.2.4",
|
||||
"php": "^8.0.2",
|
||||
"symfony/http-foundation": ">=2.8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^9.5.10"
|
||||
},
|
||||
"suggest": {
|
||||
"symfony/mime": "Recomended for better extension support",
|
||||
"symfony/var-dumper": "Usefull tool for debugging"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "6.0-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Webklex\\PHPIMAP\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Malte Goldenbaum",
|
||||
"email": "github@webklex.com",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "PHP IMAP client",
|
||||
"homepage": "https://github.com/webklex/php-imap",
|
||||
"keywords": [
|
||||
"imap",
|
||||
"mail",
|
||||
"php-imap",
|
||||
"pop3",
|
||||
"webklex"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Webklex/php-imap/issues",
|
||||
"source": "https://github.com/Webklex/php-imap/tree/6.2.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://www.buymeacoffee.com/webklex",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://ko-fi.com/webklex",
|
||||
"type": "ko_fi"
|
||||
}
|
||||
],
|
||||
"time": "2025-04-25T06:02:37+00:00"
|
||||
},
|
||||
{
|
||||
"name": "zircote/swagger-php",
|
||||
"version": "6.4.0",
|
||||
|
||||
@@ -73,6 +73,19 @@ return [
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
// Dedicated, always-verbose channel for the IMAP fetcher
|
||||
// (emails:fetch-imap) — kept separate from 'single'/LOG_LEVEL so a
|
||||
// production app typically running at LOG_LEVEL=error still gets
|
||||
// full visibility into what the fetcher did on every run, without
|
||||
// that verbosity going into the main laravel.log.
|
||||
'imap' => [
|
||||
'driver' => 'daily',
|
||||
'path' => storage_path('logs/imap.log'),
|
||||
'level' => 'debug',
|
||||
'days' => 14,
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'slack' => [
|
||||
'driver' => 'slack',
|
||||
'url' => env('LOG_SLACK_WEBHOOK_URL'),
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('ticket_watchers', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('ticket_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['ticket_id', 'user_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('ticket_watchers');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* One row per (user, event_category) — only written the first time a
|
||||
* user actually toggles a checkbox on their notification-preferences
|
||||
* page. A missing row is not "notifications off"; callers must fall
|
||||
* back to NotificationPreference::DEFAULTS, never treat absence as
|
||||
* all-false (see NotificationPreference::rowFor()).
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('notification_preferences', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('event_category');
|
||||
$table->boolean('scope_mine')->default(false);
|
||||
$table->boolean('scope_unassigned')->default(false);
|
||||
$table->boolean('scope_watched')->default(false);
|
||||
$table->boolean('scope_all')->default(false);
|
||||
$table->boolean('email')->default(false);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['user_id', 'event_category']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('notification_preferences');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Event-driven business rules (see App\Services\TriggerEngine) —
|
||||
* conditions/actions are JSON so an admin can add/edit rules entirely
|
||||
* through the UI, with no migration needed per rule. Deliberately no
|
||||
* dedup/log table here (unlike automation_rule_ticket_logs): a trigger
|
||||
* is meant to re-fire on every matching event, not latch until reset.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('triggers', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->boolean('enabled')->default(true);
|
||||
$table->string('event');
|
||||
$table->json('conditions');
|
||||
$table->json('actions');
|
||||
$table->unsignedInteger('sort_order')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('triggers');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Separate from email_templates on purpose: those are fixed 1:1 to a
|
||||
* built-in notification trigger (no add/delete/reassign — see
|
||||
* Admin\Panel::editingTemplate()), while these are freely add/edit/
|
||||
* delete-able by admins for use in the "Wyślij powiadomienie e-mail"
|
||||
* trigger action (App\Services\TriggerEngine::applySendNotification).
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('trigger_email_templates', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('subject');
|
||||
$table->text('body');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('trigger_email_templates');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('tickets', function (Blueprint $table) {
|
||||
$table->string('checksum', 20)->nullable()->unique()->after('number');
|
||||
});
|
||||
|
||||
// Backfill: every existing ticket gets a stable, HMAC-derived
|
||||
// checksum (mirrors Ticket::generateUniqueChecksum()) so the
|
||||
// "hide ticket order" numbering mode has a real, unique, indexed
|
||||
// column to resolve ticket URLs against instead of only being a
|
||||
// display-time computation.
|
||||
$assigned = [];
|
||||
|
||||
DB::table('tickets')->orderBy('id')->select('id')->chunkById(500, function ($tickets) use (&$assigned) {
|
||||
foreach ($tickets as $ticket) {
|
||||
$nonce = 0;
|
||||
|
||||
do {
|
||||
$hash = hash_hmac('sha256', $ticket->id.'|'.$nonce, (string) config('app.key'));
|
||||
$candidate = (string) (hexdec(substr($hash, 0, 8)) % 900000 + 100000);
|
||||
$nonce++;
|
||||
} while (isset($assigned[$candidate]));
|
||||
|
||||
$assigned[$candidate] = true;
|
||||
|
||||
DB::table('tickets')->where('id', $ticket->id)->update(['checksum' => $candidate]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('tickets', function (Blueprint $table) {
|
||||
$table->dropColumn('checksum');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('imap_mailboxes', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->boolean('enabled')->default(false);
|
||||
$table->string('host');
|
||||
$table->unsignedSmallInteger('port')->default(993);
|
||||
$table->string('encryption')->default('ssl');
|
||||
$table->boolean('validate_cert')->default(true);
|
||||
$table->string('username');
|
||||
$table->text('password')->nullable();
|
||||
$table->string('folder')->default('INBOX');
|
||||
$table->string('processed_folder')->nullable();
|
||||
$table->string('rejected_folder')->nullable();
|
||||
$table->foreignId('default_subcategory_id')->nullable()->constrained('subcategories')->nullOnDelete();
|
||||
$table->string('blocklist_senders')->default('mailer-daemon,postmaster,no-reply,noreply');
|
||||
$table->timestamp('last_checked_at')->nullable();
|
||||
$table->text('last_error')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('imap_mailboxes');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* category_id lets a ticket carry just a Category with no specific
|
||||
* Subcategory (e.g. an IMAP mailbox routed to "całą kategorię" rather
|
||||
* than one subcategory) — subcategory_id already implies a category via
|
||||
* its own relation, so category_id is only ever populated when there's
|
||||
* no subcategory to derive it from (see Ticket::categoryLabel()).
|
||||
*
|
||||
* source records how the ticket was created (web/e-mail/...), surfaced
|
||||
* as a badge in the operator queue/ticket view.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('tickets', function (Blueprint $table) {
|
||||
$table->foreignId('category_id')->nullable()->after('subcategory_id')->constrained('categories')->nullOnDelete();
|
||||
$table->string('source')->default('web')->after('api_client_id');
|
||||
});
|
||||
|
||||
Schema::table('imap_mailboxes', function (Blueprint $table) {
|
||||
$table->foreignId('default_category_id')->nullable()->after('default_subcategory_id')->constrained('categories')->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('imap_mailboxes', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('default_category_id');
|
||||
});
|
||||
|
||||
Schema::table('tickets', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('category_id');
|
||||
$table->dropColumn('source');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Mirrors tickets.source at the individual-message level — a ticket
|
||||
* created on the web can still later receive a reply by e-mail (or vice
|
||||
* versa), so this needs tracking per message, not just per ticket.
|
||||
* Null means "web" (the original/default channel); only IMAP-originated
|
||||
* messages ever set it to 'email'.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('ticket_messages', function (Blueprint $table) {
|
||||
$table->string('source')->nullable()->after('api_client_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('ticket_messages', function (Blueprint $table) {
|
||||
$table->dropColumn('source');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -55,6 +55,34 @@ if (window.currentUserId) {
|
||||
.error((error) => console.error('operator.queue subscription error', error));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every logged-in user's own private notification stream — refreshes the
|
||||
* bell instantly (see NotificationBell::onBellNotification()) and, when the
|
||||
* viewer has opted in via the toggle on the notification-preferences page,
|
||||
* also raises an in-tab browser Notification. Deliberately lightweight: no
|
||||
* service worker, no push subscription — this only fires while the tab
|
||||
* calling it is open, same limitation as the operator.queue block above.
|
||||
*/
|
||||
if (window.currentUserId) {
|
||||
window.Echo.private('App.Models.User.' + window.currentUserId)
|
||||
.listen('.NotificationCreated', (e) => {
|
||||
Livewire.dispatch('bell-notification-received', { notificationId: e.notificationId });
|
||||
|
||||
if (
|
||||
localStorage.getItem('browserNotificationsEnabled') === '1'
|
||||
&& typeof Notification !== 'undefined'
|
||||
&& Notification.permission === 'granted'
|
||||
) {
|
||||
const popup = new Notification(e.message, { tag: e.notificationId });
|
||||
popup.onclick = () => {
|
||||
window.focus();
|
||||
window.location.href = e.url;
|
||||
};
|
||||
}
|
||||
})
|
||||
.error((error) => console.error('user notification channel subscription error', error));
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to a single ticket's channel — called by the Blade view of
|
||||
* whichever TicketShow component (operator or client) is currently mounted,
|
||||
|
||||
@@ -2,24 +2,12 @@
|
||||
|
||||
@php
|
||||
$url = \Illuminate\Support\Facades\Storage::disk('public')->url($attachment->path);
|
||||
$isImage = \Illuminate\Support\Str::startsWith($attachment->mime ?? '', 'image/');
|
||||
@endphp
|
||||
|
||||
@if ($isImage)
|
||||
<a href="{{ $url }}" target="_blank" style="display:block;margin-top:8px">
|
||||
<img
|
||||
src="{{ $url }}"
|
||||
alt="{{ $attachment->original_name }}"
|
||||
loading="lazy"
|
||||
style="max-width:220px;max-height:160px;border-radius:8px;border:1px solid var(--color-divider);object-fit:cover;cursor:zoom-in;display:block"
|
||||
>
|
||||
</a>
|
||||
@else
|
||||
<a
|
||||
href="{{ $url }}"
|
||||
target="_blank"
|
||||
style="display:inline-flex;align-items:center;gap:6px;margin-top:8px;padding:5px 10px;border:1px solid var(--color-divider);border-radius:6px;font-size:12.5px;color:inherit;text-decoration:none;background:color-mix(in srgb, var(--color-text) 5%, transparent)"
|
||||
>
|
||||
<span class="material-symbols-outlined" style="font-size:15px">attach_file</span>{{ $attachment->original_name }}
|
||||
</a>
|
||||
@endif
|
||||
<a
|
||||
href="{{ $url }}"
|
||||
target="_blank"
|
||||
style="display:inline-flex;align-items:center;gap:6px;margin-top:8px;padding:5px 10px;border:1px solid var(--color-divider);border-radius:6px;font-size:12.5px;color:inherit;text-decoration:none;background:color-mix(in srgb, var(--color-text) 5%, transparent)"
|
||||
>
|
||||
<span class="material-symbols-outlined" style="font-size:15px">attach_file</span>{{ $attachment->original_name }}
|
||||
</a>
|
||||
|
||||
@@ -42,6 +42,18 @@
|
||||
<div style="border-top:1px solid var(--color-divider)"></div>
|
||||
@endif
|
||||
|
||||
@if ($user && ($user->isOperator() || $user->isAdmin()))
|
||||
<a
|
||||
href="{{ route('settings.notifications') }}"
|
||||
wire:navigate
|
||||
@click="open = false"
|
||||
class="theme-toggle-option"
|
||||
style="text-decoration:none;color:{{ request()->routeIs('settings.*') ? 'var(--color-accent)' : 'var(--color-text)' }};font-size:12.5px"
|
||||
>Powiadomienia</a>
|
||||
|
||||
<div style="border-top:1px solid var(--color-divider)"></div>
|
||||
@endif
|
||||
|
||||
<a
|
||||
href="{{ route('logout') }}"
|
||||
onclick="event.preventDefault(); document.getElementById('profile-menu-logout-form').submit();"
|
||||
|
||||
182
src/resources/views/livewire/admin/mail-settings.blade.php
Normal file
182
src/resources/views/livewire/admin/mail-settings.blade.php
Normal file
@@ -0,0 +1,182 @@
|
||||
<div>
|
||||
<h3 style="margin:0 0 14px">E-mail (SMTP)</h3>
|
||||
<form wire:submit="saveMailConfig" class="card" style="padding:20px;gap:14px;max-width:480px;margin-bottom:32px">
|
||||
<div class="field"><label>Adres nadawcy</label><input class="input" type="email" placeholder="wsparcie@firma.pl" wire:model="mailConfig.fromAddress"></div>
|
||||
<div class="field"><label>Nazwa nadawcy</label><input class="input" placeholder="Zespół Wsparcia" wire:model="mailConfig.fromName"></div>
|
||||
|
||||
<div class="hr"></div>
|
||||
|
||||
<label class="radio"><input type="checkbox" wire:model="mailConfig.smtpEnabled" style="position:static;opacity:1;width:auto;height:auto"><strong>Włącz wysyłkę przez własny serwer SMTP</strong></label>
|
||||
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Bez włączenia aplikacja wysyła pocztę zgodnie z konfiguracją środowiska (.env).</span>
|
||||
|
||||
@if ($mailConfig['smtpEnabled'])
|
||||
<div class="field"><label>Host SMTP</label><input class="input" placeholder="smtp.example.com" wire:model="mailConfig.smtpHost"></div>
|
||||
<div style="display:flex;gap:10px">
|
||||
<div class="field" style="flex:1"><label>Port</label><input class="input" type="number" placeholder="587" wire:model="mailConfig.smtpPort"></div>
|
||||
<div class="field" style="flex:1">
|
||||
<label>Szyfrowanie</label>
|
||||
<select class="input" wire:model="mailConfig.smtpEncryption">
|
||||
<option value="none">Brak</option>
|
||||
<option value="tls">STARTTLS</option>
|
||||
<option value="ssl">SSL/TLS</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field"><label>Użytkownik</label><input class="input" wire:model="mailConfig.smtpUsername"></div>
|
||||
<div class="field"><label>Hasło</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="mailConfig.smtpPassword"></div>
|
||||
|
||||
<div style="display:flex;gap:10px;margin-top:8px;align-items:center;flex-wrap:wrap">
|
||||
<button type="button" class="btn btn-secondary" wire:click="testMailConnection">Wyślij testową wiadomość</button>
|
||||
<button type="submit" class="btn btn-primary">Zapisz</button>
|
||||
@if ($mailTestResult === 'ok')
|
||||
<div style="display:flex;align-items:center;gap:6px;color:var(--color-success)"><span class="material-symbols-outlined" style="font-size:18px">check_circle</span>Wysłano na Twój adres</div>
|
||||
@elseif ($mailTestResult === 'error')
|
||||
<div style="display:flex;align-items:center;gap:6px;color:var(--color-danger)"><span class="material-symbols-outlined" style="font-size:18px">error</span>Błąd wysyłki</div>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<button type="submit" class="btn btn-primary" style="align-self:flex-start">Zapisz</button>
|
||||
@endif
|
||||
</form>
|
||||
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
|
||||
<h3 style="margin:0">Skrzynki IMAP (zgłoszenia i odpowiedzi przez e-mail)</h3>
|
||||
<button class="btn btn-primary" type="button" wire:click="openMailboxForm">+ Nowa skrzynka</button>
|
||||
</div>
|
||||
|
||||
<p class="text-muted" style="font-size:12.5px;margin:0 0 14px">
|
||||
Każda skrzynka jest sprawdzana co kilka minut — nowa wiadomość zakłada zgłoszenie w wybranej podkategorii (np. zgloszenia-it@firma.pl → IT), a odpowiedź na powiadomienie e-mail (temat zawiera numer zgłoszenia) trafia jako odpowiedź do istniejącego zgłoszenia. Automatyczne odpowiedzi (autorespondery, „poza biurem”, bounce) są odrzucane.
|
||||
</p>
|
||||
|
||||
@if ($this->mailboxes->isNotEmpty())
|
||||
<div class="table-wrap" style="margin-bottom:20px">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nazwa</th>
|
||||
<th>Serwer</th>
|
||||
<th>Użytkownik</th>
|
||||
<th>Kategoria / podkategoria</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($this->mailboxes as $mailbox)
|
||||
<tr>
|
||||
<td>{{ $mailbox->name }}</td>
|
||||
<td class="text-muted" style="white-space:nowrap">{{ $mailbox->host }}:{{ $mailbox->port }}</td>
|
||||
<td class="text-muted">{{ $mailbox->username }}</td>
|
||||
<td class="text-muted">{{ $mailbox->targetLabel() }}</td>
|
||||
<td>
|
||||
<button type="button" class="tag" style="border:none;cursor:pointer;background:color-mix(in srgb, var(--color-{{ $mailbox->enabled ? 'success' : 'danger' }}) 18%, transparent);color:var(--color-{{ $mailbox->enabled ? 'success' : 'danger' }})"
|
||||
wire:click="toggleMailboxEnabled({{ $mailbox->id }})">
|
||||
{{ $mailbox->enabled ? 'Włączona' : 'Wyłączona' }}
|
||||
</button>
|
||||
@if ($mailbox->last_error)
|
||||
<div style="color:var(--color-danger);font-size:11px;margin-top:4px">{{ $mailbox->last_error }}</div>
|
||||
@elseif ($mailbox->last_checked_at)
|
||||
<div class="text-muted" style="font-size:11px;margin-top:4px">Sprawdzono: {{ $mailbox->last_checked_at->format('Y-m-d H:i') }}</div>
|
||||
@endif
|
||||
@if ($mailboxFetchResultId === $mailbox->id)
|
||||
<div class="text-muted" style="font-size:11px;margin-top:4px">{{ $mailboxFetchSummary }}</div>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
<div style="display:flex;gap:6px;justify-content:flex-end">
|
||||
<button class="btn btn-ghost" type="button" wire:click="fetchMailboxNow({{ $mailbox->id }})" wire:loading.attr="disabled" wire:target="fetchMailboxNow({{ $mailbox->id }})">Pobierz teraz</button>
|
||||
<button class="btn btn-ghost" type="button" wire:click="editMailbox({{ $mailbox->id }})">Edytuj</button>
|
||||
<button class="btn btn-ghost" type="button" wire:click="removeMailbox({{ $mailbox->id }})" wire:confirm="Usunąć tę skrzynkę IMAP?">Usuń</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@else
|
||||
<p class="text-muted" style="font-size:13px">Brak skonfigurowanych skrzynek IMAP. Dodaj pierwszą używając przycisku wyżej.</p>
|
||||
@endif
|
||||
|
||||
@if ($mailboxFormOpen)
|
||||
<div class="dialog-backdrop">
|
||||
<form wire:submit="submitMailboxForm" class="dialog" style="max-width:520px;max-height:90vh;overflow-y:auto">
|
||||
<div class="dialog-title">{{ $mailboxForm['id'] ? 'Edytuj skrzynkę IMAP' : 'Nowa skrzynka IMAP' }}</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Nazwa (etykieta)</label>
|
||||
<input class="input" placeholder="np. Zgłoszenia IT" wire:model="mailboxForm.name">
|
||||
</div>
|
||||
@error('mailboxForm.name') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
|
||||
|
||||
<label class="radio"><input type="checkbox" wire:model="mailboxForm.enabled" style="position:static;opacity:1;width:auto;height:auto">Włączona</label>
|
||||
|
||||
<div class="hr"></div>
|
||||
|
||||
<div class="field"><label>Host IMAP</label><input class="input" placeholder="imap.firma.pl" wire:model="mailboxForm.host"></div>
|
||||
@error('mailboxForm.host') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
|
||||
|
||||
<div style="display:flex;gap:10px">
|
||||
<div class="field" style="flex:1"><label>Port</label><input class="input" type="number" wire:model="mailboxForm.port"></div>
|
||||
<div class="field" style="flex:1">
|
||||
<label>Szyfrowanie</label>
|
||||
<select class="input" wire:model="mailboxForm.encryption">
|
||||
<option value="ssl">SSL/TLS</option>
|
||||
<option value="tls">STARTTLS</option>
|
||||
<option value="none">Brak</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="radio"><input type="checkbox" wire:model="mailboxForm.validateCert" style="position:static;opacity:1;width:auto;height:auto">Weryfikuj certyfikat TLS</label>
|
||||
|
||||
<div class="field"><label>Adres skrzynki (login)</label><input class="input" placeholder="zgloszenia-it@firma.pl" wire:model="mailboxForm.username"></div>
|
||||
@error('mailboxForm.username') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
|
||||
<div class="field"><label>Hasło</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="mailboxForm.password"></div>
|
||||
|
||||
<div class="hr"></div>
|
||||
|
||||
<div class="field">
|
||||
<label>Kategoria / podkategoria nowych zgłoszeń</label>
|
||||
<select class="input" wire:model="mailboxForm.target">
|
||||
<option value="">Brak (zgłoszenie nieprzypisane)</option>
|
||||
@foreach ($this->categoryOptions as $category)
|
||||
<optgroup label="{{ $category['name'] }}">
|
||||
<option value="category:{{ $category['id'] }}">Cała kategoria: {{ $category['name'] }}</option>
|
||||
@foreach ($category['subcategories'] as $sub)
|
||||
<option value="subcategory:{{ $sub['id'] }}">{{ $sub['name'] }}</option>
|
||||
@endforeach
|
||||
</optgroup>
|
||||
@endforeach
|
||||
</select>
|
||||
<span class="text-muted" style="font-size:11.5px">Wybierz konkretną podkategorię (trafi też do jej zespołu) albo całą kategorię, jeśli nie chcesz przypisywać konkretnej podkategorii.</span>
|
||||
</div>
|
||||
|
||||
<div class="field"><label>Folder</label><input class="input" wire:model="mailboxForm.folder"></div>
|
||||
<div style="display:flex;gap:10px">
|
||||
<div class="field" style="flex:1"><label>Folder po przetworzeniu (opcjonalnie)</label><input class="input" placeholder="pozostaw puste = oznacz jako przeczytane" wire:model="mailboxForm.processedFolder"></div>
|
||||
<div class="field" style="flex:1"><label>Folder odrzuconych (opcjonalnie)</label><input class="input" placeholder="pozostaw puste = oznacz jako przeczytane" wire:model="mailboxForm.rejectedFolder"></div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Zablokowani nadawcy (dodatkowo do filtrów autoresponderów)</label>
|
||||
<input class="input" wire:model="mailboxForm.blocklistSenders">
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:4px">
|
||||
<button type="button" class="btn btn-secondary" wire:click="testMailboxConnection">Testuj połączenie</button>
|
||||
@if ($mailboxTestResult === 'ok')
|
||||
<div style="display:flex;align-items:center;gap:6px;color:var(--color-success)"><span class="material-symbols-outlined" style="font-size:18px">check_circle</span>Połączono</div>
|
||||
@elseif ($mailboxTestResult === 'error')
|
||||
<div style="display:flex;align-items:center;gap:6px;color:var(--color-danger);font-size:12.5px"><span class="material-symbols-outlined" style="font-size:18px">error</span>{{ $mailboxTestMessage }}</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="dialog-actions">
|
||||
<button class="btn btn-secondary" type="button" wire:click="closeMailboxForm">Anuluj</button>
|
||||
<button class="btn btn-primary" type="submit">Zapisz</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -8,6 +8,7 @@ $tabGroups = [
|
||||
['key' => 'reply-quick-actions', 'label' => 'Szybkie akcje odpowiedzi', 'icon' => 'bolt'],
|
||||
['key' => 'response-templates', 'label' => 'Szablony odpowiedzi', 'icon' => 'chat'],
|
||||
['key' => 'automation-rules', 'label' => 'Automatyzacja SLA', 'icon' => 'bolt'],
|
||||
['key' => 'triggers', 'label' => 'Wyzwalacze', 'icon' => 'rule'],
|
||||
],
|
||||
'Zespół' => [
|
||||
['key' => 'users', 'label' => 'Użytkownicy', 'icon' => 'group'],
|
||||
@@ -16,8 +17,10 @@ $tabGroups = [
|
||||
],
|
||||
'Ustawienia' => [
|
||||
['key' => 'templates', 'label' => 'Szablony e-mail', 'icon' => 'mail'],
|
||||
['key' => 'email', 'label' => 'Poczta', 'icon' => 'forward_to_inbox'],
|
||||
['key' => 'branding', 'label' => 'Wygląd i branding', 'icon' => 'palette'],
|
||||
['key' => 'config', 'label' => 'Konfiguracja', 'icon' => 'settings'],
|
||||
['key' => 'integrations', 'label' => 'Integracje', 'icon' => 'hub'],
|
||||
['key' => 'api-keys', 'label' => 'Klucze API', 'icon' => 'vpn_key'],
|
||||
['key' => 'about', 'label' => 'O aplikacji', 'icon' => 'info'],
|
||||
],
|
||||
@@ -356,6 +359,11 @@ $tabGroups = [
|
||||
@endif
|
||||
@endif
|
||||
|
||||
{{-- ================= TRIGGERS ================= --}}
|
||||
@if ($tab === 'triggers')
|
||||
<livewire:admin.triggers />
|
||||
@endif
|
||||
|
||||
{{-- ================= STATUSES ================= --}}
|
||||
@if ($tab === 'statuses')
|
||||
<h3 style="margin:0 0 6px">Statusy</h3>
|
||||
@@ -435,6 +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>
|
||||
|
||||
@@ -454,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 ================= --}}
|
||||
@@ -566,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'] }} → podgląd numeru: {{ $this->ticketNumberPreview['formatted'] }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding:20px;gap:14px">
|
||||
@@ -615,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>
|
||||
@endif
|
||||
|
||||
<div class="hr"></div>
|
||||
{{-- ================= INTEGRATIONS ================= --}}
|
||||
@if ($tab === 'integrations')
|
||||
<h3 style="margin:0 0 16px">Integracje</h3>
|
||||
|
||||
<label class="radio"><input type="checkbox" wire:model="mailConfig.smtpEnabled" style="position:static;opacity:1;width:auto;height:auto"><strong>Włącz wysyłkę przez własny serwer SMTP</strong></label>
|
||||
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Bez włączenia aplikacja wysyła pocztę zgodnie z konfiguracją środowiska (.env).</span>
|
||||
|
||||
@if ($mailConfig['smtpEnabled'])
|
||||
<div class="field"><label>Host SMTP</label><input class="input" placeholder="smtp.example.com" wire:model="mailConfig.smtpHost"></div>
|
||||
<div style="display:flex;gap:10px">
|
||||
<div class="field" style="flex:1"><label>Port</label><input class="input" type="number" placeholder="587" wire:model="mailConfig.smtpPort"></div>
|
||||
<div class="field" style="flex:1">
|
||||
<label>Szyfrowanie</label>
|
||||
<select class="input" wire:model="mailConfig.smtpEncryption">
|
||||
<option value="none">Brak</option>
|
||||
<option value="tls">STARTTLS</option>
|
||||
<option value="ssl">SSL/TLS</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field"><label>Użytkownik</label><input class="input" wire:model="mailConfig.smtpUsername"></div>
|
||||
<div class="field"><label>Hasło</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="mailConfig.smtpPassword"></div>
|
||||
|
||||
<div style="display:flex;gap:10px;margin-top:8px;align-items:center;flex-wrap:wrap">
|
||||
<button type="button" class="btn btn-secondary" wire:click="testMailConnection">Wyślij testową wiadomość</button>
|
||||
<button type="submit" class="btn btn-primary">Zapisz</button>
|
||||
@if ($mailTestResult === 'ok')
|
||||
<div style="display:flex;align-items:center;gap:6px;color:var(--color-success)"><span class="material-symbols-outlined" style="font-size:18px">check_circle</span>Wysłano na Twój adres</div>
|
||||
@elseif ($mailTestResult === 'error')
|
||||
<div style="display:flex;align-items:center;gap:6px;color:var(--color-danger)"><span class="material-symbols-outlined" style="font-size:18px">error</span>Błąd wysyłki</div>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<button type="submit" class="btn btn-primary" style="align-self:flex-start">Zapisz</button>
|
||||
@endif
|
||||
</form>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:20px;align-items:start">
|
||||
|
||||
<form wire:submit="saveLdapConfig" class="card" style="padding:20px;gap:14px">
|
||||
<h4 style="margin:0">LDAP / Active Directory</h4>
|
||||
|
||||
303
src/resources/views/livewire/admin/triggers.blade.php
Normal file
303
src/resources/views/livewire/admin/triggers.blade.php
Normal file
@@ -0,0 +1,303 @@
|
||||
@php
|
||||
$eventLabels = \App\Livewire\Admin\Triggers::eventLabels();
|
||||
$fieldLabels = \App\Livewire\Admin\Triggers::fieldLabels();
|
||||
$operatorLabels = \App\Livewire\Admin\Triggers::operatorLabels();
|
||||
$actionTypeLabels = \App\Livewire\Admin\Triggers::actionTypeLabels();
|
||||
@endphp
|
||||
<div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
|
||||
<h3 style="margin:0">Wyzwalacze</h3>
|
||||
<button class="btn btn-primary" type="button" wire:click="openForm">+ Nowy wyzwalacz</button>
|
||||
</div>
|
||||
|
||||
<p class="text-muted" style="font-size:12.5px;margin:0 0 14px">
|
||||
Wyzwalacze reagują natychmiast na zdarzenie w zgłoszeniu (utworzenie, zmiana pola, nowy komentarz) — w odróżnieniu od Automatyzacji SLA (zakładka obok), która działa na podstawie czasu milczenia klienta. Warunki wyzwalacza muszą być spełnione wszystkie naraz (ORAZ); akcje wykonują się w podanej kolejności.
|
||||
</p>
|
||||
|
||||
@if ($this->triggers->isNotEmpty())
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Nazwa</th>
|
||||
<th>Zdarzenie</th>
|
||||
<th>Warunki</th>
|
||||
<th>Akcje</th>
|
||||
<th>Aktywny</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($this->triggers as $trigger)
|
||||
<tr wire:key="trigger-{{ $trigger->id }}">
|
||||
<td style="white-space:nowrap">
|
||||
<button type="button" class="btn btn-ghost" style="padding:0 4px" wire:click="moveUp({{ $trigger->id }})" title="Przenieś wyżej">
|
||||
<span class="material-symbols-outlined" style="font-size:16px">arrow_upward</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost" style="padding:0 4px" wire:click="moveDown({{ $trigger->id }})" title="Przenieś niżej">
|
||||
<span class="material-symbols-outlined" style="font-size:16px">arrow_downward</span>
|
||||
</button>
|
||||
</td>
|
||||
<td style="white-space:nowrap">{{ $trigger->name }}</td>
|
||||
<td><span class="tag tag-outline">{{ $eventLabels[$trigger->event] ?? $trigger->event }}</span></td>
|
||||
<td class="text-muted" style="font-size:12px">
|
||||
{{ count($trigger->conditions) }} {{ count($trigger->conditions) === 1 ? 'warunek' : 'warunków' }}
|
||||
</td>
|
||||
<td class="text-muted" style="font-size:12px">
|
||||
{{ count($trigger->actions) }} {{ count($trigger->actions) === 1 ? 'akcja' : 'akcji' }}
|
||||
</td>
|
||||
<td><input type="checkbox" @checked($trigger->enabled) wire:click="toggleEnabled({{ $trigger->id }})"></td>
|
||||
<td>
|
||||
<div style="display:flex;gap:6px;justify-content:flex-end">
|
||||
<button class="btn btn-ghost" type="button" wire:click="editTrigger({{ $trigger->id }})">Edytuj</button>
|
||||
<button class="btn btn-ghost" type="button" wire:click="removeTrigger({{ $trigger->id }})" wire:confirm="Usunąć wyzwalacz „{{ $trigger->name }}”?">Usuń</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@else
|
||||
<p class="text-muted" style="font-size:13px">Brak wyzwalaczy. Utwórz pierwszy używając przycisku wyżej.</p>
|
||||
@endif
|
||||
|
||||
<div class="hr" style="margin:22px 0"></div>
|
||||
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
|
||||
<div>
|
||||
<h3 style="margin:0">Szablony e-mail wyzwalaczy</h3>
|
||||
<p class="text-muted" style="font-size:12.5px;margin:4px 0 0">
|
||||
Osobne od szablonów w zakładce „Szablony e-mail” (te są przypisane na stałe do zdarzeń systemowych) — te
|
||||
tutaj możesz dowolnie dodawać, edytować i usuwać, do wykorzystania w akcji „Wyślij powiadomienie e-mail” wyzwalacza.
|
||||
</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="button" wire:click="openTemplateForm" style="flex:none">+ Nowy szablon</button>
|
||||
</div>
|
||||
|
||||
@if ($this->emailTemplates->isNotEmpty())
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead><tr><th>Nazwa</th><th>Temat</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
@foreach ($this->emailTemplates as $template)
|
||||
<tr wire:key="trigger-template-{{ $template->id }}">
|
||||
<td style="white-space:nowrap">{{ $template->name }}</td>
|
||||
<td>{{ $template->subject }}</td>
|
||||
<td>
|
||||
<div style="display:flex;gap:6px;justify-content:flex-end">
|
||||
<button class="btn btn-ghost" type="button" wire:click="editTemplate({{ $template->id }})">Edytuj</button>
|
||||
<button class="btn btn-ghost" type="button" wire:click="removeTemplate({{ $template->id }})" wire:confirm="Usunąć szablon „{{ $template->name }}”?">Usuń</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@else
|
||||
<p class="text-muted" style="font-size:13px">Brak szablonów. Dodaj pierwszy używając przycisku wyżej.</p>
|
||||
@endif
|
||||
|
||||
@if ($templateFormOpen)
|
||||
<div class="dialog-backdrop">
|
||||
<form wire:submit="submitTemplate" class="dialog" style="max-width:560px;max-height:88vh;overflow:auto">
|
||||
<div class="dialog-title">{{ $editingTemplateId ? 'Edytuj szablon' : 'Nowy szablon' }}</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Nazwa szablonu</label>
|
||||
<input class="input" wire:model="templateForm.name" placeholder="np. Przypomnienie o braku odpowiedzi">
|
||||
@error('templateForm.name') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Temat wiadomości</label>
|
||||
<input class="input" wire:model="templateForm.subject">
|
||||
@error('templateForm.subject') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Treść</label>
|
||||
<x-quill-editor wire:key="trigger-template-body-{{ $editingTemplateId ?? 'new' }}" :value="$templateForm['body']" on-change="setTemplateBodyDraft" min-height="160px" />
|
||||
@error('templateForm.body') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
<p class="text-muted" style="font-size:12px;margin:0">Dostępne zmienne: {numer}, {imie}, {temat}, {status}, {kategoria}, {priorytet}, {zespol}, {operator}, {link}. Ta treść trafia do wspólnego szablonu-pudełka (zakładka „E-MAIL”) w miejscu {tresc}.</p>
|
||||
|
||||
<div class="dialog-actions">
|
||||
<button type="button" class="btn btn-secondary" wire:click="closeTemplateForm">Anuluj</button>
|
||||
<button type="submit" class="btn btn-primary">{{ $editingTemplateId ? 'Zapisz' : 'Utwórz' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($formOpen)
|
||||
<div class="dialog-backdrop">
|
||||
<form wire:submit="submit" class="dialog" style="max-width:640px;max-height:88vh;overflow:auto">
|
||||
<div class="dialog-title">{{ $editingId ? 'Edytuj wyzwalacz' : 'Nowy wyzwalacz' }}</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Nazwa</label>
|
||||
<input class="input" wire:model="form.name" placeholder="np. Priorytet krytyczny → zespół VIP">
|
||||
@error('form.name') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Zdarzenie</label>
|
||||
<select class="input" wire:model="form.event">
|
||||
@foreach ($eventLabels as $key => $label)
|
||||
<option value="{{ $key }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label class="radio"><input type="checkbox" wire:model="form.enabled" style="position:static;opacity:1;width:auto;height:auto">Aktywny</label>
|
||||
|
||||
<div class="hr"></div>
|
||||
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<label style="font-weight:500">Warunki (wszystkie muszą być spełnione)</label>
|
||||
<button type="button" class="btn btn-ghost" wire:click="addCondition">+ Dodaj warunek</button>
|
||||
</div>
|
||||
|
||||
@foreach ($form['conditions'] as $i => $condition)
|
||||
<div wire:key="condition-{{ $i }}" style="display:flex;gap:6px;align-items:flex-start">
|
||||
<select class="input" style="flex:1" wire:model.live="form.conditions.{{ $i }}.field">
|
||||
@foreach ($fieldLabels as $key => $label)
|
||||
<option value="{{ $key }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<select class="input" style="flex:1" wire:model.live="form.conditions.{{ $i }}.operator">
|
||||
@foreach ($operatorLabels as $key => $label)
|
||||
<option value="{{ $key }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@if (! in_array($condition['operator'], ['is_empty', 'is_not_empty']))
|
||||
@if (($condition['field'] ?? null) === 'status_key')
|
||||
<select class="input" style="flex:1" wire:model="form.conditions.{{ $i }}.value">
|
||||
@foreach ($this->statuses as $status)
|
||||
<option value="{{ $status->key }}">{{ $status->label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@elseif (($condition['field'] ?? null) === 'priority_key')
|
||||
<select class="input" style="flex:1" wire:model="form.conditions.{{ $i }}.value">
|
||||
@foreach ($this->priorities as $priority)
|
||||
<option value="{{ $priority->key }}">{{ $priority->label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@elseif (($condition['field'] ?? null) === 'team_id')
|
||||
<select class="input" style="flex:1" wire:model="form.conditions.{{ $i }}.value">
|
||||
@foreach ($this->teams as $team)
|
||||
<option value="{{ $team->id }}">{{ $team->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@elseif (($condition['field'] ?? null) === 'assignee_id')
|
||||
<select class="input" style="flex:1" wire:model="form.conditions.{{ $i }}.value">
|
||||
@foreach ($this->operators as $operator)
|
||||
<option value="{{ $operator->id }}">{{ $operator->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@elseif (($condition['field'] ?? null) === 'subcategory_id')
|
||||
<select class="input" style="flex:1" wire:model="form.conditions.{{ $i }}.value">
|
||||
@foreach ($this->subcategories as $sub)
|
||||
<option value="{{ $sub->id }}">{{ $sub->category->name }} / {{ $sub->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@elseif (($condition['field'] ?? null) === 'customer_id')
|
||||
<select class="input" style="flex:1" wire:model="form.conditions.{{ $i }}.value">
|
||||
@foreach ($this->customers as $customer)
|
||||
<option value="{{ $customer->id }}">{{ $customer->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@else
|
||||
<input class="input" style="flex:1" wire:model="form.conditions.{{ $i }}.value" placeholder="wartość">
|
||||
@endif
|
||||
@else
|
||||
<div style="flex:1"></div>
|
||||
@endif
|
||||
<button type="button" class="btn btn-ghost" style="padding:0 6px" wire:click="removeCondition({{ $i }})" title="Usuń warunek">
|
||||
<span class="material-symbols-outlined" style="font-size:16px">close</span>
|
||||
</button>
|
||||
</div>
|
||||
@endforeach
|
||||
@if (empty($form['conditions']))
|
||||
<p class="text-muted" style="font-size:12px;margin:0">Brak warunków — wyzwalacz zadziała za każdym razem, gdy wybrane zdarzenie wystąpi.</p>
|
||||
@endif
|
||||
|
||||
<div class="hr"></div>
|
||||
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<label style="font-weight:500">Akcje (wykonywane po kolei)</label>
|
||||
<button type="button" class="btn btn-ghost" wire:click="addAction">+ Dodaj akcję</button>
|
||||
</div>
|
||||
@error('form.actions') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
|
||||
|
||||
@foreach ($form['actions'] as $i => $action)
|
||||
<div wire:key="action-{{ $i }}" class="card" style="padding:10px;gap:6px">
|
||||
<div style="display:flex;gap:6px;align-items:center">
|
||||
<select class="input" style="flex:1" wire:model.live="form.actions.{{ $i }}.type">
|
||||
@foreach ($actionTypeLabels as $key => $label)
|
||||
<option value="{{ $key }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<button type="button" class="btn btn-ghost" style="padding:0 4px" wire:click="moveActionUp({{ $i }})" title="Przenieś wyżej">
|
||||
<span class="material-symbols-outlined" style="font-size:16px">arrow_upward</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost" style="padding:0 4px" wire:click="moveActionDown({{ $i }})" title="Przenieś niżej">
|
||||
<span class="material-symbols-outlined" style="font-size:16px">arrow_downward</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost" style="padding:0 6px" wire:click="removeAction({{ $i }})" title="Usuń akcję">
|
||||
<span class="material-symbols-outlined" style="font-size:16px">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (($action['type'] ?? null) === 'set_status')
|
||||
<select class="input" wire:model="form.actions.{{ $i }}.value">
|
||||
@foreach ($this->statuses as $status)
|
||||
<option value="{{ $status->key }}">{{ $status->label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@elseif (($action['type'] ?? null) === 'set_priority')
|
||||
<select class="input" wire:model="form.actions.{{ $i }}.value">
|
||||
@foreach ($this->priorities as $priority)
|
||||
<option value="{{ $priority->key }}">{{ $priority->label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@elseif (($action['type'] ?? null) === 'set_team')
|
||||
<select class="input" wire:model="form.actions.{{ $i }}.value">
|
||||
@foreach ($this->teams as $team)
|
||||
<option value="{{ $team->id }}">{{ $team->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@elseif (($action['type'] ?? null) === 'set_assignee')
|
||||
<select class="input" wire:model="form.actions.{{ $i }}.value">
|
||||
@foreach ($this->operators as $operator)
|
||||
<option value="{{ $operator->id }}">{{ $operator->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@elseif (($action['type'] ?? null) === 'send_notification')
|
||||
<div style="display:flex;gap:6px">
|
||||
<select class="input" style="flex:1" wire:model="form.actions.{{ $i }}.recipient">
|
||||
<option value="client">Zgłaszający</option>
|
||||
<option value="operator">Przypisany operator</option>
|
||||
</select>
|
||||
<select class="input" style="flex:1" wire:model="form.actions.{{ $i }}.email_template_id">
|
||||
<option value="">— wybierz szablon —</option>
|
||||
@foreach ($this->emailTemplates as $template)
|
||||
<option value="{{ $template->id }}">{{ $template->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
<div class="dialog-actions">
|
||||
<button type="button" class="btn btn-secondary" wire:click="closeForm">Anuluj</button>
|
||||
<button type="submit" class="btn btn-primary">{{ $editingId ? 'Zapisz' : 'Utwórz' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -19,7 +19,7 @@
|
||||
@foreach (($tab === 'current' ? $this->currentTickets : $this->archiveTickets) as $ticket)
|
||||
<a href="{{ route('client.ticket', $ticket) }}" wire:navigate class="card elev-sm" style="padding:16px;cursor:pointer;flex-direction:row;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;text-decoration:none;color:inherit">
|
||||
<div>
|
||||
<div style="font-weight:500">#{{ $ticket->number }} — {{ $ticket->subject }}</div>
|
||||
<div style="font-weight:500">{{ $ticket->displayNumber() }} — {{ $ticket->subject }}</div>
|
||||
<div class="card-meta">{{ $ticket->categoryLabel() }} · {{ \App\Support\Rel::format($ticket->updated_at) }}</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px">
|
||||
|
||||
@@ -28,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() }} · utworzono {{ \App\Support\Rel::format($ticket->created_at) }}</div>
|
||||
<div style="white-space:pre-wrap;font-size:14px;margin-top:4px">{{ $ticket->body }}</div>
|
||||
@@ -168,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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -137,7 +137,7 @@
|
||||
<table class="table table-cards-mobile">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th><input type="checkbox" @checked($this->filteredTickets->isNotEmpty() && empty($this->filteredTickets->pluck('id')->diff($selectedIds)->all())) wire:click="toggleSelectAll" title="Zaznacz wszystkie"></th>
|
||||
@foreach ($columnDefs as $key => $label)
|
||||
@continue(! in_array($key, $visibleColumns))
|
||||
<th>
|
||||
@@ -161,7 +161,12 @@
|
||||
<tr wire:key="ticket-{{ $t->id }}">
|
||||
<td class="td-select"><input type="checkbox" @checked(in_array($t->id, $selectedIds)) wire:click="toggleSelect({{ $t->id }})"></td>
|
||||
@if (in_array('number', $visibleColumns))
|
||||
<td data-label="Numer" class="td-title"><a href="{{ route('operator.ticket', $t) }}" wire:navigate style="color:inherit;text-decoration:none;cursor:pointer">{{ $t->number }}</a></td>
|
||||
<td data-label="Numer" class="td-title">
|
||||
<a href="{{ route('operator.ticket', $t) }}" wire:navigate style="color:inherit;text-decoration:none;cursor:pointer">{{ $t->displayNumber() }}</a>
|
||||
@if ($t->source === 'email')
|
||||
<span class="material-symbols-outlined" style="font-size:15px;vertical-align:-3px;opacity:0.7" title="Utworzone przez e-mail">mail</span>
|
||||
@endif
|
||||
</td>
|
||||
@endif
|
||||
@if (in_array('subject', $visibleColumns))
|
||||
<td data-label="Temat" class="td-title"><a href="{{ route('operator.ticket', $t) }}" wire:navigate style="color:inherit;text-decoration:none;cursor:pointer;white-space:nowrap">{{ $t->subject }}</a></td>
|
||||
@@ -172,6 +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
|
||||
@@ -184,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>
|
||||
|
||||
@@ -4,7 +4,18 @@
|
||||
<div class="page-pad" style="flex:1;padding:20px 24px;overflow:auto">
|
||||
<div style="display:flex;flex-direction:column;gap:16px;max-width:1180px;margin:0 auto">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
|
||||
<a href="{{ route('operator.queue') }}" wire:navigate class="btn btn-ghost" style="padding:0">← Wróć do listy</a>
|
||||
<a href="{{ route('operator.queue') }}" wire:navigate class="btn btn-ghost" style="padding:0;margin-right:auto">← Wróć do listy</a>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
style="gap:6px"
|
||||
wire:click="toggleWatch"
|
||||
title="{{ $this->isWatching ? 'Przestań obserwować to zgłoszenie' : 'Obserwuj to zgłoszenie' }}"
|
||||
>
|
||||
<span class="material-symbols-outlined" style="font-size:18px">{{ $this->isWatching ? 'star' : 'star_outline' }}</span>
|
||||
{{ $this->isWatching ? 'Obserwowane' : 'Obserwuj' }}
|
||||
</button>
|
||||
|
||||
{{-- Live updates arrive via broadcasting, but websocket connections can
|
||||
drop silently — this is a periodic fallback refresh, with a visible
|
||||
@@ -25,7 +36,14 @@
|
||||
<div class="main-col" style="display:flex;flex-direction:column;gap:16px">
|
||||
<div class="card" style="padding:20px;gap:8px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">
|
||||
<div class="card-kicker">Zgłoszenie #{{ $ticket->number }}</div>
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<div class="card-kicker">Zgłoszenie {{ $ticket->displayNumber() }}</div>
|
||||
@if ($ticket->source === 'email')
|
||||
<span class="tag tag-outline" style="display:inline-flex;align-items:center;gap:3px;font-size:10.5px" title="Utworzone przez e-mail">
|
||||
<span class="material-symbols-outlined" style="font-size:13px">mail</span>E-mail
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
@unless ($editingDetails)
|
||||
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="toggleEditDetails">Edytuj</button>
|
||||
@endunless
|
||||
@@ -149,7 +167,12 @@
|
||||
<div wire:key="msg-{{ $m->id }}" style="display:flex;justify-content:{{ $mine ? 'flex-end' : 'flex-start' }}">
|
||||
<div style="max-width:75%;padding:10px 14px;border-radius:12px;font-size:14px;background:{{ $mine ? 'var(--color-accent-800)' : 'var(--color-surface)' }};color:{{ $mine ? 'var(--color-accent-100)' : 'var(--color-text)' }};border:1px solid var(--color-divider)">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px">
|
||||
<div style="font-size:11px;opacity:0.65;margin-bottom:4px">{{ $m->author_name }} · {{ \App\Support\Rel::format($m->created_at) }}{{ $m->edited ? ' · edytowano' : '' }}</div>
|
||||
<div style="font-size:11px;opacity:0.65;margin-bottom:4px;display:flex;align-items:center;gap:4px">
|
||||
@if ($m->source === 'email')
|
||||
<span class="material-symbols-outlined" style="font-size:13px" title="Odebrane e-mailem">mail</span>
|
||||
@endif
|
||||
{{ $m->author_name }} · {{ \App\Support\Rel::format($m->created_at) }}{{ $m->edited ? ' · edytowano' : '' }}
|
||||
</div>
|
||||
@if ($m->role === 'operator')
|
||||
<div style="display:flex;gap:6px;flex:none">
|
||||
<span class="material-symbols-outlined" style="font-size:15px;cursor:pointer;opacity:0.7" wire:click="startEditMessage({{ $m->id }}, @js($m->body))">edit</span>
|
||||
@@ -445,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>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
@php
|
||||
$categoryLabels = [
|
||||
'new_ticket' => 'Nowe zgłoszenie',
|
||||
'ticket_update' => 'Aktualizacja zgłoszenia',
|
||||
'escalation' => 'Zgłoszenie eskalowane',
|
||||
];
|
||||
$scopeColumns = [
|
||||
'scope_mine' => 'Moje zgłoszenia',
|
||||
'scope_unassigned' => 'Nie przypisany',
|
||||
'scope_watched' => 'Obserwowane zgłoszenia',
|
||||
'scope_all' => 'Wszystkie zgłoszenia',
|
||||
];
|
||||
@endphp
|
||||
<div style="flex:1;display:flex;flex-direction:column">
|
||||
<x-topbar />
|
||||
|
||||
<div class="page-pad" style="flex:1;padding:28px;display:flex;flex-direction:column;gap:24px;max-width:920px;width:100%;margin:0 auto;box-sizing:border-box">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap">
|
||||
<div>
|
||||
<h3 style="margin:0 0 6px">Powiadomienia</h3>
|
||||
<p class="text-muted" style="font-size:12.5px;margin:0">Wybierz, o których zgłoszeniach chcesz być informowany dzwoneczkiem w aplikacji, i przy których zdarzeniach dodatkowo wysłać Ci e-mail.</p>
|
||||
</div>
|
||||
<a href="{{ auth()->user()->isAdmin() ? route('admin.panel') : route('operator.queue') }}" wire:navigate class="btn btn-ghost" style="padding:0;flex:none">← Wróć</a>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding:0;overflow:hidden">
|
||||
<div class="table-wrap">
|
||||
<table class="table" style="margin:0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
@foreach ($scopeColumns as $label)
|
||||
<th style="text-align:center">{{ $label }}</th>
|
||||
@endforeach
|
||||
<th style="text-align:center">Informuj również przez e-mail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($categoryLabels as $category => $label)
|
||||
<tr wire:key="pref-row-{{ $category }}">
|
||||
<td style="white-space:nowrap">{{ $label }}</td>
|
||||
@foreach ($scopeColumns as $field => $ignored)
|
||||
<td style="text-align:center">
|
||||
<input type="checkbox" @checked($rows[$category][$field]) wire:click="toggle('{{ $category }}', '{{ $field }}')">
|
||||
</td>
|
||||
@endforeach
|
||||
<td style="text-align:center">
|
||||
<input type="checkbox" @checked($rows[$category]['email']) wire:click="toggle('{{ $category }}', 'email')">
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding:18px;gap:10px" x-data="{
|
||||
supported: typeof Notification !== 'undefined',
|
||||
permission: typeof Notification !== 'undefined' ? Notification.permission : 'unsupported',
|
||||
enabled: localStorage.getItem('browserNotificationsEnabled') === '1',
|
||||
async enable() {
|
||||
if (!this.supported) return;
|
||||
this.permission = await Notification.requestPermission();
|
||||
this.enabled = this.permission === 'granted';
|
||||
localStorage.setItem('browserNotificationsEnabled', this.enabled ? '1' : '0');
|
||||
},
|
||||
disable() {
|
||||
this.enabled = false;
|
||||
localStorage.setItem('browserNotificationsEnabled', '0');
|
||||
},
|
||||
}">
|
||||
<h4 style="margin:0">Powiadomienia push w przeglądarce</h4>
|
||||
<p class="text-muted" style="font-size:12px;margin:0">Gdy ta karta jest otwarta, nowe zdarzenia z dzwoneczka mogą dodatkowo pojawić się jako natywne powiadomienie przeglądarki.</p>
|
||||
|
||||
<template x-if="!supported">
|
||||
<span class="text-muted" style="font-size:12px">Ta przeglądarka nie obsługuje powiadomień push.</span>
|
||||
</template>
|
||||
|
||||
<template x-if="supported && permission === 'denied'">
|
||||
<span style="font-size:12px;color:var(--color-danger)">Powiadomienia zostały zablokowane w ustawieniach przeglądarki.</span>
|
||||
</template>
|
||||
|
||||
<template x-if="supported && permission !== 'denied' && !enabled">
|
||||
<button type="button" class="btn btn-secondary" style="align-self:flex-start" x-on:click="enable">Włącz powiadomienia push</button>
|
||||
</template>
|
||||
|
||||
<template x-if="supported && enabled">
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<div style="display:flex;align-items:center;gap:6px;color:var(--color-success)">
|
||||
<span class="material-symbols-outlined" style="font-size:18px">check_circle</span>Włączone
|
||||
</div>
|
||||
<button type="button" class="btn btn-ghost" x-on:click="disable">Wyłącz</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -12,14 +12,14 @@ use Illuminate\Support\Facades\Route;
|
||||
Route::prefix('v1')->middleware('throttle:api')->group(function () {
|
||||
Route::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 () {
|
||||
|
||||
@@ -34,3 +34,14 @@ Broadcast::channel('ticket.{ticketId}', function ($user, int $ticketId) {
|
||||
return (in_array('operator', $user->roles ?? []) && $ticket->isVisibleToOperator($user))
|
||||
|| $ticket->customer_id === $user->id;
|
||||
});
|
||||
|
||||
/**
|
||||
* Every logged-in user's own private notification stream (bell realtime
|
||||
* updates + in-tab browser push, see NotificationCreated). Laravel's
|
||||
* default `App.Models.User.{id}` naming convention is kept verbatim so it
|
||||
* matches what `$notifiable->notify()` already implies, rather than
|
||||
* inventing a shorter alias.
|
||||
*/
|
||||
Broadcast::channel('App.Models.User.{id}', function ($user, int $id) {
|
||||
return $user->id === $id;
|
||||
});
|
||||
|
||||
@@ -10,3 +10,4 @@ Artisan::command('inspire', function () {
|
||||
|
||||
Schedule::command('tickets:check-sla-breaches')->everyFifteenMinutes();
|
||||
Schedule::command('automation:run-rules')->everyFifteenMinutes();
|
||||
Schedule::command('emails:fetch-imap')->everyFiveMinutes()->withoutOverlapping();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
31
src/tests/Feature/DeletedTicketRedirectsInsteadOf404Test.php
Normal file
31
src/tests/Feature/DeletedTicketRedirectsInsteadOf404Test.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
test('visiting a deleted ticket as an operator redirects to the operator queue instead of 404ing', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = User::query()->create(['name' => 'Op', 'email' => 'op@example.com', 'roles' => ['operator']]);
|
||||
$ticket = makeTicket();
|
||||
$id = $ticket->id;
|
||||
$ticket->delete();
|
||||
|
||||
$this->actingAs($operator)
|
||||
->get("/operator/tickets/{$id}")
|
||||
->assertRedirect(route('operator.queue'));
|
||||
});
|
||||
|
||||
test('visiting a deleted ticket as a client redirects to the client dashboard instead of 404ing', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$client = User::query()->create(['name' => 'Klient', 'email' => 'klient@example.com', 'roles' => ['client']]);
|
||||
$ticket = makeTicket(['customer_id' => $client->id]);
|
||||
$id = $ticket->id;
|
||||
$ticket->delete();
|
||||
|
||||
$this->actingAs($client)
|
||||
->get("/client/tickets/{$id}")
|
||||
->assertRedirect(route('client.dashboard'));
|
||||
});
|
||||
|
||||
test('a guest hitting a non-existent ticket route still gets the normal (non-redirected) handling', function () {
|
||||
$this->get('/operator/tickets/999999')->assertRedirect(route('login'));
|
||||
});
|
||||
@@ -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')
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\EmailTemplate;
|
||||
use App\Models\NotificationPreference;
|
||||
use App\Models\NotificationSetting;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
@@ -140,7 +141,7 @@ test('an operator reply fires operator_replied once enabled, independent of any
|
||||
Notification::assertSentOnDemandTimes(TicketNotification::class, 1);
|
||||
});
|
||||
|
||||
test('every member of a team whose subcategory matches a new ticket gets notified once, no duplicates', function () {
|
||||
test('every operator/admin whose new_ticket preference puts a routed ticket in scope gets notified once, no duplicates', function () {
|
||||
Notification::fake();
|
||||
$this->seed();
|
||||
|
||||
@@ -152,6 +153,10 @@ test('every member of a team whose subcategory matches a new ticket gets notifie
|
||||
$memberB = User::query()->create(['name' => 'Jan', 'email' => 'team-notif-b@example.com', 'roles' => ['operator']]);
|
||||
$team->members()->attach([$memberA->id, $memberB->id]);
|
||||
|
||||
// auto_assign_by_category is on by default (seeded), so the ticket's
|
||||
// team_id actually becomes the VPN team's id — that's what now drives
|
||||
// who's "in scope" for the default scope_all preference, replacing the
|
||||
// old separate team-subcategory-routing fan-out.
|
||||
app(TicketService::class)->create([
|
||||
'email' => 'client-team-notif@example.com',
|
||||
'subject' => 'Problem z VPN',
|
||||
@@ -161,19 +166,53 @@ test('every member of a team whose subcategory matches a new ticket gets notifie
|
||||
|
||||
Notification::assertSentTo($memberA, TicketNotification::class);
|
||||
Notification::assertSentTo($memberB, TicketNotification::class);
|
||||
// The seeded admin also qualifies: Ticket::isVisibleToOperator() returns
|
||||
// true unconditionally for admins, and scope_all is the default — this
|
||||
// is intentional, it's what keeps the one real admin account notified
|
||||
// about every new ticket without any setup.
|
||||
Notification::assertSentTo(User::query()->where('email', 'admin@example.com')->firstOrFail(), TicketNotification::class);
|
||||
// Plus one more: TicketService::create() also fires the pre-existing
|
||||
// 'ticket_created' trigger, routed anonymously to the guest's e-mail
|
||||
// since this ticket has no real customer account.
|
||||
Notification::assertSentTimes(TicketNotification::class, 3);
|
||||
Notification::assertSentTimes(TicketNotification::class, 4);
|
||||
});
|
||||
|
||||
test('a new ticket with no matching team notifies no operator', function () {
|
||||
test('an operator outside the ticket\'s team is not notified, even with scope_all left at its default', function () {
|
||||
Notification::fake();
|
||||
$this->seed();
|
||||
|
||||
$category = Category::query()->create(['name' => 'IT-Pomoc']);
|
||||
$sub = $category->subcategories()->create(['name' => 'VPN']);
|
||||
$team = Team::query()->create(['name' => 'Zespół VPN']);
|
||||
$team->subcategories()->attach($sub->id);
|
||||
User::query()->create(['name' => 'Ola', 'email' => 'team-notif-a@example.com', 'roles' => ['operator']])
|
||||
->teams()->attach($team->id);
|
||||
$outsider = User::query()->create(['name' => 'Niepowiązany', 'email' => 'unrelated-op@example.com', 'roles' => ['operator']]);
|
||||
|
||||
app(TicketService::class)->create([
|
||||
'email' => 'client-team-notif-2@example.com',
|
||||
'subject' => 'Problem z VPN',
|
||||
'body' => 'Nie mogę się połączyć.',
|
||||
'subcategory_id' => $sub->id,
|
||||
], null);
|
||||
|
||||
// The ticket routed to "Zespół VPN"; $outsider belongs to no team, so
|
||||
// Ticket::isVisibleToOperator() (which scope_all delegates to) is false
|
||||
// for them even though their preference defaults to scope_all=true.
|
||||
Notification::assertNotSentTo($outsider, TicketNotification::class);
|
||||
});
|
||||
|
||||
test('an operator who turns off scope_all for new tickets stops receiving them, even for a ticket they could otherwise see', function () {
|
||||
Notification::fake();
|
||||
$this->seed();
|
||||
|
||||
$category = Category::query()->create(['name' => 'Bez zespołu']);
|
||||
$sub = $category->subcategories()->create(['name' => 'Inne']);
|
||||
$operator = User::query()->create(['name' => 'Niepowiązany', 'email' => 'unrelated-op@example.com', 'roles' => ['operator']]);
|
||||
$operator = User::query()->create(['name' => 'Cichy', 'email' => 'opted-out-op@example.com', 'roles' => ['operator']]);
|
||||
NotificationPreference::query()->create(array_merge(
|
||||
['user_id' => $operator->id, 'event_category' => 'new_ticket'],
|
||||
array_merge(NotificationPreference::DEFAULTS['new_ticket'], ['scope_all' => false])
|
||||
));
|
||||
|
||||
app(TicketService::class)->create([
|
||||
'email' => 'client-no-team@example.com',
|
||||
|
||||
203
src/tests/Feature/ImapCategoryRoutingAndSourceBadgeTest.php
Normal file
203
src/tests/Feature/ImapCategoryRoutingAndSourceBadgeTest.php
Normal file
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\MailSettings;
|
||||
use App\Livewire\Operator\Queue;
|
||||
use App\Livewire\Operator\TicketShow;
|
||||
use App\Models\Category;
|
||||
use App\Models\ImapMailbox;
|
||||
use App\Models\User;
|
||||
use App\Services\TicketService;
|
||||
use Livewire\Livewire;
|
||||
|
||||
// ===================== TicketService::create() category-only routing =====================
|
||||
|
||||
test('create() sets category_id when only a category is given (no subcategory)', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$category = Category::query()->create(['name' => 'Delegacje']);
|
||||
|
||||
$ticket = app(TicketService::class)->create([
|
||||
'email' => 'gosc@example.com',
|
||||
'category_id' => $category->id,
|
||||
'subject' => 'Sprawa delegacji',
|
||||
'body' => 'Treść',
|
||||
], null);
|
||||
|
||||
expect($ticket->category_id)->toBe($category->id)
|
||||
->and($ticket->subcategory_id)->toBeNull()
|
||||
->and($ticket->categoryLabel())->toBe('Delegacje');
|
||||
});
|
||||
|
||||
test('create() leaves category_id null when a subcategory is given (category is derived from it)', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$subcategory = subcategoryFixture();
|
||||
|
||||
$ticket = app(TicketService::class)->create([
|
||||
'email' => 'gosc@example.com',
|
||||
'subcategory_id' => $subcategory->id,
|
||||
'subject' => 'Sprawa VPN',
|
||||
'body' => 'Treść',
|
||||
], null);
|
||||
|
||||
expect($ticket->category_id)->toBeNull()
|
||||
->and($ticket->categoryLabel())->toBe('IT / VPN');
|
||||
});
|
||||
|
||||
test('create() defaults source to web, and accepts an explicit source', function () {
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
$webTicket = app(TicketService::class)->create([
|
||||
'email' => 'a@example.com', 'subject' => 'S', 'body' => 'B',
|
||||
], null);
|
||||
|
||||
$mailTicket = app(TicketService::class)->create([
|
||||
'email' => 'b@example.com', 'subject' => 'S', 'body' => 'B', 'source' => 'email',
|
||||
], null);
|
||||
|
||||
expect($webTicket->source)->toBe('web')
|
||||
->and($mailTicket->source)->toBe('email');
|
||||
});
|
||||
|
||||
// ===================== ImapMailbox::targetLabel() =====================
|
||||
|
||||
test('targetLabel reflects subcategory, whole-category, or neither', function () {
|
||||
$subcategory = subcategoryFixture();
|
||||
$category = Category::query()->create(['name' => 'Delegacje']);
|
||||
|
||||
$bySubcategory = ImapMailbox::query()->create(mailboxFixtureData(['default_subcategory_id' => $subcategory->id]));
|
||||
$byCategory = ImapMailbox::query()->create(mailboxFixtureData(['default_category_id' => $category->id]));
|
||||
$unrouted = ImapMailbox::query()->create(mailboxFixtureData());
|
||||
|
||||
expect($bySubcategory->targetLabel())->toBe('IT / VPN')
|
||||
->and($byCategory->targetLabel())->toBe('Cała kategoria: Delegacje')
|
||||
->and($unrouted->targetLabel())->toBe('—');
|
||||
});
|
||||
|
||||
function mailboxFixtureData(array $overrides = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'name' => 'Test',
|
||||
'enabled' => true,
|
||||
'host' => 'imap.example.com',
|
||||
'port' => 993,
|
||||
'encryption' => 'ssl',
|
||||
'validate_cert' => true,
|
||||
'username' => 'test@example.com',
|
||||
'password' => 'secret',
|
||||
'folder' => 'INBOX',
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
// ===================== Admin: MailSettings mailbox form =====================
|
||||
|
||||
test('admin can route a mailbox to a whole category via the combined selector', function () {
|
||||
$admin = adminUser();
|
||||
$category = Category::query()->create(['name' => 'Delegacje']);
|
||||
|
||||
Livewire::actingAs($admin)->test(MailSettings::class)
|
||||
->call('openMailboxForm')
|
||||
->set('mailboxForm.name', 'Zgłoszenia delegacji')
|
||||
->set('mailboxForm.host', 'imap.example.com')
|
||||
->set('mailboxForm.username', 'zgloszenia-delegacje@example.com')
|
||||
->set('mailboxForm.password', 'secret')
|
||||
->set('mailboxForm.target', "category:{$category->id}")
|
||||
->call('submitMailboxForm')
|
||||
->assertOk();
|
||||
|
||||
$mailbox = ImapMailbox::query()->where('name', 'Zgłoszenia delegacji')->firstOrFail();
|
||||
|
||||
expect($mailbox->default_category_id)->toBe($category->id)
|
||||
->and($mailbox->default_subcategory_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('admin can route a mailbox to a specific subcategory via the combined selector', function () {
|
||||
$admin = adminUser();
|
||||
$subcategory = subcategoryFixture();
|
||||
|
||||
Livewire::actingAs($admin)->test(MailSettings::class)
|
||||
->call('openMailboxForm')
|
||||
->set('mailboxForm.name', 'Zgłoszenia IT')
|
||||
->set('mailboxForm.host', 'imap.example.com')
|
||||
->set('mailboxForm.username', 'zgloszenia-it@example.com')
|
||||
->set('mailboxForm.password', 'secret')
|
||||
->set('mailboxForm.target', "subcategory:{$subcategory->id}")
|
||||
->call('submitMailboxForm')
|
||||
->assertOk();
|
||||
|
||||
$mailbox = ImapMailbox::query()->where('name', 'Zgłoszenia IT')->firstOrFail();
|
||||
|
||||
expect($mailbox->default_subcategory_id)->toBe($subcategory->id)
|
||||
->and($mailbox->default_category_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('switching an existing mailbox from a subcategory to a whole category clears the old target', function () {
|
||||
$admin = adminUser();
|
||||
$subcategory = subcategoryFixture();
|
||||
$category = Category::query()->create(['name' => 'Delegacje']);
|
||||
|
||||
$mailbox = ImapMailbox::query()->create(mailboxFixtureData(['default_subcategory_id' => $subcategory->id]));
|
||||
|
||||
Livewire::actingAs($admin)->test(MailSettings::class)
|
||||
->call('editMailbox', $mailbox->id)
|
||||
->assertSet('mailboxForm.target', "subcategory:{$subcategory->id}")
|
||||
->set('mailboxForm.target', "category:{$category->id}")
|
||||
->call('submitMailboxForm')
|
||||
->assertOk();
|
||||
|
||||
$mailbox->refresh();
|
||||
|
||||
expect($mailbox->default_category_id)->toBe($category->id)
|
||||
->and($mailbox->default_subcategory_id)->toBeNull();
|
||||
});
|
||||
|
||||
// ===================== Operator UI: e-mail source badge =====================
|
||||
|
||||
test('the operator queue shows a mail icon next to an e-mail-originated ticket but not a web one', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser();
|
||||
$webTicket = makeTicket(['number' => '2001', 'source' => 'web']);
|
||||
$mailTicket = makeTicket(['number' => '2002', 'source' => 'email']);
|
||||
|
||||
Livewire::actingAs($operator)->test(Queue::class)
|
||||
->assertSeeHtml('title="Utworzone przez e-mail"');
|
||||
});
|
||||
|
||||
test('the ticket detail header shows an e-mail badge only for e-mail-originated tickets', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser();
|
||||
$mailTicket = makeTicket(['number' => '2003', 'source' => 'email']);
|
||||
$webTicket = makeTicket(['number' => '2004', 'source' => 'web']);
|
||||
|
||||
Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $mailTicket])
|
||||
->assertSee('E-mail');
|
||||
|
||||
Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $webTicket])
|
||||
->assertDontSee('E-mail');
|
||||
});
|
||||
|
||||
// ===================== Operator queue: category-only tickets are filterable =====================
|
||||
|
||||
test('filtering the queue by category includes a ticket routed to that whole category with no subcategory', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser();
|
||||
$category = Category::query()->create(['name' => 'Delegacje']);
|
||||
$ticket = makeTicket(['number' => '2005', 'category_id' => $category->id]);
|
||||
|
||||
Livewire::actingAs($operator)->test(Queue::class)
|
||||
->set('filterCategory', $category->id)
|
||||
->assertSee($ticket->displayNumber());
|
||||
});
|
||||
|
||||
// ===================== Operator UI: per-message e-mail source badge =====================
|
||||
|
||||
test('a reply fetched by e-mail shows a mail badge in the thread, a normal client reply does not', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser();
|
||||
$client = User::query()->create(['name' => 'Klient', 'email' => 'klient@example.com', 'roles' => ['client']]);
|
||||
$ticket = makeTicket(['number' => '2006']);
|
||||
|
||||
app(TicketService::class)->clientReply($ticket, $client, 'Odpowiedź z portalu.');
|
||||
app(TicketService::class)->clientReply($ticket, $client, 'Odpowiedź e-mailem.', source: 'email');
|
||||
|
||||
Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket])
|
||||
->assertSeeHtml('title="Odebrane e-mailem"');
|
||||
});
|
||||
215
src/tests/Feature/ImapMessageClassifierTest.php
Normal file
215
src/tests/Feature/ImapMessageClassifierTest.php
Normal file
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
use App\Ldap\LldapUser;
|
||||
use App\Models\User;
|
||||
use App\Services\ImapMessageClassifier;
|
||||
use App\Support\Imap\InboundEmail;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Str;
|
||||
use LdapRecord\Laravel\Testing\DirectoryEmulator;
|
||||
|
||||
afterEach(function () {
|
||||
DirectoryEmulator::tearDown();
|
||||
});
|
||||
|
||||
function makeInboundEmail(array $overrides = []): InboundEmail
|
||||
{
|
||||
return new InboundEmail(
|
||||
fromEmail: $overrides['fromEmail'] ?? 'klient@example.com',
|
||||
fromName: $overrides['fromName'] ?? 'Jan Kowalski',
|
||||
subject: $overrides['subject'] ?? 'Zwykła wiadomość',
|
||||
textBody: $overrides['textBody'] ?? 'Treść wiadomości.',
|
||||
htmlBody: $overrides['htmlBody'] ?? '',
|
||||
headers: $overrides['headers'] ?? [],
|
||||
attachments: $overrides['attachments'] ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
// ===================== rejectionReason() =====================
|
||||
|
||||
test('a normal reply is not rejected', function () {
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
expect($classifier->rejectionReason(makeInboundEmail()))->toBeNull();
|
||||
});
|
||||
|
||||
test('Auto-Submitted header other than "no" is rejected', function () {
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
$email = makeInboundEmail(['headers' => ['auto-submitted' => 'auto-replied']]);
|
||||
|
||||
expect($classifier->rejectionReason($email))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('Auto-Submitted: no is not rejected', function () {
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
$email = makeInboundEmail(['headers' => ['auto-submitted' => 'no']]);
|
||||
|
||||
expect($classifier->rejectionReason($email))->toBeNull();
|
||||
});
|
||||
|
||||
test('X-Autoreply header is rejected', function () {
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
$email = makeInboundEmail(['headers' => ['x-autoreply' => '1']]);
|
||||
|
||||
expect($classifier->rejectionReason($email))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('regression: an empty-string header value (present key, no content) is treated as absent, not rejected', function () {
|
||||
// Reproduces the real production bug: Webklex's Header::get() returns
|
||||
// an empty (non-null) Attribute for a header that isn't on the message
|
||||
// at all, so a naive "!== null" check on x-autoreply/x-autorespond
|
||||
// rejected every single inbound e-mail.
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
$email = makeInboundEmail(['headers' => [
|
||||
'auto-submitted' => '', 'x-autoreply' => '', 'x-autorespond' => '', 'precedence' => '',
|
||||
]]);
|
||||
|
||||
expect($classifier->rejectionReason($email))->toBeNull();
|
||||
});
|
||||
|
||||
test('Precedence: bulk is rejected', function () {
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
$email = makeInboundEmail(['headers' => ['precedence' => 'bulk']]);
|
||||
|
||||
expect($classifier->rejectionReason($email))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('a blocklisted sender is rejected', function () {
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
$email = makeInboundEmail(['fromEmail' => 'mailer-daemon@example.com']);
|
||||
|
||||
expect($classifier->rejectionReason($email, ['mailer-daemon']))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('an out-of-office subject is rejected', function () {
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
$email = makeInboundEmail(['subject' => 'Automatic reply: Out of Office']);
|
||||
|
||||
expect($classifier->rejectionReason($email))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('a Polish autoresponder subject is rejected', function () {
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
$email = makeInboundEmail(['subject' => 'Automatyczna odpowiedz: nieobecnosc w biurze']);
|
||||
|
||||
expect($classifier->rejectionReason($email))->not->toBeNull();
|
||||
});
|
||||
|
||||
// ===================== matchTicket() =====================
|
||||
|
||||
test('matches an existing ticket by its plain sequential number in the subject', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$ticket = makeTicket(['number' => '1042']);
|
||||
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
expect($classifier->matchTicket('Re: Aktualizacja zgłoszenia #1042')->id)->toBe($ticket->id);
|
||||
});
|
||||
|
||||
test('matches an existing ticket by its checksum when obfuscation is enabled', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$ticket = makeTicket(['number' => '1042']);
|
||||
Settings::set('ticket_number_obfuscate', '1');
|
||||
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
expect($classifier->matchTicket("Re: Aktualizacja zgłoszenia #{$ticket->checksum}")->id)->toBe($ticket->id);
|
||||
});
|
||||
|
||||
test('returns null when no digit run in the subject matches any ticket', function () {
|
||||
seedStatusesAndPriorities();
|
||||
makeTicket(['number' => '1042']);
|
||||
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
expect($classifier->matchTicket('Nowa sprawa bez numeru'))->toBeNull();
|
||||
});
|
||||
|
||||
test('strips common reply/forward prefixes before matching', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$ticket = makeTicket(['number' => '1042']);
|
||||
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
foreach (['Re:', 'RE:', 'Odp:', 'Fwd:', 'FW:', 'Aw:'] as $prefix) {
|
||||
expect($classifier->matchTicket("{$prefix} Zgłoszenie #1042")->id)->toBe($ticket->id);
|
||||
}
|
||||
});
|
||||
|
||||
test('when the subject has multiple digit runs, the one that actually resolves wins', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$ticket = makeTicket(['number' => '1042']);
|
||||
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
// "2026" (a year, 4 digits) doesn't resolve to any ticket; "1042" does.
|
||||
expect($classifier->matchTicket('Zgłoszenie #1042 z dnia 2026-07-23')->id)->toBe($ticket->id);
|
||||
});
|
||||
|
||||
// ===================== isSenderAllowed() / resolveSender() =====================
|
||||
|
||||
test('isSenderAllowed allows any e-mail when restrict_tickets_to_ldap is off (the default)', function () {
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
expect($classifier->isSenderAllowed('ktokolwiek@example.com'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('isSenderAllowed rejects an unknown e-mail when restrict_tickets_to_ldap is on', function () {
|
||||
DirectoryEmulator::setup();
|
||||
Settings::set('restrict_tickets_to_ldap', '1');
|
||||
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
expect($classifier->isSenderAllowed('nieznany@firma.pl'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('isSenderAllowed allows an e-mail that exists in LDAP when restrict_tickets_to_ldap is on', function () {
|
||||
DirectoryEmulator::setup();
|
||||
Settings::set('restrict_tickets_to_ldap', '1');
|
||||
|
||||
LldapUser::create([
|
||||
'uid' => 'znany.gosc',
|
||||
'cn' => 'Znany Gość',
|
||||
'mail' => 'znany.gosc@firma.pl',
|
||||
'entryuuid' => (string) Str::uuid(),
|
||||
]);
|
||||
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
expect($classifier->isSenderAllowed('znany.gosc@firma.pl'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('isSenderAllowed allows an already-known local account even when restrict_tickets_to_ldap is on', function () {
|
||||
DirectoryEmulator::setup();
|
||||
Settings::set('restrict_tickets_to_ldap', '1');
|
||||
|
||||
User::query()->create(['name' => 'Istniejący Klient', 'email' => 'istniejacy@firma.pl', 'roles' => ['client']]);
|
||||
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
expect($classifier->isSenderAllowed('istniejacy@firma.pl'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('resolveSender returns an existing local user without touching LDAP', function () {
|
||||
$user = User::query()->create(['name' => 'Istniejący', 'email' => 'istniejacy@firma.pl', 'roles' => ['client']]);
|
||||
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
expect($classifier->resolveSender('istniejacy@firma.pl')->id)->toBe($user->id);
|
||||
});
|
||||
|
||||
test('resolveSender returns null for an unprovisionable guest', function () {
|
||||
Settings::set('ldap_auto_provision_guests', '0');
|
||||
|
||||
$classifier = new ImapMessageClassifier;
|
||||
|
||||
expect($classifier->resolveSender('nikt@example.com'))->toBeNull();
|
||||
});
|
||||
@@ -1,18 +1,18 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\Panel;
|
||||
use App\Livewire\Admin\MailSettings;
|
||||
use App\Models\EmailTemplate;
|
||||
use App\Notifications\TicketNotification;
|
||||
use App\Providers\AppServiceProvider;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('admin can save the SMTP/from settings, and the password is only overwritten when provided', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', '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;
|
||||
}
|
||||
});
|
||||
|
||||
95
src/tests/Feature/NotificationDeliveryRewiringTest.php
Normal file
95
src/tests/Feature/NotificationDeliveryRewiringTest.php
Normal 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);
|
||||
});
|
||||
70
src/tests/Feature/NotificationPreferencesTest.php
Normal file
70
src/tests/Feature/NotificationPreferencesTest.php
Normal 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();
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
39
src/tests/Feature/OperatorQueueSelectAllTest.php
Normal file
39
src/tests/Feature/OperatorQueueSelectAllTest.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Operator\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('toggleSelectAll selects every currently visible ticket', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('select-all-1@example.com');
|
||||
$a = makeTicket(['number' => '1001']);
|
||||
$b = makeTicket(['number' => '1002']);
|
||||
|
||||
Livewire::actingAs($operator)->test(Queue::class)
|
||||
->call('toggleSelectAll')
|
||||
->assertSet('selectedIds', [$a->id, $b->id]);
|
||||
});
|
||||
|
||||
test('toggleSelectAll deselects everything when all visible tickets are already selected', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('select-all-2@example.com');
|
||||
makeTicket(['number' => '1001']);
|
||||
makeTicket(['number' => '1002']);
|
||||
|
||||
Livewire::actingAs($operator)->test(Queue::class)
|
||||
->call('toggleSelectAll')
|
||||
->call('toggleSelectAll')
|
||||
->assertSet('selectedIds', []);
|
||||
});
|
||||
|
||||
test('toggleSelectAll only affects tickets visible under the active filter, not every ticket', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('select-all-3@example.com');
|
||||
makeTicket(['number' => '1001', 'status_key' => 'open']);
|
||||
$closed = makeTicket(['number' => '1002', 'status_key' => 'closed']);
|
||||
|
||||
Livewire::actingAs($operator)->test(Queue::class)
|
||||
->set('queue', 'closed')
|
||||
->call('toggleSelectAll')
|
||||
->assertSet('selectedIds', [$closed->id]);
|
||||
});
|
||||
60
src/tests/Feature/RealtimeBellNotificationTest.php
Normal file
60
src/tests/Feature/RealtimeBellNotificationTest.php
Normal 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);
|
||||
});
|
||||
39
src/tests/Feature/TabPersistenceAndNavigationTest.php
Normal file
39
src/tests/Feature/TabPersistenceAndNavigationTest.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\Panel;
|
||||
use App\Livewire\Operator\Queue;
|
||||
use App\Livewire\Settings\NotificationPreferences;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('the admin panel remembers the active tab across a fresh page load via the URL', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||
->assertSet('tab', 'integrations')
|
||||
->assertSee('LDAP / Active Directory')
|
||||
->assertSee('Baza wiedzy BookStack')
|
||||
->assertDontSee('Sesja i strefa czasowa');
|
||||
});
|
||||
|
||||
test('LDAP and BookStack config moved out of the Konfiguracja tab into their own Integracje tab', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
$config = Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'config']);
|
||||
$config->assertSee('Sesja i strefa czasowa')
|
||||
->assertDontSee('LDAP / Active Directory')
|
||||
->assertDontSee('Baza wiedzy BookStack');
|
||||
});
|
||||
|
||||
test('the operator queue remembers the active queue tab across a fresh page load via the URL', function () {
|
||||
$operator = operatorUser('queue-persist@example.com');
|
||||
|
||||
Livewire::actingAs($operator)->test(Queue::class, ['queue' => 'mine'])
|
||||
->assertSet('queue', 'mine');
|
||||
});
|
||||
|
||||
test('the notifications settings page has a back link to the operator queue', function () {
|
||||
$operator = operatorUser('settings-nav@example.com');
|
||||
|
||||
Livewire::actingAs($operator)->test(NotificationPreferences::class)
|
||||
->assertSeeHtml(route('operator.queue'));
|
||||
});
|
||||
65
src/tests/Feature/TicketNumberObfuscationTest.php
Normal file
65
src/tests/Feature/TicketNumberObfuscationTest.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
use App\Models\ApiClient;
|
||||
use App\Models\User;
|
||||
use App\Support\Settings;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
test('a ticket is assigned a stable, unique checksum on creation', function () {
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
$ticket = makeTicket();
|
||||
|
||||
expect($ticket->checksum)->not->toBeNull()
|
||||
->and($ticket->checksum)->toMatch('/^\d{6}$/')
|
||||
->and($ticket->fresh()->checksum)->toBe($ticket->checksum);
|
||||
});
|
||||
|
||||
test('with obfuscation off, the ticket URL and the displayed number both use the raw sequential number', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Settings::set('ticket_number_obfuscate', '0');
|
||||
|
||||
$operator = User::query()->create(['name' => 'Op', 'email' => 'op@example.com', 'roles' => ['operator']]);
|
||||
$ticket = makeTicket(['number' => '1042']);
|
||||
|
||||
$url = route('operator.ticket', $ticket);
|
||||
|
||||
expect($url)->toContain('/1042')
|
||||
->and($ticket->displayNumber())->toBe('#1042');
|
||||
|
||||
$this->actingAs($operator)->get($url)->assertOk();
|
||||
});
|
||||
|
||||
test('with obfuscation on, the ticket URL and the displayed number both use the checksum, and the raw number no longer resolves', function () {
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
$operator = User::query()->create(['name' => 'Op', 'email' => 'op@example.com', 'roles' => ['operator']]);
|
||||
$ticket = makeTicket(['number' => '1042']);
|
||||
|
||||
Settings::set('ticket_number_obfuscate', '1');
|
||||
|
||||
$url = route('operator.ticket', $ticket);
|
||||
|
||||
expect($url)->toContain($ticket->checksum)
|
||||
->and($url)->not->toContain('/1042')
|
||||
->and($ticket->displayNumber())->toBe('#'.$ticket->checksum);
|
||||
|
||||
$this->actingAs($operator)->get($url)->assertOk();
|
||||
|
||||
// A "no ticket matches this identifier" route-binding failure now
|
||||
// redirects to the area's own queue instead of a bare 404 (see
|
||||
// bootstrap/app.php) — the raw sequential number still doesn't resolve
|
||||
// to the ticket, it just no longer surfaces as a dead-end error page.
|
||||
$this->actingAs($operator)->get('/operator/tickets/1042')->assertRedirect(route('operator.queue'));
|
||||
});
|
||||
|
||||
test('the API still binds tickets by numeric id regardless of the obfuscation setting', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Settings::set('ticket_number_obfuscate', '1');
|
||||
|
||||
$ticket = makeTicket();
|
||||
$client = ApiClient::factory()->create();
|
||||
Sanctum::actingAs($client, ['tickets:read']);
|
||||
|
||||
$this->getJson("/api/v1/tickets/{$ticket->id}")->assertOk()->assertJsonPath('data.id', $ticket->id);
|
||||
});
|
||||
61
src/tests/Feature/TicketServiceGuestReplyTest.php
Normal file
61
src/tests/Feature/TicketServiceGuestReplyTest.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AutomationRule;
|
||||
use App\Models\User;
|
||||
use App\Services\TicketService;
|
||||
|
||||
test('guestReply records a client-role message with no author id', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$ticket = makeTicket();
|
||||
|
||||
$message = app(TicketService::class)->guestReply($ticket, 'Anonimowy Gość', 'Odpowiedź gościa e-mailem.');
|
||||
|
||||
expect($message->author_name)->toBe('Anonimowy Gość')
|
||||
->and($message->body)->toBe('Odpowiedź gościa e-mailem.')
|
||||
->and($message->author_id)->toBeNull()
|
||||
->and($message->role)->toBe('client')
|
||||
->and($message->source)->toBeNull();
|
||||
});
|
||||
|
||||
test('guestReply tags the message source as email when told to', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$ticket = makeTicket();
|
||||
|
||||
$message = app(TicketService::class)->guestReply($ticket, 'Gość', 'Treść', source: 'email');
|
||||
|
||||
expect($message->source)->toBe('email');
|
||||
});
|
||||
|
||||
test('clientReply defaults to a null (web) source, and can be tagged as email', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$ticket = makeTicket();
|
||||
$client = User::query()->create(['name' => 'Klient', 'email' => 'klient@example.com', 'roles' => ['client']]);
|
||||
|
||||
app(TicketService::class)->clientReply($ticket, $client, 'Odpowiedź z portalu.');
|
||||
$webMessage = $ticket->messages()->latest('id')->first();
|
||||
|
||||
app(TicketService::class)->clientReply($ticket, $client, 'Odpowiedź e-mailem.', source: 'email');
|
||||
$emailMessage = $ticket->messages()->latest('id')->first();
|
||||
|
||||
expect($webMessage->source)->toBeNull()
|
||||
->and($emailMessage->source)->toBe('email');
|
||||
});
|
||||
|
||||
test('guestReply updates last_customer_activity_at and clears automation logs, like clientReply', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$ticket = makeTicket(['last_customer_activity_at' => now()->subDays(1)]);
|
||||
|
||||
$rule = AutomationRule::query()->create([
|
||||
'label' => 'Test rule',
|
||||
'condition_minutes' => 60,
|
||||
'action_type' => 'set_priority',
|
||||
'action_value' => 'high',
|
||||
'enabled' => true,
|
||||
]);
|
||||
$ticket->automationRuleLogs()->create(['automation_rule_id' => $rule->id, 'triggered_at' => now()]);
|
||||
|
||||
app(TicketService::class)->guestReply($ticket, 'Gość', 'Treść');
|
||||
|
||||
expect($ticket->fresh()->last_customer_activity_at->diffInSeconds(now()))->toBeLessThan(5)
|
||||
->and($ticket->automationRuleLogs()->count())->toBe(0);
|
||||
});
|
||||
45
src/tests/Feature/TicketWatchingTest.php
Normal file
45
src/tests/Feature/TicketWatchingTest.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Operator\TicketShow as OperatorTicketShow;
|
||||
use App\Services\TicketService;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('an operator can watch and unwatch a ticket', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser();
|
||||
$ticket = makeTicket();
|
||||
|
||||
expect($ticket->isWatchedBy($operator))->toBeFalse();
|
||||
|
||||
app(TicketService::class)->toggleWatch($ticket, $operator);
|
||||
expect($ticket->fresh()->isWatchedBy($operator))->toBeTrue()
|
||||
->and($operator->watchedTickets()->pluck('tickets.id'))->toContain($ticket->id);
|
||||
|
||||
app(TicketService::class)->toggleWatch($ticket, $operator);
|
||||
expect($ticket->fresh()->isWatchedBy($operator))->toBeFalse();
|
||||
});
|
||||
|
||||
test('the ticket-show watch button toggles watch state for the viewing operator', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser();
|
||||
$ticket = makeTicket();
|
||||
|
||||
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket])
|
||||
->assertSet('isWatching', false)
|
||||
->call('toggleWatch')
|
||||
->assertSet('isWatching', true);
|
||||
|
||||
expect($ticket->fresh()->isWatchedBy($operator))->toBeTrue();
|
||||
});
|
||||
|
||||
test('watching a ticket is per-operator, not shared', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$watcher = operatorUser('watcher@example.com');
|
||||
$other = operatorUser('other@example.com');
|
||||
$ticket = makeTicket();
|
||||
|
||||
app(TicketService::class)->toggleWatch($ticket, $watcher);
|
||||
|
||||
expect($ticket->fresh()->isWatchedBy($watcher))->toBeTrue()
|
||||
->and($ticket->fresh()->isWatchedBy($other))->toBeFalse();
|
||||
});
|
||||
172
src/tests/Feature/TriggerEngineTest.php
Normal file
172
src/tests/Feature/TriggerEngineTest.php
Normal file
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Priority;
|
||||
use App\Models\Team;
|
||||
use App\Models\Trigger;
|
||||
use App\Models\TriggerEmailTemplate;
|
||||
use App\Models\User;
|
||||
use App\Notifications\TicketNotification;
|
||||
use App\Services\TicketService;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
test('a trigger with no conditions fires on every matching event', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Trigger::query()->create([
|
||||
'name' => 'Always high on update', 'enabled' => true, 'event' => 'status_changed',
|
||||
'conditions' => [], 'actions' => [['type' => 'set_priority', 'value' => 'high']],
|
||||
]);
|
||||
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#ccc', 'sort_order' => 2]);
|
||||
$ticket = makeTicket(['priority_key' => 'low']);
|
||||
|
||||
app(TicketService::class)->setStatus($ticket, 'open');
|
||||
|
||||
expect($ticket->fresh()->priority_key)->toBe('high');
|
||||
});
|
||||
|
||||
test('a trigger only fires when every condition matches (AND)', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$team = Team::query()->create(['name' => 'VIP']);
|
||||
Trigger::query()->create([
|
||||
'name' => 'High priority to VIP team', 'enabled' => true, 'event' => 'priority_changed',
|
||||
'conditions' => [['field' => 'priority_key', 'operator' => 'equals', 'value' => 'high']],
|
||||
'actions' => [['type' => 'set_team', 'value' => $team->id]],
|
||||
]);
|
||||
|
||||
$lowTicket = makeTicket(['number' => '2001', 'priority_key' => 'high']);
|
||||
app(TicketService::class)->setPriority($lowTicket, 'high');
|
||||
expect($lowTicket->fresh()->team_id)->toBe($team->id);
|
||||
|
||||
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#ccc', 'sort_order' => 2]);
|
||||
$otherTicket = makeTicket(['number' => '2002', 'priority_key' => 'high']);
|
||||
app(TicketService::class)->setPriority($otherTicket, 'low');
|
||||
expect($otherTicket->fresh()->team_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('a disabled trigger never fires', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Trigger::query()->create([
|
||||
'name' => 'Disabled', 'enabled' => false, 'event' => 'priority_changed',
|
||||
'conditions' => [], 'actions' => [['type' => 'set_status', 'value' => 'closed']],
|
||||
]);
|
||||
$ticket = makeTicket();
|
||||
|
||||
app(TicketService::class)->setPriority($ticket, 'high');
|
||||
|
||||
expect($ticket->fresh()->status_key)->toBe('new');
|
||||
});
|
||||
|
||||
test('is_empty and is_not_empty operators work without a value', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Trigger::query()->create([
|
||||
'name' => 'No team yet -> VIP team', 'enabled' => true, 'event' => 'priority_changed',
|
||||
'conditions' => [['field' => 'team_id', 'operator' => 'is_empty', 'value' => null]],
|
||||
'actions' => [['type' => 'set_status', 'value' => 'open']],
|
||||
]);
|
||||
$ticket = makeTicket();
|
||||
|
||||
app(TicketService::class)->setPriority($ticket, 'high');
|
||||
|
||||
expect($ticket->fresh()->status_key)->toBe('open');
|
||||
});
|
||||
|
||||
test('the contains operator matches substrings case-insensitively', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Trigger::query()->create([
|
||||
'name' => 'VPN keyword -> closed', 'enabled' => true, 'event' => 'priority_changed',
|
||||
'conditions' => [['field' => 'subject', 'operator' => 'contains', 'value' => 'VPN']],
|
||||
'actions' => [['type' => 'set_status', 'value' => 'closed']],
|
||||
]);
|
||||
$ticket = makeTicket(['subject' => 'Problem z vpn na laptopie']);
|
||||
|
||||
app(TicketService::class)->setPriority($ticket, 'high');
|
||||
|
||||
expect($ticket->fresh()->status_key)->toBe('closed');
|
||||
});
|
||||
|
||||
test('an action that would only reassert the current value is a no-op and does not re-trigger anything', function () {
|
||||
seedStatusesAndPriorities();
|
||||
// If this looped, it would recurse until TriggerEngine's depth guard
|
||||
// kicked in; asserting the final state (rather than call counts) proves
|
||||
// the no-op short-circuit stopped it after a single, harmless pass.
|
||||
Trigger::query()->create([
|
||||
'name' => 'Keep status open', 'enabled' => true, 'event' => 'status_changed',
|
||||
'conditions' => [], 'actions' => [['type' => 'set_status', 'value' => 'open']],
|
||||
]);
|
||||
$ticket = makeTicket(['status_key' => 'new']);
|
||||
|
||||
app(TicketService::class)->setStatus($ticket, 'open');
|
||||
|
||||
expect($ticket->fresh()->status_key)->toBe('open');
|
||||
});
|
||||
|
||||
test('two triggers that keep flipping the same field between each other are bounded by the depth guard, not an infinite loop', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Trigger::query()->create([
|
||||
'name' => 'To open', 'enabled' => true, 'event' => 'status_changed',
|
||||
'conditions' => [['field' => 'status_key', 'operator' => 'not_equals', 'value' => 'open']],
|
||||
'actions' => [['type' => 'set_status', 'value' => 'open']],
|
||||
]);
|
||||
Trigger::query()->create([
|
||||
'name' => 'To new', 'enabled' => true, 'event' => 'status_changed',
|
||||
'conditions' => [['field' => 'status_key', 'operator' => 'not_equals', 'value' => 'new']],
|
||||
'actions' => [['type' => 'set_status', 'value' => 'new']],
|
||||
]);
|
||||
$ticket = makeTicket(['status_key' => 'new']);
|
||||
|
||||
// Would hang/exceed PHP's execution time without the depth guard —
|
||||
// simply completing is the assertion.
|
||||
app(TicketService::class)->setStatus($ticket, 'open');
|
||||
|
||||
expect($ticket->fresh()->status_key)->toBeIn(['new', 'open']);
|
||||
});
|
||||
|
||||
test('the send_notification action sends the chosen template to the chosen recipient, ignoring NotificationSetting entirely', function () {
|
||||
Notification::fake();
|
||||
seedStatusesAndPriorities();
|
||||
$template = TriggerEmailTemplate::query()->create([
|
||||
'name' => 'x', 'subject' => 'Priorytet zmieniony na {priorytet}', 'body' => 'B',
|
||||
]);
|
||||
Trigger::query()->create([
|
||||
'name' => 'Notify on high priority', 'enabled' => true, 'event' => 'priority_changed',
|
||||
'conditions' => [], 'actions' => [['type' => 'send_notification', 'recipient' => 'client', 'email_template_id' => $template->id]],
|
||||
]);
|
||||
$ticket = makeTicket();
|
||||
|
||||
app(TicketService::class)->setPriority($ticket, 'high');
|
||||
|
||||
Notification::assertSentOnDemandTimes(TicketNotification::class, 1);
|
||||
|
||||
// Trigger notifications draw from trigger_email_templates, not the
|
||||
// fixed email_templates table used by NotificationSetting.
|
||||
$mail = (new TicketNotification($ticket->fresh(), $template->id, templateSource: 'trigger_email_template'))
|
||||
->toMail((object) ['routes' => ['mail' => $ticket->email]]);
|
||||
expect($mail->subject)->toBe('Priorytet zmieniony na Wysoki');
|
||||
});
|
||||
|
||||
test('a trigger with an unrecognized action type is silently ignored, not fatal', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Trigger::query()->create([
|
||||
'name' => 'Bogus action', 'enabled' => true, 'event' => 'priority_changed',
|
||||
'conditions' => [], 'actions' => [['type' => 'not_a_real_action']],
|
||||
]);
|
||||
$ticket = makeTicket();
|
||||
|
||||
app(TicketService::class)->setPriority($ticket, 'high');
|
||||
|
||||
expect($ticket->fresh()->priority_key)->toBe('high');
|
||||
});
|
||||
|
||||
test('a client reply fires the comment_added event, even though clientReply() never fired a notification trigger before', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Trigger::query()->create([
|
||||
'name' => 'Reopen on client reply', 'enabled' => true, 'event' => 'comment_added',
|
||||
'conditions' => [['field' => 'status_key', 'operator' => 'equals', 'value' => 'closed']],
|
||||
'actions' => [['type' => 'set_status', 'value' => 'open']],
|
||||
]);
|
||||
$ticket = makeTicket(['status_key' => 'closed']);
|
||||
$client = User::query()->create(['name' => 'Klient', 'email' => 'reopener@example.com', 'roles' => ['client']]);
|
||||
|
||||
app(TicketService::class)->clientReply($ticket, $client, 'Nadal mam problem.');
|
||||
|
||||
expect($ticket->fresh()->status_key)->toBe('open');
|
||||
});
|
||||
146
src/tests/Feature/TriggersAdminTest.php
Normal file
146
src/tests/Feature/TriggersAdminTest.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\Panel;
|
||||
use App\Livewire\Admin\Triggers;
|
||||
use App\Models\Trigger;
|
||||
use App\Models\TriggerEmailTemplate;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('admin can create a trigger with a condition and an action', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Triggers::class)
|
||||
->call('openForm')
|
||||
->set('form.name', 'Priorytet wysoki -> status otwarty')
|
||||
->set('form.event', 'priority_changed')
|
||||
->call('addCondition')
|
||||
->set('form.conditions.0.field', 'priority_key')
|
||||
->set('form.conditions.0.operator', 'equals')
|
||||
->set('form.conditions.0.value', 'high')
|
||||
->call('addAction')
|
||||
->set('form.actions.0.type', 'set_status')
|
||||
->set('form.actions.0.value', 'open')
|
||||
->call('submit')
|
||||
->assertSet('formOpen', false);
|
||||
|
||||
$trigger = Trigger::query()->where('name', 'Priorytet wysoki -> status otwarty')->firstOrFail();
|
||||
expect($trigger->event)->toBe('priority_changed')
|
||||
->and($trigger->conditions)->toBe([['field' => 'priority_key', 'operator' => 'equals', 'value' => 'high']])
|
||||
->and($trigger->actions[0]['type'])->toBe('set_status')
|
||||
->and($trigger->actions[0]['value'])->toBe('open');
|
||||
});
|
||||
|
||||
test('a trigger requires a name and at least one action', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Triggers::class)
|
||||
->call('openForm')
|
||||
->set('form.name', '')
|
||||
->call('submit')
|
||||
->assertHasErrors(['form.name', 'form.actions']);
|
||||
});
|
||||
|
||||
test('admin can edit an existing trigger', function () {
|
||||
$admin = adminUser();
|
||||
$trigger = Trigger::query()->create([
|
||||
'name' => 'Original', 'enabled' => true, 'event' => 'ticket_created',
|
||||
'conditions' => [], 'actions' => [['type' => 'set_priority', 'value' => 'high']],
|
||||
]);
|
||||
|
||||
Livewire::actingAs($admin)->test(Triggers::class)
|
||||
->call('editTrigger', $trigger->id)
|
||||
->set('form.name', 'Renamed')
|
||||
->call('submit')
|
||||
->assertSet('formOpen', false);
|
||||
|
||||
expect($trigger->fresh()->name)->toBe('Renamed');
|
||||
});
|
||||
|
||||
test('admin can toggle a trigger on/off and delete it', function () {
|
||||
$admin = adminUser();
|
||||
$trigger = Trigger::query()->create([
|
||||
'name' => 'Toggle me', 'enabled' => true, 'event' => 'ticket_created',
|
||||
'conditions' => [], 'actions' => [['type' => 'set_priority', 'value' => 'high']],
|
||||
]);
|
||||
|
||||
Livewire::actingAs($admin)->test(Triggers::class)
|
||||
->call('toggleEnabled', $trigger->id);
|
||||
expect($trigger->fresh()->enabled)->toBeFalse();
|
||||
|
||||
Livewire::actingAs($admin)->test(Triggers::class)
|
||||
->call('removeTrigger', $trigger->id);
|
||||
expect(Trigger::query()->find($trigger->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('admin can reorder triggers with move up/down', function () {
|
||||
$admin = adminUser();
|
||||
$first = Trigger::query()->create(['name' => 'A', 'enabled' => true, 'event' => 'ticket_created', 'conditions' => [], 'actions' => [['type' => 'set_priority', 'value' => 'high']], 'sort_order' => 1]);
|
||||
$second = Trigger::query()->create(['name' => 'B', 'enabled' => true, 'event' => 'ticket_created', 'conditions' => [], 'actions' => [['type' => 'set_priority', 'value' => 'high']], 'sort_order' => 2]);
|
||||
|
||||
Livewire::actingAs($admin)->test(Triggers::class)
|
||||
->call('moveDown', $first->id);
|
||||
|
||||
expect($first->fresh()->sort_order)->toBe(2)
|
||||
->and($second->fresh()->sort_order)->toBe(1);
|
||||
});
|
||||
|
||||
test('adding and removing condition/action rows in the form works', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
$component = Livewire::actingAs($admin)->test(Triggers::class)
|
||||
->call('openForm')
|
||||
->call('addCondition')
|
||||
->call('addCondition')
|
||||
->assertCount('form.conditions', 2)
|
||||
->call('removeCondition', 0)
|
||||
->assertCount('form.conditions', 1)
|
||||
->call('addAction')
|
||||
->assertCount('form.actions', 1);
|
||||
|
||||
$component->call('removeAction', 0)->assertCount('form.actions', 0);
|
||||
});
|
||||
|
||||
test('the admin panel triggers tab renders the Triggers component', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'triggers')
|
||||
->assertSeeLivewire(Triggers::class);
|
||||
});
|
||||
|
||||
test('admin can create, edit and delete a trigger email template, independent of the fixed email templates', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Triggers::class)
|
||||
->call('openTemplateForm')
|
||||
->set('templateForm.name', 'Przypomnienie')
|
||||
->set('templateForm.subject', 'Temat')
|
||||
->set('templateForm.body', 'Treść')
|
||||
->call('submitTemplate')
|
||||
->assertSet('templateFormOpen', false);
|
||||
|
||||
$template = TriggerEmailTemplate::query()->where('name', 'Przypomnienie')->firstOrFail();
|
||||
expect($template->subject)->toBe('Temat');
|
||||
|
||||
Livewire::actingAs($admin)->test(Triggers::class)
|
||||
->call('editTemplate', $template->id)
|
||||
->set('templateForm.subject', 'Nowy temat')
|
||||
->call('submitTemplate');
|
||||
|
||||
expect($template->fresh()->subject)->toBe('Nowy temat');
|
||||
|
||||
Livewire::actingAs($admin)->test(Triggers::class)
|
||||
->call('removeTemplate', $template->id);
|
||||
|
||||
expect(TriggerEmailTemplate::query()->find($template->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('a trigger email template requires a name, subject and body', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Triggers::class)
|
||||
->call('openTemplateForm')
|
||||
->call('submitTemplate')
|
||||
->assertHasErrors(['templateForm.name', 'templateForm.subject', 'templateForm.body']);
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
# Przewodnik — Administrator
|
||||
|
||||
Panel administratora (`/admin`) to jedno miejsce do konfiguracji całego systemu:
|
||||
struktura zgłoszeń (kategorie, pola, statusy, priorytety, SLA), użytkownicy i
|
||||
zespoły, treści (szablony, szybkie akcje, e-maile), wygląd/branding oraz
|
||||
integracje (LDAP, SMTP, API).
|
||||
struktura zgłoszeń (kategorie, pola, statusy, priorytety, SLA), automatyzacje
|
||||
(reguły SLA, wyzwalacze), użytkownicy i zespoły, treści (szablony, szybkie
|
||||
akcje, e-maile), wygląd/branding oraz integracje (LDAP, poczta SMTP/IMAP,
|
||||
BookStack, API).
|
||||
|
||||
Domyślnie każde konto ląduje po zalogowaniu w panelu Klienta; przełącz się do
|
||||
panelu Administratora przez menu profilu (prawy górny róg).
|
||||
@@ -77,6 +78,28 @@ tego samego zgłoszenia, dopóki klient znów nie napisze albo zgłoszenie nie
|
||||
zostanie zamknięte i otwarte ponownie — więc bezpiecznie zostawić kilka
|
||||
aktywnych reguł naraz, bez ryzyka zapętlenia się co 15 minut.
|
||||
|
||||
## Wyzwalacze
|
||||
|
||||
W odróżnieniu od Automatyzacji SLA (działa po czasie ciszy klienta), wyzwalacze
|
||||
reagują **natychmiast** na zdarzenie w zgłoszeniu: utworzenie, dowolna zmiana
|
||||
pola, zmiana statusu/priorytetu/przypisania/zespołu/kategorii, nowa wiadomość
|
||||
publiczna. Każdy wyzwalacz ma:
|
||||
|
||||
- **Zdarzenie**, na które reaguje.
|
||||
- **Warunki** (opcjonalne, wszystkie muszą być spełnione naraz — ORAZ) na polu
|
||||
statusu, priorytetu, zespołu, podkategorii, zgłaszającego, tematu lub treści.
|
||||
- **Akcje** wykonywane po kolei — ustaw status/priorytet/zespół/operatora, albo
|
||||
wyślij powiadomienie e-mail do zgłaszającego lub przypisanego operatora.
|
||||
|
||||
Akcja „Wyślij powiadomienie e-mail” korzysta z **własnych szablonów wyzwalaczy**
|
||||
(sekcja „Szablony e-mail wyzwalaczy” na tej samej zakładce) — w pełni
|
||||
dodawalnych/edytowalnych/usuwalnych przez administratora, celowo osobnych od
|
||||
stałych szablonów opisanych niżej (te są przypisane 1:1 do zdarzeń systemowych
|
||||
i nie da się ich usunąć ani dodać nowego). Wyzwalacz może zmienić to samo pole,
|
||||
które sam sprawdza w warunku — zabezpieczenie przed zapętleniem: akcja, która
|
||||
tylko potwierdzałaby już ustawioną wartość, nic nie robi, a licznik głębokości
|
||||
zatrzymuje prawdziwy cykl między dwoma wyzwalaczami.
|
||||
|
||||
## Szybkie akcje odpowiedzi
|
||||
|
||||
Przyciski w widoku zgłoszenia operatora, które **wysyłają odpowiedź i od razu
|
||||
@@ -98,6 +121,9 @@ więcej informacji”, „Restart usuwa problem”.
|
||||
placeholderami: `{numer}`, `{imie}`, `{temat}`, `{status}`, `{kategoria}`,
|
||||
`{priorytet}`, `{zespol}`, `{operator}`, `{link}`. Każdy szablon opakowuje się
|
||||
automatycznie we wspólny layout (nagłówek z nazwą firmy + stopka — patrz niżej).
|
||||
Te szablony są przypisane **na stałe** do zdarzeń systemowych (nie da się ich
|
||||
dodać/usunąć/przepiąć na inne zdarzenie) — dla wyzwalaczy (zakładka
|
||||
Wyzwalacze) służy osobny, w pełni dowolny zestaw szablonów, opisany wyżej.
|
||||
- **Stopka e-mail** i **layout HTML** — stopka jest edytowalna (z przyciskiem
|
||||
„Resetuj” do wartości domyślnej); sam layout nie jest edytowalny z poziomu UI.
|
||||
- **Powiadomienia** — lista zdarzeń (zgłoszenie utworzone, zmiana statusu/
|
||||
@@ -113,7 +139,15 @@ więcej informacji”, „Restart usuwa problem”.
|
||||
tylko do jednej przypisanej osoby. **Ten sam przełącznik kontroluje zarówno
|
||||
e-mail, jak i powiadomienie w dzwoneczku w aplikacji** — nie ma osobnego
|
||||
ustawienia dla powiadomień w apce, a dzwoneczek pokazuje tylko nieprzeczytane
|
||||
(znikają po kliknięciu/oznaczeniu).
|
||||
(znikają po kliknięciu/oznaczeniu) i aktualizuje się na żywo.
|
||||
- **Preferencje powiadomień per operator/admin** (`/settings/notifications`,
|
||||
menu profilu → „Powiadomienia”) — każdy sam wybiera, dla nowego zgłoszenia/
|
||||
aktualizacji/eskalacji, jaki zakres zgłoszeń (moje / nieprzypisane /
|
||||
obserwowane / wszystkie) ma go powiadamiać dzwoneczkiem i czy dodatkowo
|
||||
e-mailem, plus opcjonalne natywne powiadomienia push przeglądarki. To
|
||||
ustawienie jest niezależne od globalnego przełącznika powiadomień opisanego
|
||||
wyżej — dotyczy dodatkowego powiadamiania innych operatorów/adminów o
|
||||
zgłoszeniach w ich zakresie, nie zastępuje go.
|
||||
|
||||
## Wygląd / Branding
|
||||
|
||||
@@ -126,14 +160,82 @@ ważne + treść HTML).
|
||||
- **Ogólne** — domyślny status nowego zgłoszenia, automatyczne przypisywanie wg
|
||||
kategorii, limity załączników (rozmiar/liczba/typy), czas życia sesji, strefa
|
||||
czasowa.
|
||||
- **Numeracja zgłoszeń** — dowolny **prefiks** numeru (domyślnie `#`) i
|
||||
**minimalna długość** (dopełniana zerami z przodu, dotyczy tylko trybu
|
||||
sekwencyjnego). Checkbox **„Ukryj kolejność zgłoszeń”** przełącza
|
||||
wyświetlany numer z kolejnego (np. `#1042`) na stałą, losowo wyglądającą
|
||||
**sumę kontrolną** (np. `#559122`) przypisaną zgłoszeniu raz, na zawsze —
|
||||
tak, by po samym numerze nie dało się odgadnąć, ile jest zgłoszeń ani w
|
||||
jakiej kolejności powstały. Podgląd pod polami pokazuje na żywo, jak
|
||||
będzie wyglądał numer dla realnego zgłoszenia z bazy, zanim się zapisze
|
||||
zmiany. Gdy ta opcja jest włączona, **linki do zgłoszeń też** posługują
|
||||
się sumą kontrolną zamiast kolejnego numeru — stary link ze zwykłym
|
||||
numerem przestaje działać. REST API (`/api/v1/...`) tego nie dotyczy —
|
||||
tam zgłoszenia zawsze identyfikuje się po `id`, niezależnie od tego
|
||||
ustawienia.
|
||||
|
||||
SMTP (host, port, szyfrowanie, użytkownik/hasło, adres/nazwa nadawcy, z
|
||||
przyciskiem **„Testuj połączenie”**) konfiguruje się w zakładce **Poczta**,
|
||||
razem z layoutem/stopką wiadomości i skrzynkami IMAP (patrz niżej).
|
||||
|
||||
## Poczta — odbieranie zgłoszeń i odpowiedzi e-mailem (IMAP)
|
||||
|
||||
Zakładka **Poczta** (dawniej „E-MAIL") łączy konfigurację SMTP (wysyłka) ze
|
||||
skrzynkami IMAP (odbiór) — obie strony wymiany e-mailowej z klientem żyją
|
||||
razem, zamiast być rozrzucone po różnych zakładkach.
|
||||
|
||||
- **Wiele skrzynek IMAP jednocześnie** — np. `zgloszenia-it@firma.pl` i
|
||||
`zgloszenia-hr@firma.pl` jako dwie osobne, niezależnie włączane skrzynki,
|
||||
każda z własnym hostem/portem/szyfrowaniem/loginem/hasłem i folderem.
|
||||
- **Cel nowych zgłoszeń** — jeden wspólny selektor pozwala wybrać albo
|
||||
**konkretną podkategorię** (trafi też do jej zespołu, tak jak zgłoszenie
|
||||
założone przez formularz web), albo **całą kategorię** bez wskazywania
|
||||
podkategorii (zgłoszenie zostaje nieprzypisane do zespołu, ale kategoria
|
||||
jest widoczna i można po niej filtrować kolejkę operatora), albo zostawić
|
||||
puste (zgłoszenie całkiem nieprzypisane).
|
||||
- **Dopasowywanie odpowiedzi** — odpowiedź na powiadomienie e-mail (temat
|
||||
zawiera numer/sumę kontrolną zgłoszenia) trafia jako kolejna wiadomość do
|
||||
tego samego wątku, nie jako nowe zgłoszenie — widoczna na żywo u operatora,
|
||||
tak jak każda inna odpowiedź.
|
||||
- **Filtry przed śmieciowymi zgłoszeniami** — automatyczne odpowiedzi
|
||||
(autorespondery, „poza biurem”, bounce/mailer-daemon) są rozpoznawane po
|
||||
nagłówkach (`Auto-Submitted`, `Precedence`) i typowych frazach w temacie
|
||||
(PL i EN) i **odrzucane bez tworzenia zgłoszenia**; dodatkowa lista
|
||||
zablokowanych nadawców per skrzynka (domyślnie `mailer-daemon, postmaster,
|
||||
no-reply, noreply`).
|
||||
- **„Tylko użytkownicy z LDAP”** (Integracje → LDAP) działa identycznie dla
|
||||
poczty jak dla formularza gościa na stronie głównej — jeśli włączone, e-mail
|
||||
od nieznanego nadawcy (spoza LDAP i bez lokalnego konta) jest odrzucany, nie
|
||||
tworzy zgłoszenia.
|
||||
- **Folder po przetworzeniu / folder odrzuconych** (opcjonalnie) — jeśli
|
||||
puste, wiadomość zostaje na miejscu tylko oznaczona jako przeczytana.
|
||||
- **Przycisk „Pobierz teraz”** przy każdej skrzynce — ręczne, natychmiastowe
|
||||
sprawdzenie poczty bez czekania na harmonogram (co 5 minut), działa też dla
|
||||
wyłączonej skrzynki; pokazuje od razu liczbę nowych/odpowiedzi/odrzuconych/
|
||||
błędów.
|
||||
- **Przycisk „Testuj połączenie”** sprawdza niezapisane wartości formularza,
|
||||
bez zapisywania.
|
||||
- **Log** — cała aktywność (połączenia, każda decyzja per wiadomość, błędy)
|
||||
trafia do osobnego pliku `storage/logs/imap-*.log`, niezależnie od
|
||||
ogólnego poziomu logowania aplikacji — najlepsze miejsce do sprawdzenia,
|
||||
dlaczego dany e-mail się nie przetworzył.
|
||||
- **Znacznik „e-mail"** — zgłoszenie i pojedyncze wiadomości utworzone z
|
||||
poczty mają widoczną ikonę koperty w kolejce operatora i w widoku
|
||||
zgłoszenia, odróżniając je od zgłoszeń/odpowiedzi z formularza web.
|
||||
|
||||
> Sprawdzanie skrzynek działa cyklicznie tylko wtedy, gdy na serwerze jest
|
||||
> skonfigurowany zewnętrzny cron wywołujący `php artisan schedule:run` (patrz
|
||||
> [install.md](../../install.md)) — bez tego działa wyłącznie przycisk
|
||||
> „Pobierz teraz”.
|
||||
|
||||
## Integracje
|
||||
|
||||
- **LDAP** — host, port, base DN, bind DN + hasło, SSL, filtr użytkownika
|
||||
(`(uid={0})` domyślnie), auto-provisioning gości, ograniczenie tworzenia
|
||||
kont/zgłaszania tylko przez LDAP. Przycisk **„Testuj połączenie”** sprawdza
|
||||
bind bez zapisywania zmian.
|
||||
- **SMTP** — host, port, szyfrowanie, użytkownik/hasło, adres/nazwa nadawcy.
|
||||
Przycisk **„Testuj połączenie”** analogicznie do LDAP.
|
||||
|
||||
> Po świeżej instalacji (`migrate:fresh --seed`) te dwie sekcje zawierają
|
||||
> Po świeżej instalacji (`migrate:fresh --seed`) LDAP i SMTP zawierają
|
||||
> **przykładowe wartości** (`ldap.example.com`, `smtp.example.com`,
|
||||
> `changeme-*-password`) — koniecznie podmień je na rzeczywiste dane przed
|
||||
> oddaniem systemu do użytku.
|
||||
|
||||
@@ -6,8 +6,17 @@ odpowiadanie, zmiana statusu/priorytetu/przypisania oraz statystyki zespołu.
|
||||
Domyślnie każde konto ląduje po zalogowaniu w panelu Klienta; przełącz się do
|
||||
panelu Operatora przez menu profilu (prawy górny róg), jeśli konto ma tę rolę.
|
||||
Dzwoneczek powiadomień w górnym pasku (widoczny we wszystkich panelach) pokazuje
|
||||
Twoje **nieprzeczytane** powiadomienia — kliknięcie (albo „Oznacz wszystkie jako
|
||||
przeczytane”) usuwa je z listy.
|
||||
Twoje **nieprzeczytane** powiadomienia, aktualizowane **na żywo** w chwili ich
|
||||
utworzenia (niezależny od tego 30-sekundowy fallback dogrywa to, co ominęłoby
|
||||
zerwane połączenie) — kliknięcie (albo „Oznacz wszystkie jako przeczytane”)
|
||||
usuwa je z listy.
|
||||
|
||||
**„Powiadomienia”** w menu profilu (`/settings/notifications`) pozwala wybrać,
|
||||
dla każdej kategorii zdarzeń (nowe zgłoszenie, aktualizacja zgłoszenia,
|
||||
eskalacja), jaki zakres zgłoszeń ma Cię powiadamiać dzwoneczkiem — moje /
|
||||
nieprzypisane / **obserwowane** / wszystkie — oraz czy dodatkowo wysłać e-mail.
|
||||
Tam też włączysz natywne powiadomienia push przeglądarki (działają, dopóki
|
||||
karta jest otwarta).
|
||||
|
||||
## Kolejka zgłoszeń — aktualizacje na żywo
|
||||
|
||||
@@ -26,7 +35,11 @@ przypisanego zespołu, oraz wszystko przypisane bezpośrednio do nich.
|
||||
|
||||
**Filtry** nad tabelą: status, priorytet, kategoria, wyszukiwanie po numerze/
|
||||
temacie/kliencie/treści zgłoszenia i odpowiedzi w wątku. **Kolumny** można dowolnie
|
||||
włączać/wyłączać przyciskiem „Kolumny”, a nagłówki kolumn sortują listę.
|
||||
włączać/wyłączać przyciskiem „Kolumny” (numer, temat, klient, kategoria,
|
||||
podkategoria, priorytet, status, SLA, przypisany, zespół, utworzono — kilka z
|
||||
nich domyślnie ukryte), a nagłówki kolumn sortują listę. Wybrana zakładka i
|
||||
kolumny zostają zapamiętane w adresie strony, więc odświeżenie nie cofa Cię do
|
||||
pierwszej zakładki.
|
||||
|
||||
**Zapisane widoki** — przycisk „Zapisane widoki” pozwala zapisać bieżącą
|
||||
kombinację zakładki/filtrów/sortowania/kolumn pod własną nazwą, oznaczyć jeden z
|
||||
@@ -36,7 +49,13 @@ swoje.
|
||||
|
||||
**Akcje zbiorcze**: zaznacz kilka zgłoszeń checkboxami, by je **scalić** (pierwsze
|
||||
zaznaczone staje się główne, reszta trafia do niego jako wiadomości i zostaje
|
||||
zamknięta) albo **usunąć**.
|
||||
zamknięta) albo **usunąć**. Checkbox w nagłówku tabeli zaznacza/odznacza od
|
||||
razu wszystkie zgłoszenia aktualnie widoczne pod bieżącym filtrem/zakładką
|
||||
(nie wszystkie w systemie).
|
||||
|
||||
Zgłoszenie założone albo odpowiedziane przez e-mail (patrz konfiguracja w
|
||||
Admin > Poczta) ma widoczną ikonę koperty obok numeru w kolejce oraz przy
|
||||
konkretnej wiadomości w wątku zgłoszenia.
|
||||
|
||||
Kolejka aktualizuje się **na żywo** — nowe zgłoszenie, zmiana statusu/priorytetu/
|
||||
przypisania czy nowa odpowiedź pojawiają się bez odświeżania strony. Obok
|
||||
@@ -54,6 +73,10 @@ przy przycisku „Wróć do listy” to taki sam fallbackowy zegar jak w kolejce
|
||||
|
||||
W widoku pojedynczego zgłoszenia:
|
||||
|
||||
- **Obserwuj** — przycisk obok licznika auto-odświeżania oznacza zgłoszenie
|
||||
jako obserwowane niezależnie od przypisania czy zespołu; zasila zakres
|
||||
„Obserwowane zgłoszenia” w Twoich preferencjach powiadomień
|
||||
(`/settings/notifications`).
|
||||
- **Zmiana statusu / priorytetu / zespołu / przypisanego operatora** — z listy
|
||||
rozwijanej; „Przypisz do mnie” to skrót jednym kliknięciem.
|
||||
- **Odpowiedź publiczna** — widoczna dla klienta; można wybrać **szablon
|
||||
|
||||
Reference in New Issue
Block a user