diff --git a/composer.json b/composer.json index fa3ad8d..e648d50 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "dashboard/ticketing", "description": "Help desk ticketing snap-in for the Dashboard platform", - "version": "1.0.3", + "version": "1.0.4", "type": "library", "require": { "php": "^8.2", diff --git a/database/migrations/2026_07_26_000001_add_archived_at_to_ticketing_groups.php b/database/migrations/2026_07_26_000001_add_archived_at_to_ticketing_groups.php new file mode 100644 index 0000000..259becd --- /dev/null +++ b/database/migrations/2026_07_26_000001_add_archived_at_to_ticketing_groups.php @@ -0,0 +1,38 @@ +timestamp('archived_at')->nullable()->after('prefix'); + }); + } + + public function down(): void + { + // Deliberate NO-OP, matching the warnings-column precedent in + // dashboard-myedpuller-sync: DashboardDoctor greps the WHOLE migration file + // for drop/rename patterns, so a dropColumn even in down() raises the + // "pending destructive migration" advisory on every host where this is + // still pending — a confusing warning for a purely additive change. A + // nullable column costs nothing to leave behind. + } +}; diff --git a/src/Http/Controllers/TicketController.php b/src/Http/Controllers/TicketController.php index 58518be..c0943d8 100644 --- a/src/Http/Controllers/TicketController.php +++ b/src/Http/Controllers/TicketController.php @@ -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]), ]; diff --git a/src/Http/Controllers/TicketingSettingsController.php b/src/Http/Controllers/TicketingSettingsController.php index 213e1ae..5c93b2d 100644 --- a/src/Http/Controllers/TicketingSettingsController.php +++ b/src/Http/Controllers/TicketingSettingsController.php @@ -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(); diff --git a/src/Models/TicketingGroup.php b/src/Models/TicketingGroup.php index b42778d..f939556 100644 --- a/src/Models/TicketingGroup.php +++ b/src/Models/TicketingGroup.php @@ -2,6 +2,7 @@ namespace Dashboard\Ticketing\Models; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -9,6 +10,36 @@ class TicketingGroup extends Model { protected $fillable = ['name', 'email_address', 'color', 'prefix']; + protected $casts = [ + 'archived_at' => 'datetime', + ]; + + protected $appends = ['is_archived']; + + /** + * Archived groups are deliberately NOT hidden by a global scope. + * + * The tickets in an archived group are still live correspondence: their + * agents must keep working them, `$ticket->group` must still resolve, and + * Settings must still list the group so a manager can restore it. Only the + * *pickers* — "which group should this new ticket go to", "filter by + * group" — exclude archived ones, via this scope, one call site at a time. + */ + public function scopeActive(Builder $query): Builder + { + return $query->whereNull('archived_at'); + } + + public function scopeArchived(Builder $query): Builder + { + return $query->whereNotNull('archived_at'); + } + + public function getIsArchivedAttribute(): bool + { + return $this->archived_at !== null; + } + public function tickets(): HasMany { return $this->hasMany(Ticket::class, 'group_id'); diff --git a/src/routes/ticketing.php b/src/routes/ticketing.php index 15ccd91..65ca592 100644 --- a/src/routes/ticketing.php +++ b/src/routes/ticketing.php @@ -33,6 +33,8 @@ Route::middleware(['web', 'auth', 'app.access:ticketing']) ->where('tab', 'groups|agents|priorities|statuses|projects|automation')->name('settings.tab'); Route::post('/settings/groups', [TicketingSettingsController::class, 'storeGroup']) ->name('settings.groups.store'); Route::put('/settings/groups/{group}', [TicketingSettingsController::class, 'updateGroup']) ->name('settings.groups.update'); + Route::delete('/settings/groups/{group}', [TicketingSettingsController::class, 'destroyGroup']) ->name('settings.groups.destroy'); + Route::post('/settings/groups/{group}/restore', [TicketingSettingsController::class, 'restoreGroup']) ->name('settings.groups.restore'); Route::post('/settings/agents', [TicketingSettingsController::class, 'storeAgent']) ->name('settings.agents.store'); Route::delete('/settings/agents/{access}', [TicketingSettingsController::class, 'destroyAgent']) ->name('settings.agents.destroy'); Route::post('/settings/priorities', [TicketingSettingsController::class, 'storePriority']) ->name('settings.priorities.store');