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(); } private function loadUserPrefs(): array { $raw = Setting::get('ticketing.pref.' . Auth::id(), '{}'); $decoded = json_decode($raw, true); return is_array($decoded) ? $decoded : []; } public function index(Request $request): Response { $user = Auth::user(); $agentGroupIds = $this->agentGroupIds(); $isAgent = count($agentGroupIds) > 0; // Merge saved prefs as defaults — URL params always win $pref = $this->loadUserPrefs(); if (! $request->filled('filter') && ! empty($pref['filter'])) { $request->merge(['filter' => $pref['filter']]); } // If no groups exist at all, render a first-run / bootstrap state $totalGroups = TicketingGroup::count(); if ($totalGroups === 0) { return Inertia::render('Ticketing/Index', [ 'tickets' => ['data' => [], 'total' => 0, 'per_page' => 20, 'current_page' => 1, 'last_page' => 1], 'groups' => [], 'priorities' => [], 'projects' => [], 'ticketStatuses' => TicketStatus::orderBy('sort_order')->get(), 'isAgent' => $isAgent, 'filters' => [], 'ticketDetail' => null, 'detailAgents' => [], 'viewCounts' => ['all' => 0, 'mine' => 0, 'unassigned' => 0, 'pending' => 0, 'resolved' => 0], 'isBootstrap' => true, 'userPrefs' => ['view_mode' => 'list', 'filter' => 'all'], ]); } $closedSlugs = TicketStatus::where('is_closed', true)->pluck('slug')->toArray(); $baseQuery = Ticket::query(); if ($isAgent) { $baseQuery->whereIn('group_id', $agentGroupIds); } else { $baseQuery->where('submitter_id', $user->id); } $viewCounts = $isAgent ? [ 'all' => (clone $baseQuery)->whereNotIn('status', $closedSlugs)->count(), 'mine' => (clone $baseQuery)->where('assigned_to', $user->id)->whereNotIn('status', $closedSlugs)->count(), 'unassigned' => (clone $baseQuery)->whereNull('assigned_to')->whereNotIn('status', $closedSlugs)->count(), 'pending' => (clone $baseQuery)->where('status', 'pending')->count(), 'resolved' => (clone $baseQuery)->whereIn('status', $closedSlugs)->count(), ] : [ 'all' => (clone $baseQuery)->count(), 'mine' => 0, 'unassigned' => 0, 'pending' => (clone $baseQuery)->where('status', 'pending')->count(), 'resolved' => (clone $baseQuery)->whereIn('status', $closedSlugs)->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', $closedSlugs); } elseif (!$request->filled('status')) { $query->whereNotIn('status', $closedSlugs); } } 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', $closedSlugs); } } $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; }); // Picker data: the group switcher/filter and the detail pane's transfer // select both read this, so archived groups are excluded. The tickets // themselves are NOT filtered — an archived group's tickets stay in the // list and stay workable; Index.vue re-adds the current group to the // transfer options when it happens to be archived. $groups = TicketingGroup::active() ->when($isAgent, fn ($q) => $q->whereIn('id', $agentGroupIds)) ->get(); $priorities = PriorityLevel::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', 'views', ])->whereKey($request->detail); if ($isAgent) { $detailQuery->whereIn('group_id', $agentGroupIds); } else { $detailQuery->where('submitter_id', $user->id); } $dt = $detailQuery->first(); if ($dt) { $viewUserIds = $isAgent ? $dt->views->pluck('user_id') : collect(); $detailUserIds = collect([$dt->submitter_id, $dt->assigned_to]) ->merge($dt->messages->pluck('user_id')) ->merge($viewUserIds) ->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; // 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); // Augment views with user names (agents only — don't expose to submitters) if ($isAgent) { $dt->views->each(fn($v) => $v->user = $detailUsers[$v->user_id] ?? null); } else { $dt->setRelation('views', collect()); } $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, 'ticketStatuses' => TicketStatus::orderBy('sort_order')->get(), 'isAgent' => $isAgent, 'filters' => $request->only(['group_id', 'status', 'priority_id', 'project_id', 'filter', 'detail']), 'ticketDetail' => $ticketDetail, 'detailAgents' => $detailAgents, 'viewCounts' => $viewCounts, 'isBootstrap' => false, 'userPrefs' => [ 'view_mode' => $pref['view_mode'] ?? 'list', 'filter' => $pref['filter'] ?? 'all', ], ]); } public function create(): Response { $user = Auth::user(); $agentGroupIds = $this->agentGroupIds(); $isAgent = count($agentGroupIds) > 0; // New tickets can only be filed against a live group. $groups = TicketingGroup::active() ->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, 'isSiteAdmin' => in_array(Auth::user()->role ?? '', ['admin', 'super_admin']), ]); } $priorities = PriorityLevel::orderBy('sort_order')->get(); return Inertia::render('Ticketing/Create', [ 'groups' => $groups, 'priorities' => $priorities, 'isBootstrap' => false, 'isSiteAdmin' => in_array(Auth::user()->role ?? '', ['admin', 'super_admin']), ]); } public function store(Request $request) { $validated = $request->validate([ 'title' => 'required|string|max:255', 'description' => 'required|string', // Archived groups are gone from the picker; they must also be // unreachable by posting a stale id. 'group_id' => ['required', Rule::exists('ticketing_groups', 'id')->whereNull('archived_at')], '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', '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', 'participants']); // Bug #2: Filter internal notes for submitters $visibleMessages = $this->filterMessagesForRole($ticket->messages, $isAgent); $participantUserIds = $ticket->participants->pluck('user_id'); $userIds = $visibleMessages->pluck('user_id') ->merge($participantUserIds) ->push($ticket->submitter_id) ->push($ticket->assigned_to) ->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); // Attach submitter and assignee info $ticket->submitter = $users[$ticket->submitter_id] ?? null; $ticket->assignee = $ticket->assigned_to ? ($users[$ticket->assigned_to] ?? null) : null; // Augment participants with user info $ticket->participants->each(function ($p) use ($users) { $p->user = $users[$p->user_id] ?? null; }); $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::orderBy('sort_order')->get(); return Inertia::render('Ticketing/Show', [ 'ticket' => $ticket, 'isAgent' => $isAgent, 'isManager' => $isAgent && $this->isManager($ticket->group_id), 'agents' => $agents, 'priorities' => $priorities, 'ticketStatuses' => TicketStatus::orderBy('sort_order')->get(), ]); } public function edit(Ticket $ticket): Response { if (!$this->isAgent($ticket->group_id)) { abort(403); } $ticket->load(['group', 'priority', 'project']); $priorities = PriorityLevel::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); } // Determine effective group (may be changing via a transfer) $targetGroupId = $request->filled('group_id') ? (int) $request->input('group_id') : $ticket->group_id; // Valid assignees are agents of the target group $validAssigneeIds = TicketingAgentAccess::where('group_id', $targetGroupId) ->pluck('user_id') ->toArray(); // Build valid status list from DB (exclude 'merged' — set only by the merge operation) $validStatusSlugs = TicketStatus::where('slug', '!=', 'merged')->pluck('slug')->implode(','); $rules = [ 'title' => 'sometimes|required|string|max:255', 'description' => 'sometimes|required|string', 'status' => "sometimes|in:{$validStatusSlugs}", 'priority_id' => 'nullable|exists:ticketing_priority_levels,id', 'due_date' => 'nullable|date', 'project_id' => 'nullable|exists:ticketing_projects,id', // A ticket may not be transferred INTO an archived group, but a // ticket already sitting in one must still be editable — so its own // group id always validates. 'group_id' => ['nullable', 'exists:ticketing_groups,id', function ($attribute, $value, $fail) use ($ticket) { if (! $value || (int) $value === (int) $ticket->group_id) { return; } if (TicketingGroup::whereKey($value)->archived()->exists()) { $fail('That group is archived — choose an active group.'); } }, ], 'assigned_to' => 'nullable|in:' . implode(',', $validAssigneeIds ?: [0]), ]; $validated = $request->validate($rules); // Group transfer: requester must be a manager of the target group if (!empty($validated['group_id']) && $validated['group_id'] !== $ticket->group_id) { if (! $this->isManager((int) $validated['group_id'])) { abort(403, 'You must be a manager of the destination group to transfer tickets.'); } // Clear assignee and project if they don't belong to the new group $validated['assigned_to'] = null; $validated['project_id'] = null; } else { unset($validated['group_id']); // no-op if unchanged } // project_id must belong to the effective group if (!empty($validated['project_id'])) { $project = TicketingProject::find($validated['project_id']); if ($project && $project->group_id !== ($validated['group_id'] ?? $ticket->group_id)) { abort(422, 'Project does not belong to this ticket\'s group.'); } } // Issue #8: capture the previous assignee BEFORE the write, so a genuine // hand-off can be told from a save that merely touched other fields. $previousAssignee = $ticket->assigned_to; $ticket->update($validated); $newAssignee = $ticket->assigned_to; $handedOver = $newAssignee && $newAssignee !== $previousAssignee && $newAssignee !== Auth::id(); // assigning to yourself needs no telling if ($handedOver) { $agent = \App\Models\User::find($newAssignee); $email = trim((string) ($agent?->email ?? '')); if ($email !== '') { \Dashboard\Ticketing\Support\TicketingNotifier::deliver( \Dashboard\Ticketing\Support\TicketingNotifier::ASSIGNED, $email, "[{$ticket->number}] Assigned to you: {$ticket->title}", '

'.e(Auth::user()?->name ?? 'Somebody').' assigned this ticket to you.

' .'

View the ticket

', $ticket, ); } } return back()->with('success', 'Ticket updated.'); } /** * Record that an agent viewed this ticket (called after 10s of the pane being open). * Throttled: at most one record per user per 2 hours per ticket. */ public function recordView(Request $request, Ticket $ticket) { if (! $this->isAgent($ticket->group_id)) { return response()->json(['ok' => false], 403); } $alreadySeen = TicketView::where('ticket_id', $ticket->id) ->where('user_id', Auth::id()) ->where('viewed_at', '>=', now()->subHours(2)) ->exists(); if (! $alreadySeen) { TicketView::create([ 'ticket_id' => $ticket->id, 'user_id' => Auth::id(), 'viewed_at' => now(), ]); } return response()->json(['ok' => true]); } public function savePrefs(Request $request) { $validated = $request->validate([ 'view_mode' => 'sometimes|in:list,kanban', 'filter' => 'sometimes|in:all,mine,unassigned,pending,resolved', ]); if (empty($validated)) { return response()->json(['ok' => false], 422); } $key = 'ticketing.pref.' . Auth::id(); $current = json_decode(Setting::get($key, '{}'), true) ?: []; Setting::set($key, json_encode(array_merge($current, $validated))); return response()->json(['ok' => true]); } public function destroy(Ticket $ticket) { if (!$this->isManager($ticket->group_id)) { abort(403); } $ticket->delete(); return redirect()->route('ticketing.index'); } /** * Merge another ticket INTO this one. * The source ticket's messages and attachments move here; source is marked merged. */ public function merge(Request $request, Ticket $ticket) { if (! $this->isAgent($ticket->group_id)) { abort(403); } $validated = $request->validate([ 'source_number' => 'required|string', ]); $source = Ticket::where('number', $validated['source_number'])->first(); if (! $source) { return back()->withErrors(['source_number' => 'Ticket not found.']); } if ($source->id === $ticket->id) { return back()->withErrors(['source_number' => 'Cannot merge a ticket into itself.']); } if ($source->status === 'merged') { return back()->withErrors(['source_number' => 'That ticket has already been merged.']); } if (! $this->isAgent($source->group_id)) { return back()->withErrors(['source_number' => 'You do not have access to that ticket.']); } DB::transaction(function () use ($ticket, $source) { // Move messages to this ticket TicketMessage::where('ticket_id', $source->id) ->update(['ticket_id' => $ticket->id]); // Move attachments to this ticket TicketAttachment::where('ticket_id', $source->id) ->update(['ticket_id' => $ticket->id]); // Add a system note marking the merge TicketMessage::create([ 'ticket_id' => $ticket->id, 'user_id' => Auth::id(), 'body' => "Merged from ticket {$source->number}.", 'is_internal' => true, 'source' => 'system', ]); // Add source's submitter as a participant (if not already the submitter of target) if ($source->submitter_id !== $ticket->submitter_id) { TicketParticipant::firstOrCreate( ['ticket_id' => $ticket->id, 'user_id' => $source->submitter_id], ['added_by' => Auth::id()] ); } // Mark source as merged $source->update([ 'status' => 'merged', 'merged_into_id' => $ticket->id, ]); }); return redirect()->route('ticketing.show', $ticket) ->with('success', "Ticket {$source->number} merged in."); } /** * Split this ticket — create a copy with a new number, same metadata, duplicated messages. */ public function split(Request $request, Ticket $ticket) { if (! $this->isAgent($ticket->group_id)) { abort(403); } $newTicket = DB::transaction(function () use ($ticket) { $group = TicketingGroup::findOrFail($ticket->group_id); $number = $group->nextTicketNumber(); $newTicket = Ticket::create([ 'number' => $number, 'group_id' => $ticket->group_id, 'submitter_id' => $ticket->submitter_id, 'assigned_to' => $ticket->assigned_to, 'project_id' => $ticket->project_id, 'title' => $ticket->title . ' (split)', 'description' => $ticket->description, 'status' => 'open', 'priority_id' => $ticket->priority_id, 'due_date' => $ticket->due_date, ]); // Duplicate messages (exclude system notes) $ticket->load('messages'); foreach ($ticket->messages as $msg) { TicketMessage::create([ 'ticket_id' => $newTicket->id, 'user_id' => $msg->user_id, 'author_email' => $msg->author_email, 'body' => $msg->body, 'is_internal' => $msg->is_internal, 'source' => $msg->source, ]); } // Cross-reference notes on both tickets TicketMessage::create([ 'ticket_id' => $ticket->id, 'user_id' => Auth::id(), 'body' => "Split: new ticket {$newTicket->number} created from this ticket.", 'is_internal' => true, 'source' => 'system', ]); TicketMessage::create([ 'ticket_id' => $newTicket->id, 'user_id' => Auth::id(), 'body' => "Split from ticket {$ticket->number}.", 'is_internal' => true, 'source' => 'system', ]); return $newTicket; }); return redirect()->route('ticketing.show', $newTicket) ->with('success', "Ticket {$newTicket->number} created from split."); } /** * Add a participant to a ticket. */ public function addParticipant(Request $request, Ticket $ticket) { if (! $this->isAgent($ticket->group_id)) { abort(403); } $validated = $request->validate([ 'user_id' => 'required|integer|exists:users,id', ]); TicketParticipant::firstOrCreate( ['ticket_id' => $ticket->id, 'user_id' => $validated['user_id']], ['added_by' => Auth::id()] ); return back()->with('success', 'Participant added.'); } /** * Remove a participant from a ticket. */ public function removeParticipant(Ticket $ticket, int $userId) { if (! $this->isAgent($ticket->group_id)) { abort(403); } TicketParticipant::where('ticket_id', $ticket->id) ->where('user_id', $userId) ->delete(); return back()->with('success', 'Participant removed.'); } /** * Search users by name/email — for the participant picker. * Returns up to 10 matches, excluding existing participants and the submitter. */ public function searchUsers(Request $request, Ticket $ticket) { if (! $this->isAgent($ticket->group_id)) { abort(403); } $q = $request->input('q', ''); if (strlen($q) < 2) { return response()->json([]); } $excludeIds = $ticket->participants->pluck('user_id') ->push($ticket->submitter_id) ->filter() ->unique() ->values(); $users = DB::table('users') ->where(function ($query) use ($q) { $query->where('name', 'like', "%{$q}%") ->orWhere('email', 'like', "%{$q}%"); }) ->whereNotIn('id', $excludeIds) ->limit(10) ->get(['id', 'name', 'email']); return response()->json($users); } public function myTickets(): Response { $tickets = Ticket::where('submitter_id', Auth::id()) ->with(['group', 'priority']) ->latest() ->paginate(20); return Inertia::render('Ticketing/MyTickets', [ 'tickets' => $tickets, ]); } }