v1.0.0
This commit is contained in:
288
src/app/Services/TicketService.php
Normal file
288
src/app/Services/TicketService.php
Normal file
@@ -0,0 +1,288 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ApiClient;
|
||||
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\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,
|
||||
'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'] ?? [],
|
||||
]);
|
||||
|
||||
$message = $ticket->messages()->create([
|
||||
'author_name' => $authorName ?? $ticket->name,
|
||||
'body' => $data['body'],
|
||||
]);
|
||||
$message->attachAuthor($customer?->id, 'client');
|
||||
|
||||
$this->notify($ticket, 'ticket_created');
|
||||
|
||||
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));
|
||||
|
||||
$this->notify($ticket, 'status_changed');
|
||||
|
||||
if ($statusKey === 'closed') {
|
||||
$this->notify($ticket, 'ticket_closed');
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
public function clientReply(Ticket $ticket, User $client, string $body, array $attachments = []): void
|
||||
{
|
||||
$message = $ticket->messages()->create([
|
||||
'author_name' => $client->name,
|
||||
'body' => $body,
|
||||
]);
|
||||
$message->attachAuthor($client->id, 'client');
|
||||
$ticket->touch();
|
||||
$this->attachFiles($ticket, $message, $attachments);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
|
||||
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->number)->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']);
|
||||
$note = $other->messages()->create([
|
||||
'author_name' => 'System',
|
||||
'internal' => true,
|
||||
'body' => 'Scalone ze zgłoszeniem #'.$primary->number,
|
||||
]);
|
||||
$note->attachAuthor(null, 'operator');
|
||||
}
|
||||
|
||||
$primary->touch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Public so the scheduled SLA-breach check (which isn't a ticket lifecycle
|
||||
* event raised from within this service) can trigger the same way.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
$email = $setting->recipient === 'operator' ? $ticket->assignee?->email : $ticket->email;
|
||||
|
||||
if (! $email) {
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::route('mail', $email)
|
||||
->notify(new TicketNotification($ticket, $setting->email_template_id));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user