singleloginsystem/app/Services/TicketService.php
= 8a41ae6ac1 Feat: assigned user status change, search, pagination and notification
- 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.
2026-05-28 13:22:36 +00:00

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}");
}
}