- 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>
This commit is contained in:
2026-07-22 23:43:01 +02:00
parent 0b06687ea1
commit 2df6b8f3de
47 changed files with 2480 additions and 139 deletions

View File

@@ -99,7 +99,8 @@ attributes.
`App\Support\Settings` (`app/Support/Settings.php`) is a cached key/value reader
over the `settings` table, with hardcoded defaults for every key (company name,
LDAP/SMTP connection details, attachment limits, session lifetime, timezone,
branding/email HTML, etc.). Admin > Konfiguracja writes to this table, and
branding/email HTML, etc.). Admin > Konfiguracja (general/attachments/session),
E-MAIL (SMTP) and Integracje (LDAP, BookStack) all write to this same table, and
`AppServiceProvider::boot()` re-applies the relevant subset of it over
`config()` on every request — meaning **`Setting` rows win over `.env`** for
LDAP, mail, session lifetime and timezone once they're non-empty. This is by
@@ -192,6 +193,20 @@ themselves every 3060 seconds via a small Alpine countdown calling
`$wire.refreshQueue()` / `$wire.refreshTicketData()` — broadcasting is
best-effort, not the only way these views ever update.
A third private channel, **`App.Models.User.{id}`** (Laravel's default
per-notifiable convention, kept verbatim rather than a shorter alias),
carries realtime bell delivery: `AppServiceProvider::broadcastBellNotifications()`
listens for the framework's own `NotificationSent` event, and — only for the
`database` channel of a `TicketNotification` — dispatches `NotificationCreated`
on the recipient's own channel. This is a single choke point rather than
threading a broadcast call into every `TicketService` notification call site
(including the Trigger engine's `send_notification` action, below).
`resources/js/echo.js` bridges it into a `bell-notification-received` Livewire
event (refreshing `NotificationBell` instantly) and, if the viewer opted in via
the toggle on `/settings/notifications`, also raises a native in-tab
`Notification` popup — no service worker or push subscription, so this only
fires while the tab is open, same limitation as the other Echo listeners here.
## SLA
`SlaRule` holds per-priority response/resolution targets in minutes. The

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ APP_DEBUG=true
APP_URL=http://localhost
AUTHOR_CONTACT=helpdesk@kzbikowski.pl
VERSION=1.1.2
VERSION=1.1.3
APP_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

@@ -26,6 +26,7 @@ use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use LdapRecord\Connection;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithFileUploads;
@@ -33,6 +34,7 @@ class Panel extends Component
{
use WithFileUploads;
#[Url]
public string $tab = 'categories';
// ---- categories ----

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

View File

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

View File

@@ -91,6 +91,18 @@ class TicketShow extends Component
$this->ticket->resumeTimer();
}
#[Computed]
public function isWatching(): bool
{
return $this->ticket->isWatchedBy(Auth::user());
}
public function toggleWatch(): void
{
app(TicketService::class)->toggleWatch($this->ticket, Auth::user());
unset($this->isWatching);
}
// -------- time tracking --------
public function stopTimer(): void

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

@@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
@@ -56,6 +57,16 @@ class Ticket extends Model
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
{
return $this->belongsTo(Status::class, 'status_key');

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

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

View File

@@ -4,7 +4,18 @@
<div class="page-pad" style="flex:1;padding:20px 24px;overflow:auto">
<div style="display:flex;flex-direction:column;gap:16px;max-width:1180px;margin:0 auto">
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
<a href="{{ route('operator.queue') }}" wire:navigate class="btn btn-ghost" style="padding:0">&larr; Wróć do listy</a>
<a href="{{ route('operator.queue') }}" wire:navigate class="btn btn-ghost" style="padding:0;margin-right:auto">&larr; Wróć do listy</a>
<button
type="button"
class="btn btn-secondary"
style="gap:6px"
wire:click="toggleWatch"
title="{{ $this->isWatching ? 'Przestań obserwować to zgłoszenie' : 'Obserwuj to zgłoszenie' }}"
>
<span class="material-symbols-outlined" style="font-size:18px">{{ $this->isWatching ? 'star' : 'star_outline' }}</span>
{{ $this->isWatching ? 'Obserwowane' : 'Obserwuj' }}
</button>
{{-- Live updates arrive via broadcasting, but websocket connections can
drop silently this is a periodic fallback refresh, with a visible

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

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

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,7 @@ test('admin can save the SMTP/from settings, and the password is only overwritte
$admin = adminUser();
Livewire::actingAs($admin)->test(Panel::class)
->call('setTab', 'config')
->call('setTab', 'email')
->set('mailConfig.fromAddress', 'wsparcie@firma.pl')
->set('mailConfig.fromName', 'Zespół Wsparcia')
->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')
->assertSet('visibleColumns', fn ($cols) => in_array('sla', $cols, true));
// Hide every column except one, then try to hide the last one too.
foreach (array_keys((new \App\Livewire\Operator\Queue)->columnDefs()) as $key) {
// Hide every visible-by-default column except one, then try to hide the last one too.
foreach ((new Queue)->visibleColumns as $key) {
if ($key !== 'number') {
$component->call('toggleColumn', $key);
}

View File

@@ -0,0 +1,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,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
Panel administratora (`/admin`) to jedno miejsce do konfiguracji całego systemu:
struktura zgłoszeń (kategorie, pola, statusy, priorytety, SLA), użytkownicy i
zespoły, treści (szablony, szybkie akcje, e-maile), wygląd/branding oraz
integracje (LDAP, SMTP, API).
struktura zgłoszeń (kategorie, pola, statusy, priorytety, SLA), automatyzacje
(reguły SLA, wyzwalacze), użytkownicy i zespoły, treści (szablony, szybkie
akcje, e-maile), wygląd/branding oraz integracje (LDAP, SMTP, BookStack, API).
Domyślnie każde konto ląduje po zalogowaniu w panelu Klienta; przełącz się do
panelu Administratora przez menu profilu (prawy górny róg).
@@ -77,6 +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
aktywnych reguł naraz, bez ryzyka zapętlenia się co 15 minut.
## Wyzwalacze
W odróżnieniu od Automatyzacji SLA (działa po czasie ciszy klienta), wyzwalacze
reagują **natychmiast** na zdarzenie w zgłoszeniu: utworzenie, dowolna zmiana
pola, zmiana statusu/priorytetu/przypisania/zespołu/kategorii, nowa wiadomość
publiczna. Każdy wyzwalacz ma:
- **Zdarzenie**, na które reaguje.
- **Warunki** (opcjonalne, wszystkie muszą być spełnione naraz — ORAZ) na polu
statusu, priorytetu, zespołu, podkategorii, zgłaszającego, tematu lub treści.
- **Akcje** wykonywane po kolei — ustaw status/priorytet/zespół/operatora, albo
wyślij powiadomienie e-mail do zgłaszającego lub przypisanego operatora.
Akcja „Wyślij powiadomienie e-mail” korzysta z **własnych szablonów wyzwalaczy**
(sekcja „Szablony e-mail wyzwalaczy” na tej samej zakładce) — w pełni
dodawalnych/edytowalnych/usuwalnych przez administratora, celowo osobnych od
stałych szablonów opisanych niżej (te są przypisane 1:1 do zdarzeń systemowych
i nie da się ich usunąć ani dodać nowego). Wyzwalacz może zmienić to samo pole,
które sam sprawdza w warunku — zabezpieczenie przed zapętleniem: akcja, która
tylko potwierdzałaby już ustawioną wartość, nic nie robi, a licznik głębokości
zatrzymuje prawdziwy cykl między dwoma wyzwalaczami.
## Szybkie akcje odpowiedzi
Przyciski w widoku zgłoszenia operatora, które **wysyłają odpowiedź i od razu
@@ -98,6 +120,9 @@ więcej informacji”, „Restart usuwa problem”.
placeholderami: `{numer}`, `{imie}`, `{temat}`, `{status}`, `{kategoria}`,
`{priorytet}`, `{zespol}`, `{operator}`, `{link}`. Każdy szablon opakowuje się
automatycznie we wspólny layout (nagłówek z nazwą firmy + stopka — patrz niżej).
Te szablony są przypisane **na stałe** do zdarzeń systemowych (nie da się ich
dodać/usunąć/przepiąć na inne zdarzenie) — dla wyzwalaczy (zakładka
Wyzwalacze) służy osobny, w pełni dowolny zestaw szablonów, opisany wyżej.
- **Stopka e-mail** i **layout HTML** — stopka jest edytowalna (z przyciskiem
„Resetuj” do wartości domyślnej); sam layout nie jest edytowalny z poziomu UI.
- **Powiadomienia** — lista zdarzeń (zgłoszenie utworzone, zmiana statusu/
@@ -113,7 +138,15 @@ więcej informacji”, „Restart usuwa problem”.
tylko do jednej przypisanej osoby. **Ten sam przełącznik kontroluje zarówno
e-mail, jak i powiadomienie w dzwoneczku w aplikacji** — nie ma osobnego
ustawienia dla powiadomień w apce, a dzwoneczek pokazuje tylko nieprzeczytane
(znikają po kliknięciu/oznaczeniu).
(znikają po kliknięciu/oznaczeniu) i aktualizuje się na żywo.
- **Preferencje powiadomień per operator/admin** (`/settings/notifications`,
menu profilu → „Powiadomienia”) — każdy sam wybiera, dla nowego zgłoszenia/
aktualizacji/eskalacji, jaki zakres zgłoszeń (moje / nieprzypisane /
obserwowane / wszystkie) ma go powiadamiać dzwoneczkiem i czy dodatkowo
e-mailem, plus opcjonalne natywne powiadomienia push przeglądarki. To
ustawienie jest niezależne od globalnego przełącznika powiadomień opisanego
wyżej — dotyczy dodatkowego powiadamiania innych operatorów/adminów o
zgłoszeniach w ich zakresie, nie zastępuje go.
## Wygląd / Branding
@@ -126,14 +159,19 @@ ważne + treść HTML).
- **Ogólne** — domyślny status nowego zgłoszenia, automatyczne przypisywanie wg
kategorii, limity załączników (rozmiar/liczba/typy), czas życia sesji, strefa
czasowa.
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
(`(uid={0})` domyślnie), auto-provisioning gości, ograniczenie tworzenia
kont/zgłaszania tylko przez LDAP. Przycisk **„Testuj połączenie”** sprawdza
bind bez zapisywania zmian.
- **SMTP** — host, port, szyfrowanie, użytkownik/hasło, adres/nazwa nadawcy.
Przycisk **„Testuj połączenie”** analogicznie do LDAP.
> Po świeżej instalacji (`migrate:fresh --seed`) te dwie sekcje zawierają
> Po świeżej instalacji (`migrate:fresh --seed`) LDAP i SMTP zawierają
> **przykładowe wartości** (`ldap.example.com`, `smtp.example.com`,
> `changeme-*-password`) — koniecznie podmień je na rzeczywiste dane przed
> oddaniem systemu do użytku.

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
panelu Operatora przez menu profilu (prawy górny róg), jeśli konto ma tę rolę.
Dzwoneczek powiadomień w górnym pasku (widoczny we wszystkich panelach) pokazuje
Twoje **nieprzeczytane** powiadomienia — kliknięcie (albo „Oznacz wszystkie jako
przeczytane”) usuwa je z listy.
Twoje **nieprzeczytane** powiadomienia, aktualizowane **na żywo** w chwili ich
utworzenia (niezależny od tego 30-sekundowy fallback dogrywa to, co ominęłoby
zerwane połączenie) — kliknięcie (albo „Oznacz wszystkie jako przeczytane”)
usuwa je z listy.
**„Powiadomienia”** w menu profilu (`/settings/notifications`) pozwala wybrać,
dla każdej kategorii zdarzeń (nowe zgłoszenie, aktualizacja zgłoszenia,
eskalacja), jaki zakres zgłoszeń ma Cię powiadamiać dzwoneczkiem — moje /
nieprzypisane / **obserwowane** / wszystkie — oraz czy dodatkowo wysłać e-mail.
Tam też włączysz natywne powiadomienia push przeglądarki (działają, dopóki
karta jest otwarta).
## Kolejka zgłoszeń — aktualizacje na żywo
@@ -26,7 +35,11 @@ przypisanego zespołu, oraz wszystko przypisane bezpośrednio do nich.
**Filtry** nad tabelą: status, priorytet, kategoria, wyszukiwanie po numerze/
temacie/kliencie/treści zgłoszenia i odpowiedzi w wątku. **Kolumny** można dowolnie
włączać/wyłączać przyciskiem „Kolumny”, a nagłówki kolumn sortują listę.
włączać/wyłączać przyciskiem „Kolumny” (numer, temat, klient, kategoria,
podkategoria, priorytet, status, SLA, przypisany, zespół, utworzono — kilka z
nich domyślnie ukryte), a nagłówki kolumn sortują listę. Wybrana zakładka i
kolumny zostają zapamiętane w adresie strony, więc odświeżenie nie cofa Cię do
pierwszej zakładki.
**Zapisane widoki** — przycisk „Zapisane widoki” pozwala zapisać bieżącą
kombinację zakładki/filtrów/sortowania/kolumn pod własną nazwą, oznaczyć jeden z
@@ -54,6 +67,10 @@ przy przycisku „Wróć do listy” to taki sam fallbackowy zegar jak w kolejce
W widoku pojedynczego zgłoszenia:
- **Obserwuj** — przycisk obok licznika auto-odświeżania oznacza zgłoszenie
jako obserwowane niezależnie od przypisania czy zespołu; zasila zakres
„Obserwowane zgłoszenia” w Twoich preferencjach powiadomień
(`/settings/notifications`).
- **Zmiana statusu / priorytetu / zespołu / przypisanego operatora** — z listy
rozwijanej; „Przypisz do mnie” to skrót jednym kliknięciem.
- **Odpowiedź publiczna** — widoczna dla klienta; można wybrać **szablon