The participants picker searches, and answers, in chips (#1598)
The operator, 11:46 PDT 2026-09-10, having just had the device picker
converted: "are there other places we can search users where we're not
using the chips? please go through and check." The lead's audit found ten
person searches on the platform — four drawing chips, six not — and this
is central-ticketing's.
`ticketing.users.search` answered `DB::table('users')->get(['id', 'name',
'email'])`, and `show()` handed the Participants card the same three
columns for the submitter and for everybody already on the ticket. So the
one card on this page that is entirely about WHO SOMEBODY IS drew three
text rows in three different shapes: a name over an address in the
dropdown, a name-or-address string per participant, and a third spelling
for the submitter. An agent adding a participant to a ticket about a
child chooses between the child and the parent of the same surname with
no role dot to tell them apart.
And it was worse than untidy. Name and email were the SHIPPED DEFAULT for
a chip, so a school that configured the student chip to grade + homeroom
kept being shown an address here — on the one people surface the
operator's own setting could not reach. That is the defect #871 exists to
remove, and this endpoint was still carrying it.
Both halves now go through `PersonChip::rows()`, so what a row SAYS is
the setting rather than a select list in this file, and the person PICKED
is byte-for-byte the person picked FROM — one serializer, one answer. The
search's payload is now the same, key for key, as
`admin.settings.users.search`; the test compares the two endpoints against
each other rather than against a literal list, because a literal passes on
the day it is written and then lets this surface fall behind the next key
the shared row grows.
`PersonChip::columns()` is the select list, not `['id', 'name', 'email']`:
a chip line may name any user column, and a constrained select hands the
helper a model whose attribute is simply ABSENT — which it cannot tell
from a person who has no value, so the line goes blank for everybody with
nothing failing.
`rows()` and NOT the model, in `show()`. That map also feeds the message
bylines and the assignee, so handing the page a `User` would put every
column of `users` — `preferred_pin`, `preferred_password`,
`date_of_birth` — in front of any agent who opens a ticket. `rows()` is
`{id, name, email, person}` and `person` is the curated allow-list. There
is a test that a PIN cannot reach the page.
WHO IS SEARCHABLE DOES NOT CHANGE, per the card. Same two-character
floor, same name-or-email `like`, same limit of 10, same exclusion of the
people already on the ticket, same agent gate; `User` carries no global
scope or soft delete `DB::table('users')` did not. The name-or-email
clause stays inside its own closure on purpose — flattened beside the
exclusion it is `(name) OR (email AND NOT IN)` by SQL's own precedence,
so a name match jumps the exclusion, which is the hole the same conversion
opened on [central-inventory] #1594. `orderBy('name')` is the one visible
difference and it is new: the old query had no order at all, so the same
two letters could list the same ten people differently twice running.
The Vue half, the source guard and the feature tests are on
dashboard-shell's branch of the same name.
This commit is contained in:
@@ -3,6 +3,8 @@
|
||||
namespace Dashboard\Ticketing\Http\Controllers;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use App\Support\PersonChip;
|
||||
use Dashboard\Ticketing\Models\PriorityLevel;
|
||||
use Dashboard\Ticketing\Models\Ticket;
|
||||
use Dashboard\Ticketing\Models\TicketAttachment;
|
||||
@@ -468,7 +470,39 @@ class TicketController extends Controller
|
||||
->push($ticket->submitter_id)
|
||||
->push($ticket->assigned_to)
|
||||
->filter()->unique();
|
||||
$users = DB::table('users')->whereIn('id', $userIds)->get(['id', 'name', 'email'])->keyBy('id');
|
||||
/*
|
||||
* EVERY PERSON ON THIS PAGE, IN THE PLATFORM'S ROW SHAPE (#1598).
|
||||
*
|
||||
* This was `DB::table('users')->get(['id', 'name', 'email'])`, and the
|
||||
* Participants card it feeds drew each of those as
|
||||
* `{{ p.user?.name || p.user?.email }}` — a bare text row where the rest
|
||||
* of the platform draws a chip. `PersonChip::rows()` is the shared
|
||||
* serializer, so what those rows SAY is the operator's chip setting
|
||||
* rather than this method's select list, and it is the same payload the
|
||||
* participant SEARCH answers with: the person picked and the row they
|
||||
* were picked from cannot disagree about what they are called.
|
||||
*
|
||||
* `rows()` AND NOT THE MODEL. This map also feeds the message bylines
|
||||
* and the assignee, so a `User` handed straight to Inertia would put
|
||||
* every column of `users` — `preferred_pin`, `preferred_password`,
|
||||
* `date_of_birth` — on the page for anybody who has ever replied to a
|
||||
* ticket. `rows()` is exactly `{id, name, email, person}`, and `person`
|
||||
* is the curated allow-list `PersonChip::FIELDS` describes.
|
||||
*
|
||||
* ONE BULK PASS for the whole page: `forMany()` inside `rows()` reads
|
||||
* the alias catalogue and the school year once for the collection, and a
|
||||
* chip built per row would repeat that for every participant, every
|
||||
* message author and the assignee on a fifty-reply thread.
|
||||
*
|
||||
* `PersonChip::columns()` is the select list because a chip line may
|
||||
* name ANY user column, and a constrained select hands the helper a
|
||||
* model whose attribute is simply absent — which it cannot tell from a
|
||||
* person who has no value, so the line goes blank for everybody with
|
||||
* nothing failing.
|
||||
*/
|
||||
$users = collect(PersonChip::rows(
|
||||
User::whereIn('id', $userIds)->get(PersonChip::columns())
|
||||
))->keyBy('id');
|
||||
|
||||
$visibleMessages->each(function ($msg) use ($users) {
|
||||
$msg->author = $msg->user_id ? ($users[$msg->user_id] ?? null) : null;
|
||||
@@ -1056,6 +1090,54 @@ class TicketController extends Controller
|
||||
* Search users by name/email — for the participant picker.
|
||||
* Returns up to 10 matches, excluding existing participants and the submitter.
|
||||
*/
|
||||
/**
|
||||
* WHO TO ADD AS A PARTICIPANT — and it answers in CHIPS (#1598).
|
||||
*
|
||||
* The operator, 11:46 PDT 2026-09-10, having just had the device picker
|
||||
* converted: *"are there other places we can search users where we're not
|
||||
* using the chips? please go through and check."* This endpoint was one of
|
||||
* the six the lead's audit found that were not, and it is central-ticketing's.
|
||||
*
|
||||
* It returned `DB::table('users')->get(['id', 'name', 'email'])`, so the
|
||||
* picker drew a name and an address per row and nothing else: no role dot to
|
||||
* tell an agent that "D. Ellis" is the child rather than the parent of the
|
||||
* same surname, and — because those two fields were once the WHOLE of a chip
|
||||
* — a school that configures the student chip to grade + homeroom would have
|
||||
* been left with this one picker still showing an address.
|
||||
*
|
||||
* `PersonChip::rows()` is the shared serializer and this is now the same
|
||||
* answer, key for key, as `admin.settings.users.search` — which is the point
|
||||
* rather than a coincidence: `TicketParticipantsAreChipsTest` compares the
|
||||
* two payloads against each other instead of against a literal list, so a
|
||||
* key added to the platform's row cannot skip this surface.
|
||||
*
|
||||
* WHO IS SEARCHABLE DOES NOT CHANGE. Same `like` on name-or-email, same
|
||||
* two-character floor, same limit of 10, same exclusion of the people
|
||||
* already on the ticket, and `User` carries no global scope or soft delete
|
||||
* that `DB::table('users')` did not — measured against the model on
|
||||
* 2026-09-10. The card was explicit that this one is about the DRAWING.
|
||||
*
|
||||
* Two things about the query are not cosmetic:
|
||||
*
|
||||
* - `PersonChip::columns()` and not `['id', 'name', 'email']`. A chip line
|
||||
* may name any user column, and a constrained select hands the helper a
|
||||
* model whose attribute is simply ABSENT — indistinguishable from a
|
||||
* person who has no value, so the line is blank for everybody with
|
||||
* nothing failing. `PersonChip::columns()`' own docblock has the trap;
|
||||
* `PersonChipSurfacesTest::test_a_chip_is_never_built_from_a_hand_written_select`
|
||||
* is the shell's guard against it.
|
||||
* - The name-or-email `like` stays inside its own closure. Flattened beside
|
||||
* the exclusion it would be an `OR` at the top level and an address match
|
||||
* would smuggle a person already on the ticket back into the list — the
|
||||
* operator-precedence hole the same shape opened on [central-inventory]
|
||||
* #1594.
|
||||
*
|
||||
* `orderBy('name')` is new and is the only visible difference: the old query
|
||||
* had no order at all, so ten rows arrived in whatever order the storage
|
||||
* engine happened to return them and the same two letters could list the
|
||||
* same people differently twice running. The reference search orders by
|
||||
* name, and "a consistent shape" is what this card is for.
|
||||
*/
|
||||
public function searchUsers(Request $request, Ticket $ticket)
|
||||
{
|
||||
if (! $this->isAgent($ticket->group_id)) {
|
||||
@@ -1073,16 +1155,16 @@ class TicketController extends Controller
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
$users = DB::table('users')
|
||||
->where(function ($query) use ($q) {
|
||||
return response()->json(PersonChip::rows(
|
||||
User::where(function ($query) use ($q) {
|
||||
$query->where('name', 'like', "%{$q}%")
|
||||
->orWhere('email', 'like', "%{$q}%");
|
||||
})
|
||||
->whereNotIn('id', $excludeIds)
|
||||
->limit(10)
|
||||
->get(['id', 'name', 'email']);
|
||||
|
||||
return response()->json($users);
|
||||
->whereNotIn('id', $excludeIds)
|
||||
->orderBy('name')
|
||||
->limit(10)
|
||||
->get(PersonChip::columns())
|
||||
));
|
||||
}
|
||||
|
||||
public function myTickets(): Response
|
||||
|
||||
Reference in New Issue
Block a user