- Snipe-IT asset inventory integration (Admin > Integracje), optional and off by default: connect by API address + personal token (+ SSL-verification bypass). Three independent toggles: client can pick which of their own Snipe-IT assets a ticket concerns (scoped to admin-selected subcategories, empty = never shows), operator sees the requester's assets in a ticket-view sidebar, operator can search the whole inventory from that same sidebar (not a separate page) for shared equipment. Assets shown as "numer środka - numer seryjny - producent model" + category; a linked asset's live status is fetched fresh on the ticket page, and unlinking stays available to an operator even with both view/search toggles off. - AI summary: a "Wygeneruj teraz" button for an immediate on-demand refresh, plus a new admin toggle to regenerate right after every new reply/note instead of only on the next scheduled sweep. The transcript sent to the model now also includes the ticket's own opening body, fixing summaries missing the original request on long threads. - Fixed: the status dropdown in the operator ticket view could keep showing the pre-change status after a status-changing quick action until the next page load (Livewire/Alpine-morph quirk for wire:change-bound selects). - Docs: README/ARCHITECTURE/CHANGELOG/install/wiki updated for all of the above, including correcting the AI-summary refresh description left over from the 1.2.1 release notes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
598 lines
25 KiB
PHP
598 lines
25 KiB
PHP
<?php
|
|
|
|
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;
|
|
use App\Models\Subcategory;
|
|
use App\Models\Team;
|
|
use App\Models\Ticket;
|
|
use App\Models\TicketMessage;
|
|
use App\Models\User;
|
|
use App\Notifications\TicketNotification;
|
|
use App\Support\Settings;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Notification;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
class TicketService
|
|
{
|
|
/**
|
|
* Create a ticket, either from a guest submission (no $customer) or on behalf of a logged-in client.
|
|
*/
|
|
public function create(array $data, ?User $customer, ?string $authorName = null): Ticket
|
|
{
|
|
if (! $customer && ! empty($data['email']) && Settings::bool('ldap_auto_provision_guests')) {
|
|
$customer = app(LdapUserProvisioner::class)->findOrCreateByEmail($data['email']);
|
|
}
|
|
|
|
$subcategory = ! empty($data['subcategory_id'])
|
|
? Subcategory::query()->find($data['subcategory_id'])
|
|
: null;
|
|
|
|
$ticket = Ticket::query()->create([
|
|
'number' => Ticket::nextNumber(),
|
|
'customer_id' => $customer?->id,
|
|
'email' => $customer?->email ?? $data['email'],
|
|
'name' => $customer?->name ?? ($data['name'] ?? $data['email']),
|
|
'subcategory_id' => $subcategory?->id,
|
|
// category_id only ever carries a value when there's no
|
|
// subcategory to derive one from (e.g. an IMAP mailbox routed to
|
|
// a whole category rather than a specific subcategory).
|
|
'category_id' => $subcategory ? null : ($data['category_id'] ?? null),
|
|
'subject' => $data['subject'],
|
|
'body' => $data['body'],
|
|
'status_key' => Settings::get('default_status', 'new'),
|
|
'priority_key' => $subcategory?->default_priority_key ?: 'medium',
|
|
'team_id' => $this->autoAssignTeam($subcategory),
|
|
'assignee_id' => $data['assignee_id'] ?? null,
|
|
'custom_fields' => $data['custom_values'] ?? [],
|
|
'last_customer_activity_at' => now(),
|
|
'source' => $data['source'] ?? 'web',
|
|
'snipeit_asset_id' => $data['snipeit_asset_id'] ?? null,
|
|
'snipeit_asset_name' => $data['snipeit_asset_name'] ?? null,
|
|
]);
|
|
|
|
$message = $ticket->messages()->create([
|
|
'author_name' => $authorName ?? $ticket->name,
|
|
'body' => $data['body'],
|
|
]);
|
|
$message->attachAuthor($customer?->id, 'client');
|
|
|
|
$this->notify($ticket, 'ticket_created');
|
|
$this->notify($ticket, 'ticket_created_team');
|
|
app(TriggerEngine::class)->handle($ticket, 'ticket_created');
|
|
TicketQueueChanged::dispatch($ticket->id, 'created', Auth::id());
|
|
|
|
return $ticket;
|
|
}
|
|
|
|
protected function autoAssignTeam(?Subcategory $subcategory): ?int
|
|
{
|
|
if (! $subcategory || ! Settings::bool('auto_assign_by_category')) {
|
|
return null;
|
|
}
|
|
|
|
return Team::query()->whereHas('subcategories', fn ($q) => $q->where('subcategories.id', $subcategory->id))
|
|
->value('id');
|
|
}
|
|
|
|
public function setStatus(Ticket $ticket, string $statusKey): void
|
|
{
|
|
// Any status change (closing, reopening, moving between open sub-statuses)
|
|
// resets the SLA-breach alert so a still-open ticket can report again later.
|
|
$ticket->update(['status_key' => $statusKey, 'sla_notified_at' => null]);
|
|
$ticket->addHistory('Status zmieniony na: '.Status::labelFor($statusKey));
|
|
|
|
// A transition to "closed" fires its own dedicated notification
|
|
// instead of the generic status-changed one, so closing a ticket
|
|
// doesn't send the customer/operator two emails for one event.
|
|
// Checked via the status's stage (not the literal key) since admins
|
|
// can rename/replace which key maps to the "closed" stage.
|
|
if (Status::stageFor($statusKey) === 'closed') {
|
|
$this->notify($ticket, 'ticket_closed');
|
|
|
|
// Time tracking only applies to open work — checkpoint and pause
|
|
// the running segment (if any) the moment a ticket is closed,
|
|
// regardless of which flow triggered the status change.
|
|
$ticket->stopTimer();
|
|
|
|
// A closed ticket that reopens later should give every automation
|
|
// rule a clean slate rather than staying latched from before.
|
|
$ticket->automationRuleLogs()->delete();
|
|
} else {
|
|
$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());
|
|
}
|
|
|
|
public function submitCsat(Ticket $ticket, int $rating, ?string $comment = null): void
|
|
{
|
|
if (! $ticket->csatSubmittable()) {
|
|
return;
|
|
}
|
|
|
|
$rating = max(1, min(5, $rating));
|
|
|
|
$ticket->update([
|
|
'csat_rating' => $rating,
|
|
'csat_comment' => $comment,
|
|
'csat_rated_at' => now(),
|
|
]);
|
|
$ticket->addHistory('Klient ocenił obsługę: '.$rating.'/5');
|
|
}
|
|
|
|
public function setPriority(Ticket $ticket, string $priorityKey): void
|
|
{
|
|
$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());
|
|
}
|
|
|
|
public function setAssignee(Ticket $ticket, ?User $assignee): void
|
|
{
|
|
// A newly assigned operator should get a fresh chance at an SLA-breach
|
|
// alert instead of staying silent because the previous one already fired.
|
|
$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());
|
|
}
|
|
|
|
public function setTeam(Ticket $ticket, ?Team $team): void
|
|
{
|
|
$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());
|
|
}
|
|
|
|
/**
|
|
* Applies one AI-triage pass's changes (see TicketAiTriageService) in a
|
|
* single update, rather than composing setPriority()/updateDetails() —
|
|
* a pass can touch category, subcategory, subject and priority
|
|
* together, and those would each write their own generic history line
|
|
* and fire notify()/TriggerEngine::handle() per field instead of once
|
|
* per pass, fragmenting one semantic AI decision into several
|
|
* unrelated-looking edits. $historyLines carries one mechanical
|
|
* "X changed to: Y" line per changed field (built by the caller, since
|
|
* it already knows the human-readable labels); this always appends one
|
|
* more attribution line on top, mirroring how RunAutomationRules logs
|
|
* "Automatyzacja: {label}" after its own field-change lines.
|
|
*
|
|
* @param array<string, mixed> $changes column => value, only the fields that actually changed
|
|
* @param string[] $historyLines
|
|
*/
|
|
public function applyAiTriage(Ticket $ticket, array $changes, array $historyLines): void
|
|
{
|
|
if (! $changes) {
|
|
return;
|
|
}
|
|
|
|
$categoryChanged = array_key_exists('category_id', $changes) || array_key_exists('subcategory_id', $changes);
|
|
$priorityChanged = array_key_exists('priority_key', $changes);
|
|
|
|
$ticket->update($changes);
|
|
|
|
foreach ($historyLines as $line) {
|
|
$ticket->addHistory($line);
|
|
}
|
|
$ticket->addHistory('Automatyzacja: klasyfikacja AI');
|
|
|
|
if ($categoryChanged) {
|
|
$this->notify($ticket, 'category_changed');
|
|
app(TriggerEngine::class)->handle($ticket, 'category_changed');
|
|
}
|
|
|
|
if ($priorityChanged) {
|
|
$this->notify($ticket, 'priority_changed');
|
|
app(TriggerEngine::class)->handle($ticket, 'priority_changed');
|
|
}
|
|
|
|
app(TriggerEngine::class)->handle($ticket, 'ticket_updated');
|
|
TicketQueueChanged::dispatch($ticket->id, 'ai_triage', Auth::id());
|
|
}
|
|
|
|
public function setReporter(Ticket $ticket, User $customer): void
|
|
{
|
|
$ticket->update(['customer_id' => $customer->id, 'email' => $customer->email, 'name' => $customer->name]);
|
|
$ticket->addHistory('Zgłaszający zmieniony na: '.$customer->name);
|
|
}
|
|
|
|
/**
|
|
* Links/unlinks the Snipe-IT asset attached to a ticket — $asset null
|
|
* unlinks. Only the label (not live status/assignment) is cached on the
|
|
* ticket row, so it still shows something if Snipe-IT later becomes
|
|
* unreachable or the asset is deleted there, without a live API call on
|
|
* every ticket list render (see SnipeItClient::asset() for the live
|
|
* fetch used on the ticket-detail page itself).
|
|
*
|
|
* @param array{id: int, label: string}|null $asset
|
|
*/
|
|
public function setSnipeitAsset(Ticket $ticket, ?array $asset): void
|
|
{
|
|
$ticket->update([
|
|
'snipeit_asset_id' => $asset['id'] ?? null,
|
|
'snipeit_asset_name' => $asset['label'] ?? null,
|
|
]);
|
|
$ticket->addHistory($asset
|
|
? 'Powiązano sprzęt (inwentarz): '.$asset['label']
|
|
: 'Odpięto powiązany sprzęt (inwentarz)');
|
|
}
|
|
|
|
public function updateDetails(Ticket $ticket, array $data): void
|
|
{
|
|
$categoryChanged = ($data['subcategory_id'] ?? null) !== $ticket->subcategory_id;
|
|
|
|
$ticket->update([
|
|
'subject' => $data['subject'],
|
|
'body' => $data['body'],
|
|
'subcategory_id' => $data['subcategory_id'] ?? null,
|
|
'custom_fields' => $data['custom_values'] ?? [],
|
|
]);
|
|
$ticket->addHistory('Zaktualizowano dane zgłoszenia');
|
|
|
|
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
|
|
{
|
|
$message = $ticket->messages()->create([
|
|
'author_name' => $operator->name,
|
|
'body' => $body,
|
|
]);
|
|
$message->attachAuthor($operator->id, 'operator');
|
|
$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);
|
|
|
|
if ($statusAfter) {
|
|
$this->setStatus($ticket, $statusAfter);
|
|
}
|
|
}
|
|
|
|
public function operatorNote(Ticket $ticket, User $operator, string $body, array $attachments = []): void
|
|
{
|
|
$message = $ticket->messages()->create([
|
|
'author_name' => $operator->name,
|
|
'internal' => true,
|
|
'body' => $body,
|
|
]);
|
|
$message->attachAuthor($operator->id, 'operator');
|
|
$this->attachFiles($ticket, $message, $attachments);
|
|
TicketMessagePosted::dispatch($ticket->id, $message->id, true, $operator->id);
|
|
}
|
|
|
|
public function clientReply(Ticket $ticket, User $client, string $body, array $attachments = [], string $source = 'web'): void
|
|
{
|
|
$message = $ticket->messages()->create([
|
|
'author_name' => $client->name,
|
|
'body' => $body,
|
|
'source' => $source === 'web' ? null : $source,
|
|
]);
|
|
$message->attachAuthor($client->id, 'client');
|
|
$ticket->touch();
|
|
$this->attachFiles($ticket, $message, $attachments);
|
|
|
|
// A fresh customer reply breaks whatever silence an automation rule
|
|
// fired on, so it should be able to fire again after a new period of
|
|
// silence rather than staying latched from before.
|
|
$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);
|
|
}
|
|
|
|
/**
|
|
* A reply from a customer with no User account — e.g. an e-mail reply
|
|
* from an address the IMAP fetcher couldn't resolve to a local/LDAP
|
|
* user. Mirrors clientReply() (real customer activity: resets SLA
|
|
* silence, fires comment_added so an admin-configured Trigger can reopen
|
|
* a closed ticket) rather than apiMessage() (attachAuthor(null, null) —
|
|
* a system/integration note, not client content). attachAuthor(null,
|
|
* 'client') matches how create() already tags a guest's opening message.
|
|
*/
|
|
public function guestReply(Ticket $ticket, string $authorName, string $body, array $attachments = [], string $source = 'web'): TicketMessage
|
|
{
|
|
$message = $ticket->messages()->create([
|
|
'author_name' => $authorName,
|
|
'body' => $body,
|
|
'source' => $source === 'web' ? null : $source,
|
|
]);
|
|
$message->attachAuthor(null, 'client');
|
|
$ticket->touch();
|
|
$this->attachFiles($ticket, $message, $attachments);
|
|
|
|
$ticket->update(['last_customer_activity_at' => now()]);
|
|
$ticket->automationRuleLogs()->delete();
|
|
|
|
app(TriggerEngine::class)->handle($ticket, 'comment_added');
|
|
TicketMessagePosted::dispatch($ticket->id, $message->id, false, null);
|
|
TicketQueueChanged::dispatch($ticket->id, 'message_posted', null);
|
|
|
|
return $message;
|
|
}
|
|
|
|
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
|
|
* merge()'s system notes). Public replies still fire the same outward
|
|
* notification as an operator's reply; internal notes don't.
|
|
*/
|
|
public function apiMessage(Ticket $ticket, ApiClient $client, string $body, bool $internal, string $authorName, array $attachments = []): TicketMessage
|
|
{
|
|
$message = $ticket->messages()->create([
|
|
'author_name' => $authorName,
|
|
'internal' => $internal,
|
|
'body' => $body,
|
|
'api_client_id' => $client->id,
|
|
]);
|
|
$message->attachAuthor(null, null);
|
|
$ticket->touch();
|
|
$this->attachFiles($ticket, $message, $attachments);
|
|
|
|
if (! $internal) {
|
|
$this->notify($ticket, 'operator_replied');
|
|
app(TriggerEngine::class)->handle($ticket, 'comment_added');
|
|
}
|
|
|
|
TicketMessagePosted::dispatch($ticket->id, $message->id, $internal, null);
|
|
TicketQueueChanged::dispatch($ticket->id, 'message_posted', null);
|
|
|
|
return $message;
|
|
}
|
|
|
|
/**
|
|
* Stores each uploaded file and links it to the given message (or, for a
|
|
* ticket's opening message, to $message === null-safe callers passing the
|
|
* ticket's first message) — shared by every attachment-picking flow,
|
|
* including the initial ticket-creation forms.
|
|
*
|
|
* @param UploadedFile[] $attachments
|
|
*/
|
|
public function attachFiles(Ticket $ticket, ?TicketMessage $message, array $attachments): void
|
|
{
|
|
if (! $attachments || ! Settings::bool('allow_attachments')) {
|
|
return;
|
|
}
|
|
|
|
foreach ($attachments as $attachment) {
|
|
$path = $attachment->store('attachments', 'public');
|
|
|
|
$ticket->attachments()->create([
|
|
'message_id' => $message?->id,
|
|
'original_name' => $attachment->getClientOriginalName(),
|
|
'path' => $path,
|
|
'size' => Storage::disk('public')->size($path),
|
|
'mime' => $attachment->getMimeType(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Merge tickets: first selected id is primary, others get closed and their
|
|
* messages copied onto the primary (mirrors the design's mergeSelected()).
|
|
*/
|
|
public function merge(array $ticketIds): void
|
|
{
|
|
if (count($ticketIds) < 2) {
|
|
return;
|
|
}
|
|
|
|
$tickets = Ticket::query()->whereIn('id', $ticketIds)->get()->keyBy('id');
|
|
$primaryId = $ticketIds[0];
|
|
$primary = $tickets->get($primaryId);
|
|
$others = $tickets->except($primaryId);
|
|
|
|
if (! $primary || $others->isEmpty()) {
|
|
return;
|
|
}
|
|
|
|
$primary->messages()->create([
|
|
'author_name' => 'System',
|
|
'body' => 'Scalono zgłoszenia: '.$others->map(fn (Ticket $o) => $o->displayNumber())->implode(', '),
|
|
]);
|
|
|
|
foreach ($others as $other) {
|
|
foreach ($other->messages()->get() as $message) {
|
|
$copy = $primary->messages()->create([
|
|
'author_name' => $message->author_name,
|
|
'internal' => $message->internal,
|
|
'body' => $message->body,
|
|
]);
|
|
$copy->attachAuthor($message->author_id, $message->authorLink?->role?->key);
|
|
}
|
|
|
|
$other->update(['status_key' => 'closed']);
|
|
$other->stopTimer();
|
|
$note = $other->messages()->create([
|
|
'author_name' => 'System',
|
|
'internal' => true,
|
|
'body' => 'Scalone ze zgłoszeniem '.$primary->displayNumber(),
|
|
]);
|
|
$note->attachAuthor(null, 'operator');
|
|
TicketQueueChanged::dispatch($other->id, 'merged', Auth::id());
|
|
}
|
|
|
|
$primary->touch();
|
|
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.
|
|
*
|
|
* 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
|
|
{
|
|
$setting = NotificationSetting::query()->where('trigger_key', $triggerKey)->first();
|
|
|
|
if (! $setting || ! $setting->enabled || ! $setting->email_template_id) {
|
|
return;
|
|
}
|
|
|
|
$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, $templateId, $recipientRole, $channels, $templateSource));
|
|
|
|
return;
|
|
}
|
|
|
|
if ($fallbackEmail) {
|
|
Notification::route('mail', $fallbackEmail)
|
|
->notify(new TicketNotification($ticket, $templateId, $recipientRole, $channels, $templateSource));
|
|
}
|
|
}
|
|
}
|