- 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>
471 lines
15 KiB
PHP
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.');
|
|
}
|
|
}
|