diff --git a/src/Http/Controllers/TicketController.php b/src/Http/Controllers/TicketController.php index 136b169..aca81de 100644 --- a/src/Http/Controllers/TicketController.php +++ b/src/Http/Controllers/TicketController.php @@ -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