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,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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user