- 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>
165 lines
5.3 KiB
PHP
165 lines
5.3 KiB
PHP
<?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);
|
|
}
|
|
}
|