diff --git a/Helpdesk/Concerns/HasHelpdeskTickets.php b/Helpdesk/Concerns/HasHelpdeskTickets.php new file mode 100644 index 00000000..31c9ecc8 --- /dev/null +++ b/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/Helpdesk/Config/.gitkeep b/Helpdesk/Config/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Helpdesk/Config/config.php b/Helpdesk/Config/config.php new file mode 100644 index 00000000..1b947558 --- /dev/null +++ b/Helpdesk/Config/config.php @@ -0,0 +1,13 @@ + env('HELPDESK_ROUTE_PREFIX', 'helpdesk'), + 'middleware' => ['web', 'auth'], + 'disk' => env('HELPDESK_ATTACHMENTS_DISK', 'local'), + 'max_upload_kb' => (int) env('HELPDESK_MAX_UPLOAD_KB', 10240), + 'allowed_mimes' => ['md','pdf', 'png', 'jpg', 'jpeg', 'webp', 'doc', 'docx', 'xls', 'xlsx', 'txt', 'csv', 'zip'], + 'default_status_slug' => 'new', + 'closed_status_slug' => 'closed', +]; diff --git a/Helpdesk/Console/.gitkeep b/Helpdesk/Console/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Helpdesk/Database/Migrations/.gitkeep b/Helpdesk/Database/Migrations/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Helpdesk/Database/Migrations/2026_04_24_000001_add_helpdesk_flags_to_users_table.php b/Helpdesk/Database/Migrations/2026_04_24_000001_add_helpdesk_flags_to_users_table.php new file mode 100644 index 00000000..c8969030 --- /dev/null +++ b/Helpdesk/Database/Migrations/2026_04_24_000001_add_helpdesk_flags_to_users_table.php @@ -0,0 +1,26 @@ +boolean('helpdesk_agent')->default(false)->after('password'); + } + + if (! Schema::hasColumn('users', 'helpdesk_admin')) { + $table->boolean('helpdesk_admin')->default(false)->after('helpdesk_agent'); + } + }); + } +}; diff --git a/Helpdesk/Database/Migrations/2026_06_12_000010_create_helpdesk_lookup_tables.php b/Helpdesk/Database/Migrations/2026_06_12_000010_create_helpdesk_lookup_tables.php new file mode 100644 index 00000000..bf8b9a64 --- /dev/null +++ b/Helpdesk/Database/Migrations/2026_06_12_000010_create_helpdesk_lookup_tables.php @@ -0,0 +1,73 @@ +id(); + $table->string('name', 80); + $table->string('slug', 100)->unique(); + $table->string('color', 30)->default('zinc'); + $table->unsignedSmallInteger('sort_order')->default(0); + $table->boolean('is_closed')->default(false); + $table->timestamps(); + }); + + Schema::create('helpdesk_priorities', static function (Blueprint $table): void { + $table->id(); + $table->string('name', 80); + $table->string('slug', 100)->unique(); + $table->string('color', 30)->default('zinc'); + $table->unsignedSmallInteger('sort_order')->default(0); + $table->timestamps(); + }); + + Schema::create('helpdesk_categories', static function (Blueprint $table): void { + $table->id(); + $table->foreignId('company_id')->nullable()->constrained('companies')->nullOnDelete(); + $table->string('name', 120); + $table->string('slug', 140); + $table->string('color', 30)->default('blue'); + $table->text('description')->nullable(); + $table->timestamps(); + + $table->unique(['company_id', 'slug']); + }); + + Schema::create('helpdesk_teams', static function (Blueprint $table): void { + $table->id(); + $table->foreignId('company_id')->nullable()->constrained('companies')->nullOnDelete(); + $table->string('name', 120); + $table->string('slug', 140); + $table->string('color', 30)->default('indigo'); + $table->text('description')->nullable(); + $table->timestamp('archived_at')->nullable(); + $table->softDeletes(); + $table->timestamps(); + + $table->unique(['company_id', 'slug']); + }); + + Schema::create('helpdesk_projects', static function (Blueprint $table): void { + $table->id(); + $table->foreignId('company_id')->nullable()->constrained('companies')->nullOnDelete(); + $table->foreignId('team_id')->nullable()->constrained('helpdesk_teams')->nullOnDelete(); + $table->string('title', 160); + $table->string('slug', 180); + $table->string('color', 30)->default('blue'); + $table->text('description')->nullable(); + $table->timestamp('archived_at')->nullable(); + $table->softDeletes(); + $table->timestamps(); + + $table->unique(['company_id', 'slug']); + }); + } +}; diff --git a/Helpdesk/Database/Migrations/2026_06_12_000020_create_helpdesk_tickets_table.php b/Helpdesk/Database/Migrations/2026_06_12_000020_create_helpdesk_tickets_table.php new file mode 100644 index 00000000..f139a3be --- /dev/null +++ b/Helpdesk/Database/Migrations/2026_06_12_000020_create_helpdesk_tickets_table.php @@ -0,0 +1,41 @@ +id(); + $table->string('reference', 32)->unique(); + $table->foreignId('company_id')->nullable()->constrained('companies')->nullOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->foreignId('agent_id')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('status_id')->constrained('helpdesk_statuses')->restrictOnDelete(); + $table->foreignId('priority_id')->constrained('helpdesk_priorities')->restrictOnDelete(); + $table->foreignId('category_id')->nullable()->constrained('helpdesk_categories')->nullOnDelete(); + $table->foreignId('project_id')->nullable()->constrained('helpdesk_projects')->nullOnDelete(); + $table->foreignId('team_id')->nullable()->constrained('helpdesk_teams')->nullOnDelete(); + $table->string('subject', 200); + $table->longText('body'); + $table->timestamp('due_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamp('last_activity_at')->nullable(); + $table->timestamp('archived_at')->nullable(); + $table->softDeletes(); + $table->timestamps(); + + $table->index(['company_id', 'project_id']); + $table->index(['team_id', 'status_id']); + $table->index(['status_id', 'priority_id']); + $table->index(['agent_id', 'completed_at']); + $table->index(['user_id', 'created_at']); + $table->index('last_activity_at'); + }); + } +}; diff --git a/Helpdesk/Database/Migrations/2026_06_12_000030_create_helpdesk_git_and_workflow_tables.php b/Helpdesk/Database/Migrations/2026_06_12_000030_create_helpdesk_git_and_workflow_tables.php new file mode 100644 index 00000000..25446f2d --- /dev/null +++ b/Helpdesk/Database/Migrations/2026_06_12_000030_create_helpdesk_git_and_workflow_tables.php @@ -0,0 +1,78 @@ +id(); + $table->foreignId('company_id')->nullable()->constrained('companies')->nullOnDelete(); + $table->foreignId('project_id')->nullable()->constrained('helpdesk_projects')->nullOnDelete(); + $table->string('name', 160); + $table->string('provider', 40)->default('git'); + $table->string('remote_url', 500); + $table->string('default_branch', 120)->default('main'); + $table->string('webhook_secret', 120)->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->index(['company_id', 'project_id']); + $table->index(['provider', 'is_active']); + }); + + Schema::create('helpdesk_git_commits', static function (Blueprint $table): void { + $table->id(); + $table->foreignId('repository_id')->constrained('helpdesk_git_repositories')->cascadeOnDelete(); + $table->foreignId('ticket_id')->nullable()->constrained('helpdesk_tickets')->nullOnDelete(); + $table->string('hash', 80); + $table->string('short_hash', 16); + $table->string('branch', 120)->nullable(); + $table->string('author_name', 160)->nullable(); + $table->string('author_email', 190)->nullable(); + $table->text('message'); + $table->string('url', 500)->nullable(); + $table->timestamp('committed_at')->nullable(); + $table->timestamps(); + + $table->unique(['repository_id', 'hash']); + $table->index(['ticket_id', 'committed_at']); + }); + + Schema::create('helpdesk_workflow_templates', static function (Blueprint $table): void { + $table->id(); + $table->string('name', 160); + $table->string('event', 80); + $table->string('subject', 220); + $table->longText('html_body'); + $table->longText('text_body')->nullable(); + $table->string('to_role', 40)->default('requester'); + $table->string('cc', 500)->nullable(); + $table->string('bcc', 500)->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->index(['event', 'is_active']); + }); + + Schema::create('helpdesk_workflow_logs', static function (Blueprint $table): void { + $table->id(); + $table->foreignId('template_id')->nullable()->constrained('helpdesk_workflow_templates')->nullOnDelete(); + $table->foreignId('ticket_id')->nullable()->constrained('helpdesk_tickets')->nullOnDelete(); + $table->string('event', 80); + $table->string('recipient', 190)->nullable(); + $table->string('subject', 220)->nullable(); + $table->string('status', 40)->default('pending'); + $table->text('error')->nullable(); + $table->timestamps(); + + $table->index(['ticket_id', 'event']); + $table->index(['status', 'created_at']); + }); + } +}; diff --git a/Helpdesk/Database/Migrations/2026_06_12_000040_create_helpdesk_comments_and_attachments_tables.php b/Helpdesk/Database/Migrations/2026_06_12_000040_create_helpdesk_comments_and_attachments_tables.php new file mode 100644 index 00000000..14ef7892 --- /dev/null +++ b/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/Helpdesk/Database/Migrations/2026_06_12_000050_create_helpdesk_category_user_table.php b/Helpdesk/Database/Migrations/2026_06_12_000050_create_helpdesk_category_user_table.php new file mode 100644 index 00000000..e95e1def --- /dev/null +++ b/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/Helpdesk/Database/Migrations/2026_06_12_130000_create_helpdesk_kanban_boards_table.php b/Helpdesk/Database/Migrations/2026_06_12_130000_create_helpdesk_kanban_boards_table.php new file mode 100644 index 00000000..974dd989 --- /dev/null +++ b/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/Helpdesk/Database/Migrations/2026_06_12_130010_create_helpdesk_kanban_board_statuses_table.php b/Helpdesk/Database/Migrations/2026_06_12_130010_create_helpdesk_kanban_board_statuses_table.php new file mode 100644 index 00000000..4146c1c6 --- /dev/null +++ b/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/Helpdesk/Database/Migrations/2026_06_15_000100_add_redmineup_inspired_helpdesk_features.php b/Helpdesk/Database/Migrations/2026_06_15_000100_add_redmineup_inspired_helpdesk_features.php new file mode 100644 index 00000000..c563f29a --- /dev/null +++ b/Helpdesk/Database/Migrations/2026_06_15_000100_add_redmineup_inspired_helpdesk_features.php @@ -0,0 +1,208 @@ +id(); + $table->foreignId('company_id')->nullable()->constrained('companies')->nullOnDelete(); + $table->string('name', 160); + $table->string('slug', 180); + $table->string('type', 60)->default('service'); + $table->string('version', 80)->nullable(); + $table->string('release_channel', 80)->nullable(); + $table->text('description')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->unique(['company_id', 'slug']); + }); + + + Schema::create('helpdesk_sla_policies', static function (Blueprint $table): void { + $table->id(); + $table->foreignId('company_id')->nullable()->constrained('companies')->nullOnDelete(); + $table->foreignId('priority_id')->nullable()->constrained('helpdesk_priorities')->nullOnDelete(); + $table->foreignId('category_id')->nullable()->constrained('helpdesk_categories')->nullOnDelete(); + $table->string('name', 160); + $table->string('slug', 180); + $table->unsignedInteger('first_response_minutes')->default(240); + $table->unsignedInteger('resolution_minutes')->default(2880); + $table->boolean('business_hours_only')->default(false); + $table->boolean('is_default')->default(false); + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->unique(['company_id', 'slug']); + $table->index(['priority_id', 'category_id', 'is_active']); + }); + + Schema::table('helpdesk_tickets', static function (Blueprint $table): void { + if (! Schema::hasColumn('helpdesk_tickets', 'sla_policy_id')) { + $table->foreignId('sla_policy_id')->nullable()->after('team_id')->constrained('helpdesk_sla_policies')->nullOnDelete(); + } + if (! Schema::hasColumn('helpdesk_tickets', 'product_id')) { + $table->foreignId('product_id')->nullable()->after('project_id')->constrained('helpdesk_products')->nullOnDelete(); + } + if (! Schema::hasColumn('helpdesk_tickets', 'first_response_at')) { + $table->timestamp('first_response_at')->nullable()->after('due_at'); + } + if (! Schema::hasColumn('helpdesk_tickets', 'sla_first_response_due_at')) { + $table->timestamp('sla_first_response_due_at')->nullable()->after('first_response_at'); + } + if (! Schema::hasColumn('helpdesk_tickets', 'sla_resolution_due_at')) { + $table->timestamp('sla_resolution_due_at')->nullable()->after('sla_first_response_due_at'); + } + if (! Schema::hasColumn('helpdesk_tickets', 'sla_first_response_breached_at')) { + $table->timestamp('sla_first_response_breached_at')->nullable()->after('sla_resolution_due_at'); + } + if (! Schema::hasColumn('helpdesk_tickets', 'sla_resolution_breached_at')) { + $table->timestamp('sla_resolution_breached_at')->nullable()->after('sla_first_response_breached_at'); + } + if (! Schema::hasColumn('helpdesk_tickets', 'public_replies_count')) { + $table->unsignedInteger('public_replies_count')->default(0)->after('sla_resolution_breached_at'); + } + if (! Schema::hasColumn('helpdesk_tickets', 'time_spent_minutes')) { + $table->unsignedInteger('time_spent_minutes')->default(0)->after('public_replies_count'); + } + + $table->index(['sla_policy_id', 'sla_resolution_due_at'], 'helpdesk_tickets_sla_policy_due_idx'); + $table->index(['product_id', 'completed_at'], 'helpdesk_tickets_product_closed_idx'); + }); + + Schema::table('helpdesk_workflow_logs', static function (Blueprint $table): void { + if (! Schema::hasColumn('helpdesk_workflow_logs', 'tracking_token')) { + $table->string('tracking_token', 64)->nullable()->unique()->after('status'); + } + if (! Schema::hasColumn('helpdesk_workflow_logs', 'first_opened_at')) { + $table->timestamp('first_opened_at')->nullable()->after('tracking_token'); + } + if (! Schema::hasColumn('helpdesk_workflow_logs', 'last_opened_at')) { + $table->timestamp('last_opened_at')->nullable()->after('first_opened_at'); + } + if (! Schema::hasColumn('helpdesk_workflow_logs', 'opened_count')) { + $table->unsignedInteger('opened_count')->default(0)->after('last_opened_at'); + } + }); + + Schema::create('helpdesk_canned_responses', static function (Blueprint $table): void { + $table->id(); + $table->foreignId('company_id')->nullable()->constrained('companies')->nullOnDelete(); + $table->foreignId('category_id')->nullable()->constrained('helpdesk_categories')->nullOnDelete(); + $table->foreignId('team_id')->nullable()->constrained('helpdesk_teams')->nullOnDelete(); + $table->string('name', 160); + $table->string('slug', 180); + $table->string('visibility', 40)->default('agents'); + $table->string('subject', 220)->nullable(); + $table->longText('body'); + $table->boolean('is_active')->default(true); + $table->unsignedInteger('sort_order')->default(0); + $table->timestamps(); + $table->unique(['company_id', 'slug']); + $table->index(['category_id', 'team_id', 'is_active']); + }); + + + + Schema::create('helpdesk_automation_rules', static function (Blueprint $table): void { + $table->id(); + $table->foreignId('company_id')->nullable()->constrained('companies')->nullOnDelete(); + $table->string('name', 160); + $table->string('slug', 180); + $table->string('match_sender', 190)->nullable(); + $table->string('match_subject', 220)->nullable(); + $table->string('match_mailbox', 190)->nullable(); + $table->foreignId('match_category_id')->nullable()->constrained('helpdesk_categories')->nullOnDelete(); + $table->foreignId('set_priority_id')->nullable()->constrained('helpdesk_priorities')->nullOnDelete(); + $table->foreignId('set_category_id')->nullable()->constrained('helpdesk_categories')->nullOnDelete(); + $table->foreignId('set_team_id')->nullable()->constrained('helpdesk_teams')->nullOnDelete(); + $table->foreignId('set_project_id')->nullable()->constrained('helpdesk_projects')->nullOnDelete(); + $table->foreignId('set_status_id')->nullable()->constrained('helpdesk_statuses')->nullOnDelete(); + $table->foreignId('set_agent_id')->nullable()->constrained('users')->nullOnDelete(); + $table->boolean('is_active')->default(true); + $table->unsignedInteger('sort_order')->default(0); + $table->timestamps(); + $table->unique(['company_id', 'slug']); + $table->index(['is_active', 'sort_order']); + }); + + Schema::create('helpdesk_ticket_sla_events', static function (Blueprint $table): void { + $table->id(); + $table->foreignId('ticket_id')->constrained('helpdesk_tickets')->cascadeOnDelete(); + $table->foreignId('sla_policy_id')->nullable()->constrained('helpdesk_sla_policies')->nullOnDelete(); + $table->string('event', 80); + $table->timestamp('due_at')->nullable(); + $table->timestamp('occurred_at')->nullable(); + $table->boolean('breached')->default(false); + $table->json('meta')->nullable(); + $table->timestamps(); + $table->index(['ticket_id', 'event']); + $table->index(['breached', 'due_at']); + }); + + Schema::create('helpdesk_ticket_ratings', static function (Blueprint $table): void { + $table->id(); + $table->foreignId('ticket_id')->constrained('helpdesk_tickets')->cascadeOnDelete(); + $table->foreignId('agent_id')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('team_id')->nullable()->constrained('helpdesk_teams')->nullOnDelete(); + $table->foreignId('category_id')->nullable()->constrained('helpdesk_categories')->nullOnDelete(); + $table->unsignedTinyInteger('rating'); + $table->string('token', 80)->unique(); + $table->text('comment')->nullable(); + $table->timestamp('rated_at')->nullable(); + $table->timestamps(); + $table->index(['agent_id', 'rating']); + $table->index(['team_id', 'category_id']); + }); + + Schema::create('helpdesk_tags', static function (Blueprint $table): void { + $table->id(); + $table->foreignId('company_id')->nullable()->constrained('companies')->nullOnDelete(); + $table->string('name', 80); + $table->string('slug', 100); + $table->string('color', 30)->default('zinc'); + $table->timestamps(); + $table->unique(['company_id', 'slug']); + }); + + Schema::create('helpdesk_ticket_tag', static function (Blueprint $table): void { + $table->foreignId('ticket_id')->constrained('helpdesk_tickets')->cascadeOnDelete(); + $table->foreignId('tag_id')->constrained('helpdesk_tags')->cascadeOnDelete(); + $table->primary(['ticket_id', 'tag_id']); + }); + + Schema::create('helpdesk_checklist_templates', static function (Blueprint $table): void { + $table->id(); + $table->foreignId('company_id')->nullable()->constrained('companies')->nullOnDelete(); + $table->foreignId('category_id')->nullable()->constrained('helpdesk_categories')->nullOnDelete(); + $table->foreignId('project_id')->nullable()->constrained('helpdesk_projects')->nullOnDelete(); + $table->string('name', 160); + $table->string('slug', 180); + $table->json('items'); + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->unique(['company_id', 'slug']); + $table->index(['category_id', 'project_id', 'is_active']); + }); + + Schema::create('helpdesk_checklist_items', static function (Blueprint $table): void { + $table->id(); + $table->foreignId('ticket_id')->constrained('helpdesk_tickets')->cascadeOnDelete(); + $table->foreignId('template_id')->nullable()->constrained('helpdesk_checklist_templates')->nullOnDelete(); + $table->string('title', 220); + $table->text('description')->nullable(); + $table->boolean('is_completed')->default(false); + $table->foreignId('completed_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('completed_at')->nullable(); + $table->unsignedInteger('sort_order')->default(0); + $table->timestamps(); + $table->index(['ticket_id', 'is_completed']); + }); + + } +}; diff --git a/Helpdesk/Database/Migrations/2026_06_15_000200_add_helpdesk_git_webhook_ingestion.php b/Helpdesk/Database/Migrations/2026_06_15_000200_add_helpdesk_git_webhook_ingestion.php new file mode 100644 index 00000000..49c06d9b --- /dev/null +++ b/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']); + $table->index(['provider', 'event']); + }); + } +}; diff --git a/Helpdesk/Database/Seeders/.gitkeep b/Helpdesk/Database/Seeders/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Helpdesk/Database/Seeders/HelpdeskDatabaseSeeder.php b/Helpdesk/Database/Seeders/HelpdeskDatabaseSeeder.php new file mode 100644 index 00000000..3c880c53 --- /dev/null +++ b/Helpdesk/Database/Seeders/HelpdeskDatabaseSeeder.php @@ -0,0 +1,257 @@ +seedStatuses(); + $this->seedPriorities(); + $this->seedCategories(); + $this->seedTeamsAndProjects(); + $this->seedKanbanBoards(); + $this->seedWorkflowTemplates(); + $this->seedRedmineInspiredDefaults(); + } + + /** + * Seed ticket workflow statuses. + * + * @return void + */ + private function seedStatuses(): void + { + foreach ([ + ['name' => 'New', 'slug' => 'new', 'color' => 'sky', 'sort_order' => 5, 'is_closed' => false], + ['name' => 'Open', 'slug' => 'open', 'color' => 'blue', 'sort_order' => 10, 'is_closed' => false], + ['name' => 'In Progress', 'slug' => 'in-progress', 'color' => 'amber', 'sort_order' => 20, 'is_closed' => false], + ['name' => 'Stalled', 'slug' => 'stalled', 'color' => 'rose', 'sort_order' => 30, 'is_closed' => false], + ['name' => 'On-Hold', 'slug' => 'on-hold', 'color' => 'violet', 'sort_order' => 40, 'is_closed' => false], + ['name' => 'Closed', 'slug' => 'closed', 'color' => 'emerald', 'sort_order' => 90, 'is_closed' => true], + ] as $row) { + Status::query()->updateOrCreate(['slug' => $row['slug']], $row); + } + } + + /** + * Seed ticket priorities. + * + * @return void + */ + private function seedPriorities(): void + { + foreach ([ + ['name' => 'High', 'slug' => 'high', 'color' => 'red', 'sort_order' => 10], + ['name' => 'Medium', 'slug' => 'medium', 'color' => 'amber', 'sort_order' => 20], + ['name' => 'Low', 'slug' => 'low', 'color' => 'emerald', 'sort_order' => 30], + ] as $row) { + Priority::query()->updateOrCreate(['slug' => $row['slug']], $row); + } + } + + /** + * Seed ticket categories. + * + * @return void + */ + private function seedCategories(): void + { + foreach ([ + ['name' => 'Development', 'color' => 'indigo', 'description' => 'Software changes, enhancements and development work.'], + ['name' => 'Systems Deployment', 'color' => 'cyan', 'description' => 'Deployment, release, infrastructure and environment requests.'], + ['name' => 'Concepts', 'color' => 'purple', 'description' => 'Ideas, concepts, design discussion and future improvements.'], + ['name' => 'Bug', 'color' => 'rose', 'description' => 'Defects, regressions and incorrect behaviour.'], + ] as $row) { + Category::query()->updateOrCreate( + ['company_id' => null, 'slug' => Str::slug($row['name'])], + $row + ['company_id' => null, 'slug' => Str::slug($row['name'])] + ); + } + } + + /** + * Seed the default support team and a default project. + * + * @return void + */ + private function seedTeamsAndProjects(): void + { + $team = Team::query()->updateOrCreate( + ['company_id' => null, 'slug' => 'support'], + [ + 'company_id' => null, + 'name' => 'Support', + 'slug' => 'support', + 'color' => 'blue', + 'description' => 'Default support team for triage, ownership and ticket routing.', + ] + ); + + Project::query()->updateOrCreate( + ['company_id' => null, 'slug' => 'support-desk'], + [ + 'company_id' => null, + 'team_id' => $team->id, + 'title' => 'Support Desk', + 'slug' => 'support-desk', + 'color' => 'indigo', + 'description' => 'Default project for operational helpdesk tickets.', + ] + ); + } + + /** + * Seed the default configurable Kanban board. + * + * @return void + */ + 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. + * + * @return void + */ + private function seedWorkflowTemplates(): void + { + foreach ([ + ['name' => 'Ticket raised requester notice', 'event' => 'ticket.created', 'subject' => 'Ticket {{ ticket.reference }} has been raised', 'html_body' => '

Hello {{ requester.name }},

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

Subject: {{ ticket.subject }}

Priority: {{ ticket.priority }}

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

Hello {{ requester.name }},

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

Status: {{ ticket.status }}

Priority: {{ ticket.priority }}

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

Hello {{ requester.name }},

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

Subject: {{ ticket.subject }}

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

Hello {{ requester.name }},

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

Thank you for using the support desk.

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

Hello {{ requester.name }},

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

Regards,
{{ agent.name }}

', + 'is_active' => true, + 'sort_order' => 10, + ] + ); + + ChecklistTemplate::query()->updateOrCreate( + ['company_id' => null, 'slug' => 'bug-triage-checklist'], + [ + 'company_id' => null, + 'category_id' => $bug?->id, + 'project_id' => null, + 'name' => 'Bug triage checklist', + 'slug' => 'bug-triage-checklist', + 'items' => ['Confirm reproduction steps', 'Check recent deployment/build', 'Identify affected tenant/company', 'Record fix or workaround'], + 'is_active' => true, + ] + ); + + foreach ([ + ['name' => 'Escalated', 'slug' => 'escalated', 'color' => 'red'], + ['name' => 'Waiting on customer', 'slug' => 'waiting-on-customer', 'color' => 'amber'], + ] as $tag) { + Tag::query()->updateOrCreate(['company_id' => null, 'slug' => $tag['slug']], $tag + ['company_id' => null]); + } + + Product::query()->updateOrCreate( + ['company_id' => null, 'slug' => 'wms-platform'], + [ + 'company_id' => null, + 'name' => 'WMS Platform', + 'slug' => 'wms-platform', + 'type' => 'service', + 'version' => null, + 'release_channel' => 'core', + 'description' => 'Default service record for WMS platform support tickets.', + 'is_active' => true, + ] + ); + } + +} + diff --git a/Helpdesk/Database/factories/.gitkeep b/Helpdesk/Database/factories/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Helpdesk/Docs/GIT_WEBHOOK_TRACKING.md b/Helpdesk/Docs/GIT_WEBHOOK_TRACKING.md new file mode 100644 index 00000000..7e306245 --- /dev/null +++ b/Helpdesk/Docs/GIT_WEBHOOK_TRACKING.md @@ -0,0 +1,122 @@ +# Helpdesk Git Webhook Tracking + +The Helpdesk module can ingest signed Git push webhooks and automatically link commits to tickets when commit messages, branch names, or commit URLs contain a ticket reference such as `HD-2026-00012`. + +## 1. Register the repository + +Open the Helpdesk settings page: + +```text +/helpdesk/settings +``` + +In the **Git** section, create a repository with: + +| Field | Value | +| --- | --- | +| Provider | `github`, `gitlab`, or `git` | +| Remote URL | Your repository remote URL | +| Default branch | Usually `develop` or `main` | +| Webhook secret | A strong random secret, for example `openssl rand -hex 32` | + +After saving, the repository table shows the webhook endpoint: + +```text +/api/helpdesk/git/webhook/{repository_id} +``` + +## 2. Ticket reference convention + +Use the Helpdesk ticket reference in branch names and commit messages: + +```bash +git checkout -b feature/HD-2026-00012-canned-responses +git commit -m "HD-2026-00012 Add canned response macro rendering" +``` + +The parser recognises this format: + +```text +HD-YYYY-00000 +``` + +The first matching reference is linked to the stored commit when the ticket exists and matches the repository company/project scope. + +## 3. GitHub setup + +In GitHub, open the repository settings and add a webhook: + +| GitHub field | Value | +| --- | --- | +| Payload URL | `https://your-app.test/api/helpdesk/git/webhook/{repository_id}` | +| Content type | `application/json` | +| Secret | Same value stored in Helpdesk | +| Events | Push events | + +The module validates `X-Hub-Signature-256` using HMAC SHA-256 and rejects unsigned or invalid requests. + +## 4. GitLab setup + +In GitLab, open **Settings > Webhooks** and add: + +| GitLab field | Value | +| --- | --- | +| URL | `https://your-app.test/api/helpdesk/git/webhook/{repository_id}` | +| Secret token | Same value stored in Helpdesk | +| Trigger | Push events | + +The module validates `X-Gitlab-Token` and rejects invalid requests. + +## 5. Generic Git provider setup + +For a simple internal provider, send a JSON push payload to the same endpoint with: + +```http +X-Helpdesk-Token: your-secret +X-Helpdesk-Event: push +``` + +The payload should include: + +```json +{ + "ref": "refs/heads/develop", + "commits": [ + { + "id": "full-commit-hash", + "message": "HD-2026-00012 Fix ticket workflow", + "url": "https://example.test/commit/full-commit-hash", + "timestamp": "2026-06-15T10:00:00+00:00", + "author": { + "name": "Support Agent", + "email": "agent@example.com" + } + } + ] +} +``` + +## 6. Audit trail + +Every accepted, ignored, or rejected webhook delivery is recorded in: + +```text +helpdesk_git_webhook_logs +``` + +Stored data includes provider, event, delivery id, status, message, commit count, linked count, ignored count, payload, and received timestamp. + +## 7. Verification commands + +Run the focused feature test after installing the updated module: + +```bash +php artisan test Modules/Helpdesk/Tests/Feature/GitWebhookIngestionTest.php +``` + +Then run the full Helpdesk test set: + +```bash +php artisan test Modules/Helpdesk/Tests/Feature Modules/Helpdesk/Tests/Unit +php artisan dusk Modules/Helpdesk/Tests/Browser +``` diff --git a/Helpdesk/Docs/INSTALL.md b/Helpdesk/Docs/INSTALL.md new file mode 100644 index 00000000..c799944e --- /dev/null +++ b/Helpdesk/Docs/INSTALL.md @@ -0,0 +1,99 @@ +# Helpdesk Module Install + +## 1. Copy the module + +Copy `Modules/Helpdesk` into your Laravel 13 project. + +## 2. Register the provider + +If your module loader reads `module.json`, no manual provider entry is needed. Otherwise add this provider to `bootstrap/providers.php`: + +```php +Modules\Helpdesk\Providers\HelpdeskServiceProvider::class, +``` + +## 3. Add the helpdesk disk + +Add this to `config/filesystems.php` disks: + +```php +'helpdesk' => [ + 'driver' => 'local', + 'root' => storage_path('app/public/helpdesk'), + 'url' => env('APP_URL') . '/storage/helpdesk', + 'visibility' => 'public', +], +``` + +Then run: + +```bash +php artisan storage:link +``` + +## 4. Add relations to `App\Models\User` + +```php +use Modules\Helpdesk\Concerns\HasHelpdeskTickets; + +class User extends Authenticatable +{ + use HasHelpdeskTickets; +} +``` + +Add casts if you want strict booleans: + +```php +protected function casts(): array +{ + return [ + 'email_verified_at' => 'datetime', + 'password' => 'hashed', + 'helpdesk_agent' => 'boolean', + 'helpdesk_admin' => 'boolean', + ]; +} +``` + +## 5. Run migrations and seeders + +```bash +php artisan migrate +php artisan db:seed --class="Modules\\Helpdesk\\database\\seeders\\HelpdeskDatabaseSeeder" +``` + +## 6. Add dashboard widget + +Place this where you want it on your dashboard: + +```blade +@include('helpdesk::dashboard.card') +``` + +or directly: + +```blade + +``` + +## 7. Navigation link + +```blade + + Helpdesk + +``` + +## 8. Tests + +```bash +php artisan test Modules/Helpdesk/tests/Feature +php artisan dusk Modules/Helpdesk/tests/Browser +``` +## 9. Git webhook tracking + +The module supports signed Git push webhooks for repository commit tracking. Register repositories in `/helpdesk/settings`, copy the generated webhook endpoint from the Git table, and configure GitHub/GitLab push webhooks with the same secret stored on the repository. + +See `Docs/GIT_WEBHOOK_TRACKING.md` for provider-specific setup, ticket reference conventions, and verification commands. + diff --git a/Helpdesk/Docs/WMS_SUPPORT_MENU_PATCH.md b/Helpdesk/Docs/WMS_SUPPORT_MENU_PATCH.md new file mode 100644 index 00000000..4c26b9c4 --- /dev/null +++ b/Helpdesk/Docs/WMS_SUPPORT_MENU_PATCH.md @@ -0,0 +1,16 @@ +# WMS Support Menu Patch + +Add this block to the main application sidebar inside `