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.
This commit is contained in:
parent
df4a84b970
commit
8a41ae6ac1
@ -19,6 +19,7 @@ public function __construct(
|
||||
public string $createdAt,
|
||||
public TicketStatusEnum $status,
|
||||
public string $statusSlug,
|
||||
public ?int $assignedUserId = null,
|
||||
) {}
|
||||
|
||||
public static function fromModel(Ticket $ticket): self
|
||||
@ -32,6 +33,7 @@ public static function fromModel(Ticket $ticket): self
|
||||
createdAt: $ticket->created_at->toDateTimeString(),
|
||||
status: TicketStatusEnum::from($ticket->status->name),
|
||||
statusSlug: $ticket->status->name,
|
||||
assignedUserId: $ticket->assigned_user_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Notifications\{TicketAssignedNotification, TicketCommentedNotification, TicketRaisedNotification};
|
||||
use App\Notifications\{TicketAssignedNotification, TicketCommentedNotification, TicketRaisedNotification, TicketStatusChangedNotification};
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ResolveNotificationRouteController extends Controller
|
||||
@ -23,7 +23,8 @@ public function __invoke(Request $request, string $id)
|
||||
$url = match ($notification->type) {
|
||||
TicketRaisedNotification::class,
|
||||
TicketAssignedNotification::class,
|
||||
TicketCommentedNotification::class => route('tickets.index', [
|
||||
TicketCommentedNotification::class,
|
||||
TicketStatusChangedNotification::class => route('tickets.index', [
|
||||
'ticket' => data_get($notification->data, 'ticket_id'),
|
||||
]),
|
||||
|
||||
|
||||
69
app/Notifications/TicketStatusChangedNotification.php
Normal file
69
app/Notifications/TicketStatusChangedNotification.php
Normal file
@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Enums\TicketStatusEnum;
|
||||
use App\Models\Ticket;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class TicketStatusChangedNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
public readonly Ticket $ticket
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database', 'mail'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mail representation of the notification.
|
||||
*/
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$statusLabel = TicketStatusEnum::tryFrom($this->ticket->status->name)?->label() ?? ucfirst($this->ticket->status->name);
|
||||
|
||||
return (new MailMessage())
|
||||
->subject("SSO Support Ticket Status Changed: {$this->ticket->ticket_id}")
|
||||
->greeting('Hello,')
|
||||
->line("The status of your support ticket {$this->ticket->ticket_id} regarding {$this->ticket->issue_category} has been updated.")
|
||||
->line("New Status: {$statusLabel}")
|
||||
->action('View Ticket Details', route('tickets.index'))
|
||||
->line('Thank you for using our application!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
$appName = $this->ticket->connectedApp->name ?? 'General Inquiry';
|
||||
$statusLabel = TicketStatusEnum::tryFrom($this->ticket->status->name)?->label() ?? ucfirst($this->ticket->status->name);
|
||||
|
||||
return [
|
||||
'ticket_id' => $this->ticket->id,
|
||||
'ticket_code' => $this->ticket->ticket_id,
|
||||
'user_name' => $this->ticket->user->name,
|
||||
'issue_category' => $this->ticket->issue_category,
|
||||
'app_name' => $appName,
|
||||
'description' => Str::limit($this->ticket->description ?? '', 100),
|
||||
'message' => "The status of your support ticket {$this->ticket->ticket_id} has been changed to {$statusLabel}.",
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -8,12 +8,13 @@
|
||||
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 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
|
||||
@ -56,11 +57,11 @@ public function getAll(): Collection
|
||||
}
|
||||
|
||||
/**
|
||||
* Get lightweight ticket list for a specific user.
|
||||
* Get lightweight ticket list for a specific user with search and status filtering.
|
||||
*
|
||||
* @return Collection<int, TicketListData>
|
||||
* @return PaginatedDataCollection<int, TicketListData>
|
||||
*/
|
||||
public function getListForUser(int $userId): Collection
|
||||
public function getListForUser(int $userId, ?string $search = null, ?string $statusName = null, int $perPage = 10): PaginatedDataCollection
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
throw new InvalidArgumentException('Invalid user ID');
|
||||
@ -80,24 +81,40 @@ public function getListForUser(int $userId): Collection
|
||||
$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()
|
||||
->get();
|
||||
->paginate($perPage);
|
||||
|
||||
return TicketListData::collect($tickets);
|
||||
return TicketListData::collect($tickets, PaginatedDataCollection::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get lightweight ticket list of all tickets.
|
||||
* Get lightweight ticket list of all tickets with search and status filtering.
|
||||
*
|
||||
* @return Collection<int, TicketListData>
|
||||
* @return PaginatedDataCollection<int, TicketListData>
|
||||
*/
|
||||
public function getList(): Collection
|
||||
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()
|
||||
->get();
|
||||
->paginate($perPage);
|
||||
|
||||
return TicketListData::collect($tickets);
|
||||
return TicketListData::collect($tickets, PaginatedDataCollection::class);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -112,6 +129,15 @@ public function getTicket(int $id): TicketData
|
||||
$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);
|
||||
}
|
||||
|
||||
@ -128,9 +154,37 @@ public function updateStatus(int $ticketId, string $statusSlug): void
|
||||
|
||||
DB::transaction(function () use ($ticketId, $statusSlug): void {
|
||||
$ticket = Ticket::findOrFail($ticketId);
|
||||
$status = TicketStatus::where('name', $statusSlug)->firstOrFail();
|
||||
|
||||
$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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -154,6 +208,7 @@ public function assignUser(int $ticketId, ?int $assignedUserId): void
|
||||
if ($assignedUserId && $assignedUserId !== $oldAssignedUserId) {
|
||||
$assignedUser = User::findOrFail($assignedUserId);
|
||||
$assignedUser->notify(new TicketAssignedNotification($ticket));
|
||||
$this->logNotificationActivity($ticket, $assignedUser, TicketAssignedNotification::class);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -188,23 +243,51 @@ public function postReply(PostReplyRequest $request): ReplyData
|
||||
'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'));
|
||||
@ -264,6 +347,7 @@ private function sendNotifications(Ticket $ticket): void
|
||||
// Notify specific users
|
||||
foreach ($ticket->notifiedUsers as $user) {
|
||||
$user->notify($notification);
|
||||
$this->logNotificationActivity($ticket, $user, TicketRaisedNotification::class);
|
||||
$notifiedUserIds[] = $user->id;
|
||||
}
|
||||
|
||||
@ -274,6 +358,7 @@ private function sendNotifications(Ticket $ticket): void
|
||||
// Avoid double notifications
|
||||
if (! in_array($user->id, $notifiedUserIds, true)) {
|
||||
$user->notify($notification);
|
||||
$this->logNotificationActivity($ticket, $user, TicketRaisedNotification::class);
|
||||
$notifiedUserIds[] = $user->id;
|
||||
}
|
||||
}
|
||||
@ -286,8 +371,27 @@ private function sendNotifications(Ticket $ticket): void
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Data\Ticket\CreateTicketRequest;
|
||||
use App\Data\Ticket\PostReplyRequest;
|
||||
use App\Data\Ui\Ticket\TicketData;
|
||||
use App\Data\Ui\Ticket\TicketListData;
|
||||
use App\Enums\DefaultUserRole;
|
||||
use App\Enums\Permissions\TicketPermissionEnum;
|
||||
use App\Enums\TicketStatusEnum;
|
||||
@ -15,12 +16,14 @@
|
||||
use Livewire\Attributes\{Computed, Layout, Title, Url};
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
use Livewire\WithPagination;
|
||||
use Spatie\LaravelData\PaginatedDataCollection;
|
||||
|
||||
new
|
||||
#[Layout('layouts.app.sidebar')]
|
||||
#[Title('Support Tickets')]
|
||||
class extends Component {
|
||||
use WithFileUploads, HandlesOperations;
|
||||
use WithFileUploads, HandlesOperations, WithPagination;
|
||||
|
||||
private TicketService $ticketService;
|
||||
private UserAppAccessService $appAccessService;
|
||||
@ -60,10 +63,23 @@ class extends Component {
|
||||
// Selected status for filtering (Admin/Support only)
|
||||
public string $statusFilter = 'all';
|
||||
|
||||
// Ticket search keyword by code/ID
|
||||
public string $search = '';
|
||||
|
||||
// Comment and assignment state
|
||||
public string $replyMessage = '';
|
||||
public $assignedUserId = null;
|
||||
|
||||
public function updatedSearch(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function updatedStatusFilter(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function boot(TicketService $ticketService, UserAppAccessService $appAccessService): void
|
||||
{
|
||||
$this->ticketService = $ticketService;
|
||||
@ -121,26 +137,21 @@ public function rendering(): void
|
||||
* Compute visible tickets based on permissions.
|
||||
*/
|
||||
#[Computed]
|
||||
public function tickets(): Collection
|
||||
public function tickets(): PaginatedDataCollection
|
||||
{
|
||||
$user = auth()->user();
|
||||
if (!$user) {
|
||||
return collect();
|
||||
return TicketListData::collect(collect(), PaginatedDataCollection::class);
|
||||
}
|
||||
|
||||
$search = trim($this->search);
|
||||
$search = empty($search) ? null : $search;
|
||||
|
||||
if ($user->hasRole('Admin') || $user->hasRole('admin') || $user->can(TicketPermissionEnum::Read->value)) {
|
||||
$tickets = $this->ticketService->getList();
|
||||
if ($this->statusFilter !== 'all') {
|
||||
$tickets = $tickets->filter(fn($t) => $t->status->value === $this->statusFilter);
|
||||
}
|
||||
return $tickets;
|
||||
return $this->ticketService->getList($search, $this->statusFilter, 10);
|
||||
}
|
||||
|
||||
$tickets = $this->ticketService->getListForUser($user->id);
|
||||
if ($this->statusFilter !== 'all') {
|
||||
$tickets = $tickets->filter(fn($t) => $t->status->value === $this->statusFilter);
|
||||
}
|
||||
return $tickets;
|
||||
return $this->ticketService->getListForUser($user->id, $search, $this->statusFilter, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -511,16 +522,29 @@ public function toggleDrawer(): void
|
||||
?>
|
||||
|
||||
<div class="space-y-6">
|
||||
@if(!auth()->user()->hasRole('Admin') && !auth()->user()->hasRole('admin') && $this->eligibleApps->isEmpty())
|
||||
<x-mary-alert icon="lucide.alert-triangle" class="alert-warning text-xs shadow-sm rounded-xl" dismissible>
|
||||
You do not currently have any active services expiring within 3 days. You can still submit a general
|
||||
inquiry ticket without choosing a service.
|
||||
</x-mary-alert>
|
||||
@endif
|
||||
|
||||
<!-- Page Header Card -->
|
||||
<x-shared.card title="Support Tickets">
|
||||
<x-slot:actions>
|
||||
@if(auth()->user()->hasRole('Admin') || auth()->user()->can('ticket:read'))
|
||||
<x-mary-input
|
||||
wire:model.live.debounce.300ms="search"
|
||||
placeholder="Search ticket ID..."
|
||||
icon="lucide.search"
|
||||
class="input-sm w-48"
|
||||
clearable
|
||||
/>
|
||||
|
||||
<x-mary-select
|
||||
wire:model.live="statusFilter"
|
||||
:options="array_merge([['id' => 'all', 'name' => 'All Statuses']], $this->statuses->map(fn($s) => ['id' => $s->name, 'name' => TicketStatusEnum::tryFrom($s->name)?->label() ?? ucfirst($s->name)])->toArray())"
|
||||
class="select-sm select-bordered w-48"
|
||||
/>
|
||||
@endif
|
||||
|
||||
<x-mary-button
|
||||
wire:click="toggleDrawer"
|
||||
@ -530,15 +554,24 @@ class="btn-primary btn-sm rounded-xl font-semibold shadow-xs"
|
||||
Raise Ticket
|
||||
</x-mary-button>
|
||||
</x-slot:actions>
|
||||
@if($this->tickets->isEmpty())
|
||||
@if($this->tickets->items()->isEmpty())
|
||||
<div class="flex flex-col items-center justify-center text-center p-16">
|
||||
<div class="p-4 bg-blue-50 dark:bg-blue-900/10 rounded-full mb-4">
|
||||
<x-mary-icon name="lucide.life-buoy" class="w-12 h-12 text-blue-500"/>
|
||||
</div>
|
||||
@if(!empty(trim($search)) || $statusFilter !== 'all')
|
||||
<h3 class="text-lg font-semibold text-gray-900">No Matching Tickets Found</h3>
|
||||
<p class="text-sm text-gray-500 max-w-sm mt-1 mb-6">
|
||||
We couldn't find any support tickets matching your search query or filter criteria. Try
|
||||
adjusting your search term or status filter.
|
||||
</p>
|
||||
@else
|
||||
<h3 class="text-lg font-semibold text-gray-900">No Support Tickets Found</h3>
|
||||
<p class="text-sm text-gray-500 max-w-sm mt-1 mb-6">
|
||||
You have not raised any support tickets yet. Click "Raise Ticket" to submit your renewal queries.
|
||||
You have not raised any support tickets yet. Click "Raise Ticket" to submit your renewal
|
||||
queries.
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<x-mary-table
|
||||
@ -550,7 +583,8 @@ class="btn-primary btn-sm rounded-xl font-semibold shadow-xs"
|
||||
['key' => 'createdAt', 'label' => 'Date Raised'],
|
||||
['key' => 'statusName', 'label' => 'Status'],
|
||||
]"
|
||||
:rows="$this->tickets"
|
||||
:rows="$this->tickets->items()"
|
||||
with-pagination
|
||||
wire:loading.class="opacity-60"
|
||||
>
|
||||
@scope('cell_ticketId', $row)
|
||||
@ -601,7 +635,7 @@ class="btn-xs btn-circle btn-ghost text-gray-500"
|
||||
tooltip="View Details"
|
||||
/>
|
||||
|
||||
@if(auth()->user()->hasRole('Admin') || auth()->user()->can('ticket:update'))
|
||||
@if(auth()->user()->hasRole('Admin') || auth()->user()->hasRole('admin') || auth()->user()->can('ticket:update') || $row->assignedUserId === auth()->id())
|
||||
<x-mary-dropdown right class="btn-xs">
|
||||
<x-slot:trigger>
|
||||
<x-mary-button icon="lucide.settings-2"
|
||||
@ -635,14 +669,6 @@ class="btn-xs btn-circle btn-ghost text-gray-500"/>
|
||||
class="w-11/12 md:w-5/12 p-6 bg-white"
|
||||
>
|
||||
<x-mary-form wire:submit="saveTicket" class="space-y-4">
|
||||
<!-- Alert if no expiring services are found -->
|
||||
@if($this->eligibleApps->isEmpty())
|
||||
<x-mary-alert icon="lucide.alert-triangle" class="alert-warning text-xs">
|
||||
You do not currently have any active services expiring within 3 days. You can still submit a general
|
||||
inquiry ticket without choosing a service.
|
||||
</x-mary-alert>
|
||||
@endif
|
||||
|
||||
<!-- Connected App Selection (Nullable & Searchable) -->
|
||||
<x-mary-choices
|
||||
wire:model="form.connectedAppId"
|
||||
@ -966,8 +992,8 @@ class="btn-xs btn-soft btn-primary btn-circle shrink-0"
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Admin operations inside modal -->
|
||||
@if(auth()->user()->hasRole('Admin') || auth()->user()->can('ticket:update'))
|
||||
<!-- Admin or Assigned operations inside modal -->
|
||||
@if(auth()->user()->hasRole('Admin') || auth()->user()->hasRole('admin') || auth()->user()->can('ticket:update') || auth()->id() === $selectedTicket->assignedUserId)
|
||||
<div class="pt-4 border-t border-gray-100 flex items-center justify-between gap-4">
|
||||
<span class="text-xs font-semibold text-gray-400 uppercase">Change Status</span>
|
||||
<div class="flex gap-2">
|
||||
|
||||
244
tests/Feature/TicketActivityLogTest.php
Normal file
244
tests/Feature/TicketActivityLogTest.php
Normal file
@ -0,0 +1,244 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Data\Ticket\{CreateTicketRequest, PostReplyRequest};
|
||||
use App\Enums\TicketStatusEnum;
|
||||
use App\Models\{Ticket, User};
|
||||
use App\Notifications\TicketStatusChangedNotification;
|
||||
use App\Services\TicketService;
|
||||
use Database\Seeders\DatabaseSeeder;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
});
|
||||
|
||||
test('assigned support user can change status of their assigned ticket and it logs activity', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$user->assignRole('user');
|
||||
|
||||
$supportUser = User::factory()->create();
|
||||
$supportUser->assignRole('user'); // normal user assigned as support staff
|
||||
|
||||
// Create ticket via service
|
||||
$dto = new CreateTicketRequest(
|
||||
userId: $user->id,
|
||||
connectedAppId: null,
|
||||
issueCategory: 'Technical Issue',
|
||||
description: 'SSO login error.',
|
||||
notifiedRoleIds: [],
|
||||
notifiedUserIds: [],
|
||||
attachment: null,
|
||||
);
|
||||
|
||||
$service = app(TicketService::class);
|
||||
$ticketData = $service->create($dto);
|
||||
|
||||
$ticket = Ticket::find($ticketData->id);
|
||||
|
||||
// Assign the ticket to the support user
|
||||
$service->assignUser($ticket->id, $supportUser->id);
|
||||
|
||||
// Act as the assigned support user
|
||||
$this->actingAs($supportUser);
|
||||
|
||||
// Status change should be successful
|
||||
$service->updateStatus($ticket->id, TicketStatusEnum::Hold->value);
|
||||
|
||||
expect($ticket->fresh()->status->name)->toBe(TicketStatusEnum::Hold->value);
|
||||
|
||||
// Verify activity log
|
||||
$activity = Activity::where('subject_id', $ticket->id)
|
||||
->where('event', 'status_changed')
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
expect($activity)->not->toBeNull()
|
||||
->and($activity->causer_id)->toBe($supportUser->id)
|
||||
->and($activity->properties['old_status'])->toBe(TicketStatusEnum::Open->value)
|
||||
->and($activity->properties['new_status'])->toBe(TicketStatusEnum::Hold->value)
|
||||
->and($activity->description)->toContain('Status updated from open to hold');
|
||||
});
|
||||
|
||||
test('unauthorized users cannot change status of ticket', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$user->assignRole('user');
|
||||
|
||||
$otherUser = User::factory()->create();
|
||||
$otherUser->assignRole('user');
|
||||
|
||||
$dto = new CreateTicketRequest(
|
||||
userId: $user->id,
|
||||
connectedAppId: null,
|
||||
issueCategory: 'Technical Issue',
|
||||
description: 'SSO login error.',
|
||||
notifiedRoleIds: [],
|
||||
notifiedUserIds: [],
|
||||
attachment: null,
|
||||
);
|
||||
|
||||
$service = app(TicketService::class);
|
||||
$ticketData = $service->create($dto);
|
||||
|
||||
// Act as other user
|
||||
$this->actingAs($otherUser);
|
||||
|
||||
// Changing status should fail with authorization exception
|
||||
$this->expectException(Illuminate\Auth\Access\AuthorizationException::class);
|
||||
$service->updateStatus($ticketData->id, TicketStatusEnum::Hold->value);
|
||||
});
|
||||
|
||||
test('commenting on a ticket logs comment activity and status transition', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$user->assignRole('user');
|
||||
|
||||
$dto = new CreateTicketRequest(
|
||||
userId: $user->id,
|
||||
connectedAppId: null,
|
||||
issueCategory: 'Technical Issue',
|
||||
description: 'SSO login error.',
|
||||
notifiedRoleIds: [],
|
||||
notifiedUserIds: [],
|
||||
attachment: null,
|
||||
);
|
||||
|
||||
$service = app(TicketService::class);
|
||||
$ticketData = $service->create($dto);
|
||||
|
||||
$ticket = Ticket::find($ticketData->id);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$replyDto = new PostReplyRequest(
|
||||
ticketId: $ticket->id,
|
||||
userId: $user->id,
|
||||
message: 'This is a test comment.',
|
||||
);
|
||||
|
||||
$service->postReply($replyDto);
|
||||
|
||||
// Verify comment activity log
|
||||
$commentActivity = Activity::where('subject_id', $ticket->id)
|
||||
->where('event', 'comment_posted')
|
||||
->first();
|
||||
|
||||
expect($commentActivity)->not->toBeNull()
|
||||
->and($commentActivity->properties['message'])->toBe('This is a test comment.')
|
||||
->and($commentActivity->description)->toContain('Comment posted on ticket');
|
||||
|
||||
// Verify transition status changed log (Open -> In Progress)
|
||||
$statusActivity = Activity::where('subject_id', $ticket->id)
|
||||
->where('event', 'status_changed')
|
||||
->first();
|
||||
|
||||
expect($statusActivity)->not->toBeNull()
|
||||
->and($statusActivity->properties['old_status'])->toBe(TicketStatusEnum::Open->value)
|
||||
->and($statusActivity->properties['new_status'])->toBe(TicketStatusEnum::InProgress->value)
|
||||
->and($statusActivity->description)->toContain('Status updated from open to in-progress');
|
||||
});
|
||||
|
||||
test('getting/opening a ticket logs ticket_opened activity', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$user->assignRole('user');
|
||||
|
||||
$dto = new CreateTicketRequest(
|
||||
userId: $user->id,
|
||||
connectedAppId: null,
|
||||
issueCategory: 'Technical Issue',
|
||||
description: 'SSO login error.',
|
||||
notifiedRoleIds: [],
|
||||
notifiedUserIds: [],
|
||||
attachment: null,
|
||||
);
|
||||
|
||||
$service = app(TicketService::class);
|
||||
$ticketData = $service->create($dto);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
// Retrieve ticket details
|
||||
$service->getTicket($ticketData->id);
|
||||
|
||||
// Verify ticket opened activity log
|
||||
$activity = Activity::where('subject_id', $ticketData->id)
|
||||
->where('event', 'ticket_opened')
|
||||
->first();
|
||||
|
||||
expect($activity)->not->toBeNull()
|
||||
->and($activity->causer_id)->toBe($user->id)
|
||||
->and($activity->description)->toContain('opened by');
|
||||
});
|
||||
|
||||
test('notifying users logs user_notified activities', function (): void {
|
||||
Notification::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
$user->assignRole('user');
|
||||
|
||||
$admin = User::where('email', 'admin@example.com')->firstOrFail();
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$dto = new CreateTicketRequest(
|
||||
userId: $user->id,
|
||||
connectedAppId: null,
|
||||
issueCategory: 'Technical Issue',
|
||||
description: 'SSO login error.',
|
||||
notifiedRoleIds: [],
|
||||
notifiedUserIds: [$admin->id],
|
||||
attachment: null,
|
||||
);
|
||||
|
||||
$service = app(TicketService::class);
|
||||
$service->create($dto);
|
||||
|
||||
// Verify user notified activity logs
|
||||
$activities = Activity::where('subject_id', Ticket::latest()->first()->id)
|
||||
->where('event', 'user_notified')
|
||||
->get();
|
||||
|
||||
// Should log notifications for the specifically notified admin user and all seeded admins
|
||||
expect($activities->count())->toBeGreaterThan(0);
|
||||
|
||||
$adminNotificationLog = $activities->first(fn ($act) => $act->properties['recipient_id'] === $admin->id);
|
||||
expect($adminNotificationLog)->not->toBeNull()
|
||||
->and($adminNotificationLog->properties['recipient_name'])->toBe($admin->name)
|
||||
->and($adminNotificationLog->description)->toContain('Notified user');
|
||||
});
|
||||
|
||||
test('status changes trigger status changed notification to the user', function (): void {
|
||||
Notification::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
$user->assignRole('user');
|
||||
|
||||
$admin = User::where('email', 'admin@example.com')->firstOrFail();
|
||||
|
||||
$dto = new CreateTicketRequest(
|
||||
userId: $user->id,
|
||||
connectedAppId: null,
|
||||
issueCategory: 'Technical Issue',
|
||||
description: 'SSO login error.',
|
||||
notifiedRoleIds: [],
|
||||
notifiedUserIds: [],
|
||||
attachment: null,
|
||||
);
|
||||
|
||||
$service = app(TicketService::class);
|
||||
$ticketData = $service->create($dto);
|
||||
|
||||
$ticket = Ticket::find($ticketData->id);
|
||||
|
||||
// Act as admin to change status
|
||||
$this->actingAs($admin);
|
||||
|
||||
$service->updateStatus($ticket->id, TicketStatusEnum::Hold->value);
|
||||
|
||||
// The user who raised the ticket should receive the status changed notification
|
||||
Notification::assertSentTo(
|
||||
$user,
|
||||
TicketStatusChangedNotification::class
|
||||
);
|
||||
});
|
||||
188
tests/Feature/TicketSearchAndPaginationTest.php
Normal file
188
tests/Feature/TicketSearchAndPaginationTest.php
Normal file
@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Data\Ticket\CreateTicketRequest;
|
||||
use App\Enums\TicketStatusEnum;
|
||||
use App\Models\{Ticket, User};
|
||||
use App\Services\TicketService;
|
||||
use Database\Seeders\DatabaseSeeder;
|
||||
use Livewire\Livewire;
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
});
|
||||
|
||||
test('anyone can search tickets by ticket id', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$user->assignRole('user');
|
||||
$this->actingAs($user);
|
||||
|
||||
$service = app(TicketService::class);
|
||||
|
||||
// Create Ticket 1
|
||||
$dto1 = new CreateTicketRequest(
|
||||
userId: $user->id,
|
||||
connectedAppId: null,
|
||||
issueCategory: 'Technical Issue',
|
||||
description: 'First test ticket.',
|
||||
notifiedRoleIds: [],
|
||||
notifiedUserIds: [],
|
||||
attachment: null,
|
||||
);
|
||||
$ticket1Data = $service->create($dto1);
|
||||
|
||||
// Create Ticket 2
|
||||
$dto2 = new CreateTicketRequest(
|
||||
userId: $user->id,
|
||||
connectedAppId: null,
|
||||
issueCategory: 'Billing Query',
|
||||
description: 'Second test ticket.',
|
||||
notifiedRoleIds: [],
|
||||
notifiedUserIds: [],
|
||||
attachment: null,
|
||||
);
|
||||
$ticket2Data = $service->create($dto2);
|
||||
|
||||
// Search by Ticket 1 code/ID using Livewire component
|
||||
Livewire::test('pages::tickets.index')
|
||||
->set('search', $ticket1Data->ticketId)
|
||||
->assertSee($ticket1Data->ticketId)
|
||||
->assertDontSee($ticket2Data->ticketId);
|
||||
|
||||
// Search by Ticket 2 code/ID
|
||||
Livewire::test('pages::tickets.index')
|
||||
->set('search', $ticket2Data->ticketId)
|
||||
->assertSee($ticket2Data->ticketId)
|
||||
->assertDontSee($ticket1Data->ticketId);
|
||||
});
|
||||
|
||||
test('tickets table is paginated to 10 items per page', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$user->assignRole('user');
|
||||
$this->actingAs($user);
|
||||
|
||||
$service = app(TicketService::class);
|
||||
|
||||
// Create 12 tickets
|
||||
$createdTicketIds = [];
|
||||
for ($i = 1; $i <= 12; $i++) {
|
||||
$dto = new CreateTicketRequest(
|
||||
userId: $user->id,
|
||||
connectedAppId: null,
|
||||
issueCategory: 'Technical Issue',
|
||||
description: "Ticket number {$i}.",
|
||||
notifiedRoleIds: [],
|
||||
notifiedUserIds: [],
|
||||
attachment: null,
|
||||
);
|
||||
$ticketData = $service->create($dto);
|
||||
Ticket::where('id', $ticketData->id)->update([
|
||||
'created_at' => now()->addSeconds($i),
|
||||
]);
|
||||
$createdTicketIds[] = $ticketData->ticketId;
|
||||
}
|
||||
|
||||
$component = Livewire::test('pages::tickets.index');
|
||||
|
||||
// On page 1, we should see the latest 10 tickets (since we order by latest, the last 10 created)
|
||||
$page1Tickets = array_slice(array_reverse($createdTicketIds), 0, 10);
|
||||
$page2Tickets = array_slice(array_reverse($createdTicketIds), 10, 2);
|
||||
|
||||
foreach ($page1Tickets as $id) {
|
||||
$component->assertSee($id);
|
||||
}
|
||||
|
||||
foreach ($page2Tickets as $id) {
|
||||
$component->assertDontSee($id);
|
||||
}
|
||||
|
||||
// Go to page 2
|
||||
$component->call('gotoPage', 2);
|
||||
|
||||
foreach ($page1Tickets as $id) {
|
||||
$component->assertDontSee($id);
|
||||
}
|
||||
|
||||
foreach ($page2Tickets as $id) {
|
||||
$component->assertSee($id);
|
||||
}
|
||||
});
|
||||
|
||||
test('status filtering works on paginated data at query level', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$user->assignRole('user');
|
||||
$this->actingAs($user);
|
||||
|
||||
$service = app(TicketService::class);
|
||||
$admin = User::where('email', 'admin@example.com')->firstOrFail();
|
||||
|
||||
// Create Ticket 1 (Open status by default)
|
||||
$dto1 = new CreateTicketRequest(
|
||||
userId: $user->id,
|
||||
connectedAppId: null,
|
||||
issueCategory: 'Technical Issue',
|
||||
description: 'Open ticket.',
|
||||
notifiedRoleIds: [],
|
||||
notifiedUserIds: [],
|
||||
attachment: null,
|
||||
);
|
||||
$ticket1Data = $service->create($dto1);
|
||||
|
||||
// Create Ticket 2 and change its status to Hold
|
||||
$dto2 = new CreateTicketRequest(
|
||||
userId: $user->id,
|
||||
connectedAppId: null,
|
||||
issueCategory: 'Billing Query',
|
||||
description: 'Hold ticket.',
|
||||
notifiedRoleIds: [],
|
||||
notifiedUserIds: [],
|
||||
attachment: null,
|
||||
);
|
||||
$ticket2Data = $service->create($dto2);
|
||||
|
||||
// Act as admin to change status
|
||||
$this->actingAs($admin);
|
||||
$service->updateStatus($ticket2Data->id, TicketStatusEnum::Hold->value);
|
||||
|
||||
// Act as user to view
|
||||
$this->actingAs($user);
|
||||
|
||||
// Filter by Open
|
||||
Livewire::test('pages::tickets.index')
|
||||
->set('statusFilter', TicketStatusEnum::Open->value)
|
||||
->assertSee($ticket1Data->ticketId)
|
||||
->assertDontSee($ticket2Data->ticketId);
|
||||
|
||||
// Filter by Hold
|
||||
Livewire::test('pages::tickets.index')
|
||||
->set('statusFilter', TicketStatusEnum::Hold->value)
|
||||
->assertSee($ticket2Data->ticketId)
|
||||
->assertDontSee($ticket1Data->ticketId);
|
||||
});
|
||||
|
||||
test('normal user can see status filter and see custom empty state message when no results match search', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$user->assignRole('user');
|
||||
$this->actingAs($user);
|
||||
|
||||
// Initial load: Should see default empty state message
|
||||
Livewire::test('pages::tickets.index')
|
||||
->assertSee('No Support Tickets Found')
|
||||
->assertDontSee('No Matching Tickets Found')
|
||||
->assertSee('You have not raised any support tickets yet.');
|
||||
|
||||
// Search for non-existent ticket ID: Should see the custom empty search state message
|
||||
Livewire::test('pages::tickets.index')
|
||||
->set('search', 'TKT-NONEXISTENT-99')
|
||||
->assertSee('No Matching Tickets Found')
|
||||
->assertDontSee('No Support Tickets Found')
|
||||
->assertSee('matching your search query or filter criteria');
|
||||
|
||||
// Filter by Hold status: Should see the custom empty filter state message
|
||||
Livewire::test('pages::tickets.index')
|
||||
->set('statusFilter', 'hold')
|
||||
->assertSee('No Matching Tickets Found')
|
||||
->assertDontSee('No Support Tickets Found')
|
||||
->assertSee('matching your search query or filter criteria');
|
||||
});
|
||||
@ -28,7 +28,8 @@
|
||||
|
||||
$this->get(route('tickets.index'))
|
||||
->assertOk()
|
||||
->assertSee('Support Tickets');
|
||||
->assertSee('Support Tickets')
|
||||
->assertSee('You do not currently have any active services expiring within 3 days');
|
||||
});
|
||||
|
||||
test('submitting a support ticket creates the ticket and generates a unique ticket ID', function (): void {
|
||||
@ -306,7 +307,7 @@
|
||||
$service = app(TicketService::class);
|
||||
$tickets = $service->getListForUser($supportUser->id);
|
||||
|
||||
expect($tickets->contains('id', $ticket->id))->toBeTrue();
|
||||
expect(collect($tickets->all())->contains('id', $ticket->id))->toBeTrue();
|
||||
});
|
||||
|
||||
test('commenting on a ticket notifies the creator and assigned user', function (): void {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user