Files
dashboard-ticketing/src/Http/Controllers/TicketingSettingsController.php
Joel Wedemire dd0a458250 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>
2026-04-19 22:22:45 -07:00

471 lines
15 KiB
PHP

<?php
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 Dashboard\Ticketing\Models\TicketStatus;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
use Inertia\Response;
class TicketingSettingsController extends Controller
{
/**
* True if there are zero groups in the system (first-run bootstrap state).
*/
private function isBootstrapState(): bool
{
return TicketingGroup::count() === 0;
}
/**
* True if the current user is a site-level admin or super_admin.
*/
private function isSiteAdmin(): bool
{
$user = Auth::user();
return $user && ($user->is_super_admin || $user->can('ticketing.settings'));
}
/**
* Require either:
* - existing agent access, OR
* - bootstrap state + site admin (so first admin can seed the system)
*/
private function requireAgentAccess(): void
{
// Site admins always have access
if ($this->isSiteAdmin()) {
return;
}
$hasAccess = TicketingAgentAccess::where('user_id', Auth::id())->exists();
if (!$hasAccess) {
abort(403, 'You need agent access to manage ticketing settings.');
}
}
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();
$isBootstrap = $this->isBootstrapState();
$myGroupIds = TicketingAgentAccess::where('user_id', $userId)->pluck('group_id');
$groups = $isBootstrap
? collect()
: TicketingGroup::whereIn('id', $myGroupIds)->get();
$agents = $isBootstrap
? collect()
: TicketingAgentAccess::whereIn('group_id', $myGroupIds)->with('group')->get();
if ($agents->isNotEmpty()) {
$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 = $isBootstrap
? collect()
: PriorityLevel::orderBy('sort_order')->get();
$projects = $isBootstrap
? collect()
: TicketingProject::whereIn('group_id', $myGroupIds)
->orderBy('name')
->get();
$emailConnections = $isBootstrap
? collect()
: EmailConnection::whereIn('group_id', $myGroupIds)->get();
return Inertia::render('Ticketing/Settings', [
'groups' => $groups,
'agents' => $agents,
'priorities' => $priorities,
'projects' => $projects,
'ticketStatuses' => TicketStatus::orderBy('sort_order')->get(),
'emailConnections' => $emailConnections,
'myGroupIds' => $myGroupIds,
'isBootstrap' => $isBootstrap,
'isSiteAdmin' => $this->isSiteAdmin(),
'autoCloseDays' => (int) Setting::get('ticketing.auto_close_days', 0),
]);
}
public function storeGroup(Request $request)
{
// During bootstrap, any site admin can create the first group.
// After bootstrap, require existing agent access.
if ($this->isBootstrapState()) {
if (!$this->isSiteAdmin()) {
abort(403, 'Only site admins can create the first group.');
}
} else {
$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',
]);
// Seed default priorities for this group if none exist globally
if (PriorityLevel::count() === 0) {
$defaults = [
['name' => 'Low', 'color' => '#6b7280', 'sort_order' => 1],
['name' => 'Medium', 'color' => '#3b82f6', 'sort_order' => 2],
['name' => 'High', 'color' => '#f59e0b', 'sort_order' => 3],
['name' => 'Urgent', 'color' => '#ef4444', 'sort_order' => 4],
];
foreach ($defaults as $d) {
PriorityLevel::create([
'name' => $d['name'],
'color' => $d['color'],
'description' => null,
'sort_order' => $d['sort_order'],
]);
}
}
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)
{
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}$/',
'description' => 'nullable|string',
'sort_order' => 'integer|min:0',
]);
PriorityLevel::create($validated);
return back()->with('success', 'Priority level created.');
}
public function updatePriority(Request $request, PriorityLevel $priority)
{
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}$/',
'description' => 'nullable|string',
'sort_order' => 'required|integer|min:0',
]);
$priority->update($validated);
return back()->with('success', 'Priority level updated.');
}
public function destroyPriority(PriorityLevel $priority)
{
if (!$this->isSiteAdmin()) {
abort(403, 'Only site admins can manage global priorities.');
}
if ($priority->tickets()->exists()) {
return back()->withErrors([
'priority' => 'Cannot delete a priority that is in use by tickets.',
]);
}
$priority->delete();
return back()->with('success', 'Priority level removed.');
}
public function storeProject(Request $request)
{
$this->requireAgentAccess();
$validated = $request->validate([
'group_id' => 'required|exists:ticketing_groups,id',
'name' => 'required|string|max:100',
'description' => 'nullable|string',
'status' => 'required|in:active,archived',
]);
$this->requireManagerAccess($validated['group_id']);
TicketingProject::create([
...$validated,
'created_by' => Auth::id(),
]);
return back()->with('success', 'Project created.');
}
public function updateProject(Request $request, TicketingProject $project)
{
$this->requireAgentAccess();
$this->requireManagerAccess($project->group_id);
$validated = $request->validate([
'name' => 'required|string|max:100',
'description' => 'nullable|string',
'status' => 'required|in:active,archived',
]);
$project->update($validated);
return back()->with('success', 'Project updated.');
}
public function destroyProject(TicketingProject $project)
{
$this->requireAgentAccess();
$this->requireManagerAccess($project->group_id);
if ($project->tickets()->exists()) {
return back()->withErrors([
'project' => 'Cannot delete a project that is in use by tickets.',
]);
}
$project->delete();
return back()->with('success', 'Project removed.');
}
public function storeEmailConnection(Request $request)
{
$this->requireAgentAccess();
$validated = $request->validate([
'group_id' => 'required|exists:ticketing_groups,id',
'type' => 'required|in:gmail,imap',
'active' => 'boolean',
'config.host' => 'nullable|string|max:255',
'config.port' => 'nullable|integer',
'config.username' => 'nullable|string|max:255',
'config.password' => 'nullable|string|max:500',
'config.encryption' => 'nullable|in:ssl,tls,none',
'config.mailbox' => 'nullable|string|max:255',
]);
$this->requireManagerAccess($validated['group_id']);
EmailConnection::create([
'group_id' => $validated['group_id'],
'type' => $validated['type'],
'active' => $request->boolean('active', true),
'config' => $request->input('config', []),
]);
return back()->with('success', 'Email connection saved.');
}
public function updateEmailConnection(Request $request, EmailConnection $connection)
{
$this->requireAgentAccess();
$this->requireManagerAccess($connection->group_id);
$request->validate([
'type' => 'required|in:gmail,imap',
'active' => 'boolean',
'config.host' => 'nullable|string|max:255',
'config.port' => 'nullable|integer',
'config.username' => 'nullable|string|max:255',
'config.password' => 'nullable|string|max:500',
'config.encryption' => 'nullable|in:ssl,tls,none',
'config.mailbox' => 'nullable|string|max:255',
]);
$connection->update([
'type' => $request->type,
'active' => $request->boolean('active', true),
'config' => $request->input('config', $connection->config),
]);
return back()->with('success', 'Email connection updated.');
}
public function destroyEmailConnection(EmailConnection $connection)
{
$this->requireAgentAccess();
$this->requireManagerAccess($connection->group_id);
$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.');
}
}