Files
central-ticketing/src/Http/Controllers/TicketingSettingsController.php
central-worker-6 e4d0622361 #6081: statuses store 422s on oversized sort_order instead of 500
sort_order was validated as integer|min:0 with no upper bound, but the
column is an unsigned smallint (0..65535). A client-supplied value
above the ceiling passed validation and overflowed the column on
INSERT/UPDATE, surfacing as an unhandled 500. Added max:65535 to both
storeStatus and updateStatus, which take the same client field through
the same rule.
2026-11-03 06:52:19 -07:00

1030 lines
42 KiB
PHP

<?php
namespace Dashboard\Ticketing\Http\Controllers;
use App\Models\Setting;
use App\Models\User;
use App\Support\PersonChip;
use App\Support\PersonNames;
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 Dashboard\Ticketing\Support\MergedTicketRepair;
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
{
return $this->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
* `<input type="number">` 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 `<PersonChip>` 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.');
}
}