feat: ticket views, statuses, participants, merge; mobile layout fixes (#5)

- New migrations: ticket views, statuses, participants, merge support
- New models: TicketView, TicketStatus, TicketParticipant
- New seeder: EmailTemplatesSeeder
- Console commands for ticketing
- Mobile: sidebar min-w-0/overflow-hidden, tab nav overflow-x-auto

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Joel Wedemire
2026-04-19 22:22:45 -07:00
parent ffb64078d8
commit dd0a458250
17 changed files with 1399 additions and 260 deletions

View File

@@ -2,11 +2,17 @@
namespace Dashboard\Ticketing\Http\Controllers;
use App\Models\Setting;
use Dashboard\Ticketing\Models\PriorityLevel;
use Dashboard\Ticketing\Models\Ticket;
use Dashboard\Ticketing\Models\TicketAttachment;
use Dashboard\Ticketing\Models\TicketingAgentAccess;
use Dashboard\Ticketing\Models\TicketingGroup;
use Dashboard\Ticketing\Models\TicketingProject;
use Dashboard\Ticketing\Models\TicketMessage;
use Dashboard\Ticketing\Models\TicketParticipant;
use Dashboard\Ticketing\Models\TicketStatus;
use Dashboard\Ticketing\Models\TicketView;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Auth;
@@ -51,29 +57,46 @@ class TicketController extends Controller
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' => [],
'isAgent' => $isAgent,
'filters' => [],
'ticketDetail' => null,
'detailAgents' => [],
'viewCounts' => ['all' => 0, 'mine' => 0, 'unassigned' => 0, 'pending' => 0, 'resolved' => 0],
'isBootstrap' => true,
'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) {
@@ -84,18 +107,18 @@ class TicketController extends Controller
$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(),
'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', ['resolved', 'closed'])->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', ['resolved', 'closed'])->count(),
'resolved' => (clone $baseQuery)->whereIn('status', $closedSlugs)->count(),
];
$query = Ticket::with(['group', 'priority', 'project']);
@@ -122,9 +145,9 @@ class TicketController extends Controller
} elseif ($request->filter === 'pending') {
$query->where('status', 'pending');
} elseif ($request->filter === 'resolved') {
$query->whereIn('status', ['resolved', 'closed']);
$query->whereIn('status', $closedSlugs);
} elseif (!$request->filled('status')) {
$query->whereNotIn('status', ['resolved', 'closed']);
$query->whereNotIn('status', $closedSlugs);
}
} else {
$query->where('submitter_id', $user->id);
@@ -134,7 +157,7 @@ class TicketController extends Controller
if ($request->filter === 'pending') {
$query->where('status', 'pending');
} elseif ($request->filter === 'resolved') {
$query->whereIn('status', ['resolved', 'closed']);
$query->whereIn('status', $closedSlugs);
}
}
@@ -150,10 +173,7 @@ class TicketController extends Controller
});
$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();
$priorities = PriorityLevel::orderBy('sort_order')->get();
$projects = TicketingProject::when($isAgent, fn ($q) => $q->whereIn('group_id', $agentGroupIds))
->where('status', 'active')
->get();
@@ -178,6 +198,7 @@ class TicketController extends Controller
'project',
'messages.attachments',
'attachments',
'views',
])->whereKey($request->detail);
if ($isAgent) {
@@ -189,42 +210,56 @@ class TicketController extends Controller
$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;
$dt->assignee = $dt->assigned_to ? ($detailUsers[$dt->assigned_to] ?? null) : null;
// Bug #2: Filter internal notes for non-agents
// 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');
$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,
'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',
],
]);
}
@@ -246,11 +281,7 @@ class TicketController extends Controller
]);
}
// 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();
$priorities = PriorityLevel::orderBy('sort_order')->get();
return Inertia::render('Ticketing/Create', [
'groups' => $groups,
@@ -319,12 +350,17 @@ class TicketController extends Controller
abort(403);
}
$ticket->load(['group', 'priority', 'project', 'messages', 'attachments']);
$ticket->load(['group', 'priority', 'project', 'messages', 'attachments', 'participants']);
// Bug #2: Filter internal notes for submitters
$visibleMessages = $this->filterMessagesForRole($ticket->messages, $isAgent);
$userIds = $visibleMessages->pluck('user_id')->filter()->unique();
$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) {
@@ -333,21 +369,30 @@ class TicketController extends Controller
$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::where(fn($q) => $q->whereNull('group_id')->orWhere('group_id', $ticket->group_id))
->orderBy('sort_order')->get();
$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,
'ticket' => $ticket,
'isAgent' => $isAgent,
'isManager' => $isAgent && $this->isManager($ticket->group_id),
'agents' => $agents,
'priorities' => $priorities,
'ticketStatuses' => TicketStatus::orderBy('sort_order')->get(),
]);
}
@@ -358,8 +403,7 @@ class TicketController extends Controller
}
$ticket->load(['group', 'priority', 'project']);
$priorities = PriorityLevel::where(fn($q) => $q->whereNull('group_id')->orWhere('group_id', $ticket->group_id))
->orderBy('sort_order')->get();
$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();
@@ -378,35 +422,48 @@ class TicketController extends Controller
abort(403);
}
// Bug #3: Valid assignees are users with agent access to this group
$validAssigneeIds = TicketingAgentAccess::where('group_id', $ticket->group_id)
// 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',
'title' => 'sometimes|required|string|max:255',
'description' => 'sometimes|required|string',
'status' => 'sometimes|in:open,in_progress,pending,resolved,closed',
'status' => "sometimes|in:{$validStatusSlugs}",
'priority_id' => 'nullable|exists:ticketing_priority_levels,id',
'due_date' => 'nullable|date',
'project_id' => 'nullable|exists:ticketing_projects,id',
'due_date' => 'nullable|date',
'project_id' => 'nullable|exists:ticketing_projects,id',
'group_id' => 'nullable|exists:ticketing_groups,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.');
// 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
}
// Bug #4: project_id must belong to this group
// project_id must belong to the effective group
if (!empty($validated['project_id'])) {
$project = TicketingProject::find($validated['project_id']);
if ($project && $project->group_id !== $ticket->group_id) {
if ($project && $project->group_id !== ($validated['group_id'] ?? $ticket->group_id)) {
abort(422, 'Project does not belong to this ticket\'s group.');
}
}
@@ -416,6 +473,50 @@ class TicketController extends Controller
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)) {
@@ -426,6 +527,204 @@ class TicketController extends Controller
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())

View File

@@ -2,12 +2,13 @@
namespace Dashboard\Ticketing\Http\Controllers;
use App\Models\Setting;
use Dashboard\Ticketing\Models\EmailConnection;
use Dashboard\Ticketing\Models\PriorityLevel;
use Dashboard\Ticketing\Models\TicketingAgentAccess;
use Dashboard\Ticketing\Models\TicketingGroup;
use Dashboard\Ticketing\Models\TicketingProject;
use Illuminate\Validation\Rule;
use Dashboard\Ticketing\Models\TicketStatus;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Auth;
@@ -77,7 +78,7 @@ class TicketingSettingsController extends Controller
$agents = $isBootstrap
? collect()
: TicketingAgentAccess::whereIn('group_id', $myGroupIds)->get();
: TicketingAgentAccess::whereIn('group_id', $myGroupIds)->with('group')->get();
if ($agents->isNotEmpty()) {
$agentUserIds = $agents->pluck('user_id')->unique();
@@ -87,8 +88,7 @@ class TicketingSettingsController extends Controller
$priorities = $isBootstrap
? collect()
: PriorityLevel::where(fn($q) => $q->whereNull('group_id')->orWhereIn('group_id', $myGroupIds))
->orderBy('sort_order')->get();
: PriorityLevel::orderBy('sort_order')->get();
$projects = $isBootstrap
? collect()
@@ -101,14 +101,16 @@ class TicketingSettingsController extends Controller
: EmailConnection::whereIn('group_id', $myGroupIds)->get();
return Inertia::render('Ticketing/Settings', [
'groups' => $groups,
'agents' => $agents,
'priorities' => $priorities,
'projects' => $projects,
'groups' => $groups,
'agents' => $agents,
'priorities' => $priorities,
'projects' => $projects,
'ticketStatuses' => TicketStatus::orderBy('sort_order')->get(),
'emailConnections' => $emailConnections,
'myGroupIds' => $myGroupIds,
'isBootstrap' => $isBootstrap,
'isSiteAdmin' => $this->isSiteAdmin(),
'myGroupIds' => $myGroupIds,
'isBootstrap' => $isBootstrap,
'isSiteAdmin' => $this->isSiteAdmin(),
'autoCloseDays' => (int) Setting::get('ticketing.auto_close_days', 0),
]);
}
@@ -150,11 +152,10 @@ class TicketingSettingsController extends Controller
];
foreach ($defaults as $d) {
PriorityLevel::create([
'group_id' => $group->id,
'name' => $d['name'],
'color' => $d['color'],
'name' => $d['name'],
'color' => $d['color'],
'description' => null,
'sort_order' => $d['sort_order'],
'sort_order' => $d['sort_order'],
]);
}
}
@@ -209,21 +210,17 @@ class TicketingSettingsController extends Controller
public function storePriority(Request $request)
{
$this->requireAgentAccess();
if (!$this->isSiteAdmin()) {
abort(403, 'Only site admins can manage global priorities.');
}
$validated = $request->validate([
'name' => 'required|string|max:100',
'color' => 'required|string|regex:/^#[0-9a-fA-F]{6}$/',
'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',
'sort_order' => 'integer|min:0',
]);
// If group_id given, caller must be manager of that group
if (!empty($validated['group_id'])) {
$this->requireManagerAccess($validated['group_id']);
}
PriorityLevel::create($validated);
return back()->with('success', 'Priority level created.');
@@ -231,33 +228,17 @@ class TicketingSettingsController extends Controller
public function updatePriority(Request $request, PriorityLevel $priority)
{
$this->requireAgentAccess();
if ($priority->group_id) {
$this->requireManagerAccess($priority->group_id);
} else {
// Global priorities require site admin
if (!$this->isSiteAdmin()) {
abort(403, 'Only site admins can manage global priorities.');
}
if (!$this->isSiteAdmin()) {
abort(403, 'Only site admins can manage global priorities.');
}
$validated = $request->validate([
'name' => 'required|string|max:100',
'color' => 'required|string|regex:/^#[0-9a-fA-F]{6}$/',
'name' => 'required|string|max:100',
'color' => 'required|string|regex:/^#[0-9a-fA-F]{6}$/',
'description' => 'nullable|string',
'sort_order' => 'required|integer|min:0',
'group_id' => [
'nullable',
'exists:ticketing_groups,id',
Rule::in([$priority->group_id, null]),
],
'sort_order' => 'required|integer|min:0',
]);
if (!empty($validated['group_id'])) {
$this->requireManagerAccess($validated['group_id']);
}
$priority->update($validated);
return back()->with('success', 'Priority level updated.');
@@ -265,15 +246,8 @@ class TicketingSettingsController extends Controller
public function destroyPriority(PriorityLevel $priority)
{
$this->requireAgentAccess();
if ($priority->group_id) {
$this->requireManagerAccess($priority->group_id);
} else {
// Global priorities require site admin
if (!$this->isSiteAdmin()) {
abort(403, 'Only site admins can manage global priorities.');
}
if (!$this->isSiteAdmin()) {
abort(403, 'Only site admins can manage global priorities.');
}
if ($priority->tickets()->exists()) {
@@ -400,4 +374,97 @@ class TicketingSettingsController extends Controller
$connection->delete();
return back()->with('success', 'Email connection removed.');
}
public function storeStatus(Request $request)
{
if (! $this->isSiteAdmin()) {
abort(403, 'Only site admins can manage ticket statuses.');
}
$validated = $request->validate([
'name' => 'required|string|max:100',
'color' => 'required|string|regex:/^#[0-9a-fA-F]{6}$/',
'sort_order' => 'required|integer|min:0',
'is_closed' => 'boolean',
]);
// Derive a slug from the name (lowercase, underscores) — must be unique
$slug = \Str::slug($validated['name'], '_');
if (TicketStatus::where('slug', $slug)->exists()) {
$slug = $slug . '_' . time();
}
TicketStatus::create([
'slug' => $slug,
'name' => $validated['name'],
'color' => $validated['color'],
'sort_order' => $validated['sort_order'],
'is_closed' => $request->boolean('is_closed', false),
'is_system' => false,
]);
return back()->with('success', 'Status created.');
}
public function updateStatus(Request $request, TicketStatus $status)
{
if (! $this->isSiteAdmin()) {
abort(403, 'Only site admins can manage ticket statuses.');
}
$validated = $request->validate([
'name' => 'required|string|max:100',
'color' => 'required|string|regex:/^#[0-9a-fA-F]{6}$/',
'sort_order' => 'required|integer|min:0',
'is_closed' => 'boolean',
]);
// System statuses: only allow name, color, sort_order to be changed — not is_closed
$update = [
'name' => $validated['name'],
'color' => $validated['color'],
'sort_order' => $validated['sort_order'],
];
if (! $status->is_system) {
$update['is_closed'] = $request->boolean('is_closed', false);
}
$status->update($update);
return back()->with('success', 'Status updated.');
}
public function destroyStatus(TicketStatus $status)
{
if (! $this->isSiteAdmin()) {
abort(403, 'Only site admins can manage ticket statuses.');
}
if ($status->is_system) {
return back()->withErrors(['status' => 'System statuses cannot be deleted.']);
}
if ($status->tickets()->exists()) {
return back()->withErrors(['status' => 'Cannot delete a status that is in use by tickets.']);
}
$status->delete();
return back()->with('success', 'Status removed.');
}
public function updateAutomation(Request $request)
{
if (! $this->isSiteAdmin()) {
abort(403, 'Only site admins can manage automation settings.');
}
$validated = $request->validate([
'auto_close_days' => 'required|integer|min:0|max:365',
]);
Setting::set('ticketing.auto_close_days', $validated['auto_close_days']);
return back()->with('success', 'Automation settings saved.');
}
}