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