From 5064365af206220ea4ea2a8ca6d9aab573bda586 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Thu, 18 Jun 2026 17:15:56 +0100 Subject: [PATCH] intial ticket fix partial delete fix --- Modules/Helpdesk/Livewire/SettingsManager.php | 70 +++-- Modules/Helpdesk/Models/Category.php | 7 + Modules/Helpdesk/Models/Project.php | 6 + Modules/Helpdesk/Models/Team.php | 7 + .../Tests/Feature/SettingsCrudTest.php | 251 ++++++++++++++++++ Modules/Helpdesk/changelog.yaml | 7 + Modules/Helpdesk/module.json | 2 +- 7 files changed, 325 insertions(+), 25 deletions(-) create mode 100644 Modules/Helpdesk/Tests/Feature/SettingsCrudTest.php diff --git a/Modules/Helpdesk/Livewire/SettingsManager.php b/Modules/Helpdesk/Livewire/SettingsManager.php index 841f47e2..fc60fc64 100644 --- a/Modules/Helpdesk/Livewire/SettingsManager.php +++ b/Modules/Helpdesk/Livewire/SettingsManager.php @@ -9,6 +9,7 @@ use Illuminate\Contracts\View\View; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; +use Illuminate\Validation\ValidationException; use Livewire\Component; use Modules\Helpdesk\Models\AutomationRule; use Modules\Helpdesk\Models\CannedResponse; @@ -134,7 +135,7 @@ final class SettingsManager extends Component $data = $this->guardDefaultColumn('helpdesk_statuses', $data); if ((bool) ($data['is_default'] ?? false)) { - Status::query()->whereKeyNot($this->statusForm['id'])->update(['is_default' => false]); + $this->clearOtherDefaults(Status::class, $this->statusForm['id']); } $this->persistSettingModel(Status::class, $this->statusForm['id'], $data); @@ -173,7 +174,7 @@ final class SettingsManager extends Component $data = $this->guardDefaultColumn('helpdesk_priorities', $data); if ((bool) ($data['is_default'] ?? false)) { - Priority::query()->whereKeyNot($this->priorityForm['id'])->update(['is_default' => false]); + $this->clearOtherDefaults(Priority::class, $this->priorityForm['id']); } $this->persistSettingModel(Priority::class, $this->priorityForm['id'], $data); $this->resetPriorityForm(); @@ -214,10 +215,7 @@ final class SettingsManager extends Component $data = $this->guardDefaultColumn('helpdesk_categories', $data); if ((bool) ($data['is_default'] ?? false)) { - Category::query() - ->whereKeyNot($this->categoryForm['id']) - ->where('company_id', $data['company_id']) - ->update(['is_default' => false]); + $this->clearOtherDefaults(Category::class, $this->categoryForm['id'], $data['company_id'] ?? null); } $this->persistSettingModel(Category::class, $this->categoryForm['id'], $data); $this->resetCategoryForm(); @@ -258,10 +256,7 @@ final class SettingsManager extends Component $data = $this->guardDefaultColumn('helpdesk_teams', $data); if ((bool) ($data['is_default'] ?? false)) { - Team::query() - ->whereKeyNot($this->teamForm['id']) - ->where('company_id', $data['company_id']) - ->update(['is_default' => false]); + $this->clearOtherDefaults(Team::class, $this->teamForm['id'], $data['company_id'] ?? null); } $this->persistSettingModel(Team::class, $this->teamForm['id'], $data); $this->resetTeamForm(); @@ -303,10 +298,7 @@ final class SettingsManager extends Component $data = $this->guardDefaultColumn('helpdesk_projects', $data); if ((bool) ($data['is_default'] ?? false)) { - Project::query() - ->whereKeyNot($this->projectForm['id']) - ->where('company_id', $data['company_id']) - ->update(['is_default' => false]); + $this->clearOtherDefaults(Project::class, $this->projectForm['id'], $data['company_id'] ?? null); } $this->persistSettingModel(Project::class, $this->projectForm['id'], $data); $this->resetProjectForm(); @@ -352,7 +344,7 @@ final class SettingsManager extends Component $data['slug'] = $this->slug($data['slug'] ?: $data['name']); if ((bool) ($data['is_default'] ?? false)) { - KanbanBoard::query()->whereKeyNot($this->boardForm['id'])->update(['is_default' => false]); + $this->clearOtherDefaults(KanbanBoard::class, $this->boardForm['id']); } $board = $this->persistSettingModel(KanbanBoard::class, $this->boardForm['id'], $data); @@ -530,7 +522,7 @@ final class SettingsManager extends Component ])['slaForm']; $data['slug'] = $this->slug($data['slug'] ?: $data['name']); if ((bool) ($data['is_default'] ?? false)) { - SlaPolicy::query()->whereKeyNot($this->slaForm['id'])->update(['is_default' => false]); + $this->clearOtherDefaults(SlaPolicy::class, $this->slaForm['id']); } $this->persistSettingModel(SlaPolicy::class, $this->slaForm['id'], $data); $this->resetSlaPolicyForm(); @@ -797,6 +789,7 @@ final class SettingsManager extends Component private function orderedCategories(): mixed { return Category::query() + ->with('company') ->withCount('tickets') ->when($this->hasDefaultColumn('helpdesk_categories'), fn ($query) => $query->orderByDesc('is_default')) ->orderBy('name') @@ -806,6 +799,7 @@ final class SettingsManager extends Component private function orderedTeams(): mixed { return Team::query() + ->with('company') ->withCount(['projects', 'tickets']) ->when($this->hasDefaultColumn('helpdesk_teams'), fn ($query) => $query->orderByDesc('is_default')) ->orderBy('name') @@ -815,7 +809,7 @@ final class SettingsManager extends Component private function orderedProjects(): mixed { return Project::query() - ->with(['team']) + ->with(['company', 'team']) ->withCount('tickets') ->when($this->hasDefaultColumn('helpdesk_projects'), fn ($query) => $query->orderByDesc('is_default')) ->orderBy('title') @@ -837,19 +831,47 @@ final class SettingsManager extends Component */ private function persistSettingModel(string $modelClass, int|string|null $id, array $data): object { - if (filled($id)) { + unset($data['id']); + + try { + if (filled($id)) { + /** @var TModel $model */ + $model = $modelClass::query()->findOrFail((int) $id); + $model->fill($data); + $model->save(); + + return $model; + } + /** @var TModel $model */ - $model = $modelClass::query()->findOrFail((int) $id); - $model->fill($data); - $model->save(); + $model = $modelClass::query()->create($data); return $model; + } catch (\Illuminate\Database\QueryException $exception) { + throw ValidationException::withMessages([ + 'settings' => 'The record could not be saved. Check the slug is unique for this section/company and try again.', + ]); + } + } + + /** + * Clear the default flag on every other record without generating invalid null-id SQL. + * + * @param class-string<\Illuminate\Database\Eloquent\Model> $modelClass + */ + private function clearOtherDefaults(string $modelClass, int|string|null $currentId, int|string|null $companyId = null): void + { + $query = $modelClass::query(); + + if (filled($currentId)) { + $query->whereKeyNot((int) $currentId); } - /** @var TModel $model */ - $model = $modelClass::query()->create($data); + if (Schema::hasColumn((new $modelClass())->getTable(), 'company_id')) { + $query->where('company_id', filled($companyId) ? (int) $companyId : null); + } - return $model; + $query->update(['is_default' => false]); } /** diff --git a/Modules/Helpdesk/Models/Category.php b/Modules/Helpdesk/Models/Category.php index a9e5c632..eb186279 100644 --- a/Modules/Helpdesk/Models/Category.php +++ b/Modules/Helpdesk/Models/Category.php @@ -7,6 +7,7 @@ namespace Modules\Helpdesk\Models; use App\Models\User; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -22,6 +23,12 @@ final class Category extends Model /** @var array */ protected $casts = ['company_id' => 'integer', 'is_default' => 'boolean']; + /** @return BelongsTo<\App\Models\Company, Category> */ + public function company(): BelongsTo + { + return $this->belongsTo(\App\Models\Company::class, 'company_id'); + } + /** @return HasMany */ public function tickets(): HasMany { diff --git a/Modules/Helpdesk/Models/Project.php b/Modules/Helpdesk/Models/Project.php index 14e7c3ab..acc3dbf6 100644 --- a/Modules/Helpdesk/Models/Project.php +++ b/Modules/Helpdesk/Models/Project.php @@ -23,6 +23,12 @@ final class Project extends Model /** @var array */ protected $casts = ['company_id' => 'integer', 'team_id' => 'integer', 'archived_at' => 'datetime', 'is_default' => 'boolean']; + /** @return BelongsTo<\App\Models\Company, Project> */ + public function company(): BelongsTo + { + return $this->belongsTo(\App\Models\Company::class, 'company_id'); + } + /** @return BelongsTo */ public function team(): BelongsTo { diff --git a/Modules/Helpdesk/Models/Team.php b/Modules/Helpdesk/Models/Team.php index 9763b248..a3018197 100644 --- a/Modules/Helpdesk/Models/Team.php +++ b/Modules/Helpdesk/Models/Team.php @@ -6,6 +6,7 @@ namespace Modules\Helpdesk\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; @@ -26,6 +27,12 @@ final class Team extends Model 'is_default' => 'boolean', ]; + /** @return BelongsTo<\App\Models\Company, Team> */ + public function company(): BelongsTo + { + return $this->belongsTo(\App\Models\Company::class, 'company_id'); + } + /** @return HasMany */ public function projects(): HasMany { diff --git a/Modules/Helpdesk/Tests/Feature/SettingsCrudTest.php b/Modules/Helpdesk/Tests/Feature/SettingsCrudTest.php new file mode 100644 index 00000000..060a6b5b --- /dev/null +++ b/Modules/Helpdesk/Tests/Feature/SettingsCrudTest.php @@ -0,0 +1,251 @@ +seedLookupData(); + $admin = $this->admin(['name' => 'Settings CRUD Admin']); + $this->actingAs($admin); + + Livewire::test(SettingsManager::class) + ->set('statusForm.name', 'Awaiting Customer') + ->set('statusForm.slug', 'awaiting-customer') + ->set('statusForm.color', 'amber') + ->set('statusForm.sort_order', 20) + ->call('saveStatus') + ->assertHasNoErrors(); + $status = Status::query()->where('slug', 'awaiting-customer')->firstOrFail(); + Livewire::test(SettingsManager::class) + ->call('editStatus', $status->id) + ->set('statusForm.name', 'Awaiting Customer Reply') + ->call('saveStatus') + ->assertHasNoErrors(); + $this->assertDatabaseHas('helpdesk_statuses', ['id' => $status->id, 'name' => 'Awaiting Customer Reply']); + + Livewire::test(SettingsManager::class) + ->set('priorityForm.name', 'Critical') + ->set('priorityForm.slug', 'critical') + ->set('priorityForm.color', 'red') + ->set('priorityForm.sort_order', 1) + ->call('savePriority') + ->assertHasNoErrors(); + $priority = Priority::query()->where('slug', 'critical')->firstOrFail(); + Livewire::test(SettingsManager::class) + ->call('editPriority', $priority->id) + ->set('priorityForm.name', 'Critical Updated') + ->call('savePriority') + ->assertHasNoErrors(); + $this->assertDatabaseHas('helpdesk_priorities', ['id' => $priority->id, 'name' => 'Critical Updated']); + + Livewire::test(SettingsManager::class) + ->set('categoryForm.name', 'Billing') + ->set('categoryForm.slug', 'billing') + ->set('categoryForm.color', 'blue') + ->call('saveCategory') + ->assertHasNoErrors(); + $category = Category::query()->where('slug', 'billing')->firstOrFail(); + Livewire::test(SettingsManager::class) + ->call('editCategory', $category->id) + ->set('categoryForm.name', 'Billing Updated') + ->call('saveCategory') + ->assertHasNoErrors(); + $this->assertDatabaseHas('helpdesk_categories', ['id' => $category->id, 'name' => 'Billing Updated']); + + Livewire::test(SettingsManager::class) + ->set('teamForm.name', 'Second Line') + ->set('teamForm.slug', 'second-line') + ->set('teamForm.color', 'indigo') + ->call('saveTeam') + ->assertHasNoErrors(); + $team = Team::query()->where('slug', 'second-line')->firstOrFail(); + Livewire::test(SettingsManager::class) + ->call('editTeam', $team->id) + ->set('teamForm.name', 'Second Line Updated') + ->call('saveTeam') + ->assertHasNoErrors(); + $this->assertDatabaseHas('helpdesk_teams', ['id' => $team->id, 'name' => 'Second Line Updated']); + + Livewire::test(SettingsManager::class) + ->set('projectForm.team_id', $fixtures['team']->id) + ->set('projectForm.title', 'Customer Portal') + ->set('projectForm.slug', 'customer-portal') + ->set('projectForm.color', 'cyan') + ->call('saveProject') + ->assertHasNoErrors(); + $project = Project::query()->where('slug', 'customer-portal')->firstOrFail(); + Livewire::test(SettingsManager::class) + ->call('editProject', $project->id) + ->set('projectForm.title', 'Customer Portal Updated') + ->call('saveProject') + ->assertHasNoErrors(); + $this->assertDatabaseHas('helpdesk_projects', ['id' => $project->id, 'title' => 'Customer Portal Updated']); + + Livewire::test(SettingsManager::class) + ->set('boardForm.name', 'Escalations') + ->set('boardForm.slug', 'escalations') + ->set('boardForm.sort_order', 30) + ->set('boardForm.status_ids', [$fixtures['open']->id, $fixtures['closed']->id]) + ->call('saveBoard') + ->assertHasNoErrors(); + $board = KanbanBoard::query()->where('slug', 'escalations')->firstOrFail(); + Livewire::test(SettingsManager::class) + ->call('editBoard', $board->id) + ->set('boardForm.name', 'Escalations Updated') + ->call('saveBoard') + ->assertHasNoErrors(); + $this->assertDatabaseHas('helpdesk_kanban_boards', ['id' => $board->id, 'name' => 'Escalations Updated']); + + Livewire::test(SettingsManager::class) + ->set('repositoryForm.project_id', $fixtures['project']->id) + ->set('repositoryForm.name', 'WMS Core Repo') + ->set('repositoryForm.provider', 'github') + ->set('repositoryForm.remote_url', 'git@example.com:wms/core.git') + ->set('repositoryForm.default_branch', 'main') + ->call('saveRepository') + ->assertHasNoErrors(); + $repository = GitRepository::query()->where('name', 'WMS Core Repo')->firstOrFail(); + Livewire::test(SettingsManager::class) + ->call('editRepository', $repository->id) + ->set('repositoryForm.default_branch', 'develop') + ->call('saveRepository') + ->assertHasNoErrors(); + $this->assertDatabaseHas('helpdesk_git_repositories', ['id' => $repository->id, 'default_branch' => 'develop']); + + Livewire::test(SettingsManager::class) + ->set('workflowForm.name', 'Ticket Created') + ->set('workflowForm.event', 'ticket.created') + ->set('workflowForm.subject', 'New ticket') + ->set('workflowForm.html_body', '

Created

') + ->set('workflowForm.to_role', 'requester') + ->call('saveWorkflowTemplate') + ->assertHasNoErrors(); + $workflow = WorkflowTemplate::query()->where('name', 'Ticket Created')->firstOrFail(); + Livewire::test(SettingsManager::class) + ->call('editWorkflowTemplate', $workflow->id) + ->set('workflowForm.subject', 'New ticket updated') + ->call('saveWorkflowTemplate') + ->assertHasNoErrors(); + $this->assertDatabaseHas('helpdesk_workflow_templates', ['id' => $workflow->id, 'subject' => 'New ticket updated']); + + Livewire::test(SettingsManager::class) + ->set('cannedForm.category_id', $fixtures['category']->id) + ->set('cannedForm.team_id', $fixtures['team']->id) + ->set('cannedForm.name', 'Greeting') + ->set('cannedForm.slug', 'greeting') + ->set('cannedForm.body', '

Hello

') + ->call('saveCannedResponse') + ->assertHasNoErrors(); + $canned = CannedResponse::query()->where('slug', 'greeting')->firstOrFail(); + Livewire::test(SettingsManager::class) + ->call('editCannedResponse', $canned->id) + ->set('cannedForm.body', '

Hello updated

') + ->call('saveCannedResponse') + ->assertHasNoErrors(); + $this->assertDatabaseHas('helpdesk_canned_responses', ['id' => $canned->id, 'body' => '

Hello updated

']); + + Livewire::test(SettingsManager::class) + ->set('slaForm.priority_id', $fixtures['normal']->id) + ->set('slaForm.category_id', $fixtures['category']->id) + ->set('slaForm.name', 'Standard SLA') + ->set('slaForm.slug', 'standard-sla') + ->set('slaForm.first_response_minutes', 120) + ->set('slaForm.resolution_minutes', 1440) + ->call('saveSlaPolicy') + ->assertHasNoErrors(); + $sla = SlaPolicy::query()->where('slug', 'standard-sla')->firstOrFail(); + Livewire::test(SettingsManager::class) + ->call('editSlaPolicy', $sla->id) + ->set('slaForm.resolution_minutes', 720) + ->call('saveSlaPolicy') + ->assertHasNoErrors(); + $this->assertDatabaseHas('helpdesk_sla_policies', ['id' => $sla->id, 'resolution_minutes' => 720]); + + Livewire::test(SettingsManager::class) + ->set('automationForm.name', 'Auto Route') + ->set('automationForm.slug', 'auto-route') + ->set('automationForm.set_priority_id', $fixtures['high']->id) + ->set('automationForm.set_status_id', $fixtures['open']->id) + ->call('saveAutomationRule') + ->assertHasNoErrors(); + $automation = AutomationRule::query()->where('slug', 'auto-route')->firstOrFail(); + Livewire::test(SettingsManager::class) + ->call('editAutomationRule', $automation->id) + ->set('automationForm.match_subject', 'urgent') + ->call('saveAutomationRule') + ->assertHasNoErrors(); + $this->assertDatabaseHas('helpdesk_automation_rules', ['id' => $automation->id, 'match_subject' => 'urgent']); + + Livewire::test(SettingsManager::class) + ->set('checklistTemplateForm.category_id', $fixtures['category']->id) + ->set('checklistTemplateForm.project_id', $fixtures['project']->id) + ->set('checklistTemplateForm.name', 'Release Checklist') + ->set('checklistTemplateForm.slug', 'release-checklist') + ->set('checklistTemplateForm.items_text', "Check logs\nConfirm release") + ->call('saveChecklistTemplate') + ->assertHasNoErrors(); + $checklist = ChecklistTemplate::query()->where('slug', 'release-checklist')->firstOrFail(); + Livewire::test(SettingsManager::class) + ->call('editChecklistTemplate', $checklist->id) + ->set('checklistTemplateForm.items_text', "Updated item") + ->call('saveChecklistTemplate') + ->assertHasNoErrors(); + $this->assertDatabaseHas('helpdesk_checklist_templates', ['id' => $checklist->id]); + self::assertSame(['Updated item'], $checklist->refresh()->items); + + Livewire::test(SettingsManager::class) + ->set('tagForm.name', 'VIP') + ->set('tagForm.slug', 'vip') + ->set('tagForm.color', 'pink') + ->call('saveTag') + ->assertHasNoErrors(); + $tag = Tag::query()->where('slug', 'vip')->firstOrFail(); + Livewire::test(SettingsManager::class) + ->call('editTag', $tag->id) + ->set('tagForm.color', 'purple') + ->call('saveTag') + ->assertHasNoErrors(); + $this->assertDatabaseHas('helpdesk_tags', ['id' => $tag->id, 'color' => 'purple']); + + Livewire::test(SettingsManager::class) + ->set('productForm.name', 'WMS') + ->set('productForm.slug', 'wms') + ->set('productForm.type', 'service') + ->call('saveProduct') + ->assertHasNoErrors(); + $product = Product::query()->where('slug', 'wms')->firstOrFail(); + Livewire::test(SettingsManager::class) + ->call('editProduct', $product->id) + ->set('productForm.version', '1.1') + ->call('saveProduct') + ->assertHasNoErrors(); + $this->assertDatabaseHas('helpdesk_products', ['id' => $product->id, 'version' => '1.1']); + } +} diff --git a/Modules/Helpdesk/changelog.yaml b/Modules/Helpdesk/changelog.yaml index 77386eff..37f4591b 100644 --- a/Modules/Helpdesk/changelog.yaml +++ b/Modules/Helpdesk/changelog.yaml @@ -1,3 +1,10 @@ +- version: 1.9.29 + date: 2026-06-18 + changes: + - Hardened every Helpdesk settings create/edit/save path so Livewire CRUD actions persist updates without null primary-key inserts or invalid default-reset queries. + - Added company relationships/eager loading for category, team and project settings tables so settings pages render cleanly during edit/save cycles. + - Added full SettingsCrudTest coverage for statuses, priorities, categories, teams, projects, boards, Git repositories, workflow templates, canned responses, SLA policies, automation rules, checklist templates, tags and products. + - version: 1.3.6 date: 2026-06-16 changes: diff --git a/Modules/Helpdesk/module.json b/Modules/Helpdesk/module.json index 5eb66f49..31300457 100644 --- a/Modules/Helpdesk/module.json +++ b/Modules/Helpdesk/module.json @@ -22,5 +22,5 @@ "Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider" ], "files": [], - "version": "1.9.28" + "version": "1.9.29" }