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

@@ -72,9 +72,18 @@ class TicketingSettingsController extends Controller
$isBootstrap = $this->isBootstrapState();
$myGroupIds = TicketingAgentAccess::where('user_id', $userId)->pluck('group_id');
// Archived groups stay in this list — this is the one place they must
// remain visible, so a manager can see what happened and restore it.
// Actives first, then archived, each alphabetical.
$groups = $isBootstrap
? collect()
: TicketingGroup::whereIn('id', $myGroupIds)->get();
: TicketingGroup::whereIn('id', $myGroupIds)
// tickets_count decides archive-vs-delete, so the UI can warn
// the operator which one their click is about to do.
->withCount('tickets')
->orderByRaw('archived_at IS NOT NULL')
->orderBy('name')
->get();
$agents = $isBootstrap
? collect()
@@ -180,6 +189,104 @@ class TicketingSettingsController extends Controller
return back()->with('success', 'Group updated.');
}
/**
* Retire a group — hard delete when it is empty, archive when it is not.
*
* Tickets are the audit record of real correspondence, so a group that owns
* any ticket can never be deleted: `tickets.group_id` is a RESTRICT foreign
* key, which is the backstop, and this method is the polite front door. It
* archives instead, leaving every ticket byte-identical and readable.
*
* An empty group has nothing to protect, so it really is deleted — which is
* what lets an operator undo a typo and lets the e2e CRUD spec clean up the
* throwaway group it creates as its setup step (issue #7).
*
* The caller is told which of the two happened, explicitly: an `outcome` of
* `deleted` or `archived` in the JSON body for API callers, and distinct
* flash wording for the Inertia UI. Nobody has to guess from a 302.
*/
public function destroyGroup(Request $request, TicketingGroup $group)
{
$this->requireManagerAccess($group->id);
$ticketCount = $group->tickets()->count();
if ($ticketCount > 0) {
// Idempotent: archiving an already-archived group is not an error,
// it just re-reports the same outcome.
if ($group->archived_at === null) {
$group->archived_at = now();
$group->save();
}
return $this->groupRetirementResponse($request, 'archived', $group, $ticketCount);
}
// Empty group: agent access, projects and email connections all cascade
// on delete (see their migrations), so nothing is left dangling.
$group->delete();
return $this->groupRetirementResponse($request, 'deleted', $group, 0);
}
/**
* Un-archive a group so it can take new tickets again.
*/
public function restoreGroup(Request $request, TicketingGroup $group)
{
$this->requireManagerAccess($group->id);
if ($group->archived_at !== null) {
$group->archived_at = null;
$group->save();
}
if ($this->wantsJsonBody($request)) {
return response()->json([
'outcome' => 'restored',
'group_id' => $group->id,
]);
}
return back()->with('success', "Group “{$group->name}” restored.");
}
/**
* One shape for "what happened to that group", for both kinds of caller.
*/
private function groupRetirementResponse(Request $request, string $outcome, TicketingGroup $group, int $ticketCount)
{
if ($this->wantsJsonBody($request)) {
return response()->json([
'outcome' => $outcome,
'group_id' => $group->id,
'ticket_count' => $ticketCount,
]);
}
$message = $outcome === 'deleted'
? "Group “{$group->name}” deleted — it had no tickets."
: "Group “{$group->name}” archived — it has {$ticketCount} ticket"
. ($ticketCount === 1 ? '' : 's') . ', which stay readable.';
return back()->with('success', $message);
}
/**
* True for an API/script caller that wants a JSON body.
*
* Inertia sends `Accept: text/html` but also XHR headers, so
* expectsJson() alone would hijack the UI's redirect-back flow.
*/
private function wantsJsonBody(Request $request): bool
{
if ($request->header('X-Inertia')) {
return false;
}
return $request->expectsJson();
}
public function storeAgent(Request $request)
{
$this->requireAgentAccess();