The Unassigned queue stops offering merged tickets as work (#1054)

The count and the list were the same question spelled two ways in one
method: the sidebar counts scope by $closedSlugs, read from
ticketing_statuses, while the `unassigned` and `mine` list branches
carried a hand-written ["resolved", "closed"] that predates the `merged`
status. So a correctly merged ticket — status merged, merged_into_id set,
its own Show page already calling it read-only — stayed in the queue
agents pull new work from, and the page contradicted itself on screen:
"Unassigned 4" beside five rendered rows.

Both queues now scope by $closedSlugs, so they exclude exactly what their
own counts exclude. The scoping is by STATUS and not by merged_into_id on
purpose: #938 rows (pointer set, status still open) are live, editable
work until a human repairs them, and dropping them out of the queue would
hide the rows that most need one. There is a NEGCTRL test for that.

Also, the secondary finding: ?filter=closed was accepted, silently
returned the All-Open set and was echoed back in props.filters as though
a Closed view existed. The sidebar has six views and that is not one of
them, so an unrecognised filter normalises to `all` and says so. The list
is now a constant that savePrefs() validates against too, rather than the
same six slugs written out twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
central-worker-3
2026-09-04 15:06:02 -07:00
parent 420ef78603
commit 5eb2ec7bcf

View File

@@ -24,6 +24,13 @@ use Inertia\Response;
class TicketController extends Controller
{
/**
* The sidebar's views, and the only values `?filter=` means anything as.
* Index.vue's `visibleViews` draws exactly these; `savePrefs()` validates
* against them; `index()` normalises anything else to `all` (#1054).
*/
private const VIEW_FILTERS = ['all', 'mine', 'unassigned', 'pending', 'resolved', 'merged'];
private function agentGroupIds(): array
{
return TicketingAgentAccess::where('user_id', Auth::id())
@@ -112,6 +119,18 @@ class TicketController extends Controller
$request->merge(['filter' => $pref['filter']]);
}
// #1054 — a `filter` the sidebar has no view for used to fall through to
// the All Open branch and then be echoed straight back in `props.filters`,
// so `?filter=closed` looked like a real view that just happened to be
// showing open tickets. There is no Closed view anywhere in the UI;
// anything unrecognised is All Open, and now says so. The list is the
// one `savePrefs()` validates against and the one Index.vue draws.
// Only rewrites a filter that was actually asked for: an absent one keeps
// meaning "no view chosen", which is what leaves `filter` out of the URL.
if ($request->filled('filter') && ! in_array($request->filter, self::VIEW_FILTERS, true)) {
$request->merge(['filter' => 'all']);
}
// If no groups exist at all, render a first-run / bootstrap state
$totalGroups = TicketingGroup::count();
if ($totalGroups === 0) {
@@ -181,10 +200,20 @@ class TicketController extends Controller
if ($request->filled('project_id')) {
$query->where('project_id', $request->project_id);
}
// #1054 — the work queues scope by `$closedSlugs`, the same list the
// counts above use. They used to spell it `['resolved', 'closed']`
// by hand, which predates the `merged` status: a correctly merged
// ticket stayed in Unassigned as pickable work while the sidebar
// count next to it — already reading `$closedSlugs` — excluded it,
// so the view disagreed with its own header (4 vs 5). Scoping by
// STATUS and not by `merged_into_id` is deliberate: #938's
// inconsistent rows (pointer set, status still open) are live work
// until somebody repairs them, and hiding them from the queue would
// hide the rows that most need a human.
if ($request->filter === 'mine') {
$query->where('assigned_to', $user->id)->whereNotIn('status', ['resolved', 'closed']);
$query->where('assigned_to', $user->id)->whereNotIn('status', $closedSlugs);
} elseif ($request->filter === 'unassigned') {
$query->whereNull('assigned_to')->whereNotIn('status', ['resolved', 'closed']);
$query->whereNull('assigned_to')->whereNotIn('status', $closedSlugs);
} elseif ($request->filter === 'pending') {
$query->where('status', 'pending');
} elseif ($request->filter === 'resolved') {
@@ -630,7 +659,7 @@ class TicketController extends Controller
{
$validated = $request->validate([
'view_mode' => 'sometimes|in:list,kanban',
'filter' => 'sometimes|in:all,mine,unassigned,pending,resolved,merged',
'filter' => 'sometimes|in:'.implode(',', self::VIEW_FILTERS),
]);
if (empty($validated)) {