- Added activity logs for ticket status changes, comments, and ticket views. - Implemented `TicketStatusChangedNotification` to notify users of status updates. - Enhanced ticket list filtering with pagination, search, and status filters. - Updated UI to better handle ticket assignments, status updates, and comment notifications. - Extended tests for activity logs, unauthorized actions, status change notifications, and filtering functionalities.
398 lines
15 KiB
PHP
398 lines
15 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Data\Ticket\{CreateTicketRequest, PostReplyRequest};
|
|
use App\Data\Ui\Ticket\{ReplyData, TicketData, TicketListData};
|
|
use App\Enums\TicketStatusEnum;
|
|
use App\Models\{Ticket, TicketReply, TicketStatus, User};
|
|
use App\Notifications\{TicketAssignedNotification, TicketCommentedNotification, TicketRaisedNotification, TicketStatusChangedNotification};
|
|
use Illuminate\Auth\Access\AuthorizationException;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\DB;
|
|
use InvalidArgumentException;
|
|
use Spatie\LaravelData\PaginatedDataCollection;
|
|
use Throwable;
|
|
|
|
final class TicketService
|
|
{
|
|
public function __construct(
|
|
private readonly TicketAttachmentService $attachmentService
|
|
) {}
|
|
|
|
/**
|
|
* Get all tickets raised by a specific user.
|
|
*
|
|
* @return Collection<int, TicketData>
|
|
*/
|
|
public function getAllForUser(int $userId): Collection
|
|
{
|
|
if ($userId <= 0) {
|
|
throw new InvalidArgumentException('Invalid user ID');
|
|
}
|
|
|
|
$tickets = Ticket::with(['user', 'connectedApp', 'status', 'notifiedRoles', 'notifiedUsers', 'attachments', 'assignedUser', 'replies.user.roles'])
|
|
->where('user_id', $userId)
|
|
->latest()
|
|
->get();
|
|
|
|
return TicketData::collect($tickets);
|
|
}
|
|
|
|
/**
|
|
* Get all tickets in the system (for admins/support).
|
|
*
|
|
* @return Collection<int, TicketData>
|
|
*/
|
|
public function getAll(): Collection
|
|
{
|
|
$tickets = Ticket::with(['user', 'connectedApp', 'status', 'notifiedRoles', 'notifiedUsers', 'attachments', 'assignedUser', 'replies.user.roles'])
|
|
->latest()
|
|
->get();
|
|
|
|
return TicketData::collect($tickets);
|
|
}
|
|
|
|
/**
|
|
* Get lightweight ticket list for a specific user with search and status filtering.
|
|
*
|
|
* @return PaginatedDataCollection<int, TicketListData>
|
|
*/
|
|
public function getListForUser(int $userId, ?string $search = null, ?string $statusName = null, int $perPage = 10): PaginatedDataCollection
|
|
{
|
|
if ($userId <= 0) {
|
|
throw new InvalidArgumentException('Invalid user ID');
|
|
}
|
|
|
|
$user = User::findOrFail($userId);
|
|
$roleIds = $user->roles->pluck('id')->toArray();
|
|
|
|
$tickets = Ticket::with(['user', 'connectedApp', 'status'])
|
|
->where(function ($query) use ($userId, $roleIds): void {
|
|
$query->where('user_id', $userId)
|
|
->orWhere('assigned_user_id', $userId)
|
|
->orWhereHas('notifiedUsers', function ($q) use ($userId): void {
|
|
$q->where('users.id', $userId);
|
|
})
|
|
->orWhereHas('notifiedRoles', function ($q) use ($roleIds): void {
|
|
$q->whereIn('roles.id', $roleIds);
|
|
});
|
|
})
|
|
->when($search, function ($query) use ($search): void {
|
|
$query->where('ticket_id', 'like', "%{$search}%");
|
|
})
|
|
->when($statusName && 'all' !== $statusName, function ($query) use ($statusName): void {
|
|
$query->whereHas('status', function ($q) use ($statusName): void {
|
|
$q->where('name', $statusName);
|
|
});
|
|
})
|
|
->latest()
|
|
->paginate($perPage);
|
|
|
|
return TicketListData::collect($tickets, PaginatedDataCollection::class);
|
|
}
|
|
|
|
/**
|
|
* Get lightweight ticket list of all tickets with search and status filtering.
|
|
*
|
|
* @return PaginatedDataCollection<int, TicketListData>
|
|
*/
|
|
public function getList(?string $search = null, ?string $statusName = null, int $perPage = 10): PaginatedDataCollection
|
|
{
|
|
$tickets = Ticket::with(['user', 'connectedApp', 'status'])
|
|
->when($search, function ($query) use ($search): void {
|
|
$query->where('ticket_id', 'like', "%{$search}%");
|
|
})
|
|
->when($statusName && 'all' !== $statusName, function ($query) use ($statusName): void {
|
|
$query->whereHas('status', function ($q) use ($statusName): void {
|
|
$q->where('name', $statusName);
|
|
});
|
|
})
|
|
->latest()
|
|
->paginate($perPage);
|
|
|
|
return TicketListData::collect($tickets, PaginatedDataCollection::class);
|
|
}
|
|
|
|
/**
|
|
* Get a specific ticket.
|
|
*/
|
|
public function getTicket(int $id): TicketData
|
|
{
|
|
if ($id <= 0) {
|
|
throw new InvalidArgumentException('Invalid ticket ID');
|
|
}
|
|
|
|
$ticket = Ticket::with(['user', 'connectedApp', 'status', 'notifiedRoles', 'notifiedUsers', 'attachments', 'assignedUser', 'replies.user.roles'])
|
|
->findOrFail($id);
|
|
|
|
$user = auth()->user();
|
|
if ($user) {
|
|
activity()
|
|
->performedOn($ticket)
|
|
->causedBy($user)
|
|
->event('ticket_opened')
|
|
->log("Ticket {$ticket->ticket_id} opened by {$user->name}");
|
|
}
|
|
|
|
return TicketData::fromModel($ticket);
|
|
}
|
|
|
|
/**
|
|
* Update the status of a ticket.
|
|
*
|
|
* @throws Throwable
|
|
*/
|
|
public function updateStatus(int $ticketId, string $statusSlug): void
|
|
{
|
|
if ($ticketId <= 0) {
|
|
throw new InvalidArgumentException('Invalid ticket ID');
|
|
}
|
|
|
|
DB::transaction(function () use ($ticketId, $statusSlug): void {
|
|
$ticket = Ticket::findOrFail($ticketId);
|
|
|
|
$user = auth()->user();
|
|
if (! $user) {
|
|
throw new AuthorizationException('Unauthenticated');
|
|
}
|
|
|
|
if (! $user->hasRole('Admin') && ! $user->hasRole('admin') && ! $user->can('ticket:update') && $ticket->assigned_user_id !== $user->id) {
|
|
throw new AuthorizationException('You are not authorized to update this ticket\'s status.');
|
|
}
|
|
|
|
$status = TicketStatus::where('name', $statusSlug)->firstOrFail();
|
|
$oldStatus = $ticket->status->name;
|
|
|
|
if ($oldStatus !== $status->name) {
|
|
$ticket->update(['status_id' => $status->id]);
|
|
|
|
activity()
|
|
->performedOn($ticket)
|
|
->causedBy($user)
|
|
->event('status_changed')
|
|
->withProperties([
|
|
'old_status' => $oldStatus,
|
|
'new_status' => $status->name,
|
|
])
|
|
->log("Status updated from {$oldStatus} to {$status->name}");
|
|
|
|
if ($ticket->user_id !== $user->id) {
|
|
$ticket->user->notify(new TicketStatusChangedNotification($ticket));
|
|
$this->logNotificationActivity($ticket, $ticket->user, TicketStatusChangedNotification::class);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Assign/remove assigned user to ticket.
|
|
*
|
|
* @throws Throwable
|
|
*/
|
|
public function assignUser(int $ticketId, ?int $assignedUserId): void
|
|
{
|
|
if ($ticketId <= 0) {
|
|
throw new InvalidArgumentException('Invalid ticket ID');
|
|
}
|
|
|
|
DB::transaction(function () use ($ticketId, $assignedUserId): void {
|
|
$ticket = Ticket::findOrFail($ticketId);
|
|
$oldAssignedUserId = $ticket->assigned_user_id;
|
|
|
|
$ticket->update(['assigned_user_id' => $assignedUserId]);
|
|
|
|
if ($assignedUserId && $assignedUserId !== $oldAssignedUserId) {
|
|
$assignedUser = User::findOrFail($assignedUserId);
|
|
$assignedUser->notify(new TicketAssignedNotification($ticket));
|
|
$this->logNotificationActivity($ticket, $assignedUser, TicketAssignedNotification::class);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Post a comment/reply to a ticket.
|
|
*
|
|
* @throws Throwable
|
|
*/
|
|
public function postReply(PostReplyRequest $request): ReplyData
|
|
{
|
|
return DB::transaction(function () use ($request): ReplyData {
|
|
$user = User::findOrFail($request->userId);
|
|
$ticket = Ticket::findOrFail($request->ticketId);
|
|
|
|
// Authorization check: Admin, Creator, Assigned, or Notified directly/via role
|
|
$isAuthorized = $user->hasRole('Admin')
|
|
|| $user->hasRole('admin')
|
|
|| $ticket->user_id === $user->id
|
|
|| $ticket->assigned_user_id === $user->id
|
|
|| $ticket->notifiedUsers()->where('users.id', $user->id)->exists()
|
|
|| $ticket->notifiedRoles()->whereIn('roles.id', $user->roles->pluck('id')->toArray())->exists();
|
|
|
|
if (! $isAuthorized) {
|
|
throw new AuthorizationException('You are not authorized to comment on this ticket.');
|
|
}
|
|
|
|
/** @var TicketReply $reply */
|
|
$reply = TicketReply::query()->create([
|
|
'ticket_id' => $request->ticketId,
|
|
'user_id' => $request->userId,
|
|
'message' => mb_trim($request->message),
|
|
]);
|
|
|
|
activity()
|
|
->performedOn($ticket)
|
|
->causedBy($user)
|
|
->event('comment_posted')
|
|
->withProperties([
|
|
'comment_id' => $reply->id,
|
|
'message' => $reply->message,
|
|
])
|
|
->log("Comment posted on ticket {$ticket->ticket_id}");
|
|
|
|
// Transition ticket status from open to in-progress if it's currently open
|
|
$ticket->load(['status', 'user', 'assignedUser']);
|
|
if ($ticket->status->name === TicketStatusEnum::Open->value) {
|
|
$inProgressStatus = TicketStatus::where('name', TicketStatusEnum::InProgress->value)->first();
|
|
if ($inProgressStatus) {
|
|
$oldStatus = $ticket->status->name;
|
|
$ticket->update(['status_id' => $inProgressStatus->id]);
|
|
|
|
activity()
|
|
->performedOn($ticket)
|
|
->causedBy($user)
|
|
->event('status_changed')
|
|
->withProperties([
|
|
'old_status' => $oldStatus,
|
|
'new_status' => TicketStatusEnum::InProgress->value,
|
|
])
|
|
->log("Status updated from {$oldStatus} to ".TicketStatusEnum::InProgress->value);
|
|
|
|
if ($ticket->user_id !== $user->id) {
|
|
$ticket->user->notify(new TicketStatusChangedNotification($ticket));
|
|
$this->logNotificationActivity($ticket, $ticket->user, TicketStatusChangedNotification::class);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Notify ticket raiser if they did not make this comment
|
|
if ($ticket->user_id !== $request->userId) {
|
|
$ticket->user->notify(new TicketCommentedNotification($ticket, $reply));
|
|
$this->logNotificationActivity($ticket, $ticket->user, TicketCommentedNotification::class);
|
|
}
|
|
|
|
// Notify assigned support user if they are set and did not make this comment
|
|
if ($ticket->assigned_user_id && $ticket->assigned_user_id !== $request->userId) {
|
|
$ticket->assignedUser->notify(new TicketCommentedNotification($ticket, $reply));
|
|
$this->logNotificationActivity($ticket, $ticket->assignedUser, TicketCommentedNotification::class);
|
|
}
|
|
|
|
return ReplyData::fromModel($reply->load('user.roles'));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create a new support ticket.
|
|
*
|
|
* @throws Throwable
|
|
*/
|
|
public function create(CreateTicketRequest $request): TicketData
|
|
{
|
|
return DB::transaction(function () use ($request): TicketData {
|
|
$defaultStatus = TicketStatus::query()
|
|
->where('name', TicketStatusEnum::Open->value)
|
|
->firstOrFail();
|
|
|
|
/** @var Ticket $ticket */
|
|
$ticket = Ticket::query()->create([
|
|
'user_id' => $request->userId,
|
|
'connected_app_id' => $request->connectedAppId,
|
|
'issue_category' => $request->issueCategory,
|
|
'description' => $request->description,
|
|
'status_id' => $defaultStatus->id,
|
|
]);
|
|
|
|
if (! empty($request->notifiedRoleIds)) {
|
|
$ticket->notifiedRoles()->sync($request->notifiedRoleIds);
|
|
}
|
|
|
|
if (! empty($request->notifiedUserIds)) {
|
|
$ticket->notifiedUsers()->sync($request->notifiedUserIds);
|
|
}
|
|
|
|
if ($request->attachment instanceof UploadedFile) {
|
|
$this->attachmentService->store($ticket->id, $request->attachment);
|
|
}
|
|
|
|
// Load relations to build complete DTO and trigger notifications
|
|
$ticket->load(['user', 'connectedApp', 'status', 'notifiedRoles', 'notifiedUsers', 'attachments', 'assignedUser', 'replies.user.roles']);
|
|
|
|
$this->sendNotifications($ticket);
|
|
|
|
return TicketData::fromModel($ticket);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Send notifications to all routed recipients.
|
|
*/
|
|
private function sendNotifications(Ticket $ticket): void
|
|
{
|
|
$notification = new TicketRaisedNotification($ticket);
|
|
$notifiedUserIds = [];
|
|
|
|
// Notify specific users
|
|
foreach ($ticket->notifiedUsers as $user) {
|
|
$user->notify($notification);
|
|
$this->logNotificationActivity($ticket, $user, TicketRaisedNotification::class);
|
|
$notifiedUserIds[] = $user->id;
|
|
}
|
|
|
|
// Notify role users
|
|
foreach ($ticket->notifiedRoles as $role) {
|
|
$users = User::role($role->name)->get();
|
|
foreach ($users as $user) {
|
|
// Avoid double notifications
|
|
if (! in_array($user->id, $notifiedUserIds, true)) {
|
|
$user->notify($notification);
|
|
$this->logNotificationActivity($ticket, $user, TicketRaisedNotification::class);
|
|
$notifiedUserIds[] = $user->id;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Always notify all administrators when a ticket is raised
|
|
$admins = User::whereHas('roles', function ($q): void {
|
|
$q->whereIn('name', ['Admin', 'admin']);
|
|
})->get();
|
|
foreach ($admins as $admin) {
|
|
if (! in_array($admin->id, $notifiedUserIds, true)) {
|
|
$admin->notify($notification);
|
|
$this->logNotificationActivity($ticket, $admin, TicketRaisedNotification::class);
|
|
$notifiedUserIds[] = $admin->id;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Log notification dispatch activity.
|
|
*/
|
|
private function logNotificationActivity(Ticket $ticket, User $recipient, string $notificationClass): void
|
|
{
|
|
$user = auth()->user();
|
|
activity()
|
|
->performedOn($ticket)
|
|
->causedBy($user)
|
|
->event('user_notified')
|
|
->withProperties([
|
|
'recipient_id' => $recipient->id,
|
|
'recipient_name' => $recipient->name,
|
|
'notification_type' => $notificationClass,
|
|
])
|
|
->log("Notified user {$recipient->name} about ticket {$ticket->ticket_id}");
|
|
}
|
|
}
|