diff --git a/Modules/Helpdesk/Concerns/HasHelpdeskTickets.php b/Modules/Helpdesk/Concerns/HasHelpdeskTickets.php deleted file mode 100644 index 31c9ecc..0000000 --- a/Modules/Helpdesk/Concerns/HasHelpdeskTickets.php +++ /dev/null @@ -1,36 +0,0 @@ - */ - public function helpdeskTickets(): HasMany - { - return $this->hasMany(Ticket::class, 'user_id'); - } - - /** @return HasMany */ - public function helpdeskAssignedTickets(): HasMany - { - return $this->hasMany(Ticket::class, 'agent_id'); - } - - public function isHelpdeskAgent(): bool - { - return (bool) (($this->helpdesk_agent ?? false) || ($this->helpdesk_admin ?? false)); - } - - public function isHelpdeskAdmin(): bool - { - return (bool) ($this->helpdesk_admin ?? false); - } -} diff --git a/Modules/Helpdesk/Config/.gitkeep b/Modules/Helpdesk/Config/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/Modules/Helpdesk/Config/config.php b/Modules/Helpdesk/Config/config.php deleted file mode 100644 index d09cb6b..0000000 --- a/Modules/Helpdesk/Config/config.php +++ /dev/null @@ -1,31 +0,0 @@ - env('HELPDESK_ROUTE_PREFIX', 'helpdesk'), - 'middleware' => ['web', 'auth'], - 'disk' => env('HELPDESK_ATTACHMENTS_DISK', 'local'), - 'max_upload_kb' => (int) env('HELPDESK_MAX_UPLOAD_KB', 10240), - 'allowed_extensions' => [ - 'md', 'pdf', 'png', 'jpg', 'jpeg', 'webp', 'doc', 'docx', 'xls', 'xlsx', 'txt', 'csv', 'log', 'zip', - 'php', 'phtml', 'phps', 'js', 'jsx', 'ts', 'tsx', 'vue', 'css', 'scss', 'sass', 'less', - 'html', 'htm', 'xml', 'json', 'yaml', 'yml', 'sql', 'sh', 'bash', 'zsh', 'ps1', 'bat', 'cmd', - 'py', 'rb', 'go', 'rs', 'java', 'kt', 'kts', 'cs', 'cpp', 'c', 'h', 'hpp', 'swift', 'dart', - 'rpg', 'rpgle', 'cl', 'clle', 'cbl', 'cob', 'copybook', 'inc', 'env', 'ini', 'conf', 'config', - ], - - 'allowed_source_extensions' => [ - 'php', 'phtml', 'phps', 'js', 'jsx', 'ts', 'tsx', 'vue', 'css', 'scss', 'sass', 'less', - 'html', 'htm', 'xml', 'json', 'yaml', 'yml', 'sql', 'sh', 'bash', 'zsh', 'ps1', 'bat', 'cmd', - 'py', 'rb', 'go', 'rs', 'java', 'kt', 'kts', 'cs', 'cpp', 'c', 'h', 'hpp', 'swift', 'dart', - 'rpg', 'rpgle', 'cl', 'clle', 'cbl', 'cob', 'copybook', 'inc', 'env', 'ini', 'conf', 'config', 'txt', 'log', 'md', - ], - 'blocked_extensions' => ['exe', 'dll', 'com', 'scr', 'msi', 'app', 'dmg', 'pkg', 'jar', 'war'], - 'virus_scanner' => env('HELPDESK_VIRUS_SCANNER', null), - - // Backwards-compatible alias for older module code/tests that still read this key. - 'allowed_mimes' => [], - 'default_status_slug' => 'new', - 'closed_status_slug' => 'closed', -]; diff --git a/Modules/Helpdesk/Console/.gitkeep b/Modules/Helpdesk/Console/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/Modules/Helpdesk/Database/Migrations/.gitkeep b/Modules/Helpdesk/Database/Migrations/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/Modules/Helpdesk/Database/Migrations/2026_04_24_000001_add_helpdesk_flags_to_users_table.php b/Modules/Helpdesk/Database/Migrations/2026_04_24_000001_add_helpdesk_flags_to_users_table.php deleted file mode 100644 index c896903..0000000 --- a/Modules/Helpdesk/Database/Migrations/2026_04_24_000001_add_helpdesk_flags_to_users_table.php +++ /dev/null @@ -1,26 +0,0 @@ -boolean('helpdesk_agent')->default(false)->after('password'); - } - - if (! Schema::hasColumn('users', 'helpdesk_admin')) { - $table->boolean('helpdesk_admin')->default(false)->after('helpdesk_agent'); - } - }); - } -}; diff --git a/Modules/Helpdesk/Database/Migrations/2026_06_12_000010_create_helpdesk_lookup_tables.php b/Modules/Helpdesk/Database/Migrations/2026_06_12_000010_create_helpdesk_lookup_tables.php deleted file mode 100644 index bf8b9a6..0000000 --- a/Modules/Helpdesk/Database/Migrations/2026_06_12_000010_create_helpdesk_lookup_tables.php +++ /dev/null @@ -1,73 +0,0 @@ -id(); - $table->string('name', 80); - $table->string('slug', 100)->unique(); - $table->string('color', 30)->default('zinc'); - $table->unsignedSmallInteger('sort_order')->default(0); - $table->boolean('is_closed')->default(false); - $table->timestamps(); - }); - - Schema::create('helpdesk_priorities', static function (Blueprint $table): void { - $table->id(); - $table->string('name', 80); - $table->string('slug', 100)->unique(); - $table->string('color', 30)->default('zinc'); - $table->unsignedSmallInteger('sort_order')->default(0); - $table->timestamps(); - }); - - Schema::create('helpdesk_categories', static function (Blueprint $table): void { - $table->id(); - $table->foreignId('company_id')->nullable()->constrained('companies')->nullOnDelete(); - $table->string('name', 120); - $table->string('slug', 140); - $table->string('color', 30)->default('blue'); - $table->text('description')->nullable(); - $table->timestamps(); - - $table->unique(['company_id', 'slug']); - }); - - Schema::create('helpdesk_teams', static function (Blueprint $table): void { - $table->id(); - $table->foreignId('company_id')->nullable()->constrained('companies')->nullOnDelete(); - $table->string('name', 120); - $table->string('slug', 140); - $table->string('color', 30)->default('indigo'); - $table->text('description')->nullable(); - $table->timestamp('archived_at')->nullable(); - $table->softDeletes(); - $table->timestamps(); - - $table->unique(['company_id', 'slug']); - }); - - Schema::create('helpdesk_projects', static function (Blueprint $table): void { - $table->id(); - $table->foreignId('company_id')->nullable()->constrained('companies')->nullOnDelete(); - $table->foreignId('team_id')->nullable()->constrained('helpdesk_teams')->nullOnDelete(); - $table->string('title', 160); - $table->string('slug', 180); - $table->string('color', 30)->default('blue'); - $table->text('description')->nullable(); - $table->timestamp('archived_at')->nullable(); - $table->softDeletes(); - $table->timestamps(); - - $table->unique(['company_id', 'slug']); - }); - } -}; diff --git a/Modules/Helpdesk/Database/Migrations/2026_06_12_000020_create_helpdesk_tickets_table.php b/Modules/Helpdesk/Database/Migrations/2026_06_12_000020_create_helpdesk_tickets_table.php deleted file mode 100644 index f139a3b..0000000 --- a/Modules/Helpdesk/Database/Migrations/2026_06_12_000020_create_helpdesk_tickets_table.php +++ /dev/null @@ -1,41 +0,0 @@ -id(); - $table->string('reference', 32)->unique(); - $table->foreignId('company_id')->nullable()->constrained('companies')->nullOnDelete(); - $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); - $table->foreignId('agent_id')->nullable()->constrained('users')->nullOnDelete(); - $table->foreignId('status_id')->constrained('helpdesk_statuses')->restrictOnDelete(); - $table->foreignId('priority_id')->constrained('helpdesk_priorities')->restrictOnDelete(); - $table->foreignId('category_id')->nullable()->constrained('helpdesk_categories')->nullOnDelete(); - $table->foreignId('project_id')->nullable()->constrained('helpdesk_projects')->nullOnDelete(); - $table->foreignId('team_id')->nullable()->constrained('helpdesk_teams')->nullOnDelete(); - $table->string('subject', 200); - $table->longText('body'); - $table->timestamp('due_at')->nullable(); - $table->timestamp('completed_at')->nullable(); - $table->timestamp('last_activity_at')->nullable(); - $table->timestamp('archived_at')->nullable(); - $table->softDeletes(); - $table->timestamps(); - - $table->index(['company_id', 'project_id']); - $table->index(['team_id', 'status_id']); - $table->index(['status_id', 'priority_id']); - $table->index(['agent_id', 'completed_at']); - $table->index(['user_id', 'created_at']); - $table->index('last_activity_at'); - }); - } -}; diff --git a/Modules/Helpdesk/Database/Migrations/2026_06_12_000030_create_helpdesk_git_and_workflow_tables.php b/Modules/Helpdesk/Database/Migrations/2026_06_12_000030_create_helpdesk_git_and_workflow_tables.php deleted file mode 100644 index 25446f2..0000000 --- a/Modules/Helpdesk/Database/Migrations/2026_06_12_000030_create_helpdesk_git_and_workflow_tables.php +++ /dev/null @@ -1,78 +0,0 @@ -id(); - $table->foreignId('company_id')->nullable()->constrained('companies')->nullOnDelete(); - $table->foreignId('project_id')->nullable()->constrained('helpdesk_projects')->nullOnDelete(); - $table->string('name', 160); - $table->string('provider', 40)->default('git'); - $table->string('remote_url', 500); - $table->string('default_branch', 120)->default('main'); - $table->string('webhook_secret', 120)->nullable(); - $table->boolean('is_active')->default(true); - $table->timestamps(); - - $table->index(['company_id', 'project_id']); - $table->index(['provider', 'is_active']); - }); - - Schema::create('helpdesk_git_commits', static function (Blueprint $table): void { - $table->id(); - $table->foreignId('repository_id')->constrained('helpdesk_git_repositories')->cascadeOnDelete(); - $table->foreignId('ticket_id')->nullable()->constrained('helpdesk_tickets')->nullOnDelete(); - $table->string('hash', 80); - $table->string('short_hash', 16); - $table->string('branch', 120)->nullable(); - $table->string('author_name', 160)->nullable(); - $table->string('author_email', 190)->nullable(); - $table->text('message'); - $table->string('url', 500)->nullable(); - $table->timestamp('committed_at')->nullable(); - $table->timestamps(); - - $table->unique(['repository_id', 'hash']); - $table->index(['ticket_id', 'committed_at']); - }); - - Schema::create('helpdesk_workflow_templates', static function (Blueprint $table): void { - $table->id(); - $table->string('name', 160); - $table->string('event', 80); - $table->string('subject', 220); - $table->longText('html_body'); - $table->longText('text_body')->nullable(); - $table->string('to_role', 40)->default('requester'); - $table->string('cc', 500)->nullable(); - $table->string('bcc', 500)->nullable(); - $table->boolean('is_active')->default(true); - $table->timestamps(); - - $table->index(['event', 'is_active']); - }); - - Schema::create('helpdesk_workflow_logs', static function (Blueprint $table): void { - $table->id(); - $table->foreignId('template_id')->nullable()->constrained('helpdesk_workflow_templates')->nullOnDelete(); - $table->foreignId('ticket_id')->nullable()->constrained('helpdesk_tickets')->nullOnDelete(); - $table->string('event', 80); - $table->string('recipient', 190)->nullable(); - $table->string('subject', 220)->nullable(); - $table->string('status', 40)->default('pending'); - $table->text('error')->nullable(); - $table->timestamps(); - - $table->index(['ticket_id', 'event']); - $table->index(['status', 'created_at']); - }); - } -}; diff --git a/Modules/Helpdesk/Database/Migrations/2026_06_12_000040_create_helpdesk_comments_and_attachments_tables.php b/Modules/Helpdesk/Database/Migrations/2026_06_12_000040_create_helpdesk_comments_and_attachments_tables.php deleted file mode 100644 index 14ef789..0000000 --- a/Modules/Helpdesk/Database/Migrations/2026_06_12_000040_create_helpdesk_comments_and_attachments_tables.php +++ /dev/null @@ -1,40 +0,0 @@ -id(); - $table->foreignId('ticket_id')->constrained('helpdesk_tickets')->cascadeOnDelete(); - $table->foreignId('parent_id')->nullable()->constrained('helpdesk_comments')->cascadeOnDelete(); - $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete(); - $table->longText('body'); - $table->boolean('is_internal')->default(false); - $table->timestamps(); - - $table->index(['ticket_id', 'parent_id', 'created_at']); - }); - - Schema::create('helpdesk_attachments', static function (Blueprint $table): void { - $table->id(); - $table->foreignId('ticket_id')->constrained('helpdesk_tickets')->cascadeOnDelete(); - $table->foreignId('comment_id')->nullable()->constrained('helpdesk_comments')->cascadeOnDelete(); - $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete(); - $table->string('disk', 80); - $table->string('path', 500); - $table->string('original_name', 255); - $table->string('mime_type', 160)->nullable(); - $table->unsignedBigInteger('size')->default(0); - $table->timestamps(); - - $table->index(['ticket_id', 'comment_id', 'created_at']); - }); - } -}; diff --git a/Modules/Helpdesk/Database/Migrations/2026_06_12_000050_create_helpdesk_category_user_table.php b/Modules/Helpdesk/Database/Migrations/2026_06_12_000050_create_helpdesk_category_user_table.php deleted file mode 100644 index e95e1de..0000000 --- a/Modules/Helpdesk/Database/Migrations/2026_06_12_000050_create_helpdesk_category_user_table.php +++ /dev/null @@ -1,21 +0,0 @@ -id(); - $table->foreignId('category_id')->constrained('helpdesk_categories')->cascadeOnDelete(); - $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); - $table->timestamps(); - $table->unique(['category_id', 'user_id']); - }); - } -}; diff --git a/Modules/Helpdesk/Database/Migrations/2026_06_12_130000_create_helpdesk_kanban_boards_table.php b/Modules/Helpdesk/Database/Migrations/2026_06_12_130000_create_helpdesk_kanban_boards_table.php deleted file mode 100644 index 974dd98..0000000 --- a/Modules/Helpdesk/Database/Migrations/2026_06_12_130000_create_helpdesk_kanban_boards_table.php +++ /dev/null @@ -1,39 +0,0 @@ -id(); - $table->string('name'); - $table->string('slug')->unique(); - $table->text('description')->nullable(); - $table->boolean('is_default')->default(false); - $table->unsignedInteger('sort_order')->default(0); - $table->timestamps(); - $table->softDeletes(); - - $table->index(['is_default', 'sort_order']); - }); - } - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('helpdesk_kanban_boards'); - } -}; diff --git a/Modules/Helpdesk/Database/Migrations/2026_06_12_130010_create_helpdesk_kanban_board_statuses_table.php b/Modules/Helpdesk/Database/Migrations/2026_06_12_130010_create_helpdesk_kanban_board_statuses_table.php deleted file mode 100644 index 4146c1c..0000000 --- a/Modules/Helpdesk/Database/Migrations/2026_06_12_130010_create_helpdesk_kanban_board_statuses_table.php +++ /dev/null @@ -1,31 +0,0 @@ -id(); - $table->foreignId('board_id')->constrained('helpdesk_kanban_boards')->cascadeOnDelete(); - $table->foreignId('status_id')->constrained('helpdesk_statuses')->cascadeOnDelete(); - $table->unsignedInteger('sort_order')->default(0); - $table->timestamps(); - - $table->unique(['board_id', 'status_id']); - $table->index(['board_id', 'sort_order']); - }); - } - } - - public function down(): void - { - Schema::dropIfExists('helpdesk_kanban_board_statuses'); - } -}; diff --git a/Modules/Helpdesk/Database/Migrations/2026_06_15_000100_add_redmineup_inspired_helpdesk_features.php b/Modules/Helpdesk/Database/Migrations/2026_06_15_000100_add_redmineup_inspired_helpdesk_features.php deleted file mode 100644 index 2bed065..0000000 --- a/Modules/Helpdesk/Database/Migrations/2026_06_15_000100_add_redmineup_inspired_helpdesk_features.php +++ /dev/null @@ -1,205 +0,0 @@ -id(); - $table->foreignId('company_id')->nullable()->constrained('companies')->nullOnDelete(); - $table->string('name', 160); - $table->string('slug', 180); - $table->string('type', 60)->default('service'); - $table->string('version', 80)->nullable(); - $table->string('release_channel', 80)->nullable(); - $table->text('description')->nullable(); - $table->boolean('is_active')->default(true); - $table->timestamps(); - $table->unique(['company_id', 'slug']); - }); - - Schema::create('helpdesk_sla_policies', static function (Blueprint $table): void { - $table->id(); - $table->foreignId('company_id')->nullable()->constrained('companies')->nullOnDelete(); - $table->foreignId('priority_id')->nullable()->constrained('helpdesk_priorities')->nullOnDelete(); - $table->foreignId('category_id')->nullable()->constrained('helpdesk_categories')->nullOnDelete(); - $table->string('name', 160); - $table->string('slug', 180); - $table->unsignedInteger('first_response_minutes')->default(240); - $table->unsignedInteger('resolution_minutes')->default(2880); - $table->boolean('business_hours_only')->default(false); - $table->boolean('is_default')->default(false); - $table->boolean('is_active')->default(true); - $table->timestamps(); - $table->unique(['company_id', 'slug']); - $table->index(['priority_id', 'category_id', 'is_active']); - }); - - Schema::table('helpdesk_tickets', static function (Blueprint $table): void { - if (! Schema::hasColumn('helpdesk_tickets', 'sla_policy_id')) { - $table->foreignId('sla_policy_id')->nullable()->after('team_id')->constrained('helpdesk_sla_policies')->nullOnDelete(); - } - if (! Schema::hasColumn('helpdesk_tickets', 'product_id')) { - $table->foreignId('product_id')->nullable()->after('project_id')->constrained('helpdesk_products')->nullOnDelete(); - } - if (! Schema::hasColumn('helpdesk_tickets', 'first_response_at')) { - $table->timestamp('first_response_at')->nullable()->after('due_at'); - } - if (! Schema::hasColumn('helpdesk_tickets', 'sla_first_response_due_at')) { - $table->timestamp('sla_first_response_due_at')->nullable()->after('first_response_at'); - } - if (! Schema::hasColumn('helpdesk_tickets', 'sla_resolution_due_at')) { - $table->timestamp('sla_resolution_due_at')->nullable()->after('sla_first_response_due_at'); - } - if (! Schema::hasColumn('helpdesk_tickets', 'sla_first_response_breached_at')) { - $table->timestamp('sla_first_response_breached_at')->nullable()->after('sla_resolution_due_at'); - } - if (! Schema::hasColumn('helpdesk_tickets', 'sla_resolution_breached_at')) { - $table->timestamp('sla_resolution_breached_at')->nullable()->after('sla_first_response_breached_at'); - } - if (! Schema::hasColumn('helpdesk_tickets', 'public_replies_count')) { - $table->unsignedInteger('public_replies_count')->default(0)->after('sla_resolution_breached_at'); - } - if (! Schema::hasColumn('helpdesk_tickets', 'time_spent_minutes')) { - $table->unsignedInteger('time_spent_minutes')->default(0)->after('public_replies_count'); - } - - $table->index(['sla_policy_id', 'sla_resolution_due_at'], 'helpdesk_tickets_sla_policy_due_idx'); - $table->index(['product_id', 'completed_at'], 'helpdesk_tickets_product_closed_idx'); - }); - - Schema::table('helpdesk_workflow_logs', static function (Blueprint $table): void { - if (! Schema::hasColumn('helpdesk_workflow_logs', 'tracking_token')) { - $table->string('tracking_token', 64)->nullable()->unique()->after('status'); - } - if (! Schema::hasColumn('helpdesk_workflow_logs', 'first_opened_at')) { - $table->timestamp('first_opened_at')->nullable()->after('tracking_token'); - } - if (! Schema::hasColumn('helpdesk_workflow_logs', 'last_opened_at')) { - $table->timestamp('last_opened_at')->nullable()->after('first_opened_at'); - } - if (! Schema::hasColumn('helpdesk_workflow_logs', 'opened_count')) { - $table->unsignedInteger('opened_count')->default(0)->after('last_opened_at'); - } - }); - - Schema::create('helpdesk_canned_responses', static function (Blueprint $table): void { - $table->id(); - $table->foreignId('company_id')->nullable()->constrained('companies')->nullOnDelete(); - $table->foreignId('category_id')->nullable()->constrained('helpdesk_categories')->nullOnDelete(); - $table->foreignId('team_id')->nullable()->constrained('helpdesk_teams')->nullOnDelete(); - $table->string('name', 160); - $table->string('slug', 180); - $table->string('visibility', 40)->default('agents'); - $table->string('subject', 220)->nullable(); - $table->longText('body'); - $table->boolean('is_active')->default(true); - $table->unsignedInteger('sort_order')->default(0); - $table->timestamps(); - $table->unique(['company_id', 'slug']); - $table->index(['category_id', 'team_id', 'is_active']); - }); - - Schema::create('helpdesk_automation_rules', static function (Blueprint $table): void { - $table->id(); - $table->foreignId('company_id')->nullable()->constrained('companies')->nullOnDelete(); - $table->string('name', 160); - $table->string('slug', 180); - $table->string('match_sender', 190)->nullable(); - $table->string('match_subject', 220)->nullable(); - $table->string('match_mailbox', 190)->nullable(); - $table->foreignId('match_category_id')->nullable()->constrained('helpdesk_categories')->nullOnDelete(); - $table->foreignId('set_priority_id')->nullable()->constrained('helpdesk_priorities')->nullOnDelete(); - $table->foreignId('set_category_id')->nullable()->constrained('helpdesk_categories')->nullOnDelete(); - $table->foreignId('set_team_id')->nullable()->constrained('helpdesk_teams')->nullOnDelete(); - $table->foreignId('set_project_id')->nullable()->constrained('helpdesk_projects')->nullOnDelete(); - $table->foreignId('set_status_id')->nullable()->constrained('helpdesk_statuses')->nullOnDelete(); - $table->foreignId('set_agent_id')->nullable()->constrained('users')->nullOnDelete(); - $table->boolean('is_active')->default(true); - $table->unsignedInteger('sort_order')->default(0); - $table->timestamps(); - $table->unique(['company_id', 'slug']); - $table->index(['is_active', 'sort_order']); - }); - - Schema::create('helpdesk_ticket_sla_events', static function (Blueprint $table): void { - $table->id(); - $table->foreignId('ticket_id')->constrained('helpdesk_tickets')->cascadeOnDelete(); - $table->foreignId('sla_policy_id')->nullable()->constrained('helpdesk_sla_policies')->nullOnDelete(); - $table->string('event', 80); - $table->timestamp('due_at')->nullable(); - $table->timestamp('occurred_at')->nullable(); - $table->boolean('breached')->default(false); - $table->json('meta')->nullable(); - $table->timestamps(); - $table->index(['ticket_id', 'event']); - $table->index(['breached', 'due_at']); - }); - - Schema::create('helpdesk_ticket_ratings', static function (Blueprint $table): void { - $table->id(); - $table->foreignId('ticket_id')->constrained('helpdesk_tickets')->cascadeOnDelete(); - $table->foreignId('agent_id')->nullable()->constrained('users')->nullOnDelete(); - $table->foreignId('team_id')->nullable()->constrained('helpdesk_teams')->nullOnDelete(); - $table->foreignId('category_id')->nullable()->constrained('helpdesk_categories')->nullOnDelete(); - $table->unsignedTinyInteger('rating'); - $table->string('token', 80)->unique(); - $table->text('comment')->nullable(); - $table->timestamp('rated_at')->nullable(); - $table->timestamps(); - $table->index(['agent_id', 'rating']); - $table->index(['team_id', 'category_id']); - }); - - Schema::create('helpdesk_tags', static function (Blueprint $table): void { - $table->id(); - $table->foreignId('company_id')->nullable()->constrained('companies')->nullOnDelete(); - $table->string('name', 80); - $table->string('slug', 100); - $table->string('color', 30)->default('zinc'); - $table->timestamps(); - $table->unique(['company_id', 'slug']); - }); - - Schema::create('helpdesk_ticket_tag', static function (Blueprint $table): void { - $table->foreignId('ticket_id')->constrained('helpdesk_tickets')->cascadeOnDelete(); - $table->foreignId('tag_id')->constrained('helpdesk_tags')->cascadeOnDelete(); - $table->primary(['ticket_id', 'tag_id']); - }); - - Schema::create('helpdesk_checklist_templates', static function (Blueprint $table): void { - $table->id(); - $table->foreignId('company_id')->nullable()->constrained('companies')->nullOnDelete(); - $table->foreignId('category_id')->nullable()->constrained('helpdesk_categories')->nullOnDelete(); - $table->foreignId('project_id')->nullable()->constrained('helpdesk_projects')->nullOnDelete(); - $table->string('name', 160); - $table->string('slug', 180); - $table->json('items'); - $table->boolean('is_active')->default(true); - $table->timestamps(); - $table->unique(['company_id', 'slug']); - $table->index(['category_id', 'project_id', 'is_active']); - }); - - Schema::create('helpdesk_checklist_items', static function (Blueprint $table): void { - $table->id(); - $table->foreignId('ticket_id')->constrained('helpdesk_tickets')->cascadeOnDelete(); - $table->foreignId('template_id')->nullable()->constrained('helpdesk_checklist_templates')->nullOnDelete(); - $table->string('title', 220); - $table->text('description')->nullable(); - $table->boolean('is_completed')->default(false); - $table->foreignId('completed_by')->nullable()->constrained('users')->nullOnDelete(); - $table->timestamp('completed_at')->nullable(); - $table->unsignedInteger('sort_order')->default(0); - $table->timestamps(); - $table->index(['ticket_id', 'is_completed']); - }); - - } -}; diff --git a/Modules/Helpdesk/Database/Migrations/2026_06_15_000200_add_helpdesk_git_webhook_ingestion.php b/Modules/Helpdesk/Database/Migrations/2026_06_15_000200_add_helpdesk_git_webhook_ingestion.php deleted file mode 100644 index 49c06d9..0000000 --- a/Modules/Helpdesk/Database/Migrations/2026_06_15_000200_add_helpdesk_git_webhook_ingestion.php +++ /dev/null @@ -1,39 +0,0 @@ -id(); - $table->foreignId('repository_id')->constrained('helpdesk_git_repositories')->cascadeOnDelete(); - $table->string('provider', 40); - $table->string('delivery_id', 190)->nullable(); - $table->string('event', 120)->nullable(); - $table->string('status', 40)->default('accepted'); - $table->string('message', 500)->nullable(); - $table->unsignedInteger('commit_count')->default(0); - $table->unsignedInteger('linked_count')->default(0); - $table->unsignedInteger('ignored_count')->default(0); - $table->json('payload')->nullable(); - $table->timestamp('received_at')->nullable(); - $table->timestamps(); - - $table->index(['repository_id', 'status', 'received_at']); - $table->index(['provider', 'event']); - }); - } -}; diff --git a/Modules/Helpdesk/Database/Migrations/2026_06_15_143824_default_flags_to_helpdesk_lookup_tables.php b/Modules/Helpdesk/Database/Migrations/2026_06_15_143824_default_flags_to_helpdesk_lookup_tables.php deleted file mode 100644 index fa90d1a..0000000 --- a/Modules/Helpdesk/Database/Migrations/2026_06_15_143824_default_flags_to_helpdesk_lookup_tables.php +++ /dev/null @@ -1,48 +0,0 @@ -boolean('is_default')->default(false); - }); - } - } - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - foreach ([ - 'helpdesk_statuses', - 'helpdesk_priorities', - 'helpdesk_categories', - 'helpdesk_projects', - 'helpdesk_teams', - ] as $tableName) { - if (Schema::hasColumn($tableName, 'is_default')) { - Schema::table($tableName, function (Blueprint $table): void { - $table->dropColumn('is_default'); - }); - } - } - } -}; diff --git a/Modules/Helpdesk/Database/Migrations/2026_06_15_144126_add_default_helpdesk_flag_to_companies_table.php b/Modules/Helpdesk/Database/Migrations/2026_06_15_144126_add_default_helpdesk_flag_to_companies_table.php deleted file mode 100644 index d7a10cb..0000000 --- a/Modules/Helpdesk/Database/Migrations/2026_06_15_144126_add_default_helpdesk_flag_to_companies_table.php +++ /dev/null @@ -1,34 +0,0 @@ -boolean('is_default_helpdesk_company') - ->default(false) - ->after('is_system_company'); - }); - } - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - if (Schema::hasColumn('companies', 'is_default_helpdesk_company')) { - Schema::table('companies', function (Blueprint $table): void { - $table->dropColumn('is_default_helpdesk_company'); - }); - } - } -}; diff --git a/Modules/Helpdesk/Database/Migrations/2026_06_15_151500_add_helpdesk_status_default_and_harden_defaults.php b/Modules/Helpdesk/Database/Migrations/2026_06_15_151500_add_helpdesk_status_default_and_harden_defaults.php deleted file mode 100644 index 8e76ecc..0000000 --- a/Modules/Helpdesk/Database/Migrations/2026_06_15_151500_add_helpdesk_status_default_and_harden_defaults.php +++ /dev/null @@ -1,50 +0,0 @@ -boolean('is_default')->default(false); - }); - } - } - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - foreach ([ - 'helpdesk_statuses', - 'helpdesk_priorities', - 'helpdesk_categories', - 'helpdesk_projects', - 'helpdesk_teams', - ] as $tableName) { - if (Schema::hasTable($tableName) && Schema::hasColumn($tableName, 'is_default')) { - Schema::table($tableName, static function (Blueprint $table): void { - $table->dropColumn('is_default'); - }); - } - } - } -}; diff --git a/Modules/Helpdesk/Database/Migrations/2026_06_16_090000_add_single_board_assignment_and_owner_to_helpdesk.php b/Modules/Helpdesk/Database/Migrations/2026_06_16_090000_add_single_board_assignment_and_owner_to_helpdesk.php deleted file mode 100644 index a79892f..0000000 --- a/Modules/Helpdesk/Database/Migrations/2026_06_16_090000_add_single_board_assignment_and_owner_to_helpdesk.php +++ /dev/null @@ -1,76 +0,0 @@ -foreignId('owner_id') - ->nullable() - ->after('description') - ->constrained('users') - ->nullOnDelete(); - - $table->index('owner_id'); - }); - } - - if (Schema::hasTable('helpdesk_tickets') && ! Schema::hasColumn('helpdesk_tickets', 'board_id')) { - Schema::table('helpdesk_tickets', static function (Blueprint $table): void { - $table->foreignId('board_id') - ->nullable() - ->after('status_id') - ->constrained('helpdesk_kanban_boards') - ->nullOnDelete(); - - $table->index(['board_id', 'status_id']); - }); - } - - if (Schema::hasTable('helpdesk_tickets') && Schema::hasTable('helpdesk_kanban_boards') && Schema::hasColumn('helpdesk_tickets', 'board_id')) { - $defaultBoardId = DB::table('helpdesk_kanban_boards') - ->where('is_default', true) - ->orderBy('sort_order') - ->value('id') ?: DB::table('helpdesk_kanban_boards')->orderBy('sort_order')->value('id'); - - if ($defaultBoardId !== null) { - DB::table('helpdesk_tickets') - ->whereNull('board_id') - ->update(['board_id' => $defaultBoardId]); - } - } - } - - /** - * Reverse the migration. - */ - public function down(): void - { - if (Schema::hasTable('helpdesk_tickets') && Schema::hasColumn('helpdesk_tickets', 'board_id')) { - Schema::table('helpdesk_tickets', static function (Blueprint $table): void { - $table->dropForeign(['board_id']); - $table->dropIndex(['board_id', 'status_id']); - $table->dropColumn('board_id'); - }); - } - - if (Schema::hasTable('helpdesk_kanban_boards') && Schema::hasColumn('helpdesk_kanban_boards', 'owner_id')) { - Schema::table('helpdesk_kanban_boards', static function (Blueprint $table): void { - $table->dropForeign(['owner_id']); - $table->dropIndex(['owner_id']); - $table->dropColumn('owner_id'); - }); - } - } -}; diff --git a/Modules/Helpdesk/Database/Migrations/2026_06_16_093000_backfill_helpdesk_ticket_board_assignments.php b/Modules/Helpdesk/Database/Migrations/2026_06_16_093000_backfill_helpdesk_ticket_board_assignments.php deleted file mode 100644 index d2c3d6b..0000000 --- a/Modules/Helpdesk/Database/Migrations/2026_06_16_093000_backfill_helpdesk_ticket_board_assignments.php +++ /dev/null @@ -1,60 +0,0 @@ -where('is_default', true) - ->orderBy('sort_order') - ->value('id') ?: DB::table('helpdesk_kanban_boards')->orderBy('sort_order')->value('id'); - - if ($defaultBoardId === null) { - return; - } - - DB::table('helpdesk_tickets') - ->whereNull('board_id') - ->update([ - 'board_id' => $defaultBoardId, - 'updated_at' => now(), - ]); - - DB::table('helpdesk_tickets') - ->whereNotNull('board_id') - ->whereNotExists(function ($query): void { - $query->selectRaw('1') - ->from('helpdesk_kanban_boards') - ->whereColumn('helpdesk_kanban_boards.id', 'helpdesk_tickets.board_id'); - }) - ->update([ - 'board_id' => $defaultBoardId, - 'updated_at' => now(), - ]); - } - - /** - * This data repair is intentionally not reversed. - */ - public function down(): void - { - // No-op: do not remove repaired board ownership from existing tickets. - } -}; diff --git a/Modules/Helpdesk/Database/Migrations/2026_06_16_094500_backfill_kanban_board_status_columns.php b/Modules/Helpdesk/Database/Migrations/2026_06_16_094500_backfill_kanban_board_status_columns.php deleted file mode 100644 index c3c7d96..0000000 --- a/Modules/Helpdesk/Database/Migrations/2026_06_16_094500_backfill_kanban_board_status_columns.php +++ /dev/null @@ -1,65 +0,0 @@ -orderBy('sort_order') - ->orderBy('name') - ->get(['id']); - - if ($statuses->isEmpty()) { - return; - } - - $boards = DB::table('helpdesk_kanban_boards') - ->orderBy('sort_order') - ->orderBy('name') - ->get(['id']); - - foreach ($boards as $board) { - $hasColumns = DB::table('helpdesk_kanban_board_statuses') - ->where('board_id', $board->id) - ->exists(); - - if ($hasColumns) { - continue; - } - - foreach ($statuses as $index => $status) { - DB::table('helpdesk_kanban_board_statuses')->updateOrInsert( - ['board_id' => $board->id, 'status_id' => $status->id], - [ - 'sort_order' => ($index + 1) * 10, - 'created_at' => now(), - 'updated_at' => now(), - ], - ); - } - } - } - - /** - * The migration only repairs missing board columns and is intentionally not destructive. - */ - public function down(): void - { - // No-op. Do not remove configured board columns. - } -}; diff --git a/Modules/Helpdesk/Database/Migrations/2026_06_17_084500_add_lotus_replication_fields_to_helpdesk_comments.php b/Modules/Helpdesk/Database/Migrations/2026_06_17_084500_add_lotus_replication_fields_to_helpdesk_comments.php deleted file mode 100644 index e8b930d..0000000 --- a/Modules/Helpdesk/Database/Migrations/2026_06_17_084500_add_lotus_replication_fields_to_helpdesk_comments.php +++ /dev/null @@ -1,132 +0,0 @@ -uuid('guid')->nullable()->after('id'); - } - - if (! Schema::hasColumn('helpdesk_comments', 'unid')) { - $table->string('unid', 64)->nullable()->after('guid'); - } - - if (! Schema::hasColumn('helpdesk_comments', 'replica_id')) { - $table->string('replica_id', 64)->nullable()->after('unid'); - } - - if (! Schema::hasColumn('helpdesk_comments', 'parent_unid')) { - $table->string('parent_unid', 64)->nullable()->after('parent_id'); - } - - if (! Schema::hasColumn('helpdesk_comments', 'root_unid')) { - $table->string('root_unid', 64)->nullable()->after('parent_unid'); - } - - if (! Schema::hasColumn('helpdesk_comments', 'originator_id')) { - $table->string('originator_id', 96)->nullable()->after('root_unid'); - } - - if (! Schema::hasColumn('helpdesk_comments', 'sync_sequence')) { - $table->unsignedBigInteger('sync_sequence')->default(1)->after('originator_id'); - } - - if (! Schema::hasColumn('helpdesk_comments', 'sync_hash')) { - $table->string('sync_hash', 128)->nullable()->after('sync_sequence'); - } - - if (! Schema::hasColumn('helpdesk_comments', 'source_system')) { - $table->string('source_system', 80)->default('wms-helpdesk')->after('sync_hash'); - } - - if (! Schema::hasColumn('helpdesk_comments', 'source_record_id')) { - $table->string('source_record_id', 120)->nullable()->after('source_system'); - } - - if (! Schema::hasColumn('helpdesk_comments', 'body_text')) { - $table->text('body_text')->nullable()->after('body'); - } - - if (! Schema::hasColumn('helpdesk_comments', 'replicated_at')) { - $table->timestamp('replicated_at')->nullable()->after('updated_at'); - } - - if (! Schema::hasColumn('helpdesk_comments', 'synced_at')) { - $table->timestamp('synced_at')->nullable()->after('replicated_at'); - } - - if (! Schema::hasColumn('helpdesk_comments', 'tombstoned_at')) { - $table->timestamp('tombstoned_at')->nullable()->after('synced_at'); - } - }); - - Comment::query()->orderBy('id')->chunkById(100, static function ($comments): void { - foreach ($comments as $comment) { - $guid = $comment->guid ?: (string) Str::uuid(); - $unid = $comment->unid ?: strtoupper(hash('sha256', $guid . '|' . $comment->ticket_id . '|' . $comment->id)); - $parent = $comment->parent_id ? Comment::query()->find($comment->parent_id) : null; - $parentUnid = $parent?->unid; - $rootUnid = $parent?->root_unid ?: $parentUnid ?: $unid; - - DB::table('helpdesk_comments') - ->where('id', $comment->id) - ->update([ - 'guid' => $guid, - 'unid' => $unid, - 'parent_unid' => $parentUnid, - 'root_unid' => $rootUnid, - 'originator_id' => $comment->originator_id ?: $unid . ':1', - 'sync_sequence' => $comment->sync_sequence ?: 1, - 'sync_hash' => hash('sha256', (string) $comment->body), - 'body_text' => trim(strip_tags((string) $comment->body)), - 'source_system' => $comment->source_system ?: 'wms-helpdesk', - ]); - } - }); - - Schema::table('helpdesk_comments', static function (Blueprint $table): void { - $table->unique('guid', 'helpdesk_comments_guid_unique'); - $table->unique('unid', 'helpdesk_comments_unid_unique'); - $table->index(['ticket_id', 'root_unid', 'parent_unid'], 'helpdesk_comments_lotus_thread_index'); - $table->index(['ticket_id', 'is_internal', 'created_at'], 'helpdesk_comments_visibility_index'); - }); - } - - public function down(): void - { - Schema::table('helpdesk_comments', static function (Blueprint $table): void { - $table->dropUnique('helpdesk_comments_guid_unique'); - $table->dropUnique('helpdesk_comments_unid_unique'); - $table->dropIndex('helpdesk_comments_lotus_thread_index'); - $table->dropIndex('helpdesk_comments_visibility_index'); - - $table->dropColumn([ - 'guid', - 'unid', - 'replica_id', - 'parent_unid', - 'root_unid', - 'originator_id', - 'sync_sequence', - 'sync_hash', - 'source_system', - 'source_record_id', - 'body_text', - 'replicated_at', - 'synced_at', - 'tombstoned_at', - ]); - }); - } -}; diff --git a/Modules/Helpdesk/Database/Migrations/2026_06_17_111500_add_subject_to_helpdesk_comments.php b/Modules/Helpdesk/Database/Migrations/2026_06_17_111500_add_subject_to_helpdesk_comments.php deleted file mode 100644 index c1153dc..0000000 --- a/Modules/Helpdesk/Database/Migrations/2026_06_17_111500_add_subject_to_helpdesk_comments.php +++ /dev/null @@ -1,53 +0,0 @@ -string('subject', 180)->nullable()->after('user_id'); - } - }); - - DB::table('helpdesk_comments') - ->whereNull('subject') - ->orWhere('subject', '') - ->orderBy('id') - ->select(['id', 'parent_id', 'body', 'body_text', 'is_internal']) - ->chunkById(100, static function ($comments): void { - foreach ($comments as $comment) { - $plain = trim(preg_replace('/\s+/', ' ', strip_tags((string) ($comment->body_text ?: $comment->body))) ?: ''); - $subject = (bool) $comment->is_internal - ? 'Internal note' - : ((int) ($comment->parent_id ?? 0) > 0 ? 'RE: ' : '') . Str::limit($plain !== '' ? $plain : 'Response', 120, ''); - - DB::table('helpdesk_comments') - ->where('id', $comment->id) - ->update(['subject' => $subject]); - } - }); - - Schema::table('helpdesk_comments', static function (Blueprint $table): void { - $table->index(['ticket_id', 'subject'], 'helpdesk_comments_ticket_subject_index'); - }); - } - - public function down(): void - { - Schema::table('helpdesk_comments', static function (Blueprint $table): void { - if (Schema::hasColumn('helpdesk_comments', 'subject')) { - $table->dropIndex('helpdesk_comments_ticket_subject_index'); - $table->dropColumn('subject'); - } - }); - } -}; diff --git a/Modules/Helpdesk/Database/Migrations/2026_06_17_123000_add_deletion_audit_fields_to_helpdesk_comments.php b/Modules/Helpdesk/Database/Migrations/2026_06_17_123000_add_deletion_audit_fields_to_helpdesk_comments.php deleted file mode 100644 index 2c9a54b..0000000 --- a/Modules/Helpdesk/Database/Migrations/2026_06_17_123000_add_deletion_audit_fields_to_helpdesk_comments.php +++ /dev/null @@ -1,40 +0,0 @@ -timestamp('tombstoned_at')->nullable()->after('synced_at'); - } - - if (! Schema::hasColumn('helpdesk_comments', 'deleted_by')) { - $table->foreignId('deleted_by')->nullable()->after('tombstoned_at')->constrained('users')->nullOnDelete(); - } - - if (! Schema::hasColumn('helpdesk_comments', 'delete_reason')) { - $table->string('delete_reason', 255)->nullable()->after('deleted_by'); - } - }); - } - - public function down(): void - { - Schema::table('helpdesk_comments', static function (Blueprint $table): void { - if (Schema::hasColumn('helpdesk_comments', 'deleted_by')) { - $table->dropConstrainedForeignId('deleted_by'); - } - - if (Schema::hasColumn('helpdesk_comments', 'delete_reason')) { - $table->dropColumn('delete_reason'); - } - }); - } -}; diff --git a/Modules/Helpdesk/Database/Migrations/2026_06_22_000001_add_timer_fields_to_helpdesk_time_logs_table.php b/Modules/Helpdesk/Database/Migrations/2026_06_22_000001_add_timer_fields_to_helpdesk_time_logs_table.php deleted file mode 100644 index 41a2423..0000000 --- a/Modules/Helpdesk/Database/Migrations/2026_06_22_000001_add_timer_fields_to_helpdesk_time_logs_table.php +++ /dev/null @@ -1,54 +0,0 @@ -timestamp('started_at')->nullable()->after('logged_at'); - } - - if (! Schema::hasColumn($tableName, 'ended_at')) { - $table->timestamp('ended_at')->nullable()->after('started_at'); - } - - if (! Schema::hasColumn($tableName, 'duration_seconds')) { - $table->unsignedInteger('duration_seconds')->default(0)->after('minutes'); - } - }); - } - } - - /** - * Remove persistent timer fields from the existing helpdesk time log table. - */ - public function down(): void - { - foreach (['helpdesk_time_logs', 'time_logs'] as $tableName) { - if (! Schema::hasTable($tableName)) { - continue; - } - - Schema::table($tableName, function (Blueprint $table) use ($tableName): void { - foreach (['duration_seconds', 'ended_at', 'started_at'] as $column) { - if (Schema::hasColumn($tableName, $column)) { - $table->dropColumn($column); - } - } - }); - } - } -}; diff --git a/Modules/Helpdesk/Database/Migrations/2026_06_22_000001_create_helpdesk_ticket_time_logs_table.php b/Modules/Helpdesk/Database/Migrations/2026_06_22_000001_create_helpdesk_ticket_time_logs_table.php deleted file mode 100644 index 050dcf6..0000000 --- a/Modules/Helpdesk/Database/Migrations/2026_06_22_000001_create_helpdesk_ticket_time_logs_table.php +++ /dev/null @@ -1,41 +0,0 @@ -id(); - $table->foreignId('ticket_id')->constrained('helpdesk_tickets')->cascadeOnDelete(); - $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); - $table->timestamp('started_at'); - $table->timestamp('ended_at')->nullable(); - $table->unsignedInteger('duration_seconds')->default(0); - $table->unsignedInteger('minutes')->default(0); - $table->text('description')->nullable(); - $table->timestamps(); - - $table->index(['ticket_id', 'user_id', 'ended_at'], 'helpdesk_timer_active_idx'); - $table->index(['ticket_id', 'started_at'], 'helpdesk_timer_ticket_started_idx'); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('helpdesk_ticket_time_logs'); - } -}; diff --git a/Modules/Helpdesk/Database/Migrations/2026_06_22_000001_create_helpdesk_time_logs_table.php b/Modules/Helpdesk/Database/Migrations/2026_06_22_000001_create_helpdesk_time_logs_table.php deleted file mode 100644 index e193224..0000000 --- a/Modules/Helpdesk/Database/Migrations/2026_06_22_000001_create_helpdesk_time_logs_table.php +++ /dev/null @@ -1,41 +0,0 @@ -id(); - $table->foreignId('ticket_id')->constrained('helpdesk_tickets')->cascadeOnDelete(); - $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); - $table->unsignedInteger('minutes'); - $table->text('description')->nullable(); - $table->timestamp('logged_at')->useCurrent(); - $table->timestamps(); - - $table->index(['ticket_id', 'logged_at']); - $table->index(['user_id', 'logged_at']); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('helpdesk_time_logs'); - } -}; diff --git a/Modules/Helpdesk/Database/Migrations/2026_06_22_124500_add_logged_at_to_helpdesk_ticket_time_logs_table.php b/Modules/Helpdesk/Database/Migrations/2026_06_22_124500_add_logged_at_to_helpdesk_ticket_time_logs_table.php deleted file mode 100644 index 75a6cb0..0000000 --- a/Modules/Helpdesk/Database/Migrations/2026_06_22_124500_add_logged_at_to_helpdesk_ticket_time_logs_table.php +++ /dev/null @@ -1,45 +0,0 @@ -timestamp('logged_at')->nullable()->after('ended_at')->index(); - }); - } - - /** - * Remove the logged_at timestamp if this migration is rolled back. - */ - public function down(): void - { - if (! Schema::hasTable('helpdesk_ticket_time_logs')) { - return; - } - - if (! Schema::hasColumn('helpdesk_ticket_time_logs', 'logged_at')) { - return; - } - - Schema::table('helpdesk_ticket_time_logs', function (Blueprint $table): void { - $table->dropIndex(['logged_at']); - $table->dropColumn('logged_at'); - }); - } -}; diff --git a/Modules/Helpdesk/Database/Migrations/2026_06_22_130000_add_seconds_to_helpdesk_ticket_time_logs_table.php b/Modules/Helpdesk/Database/Migrations/2026_06_22_130000_add_seconds_to_helpdesk_ticket_time_logs_table.php deleted file mode 100644 index 3c2e379..0000000 --- a/Modules/Helpdesk/Database/Migrations/2026_06_22_130000_add_seconds_to_helpdesk_ticket_time_logs_table.php +++ /dev/null @@ -1,40 +0,0 @@ -unsignedInteger('seconds')->default(0)->after('minutes'); - } - }); - } - - /** - * Remove seconds precision from helpdesk ticket timer logs. - */ - public function down(): void - { - if (! Schema::hasTable('helpdesk_ticket_time_logs')) { - return; - } - - Schema::table('helpdesk_ticket_time_logs', function (Blueprint $table): void { - if (Schema::hasColumn('helpdesk_ticket_time_logs', 'seconds')) { - $table->dropColumn('seconds'); - } - }); - } -}; diff --git a/Modules/Helpdesk/Database/Migrations/2026_06_23_150000_normalise_helpdesk_lookup_colours.php b/Modules/Helpdesk/Database/Migrations/2026_06_23_150000_normalise_helpdesk_lookup_colours.php deleted file mode 100644 index 459ff37..0000000 --- a/Modules/Helpdesk/Database/Migrations/2026_06_23_150000_normalise_helpdesk_lookup_colours.php +++ /dev/null @@ -1,68 +0,0 @@ -lookupTables() as $table) { - if (! Schema::hasTable($table) || ! Schema::hasColumn($table, 'color')) { - continue; - } - - foreach ($this->colourMap() as $legacy => $approved) { - DB::table($table)->where('color', $legacy)->update(['color' => $approved]); - } - } - } - - /** - * The migration is intentionally one-way because approved colours are the new baseline. - */ - public function down(): void - { - // No-op: do not reintroduce legacy colour aliases. - } - - /** - * @return list - */ - private function lookupTables(): array - { - return [ - 'helpdesk_statuses', - 'helpdesk_priorities', - 'helpdesk_categories', - 'helpdesk_teams', - 'helpdesk_projects', - 'helpdesk_tags', - ]; - } - - /** - * @return array - */ - private function colourMap(): array - { - return [ - 'emerald' => 'green', - 'lime' => 'green', - 'rose' => 'red', - 'yellow' => 'amber', - 'orange' => 'amber', - 'sky' => 'blue', - 'cyan' => 'blue', - 'violet' => 'purple', - 'slate' => 'gray', - 'zinc' => 'gray', - ]; - } -}; diff --git a/Modules/Helpdesk/Database/Migrations/2026_06_24_000100_add_helpdesk_attachment_experience_metadata.php b/Modules/Helpdesk/Database/Migrations/2026_06_24_000100_add_helpdesk_attachment_experience_metadata.php deleted file mode 100644 index 4f549bc..0000000 --- a/Modules/Helpdesk/Database/Migrations/2026_06_24_000100_add_helpdesk_attachment_experience_metadata.php +++ /dev/null @@ -1,64 +0,0 @@ -string('extension', 32)->nullable()->after('original_name')->index(); - } - - if (! Schema::hasColumn('helpdesk_attachments', 'category')) { - $table->string('category', 32)->nullable()->after('extension')->index(); - } - - if (! Schema::hasColumn('helpdesk_attachments', 'checksum_sha256')) { - $table->string('checksum_sha256', 64)->nullable()->after('size')->index(); - } - - if (! Schema::hasColumn('helpdesk_attachments', 'download_count')) { - $table->unsignedInteger('download_count')->default(0)->after('checksum_sha256'); - } - - if (! Schema::hasColumn('helpdesk_attachments', 'last_downloaded_at')) { - $table->timestamp('last_downloaded_at')->nullable()->after('download_count'); - } - - if (! Schema::hasColumn('helpdesk_attachments', 'deleted_at')) { - $table->timestamp('deleted_at')->nullable()->after('last_downloaded_at')->index(); - } - - if (! Schema::hasColumn('helpdesk_attachments', 'deleted_by')) { - $table->foreignId('deleted_by')->nullable()->after('deleted_at')->constrained('users')->nullOnDelete(); - } - }); - } - - /** - * Remove attachment experience metadata. - */ - public function down(): void - { - Schema::table('helpdesk_attachments', static function (Blueprint $table): void { - if (Schema::hasColumn('helpdesk_attachments', 'deleted_by')) { - $table->dropConstrainedForeignId('deleted_by'); - } - - foreach (['deleted_at', 'last_downloaded_at', 'download_count', 'checksum_sha256', 'category', 'extension'] as $column) { - if (Schema::hasColumn('helpdesk_attachments', $column)) { - $table->dropColumn($column); - } - } - }); - } -}; diff --git a/Modules/Helpdesk/Database/Seeders/.gitkeep b/Modules/Helpdesk/Database/Seeders/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/Modules/Helpdesk/Database/Seeders/HelpdeskDatabaseSeeder.php b/Modules/Helpdesk/Database/Seeders/HelpdeskDatabaseSeeder.php deleted file mode 100644 index 3e9f7d1..0000000 --- a/Modules/Helpdesk/Database/Seeders/HelpdeskDatabaseSeeder.php +++ /dev/null @@ -1,277 +0,0 @@ -seedStatuses(); - $this->seedPriorities(); - $this->seedCategories(); - $this->seedTeamsAndProjects(); - $this->seedKanbanBoards(); - $this->seedWorkflowTemplates(); - $this->seedRedmineInspiredDefaults(); - } - - /** - * Seed ticket workflow statuses. - */ - private function seedStatuses(): void - { - - Status::query()->where('is_default', true)->update(['is_default' => false]); - - foreach ([ - ['name' => 'New', 'slug' => 'new', 'color' => 'blue', 'sort_order' => 5, 'is_closed' => false, 'is_default' => true], - ['name' => 'Open', 'slug' => 'open', 'color' => 'green', 'sort_order' => 10, 'is_closed' => false, 'is_default' => false], - ['name' => 'In Progress', 'slug' => 'in-progress', 'color' => 'indigo', 'sort_order' => 20, 'is_closed' => false, 'is_default' => false], - ['name' => 'Waiting Customer', 'slug' => 'waiting-customer', 'color' => 'amber', 'sort_order' => 30, 'is_closed' => false, 'is_default' => false], - ['name' => 'Waiting Third Party', 'slug' => 'waiting-third-party', 'color' => 'purple', 'sort_order' => 40, 'is_closed' => false, 'is_default' => false], - ['name' => 'Resolved', 'slug' => 'resolved', 'color' => 'green', 'sort_order' => 80, 'is_closed' => true, 'is_default' => false], - ['name' => 'Closed', 'slug' => 'closed', 'color' => 'gray', 'sort_order' => 90, 'is_closed' => true, 'is_default' => false], - ['name' => 'Cancelled', 'slug' => 'cancelled', 'color' => 'red', 'sort_order' => 100, 'is_closed' => true, 'is_default' => false], - ] as $row) { - Status::query()->updateOrCreate(['slug' => $row['slug']], $row); - } - } - - /** - * Seed ticket priorities. - */ - private function seedPriorities(): void - { - - Priority::query()->where('is_default', true)->update(['is_default' => false]); - foreach ([ - ['name' => 'Low', 'slug' => 'low', 'color' => 'gray', 'sort_order' => 10, 'is_default' => false], - ['name' => 'Normal', 'slug' => 'normal', 'color' => 'blue', 'sort_order' => 20, 'is_default' => true], - ['name' => 'High', 'slug' => 'high', 'color' => 'amber', 'sort_order' => 30, 'is_default' => false], - ['name' => 'Urgent', 'slug' => 'urgent', 'color' => 'red', 'sort_order' => 40, 'is_default' => false], - ['name' => 'Critical', 'slug' => 'critical', 'color' => 'red', 'sort_order' => 50, 'is_default' => false], - ] as $row) { - Priority::query()->updateOrCreate(['slug' => $row['slug']], $row); - } - } - - /** - * Seed ticket categories. - */ - private function seedCategories(): void - { - Category::query() - ->whereNull('company_id') - ->where('is_default', true) - ->update(['is_default' => false]); - foreach ([ - ['name' => 'Development', 'color' => 'indigo', 'description' => 'Software changes, enhancements and development work.', 'is_default' => true], - ['name' => 'Systems Deployment', 'color' => 'blue', 'description' => 'Deployment, release, infrastructure and environment requests.', 'is_default' => false], - ['name' => 'Concepts', 'color' => 'purple', 'description' => 'Ideas, concepts, design discussion and future improvements.', 'is_default' => false], - ['name' => 'Bug', 'color' => 'red', 'description' => 'Defects, regressions and incorrect behaviour.', 'is_default' => false], - ] as $row) { - Category::query()->updateOrCreate( - ['company_id' => null, 'slug' => Str::slug($row['name'])], - $row + ['company_id' => null, 'slug' => Str::slug($row['name'])], - ); - } - } - - /** - * Seed the default support team and a default project. - */ - private function seedTeamsAndProjects(): void - { - - Team::query() - ->whereNull('company_id') - ->where('is_default', true) - ->update(['is_default' => false]); - - Project::query() - ->whereNull('company_id') - ->where('is_default', true) - ->update(['is_default' => false]); - - $team = Team::query()->updateOrCreate( - ['company_id' => null, 'slug' => 'support'], - [ - 'company_id' => null, - 'name' => 'Support', - 'slug' => 'support', - 'color' => 'blue', - 'description' => 'Default support team for triage, ownership and ticket routing.', - 'is_default' => true, - ], - ); - - Project::query()->updateOrCreate( - ['company_id' => null, 'slug' => 'support-desk'], - [ - 'company_id' => null, - 'team_id' => $team->id, - 'title' => 'Support Desk', - 'slug' => 'support-desk', - 'color' => 'indigo', - 'description' => 'Default project for operational helpdesk tickets.', - 'is_default' => true, - ], - ); - } - - /** - * Seed the default configurable Kanban board. - */ - private function seedKanbanBoards(): void - { - KanbanBoard::query()->where('is_default', true)->update(['is_default' => false]); - - $board = KanbanBoard::query()->updateOrCreate( - ['slug' => 'standard-support'], - [ - 'name' => 'Standard Support', - 'slug' => 'standard-support', - 'description' => 'Default Helpdesk workflow board covering new work through closure.', - 'is_default' => true, - 'sort_order' => 1, - ], - ); - - $statuses = Status::query() - ->whereIn('slug', ['new', 'open', 'in-progress', 'stalled', 'on-hold', 'closed']) - ->orderBy('sort_order') - ->get(); - - $sync = []; - - foreach ($statuses as $index => $status) { - $sync[$status->id] = ['sort_order' => ($index + 1) * 10]; - } - - $board->statuses()->syncWithoutDetaching($sync); - } - - /** - * Seed default professional workflow email templates. - */ - private function seedWorkflowTemplates(): void - { - foreach ([ - ['name' => 'Ticket raised requester notice', 'event' => 'ticket.created', 'subject' => 'Ticket {{ ticket.reference }} has been raised', 'html_body' => '

Hello {{ requester.name }},

Your ticket {{ ticket.reference }} has been raised successfully.

Subject: {{ ticket.subject }}

Priority: {{ ticket.priority }}

', 'to_role' => 'requester'], - ['name' => 'Ticket updated requester notice', 'event' => 'ticket.updated', 'subject' => 'Ticket {{ ticket.reference }} has been updated', 'html_body' => '

Hello {{ requester.name }},

Your ticket {{ ticket.reference }} has been updated.

Status: {{ ticket.status }}

Priority: {{ ticket.priority }}

', 'to_role' => 'requester'], - ['name' => 'Ticket response requester notice', 'event' => 'ticket.commented', 'subject' => 'New response on ticket {{ ticket.reference }}', 'html_body' => '

Hello {{ requester.name }},

A new response has been added to {{ ticket.reference }}.

Subject: {{ ticket.subject }}

', 'to_role' => 'requester'], - ['name' => 'Ticket closed requester notice', 'event' => 'ticket.closed', 'subject' => 'Ticket {{ ticket.reference }} has been closed', 'html_body' => '

Hello {{ requester.name }},

Your ticket {{ ticket.reference }} has been closed.

Thank you for using the support desk.

', 'to_role' => 'requester'], - ] as $template) { - WorkflowTemplate::query()->updateOrCreate( - ['name' => $template['name'], 'event' => $template['event']], - $template + ['text_body' => null, 'cc' => null, 'bcc' => null, 'is_active' => true], - ); - } - } - - /** - * Seed RedmineUP-inspired professional helpdesk defaults. - */ - private function seedRedmineInspiredDefaults(): void - { - $medium = Priority::query()->where('slug', 'medium')->first(); - $bug = Category::query()->where('slug', 'bug')->first(); - - SlaPolicy::query()->updateOrCreate( - ['company_id' => null, 'slug' => 'standard-support-sla'], - [ - 'company_id' => null, - 'priority_id' => $medium?->id, - 'category_id' => null, - 'name' => 'Standard Support SLA', - 'slug' => 'standard-support-sla', - 'first_response_minutes' => 240, - 'resolution_minutes' => 2880, - 'business_hours_only' => false, - 'is_default' => true, - 'is_active' => true, - ], - ); - - CannedResponse::query()->updateOrCreate( - ['company_id' => null, 'slug' => 'professional-acknowledgement'], - [ - 'company_id' => null, - 'category_id' => null, - 'team_id' => null, - 'name' => 'Professional acknowledgement', - 'slug' => 'professional-acknowledgement', - 'visibility' => 'agents', - 'subject' => null, - 'body' => '

Hello {{ requester.name }},

Thank you for the update on {{ ticket.reference }}. I am reviewing this now and will come back with the next action shortly.

Regards,
{{ agent.name }}

', - 'is_active' => true, - 'sort_order' => 10, - ], - ); - - ChecklistTemplate::query()->updateOrCreate( - ['company_id' => null, 'slug' => 'bug-triage-checklist'], - [ - 'company_id' => null, - 'category_id' => $bug?->id, - 'project_id' => null, - 'name' => 'Bug triage checklist', - 'slug' => 'bug-triage-checklist', - 'items' => ['Confirm reproduction steps', 'Check recent deployment/build', 'Identify affected tenant/company', 'Record fix or workaround'], - 'is_active' => true, - ], - ); - - foreach ([ - ['name' => 'Escalated', 'slug' => 'escalated', 'color' => 'red'], - ['name' => 'Waiting on customer', 'slug' => 'waiting-on-customer', 'color' => 'amber'], - ] as $tag) { - Tag::query()->updateOrCreate( - ['company_id' => null, 'slug' => $tag['slug']], - $tag + ['company_id' => null], - ); - } - - if (Schema::hasColumn('helpdesk_tags', 'is_default')) { - Tag::query() - ->whereNull('company_id') - ->where('is_default', true) - ->update(['is_default' => false]); - } - - Product::query()->updateOrCreate( - ['company_id' => null, 'slug' => 'wms-platform'], - [ - 'company_id' => null, - 'name' => 'WMS Platform', - 'slug' => 'wms-platform', - 'type' => 'service', - 'version' => null, - 'release_channel' => 'core', - 'description' => 'Default service record for WMS platform support tickets.', - 'is_active' => true, - ], - ); - } -} diff --git a/Modules/Helpdesk/Database/factories/.gitkeep b/Modules/Helpdesk/Database/factories/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/Modules/Helpdesk/Database/factories/TicketFactory.php b/Modules/Helpdesk/Database/factories/TicketFactory.php deleted file mode 100644 index 783a0df..0000000 --- a/Modules/Helpdesk/Database/factories/TicketFactory.php +++ /dev/null @@ -1,162 +0,0 @@ - - */ -final class TicketFactory extends Factory -{ - /** - * The model name for the factory. - * - * @var class-string - */ - protected $model = Ticket::class; - - /** - * Define the model's default state. - * - * @return array - */ - public function definition(): array - { - $company = Company::query()->create([ - 'name' => 'Helpdesk Factory Company '.Str::random(8), - 'slug' => 'helpdesk-factory-company-'.Str::lower(Str::random(8)), - 'status' => 'active', - 'timezone' => 'Europe/London', - 'billing_email' => 'helpdesk-factory-'.Str::lower(Str::random(8)).'@example.test', - 'is_system_company' => false, - ]); - $user = User::factory()->create([ - 'company_id' => $company->getKey(), - ]); - - return $this->ticketPayload($company, $user); - } - - /** - * Build a ticket payload using only columns that exist in the current schema. - * - * @return array - */ - private function ticketPayload(Company $company, User $user): array - { - $ticket = new Ticket(); - $table = $ticket->getTable(); - - $data = []; - - $put = static function (string $column, mixed $value) use (&$data, $table): void { - if (Schema::hasColumn($table, $column)) { - $data[$column] = $value; - } - }; - - $put('company_id', $company->getKey()); - $put('user_id', $user->getKey()); - $put('owner_id', $user->getKey()); - $put('created_by_id', $user->getKey()); - $put('assigned_to_id', $user->getKey()); - $put('agent_id', $user->getKey()); - - $put('reference', 'HD-'.now()->format('Y').'-'.str_pad((string) $this->faker->unique()->numberBetween(1, 9999), 4, '0', STR_PAD_LEFT)); - $put('subject', 'Factory helpdesk ticket'); - $put('title', 'Factory helpdesk ticket'); - $put('body', '

Factory helpdesk ticket body.

'); - $put('details', '

Factory helpdesk ticket body.

'); - $put('description', '

Factory helpdesk ticket body.

'); - $put('search_text', 'factory helpdesk ticket body'); - $put('last_activity_at', now()); - - $statusId = $this->lookupId( - 'Modules\\Helpdesk\\Models\\TicketStatus', - ['name' => 'Open'], - ['slug' => 'open', 'color' => 'blue', 'is_closed' => false, 'sort_order' => 10] - ); - - $priorityId = $this->lookupId( - 'Modules\\Helpdesk\\Models\\TicketPriority', - ['name' => 'Normal'], - ['slug' => 'normal', 'color' => 'blue', 'sort_order' => 20] - ); - - $categoryId = $this->lookupId( - 'Modules\\Helpdesk\\Models\\TicketCategory', - ['name' => 'General'], - ['slug' => 'general', 'color' => 'blue', 'sort_order' => 10] - ); - - $teamId = $this->lookupId( - 'Modules\\Helpdesk\\Models\\HelpdeskTeam', - ['name' => 'Support'], - ['slug' => 'support', 'color' => 'blue', 'sort_order' => 10] - ); - - $projectId = $this->lookupId( - 'Modules\\Helpdesk\\Models\\HelpdeskProject', - ['name' => 'General'], - ['slug' => 'general', 'color' => 'blue', 'sort_order' => 10] - ); - - $put('status_id', $statusId); - $put('ticket_status_id', $statusId); - $put('priority_id', $priorityId); - $put('ticket_priority_id', $priorityId); - $put('category_id', $categoryId); - $put('ticket_category_id', $categoryId); - $put('team_id', $teamId); - $put('helpdesk_team_id', $teamId); - $put('project_id', $projectId); - $put('helpdesk_project_id', $projectId); - - $unid = strtoupper(str_replace('-', '', (string) Str::uuid())); - $put('guid', (string) Str::uuid()); - $put('uuid', (string) Str::uuid()); - $put('unid', $unid); - $put('root_unid', $unid); - $put('sync_sequence', 1); - $put('source_system', 'phpunit'); - - return array_filter($data, static fn (mixed $value): bool => $value !== null); - } - - /** - * Resolve or create a lookup row when the model exists. - */ - private function lookupId(string $class, array $identity, array $attributes): ?int - { - if (! class_exists($class)) { - return null; - } - - /** @var class-string<\Illuminate\Database\Eloquent\Model> $class */ - $model = new $class(); - - if (! Schema::hasTable($model->getTable())) { - return null; - } - - $payload = array_merge($identity, $attributes); - - foreach (['slug', 'color', 'sort_order', 'is_closed'] as $column) { - if (array_key_exists($column, $payload) && ! Schema::hasColumn($model->getTable(), $column)) { - unset($payload[$column]); - } - } - - $row = $class::query()->firstOrCreate($identity, $payload); - - return (int) $row->getKey(); - } -} diff --git a/Modules/Helpdesk/Documentation/Helpdesk_GitHub_Gitea_Integration_Guide.md b/Modules/Helpdesk/Documentation/Helpdesk_GitHub_Gitea_Integration_Guide.md deleted file mode 100644 index 4cff573..0000000 --- a/Modules/Helpdesk/Documentation/Helpdesk_GitHub_Gitea_Integration_Guide.md +++ /dev/null @@ -1,225 +0,0 @@ -# Helpdesk Git Integration Guide - -## Gitea & GitHub Integration, Ticket Linking, Development Workflow, and Deployment Standards - -Version: 1.0 -Module: Helpdesk -Platform: WMS Core -Last Updated: June 2026 - -## Overview - -The Helpdesk Git Integration allows tickets to be linked directly to commits, branches, pull requests, merges, repository activity, and deployments. - -### Ticket Reference Format - -```text -HD-YYYY-NNNNN -``` - -Example: - -```text -HD-2026-00012 -``` - -## Automatic Linking Rules - -The integration searches for ticket references in: - -- Commit messages -- Branch names -- Pull request titles -- Merge commits - -### Example Commit - -```text -HD-2026-00012 Fix ticket-level Git commit display -``` - -### Example Branch - -```text -bugfix/HD-2026-00012-fix-ticket-display -``` - -## GitFlow Branch Standards - -### Feature - -```text -feature/HD-2026-00012-add-sla-dashboard -``` - -### Bugfix - -```text -bugfix/HD-2026-00012-fix-ticket-display -``` - -### Hotfix - -```text -hotfix/HD-2026-00012-production-fix -``` - -## Commit Standards - -Always place the ticket reference first. - -```text -HD-2026-00012 Fix ticket-level Git commit display -``` - -## GitHub Setup - -### Repository - -```bash -git init -git remote add origin https://github.com/company/repository.git -git remote -v -``` - -### Webhook - -Settings → Webhooks → Add Webhook - -Webhook URL: - -```text -https://your-domain/helpdesk/git/github/webhook -``` - -Events: - -- Push -- Pull Request -- Pull Request Review -- Release - -## Gitea Setup - -### Repository - -```bash -git init -git remote add origin http://git.vm:3000/Admin/WMS-Core.git -git remote -v -``` - -### Webhook - -Settings → Webhooks → Add Webhook - -Webhook URL: - -```text -https://your-domain/helpdesk/git/gitea/webhook -``` - -Events: - -- Push -- Pull Request -- Merge -- Tags - -## Standard Workflow - -### Create Ticket - -```text -HD-2026-00012 -``` - -### Create Branch - -```bash -git checkout develop -git checkout -b bugfix/HD-2026-00012-fix-ticket-display -``` - -### Test - -```bash -composer test -``` - -### Commit - -```bash -git add . -git commit -m "HD-2026-00012 Fix ticket-level Git commit display" -``` - -### Push - -```bash -git push origin bugfix/HD-2026-00012-fix-ticket-display -``` - -### Pull Request - -```text -HD-2026-00012 Fix ticket-level Git commit display -``` - -## Manual Linking - -```php -Modules\Helpdesk\Models\GitCommit::find(7)->update([ - 'ticket_id' => 18, -]); -``` - -## Troubleshooting - -### Verify Commits - -```bash -php artisan tinker -``` - -```php -Modules\Helpdesk\Models\GitCommit::count(); -``` - -### Ticket Shows No Commits - -```php -Modules\Helpdesk\Models\GitCommit - ::where('ticket_id', 18) - ->get(); -``` - -or - -```php -Modules\Helpdesk\Models\GitCommit - ::where('message', 'like', '%HD-2026-00012%') - ->get(); -``` - -## Recommended Standard - -Every code change should contain: - -- Ticket reference in branch name -- Ticket reference in commit message -- Ticket reference in PR title - -Example: - -```text -Branch: -bugfix/HD-2026-00012-fix-ticket-display - -Commit: -HD-2026-00012 Fix ticket-level Git commit display - -PR: -HD-2026-00012 Fix ticket-level Git commit display -``` - diff --git a/Modules/Helpdesk/Helpers/.gitkeep b/Modules/Helpdesk/Helpers/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/Modules/Helpdesk/Helpers/Helper.php b/Modules/Helpdesk/Helpers/Helper.php deleted file mode 100644 index d5ac059..0000000 --- a/Modules/Helpdesk/Helpers/Helper.php +++ /dev/null @@ -1,13 +0,0 @@ -ticket; - abort_if($ticket === null, 404); - - Gate::authorize('update', $ticket); - - if (Schema::hasColumn('helpdesk_attachments', 'deleted_at')) { - $attachments->softDelete($attachment, (int) auth()->id()); - } else { - $attachments->delete($attachment); - } - - return redirect() - ->route('helpdesk.tickets.show', $ticket) - ->with('ticket-attachments-deleted', 'Attachment removed.'); - } -} diff --git a/Modules/Helpdesk/Http/Controllers/AttachmentDownloadController.php b/Modules/Helpdesk/Http/Controllers/AttachmentDownloadController.php deleted file mode 100644 index b21cb4d..0000000 --- a/Modules/Helpdesk/Http/Controllers/AttachmentDownloadController.php +++ /dev/null @@ -1,202 +0,0 @@ -where('id', $attachment)->first(); - - abort_if($row === null, 404); - - $ticketId = $row->ticket_id ?? null; - - if ($ticketId !== null) { - $ticket = Ticket::query()->findOrFail((int) $ticketId); - Gate::authorize('view', $ticket); - } - - $disk = (string) ($row->disk ?? 'public'); - $path = (string) ($row->path ?? $row->file_path ?? $row->stored_path ?? ''); - $name = (string) ($row->original_name - ?? $row->original_filename - ?? $row->file_name - ?? $row->filename - ?? basename($path)); - - abort_if($path === '', 404); - abort_unless(Storage::disk($disk)->exists($path), 404); - - if (Schema::hasColumn('helpdesk_attachments', 'download_count') && Schema::hasColumn('helpdesk_attachments', 'last_downloaded_at')) { - DB::table('helpdesk_attachments') - ->where('id', $attachment) - ->update([ - 'download_count' => DB::raw('download_count + 1'), - 'last_downloaded_at' => now(), - ]); - } - - return Storage::disk($disk)->download($path, $name); - } - - /** - * Preview a safe Helpdesk attachment using this existing controller so optimized - * Composer autoload does not need a newly introduced route action class. - * - * @throws FileNotFoundException - */ - public function preview(int $attachment): View|StreamedResponse|RedirectResponse - { - abort_unless(Schema::hasTable('helpdesk_attachments'), 404); - - $row = DB::table('helpdesk_attachments')->where('id', $attachment)->first(); - - abort_if($row === null, 404); - - $ticketId = $row->ticket_id ?? null; - - if ($ticketId !== null) { - $ticket = Ticket::query()->findOrFail((int) $ticketId); - Gate::authorize('view', $ticket); - } - - $disk = (string) ($row->disk ?? 'public'); - $path = (string) ($row->path ?? $row->file_path ?? $row->stored_path ?? ''); - $name = (string) ($row->original_name - ?? $row->original_filename - ?? $row->file_name - ?? $row->filename - ?? basename($path)); - $mimeType = (string) ($row->mime_type ?? 'application/octet-stream'); - $extension = strtolower((string) (($row->extension ?? null) ?: pathinfo($name, PATHINFO_EXTENSION))); - - abort_if($path === '', 404); - abort_unless(Storage::disk($disk)->exists($path), 404); - - if ($this->isSourceExtension($extension)) { - $contents = Storage::disk($disk)->get($path); - - return view('helpdesk::attachments.source-preview', [ - 'attachment' => $row, - 'contents' => is_string($contents) ? $contents : '', - 'language' => $this->languageForExtension($extension), - ]); - } - - abort_unless($this->isInlinePreviewable($extension, $mimeType), 404); - - return Storage::disk($disk)->response($path, $name, [ - 'Content-Disposition' => 'inline; filename="' . addslashes($name) . '"', - 'X-Content-Type-Options' => 'nosniff', - ]); - } - - /** - * Soft-delete or remove an attachment using this existing controller class. - */ - public function destroy(int $attachment): RedirectResponse - { - abort_unless(Schema::hasTable('helpdesk_attachments'), 404); - - $row = DB::table('helpdesk_attachments')->where('id', $attachment)->first(); - - abort_if($row === null, 404); - - $ticketId = $row->ticket_id ?? null; - abort_if($ticketId === null, 404); - - $ticket = Ticket::query()->findOrFail((int) $ticketId); - Gate::authorize('update', $ticket); - - if (Schema::hasColumn('helpdesk_attachments', 'deleted_at')) { - DB::table('helpdesk_attachments') - ->where('id', $attachment) - ->update(array_filter([ - 'deleted_at' => now(), - 'deleted_by' => Schema::hasColumn('helpdesk_attachments', 'deleted_by') ? (int) auth()->id() : null, - ], static fn (mixed $value): bool => $value !== null)); - } else { - Storage::disk((string) ($row->disk ?? 'public'))->delete((string) ($row->path ?? '')); - DB::table('helpdesk_attachments')->where('id', $attachment)->delete(); - } - - return redirect() - ->route('helpdesk.tickets.show', $ticket) - ->with('ticket-attachments-deleted', 'Attachment removed.'); - } - - /** - * Determine whether a file can be safely streamed inline by extension or MIME type. - */ - private function isInlinePreviewable(string $extension, string $mimeType): bool - { - return in_array($extension, ['png', 'jpg', 'jpeg', 'webp', 'gif', 'pdf'], true) - || str_starts_with($mimeType, 'image/') - || $mimeType === 'application/pdf'; - } - - /** - * Determine whether a source/configuration file can use the read-only viewer. - */ - private function isSourceExtension(string $extension): bool - { - return in_array($extension, config('helpdesk.allowed_source_extensions', [ - 'php', 'phtml', 'phps', 'js', 'jsx', 'ts', 'tsx', 'vue', 'css', 'scss', 'sass', 'less', - 'html', 'htm', 'xml', 'json', 'yaml', 'yml', 'sql', 'sh', 'bash', 'zsh', 'ps1', 'bat', 'cmd', - 'py', 'rb', 'go', 'rs', 'java', 'kt', 'kts', 'cs', 'cpp', 'c', 'h', 'hpp', 'swift', 'dart', - 'rpg', 'rpgle', 'cl', 'clle', 'cbl', 'cob', 'copybook', 'inc', 'env', 'ini', 'conf', 'config', 'txt', 'log', 'md', - ]), true); - } - - /** - * Return the source preview language key for display/highlighting. - */ - private function languageForExtension(string $extension): string - { - return match ($extension) { - 'php', 'phtml', 'phps' => 'php', - 'js', 'jsx' => 'javascript', - 'ts', 'tsx' => 'typescript', - 'vue' => 'vue', - 'css', 'scss', 'sass', 'less' => 'css', - 'html', 'htm' => 'html', - 'xml' => 'xml', - 'json' => 'json', - 'yaml', 'yml' => 'yaml', - 'sql' => 'sql', - 'sh', 'bash', 'zsh' => 'shell', - 'ps1' => 'powershell', - 'py' => 'python', - 'java', 'kt', 'kts' => 'java', - 'c', 'h', 'cpp', 'hpp', 'cs' => 'c-like', - 'rpg', 'rpgle' => 'rpgle', - 'cl', 'clle' => 'clle', - 'cbl', 'cob', 'copybook' => 'cobol', - default => 'text', - }; - } - -} diff --git a/Modules/Helpdesk/Http/Controllers/AttachmentPreviewController.php b/Modules/Helpdesk/Http/Controllers/AttachmentPreviewController.php deleted file mode 100644 index 02993a9..0000000 --- a/Modules/Helpdesk/Http/Controllers/AttachmentPreviewController.php +++ /dev/null @@ -1,50 +0,0 @@ -ticket; - - if ($ticket !== null) { - Gate::authorize('view', $ticket); - } - - abort_unless(Storage::disk($attachment->disk)->exists($attachment->path), 404); - - if ($attachment->isSourceCode()) { - $contents = Storage::disk($attachment->disk)->get($attachment->path); - - return view('helpdesk::attachments.source-preview', [ - 'attachment' => $attachment, - 'contents' => is_string($contents) ? $contents : '', - 'language' => $attachment->language(), - ]); - } - - abort_unless($attachment->isPreviewableBinary(), 404); - - return Storage::disk($attachment->disk)->response($attachment->path, $attachment->original_name, [ - 'Content-Disposition' => 'inline; filename="' . addslashes($attachment->original_name) . '"', - 'X-Content-Type-Options' => 'nosniff', - ]); - } -} diff --git a/Modules/Helpdesk/Http/Controllers/GitWebhookController.php b/Modules/Helpdesk/Http/Controllers/GitWebhookController.php deleted file mode 100644 index fab7f21..0000000 --- a/Modules/Helpdesk/Http/Controllers/GitWebhookController.php +++ /dev/null @@ -1,355 +0,0 @@ -provider ?: 'git')); - $event = $this->eventName($request, $provider); - $deliveryId = $this->deliveryId($request, $provider); - $rawPayload = $request->getContent(); - - if (! $repository->is_active) { - $result = ['status' => 'rejected', 'message' => 'Repository is inactive.', 'commit_count' => 0, 'linked_count' => 0, 'ignored_count' => 1]; - $service->logWebhook($repository, $provider, $deliveryId, $event, $result); - - return response()->json($result, 403); - } - - if (! $this->hasValidSignature($request, $repository, $provider, $rawPayload)) { - $result = ['status' => 'rejected', 'message' => 'Git webhook signature or token validation failed.', 'commit_count' => 0, 'linked_count' => 0, 'ignored_count' => 1]; - $service->logWebhook($repository, $provider, $deliveryId, $event, $result); - - return response()->json($result, 403); - } - - try { - /** @var array $payload */ - $payload = json_decode($rawPayload, true, 512, JSON_THROW_ON_ERROR); - } catch (JsonException) { - $result = ['status' => 'rejected', 'message' => 'Invalid JSON webhook payload.', 'commit_count' => 0, 'linked_count' => 0, 'ignored_count' => 1]; - $service->logWebhook($repository, $provider, $deliveryId, $event, $result); - - return response()->json($result, 400); - } - - $result = $service->ingest($repository, $payload, $provider, $deliveryId, $event); - - return response()->json($result, 202); - } - - /** - * Ingest the local Gitea webhook URL that does not include a repository id. - */ - public function gitea(Request $request, GitWebhookIngestionService $service): JsonResponse - { - $provider = 'gitea'; - $event = $this->eventName($request, $provider); - $deliveryId = $this->deliveryId($request, $provider); - $rawPayload = $request->getContent(); - - try { - /** @var array $payload */ - $payload = json_decode($rawPayload, true, 512, JSON_THROW_ON_ERROR); - } catch (JsonException) { - return response()->json([ - 'status' => 'rejected', - 'message' => 'Invalid JSON webhook payload.', - 'commit_count' => 0, - 'linked_count' => 0, - 'ignored_count' => 1, - ], 400); - } - - $repository = $this->repositoryForGiteaPayload($payload); - - if (! $repository instanceof GitRepository) { - return response()->json([ - 'status' => 'rejected', - 'message' => 'No active Helpdesk Git repository matched the Gitea webhook payload.', - 'commit_count' => 0, - 'linked_count' => 0, - 'ignored_count' => 1, - ], 404); - } - - if (! $this->hasValidGiteaSecurity($request, $repository, $provider, $rawPayload)) { - $result = ['status' => 'rejected', 'message' => 'Git webhook signature or token validation failed.', 'commit_count' => 0, 'linked_count' => 0, 'ignored_count' => 1]; - $service->logWebhook($repository, $provider, $deliveryId, $event, $result, $payload); - - return response()->json($result, 403); - } - - $result = $service->ingest($repository, $payload, $provider, $deliveryId, $event); - - return response()->json($result, 202); - } - - /** - * Validate provider-specific webhook signatures or tokens. - */ - private function hasValidSignature(Request $request, GitRepository $repository, string $provider, string $rawPayload): bool - { - $secret = $this->secretForProvider($repository, $provider); - - if ($secret === '') { - return false; - } - - return match ($provider) { - 'github' => $this->hasValidGitHubSignature($request, $secret, $rawPayload), - 'gitlab' => hash_equals($secret, (string) $request->header('X-Gitlab-Token', '')), - 'gitea' => $this->hasValidGiteaSignature($request, $secret, $rawPayload), - 'bitbucket' => $this->hasValidBitbucketSignature($request, $secret, $rawPayload), - default => $this->hasValidGenericSignature($request, $secret, $rawPayload), - }; - } - - /** - * Resolve the repository webhook secret, with local Gitea env fallback support. - */ - private function secretForProvider(GitRepository $repository, string $provider): string - { - $secret = trim((string) $repository->webhook_secret); - - if ($secret !== '') { - return $secret; - } - - return $provider === 'gitea' ? trim((string) config('services.gitea.webhook_secret', '')) : ''; - } - - /** - * Validate GitHub's current SHA-256 signature and legacy SHA-1 signature. - */ - private function hasValidGitHubSignature(Request $request, string $secret, string $rawPayload): bool - { - $sha256Signature = (string) $request->header('X-Hub-Signature-256', ''); - $sha1Signature = (string) $request->header('X-Hub-Signature', ''); - - return hash_equals('sha256=' . hash_hmac('sha256', $rawPayload, $secret), $sha256Signature) - || hash_equals('sha1=' . hash_hmac('sha1', $rawPayload, $secret), $sha1Signature); - } - - /** - * Validate Gitea's raw SHA-256 signature and GitHub-compatible HMAC headers. - */ - private function hasValidGiteaSignature(Request $request, string $secret, string $rawPayload): bool - { - $giteaSignature = (string) $request->header('X-Gitea-Signature', ''); - - $sha256 = hash_hmac('sha256', $rawPayload, $secret); - $sha1 = hash_hmac('sha1', $rawPayload, $secret); - - return hash_equals($sha256, $giteaSignature) - || hash_equals('sha256=' . $sha256, $giteaSignature) - || hash_equals('sha1=' . $sha1, $giteaSignature) - || $this->hasValidGitHubSignature($request, $secret, $rawPayload) - || $this->hasValidGenericSignature($request, $secret, $rawPayload); - } - - /** - * Validate Bitbucket's webhook HMAC signature. - */ - private function hasValidBitbucketSignature(Request $request, string $secret, string $rawPayload): bool - { - $signature = (string) $request->header('X-Hub-Signature', ''); - - return hash_equals('sha256=' . hash_hmac('sha256', $rawPayload, $secret), $signature) - || hash_equals($secret, (string) $request->header('X-Helpdesk-Token', '')); - } - - /** - * Validate a generic Git webhook using either a token header or HMAC signature. - */ - private function hasValidGenericSignature(Request $request, string $secret, string $rawPayload): bool - { - $token = (string) ($request->header('X-Helpdesk-Token') ?: $request->header('X-Gitlab-Token') ?: $request->header('X-Gitea-Token')); - $signature = (string) ($request->header('X-Hub-Signature-256') ?: $request->header('X-Hub-Signature')); - - return hash_equals($secret, $token) - || hash_equals('sha256=' . hash_hmac('sha256', $rawPayload, $secret), $signature) - || hash_equals('sha1=' . hash_hmac('sha1', $rawPayload, $secret), $signature); - } - - /** - * Validate Gitea webhook security without allowing an optional Authorization - * header mismatch to hide otherwise valid signed push deliveries. - */ - private function hasValidGiteaSecurity(Request $request, GitRepository $repository, string $provider, string $rawPayload): bool - { - if ($this->hasValidSignature($request, $repository, $provider, $rawPayload)) { - return true; - } - - return $this->hasValidGiteaAuthorization($request); - } - - /** - * Validate the optional Authorization header configured for the local Gitea webhook. - */ - private function hasValidGiteaAuthorization(Request $request): bool - { - $expected = trim((string) config('services.gitea.webhook_authorization', '')); - - if ($expected === '') { - return true; - } - - $actual = trim((string) $request->header('Authorization', '')); - - return hash_equals($expected, $actual) - || hash_equals('Basic ' . $expected, $actual) - || (Str::startsWith($expected, 'Basic ') && hash_equals($expected, $actual)); - } - - /** - * Match a Gitea webhook payload to the configured Helpdesk repository. - * - * @param array $payload - */ - private function repositoryForGiteaPayload(array $payload): ?GitRepository - { - $repositories = GitRepository::query() - ->where('is_active', true) - ->whereRaw('LOWER(provider) IN (?, ?)', ['gitea', 'git']) - ->get(); - - if ($repositories->isEmpty()) { - Log::warning('Helpdesk Gitea webhook received but no active Gitea/git repository is configured.'); - - return null; - } - - $payloadValues = array_values(array_filter([ - Arr::get($payload, 'repository.full_name'), - Arr::get($payload, 'repository.name'), - Arr::get($payload, 'repository.html_url'), - Arr::get($payload, 'repository.clone_url'), - Arr::get($payload, 'repository.ssh_url'), - Arr::get($payload, 'repository.url'), - Arr::get($payload, 'repository.owner.username') && Arr::get($payload, 'repository.name') - ? Arr::get($payload, 'repository.owner.username') . '/' . Arr::get($payload, 'repository.name') - : null, - ], static fn (mixed $value): bool => is_scalar($value) && trim((string) $value) !== '')); - - $normalisedPayloadValues = array_values(array_filter(array_map( - fn (mixed $value): ?string => $this->normaliseRepositoryIdentity((string) $value), - $payloadValues, - ))); - - foreach ($repositories as $repository) { - $repositoryValues = array_filter([ - $repository->remote_url, - $repository->name, - Str::of((string) $repository->remote_url)->afterLast(':')->beforeLast('.git')->value(), - ], static fn (mixed $value): bool => is_scalar($value) && trim((string) $value) !== ''); - - $normalisedRepositoryValues = array_values(array_filter(array_map( - fn (mixed $value): ?string => $this->normaliseRepositoryIdentity((string) $value), - $repositoryValues, - ))); - - foreach ($normalisedPayloadValues as $payloadValue) { - foreach ($normalisedRepositoryValues as $repositoryValue) { - if ($payloadValue === $repositoryValue - || str_ends_with($repositoryValue, '/' . $payloadValue) - || str_ends_with($payloadValue, '/' . $repositoryValue)) { - return $repository; - } - } - } - } - - if ($repositories->count() === 1) { - return $repositories->first(); - } - - Log::warning('Helpdesk Gitea webhook did not match any configured repository.', [ - 'payload_repository' => Arr::get($payload, 'repository.full_name') ?: Arr::get($payload, 'repository.clone_url') ?: Arr::get($payload, 'repository.html_url'), - 'configured_repository_count' => $repositories->count(), - ]); - - return null; - } - - /** - * Normalise a repository name or URL so Gitea payloads match local settings reliably. - */ - private function normaliseRepositoryIdentity(string $value): ?string - { - $value = trim($value); - - if ($value === '') { - return null; - } - - $value = Str::of($value) - ->replace('\\', '/') - ->replace('ssh://git@', 'ssh://') - ->replace('git@', '') - ->replace('.git', '') - ->lower() - ->value(); - - $path = parse_url($value, PHP_URL_PATH); - - if (is_string($path) && trim($path, '/') !== '') { - $value = trim($path, '/'); - } elseif (str_contains($value, ':')) { - $value = Str::of($value)->afterLast(':')->trim('/')->value(); - } - - $segments = array_values(array_filter(explode('/', trim($value, '/')))); - - if ($segments === []) { - return null; - } - - return implode('/', array_slice($segments, -2)); - } - - /** - * Read the event name from provider-specific headers. - */ - private function eventName(Request $request, string $provider): ?string - { - return match ($provider) { - 'github' => $request->header('X-GitHub-Event'), - 'gitlab' => $request->header('X-Gitlab-Event'), - 'gitea' => $request->header('X-Gitea-Event') ?: $request->header('X-Gitea-Event-Type') ?: $request->header('X-GitHub-Event'), - 'bitbucket' => $request->header('X-Event-Key'), - default => $request->header('X-Helpdesk-Event') ?: $request->header('X-GitHub-Event') ?: $request->header('X-Gitlab-Event') ?: 'push', - }; - } - - /** - * Read the provider delivery/request id used for idempotent audit tracing. - */ - private function deliveryId(Request $request, string $provider): ?string - { - return match ($provider) { - 'github' => $request->header('X-GitHub-Delivery'), - 'gitlab' => $request->header('X-Gitlab-Event-UUID') ?: $request->header('X-Request-Id'), - 'gitea' => $request->header('X-Gitea-Delivery') ?: $request->header('X-GitHub-Delivery') ?: $request->header('X-Request-Id'), - 'bitbucket' => $request->header('X-Request-UUID') ?: $request->header('X-Request-Id'), - default => $request->header('X-Helpdesk-Delivery') ?: $request->header('X-GitHub-Delivery') ?: $request->header('X-Request-Id'), - }; - } -} diff --git a/Modules/Helpdesk/Http/Controllers/HelpdeskController.php b/Modules/Helpdesk/Http/Controllers/HelpdeskController.php deleted file mode 100644 index 8cbefea..0000000 --- a/Modules/Helpdesk/Http/Controllers/HelpdeskController.php +++ /dev/null @@ -1,23 +0,0 @@ - __('Helpdesk'), - 'slot' => new HtmlString(view('helpdesk::index')->render()), - ]); - } -} diff --git a/Modules/Helpdesk/Http/Controllers/TicketAttachmentStoreController.php b/Modules/Helpdesk/Http/Controllers/TicketAttachmentStoreController.php deleted file mode 100644 index 05ace4c..0000000 --- a/Modules/Helpdesk/Http/Controllers/TicketAttachmentStoreController.php +++ /dev/null @@ -1,75 +0,0 @@ -validate([ - 'attachments' => ['required', 'array', 'min:1'], - 'attachments.*' => $this->attachmentFileRules(required: true), - ]); - - $user = Auth::user(); - abort_if($user === null, 403); - - $uploadedFiles = $request->file('attachments', []); - $uploadedFiles = is_array($uploadedFiles) ? $uploadedFiles : [$uploadedFiles]; - $uploadedFiles = array_values(array_filter($uploadedFiles, static fn ($file): bool => $file instanceof UploadedFile && $file->isValid())); - - $attachments->storeMany($ticket, $uploadedFiles, (int) $user->id); - - return redirect() - ->route('helpdesk.tickets.show', $ticket) - ->with('ticket-attachments-added', count($uploadedFiles) . ' supporting file' . (count($uploadedFiles) === 1 ? '' : 's') . ' uploaded.'); - } - - /** - * Return Helpdesk attachment validation rules without relying on a newly autoloaded support class. - * - * @param bool $required Whether at least one uploaded file is required. - * @return array - */ - private function attachmentFileRules(bool $required = false): array - { - return array_values(array_filter([ - $required ? 'required' : null, - 'file', - 'max:' . (int) config('helpdesk.max_upload_kb', 10240), - 'extensions:' . implode(',', $this->allowedAttachmentExtensions()), - ])); - } - - /** - * Return lower-case Helpdesk attachment extensions. - * - * @return array - */ - private function allowedAttachmentExtensions(): array - { - return collect(config('helpdesk.allowed_extensions', config('helpdesk.allowed_mimes', []))) - ->filter(static fn (mixed $extension): bool => is_string($extension) && trim($extension) !== '') - ->map(static fn (string $extension): string => strtolower(ltrim(trim($extension), '.'))) - ->unique() - ->values() - ->all(); - } - -} diff --git a/Modules/Helpdesk/Http/Controllers/TicketBoardController.php b/Modules/Helpdesk/Http/Controllers/TicketBoardController.php deleted file mode 100644 index 965b966..0000000 --- a/Modules/Helpdesk/Http/Controllers/TicketBoardController.php +++ /dev/null @@ -1,105 +0,0 @@ -validate([ - 'board_id' => ['required', 'integer', Rule::exists('helpdesk_kanban_boards', 'id')], - 'return_to_board_id' => ['nullable', 'integer', Rule::exists('helpdesk_kanban_boards', 'id')], - ]); - - $targetBoardId = (int) $validated['board_id']; - - $targetBoard = KanbanBoard::query() - ->with(['statuses' => fn ($query) => $query->orderBy('helpdesk_kanban_board_statuses.sort_order')->orderBy('helpdesk_statuses.sort_order')->orderBy('helpdesk_statuses.name')]) - ->findOrFail($targetBoardId); - - if ($targetBoard->statuses->isEmpty()) { - $this->ensureBoardHasStatuses($targetBoardId); - $targetBoard->load(['statuses' => fn ($query) => $query->orderBy('helpdesk_kanban_board_statuses.sort_order')->orderBy('helpdesk_statuses.sort_order')->orderBy('helpdesk_statuses.name')]); - } - - $fresh = DB::transaction(function () use ($ticket, $targetBoard): Ticket { - $lockedTicket = Ticket::query() - ->visibleTo(Auth::user()) - ->lockForUpdate() - ->findOrFail($ticket->id); - - $targetStatus = $targetBoard->statuses->firstWhere('id', (int) $lockedTicket->status_id) - ?: $targetBoard->statuses->first(); - - if ($targetStatus === null) { - $targetStatus = Status::query()->orderBy('sort_order')->orderBy('name')->firstOrFail(); - $this->addStatusToBoard($targetBoard->id, $targetStatus->id); - } - - $updates = [ - 'board_id' => $targetBoard->id, - 'status_id' => $targetStatus->id, - 'completed_at' => $targetStatus->is_closed ? now() : null, - 'last_activity_at' => now(), - ]; - - if (DB::getSchemaBuilder()->hasColumn('helpdesk_tickets', 'updated_at')) { - $updates['updated_at'] = now(); - } - - $affected = DB::table('helpdesk_tickets') - ->where('id', $lockedTicket->id) - ->update($updates); - - if ($affected < 1) { - throw new \RuntimeException('Ticket board update did not persist.'); - } - - return Ticket::query() - ->with(['status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'checklistItems', 'board']) - ->findOrFail($lockedTicket->id); - }); - - $workflow->dispatch($fresh, 'ticket.updated', Auth::user()); - - return redirect() - ->route('helpdesk.kanban', ['board_id' => $targetBoardId]) - ->with('success', 'Ticket ' . $fresh->reference . ' moved to ' . $targetBoard->name . '.'); - } - - private function ensureBoardHasStatuses(int $boardId): void - { - Status::query() - ->orderBy('sort_order') - ->orderBy('name') - ->get(['id']) - ->values() - ->each(function (Status $status, int $index) use ($boardId): void { - $this->addStatusToBoard($boardId, (int) $status->id, ($index + 1) * 10); - }); - } - - private function addStatusToBoard(int $boardId, int $statusId, int $sortOrder = 999): void - { - DB::table('helpdesk_kanban_board_statuses')->updateOrInsert( - ['board_id' => $boardId, 'status_id' => $statusId], - ['sort_order' => $sortOrder, 'updated_at' => now(), 'created_at' => now()], - ); - } -} diff --git a/Modules/Helpdesk/Http/Controllers/TicketCommentDeleteController.php b/Modules/Helpdesk/Http/Controllers/TicketCommentDeleteController.php deleted file mode 100644 index 4f8f578..0000000 --- a/Modules/Helpdesk/Http/Controllers/TicketCommentDeleteController.php +++ /dev/null @@ -1,75 +0,0 @@ -user(); - - abort_unless($user !== null && method_exists($user, 'isMpbaPlatformAdmin') && $user->isMpbaPlatformAdmin(), 403); - abort_unless((int) $comment->ticket_id === (int) $ticket->id, 404); - - DB::transaction(function () use ($comment, $ticket, $user): void { - $updates = []; - - if (Schema::hasColumn('helpdesk_comments', 'tombstoned_at')) { - $updates['tombstoned_at'] = now(); - } - - if (Schema::hasColumn('helpdesk_comments', 'deleted_by')) { - $updates['deleted_by'] = (int) $user->id; - } - - if (Schema::hasColumn('helpdesk_comments', 'delete_reason')) { - $updates['delete_reason'] = 'Deleted by MPBA platform administrator.'; - } - - if (Schema::hasColumn('helpdesk_comments', 'sync_sequence')) { - $updates['sync_sequence'] = ((int) ($comment->sync_sequence ?? 0)) + 1; - } - - if (Schema::hasColumn('helpdesk_comments', 'sync_hash')) { - $updates['sync_hash'] = hash('sha256', implode('|', [ - (string) ($comment->guid ?? $comment->id), - 'deleted', - now()->toISOString(), - ])); - } - - if (Schema::hasColumn('helpdesk_comments', 'updated_at')) { - $updates['updated_at'] = now(); - } - - if ($updates !== []) { - DB::table('helpdesk_comments') - ->where('id', $comment->id) - ->update($updates); - } else { - $comment->delete(); - } - - if (Schema::hasColumn('helpdesk_tickets', 'last_activity_at')) { - $ticket->forceFill(['last_activity_at' => now()])->save(); - } - }); - - return redirect() - ->route('helpdesk.tickets.show', $ticket) - ->with('ticket-comment-deleted', 'Response deleted.'); - } -} diff --git a/Modules/Helpdesk/Http/Controllers/TicketCommentStoreController.php b/Modules/Helpdesk/Http/Controllers/TicketCommentStoreController.php deleted file mode 100644 index 4026c47..0000000 --- a/Modules/Helpdesk/Http/Controllers/TicketCommentStoreController.php +++ /dev/null @@ -1,200 +0,0 @@ -validate([ - 'subject' => ['nullable', 'string', 'max:180'], - 'commentBody' => ['nullable', 'string'], - 'internal' => ['nullable', 'boolean'], - 'parent_id' => [ - 'nullable', - 'integer', - Rule::exists('helpdesk_comments', 'id')->where('ticket_id', $ticket->id), - ], - 'canned_response_id' => ['nullable', 'integer', Rule::exists('helpdesk_canned_responses', 'id')], - 'attachments' => ['nullable', 'array'], - 'attachments.*' => $this->attachmentFileRules(), - ]); - - $user = Auth::user(); - abort_if($user === null, 403); - - $body = trim((string) ($validated['commentBody'] ?? '')); - $subject = trim((string) ($validated['subject'] ?? '')); - - if (filled($validated['canned_response_id'] ?? null)) { - $response = CannedResponse::query() - ->where('is_active', true) - ->findOrFail((int) $validated['canned_response_id']); - - if ($body === '') { - $body = trim($renderer->render($response->body, $ticket, $user)); - } - - if ($subject === '') { - $subject = (string) $response->name; - } - } - - $body = $this->normaliseHtmlBody($body); - $subject = $this->normaliseSubject($subject, $body, filled($validated['parent_id'] ?? null)); - - validator(['commentBody' => $body], [ - 'commentBody' => ['required', 'string', 'min:2'], - ])->validate(); - - $uploadedFiles = $request->file('attachments', []); - $uploadedFiles = is_array($uploadedFiles) ? $uploadedFiles : [$uploadedFiles]; - $uploadedFiles = array_values(array_filter($uploadedFiles, static fn ($file): bool => $file instanceof UploadedFile && $file->isValid())); - - $tickets->comment( - ticket: $ticket, - user: $user, - body: $body, - internal: (bool) ($validated['internal'] ?? false) && $this->userCanCreateInternalNote($user), - files: $uploadedFiles, - parentId: filled($validated['parent_id'] ?? null) ? (int) $validated['parent_id'] : null, - subject: $subject, - ); - - return redirect() - ->route('helpdesk.tickets.show', $ticket) - ->with('ticket-comment-added', filled($validated['parent_id'] ?? null) ? 'Reply posted.' : 'Comment posted.'); - } - - /** - * Derive a user-friendly Lotus Notes style response subject. - */ - private function normaliseSubject(string $subject, string $body, bool $isReply): string - { - $subject = trim(strip_tags(html_entity_decode($subject, ENT_QUOTES | ENT_HTML5, 'UTF-8'))); - - if ($subject !== '') { - return Str::limit($subject, 180, ''); - } - - $plain = trim(preg_replace('/\s+/', ' ', strip_tags(html_entity_decode($body, ENT_QUOTES | ENT_HTML5, 'UTF-8'))) ?: ''); - - if ($plain === '') { - return $isReply ? 'RE: Response' : 'Response'; - } - - return ($isReply ? 'RE: ' : '') . Str::limit($plain, 120, ''); - } - - /** - * Keep supported formatting tags but strip scripts and unsafe inline handlers. - */ - private function normaliseHtmlBody(string $body): string - { - $body = trim($body); - - for ($i = 0; $i < 5; $i++) { - if (! str_contains($body, '<') && ! str_contains($body, '>') && ! str_contains($body, '&lt;')) { - break; - } - - $decoded = html_entity_decode($body, ENT_QUOTES | ENT_HTML5, 'UTF-8'); - - if ($decoded === $body) { - break; - } - - $body = $decoded; - } - - $body = preg_replace('/<\s*(script|style)[^>]*>.*?<\s*\/\s*\1\s*>/is', '', $body) ?: ''; - $body = preg_replace('/\s+on[a-z]+\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)/i', '', $body) ?: ''; - $body = preg_replace('/javascript\s*:/i', '', $body) ?: ''; - $body = trim(strip_tags($body, '