isSiteAdminUser(Auth::user()); } /** * The same question asked about SOMEBODY ELSE — one definition, two callers. * * #4806 needs to know whether the manager a group already has could still * use this page, which is isSiteAdmin()'s test applied to a user who is not * signed in. Two copies of that rule would drift, and the drift would be * invisible: the recovery path below opens exactly when this returns false * for every manager of a group. */ private function isSiteAdminUser(?User $user): bool { return $user !== null && ($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.'); } } /** * Require manager rights over one group. * * Deliberately NOT widened to site admins: GroupRetirementTest pins that a * super admin with no agent access is refused here, and #4710 is a * visibility bug, not a licence to hand every `ticketing.settings` holder * manager rights over every group. Seeing a group you do not manage is the * fix; acting on it is a separate decision for a human to make. * * TWO writes do not come through here, and they are the same write in * both directions. `storeAgent()` uses * requireManagerAccessOrOrphanRecovery(), because a group with no manager * left has nobody this gate could ever admit, and the group and its * tickets are frozen for good (#4806); `destroyAgent()` uses * requireRemoveAccess(), so that recovery is not one-way (#5002) — an * appoint that could never be un-appointed froze the group again through * its own cure. Every other write on somebody else's group still ends up * here. */ private function requireManagerAccess(int $groupId): void { if (!$this->isManagerOf($groupId)) { abort(403); } $this->settleRescueStamp($groupId); } /** * THE RESCUE STAMP ENDS WHEN THE APPOINTEE USES WHAT IT GAVE THEM — #5008. * * `recovered_by` (#5002) exists so a rescuer can take back an appointment * they got wrong, including a `manager` press that shut the orphan arm * behind it. It was written as an undo and it has to expire like one. As * first built, the only thing that cleared it was the appointee re-saving * their own row — a write nobody makes — so the stamp sat on the row for * good, and a successful rescue left the rescuer a standing lever over a * desk somebody else was now running: strip the manager, then "rescue" the * group that press just orphaned, and a site admin who never ran it * manages it (#5008). That is #4710's line, which the undo must not cross. * * So the stamp settles the moment the appointee ACTS as the manager they * were made: staffs the group, renames it, archives it, gives it a project * or an email connection — anything this file lets a manager do to their * own group. The rescue has been taken up, the group is theirs, and the * rescuer's claim on it is spent. Until then nothing has happened but the * press itself, which is exactly the case the undo is for. * * Called from the gates and not from each action because the gates are * where "this write was authorised by the actor's OWN manager row" is * already decided; a read of the page never comes through them. */ private function settleRescueStamp(int $groupId): void { TicketingAgentAccess::where('user_id', Auth::id()) ->where('group_id', $groupId) ->whereNotNull('recovered_by') ->update(['recovered_by' => null]); } /** Does the signed-in user hold a `manager` row on this group? */ private function isManagerOf(int $groupId): bool { return TicketingAgentAccess::where('user_id', Auth::id()) ->where('group_id', $groupId) ->where('role', 'manager') ->exists(); } /** * Could this person still administer a help desk group today? * * NOT "do they hold a manager row" — the row is what we are judging. This * asks whether, if they signed in right now, the Agents tab's own POST * would work for them: the account has to still exist, still be active, * and still hold the permission every route in this file sits behind. * * A manager row outlives all three. On dev the sole manager of all 15 * groups was a live row pointing at an account that had been rewritten * into an admissions-only applicant (#4202/#4203) — it could not reach * /app/ticketing at all, and the Agents tab was still telling site admins * to go and ask them (#4806). */ private function canStillAdminister(?User $user): bool { if ($user === null || !($user->is_active ?? true)) { return false; } return $this->isSiteAdminUser($user); } /** * The groups nobody is left to run — every `manager` row on them points at * somebody canStillAdminister() says no to (or there is no manager row at * all). * * This is the ONLY condition under which a site admin may write an agent * row for a group they do not manage. It is deliberately a property of the * GROUP, not of the caller: an orphaned group cannot be rescued by anyone * else, and a group with a working manager is still none of an admin's * business (#4710). * * `$onlyGroupIds` narrows both queries for the single-group gate; `null` * means "every group", which is what the page's prop needs. * * @param int[]|null $onlyGroupIds * @return int[] */ private function orphanedGroupIds(?array $onlyGroupIds = null): array { $groupIds = TicketingGroup::when($onlyGroupIds !== null, fn($q) => $q->whereIn('id', $onlyGroupIds)) ->pluck('id'); if ($groupIds->isEmpty()) { return []; } $managerRows = TicketingAgentAccess::where('role', 'manager') ->whereIn('group_id', $groupIds) ->get(['group_id', 'user_id']); // One can() per distinct manager, not per row: the dev case is one // person holding the manager row on all fifteen groups. $managerIds = $managerRows->pluck('user_id')->unique(); $actingIds = $managerIds->isEmpty() ? collect() : User::whereIn('id', $managerIds)->get() ->filter(fn($u) => $this->canStillAdminister($u)) ->pluck('id'); $covered = $managerRows ->filter(fn($r) => $actingIds->contains($r->user_id)) ->pluck('group_id') ->unique(); return $groupIds->reject(fn($id) => $covered->contains($id))->values()->all(); } /** * Manager rights over one group, OR a site admin rescuing an orphaned one. * * The second arm is #4806 and nothing wider: the moment a group's last * usable manager leaves, is deactivated or is diverted elsewhere, every * write in this file 403s for everybody — the group cannot be renamed, * archived, given a project or given a single agent, and its tickets can * never be assigned or closed. The documented answer on the Agents tab was * "ask a manager of the group", which on such a group names nobody. * * So: a site admin may appoint somebody to a group that has no manager * left. That one write un-orphans the group, and from the next request on * the ordinary requireManagerAccess() rule governs it again — including * for the admin, who gets no standing rights over it unless they appointed * themselves. */ private function requireManagerAccessOrOrphanRecovery(int $groupId): void { if ($this->isManagerOf($groupId)) { $this->settleRescueStamp($groupId); return; } if ($this->isSiteAdmin() && $this->orphanedGroupIds([$groupId]) !== []) { return; } abort(403); } public function index(?string $tab = null): Response { $this->requireAgentAccess(); $userId = Auth::id(); $isBootstrap = $this->isBootstrapState(); $isSiteAdmin = $this->isSiteAdmin(); $myGroupIds = TicketingAgentAccess::where('user_id', $userId)->pluck('group_id'); // The groups this user may actually ACT on — requireManagerAccess() in // prop form. A site admin now sees groups they do not manage (#4710), // so the page has to say which rows its buttons will work on instead // of offering an Edit that 403s. $myManagedGroupIds = TicketingAgentAccess::where('user_id', $userId) ->where('role', 'manager') ->pluck('group_id'); // What this page is allowed to SHOW — separate from what it lets you // DO, which stays requireManagerAccess() per group. A site admin sees // every group: scoping the list to agent-access rows left an admin // staring at "No groups yet. Create your first group above." with // twelve groups in the database, inviting a duplicate (#4710). // `null` means "no restriction". // // Every one of these routes sits behind `permission:ticketing.settings` // and isSiteAdmin() is `is_super_admin || can('ticketing.settings')`, // so today this is true for anyone who reaches the page at all. The // scoped branch is kept because it is the correct behaviour if that // route gate is ever loosened — it is not load-bearing right now. $visibleGroupIds = $isSiteAdmin ? null : $myGroupIds; // Archived groups stay in this list — this is the one place they must // remain visible, so a manager can see what happened and restore it. // Actives first, then archived, each alphabetical. $groups = $isBootstrap ? collect() : TicketingGroup::when($visibleGroupIds !== null, fn($q) => $q->whereIn('id', $visibleGroupIds)) // tickets_count decides archive-vs-delete, so the UI can warn // the operator which one their click is about to do. ->withCount('tickets') ->orderByRaw('archived_at IS NOT NULL') ->orderBy('name') ->get(); $agents = $isBootstrap ? collect() : TicketingAgentAccess::when($visibleGroupIds !== null, fn($q) => $q->whereIn('group_id', $visibleGroupIds)) ->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); } // #4806 — the groups with no manager left who could still use this // page. Computed once: the page names them in the add form, and #5002 // asks the same question again for every Remove button below. $orphanedGroupIds = $isSiteAdmin && !$isBootstrap ? $this->orphanedGroupIds() : []; // #5002 — requireRemoveAccess() in prop form, the way myManagedGroupIds // is requireManagerAccess()'s. It cannot be `canManageGroupId(row.group)` // any more: a rescuer may also remove a row on a group that is still // orphaned, and the row their own rescue wrote even after that rescue // un-orphaned the group. A row an administrator can create has to be a // row they can see the Remove on — the list said "Not yours to manage" // over a row the reader had written seconds earlier. $orphanedSet = array_map('intval', $orphanedGroupIds); $removableAgentIds = $agents ->filter(fn ($a) => $myManagedGroupIds->contains($a->group_id) || ($isSiteAdmin && (in_array((int) $a->group_id, $orphanedSet, true) || ($a->recovered_by !== null && (int) $a->recovered_by === (int) $userId)))) ->pluck('id') ->values() ->all(); $priorities = $isBootstrap ? collect() : PriorityLevel::orderBy('sort_order')->get(); $projects = $isBootstrap ? collect() : TicketingProject::when($visibleGroupIds !== null, fn($q) => $q->whereIn('group_id', $visibleGroupIds)) ->orderBy('name') ->get(); $emailConnections = $isBootstrap ? collect() : EmailConnection::when($visibleGroupIds !== null, fn($q) => $q->whereIn('group_id', $visibleGroupIds)) ->get(); return Inertia::render('Ticketing/Settings', [ 'activeTab' => $tab, 'groups' => $groups, 'agents' => $agents, 'priorities' => $priorities, 'projects' => $projects, 'ticketStatuses' => TicketStatus::orderBy('sort_order')->get(), 'emailConnections' => $emailConnections, 'myGroupIds' => $myGroupIds, 'myManagedGroupIds' => $myManagedGroupIds, // #4806 — groups with no manager left who could still use this // page. A site admin may add an agent to these and nothing else, // so the Agents tab can offer the recovery instead of printing // "ask a manager of the group" about a group that has none. // Empty for a non-admin: the recovery is not theirs to make. 'orphanedGroupIds' => $orphanedGroupIds, // #5002 — the access rows this user may actually remove. Superset // of "rows on a group I manage": see requireRemoveAccess(). 'removableAgentIds' => $removableAgentIds, 'isBootstrap' => $isBootstrap, 'isSiteAdmin' => $isSiteAdmin, 'autoCloseDays' => (int) Setting::get('ticketing.auto_close_days', 0), // #938 — the deploy-time repair, and how many rows it would touch. The // count is what turns an abstract toggle into a decision somebody can // actually make, so it is shown beside it whether the toggle is on or off. 'autoRepairMerged' => MergedTicketRepair::enabledOnDeploy(), 'inconsistentMergedCount' => MergedTicketRepair::count(), ]); } 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.'); } /** * Retire a group — hard delete when it is empty, archive when it is not. * * Tickets are the audit record of real correspondence, so a group that owns * any ticket can never be deleted: `tickets.group_id` is a RESTRICT foreign * key, which is the backstop, and this method is the polite front door. It * archives instead, leaving every ticket byte-identical and readable. * * An empty group has nothing to protect, so it really is deleted — which is * what lets an operator undo a typo and lets the e2e CRUD spec clean up the * throwaway group it creates as its setup step (issue #7). * * The caller is told which of the two happened, explicitly: an `outcome` of * `deleted` or `archived` in the JSON body for API callers, and distinct * flash wording for the Inertia UI. Nobody has to guess from a 302. */ public function destroyGroup(Request $request, TicketingGroup $group) { $this->requireManagerAccess($group->id); $ticketCount = $group->tickets()->count(); if ($ticketCount > 0) { // Idempotent: archiving an already-archived group is not an error, // it just re-reports the same outcome. if ($group->archived_at === null) { $group->archived_at = now(); $group->save(); } return $this->groupRetirementResponse($request, 'archived', $group, $ticketCount); } // Empty group: agent access, projects and email connections all cascade // on delete (see their migrations), so nothing is left dangling. $group->delete(); return $this->groupRetirementResponse($request, 'deleted', $group, 0); } /** * Un-archive a group so it can take new tickets again. */ public function restoreGroup(Request $request, TicketingGroup $group) { $this->requireManagerAccess($group->id); if ($group->archived_at !== null) { $group->archived_at = null; $group->save(); } if ($this->wantsJsonBody($request)) { return response()->json([ 'outcome' => 'restored', 'group_id' => $group->id, ]); } return back()->with('success', "Group “{$group->name}” restored."); } /** * One shape for "what happened to that group", for both kinds of caller. */ private function groupRetirementResponse(Request $request, string $outcome, TicketingGroup $group, int $ticketCount) { if ($this->wantsJsonBody($request)) { return response()->json([ 'outcome' => $outcome, 'group_id' => $group->id, 'ticket_count' => $ticketCount, ]); } $message = $outcome === 'deleted' ? "Group “{$group->name}” deleted — it had no tickets." : "Group “{$group->name}” archived — it has {$ticketCount} ticket" . ($ticketCount === 1 ? '' : 's') . ', which stay readable.'; return back()->with('success', $message); } /** * True for an API/script caller that wants a JSON body. * * Inertia sends `Accept: text/html` but also XHR headers, so * expectsJson() alone would hijack the UI's redirect-back flow. */ private function wantsJsonBody(Request $request): bool { if ($request->header('X-Inertia')) { return false; } return $request->expectsJson(); } /** * WHO AM I APPOINTING — the Agents tab's people search ([central-ticketing] * #4926). * * Until this existed the first field of the add-an-agent form was * `` labelled "User ID", with "Find IDs in Admin → * Users" under it. That is a primary key asked of an office administrator, * and on the ONE form the product calls "the only recovery" for a group * with no manager left (#4806): the workflow was to leave the page, search * a name somewhere else, read a number off a URL and type it back in. * * The cost is not only the detour. `storeAgent()` validates * `exists:users,id`, so a NON-existent id is refused cleanly — but 47 typed * for 74 is two real accounts, and the one that gets written is handed * `manager` over a whole help desk queue: every ticket in it, plus the * power to rename, archive and staff it. Nothing on that screen named the * person before the press or after it, so #4781's audit row was the first * place anybody could have found out, after the fact. * * So this is the same search every other Central picker is: `q` over name * and email, answered by `PersonChip::rows()` — the one serializer for a * searchable person (#871) — so the operator's People setting reaches this * picker like the rest, and the page draws `` rather than a * hand-written name row. * * ── THE GATE IS THE PAGE'S, NOT THE WRITE'S ───────────────────────────── * * requireAgentAccess(), the same one index() uses, and deliberately not a * per-group one: the form picks the PERSON before the GROUP, so there is no * group to scope to yet, and narrowing it afterwards would mean a search * that empties when the picker's second field changes. Nothing is widened * by that — the write itself still goes through * requireManagerAccessOrOrphanRecovery(), so a person found here can only * be appointed to a group the caller manages or is rescuing. Reaching this * route at all already means `permission:ticketing.settings`, which is a * narrower population than TicketController::searchUsers() (any agent on * any one ticket's group) already answers the same question for. */ public function searchUsers(Request $request) { $this->requireAgentAccess(); $q = trim((string) $request->input('q', '')); // Two characters, like every other picker on the platform: one letter // is most of the directory, and the dropdown is ten rows. Counted in // CHARACTERS, like admin.settings.users.search, not bytes: an accented // letter is two bytes and a CJK surname is three, so a byte floor lets // a single keystroke answer with the directory on exactly the names a // school has the most trouble typing. if (mb_strlen($q) < 2) { return response()->json([]); } return response()->json(PersonChip::rows( User::where(function ($query) use ($q) { $query->where('name', 'like', "%{$q}%") ->orWhere('email', 'like', "%{$q}%"); }) ->tap(fn ($query) => PersonNames::orderUsers($query)) ->limit(10) ->get(PersonChip::columns()) )); } public function storeAgent(Request $request) { $this->requireAgentAccess(); $validated = $request->validate([ 'user_id' => 'required|integer|exists:users,id', // `integer` before `exists`: an array value passes `exists` element-wise, // so [14] would reach the code below as an array. See TicketController::store(). 'group_id' => 'required|integer|exists:ticketing_groups,id', 'role' => 'required|in:agent,manager', ]); // #4806 — the one way back into a group whose last manager can no // longer act. Asked BEFORE the write, because the write is what ends // the condition: appointing yourself makes isManagerOf() true. $isRecovery = !$this->isManagerOf($validated['group_id']) && $this->orphanedGroupIds([$validated['group_id']]) !== []; $this->requireManagerAccessOrOrphanRecovery($validated['group_id']); TicketingAgentAccess::updateOrCreate( ['user_id' => $validated['user_id'], 'group_id' => $validated['group_id']], [ 'role' => $validated['role'], // WHO RESCUED THIS GROUP — #5002. A recovery write is the one // write here that can lock its own author out: appoint a // `manager` and the group is no longer orphaned, so the arm // that allowed the press is shut by the press. Stamping the // row is what lets destroyAgent() hand it back. // // Set to null on an ordinary manager's write, including a // manager editing a row an earlier rescue wrote: once the // group has somebody of its own touching the row, it is // theirs and the rescuer has no further claim on it. 'recovered_by' => $isRecovery ? Auth::id() : null, ] ); /* * NAME THE PERSON — [central-ticketing] #4926. * * The flash already went to the trouble of naming the GROUP and saying * it had nobody; naming the appointee is the other half of the same * sentence, and it is the half somebody can check. This write hands a * person read access to every ticket in a queue — and, as a manager, * the power to rename, archive and staff it — so "Agent added." left * the only record of WHO in #4781's audit log, which is somewhere you * go after you already suspect something. * * `PersonNames::display()` and not `$user->name`: the confirmation has * to read the way the rest of the platform writes a name (#524), and * the chip the admin picked from in the form is composed the same way, * so the two agree. A user who vanished between the `exists` rule and * this line leaves `null` and the sentence falls back to exactly what * it printed before — a flash is not where a race should surface. */ $person = User::find($validated['user_id']); $who = $person === null ? null : PersonNames::display($person); $asWhat = $validated['role'] === 'manager' ? 'a manager' : 'an agent'; $name = TicketingGroup::find($validated['group_id'])?->name; // Say which group was rescued and that it had nobody: a recovery is a // rare, consequential click and "Agent added." does not describe it. if ($isRecovery) { $added = $who === null ? 'Agent added' : "{$who} added as {$asWhat}"; return back()->with('success', $name === null ? "{$added} — that group had no manager who could still act." : "{$added} to “{$name}”, which had no manager who could still act."); } if ($who === null) { return back()->with('success', 'Agent added.'); } return back()->with('success', $name === null ? "{$who} added as {$asWhat}." : "{$who} added to “{$name}” as {$asWhat}."); } /** * REMOVE IS THE SAME DOOR AS ADD — [central-ticketing] #5002. * * This was `requireManagerAccess()` while `storeAgent()` was * requireManagerAccessOrOrphanRecovery(), so the recovery was ONE-WAY: the * site admin who rescued a group could appoint and never un-appoint. The * row they had just written appeared in their own Agents list marked "Not * yours to manage", with no Remove control, and the DELETE answered 403 — * the same person, the same second, the same row. * * Worse for a `manager` press, which is the one the card about the people * search (#4926) called out as the expensive mistake: the write ends the * orphan state, so the admin could not remove the row, could not appoint * anyone else, and could not appoint themselves. The only person left who * could undo it was the person wrongly appointed. That is #4806's frozen * group again, reached through #4806's own cure. * * So two arms beyond an ordinary manager, both narrower than the write * they undo: */ public function destroyAgent(TicketingAgentAccess $access) { $this->requireRemoveAccess($access); $access->delete(); return back()->with('success', 'Agent removed.'); } /** * May the signed-in user take this agent access row away? * * 1. A manager of the group — the ordinary rule, unchanged. * 2. A site admin, while the group is STILL orphaned. Exactly symmetric * with storeAgent()'s arm and strictly narrower than it: on a group * with no manager left, an admin can already appoint themselves * manager and then remove anything, so this grants nothing new — it * just does not make them take the group over first to clear a * dangling row. * 3. A site admin undoing a row THEY wrote as a rescue (`recovered_by`). * This is the one that reopens the door a mistaken `manager` press * closed, and it is deliberately not "the group would be orphaned * without this row": that test cannot tell a mistaken appointment * from the sole working manager of a healthy group, and admitting the * second would let any site admin strip a manager from a group that * is none of their business, then appoint themselves (#4710). A * rescuer taking back their own appointment claims nothing they did * not already hold at the moment they made it. * * And it lasts only until the appointee USES the access — see * settleRescueStamp(). Otherwise the undo outlives the mistake it is * for: a rescue that WORKED leaves the stamp on the row of the person * now running the desk, and arm 3 becomes a permanent lever for * stripping them and taking the group over (#5008). * * Removing the last manager re-orphans the group, which is the correct * end state: it is recoverable again, by whoever gets there next. */ private function requireRemoveAccess(TicketingAgentAccess $access): void { if ($this->isManagerOf($access->group_id)) { $this->settleRescueStamp($access->group_id); return; } if ($this->isSiteAdmin()) { if ($this->orphanedGroupIds([$access->group_id]) !== []) { return; } if ($access->recovered_by !== null && (int) $access->recovered_by === (int) Auth::id()) { return; } } abort(403); } 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([ // `integer` before `exists`: an array value passes `exists` element-wise, // so [14] would reach the code below as an array. See TicketController::store(). 'group_id' => 'required|integer|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([ // `integer` before `exists`: an array value passes `exists` element-wise, // so [14] would reach the code below as an array. See TicketController::store(). 'group_id' => 'required|integer|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|max:65535', '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|max:65535', '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', 'auto_repair_merged_on_deploy' => 'sometimes|boolean', ]); Setting::set('ticketing.auto_close_days', $validated['auto_close_days']); // #938 option A is a behaviour rule, so it is a setting, and it defaults // off: a deploy must never rewrite rows nobody asked it to rewrite. if (array_key_exists('auto_repair_merged_on_deploy', $validated)) { Setting::set( MergedTicketRepair::SETTING, $validated['auto_repair_merged_on_deploy'] ? '1' : '0', ); } return back()->with('success', 'Automation settings saved.'); } }