From 67002d6979b56d10e1bfcaa69a3c8219730d6e37 Mon Sep 17 00:00:00 2001 From: = Date: Tue, 26 May 2026 07:01:18 +0000 Subject: [PATCH] Feat: add ticket assignment, comments, and notifications - Added migration for ticket assignment and comments (`assigned_user_id` and `ticket_replies` table). - Implemented `TicketAssignedNotification` and `TicketCommentedNotification`. - Created `ReplyData`, `TicketListData`, and `PostReplyRequest` data classes. - Introduced `ResolveNotificationRouteController` for modular notification redirection. - Updated UI to support assigning users and posting comments on tickets. - User is navigated to comments page when clicked on the ticket, upon navigation ticket details modal is opened. - Extended tests to cover assignments, comments, status transitions, and notifications. --- app/Concerns/HandlesOperations.php | 4 +- app/Data/Ticket/PostReplyRequest.php | 19 + app/Data/Ui/Ticket/ReplyData.php | 34 + app/Data/Ui/Ticket/TicketData.php | 17 +- app/Data/Ui/Ticket/TicketListData.php | 37 + app/Enums/TicketStatusEnum.php | 17 + .../ResolveNotificationRouteController.php | 38 + app/Models/Ticket.php | 22 +- app/Models/TicketReply.php | 40 + .../TicketAssignedNotification.php | 66 ++ .../TicketCommentedNotification.php | 69 ++ app/Services/TicketService.php | 269 +++++-- ...05_15_115455_create_activity_log_table.php | 5 + ...25_160000_create_ticket_statuses_table.php | 1 - ...d_assigned_user_and_replies_to_tickets.php | 45 ++ database/seeders/TicketStatusSeeder.php | 25 +- .../components/dashbord-topbar.blade.php | 2 +- .../views/pages/tickets/⚡index.blade.php | 711 +++++++++++++----- routes/web.php | 2 + tests/Feature/NotificationResolutionTest.php | 63 ++ tests/Feature/TicketSystemTest.php | 175 ++++- 21 files changed, 1369 insertions(+), 292 deletions(-) create mode 100644 app/Data/Ticket/PostReplyRequest.php create mode 100644 app/Data/Ui/Ticket/ReplyData.php create mode 100644 app/Data/Ui/Ticket/TicketListData.php create mode 100644 app/Http/Controllers/ResolveNotificationRouteController.php create mode 100644 app/Models/TicketReply.php create mode 100644 app/Notifications/TicketAssignedNotification.php create mode 100644 app/Notifications/TicketCommentedNotification.php create mode 100644 database/migrations/2026_05_25_173000_add_assigned_user_and_replies_to_tickets.php create mode 100644 tests/Feature/NotificationResolutionTest.php diff --git a/app/Concerns/HandlesOperations.php b/app/Concerns/HandlesOperations.php index afd8c1c..4ec0ca5 100644 --- a/app/Concerns/HandlesOperations.php +++ b/app/Concerns/HandlesOperations.php @@ -19,7 +19,7 @@ public function attempt(callable $action, ?callable $onError = null, string $suc // call the action callback $action(); if ($showSuccess) { - $this->success($successMessage); + $this->success($successMessage, css: 'alert-success alert-dismissible alert-soft'); } } catch (ValidationException $exception) { @@ -36,7 +36,7 @@ public function attempt(callable $action, ?callable $onError = null, string $suc // call the callback $onError(); } - $this->error($errorMessage); + $this->error($errorMessage, css: 'alert-error alert-dismissible alert-soft'); Log::error($exception->getMessage()); } } diff --git a/app/Data/Ticket/PostReplyRequest.php b/app/Data/Ticket/PostReplyRequest.php new file mode 100644 index 0000000..bc8e87d --- /dev/null +++ b/app/Data/Ticket/PostReplyRequest.php @@ -0,0 +1,19 @@ +id, + userId: $reply->user_id, + userName: $reply->user->name, + userInitials: method_exists($reply->user, 'initials') ? $reply->user->initials() : mb_strtoupper(mb_substr($reply->user->name, 0, 2)), + userRoles: $reply->user->roles->pluck('name')->toArray(), + message: $reply->message, + createdAt: $reply->created_at->diffForHumans(), + ); + } +} diff --git a/app/Data/Ui/Ticket/TicketData.php b/app/Data/Ui/Ticket/TicketData.php index 9b89345..bd45aff 100644 --- a/app/Data/Ui/Ticket/TicketData.php +++ b/app/Data/Ui/Ticket/TicketData.php @@ -4,6 +4,7 @@ namespace App\Data\Ui\Ticket; +use App\Enums\TicketStatusEnum; use App\Models\Ticket; use Spatie\LaravelData\Data; @@ -19,12 +20,14 @@ public function __construct( public string $issueCategory, public ?string $description, public int $statusId, - public string $statusName, - public string $statusSlug, + public TicketStatusEnum $status, public array $notifiedRoleNames, public array $notifiedUserNames, public string $createdAt, public array $attachments = [], + public ?int $assignedUserId = null, + public ?string $assignedUserName = null, + public array $replies = [], ) {} public static function fromModel(Ticket $ticket): self @@ -37,6 +40,10 @@ public static function fromModel(Ticket $ticket): self 'mime_type' => $attachment->mime_type, ])->toArray(); + $replies = $ticket->relationLoaded('replies') + ? $ticket->replies->map(fn ($reply) => ReplyData::fromModel($reply))->toArray() + : []; + return new self( id: $ticket->id, ticketId: $ticket->ticket_id, @@ -47,12 +54,14 @@ public static function fromModel(Ticket $ticket): self issueCategory: $ticket->issue_category, description: $ticket->description, statusId: $ticket->status_id, - statusName: $ticket->status->name, - statusSlug: $ticket->status->slug, + status: TicketStatusEnum::tryFrom($ticket->status->name), notifiedRoleNames: $ticket->notifiedRoles->pluck('name')->toArray(), notifiedUserNames: $ticket->notifiedUsers->pluck('name')->toArray(), createdAt: $ticket->created_at->toDateTimeString(), attachments: $attachments, + assignedUserId: $ticket->assigned_user_id, + assignedUserName: $ticket->assignedUser?->name, + replies: $replies, ); } } diff --git a/app/Data/Ui/Ticket/TicketListData.php b/app/Data/Ui/Ticket/TicketListData.php new file mode 100644 index 0000000..714c29d --- /dev/null +++ b/app/Data/Ui/Ticket/TicketListData.php @@ -0,0 +1,37 @@ +id, + ticketId: $ticket->ticket_id, + userName: $ticket->user->name, + appName: $ticket->connectedApp?->name, + issueCategory: $ticket->issue_category, + createdAt: $ticket->created_at->toDateTimeString(), + status: TicketStatusEnum::from($ticket->status->name), + statusSlug: $ticket->status->name, + ); + } +} diff --git a/app/Enums/TicketStatusEnum.php b/app/Enums/TicketStatusEnum.php index 7282309..02d6564 100644 --- a/app/Enums/TicketStatusEnum.php +++ b/app/Enums/TicketStatusEnum.php @@ -12,6 +12,23 @@ enum TicketStatusEnum: string case Open = 'open'; case InProgress = 'in-progress'; + case Hold = 'hold'; case Resolved = 'resolved'; case Closed = 'closed'; + + public static function toLabels() + { + return array_map(fn ($status) => $status->label(), self::cases()); + } + + public function label(): string + { + return match ($this) { + self::Open => 'Open', + self::InProgress => 'In Progress', + self::Hold => 'Hold', + self::Resolved => 'Resolved', + self::Closed => 'Closed', + }; + } } diff --git a/app/Http/Controllers/ResolveNotificationRouteController.php b/app/Http/Controllers/ResolveNotificationRouteController.php new file mode 100644 index 0000000..773f2de --- /dev/null +++ b/app/Http/Controllers/ResolveNotificationRouteController.php @@ -0,0 +1,38 @@ +user(); + if (! $user) { + return to_route('login'); + } + + $notification = $user->notifications()->findOrFail($id); + $notification->markAsRead(); + + // Modular pattern: Match notification type to determine where to redirect + $url = match ($notification->type) { + TicketRaisedNotification::class, + TicketAssignedNotification::class, + TicketCommentedNotification::class => route('tickets.index', [ + 'ticket' => data_get($notification->data, 'ticket_id'), + ]), + + // Future expansion: + // \App\Notifications\AppExpiredNotification::class => route('apps.index', ['app' => data_get($notification->data, 'app_id')]), + + default => route('dashboard'), + }; + + return redirect($url); + } +} diff --git a/app/Models/Ticket.php b/app/Models/Ticket.php index 8d5e076..0687027 100644 --- a/app/Models/Ticket.php +++ b/app/Models/Ticket.php @@ -4,9 +4,8 @@ namespace App\Models; -use Illuminate\Database\Eloquent\Attributes\Fillable; -use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\{Model, SoftDeletes}; +use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Relations\{BelongsTo, BelongsToMany, HasMany}; use Illuminate\Support\Str; @@ -17,10 +16,11 @@ 'issue_category', 'description', 'status_id', + 'assigned_user_id', ])] class Ticket extends Model { - use HasFactory, SoftDeletes; + use SoftDeletes; protected static function booted(): void { @@ -59,6 +59,14 @@ public function status(): BelongsTo return $this->belongsTo(TicketStatus::class, 'status_id'); } + /** + * @return BelongsTo + */ + public function assignedUser(): BelongsTo + { + return $this->belongsTo(User::class, 'assigned_user_id'); + } + /** * @return BelongsToMany */ @@ -92,4 +100,12 @@ public function attachments(): HasMany { return $this->hasMany(TicketAttachment::class, 'ticket_id'); } + + /** + * @return HasMany + */ + public function replies(): HasMany + { + return $this->hasMany(TicketReply::class, 'ticket_id'); + } } diff --git a/app/Models/TicketReply.php b/app/Models/TicketReply.php new file mode 100644 index 0000000..9a945f2 --- /dev/null +++ b/app/Models/TicketReply.php @@ -0,0 +1,40 @@ + + */ + public function ticket(): BelongsTo + { + return $this->belongsTo(Ticket::class, 'ticket_id'); + } + + /** + * Get the user who posted this reply. + * + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } +} diff --git a/app/Notifications/TicketAssignedNotification.php b/app/Notifications/TicketAssignedNotification.php new file mode 100644 index 0000000..1d823e0 --- /dev/null +++ b/app/Notifications/TicketAssignedNotification.php @@ -0,0 +1,66 @@ + + */ + 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 Assigned: {$this->ticket->ticket_id}") + ->greeting('Hello,') + ->line("You have been assigned as the support owner for ticket {$this->ticket->ticket_id} regarding {$this->ticket->issue_category}.") + ->line('Customer: '.$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' => "You have been assigned to ticket {$this->ticket->ticket_id} by the administrator.", + ]; + } +} diff --git a/app/Notifications/TicketCommentedNotification.php b/app/Notifications/TicketCommentedNotification.php new file mode 100644 index 0000000..50c026a --- /dev/null +++ b/app/Notifications/TicketCommentedNotification.php @@ -0,0 +1,69 @@ + + */ + public function via(object $notifiable): array + { + return ['database', 'mail']; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail(object $notifiable): MailMessage + { + $commenterName = $this->comment->user->name; + + return (new MailMessage()) + ->subject("New Comment on Ticket: {$this->ticket->ticket_id}") + ->greeting('Hello,') + ->line("{$commenterName} left a new comment on ticket {$this->ticket->ticket_id} regarding {$this->ticket->issue_category}.") + ->line('Comment: "'.Str::limit($this->comment->message, 300).'"') + ->action('View Discussion', 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 + { + $commenterName = $this->comment->user->name; + $appName = $this->ticket->connectedApp->name ?? 'General Inquiry'; + + return [ + 'ticket_id' => $this->ticket->id, + 'ticket_code' => $this->ticket->ticket_id, + 'user_name' => $commenterName, + 'issue_category' => $this->ticket->issue_category, + 'app_name' => $appName, + 'description' => Str::limit($this->ticket->description ?? '', 100), + 'message' => "{$commenterName} commented on ticket {$this->ticket->ticket_id}.", + ]; + } +} diff --git a/app/Services/TicketService.php b/app/Services/TicketService.php index f777ba4..9363658 100644 --- a/app/Services/TicketService.php +++ b/app/Services/TicketService.php @@ -4,11 +4,12 @@ namespace App\Services; -use App\Data\Ticket\CreateTicketRequest; -use App\Data\Ui\Ticket\TicketData; +use App\Data\Ticket\{CreateTicketRequest, PostReplyRequest}; +use App\Data\Ui\Ticket\{ReplyData, TicketData, TicketListData}; use App\Enums\TicketStatusEnum; -use App\Models\{Ticket, TicketStatus, User}; -use App\Notifications\TicketRaisedNotification; +use App\Models\{Ticket, TicketReply, TicketStatus, User}; +use App\Notifications\{TicketAssignedNotification, TicketCommentedNotification, TicketRaisedNotification}; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\UploadedFile; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -21,6 +22,195 @@ public function __construct( private readonly TicketAttachmentService $attachmentService ) {} + /** + * 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', 'assignedUser', 'replies.user.roles']) + ->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', 'assignedUser', 'replies.user.roles']) + ->latest() + ->get(); + + return TicketData::collect($tickets); + } + + /** + * Get lightweight ticket list for a specific user. + * + * @return Collection + */ + public function getListForUser(int $userId): Collection + { + if ($userId <= 0) { + throw new InvalidArgumentException('Invalid user ID'); + } + + $user = User::findOrFail($userId); + $roleIds = $user->roles->pluck('id')->toArray(); + + $tickets = Ticket::with(['user', 'connectedApp', 'status']) + ->where(function ($query) use ($userId, $roleIds): void { + $query->where('user_id', $userId) + ->orWhere('assigned_user_id', $userId) + ->orWhereHas('notifiedUsers', function ($q) use ($userId): void { + $q->where('users.id', $userId); + }) + ->orWhereHas('notifiedRoles', function ($q) use ($roleIds): void { + $q->whereIn('roles.id', $roleIds); + }); + }) + ->latest() + ->get(); + + return TicketListData::collect($tickets); + } + + /** + * Get lightweight ticket list of all tickets. + * + * @return Collection + */ + public function getList(): Collection + { + $tickets = Ticket::with(['user', 'connectedApp', 'status']) + ->latest() + ->get(); + + return TicketListData::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', 'assignedUser', 'replies.user.roles']) + ->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('name', $statusSlug)->firstOrFail(); + + $ticket->update(['status_id' => $status->id]); + }); + } + + /** + * Assign/remove assigned user to ticket. + * + * @throws Throwable + */ + public function assignUser(int $ticketId, ?int $assignedUserId): void + { + if ($ticketId <= 0) { + throw new InvalidArgumentException('Invalid ticket ID'); + } + + DB::transaction(function () use ($ticketId, $assignedUserId): void { + $ticket = Ticket::findOrFail($ticketId); + $oldAssignedUserId = $ticket->assigned_user_id; + + $ticket->update(['assigned_user_id' => $assignedUserId]); + + if ($assignedUserId && $assignedUserId !== $oldAssignedUserId) { + $assignedUser = User::findOrFail($assignedUserId); + $assignedUser->notify(new TicketAssignedNotification($ticket)); + } + }); + } + + /** + * Post a comment/reply to a ticket. + * + * @throws Throwable + */ + public function postReply(PostReplyRequest $request): ReplyData + { + return DB::transaction(function () use ($request): ReplyData { + $user = User::findOrFail($request->userId); + $ticket = Ticket::findOrFail($request->ticketId); + + // Authorization check: Admin, Creator, Assigned, or Notified directly/via role + $isAuthorized = $user->hasRole('Admin') + || $user->hasRole('admin') + || $ticket->user_id === $user->id + || $ticket->assigned_user_id === $user->id + || $ticket->notifiedUsers()->where('users.id', $user->id)->exists() + || $ticket->notifiedRoles()->whereIn('roles.id', $user->roles->pluck('id')->toArray())->exists(); + + if (! $isAuthorized) { + throw new AuthorizationException('You are not authorized to comment on this ticket.'); + } + + /** @var TicketReply $reply */ + $reply = TicketReply::query()->create([ + 'ticket_id' => $request->ticketId, + 'user_id' => $request->userId, + 'message' => mb_trim($request->message), + ]); + + // 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) { + $ticket->update(['status_id' => $inProgressStatus->id]); + } + } + + // Notify ticket raiser if they did not make this comment + if ($ticket->user_id !== $request->userId) { + $ticket->user->notify(new TicketCommentedNotification($ticket, $reply)); + } + + // 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)); + } + + return ReplyData::fromModel($reply->load('user.roles')); + }); + } + /** * Create a new support ticket. * @@ -30,7 +220,7 @@ public function create(CreateTicketRequest $request): TicketData { return DB::transaction(function () use ($request): TicketData { $defaultStatus = TicketStatus::query() - ->where('slug', TicketStatusEnum::Open->value) + ->where('name', TicketStatusEnum::Open->value) ->firstOrFail(); /** @var Ticket $ticket */ @@ -55,7 +245,7 @@ public function create(CreateTicketRequest $request): TicketData } // Load relations to build complete DTO and trigger notifications - $ticket->load(['user', 'connectedApp', 'status', 'notifiedRoles', 'notifiedUsers', 'attachments']); + $ticket->load(['user', 'connectedApp', 'status', 'notifiedRoles', 'notifiedUsers', 'attachments', 'assignedUser', 'replies.user.roles']); $this->sendNotifications($ticket); @@ -63,73 +253,6 @@ public function create(CreateTicketRequest $request): TicketData }); } - /** - * 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. */ diff --git a/database/migrations/2026_05_15_115455_create_activity_log_table.php b/database/migrations/2026_05_15_115455_create_activity_log_table.php index 804e651..a5b77f5 100644 --- a/database/migrations/2026_05_15_115455_create_activity_log_table.php +++ b/database/migrations/2026_05_15_115455_create_activity_log_table.php @@ -22,4 +22,9 @@ public function up(): void $table->timestamps(); }); } + + public function down(): void + { + Schema::dropIfExists('activity_log'); + } }; 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 index 3c0d069..fd59cc0 100644 --- a/database/migrations/2026_05_25_160000_create_ticket_statuses_table.php +++ b/database/migrations/2026_05_25_160000_create_ticket_statuses_table.php @@ -16,7 +16,6 @@ public function up(): void Schema::create('ticket_statuses', function (Blueprint $table): void { $table->id(); $table->string('name')->unique(); - $table->string('slug')->unique(); $table->timestamps(); }); } diff --git a/database/migrations/2026_05_25_173000_add_assigned_user_and_replies_to_tickets.php b/database/migrations/2026_05_25_173000_add_assigned_user_and_replies_to_tickets.php new file mode 100644 index 0000000..e5d4353 --- /dev/null +++ b/database/migrations/2026_05_25_173000_add_assigned_user_and_replies_to_tickets.php @@ -0,0 +1,45 @@ +foreignId('assigned_user_id') + ->nullable() + ->after('status_id') + ->constrained('users') + ->nullOnDelete(); + }); + + Schema::create('ticket_replies', function (Blueprint $table): void { + $table->id(); + $table->foreignId('ticket_id')->constrained('tickets')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->text('message'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('ticket_replies'); + + Schema::table('tickets', function (Blueprint $table): void { + $table->dropForeign(['assigned_user_id']); + $table->dropColumn('assigned_user_id'); + }); + } +}; diff --git a/database/seeders/TicketStatusSeeder.php b/database/seeders/TicketStatusSeeder.php index a5c3517..36b397c 100644 --- a/database/seeders/TicketStatusSeeder.php +++ b/database/seeders/TicketStatusSeeder.php @@ -15,30 +15,9 @@ class TicketStatusSeeder extends Seeder */ public function run(): void { - $data = [ - [ - 'name' => '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(), - ], - ]; + $data = array_map(fn ($value) => ['name' => $value], TicketStatusEnum::values()); TicketStatus::query() - ->upsert($data, ['slug'], ['name', 'updated_at']); + ->upsert($data, ['name'], ['name', 'updated_at']); } } diff --git a/resources/views/components/dashbord-topbar.blade.php b/resources/views/components/dashbord-topbar.blade.php index 63ff325..496f4c4 100644 --- a/resources/views/components/dashbord-topbar.blade.php +++ b/resources/views/components/dashbord-topbar.blade.php @@ -39,7 +39,7 @@ class="px-4 py-2 border-b border-gray-100 font-semibold text-[10px] text-gray-50 @foreach($unreadNotifications as $notification) - +
{{ data_get($notification->data, 'issue_category', 'Notification') }} diff --git a/resources/views/pages/tickets/⚡index.blade.php b/resources/views/pages/tickets/⚡index.blade.php index 74edcc3..040ab1d 100644 --- a/resources/views/pages/tickets/⚡index.blade.php +++ b/resources/views/pages/tickets/⚡index.blade.php @@ -1,10 +1,11 @@ ticketService = $ticketService; @@ -62,6 +76,7 @@ public function mount(): void $this->searchApps(); $this->searchRoles(); $this->searchUsers(); + $this->searchAssignableUsers(); // Select first role by default in notified roles array if empty $defaultRole = Role::where('name', 'Support')->first(); @@ -76,6 +91,11 @@ public function mount(): void $this->form->connectedAppId = $this->appId; } } + + // Auto-open ticket if requested from URL + if ($this->ticketParam && (int) $this->ticketParam > 0) { + $this->showTicket((int) $this->ticketParam); + } } /** @@ -92,6 +112,9 @@ public function rendering(): void if (empty($this->usersSearchable)) { $this->searchUsers(); } + if (empty($this->assignableUsersSearchable)) { + $this->searchAssignableUsers($this->assignableUsersSearchTerm); + } } /** @@ -105,15 +128,19 @@ public function tickets(): Collection return collect(); } - if ($user->hasRole('Admin') || $user->can(TicketPermissionEnum::Read->value)) { - $tickets = $this->ticketService->getAll(); + 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->statusSlug === $this->statusFilter); + $tickets = $tickets->filter(fn($t) => $t->status->value === $this->statusFilter); } return $tickets; } - return $this->ticketService->getAllForUser($user->id); + $tickets = $this->ticketService->getListForUser($user->id); + if ($this->statusFilter !== 'all') { + $tickets = $tickets->filter(fn($t) => $t->status->value === $this->statusFilter); + } + return $tickets; } /** @@ -148,7 +175,6 @@ 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) { @@ -211,27 +237,107 @@ public function searchUsers(string $value = ''): void } /** - * Select all roles in choices element. + * Search assignable users dynamically with incremental pagination capability. */ - public function selectAllRoles(): void + public function searchAssignableUsers(string $value = ''): void { - $this->form->notifiedRoleIds = Role::query() - ->pluck('id') + // Reset page limits if search keyword shifts + if ($this->assignableUsersSearchTerm !== $value) { + $this->assignableUsersSearchTerm = $value; + $this->assignableUsersPerPage = 10; + } + + $query = User::query(); + + $currentAssigneeId = $this->assignedUserId; + if (is_array($currentAssigneeId)) { + $currentAssigneeId = !empty($currentAssigneeId) ? $currentAssigneeId[0] : null; + } + + if ('' !== $value) { + $query->where(function ($q) use ($value) { + $q->where('name', 'like', "%{$value}%") + ->orWhere('email', 'like', "%{$value}%"); + }); + + if ($currentAssigneeId && (int) $currentAssigneeId > 0) { + $query->orWhere('id', (int) $currentAssigneeId); + } + } + + // Track total count matching query criteria to determine if "Load More" exists + $totalMatches = $query->count(); + $this->assignableUsersHasMore = $totalMatches > $this->assignableUsersPerPage; + + $users = $query + ->select('id', 'name') + ->limit($this->assignableUsersPerPage) + ->get(); + + // If there is no search term and we have an assigned user, ensure they are in the list + if ('' === $value && $currentAssigneeId && (int) $currentAssigneeId > 0) { + if (!$users->contains('id', (int) $currentAssigneeId)) { + $assignedUser = User::find($currentAssigneeId); + if ($assignedUser) { + $users->push($assignedUser); + } + } + } + + $this->assignableUsersSearchable = $users + ->map(fn($u) => ['id' => $u->id, 'name' => $u->name]) ->toArray(); - $this->searchRoles(); } /** - * Clear all selected roles in choices element. + * Increments the page window for assignable users dropdown. */ + public function loadMoreAssignableUsers(): void + { + $this->assignableUsersPerPage += 10; + $this->searchAssignableUsers($this->assignableUsersSearchTerm); + } + + /** + * Select all matching assignable users based on current search context. + */ + public function selectAllAssignableUsers(): void + { + // Pluck IDs matching current search context criteria + $matchedIds = User::query() + ->when('' !== $this->assignableUsersSearchTerm, function ($q) { + $q->where('name', 'like', "%{$this->assignableUsersSearchTerm}%") + ->orWhere('email', 'like', "%{$this->assignableUsersSearchTerm}%"); + }) + ->pluck('id') + ->toArray(); + + // Assign the first matched record or pass the array depending on single/multiple context. + // Since updateAssignment expects a single nullable ID, we take the primary match or convert accordingly. + $this->assignedUserId = !empty($matchedIds) ? (int) $matchedIds[0] : null; + $this->updateAssignment(); + } + + /** + * Clear assignment selection. + */ + public function clearAssignableUsers(): void + { + $this->assignedUserId = null; + $this->updateAssignment(); + } + + public function selectAllRoles(): void + { + $this->form->notifiedRoleIds = Role::query()->pluck('id')->toArray(); + $this->searchRoles(); + } + public function clearRoles(): void { $this->form->notifiedRoleIds = []; } - /** - * Select all users in choices element. - */ public function selectAllUsers(): void { $this->form->notifiedUserIds = User::query() @@ -241,17 +347,11 @@ public function selectAllUsers(): void $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(); @@ -271,14 +371,11 @@ public function saveTicket(): void $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(); @@ -287,31 +384,28 @@ public function saveTicket(): void ); } - /** - * 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->assignedUserId = $this->selectedTicket->assignedUserId; + $this->replyMessage = ''; + + // Keep assignment lookup synchronized upon details injection + $this->searchAssignableUsers(); $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); } @@ -320,9 +414,91 @@ public function changeStatus(int $ticketId, string $statusSlug): void ); } - /** - * Toggle Drawer manually. - */ + public function saveReply(): void + { + $this->validate(['replyMessage' => 'required|string|min:1']); + + $this->attempt( + action: function (): void { + $dto = new PostReplyRequest( + ticketId: $this->selectedTicketId, + userId: (int) auth()->id(), + message: $this->replyMessage, + ); + + $this->ticketService->postReply($dto); + $this->replyMessage = ''; + $this->selectedTicket = $this->ticketService->getTicket($this->selectedTicketId); + }, + successMessage: 'Comment posted successfully!' + ); + } + + public function updateAssignment(): void + { + $this->attempt( + action: function (): void { + $assignedId = $this->assignedUserId; + if (is_array($assignedId)) { + $assignedId = !empty($assignedId) ? $assignedId[0] : null; + } + $finalAssignedUserId = ($assignedId && (int) $assignedId > 0) ? (int) $assignedId : null; + + $this->ticketService->assignUser( + $this->selectedTicketId, + $finalAssignedUserId + ); + $this->selectedTicket = $this->ticketService->getTicket($this->selectedTicketId); + + // Refresh list options with correct paginated users + $this->searchAssignableUsers($this->assignableUsersSearchTerm); + }, + successMessage: 'Ticket assignment updated successfully!' + ); + } + + public function updatedAssignedUserId($value): void + { + $this->updateAssignment(); + } + + #[Computed] + public function canComment(): bool + { + if (!$this->selectedTicket) { + return false; + } + + $user = auth()->user(); + if (!$user) { + return false; + } + + if ($user->hasRole(DefaultUserRole::Admin)) { + return true; + } + + if ($this->selectedTicket->userId === $user->id) { + return true; + } + + if ($this->selectedTicket->assignedUserId === $user->id) { + return true; + } + + $ticket = Ticket::find($this->selectedTicketId); + if ($ticket) { + if ($ticket->notifiedUsers()->where('users.id', $user->id)->exists()) { + return true; + } + if ($ticket->notifiedRoles()->whereIn('roles.id', $user->roles->pluck('id')->toArray())->exists()) { + return true; + } + } + + return false; + } + public function toggleDrawer(): void { $this->drawerOpen = !$this->drawerOpen; @@ -341,7 +517,7 @@ public function toggleDrawer(): void @if(auth()->user()->hasRole('Admin') || auth()->user()->can('ticket:read')) @endif @@ -374,21 +550,21 @@ class="btn-primary btn-sm rounded-xl font-semibold shadow-xs" ['key' => 'createdAt', 'label' => 'Date Raised'], ['key' => 'statusName', 'label' => 'Status'], ]" - :rows="$this->tickets->toArray()" + :rows="$this->tickets" wire:loading.class="opacity-60" > @scope('cell_ticketId', $row) @endscope @scope('cell_appName', $row) - @if($row['appName']) - {{ $row['appName'] }} + @if($row->appName) + {{ $row->appName }} @else General Support @endif @@ -396,29 +572,30 @@ class="font-mono font-semibold text-blue-600 hover:underline text-left cursor-po @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', + $badgeClass = match($row->status) { + TicketStatusEnum::Open => 'badge-warning badge-soft', + TicketStatusEnum::InProgress => 'badge-info badge-soft', + TicketStatusEnum::Resolved => 'badge-success badge-soft', + TicketStatusEnum::Closed => 'badge-neutral badge-soft', + TicketStatusEnum::Hold => 'badge-warning badge-soft', default => 'badge-ghost', }; @endphp - {{ $row['statusName'] }} + {{ $row->status->label() }} @endscope @scope('cell_createdAt', $row) - {{ Carbon\Carbon::parse($row['createdAt'])->diffForHumans() }} + {{ Carbon\Carbon::parse($row->createdAt)->diffForHumans() }} @endscope @scope('actions', $row)
@foreach($this->statuses as $status) - @if($status->slug !== $row['statusSlug']) + @php + $enumCase = TicketStatusEnum::tryFrom($status->name); + @endphp + @if($enumCase && $enumCase !== $row->status) @endif @endforeach @@ -587,152 +767,315 @@ class="radio radio-primary radio-xs"/> - + @if($selectedTicket) -
- -
-
- Support Ticket -

{{ $selectedTicket->ticketId }}

+
+ +
+ +
+
+ Support Ticket +

{{ $selectedTicket->ticketId }}

+
+ + @php + $badgeClass = match($selectedTicket->status) { + TicketStatusEnum::Open => 'badge-warning badge-soft', + TicketStatusEnum::InProgress => 'badge-info badge-soft', + TicketStatusEnum::Resolved => 'badge-success badge-soft', + TicketStatusEnum::Closed => 'badge-neutral badge-soft', + TicketStatusEnum::Hold => 'badge-warning badge-soft', + default => 'badge-ghost', + }; + @endphp +
+ + {{ $selectedTicket->status->label() }} + + Raised {{ Carbon\Carbon::parse($selectedTicket->createdAt)->diffForHumans() }} +
- @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 }}

+
- -
-
- Customer -

{{ $selectedTicket->userName }}

-
+
+ Associated Service +

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

+
-
- Associated Service -

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

-
+
+ Issue Category +

{{ $selectedTicket->issueCategory }}

+
-
- Issue Category -

{{ $selectedTicket->issueCategory }}

-
- -
- Notified Recipient(s) - @if(!empty($selectedTicket->notifiedUserNames)) -
- - - Users: + +
+
+ Assigned Support + + + Saving... - @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 -

-
-
- user()->hasRole(DefaultUserRole::Admin)) +
+
- @endforeach -
-
- @endif - - - @if(auth()->user()->hasRole('Admin') || auth()->user()->can('ticket:update')) -
- Change Status -
- @foreach($this->statuses as $status) - @if($status->slug !== $selectedTicket->statusSlug) - + @else + @if($selectedTicket->assignedUserName) +
+ + + {{ $selectedTicket->assignedUserName }} + +
+ @else + + Unassigned + @endif - @endforeach + @endif +
+ +
+ 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
- @endif + + +
+ Description Details + @php + $description = $selectedTicket->description ?? 'No custom details provided.'; + $words = preg_split('/\s+/', trim($description)); + $wordCount = count($words); + $isLong = $wordCount > 50; + + if ($isLong) { + $shortDescription = implode(' ', array_slice($words, 0, 50)) . '...'; + } else { + $shortDescription = $description; + } + @endphp +
+

+ {{ $shortDescription }} +

+ @if($isLong) +

+ {{ $description }} +

+ + @endif +
+
+ + + @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) + @php + $enumCase = TicketStatusEnum::tryFrom($status->name); + @endphp + @if($enumCase && $enumCase !== $selectedTicket->status) + + @endif + @endforeach +
+
+ @endif +
+ + +
+
+

+ + Discussion Comments +

+ + {{ count($selectedTicket->replies) }} comments + +
+ + @if($this->canComment) + +
+ @if(empty($selectedTicket->replies)) +
+ +

No comments yet

+

Be the first to start the conversation on this + support ticket.

+
+ @else + @foreach($selectedTicket->replies as $reply) + +
+ +
+
+ {{ $reply['userInitials'] }} +
+
+ + +
+
+
+ {{ $reply['userName'] }} + +
+ {{ $reply['createdAt'] }} +
+

{{ $reply['message'] }}

+
+
+ @endforeach + @endif +
+ + +
+
+ +
+ +
+ +
+ @else +
+ +

Conversation Access Restricted

+

+ Only raised/tagged participants (ticket owner, assigned support, directly notified + users, or notified-role members) have access to the discussion feed. +

+
+ @endif +
@endif - - -
diff --git a/routes/web.php b/routes/web.php index 91d69cb..db004f4 100644 --- a/routes/web.php +++ b/routes/web.php @@ -19,6 +19,8 @@ return back(); })->name('notifications.read-all'); + Route::get('notifications/{id}/resolve', App\Http\Controllers\ResolveNotificationRouteController::class)->name('notifications.resolve'); + Route::livewire('tickets', 'pages::tickets.index')->name('tickets.index'); Route::prefix('dashboard')->name('dashboard.')->group(function (): void { diff --git a/tests/Feature/NotificationResolutionTest.php b/tests/Feature/NotificationResolutionTest.php new file mode 100644 index 0000000..40c0d1d --- /dev/null +++ b/tests/Feature/NotificationResolutionTest.php @@ -0,0 +1,63 @@ +seed(DatabaseSeeder::class); + } + + public function test_guest_cannot_resolve_notifications(): void + { + $this->get(route('notifications.resolve', ['id' => 'some-uuid'])) + ->assertRedirect(route('login')); + } + + public function test_user_can_resolve_and_redirect_notification_modularly(): void + { + $creator = User::factory()->create(); + $creator->assignRole('user'); + + $supportUser = User::factory()->create(); + $supportUser->assignRole('Support'); + + $openStatus = TicketStatus::where('name', 'open')->firstOrFail(); + + $ticket = Ticket::create([ + 'user_id' => $creator->id, + 'issue_category' => 'Billing Query', + 'status_id' => $openStatus->id, + 'assigned_user_id' => $supportUser->id, + ]); + + // Send a notification + $supportUser->notify(new TicketAssignedNotification($ticket)); + + // Retrieve unread notification + $notification = $supportUser->unreadNotifications()->firstOrFail(); + + $this->actingAs($supportUser); + + // Resolve notification + $response = $this->get(route('notifications.resolve', ['id' => $notification->id])); + + // Should mark as read + $this->assertSame(0, $supportUser->unreadNotifications()->count()); + + // Should redirect modularly to support tickets index page with ticket ID parameter + $response->assertRedirect(route('tickets.index', ['ticket' => $ticket->id])); + } +} diff --git a/tests/Feature/TicketSystemTest.php b/tests/Feature/TicketSystemTest.php index 21f794f..cd7f949 100644 --- a/tests/Feature/TicketSystemTest.php +++ b/tests/Feature/TicketSystemTest.php @@ -55,7 +55,7 @@ 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->status->value)->toBe(TicketStatusEnum::Open->value) ->and($ticketData->notifiedRoleNames)->toContain('Support'); $this->assertDatabaseHas('tickets', [ @@ -183,3 +183,176 @@ TicketRaisedNotification::class ); }); + +test('admins can assign users to tickets and trigger assignment notifications', function (): void { + Notification::fake(); + + $admin = User::where('email', 'admin@example.com')->firstOrFail(); + $this->actingAs($admin); + + $user = User::factory()->create(); + $user->assignRole('user'); + + $ticket = App\Models\Ticket::create([ + 'user_id' => $user->id, + 'issue_category' => 'Billing Query', + 'status_id' => App\Models\TicketStatus::where('name', 'open')->firstOrFail()->id, + ]); + + $supportUser = User::factory()->create(); + $supportUser->assignRole('Support'); + + $service = app(TicketService::class); + $service->assignUser($ticket->id, $supportUser->id); + + $this->assertDatabaseHas('tickets', [ + 'id' => $ticket->id, + 'assigned_user_id' => $supportUser->id, + ]); + + Notification::assertSentTo( + $supportUser, + App\Notifications\TicketAssignedNotification::class + ); +}); + +test('authorized users can comment on tickets and unauthorized users are blocked', function (): void { + $creator = User::factory()->create(); + $creator->assignRole('user'); + + $unauthorizedUser = User::factory()->create(); + $unauthorizedUser->assignRole('user'); + + $ticket = App\Models\Ticket::create([ + 'user_id' => $creator->id, + 'issue_category' => 'Technical Issue', + 'status_id' => App\Models\TicketStatus::where('name', 'open')->firstOrFail()->id, + ]); + + $service = app(TicketService::class); + + // 1. Authorized user (Creator) can comment + $dto = new App\Data\Ticket\PostReplyRequest( + ticketId: $ticket->id, + userId: $creator->id, + message: 'This is a comment from the creator.', + ); + $replyData = $service->postReply($dto); + + expect($replyData->message)->toBe('This is a comment from the creator.'); + + $this->assertDatabaseHas('ticket_replies', [ + 'ticket_id' => $ticket->id, + 'user_id' => $creator->id, + 'message' => 'This is a comment from the creator.', + ]); + + // 2. Unauthorized user gets blocked + $dto2 = new App\Data\Ticket\PostReplyRequest( + ticketId: $ticket->id, + userId: $unauthorizedUser->id, + message: 'Should be blocked.', + ); + + expect(fn () => $service->postReply($dto2)) + ->toThrow(Illuminate\Auth\Access\AuthorizationException::class); +}); + +test('posting a comment transitions the status from open to in-progress', function (): void { + $creator = User::factory()->create(); + $creator->assignRole('user'); + + $openStatus = App\Models\TicketStatus::where('name', 'open')->firstOrFail(); + $inProgressStatus = App\Models\TicketStatus::where('name', 'in-progress')->firstOrFail(); + + $ticket = App\Models\Ticket::create([ + 'user_id' => $creator->id, + 'issue_category' => 'Service Extension', + 'status_id' => $openStatus->id, + ]); + + $service = app(TicketService::class); + + $dto = new App\Data\Ticket\PostReplyRequest( + ticketId: $ticket->id, + userId: $creator->id, + message: 'Transition this status.', + ); + + $service->postReply($dto); + + $this->assertDatabaseHas('tickets', [ + 'id' => $ticket->id, + 'status_id' => $inProgressStatus->id, + ]); +}); + +test('assigned support users can see their assigned tickets on their dashboard', function (): void { + $creator = User::factory()->create(); + $creator->assignRole('user'); + + $supportUser = User::factory()->create(); + $supportUser->assignRole('Support'); + + $openStatus = App\Models\TicketStatus::where('name', 'open')->firstOrFail(); + + $ticket = App\Models\Ticket::create([ + 'user_id' => $creator->id, + 'issue_category' => 'Technical Issue', + 'status_id' => $openStatus->id, + 'assigned_user_id' => $supportUser->id, + ]); + + $service = app(TicketService::class); + $tickets = $service->getListForUser($supportUser->id); + + expect($tickets->contains('id', $ticket->id))->toBeTrue(); +}); + +test('commenting on a ticket notifies the creator and assigned user', function (): void { + Notification::fake(); + + $creator = User::factory()->create(); + $creator->assignRole('user'); + + $supportUser = User::factory()->create(); + $supportUser->assignRole('Support'); + + $openStatus = App\Models\TicketStatus::where('name', 'open')->firstOrFail(); + + $ticket = App\Models\Ticket::create([ + 'user_id' => $creator->id, + 'issue_category' => 'Billing Query', + 'status_id' => $openStatus->id, + 'assigned_user_id' => $supportUser->id, + ]); + + // Another admin comments + $admin = User::where('email', 'admin@example.com')->firstOrFail(); + + $dto = new App\Data\Ticket\PostReplyRequest( + ticketId: $ticket->id, + userId: $admin->id, + message: 'This is an admin comment.', + ); + + $service = app(TicketService::class); + $service->postReply($dto); + + // Both the creator and the support assignee should be notified of the comment + Notification::assertSentTo( + $creator, + App\Notifications\TicketCommentedNotification::class + ); + + Notification::assertSentTo( + $supportUser, + App\Notifications\TicketCommentedNotification::class + ); + + // Commenter (admin) should not get notified + Notification::assertNotSentTo( + $admin, + App\Notifications\TicketCommentedNotification::class + ); +});