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();
$ticket->update(['status_id' => $status->id]);
$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-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-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"
/>
<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>
<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.
</p>
@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.
</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 {