From ab90abcaa3780152b97ab70d05f36e6a317706a3 Mon Sep 17 00:00:00 2001 From: Kacper Date: Wed, 22 Jul 2026 23:43:01 +0200 Subject: [PATCH] v1.1.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- ARCHITECTURE.md | 17 +- CHANGELOG.md | 43 +++ README.md | 34 +- install.md | 4 +- src/.env.example | 2 +- src/app/Events/NotificationCreated.php | 47 +++ src/app/Livewire/Admin/Panel.php | 2 + src/app/Livewire/Admin/Triggers.php | 342 ++++++++++++++++++ src/app/Livewire/NotificationBell.php | 13 + src/app/Livewire/Operator/Queue.php | 11 +- src/app/Livewire/Operator/TicketShow.php | 12 + .../Settings/NotificationPreferences.php | 44 +++ src/app/Models/NotificationPreference.php | 68 ++++ src/app/Models/Ticket.php | 11 + src/app/Models/Trigger.php | 33 ++ src/app/Models/TriggerEmailTemplate.php | 26 ++ src/app/Models/User.php | 5 + src/app/Notifications/TicketNotification.php | 26 +- src/app/Providers/AppServiceProvider.php | 27 ++ src/app/Services/TicketService.php | 191 +++++++--- src/app/Services/TriggerEngine.php | 164 +++++++++ ...22_000148_create_ticket_watchers_table.php | 25 ++ ..._create_notification_preferences_table.php | 37 ++ ...026_07_22_000150_create_triggers_table.php | 34 ++ ...1_create_trigger_email_templates_table.php | 31 ++ src/resources/js/echo.js | 28 ++ .../views/components/profile-menu.blade.php | 12 + .../views/livewire/admin/panel.blade.php | 129 ++++--- .../views/livewire/admin/triggers.blade.php | 303 ++++++++++++++++ .../views/livewire/operator/queue.blade.php | 9 + .../livewire/operator/ticket-show.blade.php | 13 +- .../notification-preferences.blade.php | 97 +++++ src/routes/channels.php | 11 + src/routes/web.php | 5 + src/tests/Feature/EmailLayoutTest.php | 8 +- .../ExtendedNotificationTriggersTest.php | 47 ++- src/tests/Feature/MailSmtpConfigTest.php | 2 +- .../NotificationDeliveryRewiringTest.php | 95 +++++ .../Feature/NotificationPreferencesTest.php | 70 ++++ .../OperatorQueueSearchSortColumnsTest.php | 4 +- .../Feature/RealtimeBellNotificationTest.php | 60 +++ .../TabPersistenceAndNavigationTest.php | 39 ++ src/tests/Feature/TicketWatchingTest.php | 45 +++ src/tests/Feature/TriggerEngineTest.php | 172 +++++++++ src/tests/Feature/TriggersAdminTest.php | 146 ++++++++ wiki/admin/README.md | 52 ++- wiki/operator/README.md | 23 +- 47 files changed, 2480 insertions(+), 139 deletions(-) create mode 100644 src/app/Events/NotificationCreated.php create mode 100644 src/app/Livewire/Admin/Triggers.php create mode 100644 src/app/Livewire/Settings/NotificationPreferences.php create mode 100644 src/app/Models/NotificationPreference.php create mode 100644 src/app/Models/Trigger.php create mode 100644 src/app/Models/TriggerEmailTemplate.php create mode 100644 src/app/Services/TriggerEngine.php create mode 100644 src/database/migrations/2026_07_22_000148_create_ticket_watchers_table.php create mode 100644 src/database/migrations/2026_07_22_000149_create_notification_preferences_table.php create mode 100644 src/database/migrations/2026_07_22_000150_create_triggers_table.php create mode 100644 src/database/migrations/2026_07_22_000151_create_trigger_email_templates_table.php create mode 100644 src/resources/views/livewire/admin/triggers.blade.php create mode 100644 src/resources/views/livewire/settings/notification-preferences.blade.php create mode 100644 src/tests/Feature/NotificationDeliveryRewiringTest.php create mode 100644 src/tests/Feature/NotificationPreferencesTest.php create mode 100644 src/tests/Feature/RealtimeBellNotificationTest.php create mode 100644 src/tests/Feature/TabPersistenceAndNavigationTest.php create mode 100644 src/tests/Feature/TicketWatchingTest.php create mode 100644 src/tests/Feature/TriggerEngineTest.php create mode 100644 src/tests/Feature/TriggersAdminTest.php diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 91b3657..1e45576 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 30–60 seconds via a small Alpine countdown calling `$wire.refreshQueue()` / `$wire.refreshTicketData()` — broadcasting is best-effort, not the only way these views ever update. +A third private channel, **`App.Models.User.{id}`** (Laravel's default +per-notifiable convention, kept verbatim rather than a shorter alias), +carries realtime bell delivery: `AppServiceProvider::broadcastBellNotifications()` +listens for the framework's own `NotificationSent` event, and — only for the +`database` channel of a `TicketNotification` — dispatches `NotificationCreated` +on the recipient's own channel. This is a single choke point rather than +threading a broadcast call into every `TicketService` notification call site +(including the Trigger engine's `send_notification` action, below). +`resources/js/echo.js` bridges it into a `bell-notification-received` Livewire +event (refreshing `NotificationBell` instantly) and, if the viewer opted in via +the toggle on `/settings/notifications`, also raises a native in-tab +`Notification` popup — no service worker or push subscription, so this only +fires while the tab is open, same limitation as the other Echo listeners here. + ## SLA `SlaRule` holds per-priority response/resolution targets in minutes. The diff --git a/CHANGELOG.md b/CHANGELOG.md index 404517b..c189865 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 6f29c1d..d50011d 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ The app has three areas, gated by role (a user can hold more than one at once): |---|---|---|---| | Client | `/client` | `client` | Submit tickets, track status, reply, see resolution | | Operator | `/operator` | `operator` | Work the ticket queue, reply/resolve, see team statistics | -| Admin | `/admin` | `admin` | Configure categories, users, teams, SLA, templates, branding, LDAP/SMTP | +| Admin | `/admin` | `admin` | Configure categories, users, teams, SLA, templates, triggers, branding, LDAP/SMTP/BookStack | Every account gets the `client` role by default (see `AssignDefaultRole` for LDAP-provisioned accounts), and always lands on `/client` first after login regardless of what other @@ -60,11 +60,28 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides. subcategories, rest folded into "Inne"); CSAT average by team and by operator; and a daily created-vs-closed trend. - **Branding & config** — company name/logo/favicon/accent color, login notice, - e-mail layout/footer, LDAP connection + user sync, SMTP connection, attachment - limits, session lifetime, timezone — all editable from Admin > Konfiguracja. + e-mail layout/footer, SMTP connection (Admin > E-MAIL), attachment limits, + session lifetime, timezone (Admin > Konfiguracja), and LDAP connection + user + sync + BookStack (Admin > Integracje). - **LDAP auth** — logins bind against an LDAP/LLDAP directory (`config/auth.php`, `config/ldap.php`); local accounts (e.g. the emergency `admin` account) fall back to e-mail + local password when the LDAP bind doesn't match. +- **Triggers** (Admin > Wyzwalacze) — event-driven business rules that fire + immediately on a ticket lifecycle event (created, any field updated, status/ + priority/assignee/team/category changed, new public reply): AND-combined + conditions gate a sequence of actions (set status/priority/team/assignee, or + send an e-mail using a dedicated set of freely add/edit/delete-able trigger + e-mail templates, kept separate from the fixed per-event system templates). + Complements the time-based SLA automation rules above rather than replacing + them. +- **Ticket watching** — operators can star/"Obserwuj" any ticket to follow it + regardless of assignment/team, which feeds the "Obserwowane zgłoszenia" scope + in their notification preferences. +- **Per-user notification preferences** (`/settings/notifications`) — each + operator/admin chooses, per event category (new ticket, ticket update, + escalation), which scope of tickets (mine, unassigned, watched, all) notifies + them via the in-app bell, and whether that also sends an e-mail; plus an + opt-in toggle for native in-tab browser push notifications. - **REST API** (`/api/v1/...`, Sanctum token auth, ability-scoped: `tickets:read`, `tickets:write`, `dictionaries:read`, `users:read`) for tickets/messages/users/ categories/statuses/priorities/teams — issued via admin-managed API clients. @@ -73,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 1–5 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 diff --git a/install.md b/install.md index ec513f5..46d5c97 100644 --- a/install.md +++ b/install.md @@ -74,7 +74,7 @@ APP_LOCALE=pl APP_FALLBACK_LOCALE=pl AUTHOR_CONTACT=helpdesk@twoja-domena.pl # widoczne w Admin > O aplikacji -VERSION=1.1.2 # widoczne w Admin > O aplikacji +VERSION=1.1.3 # widoczne w Admin > O aplikacji DB_CONNECTION=mysql DB_HOST=mariadb # nazwa serwisu z compose.yaml, NIE 127.0.0.1 @@ -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 diff --git a/src/.env.example b/src/.env.example index c7d6ba4..b2000ad 100644 --- a/src/.env.example +++ b/src/.env.example @@ -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 diff --git a/src/app/Events/NotificationCreated.php b/src/app/Events/NotificationCreated.php new file mode 100644 index 0000000..01bf3e6 --- /dev/null +++ b/src/app/Events/NotificationCreated.php @@ -0,0 +1,47 @@ + + */ + 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, + ]; + } +} diff --git a/src/app/Livewire/Admin/Panel.php b/src/app/Livewire/Admin/Panel.php index 6adef92..c640b5a 100644 --- a/src/app/Livewire/Admin/Panel.php +++ b/src/app/Livewire/Admin/Panel.php @@ -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 ---- diff --git a/src/app/Livewire/Admin/Triggers.php b/src/app/Livewire/Admin/Triggers.php new file mode 100644 index 0000000..b3f253c --- /dev/null +++ b/src/app/Livewire/Admin/Triggers.php @@ -0,0 +1,342 @@ + '', + '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'); + } +} diff --git a/src/app/Livewire/NotificationBell.php b/src/app/Livewire/NotificationBell.php index 0a8260a..e54992d 100644 --- a/src/app/Livewire/NotificationBell.php +++ b/src/app/Livewire/NotificationBell.php @@ -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 diff --git a/src/app/Livewire/Operator/Queue.php b/src/app/Livewire/Operator/Queue.php index a30c87a..92ba532 100644 --- a/src/app/Livewire/Operator/Queue.php +++ b/src/app/Livewire/Operator/Queue.php @@ -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 diff --git a/src/app/Livewire/Operator/TicketShow.php b/src/app/Livewire/Operator/TicketShow.php index da387fd..db7a615 100644 --- a/src/app/Livewire/Operator/TicketShow.php +++ b/src/app/Livewire/Operator/TicketShow.php @@ -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 diff --git a/src/app/Livewire/Settings/NotificationPreferences.php b/src/app/Livewire/Settings/NotificationPreferences.php new file mode 100644 index 0000000..c8d19d7 --- /dev/null +++ b/src/app/Livewire/Settings/NotificationPreferences.php @@ -0,0 +1,44 @@ +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()]); + } +} diff --git a/src/app/Models/NotificationPreference.php b/src/app/Models/NotificationPreference.php new file mode 100644 index 0000000..16d5f30 --- /dev/null +++ b/src/app/Models/NotificationPreference.php @@ -0,0 +1,68 @@ + ['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, + ]; + } +} diff --git a/src/app/Models/Ticket.php b/src/app/Models/Ticket.php index be1d28c..02d1de1 100644 --- a/src/app/Models/Ticket.php +++ b/src/app/Models/Ticket.php @@ -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'); diff --git a/src/app/Models/Trigger.php b/src/app/Models/Trigger.php new file mode 100644 index 0000000..bbce8b4 --- /dev/null +++ b/src/app/Models/Trigger.php @@ -0,0 +1,33 @@ + 'boolean', + 'conditions' => 'array', + 'actions' => 'array', + 'sort_order' => 'integer', + ]; + } +} diff --git a/src/app/Models/TriggerEmailTemplate.php b/src/app/Models/TriggerEmailTemplate.php new file mode 100644 index 0000000..600cf5f --- /dev/null +++ b/src/app/Models/TriggerEmailTemplate.php @@ -0,0 +1,26 @@ + $value) { + $text = str_replace('{'.$key.'}', (string) $value, $text); + } + + return $text; + }; + + return [ + 'subject' => $replace($this->subject), + 'body' => $replace($this->body), + ]; + } +} diff --git a/src/app/Models/User.php b/src/app/Models/User.php index 386f1c1..5e81ac7 100644 --- a/src/app/Models/User.php +++ b/src/app/Models/User.php @@ -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'); diff --git a/src/app/Notifications/TicketNotification.php b/src/app/Notifications/TicketNotification.php index 1ebfc28..25bebcc 100644 --- a/src/app/Notifications/TicketNotification.php +++ b/src/app/Notifications/TicketNotification.php @@ -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); diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php index 72669bc..bc0c624 100644 --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -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 diff --git a/src/app/Services/TicketService.php b/src/app/Services/TicketService.php index 22b9418..e542e69 100644 --- a/src/app/Services/TicketService.php +++ b/src/app/Services/TicketService.php @@ -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)); } } diff --git a/src/app/Services/TriggerEngine.php b/src/app/Services/TriggerEngine.php new file mode 100644 index 0000000..ca9724b --- /dev/null +++ b/src/app/Services/TriggerEngine.php @@ -0,0 +1,164 @@ += 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); + } +} diff --git a/src/database/migrations/2026_07_22_000148_create_ticket_watchers_table.php b/src/database/migrations/2026_07_22_000148_create_ticket_watchers_table.php new file mode 100644 index 0000000..98cedb6 --- /dev/null +++ b/src/database/migrations/2026_07_22_000148_create_ticket_watchers_table.php @@ -0,0 +1,25 @@ +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'); + } +}; diff --git a/src/database/migrations/2026_07_22_000149_create_notification_preferences_table.php b/src/database/migrations/2026_07_22_000149_create_notification_preferences_table.php new file mode 100644 index 0000000..9b38883 --- /dev/null +++ b/src/database/migrations/2026_07_22_000149_create_notification_preferences_table.php @@ -0,0 +1,37 @@ +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'); + } +}; diff --git a/src/database/migrations/2026_07_22_000150_create_triggers_table.php b/src/database/migrations/2026_07_22_000150_create_triggers_table.php new file mode 100644 index 0000000..039db35 --- /dev/null +++ b/src/database/migrations/2026_07_22_000150_create_triggers_table.php @@ -0,0 +1,34 @@ +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'); + } +}; diff --git a/src/database/migrations/2026_07_22_000151_create_trigger_email_templates_table.php b/src/database/migrations/2026_07_22_000151_create_trigger_email_templates_table.php new file mode 100644 index 0000000..948286a --- /dev/null +++ b/src/database/migrations/2026_07_22_000151_create_trigger_email_templates_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('name'); + $table->string('subject'); + $table->text('body'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('trigger_email_templates'); + } +}; diff --git a/src/resources/js/echo.js b/src/resources/js/echo.js index 3fb2a77..8598143 100644 --- a/src/resources/js/echo.js +++ b/src/resources/js/echo.js @@ -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, diff --git a/src/resources/views/components/profile-menu.blade.php b/src/resources/views/components/profile-menu.blade.php index d2a36fa..124c8f0 100644 --- a/src/resources/views/components/profile-menu.blade.php +++ b/src/resources/views/components/profile-menu.blade.php @@ -42,6 +42,18 @@
@endif + @if ($user && ($user->isOperator() || $user->isAdmin())) + Powiadomienia + +
+ @endif + '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') + + @endif + {{-- ================= STATUSES ================= --}} @if ($tab === 'statuses')

Statusy

@@ -435,6 +443,27 @@ $tabGroups = [ {{-- ================= EMAIL TEMPLATES ================= --}} @if ($tab === 'templates') +

Powiadomienia e-mail

+

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.

+
+ + + + @foreach ($this->notificationSettings as $ns) + + + + + + + @endforeach + +
ZdarzenieOdbiorcaWysyłane
{{ $ns->trigger_label }}{{ $ns->recipient === 'operator' ? 'Operator' : 'Zgłaszający' }}enabled) wire:click="toggleNotificationEnabled({{ $ns->id }})">
+
+ @endif + + {{-- ================= E-MAIL ================= --}} + @if ($tab === 'email')

Wygląd wiadomości e-mail

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.

@@ -454,23 +483,45 @@ $tabGroups = [ -

Powiadomienia e-mail

-

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.

-
- - - - @foreach ($this->notificationSettings as $ns) - - - - - - - @endforeach - -
ZdarzenieOdbiorcaWysyłane
{{ $ns->trigger_label }}{{ $ns->recipient === 'operator' ? 'Operator' : 'Zgłaszający' }}enabled) wire:click="toggleNotificationEnabled({{ $ns->id }})">
-
+

E-mail (SMTP)

+
+
+
+ +
+ + + Bez włączenia aplikacja wysyła pocztę zgodnie z konfiguracją środowiska (.env). + + @if ($mailConfig['smtpEnabled']) +
+
+
+
+ + +
+
+
+
+ +
+ + + @if ($mailTestResult === 'ok') +
check_circleWysłano na Twój adres
+ @elseif ($mailTestResult === 'error') +
errorBłąd wysyłki
+ @endif +
+ @else + + @endif +
@endif {{-- ================= BRANDING ================= --}} @@ -615,46 +666,14 @@ $tabGroups = [ -
-

E-mail (SMTP)

-
-
-

Stopka wiadomości e-mail edytowana jest w zakładce „Szablony e-mail”.

+ + @endif -
+ {{-- ================= INTEGRATIONS ================= --}} + @if ($tab === 'integrations') +

Integracje

- - Bez włączenia aplikacja wysyła pocztę zgodnie z konfiguracją środowiska (.env). - - @if ($mailConfig['smtpEnabled']) -
-
-
-
- - -
-
-
-
- -
- - - @if ($mailTestResult === 'ok') -
check_circleWysłano na Twój adres
- @elseif ($mailTestResult === 'error') -
errorBłąd wysyłki
- @endif -
- @else - - @endif -
+

LDAP / Active Directory

diff --git a/src/resources/views/livewire/admin/triggers.blade.php b/src/resources/views/livewire/admin/triggers.blade.php new file mode 100644 index 0000000..9c71cd1 --- /dev/null +++ b/src/resources/views/livewire/admin/triggers.blade.php @@ -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 +
+
+

Wyzwalacze

+ +
+ +

+ 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. +

+ + @if ($this->triggers->isNotEmpty()) +
+ + + + + + + + + + + + + + @foreach ($this->triggers as $trigger) + + + + + + + + + + @endforeach + +
NazwaZdarzenieWarunkiAkcjeAktywny
+ + + {{ $trigger->name }}{{ $eventLabels[$trigger->event] ?? $trigger->event }} + {{ count($trigger->conditions) }} {{ count($trigger->conditions) === 1 ? 'warunek' : 'warunków' }} + + {{ count($trigger->actions) }} {{ count($trigger->actions) === 1 ? 'akcja' : 'akcji' }} + enabled) wire:click="toggleEnabled({{ $trigger->id }})"> +
+ + +
+
+
+ @else +

Brak wyzwalaczy. Utwórz pierwszy używając przycisku wyżej.

+ @endif + +
+ +
+
+

Szablony e-mail wyzwalaczy

+

+ Osobne od szablonów w zakładce „Szablony e-mail” (te są przypisane na stałe do zdarzeń systemowych) — te + tutaj możesz dowolnie dodawać, edytować i usuwać, do wykorzystania w akcji „Wyślij powiadomienie e-mail” wyzwalacza. +

+
+ +
+ + @if ($this->emailTemplates->isNotEmpty()) +
+ + + + @foreach ($this->emailTemplates as $template) + + + + + + @endforeach + +
NazwaTemat
{{ $template->name }}{{ $template->subject }} +
+ + +
+
+
+ @else +

Brak szablonów. Dodaj pierwszy używając przycisku wyżej.

+ @endif + + @if ($templateFormOpen) +
+ +
{{ $editingTemplateId ? 'Edytuj szablon' : 'Nowy szablon' }}
+ +
+ + + @error('templateForm.name') {{ $message }} @enderror +
+ +
+ + + @error('templateForm.subject') {{ $message }} @enderror +
+ +
+ + + @error('templateForm.body') {{ $message }} @enderror +
+

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}.

+ +
+ + +
+ +
+ @endif + + @if ($formOpen) +
+
+
{{ $editingId ? 'Edytuj wyzwalacz' : 'Nowy wyzwalacz' }}
+ +
+ + + @error('form.name') {{ $message }} @enderror +
+ +
+ + +
+ + + +
+ +
+ + +
+ + @foreach ($form['conditions'] as $i => $condition) +
+ + + @if (! in_array($condition['operator'], ['is_empty', 'is_not_empty'])) + @if (($condition['field'] ?? null) === 'status_key') + + @elseif (($condition['field'] ?? null) === 'priority_key') + + @elseif (($condition['field'] ?? null) === 'team_id') + + @elseif (($condition['field'] ?? null) === 'assignee_id') + + @elseif (($condition['field'] ?? null) === 'subcategory_id') + + @elseif (($condition['field'] ?? null) === 'customer_id') + + @else + + @endif + @else +
+ @endif + +
+ @endforeach + @if (empty($form['conditions'])) +

Brak warunków — wyzwalacz zadziała za każdym razem, gdy wybrane zdarzenie wystąpi.

+ @endif + +
+ +
+ + +
+ @error('form.actions') {{ $message }} @enderror + + @foreach ($form['actions'] as $i => $action) +
+
+ + + + +
+ + @if (($action['type'] ?? null) === 'set_status') + + @elseif (($action['type'] ?? null) === 'set_priority') + + @elseif (($action['type'] ?? null) === 'set_team') + + @elseif (($action['type'] ?? null) === 'set_assignee') + + @elseif (($action['type'] ?? null) === 'send_notification') +
+ + +
+ @endif +
+ @endforeach + +
+ + +
+
+
+ @endif +
diff --git a/src/resources/views/livewire/operator/queue.blade.php b/src/resources/views/livewire/operator/queue.blade.php index 3d8cd05..8518dbc 100644 --- a/src/resources/views/livewire/operator/queue.blade.php +++ b/src/resources/views/livewire/operator/queue.blade.php @@ -172,6 +172,9 @@ @if (in_array('category', $visibleColumns)) {{ $t->categoryLabel() }} @endif + @if (in_array('subcategory', $visibleColumns)) + {{ $t->subcategory?->name ?? '—' }} + @endif @if (in_array('priority', $visibleColumns)) {{ $t->priorityLabel() }} @endif @@ -184,6 +187,12 @@ @if (in_array('assignee', $visibleColumns)) {{ $t->assignee?->name ?? 'Nieprzypisane' }} @endif + @if (in_array('team', $visibleColumns)) + {{ $t->team?->name ?? '—' }} + @endif + @if (in_array('created', $visibleColumns)) + {{ \App\Support\Rel::format($t->created_at) }} + @endif @endforeach diff --git a/src/resources/views/livewire/operator/ticket-show.blade.php b/src/resources/views/livewire/operator/ticket-show.blade.php index 94fa3e2..fd05134 100644 --- a/src/resources/views/livewire/operator/ticket-show.blade.php +++ b/src/resources/views/livewire/operator/ticket-show.blade.php @@ -4,7 +4,18 @@
- ← Wróć do listy + ← Wróć do listy + + {{-- Live updates arrive via broadcasting, but websocket connections can drop silently — this is a periodic fallback refresh, with a visible diff --git a/src/resources/views/livewire/settings/notification-preferences.blade.php b/src/resources/views/livewire/settings/notification-preferences.blade.php new file mode 100644 index 0000000..da4a4b0 --- /dev/null +++ b/src/resources/views/livewire/settings/notification-preferences.blade.php @@ -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 +
+ + +
+
+
+

Powiadomienia

+

Wybierz, o których zgłoszeniach chcesz być informowany dzwoneczkiem w aplikacji, i przy których zdarzeniach dodatkowo wysłać Ci e-mail.

+
+ ← Wróć +
+ +
+
+ + + + + @foreach ($scopeColumns as $label) + + @endforeach + + + + + @foreach ($categoryLabels as $category => $label) + + + @foreach ($scopeColumns as $field => $ignored) + + @endforeach + + + @endforeach + +
{{ $label }}Informuj również przez e-mail
{{ $label }} + + + +
+
+
+ +
+

Powiadomienia push w przeglądarce

+

Gdy ta karta jest otwarta, nowe zdarzenia z dzwoneczka mogą dodatkowo pojawić się jako natywne powiadomienie przeglądarki.

+ + + + + + + + +
+
+
diff --git a/src/routes/channels.php b/src/routes/channels.php index 80837aa..66f0c22 100644 --- a/src/routes/channels.php +++ b/src/routes/channels.php @@ -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; +}); diff --git a/src/routes/web.php b/src/routes/web.php index 75ee81d..3446930 100644 --- a/src/routes/web.php +++ b/src/routes/web.php @@ -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'); +}); diff --git a/src/tests/Feature/EmailLayoutTest.php b/src/tests/Feature/EmailLayoutTest.php index 3cb081e..a929403 100644 --- a/src/tests/Feature/EmailLayoutTest.php +++ b/src/tests/Feature/EmailLayoutTest.php @@ -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', '

Pozdrawiamy, Zespół Wsparcia

') ->assertOk() ->assertSet('emailFooterHtml', '

Pozdrawiamy, Zespół Wsparcia

'); @@ -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') diff --git a/src/tests/Feature/ExtendedNotificationTriggersTest.php b/src/tests/Feature/ExtendedNotificationTriggersTest.php index 7538574..271c90d 100644 --- a/src/tests/Feature/ExtendedNotificationTriggersTest.php +++ b/src/tests/Feature/ExtendedNotificationTriggersTest.php @@ -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', diff --git a/src/tests/Feature/MailSmtpConfigTest.php b/src/tests/Feature/MailSmtpConfigTest.php index 6178081..965fc5a 100644 --- a/src/tests/Feature/MailSmtpConfigTest.php +++ b/src/tests/Feature/MailSmtpConfigTest.php @@ -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) diff --git a/src/tests/Feature/NotificationDeliveryRewiringTest.php b/src/tests/Feature/NotificationDeliveryRewiringTest.php new file mode 100644 index 0000000..18c0c83 --- /dev/null +++ b/src/tests/Feature/NotificationDeliveryRewiringTest.php @@ -0,0 +1,95 @@ +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); +}); diff --git a/src/tests/Feature/NotificationPreferencesTest.php b/src/tests/Feature/NotificationPreferencesTest.php new file mode 100644 index 0000000..86d7116 --- /dev/null +++ b/src/tests/Feature/NotificationPreferencesTest.php @@ -0,0 +1,70 @@ +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(); +}); diff --git a/src/tests/Feature/OperatorQueueSearchSortColumnsTest.php b/src/tests/Feature/OperatorQueueSearchSortColumnsTest.php index 5167794..139d4fc 100644 --- a/src/tests/Feature/OperatorQueueSearchSortColumnsTest.php +++ b/src/tests/Feature/OperatorQueueSearchSortColumnsTest.php @@ -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); } diff --git a/src/tests/Feature/RealtimeBellNotificationTest.php b/src/tests/Feature/RealtimeBellNotificationTest.php new file mode 100644 index 0000000..d485fac --- /dev/null +++ b/src/tests/Feature/RealtimeBellNotificationTest.php @@ -0,0 +1,60 @@ +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); +}); diff --git a/src/tests/Feature/TabPersistenceAndNavigationTest.php b/src/tests/Feature/TabPersistenceAndNavigationTest.php new file mode 100644 index 0000000..1e43c53 --- /dev/null +++ b/src/tests/Feature/TabPersistenceAndNavigationTest.php @@ -0,0 +1,39 @@ +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')); +}); diff --git a/src/tests/Feature/TicketWatchingTest.php b/src/tests/Feature/TicketWatchingTest.php new file mode 100644 index 0000000..c337e46 --- /dev/null +++ b/src/tests/Feature/TicketWatchingTest.php @@ -0,0 +1,45 @@ +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(); +}); diff --git a/src/tests/Feature/TriggerEngineTest.php b/src/tests/Feature/TriggerEngineTest.php new file mode 100644 index 0000000..d014ac3 --- /dev/null +++ b/src/tests/Feature/TriggerEngineTest.php @@ -0,0 +1,172 @@ +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'); +}); diff --git a/src/tests/Feature/TriggersAdminTest.php b/src/tests/Feature/TriggersAdminTest.php new file mode 100644 index 0000000..b40ebe8 --- /dev/null +++ b/src/tests/Feature/TriggersAdminTest.php @@ -0,0 +1,146 @@ +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']); +}); diff --git a/wiki/admin/README.md b/wiki/admin/README.md index 73a17dd..f571ec3 100644 --- a/wiki/admin/README.md +++ b/wiki/admin/README.md @@ -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. diff --git a/wiki/operator/README.md b/wiki/operator/README.md index 25bd748..b17b163 100644 --- a/wiki/operator/README.md +++ b/wiki/operator/README.md @@ -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