#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:
@@ -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
|
||||
* `<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)
|
||||
{
|
||||
$this->requireAgentAccess();
|
||||
@@ -488,19 +551,48 @@ 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.");
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
$this->requireManagerAccess($access->group_id);
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user