v1.1.3
- Triggers (Admin > Wyzwalacze): event-driven rules that fire immediately on a ticket lifecycle event (created/updated/status/priority/assignee/team/ category changed, new reply), with AND-conditions and ordered actions (set status/priority/team/assignee, send e-mail). Ships its own dedicated, freely add/edit/delete-able e-mail templates, kept separate from the fixed system templates. - Ticket watching: operators can star/"Obserwuj" any ticket to follow it regardless of assignment/team. - Real-time notification bell (private per-user broadcast channel, 30s fallback poll) with an opt-in in-tab browser push notification. - Per-user notification preferences (/settings/notifications): scope (mine/unassigned/watched/all) and e-mail toggle per event category. - Admin > Integracje: new tab for LDAP/AD + BookStack config, split out of Konfiguracja. - Operator queue: Podkategoria/Zespół/Utworzono columns (off by default). - Obserwuj button moved next to the auto-refresh countdown; trigger condition builder shows subcategory/zgłaszający as name dropdowns instead of raw IDs; /settings/notifications got a back link, full-width push card, and a bordered table container; admin panel tab and operator queue view now persist across a plain page refresh. - Docs: README/ARCHITECTURE/wiki updated for all of the above. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
47
src/app/Events/NotificationCreated.php
Normal file
47
src/app/Events/NotificationCreated.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\Channel;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
|
||||
/**
|
||||
* Fired once per database (bell) notification actually created for a real
|
||||
* user — see the NotificationSent listener in AppServiceProvider::boot(),
|
||||
* which is the single choke point that dispatches this regardless of which
|
||||
* of the several TicketService call sites created the underlying
|
||||
* notification. Drives both the realtime bell badge (NotificationBell) and,
|
||||
* when the viewing browser has granted permission, an in-tab
|
||||
* `Notification` API popup.
|
||||
*/
|
||||
class NotificationCreated implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets;
|
||||
|
||||
public function __construct(
|
||||
public int $userId,
|
||||
public string $notificationId,
|
||||
public string $message,
|
||||
public string $url,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<int, Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PrivateChannel('App.Models.User.'.$this->userId)];
|
||||
}
|
||||
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'notificationId' => $this->notificationId,
|
||||
'message' => $this->message,
|
||||
'url' => $this->url,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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 ----
|
||||
|
||||
342
src/app/Livewire/Admin/Triggers.php
Normal file
342
src/app/Livewire/Admin/Triggers.php
Normal file
@@ -0,0 +1,342 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Models\Priority;
|
||||
use App\Models\Status;
|
||||
use App\Models\Subcategory;
|
||||
use App\Models\Team;
|
||||
use App\Models\Trigger;
|
||||
use App\Models\TriggerEmailTemplate;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
|
||||
class Triggers extends Component
|
||||
{
|
||||
public bool $formOpen = false;
|
||||
|
||||
public ?int $editingId = null;
|
||||
|
||||
public array $form = [
|
||||
'name' => '',
|
||||
'enabled' => true,
|
||||
'event' => 'ticket_created',
|
||||
'conditions' => [],
|
||||
'actions' => [],
|
||||
];
|
||||
|
||||
public bool $templateFormOpen = false;
|
||||
|
||||
public ?int $editingTemplateId = null;
|
||||
|
||||
public array $templateForm = ['name' => '', 'subject' => '', 'body' => ''];
|
||||
|
||||
public static function eventLabels(): array
|
||||
{
|
||||
return [
|
||||
'ticket_created' => 'Zgłoszenie utworzone',
|
||||
'ticket_updated' => 'Zgłoszenie zaktualizowane (dowolne pole)',
|
||||
'status_changed' => 'Zmiana statusu',
|
||||
'priority_changed' => 'Zmiana priorytetu',
|
||||
'assignee_changed' => 'Zmiana przypisanego operatora',
|
||||
'team_changed' => 'Zmiana zespołu',
|
||||
'category_changed' => 'Zmiana kategorii',
|
||||
'comment_added' => 'Nowa wiadomość (publiczna)',
|
||||
];
|
||||
}
|
||||
|
||||
public static function fieldLabels(): array
|
||||
{
|
||||
return [
|
||||
'status_key' => 'Status',
|
||||
'priority_key' => 'Priorytet',
|
||||
'team_id' => 'Zespół',
|
||||
'subcategory_id' => 'Podkategoria',
|
||||
'assignee_id' => 'Operator przypisany',
|
||||
'customer_id' => 'Zgłaszający',
|
||||
'subject' => 'Temat',
|
||||
'body' => 'Treść',
|
||||
];
|
||||
}
|
||||
|
||||
public static function operatorLabels(): array
|
||||
{
|
||||
return [
|
||||
'equals' => 'jest równe',
|
||||
'not_equals' => 'jest różne od',
|
||||
'is_empty' => 'jest puste',
|
||||
'is_not_empty' => 'nie jest puste',
|
||||
'contains' => 'zawiera',
|
||||
];
|
||||
}
|
||||
|
||||
public static function actionTypeLabels(): array
|
||||
{
|
||||
return [
|
||||
'set_status' => 'Ustaw status',
|
||||
'set_priority' => 'Ustaw priorytet',
|
||||
'set_team' => 'Ustaw zespół',
|
||||
'set_assignee' => 'Ustaw operatora',
|
||||
'send_notification' => 'Wyślij powiadomienie e-mail',
|
||||
];
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function triggers(): Collection
|
||||
{
|
||||
return Trigger::query()->orderBy('sort_order')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function statuses(): Collection
|
||||
{
|
||||
return Status::query()->orderBy('sort_order')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function priorities(): Collection
|
||||
{
|
||||
return Priority::query()->orderBy('sort_order')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function teams(): Collection
|
||||
{
|
||||
return Team::query()->orderBy('name')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function operators(): Collection
|
||||
{
|
||||
return User::query()->whereHas('roleAssignments', fn ($q) => $q->whereIn('key', ['operator', 'admin']))->orderBy('name')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function subcategories(): Collection
|
||||
{
|
||||
return Subcategory::query()->with('category')->get()
|
||||
->sortBy(fn (Subcategory $s) => $s->category->name.' / '.$s->name, SORT_NATURAL | SORT_FLAG_CASE)
|
||||
->values();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function customers(): Collection
|
||||
{
|
||||
return User::query()->whereHas('roleAssignments', fn ($q) => $q->where('key', 'client'))->orderBy('name')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function emailTemplates(): Collection
|
||||
{
|
||||
return TriggerEmailTemplate::query()->orderBy('name')->get();
|
||||
}
|
||||
|
||||
public function openForm(): void
|
||||
{
|
||||
$this->editingId = null;
|
||||
$this->form = ['name' => '', 'enabled' => true, 'event' => 'ticket_created', 'conditions' => [], 'actions' => []];
|
||||
$this->resetErrorBag();
|
||||
$this->formOpen = true;
|
||||
}
|
||||
|
||||
public function editTrigger(int $id): void
|
||||
{
|
||||
$trigger = Trigger::query()->findOrFail($id);
|
||||
|
||||
$this->editingId = $trigger->id;
|
||||
$this->form = [
|
||||
'name' => $trigger->name,
|
||||
'enabled' => $trigger->enabled,
|
||||
'event' => $trigger->event,
|
||||
'conditions' => $trigger->conditions,
|
||||
'actions' => $trigger->actions,
|
||||
];
|
||||
$this->resetErrorBag();
|
||||
$this->formOpen = true;
|
||||
}
|
||||
|
||||
public function closeForm(): void
|
||||
{
|
||||
$this->formOpen = false;
|
||||
}
|
||||
|
||||
public function addCondition(): void
|
||||
{
|
||||
$this->form['conditions'][] = ['field' => Trigger::CONDITION_FIELDS[0], 'operator' => 'equals', 'value' => ''];
|
||||
}
|
||||
|
||||
public function removeCondition(int $index): void
|
||||
{
|
||||
unset($this->form['conditions'][$index]);
|
||||
$this->form['conditions'] = array_values($this->form['conditions']);
|
||||
}
|
||||
|
||||
public function addAction(): void
|
||||
{
|
||||
$this->form['actions'][] = ['type' => Trigger::ACTION_TYPES[0], 'value' => '', 'recipient' => 'client', 'email_template_id' => ''];
|
||||
}
|
||||
|
||||
public function removeAction(int $index): void
|
||||
{
|
||||
unset($this->form['actions'][$index]);
|
||||
$this->form['actions'] = array_values($this->form['actions']);
|
||||
}
|
||||
|
||||
public function moveActionUp(int $index): void
|
||||
{
|
||||
$this->swapFormActions($index, $index - 1);
|
||||
}
|
||||
|
||||
public function moveActionDown(int $index): void
|
||||
{
|
||||
$this->swapFormActions($index, $index + 1);
|
||||
}
|
||||
|
||||
protected function swapFormActions(int $a, int $b): void
|
||||
{
|
||||
if ($b < 0 || $b >= count($this->form['actions'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
[$this->form['actions'][$a], $this->form['actions'][$b]] = [$this->form['actions'][$b], $this->form['actions'][$a]];
|
||||
}
|
||||
|
||||
public function submit(): void
|
||||
{
|
||||
$this->validate([
|
||||
'form.name' => ['required', 'string', 'max:255'],
|
||||
'form.event' => ['required', 'string', 'in:'.implode(',', Trigger::EVENTS)],
|
||||
'form.conditions' => ['array'],
|
||||
'form.conditions.*.field' => ['required', 'string', 'in:'.implode(',', Trigger::CONDITION_FIELDS)],
|
||||
'form.conditions.*.operator' => ['required', 'string', 'in:'.implode(',', Trigger::CONDITION_OPERATORS)],
|
||||
'form.actions' => ['required', 'array', 'min:1'],
|
||||
'form.actions.*.type' => ['required', 'string', 'in:'.implode(',', Trigger::ACTION_TYPES)],
|
||||
]);
|
||||
|
||||
$data = [
|
||||
'name' => $this->form['name'],
|
||||
'enabled' => (bool) $this->form['enabled'],
|
||||
'event' => $this->form['event'],
|
||||
'conditions' => array_values($this->form['conditions']),
|
||||
'actions' => array_values($this->form['actions']),
|
||||
];
|
||||
|
||||
if ($this->editingId) {
|
||||
Trigger::query()->findOrFail($this->editingId)->update($data);
|
||||
} else {
|
||||
$data['sort_order'] = (Trigger::query()->max('sort_order') ?? 0) + 1;
|
||||
Trigger::query()->create($data);
|
||||
}
|
||||
|
||||
$this->formOpen = false;
|
||||
unset($this->triggers);
|
||||
}
|
||||
|
||||
public function toggleEnabled(int $id): void
|
||||
{
|
||||
$trigger = Trigger::query()->findOrFail($id);
|
||||
$trigger->update(['enabled' => ! $trigger->enabled]);
|
||||
unset($this->triggers);
|
||||
}
|
||||
|
||||
public function removeTrigger(int $id): void
|
||||
{
|
||||
Trigger::query()->findOrFail($id)->delete();
|
||||
unset($this->triggers);
|
||||
}
|
||||
|
||||
public function moveUp(int $id): void
|
||||
{
|
||||
$this->swapAdjacentSortOrder($id, -1);
|
||||
}
|
||||
|
||||
public function moveDown(int $id): void
|
||||
{
|
||||
$this->swapAdjacentSortOrder($id, 1);
|
||||
}
|
||||
|
||||
protected function swapAdjacentSortOrder(int $id, int $direction): void
|
||||
{
|
||||
$ordered = $this->triggers;
|
||||
$index = $ordered->search(fn ($row) => $row->id === $id);
|
||||
$swapIndex = $index + $direction;
|
||||
|
||||
if ($index === false || $swapIndex < 0 || $swapIndex >= $ordered->count()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$row = $ordered[$index];
|
||||
$neighbor = $ordered[$swapIndex];
|
||||
|
||||
[$rowOrder, $neighborOrder] = [$row->sort_order, $neighbor->sort_order];
|
||||
$row->update(['sort_order' => $neighborOrder]);
|
||||
$neighbor->update(['sort_order' => $rowOrder]);
|
||||
|
||||
unset($this->triggers);
|
||||
}
|
||||
|
||||
// ===================== TEMPLATES (wyzwalaczy) =====================
|
||||
|
||||
public function openTemplateForm(): void
|
||||
{
|
||||
$this->editingTemplateId = null;
|
||||
$this->templateForm = ['name' => '', 'subject' => '', 'body' => ''];
|
||||
$this->resetErrorBag();
|
||||
$this->templateFormOpen = true;
|
||||
}
|
||||
|
||||
public function editTemplate(int $id): void
|
||||
{
|
||||
$template = TriggerEmailTemplate::query()->findOrFail($id);
|
||||
|
||||
$this->editingTemplateId = $template->id;
|
||||
$this->templateForm = [
|
||||
'name' => $template->name,
|
||||
'subject' => $template->subject,
|
||||
'body' => $template->body,
|
||||
];
|
||||
$this->resetErrorBag();
|
||||
$this->templateFormOpen = true;
|
||||
}
|
||||
|
||||
public function closeTemplateForm(): void
|
||||
{
|
||||
$this->templateFormOpen = false;
|
||||
}
|
||||
|
||||
public function setTemplateBodyDraft(string $value): void
|
||||
{
|
||||
$this->templateForm['body'] = $value;
|
||||
}
|
||||
|
||||
public function submitTemplate(): void
|
||||
{
|
||||
$this->validate([
|
||||
'templateForm.name' => ['required', 'string', 'max:255'],
|
||||
'templateForm.subject' => ['required', 'string', 'max:255'],
|
||||
'templateForm.body' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
if ($this->editingTemplateId) {
|
||||
TriggerEmailTemplate::query()->findOrFail($this->editingTemplateId)->update($this->templateForm);
|
||||
} else {
|
||||
TriggerEmailTemplate::query()->create($this->templateForm);
|
||||
}
|
||||
|
||||
$this->templateFormOpen = false;
|
||||
unset($this->emailTemplates);
|
||||
}
|
||||
|
||||
public function removeTemplate(int $id): void
|
||||
{
|
||||
TriggerEmailTemplate::query()->findOrFail($id)->delete();
|
||||
unset($this->emailTemplates);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin.triggers');
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,23 @@ namespace App\Livewire;
|
||||
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
class NotificationBell extends Component
|
||||
{
|
||||
/**
|
||||
* Fired by echo.js the moment a NotificationCreated broadcast arrives on
|
||||
* this user's private channel — refreshes the badge/list instantly
|
||||
* instead of waiting for the next 30s poll, which stays in place below
|
||||
* as a fallback for dropped websocket connections.
|
||||
*/
|
||||
#[On('bell-notification-received')]
|
||||
public function onBellNotification(): void
|
||||
{
|
||||
unset($this->notifications, $this->unreadCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only unread — once a notification is read (clicked through, or via
|
||||
* "mark all as read"), it disappears from the bell rather than staying
|
||||
|
||||
@@ -18,6 +18,7 @@ use Livewire\Component;
|
||||
|
||||
class Queue extends Component
|
||||
{
|
||||
#[Url]
|
||||
public string $queue = 'all';
|
||||
|
||||
public string $filterStatus = 'all';
|
||||
@@ -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
|
||||
|
||||
@@ -91,6 +91,18 @@ class TicketShow extends Component
|
||||
$this->ticket->resumeTimer();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function isWatching(): bool
|
||||
{
|
||||
return $this->ticket->isWatchedBy(Auth::user());
|
||||
}
|
||||
|
||||
public function toggleWatch(): void
|
||||
{
|
||||
app(TicketService::class)->toggleWatch($this->ticket, Auth::user());
|
||||
unset($this->isWatching);
|
||||
}
|
||||
|
||||
// -------- time tracking --------
|
||||
|
||||
public function stopTimer(): void
|
||||
|
||||
44
src/app/Livewire/Settings/NotificationPreferences.php
Normal file
44
src/app/Livewire/Settings/NotificationPreferences.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Models\NotificationPreference;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Component;
|
||||
|
||||
class NotificationPreferences extends Component
|
||||
{
|
||||
protected const SCOPE_FIELDS = ['scope_mine', 'scope_unassigned', 'scope_watched', 'scope_all', 'email'];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
abort_unless(Auth::user()->isOperator() || Auth::user()->isAdmin(), 403);
|
||||
}
|
||||
|
||||
public function rows(): array
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
return collect(NotificationPreference::CATEGORIES)
|
||||
->mapWithKeys(fn (string $category) => [$category => NotificationPreference::rowFor($user, $category)])
|
||||
->all();
|
||||
}
|
||||
|
||||
public function toggle(string $category, string $field): void
|
||||
{
|
||||
abort_unless(in_array($category, NotificationPreference::CATEGORIES, true), 404);
|
||||
abort_unless(in_array($field, self::SCOPE_FIELDS, true), 404);
|
||||
|
||||
$preference = NotificationPreference::query()->firstOrCreate(
|
||||
['user_id' => Auth::id(), 'event_category' => $category],
|
||||
array_merge(['user_id' => Auth::id(), 'event_category' => $category], NotificationPreference::DEFAULTS[$category])
|
||||
);
|
||||
|
||||
$preference->update([$field => ! $preference->$field]);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.settings.notification-preferences', ['rows' => $this->rows()]);
|
||||
}
|
||||
}
|
||||
68
src/app/Models/NotificationPreference.php
Normal file
68
src/app/Models/NotificationPreference.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable(['user_id', 'event_category', 'scope_mine', 'scope_unassigned', 'scope_watched', 'scope_all', 'email'])]
|
||||
class NotificationPreference extends Model
|
||||
{
|
||||
public const CATEGORIES = ['new_ticket', 'ticket_update', 'escalation'];
|
||||
|
||||
/**
|
||||
* Applied whenever a user has never touched a given row — including
|
||||
* every user created after this feature ships (new hires, LDAP JIT
|
||||
* provisioning). new_ticket defaults to exactly what
|
||||
* TicketService::notifyOperatorsForNewTicket() used to do
|
||||
* unconditionally (notify every relevant operator, by mail and bell),
|
||||
* so shipping this feature doesn't silently change what the existing
|
||||
* admin account already receives. ticket_update/escalation have no
|
||||
* current staff-facing equivalent, so any default there is purely
|
||||
* additive rather than a behavior change.
|
||||
*/
|
||||
public const DEFAULTS = [
|
||||
'new_ticket' => ['scope_mine' => false, 'scope_unassigned' => false, 'scope_watched' => false, 'scope_all' => true, 'email' => true],
|
||||
'ticket_update' => ['scope_mine' => true, 'scope_unassigned' => false, 'scope_watched' => true, 'scope_all' => false, 'email' => false],
|
||||
'escalation' => ['scope_mine' => true, 'scope_unassigned' => false, 'scope_watched' => true, 'scope_all' => false, 'email' => true],
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'scope_mine' => 'boolean',
|
||||
'scope_unassigned' => 'boolean',
|
||||
'scope_watched' => 'boolean',
|
||||
'scope_all' => 'boolean',
|
||||
'email' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Always returns a usable row — the persisted one if the user has ever
|
||||
* toggled this category, DEFAULTS[$category] otherwise — so callers
|
||||
* never need to null-check.
|
||||
*/
|
||||
public static function rowFor(User $user, string $category): array
|
||||
{
|
||||
$row = static::query()->where('user_id', $user->id)->where('event_category', $category)->first();
|
||||
|
||||
if (! $row) {
|
||||
return static::DEFAULTS[$category];
|
||||
}
|
||||
|
||||
return [
|
||||
'scope_mine' => $row->scope_mine,
|
||||
'scope_unassigned' => $row->scope_unassigned,
|
||||
'scope_watched' => $row->scope_watched,
|
||||
'scope_all' => $row->scope_all,
|
||||
'email' => $row->email,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
33
src/app/Models/Trigger.php
Normal file
33
src/app/Models/Trigger.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
#[Fillable(['name', 'enabled', 'event', 'conditions', 'actions', 'sort_order'])]
|
||||
class Trigger extends Model
|
||||
{
|
||||
public const EVENTS = [
|
||||
'ticket_created', 'ticket_updated', 'status_changed', 'priority_changed',
|
||||
'assignee_changed', 'team_changed', 'category_changed', 'comment_added',
|
||||
];
|
||||
|
||||
public const CONDITION_FIELDS = [
|
||||
'status_key', 'priority_key', 'team_id', 'subcategory_id', 'assignee_id', 'customer_id', 'subject', 'body',
|
||||
];
|
||||
|
||||
public const CONDITION_OPERATORS = ['equals', 'not_equals', 'is_empty', 'is_not_empty', 'contains'];
|
||||
|
||||
public const ACTION_TYPES = ['set_status', 'set_priority', 'set_team', 'set_assignee', 'send_notification'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => 'boolean',
|
||||
'conditions' => 'array',
|
||||
'actions' => 'array',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
}
|
||||
}
|
||||
26
src/app/Models/TriggerEmailTemplate.php
Normal file
26
src/app/Models/TriggerEmailTemplate.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
#[Fillable(['name', 'subject', 'body'])]
|
||||
class TriggerEmailTemplate extends Model
|
||||
{
|
||||
public function render(array $placeholders): array
|
||||
{
|
||||
$replace = function (string $text) use ($placeholders): string {
|
||||
foreach ($placeholders as $key => $value) {
|
||||
$text = str_replace('{'.$key.'}', (string) $value, $text);
|
||||
}
|
||||
|
||||
return $text;
|
||||
};
|
||||
|
||||
return [
|
||||
'subject' => $replace($this->subject),
|
||||
'body' => $replace($this->body),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -184,6 +184,11 @@ class User extends Authenticatable implements LdapAuthenticatable
|
||||
return $this->hasMany(Ticket::class, 'customer_id');
|
||||
}
|
||||
|
||||
public function watchedTickets(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Ticket::class, 'ticket_watchers');
|
||||
}
|
||||
|
||||
public function ticketsAssigned(): HasMany
|
||||
{
|
||||
return $this->hasMany(Ticket::class, 'assignee_id');
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Notifications;
|
||||
|
||||
use App\Models\EmailTemplate;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TriggerEmailTemplate;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\AnonymousNotifiable;
|
||||
@@ -20,8 +21,25 @@ class TicketNotification extends Notification
|
||||
* user can hold both roles at once, so this can't be inferred from the
|
||||
* notifiable itself; it decides which ticket URL (client vs operator
|
||||
* area) both the e-mail link and the in-app notification point to.
|
||||
*
|
||||
* $channels lets a caller with a real per-recipient preference (see
|
||||
* TicketService::notifyStaffForCategory()) send only 'database' (bell,
|
||||
* no e-mail) for a given recipient — defaults to the original
|
||||
* unconditional "both" behaviour so every existing call site is
|
||||
* unaffected.
|
||||
*
|
||||
* $templateSource picks which table $emailTemplateId is looked up in:
|
||||
* 'email_template' (the fixed, built-in templates) or
|
||||
* 'trigger_email_template' (the freely add/edit/delete-able templates
|
||||
* used by trigger "send_notification" actions — see TriggerEngine).
|
||||
*/
|
||||
public function __construct(protected Ticket $ticket, protected int $emailTemplateId, protected string $recipientRole = 'client') {}
|
||||
public function __construct(
|
||||
protected Ticket $ticket,
|
||||
protected int $emailTemplateId,
|
||||
protected string $recipientRole = 'client',
|
||||
protected array $channels = ['mail', 'database'],
|
||||
protected string $templateSource = 'email_template',
|
||||
) {}
|
||||
|
||||
/**
|
||||
* A guest customer with no account is routed anonymously (see
|
||||
@@ -30,7 +48,7 @@ class TicketNotification extends Notification
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return $notifiable instanceof AnonymousNotifiable ? ['mail'] : ['mail', 'database'];
|
||||
return $notifiable instanceof AnonymousNotifiable ? ['mail'] : $this->channels;
|
||||
}
|
||||
|
||||
protected function ticketUrl(): string
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
164
src/app/Services/TriggerEngine.php
Normal file
164
src/app/Services/TriggerEngine.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Priority;
|
||||
use App\Models\Status;
|
||||
use App\Models\Team;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\Trigger;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Event-based business rules, configured entirely by admins through the
|
||||
* Wyzwalacze tab — additive and independent from AutomationRule (which is
|
||||
* time/silence-based and evaluated by a scheduled command instead). Fires
|
||||
* synchronously on every matching ticket-lifecycle event (see the
|
||||
* TicketService call sites), same as the app's other side effects — there
|
||||
* is no queue worker in this stack to defer work to.
|
||||
*/
|
||||
class TriggerEngine
|
||||
{
|
||||
private static int $depth = 0;
|
||||
|
||||
private const MAX_DEPTH = 5;
|
||||
|
||||
public function __construct(protected TicketService $tickets) {}
|
||||
|
||||
/**
|
||||
* Guarded two ways against runaway loops: an action that would only
|
||||
* reassert the ticket's current value is a no-op before it ever gets
|
||||
* here (see the apply* methods below), which kills the common case of a
|
||||
* trigger re-matching its own result; the depth counter below is the
|
||||
* hard backstop for genuine cycles between two or more different
|
||||
* triggers.
|
||||
*/
|
||||
public function handle(Ticket $ticket, string $event): void
|
||||
{
|
||||
if (self::$depth >= self::MAX_DEPTH) {
|
||||
Log::warning('TriggerEngine: max depth reached, aborting further evaluation', [
|
||||
'ticket_id' => $ticket->id,
|
||||
'event' => $event,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
self::$depth++;
|
||||
|
||||
try {
|
||||
$triggers = Trigger::query()->where('enabled', true)->where('event', $event)->orderBy('sort_order')->get();
|
||||
|
||||
foreach ($triggers as $trigger) {
|
||||
$current = $ticket->fresh();
|
||||
|
||||
if ($current && $this->matches($trigger, $current)) {
|
||||
$this->applyActions($trigger, $current);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
self::$depth--;
|
||||
}
|
||||
}
|
||||
|
||||
protected function matches(Trigger $trigger, Ticket $ticket): bool
|
||||
{
|
||||
foreach ($trigger->conditions as $condition) {
|
||||
$field = $condition['field'] ?? null;
|
||||
|
||||
if (! in_array($field, Trigger::CONDITION_FIELDS, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->conditionMatches($condition, $ticket->{$field})) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function conditionMatches(array $condition, mixed $actual): bool
|
||||
{
|
||||
$value = $condition['value'] ?? null;
|
||||
|
||||
return match ($condition['operator'] ?? null) {
|
||||
'equals' => (string) $actual === (string) $value,
|
||||
'not_equals' => (string) $actual !== (string) $value,
|
||||
'is_empty' => $actual === null || $actual === '',
|
||||
'is_not_empty' => $actual !== null && $actual !== '',
|
||||
'contains' => is_string($actual) && $value !== null && str_contains(mb_strtolower($actual), mb_strtolower((string) $value)),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
protected function applyActions(Trigger $trigger, Ticket $ticket): void
|
||||
{
|
||||
foreach ($trigger->actions as $action) {
|
||||
match ($action['type'] ?? null) {
|
||||
'set_status' => $this->applySetStatus($ticket, $action['value'] ?? null),
|
||||
'set_priority' => $this->applySetPriority($ticket, $action['value'] ?? null),
|
||||
'set_team' => $this->applySetTeam($ticket, $action['value'] ?? null),
|
||||
'set_assignee' => $this->applySetAssignee($ticket, $action['value'] ?? null),
|
||||
'send_notification' => $this->applySendNotification($ticket, $action),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
protected function applySetStatus(Ticket $ticket, ?string $value): void
|
||||
{
|
||||
if (! $value || ! Status::query()->where('key', $value)->exists() || $ticket->status_key === $value) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->tickets->setStatus($ticket, $value);
|
||||
}
|
||||
|
||||
protected function applySetPriority(Ticket $ticket, ?string $value): void
|
||||
{
|
||||
if (! $value || ! Priority::query()->where('key', $value)->exists() || $ticket->priority_key === $value) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->tickets->setPriority($ticket, $value);
|
||||
}
|
||||
|
||||
protected function applySetTeam(Ticket $ticket, null|string|int $value): void
|
||||
{
|
||||
if ($value === null || (int) $ticket->team_id === (int) $value) {
|
||||
return;
|
||||
}
|
||||
|
||||
$team = Team::query()->find($value);
|
||||
|
||||
if ($team) {
|
||||
$this->tickets->setTeam($ticket, $team);
|
||||
}
|
||||
}
|
||||
|
||||
protected function applySetAssignee(Ticket $ticket, null|string|int $value): void
|
||||
{
|
||||
if ($value === null || (int) $ticket->assignee_id === (int) $value) {
|
||||
return;
|
||||
}
|
||||
|
||||
$assignee = User::query()->find($value);
|
||||
|
||||
if ($assignee) {
|
||||
$this->tickets->setAssignee($ticket, $assignee);
|
||||
}
|
||||
}
|
||||
|
||||
protected function applySendNotification(Ticket $ticket, array $action): void
|
||||
{
|
||||
$templateId = $action['email_template_id'] ?? null;
|
||||
|
||||
if (! $templateId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->tickets->sendCustomNotification($ticket, $action['recipient'] ?? 'client', (int) $templateId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('ticket_watchers', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('ticket_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['ticket_id', 'user_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('ticket_watchers');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* One row per (user, event_category) — only written the first time a
|
||||
* user actually toggles a checkbox on their notification-preferences
|
||||
* page. A missing row is not "notifications off"; callers must fall
|
||||
* back to NotificationPreference::DEFAULTS, never treat absence as
|
||||
* all-false (see NotificationPreference::rowFor()).
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('notification_preferences', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('event_category');
|
||||
$table->boolean('scope_mine')->default(false);
|
||||
$table->boolean('scope_unassigned')->default(false);
|
||||
$table->boolean('scope_watched')->default(false);
|
||||
$table->boolean('scope_all')->default(false);
|
||||
$table->boolean('email')->default(false);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['user_id', 'event_category']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('notification_preferences');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Event-driven business rules (see App\Services\TriggerEngine) —
|
||||
* conditions/actions are JSON so an admin can add/edit rules entirely
|
||||
* through the UI, with no migration needed per rule. Deliberately no
|
||||
* dedup/log table here (unlike automation_rule_ticket_logs): a trigger
|
||||
* is meant to re-fire on every matching event, not latch until reset.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('triggers', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->boolean('enabled')->default(true);
|
||||
$table->string('event');
|
||||
$table->json('conditions');
|
||||
$table->json('actions');
|
||||
$table->unsignedInteger('sort_order')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('triggers');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Separate from email_templates on purpose: those are fixed 1:1 to a
|
||||
* built-in notification trigger (no add/delete/reassign — see
|
||||
* Admin\Panel::editingTemplate()), while these are freely add/edit/
|
||||
* delete-able by admins for use in the "Wyślij powiadomienie e-mail"
|
||||
* trigger action (App\Services\TriggerEngine::applySendNotification).
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('trigger_email_templates', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('subject');
|
||||
$table->text('body');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('trigger_email_templates');
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -42,6 +42,18 @@
|
||||
<div style="border-top:1px solid var(--color-divider)"></div>
|
||||
@endif
|
||||
|
||||
@if ($user && ($user->isOperator() || $user->isAdmin()))
|
||||
<a
|
||||
href="{{ route('settings.notifications') }}"
|
||||
wire:navigate
|
||||
@click="open = false"
|
||||
class="theme-toggle-option"
|
||||
style="text-decoration:none;color:{{ request()->routeIs('settings.*') ? 'var(--color-accent)' : 'var(--color-text)' }};font-size:12.5px"
|
||||
>Powiadomienia</a>
|
||||
|
||||
<div style="border-top:1px solid var(--color-divider)"></div>
|
||||
@endif
|
||||
|
||||
<a
|
||||
href="{{ route('logout') }}"
|
||||
onclick="event.preventDefault(); document.getElementById('profile-menu-logout-form').submit();"
|
||||
|
||||
@@ -8,6 +8,7 @@ $tabGroups = [
|
||||
['key' => 'reply-quick-actions', 'label' => 'Szybkie akcje odpowiedzi', 'icon' => 'bolt'],
|
||||
['key' => 'response-templates', 'label' => 'Szablony odpowiedzi', 'icon' => 'chat'],
|
||||
['key' => 'automation-rules', 'label' => 'Automatyzacja SLA', 'icon' => 'bolt'],
|
||||
['key' => 'triggers', 'label' => 'Wyzwalacze', 'icon' => 'rule'],
|
||||
],
|
||||
'Zespół' => [
|
||||
['key' => 'users', 'label' => 'Użytkownicy', 'icon' => 'group'],
|
||||
@@ -16,8 +17,10 @@ $tabGroups = [
|
||||
],
|
||||
'Ustawienia' => [
|
||||
['key' => 'templates', 'label' => 'Szablony e-mail', 'icon' => 'mail'],
|
||||
['key' => 'email', 'label' => 'E-MAIL', 'icon' => 'forward_to_inbox'],
|
||||
['key' => 'branding', 'label' => 'Wygląd i branding', 'icon' => 'palette'],
|
||||
['key' => 'config', 'label' => 'Konfiguracja', 'icon' => 'settings'],
|
||||
['key' => 'integrations', 'label' => 'Integracje', 'icon' => 'hub'],
|
||||
['key' => 'api-keys', 'label' => 'Klucze API', 'icon' => 'vpn_key'],
|
||||
['key' => 'about', 'label' => 'O aplikacji', 'icon' => 'info'],
|
||||
],
|
||||
@@ -356,6 +359,11 @@ $tabGroups = [
|
||||
@endif
|
||||
@endif
|
||||
|
||||
{{-- ================= TRIGGERS ================= --}}
|
||||
@if ($tab === 'triggers')
|
||||
<livewire:admin.triggers />
|
||||
@endif
|
||||
|
||||
{{-- ================= STATUSES ================= --}}
|
||||
@if ($tab === 'statuses')
|
||||
<h3 style="margin:0 0 6px">Statusy</h3>
|
||||
@@ -435,6 +443,27 @@ $tabGroups = [
|
||||
|
||||
{{-- ================= EMAIL TEMPLATES ================= --}}
|
||||
@if ($tab === 'templates')
|
||||
<h3 style="margin:0 0 6px">Powiadomienia e-mail</h3>
|
||||
<p class="text-muted" style="font-size:12.5px;margin:0 0 14px">Każde zdarzenie ma stały, przypisany na stałe szablon — możesz go dowolnie edytować, ale nie zmienić na inny. Wyłącz przełącznik, żeby dana wiadomość nigdy nie była wysyłana.</p>
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead><tr><th>Zdarzenie</th><th>Odbiorca</th><th>Wysyłane</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
@foreach ($this->notificationSettings as $ns)
|
||||
<tr>
|
||||
<td style="white-space:nowrap">{{ $ns->trigger_label }}</td>
|
||||
<td><span class="tag tag-outline">{{ $ns->recipient === 'operator' ? 'Operator' : 'Zgłaszający' }}</span></td>
|
||||
<td><input type="checkbox" @checked($ns->enabled) wire:click="toggleNotificationEnabled({{ $ns->id }})"></td>
|
||||
<td><button type="button" class="btn btn-ghost" wire:click="editEmailTemplate({{ $ns->email_template_id }})">Edytuj szablon</button></td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- ================= E-MAIL ================= --}}
|
||||
@if ($tab === 'email')
|
||||
<h3 style="margin:0 0 6px">Wygląd wiadomości e-mail</h3>
|
||||
<p class="text-muted" style="font-size:12.5px;margin:0 0 14px">Każde powiadomienie wysyłane jest w stałym „pudełku” (nazwa firmy, ramka, treść zdarzenia) — tu edytujesz tylko jego stopkę. Po prawej — podgląd na żywo na przykładowym zgłoszeniu.</p>
|
||||
|
||||
@@ -454,23 +483,45 @@ $tabGroups = [
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 style="margin:0 0 6px">Powiadomienia e-mail</h3>
|
||||
<p class="text-muted" style="font-size:12.5px;margin:0 0 14px">Każde zdarzenie ma stały, przypisany na stałe szablon — możesz go dowolnie edytować, ale nie zmienić na inny. Wyłącz przełącznik, żeby dana wiadomość nigdy nie była wysyłana.</p>
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead><tr><th>Zdarzenie</th><th>Odbiorca</th><th>Wysyłane</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
@foreach ($this->notificationSettings as $ns)
|
||||
<tr>
|
||||
<td style="white-space:nowrap">{{ $ns->trigger_label }}</td>
|
||||
<td><span class="tag tag-outline">{{ $ns->recipient === 'operator' ? 'Operator' : 'Zgłaszający' }}</span></td>
|
||||
<td><input type="checkbox" @checked($ns->enabled) wire:click="toggleNotificationEnabled({{ $ns->id }})"></td>
|
||||
<td><button type="button" class="btn btn-ghost" wire:click="editEmailTemplate({{ $ns->email_template_id }})">Edytuj szablon</button></td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<h3 style="margin:0 0 14px">E-mail (SMTP)</h3>
|
||||
<form wire:submit="saveMailConfig" class="card" style="padding:20px;gap:14px;max-width:480px">
|
||||
<div class="field"><label>Adres nadawcy</label><input class="input" type="email" placeholder="wsparcie@firma.pl" wire:model="mailConfig.fromAddress"></div>
|
||||
<div class="field"><label>Nazwa nadawcy</label><input class="input" placeholder="Zespół Wsparcia" wire:model="mailConfig.fromName"></div>
|
||||
|
||||
<div class="hr"></div>
|
||||
|
||||
<label class="radio"><input type="checkbox" wire:model="mailConfig.smtpEnabled" style="position:static;opacity:1;width:auto;height:auto"><strong>Włącz wysyłkę przez własny serwer SMTP</strong></label>
|
||||
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Bez włączenia aplikacja wysyła pocztę zgodnie z konfiguracją środowiska (.env).</span>
|
||||
|
||||
@if ($mailConfig['smtpEnabled'])
|
||||
<div class="field"><label>Host SMTP</label><input class="input" placeholder="smtp.example.com" wire:model="mailConfig.smtpHost"></div>
|
||||
<div style="display:flex;gap:10px">
|
||||
<div class="field" style="flex:1"><label>Port</label><input class="input" type="number" placeholder="587" wire:model="mailConfig.smtpPort"></div>
|
||||
<div class="field" style="flex:1">
|
||||
<label>Szyfrowanie</label>
|
||||
<select class="input" wire:model="mailConfig.smtpEncryption">
|
||||
<option value="none">Brak</option>
|
||||
<option value="tls">STARTTLS</option>
|
||||
<option value="ssl">SSL/TLS</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field"><label>Użytkownik</label><input class="input" wire:model="mailConfig.smtpUsername"></div>
|
||||
<div class="field"><label>Hasło</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="mailConfig.smtpPassword"></div>
|
||||
|
||||
<div style="display:flex;gap:10px;margin-top:8px;align-items:center;flex-wrap:wrap">
|
||||
<button type="button" class="btn btn-secondary" wire:click="testMailConnection">Wyślij testową wiadomość</button>
|
||||
<button type="submit" class="btn btn-primary">Zapisz</button>
|
||||
@if ($mailTestResult === 'ok')
|
||||
<div style="display:flex;align-items:center;gap:6px;color:var(--color-success)"><span class="material-symbols-outlined" style="font-size:18px">check_circle</span>Wysłano na Twój adres</div>
|
||||
@elseif ($mailTestResult === 'error')
|
||||
<div style="display:flex;align-items:center;gap:6px;color:var(--color-danger)"><span class="material-symbols-outlined" style="font-size:18px">error</span>Błąd wysyłki</div>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<button type="submit" class="btn btn-primary" style="align-self:flex-start">Zapisz</button>
|
||||
@endif
|
||||
</form>
|
||||
@endif
|
||||
|
||||
{{-- ================= BRANDING ================= --}}
|
||||
@@ -615,46 +666,14 @@ $tabGroups = [
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form wire:submit="saveMailConfig" class="card" style="padding:20px;gap:14px">
|
||||
<h4 style="margin:0">E-mail (SMTP)</h4>
|
||||
<div class="field"><label>Adres nadawcy</label><input class="input" type="email" placeholder="wsparcie@firma.pl" wire:model="mailConfig.fromAddress"></div>
|
||||
<div class="field"><label>Nazwa nadawcy</label><input class="input" placeholder="Zespół Wsparcia" wire:model="mailConfig.fromName"></div>
|
||||
<p class="text-muted" style="font-size:11.5px;margin:-4px 0 0">Stopka wiadomości e-mail edytowana jest w zakładce „Szablony e-mail”.</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="hr"></div>
|
||||
{{-- ================= INTEGRATIONS ================= --}}
|
||||
@if ($tab === 'integrations')
|
||||
<h3 style="margin:0 0 16px">Integracje</h3>
|
||||
|
||||
<label class="radio"><input type="checkbox" wire:model="mailConfig.smtpEnabled" style="position:static;opacity:1;width:auto;height:auto"><strong>Włącz wysyłkę przez własny serwer SMTP</strong></label>
|
||||
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Bez włączenia aplikacja wysyła pocztę zgodnie z konfiguracją środowiska (.env).</span>
|
||||
|
||||
@if ($mailConfig['smtpEnabled'])
|
||||
<div class="field"><label>Host SMTP</label><input class="input" placeholder="smtp.example.com" wire:model="mailConfig.smtpHost"></div>
|
||||
<div style="display:flex;gap:10px">
|
||||
<div class="field" style="flex:1"><label>Port</label><input class="input" type="number" placeholder="587" wire:model="mailConfig.smtpPort"></div>
|
||||
<div class="field" style="flex:1">
|
||||
<label>Szyfrowanie</label>
|
||||
<select class="input" wire:model="mailConfig.smtpEncryption">
|
||||
<option value="none">Brak</option>
|
||||
<option value="tls">STARTTLS</option>
|
||||
<option value="ssl">SSL/TLS</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field"><label>Użytkownik</label><input class="input" wire:model="mailConfig.smtpUsername"></div>
|
||||
<div class="field"><label>Hasło</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="mailConfig.smtpPassword"></div>
|
||||
|
||||
<div style="display:flex;gap:10px;margin-top:8px;align-items:center;flex-wrap:wrap">
|
||||
<button type="button" class="btn btn-secondary" wire:click="testMailConnection">Wyślij testową wiadomość</button>
|
||||
<button type="submit" class="btn btn-primary">Zapisz</button>
|
||||
@if ($mailTestResult === 'ok')
|
||||
<div style="display:flex;align-items:center;gap:6px;color:var(--color-success)"><span class="material-symbols-outlined" style="font-size:18px">check_circle</span>Wysłano na Twój adres</div>
|
||||
@elseif ($mailTestResult === 'error')
|
||||
<div style="display:flex;align-items:center;gap:6px;color:var(--color-danger)"><span class="material-symbols-outlined" style="font-size:18px">error</span>Błąd wysyłki</div>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<button type="submit" class="btn btn-primary" style="align-self:flex-start">Zapisz</button>
|
||||
@endif
|
||||
</form>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:20px;align-items:start">
|
||||
|
||||
<form wire:submit="saveLdapConfig" class="card" style="padding:20px;gap:14px">
|
||||
<h4 style="margin:0">LDAP / Active Directory</h4>
|
||||
|
||||
303
src/resources/views/livewire/admin/triggers.blade.php
Normal file
303
src/resources/views/livewire/admin/triggers.blade.php
Normal file
@@ -0,0 +1,303 @@
|
||||
@php
|
||||
$eventLabels = \App\Livewire\Admin\Triggers::eventLabels();
|
||||
$fieldLabels = \App\Livewire\Admin\Triggers::fieldLabels();
|
||||
$operatorLabels = \App\Livewire\Admin\Triggers::operatorLabels();
|
||||
$actionTypeLabels = \App\Livewire\Admin\Triggers::actionTypeLabels();
|
||||
@endphp
|
||||
<div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
|
||||
<h3 style="margin:0">Wyzwalacze</h3>
|
||||
<button class="btn btn-primary" type="button" wire:click="openForm">+ Nowy wyzwalacz</button>
|
||||
</div>
|
||||
|
||||
<p class="text-muted" style="font-size:12.5px;margin:0 0 14px">
|
||||
Wyzwalacze reagują natychmiast na zdarzenie w zgłoszeniu (utworzenie, zmiana pola, nowy komentarz) — w odróżnieniu od Automatyzacji SLA (zakładka obok), która działa na podstawie czasu milczenia klienta. Warunki wyzwalacza muszą być spełnione wszystkie naraz (ORAZ); akcje wykonują się w podanej kolejności.
|
||||
</p>
|
||||
|
||||
@if ($this->triggers->isNotEmpty())
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Nazwa</th>
|
||||
<th>Zdarzenie</th>
|
||||
<th>Warunki</th>
|
||||
<th>Akcje</th>
|
||||
<th>Aktywny</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($this->triggers as $trigger)
|
||||
<tr wire:key="trigger-{{ $trigger->id }}">
|
||||
<td style="white-space:nowrap">
|
||||
<button type="button" class="btn btn-ghost" style="padding:0 4px" wire:click="moveUp({{ $trigger->id }})" title="Przenieś wyżej">
|
||||
<span class="material-symbols-outlined" style="font-size:16px">arrow_upward</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost" style="padding:0 4px" wire:click="moveDown({{ $trigger->id }})" title="Przenieś niżej">
|
||||
<span class="material-symbols-outlined" style="font-size:16px">arrow_downward</span>
|
||||
</button>
|
||||
</td>
|
||||
<td style="white-space:nowrap">{{ $trigger->name }}</td>
|
||||
<td><span class="tag tag-outline">{{ $eventLabels[$trigger->event] ?? $trigger->event }}</span></td>
|
||||
<td class="text-muted" style="font-size:12px">
|
||||
{{ count($trigger->conditions) }} {{ count($trigger->conditions) === 1 ? 'warunek' : 'warunków' }}
|
||||
</td>
|
||||
<td class="text-muted" style="font-size:12px">
|
||||
{{ count($trigger->actions) }} {{ count($trigger->actions) === 1 ? 'akcja' : 'akcji' }}
|
||||
</td>
|
||||
<td><input type="checkbox" @checked($trigger->enabled) wire:click="toggleEnabled({{ $trigger->id }})"></td>
|
||||
<td>
|
||||
<div style="display:flex;gap:6px;justify-content:flex-end">
|
||||
<button class="btn btn-ghost" type="button" wire:click="editTrigger({{ $trigger->id }})">Edytuj</button>
|
||||
<button class="btn btn-ghost" type="button" wire:click="removeTrigger({{ $trigger->id }})" wire:confirm="Usunąć wyzwalacz „{{ $trigger->name }}”?">Usuń</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@else
|
||||
<p class="text-muted" style="font-size:13px">Brak wyzwalaczy. Utwórz pierwszy używając przycisku wyżej.</p>
|
||||
@endif
|
||||
|
||||
<div class="hr" style="margin:22px 0"></div>
|
||||
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
|
||||
<div>
|
||||
<h3 style="margin:0">Szablony e-mail wyzwalaczy</h3>
|
||||
<p class="text-muted" style="font-size:12.5px;margin:4px 0 0">
|
||||
Osobne od szablonów w zakładce „Szablony e-mail” (te są przypisane na stałe do zdarzeń systemowych) — te
|
||||
tutaj możesz dowolnie dodawać, edytować i usuwać, do wykorzystania w akcji „Wyślij powiadomienie e-mail” wyzwalacza.
|
||||
</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="button" wire:click="openTemplateForm" style="flex:none">+ Nowy szablon</button>
|
||||
</div>
|
||||
|
||||
@if ($this->emailTemplates->isNotEmpty())
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead><tr><th>Nazwa</th><th>Temat</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
@foreach ($this->emailTemplates as $template)
|
||||
<tr wire:key="trigger-template-{{ $template->id }}">
|
||||
<td style="white-space:nowrap">{{ $template->name }}</td>
|
||||
<td>{{ $template->subject }}</td>
|
||||
<td>
|
||||
<div style="display:flex;gap:6px;justify-content:flex-end">
|
||||
<button class="btn btn-ghost" type="button" wire:click="editTemplate({{ $template->id }})">Edytuj</button>
|
||||
<button class="btn btn-ghost" type="button" wire:click="removeTemplate({{ $template->id }})" wire:confirm="Usunąć szablon „{{ $template->name }}”?">Usuń</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@else
|
||||
<p class="text-muted" style="font-size:13px">Brak szablonów. Dodaj pierwszy używając przycisku wyżej.</p>
|
||||
@endif
|
||||
|
||||
@if ($templateFormOpen)
|
||||
<div class="dialog-backdrop">
|
||||
<form wire:submit="submitTemplate" class="dialog" style="max-width:560px;max-height:88vh;overflow:auto">
|
||||
<div class="dialog-title">{{ $editingTemplateId ? 'Edytuj szablon' : 'Nowy szablon' }}</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Nazwa szablonu</label>
|
||||
<input class="input" wire:model="templateForm.name" placeholder="np. Przypomnienie o braku odpowiedzi">
|
||||
@error('templateForm.name') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Temat wiadomości</label>
|
||||
<input class="input" wire:model="templateForm.subject">
|
||||
@error('templateForm.subject') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Treść</label>
|
||||
<x-quill-editor wire:key="trigger-template-body-{{ $editingTemplateId ?? 'new' }}" :value="$templateForm['body']" on-change="setTemplateBodyDraft" min-height="160px" />
|
||||
@error('templateForm.body') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
<p class="text-muted" style="font-size:12px;margin:0">Dostępne zmienne: {numer}, {imie}, {temat}, {status}, {kategoria}, {priorytet}, {zespol}, {operator}, {link}. Ta treść trafia do wspólnego szablonu-pudełka (zakładka „E-MAIL”) w miejscu {tresc}.</p>
|
||||
|
||||
<div class="dialog-actions">
|
||||
<button type="button" class="btn btn-secondary" wire:click="closeTemplateForm">Anuluj</button>
|
||||
<button type="submit" class="btn btn-primary">{{ $editingTemplateId ? 'Zapisz' : 'Utwórz' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($formOpen)
|
||||
<div class="dialog-backdrop">
|
||||
<form wire:submit="submit" class="dialog" style="max-width:640px;max-height:88vh;overflow:auto">
|
||||
<div class="dialog-title">{{ $editingId ? 'Edytuj wyzwalacz' : 'Nowy wyzwalacz' }}</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Nazwa</label>
|
||||
<input class="input" wire:model="form.name" placeholder="np. Priorytet krytyczny → zespół VIP">
|
||||
@error('form.name') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Zdarzenie</label>
|
||||
<select class="input" wire:model="form.event">
|
||||
@foreach ($eventLabels as $key => $label)
|
||||
<option value="{{ $key }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label class="radio"><input type="checkbox" wire:model="form.enabled" style="position:static;opacity:1;width:auto;height:auto">Aktywny</label>
|
||||
|
||||
<div class="hr"></div>
|
||||
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<label style="font-weight:500">Warunki (wszystkie muszą być spełnione)</label>
|
||||
<button type="button" class="btn btn-ghost" wire:click="addCondition">+ Dodaj warunek</button>
|
||||
</div>
|
||||
|
||||
@foreach ($form['conditions'] as $i => $condition)
|
||||
<div wire:key="condition-{{ $i }}" style="display:flex;gap:6px;align-items:flex-start">
|
||||
<select class="input" style="flex:1" wire:model.live="form.conditions.{{ $i }}.field">
|
||||
@foreach ($fieldLabels as $key => $label)
|
||||
<option value="{{ $key }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<select class="input" style="flex:1" wire:model.live="form.conditions.{{ $i }}.operator">
|
||||
@foreach ($operatorLabels as $key => $label)
|
||||
<option value="{{ $key }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@if (! in_array($condition['operator'], ['is_empty', 'is_not_empty']))
|
||||
@if (($condition['field'] ?? null) === 'status_key')
|
||||
<select class="input" style="flex:1" wire:model="form.conditions.{{ $i }}.value">
|
||||
@foreach ($this->statuses as $status)
|
||||
<option value="{{ $status->key }}">{{ $status->label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@elseif (($condition['field'] ?? null) === 'priority_key')
|
||||
<select class="input" style="flex:1" wire:model="form.conditions.{{ $i }}.value">
|
||||
@foreach ($this->priorities as $priority)
|
||||
<option value="{{ $priority->key }}">{{ $priority->label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@elseif (($condition['field'] ?? null) === 'team_id')
|
||||
<select class="input" style="flex:1" wire:model="form.conditions.{{ $i }}.value">
|
||||
@foreach ($this->teams as $team)
|
||||
<option value="{{ $team->id }}">{{ $team->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@elseif (($condition['field'] ?? null) === 'assignee_id')
|
||||
<select class="input" style="flex:1" wire:model="form.conditions.{{ $i }}.value">
|
||||
@foreach ($this->operators as $operator)
|
||||
<option value="{{ $operator->id }}">{{ $operator->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@elseif (($condition['field'] ?? null) === 'subcategory_id')
|
||||
<select class="input" style="flex:1" wire:model="form.conditions.{{ $i }}.value">
|
||||
@foreach ($this->subcategories as $sub)
|
||||
<option value="{{ $sub->id }}">{{ $sub->category->name }} / {{ $sub->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@elseif (($condition['field'] ?? null) === 'customer_id')
|
||||
<select class="input" style="flex:1" wire:model="form.conditions.{{ $i }}.value">
|
||||
@foreach ($this->customers as $customer)
|
||||
<option value="{{ $customer->id }}">{{ $customer->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@else
|
||||
<input class="input" style="flex:1" wire:model="form.conditions.{{ $i }}.value" placeholder="wartość">
|
||||
@endif
|
||||
@else
|
||||
<div style="flex:1"></div>
|
||||
@endif
|
||||
<button type="button" class="btn btn-ghost" style="padding:0 6px" wire:click="removeCondition({{ $i }})" title="Usuń warunek">
|
||||
<span class="material-symbols-outlined" style="font-size:16px">close</span>
|
||||
</button>
|
||||
</div>
|
||||
@endforeach
|
||||
@if (empty($form['conditions']))
|
||||
<p class="text-muted" style="font-size:12px;margin:0">Brak warunków — wyzwalacz zadziała za każdym razem, gdy wybrane zdarzenie wystąpi.</p>
|
||||
@endif
|
||||
|
||||
<div class="hr"></div>
|
||||
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<label style="font-weight:500">Akcje (wykonywane po kolei)</label>
|
||||
<button type="button" class="btn btn-ghost" wire:click="addAction">+ Dodaj akcję</button>
|
||||
</div>
|
||||
@error('form.actions') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
|
||||
|
||||
@foreach ($form['actions'] as $i => $action)
|
||||
<div wire:key="action-{{ $i }}" class="card" style="padding:10px;gap:6px">
|
||||
<div style="display:flex;gap:6px;align-items:center">
|
||||
<select class="input" style="flex:1" wire:model.live="form.actions.{{ $i }}.type">
|
||||
@foreach ($actionTypeLabels as $key => $label)
|
||||
<option value="{{ $key }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<button type="button" class="btn btn-ghost" style="padding:0 4px" wire:click="moveActionUp({{ $i }})" title="Przenieś wyżej">
|
||||
<span class="material-symbols-outlined" style="font-size:16px">arrow_upward</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost" style="padding:0 4px" wire:click="moveActionDown({{ $i }})" title="Przenieś niżej">
|
||||
<span class="material-symbols-outlined" style="font-size:16px">arrow_downward</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost" style="padding:0 6px" wire:click="removeAction({{ $i }})" title="Usuń akcję">
|
||||
<span class="material-symbols-outlined" style="font-size:16px">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (($action['type'] ?? null) === 'set_status')
|
||||
<select class="input" wire:model="form.actions.{{ $i }}.value">
|
||||
@foreach ($this->statuses as $status)
|
||||
<option value="{{ $status->key }}">{{ $status->label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@elseif (($action['type'] ?? null) === 'set_priority')
|
||||
<select class="input" wire:model="form.actions.{{ $i }}.value">
|
||||
@foreach ($this->priorities as $priority)
|
||||
<option value="{{ $priority->key }}">{{ $priority->label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@elseif (($action['type'] ?? null) === 'set_team')
|
||||
<select class="input" wire:model="form.actions.{{ $i }}.value">
|
||||
@foreach ($this->teams as $team)
|
||||
<option value="{{ $team->id }}">{{ $team->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@elseif (($action['type'] ?? null) === 'set_assignee')
|
||||
<select class="input" wire:model="form.actions.{{ $i }}.value">
|
||||
@foreach ($this->operators as $operator)
|
||||
<option value="{{ $operator->id }}">{{ $operator->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@elseif (($action['type'] ?? null) === 'send_notification')
|
||||
<div style="display:flex;gap:6px">
|
||||
<select class="input" style="flex:1" wire:model="form.actions.{{ $i }}.recipient">
|
||||
<option value="client">Zgłaszający</option>
|
||||
<option value="operator">Przypisany operator</option>
|
||||
</select>
|
||||
<select class="input" style="flex:1" wire:model="form.actions.{{ $i }}.email_template_id">
|
||||
<option value="">— wybierz szablon —</option>
|
||||
@foreach ($this->emailTemplates as $template)
|
||||
<option value="{{ $template->id }}">{{ $template->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
<div class="dialog-actions">
|
||||
<button type="button" class="btn btn-secondary" wire:click="closeForm">Anuluj</button>
|
||||
<button type="submit" class="btn btn-primary">{{ $editingId ? 'Zapisz' : 'Utwórz' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -172,6 +172,9 @@
|
||||
@if (in_array('category', $visibleColumns))
|
||||
<td data-label="Kategoria" style="white-space:nowrap">{{ $t->categoryLabel() }}</td>
|
||||
@endif
|
||||
@if (in_array('subcategory', $visibleColumns))
|
||||
<td data-label="Podkategoria" style="white-space:nowrap">{{ $t->subcategory?->name ?? '—' }}</td>
|
||||
@endif
|
||||
@if (in_array('priority', $visibleColumns))
|
||||
<td data-label="Priorytet"><span style="{{ $t->priorityStyle() }}">{{ $t->priorityLabel() }}</span></td>
|
||||
@endif
|
||||
@@ -184,6 +187,12 @@
|
||||
@if (in_array('assignee', $visibleColumns))
|
||||
<td data-label="Przypisany" style="white-space:nowrap">{{ $t->assignee?->name ?? 'Nieprzypisane' }}</td>
|
||||
@endif
|
||||
@if (in_array('team', $visibleColumns))
|
||||
<td data-label="Zespół" style="white-space:nowrap">{{ $t->team?->name ?? '—' }}</td>
|
||||
@endif
|
||||
@if (in_array('created', $visibleColumns))
|
||||
<td data-label="Utworzono" style="white-space:nowrap">{{ \App\Support\Rel::format($t->created_at) }}</td>
|
||||
@endif
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
|
||||
@@ -4,7 +4,18 @@
|
||||
<div class="page-pad" style="flex:1;padding:20px 24px;overflow:auto">
|
||||
<div style="display:flex;flex-direction:column;gap:16px;max-width:1180px;margin:0 auto">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
|
||||
<a href="{{ route('operator.queue') }}" wire:navigate class="btn btn-ghost" style="padding:0">← Wróć do listy</a>
|
||||
<a href="{{ route('operator.queue') }}" wire:navigate class="btn btn-ghost" style="padding:0;margin-right:auto">← Wróć do listy</a>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
style="gap:6px"
|
||||
wire:click="toggleWatch"
|
||||
title="{{ $this->isWatching ? 'Przestań obserwować to zgłoszenie' : 'Obserwuj to zgłoszenie' }}"
|
||||
>
|
||||
<span class="material-symbols-outlined" style="font-size:18px">{{ $this->isWatching ? 'star' : 'star_outline' }}</span>
|
||||
{{ $this->isWatching ? 'Obserwowane' : 'Obserwuj' }}
|
||||
</button>
|
||||
|
||||
{{-- Live updates arrive via broadcasting, but websocket connections can
|
||||
drop silently — this is a periodic fallback refresh, with a visible
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
@php
|
||||
$categoryLabels = [
|
||||
'new_ticket' => 'Nowe zgłoszenie',
|
||||
'ticket_update' => 'Aktualizacja zgłoszenia',
|
||||
'escalation' => 'Zgłoszenie eskalowane',
|
||||
];
|
||||
$scopeColumns = [
|
||||
'scope_mine' => 'Moje zgłoszenia',
|
||||
'scope_unassigned' => 'Nie przypisany',
|
||||
'scope_watched' => 'Obserwowane zgłoszenia',
|
||||
'scope_all' => 'Wszystkie zgłoszenia',
|
||||
];
|
||||
@endphp
|
||||
<div style="flex:1;display:flex;flex-direction:column">
|
||||
<x-topbar />
|
||||
|
||||
<div class="page-pad" style="flex:1;padding:28px;display:flex;flex-direction:column;gap:24px;max-width:920px;width:100%;margin:0 auto;box-sizing:border-box">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap">
|
||||
<div>
|
||||
<h3 style="margin:0 0 6px">Powiadomienia</h3>
|
||||
<p class="text-muted" style="font-size:12.5px;margin:0">Wybierz, o których zgłoszeniach chcesz być informowany dzwoneczkiem w aplikacji, i przy których zdarzeniach dodatkowo wysłać Ci e-mail.</p>
|
||||
</div>
|
||||
<a href="{{ auth()->user()->isAdmin() ? route('admin.panel') : route('operator.queue') }}" wire:navigate class="btn btn-ghost" style="padding:0;flex:none">← Wróć</a>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding:0;overflow:hidden">
|
||||
<div class="table-wrap">
|
||||
<table class="table" style="margin:0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
@foreach ($scopeColumns as $label)
|
||||
<th style="text-align:center">{{ $label }}</th>
|
||||
@endforeach
|
||||
<th style="text-align:center">Informuj również przez e-mail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($categoryLabels as $category => $label)
|
||||
<tr wire:key="pref-row-{{ $category }}">
|
||||
<td style="white-space:nowrap">{{ $label }}</td>
|
||||
@foreach ($scopeColumns as $field => $ignored)
|
||||
<td style="text-align:center">
|
||||
<input type="checkbox" @checked($rows[$category][$field]) wire:click="toggle('{{ $category }}', '{{ $field }}')">
|
||||
</td>
|
||||
@endforeach
|
||||
<td style="text-align:center">
|
||||
<input type="checkbox" @checked($rows[$category]['email']) wire:click="toggle('{{ $category }}', 'email')">
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding:18px;gap:10px" x-data="{
|
||||
supported: typeof Notification !== 'undefined',
|
||||
permission: typeof Notification !== 'undefined' ? Notification.permission : 'unsupported',
|
||||
enabled: localStorage.getItem('browserNotificationsEnabled') === '1',
|
||||
async enable() {
|
||||
if (!this.supported) return;
|
||||
this.permission = await Notification.requestPermission();
|
||||
this.enabled = this.permission === 'granted';
|
||||
localStorage.setItem('browserNotificationsEnabled', this.enabled ? '1' : '0');
|
||||
},
|
||||
disable() {
|
||||
this.enabled = false;
|
||||
localStorage.setItem('browserNotificationsEnabled', '0');
|
||||
},
|
||||
}">
|
||||
<h4 style="margin:0">Powiadomienia push w przeglądarce</h4>
|
||||
<p class="text-muted" style="font-size:12px;margin:0">Gdy ta karta jest otwarta, nowe zdarzenia z dzwoneczka mogą dodatkowo pojawić się jako natywne powiadomienie przeglądarki.</p>
|
||||
|
||||
<template x-if="!supported">
|
||||
<span class="text-muted" style="font-size:12px">Ta przeglądarka nie obsługuje powiadomień push.</span>
|
||||
</template>
|
||||
|
||||
<template x-if="supported && permission === 'denied'">
|
||||
<span style="font-size:12px;color:var(--color-danger)">Powiadomienia zostały zablokowane w ustawieniach przeglądarki.</span>
|
||||
</template>
|
||||
|
||||
<template x-if="supported && permission !== 'denied' && !enabled">
|
||||
<button type="button" class="btn btn-secondary" style="align-self:flex-start" x-on:click="enable">Włącz powiadomienia push</button>
|
||||
</template>
|
||||
|
||||
<template x-if="supported && enabled">
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<div style="display:flex;align-items:center;gap:6px;color:var(--color-success)">
|
||||
<span class="material-symbols-outlined" style="font-size:18px">check_circle</span>Włączone
|
||||
</div>
|
||||
<button type="button" class="btn btn-ghost" x-on:click="disable">Wyłącz</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -34,3 +34,14 @@ Broadcast::channel('ticket.{ticketId}', function ($user, int $ticketId) {
|
||||
return (in_array('operator', $user->roles ?? []) && $ticket->isVisibleToOperator($user))
|
||||
|| $ticket->customer_id === $user->id;
|
||||
});
|
||||
|
||||
/**
|
||||
* Every logged-in user's own private notification stream (bell realtime
|
||||
* updates + in-tab browser push, see NotificationCreated). Laravel's
|
||||
* default `App.Models.User.{id}` naming convention is kept verbatim so it
|
||||
* matches what `$notifiable->notify()` already implies, rather than
|
||||
* inventing a shorter alias.
|
||||
*/
|
||||
Broadcast::channel('App.Models.User.{id}', function ($user, int $id) {
|
||||
return $user->id === $id;
|
||||
});
|
||||
|
||||
@@ -10,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');
|
||||
});
|
||||
|
||||
@@ -37,7 +37,7 @@ test('admin can reset the email footer back to its default, remounting the edito
|
||||
$admin = adminUser();
|
||||
|
||||
$component = Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'templates')
|
||||
->call('setTab', 'email')
|
||||
->call('saveEmailFooter', 'Coś innego')
|
||||
->assertSet('emailFooterVersion', 0);
|
||||
|
||||
@@ -50,11 +50,11 @@ test('admin can reset the email footer back to its default, remounting the edito
|
||||
expect(Settings::get('email_footer'))->toBe(Settings::default('email_footer'));
|
||||
});
|
||||
|
||||
test('admin can save the email footer from the Szablony e-mail tab (moved out of Konfiguracja)', function () {
|
||||
test('admin can save the email footer from the E-MAIL tab', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'templates')
|
||||
->call('setTab', 'email')
|
||||
->call('saveEmailFooter', '<p>Pozdrawiamy, Zespół Wsparcia</p>')
|
||||
->assertOk()
|
||||
->assertSet('emailFooterHtml', '<p>Pozdrawiamy, Zespół Wsparcia</p>');
|
||||
@@ -66,7 +66,7 @@ test('the live example preview reflects the currently saved footer', function ()
|
||||
$admin = adminUser();
|
||||
|
||||
$component = Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'templates')
|
||||
->call('setTab', 'email')
|
||||
->call('saveEmailFooter', 'Stopka na żywo');
|
||||
|
||||
expect($component->instance()->emailPreviewHtml)->toContain('Stopka na żywo')
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\EmailTemplate;
|
||||
use App\Models\NotificationPreference;
|
||||
use App\Models\NotificationSetting;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
@@ -140,7 +141,7 @@ test('an operator reply fires operator_replied once enabled, independent of any
|
||||
Notification::assertSentOnDemandTimes(TicketNotification::class, 1);
|
||||
});
|
||||
|
||||
test('every member of a team whose subcategory matches a new ticket gets notified once, no duplicates', function () {
|
||||
test('every operator/admin whose new_ticket preference puts a routed ticket in scope gets notified once, no duplicates', function () {
|
||||
Notification::fake();
|
||||
$this->seed();
|
||||
|
||||
@@ -152,6 +153,10 @@ test('every member of a team whose subcategory matches a new ticket gets notifie
|
||||
$memberB = User::query()->create(['name' => 'Jan', 'email' => 'team-notif-b@example.com', 'roles' => ['operator']]);
|
||||
$team->members()->attach([$memberA->id, $memberB->id]);
|
||||
|
||||
// auto_assign_by_category is on by default (seeded), so the ticket's
|
||||
// team_id actually becomes the VPN team's id — that's what now drives
|
||||
// who's "in scope" for the default scope_all preference, replacing the
|
||||
// old separate team-subcategory-routing fan-out.
|
||||
app(TicketService::class)->create([
|
||||
'email' => 'client-team-notif@example.com',
|
||||
'subject' => 'Problem z VPN',
|
||||
@@ -161,19 +166,53 @@ test('every member of a team whose subcategory matches a new ticket gets notifie
|
||||
|
||||
Notification::assertSentTo($memberA, TicketNotification::class);
|
||||
Notification::assertSentTo($memberB, TicketNotification::class);
|
||||
// The seeded admin also qualifies: Ticket::isVisibleToOperator() returns
|
||||
// true unconditionally for admins, and scope_all is the default — this
|
||||
// is intentional, it's what keeps the one real admin account notified
|
||||
// about every new ticket without any setup.
|
||||
Notification::assertSentTo(User::query()->where('email', 'admin@example.com')->firstOrFail(), TicketNotification::class);
|
||||
// Plus one more: TicketService::create() also fires the pre-existing
|
||||
// 'ticket_created' trigger, routed anonymously to the guest's e-mail
|
||||
// since this ticket has no real customer account.
|
||||
Notification::assertSentTimes(TicketNotification::class, 3);
|
||||
Notification::assertSentTimes(TicketNotification::class, 4);
|
||||
});
|
||||
|
||||
test('a new ticket with no matching team notifies no operator', function () {
|
||||
test('an operator outside the ticket\'s team is not notified, even with scope_all left at its default', function () {
|
||||
Notification::fake();
|
||||
$this->seed();
|
||||
|
||||
$category = Category::query()->create(['name' => 'IT-Pomoc']);
|
||||
$sub = $category->subcategories()->create(['name' => 'VPN']);
|
||||
$team = Team::query()->create(['name' => 'Zespół VPN']);
|
||||
$team->subcategories()->attach($sub->id);
|
||||
User::query()->create(['name' => 'Ola', 'email' => 'team-notif-a@example.com', 'roles' => ['operator']])
|
||||
->teams()->attach($team->id);
|
||||
$outsider = User::query()->create(['name' => 'Niepowiązany', 'email' => 'unrelated-op@example.com', 'roles' => ['operator']]);
|
||||
|
||||
app(TicketService::class)->create([
|
||||
'email' => 'client-team-notif-2@example.com',
|
||||
'subject' => 'Problem z VPN',
|
||||
'body' => 'Nie mogę się połączyć.',
|
||||
'subcategory_id' => $sub->id,
|
||||
], null);
|
||||
|
||||
// The ticket routed to "Zespół VPN"; $outsider belongs to no team, so
|
||||
// Ticket::isVisibleToOperator() (which scope_all delegates to) is false
|
||||
// for them even though their preference defaults to scope_all=true.
|
||||
Notification::assertNotSentTo($outsider, TicketNotification::class);
|
||||
});
|
||||
|
||||
test('an operator who turns off scope_all for new tickets stops receiving them, even for a ticket they could otherwise see', function () {
|
||||
Notification::fake();
|
||||
$this->seed();
|
||||
|
||||
$category = Category::query()->create(['name' => 'Bez zespołu']);
|
||||
$sub = $category->subcategories()->create(['name' => 'Inne']);
|
||||
$operator = User::query()->create(['name' => 'Niepowiązany', 'email' => 'unrelated-op@example.com', 'roles' => ['operator']]);
|
||||
$operator = User::query()->create(['name' => 'Cichy', 'email' => 'opted-out-op@example.com', 'roles' => ['operator']]);
|
||||
NotificationPreference::query()->create(array_merge(
|
||||
['user_id' => $operator->id, 'event_category' => 'new_ticket'],
|
||||
array_merge(NotificationPreference::DEFAULTS['new_ticket'], ['scope_all' => false])
|
||||
));
|
||||
|
||||
app(TicketService::class)->create([
|
||||
'email' => 'client-no-team@example.com',
|
||||
|
||||
@@ -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)
|
||||
|
||||
95
src/tests/Feature/NotificationDeliveryRewiringTest.php
Normal file
95
src/tests/Feature/NotificationDeliveryRewiringTest.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
use App\Models\NotificationPreference;
|
||||
use App\Models\NotificationSetting;
|
||||
use App\Notifications\TicketNotification;
|
||||
use App\Services\TicketService;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
test('an assignee with scope_mine enabled for escalations is not double-notified on top of the direct sla_breached send', function () {
|
||||
Notification::fake();
|
||||
$this->seed();
|
||||
NotificationSetting::query()->where('trigger_key', 'sla_breached')->update(['enabled' => true]);
|
||||
|
||||
$ticket = makeTicket();
|
||||
$assignee = operatorUser('assignee-sla@example.com');
|
||||
$ticket->update(['assignee_id' => $assignee->id]);
|
||||
|
||||
app(TicketService::class)->notify($ticket->fresh(), 'sla_breached');
|
||||
|
||||
// NotificationPreference::DEFAULTS['escalation'] has scope_mine=true, so
|
||||
// without the notify()->notifyStaffForCategory() dedup, the assignee
|
||||
// would receive this twice: once as the fixed NotificationSetting
|
||||
// recipient, once again from the scope_mine fan-out.
|
||||
Notification::assertSentToTimes($assignee, TicketNotification::class, 1);
|
||||
});
|
||||
|
||||
test('a staff member with the e-mail column off for an event still gets the bell but not a mail', function () {
|
||||
Notification::fake();
|
||||
$this->seed();
|
||||
|
||||
$operator = operatorUser('bell-only@example.com');
|
||||
NotificationPreference::query()->create(array_merge(
|
||||
['user_id' => $operator->id, 'event_category' => 'ticket_update'],
|
||||
array_merge(NotificationPreference::DEFAULTS['ticket_update'], ['scope_all' => true, 'email' => false])
|
||||
));
|
||||
|
||||
NotificationSetting::query()->where('trigger_key', 'priority_changed')->update(['enabled' => true]);
|
||||
|
||||
$ticket = makeTicket();
|
||||
app(TicketService::class)->setPriority($ticket, 'high');
|
||||
|
||||
Notification::assertSentTo($operator, TicketNotification::class, function ($notification, $channels) {
|
||||
return $channels === ['database'];
|
||||
});
|
||||
});
|
||||
|
||||
test('a staff member with the e-mail column on for an event gets both the bell and a mail', function () {
|
||||
Notification::fake();
|
||||
$this->seed();
|
||||
|
||||
$operator = operatorUser('bell-and-mail@example.com');
|
||||
NotificationPreference::query()->create(array_merge(
|
||||
['user_id' => $operator->id, 'event_category' => 'ticket_update'],
|
||||
array_merge(NotificationPreference::DEFAULTS['ticket_update'], ['scope_all' => true, 'email' => true])
|
||||
));
|
||||
NotificationSetting::query()->where('trigger_key', 'priority_changed')->update(['enabled' => true]);
|
||||
|
||||
$ticket = makeTicket();
|
||||
app(TicketService::class)->setPriority($ticket, 'high');
|
||||
|
||||
Notification::assertSentTo($operator, TicketNotification::class, function ($notification, $channels) {
|
||||
return $channels === ['mail', 'database'];
|
||||
});
|
||||
});
|
||||
|
||||
test('the operator performing the action is never notified about their own change', function () {
|
||||
Notification::fake();
|
||||
$this->seed();
|
||||
NotificationSetting::query()->where('trigger_key', 'priority_changed')->update(['enabled' => true]);
|
||||
|
||||
$actor = operatorUser('actor@example.com');
|
||||
$this->actingAs($actor);
|
||||
|
||||
$ticket = makeTicket();
|
||||
app(TicketService::class)->setPriority($ticket, 'high');
|
||||
|
||||
Notification::assertNotSentTo($actor, TicketNotification::class);
|
||||
});
|
||||
|
||||
test('disabling a trigger instance-wide silences the staff fan-out too, regardless of any individual preference', function () {
|
||||
Notification::fake();
|
||||
$this->seed();
|
||||
|
||||
$operator = operatorUser('kill-switch@example.com');
|
||||
NotificationPreference::query()->create(array_merge(
|
||||
['user_id' => $operator->id, 'event_category' => 'ticket_update'],
|
||||
array_merge(NotificationPreference::DEFAULTS['ticket_update'], ['scope_all' => true, 'email' => true])
|
||||
));
|
||||
NotificationSetting::query()->where('trigger_key', 'priority_changed')->update(['enabled' => false]);
|
||||
|
||||
$ticket = makeTicket();
|
||||
app(TicketService::class)->setPriority($ticket, 'high');
|
||||
|
||||
Notification::assertNotSentTo($operator, TicketNotification::class);
|
||||
});
|
||||
70
src/tests/Feature/NotificationPreferencesTest.php
Normal file
70
src/tests/Feature/NotificationPreferencesTest.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Settings\NotificationPreferences;
|
||||
use App\Models\NotificationPreference;
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('a client cannot open the notification preferences page', function () {
|
||||
$client = User::query()->create(['name' => 'Client', 'email' => 'client-np@example.com', 'roles' => ['client']]);
|
||||
|
||||
Livewire::actingAs($client)->test(NotificationPreferences::class)->assertStatus(403);
|
||||
});
|
||||
|
||||
test('an operator with no saved preferences sees the built-in defaults', function () {
|
||||
$operator = operatorUser();
|
||||
|
||||
Livewire::actingAs($operator)->test(NotificationPreferences::class)
|
||||
->assertViewHas('rows', [
|
||||
'new_ticket' => NotificationPreference::DEFAULTS['new_ticket'],
|
||||
'ticket_update' => NotificationPreference::DEFAULTS['ticket_update'],
|
||||
'escalation' => NotificationPreference::DEFAULTS['escalation'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('toggling a checkbox persists just that one field and leaves the rest at their defaults', function () {
|
||||
$operator = operatorUser();
|
||||
|
||||
Livewire::actingAs($operator)->test(NotificationPreferences::class)
|
||||
->call('toggle', 'ticket_update', 'scope_all')
|
||||
->assertOk();
|
||||
|
||||
$row = NotificationPreference::query()->where('user_id', $operator->id)->where('event_category', 'ticket_update')->firstOrFail();
|
||||
|
||||
expect($row->scope_all)->toBeTrue()
|
||||
->and($row->scope_mine)->toBe(NotificationPreference::DEFAULTS['ticket_update']['scope_mine'])
|
||||
->and($row->email)->toBe(NotificationPreference::DEFAULTS['ticket_update']['email']);
|
||||
});
|
||||
|
||||
test('toggling twice flips the field back off', function () {
|
||||
$operator = operatorUser();
|
||||
|
||||
Livewire::actingAs($operator)->test(NotificationPreferences::class)
|
||||
->call('toggle', 'escalation', 'email')
|
||||
->call('toggle', 'escalation', 'email');
|
||||
|
||||
$row = NotificationPreference::query()->where('user_id', $operator->id)->where('event_category', 'escalation')->firstOrFail();
|
||||
|
||||
expect($row->email)->toBe(NotificationPreference::DEFAULTS['escalation']['email']);
|
||||
});
|
||||
|
||||
test('an unknown category or field is rejected', function () {
|
||||
$operator = operatorUser();
|
||||
|
||||
Livewire::actingAs($operator)->test(NotificationPreferences::class)
|
||||
->call('toggle', 'not_a_category', 'scope_all')
|
||||
->assertStatus(404);
|
||||
});
|
||||
|
||||
test('NotificationPreference::rowFor falls back to defaults when nothing is saved, and to the saved row once toggled', function () {
|
||||
$operator = operatorUser();
|
||||
|
||||
expect(NotificationPreference::rowFor($operator, 'new_ticket'))->toBe(NotificationPreference::DEFAULTS['new_ticket']);
|
||||
|
||||
NotificationPreference::query()->create(array_merge(
|
||||
['user_id' => $operator->id, 'event_category' => 'new_ticket'],
|
||||
array_merge(NotificationPreference::DEFAULTS['new_ticket'], ['scope_all' => false])
|
||||
));
|
||||
|
||||
expect(NotificationPreference::rowFor($operator, 'new_ticket')['scope_all'])->toBeFalse();
|
||||
});
|
||||
@@ -58,8 +58,8 @@ test('columns can be hidden and shown again, but at least one must stay visible'
|
||||
$component->call('toggleColumn', 'sla')
|
||||
->assertSet('visibleColumns', fn ($cols) => in_array('sla', $cols, true));
|
||||
|
||||
// Hide every column except one, then try to hide the last one too.
|
||||
foreach (array_keys((new \App\Livewire\Operator\Queue)->columnDefs()) as $key) {
|
||||
// Hide every visible-by-default column except one, then try to hide the last one too.
|
||||
foreach ((new Queue)->visibleColumns as $key) {
|
||||
if ($key !== 'number') {
|
||||
$component->call('toggleColumn', $key);
|
||||
}
|
||||
|
||||
60
src/tests/Feature/RealtimeBellNotificationTest.php
Normal file
60
src/tests/Feature/RealtimeBellNotificationTest.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
use App\Events\NotificationCreated;
|
||||
use App\Models\EmailTemplate;
|
||||
use App\Notifications\TicketNotification;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
test('sending a bell notification to a real user dispatches NotificationCreated on their private channel', function () {
|
||||
Mail::fake();
|
||||
Event::fake([NotificationCreated::class]);
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
$operator = operatorUser('realtime-bell@example.com');
|
||||
$template = EmailTemplate::query()->create([
|
||||
'key' => 'tpl-realtime-test', 'name' => 'x', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'B',
|
||||
]);
|
||||
$ticket = makeTicket();
|
||||
|
||||
$operator->notify(new TicketNotification($ticket, $template->id, 'operator'));
|
||||
|
||||
Event::assertDispatched(NotificationCreated::class, function (NotificationCreated $event) use ($operator, $ticket) {
|
||||
return $event->userId === $operator->id
|
||||
&& str_contains($event->message, $ticket->number)
|
||||
&& $event->url === route('operator.ticket', $ticket);
|
||||
});
|
||||
});
|
||||
|
||||
test('a bell-only notification (no mail channel) still dispatches NotificationCreated', function () {
|
||||
Mail::fake();
|
||||
Event::fake([NotificationCreated::class]);
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
$operator = operatorUser('bell-only-realtime@example.com');
|
||||
$template = EmailTemplate::query()->create([
|
||||
'key' => 'tpl-realtime-bell-only', 'name' => 'x', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'B',
|
||||
]);
|
||||
$ticket = makeTicket();
|
||||
|
||||
$operator->notify(new TicketNotification($ticket, $template->id, 'operator', ['database']));
|
||||
|
||||
Event::assertDispatched(NotificationCreated::class, fn (NotificationCreated $event) => $event->userId === $operator->id);
|
||||
});
|
||||
|
||||
test('a guest customer notified by mail only never broadcasts a bell event', function () {
|
||||
Mail::fake();
|
||||
Event::fake([NotificationCreated::class]);
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
$template = EmailTemplate::query()->create([
|
||||
'key' => 'tpl-realtime-guest', 'name' => 'x', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'B',
|
||||
]);
|
||||
$ticket = makeTicket();
|
||||
|
||||
Notification::route('mail', $ticket->email)
|
||||
->notify(new TicketNotification($ticket, $template->id));
|
||||
|
||||
Event::assertNotDispatched(NotificationCreated::class);
|
||||
});
|
||||
39
src/tests/Feature/TabPersistenceAndNavigationTest.php
Normal file
39
src/tests/Feature/TabPersistenceAndNavigationTest.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\Panel;
|
||||
use App\Livewire\Operator\Queue;
|
||||
use App\Livewire\Settings\NotificationPreferences;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('the admin panel remembers the active tab across a fresh page load via the URL', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
|
||||
->assertSet('tab', 'integrations')
|
||||
->assertSee('LDAP / Active Directory')
|
||||
->assertSee('Baza wiedzy BookStack')
|
||||
->assertDontSee('Sesja i strefa czasowa');
|
||||
});
|
||||
|
||||
test('LDAP and BookStack config moved out of the Konfiguracja tab into their own Integracje tab', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
$config = Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'config']);
|
||||
$config->assertSee('Sesja i strefa czasowa')
|
||||
->assertDontSee('LDAP / Active Directory')
|
||||
->assertDontSee('Baza wiedzy BookStack');
|
||||
});
|
||||
|
||||
test('the operator queue remembers the active queue tab across a fresh page load via the URL', function () {
|
||||
$operator = operatorUser('queue-persist@example.com');
|
||||
|
||||
Livewire::actingAs($operator)->test(Queue::class, ['queue' => 'mine'])
|
||||
->assertSet('queue', 'mine');
|
||||
});
|
||||
|
||||
test('the notifications settings page has a back link to the operator queue', function () {
|
||||
$operator = operatorUser('settings-nav@example.com');
|
||||
|
||||
Livewire::actingAs($operator)->test(NotificationPreferences::class)
|
||||
->assertSeeHtml(route('operator.queue'));
|
||||
});
|
||||
45
src/tests/Feature/TicketWatchingTest.php
Normal file
45
src/tests/Feature/TicketWatchingTest.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Operator\TicketShow as OperatorTicketShow;
|
||||
use App\Services\TicketService;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('an operator can watch and unwatch a ticket', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser();
|
||||
$ticket = makeTicket();
|
||||
|
||||
expect($ticket->isWatchedBy($operator))->toBeFalse();
|
||||
|
||||
app(TicketService::class)->toggleWatch($ticket, $operator);
|
||||
expect($ticket->fresh()->isWatchedBy($operator))->toBeTrue()
|
||||
->and($operator->watchedTickets()->pluck('tickets.id'))->toContain($ticket->id);
|
||||
|
||||
app(TicketService::class)->toggleWatch($ticket, $operator);
|
||||
expect($ticket->fresh()->isWatchedBy($operator))->toBeFalse();
|
||||
});
|
||||
|
||||
test('the ticket-show watch button toggles watch state for the viewing operator', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser();
|
||||
$ticket = makeTicket();
|
||||
|
||||
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket])
|
||||
->assertSet('isWatching', false)
|
||||
->call('toggleWatch')
|
||||
->assertSet('isWatching', true);
|
||||
|
||||
expect($ticket->fresh()->isWatchedBy($operator))->toBeTrue();
|
||||
});
|
||||
|
||||
test('watching a ticket is per-operator, not shared', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$watcher = operatorUser('watcher@example.com');
|
||||
$other = operatorUser('other@example.com');
|
||||
$ticket = makeTicket();
|
||||
|
||||
app(TicketService::class)->toggleWatch($ticket, $watcher);
|
||||
|
||||
expect($ticket->fresh()->isWatchedBy($watcher))->toBeTrue()
|
||||
->and($ticket->fresh()->isWatchedBy($other))->toBeFalse();
|
||||
});
|
||||
172
src/tests/Feature/TriggerEngineTest.php
Normal file
172
src/tests/Feature/TriggerEngineTest.php
Normal file
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Priority;
|
||||
use App\Models\Team;
|
||||
use App\Models\Trigger;
|
||||
use App\Models\TriggerEmailTemplate;
|
||||
use App\Models\User;
|
||||
use App\Notifications\TicketNotification;
|
||||
use App\Services\TicketService;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
test('a trigger with no conditions fires on every matching event', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Trigger::query()->create([
|
||||
'name' => 'Always high on update', 'enabled' => true, 'event' => 'status_changed',
|
||||
'conditions' => [], 'actions' => [['type' => 'set_priority', 'value' => 'high']],
|
||||
]);
|
||||
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#ccc', 'sort_order' => 2]);
|
||||
$ticket = makeTicket(['priority_key' => 'low']);
|
||||
|
||||
app(TicketService::class)->setStatus($ticket, 'open');
|
||||
|
||||
expect($ticket->fresh()->priority_key)->toBe('high');
|
||||
});
|
||||
|
||||
test('a trigger only fires when every condition matches (AND)', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$team = Team::query()->create(['name' => 'VIP']);
|
||||
Trigger::query()->create([
|
||||
'name' => 'High priority to VIP team', 'enabled' => true, 'event' => 'priority_changed',
|
||||
'conditions' => [['field' => 'priority_key', 'operator' => 'equals', 'value' => 'high']],
|
||||
'actions' => [['type' => 'set_team', 'value' => $team->id]],
|
||||
]);
|
||||
|
||||
$lowTicket = makeTicket(['number' => '2001', 'priority_key' => 'high']);
|
||||
app(TicketService::class)->setPriority($lowTicket, 'high');
|
||||
expect($lowTicket->fresh()->team_id)->toBe($team->id);
|
||||
|
||||
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#ccc', 'sort_order' => 2]);
|
||||
$otherTicket = makeTicket(['number' => '2002', 'priority_key' => 'high']);
|
||||
app(TicketService::class)->setPriority($otherTicket, 'low');
|
||||
expect($otherTicket->fresh()->team_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('a disabled trigger never fires', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Trigger::query()->create([
|
||||
'name' => 'Disabled', 'enabled' => false, 'event' => 'priority_changed',
|
||||
'conditions' => [], 'actions' => [['type' => 'set_status', 'value' => 'closed']],
|
||||
]);
|
||||
$ticket = makeTicket();
|
||||
|
||||
app(TicketService::class)->setPriority($ticket, 'high');
|
||||
|
||||
expect($ticket->fresh()->status_key)->toBe('new');
|
||||
});
|
||||
|
||||
test('is_empty and is_not_empty operators work without a value', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Trigger::query()->create([
|
||||
'name' => 'No team yet -> VIP team', 'enabled' => true, 'event' => 'priority_changed',
|
||||
'conditions' => [['field' => 'team_id', 'operator' => 'is_empty', 'value' => null]],
|
||||
'actions' => [['type' => 'set_status', 'value' => 'open']],
|
||||
]);
|
||||
$ticket = makeTicket();
|
||||
|
||||
app(TicketService::class)->setPriority($ticket, 'high');
|
||||
|
||||
expect($ticket->fresh()->status_key)->toBe('open');
|
||||
});
|
||||
|
||||
test('the contains operator matches substrings case-insensitively', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Trigger::query()->create([
|
||||
'name' => 'VPN keyword -> closed', 'enabled' => true, 'event' => 'priority_changed',
|
||||
'conditions' => [['field' => 'subject', 'operator' => 'contains', 'value' => 'VPN']],
|
||||
'actions' => [['type' => 'set_status', 'value' => 'closed']],
|
||||
]);
|
||||
$ticket = makeTicket(['subject' => 'Problem z vpn na laptopie']);
|
||||
|
||||
app(TicketService::class)->setPriority($ticket, 'high');
|
||||
|
||||
expect($ticket->fresh()->status_key)->toBe('closed');
|
||||
});
|
||||
|
||||
test('an action that would only reassert the current value is a no-op and does not re-trigger anything', function () {
|
||||
seedStatusesAndPriorities();
|
||||
// If this looped, it would recurse until TriggerEngine's depth guard
|
||||
// kicked in; asserting the final state (rather than call counts) proves
|
||||
// the no-op short-circuit stopped it after a single, harmless pass.
|
||||
Trigger::query()->create([
|
||||
'name' => 'Keep status open', 'enabled' => true, 'event' => 'status_changed',
|
||||
'conditions' => [], 'actions' => [['type' => 'set_status', 'value' => 'open']],
|
||||
]);
|
||||
$ticket = makeTicket(['status_key' => 'new']);
|
||||
|
||||
app(TicketService::class)->setStatus($ticket, 'open');
|
||||
|
||||
expect($ticket->fresh()->status_key)->toBe('open');
|
||||
});
|
||||
|
||||
test('two triggers that keep flipping the same field between each other are bounded by the depth guard, not an infinite loop', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Trigger::query()->create([
|
||||
'name' => 'To open', 'enabled' => true, 'event' => 'status_changed',
|
||||
'conditions' => [['field' => 'status_key', 'operator' => 'not_equals', 'value' => 'open']],
|
||||
'actions' => [['type' => 'set_status', 'value' => 'open']],
|
||||
]);
|
||||
Trigger::query()->create([
|
||||
'name' => 'To new', 'enabled' => true, 'event' => 'status_changed',
|
||||
'conditions' => [['field' => 'status_key', 'operator' => 'not_equals', 'value' => 'new']],
|
||||
'actions' => [['type' => 'set_status', 'value' => 'new']],
|
||||
]);
|
||||
$ticket = makeTicket(['status_key' => 'new']);
|
||||
|
||||
// Would hang/exceed PHP's execution time without the depth guard —
|
||||
// simply completing is the assertion.
|
||||
app(TicketService::class)->setStatus($ticket, 'open');
|
||||
|
||||
expect($ticket->fresh()->status_key)->toBeIn(['new', 'open']);
|
||||
});
|
||||
|
||||
test('the send_notification action sends the chosen template to the chosen recipient, ignoring NotificationSetting entirely', function () {
|
||||
Notification::fake();
|
||||
seedStatusesAndPriorities();
|
||||
$template = TriggerEmailTemplate::query()->create([
|
||||
'name' => 'x', 'subject' => 'Priorytet zmieniony na {priorytet}', 'body' => 'B',
|
||||
]);
|
||||
Trigger::query()->create([
|
||||
'name' => 'Notify on high priority', 'enabled' => true, 'event' => 'priority_changed',
|
||||
'conditions' => [], 'actions' => [['type' => 'send_notification', 'recipient' => 'client', 'email_template_id' => $template->id]],
|
||||
]);
|
||||
$ticket = makeTicket();
|
||||
|
||||
app(TicketService::class)->setPriority($ticket, 'high');
|
||||
|
||||
Notification::assertSentOnDemandTimes(TicketNotification::class, 1);
|
||||
|
||||
// Trigger notifications draw from trigger_email_templates, not the
|
||||
// fixed email_templates table used by NotificationSetting.
|
||||
$mail = (new TicketNotification($ticket->fresh(), $template->id, templateSource: 'trigger_email_template'))
|
||||
->toMail((object) ['routes' => ['mail' => $ticket->email]]);
|
||||
expect($mail->subject)->toBe('Priorytet zmieniony na Wysoki');
|
||||
});
|
||||
|
||||
test('a trigger with an unrecognized action type is silently ignored, not fatal', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Trigger::query()->create([
|
||||
'name' => 'Bogus action', 'enabled' => true, 'event' => 'priority_changed',
|
||||
'conditions' => [], 'actions' => [['type' => 'not_a_real_action']],
|
||||
]);
|
||||
$ticket = makeTicket();
|
||||
|
||||
app(TicketService::class)->setPriority($ticket, 'high');
|
||||
|
||||
expect($ticket->fresh()->priority_key)->toBe('high');
|
||||
});
|
||||
|
||||
test('a client reply fires the comment_added event, even though clientReply() never fired a notification trigger before', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Trigger::query()->create([
|
||||
'name' => 'Reopen on client reply', 'enabled' => true, 'event' => 'comment_added',
|
||||
'conditions' => [['field' => 'status_key', 'operator' => 'equals', 'value' => 'closed']],
|
||||
'actions' => [['type' => 'set_status', 'value' => 'open']],
|
||||
]);
|
||||
$ticket = makeTicket(['status_key' => 'closed']);
|
||||
$client = User::query()->create(['name' => 'Klient', 'email' => 'reopener@example.com', 'roles' => ['client']]);
|
||||
|
||||
app(TicketService::class)->clientReply($ticket, $client, 'Nadal mam problem.');
|
||||
|
||||
expect($ticket->fresh()->status_key)->toBe('open');
|
||||
});
|
||||
146
src/tests/Feature/TriggersAdminTest.php
Normal file
146
src/tests/Feature/TriggersAdminTest.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\Panel;
|
||||
use App\Livewire\Admin\Triggers;
|
||||
use App\Models\Trigger;
|
||||
use App\Models\TriggerEmailTemplate;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('admin can create a trigger with a condition and an action', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Triggers::class)
|
||||
->call('openForm')
|
||||
->set('form.name', 'Priorytet wysoki -> status otwarty')
|
||||
->set('form.event', 'priority_changed')
|
||||
->call('addCondition')
|
||||
->set('form.conditions.0.field', 'priority_key')
|
||||
->set('form.conditions.0.operator', 'equals')
|
||||
->set('form.conditions.0.value', 'high')
|
||||
->call('addAction')
|
||||
->set('form.actions.0.type', 'set_status')
|
||||
->set('form.actions.0.value', 'open')
|
||||
->call('submit')
|
||||
->assertSet('formOpen', false);
|
||||
|
||||
$trigger = Trigger::query()->where('name', 'Priorytet wysoki -> status otwarty')->firstOrFail();
|
||||
expect($trigger->event)->toBe('priority_changed')
|
||||
->and($trigger->conditions)->toBe([['field' => 'priority_key', 'operator' => 'equals', 'value' => 'high']])
|
||||
->and($trigger->actions[0]['type'])->toBe('set_status')
|
||||
->and($trigger->actions[0]['value'])->toBe('open');
|
||||
});
|
||||
|
||||
test('a trigger requires a name and at least one action', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Triggers::class)
|
||||
->call('openForm')
|
||||
->set('form.name', '')
|
||||
->call('submit')
|
||||
->assertHasErrors(['form.name', 'form.actions']);
|
||||
});
|
||||
|
||||
test('admin can edit an existing trigger', function () {
|
||||
$admin = adminUser();
|
||||
$trigger = Trigger::query()->create([
|
||||
'name' => 'Original', 'enabled' => true, 'event' => 'ticket_created',
|
||||
'conditions' => [], 'actions' => [['type' => 'set_priority', 'value' => 'high']],
|
||||
]);
|
||||
|
||||
Livewire::actingAs($admin)->test(Triggers::class)
|
||||
->call('editTrigger', $trigger->id)
|
||||
->set('form.name', 'Renamed')
|
||||
->call('submit')
|
||||
->assertSet('formOpen', false);
|
||||
|
||||
expect($trigger->fresh()->name)->toBe('Renamed');
|
||||
});
|
||||
|
||||
test('admin can toggle a trigger on/off and delete it', function () {
|
||||
$admin = adminUser();
|
||||
$trigger = Trigger::query()->create([
|
||||
'name' => 'Toggle me', 'enabled' => true, 'event' => 'ticket_created',
|
||||
'conditions' => [], 'actions' => [['type' => 'set_priority', 'value' => 'high']],
|
||||
]);
|
||||
|
||||
Livewire::actingAs($admin)->test(Triggers::class)
|
||||
->call('toggleEnabled', $trigger->id);
|
||||
expect($trigger->fresh()->enabled)->toBeFalse();
|
||||
|
||||
Livewire::actingAs($admin)->test(Triggers::class)
|
||||
->call('removeTrigger', $trigger->id);
|
||||
expect(Trigger::query()->find($trigger->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('admin can reorder triggers with move up/down', function () {
|
||||
$admin = adminUser();
|
||||
$first = Trigger::query()->create(['name' => 'A', 'enabled' => true, 'event' => 'ticket_created', 'conditions' => [], 'actions' => [['type' => 'set_priority', 'value' => 'high']], 'sort_order' => 1]);
|
||||
$second = Trigger::query()->create(['name' => 'B', 'enabled' => true, 'event' => 'ticket_created', 'conditions' => [], 'actions' => [['type' => 'set_priority', 'value' => 'high']], 'sort_order' => 2]);
|
||||
|
||||
Livewire::actingAs($admin)->test(Triggers::class)
|
||||
->call('moveDown', $first->id);
|
||||
|
||||
expect($first->fresh()->sort_order)->toBe(2)
|
||||
->and($second->fresh()->sort_order)->toBe(1);
|
||||
});
|
||||
|
||||
test('adding and removing condition/action rows in the form works', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
$component = Livewire::actingAs($admin)->test(Triggers::class)
|
||||
->call('openForm')
|
||||
->call('addCondition')
|
||||
->call('addCondition')
|
||||
->assertCount('form.conditions', 2)
|
||||
->call('removeCondition', 0)
|
||||
->assertCount('form.conditions', 1)
|
||||
->call('addAction')
|
||||
->assertCount('form.actions', 1);
|
||||
|
||||
$component->call('removeAction', 0)->assertCount('form.actions', 0);
|
||||
});
|
||||
|
||||
test('the admin panel triggers tab renders the Triggers component', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'triggers')
|
||||
->assertSeeLivewire(Triggers::class);
|
||||
});
|
||||
|
||||
test('admin can create, edit and delete a trigger email template, independent of the fixed email templates', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Triggers::class)
|
||||
->call('openTemplateForm')
|
||||
->set('templateForm.name', 'Przypomnienie')
|
||||
->set('templateForm.subject', 'Temat')
|
||||
->set('templateForm.body', 'Treść')
|
||||
->call('submitTemplate')
|
||||
->assertSet('templateFormOpen', false);
|
||||
|
||||
$template = TriggerEmailTemplate::query()->where('name', 'Przypomnienie')->firstOrFail();
|
||||
expect($template->subject)->toBe('Temat');
|
||||
|
||||
Livewire::actingAs($admin)->test(Triggers::class)
|
||||
->call('editTemplate', $template->id)
|
||||
->set('templateForm.subject', 'Nowy temat')
|
||||
->call('submitTemplate');
|
||||
|
||||
expect($template->fresh()->subject)->toBe('Nowy temat');
|
||||
|
||||
Livewire::actingAs($admin)->test(Triggers::class)
|
||||
->call('removeTemplate', $template->id);
|
||||
|
||||
expect(TriggerEmailTemplate::query()->find($template->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('a trigger email template requires a name, subject and body', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Triggers::class)
|
||||
->call('openTemplateForm')
|
||||
->call('submitTemplate')
|
||||
->assertHasErrors(['templateForm.name', 'templateForm.subject', 'templateForm.body']);
|
||||
});
|
||||
Reference in New Issue
Block a user