Files
central-ticketing/src/Http/Controllers/TicketController.php
2026-04-08 21:58:33 -07:00

439 lines
17 KiB
PHP

<?php
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 Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
class TicketController extends Controller
{
private function agentGroupIds(): array
{
return TicketingAgentAccess::where('user_id', Auth::id())
->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,
]);
}
}