From 391699220f046ca150e9d69c7d10e04800f3b38d Mon Sep 17 00:00:00 2001 From: Joel Wedemire Date: Wed, 8 Apr 2026 17:10:30 -0700 Subject: [PATCH] feat: full dashboard-ticketing scaffold with data model, controllers, Vue pages --- composer.json | 17 +- ...8_000001_create_ticketing_groups_table.php | 25 ++ ...2026_04_08_000001_create_tickets_table.php | 28 -- ...08_000002_create_ticket_comments_table.php | 25 -- ...create_ticketing_priority_levels_table.php | 26 ++ ...000003_create_ticketing_projects_table.php | 26 ++ ...2026_04_08_000004_create_tickets_table.php | 31 ++ ...08_000005_create_ticket_messages_table.php | 28 ++ ...000006_create_ticket_attachments_table.php | 27 ++ ...07_create_ticketing_agent_access_table.php | 26 ++ ...eate_ticketing_email_connections_table.php | 26 ++ resources/js/Pages/Ticketing/Create.vue | 187 +++++---- resources/js/Pages/Ticketing/Edit.vue | 215 +++++----- resources/js/Pages/Ticketing/Index.vue | 372 +++++++++-------- resources/js/Pages/Ticketing/MyTickets.vue | 73 ++++ resources/js/Pages/Ticketing/Settings.vue | 299 ++++++++++++++ resources/js/Pages/Ticketing/Show.vue | 380 +++++++++++++----- .../TicketAttachmentController.php | 63 +++ .../Controllers/TicketCommentController.php | 36 -- src/Http/Controllers/TicketController.php | 267 ++++++++---- .../Controllers/TicketMessageController.php | 70 ++++ .../TicketingSettingsController.php | 144 +++++++ src/Models/EmailConnection.php | 24 ++ src/Models/PriorityLevel.php | 24 ++ src/Models/Ticket.php | 35 +- src/Models/TicketAttachment.php | 21 + src/Models/TicketComment.php | 30 -- src/Models/TicketMessage.php | 29 ++ src/Models/TicketingAgentAccess.php | 18 + src/Models/TicketingGroup.php | 42 ++ src/Models/TicketingProject.php | 22 + src/TicketingServiceProvider.php | 5 +- src/routes/ticketing.php | 25 +- 33 files changed, 1947 insertions(+), 719 deletions(-) create mode 100644 database/migrations/2026_04_08_000001_create_ticketing_groups_table.php delete mode 100644 database/migrations/2026_04_08_000001_create_tickets_table.php delete mode 100644 database/migrations/2026_04_08_000002_create_ticket_comments_table.php create mode 100644 database/migrations/2026_04_08_000002_create_ticketing_priority_levels_table.php create mode 100644 database/migrations/2026_04_08_000003_create_ticketing_projects_table.php create mode 100644 database/migrations/2026_04_08_000004_create_tickets_table.php create mode 100644 database/migrations/2026_04_08_000005_create_ticket_messages_table.php create mode 100644 database/migrations/2026_04_08_000006_create_ticket_attachments_table.php create mode 100644 database/migrations/2026_04_08_000007_create_ticketing_agent_access_table.php create mode 100644 database/migrations/2026_04_08_000008_create_ticketing_email_connections_table.php create mode 100644 resources/js/Pages/Ticketing/MyTickets.vue create mode 100644 resources/js/Pages/Ticketing/Settings.vue create mode 100644 src/Http/Controllers/TicketAttachmentController.php delete mode 100644 src/Http/Controllers/TicketCommentController.php create mode 100644 src/Http/Controllers/TicketMessageController.php create mode 100644 src/Http/Controllers/TicketingSettingsController.php create mode 100644 src/Models/EmailConnection.php create mode 100644 src/Models/PriorityLevel.php create mode 100644 src/Models/TicketAttachment.php delete mode 100644 src/Models/TicketComment.php create mode 100644 src/Models/TicketMessage.php create mode 100644 src/Models/TicketingAgentAccess.php create mode 100644 src/Models/TicketingGroup.php create mode 100644 src/Models/TicketingProject.php diff --git a/composer.json b/composer.json index 94dd4d9..792e390 100644 --- a/composer.json +++ b/composer.json @@ -1,8 +1,12 @@ { "name": "dashboard/ticketing", - "description": "Ticketing / help-desk snap-in for the Dashboard platform", + "description": "Ticketing snap-in for dashboard-shell", "type": "library", - "license": "MIT", + "require": { + "php": "^8.2", + "illuminate/support": "^11.0|^12.0", + "inertiajs/inertia-laravel": "^2.0" + }, "autoload": { "psr-4": { "Dashboard\\Ticketing\\": "src/" @@ -10,14 +14,7 @@ }, "extra": { "laravel": { - "providers": [ - "Dashboard\\Ticketing\\TicketingServiceProvider" - ] + "providers": ["Dashboard\\Ticketing\\TicketingServiceProvider"] } - }, - "require": { - "php": "^8.2", - "illuminate/support": "^11.0|^12.0|^13.0", - "inertiajs/inertia-laravel": "^1.0|^2.0" } } diff --git a/database/migrations/2026_04_08_000001_create_ticketing_groups_table.php b/database/migrations/2026_04_08_000001_create_ticketing_groups_table.php new file mode 100644 index 0000000..a429334 --- /dev/null +++ b/database/migrations/2026_04_08_000001_create_ticketing_groups_table.php @@ -0,0 +1,25 @@ +id(); + $table->string('name'); + $table->string('email_address')->nullable(); + $table->string('color', 7)->default('#6366f1'); // #hex + $table->string('prefix', 10); // e.g. "IT" + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('ticketing_groups'); + } +}; diff --git a/database/migrations/2026_04_08_000001_create_tickets_table.php b/database/migrations/2026_04_08_000001_create_tickets_table.php deleted file mode 100644 index 453ed03..0000000 --- a/database/migrations/2026_04_08_000001_create_tickets_table.php +++ /dev/null @@ -1,28 +0,0 @@ -id(); - $table->foreignId('user_id')->constrained()->cascadeOnDelete(); - $table->foreignId('assigned_to')->nullable()->constrained('users')->nullOnDelete(); - $table->string('title'); - $table->text('description'); - $table->enum('category', ['IT', 'Facilities', 'HR', 'Other'])->default('Other'); - $table->enum('priority', ['low', 'medium', 'high', 'urgent'])->default('medium'); - $table->enum('status', ['open', 'in_progress', 'resolved', 'closed'])->default('open'); - $table->timestamps(); - }); - } - - public function down(): void - { - Schema::dropIfExists('tickets'); - } -}; diff --git a/database/migrations/2026_04_08_000002_create_ticket_comments_table.php b/database/migrations/2026_04_08_000002_create_ticket_comments_table.php deleted file mode 100644 index 109072e..0000000 --- a/database/migrations/2026_04_08_000002_create_ticket_comments_table.php +++ /dev/null @@ -1,25 +0,0 @@ -id(); - $table->foreignId('ticket_id')->constrained()->cascadeOnDelete(); - $table->foreignId('user_id')->constrained()->cascadeOnDelete(); - $table->text('body'); - $table->boolean('is_internal')->default(false); - $table->timestamps(); - }); - } - - public function down(): void - { - Schema::dropIfExists('ticket_comments'); - } -}; diff --git a/database/migrations/2026_04_08_000002_create_ticketing_priority_levels_table.php b/database/migrations/2026_04_08_000002_create_ticketing_priority_levels_table.php new file mode 100644 index 0000000..8886577 --- /dev/null +++ b/database/migrations/2026_04_08_000002_create_ticketing_priority_levels_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('group_id')->nullable()->constrained('ticketing_groups')->nullOnDelete(); + $table->string('name'); + $table->string('color', 7)->default('#6b7280'); + $table->text('description')->nullable(); + $table->unsignedSmallInteger('sort_order')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('ticketing_priority_levels'); + } +}; diff --git a/database/migrations/2026_04_08_000003_create_ticketing_projects_table.php b/database/migrations/2026_04_08_000003_create_ticketing_projects_table.php new file mode 100644 index 0000000..2c46cd3 --- /dev/null +++ b/database/migrations/2026_04_08_000003_create_ticketing_projects_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('group_id')->constrained('ticketing_groups')->cascadeOnDelete(); + $table->string('name'); + $table->text('description')->nullable(); + $table->enum('status', ['active', 'completed', 'archived'])->default('active'); + $table->unsignedBigInteger('created_by'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('ticketing_projects'); + } +}; diff --git a/database/migrations/2026_04_08_000004_create_tickets_table.php b/database/migrations/2026_04_08_000004_create_tickets_table.php new file mode 100644 index 0000000..219b866 --- /dev/null +++ b/database/migrations/2026_04_08_000004_create_tickets_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('number')->unique(); // e.g. "IT-0042" + $table->foreignId('group_id')->constrained('ticketing_groups'); + $table->unsignedBigInteger('submitter_id'); + $table->unsignedBigInteger('assigned_to')->nullable(); + $table->foreignId('project_id')->nullable()->constrained('ticketing_projects')->nullOnDelete(); + $table->string('title'); + $table->text('description'); + $table->enum('status', ['open', 'in_progress', 'pending', 'resolved', 'closed'])->default('open'); + $table->foreignId('priority_id')->nullable()->constrained('ticketing_priority_levels')->nullOnDelete(); + $table->date('due_date')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('tickets'); + } +}; diff --git a/database/migrations/2026_04_08_000005_create_ticket_messages_table.php b/database/migrations/2026_04_08_000005_create_ticket_messages_table.php new file mode 100644 index 0000000..76a131a --- /dev/null +++ b/database/migrations/2026_04_08_000005_create_ticket_messages_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('ticket_id')->constrained('tickets')->cascadeOnDelete(); + $table->unsignedBigInteger('user_id')->nullable(); + $table->string('author_email')->nullable(); + $table->text('body'); + $table->boolean('is_internal')->default(false); + $table->enum('source', ['web', 'email'])->default('web'); + $table->string('email_message_id')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('ticket_messages'); + } +}; diff --git a/database/migrations/2026_04_08_000006_create_ticket_attachments_table.php b/database/migrations/2026_04_08_000006_create_ticket_attachments_table.php new file mode 100644 index 0000000..4cdc229 --- /dev/null +++ b/database/migrations/2026_04_08_000006_create_ticket_attachments_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('ticket_id')->constrained('tickets')->cascadeOnDelete(); + $table->foreignId('message_id')->nullable()->constrained('ticket_messages')->nullOnDelete(); + $table->string('filename'); + $table->string('path'); + $table->string('mime_type'); + $table->unsignedBigInteger('size'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('ticket_attachments'); + } +}; diff --git a/database/migrations/2026_04_08_000007_create_ticketing_agent_access_table.php b/database/migrations/2026_04_08_000007_create_ticketing_agent_access_table.php new file mode 100644 index 0000000..400dc12 --- /dev/null +++ b/database/migrations/2026_04_08_000007_create_ticketing_agent_access_table.php @@ -0,0 +1,26 @@ +id(); + $table->unsignedBigInteger('user_id'); + $table->foreignId('group_id')->constrained('ticketing_groups')->cascadeOnDelete(); + $table->enum('role', ['agent', 'manager'])->default('agent'); + $table->timestamps(); + + $table->unique(['user_id', 'group_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('ticketing_agent_access'); + } +}; diff --git a/database/migrations/2026_04_08_000008_create_ticketing_email_connections_table.php b/database/migrations/2026_04_08_000008_create_ticketing_email_connections_table.php new file mode 100644 index 0000000..ef42704 --- /dev/null +++ b/database/migrations/2026_04_08_000008_create_ticketing_email_connections_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('group_id')->constrained('ticketing_groups')->cascadeOnDelete(); + $table->enum('type', ['gmail', 'imap'])->default('imap'); + $table->json('config'); // credentials + $table->timestamp('last_polled_at')->nullable(); + $table->boolean('active')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('ticketing_email_connections'); + } +}; diff --git a/resources/js/Pages/Ticketing/Create.vue b/resources/js/Pages/Ticketing/Create.vue index d2e45ff..11332a8 100644 --- a/resources/js/Pages/Ticketing/Create.vue +++ b/resources/js/Pages/Ticketing/Create.vue @@ -1,112 +1,111 @@ diff --git a/resources/js/Pages/Ticketing/Edit.vue b/resources/js/Pages/Ticketing/Edit.vue index caae88d..1bc308b 100644 --- a/resources/js/Pages/Ticketing/Edit.vue +++ b/resources/js/Pages/Ticketing/Edit.vue @@ -1,147 +1,114 @@ diff --git a/src/Http/Controllers/TicketAttachmentController.php b/src/Http/Controllers/TicketAttachmentController.php new file mode 100644 index 0000000..bcc002a --- /dev/null +++ b/src/Http/Controllers/TicketAttachmentController.php @@ -0,0 +1,63 @@ +id) + ->where('group_id', $ticket->group_id) + ->exists(); + return $isAgent || $ticket->submitter_id === $user->id; + } + + public function store(Request $request, Ticket $ticket) + { + if (!$this->canAccessTicket($ticket)) { + abort(403); + } + + $request->validate([ + 'file' => 'required|file|max:' . (self::MAX_SIZE_MB * 1024), + 'message_id' => 'nullable|exists:ticket_messages,id', + ]); + + $file = $request->file('file'); + $disk = config('ticketing.storage_disk', env('TICKETING_STORAGE_DISK', 'local')); + $path = $file->store('ticketing/attachments/' . $ticket->id, $disk); + + TicketAttachment::create([ + 'ticket_id' => $ticket->id, + 'message_id' => $request->message_id, + 'filename' => $file->getClientOriginalName(), + 'path' => $path, + 'mime_type' => $file->getMimeType(), + 'size' => $file->getSize(), + ]); + + return back()->with('success', 'Attachment uploaded.'); + } + + public function show(TicketAttachment $attachment) + { + $ticket = $attachment->ticket; + if (!$this->canAccessTicket($ticket)) { + abort(403); + } + + $disk = config('ticketing.storage_disk', env('TICKETING_STORAGE_DISK', 'local')); + return Storage::disk($disk)->download($attachment->path, $attachment->filename); + } +} diff --git a/src/Http/Controllers/TicketCommentController.php b/src/Http/Controllers/TicketCommentController.php deleted file mode 100644 index c84e978..0000000 --- a/src/Http/Controllers/TicketCommentController.php +++ /dev/null @@ -1,36 +0,0 @@ -user(); - $isAdmin = in_array($user->role ?? '', ['admin', 'super_admin']); - - abort_if(! $isAdmin && $ticket->user_id !== $user->id, 403); - - $request->validate([ - 'body' => 'required|string', - 'is_internal' => 'boolean', - ]); - - // Non-admins cannot post internal notes - $isInternal = $isAdmin && $request->boolean('is_internal'); - - TicketComment::create([ - 'ticket_id' => $ticket->id, - 'user_id' => $user->id, - 'body' => $request->body, - 'is_internal' => $isInternal, - ]); - - return redirect()->route('ticketing.show', $ticket)->with('success', 'Comment added.'); - } -} diff --git a/src/Http/Controllers/TicketController.php b/src/Http/Controllers/TicketController.php index 919d082..a43a5fb 100644 --- a/src/Http/Controllers/TicketController.php +++ b/src/Http/Controllers/TicketController.php @@ -2,155 +2,250 @@ namespace Dashboard\Ticketing\Http\Controllers; +use Dashboard\Ticketing\Models\PriorityLevel; use Dashboard\Ticketing\Models\Ticket; +use Dashboard\Ticketing\Models\TicketingAgentAccess; +use Dashboard\Ticketing\Models\TicketingGroup; +use Dashboard\Ticketing\Models\TicketingProject; use Illuminate\Http\Request; use Illuminate\Routing\Controller; +use Illuminate\Support\Facades\Auth; use Inertia\Inertia; +use Inertia\Response; class TicketController extends Controller { - public function index(Request $request) + private function agentGroupIds(): array { - $user = auth()->user(); - $isAdmin = in_array($user->role ?? '', ['admin', 'super_admin']); + return TicketingAgentAccess::where('user_id', Auth::id()) + ->pluck('group_id') + ->toArray(); + } - $query = Ticket::with(['submitter:id,name', 'assignee:id,name']); + private function isAgent(int $groupId = null): bool + { + $query = TicketingAgentAccess::where('user_id', Auth::id()); + if ($groupId) { + $query->where('group_id', $groupId); + } + return $query->exists(); + } - if (! $isAdmin) { - $query->where('user_id', $user->id); + private function isManager(int $groupId): bool + { + return TicketingAgentAccess::where('user_id', Auth::id()) + ->where('group_id', $groupId) + ->where('role', 'manager') + ->exists(); + } + + public function index(Request $request): Response + { + $user = Auth::user(); + $agentGroupIds = $this->agentGroupIds(); + $isAgent = count($agentGroupIds) > 0; + + $query = Ticket::with(['group', 'priority', 'project']); + + if ($isAgent) { + $query->whereIn('group_id', $agentGroupIds); + + // Filters + if ($request->filled('group_id')) { + $query->where('group_id', $request->group_id); + } + if ($request->filled('status')) { + $query->where('status', $request->status); + } + if ($request->filled('priority_id')) { + $query->where('priority_id', $request->priority_id); + } + if ($request->filter === 'mine') { + $query->where('assigned_to', $user->id); + } elseif ($request->filter === 'unassigned') { + $query->whereNull('assigned_to'); + } elseif ($request->filter === 'pending') { + $query->where('status', 'pending'); + } elseif (!$request->filled('status')) { + $query->whereNotIn('status', ['resolved', 'closed']); + } + } else { + $query->where('submitter_id', $user->id); } - if ($search = $request->get('search')) { - $query->where(function ($q) use ($search) { - $q->where('title', 'like', "%{$search}%") - ->orWhere('description', 'like', "%{$search}%"); - }); - } + $tickets = $query->latest()->paginate(30)->withQueryString(); - if ($status = $request->get('status')) { - $query->where('status', $status); - } + // Enrich with submitter name + $userIds = $tickets->pluck('submitter_id')->merge($tickets->pluck('assigned_to'))->filter()->unique(); + $users = \DB::table('users')->whereIn('id', $userIds)->get(['id', 'name', 'email'])->keyBy('id'); - if ($priority = $request->get('priority')) { - $query->where('priority', $priority); - } + $tickets->getCollection()->transform(function ($ticket) use ($users) { + $ticket->submitter = $users[$ticket->submitter_id] ?? null; + $ticket->assignee = $ticket->assigned_to ? ($users[$ticket->assigned_to] ?? null) : null; + return $ticket; + }); - if ($category = $request->get('category')) { - $query->where('category', $category); - } - - $tickets = $query->latest()->paginate(25)->withQueryString(); + $groups = TicketingGroup::when($isAgent, fn($q) => $q->whereIn('id', $agentGroupIds))->get(); + $priorities = PriorityLevel::whereNull('group_id') + ->orWhenIn('group_id', $agentGroupIds ?? []) + ->orderBy('sort_order') + ->get(); + $projects = TicketingProject::when($isAgent, fn($q) => $q->whereIn('group_id', $agentGroupIds)) + ->where('status', 'active') + ->get(); return Inertia::render('Ticketing/Index', [ - 'tickets' => $tickets, - 'search' => $request->get('search', ''), - 'statusFilter' => $request->get('status', ''), - 'priorityFilter' => $request->get('priority', ''), - 'categoryFilter' => $request->get('category', ''), - 'isAdmin' => $isAdmin, + 'tickets' => $tickets, + 'groups' => $groups, + 'priorities' => $priorities, + 'projects' => $projects, + 'isAgent' => $isAgent, + 'filters' => $request->only(['group_id', 'status', 'priority_id', 'filter']), ]); } - public function create() + public function create(): Response { - return Inertia::render('Ticketing/Create'); + $user = Auth::user(); + $agentGroupIds = $this->agentGroupIds(); + $isAgent = count($agentGroupIds) > 0; + + $groups = TicketingGroup::when($isAgent, fn($q) => $q->whereIn('id', $agentGroupIds))->get(); + $priorities = PriorityLevel::orderBy('sort_order')->get(); + + return Inertia::render('Ticketing/Create', [ + 'groups' => $groups, + 'priorities' => $priorities, + ]); } public function store(Request $request) { - $request->validate([ - 'title' => 'required|string|max:255', + $validated = $request->validate([ + 'title' => 'required|string|max:255', 'description' => 'required|string', - 'category' => 'required|in:IT,Facilities,HR,Other', - 'priority' => 'required|in:low,medium,high,urgent', + 'group_id' => 'required|exists:ticketing_groups,id', + 'priority_id' => 'nullable|exists:ticketing_priority_levels,id', + 'due_date' => 'nullable|date', ]); - Ticket::create([ - 'user_id' => auth()->id(), - 'title' => $request->title, - 'description' => $request->description, - 'category' => $request->category, - 'priority' => $request->priority, - 'status' => 'open', + $group = TicketingGroup::findOrFail($validated['group_id']); + $number = $group->nextTicketNumber(); + + $ticket = Ticket::create([ + ...$validated, + 'number' => $number, + 'submitter_id' => Auth::id(), + 'status' => 'open', ]); - return redirect()->route('ticketing.index')->with('success', 'Ticket submitted.'); + return redirect()->route('ticketing.show', $ticket); } - public function show(Ticket $ticket) + public function show(Ticket $ticket): Response { - $user = auth()->user(); - $isAdmin = in_array($user->role ?? '', ['admin', 'super_admin']); + $user = Auth::user(); + $isAgent = $this->isAgent($ticket->group_id); + $isSubmitter = $ticket->submitter_id === $user->id; - abort_if(! $isAdmin && $ticket->user_id !== $user->id, 403); + if (!$isAgent && !$isSubmitter) { + abort(403); + } - $ticket->load([ - 'submitter:id,name', - 'assignee:id,name', - 'comments.author:id,name', - ]); + $ticket->load(['group', 'priority', 'project', 'messages', 'attachments']); + + // Enrich messages with user data + $userIds = $ticket->messages->pluck('user_id')->filter()->unique(); + $users = \DB::table('users')->whereIn('id', $userIds)->get(['id', 'name', 'email'])->keyBy('id'); + + $ticket->messages->each(function ($msg) use ($users) { + $msg->author = $msg->user_id ? ($users[$msg->user_id] ?? null) : null; + }); + + // Agents for assignment picker + $agents = []; + if ($isAgent) { + $agentIds = TicketingAgentAccess::where('group_id', $ticket->group_id)->pluck('user_id'); + $agents = \DB::table('users')->whereIn('id', $agentIds)->get(['id', 'name', 'email']); + } + + $priorities = PriorityLevel::where(fn($q) => $q->whereNull('group_id')->orWhere('group_id', $ticket->group_id)) + ->orderBy('sort_order')->get(); return Inertia::render('Ticketing/Show', [ - 'ticket' => $ticket, - 'isAdmin' => $isAdmin, + 'ticket' => $ticket, + 'isAgent' => $isAgent, + 'isManager' => $isAgent && $this->isManager($ticket->group_id), + 'agents' => $agents, + 'priorities' => $priorities, ]); } - public function edit(Ticket $ticket) + public function edit(Ticket $ticket): Response { - $user = auth()->user(); - $isAdmin = in_array($user->role ?? '', ['admin', 'super_admin']); + if (!$this->isAgent($ticket->group_id)) { + abort(403); + } - abort_if(! $isAdmin && $ticket->user_id !== $user->id, 403); + $ticket->load(['group', 'priority', 'project']); + $priorities = PriorityLevel::where(fn($q) => $q->whereNull('group_id')->orWhere('group_id', $ticket->group_id)) + ->orderBy('sort_order')->get(); + $agentIds = TicketingAgentAccess::where('group_id', $ticket->group_id)->pluck('user_id'); + $agents = \DB::table('users')->whereIn('id', $agentIds)->get(['id', 'name', 'email']); + $projects = TicketingProject::where('group_id', $ticket->group_id)->get(); return Inertia::render('Ticketing/Edit', [ - 'ticket' => $ticket, - 'isAdmin' => $isAdmin, + 'ticket' => $ticket, + 'priorities' => $priorities, + 'agents' => $agents, + 'projects' => $projects, ]); } public function update(Request $request, Ticket $ticket) { - $user = auth()->user(); - $isAdmin = in_array($user->role ?? '', ['admin', 'super_admin']); - - abort_if(! $isAdmin && $ticket->user_id !== $user->id, 403); + if (!$this->isAgent($ticket->group_id)) { + abort(403); + } $rules = [ - 'title' => 'required|string|max:255', - 'description' => 'required|string', - 'category' => 'required|in:IT,Facilities,HR,Other', - 'priority' => 'required|in:low,medium,high,urgent', + 'title' => 'sometimes|required|string|max:255', + 'description' => 'sometimes|required|string', + 'status' => 'sometimes|in:open,in_progress,pending,resolved,closed', + 'priority_id' => 'nullable|exists:ticketing_priority_levels,id', + 'due_date' => 'nullable|date', + 'project_id' => 'nullable|exists:ticketing_projects,id', ]; - if ($isAdmin) { - $rules['status'] = 'required|in:open,in_progress,resolved,closed'; + // assigned_to only settable by agents + if ($this->isAgent($ticket->group_id)) { $rules['assigned_to'] = 'nullable|exists:users,id'; } - $request->validate($rules); + $ticket->update($request->validate($rules)); - $data = $request->only(['title', 'description', 'category', 'priority']); - - if ($isAdmin) { - $data['status'] = $request->status; - $data['assigned_to'] = $request->assigned_to; - } - - $ticket->update($data); - - return redirect()->route('ticketing.show', $ticket)->with('success', 'Ticket updated.'); + return back()->with('success', 'Ticket updated.'); } public function destroy(Ticket $ticket) { - $user = auth()->user(); - $isAdmin = in_array($user->role ?? '', ['admin', 'super_admin']); - - abort_if(! $isAdmin && $ticket->user_id !== $user->id, 403); + if (!$this->isManager($ticket->group_id)) { + abort(403); + } $ticket->delete(); + return redirect()->route('ticketing.index'); + } - return redirect()->route('ticketing.index')->with('success', 'Ticket deleted.'); + public function myTickets(): Response + { + $tickets = Ticket::where('submitter_id', Auth::id()) + ->with(['group', 'priority']) + ->latest() + ->paginate(20); + + return Inertia::render('Ticketing/MyTickets', [ + 'tickets' => $tickets, + ]); } } diff --git a/src/Http/Controllers/TicketMessageController.php b/src/Http/Controllers/TicketMessageController.php new file mode 100644 index 0000000..faa63f4 --- /dev/null +++ b/src/Http/Controllers/TicketMessageController.php @@ -0,0 +1,70 @@ +where('group_id', $groupId) + ->exists(); + } + + public function store(Request $request, Ticket $ticket) + { + $user = Auth::user(); + $isAgent = $this->isAgent($ticket->group_id); + $isSubmitter = $ticket->submitter_id === $user->id; + + if (!$isAgent && !$isSubmitter) { + abort(403); + } + + $validated = $request->validate([ + 'body' => 'required|string', + 'is_internal' => 'boolean', + ]); + + // Only agents can post internal notes + if ($validated['is_internal'] ?? false) { + if (!$isAgent) { + abort(403, 'Only agents can post internal notes.'); + } + } + + $message = TicketMessage::create([ + 'ticket_id' => $ticket->id, + 'user_id' => $user->id, + 'body' => $validated['body'], + 'is_internal' => $validated['is_internal'] ?? false, + 'source' => 'web', + ]); + + // Submitter reply reopens resolved/closed tickets + if ($isSubmitter && in_array($ticket->status, ['resolved', 'closed'])) { + $ticket->update(['status' => 'open']); + } + + // Notify submitter if agent replied (not internal) + if ($isAgent && !($validated['is_internal'] ?? false)) { + $this->notifySubmitter($ticket, $message); + } + + return back()->with('success', 'Message sent.'); + } + + private function notifySubmitter(Ticket $ticket, TicketMessage $message): void + { + // Placeholder for email notification + // In production: Mail::to($submitterEmail)->send(new TicketReplyMail($ticket, $message)); + } +} diff --git a/src/Http/Controllers/TicketingSettingsController.php b/src/Http/Controllers/TicketingSettingsController.php new file mode 100644 index 0000000..010411e --- /dev/null +++ b/src/Http/Controllers/TicketingSettingsController.php @@ -0,0 +1,144 @@ +exists(); + if (!$hasAccess) { + abort(403); + } + } + + private function requireManagerAccess(int $groupId): void + { + $isManager = TicketingAgentAccess::where('user_id', Auth::id()) + ->where('group_id', $groupId) + ->where('role', 'manager') + ->exists(); + if (!$isManager) { + abort(403); + } + } + + public function index(): Response + { + $this->requireAgentAccess(); + + $userId = Auth::id(); + $myGroupIds = TicketingAgentAccess::where('user_id', $userId)->pluck('group_id'); + + $groups = TicketingGroup::whereIn('id', $myGroupIds)->get(); + + $agents = TicketingAgentAccess::whereIn('group_id', $myGroupIds)->get(); + $agentUserIds = $agents->pluck('user_id')->unique(); + $agentUsers = \DB::table('users')->whereIn('id', $agentUserIds)->get(['id', 'name', 'email'])->keyBy('id'); + $agents->each(fn($a) => $a->user = $agentUsers[$a->user_id] ?? null); + + $priorities = PriorityLevel::where(fn($q) => $q->whereNull('group_id')->orWhereIn('group_id', $myGroupIds)) + ->orderBy('sort_order')->get(); + + return Inertia::render('Ticketing/Settings', [ + 'groups' => $groups, + 'agents' => $agents, + 'priorities' => $priorities, + 'myGroupIds' => $myGroupIds, + ]); + } + + public function storeGroup(Request $request) + { + $this->requireAgentAccess(); + + $validated = $request->validate([ + 'name' => 'required|string|max:100', + 'email_address' => 'nullable|email', + 'color' => 'required|string|regex:/^#[0-9a-fA-F]{6}$/', + 'prefix' => 'required|string|max:10|alpha_num|unique:ticketing_groups,prefix', + ]); + + $group = TicketingGroup::create($validated); + + // Auto-add creator as manager + TicketingAgentAccess::create([ + 'user_id' => Auth::id(), + 'group_id' => $group->id, + 'role' => 'manager', + ]); + + return back()->with('success', 'Group created.'); + } + + public function updateGroup(Request $request, TicketingGroup $group) + { + $this->requireManagerAccess($group->id); + + $validated = $request->validate([ + 'name' => 'required|string|max:100', + 'email_address' => 'nullable|email', + 'color' => 'required|string|regex:/^#[0-9a-fA-F]{6}$/', + 'prefix' => 'required|string|max:10|alpha_num|unique:ticketing_groups,prefix,' . $group->id, + ]); + + $group->update($validated); + + return back()->with('success', 'Group updated.'); + } + + public function storeAgent(Request $request) + { + $this->requireAgentAccess(); + + $validated = $request->validate([ + 'user_id' => 'required|exists:users,id', + 'group_id' => 'required|exists:ticketing_groups,id', + 'role' => 'required|in:agent,manager', + ]); + + $this->requireManagerAccess($validated['group_id']); + + TicketingAgentAccess::updateOrCreate( + ['user_id' => $validated['user_id'], 'group_id' => $validated['group_id']], + ['role' => $validated['role']] + ); + + return back()->with('success', 'Agent added.'); + } + + public function destroyAgent(TicketingAgentAccess $access) + { + $this->requireManagerAccess($access->group_id); + + $access->delete(); + + return back()->with('success', 'Agent removed.'); + } + + public function storePriority(Request $request) + { + $this->requireAgentAccess(); + + $validated = $request->validate([ + 'name' => 'required|string|max:100', + 'color' => 'required|string|regex:/^#[0-9a-fA-F]{6}$/', + 'description' => 'nullable|string', + 'sort_order' => 'integer|min:0', + 'group_id' => 'nullable|exists:ticketing_groups,id', + ]); + + PriorityLevel::create($validated); + + return back()->with('success', 'Priority level created.'); + } +} diff --git a/src/Models/EmailConnection.php b/src/Models/EmailConnection.php new file mode 100644 index 0000000..06f6728 --- /dev/null +++ b/src/Models/EmailConnection.php @@ -0,0 +1,24 @@ + 'array', + 'active' => 'boolean', + 'last_polled_at' => 'datetime', + ]; + + public function group(): BelongsTo + { + return $this->belongsTo(TicketingGroup::class, 'group_id'); + } +} diff --git a/src/Models/PriorityLevel.php b/src/Models/PriorityLevel.php new file mode 100644 index 0000000..cc7475d --- /dev/null +++ b/src/Models/PriorityLevel.php @@ -0,0 +1,24 @@ +belongsTo(TicketingGroup::class, 'group_id'); + } + + public function tickets(): HasMany + { + return $this->hasMany(Ticket::class, 'priority_id'); + } +} diff --git a/src/Models/Ticket.php b/src/Models/Ticket.php index 658bcf4..8140811 100644 --- a/src/Models/Ticket.php +++ b/src/Models/Ticket.php @@ -9,27 +9,36 @@ use Illuminate\Database\Eloquent\Relations\HasMany; class Ticket extends Model { protected $fillable = [ - 'user_id', - 'assigned_to', - 'title', - 'description', - 'category', - 'priority', - 'status', + 'number', 'group_id', 'submitter_id', 'assigned_to', 'project_id', + 'title', 'description', 'status', 'priority_id', 'due_date', ]; - public function submitter(): BelongsTo + protected $casts = [ + 'due_date' => 'date', + ]; + + public function group(): BelongsTo { - return $this->belongsTo(\App\Models\User::class, 'user_id'); + return $this->belongsTo(TicketingGroup::class, 'group_id'); } - public function assignee(): BelongsTo + public function priority(): BelongsTo { - return $this->belongsTo(\App\Models\User::class, 'assigned_to'); + return $this->belongsTo(PriorityLevel::class, 'priority_id'); } - public function comments(): HasMany + public function project(): BelongsTo { - return $this->hasMany(TicketComment::class); + return $this->belongsTo(TicketingProject::class, 'project_id'); + } + + public function messages(): HasMany + { + return $this->hasMany(TicketMessage::class, 'ticket_id')->orderBy('created_at'); + } + + public function attachments(): HasMany + { + return $this->hasMany(TicketAttachment::class, 'ticket_id'); } } diff --git a/src/Models/TicketAttachment.php b/src/Models/TicketAttachment.php new file mode 100644 index 0000000..3d8175c --- /dev/null +++ b/src/Models/TicketAttachment.php @@ -0,0 +1,21 @@ +belongsTo(Ticket::class, 'ticket_id'); + } + + public function message(): BelongsTo + { + return $this->belongsTo(TicketMessage::class, 'message_id'); + } +} diff --git a/src/Models/TicketComment.php b/src/Models/TicketComment.php deleted file mode 100644 index 9462654..0000000 --- a/src/Models/TicketComment.php +++ /dev/null @@ -1,30 +0,0 @@ - 'boolean', - ]; - - public function ticket(): BelongsTo - { - return $this->belongsTo(Ticket::class); - } - - public function author(): BelongsTo - { - return $this->belongsTo(\App\Models\User::class, 'user_id'); - } -} diff --git a/src/Models/TicketMessage.php b/src/Models/TicketMessage.php new file mode 100644 index 0000000..ab5cd91 --- /dev/null +++ b/src/Models/TicketMessage.php @@ -0,0 +1,29 @@ + 'boolean', + ]; + + public function ticket(): BelongsTo + { + return $this->belongsTo(Ticket::class, 'ticket_id'); + } + + public function attachments(): HasMany + { + return $this->hasMany(TicketAttachment::class, 'message_id'); + } +} diff --git a/src/Models/TicketingAgentAccess.php b/src/Models/TicketingAgentAccess.php new file mode 100644 index 0000000..65f89dd --- /dev/null +++ b/src/Models/TicketingAgentAccess.php @@ -0,0 +1,18 @@ +belongsTo(TicketingGroup::class, 'group_id'); + } +} diff --git a/src/Models/TicketingGroup.php b/src/Models/TicketingGroup.php new file mode 100644 index 0000000..56d71e1 --- /dev/null +++ b/src/Models/TicketingGroup.php @@ -0,0 +1,42 @@ +hasMany(Ticket::class, 'group_id'); + } + + public function priorityLevels(): HasMany + { + return $this->hasMany(PriorityLevel::class, 'group_id'); + } + + public function agentAccess(): HasMany + { + return $this->hasMany(TicketingAgentAccess::class, 'group_id'); + } + + public function projects(): HasMany + { + return $this->hasMany(TicketingProject::class, 'group_id'); + } + + public function emailConnections(): HasMany + { + return $this->hasMany(EmailConnection::class, 'group_id'); + } + + public function nextTicketNumber(): string + { + $count = $this->tickets()->count() + 1; + return $this->prefix . '-' . str_pad($count, 4, '0', STR_PAD_LEFT); + } +} diff --git a/src/Models/TicketingProject.php b/src/Models/TicketingProject.php new file mode 100644 index 0000000..c1b1e1e --- /dev/null +++ b/src/Models/TicketingProject.php @@ -0,0 +1,22 @@ +belongsTo(TicketingGroup::class, 'group_id'); + } + + public function tickets(): HasMany + { + return $this->hasMany(Ticket::class, 'project_id'); + } +} diff --git a/src/TicketingServiceProvider.php b/src/TicketingServiceProvider.php index 48eb4f6..519b4b5 100644 --- a/src/TicketingServiceProvider.php +++ b/src/TicketingServiceProvider.php @@ -6,10 +6,7 @@ use Illuminate\Support\ServiceProvider; class TicketingServiceProvider extends ServiceProvider { - public function register(): void - { - // - } + public function register(): void {} public function boot(): void { diff --git a/src/routes/ticketing.php b/src/routes/ticketing.php index ea2e0e4..a3252f3 100644 --- a/src/routes/ticketing.php +++ b/src/routes/ticketing.php @@ -1,16 +1,37 @@ prefix('app/ticketing')->name('ticketing.')->group(function () { + // Submitter portal (must come before /{ticket} to avoid conflict) + Route::get('/my-tickets', [TicketController::class, 'myTickets'])->name('my-tickets'); + + // Settings + Route::get('/settings', [TicketingSettingsController::class, 'index'])->name('settings'); + Route::post('/settings/groups', [TicketingSettingsController::class, 'storeGroup'])->name('settings.groups.store'); + Route::put('/settings/groups/{group}', [TicketingSettingsController::class, 'updateGroup'])->name('settings.groups.update'); + Route::post('/settings/agents', [TicketingSettingsController::class, 'storeAgent'])->name('settings.agents.store'); + Route::delete('/settings/agents/{access}', [TicketingSettingsController::class, 'destroyAgent'])->name('settings.agents.destroy'); + Route::post('/settings/priorities', [TicketingSettingsController::class, 'storePriority'])->name('settings.priorities.store'); + + // Ticket routes Route::get('/', [TicketController::class, 'index'])->name('index'); Route::get('/create', [TicketController::class, 'create'])->name('create'); Route::post('/', [TicketController::class, 'store'])->name('store'); + + // Attachment show (before /{ticket} wildcard) + Route::get('/attachments/{attachment}', [TicketAttachmentController::class, 'show'])->name('attachments.show'); + Route::get('/{ticket}', [TicketController::class, 'show'])->name('show'); Route::get('/{ticket}/edit', [TicketController::class, 'edit'])->name('edit'); Route::put('/{ticket}', [TicketController::class, 'update'])->name('update'); Route::delete('/{ticket}', [TicketController::class, 'destroy'])->name('destroy'); - Route::post('/{ticket}/comments', [TicketCommentController::class, 'store'])->name('comments.store'); + + // Messages & attachments + Route::post('/{ticket}/messages', [TicketMessageController::class, 'store'])->name('messages.store'); + Route::post('/{ticket}/attachments', [TicketAttachmentController::class, 'store'])->name('attachments.store'); });