2 Commits

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

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

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

View File

@@ -63,6 +63,44 @@ queue + unassigned + anything assigned to them, an admin sees everything), and
work-timer tracking (`timerElapsedSeconds()`). Keep ticket-shaped logic here work-timer tracking (`timerElapsedSeconds()`). Keep ticket-shaped logic here
rather than spreading it across Livewire components. rather than spreading it across Livewire components.
## Ticket numbering & URLs
A ticket carries three distinct identifiers, each with a different job:
- **`id`** — the DB primary key. Never shown to users; the REST API
(`routes/api.php`) is deliberately pinned to it (`{ticket:id}` explicit
binding on every `{ticket}` route) so external integrations have a stable
contract regardless of the numbering settings below.
- **`number`** — a plain sequential string (`Ticket::nextNumber()`, max+1
starting at 1001), unique but otherwise unremarkable. Backs `scopeSearch()`
and the numeric sort in `Operator/Queue.php` regardless of display mode.
- **`checksum`** — a 6-digit HMAC-derived value (salted with `app.key`,
keyed off `id`), assigned once in a `Ticket::booted()` `created` listener
and never changed afterward. Collisions are handled for real, not just
assumed away: `Ticket::generateUniqueChecksum()` walks a nonce forward
until the candidate is free (checked against the DB), and the column has a
`unique()` constraint as a hard backstop.
`Ticket::displayNumber()`/`formattedNumber()` pick between `number` (zero-padded
to `Settings::get('ticket_number_min_length')`) and `checksum` based on
`Settings::bool('ticket_number_obfuscate')` — the "Ukryj kolejność zgłoszeń"
toggle in Admin > Konfiguracja. `Ticket` also overrides `getRouteKey()` and
`resolveRouteBinding()` to mirror that same choice, so **the web routes**
(`routes/web.php`, all plain `{ticket}` implicit bindings — no explicit field)
resolve and generate URLs against whichever column is currently the display
number: flip the setting and both the visible number *and* every link
(`route('client.ticket', $ticket)` etc.) switch together, and a bookmarked URL
built under the old mode stops resolving. This is why the API routes need the
explicit `{ticket:id}` override — without it, the same global `getRouteKey()`
change would silently start requiring `number`/`checksum` in API path params
too, breaking the documented `integer` "Ticket id" contract.
The `{numer}` placeholder available in admin-editable e-mail templates
(Admin > Szablony e-mail / Wyzwalacze) resolves to `formattedNumber()`
*without* `displayNumber()`'s prefix — those templates already hardcode their
own `#{numer}`, so adding the prefix there too would double it up or clash
with a non-default prefix.
## Roles & permissions ## Roles & permissions
`$user->roles` reads/writes as a plain array (`['client', 'operator']`), but `$user->roles` reads/writes as a plain array (`['client', 'operator']`), but
@@ -99,7 +137,8 @@ attributes.
`App\Support\Settings` (`app/Support/Settings.php`) is a cached key/value reader `App\Support\Settings` (`app/Support/Settings.php`) is a cached key/value reader
over the `settings` table, with hardcoded defaults for every key (company name, over the `settings` table, with hardcoded defaults for every key (company name,
LDAP/SMTP connection details, attachment limits, session lifetime, timezone, LDAP/SMTP connection details, attachment limits, session lifetime, timezone,
branding/email HTML, etc.). Admin > Konfiguracja writes to this table, and branding/email HTML, etc.). Admin > Konfiguracja (general/attachments/session),
E-MAIL (SMTP) and Integracje (LDAP, BookStack) all write to this same table, and
`AppServiceProvider::boot()` re-applies the relevant subset of it over `AppServiceProvider::boot()` re-applies the relevant subset of it over
`config()` on every request — meaning **`Setting` rows win over `.env`** for `config()` on every request — meaning **`Setting` rows win over `.env`** for
LDAP, mail, session lifetime and timezone once they're non-empty. This is by LDAP, mail, session lifetime and timezone once they're non-empty. This is by
@@ -192,6 +231,20 @@ themselves every 3060 seconds via a small Alpine countdown calling
`$wire.refreshQueue()` / `$wire.refreshTicketData()` — broadcasting is `$wire.refreshQueue()` / `$wire.refreshTicketData()` — broadcasting is
best-effort, not the only way these views ever update. 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 ## SLA
`SlaRule` holds per-priority response/resolution targets in minutes. The `SlaRule` holds per-priority response/resolution targets in minutes. The

View File

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

View File

@@ -13,7 +13,7 @@ The app has three areas, gated by role (a user can hold more than one at once):
|---|---|---|---| |---|---|---|---|
| Client | `/client` | `client` | Submit tickets, track status, reply, see resolution | | Client | `/client` | `client` | Submit tickets, track status, reply, see resolution |
| Operator | `/operator` | `operator` | Work the ticket queue, reply/resolve, see team statistics | | Operator | `/operator` | `operator` | Work the ticket queue, reply/resolve, see team statistics |
| Admin | `/admin` | `admin` | Configure categories, users, teams, SLA, templates, branding, LDAP/SMTP | | Admin | `/admin` | `admin` | Configure categories, users, teams, SLA, templates, triggers, branding, LDAP/SMTP/BookStack |
Every account gets the `client` role by default (see `AssignDefaultRole` for LDAP-provisioned Every account gets the `client` role by default (see `AssignDefaultRole` for LDAP-provisioned
accounts), and always lands on `/client` first after login regardless of what other accounts), and always lands on `/client` first after login regardless of what other
@@ -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; subcategories, rest folded into "Inne"); CSAT average by team and by operator;
and a daily created-vs-closed trend. and a daily created-vs-closed trend.
- **Branding & config** — company name/logo/favicon/accent color, login notice, - **Branding & config** — company name/logo/favicon/accent color, login notice,
e-mail layout/footer, LDAP connection + user sync, SMTP connection, attachment e-mail layout/footer, SMTP connection (Admin > E-MAIL), attachment limits,
limits, session lifetime, timezone — all editable from Admin > Konfiguracja. session lifetime, timezone (Admin > Konfiguracja), and LDAP connection + user
sync + BookStack (Admin > Integracje).
- **LDAP auth** — logins bind against an LDAP/LLDAP directory (`config/auth.php`, - **LDAP auth** — logins bind against an LDAP/LLDAP directory (`config/auth.php`,
`config/ldap.php`); local accounts (e.g. the emergency `admin` account) fall back `config/ldap.php`); local accounts (e.g. the emergency `admin` account) fall back
to e-mail + local password when the LDAP bind doesn't match. to e-mail + local password when the LDAP bind doesn't match.
- **Triggers** (Admin > Wyzwalacze) — event-driven business rules that fire
immediately on a ticket lifecycle event (created, any field updated, status/
priority/assignee/team/category changed, new public reply): AND-combined
conditions gate a sequence of actions (set status/priority/team/assignee, or
send an e-mail using a dedicated set of freely add/edit/delete-able trigger
e-mail templates, kept separate from the fixed per-event system templates).
Complements the time-based SLA automation rules above rather than replacing
them.
- **Ticket watching** — operators can star/"Obserwuj" any ticket to follow it
regardless of assignment/team, which feeds the "Obserwowane zgłoszenia" scope
in their notification preferences.
- **Per-user notification preferences** (`/settings/notifications`) — each
operator/admin chooses, per event category (new ticket, ticket update,
escalation), which scope of tickets (mine, unassigned, watched, all) notifies
them via the in-app bell, and whether that also sends an e-mail; plus an
opt-in toggle for native in-tab browser push notifications.
- **REST API** (`/api/v1/...`, Sanctum token auth, ability-scoped: `tickets:read`, - **REST API** (`/api/v1/...`, Sanctum token auth, ability-scoped: `tickets:read`,
`tickets:write`, `dictionaries:read`, `users:read`) for tickets/messages/users/ `tickets:write`, `dictionaries:read`, `users:read`) for tickets/messages/users/
categories/statuses/priorities/teams — issued via admin-managed API clients. categories/statuses/priorities/teams — issued via admin-managed API clients.
@@ -73,11 +90,21 @@ 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) - **In-app notifications** — a bell in the top bar (client/operator/admin areas)
backed by Laravel's database notification channel, alongside the existing backed by Laravel's database notification channel, alongside the existing
e-mail notifications (same per-trigger enable toggle drives both); shows e-mail notifications (same per-trigger enable toggle drives both); shows
unread notifications only — reading one removes it from the list. Includes a unread notifications only — reading one removes it from the list. Updates
dedicated trigger notifying every operator on a team whose subcategories live over WebSockets the moment a notification is created (with a 30s
match a newly created ticket. fallback poll), and can optionally raise a native browser push notification
- **Attachments** — drag-and-drop upload (in addition to the file picker) and while the tab is open (see per-user notification preferences above).
inline image thumbnails in the message thread instead of a plain download link. 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).
- **Configurable ticket numbering** (Admin > Konfiguracja) — a custom prefix and
minimum zero-padded length for the ticket number, plus an optional "hide
ticket order" mode that displays a stable per-ticket checksum instead of the
sequential number. When enabled, ticket URLs switch to the same checksum too,
so the number in the link always matches the one on the page; the REST API is
unaffected and always addresses tickets by `id`.
- **Customer satisfaction (CSAT)** — clients rate a ticket 15 stars (+ optional - **Customer satisfaction (CSAT)** — clients rate a ticket 15 stars (+ optional
comment) once it's closed; average/response-rate surfaced as a KPI on the comment) once it's closed; average/response-rate surfaced as a KPI on the
operator stats dashboard, with a link in the "ticket closed" e-mail. operator stats dashboard, with a link in the "ticket closed" e-mail.
@@ -93,7 +120,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 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 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 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 bypass for self-signed instances, page/book search-type filter, and two
independent per-shelf allow-lists (nothing is searched until an admin opts independent per-shelf allow-lists (nothing is searched until an admin opts
specific shelves in, separately for ticket-creation suggestions vs. the specific shelves in, separately for ticket-creation suggestions vs. the

View File

@@ -74,7 +74,7 @@ APP_LOCALE=pl
APP_FALLBACK_LOCALE=pl APP_FALLBACK_LOCALE=pl
AUTHOR_CONTACT=helpdesk@twoja-domena.pl # widoczne w Admin > O aplikacji AUTHOR_CONTACT=helpdesk@twoja-domena.pl # widoczne w Admin > O aplikacji
VERSION=1.1.2 # widoczne w Admin > O aplikacji VERSION=1.1.3 # widoczne w Admin > O aplikacji
DB_CONNECTION=mysql DB_CONNECTION=mysql
DB_HOST=mariadb # nazwa serwisu z compose.yaml, NIE 127.0.0.1 DB_HOST=mariadb # nazwa serwisu z compose.yaml, NIE 127.0.0.1
@@ -349,7 +349,7 @@ APP_LOCALE=pl
APP_FALLBACK_LOCALE=pl APP_FALLBACK_LOCALE=pl
AUTHOR_CONTACT=helpdesk@twoja-domena.pl AUTHOR_CONTACT=helpdesk@twoja-domena.pl
VERSION=1.1.2 VERSION=1.1.3
DB_CONNECTION=mysql DB_CONNECTION=mysql
DB_HOST=127.0.0.1 # albo adres IP/hostname prawdziwego serwera DB DB_HOST=127.0.0.1 # albo adres IP/hostname prawdziwego serwera DB

View File

@@ -1,11 +1,11 @@
APP_NAME=Laravel APP_NAME=Laravel
APP_ENV=local APP_ENV=local
APP_KEY= APP_KEY=
APP_DEBUG=true APP_DEBUG=false
APP_URL=http://localhost APP_URL=http://localhost
AUTHOR_CONTACT=helpdesk@kzbikowski.pl AUTHOR_CONTACT=helpdesk@kzbikowski.pl
VERSION=1.1.2 VERSION=1.1.4
APP_LOCALE=en APP_LOCALE=en
APP_FALLBACK_LOCALE=en APP_FALLBACK_LOCALE=en

View File

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

View File

@@ -14,6 +14,7 @@ use App\Models\SlaRule;
use App\Models\Status; use App\Models\Status;
use App\Models\Subcategory; use App\Models\Subcategory;
use App\Models\Team; use App\Models\Team;
use App\Models\Ticket;
use App\Models\User; use App\Models\User;
use App\Models\UserField; use App\Models\UserField;
use App\Services\BookStackClient; use App\Services\BookStackClient;
@@ -26,6 +27,7 @@ use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use LdapRecord\Connection; use LdapRecord\Connection;
use Livewire\Attributes\Computed; use Livewire\Attributes\Computed;
use Livewire\Attributes\Url;
use Livewire\Component; use Livewire\Component;
use Livewire\WithFileUploads; use Livewire\WithFileUploads;
@@ -33,6 +35,7 @@ class Panel extends Component
{ {
use WithFileUploads; use WithFileUploads;
#[Url]
public string $tab = 'categories'; public string $tab = 'categories';
// ---- categories ---- // ---- categories ----
@@ -179,6 +182,9 @@ class Panel extends Component
'attachmentAllowedTypes' => Settings::get('attachment_allowed_types'), 'attachmentAllowedTypes' => Settings::get('attachment_allowed_types'),
'sessionLifetimeMinutes' => Settings::get('session_lifetime_minutes'), 'sessionLifetimeMinutes' => Settings::get('session_lifetime_minutes'),
'timezone' => Settings::timezone(), 'timezone' => Settings::timezone(),
'ticketNumberPrefix' => Settings::get('ticket_number_prefix'),
'ticketNumberObfuscate' => Settings::bool('ticket_number_obfuscate'),
'ticketNumberMinLength' => Settings::get('ticket_number_min_length'),
]; ];
$this->ldapConfig = [ $this->ldapConfig = [
@@ -1355,6 +1361,33 @@ class Panel extends Component
if (in_array($this->systemConfig['timezone'], \DateTimeZone::listIdentifiers(), true)) { if (in_array($this->systemConfig['timezone'], \DateTimeZone::listIdentifiers(), true)) {
Settings::set('timezone', $this->systemConfig['timezone']); Settings::set('timezone', $this->systemConfig['timezone']);
} }
Settings::set('ticket_number_prefix', trim((string) $this->systemConfig['ticketNumberPrefix']));
Settings::set('ticket_number_obfuscate', $this->systemConfig['ticketNumberObfuscate'] ? '1' : '0');
Settings::set('ticket_number_min_length', (string) max(1, (int) $this->systemConfig['ticketNumberMinLength']));
}
/**
* Live preview for the "Numeracja zgłoszeń" settings renders a real
* ticket's id/number against the form's current (not-yet-saved) values,
* so the admin sees exactly how numbers will look before hitting Zapisz.
*/
#[Computed]
public function ticketNumberPreview(): array
{
$ticket = Ticket::query()->latest('id')->first();
$id = $ticket->id ?? 1;
$raw = $ticket->number ?? '1001';
$checksum = $ticket->checksum ?? Ticket::generateUniqueChecksum($id);
$obfuscate = (bool) ($this->systemConfig['ticketNumberObfuscate'] ?? false);
$minLength = max(1, (int) ($this->systemConfig['ticketNumberMinLength'] ?? 4));
$number = $obfuscate ? $checksum : str_pad($raw, $minLength, '0', STR_PAD_LEFT);
return [
'id' => $id,
'formatted' => trim((string) ($this->systemConfig['ticketNumberPrefix'] ?? '')).$number,
];
} }
// ===================== LDAP CONFIG ===================== // ===================== LDAP CONFIG =====================

View File

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

View File

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

View File

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

View File

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

View File

@@ -91,6 +91,18 @@ class TicketShow extends Component
$this->ticket->resumeTimer(); $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 -------- // -------- time tracking --------
public function stopTimer(): void public function stopTimer(): void

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -55,6 +55,34 @@ if (window.currentUserId) {
.error((error) => console.error('operator.queue subscription error', error)); .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 * Subscribes to a single ticket's channel — called by the Blade view of
* whichever TicketShow component (operator or client) is currently mounted, * whichever TicketShow component (operator or client) is currently mounted,

View File

@@ -2,24 +2,12 @@
@php @php
$url = \Illuminate\Support\Facades\Storage::disk('public')->url($attachment->path); $url = \Illuminate\Support\Facades\Storage::disk('public')->url($attachment->path);
$isImage = \Illuminate\Support\Str::startsWith($attachment->mime ?? '', 'image/');
@endphp @endphp
@if ($isImage) <a
<a href="{{ $url }}" target="_blank" style="display:block;margin-top:8px">
<img
src="{{ $url }}"
alt="{{ $attachment->original_name }}"
loading="lazy"
style="max-width:220px;max-height:160px;border-radius:8px;border:1px solid var(--color-divider);object-fit:cover;cursor:zoom-in;display:block"
>
</a>
@else
<a
href="{{ $url }}" href="{{ $url }}"
target="_blank" target="_blank"
style="display:inline-flex;align-items:center;gap:6px;margin-top:8px;padding:5px 10px;border:1px solid var(--color-divider);border-radius:6px;font-size:12.5px;color:inherit;text-decoration:none;background:color-mix(in srgb, var(--color-text) 5%, transparent)" style="display:inline-flex;align-items:center;gap:6px;margin-top:8px;padding:5px 10px;border:1px solid var(--color-divider);border-radius:6px;font-size:12.5px;color:inherit;text-decoration:none;background:color-mix(in srgb, var(--color-text) 5%, transparent)"
> >
<span class="material-symbols-outlined" style="font-size:15px">attach_file</span>{{ $attachment->original_name }} <span class="material-symbols-outlined" style="font-size:15px">attach_file</span>{{ $attachment->original_name }}
</a> </a>
@endif

View File

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

View File

@@ -8,6 +8,7 @@ $tabGroups = [
['key' => 'reply-quick-actions', 'label' => 'Szybkie akcje odpowiedzi', 'icon' => 'bolt'], ['key' => 'reply-quick-actions', 'label' => 'Szybkie akcje odpowiedzi', 'icon' => 'bolt'],
['key' => 'response-templates', 'label' => 'Szablony odpowiedzi', 'icon' => 'chat'], ['key' => 'response-templates', 'label' => 'Szablony odpowiedzi', 'icon' => 'chat'],
['key' => 'automation-rules', 'label' => 'Automatyzacja SLA', 'icon' => 'bolt'], ['key' => 'automation-rules', 'label' => 'Automatyzacja SLA', 'icon' => 'bolt'],
['key' => 'triggers', 'label' => 'Wyzwalacze', 'icon' => 'rule'],
], ],
'Zespół' => [ 'Zespół' => [
['key' => 'users', 'label' => 'Użytkownicy', 'icon' => 'group'], ['key' => 'users', 'label' => 'Użytkownicy', 'icon' => 'group'],
@@ -16,8 +17,10 @@ $tabGroups = [
], ],
'Ustawienia' => [ 'Ustawienia' => [
['key' => 'templates', 'label' => 'Szablony e-mail', 'icon' => 'mail'], ['key' => 'templates', 'label' => 'Szablony e-mail', 'icon' => 'mail'],
['key' => 'email', 'label' => 'E-MAIL', 'icon' => 'forward_to_inbox'],
['key' => 'branding', 'label' => 'Wygląd i branding', 'icon' => 'palette'], ['key' => 'branding', 'label' => 'Wygląd i branding', 'icon' => 'palette'],
['key' => 'config', 'label' => 'Konfiguracja', 'icon' => 'settings'], ['key' => 'config', 'label' => 'Konfiguracja', 'icon' => 'settings'],
['key' => 'integrations', 'label' => 'Integracje', 'icon' => 'hub'],
['key' => 'api-keys', 'label' => 'Klucze API', 'icon' => 'vpn_key'], ['key' => 'api-keys', 'label' => 'Klucze API', 'icon' => 'vpn_key'],
['key' => 'about', 'label' => 'O aplikacji', 'icon' => 'info'], ['key' => 'about', 'label' => 'O aplikacji', 'icon' => 'info'],
], ],
@@ -356,6 +359,11 @@ $tabGroups = [
@endif @endif
@endif @endif
{{-- ================= TRIGGERS ================= --}}
@if ($tab === 'triggers')
<livewire:admin.triggers />
@endif
{{-- ================= STATUSES ================= --}} {{-- ================= STATUSES ================= --}}
@if ($tab === 'statuses') @if ($tab === 'statuses')
<h3 style="margin:0 0 6px">Statusy</h3> <h3 style="margin:0 0 6px">Statusy</h3>
@@ -435,6 +443,27 @@ $tabGroups = [
{{-- ================= EMAIL TEMPLATES ================= --}} {{-- ================= EMAIL TEMPLATES ================= --}}
@if ($tab === 'templates') @if ($tab === 'templates')
<h3 style="margin:0 0 6px">Powiadomienia e-mail</h3>
<p class="text-muted" style="font-size:12.5px;margin:0 0 14px">Każde zdarzenie ma stały, przypisany na stałe szablon możesz go dowolnie edytować, ale nie zmienić na inny. Wyłącz przełącznik, żeby dana wiadomość nigdy nie była wysyłana.</p>
<div class="table-wrap">
<table class="table">
<thead><tr><th>Zdarzenie</th><th>Odbiorca</th><th>Wysyłane</th><th></th></tr></thead>
<tbody>
@foreach ($this->notificationSettings as $ns)
<tr>
<td style="white-space:nowrap">{{ $ns->trigger_label }}</td>
<td><span class="tag tag-outline">{{ $ns->recipient === 'operator' ? 'Operator' : 'Zgłaszający' }}</span></td>
<td><input type="checkbox" @checked($ns->enabled) wire:click="toggleNotificationEnabled({{ $ns->id }})"></td>
<td><button type="button" class="btn btn-ghost" wire:click="editEmailTemplate({{ $ns->email_template_id }})">Edytuj szablon</button></td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
{{-- ================= E-MAIL ================= --}}
@if ($tab === 'email')
<h3 style="margin:0 0 6px">Wygląd wiadomości e-mail</h3> <h3 style="margin:0 0 6px">Wygląd wiadomości e-mail</h3>
<p class="text-muted" style="font-size:12.5px;margin:0 0 14px">Każde powiadomienie wysyłane jest w stałym „pudełku” (nazwa firmy, ramka, treść zdarzenia) tu edytujesz tylko jego stopkę. Po prawej podgląd na żywo na przykładowym zgłoszeniu.</p> <p class="text-muted" style="font-size:12.5px;margin:0 0 14px">Każde powiadomienie wysyłane jest w stałym „pudełku” (nazwa firmy, ramka, treść zdarzenia) tu edytujesz tylko jego stopkę. Po prawej podgląd na żywo na przykładowym zgłoszeniu.</p>
@@ -454,23 +483,45 @@ $tabGroups = [
</div> </div>
</div> </div>
<h3 style="margin:0 0 6px">Powiadomienia e-mail</h3> <h3 style="margin:0 0 14px">E-mail (SMTP)</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> <form wire:submit="saveMailConfig" class="card" style="padding:20px;gap:14px;max-width:480px">
<div class="table-wrap"> <div class="field"><label>Adres nadawcy</label><input class="input" type="email" placeholder="wsparcie@firma.pl" wire:model="mailConfig.fromAddress"></div>
<table class="table"> <div class="field"><label>Nazwa nadawcy</label><input class="input" placeholder="Zespół Wsparcia" wire:model="mailConfig.fromName"></div>
<thead><tr><th>Zdarzenie</th><th>Odbiorca</th><th>Wysyłane</th><th></th></tr></thead>
<tbody> <div class="hr"></div>
@foreach ($this->notificationSettings as $ns)
<tr> <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>
<td style="white-space:nowrap">{{ $ns->trigger_label }}</td> <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>
<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> @if ($mailConfig['smtpEnabled'])
<td><button type="button" class="btn btn-ghost" wire:click="editEmailTemplate({{ $ns->email_template_id }})">Edytuj szablon</button></td> <div class="field"><label>Host SMTP</label><input class="input" placeholder="smtp.example.com" wire:model="mailConfig.smtpHost"></div>
</tr> <div style="display:flex;gap:10px">
@endforeach <div class="field" style="flex:1"><label>Port</label><input class="input" type="number" placeholder="587" wire:model="mailConfig.smtpPort"></div>
</tbody> <div class="field" style="flex:1">
</table> <label>Szyfrowanie</label>
<select class="input" wire:model="mailConfig.smtpEncryption">
<option value="none">Brak</option>
<option value="tls">STARTTLS</option>
<option value="ssl">SSL/TLS</option>
</select>
</div> </div>
</div>
<div class="field"><label>Użytkownik</label><input class="input" wire:model="mailConfig.smtpUsername"></div>
<div class="field"><label>Hasło</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="mailConfig.smtpPassword"></div>
<div style="display:flex;gap:10px;margin-top:8px;align-items:center;flex-wrap:wrap">
<button type="button" class="btn btn-secondary" wire:click="testMailConnection">Wyślij testową wiadomość</button>
<button type="submit" class="btn btn-primary">Zapisz</button>
@if ($mailTestResult === 'ok')
<div style="display:flex;align-items:center;gap:6px;color:var(--color-success)"><span class="material-symbols-outlined" style="font-size:18px">check_circle</span>Wysłano na Twój adres</div>
@elseif ($mailTestResult === 'error')
<div style="display:flex;align-items:center;gap:6px;color:var(--color-danger)"><span class="material-symbols-outlined" style="font-size:18px">error</span>Błąd wysyłki</div>
@endif
</div>
@else
<button type="submit" class="btn btn-primary" style="align-self:flex-start">Zapisz</button>
@endif
</form>
@endif @endif
{{-- ================= BRANDING ================= --}} {{-- ================= BRANDING ================= --}}
@@ -566,6 +617,21 @@ $tabGroups = [
</select> </select>
</div> </div>
<label class="radio"><input type="checkbox" wire:model="systemConfig.autoAssignByCategory" style="position:static;opacity:1;width:auto;height:auto">Automatyczne przypisywanie do zespołu wg kategorii</label> <label class="radio"><input type="checkbox" wire:model="systemConfig.autoAssignByCategory" style="position:static;opacity:1;width:auto;height:auto">Automatyczne przypisywanie do zespołu wg kategorii</label>
<div class="hr"></div>
<div class="field">
<label>Prefiks numeru zgłoszenia</label>
<input class="input" maxlength="20" placeholder="#" wire:model.live="systemConfig.ticketNumberPrefix">
</div>
<div class="field">
<label>Minimalna długość numeru (uzupełniana zerami z przodu)</label>
<input class="input" type="number" min="1" max="10" wire:model.live="systemConfig.ticketNumberMinLength">
</div>
<label class="radio"><input type="checkbox" wire:model.live="systemConfig.ticketNumberObfuscate" style="position:static;opacity:1;width:auto;height:auto">Ukryj kolejność zgłoszeń (numer wyświetlany jako suma kontrolna zamiast kolejnego numeru)</label>
<div class="text-muted" style="font-size:12px">
ID z bazy: {{ $this->ticketNumberPreview['id'] }} &rarr; podgląd numeru: {{ $this->ticketNumberPreview['formatted'] }}
</div>
</div> </div>
<div class="card" style="padding:20px;gap:14px"> <div class="card" style="padding:20px;gap:14px">
@@ -615,46 +681,14 @@ $tabGroups = [
</div> </div>
</form> </form>
<form wire:submit="saveMailConfig" class="card" style="padding:20px;gap:14px">
<h4 style="margin:0">E-mail (SMTP)</h4>
<div class="field"><label>Adres nadawcy</label><input class="input" type="email" placeholder="wsparcie@firma.pl" wire:model="mailConfig.fromAddress"></div>
<div class="field"><label>Nazwa nadawcy</label><input class="input" placeholder="Zespół Wsparcia" wire:model="mailConfig.fromName"></div>
<p class="text-muted" style="font-size:11.5px;margin:-4px 0 0">Stopka wiadomości e-mail edytowana jest w zakładce „Szablony e-mail”.</p>
<div class="hr"></div>
<label class="radio"><input type="checkbox" wire:model="mailConfig.smtpEnabled" style="position:static;opacity:1;width:auto;height:auto"><strong>Włącz wysyłkę przez własny serwer SMTP</strong></label>
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Bez włączenia aplikacja wysyła pocztę zgodnie z konfiguracją środowiska (.env).</span>
@if ($mailConfig['smtpEnabled'])
<div class="field"><label>Host SMTP</label><input class="input" placeholder="smtp.example.com" wire:model="mailConfig.smtpHost"></div>
<div style="display:flex;gap:10px">
<div class="field" style="flex:1"><label>Port</label><input class="input" type="number" placeholder="587" wire:model="mailConfig.smtpPort"></div>
<div class="field" style="flex:1">
<label>Szyfrowanie</label>
<select class="input" wire:model="mailConfig.smtpEncryption">
<option value="none">Brak</option>
<option value="tls">STARTTLS</option>
<option value="ssl">SSL/TLS</option>
</select>
</div> </div>
</div>
<div class="field"><label>Użytkownik</label><input class="input" wire:model="mailConfig.smtpUsername"></div>
<div class="field"><label>Hasło</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="mailConfig.smtpPassword"></div>
<div style="display:flex;gap:10px;margin-top:8px;align-items:center;flex-wrap:wrap">
<button type="button" class="btn btn-secondary" wire:click="testMailConnection">Wyślij testową wiadomość</button>
<button type="submit" class="btn btn-primary">Zapisz</button>
@if ($mailTestResult === 'ok')
<div style="display:flex;align-items:center;gap:6px;color:var(--color-success)"><span class="material-symbols-outlined" style="font-size:18px">check_circle</span>Wysłano na Twój adres</div>
@elseif ($mailTestResult === 'error')
<div style="display:flex;align-items:center;gap:6px;color:var(--color-danger)"><span class="material-symbols-outlined" style="font-size:18px">error</span>Błąd wysyłki</div>
@endif @endif
</div>
@else {{-- ================= INTEGRATIONS ================= --}}
<button type="submit" class="btn btn-primary" style="align-self:flex-start">Zapisz</button> @if ($tab === 'integrations')
@endif <h3 style="margin:0 0 16px">Integracje</h3>
</form>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:20px;align-items:start">
<form wire:submit="saveLdapConfig" class="card" style="padding:20px;gap:14px"> <form wire:submit="saveLdapConfig" class="card" style="padding:20px;gap:14px">
<h4 style="margin:0">LDAP / Active Directory</h4> <h4 style="margin:0">LDAP / Active Directory</h4>

View File

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

View File

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

View File

@@ -28,7 +28,7 @@
@endphp @endphp
<div class="card" style="padding:22px;gap:10px"> <div class="card" style="padding:22px;gap:10px">
<div class="card-kicker">Zgłoszenie #{{ $ticket->number }}</div> <div class="card-kicker">Zgłoszenie {{ $ticket->displayNumber() }}</div>
<h2 style="margin:2px 0 0">{{ $ticket->subject }}</h2> <h2 style="margin:2px 0 0">{{ $ticket->subject }}</h2>
<div class="card-meta">{{ $ticket->categoryLabel() }} &middot; utworzono {{ \App\Support\Rel::format($ticket->created_at) }}</div> <div class="card-meta">{{ $ticket->categoryLabel() }} &middot; utworzono {{ \App\Support\Rel::format($ticket->created_at) }}</div>
<div style="white-space:pre-wrap;font-size:14px;margin-top:4px">{{ $ticket->body }}</div> <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> <div class="card-kicker">Inne Twoje zgłoszenia</div>
@forelse ($this->otherTickets as $ot) @forelse ($this->otherTickets as $ot)
<a href="{{ route('client.ticket', $ot) }}" wire:navigate style="display:flex;justify-content:space-between;align-items:center;gap:8px;cursor:pointer;text-decoration:none;color:inherit"> <a href="{{ route('client.ticket', $ot) }}" wire:navigate style="display:flex;justify-content:space-between;align-items:center;gap:8px;cursor:pointer;text-decoration:none;color:inherit">
<span style="font-size:13px">#{{ $ot->number }} — {{ $ot->subject }}</span> <span style="font-size:13px">{{ $ot->displayNumber() }} {{ $ot->subject }}</span>
<span style="{{ $ot->statusStyle() }};flex:none">{{ $ot->statusLabel() }}</span> <span style="{{ $ot->statusStyle() }};flex:none">{{ $ot->statusLabel() }}</span>
</a> </a>
@empty @empty

View File

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

View File

@@ -161,7 +161,7 @@
<tr wire:key="ticket-{{ $t->id }}"> <tr wire:key="ticket-{{ $t->id }}">
<td class="td-select"><input type="checkbox" @checked(in_array($t->id, $selectedIds)) wire:click="toggleSelect({{ $t->id }})"></td> <td class="td-select"><input type="checkbox" @checked(in_array($t->id, $selectedIds)) wire:click="toggleSelect({{ $t->id }})"></td>
@if (in_array('number', $visibleColumns)) @if (in_array('number', $visibleColumns))
<td data-label="Numer" class="td-title"><a href="{{ route('operator.ticket', $t) }}" wire:navigate style="color:inherit;text-decoration:none;cursor:pointer">{{ $t->number }}</a></td> <td data-label="Numer" class="td-title"><a href="{{ route('operator.ticket', $t) }}" wire:navigate style="color:inherit;text-decoration:none;cursor:pointer">{{ $t->displayNumber() }}</a></td>
@endif @endif
@if (in_array('subject', $visibleColumns)) @if (in_array('subject', $visibleColumns))
<td data-label="Temat" class="td-title"><a href="{{ route('operator.ticket', $t) }}" wire:navigate style="color:inherit;text-decoration:none;cursor:pointer;white-space:nowrap">{{ $t->subject }}</a></td> <td data-label="Temat" class="td-title"><a href="{{ route('operator.ticket', $t) }}" wire:navigate style="color:inherit;text-decoration:none;cursor:pointer;white-space:nowrap">{{ $t->subject }}</a></td>
@@ -172,6 +172,9 @@
@if (in_array('category', $visibleColumns)) @if (in_array('category', $visibleColumns))
<td data-label="Kategoria" style="white-space:nowrap">{{ $t->categoryLabel() }}</td> <td data-label="Kategoria" style="white-space:nowrap">{{ $t->categoryLabel() }}</td>
@endif @endif
@if (in_array('subcategory', $visibleColumns))
<td data-label="Podkategoria" style="white-space:nowrap">{{ $t->subcategory?->name ?? '—' }}</td>
@endif
@if (in_array('priority', $visibleColumns)) @if (in_array('priority', $visibleColumns))
<td data-label="Priorytet"><span style="{{ $t->priorityStyle() }}">{{ $t->priorityLabel() }}</span></td> <td data-label="Priorytet"><span style="{{ $t->priorityStyle() }}">{{ $t->priorityLabel() }}</span></td>
@endif @endif
@@ -184,6 +187,12 @@
@if (in_array('assignee', $visibleColumns)) @if (in_array('assignee', $visibleColumns))
<td data-label="Przypisany" style="white-space:nowrap">{{ $t->assignee?->name ?? 'Nieprzypisane' }}</td> <td data-label="Przypisany" style="white-space:nowrap">{{ $t->assignee?->name ?? 'Nieprzypisane' }}</td>
@endif @endif
@if (in_array('team', $visibleColumns))
<td data-label="Zespół" style="white-space:nowrap">{{ $t->team?->name ?? '—' }}</td>
@endif
@if (in_array('created', $visibleColumns))
<td data-label="Utworzono" style="white-space:nowrap">{{ \App\Support\Rel::format($t->created_at) }}</td>
@endif
</tr> </tr>
@endforeach @endforeach
</tbody> </tbody>

View File

@@ -4,7 +4,18 @@
<div class="page-pad" style="flex:1;padding:20px 24px;overflow:auto"> <div class="page-pad" style="flex:1;padding:20px 24px;overflow:auto">
<div style="display:flex;flex-direction:column;gap:16px;max-width:1180px;margin:0 auto"> <div style="display:flex;flex-direction:column;gap:16px;max-width:1180px;margin:0 auto">
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap"> <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
<a href="{{ route('operator.queue') }}" wire:navigate class="btn btn-ghost" style="padding:0">&larr; Wróć do listy</a> <a href="{{ route('operator.queue') }}" wire:navigate class="btn btn-ghost" style="padding:0;margin-right:auto">&larr; Wróć do listy</a>
<button
type="button"
class="btn btn-secondary"
style="gap:6px"
wire:click="toggleWatch"
title="{{ $this->isWatching ? 'Przestań obserwować to zgłoszenie' : 'Obserwuj to zgłoszenie' }}"
>
<span class="material-symbols-outlined" style="font-size:18px">{{ $this->isWatching ? 'star' : 'star_outline' }}</span>
{{ $this->isWatching ? 'Obserwowane' : 'Obserwuj' }}
</button>
{{-- Live updates arrive via broadcasting, but websocket connections can {{-- Live updates arrive via broadcasting, but websocket connections can
drop silently this is a periodic fallback refresh, with a visible drop silently this is a periodic fallback refresh, with a visible
@@ -25,7 +36,7 @@
<div class="main-col" style="display:flex;flex-direction:column;gap:16px"> <div class="main-col" style="display:flex;flex-direction:column;gap:16px">
<div class="card" style="padding:20px;gap:8px"> <div class="card" style="padding:20px;gap:8px">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px"> <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">
<div class="card-kicker">Zgłoszenie #{{ $ticket->number }}</div> <div class="card-kicker">Zgłoszenie {{ $ticket->displayNumber() }}</div>
@unless ($editingDetails) @unless ($editingDetails)
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="toggleEditDetails">Edytuj</button> <button type="button" class="btn btn-ghost" style="padding:0" wire:click="toggleEditDetails">Edytuj</button>
@endunless @endunless
@@ -445,7 +456,7 @@
<div class="dialog-backdrop"> <div class="dialog-backdrop">
<div class="dialog" style="max-width:400px"> <div class="dialog" style="max-width:400px">
<div class="dialog-title">Potwierdź usunięcie</div> <div class="dialog-title">Potwierdź usunięcie</div>
<div class="dialog-body">Czy na pewno usunąć zgłoszenie #{{ $ticket->number }}?</div> <div class="dialog-body">Czy na pewno usunąć zgłoszenie {{ $ticket->displayNumber() }}?</div>
<div class="dialog-actions"> <div class="dialog-actions">
<button type="button" class="btn btn-secondary" wire:click="cancelDeleteTicket">Anuluj</button> <button type="button" class="btn btn-secondary" wire:click="cancelDeleteTicket">Anuluj</button>
<button type="button" class="btn btn-primary" wire:click="confirmDeleteTicket">Usuń</button> <button type="button" class="btn btn-primary" wire:click="confirmDeleteTicket">Usuń</button>

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,7 +37,7 @@ test('admin can reset the email footer back to its default, remounting the edito
$admin = adminUser(); $admin = adminUser();
$component = Livewire::actingAs($admin)->test(Panel::class) $component = Livewire::actingAs($admin)->test(Panel::class)
->call('setTab', 'templates') ->call('setTab', 'email')
->call('saveEmailFooter', 'Coś innego') ->call('saveEmailFooter', 'Coś innego')
->assertSet('emailFooterVersion', 0); ->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')); 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(); $admin = adminUser();
Livewire::actingAs($admin)->test(Panel::class) Livewire::actingAs($admin)->test(Panel::class)
->call('setTab', 'templates') ->call('setTab', 'email')
->call('saveEmailFooter', '<p>Pozdrawiamy, Zespół Wsparcia</p>') ->call('saveEmailFooter', '<p>Pozdrawiamy, Zespół Wsparcia</p>')
->assertOk() ->assertOk()
->assertSet('emailFooterHtml', '<p>Pozdrawiamy, Zespół Wsparcia</p>'); ->assertSet('emailFooterHtml', '<p>Pozdrawiamy, Zespół Wsparcia</p>');
@@ -66,7 +66,7 @@ test('the live example preview reflects the currently saved footer', function ()
$admin = adminUser(); $admin = adminUser();
$component = Livewire::actingAs($admin)->test(Panel::class) $component = Livewire::actingAs($admin)->test(Panel::class)
->call('setTab', 'templates') ->call('setTab', 'email')
->call('saveEmailFooter', 'Stopka na żywo'); ->call('saveEmailFooter', 'Stopka na żywo');
expect($component->instance()->emailPreviewHtml)->toContain('Stopka na żywo') expect($component->instance()->emailPreviewHtml)->toContain('Stopka na żywo')

View File

@@ -2,6 +2,7 @@
use App\Models\Category; use App\Models\Category;
use App\Models\EmailTemplate; use App\Models\EmailTemplate;
use App\Models\NotificationPreference;
use App\Models\NotificationSetting; use App\Models\NotificationSetting;
use App\Models\Team; use App\Models\Team;
use App\Models\User; 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); 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(); Notification::fake();
$this->seed(); $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']]); $memberB = User::query()->create(['name' => 'Jan', 'email' => 'team-notif-b@example.com', 'roles' => ['operator']]);
$team->members()->attach([$memberA->id, $memberB->id]); $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([ app(TicketService::class)->create([
'email' => 'client-team-notif@example.com', 'email' => 'client-team-notif@example.com',
'subject' => 'Problem z VPN', '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($memberA, TicketNotification::class);
Notification::assertSentTo($memberB, 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 // Plus one more: TicketService::create() also fires the pre-existing
// 'ticket_created' trigger, routed anonymously to the guest's e-mail // 'ticket_created' trigger, routed anonymously to the guest's e-mail
// since this ticket has no real customer account. // 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(); Notification::fake();
$this->seed(); $this->seed();
$category = Category::query()->create(['name' => 'Bez zespołu']); $category = Category::query()->create(['name' => 'Bez zespołu']);
$sub = $category->subcategories()->create(['name' => 'Inne']); $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([ app(TicketService::class)->create([
'email' => 'client-no-team@example.com', 'email' => 'client-no-team@example.com',

View File

@@ -12,7 +12,7 @@ test('admin can save the SMTP/from settings, and the password is only overwritte
$admin = adminUser(); $admin = adminUser();
Livewire::actingAs($admin)->test(Panel::class) Livewire::actingAs($admin)->test(Panel::class)
->call('setTab', 'config') ->call('setTab', 'email')
->set('mailConfig.fromAddress', 'wsparcie@firma.pl') ->set('mailConfig.fromAddress', 'wsparcie@firma.pl')
->set('mailConfig.fromName', 'Zespół Wsparcia') ->set('mailConfig.fromName', 'Zespół Wsparcia')
->set('mailConfig.smtpEnabled', true) ->set('mailConfig.smtpEnabled', true)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,60 @@
<?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();
$this->actingAs($operator)->get('/operator/tickets/1042')->assertNotFound();
});
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);
});

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

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

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

View File

@@ -1,9 +1,9 @@
# Przewodnik — Administrator # Przewodnik — Administrator
Panel administratora (`/admin`) to jedno miejsce do konfiguracji całego systemu: Panel administratora (`/admin`) to jedno miejsce do konfiguracji całego systemu:
struktura zgłoszeń (kategorie, pola, statusy, priorytety, SLA), użytkownicy i struktura zgłoszeń (kategorie, pola, statusy, priorytety, SLA), automatyzacje
zespoły, treści (szablony, szybkie akcje, e-maile), wygląd/branding oraz (reguły SLA, wyzwalacze), użytkownicy i zespoły, treści (szablony, szybkie
integracje (LDAP, SMTP, API). akcje, e-maile), wygląd/branding oraz integracje (LDAP, SMTP, BookStack, API).
Domyślnie każde konto ląduje po zalogowaniu w panelu Klienta; przełącz się do 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). panelu Administratora przez menu profilu (prawy górny róg).
@@ -77,6 +77,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 zostanie zamknięte i otwarte ponownie — więc bezpiecznie zostawić kilka
aktywnych reguł naraz, bez ryzyka zapętlenia się co 15 minut. 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 ## Szybkie akcje odpowiedzi
Przyciski w widoku zgłoszenia operatora, które **wysyłają odpowiedź i od razu Przyciski w widoku zgłoszenia operatora, które **wysyłają odpowiedź i od razu
@@ -98,6 +120,9 @@ więcej informacji”, „Restart usuwa problem”.
placeholderami: `{numer}`, `{imie}`, `{temat}`, `{status}`, `{kategoria}`, placeholderami: `{numer}`, `{imie}`, `{temat}`, `{status}`, `{kategoria}`,
`{priorytet}`, `{zespol}`, `{operator}`, `{link}`. Każdy szablon opakowuje się `{priorytet}`, `{zespol}`, `{operator}`, `{link}`. Każdy szablon opakowuje się
automatycznie we wspólny layout (nagłówek z nazwą firmy + stopka — patrz niżej). 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 - **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. „Resetuj” do wartości domyślnej); sam layout nie jest edytowalny z poziomu UI.
- **Powiadomienia** — lista zdarzeń (zgłoszenie utworzone, zmiana statusu/ - **Powiadomienia** — lista zdarzeń (zgłoszenie utworzone, zmiana statusu/
@@ -113,7 +138,15 @@ więcej informacji”, „Restart usuwa problem”.
tylko do jednej przypisanej osoby. **Ten sam przełącznik kontroluje zarówno tylko do jednej przypisanej osoby. **Ten sam przełącznik kontroluje zarówno
e-mail, jak i powiadomienie w dzwoneczku w aplikacji** — nie ma osobnego e-mail, jak i powiadomienie w dzwoneczku w aplikacji** — nie ma osobnego
ustawienia dla powiadomień w apce, a dzwoneczek pokazuje tylko nieprzeczytane 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 ## Wygląd / Branding
@@ -126,14 +159,32 @@ ważne + treść HTML).
- **Ogólne** — domyślny status nowego zgłoszenia, automatyczne przypisywanie wg - **Ogólne** — domyślny status nowego zgłoszenia, automatyczne przypisywanie wg
kategorii, limity załączników (rozmiar/liczba/typy), czas życia sesji, strefa kategorii, limity załączników (rozmiar/liczba/typy), czas życia sesji, strefa
czasowa. 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 **E-MAIL**,
razem z layoutem/stopką wiadomości — patrz sekcja wyżej.
## Integracje
- **LDAP** — host, port, base DN, bind DN + hasło, SSL, filtr użytkownika - **LDAP** — host, port, base DN, bind DN + hasło, SSL, filtr użytkownika
(`(uid={0})` domyślnie), auto-provisioning gości, ograniczenie tworzenia (`(uid={0})` domyślnie), auto-provisioning gości, ograniczenie tworzenia
kont/zgłaszania tylko przez LDAP. Przycisk **„Testuj połączenie”** sprawdza kont/zgłaszania tylko przez LDAP. Przycisk **„Testuj połączenie”** sprawdza
bind bez zapisywania zmian. 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`, > **przykładowe wartości** (`ldap.example.com`, `smtp.example.com`,
> `changeme-*-password`) — koniecznie podmień je na rzeczywiste dane przed > `changeme-*-password`) — koniecznie podmień je na rzeczywiste dane przed
> oddaniem systemu do użytku. > oddaniem systemu do użytku.

View File

@@ -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 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ę. 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 Dzwoneczek powiadomień w górnym pasku (widoczny we wszystkich panelach) pokazuje
Twoje **nieprzeczytane** powiadomienia — kliknięcie (albo „Oznacz wszystkie jako Twoje **nieprzeczytane** powiadomienia, aktualizowane **na żywo** w chwili ich
przeczytane”) usuwa je z listy. 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 ## 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/ **Filtry** nad tabelą: status, priorytet, kategoria, wyszukiwanie po numerze/
temacie/kliencie/treści zgłoszenia i odpowiedzi w wątku. **Kolumny** można dowolnie 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ą **Zapisane widoki** — przycisk „Zapisane widoki” pozwala zapisać bieżącą
kombinację zakładki/filtrów/sortowania/kolumn pod własną nazwą, oznaczyć jeden z kombinację zakładki/filtrów/sortowania/kolumn pod własną nazwą, oznaczyć jeden z
@@ -54,6 +67,10 @@ przy przycisku „Wróć do listy” to taki sam fallbackowy zegar jak w kolejce
W widoku pojedynczego zgłoszenia: 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 - **Zmiana statusu / priorytetu / zespołu / przypisanego operatora** — z listy
rozwijanej; „Przypisz do mnie” to skrót jednym kliknięciem. rozwijanej; „Przypisz do mnie” to skrót jednym kliknięciem.
- **Odpowiedź publiczna** — widoczna dla klienta; można wybrać **szablon - **Odpowiedź publiczna** — widoczna dla klienta; można wybrać **szablon