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:
= 2026-05-28 13:22:36 +00:00
parent df4a84b970
commit 8a41ae6ac1
8 changed files with 689 additions and 54 deletions

View File

@ -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,
);
}
}

View File

@ -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'),
]),

View 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}.",
];
}
}

View File

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

View File

@ -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">

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

View 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');
});

View File

@ -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 {