diff --git a/src/Http/Controllers/TicketingSettingsController.php b/src/Http/Controllers/TicketingSettingsController.php index 6369b70..d85418e 100644 --- a/src/Http/Controllers/TicketingSettingsController.php +++ b/src/Http/Controllers/TicketingSettingsController.php @@ -4,6 +4,8 @@ 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; @@ -464,6 +466,67 @@ class TicketingSettingsController extends Controller 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. + if (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(); @@ -488,17 +551,46 @@ class TicketingSettingsController extends Controller ['role' => $validated['role']] ); + /* + * 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) { - $name = TicketingGroup::find($validated['group_id'])?->name; + $added = $who === null ? 'Agent added' : "{$who} added as {$asWhat}"; return back()->with('success', $name === null - ? 'Agent added — that group had no manager who could still act.' - : "Agent added to “{$name}”, which had no manager who could still act."); + ? "{$added} — that group had no manager who could still act." + : "{$added} to “{$name}”, which had no manager who could still act."); } - return back()->with('success', 'Agent added.'); + 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}."); } public function destroyAgent(TicketingAgentAccess $access) diff --git a/src/routes/ticketing.php b/src/routes/ticketing.php index dad7af1..f19d798 100644 --- a/src/routes/ticketing.php +++ b/src/routes/ticketing.php @@ -47,6 +47,12 @@ Route::middleware(['web', 'auth', 'app.access:ticketing']) Route::put('/settings/groups/{group}', [TicketingSettingsController::class, 'updateGroup']) ->name('settings.groups.update'); Route::delete('/settings/groups/{group}', [TicketingSettingsController::class, 'destroyGroup']) ->name('settings.groups.destroy'); Route::post('/settings/groups/{group}/restore', [TicketingSettingsController::class, 'restoreGroup']) ->name('settings.groups.restore'); + // #4926 — the add-an-agent form's people search. Before it the form's + // first field was a raw numeric user id with "Find IDs in Admin → Users" + // under it, on the one screen that is the only recovery for a help desk + // group with no manager left (#4806). Two segments, so it cannot collide + // with the single-segment `/settings/{tab}` above. + Route::get('/settings/agents/search', [TicketingSettingsController::class, 'searchUsers']) ->name('settings.agents.search'); Route::post('/settings/agents', [TicketingSettingsController::class, 'storeAgent']) ->name('settings.agents.store'); Route::delete('/settings/agents/{access}', [TicketingSettingsController::class, 'destroyAgent']) ->name('settings.agents.destroy'); Route::post('/settings/priorities', [TicketingSettingsController::class, 'storePriority']) ->name('settings.priorities.store');