pluck('group_id') ->toArray(); } private function isAgent(int $groupId = null): bool { $query = TicketingAgentAccess::where('user_id', Auth::id()); if ($groupId) { $query->where('group_id', $groupId); } return $query->exists(); } private function isManager(int $groupId): bool { return TicketingAgentAccess::where('user_id', Auth::id()) ->where('group_id', $groupId) ->where('role', 'manager') ->exists(); } /** * Filter messages for submitters — strip internal notes. */ private function filterMessagesForRole($messages, bool $isAgent) { if ($isAgent) { return $messages; } return $messages->filter(fn($m) => !$m->is_internal)->values(); } public function index(Request $request): Response { $user = Auth::user(); $agentGroupIds = $this->agentGroupIds(); $isAgent = count($agentGroupIds) > 0; // If no groups exist at all, render a first-run / bootstrap state $totalGroups = TicketingGroup::count(); if ($totalGroups === 0) { return Inertia::render('Ticketing/Index', [ 'tickets' => null, 'groups' => [], 'priorities' => [], 'projects' => [], 'isAgent' => $isAgent, 'filters' => [], 'ticketDetail' => null, 'detailAgents' => [], 'viewCounts' => ['all' => 0, 'mine' => 0, 'unassigned' => 0, 'pending' => 0, 'resolved' => 0], 'isBootstrap' => true, ]); } $baseQuery = Ticket::query(); if ($isAgent) { $baseQuery->whereIn('group_id', $agentGroupIds); } else { $baseQuery->where('submitter_id', $user->id); } $viewCounts = $isAgent ? [ 'all' => (clone $baseQuery)->whereNotIn('status', ['resolved', 'closed'])->count(), 'mine' => (clone $baseQuery)->where('assigned_to', $user->id)->whereNotIn('status', ['resolved', 'closed'])->count(), 'unassigned' => (clone $baseQuery)->whereNull('assigned_to')->whereNotIn('status', ['resolved', 'closed'])->count(), 'pending' => (clone $baseQuery)->where('status', 'pending')->count(), 'resolved' => (clone $baseQuery)->whereIn('status', ['resolved', 'closed'])->count(), ] : [ 'all' => (clone $baseQuery)->count(), 'mine' => 0, 'unassigned' => 0, 'pending' => (clone $baseQuery)->where('status', 'pending')->count(), 'resolved' => (clone $baseQuery)->whereIn('status', ['resolved', 'closed'])->count(), ]; $query = Ticket::with(['group', 'priority', 'project']); if ($isAgent) { $query->whereIn('group_id', $agentGroupIds); 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->filled('project_id')) { $query->where('project_id', $request->project_id); } if ($request->filter === 'mine') { $query->where('assigned_to', $user->id)->whereNotIn('status', ['resolved', 'closed']); } elseif ($request->filter === 'unassigned') { $query->whereNull('assigned_to')->whereNotIn('status', ['resolved', 'closed']); } elseif ($request->filter === 'pending') { $query->where('status', 'pending'); } elseif ($request->filter === 'resolved') { $query->whereIn('status', ['resolved', 'closed']); } elseif (!$request->filled('status')) { $query->whereNotIn('status', ['resolved', 'closed']); } } else { $query->where('submitter_id', $user->id); if ($request->filled('project_id')) { $query->where('project_id', $request->project_id); } if ($request->filter === 'pending') { $query->where('status', 'pending'); } elseif ($request->filter === 'resolved') { $query->whereIn('status', ['resolved', 'closed']); } } $tickets = $query->latest()->paginate(30)->withQueryString(); $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'); $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; }); $groups = TicketingGroup::when($isAgent, fn ($q) => $q->whereIn('id', $agentGroupIds))->get(); $priorities = PriorityLevel::whereNull('group_id') ->orWhereIn('group_id', $agentGroupIds ?: [0]) ->orderBy('sort_order') ->get(); $projects = TicketingProject::when($isAgent, fn ($q) => $q->whereIn('group_id', $agentGroupIds)) ->where('status', 'active') ->get(); $projectCounts = (clone $baseQuery) ->select('project_id', DB::raw('count(*) as aggregate')) ->whereNotNull('project_id') ->groupBy('project_id') ->pluck('aggregate', 'project_id'); $projects->transform(function ($project) use ($projectCounts) { $project->ticket_count = (int) ($projectCounts[$project->id] ?? 0); return $project; }); $ticketDetail = null; $detailAgents = []; if ($request->filled('detail')) { $detailQuery = Ticket::with([ 'group', 'priority', 'project', 'messages.attachments', 'attachments', ])->whereKey($request->detail); if ($isAgent) { $detailQuery->whereIn('group_id', $agentGroupIds); } else { $detailQuery->where('submitter_id', $user->id); } $dt = $detailQuery->first(); if ($dt) { $detailUserIds = collect([$dt->submitter_id, $dt->assigned_to]) ->merge($dt->messages->pluck('user_id')) ->filter() ->unique(); $detailUsers = DB::table('users')->whereIn('id', $detailUserIds)->get(['id', 'name', 'email'])->keyBy('id'); $dt->submitter = $detailUsers[$dt->submitter_id] ?? null; $dt->assignee = $dt->assigned_to ? ($detailUsers[$dt->assigned_to] ?? null) : null; // Bug #2: Filter internal notes for non-agents $visibleMessages = $this->filterMessagesForRole($dt->messages, $isAgent); $visibleMessages->each(function ($msg) use ($detailUsers) { $msg->author = $msg->user_id ? ($detailUsers[$msg->user_id] ?? null) : null; }); $dt->setRelation('messages', $visibleMessages); $ticketDetail = $dt; if ($isAgent) { $agentIds = TicketingAgentAccess::where('group_id', $dt->group_id)->pluck('user_id'); $detailAgents = DB::table('users')->whereIn('id', $agentIds)->get(['id', 'name', 'email']); } } } return Inertia::render('Ticketing/Index', [ 'tickets' => $tickets, 'groups' => $groups, 'priorities' => $priorities, 'projects' => $projects, 'isAgent' => $isAgent, 'filters' => $request->only(['group_id', 'status', 'priority_id', 'project_id', 'filter', 'detail']), 'ticketDetail' => $ticketDetail, 'detailAgents' => $detailAgents, 'viewCounts' => $viewCounts, 'isBootstrap' => false, ]); } public function create(): Response { $user = Auth::user(); $agentGroupIds = $this->agentGroupIds(); $isAgent = count($agentGroupIds) > 0; $groups = TicketingGroup::when($isAgent, fn($q) => $q->whereIn('id', $agentGroupIds))->get(); // No groups at all? Show first-run state. if ($groups->isEmpty() && TicketingGroup::count() === 0) { return Inertia::render('Ticketing/Create', [ 'groups' => [], 'priorities' => [], 'isBootstrap' => true, ]); } // Only pass global priorities + priorities scoped to accessible groups $groupIds = $groups->pluck('id')->toArray(); $priorities = PriorityLevel::where(fn($q) => $q->whereNull('group_id')->orWhereIn('group_id', $groupIds)) ->orderBy('sort_order') ->get(); return Inertia::render('Ticketing/Create', [ 'groups' => $groups, 'priorities' => $priorities, 'isBootstrap' => false, ]); } public function store(Request $request) { $validated = $request->validate([ 'title' => 'required|string|max:255', 'description' => 'required|string', 'group_id' => 'required|exists:ticketing_groups,id', 'priority_id' => 'nullable|exists:ticketing_priority_levels,id', 'due_date' => 'nullable|date', 'attachments' => 'nullable|array|max:5', 'attachments.*' => 'file|max:20480', ]); // Ensure priority belongs to the chosen group (or is global) if (!empty($validated['priority_id'])) { $priority = PriorityLevel::find($validated['priority_id']); if ($priority && $priority->group_id !== null && $priority->group_id != $validated['group_id']) { abort(422, 'Priority does not belong to the selected group.'); } } $group = TicketingGroup::findOrFail($validated['group_id']); $number = $group->nextTicketNumber(); $ticket = Ticket::create([ ...collect($validated)->except('attachments')->all(), 'number' => $number, 'submitter_id' => Auth::id(), 'status' => 'open', ]); if ($request->hasFile('attachments')) { $disk = config('ticketing.storage_disk', env('TICKETING_STORAGE_DISK', 'local')); foreach ($request->file('attachments') as $file) { if (!$file->isValid()) continue; $path = $file->store('ticketing/attachments/' . $ticket->id, $disk); \Dashboard\Ticketing\Models\TicketAttachment::create([ 'ticket_id' => $ticket->id, 'message_id' => null, 'filename' => $file->getClientOriginalName(), 'path' => $path, 'mime_type' => $file->getMimeType(), 'size' => $file->getSize(), ]); } } return redirect()->route('ticketing.show', $ticket); } public function show(Ticket $ticket): Response { $user = Auth::user(); $isAgent = $this->isAgent($ticket->group_id); $isSubmitter = $ticket->submitter_id === $user->id; if (!$isAgent && !$isSubmitter) { abort(403); } $ticket->load(['group', 'priority', 'project', 'messages', 'attachments']); // Bug #2: Filter internal notes for submitters $visibleMessages = $this->filterMessagesForRole($ticket->messages, $isAgent); $userIds = $visibleMessages->pluck('user_id')->filter()->unique(); $users = DB::table('users')->whereIn('id', $userIds)->get(['id', 'name', 'email'])->keyBy('id'); $visibleMessages->each(function ($msg) use ($users) { $msg->author = $msg->user_id ? ($users[$msg->user_id] ?? null) : null; }); $ticket->setRelation('messages', $visibleMessages); $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, 'isAgent' => $isAgent, 'isManager' => $isAgent && $this->isManager($ticket->group_id), 'agents' => $agents, 'priorities' => $priorities, ]); } public function edit(Ticket $ticket): Response { if (!$this->isAgent($ticket->group_id)) { abort(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, 'priorities' => $priorities, 'agents' => $agents, 'projects' => $projects, ]); } public function update(Request $request, Ticket $ticket) { if (!$this->isAgent($ticket->group_id)) { abort(403); } // Bug #3: Valid assignees are users with agent access to this group $validAssigneeIds = TicketingAgentAccess::where('group_id', $ticket->group_id) ->pluck('user_id') ->toArray(); $rules = [ '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', 'assigned_to' => 'nullable|in:' . implode(',', $validAssigneeIds ?: [0]), ]; $validated = $request->validate($rules); // Bug #4: priority_id must belong to this group or be global if (!empty($validated['priority_id'])) { $priority = PriorityLevel::find($validated['priority_id']); if ($priority && $priority->group_id !== null && $priority->group_id !== $ticket->group_id) { abort(422, 'Priority does not belong to this ticket\'s group.'); } } // Bug #4: project_id must belong to this group if (!empty($validated['project_id'])) { $project = TicketingProject::find($validated['project_id']); if ($project && $project->group_id !== $ticket->group_id) { abort(422, 'Project does not belong to this ticket\'s group.'); } } $ticket->update($validated); return back()->with('success', 'Ticket updated.'); } public function destroy(Ticket $ticket) { if (!$this->isManager($ticket->group_id)) { abort(403); } $ticket->delete(); return redirect()->route('ticketing.index'); } public function myTickets(): Response { $tickets = Ticket::where('submitter_id', Auth::id()) ->with(['group', 'priority']) ->latest() ->paginate(20); return Inertia::render('Ticketing/MyTickets', [ 'tickets' => $tickets, ]); } }