Ticketing groups can be retired (e2e dashboard-ticketing#7)

There was no destroy route at all — only settings.groups.store and .update — so
the e2e CRUD spec, which creates an ephemeral group as its documented setup step,
could never clean up (a DELETE-spoofed POST returned 405). Every sweep left litter
behind, and an operator who mistyped a group was stuck with it forever.

Deleting a group cannot be unconditional: tickets are the audit record of real
correspondence, and tickets.group_id is a RESTRICT FK. So destroyGroup counts the
tickets and picks:

  0 tickets  -> real delete (agent access / projects / email connections are
                cascadeOnDelete, so nothing dangles). This is the teardown and
                undo-a-typo path.
  >=1 ticket -> archived_at = now(). Tickets untouched and still readable.

The caller is TOLD which happened rather than inferring it from a 302: an
expectsJson caller gets {"outcome":"deleted"|"archived","ticket_count":N}, the UI
gets distinct flash wording, and the confirm modal says which it will be BEFORE
you click ("This group has 1 ticket and will be archived, not deleted").
Archiving is idempotent; restore is a separate route.

Archived groups leave the PICKERS but nothing else: index() and create() use
scopeActive, store() validation adds whereNull('archived_at') so a stale id cannot
bypass the picker, and update() rejects transfers INTO an archived group while
always allowing the ticket's own group — otherwise a ticket stranded in an
archived group would become uneditable. Index.vue re-adds that own group to the
transfer select labelled "(archived)" so the select is never blank. Deliberately
NOT filtered: the Settings groups list (archived rows show a badge + Restore), and
agentGroupIds()/isAgent()/isManager(), which read ticketing_agent_access — so
existing agents keep working the tickets that remain. No global scope; exclusion
is per call site.

Gating is byte-identical to updateGroup: inside the existing
permission:ticketing.settings group plus requireManagerAccess(), so a super admin
with no manager row on that group is still refused (asserted).

Migration is additive — one nullable timestamp. down() is a deliberate NO-OP,
matching the myedpuller-sync warnings-column precedent: DashboardDoctor greps the
whole file for drop/rename patterns, so a dropColumn even in down() would raise a
"pending destructive migration" advisory on every host where it is still pending,
which is a confusing warning for a purely additive change. Verified against
doctor's exact regex, not a looser grep.

Tests: 9 (69 assertions). 10 mutations, all 10 detected — including forcing a hard
delete on a group with tickets, which throws a QueryException from the RESTRICT FK,
i.e. the database backs up the application check.

Browser-verified on dev: the confirm modal wording for both cases, the ARCHIVED
badge and Restore, the archived group vanishing from both the create picker and the
All-Tickets switcher while its ticket stays listed, and ZERO native
confirm/alert dialogs. doctor exit 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 19:35:04 -04:00
parent 9ce5b45b79
commit 1175c20a57
6 changed files with 209 additions and 6 deletions

View File

@@ -17,6 +17,7 @@ use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
@@ -172,7 +173,14 @@ class TicketController extends Controller
return $ticket;
});
$groups = TicketingGroup::when($isAgent, fn ($q) => $q->whereIn('id', $agentGroupIds))->get();
// Picker data: the group switcher/filter and the detail pane's transfer
// select both read this, so archived groups are excluded. The tickets
// themselves are NOT filtered — an archived group's tickets stay in the
// list and stay workable; Index.vue re-adds the current group to the
// transfer options when it happens to be archived.
$groups = TicketingGroup::active()
->when($isAgent, fn ($q) => $q->whereIn('id', $agentGroupIds))
->get();
$priorities = PriorityLevel::orderBy('sort_order')->get();
$projects = TicketingProject::when($isAgent, fn ($q) => $q->whereIn('group_id', $agentGroupIds))
->where('status', 'active')
@@ -269,7 +277,10 @@ class TicketController extends Controller
$agentGroupIds = $this->agentGroupIds();
$isAgent = count($agentGroupIds) > 0;
$groups = TicketingGroup::when($isAgent, fn($q) => $q->whereIn('id', $agentGroupIds))->get();
// New tickets can only be filed against a live group.
$groups = TicketingGroup::active()
->when($isAgent, fn($q) => $q->whereIn('id', $agentGroupIds))
->get();
// No groups at all? Show first-run state.
if ($groups->isEmpty() && TicketingGroup::count() === 0) {
@@ -296,7 +307,9 @@ class TicketController extends Controller
$validated = $request->validate([
'title' => 'required|string|max:255',
'description' => 'required|string',
'group_id' => 'required|exists:ticketing_groups,id',
// Archived groups are gone from the picker; they must also be
// unreachable by posting a stale id.
'group_id' => ['required', Rule::exists('ticketing_groups', 'id')->whereNull('archived_at')],
'priority_id' => 'nullable|exists:ticketing_priority_levels,id',
'due_date' => 'nullable|date',
'attachments' => 'nullable|array|max:5',
@@ -442,7 +455,19 @@ class TicketController extends Controller
'priority_id' => 'nullable|exists:ticketing_priority_levels,id',
'due_date' => 'nullable|date',
'project_id' => 'nullable|exists:ticketing_projects,id',
'group_id' => 'nullable|exists:ticketing_groups,id',
// A ticket may not be transferred INTO an archived group, but a
// ticket already sitting in one must still be editable — so its own
// group id always validates.
'group_id' => ['nullable', 'exists:ticketing_groups,id',
function ($attribute, $value, $fail) use ($ticket) {
if (! $value || (int) $value === (int) $ticket->group_id) {
return;
}
if (TicketingGroup::whereKey($value)->archived()->exists()) {
$fail('That group is archived — choose an active group.');
}
},
],
'assigned_to' => 'nullable|in:' . implode(',', $validAssigneeIds ?: [0]),
];