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.
This commit is contained in:
parent
c826ffbfcb
commit
cac5f4905b
23
app/Data/Ticket/CreateTicketRequest.php
Normal file
23
app/Data/Ticket/CreateTicketRequest.php
Normal file
@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Data\Ticket;
|
||||
|
||||
use Spatie\LaravelData\Attributes\MapOutputName;
|
||||
use Spatie\LaravelData\Data;
|
||||
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
|
||||
|
||||
#[MapOutputName(SnakeCaseMapper::class)]
|
||||
class CreateTicketRequest extends Data
|
||||
{
|
||||
public function __construct(
|
||||
public int $userId,
|
||||
public ?int $connectedAppId,
|
||||
public string $issueCategory,
|
||||
public ?string $description = null,
|
||||
public array $notifiedRoleIds = [],
|
||||
public array $notifiedUserIds = [],
|
||||
public mixed $attachment = null,
|
||||
) {}
|
||||
}
|
||||
58
app/Data/Ui/Ticket/TicketData.php
Normal file
58
app/Data/Ui/Ticket/TicketData.php
Normal file
@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Data\Ui\Ticket;
|
||||
|
||||
use App\Models\Ticket;
|
||||
use Spatie\LaravelData\Data;
|
||||
|
||||
final class TicketData extends Data
|
||||
{
|
||||
public function __construct(
|
||||
public int $id,
|
||||
public string $ticketId,
|
||||
public int $userId,
|
||||
public string $userName,
|
||||
public ?int $connectedAppId,
|
||||
public ?string $appName,
|
||||
public string $issueCategory,
|
||||
public ?string $description,
|
||||
public int $statusId,
|
||||
public string $statusName,
|
||||
public string $statusSlug,
|
||||
public array $notifiedRoleNames,
|
||||
public array $notifiedUserNames,
|
||||
public string $createdAt,
|
||||
public array $attachments = [],
|
||||
) {}
|
||||
|
||||
public static function fromModel(Ticket $ticket): self
|
||||
{
|
||||
$attachments = $ticket->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,
|
||||
);
|
||||
}
|
||||
}
|
||||
14
app/Enums/Permissions/TicketPermissionEnum.php
Normal file
14
app/Enums/Permissions/TicketPermissionEnum.php
Normal file
@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums\Permissions;
|
||||
|
||||
enum TicketPermissionEnum: string
|
||||
{
|
||||
case Access = 'ticket:access';
|
||||
case Create = 'ticket:create';
|
||||
case Read = 'ticket:read';
|
||||
case Update = 'ticket:update';
|
||||
case Delete = 'ticket:delete';
|
||||
}
|
||||
17
app/Enums/TicketStatusEnum.php
Normal file
17
app/Enums/TicketStatusEnum.php
Normal file
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use App\Concerns\EnumValuesAsArray;
|
||||
|
||||
enum TicketStatusEnum: string
|
||||
{
|
||||
use EnumValuesAsArray;
|
||||
|
||||
case Open = 'open';
|
||||
case InProgress = 'in-progress';
|
||||
case Resolved = 'resolved';
|
||||
case Closed = 'closed';
|
||||
}
|
||||
62
app/Livewire/Forms/CreateTicketForm.php
Normal file
62
app/Livewire/Forms/CreateTicketForm.php
Normal file
@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Livewire\Forms;
|
||||
|
||||
use Livewire\Form;
|
||||
|
||||
class CreateTicketForm extends Form
|
||||
{
|
||||
public ?int $connectedAppId = null;
|
||||
|
||||
public string $issueCategory = 'Billing Query';
|
||||
|
||||
public string $description = '';
|
||||
|
||||
public string $routingType = 'none';
|
||||
|
||||
public array $notifiedRoleIds = [];
|
||||
|
||||
public array $notifiedUserIds = [];
|
||||
|
||||
public mixed $attachment = null;
|
||||
|
||||
/**
|
||||
* Define the validation rules dynamically.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
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<string, string>
|
||||
*/
|
||||
public function validationAttributes(): array
|
||||
{
|
||||
return [
|
||||
'connectedAppId' => 'service',
|
||||
'issueCategory' => 'issue category',
|
||||
'routingType' => 'routing type',
|
||||
'notifiedRoleIds' => 'notified roles',
|
||||
'notifiedUserIds' => 'notified users',
|
||||
'description' => 'description',
|
||||
'attachment' => 'file attachment',
|
||||
];
|
||||
}
|
||||
}
|
||||
95
app/Models/Ticket.php
Normal file
95
app/Models/Ticket.php
Normal file
@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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\Relations\{BelongsTo, BelongsToMany, HasMany};
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
#[Fillable([
|
||||
'ticket_id',
|
||||
'user_id',
|
||||
'connected_app_id',
|
||||
'issue_category',
|
||||
'description',
|
||||
'status_id',
|
||||
])]
|
||||
class Ticket extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (Ticket $ticket): void {
|
||||
if (empty($ticket->ticket_id)) {
|
||||
do {
|
||||
$ticketId = 'TKT-'.mb_strtoupper(Str::random(8));
|
||||
} while (static::query()->where('ticket_id', $ticketId)->exists());
|
||||
|
||||
$ticket->ticket_id = $ticketId;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<ConnectedApp, $this>
|
||||
*/
|
||||
public function connectedApp(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ConnectedApp::class, 'connected_app_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<TicketStatus, $this>
|
||||
*/
|
||||
public function status(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(TicketStatus::class, 'status_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<Role, $this>
|
||||
*/
|
||||
public function notifiedRoles(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(
|
||||
Role::class,
|
||||
'ticket_notified_roles',
|
||||
'ticket_id',
|
||||
'role_id'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<User, $this>
|
||||
*/
|
||||
public function notifiedUsers(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(
|
||||
User::class,
|
||||
'ticket_notified_users',
|
||||
'ticket_id',
|
||||
'user_id'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<TicketAttachment, $this>
|
||||
*/
|
||||
public function attachments(): HasMany
|
||||
{
|
||||
return $this->hasMany(TicketAttachment::class, 'ticket_id');
|
||||
}
|
||||
}
|
||||
30
app/Models/TicketAttachment.php
Normal file
30
app/Models/TicketAttachment.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable([
|
||||
'ticket_id',
|
||||
'file_path',
|
||||
'file_name',
|
||||
'file_size',
|
||||
'mime_type',
|
||||
])]
|
||||
class TicketAttachment extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Ticket, $this>
|
||||
*/
|
||||
public function ticket(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Ticket::class, 'ticket_id');
|
||||
}
|
||||
}
|
||||
15
app/Models/TicketStatus.php
Normal file
15
app/Models/TicketStatus.php
Normal file
@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
#[Fillable(['name', 'slug'])]
|
||||
class TicketStatus extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
}
|
||||
67
app/Notifications/TicketRaisedNotification.php
Normal file
67
app/Notifications/TicketRaisedNotification.php
Normal file
@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
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 TicketRaisedNotification 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
|
||||
{
|
||||
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<string, mixed>
|
||||
*/
|
||||
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}).",
|
||||
];
|
||||
}
|
||||
}
|
||||
40
app/Services/TicketAttachmentService.php
Normal file
40
app/Services/TicketAttachmentService.php
Normal file
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\TicketAttachment;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class TicketAttachmentService
|
||||
{
|
||||
/**
|
||||
* Store a ticket attachment and create the database record.
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function store(int $ticketId, UploadedFile $file): TicketAttachment
|
||||
{
|
||||
if ($ticketId <= 0) {
|
||||
throw new InvalidArgumentException('Invalid ticket ID');
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($ticketId, $file): TicketAttachment {
|
||||
$path = $file->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;
|
||||
});
|
||||
}
|
||||
}
|
||||
159
app/Services/TicketService.php
Normal file
159
app/Services/TicketService.php
Normal file
@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Data\Ticket\CreateTicketRequest;
|
||||
use App\Data\Ui\Ticket\TicketData;
|
||||
use App\Enums\TicketStatusEnum;
|
||||
use App\Models\{Ticket, TicketStatus, User};
|
||||
use App\Notifications\TicketRaisedNotification;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use InvalidArgumentException;
|
||||
use Throwable;
|
||||
|
||||
final class TicketService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TicketAttachmentService $attachmentService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a new support ticket.
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function create(CreateTicketRequest $request): TicketData
|
||||
{
|
||||
return DB::transaction(function () use ($request): TicketData {
|
||||
$defaultStatus = TicketStatus::query()
|
||||
->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<int, TicketData>
|
||||
*/
|
||||
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<int, TicketData>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class() extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('notifications', function (Blueprint $table): void {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class() extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('ticket_statuses', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('name')->unique();
|
||||
$table->string('slug')->unique();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('ticket_statuses');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class() extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('tickets', function (Blueprint $table): void {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
44
database/seeders/TicketStatusSeeder.php
Normal file
44
database/seeders/TicketStatusSeeder.php
Normal file
@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Enums\TicketStatusEnum;
|
||||
use App\Models\TicketStatus;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class TicketStatusSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
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(),
|
||||
],
|
||||
];
|
||||
|
||||
TicketStatus::query()
|
||||
->upsert($data, ['slug'], ['name', 'updated_at']);
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -8,9 +8,53 @@
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex space-x-4 items-center">
|
||||
<x-shared.button>
|
||||
<x-lucide-bell class="w-5 h-5"/>
|
||||
</x-shared.button>
|
||||
@php
|
||||
$unreadNotifications = $user?->unreadNotifications()->take(5)->get() ?? collect();
|
||||
$unreadCount = $user?->unreadNotifications()->count() ?? 0;
|
||||
@endphp
|
||||
<x-mary-dropdown right class="relative">
|
||||
<x-slot:trigger>
|
||||
<div class="relative cursor-pointer p-2 hover:bg-gray-100 rounded-full">
|
||||
<x-lucide-bell class="w-5 h-5 text-gray-600"/>
|
||||
@if($unreadCount > 0)
|
||||
<span
|
||||
class="absolute top-1.5 right-1.5 inline-flex items-center justify-center px-1.5 py-0.5 text-[9px] font-bold leading-none text-white transform translate-x-1/3 -translate-y-1/3 bg-red-500 rounded-full">
|
||||
{{ $unreadCount }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
</x-slot:trigger>
|
||||
|
||||
@if($unreadNotifications->isEmpty())
|
||||
<x-mary-menu-item title="No new notifications" class="text-gray-400 text-xs py-3 px-4 min-w-50"/>
|
||||
@else
|
||||
<div
|
||||
class="px-4 py-2 border-b border-gray-100 font-semibold text-[10px] text-gray-500 uppercase tracking-wider flex justify-between items-center gap-4 min-w-60">
|
||||
<span>Notifications</span>
|
||||
<form method="POST" action="{{ route('notifications.read-all') }}">
|
||||
@csrf
|
||||
<button type="submit" class="text-blue-600 hover:underline text-[10px] cursor-pointer">Mark all
|
||||
read
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@foreach($unreadNotifications as $notification)
|
||||
<x-mary-menu-item>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="font-semibold text-xs text-gray-900">
|
||||
{{ data_get($notification->data, 'issue_category', 'Notification') }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500 line-clamp-2 leading-tight">
|
||||
{{ data_get($notification->data, 'message', '') }}
|
||||
</span>
|
||||
<span class="text-[9px] text-gray-400 mt-1">
|
||||
{{ $notification->created_at->diffForHumans() }}
|
||||
</span>
|
||||
</div>
|
||||
</x-mary-menu-item>
|
||||
@endforeach
|
||||
@endif
|
||||
</x-mary-dropdown>
|
||||
|
||||
<x-mary-dropdown>
|
||||
<x-slot:trigger>
|
||||
|
||||
@ -126,6 +126,17 @@ class="text-sm font-medium {{ $service->is_warning ? 'text-amber-600 dark:text-a
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
@if($service->is_warning)
|
||||
<x-mary-button
|
||||
icon="lucide.life-buoy"
|
||||
:link="route('tickets.index', ['raise' => 1, 'app_id' => $service->id])"
|
||||
wire:navigate
|
||||
class="btn-sm btn-circle btn-soft btn-warning"
|
||||
tooltip="Raise Ticket"
|
||||
/>
|
||||
@endif
|
||||
|
||||
<x-mary-button
|
||||
icon="lucide.external-link"
|
||||
class="btn-sm btn-circle btn-soft btn-primary"
|
||||
@ -133,6 +144,7 @@ class="btn-sm btn-circle btn-soft btn-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-shared.card>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
738
resources/views/pages/tickets/⚡index.blade.php
Normal file
738
resources/views/pages/tickets/⚡index.blade.php
Normal file
@ -0,0 +1,738 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Concerns\HandlesOperations;
|
||||
use App\Data\Ticket\CreateTicketRequest;
|
||||
use App\Data\Ui\Ticket\TicketData;
|
||||
use App\Enums\Permissions\TicketPermissionEnum;
|
||||
use App\Enums\TicketStatusEnum;
|
||||
use App\Livewire\Forms\CreateTicketForm;
|
||||
use App\Models\{ConnectedApp, Role, Ticket, TicketStatus, User};
|
||||
use App\Services\{TicketService, UserAppAccessService};
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Attributes\{Computed, Layout, Title, Url};
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
new
|
||||
#[Layout('layouts.app.sidebar')]
|
||||
#[Title('Support Tickets')]
|
||||
class extends Component {
|
||||
use WithFileUploads, HandlesOperations;
|
||||
|
||||
private TicketService $ticketService;
|
||||
private UserAppAccessService $appAccessService;
|
||||
|
||||
// URL parameters to auto-trigger the drawer
|
||||
#[Url(as: 'raise', keep: true)]
|
||||
public bool $raise = false;
|
||||
|
||||
#[Url(as: 'app_id', keep: true)]
|
||||
public ?int $appId = null;
|
||||
|
||||
// Livewire Form Object
|
||||
public CreateTicketForm $form;
|
||||
|
||||
// Drawer Open States
|
||||
public bool $drawerOpen = false;
|
||||
public bool $detailsModalOpen = false;
|
||||
|
||||
// Searchable lists for mary-choices
|
||||
public array $appsSearchable = [];
|
||||
public array $rolesSearchable = [];
|
||||
public array $usersSearchable = [];
|
||||
|
||||
// Selected ticket for details pane
|
||||
public ?int $selectedTicketId = null;
|
||||
public ?TicketData $selectedTicket = null;
|
||||
|
||||
// Selected status for filtering (Admin/Support only)
|
||||
public string $statusFilter = 'all';
|
||||
|
||||
public function boot(TicketService $ticketService, UserAppAccessService $appAccessService): void
|
||||
{
|
||||
$this->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();
|
||||
}
|
||||
}
|
||||
};
|
||||
?>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- 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->slug, 'name' => $s->name])->toArray())"
|
||||
class="select-sm select-bordered w-48"
|
||||
/>
|
||||
@endif
|
||||
|
||||
<x-mary-button
|
||||
wire:click="toggleDrawer"
|
||||
icon="lucide.plus"
|
||||
class="btn-primary btn-sm rounded-xl font-semibold shadow-xs"
|
||||
>
|
||||
Raise Ticket
|
||||
</x-mary-button>
|
||||
</x-slot:actions>
|
||||
@if($this->tickets->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>
|
||||
</div>
|
||||
@else
|
||||
<x-mary-table
|
||||
:headers="[
|
||||
['key' => 'ticketId', 'label' => 'Ticket ID', 'class' => 'font-semibold font-mono text-xs'],
|
||||
['key' => 'userName', 'label' => 'Customer', 'hidden' => !(auth()->user()->hasRole('Admin') || auth()->user()->can('ticket:read'))],
|
||||
['key' => 'appName', 'label' => 'Service'],
|
||||
['key' => 'issueCategory', 'label' => 'Category'],
|
||||
['key' => 'createdAt', 'label' => 'Date Raised'],
|
||||
['key' => 'statusName', 'label' => 'Status'],
|
||||
]"
|
||||
:rows="$this->tickets->toArray()"
|
||||
wire:loading.class="opacity-60"
|
||||
>
|
||||
@scope('cell_ticketId', $row)
|
||||
<button
|
||||
wire:click="showTicket({{ $row['id'] }})"
|
||||
class="font-mono font-semibold text-blue-600 hover:underline text-left cursor-pointer"
|
||||
>
|
||||
{{ $row['ticketId'] }}
|
||||
</button>
|
||||
@endscope
|
||||
|
||||
@scope('cell_appName', $row)
|
||||
@if($row['appName'])
|
||||
<span class="font-medium text-gray-700">{{ $row['appName'] }}</span>
|
||||
@else
|
||||
<span class="text-gray-400 italic">General Support</span>
|
||||
@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
|
||||
<x-mary-badge class="{{ $badgeClass }} font-semibold text-xs px-2.5 py-1">
|
||||
{{ $row['statusName'] }}
|
||||
</x-mary-badge>
|
||||
@endscope
|
||||
|
||||
@scope('cell_createdAt', $row)
|
||||
<span class="text-xs text-gray-500">
|
||||
{{ Carbon\Carbon::parse($row['createdAt'])->diffForHumans() }}
|
||||
</span>
|
||||
@endscope
|
||||
|
||||
@scope('actions', $row)
|
||||
<div class="flex items-center gap-2">
|
||||
<x-mary-button
|
||||
wire:click="showTicket({{ $row['id'] }})"
|
||||
icon="lucide.eye"
|
||||
class="btn-xs btn-circle btn-ghost text-gray-500"
|
||||
tooltip="View Details"
|
||||
/>
|
||||
|
||||
@if(auth()->user()->hasRole('Admin') || auth()->user()->can('ticket:update'))
|
||||
<x-mary-dropdown right class="btn-xs">
|
||||
<x-slot:trigger>
|
||||
<x-mary-button icon="lucide.settings-2"
|
||||
class="btn-xs btn-circle btn-ghost text-gray-500"/>
|
||||
</x-slot:trigger>
|
||||
@foreach($this->statuses as $status)
|
||||
@if($status->slug !== $row['statusSlug'])
|
||||
<x-mary-menu-item
|
||||
title="Mark as {{ $status->name }}"
|
||||
wire:click="changeStatus({{ $row['id'] }}, '{{ $status->slug }}')"
|
||||
/>
|
||||
@endif
|
||||
@endforeach
|
||||
</x-mary-dropdown>
|
||||
@endif
|
||||
</div>
|
||||
@endscope
|
||||
</x-mary-table>
|
||||
@endif
|
||||
</x-shared.card>
|
||||
|
||||
<!-- Support Ticket Drawer (Right slide-out) -->
|
||||
<x-mary-drawer
|
||||
wire:model="drawerOpen"
|
||||
title="Raise Support Ticket"
|
||||
right
|
||||
separator
|
||||
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"
|
||||
:label="__('Select Service (Expiring Soon)')"
|
||||
placeholder="Search/Select service..."
|
||||
:options="$appsSearchable"
|
||||
:single="true"
|
||||
search-function="searchApps"
|
||||
searchable
|
||||
/>
|
||||
|
||||
<!-- Issue Category -->
|
||||
<x-mary-select
|
||||
wire:model.live="form.issueCategory"
|
||||
required
|
||||
:label="__('Issue Category')"
|
||||
:options="[
|
||||
['id' => 'Billing Query', 'name' => 'Billing Query'],
|
||||
['id' => 'Service Extension', 'name' => 'Service Extension'],
|
||||
['id' => 'Technical Issue', 'name' => 'Technical Issue'],
|
||||
['id' => 'Others', 'name' => 'Others (Requires custom details)']
|
||||
]"
|
||||
/>
|
||||
|
||||
<!-- Dynamic Conditional Fields (When "Others" is selected) -->
|
||||
@if($form->issueCategory === 'Others')
|
||||
<!-- Description Field (Mandatory if "Others") -->
|
||||
<div wire:key="description-others-container">
|
||||
<x-mary-textarea
|
||||
wire:model="form.description"
|
||||
required
|
||||
:label="__('Description')"
|
||||
placeholder="Describe your query or issue details..."
|
||||
rows="4"
|
||||
hint="Mandatory: Minimum 20 characters required."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- File Attachment -->
|
||||
<div wire:key="attachment-others-container" class="space-y-2">
|
||||
<x-mary-file
|
||||
wire:model="form.attachment"
|
||||
:label="__('File Attachment')"
|
||||
accept=".jpg,.jpeg,.png,.pdf"
|
||||
hint="Accepted formats: .jpg, .jpeg, .png, .pdf (Max size: 5MB)"
|
||||
/>
|
||||
@if($form->attachment)
|
||||
<div class="text-xs text-green-600 flex items-center gap-1">
|
||||
<x-mary-icon name="lucide.check-circle" class="w-3.5 h-3.5"/>
|
||||
File successfully uploaded.
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<!-- Optional Description for other categories -->
|
||||
<div wire:key="description-optional-container">
|
||||
<x-mary-textarea
|
||||
wire:model="form.description"
|
||||
:label="__('Description (Optional)')"
|
||||
placeholder="Provide any additional details..."
|
||||
rows="4"
|
||||
/>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<hr class="border-gray-100 my-2"/>
|
||||
|
||||
<!-- Notification Routing (Role or User selection with dynamic choices and select-all actions) -->
|
||||
<div class="space-y-3">
|
||||
<label class="text-xs font-semibold text-gray-500">Notification Routing</label>
|
||||
|
||||
<!-- Select between Role or User or null -->
|
||||
<div class="flex gap-4 mb-2">
|
||||
<label class="flex items-center gap-2 text-xs cursor-pointer">
|
||||
<input type="radio" wire:model.live="form.routingType" value="role"
|
||||
class="radio radio-primary radio-xs"/>
|
||||
Notify Roles
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-xs cursor-pointer">
|
||||
<input type="radio" wire:model.live="form.routingType" value="user"
|
||||
class="radio radio-primary radio-xs"/>
|
||||
Notify Specific Users
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-xs cursor-pointer">
|
||||
<input type="radio" wire:model.live="form.routingType" value="none"
|
||||
class="radio radio-primary radio-xs"/>
|
||||
None
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@if($form->routingType === 'role')
|
||||
<div wire:key="routing-roles-container">
|
||||
<x-shared.choices
|
||||
wire:model="form.notifiedRoleIds"
|
||||
:options="$rolesSearchable"
|
||||
search-function="searchRoles"
|
||||
select-all-action="selectAllRoles"
|
||||
clear-action="clearRoles"
|
||||
label="Roles to Notify"
|
||||
/>
|
||||
</div>
|
||||
@else
|
||||
<div wire:key="routing-users-container">
|
||||
<x-shared.choices
|
||||
wire:model="form.notifiedUserIds"
|
||||
:options="$usersSearchable"
|
||||
search-function="searchUsers"
|
||||
select-all-action="selectAllUsers"
|
||||
clear-action="clearUsers"
|
||||
label="Users to Notify"
|
||||
/>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Submit actions inside Drawer footer -->
|
||||
<x-slot:actions>
|
||||
<x-mary-button label="Cancel" wire:click="toggleDrawer" class="btn-ghost"/>
|
||||
<x-mary-button label="Submit" type="submit" class="btn-primary" spinner="saveTicket"/>
|
||||
</x-slot:actions>
|
||||
</x-mary-form>
|
||||
</x-mary-drawer>
|
||||
|
||||
<!-- Support Ticket Details Modal -->
|
||||
<x-mary-modal wire:model="detailsModalOpen" title="Ticket Details" class="">
|
||||
@if($selectedTicket)
|
||||
<div class="space-y-6">
|
||||
<!-- ID & Status Section -->
|
||||
<div class="flex justify-between items-start border-b border-gray-100 pb-4">
|
||||
<div>
|
||||
<span class="text-xs font-semibold uppercase tracking-wider text-gray-400 font-mono">Support Ticket</span>
|
||||
<h2 class="text-xl font-bold font-mono text-gray-900 mt-0.5">{{ $selectedTicket->ticketId }}</h2>
|
||||
</div>
|
||||
|
||||
@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
|
||||
<div class="flex flex-col items-end gap-1.5">
|
||||
<x-mary-badge class="{{ $badgeClass }} font-semibold px-3 py-1.5">
|
||||
{{ $selectedTicket->statusName }}
|
||||
</x-mary-badge>
|
||||
<span
|
||||
class="text-[10px] text-gray-400">Raised {{ Carbon\Carbon::parse($selectedTicket->createdAt)->diffForHumans() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ticket Properties -->
|
||||
<div class="grid grid-cols-2 gap-y-4 gap-x-6 text-sm">
|
||||
<div>
|
||||
<span class="text-xs font-semibold text-gray-400 uppercase">Customer</span>
|
||||
<p class="font-medium text-gray-900 mt-0.5">{{ $selectedTicket->userName }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="text-xs font-semibold text-gray-400 uppercase">Associated Service</span>
|
||||
<p class="font-medium text-gray-900 mt-0.5">{{ $selectedTicket->appName ?? 'General Inquiry' }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="text-xs font-semibold text-gray-400 uppercase">Issue Category</span>
|
||||
<p class="font-medium text-gray-900 mt-0.5">{{ $selectedTicket->issueCategory }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="text-xs font-semibold text-gray-400 uppercase">Notified Recipient(s)</span>
|
||||
@if(!empty($selectedTicket->notifiedUserNames))
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
<span class="text-xs text-gray-500 mr-1 flex items-center gap-1 font-semibold">
|
||||
<x-mary-icon name="lucide.user" class="w-3 h-3 text-gray-400"/>
|
||||
Users:
|
||||
</span>
|
||||
@foreach($selectedTicket->notifiedUserNames as $name)
|
||||
<x-mary-badge class="badge-soft badge-primary text-[10px] px-1.5 py-0.5"
|
||||
:value="$name"/>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
@if(!empty($selectedTicket->notifiedRoleNames))
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
<span class="text-xs text-gray-500 mr-1 flex items-center gap-1 font-semibold">
|
||||
<x-mary-icon name="lucide.users" class="w-3 h-3 text-gray-400"/>
|
||||
Roles:
|
||||
</span>
|
||||
@foreach($selectedTicket->notifiedRoleNames as $name)
|
||||
<x-mary-badge class="badge-soft badge-secondary text-[10px] px-1.5 py-0.5"
|
||||
:value="$name"/>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
@if(empty($selectedTicket->notifiedUserNames) && empty($selectedTicket->notifiedRoleNames))
|
||||
<p class="text-gray-400 italic">None specified</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="bg-gray-100 p-2 rounded-xl space-y-1">
|
||||
<span class="text-xs font-semibold text-gray-400 uppercase pb-1 border-b border-gray-300">Description Details</span>
|
||||
<p class="text-sm text-left text-gray-700 mt-1">
|
||||
{{ $selectedTicket->description ?? 'No custom details provided.' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Attachments Section -->
|
||||
@if(!empty($selectedTicket->attachments))
|
||||
<div class="space-y-2">
|
||||
<span class="text-xs font-semibold text-gray-400 uppercase block">Attachments</span>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
@foreach($selectedTicket->attachments as $attachment)
|
||||
<div
|
||||
class="flex items-center justify-between p-3 border border-gray-100 rounded-xl bg-white shadow-2xs hover:border-blue-100 transition-colors">
|
||||
<div class="flex items-center gap-2.5 min-w-0">
|
||||
@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
|
||||
<x-mary-icon :name="$icon" class="w-8 h-8 text-blue-500 shrink-0"/>
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs font-medium text-gray-800 truncate"
|
||||
title="{{ $attachment['file_name'] }}">
|
||||
{{ $attachment['file_name'] }}
|
||||
</p>
|
||||
<p class="text-[10px] text-gray-400">
|
||||
{{ round($attachment['file_size'] / 1024, 1) }} KB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<x-mary-button
|
||||
:link="asset('storage/' . $attachment['file_path'])"
|
||||
target="_blank"
|
||||
icon="lucide.download"
|
||||
class="btn-xs btn-soft btn-primary btn-circle shrink-0"
|
||||
tooltip="Download Attachment"
|
||||
/>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Admin operations inside modal -->
|
||||
@if(auth()->user()->hasRole('Admin') || auth()->user()->can('ticket:update'))
|
||||
<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">
|
||||
@foreach($this->statuses as $status)
|
||||
@if($status->slug !== $selectedTicket->statusSlug)
|
||||
<x-mary-button
|
||||
label="{{ $status->name }}"
|
||||
wire:click="changeStatus({{ $selectedTicket->id }}, '{{ $status->slug }}')"
|
||||
class="btn-xs rounded-lg btn-soft"
|
||||
/>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
<x-slot:actions>
|
||||
<x-mary-button label="Close" wire:click="$set('detailsModalOpen', false)" class="btn-ghost"/>
|
||||
</x-slot:actions>
|
||||
</x-mary-modal>
|
||||
</div>
|
||||
@ -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))
|
||||
|
||||
185
tests/Feature/TicketSystemTest.php
Normal file
185
tests/Feature/TicketSystemTest.php
Normal file
@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Data\Ticket\CreateTicketRequest;
|
||||
use App\Enums\TicketStatusEnum;
|
||||
use App\Models\{ConnectedApp, Role, User};
|
||||
use App\Notifications\TicketRaisedNotification;
|
||||
use App\Services\TicketService;
|
||||
use Database\Seeders\DatabaseSeeder;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\{Notification, Storage};
|
||||
use Livewire\Livewire;
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->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
|
||||
);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user