commit 903423cd6e0070b583582763aaab3e920604c6b5 Author: Mark Gregory Date: Tue Aug 25 12:36:04 2026 +0100 Initial Commit diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6df8428 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[{compose,docker-compose}.{yml,yaml}] +indent_size = 4 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..40bb13b --- /dev/null +++ b/.env.example @@ -0,0 +1,65 @@ +APP_NAME=Laravel +APP_ENV=local +APP_KEY= +APP_DEBUG=true +APP_URL=http://localhost:8000 + +APP_LOCALE=en +APP_FALLBACK_LOCALE=en +APP_FAKER_LOCALE=en_US + +APP_MAINTENANCE_DRIVER=file +# APP_MAINTENANCE_STORE=database + +# PHP_CLI_SERVER_WORKERS=4 + +BCRYPT_ROUNDS=12 + +LOG_CHANNEL=stack +LOG_STACK=single +LOG_DEPRECATIONS_CHANNEL=null +LOG_LEVEL=debug + +DB_CONNECTION=sqlite +# DB_HOST=127.0.0.1 +# DB_PORT=3306 +# DB_DATABASE=laravel +# DB_USERNAME=root +# DB_PASSWORD= + +SESSION_DRIVER=database +SESSION_LIFETIME=120 +SESSION_ENCRYPT=false +SESSION_PATH=/ +SESSION_DOMAIN=null + +BROADCAST_CONNECTION=log +FILESYSTEM_DISK=local +QUEUE_CONNECTION=database + +CACHE_STORE=database +# CACHE_PREFIX= + +MEMCACHED_HOST=127.0.0.1 + +REDIS_CLIENT=phpredis +REDIS_HOST=127.0.0.1 +REDIS_PASSWORD=null +REDIS_PORT=6379 + +MAIL_MAILER=log +MAIL_SCHEME=null +MAIL_HOST=127.0.0.1 +MAIL_PORT=2525 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_NAME="${APP_NAME}" + +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_DEFAULT_REGION=us-east-1 +AWS_BUCKET= +AWS_USE_PATH_STYLE_ENDPOINT=false + +VITE_APP_NAME="${APP_NAME}" diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..4da48a8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +* text=auto eol=lf + +*.blade.php diff=html +*.css diff=css +*.html diff=html +*.md diff=markdown +*.php diff=php + +CHANGELOG.md export-ignore +README.md export-ignore +.github/workflows/browser-tests.yml export-ignore diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ae67c4d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + cooldown: + default-days: 5 + groups: + github-actions: + patterns: + - "*" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..7fbaaf7 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,38 @@ +name: tests + +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + ci: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + with: + php-version: '8.3' + tools: composer:v2 + coverage: none + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + + - name: Setup Application + run: composer setup + + - name: Run CI Checks + run: composer ci:check diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..99b900e --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +/.phpunit.cache +/node_modules +/public/build +/public/fonts-manifest.dev.json +/public/hot +/public/storage +/storage/*.key +/storage/pail +/vendor +.env +.env.backup +.env.production +.phpactor.json +.phpunit.result.cache +Homestead.json +Homestead.yaml +npm-debug.log +yarn-error.log +/auth.json +/.fleet +/.idea +/.nova +/.vscode +/.zed +.DS_Store +*/.DS_Store +**/.DS_Store +/provision/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..495a6af --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +ignore-scripts=true +audit=true diff --git a/.scannerwork/.sonar_lock b/.scannerwork/.sonar_lock new file mode 100644 index 0000000..e69de29 diff --git a/.scannerwork/report-task.txt b/.scannerwork/report-task.txt new file mode 100644 index 0000000..364751a --- /dev/null +++ b/.scannerwork/report-task.txt @@ -0,0 +1,6 @@ +projectKey=Helpdesk +serverUrl=http://127.0.0.1:9001 +serverVersion=9.9.8.100196 +dashboardUrl=http://127.0.0.1:9001/dashboard?id=Helpdesk +ceTaskId=AaA4s_Is8O8WM0gRYMf8 +ceTaskUrl=http://127.0.0.1:9001/api/ce/task?id=AaA4s_Is8O8WM0gRYMf8 diff --git a/Modules/Helpdesk/.gitignore b/Modules/Helpdesk/.gitignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/Modules/Helpdesk/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/Modules/Helpdesk/Concerns/HasHelpdeskTickets.php b/Modules/Helpdesk/Concerns/HasHelpdeskTickets.php new file mode 100644 index 0000000..31c9ecc --- /dev/null +++ b/Modules/Helpdesk/Concerns/HasHelpdeskTickets.php @@ -0,0 +1,36 @@ + */ + 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 new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Helpdesk/Config/config.php b/Modules/Helpdesk/Config/config.php new file mode 100644 index 0000000..ef6525a --- /dev/null +++ b/Modules/Helpdesk/Config/config.php @@ -0,0 +1,31 @@ + env('HELPDESK_ROUTE_PREFIX', 'admin/helpdesk'), + 'middleware' => ['web', 'auth', 'thredary.admin'], + '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 new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Helpdesk/Database/Factories/.gitkeep b/Modules/Helpdesk/Database/Factories/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Helpdesk/Database/Factories/TicketFactory.php b/Modules/Helpdesk/Database/Factories/TicketFactory.php new file mode 100644 index 0000000..717d524 --- /dev/null +++ b/Modules/Helpdesk/Database/Factories/TicketFactory.php @@ -0,0 +1,28 @@ +create(); + + return [ + 'reference' => 'THR-'.Str::upper(Str::random(8)), + 'user_id' => $user->getKey(), + 'subject' => fake()->sentence(5), + 'body' => fake()->paragraph(), + 'last_activity_at' => now(), + ]; + } +} diff --git a/Modules/Helpdesk/Database/Migrations/.gitkeep b/Modules/Helpdesk/Database/Migrations/.gitkeep new file mode 100644 index 0000000..e69de29 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 new file mode 100644 index 0000000..bde1d01 --- /dev/null +++ b/Modules/Helpdesk/Database/Migrations/2026_04_24_000001_add_helpdesk_flags_to_users_table.php @@ -0,0 +1,26 @@ +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->string('name', 120); + $table->string('slug', 140); + $table->string('color', 30)->default('blue'); + $table->text('description')->nullable(); + $table->timestamps(); + + $table->unique('slug'); + }); + + Schema::create('helpdesk_teams', static function (Blueprint $table): void { + $table->id(); + $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('slug'); + }); + + Schema::create('helpdesk_projects', static function (Blueprint $table): void { + $table->id(); + $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('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 new file mode 100644 index 0000000..17c5d10 --- /dev/null +++ b/Modules/Helpdesk/Database/Migrations/2026_06_12_000020_create_helpdesk_tickets_table.php @@ -0,0 +1,40 @@ +id(); + $table->string('reference', 32)->unique(); + $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('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 new file mode 100644 index 0000000..b89dc02 --- /dev/null +++ b/Modules/Helpdesk/Database/Migrations/2026_06_12_000030_create_helpdesk_git_and_workflow_tables.php @@ -0,0 +1,77 @@ +id(); + $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('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 new file mode 100644 index 0000000..14ef789 --- /dev/null +++ b/Modules/Helpdesk/Database/Migrations/2026_06_12_000040_create_helpdesk_comments_and_attachments_tables.php @@ -0,0 +1,40 @@ +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 new file mode 100644 index 0000000..e95e1de --- /dev/null +++ b/Modules/Helpdesk/Database/Migrations/2026_06_12_000050_create_helpdesk_category_user_table.php @@ -0,0 +1,21 @@ +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 new file mode 100644 index 0000000..974dd98 --- /dev/null +++ b/Modules/Helpdesk/Database/Migrations/2026_06_12_130000_create_helpdesk_kanban_boards_table.php @@ -0,0 +1,39 @@ +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 new file mode 100644 index 0000000..4146c1c --- /dev/null +++ b/Modules/Helpdesk/Database/Migrations/2026_06_12_130010_create_helpdesk_kanban_board_statuses_table.php @@ -0,0 +1,31 @@ +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 new file mode 100644 index 0000000..46ea13f --- /dev/null +++ b/Modules/Helpdesk/Database/Migrations/2026_06_15_000100_add_redmineup_inspired_helpdesk_features.php @@ -0,0 +1,234 @@ +id(); + $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('slug'); + }); + } + + if (! Schema::hasTable('helpdesk_sla_policies')) { + Schema::create('helpdesk_sla_policies', static function (Blueprint $table): void { + $table->id(); + $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('slug'); + $table->index(['priority_id', 'category_id', 'is_active'], 'hd_sla_priority_category_active_idx'); + }); + } + + 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'); + } + + }); + + if (! Schema::hasIndex('helpdesk_tickets', ['sla_policy_id', 'sla_resolution_due_at'])) { + Schema::table('helpdesk_tickets', static function (Blueprint $table): void { + $table->index(['sla_policy_id', 'sla_resolution_due_at'], 'hd_tickets_sla_due_idx'); + }); + } + + if (! Schema::hasIndex('helpdesk_tickets', ['product_id', 'completed_at'])) { + Schema::table('helpdesk_tickets', static function (Blueprint $table): void { + $table->index(['product_id', 'completed_at'], 'hd_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'); + } + }); + + if (! Schema::hasTable('helpdesk_canned_responses')) { + Schema::create('helpdesk_canned_responses', static function (Blueprint $table): void { + $table->id(); + $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('slug'); + $table->index(['category_id', 'team_id', 'is_active'], 'hd_canned_category_team_active_idx'); + }); + } + + if (! Schema::hasTable('helpdesk_automation_rules')) { + Schema::create('helpdesk_automation_rules', static function (Blueprint $table): void { + $table->id(); + $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('slug'); + $table->index(['is_active', 'sort_order']); + }); + } + + if (! Schema::hasTable('helpdesk_ticket_sla_events')) { + 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']); + }); + } + + if (! Schema::hasTable('helpdesk_ticket_ratings')) { + 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']); + }); + } + + if (! Schema::hasTable('helpdesk_tags')) { + Schema::create('helpdesk_tags', static function (Blueprint $table): void { + $table->id(); + $table->string('name', 80); + $table->string('slug', 100); + $table->string('color', 30)->default('zinc'); + $table->timestamps(); + $table->unique('slug'); + }); + } + + if (! Schema::hasTable('helpdesk_ticket_tag')) { + 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']); + }); + } + + if (! Schema::hasTable('helpdesk_checklist_templates')) { + Schema::create('helpdesk_checklist_templates', static function (Blueprint $table): void { + $table->id(); + $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('slug'); + }); + } + + if (! Schema::hasIndex('helpdesk_checklist_templates', ['category_id', 'project_id', 'is_active'])) { + Schema::table('helpdesk_checklist_templates', static function (Blueprint $table): void { + $table->index(['category_id', 'project_id', 'is_active'], 'hd_checklist_tpl_scope_active_idx'); + }); + } + + if (! Schema::hasTable('helpdesk_checklist_items')) { + 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 new file mode 100644 index 0000000..23ca764 --- /dev/null +++ b/Modules/Helpdesk/Database/Migrations/2026_06_15_000200_add_helpdesk_git_webhook_ingestion.php @@ -0,0 +1,39 @@ +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'], 'hd_git_webhook_repo_status_received_idx'); + $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 new file mode 100644 index 0000000..fa90d1a --- /dev/null +++ b/Modules/Helpdesk/Database/Migrations/2026_06_15_143824_default_flags_to_helpdesk_lookup_tables.php @@ -0,0 +1,48 @@ +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_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 new file mode 100644 index 0000000..8e76ecc --- /dev/null +++ b/Modules/Helpdesk/Database/Migrations/2026_06_15_151500_add_helpdesk_status_default_and_harden_defaults.php @@ -0,0 +1,50 @@ +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 new file mode 100644 index 0000000..a79892f --- /dev/null +++ b/Modules/Helpdesk/Database/Migrations/2026_06_16_090000_add_single_board_assignment_and_owner_to_helpdesk.php @@ -0,0 +1,76 @@ +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 new file mode 100644 index 0000000..d2c3d6b --- /dev/null +++ b/Modules/Helpdesk/Database/Migrations/2026_06_16_093000_backfill_helpdesk_ticket_board_assignments.php @@ -0,0 +1,60 @@ +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 new file mode 100644 index 0000000..c3c7d96 --- /dev/null +++ b/Modules/Helpdesk/Database/Migrations/2026_06_16_094500_backfill_kanban_board_status_columns.php @@ -0,0 +1,65 @@ +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 new file mode 100644 index 0000000..43b6742 --- /dev/null +++ b/Modules/Helpdesk/Database/Migrations/2026_06_17_084500_add_lotus_replication_fields_to_helpdesk_comments.php @@ -0,0 +1,132 @@ +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('thredary-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 ?: 'thredary-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 new file mode 100644 index 0000000..4e37817 --- /dev/null +++ b/Modules/Helpdesk/Database/Migrations/2026_06_17_111500_add_subject_to_helpdesk_comments.php @@ -0,0 +1,53 @@ +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 new file mode 100644 index 0000000..2c9a54b --- /dev/null +++ b/Modules/Helpdesk/Database/Migrations/2026_06_17_123000_add_deletion_audit_fields_to_helpdesk_comments.php @@ -0,0 +1,40 @@ +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_create_helpdesk_ticket_time_logs_table.php b/Modules/Helpdesk/Database/Migrations/2026_06_22_000001_create_helpdesk_ticket_time_logs_table.php new file mode 100644 index 0000000..050dcf6 --- /dev/null +++ b/Modules/Helpdesk/Database/Migrations/2026_06_22_000001_create_helpdesk_ticket_time_logs_table.php @@ -0,0 +1,41 @@ +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 new file mode 100644 index 0000000..e193224 --- /dev/null +++ b/Modules/Helpdesk/Database/Migrations/2026_06_22_000001_create_helpdesk_time_logs_table.php @@ -0,0 +1,41 @@ +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_000002_add_timer_fields_to_helpdesk_time_logs_table.php b/Modules/Helpdesk/Database/Migrations/2026_06_22_000002_add_timer_fields_to_helpdesk_time_logs_table.php new file mode 100644 index 0000000..3cc21ae --- /dev/null +++ b/Modules/Helpdesk/Database/Migrations/2026_06_22_000002_add_timer_fields_to_helpdesk_time_logs_table.php @@ -0,0 +1,62 @@ +timestamp('started_at')->nullable(); + + if (Schema::hasColumn($tableName, 'logged_at')) { + $startedAt->after('logged_at'); + } + } + + if (! Schema::hasColumn($tableName, 'ended_at')) { + $endedAt = $table->timestamp('ended_at')->nullable(); + + if (Schema::hasColumn($tableName, 'started_at')) { + $endedAt->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_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 new file mode 100644 index 0000000..75a6cb0 --- /dev/null +++ b/Modules/Helpdesk/Database/Migrations/2026_06_22_124500_add_logged_at_to_helpdesk_ticket_time_logs_table.php @@ -0,0 +1,45 @@ +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 new file mode 100644 index 0000000..3c2e379 --- /dev/null +++ b/Modules/Helpdesk/Database/Migrations/2026_06_22_130000_add_seconds_to_helpdesk_ticket_time_logs_table.php @@ -0,0 +1,40 @@ +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 new file mode 100644 index 0000000..353d05b --- /dev/null +++ b/Modules/Helpdesk/Database/Migrations/2026_06_23_150000_normalise_helpdesk_lookup_colours.php @@ -0,0 +1,68 @@ +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 new file mode 100644 index 0000000..4f549bc --- /dev/null +++ b/Modules/Helpdesk/Database/Migrations/2026_06_24_000100_add_helpdesk_attachment_experience_metadata.php @@ -0,0 +1,64 @@ +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/Migrations/2026_08_11_000006_add_helpdesk_flags_to_users_table.php b/Modules/Helpdesk/Database/Migrations/2026_08_11_000006_add_helpdesk_flags_to_users_table.php new file mode 100644 index 0000000..629c0b1 --- /dev/null +++ b/Modules/Helpdesk/Database/Migrations/2026_08_11_000006_add_helpdesk_flags_to_users_table.php @@ -0,0 +1,54 @@ +boolean('helpdesk_agent')->default(false)->after('password'); + } + + if ($addAdmin) { + $table->boolean('helpdesk_admin')->default(false)->after('helpdesk_agent'); + } + }); + } + + public function down(): void + { + if (! Schema::hasTable('users')) { + return; + } + + $drop = array_values(array_filter( + ['helpdesk_agent', 'helpdesk_admin'], + static fn (string $column): bool => Schema::hasColumn('users', $column), + )); + + if ($drop === []) { + return; + } + + Schema::table('users', static function (Blueprint $table) use ($drop): void { + $table->dropColumn($drop); + }); + } +}; diff --git a/Modules/Helpdesk/Database/Seeders/.gitkeep b/Modules/Helpdesk/Database/Seeders/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Helpdesk/Database/Seeders/HelpdeskDatabaseSeeder.php b/Modules/Helpdesk/Database/Seeders/HelpdeskDatabaseSeeder.php new file mode 100644 index 0000000..c87450f --- /dev/null +++ b/Modules/Helpdesk/Database/Seeders/HelpdeskDatabaseSeeder.php @@ -0,0 +1,267 @@ +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() + ->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( + ['slug' => Str::slug($row['name'])], + $row + ['slug' => Str::slug($row['name'])], + ); + } + } + + /** + * Seed the default support team and a default project. + */ + private function seedTeamsAndProjects(): void + { + + Team::query() + ->where('is_default', true) + ->update(['is_default' => false]); + + Project::query() + ->where('is_default', true) + ->update(['is_default' => false]); + + $team = Team::query()->updateOrCreate( + ['slug' => 'support'], + [ + 'name' => 'Support', + 'slug' => 'support', + 'color' => 'blue', + 'description' => 'Default support team for triage, ownership and ticket routing.', + 'is_default' => true, + ], + ); + + Project::query()->updateOrCreate( + ['slug' => 'support-desk'], + [ + '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( + ['slug' => 'standard-support-sla'], + [ + '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( + ['slug' => 'professional-acknowledgement'], + [ + '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( + ['slug' => 'bug-triage-checklist'], + [ + '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 account', '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( + ['slug' => $tag['slug']], + $tag + [], + ); + } + + if (Schema::hasColumn('helpdesk_tags', 'is_default')) { + Tag::query() + ->where('is_default', true) + ->update(['is_default' => false]); + } + + Product::query()->updateOrCreate( + ['slug' => 'thredary'], + [ + 'name' => 'Thredary', + 'slug' => 'thredary', + 'type' => 'service', + 'version' => null, + 'release_channel' => 'core', + 'description' => 'Default service record for Thredary platform support tickets.', + 'is_active' => true, + ], + ); + } +} diff --git a/Modules/Helpdesk/Documentation/Helpdesk_GitHub_Gitea_Integration_Guide.md b/Modules/Helpdesk/Documentation/Helpdesk_GitHub_Gitea_Integration_Guide.md new file mode 100644 index 0000000..50b86e3 --- /dev/null +++ b/Modules/Helpdesk/Documentation/Helpdesk_GitHub_Gitea_Integration_Guide.md @@ -0,0 +1,225 @@ +# Helpdesk Git Integration Guide + +## Gitea & GitHub Integration, Ticket Linking, Development Workflow, and Deployment Standards + +Version: 1.0 +Module: Helpdesk +Platform: Thredary 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/account/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/Thredary-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 new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Helpdesk/Helpers/Helper.php b/Modules/Helpdesk/Helpers/Helper.php new file mode 100644 index 0000000..d5ac059 --- /dev/null +++ b/Modules/Helpdesk/Helpers/Helper.php @@ -0,0 +1,13 @@ +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 new file mode 100644 index 0000000..405a3ce --- /dev/null +++ b/Modules/Helpdesk/Http/Controllers/AttachmentDownloadController.php @@ -0,0 +1,201 @@ +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 new file mode 100644 index 0000000..4613cbb --- /dev/null +++ b/Modules/Helpdesk/Http/Controllers/AttachmentPreviewController.php @@ -0,0 +1,50 @@ +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 new file mode 100644 index 0000000..5d8992c --- /dev/null +++ b/Modules/Helpdesk/Http/Controllers/GitWebhookController.php @@ -0,0 +1,355 @@ +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 new file mode 100644 index 0000000..8cbefea --- /dev/null +++ b/Modules/Helpdesk/Http/Controllers/HelpdeskController.php @@ -0,0 +1,23 @@ + __('Helpdesk'), + 'slot' => new HtmlString(view('helpdesk::index')->render()), + ]); + } +} diff --git a/Modules/Helpdesk/Http/Controllers/TicketAttachmentStoreController.php b/Modules/Helpdesk/Http/Controllers/TicketAttachmentStoreController.php new file mode 100644 index 0000000..8cd6126 --- /dev/null +++ b/Modules/Helpdesk/Http/Controllers/TicketAttachmentStoreController.php @@ -0,0 +1,74 @@ +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 new file mode 100644 index 0000000..9b3793d --- /dev/null +++ b/Modules/Helpdesk/Http/Controllers/TicketBoardController.php @@ -0,0 +1,105 @@ +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 new file mode 100644 index 0000000..60a88ef --- /dev/null +++ b/Modules/Helpdesk/Http/Controllers/TicketCommentDeleteController.php @@ -0,0 +1,75 @@ +user(); + + abort_unless($user !== null && method_exists($user, 'isAdmin') && $user->isAdmin(), 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 Thredary 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 new file mode 100644 index 0000000..ba8f461 --- /dev/null +++ b/Modules/Helpdesk/Http/Controllers/TicketCommentStoreController.php @@ -0,0 +1,192 @@ +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, '