From cac5f4905b6b08009a35326c5c6d2c754f57f355 Mon Sep 17 00:00:00 2001 From: = Date: Mon, 25 May 2026 13:04:43 +0000 Subject: [PATCH] WIP: implement ticketing system with support for notifications and attachments - Added database migrations for tickets, statuses, roles, users, and related associations. - Introduced models and enums for ticket status, permissions, and notifications. - Implemented `TicketService` for ticket management and notification handling. - Created Livewire form and UI for raising tickets with file attachments and routing options. - Added tests covering ticket creation, validation, notifications, and file uploads. --- app/Data/Ticket/CreateTicketRequest.php | 23 + app/Data/Ui/Ticket/TicketData.php | 58 ++ .../Permissions/TicketPermissionEnum.php | 14 + app/Enums/TicketStatusEnum.php | 17 + app/Livewire/Forms/CreateTicketForm.php | 62 ++ app/Models/Ticket.php | 95 +++ app/Models/TicketAttachment.php | 30 + app/Models/TicketStatus.php | 15 + .../TicketRaisedNotification.php | 67 ++ app/Services/TicketAttachmentService.php | 40 + app/Services/TicketService.php | 159 ++++ ...5_25_105900_create_notifications_table.php | 33 + ...25_160000_create_ticket_statuses_table.php | 31 + ...2026_05_25_160001_create_tickets_table.php | 63 ++ database/seeders/DatabaseSeeder.php | 1 + .../DefaultRoleWithPermissionSeeder.php | 9 +- database/seeders/TicketStatusSeeder.php | 44 ++ .../components/dashboard-sidebar.blade.php | 15 +- .../components/dashbord-topbar.blade.php | 50 +- .../views/pages/dashboards/⚡user.blade.php | 22 +- .../views/pages/tickets/⚡index.blade.php | 738 ++++++++++++++++++ routes/web.php | 8 + tests/Feature/TicketSystemTest.php | 185 +++++ 23 files changed, 1768 insertions(+), 11 deletions(-) create mode 100644 app/Data/Ticket/CreateTicketRequest.php create mode 100644 app/Data/Ui/Ticket/TicketData.php create mode 100644 app/Enums/Permissions/TicketPermissionEnum.php create mode 100644 app/Enums/TicketStatusEnum.php create mode 100644 app/Livewire/Forms/CreateTicketForm.php create mode 100644 app/Models/Ticket.php create mode 100644 app/Models/TicketAttachment.php create mode 100644 app/Models/TicketStatus.php create mode 100644 app/Notifications/TicketRaisedNotification.php create mode 100644 app/Services/TicketAttachmentService.php create mode 100644 app/Services/TicketService.php create mode 100644 database/migrations/2026_05_25_105900_create_notifications_table.php create mode 100644 database/migrations/2026_05_25_160000_create_ticket_statuses_table.php create mode 100644 database/migrations/2026_05_25_160001_create_tickets_table.php create mode 100644 database/seeders/TicketStatusSeeder.php create mode 100644 resources/views/pages/tickets/⚡index.blade.php create mode 100644 tests/Feature/TicketSystemTest.php diff --git a/app/Data/Ticket/CreateTicketRequest.php b/app/Data/Ticket/CreateTicketRequest.php new file mode 100644 index 0000000..8524419 --- /dev/null +++ b/app/Data/Ticket/CreateTicketRequest.php @@ -0,0 +1,23 @@ +attachments->map(fn ($attachment) => [ + 'id' => $attachment->id, + 'file_name' => $attachment->file_name, + 'file_path' => $attachment->file_path, + 'file_size' => $attachment->file_size, + 'mime_type' => $attachment->mime_type, + ])->toArray(); + + return new self( + id: $ticket->id, + ticketId: $ticket->ticket_id, + userId: $ticket->user_id, + userName: $ticket->user->name, + connectedAppId: $ticket->connected_app_id, + appName: $ticket->connectedApp?->name, + issueCategory: $ticket->issue_category, + description: $ticket->description, + statusId: $ticket->status_id, + statusName: $ticket->status->name, + statusSlug: $ticket->status->slug, + notifiedRoleNames: $ticket->notifiedRoles->pluck('name')->toArray(), + notifiedUserNames: $ticket->notifiedUsers->pluck('name')->toArray(), + createdAt: $ticket->created_at->toDateTimeString(), + attachments: $attachments, + ); + } +} diff --git a/app/Enums/Permissions/TicketPermissionEnum.php b/app/Enums/Permissions/TicketPermissionEnum.php new file mode 100644 index 0000000..b729ef7 --- /dev/null +++ b/app/Enums/Permissions/TicketPermissionEnum.php @@ -0,0 +1,14 @@ + + */ + public function rules(): array + { + return [ + 'connectedAppId' => 'nullable|exists:connected_apps,id', + 'issueCategory' => 'required|string', + 'routingType' => 'nullable|in:role,user,none', + 'notifiedRoleIds' => 'role' === $this->routingType ? 'required|array|min:1' : 'nullable|array', + 'notifiedRoleIds.*' => 'role' === $this->routingType ? 'exists:roles,id' : 'nullable', + 'notifiedUserIds' => 'user' === $this->routingType ? 'required|array|min:1' : 'nullable|array', + 'notifiedUserIds.*' => 'user' === $this->routingType ? 'exists:users,id' : 'nullable', + 'description' => 'Others' === $this->issueCategory ? 'required|string|min:20' : 'nullable|string', + 'attachment' => 'Others' === $this->issueCategory ? 'nullable|file|mimes:jpg,jpeg,png,pdf|max:5120' : 'nullable', + ]; + } + + /** + * Define validation attributes. + * + * @return array + */ + public function validationAttributes(): array + { + return [ + 'connectedAppId' => 'service', + 'issueCategory' => 'issue category', + 'routingType' => 'routing type', + 'notifiedRoleIds' => 'notified roles', + 'notifiedUserIds' => 'notified users', + 'description' => 'description', + 'attachment' => 'file attachment', + ]; + } +} diff --git a/app/Models/Ticket.php b/app/Models/Ticket.php new file mode 100644 index 0000000..8d5e076 --- /dev/null +++ b/app/Models/Ticket.php @@ -0,0 +1,95 @@ +ticket_id)) { + do { + $ticketId = 'TKT-'.mb_strtoupper(Str::random(8)); + } while (static::query()->where('ticket_id', $ticketId)->exists()); + + $ticket->ticket_id = $ticketId; + } + }); + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + /** + * @return BelongsTo + */ + public function connectedApp(): BelongsTo + { + return $this->belongsTo(ConnectedApp::class, 'connected_app_id'); + } + + /** + * @return BelongsTo + */ + public function status(): BelongsTo + { + return $this->belongsTo(TicketStatus::class, 'status_id'); + } + + /** + * @return BelongsToMany + */ + public function notifiedRoles(): BelongsToMany + { + return $this->belongsToMany( + Role::class, + 'ticket_notified_roles', + 'ticket_id', + 'role_id' + ); + } + + /** + * @return BelongsToMany + */ + public function notifiedUsers(): BelongsToMany + { + return $this->belongsToMany( + User::class, + 'ticket_notified_users', + 'ticket_id', + 'user_id' + ); + } + + /** + * @return HasMany + */ + public function attachments(): HasMany + { + return $this->hasMany(TicketAttachment::class, 'ticket_id'); + } +} diff --git a/app/Models/TicketAttachment.php b/app/Models/TicketAttachment.php new file mode 100644 index 0000000..288fcfe --- /dev/null +++ b/app/Models/TicketAttachment.php @@ -0,0 +1,30 @@ + + */ + public function ticket(): BelongsTo + { + return $this->belongsTo(Ticket::class, 'ticket_id'); + } +} diff --git a/app/Models/TicketStatus.php b/app/Models/TicketStatus.php new file mode 100644 index 0000000..8efb8dc --- /dev/null +++ b/app/Models/TicketStatus.php @@ -0,0 +1,15 @@ + + */ + public function via(object $notifiable): array + { + return ['database', 'mail']; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage()) + ->subject("SSO Support Ticket Raised: {$this->ticket->ticket_id}") + ->greeting('Hello,') + ->line("A new support ticket has been raised regarding {$this->ticket->issue_category}.") + ->line('Ticket ID: '.$this->ticket->ticket_id) + ->line('Raised By: '.$this->ticket->user->name) + ->line('Description: '.($this->ticket->description ?? 'N/A')) + ->action('View Ticket Details', route('tickets.index')) + ->line('Thank you for using our application!'); + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + $appName = $this->ticket->connectedApp->name ?? 'General Inquiry'; + + 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' => "New ticket {$this->ticket->ticket_id} raised by {$this->ticket->user->name} ({$appName}).", + ]; + } +} diff --git a/app/Services/TicketAttachmentService.php b/app/Services/TicketAttachmentService.php new file mode 100644 index 0000000..c37cd6f --- /dev/null +++ b/app/Services/TicketAttachmentService.php @@ -0,0 +1,40 @@ +store('tickets/attachments', 'public'); + + /** @var TicketAttachment $attachment */ + $attachment = TicketAttachment::query()->create([ + 'ticket_id' => $ticketId, + 'file_path' => $path, + 'file_name' => $file->getClientOriginalName(), + 'file_size' => $file->getSize(), + 'mime_type' => $file->getMimeType() ?? 'application/octet-stream', + ]); + + return $attachment; + }); + } +} diff --git a/app/Services/TicketService.php b/app/Services/TicketService.php new file mode 100644 index 0000000..f777ba4 --- /dev/null +++ b/app/Services/TicketService.php @@ -0,0 +1,159 @@ +where('slug', TicketStatusEnum::Open->value) + ->firstOrFail(); + + /** @var Ticket $ticket */ + $ticket = Ticket::query()->create([ + 'user_id' => $request->userId, + 'connected_app_id' => $request->connectedAppId, + 'issue_category' => $request->issueCategory, + 'description' => $request->description, + 'status_id' => $defaultStatus->id, + ]); + + if (! empty($request->notifiedRoleIds)) { + $ticket->notifiedRoles()->sync($request->notifiedRoleIds); + } + + if (! empty($request->notifiedUserIds)) { + $ticket->notifiedUsers()->sync($request->notifiedUserIds); + } + + if ($request->attachment instanceof UploadedFile) { + $this->attachmentService->store($ticket->id, $request->attachment); + } + + // Load relations to build complete DTO and trigger notifications + $ticket->load(['user', 'connectedApp', 'status', 'notifiedRoles', 'notifiedUsers', 'attachments']); + + $this->sendNotifications($ticket); + + return TicketData::fromModel($ticket); + }); + } + + /** + * Get all tickets raised by a specific user. + * + * @return Collection + */ + public function getAllForUser(int $userId): Collection + { + if ($userId <= 0) { + throw new InvalidArgumentException('Invalid user ID'); + } + + $tickets = Ticket::with(['user', 'connectedApp', 'status', 'notifiedRoles', 'notifiedUsers', 'attachments']) + ->where('user_id', $userId) + ->latest() + ->get(); + + return TicketData::collect($tickets); + } + + /** + * Get all tickets in the system (for admins/support). + * + * @return Collection + */ + public function getAll(): Collection + { + $tickets = Ticket::with(['user', 'connectedApp', 'status', 'notifiedRoles', 'notifiedUsers', 'attachments']) + ->latest() + ->get(); + + return TicketData::collect($tickets); + } + + /** + * Get a specific ticket. + */ + public function getTicket(int $id): TicketData + { + if ($id <= 0) { + throw new InvalidArgumentException('Invalid ticket ID'); + } + + $ticket = Ticket::with(['user', 'connectedApp', 'status', 'notifiedRoles', 'notifiedUsers', 'attachments']) + ->findOrFail($id); + + return TicketData::fromModel($ticket); + } + + /** + * Update the status of a ticket. + * + * @throws Throwable + */ + public function updateStatus(int $ticketId, string $statusSlug): void + { + if ($ticketId <= 0) { + throw new InvalidArgumentException('Invalid ticket ID'); + } + + DB::transaction(function () use ($ticketId, $statusSlug): void { + $ticket = Ticket::findOrFail($ticketId); + $status = TicketStatus::where('slug', $statusSlug)->firstOrFail(); + + $ticket->update(['status_id' => $status->id]); + }); + } + + /** + * Send notifications to all routed recipients. + */ + private function sendNotifications(Ticket $ticket): void + { + $notification = new TicketRaisedNotification($ticket); + $notifiedUserIds = []; + + // Notify specific users + foreach ($ticket->notifiedUsers as $user) { + $user->notify($notification); + $notifiedUserIds[] = $user->id; + } + + // Notify role users + foreach ($ticket->notifiedRoles as $role) { + $users = User::role($role->name)->get(); + foreach ($users as $user) { + // Avoid double notifications + if (! in_array($user->id, $notifiedUserIds, true)) { + $user->notify($notification); + $notifiedUserIds[] = $user->id; + } + } + } + } +} diff --git a/database/migrations/2026_05_25_105900_create_notifications_table.php b/database/migrations/2026_05_25_105900_create_notifications_table.php new file mode 100644 index 0000000..de8710d --- /dev/null +++ b/database/migrations/2026_05_25_105900_create_notifications_table.php @@ -0,0 +1,33 @@ +uuid('id')->primary(); + $table->string('type'); + $table->morphs('notifiable'); + $table->text('data'); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; diff --git a/database/migrations/2026_05_25_160000_create_ticket_statuses_table.php b/database/migrations/2026_05_25_160000_create_ticket_statuses_table.php new file mode 100644 index 0000000..3c0d069 --- /dev/null +++ b/database/migrations/2026_05_25_160000_create_ticket_statuses_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('name')->unique(); + $table->string('slug')->unique(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('ticket_statuses'); + } +}; diff --git a/database/migrations/2026_05_25_160001_create_tickets_table.php b/database/migrations/2026_05_25_160001_create_tickets_table.php new file mode 100644 index 0000000..8d0f1d8 --- /dev/null +++ b/database/migrations/2026_05_25_160001_create_tickets_table.php @@ -0,0 +1,63 @@ +id(); + $table->string('ticket_id')->unique()->index(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->foreignId('connected_app_id')->nullable()->constrained('connected_apps')->cascadeOnDelete(); + $table->string('issue_category'); + $table->text('description')->nullable(); + $table->foreignId('status_id')->constrained('ticket_statuses')->restrictOnDelete(); + $table->softDeletes(); + $table->timestamps(); + }); + + Schema::create('ticket_notified_roles', function (Blueprint $table): void { + $table->id(); + $table->foreignId('ticket_id')->constrained('tickets')->cascadeOnDelete(); + $table->foreignId('role_id')->comment('role_id from Spatie roles table'); + $table->unique(['ticket_id', 'role_id']); + }); + + Schema::create('ticket_notified_users', function (Blueprint $table): void { + $table->id(); + $table->foreignId('ticket_id')->constrained('tickets')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->unique(['ticket_id', 'user_id']); + }); + + Schema::create('ticket_attachments', function (Blueprint $table): void { + $table->id(); + $table->foreignId('ticket_id')->constrained('tickets')->cascadeOnDelete(); + $table->string('file_path'); + $table->string('file_name'); + $table->unsignedBigInteger('file_size'); + $table->string('mime_type'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('ticket_attachments'); + Schema::dropIfExists('ticket_notified_users'); + Schema::dropIfExists('ticket_notified_roles'); + Schema::dropIfExists('tickets'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 8b90a38..751ad1c 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -21,6 +21,7 @@ public function run(): void $this->call(PermissionSeeder::class); $this->call(DefaultRoleWithPermissionSeeder::class); $this->call(ExampleUserWithRoleSeeder::class); + $this->call(TicketStatusSeeder::class); $this->call(MockDataSeeder::class); } } diff --git a/database/seeders/DefaultRoleWithPermissionSeeder.php b/database/seeders/DefaultRoleWithPermissionSeeder.php index e84be0c..2d74bf8 100644 --- a/database/seeders/DefaultRoleWithPermissionSeeder.php +++ b/database/seeders/DefaultRoleWithPermissionSeeder.php @@ -5,7 +5,7 @@ namespace Database\Seeders; use App\Enums\DefaultUserRole; -use App\Enums\Permissions\{ProfilePermissionEnum, SecurityPermissionEnum}; +use App\Enums\Permissions\{ProfilePermissionEnum, SecurityPermissionEnum, TicketPermissionEnum}; use App\Models\Role; use Illuminate\Database\Seeder; use Spatie\Permission\Models\Permission; @@ -24,6 +24,11 @@ public function run(): void private function syncUserPermissions(Role $role): void { - $role->givePermissionTo(ProfilePermissionEnum::Access->value, SecurityPermissionEnum::Access->value); + $role->givePermissionTo( + ProfilePermissionEnum::Access->value, + SecurityPermissionEnum::Access->value, + TicketPermissionEnum::Access->value, + TicketPermissionEnum::Create->value + ); } } diff --git a/database/seeders/TicketStatusSeeder.php b/database/seeders/TicketStatusSeeder.php new file mode 100644 index 0000000..a5c3517 --- /dev/null +++ b/database/seeders/TicketStatusSeeder.php @@ -0,0 +1,44 @@ + 'Open', + 'slug' => TicketStatusEnum::Open->value, + 'created_at' => now(), + ], + [ + 'name' => 'In Progress', + 'slug' => TicketStatusEnum::InProgress->value, + 'created_at' => now(), + ], + [ + 'name' => 'Resolved', + 'slug' => TicketStatusEnum::Resolved->value, + 'created_at' => now(), + ], + [ + 'name' => 'Closed', + 'slug' => TicketStatusEnum::Closed->value, + 'created_at' => now(), + ], + ]; + + TicketStatus::query() + ->upsert($data, ['slug'], ['name', 'updated_at']); + } +} diff --git a/resources/views/components/dashboard-sidebar.blade.php b/resources/views/components/dashboard-sidebar.blade.php index 99cd5b1..8d63fcb 100644 --- a/resources/views/components/dashboard-sidebar.blade.php +++ b/resources/views/components/dashboard-sidebar.blade.php @@ -2,7 +2,13 @@ use App\Data\Ui\Sidebar\NavItemData; use App\Data\Ui\Sidebar\NavSubItemData; -use App\Enums\Permissions\{AppPermissionEnum, ConnectionProviderPermissionEnum, ProfilePermissionEnum, RoleAndAppsPermissionEnum, SecurityPermissionEnum, UserPermissionEnum, PermissionPermissionEnum}; +use App\Enums\Permissions\{AppPermissionEnum, + ConnectionProviderPermissionEnum, + ProfilePermissionEnum, + RoleAndAppsPermissionEnum, + SecurityPermissionEnum, + UserPermissionEnum, + PermissionPermissionEnum}; use Livewire\Component; use Spatie\LaravelData\DataCollection; @@ -80,6 +86,13 @@ public function navItems(): DataCollection ], DataCollection::class) ), + new NavItemData( + label: 'Support Tickets', + icon: 'lucide.life-buoy', + active_pattern: 'tickets*', + route: 'tickets.index', + ), + new NavItemData( label: 'Settings', icon: 'lucide.settings', diff --git a/resources/views/components/dashbord-topbar.blade.php b/resources/views/components/dashbord-topbar.blade.php index 2e09b1d..63ff325 100644 --- a/resources/views/components/dashbord-topbar.blade.php +++ b/resources/views/components/dashbord-topbar.blade.php @@ -8,9 +8,53 @@ @endif
- - - + @php + $unreadNotifications = $user?->unreadNotifications()->take(5)->get() ?? collect(); + $unreadCount = $user?->unreadNotifications()->count() ?? 0; + @endphp + + +
+ + @if($unreadCount > 0) + + {{ $unreadCount }} + + @endif +
+
+ + @if($unreadNotifications->isEmpty()) + + @else +
+ Notifications +
+ @csrf + +
+
+ @foreach($unreadNotifications as $notification) + +
+ + {{ data_get($notification->data, 'issue_category', 'Notification') }} + + + {{ data_get($notification->data, 'message', '') }} + + + {{ $notification->created_at->diffForHumans() }} + +
+
+ @endforeach + @endif +
diff --git a/resources/views/pages/dashboards/⚡user.blade.php b/resources/views/pages/dashboards/⚡user.blade.php index 3488706..38dd3c7 100644 --- a/resources/views/pages/dashboards/⚡user.blade.php +++ b/resources/views/pages/dashboards/⚡user.blade.php @@ -126,11 +126,23 @@ class="text-sm font-medium {{ $service->is_warning ? 'text-amber-600 dark:text-a
- +
+ @if($service->is_warning) + + @endif + + +
diff --git a/resources/views/pages/tickets/⚡index.blade.php b/resources/views/pages/tickets/⚡index.blade.php new file mode 100644 index 0000000..74edcc3 --- /dev/null +++ b/resources/views/pages/tickets/⚡index.blade.php @@ -0,0 +1,738 @@ +ticketService = $ticketService; + $this->appAccessService = $appAccessService; + } + + public function mount(): void + { + // Pre-populate dynamic choice lists + $this->searchApps(); + $this->searchRoles(); + $this->searchUsers(); + + // Select first role by default in notified roles array if empty + $defaultRole = Role::where('name', 'Support')->first(); + if ($defaultRole && empty($this->form->notifiedRoleIds)) { + $this->form->notifiedRoleIds = [$defaultRole->id]; + } + + // Auto-open drawer if requested from URL + if ($this->raise) { + $this->drawerOpen = true; + if ($this->appId) { + $this->form->connectedAppId = $this->appId; + } + } + } + + /** + * Re-hydrate searchable arrays before every render to prevent losing selections. + */ + public function rendering(): void + { + if (empty($this->appsSearchable)) { + $this->searchApps(); + } + if (empty($this->rolesSearchable)) { + $this->searchRoles(); + } + if (empty($this->usersSearchable)) { + $this->searchUsers(); + } + } + + /** + * Compute visible tickets based on permissions. + */ + #[Computed] + public function tickets(): Collection + { + $user = auth()->user(); + if (!$user) { + return collect(); + } + + if ($user->hasRole('Admin') || $user->can(TicketPermissionEnum::Read->value)) { + $tickets = $this->ticketService->getAll(); + if ($this->statusFilter !== 'all') { + $tickets = $tickets->filter(fn($t) => $t->statusSlug === $this->statusFilter); + } + return $tickets; + } + + return $this->ticketService->getAllForUser($user->id); + } + + /** + * Compute expiring apps for the current user. + */ + #[Computed] + public function eligibleApps(): Collection + { + $user = auth()->user(); + if (!$user) { + return collect(); + } + + return $this->appAccessService->getActiveServices($user) + ->filter(fn($app) => $app->is_warning); + } + + /** + * Compute available ticket statuses for filtering. + */ + #[Computed] + public function statuses(): Collection + { + return TicketStatus::query()->get(); + } + + /** + * Search connected apps for mary-choices select dropdown. + */ + public function searchApps(string $value = ''): void + { + $selectedId = $this->form->connectedAppId; + $apps = $this->eligibleApps(); + + // Ensure selected app is preserved in the list even if warning state changed or not in initial list + if ($selectedId && !$apps->contains('id', $selectedId)) { + $selectedApp = ConnectedApp::find($selectedId); + if ($selectedApp) { + $apps->push($selectedApp); + } + } + + $this->appsSearchable = $apps + ->when('' !== $value, + fn($col) => $col->filter(fn($app) => str_contains(strtolower($app->name), strtolower($value)))) + ->map(fn($app) => [ + 'id' => $app->id, + 'name' => $app->name.(isset($app->days_remaining) ? ' ('.$app->days_remaining.' days left)' : ''), + ]) + ->toArray(); + } + + /** + * Search roles for mary-choices select dropdown. + */ + public function searchRoles(string $value = ''): void + { + $selectedIds = $this->form->notifiedRoleIds; + + $this->rolesSearchable = Role::query() + ->where(function ($query) use ($value, $selectedIds): void { + if ('' !== $value) { + $query->where('name', 'like', "%{$value}%"); + } + if (!empty($selectedIds)) { + $query->orWhereIn('id', $selectedIds); + } + }) + ->select('id', 'name') + ->get() + ->toArray(); + } + + /** + * Search users for mary-choices select dropdown. + */ + public function searchUsers(string $value = ''): void + { + $selectedIds = $this->form->notifiedUserIds; + + $this->usersSearchable = User::query() + ->where('id', '!=', auth()->id()) + ->where(function ($query) use ($value, $selectedIds): void { + if ('' !== $value) { + $query->where('name', 'like', "%{$value}%") + ->orWhere('email', 'like', "%{$value}%"); + } + if (!empty($selectedIds)) { + $query->orWhereIn('id', $selectedIds); + } + }) + ->select('id', 'name') + ->get() + ->toArray(); + } + + /** + * Select all roles in choices element. + */ + public function selectAllRoles(): void + { + $this->form->notifiedRoleIds = Role::query() + ->pluck('id') + ->toArray(); + $this->searchRoles(); + } + + /** + * Clear all selected roles in choices element. + */ + public function clearRoles(): void + { + $this->form->notifiedRoleIds = []; + } + + /** + * Select all users in choices element. + */ + public function selectAllUsers(): void + { + $this->form->notifiedUserIds = User::query() + ->where('id', '!=', auth()->id()) + ->pluck('id') + ->toArray(); + $this->searchUsers(); + } + + /** + * Clear all selected users in choices element. + */ + public function clearUsers(): void + { + $this->form->notifiedUserIds = []; + } + + /** + * Submit and save support ticket using the new Livewire Form class. + */ + public function saveTicket(): void + { + $this->form->validate(); + + $dto = new CreateTicketRequest( + userId: (int) auth()->id(), + connectedAppId: $this->form->connectedAppId ? (int) $this->form->connectedAppId : null, + issueCategory: $this->form->issueCategory, + description: $this->form->description ? trim($this->form->description) : null, + notifiedRoleIds: $this->form->routingType === 'role' ? array_map('intval', + $this->form->notifiedRoleIds) : [], + notifiedUserIds: $this->form->routingType === 'user' ? array_map('intval', + $this->form->notifiedUserIds) : [], + attachment: $this->form->attachment, + ); + + $this->attempt( + action: function () use ($dto): void { + $this->ticketService->create($dto); + + // Reset form state & close drawer + $this->form->reset(); + $this->drawerOpen = false; + $this->raise = false; + $this->appId = null; + + // Re-hydrate choice lists + $this->searchApps(); + $this->searchRoles(); + $this->searchUsers(); + }, + successMessage: 'Support Ticket has been raised successfully!' + ); + } + + /** + * Open Ticket Details modal. + */ + public function showTicket(int $id): void + { + $this->attempt( + action: function () use ($id): void { + $this->selectedTicketId = $id; + $this->selectedTicket = $this->ticketService->getTicket($id); + $this->detailsModalOpen = true; + }, + showSuccess: false + ); + } + + /** + * Update ticket status (Admin/Support only). + */ + public function changeStatus(int $ticketId, string $statusSlug): void + { + $this->attempt( + action: function () use ($ticketId, $statusSlug): void { + $this->ticketService->updateStatus($ticketId, $statusSlug); + + // Refresh active selected ticket details if open + if ($this->selectedTicketId === $ticketId) { + $this->selectedTicket = $this->ticketService->getTicket($ticketId); + } + }, + successMessage: 'Ticket status updated successfully!' + ); + } + + /** + * Toggle Drawer manually. + */ + public function toggleDrawer(): void + { + $this->drawerOpen = !$this->drawerOpen; + if (!$this->drawerOpen) { + $this->reset(['raise', 'appId']); + $this->form->reset(); + } + } +}; +?> + +
+ + + + @if(auth()->user()->hasRole('Admin') || auth()->user()->can('ticket:read')) + + @endif + + + Raise Ticket + + + @if($this->tickets->isEmpty()) +
+
+ +
+

No Support Tickets Found

+

+ You have not raised any support tickets yet. Click "Raise Ticket" to submit your renewal queries. +

+
+ @else + + @scope('cell_ticketId', $row) + + @endscope + + @scope('cell_appName', $row) + @if($row['appName']) + {{ $row['appName'] }} + @else + General Support + @endif + @endscope + + @scope('cell_statusName', $row) + @php + $badgeClass = match($row['statusSlug']) { + 'open' => 'badge-warning badge-soft', + 'in-progress' => 'badge-info badge-soft', + 'resolved' => 'badge-success badge-soft', + 'closed' => 'badge-neutral badge-soft', + default => 'badge-ghost', + }; + @endphp + + {{ $row['statusName'] }} + + @endscope + + @scope('cell_createdAt', $row) + + {{ Carbon\Carbon::parse($row['createdAt'])->diffForHumans() }} + + @endscope + + @scope('actions', $row) +
+ + + @if(auth()->user()->hasRole('Admin') || auth()->user()->can('ticket:update')) + + + + + @foreach($this->statuses as $status) + @if($status->slug !== $row['statusSlug']) + + @endif + @endforeach + + @endif +
+ @endscope +
+ @endif +
+ + + + + + @if($this->eligibleApps->isEmpty()) + + You do not currently have any active services expiring within 3 days. You can still submit a general + inquiry ticket without choosing a service. + + @endif + + + + + + + + + @if($form->issueCategory === 'Others') + +
+ +
+ + +
+ + @if($form->attachment) +
+ + File successfully uploaded. +
+ @endif +
+ @else + +
+ +
+ @endif + +
+ + +
+ + + +
+ + + +
+ + @if($form->routingType === 'role') +
+ +
+ @else +
+ +
+ @endif +
+ + + + + + +
+
+ + + + @if($selectedTicket) +
+ +
+
+ Support Ticket +

{{ $selectedTicket->ticketId }}

+
+ + @php + $badgeClass = match($selectedTicket->statusSlug) { + 'open' => 'badge-warning badge-soft', + 'in-progress' => 'badge-info badge-soft', + 'resolved' => 'badge-success badge-soft', + 'closed' => 'badge-neutral badge-soft', + default => 'badge-ghost', + }; + @endphp +
+ + {{ $selectedTicket->statusName }} + + Raised {{ Carbon\Carbon::parse($selectedTicket->createdAt)->diffForHumans() }} +
+
+ + +
+
+ Customer +

{{ $selectedTicket->userName }}

+
+ +
+ Associated Service +

{{ $selectedTicket->appName ?? 'General Inquiry' }}

+
+ +
+ Issue Category +

{{ $selectedTicket->issueCategory }}

+
+ +
+ Notified Recipient(s) + @if(!empty($selectedTicket->notifiedUserNames)) +
+ + + Users: + + @foreach($selectedTicket->notifiedUserNames as $name) + + @endforeach +
+ @endif + @if(!empty($selectedTicket->notifiedRoleNames)) +
+ + + Roles: + + @foreach($selectedTicket->notifiedRoleNames as $name) + + @endforeach +
+ @endif + @if(empty($selectedTicket->notifiedUserNames) && empty($selectedTicket->notifiedRoleNames)) +

None specified

+ @endif +
+
+ + +
+ Description Details +

+ {{ $selectedTicket->description ?? 'No custom details provided.' }} +

+
+ + + @if(!empty($selectedTicket->attachments)) +
+ Attachments +
+ @foreach($selectedTicket->attachments as $attachment) +
+
+ @php + $icon = match(true) { + Str::contains($attachment['mime_type'], 'pdf') => 'lucide.file-text', + Str::contains($attachment['mime_type'], 'image') => 'lucide.image', + default => 'lucide.file', + }; + @endphp + +
+

+ {{ $attachment['file_name'] }} +

+

+ {{ round($attachment['file_size'] / 1024, 1) }} KB +

+
+
+ +
+ @endforeach +
+
+ @endif + + + @if(auth()->user()->hasRole('Admin') || auth()->user()->can('ticket:update')) +
+ Change Status +
+ @foreach($this->statuses as $status) + @if($status->slug !== $selectedTicket->statusSlug) + + @endif + @endforeach +
+
+ @endif +
+ @endif + + + +
+
diff --git a/routes/web.php b/routes/web.php index eea6546..91d69cb 100644 --- a/routes/web.php +++ b/routes/web.php @@ -13,6 +13,14 @@ Route::group(['middleware' => ['auth', 'verified']], function (): void { Route::get('dashboard', ResolveDashboardRouteController::class)->name('dashboard'); + Route::post('notifications/read-all', function () { + auth()->user()?->unreadNotifications->markAsRead(); + + return back(); + })->name('notifications.read-all'); + + Route::livewire('tickets', 'pages::tickets.index')->name('tickets.index'); + Route::prefix('dashboard')->name('dashboard.')->group(function (): void { Route::livewire('/user', 'pages::dashboards.user') ->middleware(RoleMiddleware::using(DefaultUserRole::User)) diff --git a/tests/Feature/TicketSystemTest.php b/tests/Feature/TicketSystemTest.php new file mode 100644 index 0000000..21f794f --- /dev/null +++ b/tests/Feature/TicketSystemTest.php @@ -0,0 +1,185 @@ +seed(DatabaseSeeder::class); +}); + +test('guests cannot access the tickets page', function (): void { + $this->get(route('tickets.index')) + ->assertRedirect(route('login')); +}); + +test('authenticated users with correct permissions can access the tickets page', function (): void { + $user = User::factory()->create(); + $user->assignRole('user'); + $this->actingAs($user); + + $this->get(route('tickets.index')) + ->assertOk() + ->assertSee('Support Tickets'); +}); + +test('submitting a support ticket creates the ticket and generates a unique ticket ID', function (): void { + $user = User::factory()->create(); + $user->assignRole('user'); + $this->actingAs($user); + + $app = ConnectedApp::first(); + $supportRole = Role::where('name', 'Support')->firstOrFail(); + + $dto = new CreateTicketRequest( + userId: $user->id, + connectedAppId: $app->id, + issueCategory: 'Billing Query', + description: 'Need assistance with billing renewal.', + notifiedRoleIds: [$supportRole->id], + notifiedUserIds: [], + attachment: null, + ); + + $service = app(TicketService::class); + $ticketData = $service->create($dto); + + expect($ticketData->ticketId)->toStartWith('TKT-') + ->and(mb_strlen($ticketData->ticketId))->toBe(12) + ->and($ticketData->issueCategory)->toBe('Billing Query') + ->and($ticketData->statusSlug)->toBe(TicketStatusEnum::Open->value) + ->and($ticketData->notifiedRoleNames)->toContain('Support'); + + $this->assertDatabaseHas('tickets', [ + 'user_id' => $user->id, + 'connected_app_id' => $app->id, + 'issue_category' => 'Billing Query', + ]); + + $this->assertDatabaseHas('ticket_notified_roles', [ + 'ticket_id' => $ticketData->id, + 'role_id' => $supportRole->id, + ]); +}); + +test('submitting a ticket with category Others requires at least 20 characters for description', function (): void { + $user = User::factory()->create(); + $user->assignRole('user'); + $this->actingAs($user); + + $supportRole = Role::where('name', 'Support')->firstOrFail(); + + // Test Validation Failure via Livewire component form object + Livewire::test('pages::tickets.index') + ->set('form.issueCategory', 'Others') + ->set('form.description', 'Short desc') + ->set('form.routingType', 'role') + ->set('form.notifiedRoleIds', [$supportRole->id]) + ->call('saveTicket') + ->assertHasErrors(['form.description' => 'min']); +}); + +test('attaching a file for Others category uploads and stores the file', function (): void { + Storage::fake('public'); + + $user = User::factory()->create(); + $user->assignRole('user'); + $this->actingAs($user); + + $supportRole = Role::where('name', 'Support')->firstOrFail(); + $file = UploadedFile::fake()->create('screenshot.png', 500); + + $dto = new CreateTicketRequest( + userId: $user->id, + connectedAppId: null, + issueCategory: 'Others', + description: 'A very detailed query containing more than twenty characters.', + notifiedRoleIds: [$supportRole->id], + notifiedUserIds: [], + attachment: $file, + ); + + $service = app(TicketService::class); + $ticketData = $service->create($dto); + + $this->assertDatabaseHas('ticket_attachments', [ + 'file_name' => 'screenshot.png', + 'mime_type' => 'image/png', + ]); + + expect(count($ticketData->attachments))->toBe(1); + Storage::disk('public')->assertExists($ticketData->attachments[0]['file_path']); +}); + +test('notification routing to a specific user triggers notification to that user', function (): void { + Notification::fake(); + + $user = User::factory()->create(); + $user->assignRole('user'); + $this->actingAs($user); + + $adminUser = User::where('email', 'admin@example.com')->firstOrFail(); + + $dto = new CreateTicketRequest( + userId: $user->id, + connectedAppId: null, + issueCategory: 'Technical Issue', + description: 'SSO log-in error.', + notifiedRoleIds: [], + notifiedUserIds: [$adminUser->id], + attachment: null, + ); + + $service = app(TicketService::class); + $service->create($dto); + + Notification::assertSentTo( + $adminUser, + TicketRaisedNotification::class + ); +}); + +test('notification routing to multiple roles triggers notifications to all users assigned to those roles', function (): void { + Notification::fake(); + + $user = User::factory()->create(); + $user->assignRole('user'); + $this->actingAs($user); + + $supportRole = Role::where('name', 'Support')->firstOrFail(); + $managerRole = Role::where('name', 'Manager')->firstOrFail(); + + // Create a support user + $supportUser = User::factory()->create(); + $supportUser->assignRole($supportRole); + + // Create a manager user + $managerUser = User::factory()->create(); + $managerUser->assignRole($managerRole); + + $dto = new CreateTicketRequest( + userId: $user->id, + connectedAppId: null, + issueCategory: 'Service Extension', + description: 'Requesting 3 days trial extension.', + notifiedRoleIds: [$supportRole->id, $managerRole->id], + notifiedUserIds: [], + attachment: null, + ); + + $service = app(TicketService::class); + $service->create($dto); + + Notification::assertSentTo( + [$supportUser, $managerUser], + TicketRaisedNotification::class + ); +});