singleloginsystem/app/Services/TicketService.php
= 04c62145b5 Feat: notify all administrators when a ticket is raised
- Updated `TicketService` to trigger notifications to all administrators upon ticket creation.
- Added test to verify administrator notifications when a support ticket is raised.
- Minor formatting adjustments in `Ticket` model's imports for consistency.
2026-05-26 07:14:31 +00:00

294 lines
9.9 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};
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
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.
*
* @return Collection<int, TicketListData>
*/
public function getListForUser(int $userId): Collection
{
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);
});
})
->latest()
->get();
return TicketListData::collect($tickets);
}
/**
* Get lightweight ticket list of all tickets.
*
* @return Collection<int, TicketListData>
*/
public function getList(): Collection
{
$tickets = Ticket::with(['user', 'connectedApp', 'status'])
->latest()
->get();
return TicketListData::collect($tickets);
}
/**
* 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);
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);
$status = TicketStatus::where('name', $statusSlug)->firstOrFail();
$ticket->update(['status_id' => $status->id]);
});
}
/**
* 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));
}
});
}
/**
* 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),
]);
// 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) {
$ticket->update(['status_id' => $inProgressStatus->id]);
}
}
// Notify ticket raiser if they did not make this comment
if ($ticket->user_id !== $request->userId) {
$ticket->user->notify(new TicketCommentedNotification($ticket, $reply));
}
// 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));
}
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);
$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);
$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);
$notifiedUserIds[] = $admin->id;
}
}
}
}