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') +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.
+| Zdarzenie | Odbiorca | Wysyłane | |
|---|---|---|---|
| {{ $ns->trigger_label }} | +{{ $ns->recipient === 'operator' ? 'Operator' : 'Zgłaszający' }} | +enabled) wire:click="toggleNotificationEnabled({{ $ns->id }})"> | ++ |
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 = [ -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.
-| Zdarzenie | Odbiorca | Wysyłane | |
|---|---|---|---|
| {{ $ns->trigger_label }} | -{{ $ns->recipient === 'operator' ? 'Operator' : 'Zgłaszający' }} | -enabled) wire:click="toggleNotificationEnabled({{ $ns->id }})"> | -- |