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 }}

', + '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/Modules/Helpdesk/Docs/GIT_WEBHOOK_TRACKING.md b/Modules/Helpdesk/Docs/GIT_WEBHOOK_TRACKING.md new file mode 100644 index 00000000..7e306245 --- /dev/null +++ b/Modules/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/Modules/Helpdesk/Docs/INSTALL.md b/Modules/Helpdesk/Docs/INSTALL.md index 1ea4246d..c799944e 100644 --- a/Modules/Helpdesk/Docs/INSTALL.md +++ b/Modules/Helpdesk/Docs/INSTALL.md @@ -91,3 +91,9 @@ or directly: 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/Modules/Helpdesk/Http/Controllers/GitWebhookController.php b/Modules/Helpdesk/Http/Controllers/GitWebhookController.php new file mode 100644 index 00000000..15971665 --- /dev/null +++ b/Modules/Helpdesk/Http/Controllers/GitWebhookController.php @@ -0,0 +1,106 @@ +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); + } + + /** + * Validate provider-specific webhook signatures or tokens. + */ + private function hasValidSignature(Request $request, GitRepository $repository, string $provider, string $rawPayload): bool + { + $secret = trim((string) $repository->webhook_secret); + + if ($secret === '') { + return false; + } + + return match ($provider) { + 'github' => $this->hasValidGitHubSignature($request, $secret, $rawPayload), + 'gitlab' => hash_equals($secret, (string) $request->header('X-Gitlab-Token', '')), + default => hash_equals($secret, (string) $request->header('X-Helpdesk-Token', '')), + }; + } + + /** + * Validate GitHub's X-Hub-Signature-256 HMAC header. + */ + private function hasValidGitHubSignature(Request $request, string $secret, string $rawPayload): bool + { + $signature = (string) $request->header('X-Hub-Signature-256', ''); + $expected = 'sha256='.hash_hmac('sha256', $rawPayload, $secret); + + return hash_equals($expected, $signature); + } + + /** + * 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'), + default => $request->header('X-Helpdesk-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'), + default => $request->header('X-Helpdesk-Delivery'), + }; + } +} diff --git a/Modules/Helpdesk/Http/Controllers/TicketStoreController.php b/Modules/Helpdesk/Http/Controllers/TicketStoreController.php index 724b3478..f7b89abf 100644 --- a/Modules/Helpdesk/Http/Controllers/TicketStoreController.php +++ b/Modules/Helpdesk/Http/Controllers/TicketStoreController.php @@ -31,6 +31,7 @@ final class TicketStoreController extends Controller 'priority_id' => ['required', 'integer', Rule::exists('helpdesk_priorities', 'id')], 'category_id' => ['nullable', 'integer', Rule::exists('helpdesk_categories', 'id')], 'project_id' => ['nullable', 'integer', Rule::exists('helpdesk_projects', 'id')], + 'product_id' => ['nullable', 'integer', Rule::exists('helpdesk_products', 'id')], 'team_id' => ['nullable', 'integer', Rule::exists('helpdesk_teams', 'id')], 'due_at' => ['nullable', 'date'], 'attachments' => ['nullable', 'array'], @@ -51,6 +52,7 @@ final class TicketStoreController extends Controller 'priority_id' => $validated['priority_id'], 'category_id' => $validated['category_id'] ?? null, 'project_id' => $validated['project_id'] ?? null, + 'product_id' => $validated['product_id'] ?? null, 'team_id' => $validated['team_id'] ?? null, 'due_at' => $validated['due_at'] ?? null, ], $uploadedFiles); diff --git a/Modules/Helpdesk/Http/Controllers/WorkflowOpenController.php b/Modules/Helpdesk/Http/Controllers/WorkflowOpenController.php new file mode 100644 index 00000000..04ead045 --- /dev/null +++ b/Modules/Helpdesk/Http/Controllers/WorkflowOpenController.php @@ -0,0 +1,29 @@ +where('tracking_token', $token)->first(); + + if ($log) { + $log->forceFill([ + 'first_opened_at' => $log->first_opened_at ?? now(), + 'last_opened_at' => now(), + 'opened_count' => ((int) $log->opened_count) + 1, + ])->save(); + } + + return response(base64_decode('R0lGODlhAQABAPAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw=='), 200, [ + 'Content-Type' => 'image/gif', + 'Cache-Control' => 'no-store, no-cache, must-revalidate, max-age=0', + ]); + } +} diff --git a/Modules/Helpdesk/Livewire/KanbanBoard.php b/Modules/Helpdesk/Livewire/KanbanBoard.php index 68b4800c..30aceb38 100644 --- a/Modules/Helpdesk/Livewire/KanbanBoard.php +++ b/Modules/Helpdesk/Livewire/KanbanBoard.php @@ -84,7 +84,7 @@ final class KanbanBoard extends Component private function ticketsByStatus(): array { return Ticket::query() - ->with(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team']) + ->with(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'checklistItems']) ->visibleTo(Auth::user()) ->latest('last_activity_at') ->limit(300) diff --git a/Modules/Helpdesk/Livewire/SettingsManager.php b/Modules/Helpdesk/Livewire/SettingsManager.php index 09d73f6b..278c5352 100644 --- a/Modules/Helpdesk/Livewire/SettingsManager.php +++ b/Modules/Helpdesk/Livewire/SettingsManager.php @@ -9,12 +9,18 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; use Livewire\Component; +use Modules\Helpdesk\Models\AutomationRule; +use Modules\Helpdesk\Models\CannedResponse; +use Modules\Helpdesk\Models\ChecklistTemplate; use Modules\Helpdesk\Models\Category; use Modules\Helpdesk\Models\GitRepository; use Modules\Helpdesk\Models\KanbanBoard; use Modules\Helpdesk\Models\Priority; +use Modules\Helpdesk\Models\Product; use Modules\Helpdesk\Models\Project; use Modules\Helpdesk\Models\Status; +use Modules\Helpdesk\Models\SlaPolicy; +use Modules\Helpdesk\Models\Tag; use Modules\Helpdesk\Models\Team; use Modules\Helpdesk\Models\WorkflowTemplate; @@ -43,9 +49,45 @@ final class SettingsManager extends Component /** @var array */ public array $repositoryForm = ['id' => null, 'company_id' => null, 'project_id' => null, 'name' => '', 'provider' => 'git', 'remote_url' => '', 'default_branch' => 'main', 'webhook_secret' => '']; + /** @var array */ + public array $cannedForm = ['id' => null, 'company_id' => null, 'category_id' => null, 'team_id' => null, 'name' => '', 'slug' => '', 'visibility' => 'agents', 'subject' => '', 'body' => '', 'is_active' => true, 'sort_order' => 0]; + + /** @var array */ + public array $slaForm = ['id' => null, 'company_id' => null, 'priority_id' => null, 'category_id' => null, 'name' => '', 'slug' => '', 'first_response_minutes' => 240, 'resolution_minutes' => 2880, 'business_hours_only' => false, 'is_default' => false, 'is_active' => true]; + + /** @var array */ + public array $automationForm = ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'match_sender' => '', 'match_subject' => '', 'match_mailbox' => '', 'match_category_id' => null, 'set_priority_id' => null, 'set_category_id' => null, 'set_team_id' => null, 'set_project_id' => null, 'set_status_id' => null, 'set_agent_id' => null, 'is_active' => true, 'sort_order' => 0]; + + /** @var array */ + public array $productForm = ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'type' => 'service', 'version' => '', 'release_channel' => '', 'description' => '', 'is_active' => true]; + + /** @var array */ + public array $tagForm = ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'zinc']; + + /** @var array */ + public array $checklistTemplateForm = ['id' => null, 'company_id' => null, 'category_id' => null, 'project_id' => null, 'name' => '', 'slug' => '', 'items_text' => '', 'is_active' => true]; + /** @var array */ public array $workflowForm = ['id' => null, 'name' => '', 'event' => 'ticket.created', 'subject' => '', 'html_body' => '', 'text_body' => '', 'to_role' => 'requester', 'cc' => '', 'bcc' => '', 'is_active' => true]; + public function mount(): void + { + $this->statusForm += ['id' => null, 'name' => '', 'slug' => '', 'color' => 'zinc', 'sort_order' => 0, 'is_closed' => false]; + $this->priorityForm += ['id' => null, 'name' => '', 'slug' => '', 'color' => 'zinc', 'sort_order' => 0]; + $this->categoryForm += ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'blue', 'description' => '']; + $this->teamForm += ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'indigo', 'description' => '', 'archived_at' => null]; + $this->projectForm += ['id' => null, 'company_id' => null, 'team_id' => null, 'title' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'archived_at' => null]; + $this->boardForm += ['id' => null, 'name' => '', 'slug' => '', 'description' => '', 'is_default' => false, 'sort_order' => 0, 'status_ids' => []]; + $this->repositoryForm += ['id' => null, 'company_id' => null, 'project_id' => null, 'name' => '', 'provider' => 'git', 'remote_url' => '', 'default_branch' => 'main', 'webhook_secret' => '']; + $this->workflowForm += ['id' => null, 'name' => '', 'event' => 'ticket.created', 'subject' => '', 'html_body' => '', 'text_body' => '', 'to_role' => 'requester', 'cc' => '', 'bcc' => '', 'is_active' => true]; + $this->cannedForm += ['id' => null, 'company_id' => null, 'category_id' => null, 'team_id' => null, 'name' => '', 'slug' => '', 'visibility' => 'agents', 'subject' => '', 'body' => '', 'is_active' => true, 'sort_order' => 0]; + $this->slaForm += ['id' => null, 'company_id' => null, 'priority_id' => null, 'category_id' => null, 'name' => '', 'slug' => '', 'first_response_minutes' => 240, 'resolution_minutes' => 2880, 'business_hours_only' => false, 'is_default' => false, 'is_active' => true]; + $this->automationForm += ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'match_sender' => '', 'match_subject' => '', 'match_mailbox' => '', 'match_category_id' => null, 'set_priority_id' => null, 'set_category_id' => null, 'set_team_id' => null, 'set_project_id' => null, 'set_status_id' => null, 'set_agent_id' => null, 'is_active' => true, 'sort_order' => 0]; + $this->productForm += ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'type' => 'service', 'version' => '', 'release_channel' => '', 'description' => '', 'is_active' => true]; + $this->tagForm += ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'zinc']; + $this->checklistTemplateForm += ['id' => null, 'company_id' => null, 'category_id' => null, 'project_id' => null, 'name' => '', 'slug' => '', 'items_text' => '', 'is_active' => true]; + } + public function saveStatus(): void { $data = $this->validate([ @@ -286,6 +328,8 @@ final class SettingsManager extends Component 'repositoryForm.webhook_secret' => ['nullable', 'string', 'max:120'], ])['repositoryForm']; + $data['provider'] = strtolower(trim((string) $data['provider'])); + GitRepository::query()->updateOrCreate(['id' => $this->repositoryForm['id']], $data); $this->resetRepositoryForm(); session()->flash('success', 'Git repository saved.'); @@ -344,6 +388,232 @@ final class SettingsManager extends Component $this->workflowForm = ['id' => null, 'name' => '', 'event' => 'ticket.created', 'subject' => '', 'html_body' => '', 'text_body' => '', 'to_role' => 'requester', 'cc' => '', 'bcc' => '', 'is_active' => true]; } + + public function saveCannedResponse(): void + { + $data = $this->validate([ + 'cannedForm.company_id' => ['nullable', 'integer'], + 'cannedForm.category_id' => ['nullable', 'integer', 'exists:helpdesk_categories,id'], + 'cannedForm.team_id' => ['nullable', 'integer', 'exists:helpdesk_teams,id'], + 'cannedForm.name' => ['required', 'string', 'max:160'], + 'cannedForm.slug' => ['nullable', 'string', 'max:180'], + 'cannedForm.visibility' => ['required', 'string', 'max:40'], + 'cannedForm.subject' => ['nullable', 'string', 'max:220'], + 'cannedForm.body' => ['required', 'string'], + 'cannedForm.is_active' => ['boolean'], + 'cannedForm.sort_order' => ['required', 'integer', 'min:0'], + ])['cannedForm']; + $data['slug'] = $this->slug($data['slug'] ?: $data['name']); + CannedResponse::query()->updateOrCreate(['id' => $this->cannedForm['id']], $data); + $this->resetCannedResponseForm(); + session()->flash('success', 'Canned response saved.'); + } + + public function editCannedResponse(int $id): void + { + $this->cannedForm = CannedResponse::query()->findOrFail($id)->only(['id','company_id','category_id','team_id','name','slug','visibility','subject','body','is_active','sort_order']); + $this->section = 'canned'; + } + + public function deleteCannedResponse(int $id): void + { + CannedResponse::query()->findOrFail($id)->delete(); + session()->flash('success', 'Canned response deleted.'); + } + + public function resetCannedResponseForm(): void + { + $this->cannedForm = ['id' => null, 'company_id' => null, 'category_id' => null, 'team_id' => null, 'name' => '', 'slug' => '', 'visibility' => 'agents', 'subject' => '', 'body' => '', 'is_active' => true, 'sort_order' => 0]; + } + + public function saveSlaPolicy(): void + { + $data = $this->validate([ + 'slaForm.company_id' => ['nullable', 'integer'], + 'slaForm.priority_id' => ['nullable', 'integer', 'exists:helpdesk_priorities,id'], + 'slaForm.category_id' => ['nullable', 'integer', 'exists:helpdesk_categories,id'], + 'slaForm.name' => ['required', 'string', 'max:160'], + 'slaForm.slug' => ['nullable', 'string', 'max:180'], + 'slaForm.first_response_minutes' => ['required', 'integer', 'min:1'], + 'slaForm.resolution_minutes' => ['required', 'integer', 'min:1'], + 'slaForm.business_hours_only' => ['boolean'], + 'slaForm.is_default' => ['boolean'], + 'slaForm.is_active' => ['boolean'], + ])['slaForm']; + $data['slug'] = $this->slug($data['slug'] ?: $data['name']); + if ((bool) ($data['is_default'] ?? false)) { + SlaPolicy::query()->whereKeyNot($this->slaForm['id'])->update(['is_default' => false]); + } + SlaPolicy::query()->updateOrCreate(['id' => $this->slaForm['id']], $data); + $this->resetSlaPolicyForm(); + session()->flash('success', 'SLA policy saved.'); + } + + public function editSlaPolicy(int $id): void + { + $this->slaForm = SlaPolicy::query()->findOrFail($id)->only(['id','company_id','priority_id','category_id','name','slug','first_response_minutes','resolution_minutes','business_hours_only','is_default','is_active']); + $this->section = 'sla'; + } + + public function deleteSlaPolicy(int $id): void + { + SlaPolicy::query()->findOrFail($id)->delete(); + session()->flash('success', 'SLA policy deleted.'); + } + + public function resetSlaPolicyForm(): void + { + $this->slaForm = ['id' => null, 'company_id' => null, 'priority_id' => null, 'category_id' => null, 'name' => '', 'slug' => '', 'first_response_minutes' => 240, 'resolution_minutes' => 2880, 'business_hours_only' => false, 'is_default' => false, 'is_active' => true]; + } + + public function saveAutomationRule(): void + { + $data = $this->validate([ + 'automationForm.company_id' => ['nullable', 'integer'], + 'automationForm.name' => ['required', 'string', 'max:160'], + 'automationForm.slug' => ['nullable', 'string', 'max:180'], + 'automationForm.match_sender' => ['nullable', 'string', 'max:190'], + 'automationForm.match_subject' => ['nullable', 'string', 'max:220'], + 'automationForm.match_mailbox' => ['nullable', 'string', 'max:190'], + 'automationForm.match_category_id' => ['nullable', 'integer', 'exists:helpdesk_categories,id'], + 'automationForm.set_priority_id' => ['nullable', 'integer', 'exists:helpdesk_priorities,id'], + 'automationForm.set_category_id' => ['nullable', 'integer', 'exists:helpdesk_categories,id'], + 'automationForm.set_team_id' => ['nullable', 'integer', 'exists:helpdesk_teams,id'], + 'automationForm.set_project_id' => ['nullable', 'integer', 'exists:helpdesk_projects,id'], + 'automationForm.set_status_id' => ['nullable', 'integer', 'exists:helpdesk_statuses,id'], + 'automationForm.set_agent_id' => ['nullable', 'integer', 'exists:users,id'], + 'automationForm.is_active' => ['boolean'], + 'automationForm.sort_order' => ['required', 'integer', 'min:0'], + ])['automationForm']; + $data['slug'] = $this->slug($data['slug'] ?: $data['name']); + AutomationRule::query()->updateOrCreate(['id' => $this->automationForm['id']], $data); + $this->resetAutomationRuleForm(); + session()->flash('success', 'Automation rule saved.'); + } + + public function editAutomationRule(int $id): void + { + $this->automationForm = AutomationRule::query()->findOrFail($id)->only(['id','company_id','name','slug','match_sender','match_subject','match_mailbox','match_category_id','set_priority_id','set_category_id','set_team_id','set_project_id','set_status_id','set_agent_id','is_active','sort_order']); + $this->section = 'automation'; + } + + public function deleteAutomationRule(int $id): void + { + AutomationRule::query()->findOrFail($id)->delete(); + session()->flash('success', 'Automation rule deleted.'); + } + + public function resetAutomationRuleForm(): void + { + $this->automationForm = ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'match_sender' => '', 'match_subject' => '', 'match_mailbox' => '', 'match_category_id' => null, 'set_priority_id' => null, 'set_category_id' => null, 'set_team_id' => null, 'set_project_id' => null, 'set_status_id' => null, 'set_agent_id' => null, 'is_active' => true, 'sort_order' => 0]; + } + + public function saveProduct(): void + { + $data = $this->validate([ + 'productForm.company_id' => ['nullable', 'integer'], + 'productForm.name' => ['required', 'string', 'max:160'], + 'productForm.slug' => ['nullable', 'string', 'max:180'], + 'productForm.type' => ['required', 'string', 'max:60'], + 'productForm.version' => ['nullable', 'string', 'max:80'], + 'productForm.release_channel' => ['nullable', 'string', 'max:80'], + 'productForm.description' => ['nullable', 'string'], + 'productForm.is_active' => ['boolean'], + ])['productForm']; + $data['slug'] = $this->slug($data['slug'] ?: $data['name']); + Product::query()->updateOrCreate(['id' => $this->productForm['id']], $data); + $this->resetProductForm(); + session()->flash('success', 'Product/service saved.'); + } + + public function editProduct(int $id): void + { + $this->productForm = Product::query()->findOrFail($id)->only(['id','company_id','name','slug','type','version','release_channel','description','is_active']); + $this->section = 'products'; + } + + public function deleteProduct(int $id): void + { + Product::query()->findOrFail($id)->delete(); + session()->flash('success', 'Product/service deleted.'); + } + + public function resetProductForm(): void + { + $this->productForm = ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'type' => 'service', 'version' => '', 'release_channel' => '', 'description' => '', 'is_active' => true]; + } + + public function saveTag(): void + { + $data = $this->validate([ + 'tagForm.company_id' => ['nullable', 'integer'], + 'tagForm.name' => ['required', 'string', 'max:80'], + 'tagForm.slug' => ['nullable', 'string', 'max:100'], + 'tagForm.color' => ['required', 'string', 'max:30'], + ])['tagForm']; + $data['slug'] = $this->slug($data['slug'] ?: $data['name']); + Tag::query()->updateOrCreate(['id' => $this->tagForm['id']], $data); + $this->resetTagForm(); + session()->flash('success', 'Tag saved.'); + } + + public function editTag(int $id): void + { + $this->tagForm = Tag::query()->findOrFail($id)->only(['id','company_id','name','slug','color']); + $this->section = 'tags'; + } + + public function deleteTag(int $id): void + { + Tag::query()->findOrFail($id)->delete(); + session()->flash('success', 'Tag deleted.'); + } + + public function resetTagForm(): void + { + $this->tagForm = ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'zinc']; + } + + public function saveChecklistTemplate(): void + { + $data = $this->validate([ + 'checklistTemplateForm.company_id' => ['nullable', 'integer'], + 'checklistTemplateForm.category_id' => ['nullable', 'integer', 'exists:helpdesk_categories,id'], + 'checklistTemplateForm.project_id' => ['nullable', 'integer', 'exists:helpdesk_projects,id'], + 'checklistTemplateForm.name' => ['required', 'string', 'max:160'], + 'checklistTemplateForm.slug' => ['nullable', 'string', 'max:180'], + 'checklistTemplateForm.items_text' => ['required', 'string'], + 'checklistTemplateForm.is_active' => ['boolean'], + ])['checklistTemplateForm']; + $itemsText = (string) ($data['items_text'] ?? ''); + unset($data['items_text']); + $data['slug'] = $this->slug($data['slug'] ?: $data['name']); + $data['items'] = collect(preg_split('/\r\n|\r|\n/', $itemsText) ?: [])->map(fn ($line): string => trim($line))->filter()->values()->all(); + ChecklistTemplate::query()->updateOrCreate(['id' => $this->checklistTemplateForm['id']], $data); + $this->resetChecklistTemplateForm(); + session()->flash('success', 'Checklist template saved.'); + } + + public function editChecklistTemplate(int $id): void + { + $template = ChecklistTemplate::query()->findOrFail($id); + $this->checklistTemplateForm = $template->only(['id','company_id','category_id','project_id','name','slug','is_active']) + [ + 'items_text' => collect($template->items ?? [])->map(fn ($item): string => is_array($item) ? (string) ($item['title'] ?? '') : (string) $item)->implode("\n"), + ]; + $this->section = 'checklists'; + } + + public function deleteChecklistTemplate(int $id): void + { + ChecklistTemplate::query()->findOrFail($id)->delete(); + session()->flash('success', 'Checklist template deleted.'); + } + + public function resetChecklistTemplateForm(): void + { + $this->checklistTemplateForm = ['id' => null, 'company_id' => null, 'category_id' => null, 'project_id' => null, 'name' => '', 'slug' => '', 'items_text' => '', 'is_active' => true]; + } + public function render(): View { return view('helpdesk::livewire.settings-manager', [ @@ -354,8 +624,14 @@ final class SettingsManager extends Component 'teams' => Team::query()->withCount(['projects', 'tickets'])->orderBy('name')->get(), 'projects' => Project::query()->with(['team'])->withCount('tickets')->orderBy('title')->get(), 'boards' => KanbanBoard::query()->with('statuses')->orderBy('sort_order')->orderBy('name')->get(), - 'repositories' => GitRepository::query()->with(['project'])->withCount('commits')->orderBy('name')->get(), + 'repositories' => GitRepository::query()->with(['project'])->withCount(['commits', 'webhookLogs'])->orderBy('name')->get(), 'workflowTemplates' => WorkflowTemplate::query()->orderBy('event')->orderBy('name')->get(), + 'cannedResponses' => CannedResponse::query()->with(['category','team'])->orderBy('sort_order')->orderBy('name')->get(), + 'slaPolicies' => SlaPolicy::query()->with(['priority','category'])->orderByDesc('is_default')->orderBy('name')->get(), + 'automationRules' => AutomationRule::query()->orderBy('sort_order')->orderBy('name')->get(), + 'products' => Product::query()->withCount('tickets')->orderBy('name')->get(), + 'tags' => Tag::query()->orderBy('name')->get(), + 'checklistTemplates' => ChecklistTemplate::query()->orderBy('name')->get(), ])->layout('layouts.app'); } diff --git a/Modules/Helpdesk/Livewire/TicketCreate.php b/Modules/Helpdesk/Livewire/TicketCreate.php index e610c462..58e9ab74 100644 --- a/Modules/Helpdesk/Livewire/TicketCreate.php +++ b/Modules/Helpdesk/Livewire/TicketCreate.php @@ -14,6 +14,7 @@ use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; use Livewire\WithFileUploads; use Modules\Helpdesk\Models\Category; use Modules\Helpdesk\Models\Priority; +use Modules\Helpdesk\Models\Product; use Modules\Helpdesk\Models\Project; use Modules\Helpdesk\Models\Team; use Modules\Helpdesk\Services\TicketService; @@ -40,6 +41,9 @@ final class TicketCreate extends Component #[Validate('nullable|integer|exists:helpdesk_projects,id')] public ?int $project_id = null; + #[Validate('nullable|integer|exists:helpdesk_products,id')] + public ?int $product_id = null; + #[Validate('nullable|integer|exists:helpdesk_teams,id')] public ?int $team_id = null; @@ -68,6 +72,7 @@ final class TicketCreate extends Component 'priority_id' => $this->priority_id, 'category_id' => $this->category_id, 'project_id' => $this->project_id, + 'product_id' => $this->product_id, 'team_id' => $this->team_id, 'due_at' => $this->due_at, ], $this->attachments); @@ -85,6 +90,7 @@ final class TicketCreate extends Component 'companies' => Schema::hasTable('companies') ? DB::table('companies')->select(['id', 'name'])->orderBy('name')->get() : collect(), 'teams' => Team::query()->whereNull('archived_at')->orderBy('name')->get(), 'projects' => Project::query()->whereNull('archived_at')->orderBy('title')->get(), + 'products' => Product::query()->where('is_active', true)->orderBy('name')->get(), ])->layout('layouts.app'); } } diff --git a/Modules/Helpdesk/Livewire/TicketEdit.php b/Modules/Helpdesk/Livewire/TicketEdit.php index 98cfe6c6..ab4c16a2 100644 --- a/Modules/Helpdesk/Livewire/TicketEdit.php +++ b/Modules/Helpdesk/Livewire/TicketEdit.php @@ -13,10 +13,12 @@ use Livewire\Attributes\Validate; use Livewire\Component; use Modules\Helpdesk\Models\Category; use Modules\Helpdesk\Models\Priority; +use Modules\Helpdesk\Models\Product; use Modules\Helpdesk\Models\Project; use Modules\Helpdesk\Models\Status; use Modules\Helpdesk\Models\Team; use Modules\Helpdesk\Models\Ticket; +use Modules\Helpdesk\Services\SlaService; use Modules\Helpdesk\Services\WorkflowNotificationService; final class TicketEdit extends Component @@ -44,6 +46,9 @@ final class TicketEdit extends Component #[Validate('nullable|integer|exists:helpdesk_projects,id')] public ?int $project_id = null; + #[Validate('nullable|integer|exists:helpdesk_products,id')] + public ?int $product_id = null; + #[Validate('nullable|integer|exists:helpdesk_teams,id')] public ?int $team_id = null; @@ -65,12 +70,13 @@ final class TicketEdit extends Component $this->priority_id = $this->ticket->priority_id; $this->category_id = $this->ticket->category_id; $this->project_id = $this->ticket->project_id; + $this->product_id = $this->ticket->product_id; $this->team_id = $this->ticket->team_id; $this->agent_id = $this->ticket->agent_id; $this->due_at = $this->ticket->due_at?->format('Y-m-d'); } - public function save(WorkflowNotificationService $workflow): mixed + public function save(WorkflowNotificationService $workflow, SlaService $sla): mixed { $this->authorize('update', $this->ticket); $this->validate(); @@ -85,6 +91,7 @@ final class TicketEdit extends Component 'priority_id' => $this->priority_id, 'category_id' => $this->category_id, 'project_id' => $this->project_id, + 'product_id' => $this->product_id, 'team_id' => $this->team_id, 'agent_id' => $this->agent_id, 'due_at' => $this->due_at, @@ -92,7 +99,11 @@ final class TicketEdit extends Component 'last_activity_at' => now(), ])->save(); - $fresh = $this->ticket->fresh(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team']) ?? $this->ticket; + if ($status->is_closed) { + $sla->recordClosure($this->ticket); + } + + $fresh = $this->ticket->fresh(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team', 'product']) ?? $this->ticket; $workflow->dispatch($fresh, $status->is_closed ? 'ticket.closed' : 'ticket.updated', Auth::user()); session()->flash('success', 'Ticket ' . $fresh->reference . ' has been updated.'); @@ -109,6 +120,7 @@ final class TicketEdit extends Component 'companies' => Schema::hasTable('companies') ? DB::table('companies')->select(['id', 'name'])->orderBy('name')->get() : collect(), 'teams' => Team::query()->whereNull('archived_at')->orderBy('name')->get(), 'projects' => Project::query()->whereNull('archived_at')->orderBy('title')->get(), + 'products' => Product::query()->where('is_active', true)->orderBy('name')->get(), 'agents' => User::query() ->where(static function ($query): void { $query->where('helpdesk_agent', true)->orWhere('helpdesk_admin', true); diff --git a/Modules/Helpdesk/Livewire/TicketShow.php b/Modules/Helpdesk/Livewire/TicketShow.php index 0e5fe8fa..ea84c274 100644 --- a/Modules/Helpdesk/Livewire/TicketShow.php +++ b/Modules/Helpdesk/Livewire/TicketShow.php @@ -12,9 +12,14 @@ use Livewire\Attributes\Validate; use Livewire\Component; use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; use Livewire\WithFileUploads; +use Modules\Helpdesk\Models\CannedResponse; +use Modules\Helpdesk\Models\ChecklistItem; use Modules\Helpdesk\Models\Priority; use Modules\Helpdesk\Models\Status; use Modules\Helpdesk\Models\Ticket; +use Modules\Helpdesk\Services\CannedResponseRenderer; +use Modules\Helpdesk\Services\ChecklistService; +use Modules\Helpdesk\Services\SlaService; use Modules\Helpdesk\Services\TicketService; use Modules\Helpdesk\Services\WorkflowNotificationService; @@ -40,6 +45,8 @@ final class TicketShow extends Component public string $replyBody = ''; + public ?int $canned_response_id = null; + public function mount(Ticket $ticket): void { $this->authorize('view', $ticket); @@ -94,7 +101,37 @@ final class TicketShow extends Component $this->dispatch('helpdesk-reply-added'); } - public function updateStatus(WorkflowNotificationService $workflow): void + + public function applyCannedResponse(CannedResponseRenderer $renderer): void + { + $this->authorize('comment', $this->ticket); + + if ($this->canned_response_id === null) { + return; + } + + $response = CannedResponse::query() + ->where('is_active', true) + ->findOrFail($this->canned_response_id); + + $rendered = $renderer->render($response->body, $this->ticket, Auth::user()); + $this->commentBody = trim($this->commentBody."\n\n".$rendered); + $this->dispatch('helpdesk-canned-response-applied', body: $this->commentBody); + } + + public function toggleChecklistItem(int $itemId, ChecklistService $checklists): void + { + $this->authorize('update', $this->ticket); + + $item = ChecklistItem::query() + ->where('ticket_id', $this->ticket->id) + ->findOrFail($itemId); + + $checklists->toggle($item, Auth::user()); + $this->ticket = $this->freshTicket($this->ticket); + } + + public function updateStatus(WorkflowNotificationService $workflow, SlaService $sla): void { $this->authorize('update', $this->ticket); $this->validate(['status_id' => ['required', 'integer', 'exists:helpdesk_statuses,id']]); @@ -106,6 +143,10 @@ final class TicketShow extends Component 'last_activity_at' => now(), ])->save(); + if ($status->is_closed) { + $sla->recordClosure($this->ticket); + } + $this->ticket = $this->freshTicket($this->ticket); $workflow->dispatch($this->ticket, $status->is_closed ? 'ticket.closed' : 'ticket.updated', Auth::user()); $this->dispatch('helpdesk-status-updated'); @@ -131,6 +172,7 @@ final class TicketShow extends Component return view('helpdesk::livewire.ticket-show', [ 'statuses' => Status::query()->orderBy('sort_order')->get(), 'priorities' => Priority::query()->orderBy('sort_order')->get(), + 'cannedResponses' => CannedResponse::query()->where('is_active', true)->orderBy('sort_order')->orderBy('name')->get(), ])->layout('layouts.app'); } @@ -144,6 +186,12 @@ final class TicketShow extends Component 'category', 'project', 'team', + 'product', + 'slaPolicy', + 'slaEvents', + 'ratings', + 'tags', + 'checklistItems', 'attachments.user', 'gitCommits.repository', ]) ?? $ticket; diff --git a/Modules/Helpdesk/Models/AutomationRule.php b/Modules/Helpdesk/Models/AutomationRule.php new file mode 100644 index 00000000..3665923c --- /dev/null +++ b/Modules/Helpdesk/Models/AutomationRule.php @@ -0,0 +1,14 @@ +'integer','match_category_id'=>'integer','set_priority_id'=>'integer','set_category_id'=>'integer','set_team_id'=>'integer','set_project_id'=>'integer','set_status_id'=>'integer','set_agent_id'=>'integer','is_active'=>'boolean','sort_order'=>'integer']; +} diff --git a/Modules/Helpdesk/Models/CannedResponse.php b/Modules/Helpdesk/Models/CannedResponse.php new file mode 100644 index 00000000..d932c603 --- /dev/null +++ b/Modules/Helpdesk/Models/CannedResponse.php @@ -0,0 +1,17 @@ +'integer','category_id'=>'integer','team_id'=>'integer','is_active'=>'boolean','sort_order'=>'integer']; + public function category(): BelongsTo { return $this->belongsTo(Category::class, 'category_id'); } + public function team(): BelongsTo { return $this->belongsTo(Team::class, 'team_id'); } +} diff --git a/Modules/Helpdesk/Models/ChecklistItem.php b/Modules/Helpdesk/Models/ChecklistItem.php new file mode 100644 index 00000000..1a431bb1 --- /dev/null +++ b/Modules/Helpdesk/Models/ChecklistItem.php @@ -0,0 +1,16 @@ +'integer','template_id'=>'integer','is_completed'=>'boolean','completed_by'=>'integer','completed_at'=>'datetime','sort_order'=>'integer']; + public function ticket(): BelongsTo { return $this->belongsTo(Ticket::class, 'ticket_id'); } +} diff --git a/Modules/Helpdesk/Models/ChecklistTemplate.php b/Modules/Helpdesk/Models/ChecklistTemplate.php new file mode 100644 index 00000000..e9bb4270 --- /dev/null +++ b/Modules/Helpdesk/Models/ChecklistTemplate.php @@ -0,0 +1,14 @@ +'integer','category_id'=>'integer','project_id'=>'integer','items'=>'array','is_active'=>'boolean']; +} diff --git a/Modules/Helpdesk/Models/GitRepository.php b/Modules/Helpdesk/Models/GitRepository.php index a09595f2..cf58fc29 100644 --- a/Modules/Helpdesk/Models/GitRepository.php +++ b/Modules/Helpdesk/Models/GitRepository.php @@ -32,4 +32,10 @@ final class GitRepository extends Model { return $this->hasMany(GitCommit::class, 'repository_id')->latest('committed_at'); } + + /** @return HasMany */ + public function webhookLogs(): HasMany + { + return $this->hasMany(GitWebhookLog::class, 'repository_id')->latest('received_at'); + } } diff --git a/Modules/Helpdesk/Models/GitWebhookLog.php b/Modules/Helpdesk/Models/GitWebhookLog.php new file mode 100644 index 00000000..ffdabe56 --- /dev/null +++ b/Modules/Helpdesk/Models/GitWebhookLog.php @@ -0,0 +1,51 @@ + */ + protected $fillable = [ + 'repository_id', + 'provider', + 'delivery_id', + 'event', + 'status', + 'message', + 'commit_count', + 'linked_count', + 'ignored_count', + 'payload', + 'received_at', + ]; + + /** @var array */ + protected $casts = [ + 'repository_id' => 'integer', + 'commit_count' => 'integer', + 'linked_count' => 'integer', + 'ignored_count' => 'integer', + 'payload' => 'array', + 'received_at' => 'datetime', + ]; + + /** + * Repository that received the provider webhook. + * + * @return BelongsTo + */ + public function repository(): BelongsTo + { + return $this->belongsTo(GitRepository::class, 'repository_id'); + } +} diff --git a/Modules/Helpdesk/Models/Product.php b/Modules/Helpdesk/Models/Product.php new file mode 100644 index 00000000..cec09c48 --- /dev/null +++ b/Modules/Helpdesk/Models/Product.php @@ -0,0 +1,16 @@ +'integer','is_active'=>'boolean']; + public function tickets(): HasMany { return $this->hasMany(Ticket::class, 'product_id'); } +} diff --git a/Modules/Helpdesk/Models/SlaPolicy.php b/Modules/Helpdesk/Models/SlaPolicy.php new file mode 100644 index 00000000..e249c742 --- /dev/null +++ b/Modules/Helpdesk/Models/SlaPolicy.php @@ -0,0 +1,17 @@ +'integer','priority_id'=>'integer','category_id'=>'integer','first_response_minutes'=>'integer','resolution_minutes'=>'integer','business_hours_only'=>'boolean','is_default'=>'boolean','is_active'=>'boolean']; + public function priority(): BelongsTo { return $this->belongsTo(Priority::class, 'priority_id'); } + public function category(): BelongsTo { return $this->belongsTo(Category::class, 'category_id'); } +} diff --git a/Modules/Helpdesk/Models/Tag.php b/Modules/Helpdesk/Models/Tag.php new file mode 100644 index 00000000..5683f26a --- /dev/null +++ b/Modules/Helpdesk/Models/Tag.php @@ -0,0 +1,16 @@ +'integer']; + public function tickets(): BelongsToMany { return $this->belongsToMany(Ticket::class, 'helpdesk_ticket_tag', 'tag_id', 'ticket_id'); } +} diff --git a/Modules/Helpdesk/Models/Ticket.php b/Modules/Helpdesk/Models/Ticket.php index 283b16d3..1efa5bb8 100644 --- a/Modules/Helpdesk/Models/Ticket.php +++ b/Modules/Helpdesk/Models/Ticket.php @@ -8,6 +8,7 @@ use App\Models\User; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; @@ -27,6 +28,8 @@ final class Ticket extends Model protected $fillable = [ 'reference', 'company_id', 'user_id', 'agent_id', 'status_id', 'priority_id', 'category_id', 'project_id', 'team_id', 'subject', 'body', 'due_at', 'completed_at', 'last_activity_at', 'archived_at', + 'sla_policy_id', 'product_id', 'first_response_at', 'sla_first_response_due_at', 'sla_resolution_due_at', + 'sla_first_response_breached_at', 'sla_resolution_breached_at', 'public_replies_count', 'time_spent_minutes', ]; /** @var array */ @@ -37,6 +40,15 @@ final class Ticket extends Model 'completed_at' => 'datetime', 'last_activity_at' => 'datetime', 'archived_at' => 'datetime', + 'sla_policy_id' => 'integer', + 'product_id' => 'integer', + 'first_response_at' => 'datetime', + 'sla_first_response_due_at' => 'datetime', + 'sla_resolution_due_at' => 'datetime', + 'sla_first_response_breached_at' => 'datetime', + 'sla_resolution_breached_at' => 'datetime', + 'public_replies_count' => 'integer', + 'time_spent_minutes' => 'integer', ]; /** @return BelongsTo */ @@ -89,6 +101,57 @@ final class Ticket extends Model ->oldest(); } + + /** @return BelongsTo */ + public function slaPolicy(): BelongsTo + { + return $this->belongsTo(SlaPolicy::class, 'sla_policy_id'); + } + + /** @return BelongsTo */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class, 'product_id'); + } + + /** @return HasMany */ + public function slaEvents(): HasMany + { + return $this->hasMany(TicketSlaEvent::class, 'ticket_id')->latest(); + } + + /** @return HasMany */ + public function ratings(): HasMany + { + return $this->hasMany(TicketRating::class, 'ticket_id')->latest(); + } + + /** @return HasMany */ + public function checklistItems(): HasMany + { + return $this->hasMany(ChecklistItem::class, 'ticket_id')->orderBy('sort_order')->orderBy('id'); + } + + /** @return BelongsToMany */ + public function tags(): BelongsToMany + { + return $this->belongsToMany(Tag::class, 'helpdesk_ticket_tag', 'ticket_id', 'tag_id'); + } + + public function getSlaFirstResponseBreachedAttribute(): bool + { + return $this->first_response_at === null + && $this->sla_first_response_due_at !== null + && now()->greaterThan($this->sla_first_response_due_at); + } + + public function getSlaResolutionBreachedAttribute(): bool + { + return $this->completed_at === null + && $this->sla_resolution_due_at !== null + && now()->greaterThan($this->sla_resolution_due_at); + } + /** @return HasMany */ public function gitCommits(): HasMany { diff --git a/Modules/Helpdesk/Models/TicketRating.php b/Modules/Helpdesk/Models/TicketRating.php new file mode 100644 index 00000000..6595b314 --- /dev/null +++ b/Modules/Helpdesk/Models/TicketRating.php @@ -0,0 +1,14 @@ +'integer','agent_id'=>'integer','team_id'=>'integer','category_id'=>'integer','rating'=>'integer','rated_at'=>'datetime']; +} diff --git a/Modules/Helpdesk/Models/TicketSlaEvent.php b/Modules/Helpdesk/Models/TicketSlaEvent.php new file mode 100644 index 00000000..567bd41b --- /dev/null +++ b/Modules/Helpdesk/Models/TicketSlaEvent.php @@ -0,0 +1,14 @@ +'integer','sla_policy_id'=>'integer','due_at'=>'datetime','occurred_at'=>'datetime','breached'=>'boolean','meta'=>'array']; +} diff --git a/Modules/Helpdesk/Models/WorkflowLog.php b/Modules/Helpdesk/Models/WorkflowLog.php index 337eb544..2b218c1f 100644 --- a/Modules/Helpdesk/Models/WorkflowLog.php +++ b/Modules/Helpdesk/Models/WorkflowLog.php @@ -15,7 +15,14 @@ final class WorkflowLog extends Model protected $table = 'helpdesk_workflow_logs'; /** @var list */ - protected $fillable = ['template_id', 'ticket_id', 'event', 'recipient', 'subject', 'status', 'error']; + protected $fillable = ['template_id', 'ticket_id', 'event', 'recipient', 'subject', 'status', 'tracking_token', 'first_opened_at', 'last_opened_at', 'opened_count', 'error']; + + /** @var array */ + protected $casts = [ + 'first_opened_at' => 'datetime', + 'last_opened_at' => 'datetime', + 'opened_count' => 'integer', + ]; /** @return BelongsTo */ public function template(): BelongsTo diff --git a/Modules/Helpdesk/Providers/RouteServiceProvider.php b/Modules/Helpdesk/Providers/RouteServiceProvider.php index fde52daf..9acc652e 100644 --- a/Modules/Helpdesk/Providers/RouteServiceProvider.php +++ b/Modules/Helpdesk/Providers/RouteServiceProvider.php @@ -6,6 +6,7 @@ namespace Modules\Helpdesk\Providers; use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider; use Illuminate\Support\Facades\Route; +use Modules\Helpdesk\Http\Controllers\WorkflowOpenController; use Modules\Helpdesk\Support\ModuleInfo; final class RouteServiceProvider extends ServiceProvider @@ -24,6 +25,12 @@ final class RouteServiceProvider extends ServiceProvider */ protected function mapWebRoutes(): void { + Route::middleware(['web']) + ->prefix(config('helpdesk.route_prefix', 'helpdesk')) + ->name('helpdesk.') + ->get('/workflow/open/{token}', WorkflowOpenController::class) + ->name('workflow.open'); + Route::middleware(config('helpdesk.middleware', ['web', 'auth'])) ->prefix(config('helpdesk.route_prefix', 'helpdesk')) ->name('helpdesk.') diff --git a/Modules/Helpdesk/Resources/views/components/repository-table.blade.php b/Modules/Helpdesk/Resources/views/components/repository-table.blade.php index 83cb1d45..54d4d0d8 100644 --- a/Modules/Helpdesk/Resources/views/components/repository-table.blade.php +++ b/Modules/Helpdesk/Resources/views/components/repository-table.blade.php @@ -9,6 +9,7 @@ Provider Branch Commits + Webhook Actions @@ -20,13 +21,17 @@ {{ $item->provider }} {{ $item->default_branch }} {{ $item->commits_count }} + +
{{ $item->webhook_logs_count ?? 0 }} deliveries
+ {{ route('api.helpdesk.git.webhook', ['repository' => $item->id]) }} + Edit Delete @empty - No repositories yet. + No repositories yet. @endforelse diff --git a/Modules/Helpdesk/Resources/views/livewire/kanban-board.blade.php b/Modules/Helpdesk/Resources/views/livewire/kanban-board.blade.php index f2a50bc1..3c08d30f 100644 --- a/Modules/Helpdesk/Resources/views/livewire/kanban-board.blade.php +++ b/Modules/Helpdesk/Resources/views/livewire/kanban-board.blade.php @@ -91,8 +91,28 @@ @if ($ticket->team)
{{ $ticket->team->name }}
@endif + @if ($ticket->product) +
{{ $ticket->product->name }}
+ @endif + @if ($ticket->tags->isNotEmpty()) +
+ @foreach ($ticket->tags as $tag) + {{ $tag->name }} + @endforeach +
+ @endif + + @if ($ticket->checklistItems->isNotEmpty()) + @php($done = $ticket->checklistItems->where('is_completed', true)->count()) +
Checklist {{ $done }}/{{ $ticket->checklistItems->count() }}
+ @endif + + @if ($ticket->sla_first_response_breached || $ticket->sla_resolution_breached) +
SLA breach
+ @endif +
Open @can('update', $ticket) diff --git a/Modules/Helpdesk/Resources/views/livewire/settings-manager.blade.php b/Modules/Helpdesk/Resources/views/livewire/settings-manager.blade.php index d329a19f..b97d8372 100644 --- a/Modules/Helpdesk/Resources/views/livewire/settings-manager.blade.php +++ b/Modules/Helpdesk/Resources/views/livewire/settings-manager.blade.php @@ -1,3 +1,25 @@ +@php + /** + * Keep the settings view safe during rolling module upgrades or Livewire navigate + * hydration where the compiled Blade view may be newer than the loaded component. + */ + $companies = collect($companies ?? []); + $statuses = collect($statuses ?? []); + $priorities = collect($priorities ?? []); + $categories = collect($categories ?? []); + $teams = collect($teams ?? []); + $projects = collect($projects ?? []); + $boards = collect($boards ?? []); + $repositories = collect($repositories ?? []); + $workflowTemplates = collect($workflowTemplates ?? []); + $cannedResponses = collect($cannedResponses ?? []); + $slaPolicies = collect($slaPolicies ?? []); + $automationRules = collect($automationRules ?? []); + $checklistTemplates = collect($checklistTemplates ?? []); + $tags = collect($tags ?? []); + $products = collect($products ?? []); +@endphp +
@@ -5,7 +27,7 @@

Helpdesk administration

Support configuration

-

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.

@@ -20,7 +42,7 @@ {{ session('success') }} @endif -
+
@foreach ([ 'statuses' => ['label' => 'Statuses', 'count' => $statuses->count()], 'priorities' => ['label' => 'Priorities', 'count' => $priorities->count()], @@ -30,6 +52,12 @@ 'boards' => ['label' => 'Boards', 'count' => $boards->count()], 'git' => ['label' => 'Git', 'count' => $repositories->count()], 'workflow' => ['label' => 'Workflow', 'count' => $workflowTemplates->count()], + 'canned' => ['label' => 'Canned', 'count' => $cannedResponses->count()], + 'sla' => ['label' => 'SLA', 'count' => $slaPolicies->count()], + 'automation' => ['label' => 'Rules', 'count' => $automationRules->count()], + 'checklists' => ['label' => 'Checks', 'count' => $checklistTemplates->count()], + 'tags' => ['label' => 'Tags', 'count' => $tags->count()], + 'products' => ['label' => 'Products', 'count' => $products->count()], ] as $key => $item)
diff --git a/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php b/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php index 53dbd53e..b208c704 100644 --- a/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php +++ b/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php @@ -26,7 +26,7 @@ @include('helpdesk::partials.page-header', [ 'eyebrow' => 'Helpdesk', 'title' => 'Create ticket', - 'subtitle' => 'Raise a new support request with priority, project, category, team and full details.', + 'subtitle' => 'Raise a new support request with priority, product/service, project, category, team and full details.', ])
diff --git a/Modules/Helpdesk/Resources/views/livewire/ticket-edit.blade.php b/Modules/Helpdesk/Resources/views/livewire/ticket-edit.blade.php index 48320221..cdd06e58 100644 --- a/Modules/Helpdesk/Resources/views/livewire/ticket-edit.blade.php +++ b/Modules/Helpdesk/Resources/views/livewire/ticket-edit.blade.php @@ -26,7 +26,7 @@ @include('helpdesk::partials.page-header', [ 'eyebrow' => 'Helpdesk', 'title' => 'Edit Ticket', - 'subtitle' => 'Update ownership, assignment, workflow status, project, team, category, priority and ticket details.', + 'subtitle' => 'Update ownership, assignment, workflow status, product/service, project, team, category, priority and ticket details.', ]) diff --git a/Modules/Helpdesk/Resources/views/livewire/ticket-show.blade.php b/Modules/Helpdesk/Resources/views/livewire/ticket-show.blade.php index ad10aef7..1ed6fbd7 100644 --- a/Modules/Helpdesk/Resources/views/livewire/ticket-show.blade.php +++ b/Modules/Helpdesk/Resources/views/livewire/ticket-show.blade.php @@ -59,6 +59,20 @@

Add reply

+ @if ($cannedResponses->isNotEmpty()) +
+ + + @foreach ($cannedResponses as $response) + + @endforeach + +
+ Insert response +
+
+ @endif + @can('update', $ticket) @@ -88,12 +102,67 @@
Category
{{ $ticket->category?->name ?? 'None' }}
Project
{{ $ticket->project?->title ?? 'None' }}
+
Product/service
{{ $ticket->product?->name ?? 'None' }}
Team
{{ $ticket->team?->name ?? 'None' }}
Created
{{ $ticket->created_at->format('d M Y H:i') }}
Due
{{ $ticket->due_at?->format('d M Y') ?? 'No due date' }}
+
+

SLA metrics

+
+
+
First response
+
{{ $ticket->first_response_at?->format('d M Y H:i') ?? 'Awaiting response' }}
+
Due {{ $ticket->sla_first_response_due_at?->format('d M Y H:i') ?? 'Not set' }}
+
+
+
Resolution
+
{{ $ticket->completed_at?->format('d M Y H:i') ?? 'Open' }}
+
Due {{ $ticket->sla_resolution_due_at?->format('d M Y H:i') ?? 'Not set' }}
+
+
+
{{ $ticket->public_replies_count }}
Public replies
+
{{ $ticket->time_spent_minutes }}
Minutes logged
+
+
+
+ +
+

Requester profile

+
+
Name
{{ $ticket->owner?->name ?? 'Unknown' }}
+
Email
{{ $ticket->owner?->email ?? 'None' }}
+
Company
{{ $ticket->owner?->company?->name ?? 'Current company' }}
+
+ @if ($ticket->tags->isNotEmpty()) +
+ @foreach ($ticket->tags as $tag) + {{ $tag->name }} + @endforeach +
+ @endif +
+ + @if ($ticket->checklistItems->isNotEmpty()) +
+ @php($completedChecks = $ticket->checklistItems->where('is_completed', true)->count()) +
+

Checklist

+ {{ $completedChecks }}/{{ $ticket->checklistItems->count() }} +
+
+ @foreach ($ticket->checklistItems as $item) + + @endforeach +
+
+ @endif +

Git commits

diff --git a/Modules/Helpdesk/Routes/api.php b/Modules/Helpdesk/Routes/api.php index 1fcabb32..38392698 100644 --- a/Modules/Helpdesk/Routes/api.php +++ b/Modules/Helpdesk/Routes/api.php @@ -3,8 +3,13 @@ declare(strict_types=1); use Illuminate\Support\Facades\Route; +use Modules\Helpdesk\Http\Controllers\GitWebhookController; Route::get('/status', static fn (): array => [ 'module' => 'helpdesk', 'status' => 'ok', ])->name('status'); + +Route::post('/git/webhook/{repository}', GitWebhookController::class) + ->whereNumber('repository') + ->name('git.webhook'); diff --git a/Modules/Helpdesk/Routes/web.php b/Modules/Helpdesk/Routes/web.php index 25deacdf..7375fff7 100644 --- a/Modules/Helpdesk/Routes/web.php +++ b/Modules/Helpdesk/Routes/web.php @@ -5,6 +5,7 @@ declare(strict_types=1); use Illuminate\Support\Facades\Route; use Modules\Helpdesk\Http\Controllers\HelpdeskController; use Modules\Helpdesk\Http\Controllers\TicketStoreController; +use Modules\Helpdesk\Http\Controllers\WorkflowOpenController; use Modules\Helpdesk\Livewire\KanbanBoard; use Modules\Helpdesk\Livewire\SettingsManager; use Modules\Helpdesk\Livewire\TicketCreate; @@ -12,13 +13,15 @@ use Modules\Helpdesk\Livewire\TicketEdit; use Modules\Helpdesk\Livewire\TicketIndex; use Modules\Helpdesk\Livewire\TicketShow; +Route::get('/workflow/open/{token}', WorkflowOpenController::class)->name('workflow.open'); + Route::get('/', HelpdeskController::class)->name('index'); Route::get('/settings', SettingsManager::class)->name('settings'); Route::get('/kanban', KanbanBoard::class)->name('kanban'); Route::get('/tickets', TicketIndex::class)->name('tickets.index'); -Route::post('/tickets', [TicketStoreController::class, 'store'])->name('tickets.store'); +Route::post('/tickets', TicketStoreController::class)->name('tickets.store'); Route::get('/tickets/create', TicketCreate::class)->name('tickets.create'); Route::get('/tickets/{ticket}/edit', TicketEdit::class)->name('tickets.edit'); Route::get('/tickets/{ticket}', TicketShow::class)->name('tickets.show'); diff --git a/Modules/Helpdesk/Services/AutomationRuleService.php b/Modules/Helpdesk/Services/AutomationRuleService.php new file mode 100644 index 00000000..f0fde459 --- /dev/null +++ b/Modules/Helpdesk/Services/AutomationRuleService.php @@ -0,0 +1,66 @@ + $context */ + public function apply(Ticket $ticket, array $context = []): Ticket + { + $sender = (string) ($context['sender'] ?? $ticket->owner?->email ?? ''); + $mailbox = (string) ($context['mailbox'] ?? ''); + + AutomationRule::query() + ->where('is_active', true) + ->where(static function ($query) use ($ticket): void { + $query->whereNull('company_id')->orWhere('company_id', $ticket->company_id); + }) + ->orderBy('sort_order') + ->orderBy('id') + ->get() + ->each(function (AutomationRule $rule) use ($ticket, $sender, $mailbox): void { + if (! $this->matches($rule, $ticket, $sender, $mailbox)) { + return; + } + + $updates = array_filter([ + 'priority_id' => $rule->set_priority_id, + 'category_id' => $rule->set_category_id, + 'team_id' => $rule->set_team_id, + 'project_id' => $rule->set_project_id, + 'status_id' => $rule->set_status_id, + 'agent_id' => $rule->set_agent_id, + ], static fn ($value): bool => $value !== null); + + if ($updates !== []) { + $ticket->forceFill($updates + ['last_activity_at' => now()])->save(); + } + }); + + return $ticket->fresh() ?? $ticket; + } + + private function matches(AutomationRule $rule, Ticket $ticket, string $sender, string $mailbox): bool + { + if ($rule->match_sender && ! Str::contains(Str::lower($sender), Str::lower($rule->match_sender))) { + return false; + } + if ($rule->match_subject && ! Str::contains(Str::lower($ticket->subject), Str::lower($rule->match_subject))) { + return false; + } + if ($rule->match_mailbox && ! Str::contains(Str::lower($mailbox), Str::lower($rule->match_mailbox))) { + return false; + } + if ($rule->match_category_id && (int) $ticket->category_id !== (int) $rule->match_category_id) { + return false; + } + + return true; + } +} diff --git a/Modules/Helpdesk/Services/CannedResponseRenderer.php b/Modules/Helpdesk/Services/CannedResponseRenderer.php new file mode 100644 index 00000000..99bd8f20 --- /dev/null +++ b/Modules/Helpdesk/Services/CannedResponseRenderer.php @@ -0,0 +1,30 @@ +loadMissing(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team']); + + return strtr($template, [ + '{{ ticket.reference }}' => $ticket->reference, + '{{ ticket.subject }}' => $ticket->subject, + '{{ ticket.status }}' => (string) ($ticket->status?->name ?? ''), + '{{ ticket.priority }}' => (string) ($ticket->priority?->name ?? ''), + '{{ ticket.category }}' => (string) ($ticket->category?->name ?? ''), + '{{ ticket.project }}' => (string) ($ticket->project?->title ?? ''), + '{{ ticket.team }}' => (string) ($ticket->team?->name ?? ''), + '{{ requester.name }}' => (string) ($ticket->owner?->name ?? ''), + '{{ agent.name }}' => (string) ($ticket->agent?->name ?? ''), + '{{ actor.name }}' => (string) ($actor?->name ?? ''), + '{{ ticket.url }}' => route('helpdesk.tickets.show', ['ticket' => $ticket->getKey()]), + ]); + } +} diff --git a/Modules/Helpdesk/Services/ChecklistService.php b/Modules/Helpdesk/Services/ChecklistService.php new file mode 100644 index 00000000..00d9d897 --- /dev/null +++ b/Modules/Helpdesk/Services/ChecklistService.php @@ -0,0 +1,53 @@ +where('is_active', true) + ->where(static function ($query) use ($ticket): void { + $query->whereNull('company_id')->orWhere('company_id', $ticket->company_id); + }) + ->where(static function ($query) use ($ticket): void { + $query->whereNull('category_id')->orWhere('category_id', $ticket->category_id); + }) + ->where(static function ($query) use ($ticket): void { + $query->whereNull('project_id')->orWhere('project_id', $ticket->project_id); + }) + ->get() + ->each(function (ChecklistTemplate $template) use ($ticket): void { + foreach (array_values($template->items ?? []) as $index => $item) { + ChecklistItem::query()->firstOrCreate([ + 'ticket_id' => $ticket->id, + 'template_id' => $template->id, + 'title' => is_array($item) ? (string) ($item['title'] ?? '') : (string) $item, + ], [ + 'description' => is_array($item) ? ($item['description'] ?? null) : null, + 'sort_order' => ($index + 1) * 10, + ]); + } + }); + } + + public function toggle(ChecklistItem $item, User $user): ChecklistItem + { + $completed = ! $item->is_completed; + $item->forceFill([ + 'is_completed' => $completed, + 'completed_by' => $completed ? $user->id : null, + 'completed_at' => $completed ? now() : null, + ])->save(); + + return $item->fresh() ?? $item; + } +} diff --git a/Modules/Helpdesk/Services/GitWebhookIngestionService.php b/Modules/Helpdesk/Services/GitWebhookIngestionService.php new file mode 100644 index 00000000..f53a983c --- /dev/null +++ b/Modules/Helpdesk/Services/GitWebhookIngestionService.php @@ -0,0 +1,242 @@ + $payload + * @return array{status: string, message: string, commit_count: int, linked_count: int, ignored_count: int} + */ + public function ingest(GitRepository $repository, array $payload, string $provider, ?string $deliveryId, ?string $event): array + { + if (! $this->isPushEvent($provider, $event)) { + $result = [ + 'status' => 'ignored', + 'message' => sprintf('Ignored unsupported %s event.', $event ?: 'unknown'), + 'commit_count' => 0, + 'linked_count' => 0, + 'ignored_count' => 1, + ]; + + $this->logWebhook($repository, $provider, $deliveryId, $event, $result, $payload); + + return $result; + } + + $branch = $this->branchFromRef($payload['ref'] ?? null); + $commits = $this->commitsFromPayload($payload); + $commitCount = 0; + $linkedCount = 0; + $ignoredCount = 0; + + foreach ($commits as $commit) { + $hash = $this->stringOrNull($commit['id'] ?? $commit['sha'] ?? null); + + if ($hash === null) { + $ignoredCount++; + + continue; + } + + $message = $this->stringOrNull($commit['message'] ?? null) ?? ''; + $url = $this->stringOrNull($commit['url'] ?? null); + $author = is_array($commit['author'] ?? null) ? $commit['author'] : []; + $committer = is_array($commit['committer'] ?? null) ? $commit['committer'] : []; + $authorName = $this->stringOrNull($author['name'] ?? $committer['name'] ?? null); + $authorEmail = $this->stringOrNull($author['email'] ?? $committer['email'] ?? null); + $committedAt = $this->parseTimestamp($commit['timestamp'] ?? $commit['committed_at'] ?? null); + $ticket = $this->ticketForCommit($repository, $message, $branch, $url); + + GitCommit::query()->updateOrCreate( + [ + 'repository_id' => $repository->getKey(), + 'hash' => $hash, + ], + [ + 'ticket_id' => $ticket?->getKey(), + 'short_hash' => substr($hash, 0, 12), + 'branch' => $branch, + 'author_name' => $authorName, + 'author_email' => $authorEmail, + 'message' => $message, + 'url' => $url, + 'committed_at' => $committedAt, + ] + ); + + $commitCount++; + + if ($ticket !== null) { + $linkedCount++; + } + } + + $result = [ + 'status' => 'accepted', + 'message' => sprintf('Processed %d commit(s), linked %d to tickets.', $commitCount, $linkedCount), + 'commit_count' => $commitCount, + 'linked_count' => $linkedCount, + 'ignored_count' => $ignoredCount, + ]; + + $this->logWebhook($repository, $provider, $deliveryId, $event, $result, $payload); + + return $result; + } + + /** + * Record a provider webhook acceptance, rejection or ignore decision for auditability. + * + * @param array{status: string, message: string, commit_count?: int, linked_count?: int, ignored_count?: int} $result + * @param array|null $payload + */ + public function logWebhook(GitRepository $repository, string $provider, ?string $deliveryId, ?string $event, array $result, ?array $payload = null): void + { + try { + GitWebhookLog::query()->create([ + 'repository_id' => $repository->getKey(), + 'provider' => $provider, + 'delivery_id' => $deliveryId, + 'event' => $event, + 'status' => $result['status'], + 'message' => $result['message'], + 'commit_count' => (int) ($result['commit_count'] ?? 0), + 'linked_count' => (int) ($result['linked_count'] ?? 0), + 'ignored_count' => (int) ($result['ignored_count'] ?? 0), + 'payload' => $payload, + 'received_at' => now(), + ]); + } catch (Throwable $exception) { + Log::warning('Helpdesk Git webhook audit log could not be written.', [ + 'repository_id' => $repository->getKey(), + 'provider' => $provider, + 'event' => $event, + 'status' => $result['status'], + 'error' => $exception->getMessage(), + ]); + } + } + + /** + * Extract the first Helpdesk ticket reference from arbitrary Git text. + */ + public function extractTicketReference(string $value): ?string + { + if (! preg_match(self::TICKET_REFERENCE_PATTERN, $value, $matches)) { + return null; + } + + return strtoupper($matches[0]); + } + + /** + * Determine whether the provider event should be processed as a push payload. + */ + private function isPushEvent(string $provider, ?string $event): bool + { + $normalised = strtolower((string) $event); + + return match ($provider) { + 'github' => $normalised === 'push', + 'gitlab' => in_array($normalised, ['push hook', 'push'], true), + default => $normalised === '' || $normalised === 'push', + }; + } + + /** + * @param mixed $value + */ + private function branchFromRef(mixed $value): ?string + { + $ref = $this->stringOrNull($value); + + if ($ref === null) { + return null; + } + + return str_replace('refs/heads/', '', $ref); + } + + /** + * @param array $payload + * @return array> + */ + private function commitsFromPayload(array $payload): array + { + $commits = $payload['commits'] ?? []; + + if (! is_array($commits)) { + return []; + } + + return array_values(array_filter($commits, static fn (mixed $commit): bool => is_array($commit))); + } + + private function ticketForCommit(GitRepository $repository, string $message, ?string $branch, ?string $url): ?Ticket + { + $reference = $this->extractTicketReference(trim($message.' '.($branch ?? '').' '.($url ?? ''))); + + if ($reference === null) { + return null; + } + + $query = Ticket::query()->where('reference', $reference); + + if ($repository->company_id !== null) { + $query->where('company_id', $repository->company_id); + } + + if ($repository->project_id !== null) { + $query->where('project_id', $repository->project_id); + } + + return $query->first(); + } + + /** + * @param mixed $value + */ + private function parseTimestamp(mixed $value): ?CarbonImmutable + { + $timestamp = $this->stringOrNull($value); + + if ($timestamp === null) { + return null; + } + + try { + return CarbonImmutable::parse($timestamp); + } catch (Throwable) { + return null; + } + } + + /** + * @param mixed $value + */ + private function stringOrNull(mixed $value): ?string + { + if (! is_scalar($value)) { + return null; + } + + $string = trim((string) $value); + + return $string === '' ? null : $string; + } +} diff --git a/Modules/Helpdesk/Services/SlaService.php b/Modules/Helpdesk/Services/SlaService.php new file mode 100644 index 00000000..b464826a --- /dev/null +++ b/Modules/Helpdesk/Services/SlaService.php @@ -0,0 +1,103 @@ +policyFor($ticket); + + if (! $policy) { + return $ticket; + } + + $createdAt = $ticket->created_at ?? now(); + $ticket->forceFill([ + 'sla_policy_id' => $policy->id, + 'sla_first_response_due_at' => $createdAt->copy()->addMinutes($policy->first_response_minutes), + 'sla_resolution_due_at' => $createdAt->copy()->addMinutes($policy->resolution_minutes), + ])->save(); + + TicketSlaEvent::query()->create([ + 'ticket_id' => $ticket->id, + 'sla_policy_id' => $policy->id, + 'event' => 'sla.policy_applied', + 'due_at' => $ticket->sla_resolution_due_at, + 'occurred_at' => now(), + 'meta' => ['policy' => $policy->name], + ]); + + return $ticket->fresh() ?? $ticket; + } + + public function recordPublicResponse(Ticket $ticket, User $actor): void + { + if ($ticket->first_response_at === null && (int) $ticket->user_id !== (int) $actor->id) { + $breached = $ticket->sla_first_response_due_at !== null && now()->greaterThan($ticket->sla_first_response_due_at); + $ticket->forceFill([ + 'first_response_at' => now(), + 'sla_first_response_breached_at' => $breached ? now() : $ticket->sla_first_response_breached_at, + ])->save(); + + TicketSlaEvent::query()->create([ + 'ticket_id' => $ticket->id, + 'sla_policy_id' => $ticket->sla_policy_id, + 'event' => 'sla.first_response', + 'due_at' => $ticket->sla_first_response_due_at, + 'occurred_at' => now(), + 'breached' => $breached, + ]); + } + + $ticket->increment('public_replies_count'); + } + + public function recordClosure(Ticket $ticket): void + { + $breached = $ticket->sla_resolution_due_at !== null && now()->greaterThan($ticket->sla_resolution_due_at); + $ticket->forceFill(['sla_resolution_breached_at' => $breached ? now() : $ticket->sla_resolution_breached_at])->save(); + TicketSlaEvent::query()->create([ + 'ticket_id' => $ticket->id, + 'sla_policy_id' => $ticket->sla_policy_id, + 'event' => 'sla.resolved', + 'due_at' => $ticket->sla_resolution_due_at, + 'occurred_at' => now(), + 'breached' => $breached, + ]); + } + + public function refreshBreaches(Ticket $ticket): void + { + $updates = []; + if ($ticket->first_response_at === null && $ticket->sla_first_response_due_at !== null && now()->greaterThan($ticket->sla_first_response_due_at)) { + $updates['sla_first_response_breached_at'] = $ticket->sla_first_response_breached_at ?? now(); + } + if ($ticket->completed_at === null && $ticket->sla_resolution_due_at !== null && now()->greaterThan($ticket->sla_resolution_due_at)) { + $updates['sla_resolution_breached_at'] = $ticket->sla_resolution_breached_at ?? now(); + } + if ($updates !== []) { + $ticket->forceFill($updates)->save(); + } + } + + private function policyFor(Ticket $ticket): ?SlaPolicy + { + return SlaPolicy::query() + ->where('is_active', true) + ->where(static function ($query) use ($ticket): void { + $query->whereNull('company_id')->orWhere('company_id', $ticket->company_id); + }) + ->orderByRaw('CASE WHEN priority_id = ? THEN 0 WHEN priority_id IS NULL THEN 1 ELSE 2 END', [$ticket->priority_id]) + ->orderByRaw('CASE WHEN category_id = ? THEN 0 WHEN category_id IS NULL THEN 1 ELSE 2 END', [$ticket->category_id]) + ->orderByDesc('is_default') + ->first(); + } +} diff --git a/Modules/Helpdesk/Services/TicketService.php b/Modules/Helpdesk/Services/TicketService.php index 485fc9ce..0d14f2bc 100644 --- a/Modules/Helpdesk/Services/TicketService.php +++ b/Modules/Helpdesk/Services/TicketService.php @@ -18,6 +18,9 @@ final class TicketService private readonly AgentAssignmentService $assignment, private readonly AttachmentService $attachments, private readonly WorkflowNotificationService $workflow, + private readonly AutomationRuleService $automation, + private readonly SlaService $sla, + private readonly ChecklistService $checklists, ) {} /** @@ -52,6 +55,7 @@ final class TicketService 'priority_id' => $data['priority_id'], 'category_id' => $data['category_id'] ?? null, 'project_id' => $data['project_id'] ?? null, + 'product_id' => $data['product_id'] ?? null, 'team_id' => $data['team_id'] ?? null, 'subject' => $data['subject'], 'body' => $data['body'], @@ -59,9 +63,12 @@ final class TicketService 'last_activity_at' => now(), ]); + $ticket = $this->automation->apply($ticket, $data); + $ticket = $this->sla->applyPolicy($ticket); + $this->checklists->applyTemplates($ticket); $this->attachments->storeMany($ticket, $files, (int) $user->id); - $ticket = $ticket->fresh(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team', 'attachments']) ?? $ticket; + $ticket = $ticket->fresh(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team', 'product', 'attachments', 'checklistItems']) ?? $ticket; $this->workflow->dispatch($ticket, 'ticket.created', $user); return $ticket; @@ -85,6 +92,9 @@ final class TicketService ]); $ticket->forceFill(['last_activity_at' => now()])->save(); + if (! $internal) { + $this->sla->recordPublicResponse($ticket, $user); + } $this->attachments->storeMany($ticket, $files, (int) $user->id, $comment); $this->workflow->dispatch($ticket->fresh(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team']) ?? $ticket, $parentId === null ? 'ticket.commented' : 'ticket.reply_created', $user); @@ -105,7 +115,8 @@ final class TicketService 'last_activity_at' => now(), ])->save(); - $ticket = $ticket->fresh(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team']) ?? $ticket; + $ticket = $ticket->fresh(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team', 'product']) ?? $ticket; + $this->sla->recordClosure($ticket); $this->workflow->dispatch($ticket, 'ticket.closed', $user); return $ticket; diff --git a/Modules/Helpdesk/Services/WorkflowNotificationService.php b/Modules/Helpdesk/Services/WorkflowNotificationService.php index 3b750beb..ee5aa265 100644 --- a/Modules/Helpdesk/Services/WorkflowNotificationService.php +++ b/Modules/Helpdesk/Services/WorkflowNotificationService.php @@ -7,9 +7,12 @@ namespace Modules\Helpdesk\Services; use App\Models\User; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\Route; +use Illuminate\Support\Facades\URL; use Modules\Helpdesk\Models\Ticket; use Modules\Helpdesk\Models\WorkflowLog; use Modules\Helpdesk\Models\WorkflowTemplate; +use Illuminate\Support\Str; use Throwable; final class WorkflowNotificationService @@ -19,7 +22,7 @@ final class WorkflowNotificationService */ public function dispatch(Ticket $ticket, string $event, ?User $actor = null): void { - $ticket->loadMissing(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team']); + $ticket->loadMissing(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team', 'product']); WorkflowTemplate::query() ->where('event', $event) @@ -34,8 +37,9 @@ final class WorkflowNotificationService return; } + $trackingToken = Str::random(48); $subject = $this->renderText($template->subject, $ticket, $actor); - $html = $this->wrapHtml($this->renderText($template->html_body, $ticket, $actor), $ticket); + $html = $this->wrapHtml($this->renderText($template->html_body, $ticket, $actor), $ticket, $trackingToken); $text = $this->renderText($template->text_body ?: strip_tags($template->html_body), $ticket, $actor); try { @@ -51,7 +55,7 @@ final class WorkflowNotificationService } }); - $this->log($template, $ticket, $event, (string) $recipient->email, 'sent', null, $subject); + $this->log($template, $ticket, $event, (string) $recipient->email, 'sent', null, $subject, $trackingToken); } catch (Throwable $exception) { Log::warning('Helpdesk workflow email failed.', ['event' => $event, 'ticket_id' => $ticket->id, 'error' => $exception->getMessage()]); $this->log($template, $ticket, $event, (string) $recipient->email, 'failed', $exception->getMessage(), $subject); @@ -87,7 +91,7 @@ final class WorkflowNotificationService return strtr($template, $tokens); } - private function wrapHtml(string $body, Ticket $ticket): string + private function wrapHtml(string $body, Ticket $ticket, ?string $trackingToken = null): string { return '
' .'
' @@ -98,10 +102,24 @@ final class WorkflowNotificationService .'
' .$body .'

Open ticket

' + .$this->trackingPixel($trackingToken) .'
'; } - private function log(WorkflowTemplate $template, Ticket $ticket, string $event, ?string $recipient, string $status, ?string $error = null, ?string $subject = null): void + private function trackingPixel(?string $trackingToken): string + { + if ($trackingToken === null) { + return ''; + } + + $url = Route::has('helpdesk.workflow.open') + ? route('helpdesk.workflow.open', ['token' => $trackingToken]) + : URL::to('/'.trim((string) config('helpdesk.route_prefix', 'helpdesk'), '/').'/workflow/open/'.$trackingToken); + + return ''; + } + + private function log(WorkflowTemplate $template, Ticket $ticket, string $event, ?string $recipient, string $status, ?string $error = null, ?string $subject = null, ?string $trackingToken = null): void { WorkflowLog::query()->create([ 'template_id' => $template->id, @@ -110,6 +128,7 @@ final class WorkflowNotificationService 'recipient' => $recipient, 'subject' => $subject, 'status' => $status, + 'tracking_token' => $trackingToken, 'error' => $error, ]); } diff --git a/Modules/Helpdesk/Tests/Browser/HelpdeskSettingsBrowserTest.php b/Modules/Helpdesk/Tests/Browser/HelpdeskSettingsBrowserTest.php index 79840107..ec101ed8 100644 --- a/Modules/Helpdesk/Tests/Browser/HelpdeskSettingsBrowserTest.php +++ b/Modules/Helpdesk/Tests/Browser/HelpdeskSettingsBrowserTest.php @@ -31,12 +31,36 @@ final class HelpdeskSettingsBrowserTest extends DuskTestCase ->assertSee('Projects') ->assertSee('Git') ->assertSee('Workflow') + ->assertSee('Canned') + ->assertSee('SLA') + ->assertSee('Rules') + ->assertSee('Checks') + ->assertSee('Tags') + ->assertSee('Products') ->press('Git') ->waitForText('Git repositories') ->assertSee('Remote URL') ->press('Workflow') ->waitForText('Workflow messages') - ->assertSee('HTML email body'); + ->assertSee('HTML email body') + ->press('Canned') + ->waitForText('Canned responses') + ->assertSee('Reply body') + ->press('SLA') + ->waitForText('SLA policies') + ->assertSee('First response minutes') + ->press('Rules') + ->waitForText('Email automation rules') + ->assertSee('Subject contains') + ->press('Checks') + ->waitForText('Checklist templates') + ->assertSee('Checklist items') + ->press('Tags') + ->waitForText('Ticket tags') + ->assertSee('Colour') + ->press('Products') + ->waitForText('Products and services') + ->assertSee('Release/build'); }); } } diff --git a/Modules/Helpdesk/Tests/Feature/GitWebhookIngestionTest.php b/Modules/Helpdesk/Tests/Feature/GitWebhookIngestionTest.php new file mode 100644 index 00000000..070642a7 --- /dev/null +++ b/Modules/Helpdesk/Tests/Feature/GitWebhookIngestionTest.php @@ -0,0 +1,163 @@ +seedLookupData(); + $repository = $this->repository([ + 'provider' => 'github', + 'project_id' => $fixtures['project']->id, + 'webhook_secret' => 'github-secret', + ]); + $ticket = $this->ticket(['project_id' => $fixtures['project']->id]); + $payload = [ + 'ref' => 'refs/heads/feature/'.$ticket->reference.'-automation', + 'commits' => [ + [ + 'id' => str_repeat('a', 40), + 'message' => $ticket->reference.' Add webhook ingestion', + 'url' => 'https://github.example.test/org/repo/commit/'.str_repeat('a', 40), + 'timestamp' => '2026-06-15T10:00:00+00:00', + 'author' => ['name' => 'Mark Gregory', 'email' => 'mark@example.com'], + ], + ], + ]; + $json = json_encode($payload, JSON_THROW_ON_ERROR); + + $response = $this->call('POST', route('api.helpdesk.git.webhook', ['repository' => $repository->id]), [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_GITHUB_EVENT' => 'push', + 'HTTP_X_GITHUB_DELIVERY' => 'delivery-123', + 'HTTP_X_HUB_SIGNATURE_256' => 'sha256='.hash_hmac('sha256', $json, 'github-secret'), + ], $json); + + $response->assertStatus(202)->assertJsonPath('status', 'accepted')->assertJsonPath('linked_count', 1); + + $this->assertDatabaseHas('helpdesk_git_commits', [ + 'repository_id' => $repository->id, + 'ticket_id' => $ticket->id, + 'hash' => str_repeat('a', 40), + 'short_hash' => str_repeat('a', 12), + 'branch' => 'feature/'.$ticket->reference.'-automation', + ]); + + $this->assertDatabaseHas('helpdesk_git_webhook_logs', [ + 'repository_id' => $repository->id, + 'provider' => 'github', + 'delivery_id' => 'delivery-123', + 'event' => 'push', + 'status' => 'accepted', + 'commit_count' => 1, + 'linked_count' => 1, + ]); + } + + public function test_github_webhook_rejects_invalid_signature_without_creating_commits(): void + { + $repository = $this->repository(['provider' => 'github', 'webhook_secret' => 'github-secret']); + $payload = ['ref' => 'refs/heads/develop', 'commits' => [['id' => str_repeat('b', 40), 'message' => 'No ticket']]]; + $json = json_encode($payload, JSON_THROW_ON_ERROR); + + $response = $this->call('POST', route('api.helpdesk.git.webhook', ['repository' => $repository->id]), [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_GITHUB_EVENT' => 'push', + 'HTTP_X_HUB_SIGNATURE_256' => 'sha256=invalid', + ], $json); + + $response->assertForbidden()->assertJsonPath('status', 'rejected'); + + self::assertSame(0, GitCommit::query()->count()); + self::assertSame('rejected', GitWebhookLog::query()->firstOrFail()->status); + } + + public function test_github_non_push_webhook_is_audited_and_ignored(): void + { + $repository = $this->repository(['provider' => 'github', 'webhook_secret' => 'github-secret']); + $payload = ['zen' => 'Keep it logically awesome.']; + $json = json_encode($payload, JSON_THROW_ON_ERROR); + + $response = $this->call('POST', route('api.helpdesk.git.webhook', ['repository' => $repository->id]), [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_GITHUB_EVENT' => 'ping', + 'HTTP_X_HUB_SIGNATURE_256' => 'sha256='.hash_hmac('sha256', $json, 'github-secret'), + ], $json); + + $response->assertStatus(202)->assertJsonPath('status', 'ignored'); + + self::assertSame(0, GitCommit::query()->count()); + $this->assertDatabaseHas('helpdesk_git_webhook_logs', [ + 'repository_id' => $repository->id, + 'provider' => 'github', + 'event' => 'ping', + 'status' => 'ignored', + 'ignored_count' => 1, + ]); + } + + public function test_gitlab_push_webhook_tracks_and_links_commit_to_ticket_reference(): void + { + $fixtures = $this->seedLookupData(); + $repository = $this->repository([ + 'provider' => 'gitlab', + 'project_id' => $fixtures['project']->id, + 'webhook_secret' => 'gitlab-secret', + ]); + $ticket = $this->ticket(['project_id' => $fixtures['project']->id]); + $payload = [ + 'ref' => 'refs/heads/develop', + 'commits' => [ + [ + 'id' => str_repeat('c', 40), + 'message' => 'Fix '.$ticket->reference.' SLA report', + 'url' => 'https://gitlab.example.test/org/repo/-/commit/'.str_repeat('c', 40), + 'timestamp' => '2026-06-15T11:00:00+00:00', + 'author' => ['name' => 'Support Agent', 'email' => 'agent@example.com'], + ], + ], + ]; + $json = json_encode($payload, JSON_THROW_ON_ERROR); + + $response = $this->call('POST', route('api.helpdesk.git.webhook', ['repository' => $repository->id]), [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_GITLAB_EVENT' => 'Push Hook', + 'HTTP_X_GITLAB_TOKEN' => 'gitlab-secret', + 'HTTP_X_GITLAB_EVENT_UUID' => 'gitlab-delivery-123', + ], $json); + + $response->assertStatus(202)->assertJsonPath('status', 'accepted')->assertJsonPath('linked_count', 1); + + $this->assertDatabaseHas('helpdesk_git_commits', [ + 'repository_id' => $repository->id, + 'ticket_id' => $ticket->id, + 'hash' => str_repeat('c', 40), + 'branch' => 'develop', + ]); + + $this->assertDatabaseHas('helpdesk_git_webhook_logs', [ + 'repository_id' => $repository->id, + 'provider' => 'gitlab', + 'delivery_id' => 'gitlab-delivery-123', + 'event' => 'Push Hook', + 'status' => 'accepted', + 'linked_count' => 1, + ]); + } +} diff --git a/Modules/Helpdesk/Tests/Feature/RedmineInspiredHelpdeskFeatureTest.php b/Modules/Helpdesk/Tests/Feature/RedmineInspiredHelpdeskFeatureTest.php new file mode 100644 index 00000000..6052afbb --- /dev/null +++ b/Modules/Helpdesk/Tests/Feature/RedmineInspiredHelpdeskFeatureTest.php @@ -0,0 +1,138 @@ +seedLookupData(); + $user = $this->user(['name' => 'Mark Requester']); + $product = Product::query()->create(['name' => 'WMS Platform', 'slug' => 'wms-platform', 'type' => 'service', 'is_active' => true]); + + SlaPolicy::query()->create([ + 'name' => 'High Priority SLA', + 'slug' => 'high-priority-sla', + 'priority_id' => $fixtures['high']->id, + 'first_response_minutes' => 30, + 'resolution_minutes' => 240, + 'is_default' => true, + 'is_active' => true, + ]); + + AutomationRule::query()->create([ + 'name' => 'Printer escalation', + 'slug' => 'printer-escalation', + 'match_subject' => 'printer', + 'set_priority_id' => $fixtures['high']->id, + 'set_team_id' => $fixtures['team']->id, + 'is_active' => true, + 'sort_order' => 1, + ]); + + ChecklistTemplate::query()->create([ + 'name' => 'Bug checklist', + 'slug' => 'bug-checklist', + 'category_id' => $fixtures['category']->id, + 'items' => ['Confirm reproduction', 'Record workaround'], + 'is_active' => true, + ]); + + $ticket = app(TicketService::class)->create($user, [ + 'subject' => 'Printer labels are failing', + 'body' => '

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" }