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 string $createdAt,
|
||||||
public TicketStatusEnum $status,
|
public TicketStatusEnum $status,
|
||||||
public string $statusSlug,
|
public string $statusSlug,
|
||||||
|
public ?int $assignedUserId = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public static function fromModel(Ticket $ticket): self
|
public static function fromModel(Ticket $ticket): self
|
||||||
@ -32,6 +33,7 @@ public static function fromModel(Ticket $ticket): self
|
|||||||
createdAt: $ticket->created_at->toDateTimeString(),
|
createdAt: $ticket->created_at->toDateTimeString(),
|
||||||
status: TicketStatusEnum::from($ticket->status->name),
|
status: TicketStatusEnum::from($ticket->status->name),
|
||||||
statusSlug: $ticket->status->name,
|
statusSlug: $ticket->status->name,
|
||||||
|
assignedUserId: $ticket->assigned_user_id,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Notifications\{TicketAssignedNotification, TicketCommentedNotification, TicketRaisedNotification};
|
use App\Notifications\{TicketAssignedNotification, TicketCommentedNotification, TicketRaisedNotification, TicketStatusChangedNotification};
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
class ResolveNotificationRouteController extends Controller
|
class ResolveNotificationRouteController extends Controller
|
||||||
@ -23,7 +23,8 @@ public function __invoke(Request $request, string $id)
|
|||||||
$url = match ($notification->type) {
|
$url = match ($notification->type) {
|
||||||
TicketRaisedNotification::class,
|
TicketRaisedNotification::class,
|
||||||
TicketAssignedNotification::class,
|
TicketAssignedNotification::class,
|
||||||
TicketCommentedNotification::class => route('tickets.index', [
|
TicketCommentedNotification::class,
|
||||||
|
TicketStatusChangedNotification::class => route('tickets.index', [
|
||||||
'ticket' => data_get($notification->data, 'ticket_id'),
|
'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\Data\Ui\Ticket\{ReplyData, TicketData, TicketListData};
|
||||||
use App\Enums\TicketStatusEnum;
|
use App\Enums\TicketStatusEnum;
|
||||||
use App\Models\{Ticket, TicketReply, TicketStatus, User};
|
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\Auth\Access\AuthorizationException;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
|
use Spatie\LaravelData\PaginatedDataCollection;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
final class TicketService
|
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) {
|
if ($userId <= 0) {
|
||||||
throw new InvalidArgumentException('Invalid user ID');
|
throw new InvalidArgumentException('Invalid user ID');
|
||||||
@ -80,24 +81,40 @@ public function getListForUser(int $userId): Collection
|
|||||||
$q->whereIn('roles.id', $roleIds);
|
$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()
|
->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'])
|
$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()
|
->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'])
|
$ticket = Ticket::with(['user', 'connectedApp', 'status', 'notifiedRoles', 'notifiedUsers', 'attachments', 'assignedUser', 'replies.user.roles'])
|
||||||
->findOrFail($id);
|
->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);
|
return TicketData::fromModel($ticket);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -128,9 +154,37 @@ public function updateStatus(int $ticketId, string $statusSlug): void
|
|||||||
|
|
||||||
DB::transaction(function () use ($ticketId, $statusSlug): void {
|
DB::transaction(function () use ($ticketId, $statusSlug): void {
|
||||||
$ticket = Ticket::findOrFail($ticketId);
|
$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]);
|
$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) {
|
if ($assignedUserId && $assignedUserId !== $oldAssignedUserId) {
|
||||||
$assignedUser = User::findOrFail($assignedUserId);
|
$assignedUser = User::findOrFail($assignedUserId);
|
||||||
$assignedUser->notify(new TicketAssignedNotification($ticket));
|
$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),
|
'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
|
// Transition ticket status from open to in-progress if it's currently open
|
||||||
$ticket->load(['status', 'user', 'assignedUser']);
|
$ticket->load(['status', 'user', 'assignedUser']);
|
||||||
if ($ticket->status->name === TicketStatusEnum::Open->value) {
|
if ($ticket->status->name === TicketStatusEnum::Open->value) {
|
||||||
$inProgressStatus = TicketStatus::where('name', TicketStatusEnum::InProgress->value)->first();
|
$inProgressStatus = TicketStatus::where('name', TicketStatusEnum::InProgress->value)->first();
|
||||||
if ($inProgressStatus) {
|
if ($inProgressStatus) {
|
||||||
|
$oldStatus = $ticket->status->name;
|
||||||
$ticket->update(['status_id' => $inProgressStatus->id]);
|
$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
|
// Notify ticket raiser if they did not make this comment
|
||||||
if ($ticket->user_id !== $request->userId) {
|
if ($ticket->user_id !== $request->userId) {
|
||||||
$ticket->user->notify(new TicketCommentedNotification($ticket, $reply));
|
$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
|
// 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) {
|
if ($ticket->assigned_user_id && $ticket->assigned_user_id !== $request->userId) {
|
||||||
$ticket->assignedUser->notify(new TicketCommentedNotification($ticket, $reply));
|
$ticket->assignedUser->notify(new TicketCommentedNotification($ticket, $reply));
|
||||||
|
$this->logNotificationActivity($ticket, $ticket->assignedUser, TicketCommentedNotification::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ReplyData::fromModel($reply->load('user.roles'));
|
return ReplyData::fromModel($reply->load('user.roles'));
|
||||||
@ -264,6 +347,7 @@ private function sendNotifications(Ticket $ticket): void
|
|||||||
// Notify specific users
|
// Notify specific users
|
||||||
foreach ($ticket->notifiedUsers as $user) {
|
foreach ($ticket->notifiedUsers as $user) {
|
||||||
$user->notify($notification);
|
$user->notify($notification);
|
||||||
|
$this->logNotificationActivity($ticket, $user, TicketRaisedNotification::class);
|
||||||
$notifiedUserIds[] = $user->id;
|
$notifiedUserIds[] = $user->id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -274,6 +358,7 @@ private function sendNotifications(Ticket $ticket): void
|
|||||||
// Avoid double notifications
|
// Avoid double notifications
|
||||||
if (! in_array($user->id, $notifiedUserIds, true)) {
|
if (! in_array($user->id, $notifiedUserIds, true)) {
|
||||||
$user->notify($notification);
|
$user->notify($notification);
|
||||||
|
$this->logNotificationActivity($ticket, $user, TicketRaisedNotification::class);
|
||||||
$notifiedUserIds[] = $user->id;
|
$notifiedUserIds[] = $user->id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -286,8 +371,27 @@ private function sendNotifications(Ticket $ticket): void
|
|||||||
foreach ($admins as $admin) {
|
foreach ($admins as $admin) {
|
||||||
if (! in_array($admin->id, $notifiedUserIds, true)) {
|
if (! in_array($admin->id, $notifiedUserIds, true)) {
|
||||||
$admin->notify($notification);
|
$admin->notify($notification);
|
||||||
|
$this->logNotificationActivity($ticket, $admin, TicketRaisedNotification::class);
|
||||||
$notifiedUserIds[] = $admin->id;
|
$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\CreateTicketRequest;
|
||||||
use App\Data\Ticket\PostReplyRequest;
|
use App\Data\Ticket\PostReplyRequest;
|
||||||
use App\Data\Ui\Ticket\TicketData;
|
use App\Data\Ui\Ticket\TicketData;
|
||||||
|
use App\Data\Ui\Ticket\TicketListData;
|
||||||
use App\Enums\DefaultUserRole;
|
use App\Enums\DefaultUserRole;
|
||||||
use App\Enums\Permissions\TicketPermissionEnum;
|
use App\Enums\Permissions\TicketPermissionEnum;
|
||||||
use App\Enums\TicketStatusEnum;
|
use App\Enums\TicketStatusEnum;
|
||||||
@ -15,12 +16,14 @@
|
|||||||
use Livewire\Attributes\{Computed, Layout, Title, Url};
|
use Livewire\Attributes\{Computed, Layout, Title, Url};
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Livewire\WithFileUploads;
|
use Livewire\WithFileUploads;
|
||||||
|
use Livewire\WithPagination;
|
||||||
|
use Spatie\LaravelData\PaginatedDataCollection;
|
||||||
|
|
||||||
new
|
new
|
||||||
#[Layout('layouts.app.sidebar')]
|
#[Layout('layouts.app.sidebar')]
|
||||||
#[Title('Support Tickets')]
|
#[Title('Support Tickets')]
|
||||||
class extends Component {
|
class extends Component {
|
||||||
use WithFileUploads, HandlesOperations;
|
use WithFileUploads, HandlesOperations, WithPagination;
|
||||||
|
|
||||||
private TicketService $ticketService;
|
private TicketService $ticketService;
|
||||||
private UserAppAccessService $appAccessService;
|
private UserAppAccessService $appAccessService;
|
||||||
@ -60,10 +63,23 @@ class extends Component {
|
|||||||
// Selected status for filtering (Admin/Support only)
|
// Selected status for filtering (Admin/Support only)
|
||||||
public string $statusFilter = 'all';
|
public string $statusFilter = 'all';
|
||||||
|
|
||||||
|
// Ticket search keyword by code/ID
|
||||||
|
public string $search = '';
|
||||||
|
|
||||||
// Comment and assignment state
|
// Comment and assignment state
|
||||||
public string $replyMessage = '';
|
public string $replyMessage = '';
|
||||||
public $assignedUserId = null;
|
public $assignedUserId = null;
|
||||||
|
|
||||||
|
public function updatedSearch(): void
|
||||||
|
{
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedStatusFilter(): void
|
||||||
|
{
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
public function boot(TicketService $ticketService, UserAppAccessService $appAccessService): void
|
public function boot(TicketService $ticketService, UserAppAccessService $appAccessService): void
|
||||||
{
|
{
|
||||||
$this->ticketService = $ticketService;
|
$this->ticketService = $ticketService;
|
||||||
@ -121,26 +137,21 @@ public function rendering(): void
|
|||||||
* Compute visible tickets based on permissions.
|
* Compute visible tickets based on permissions.
|
||||||
*/
|
*/
|
||||||
#[Computed]
|
#[Computed]
|
||||||
public function tickets(): Collection
|
public function tickets(): PaginatedDataCollection
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
if (!$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)) {
|
if ($user->hasRole('Admin') || $user->hasRole('admin') || $user->can(TicketPermissionEnum::Read->value)) {
|
||||||
$tickets = $this->ticketService->getList();
|
return $this->ticketService->getList($search, $this->statusFilter, 10);
|
||||||
if ($this->statusFilter !== 'all') {
|
|
||||||
$tickets = $tickets->filter(fn($t) => $t->status->value === $this->statusFilter);
|
|
||||||
}
|
|
||||||
return $tickets;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$tickets = $this->ticketService->getListForUser($user->id);
|
return $this->ticketService->getListForUser($user->id, $search, $this->statusFilter, 10);
|
||||||
if ($this->statusFilter !== 'all') {
|
|
||||||
$tickets = $tickets->filter(fn($t) => $t->status->value === $this->statusFilter);
|
|
||||||
}
|
|
||||||
return $tickets;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -511,16 +522,29 @@ public function toggleDrawer(): void
|
|||||||
?>
|
?>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<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 -->
|
<!-- Page Header Card -->
|
||||||
<x-shared.card title="Support Tickets">
|
<x-shared.card title="Support Tickets">
|
||||||
<x-slot:actions>
|
<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
|
<x-mary-select
|
||||||
wire:model.live="statusFilter"
|
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())"
|
: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"
|
class="select-sm select-bordered w-48"
|
||||||
/>
|
/>
|
||||||
@endif
|
|
||||||
|
|
||||||
<x-mary-button
|
<x-mary-button
|
||||||
wire:click="toggleDrawer"
|
wire:click="toggleDrawer"
|
||||||
@ -530,15 +554,24 @@ class="btn-primary btn-sm rounded-xl font-semibold shadow-xs"
|
|||||||
Raise Ticket
|
Raise Ticket
|
||||||
</x-mary-button>
|
</x-mary-button>
|
||||||
</x-slot:actions>
|
</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="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">
|
<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"/>
|
<x-mary-icon name="lucide.life-buoy" class="w-12 h-12 text-blue-500"/>
|
||||||
</div>
|
</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>
|
<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">
|
<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>
|
</p>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
<x-mary-table
|
<x-mary-table
|
||||||
@ -550,7 +583,8 @@ class="btn-primary btn-sm rounded-xl font-semibold shadow-xs"
|
|||||||
['key' => 'createdAt', 'label' => 'Date Raised'],
|
['key' => 'createdAt', 'label' => 'Date Raised'],
|
||||||
['key' => 'statusName', 'label' => 'Status'],
|
['key' => 'statusName', 'label' => 'Status'],
|
||||||
]"
|
]"
|
||||||
:rows="$this->tickets"
|
:rows="$this->tickets->items()"
|
||||||
|
with-pagination
|
||||||
wire:loading.class="opacity-60"
|
wire:loading.class="opacity-60"
|
||||||
>
|
>
|
||||||
@scope('cell_ticketId', $row)
|
@scope('cell_ticketId', $row)
|
||||||
@ -601,7 +635,7 @@ class="btn-xs btn-circle btn-ghost text-gray-500"
|
|||||||
tooltip="View Details"
|
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-mary-dropdown right class="btn-xs">
|
||||||
<x-slot:trigger>
|
<x-slot:trigger>
|
||||||
<x-mary-button icon="lucide.settings-2"
|
<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"
|
class="w-11/12 md:w-5/12 p-6 bg-white"
|
||||||
>
|
>
|
||||||
<x-mary-form wire:submit="saveTicket" class="space-y-4">
|
<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) -->
|
<!-- Connected App Selection (Nullable & Searchable) -->
|
||||||
<x-mary-choices
|
<x-mary-choices
|
||||||
wire:model="form.connectedAppId"
|
wire:model="form.connectedAppId"
|
||||||
@ -966,8 +992,8 @@ class="btn-xs btn-soft btn-primary btn-circle shrink-0"
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<!-- Admin operations inside modal -->
|
<!-- Admin or Assigned operations inside modal -->
|
||||||
@if(auth()->user()->hasRole('Admin') || auth()->user()->can('ticket:update'))
|
@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">
|
<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>
|
<span class="text-xs font-semibold text-gray-400 uppercase">Change Status</span>
|
||||||
<div class="flex gap-2">
|
<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'))
|
$this->get(route('tickets.index'))
|
||||||
->assertOk()
|
->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 {
|
test('submitting a support ticket creates the ticket and generates a unique ticket ID', function (): void {
|
||||||
@ -306,7 +307,7 @@
|
|||||||
$service = app(TicketService::class);
|
$service = app(TicketService::class);
|
||||||
$tickets = $service->getListForUser($supportUser->id);
|
$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 {
|
test('commenting on a ticket notifies the creator and assigned user', function (): void {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user