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 00000000..c563f29a --- /dev/null +++ b/Modules/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/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 00000000..49c06d9b --- /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']); + $table->index(['provider', 'event']); + }); + } +}; diff --git a/Modules/Helpdesk/Database/Seeders/HelpdeskDatabaseSeeder.php b/Modules/Helpdesk/Database/Seeders/HelpdeskDatabaseSeeder.php index 98c43917..3c880c53 100644 --- a/Modules/Helpdesk/Database/Seeders/HelpdeskDatabaseSeeder.php +++ b/Modules/Helpdesk/Database/Seeders/HelpdeskDatabaseSeeder.php @@ -7,11 +7,16 @@ namespace Modules\Helpdesk\Database\Seeders; use Illuminate\Database\Seeder; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; +use Modules\Helpdesk\Models\CannedResponse; use Modules\Helpdesk\Models\Category; +use Modules\Helpdesk\Models\ChecklistTemplate; use Modules\Helpdesk\Models\KanbanBoard; use Modules\Helpdesk\Models\Priority; +use Modules\Helpdesk\Models\Product; use Modules\Helpdesk\Models\Project; +use Modules\Helpdesk\Models\SlaPolicy; use Modules\Helpdesk\Models\Status; +use Modules\Helpdesk\Models\Tag; use Modules\Helpdesk\Models\Team; use Modules\Helpdesk\Models\WorkflowTemplate; @@ -30,6 +35,7 @@ final class HelpdeskDatabaseSeeder extends Seeder $this->seedTeamsAndProjects(); $this->seedKanbanBoards(); $this->seedWorkflowTemplates(); + $this->seedRedmineInspiredDefaults(); } /** @@ -172,6 +178,80 @@ final class HelpdeskDatabaseSeeder extends Seeder } } + /** + * 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 }}
{{ route('api.helpdesk.git.webhook', ['repository' => $item->id]) }}
+ Helpdesk administration
Manage the lookup data used by tickets, including statuses, priorities, categories, company teams, company projects, Git repositories and workflow messages.
+Manage lookup data, workflow messages, canned responses, SLA policies, automation rules, checklists, tags and product/service linkage.
Printer labels fail for every order.
', + 'priority_id' => $fixtures['normal']->id, + 'category_id' => $fixtures['category']->id, + 'project_id' => $fixtures['project']->id, + 'product_id' => $product->id, + ]); + + $ticket = $ticket->fresh(['checklistItems']); + + self::assertSame($fixtures['high']->id, $ticket->priority_id); + self::assertSame($fixtures['team']->id, $ticket->team_id); + self::assertSame($product->id, $ticket->product_id); + self::assertNotNull($ticket->sla_policy_id); + self::assertNotNull($ticket->sla_first_response_due_at); + self::assertCount(2, $ticket->checklistItems); + $this->assertDatabaseHas('helpdesk_ticket_sla_events', ['ticket_id' => $ticket->id, 'event' => 'sla.policy_applied']); + } + + public function test_canned_response_macros_insert_into_ticket_reply_editor(): void + { + $fixtures = $this->seedLookupData(); + $agent = $this->agent(['name' => 'Agent Smith']); + $ticket = $this->ticket(['owner' => $this->user(['name' => 'Customer Jane']), 'agent' => $agent, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id]); + $response = CannedResponse::query()->create([ + 'name' => 'Acknowledgement', + 'slug' => 'acknowledgement', + 'body' => 'Hello {{ requester.name }}, ticket {{ ticket.reference }} is being reviewed by {{ agent.name }}.', + 'is_active' => true, + ]); + + Livewire::actingAs($agent) + ->test(TicketShow::class, ['ticket' => $ticket]) + ->set('canned_response_id', $response->id) + ->call('applyCannedResponse') + ->assertSet('commentBody', 'Hello Customer Jane, ticket '.$ticket->reference.' is being reviewed by Agent Smith.'); + } + + public function test_sla_service_records_first_response_and_resolution_breaches(): void + { + $fixtures = $this->seedLookupData(); + $agent = $this->agent(); + $ticket = $this->ticket(['status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['high']->id]); + + $ticket->forceFill([ + 'sla_first_response_due_at' => now()->subMinute(), + 'sla_resolution_due_at' => now()->subMinute(), + ])->save(); + + app(SlaService::class)->recordPublicResponse($ticket->fresh(), $agent); + app(SlaService::class)->recordClosure($ticket->fresh()); + + $ticket = $ticket->fresh(); + + self::assertNotNull($ticket->first_response_at); + self::assertNotNull($ticket->sla_first_response_breached_at); + self::assertNotNull($ticket->sla_resolution_breached_at); + self::assertGreaterThanOrEqual(2, TicketSlaEvent::query()->where('ticket_id', $ticket->id)->count()); + } + + public function test_checklist_items_can_be_toggled_by_agents(): void + { + $fixtures = $this->seedLookupData(); + $agent = $this->agent(); + $ticket = $this->ticket(['agent' => $agent, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id]); + $item = ChecklistItem::query()->create(['ticket_id' => $ticket->id, 'title' => 'Confirm reproduction', 'sort_order' => 10]); + + $updated = app(ChecklistService::class)->toggle($item, $agent); + + self::assertTrue($updated->is_completed); + self::assertSame($agent->id, $updated->completed_by); + self::assertNotNull($updated->completed_at); + } +} diff --git a/Modules/Helpdesk/changelog.yaml b/Modules/Helpdesk/changelog.yaml index f3e2e0c9..ea83f25d 100644 --- a/Modules/Helpdesk/changelog.yaml +++ b/Modules/Helpdesk/changelog.yaml @@ -63,3 +63,42 @@ versions: - Improved ticket create and edit form layout, mandatory markers, dark footer styling and searchable Select2-capable dropdown loading. - Kept module.json version in sync with changelog.yaml. + "1.2.0": + changed: + - Added RedmineUP-inspired canned responses with professional reply macro tokens for ticket, requester, agent, status, priority, category, project, team and URL values. + - Added SLA policies, first-response and resolution due dates, breach tracking, SLA event history and ticket/Kanban breach indicators. + - Added incoming email automation rule models and service support for matching sender, subject, mailbox and category to auto-set priority, category, team, project, assignee and status. + - Added database-driven autoresponder enhancements with optional read-receipt tracking tokens, workflow log open timestamps and open counts. + - Added customer/requester profile context on ticket detail pages with tags and richer support metadata. + - Added satisfaction rating data model for closed-ticket feedback reporting by agent, team and category. + - Added ticket tags, checklist templates, checklist items, checklist progress and agent checklist toggling. + - Added product/service/build linkage for tickets with support cost/time fields and product-aware administration. + - Extended Helpdesk settings with professional CRUD sections for canned responses, SLA policies, automation rules, checklist templates, tags and products/services. + - Extended ticket create/edit/show and Kanban screens to include product/service linkage, SLA status, tags and checklist progress without breaking the existing workflow. + - Added PHPUnit coverage for automation, SLA, canned response macro rendering, checklist toggling and product linkage, plus Dusk coverage for the new settings sections. + - Kept module.json version in sync with changelog.yaml. + + "1.2.1": + changed: + - Added signed Git webhook ingestion endpoint at POST /api/helpdesk/git/webhook/{repository} for registered repositories. + - Added GitHub X-Hub-Signature-256 HMAC validation, GitLab X-Gitlab-Token validation and a generic X-Helpdesk-Token fallback for internal providers. + - Added automatic commit persistence and Helpdesk ticket linking from HD-YYYY-00000 references in commit messages, branch names and commit URLs. + - Added helpdesk_git_webhook_logs for auditable accepted, ignored and rejected provider deliveries. + - Added Git webhook endpoint visibility and delivery counts to the Helpdesk Git repository settings table. + - Added provider setup documentation for GitHub, GitLab and generic signed Git payloads. + - Added PHPUnit coverage for accepted GitHub/GitLab push webhooks, invalid signature rejection and ignored non-push events. + - Kept module.json version in sync with changelog.yaml. + + "1.2.2": + changed: + - Fixed Helpdesk settings rendering during rolling upgrades by defaulting newly added RedmineUP/Git settings collections in the Blade view. + - Prevented undefined variable errors for canned responses, SLA policies, automation rules, checklist templates, tags and products when compiled views and Livewire component code are briefly out of sync. + - Kept module.json version in sync with changelog.yaml. + + "1.2.3": + changed: + - Fixed Helpdesk settings Livewire actions for the RedmineUP-inspired sections so SLA, canned responses, automation, products, tags and checklists can be saved from /helpdesk/settings. + - Added SettingsManager mount-time form normalisation to survive stale Livewire snapshots after module upgrades. + - Added a defensive workflow tracking-pixel URL fallback so ticket creation cannot fail when route caches are stale. + - Registered the workflow open pixel route in the web route file as a backup to the module provider route. + - Kept module.json version in sync with changelog.yaml. diff --git a/Modules/Helpdesk/module.json b/Modules/Helpdesk/module.json index f988ba09..c31083a0 100644 --- a/Modules/Helpdesk/module.json +++ b/Modules/Helpdesk/module.json @@ -1,17 +1,26 @@ { "name": "Helpdesk", "alias": "helpdesk", - "description": "Livewire/Flux helpdesk module with ticketing, comments, attachments, assignment and dashboard widgets.", + "description": "Professional Livewire/Flux helpdesk module with ticketing, Kanban, SLA, canned responses, automation rules, checklists, ratings, read receipts, product/service linkage and signed Git webhook tracking.", "keywords": [ "helpdesk", "tickets", "livewire", - "flux" + "flux", + "sla", + "automation", + "canned-responses", + "kanban", + "checklists", + "git-webhooks", + "github", + "gitlab", + "audit" ], "priority": 0, "providers": [ "Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider" ], "files": [], - "version": "1.1.3" + "version": "1.2.3" }