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