#4926: appointing a help desk agent is a people search, and the receipt names them

The Agents tab's add-an-agent form opened with

    <label class="form-label">User ID</label>
    <input v-model="agentForm.user_id" required type="number"
           placeholder="User ID" class="form-input" />
    <p class="form-hint">Find IDs in Admin → Users</p>

— a primary key asked of an office administrator, on the one screen #4806
documents as the only recovery for a help desk group whose last manager can no
longer act. `exists:users,id` refuses an id nobody holds, so the failure that
mattered was never a typo that errors: 47 typed for 74 is two real accounts,
and the one written is handed `agent` — or `manager` — over a whole queue, with
the person named nowhere before the press or after it. #4781's audit row was
therefore the first place anybody could find out.

Two writes here, both small:

  * `searchUsers()` — `q` over name and email, answered by `PersonChip::rows()`,
    the platform's one serializer for a searchable person (#871), so the
    operator's People setting reaches this picker like every other one. The
    gate is the page's own requireAgentAccess() and not a per-group one: the
    form picks the PERSON before the GROUP, and the write still goes through
    requireManagerAccessOrOrphanRecovery() unchanged.

  * `storeAgent()`'s flash names the appointee, through `PersonNames::display()`
    so it reads the way the chip above it does (#524). The recovery sentence
    keeps every word it had and gains the other half of itself.

The shell half — the picker's markup, its proofs and the chip-surface
registration — is on dashboard-shell card/4926-….

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
central-worker-3
2026-10-20 07:07:46 -07:00
parent 10ef42d78c
commit a977aedc95
2 changed files with 102 additions and 4 deletions

View File

@@ -4,6 +4,8 @@ namespace Dashboard\Ticketing\Http\Controllers;
use App\Models\Setting; use App\Models\Setting;
use App\Models\User; use App\Models\User;
use App\Support\PersonChip;
use App\Support\PersonNames;
use Dashboard\Ticketing\Models\EmailConnection; use Dashboard\Ticketing\Models\EmailConnection;
use Dashboard\Ticketing\Models\PriorityLevel; use Dashboard\Ticketing\Models\PriorityLevel;
use Dashboard\Ticketing\Models\TicketingAgentAccess; use Dashboard\Ticketing\Models\TicketingAgentAccess;
@@ -464,6 +466,67 @@ class TicketingSettingsController extends Controller
return $request->expectsJson(); 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.
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) public function storeAgent(Request $request)
{ {
$this->requireAgentAccess(); $this->requireAgentAccess();
@@ -488,17 +551,46 @@ class TicketingSettingsController extends Controller
['role' => $validated['role']] ['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 // Say which group was rescued and that it had nobody: a recovery is a
// rare, consequential click and "Agent added." does not describe it. // rare, consequential click and "Agent added." does not describe it.
if ($isRecovery) { if ($isRecovery) {
$name = TicketingGroup::find($validated['group_id'])?->name; $added = $who === null ? 'Agent added' : "{$who} added as {$asWhat}";
return back()->with('success', $name === null return back()->with('success', $name === null
? 'Agent added — that group had no manager who could still act.' ? "{$added} — that group had no manager who could still act."
: "Agent added to “{$name}”, which 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) public function destroyAgent(TicketingAgentAccess $access)

View File

@@ -47,6 +47,12 @@ Route::middleware(['web', 'auth', 'app.access:ticketing'])
Route::put('/settings/groups/{group}', [TicketingSettingsController::class, 'updateGroup']) ->name('settings.groups.update'); 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::delete('/settings/groups/{group}', [TicketingSettingsController::class, 'destroyGroup']) ->name('settings.groups.destroy');
Route::post('/settings/groups/{group}/restore', [TicketingSettingsController::class, 'restoreGroup']) ->name('settings.groups.restore'); 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::post('/settings/agents', [TicketingSettingsController::class, 'storeAgent']) ->name('settings.agents.store');
Route::delete('/settings/agents/{access}', [TicketingSettingsController::class, 'destroyAgent']) ->name('settings.agents.destroy'); Route::delete('/settings/agents/{access}', [TicketingSettingsController::class, 'destroyAgent']) ->name('settings.agents.destroy');
Route::post('/settings/priorities', [TicketingSettingsController::class, 'storePriority']) ->name('settings.priorities.store'); Route::post('/settings/priorities', [TicketingSettingsController::class, 'storePriority']) ->name('settings.priorities.store');