major helpdesk updates
This commit is contained in:
+208
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('helpdesk_products', 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('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']);
|
||||
});
|
||||
|
||||
}
|
||||
};
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Add auditable Git webhook ingestion support.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (Schema::hasTable('helpdesk_git_webhook_logs')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::create('helpdesk_git_webhook_logs', static function (Blueprint $table): void {
|
||||
$table->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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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' => '<p>Hello {{ requester.name }},</p><p>Thank you for the update on {{ ticket.reference }}. I am reviewing this now and will come back with the next action shortly.</p><p>Regards,<br>{{ agent.name }}</p>',
|
||||
'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,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use JsonException;
|
||||
use Modules\Helpdesk\Models\GitRepository;
|
||||
use Modules\Helpdesk\Services\GitWebhookIngestionService;
|
||||
|
||||
final class GitWebhookController
|
||||
{
|
||||
/**
|
||||
* Ingest a signed Git provider webhook for a registered Helpdesk repository.
|
||||
*/
|
||||
public function __invoke(Request $request, GitRepository $repository, GitWebhookIngestionService $service): JsonResponse
|
||||
{
|
||||
$provider = strtolower((string) ($repository->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<string, mixed> $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'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Response;
|
||||
use Modules\Helpdesk\Models\WorkflowLog;
|
||||
|
||||
final class WorkflowOpenController
|
||||
{
|
||||
public function __invoke(string $token): Response
|
||||
{
|
||||
$log = WorkflowLog::query()->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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string, mixed> */
|
||||
public array $repositoryForm = ['id' => null, 'company_id' => null, 'project_id' => null, 'name' => '', 'provider' => 'git', 'remote_url' => '', 'default_branch' => 'main', 'webhook_secret' => ''];
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
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<string, mixed> */
|
||||
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<string, mixed> */
|
||||
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<string, mixed> */
|
||||
public array $productForm = ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'type' => 'service', 'version' => '', 'release_channel' => '', 'description' => '', 'is_active' => true];
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
public array $tagForm = ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'zinc'];
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
public array $checklistTemplateForm = ['id' => null, 'company_id' => null, 'category_id' => null, 'project_id' => null, 'name' => '', 'slug' => '', 'items_text' => '', 'is_active' => true];
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
final class AutomationRule extends Model
|
||||
{
|
||||
protected $table = 'helpdesk_automation_rules';
|
||||
protected $fillable = ['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'];
|
||||
protected $casts = ['company_id'=>'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'];
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
final class CannedResponse extends Model
|
||||
{
|
||||
protected $table = 'helpdesk_canned_responses';
|
||||
protected $fillable = ['company_id','category_id','team_id','name','slug','visibility','subject','body','is_active','sort_order'];
|
||||
protected $casts = ['company_id'=>'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'); }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
final class ChecklistItem extends Model
|
||||
{
|
||||
protected $table = 'helpdesk_checklist_items';
|
||||
protected $fillable = ['ticket_id','template_id','title','description','is_completed','completed_by','completed_at','sort_order'];
|
||||
protected $casts = ['ticket_id'=>'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'); }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
final class ChecklistTemplate extends Model
|
||||
{
|
||||
protected $table = 'helpdesk_checklist_templates';
|
||||
protected $fillable = ['company_id','category_id','project_id','name','slug','items','is_active'];
|
||||
protected $casts = ['company_id'=>'integer','category_id'=>'integer','project_id'=>'integer','items'=>'array','is_active'=>'boolean'];
|
||||
}
|
||||
@@ -32,4 +32,10 @@ final class GitRepository extends Model
|
||||
{
|
||||
return $this->hasMany(GitCommit::class, 'repository_id')->latest('committed_at');
|
||||
}
|
||||
|
||||
/** @return HasMany<GitWebhookLog> */
|
||||
public function webhookLogs(): HasMany
|
||||
{
|
||||
return $this->hasMany(GitWebhookLog::class, 'repository_id')->latest('received_at');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
final class GitWebhookLog extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'helpdesk_git_webhook_logs';
|
||||
|
||||
/** @var list<string> */
|
||||
protected $fillable = [
|
||||
'repository_id',
|
||||
'provider',
|
||||
'delivery_id',
|
||||
'event',
|
||||
'status',
|
||||
'message',
|
||||
'commit_count',
|
||||
'linked_count',
|
||||
'ignored_count',
|
||||
'payload',
|
||||
'received_at',
|
||||
];
|
||||
|
||||
/** @var array<string, string> */
|
||||
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<GitRepository, GitWebhookLog>
|
||||
*/
|
||||
public function repository(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(GitRepository::class, 'repository_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
final class Product extends Model
|
||||
{
|
||||
protected $table = 'helpdesk_products';
|
||||
protected $fillable = ['company_id','name','slug','type','version','release_channel','description','is_active'];
|
||||
protected $casts = ['company_id'=>'integer','is_active'=>'boolean'];
|
||||
public function tickets(): HasMany { return $this->hasMany(Ticket::class, 'product_id'); }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
final class SlaPolicy extends Model
|
||||
{
|
||||
protected $table = 'helpdesk_sla_policies';
|
||||
protected $fillable = ['company_id','priority_id','category_id','name','slug','first_response_minutes','resolution_minutes','business_hours_only','is_default','is_active'];
|
||||
protected $casts = ['company_id'=>'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'); }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
final class Tag extends Model
|
||||
{
|
||||
protected $table = 'helpdesk_tags';
|
||||
protected $fillable = ['company_id','name','slug','color'];
|
||||
protected $casts = ['company_id'=>'integer'];
|
||||
public function tickets(): BelongsToMany { return $this->belongsToMany(Ticket::class, 'helpdesk_ticket_tag', 'tag_id', 'ticket_id'); }
|
||||
}
|
||||
@@ -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<string, string> */
|
||||
@@ -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<User, Ticket> */
|
||||
@@ -89,6 +101,57 @@ final class Ticket extends Model
|
||||
->oldest();
|
||||
}
|
||||
|
||||
|
||||
/** @return BelongsTo<SlaPolicy, Ticket> */
|
||||
public function slaPolicy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SlaPolicy::class, 'sla_policy_id');
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Product, Ticket> */
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class, 'product_id');
|
||||
}
|
||||
|
||||
/** @return HasMany<TicketSlaEvent> */
|
||||
public function slaEvents(): HasMany
|
||||
{
|
||||
return $this->hasMany(TicketSlaEvent::class, 'ticket_id')->latest();
|
||||
}
|
||||
|
||||
/** @return HasMany<TicketRating> */
|
||||
public function ratings(): HasMany
|
||||
{
|
||||
return $this->hasMany(TicketRating::class, 'ticket_id')->latest();
|
||||
}
|
||||
|
||||
/** @return HasMany<ChecklistItem> */
|
||||
public function checklistItems(): HasMany
|
||||
{
|
||||
return $this->hasMany(ChecklistItem::class, 'ticket_id')->orderBy('sort_order')->orderBy('id');
|
||||
}
|
||||
|
||||
/** @return BelongsToMany<Tag> */
|
||||
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<GitCommit> */
|
||||
public function gitCommits(): HasMany
|
||||
{
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
final class TicketRating extends Model
|
||||
{
|
||||
protected $table = 'helpdesk_ticket_ratings';
|
||||
protected $fillable = ['ticket_id','agent_id','team_id','category_id','rating','token','comment','rated_at'];
|
||||
protected $casts = ['ticket_id'=>'integer','agent_id'=>'integer','team_id'=>'integer','category_id'=>'integer','rating'=>'integer','rated_at'=>'datetime'];
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
final class TicketSlaEvent extends Model
|
||||
{
|
||||
protected $table = 'helpdesk_ticket_sla_events';
|
||||
protected $fillable = ['ticket_id','sla_policy_id','event','due_at','occurred_at','breached','meta'];
|
||||
protected $casts = ['ticket_id'=>'integer','sla_policy_id'=>'integer','due_at'=>'datetime','occurred_at'=>'datetime','breached'=>'boolean','meta'=>'array'];
|
||||
}
|
||||
@@ -15,7 +15,14 @@ final class WorkflowLog extends Model
|
||||
protected $table = 'helpdesk_workflow_logs';
|
||||
|
||||
/** @var list<string> */
|
||||
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<string, string> */
|
||||
protected $casts = [
|
||||
'first_opened_at' => 'datetime',
|
||||
'last_opened_at' => 'datetime',
|
||||
'opened_count' => 'integer',
|
||||
];
|
||||
|
||||
/** @return BelongsTo<WorkflowTemplate, WorkflowLog> */
|
||||
public function template(): BelongsTo
|
||||
|
||||
@@ -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.')
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<th class="px-4 py-3">Provider</th>
|
||||
<th class="px-4 py-3">Branch</th>
|
||||
<th class="px-4 py-3">Commits</th>
|
||||
<th class="px-4 py-3">Webhook</th>
|
||||
<th class="px-4 py-3 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -20,13 +21,17 @@
|
||||
<td class="px-4 py-3 text-zinc-600 dark:text-zinc-300">{{ $item->provider }}</td>
|
||||
<td class="px-4 py-3 text-zinc-600 dark:text-zinc-300">{{ $item->default_branch }}</td>
|
||||
<td class="px-4 py-3"><flux:badge color="zinc">{{ $item->commits_count }}</flux:badge></td>
|
||||
<td class="px-4 py-3 text-xs text-zinc-600 dark:text-zinc-300">
|
||||
<div class="font-medium text-zinc-800 dark:text-zinc-100">{{ $item->webhook_logs_count ?? 0 }} deliveries</div>
|
||||
<code class="mt-1 block max-w-xs truncate rounded bg-zinc-100 px-2 py-1 text-[11px] text-zinc-700 dark:bg-zinc-800 dark:text-zinc-200">{{ route('api.helpdesk.git.webhook', ['repository' => $item->id]) }}</code>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<flux:button size="sm" variant="primary" wire:click="editRepository({{ $item->id }})">Edit</flux:button>
|
||||
<flux:button size="sm" variant="danger" wire:click="deleteRepository({{ $item->id }})" wire:confirm="Delete this repository?">Delete</flux:button>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="6" class="px-4 py-8 text-center text-zinc-500">No repositories yet.</td></tr>
|
||||
<tr><td colspan="7" class="px-4 py-8 text-center text-zinc-500">No repositories yet.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -91,8 +91,28 @@
|
||||
@if ($ticket->team)
|
||||
<div class="flex items-center gap-2"><i class="fa-solid fa-users w-3" aria-hidden="true"></i> {{ $ticket->team->name }}</div>
|
||||
@endif
|
||||
@if ($ticket->product)
|
||||
<div class="flex items-center gap-2"><i class="fa-solid fa-box w-3" aria-hidden="true"></i> {{ $ticket->product->name }}</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($ticket->tags->isNotEmpty())
|
||||
<div class="mt-3 flex flex-wrap gap-1">
|
||||
@foreach ($ticket->tags as $tag)
|
||||
<span class="rounded-full px-2 py-0.5 text-[11px] font-semibold text-white" style="background-color: {{ \Modules\Helpdesk\Support\Color::css($tag->color) }}">{{ $tag->name }}</span>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($ticket->checklistItems->isNotEmpty())
|
||||
@php($done = $ticket->checklistItems->where('is_completed', true)->count())
|
||||
<div class="mt-3 text-xs text-zinc-500">Checklist {{ $done }}/{{ $ticket->checklistItems->count() }}</div>
|
||||
@endif
|
||||
|
||||
@if ($ticket->sla_first_response_breached || $ticket->sla_resolution_breached)
|
||||
<div class="mt-3 rounded-lg border border-red-300 bg-red-50 px-3 py-2 text-xs font-semibold text-red-800 dark:border-red-900 dark:bg-red-950 dark:text-red-100">SLA breach</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-4 flex items-center justify-between gap-2 border-t border-zinc-100 pt-3 dark:border-zinc-800">
|
||||
<a href="{{ route('helpdesk.tickets.show', $ticket) }}" wire:navigate class="rounded-lg bg-blue-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-700">Open</a>
|
||||
@can('update', $ticket)
|
||||
|
||||
@@ -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
|
||||
|
||||
<div class="space-y-6" dusk="helpdesk-settings-manager">
|
||||
<div class="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div class="bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-6 py-7 text-white">
|
||||
@@ -5,7 +27,7 @@
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-blue-200">Helpdesk administration</p>
|
||||
<h1 class="mt-2 text-3xl font-semibold tracking-tight">Support configuration</h1>
|
||||
<p class="mt-2 max-w-3xl text-sm text-blue-100/90">Manage the lookup data used by tickets, including statuses, priorities, categories, company teams, company projects, Git repositories and workflow messages.</p>
|
||||
<p class="mt-2 max-w-3xl text-sm text-blue-100/90">Manage lookup data, workflow messages, canned responses, SLA policies, automation rules, checklists, tags and product/service linkage.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@@ -20,7 +42,7 @@
|
||||
<flux:callout variant="success" icon="check-circle">{{ session('success') }}</flux:callout>
|
||||
@endif
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-4 xl:grid-cols-8">
|
||||
<div class="grid gap-3 md:grid-cols-4 xl:grid-cols-7">
|
||||
@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)
|
||||
<button type="button" wire:click="$set('section', '{{ $key }}')" class="rounded-2xl border px-4 py-3 text-left shadow-sm transition {{ $section === $key ? 'border-blue-500 bg-blue-50 text-blue-900 dark:border-blue-400 dark:bg-blue-950 dark:text-blue-100' : 'border-zinc-200 bg-white text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800' }}">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide opacity-70">{{ $item['label'] }}</div>
|
||||
@@ -292,4 +320,98 @@
|
||||
</x-helpdesk::settings-section>
|
||||
@endif
|
||||
|
||||
|
||||
@if ($section === 'canned')
|
||||
<x-helpdesk::settings-section title="Canned responses" description="Save professional reusable replies with macro tokens for ticket, requester, agent, status and URL values.">
|
||||
<x-slot:form>
|
||||
<form wire:submit="saveCannedResponse" class="grid gap-4 md:grid-cols-6">
|
||||
<flux:input wire:model="cannedForm.name" label="Name" />
|
||||
<flux:input wire:model="cannedForm.slug" label="Slug" placeholder="auto-generated" />
|
||||
<flux:input wire:model="cannedForm.sort_order" label="Sort" type="number" min="0" />
|
||||
<flux:select wire:model="cannedForm.category_id" label="Category"><option value="">Any category</option>@foreach ($categories as $category)<option value="{{ $category->id }}">{{ $category->name }}</option>@endforeach</flux:select>
|
||||
<flux:select wire:model="cannedForm.team_id" label="Team"><option value="">Any team</option>@foreach ($teams as $team)<option value="{{ $team->id }}">{{ $team->name }}</option>@endforeach</flux:select>
|
||||
<label class="flex items-end gap-2 pb-2 text-sm text-zinc-700 dark:text-zinc-200"><input type="checkbox" wire:model="cannedForm.is_active" class="rounded border-zinc-300"> Active</label>
|
||||
<div class="md:col-span-6"><flux:input wire:model="cannedForm.subject" label="Optional subject" /></div>
|
||||
<div class="md:col-span-6"><flux:textarea wire:model="cannedForm.body" label="Reply body" rows="6" placeholder="Hello @{{ requester.name }}, Your ticket @{{ ticket.reference }} has been reviewed." /></div>
|
||||
<div class="md:col-span-6 flex gap-2"><flux:button type="submit" variant="primary">Save canned response</flux:button><flux:button type="button" wire:click="resetCannedResponseForm" variant="ghost">Reset</flux:button></div>
|
||||
</form>
|
||||
</x-slot:form>
|
||||
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
@forelse ($cannedResponses as $response)
|
||||
<div class="rounded-2xl border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div class="flex items-start justify-between gap-3"><div><h3 class="font-semibold text-zinc-900 dark:text-zinc-100">{{ $response->name }}</h3><p class="text-xs text-zinc-500">{{ $response->category?->name ?? 'Any category' }} · {{ $response->team?->name ?? 'Any team' }}</p></div><flux:badge color="{{ $response->is_active ? 'emerald' : 'zinc' }}">{{ $response->is_active ? 'Active' : 'Inactive' }}</flux:badge></div>
|
||||
<p class="mt-3 line-clamp-3 text-sm text-zinc-600 dark:text-zinc-300">{{ strip_tags($response->body) }}</p>
|
||||
<div class="mt-4 flex justify-end gap-2"><flux:button size="sm" variant="primary" wire:click="editCannedResponse({{ $response->id }})">Edit</flux:button><flux:button size="sm" variant="danger" wire:click="deleteCannedResponse({{ $response->id }})" wire:confirm="Delete this canned response?">Delete</flux:button></div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="rounded-2xl border border-dashed border-zinc-300 p-8 text-center text-sm text-zinc-500 dark:border-zinc-700 md:col-span-2 xl:col-span-3">No canned responses yet.</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</x-helpdesk::settings-section>
|
||||
@endif
|
||||
|
||||
@if ($section === 'sla')
|
||||
<x-helpdesk::settings-section title="SLA policies" description="Define first response and resolution targets used for breach indicators, reporting and ticket cards.">
|
||||
<x-slot:form>
|
||||
<form wire:submit="saveSlaPolicy" class="grid gap-4 md:grid-cols-6">
|
||||
<flux:input wire:model="slaForm.name" label="Name" />
|
||||
<flux:input wire:model="slaForm.slug" label="Slug" placeholder="auto-generated" />
|
||||
<flux:select wire:model="slaForm.priority_id" label="Priority"><option value="">Any priority</option>@foreach ($priorities as $priority)<option value="{{ $priority->id }}">{{ $priority->name }}</option>@endforeach</flux:select>
|
||||
<flux:select wire:model="slaForm.category_id" label="Category"><option value="">Any category</option>@foreach ($categories as $category)<option value="{{ $category->id }}">{{ $category->name }}</option>@endforeach</flux:select>
|
||||
<flux:input wire:model="slaForm.first_response_minutes" label="First response minutes" type="number" min="1" />
|
||||
<flux:input wire:model="slaForm.resolution_minutes" label="Resolution minutes" type="number" min="1" />
|
||||
<label class="flex items-center gap-2 text-sm text-zinc-700 dark:text-zinc-200"><input type="checkbox" wire:model="slaForm.is_default" class="rounded border-zinc-300"> Default</label>
|
||||
<label class="flex items-center gap-2 text-sm text-zinc-700 dark:text-zinc-200"><input type="checkbox" wire:model="slaForm.business_hours_only" class="rounded border-zinc-300"> Business hours</label>
|
||||
<label class="flex items-center gap-2 text-sm text-zinc-700 dark:text-zinc-200"><input type="checkbox" wire:model="slaForm.is_active" class="rounded border-zinc-300"> Active</label>
|
||||
<div class="md:col-span-6 flex gap-2"><flux:button type="submit" variant="primary">Save SLA policy</flux:button><flux:button type="button" wire:click="resetSlaPolicyForm" variant="ghost">Reset</flux:button></div>
|
||||
</form>
|
||||
</x-slot:form>
|
||||
<div class="overflow-hidden rounded-2xl border border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900"><table class="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-700"><thead class="bg-zinc-50 text-left text-xs font-semibold uppercase text-zinc-500 dark:bg-zinc-800"><tr><th class="px-4 py-3">Policy</th><th class="px-4 py-3">Targets</th><th class="px-4 py-3">Applies to</th><th class="px-4 py-3 text-right">Actions</th></tr></thead><tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">@forelse ($slaPolicies as $policy)<tr><td class="px-4 py-3 font-medium">{{ $policy->name }} @if($policy->is_default)<flux:badge color="blue">Default</flux:badge>@endif</td><td class="px-4 py-3">{{ $policy->first_response_minutes }}m response · {{ $policy->resolution_minutes }}m close</td><td class="px-4 py-3">{{ $policy->priority?->name ?? 'Any priority' }} · {{ $policy->category?->name ?? 'Any category' }}</td><td class="px-4 py-3 text-right"><flux:button size="sm" variant="primary" wire:click="editSlaPolicy({{ $policy->id }})">Edit</flux:button> <flux:button size="sm" variant="danger" wire:click="deleteSlaPolicy({{ $policy->id }})" wire:confirm="Delete this SLA policy?">Delete</flux:button></td></tr>@empty<tr><td colspan="4" class="px-4 py-8 text-center text-zinc-500">No SLA policies yet.</td></tr>@endforelse</tbody></table></div>
|
||||
</x-helpdesk::settings-section>
|
||||
@endif
|
||||
|
||||
@if ($section === 'automation')
|
||||
<x-helpdesk::settings-section title="Email automation rules" description="Match incoming support messages and automatically assign priority, category, team, project, agent and status.">
|
||||
<x-slot:form>
|
||||
<form wire:submit="saveAutomationRule" class="grid gap-4 md:grid-cols-6">
|
||||
<flux:input wire:model="automationForm.name" label="Name" />
|
||||
<flux:input wire:model="automationForm.slug" label="Slug" placeholder="auto-generated" />
|
||||
<flux:input wire:model="automationForm.sort_order" label="Sort" type="number" min="0" />
|
||||
<flux:input wire:model="automationForm.match_sender" label="Sender contains" />
|
||||
<flux:input wire:model="automationForm.match_subject" label="Subject contains" />
|
||||
<flux:input wire:model="automationForm.match_mailbox" label="Mailbox contains" />
|
||||
<flux:select wire:model="automationForm.set_priority_id" label="Set priority"><option value="">No change</option>@foreach ($priorities as $priority)<option value="{{ $priority->id }}">{{ $priority->name }}</option>@endforeach</flux:select>
|
||||
<flux:select wire:model="automationForm.set_category_id" label="Set category"><option value="">No change</option>@foreach ($categories as $category)<option value="{{ $category->id }}">{{ $category->name }}</option>@endforeach</flux:select>
|
||||
<flux:select wire:model="automationForm.set_team_id" label="Set team"><option value="">No change</option>@foreach ($teams as $team)<option value="{{ $team->id }}">{{ $team->name }}</option>@endforeach</flux:select>
|
||||
<flux:select wire:model="automationForm.set_project_id" label="Set project"><option value="">No change</option>@foreach ($projects as $project)<option value="{{ $project->id }}">{{ $project->title }}</option>@endforeach</flux:select>
|
||||
<flux:select wire:model="automationForm.set_status_id" label="Set status"><option value="">No change</option>@foreach ($statuses as $status)<option value="{{ $status->id }}">{{ $status->name }}</option>@endforeach</flux:select>
|
||||
<label class="flex items-end gap-2 pb-2 text-sm text-zinc-700 dark:text-zinc-200"><input type="checkbox" wire:model="automationForm.is_active" class="rounded border-zinc-300"> Active</label>
|
||||
<div class="md:col-span-6 flex gap-2"><flux:button type="submit" variant="primary">Save automation rule</flux:button><flux:button type="button" wire:click="resetAutomationRuleForm" variant="ghost">Reset</flux:button></div>
|
||||
</form>
|
||||
</x-slot:form>
|
||||
<div class="grid gap-3 md:grid-cols-2">@forelse ($automationRules as $rule)<div class="rounded-2xl border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900"><h3 class="font-semibold">{{ $rule->name }}</h3><p class="mt-1 text-sm text-zinc-500">Sender: {{ $rule->match_sender ?: 'Any' }} · Subject: {{ $rule->match_subject ?: 'Any' }}</p><div class="mt-4 flex justify-end gap-2"><flux:button size="sm" variant="primary" wire:click="editAutomationRule({{ $rule->id }})">Edit</flux:button><flux:button size="sm" variant="danger" wire:click="deleteAutomationRule({{ $rule->id }})" wire:confirm="Delete this rule?">Delete</flux:button></div></div>@empty<div class="rounded-2xl border border-dashed border-zinc-300 p-8 text-center text-sm text-zinc-500 dark:border-zinc-700 md:col-span-2">No automation rules yet.</div>@endforelse</div>
|
||||
</x-helpdesk::settings-section>
|
||||
@endif
|
||||
|
||||
@if ($section === 'checklists')
|
||||
<x-helpdesk::settings-section title="Checklist templates" description="Apply repeatable checklist items by category or project and show completion progress on tickets and Kanban cards.">
|
||||
<x-slot:form><form wire:submit="saveChecklistTemplate" class="grid gap-4 md:grid-cols-6"><flux:input wire:model="checklistTemplateForm.name" label="Name" /><flux:input wire:model="checklistTemplateForm.slug" label="Slug" /><flux:select wire:model="checklistTemplateForm.category_id" label="Category"><option value="">Any category</option>@foreach ($categories as $category)<option value="{{ $category->id }}">{{ $category->name }}</option>@endforeach</flux:select><flux:select wire:model="checklistTemplateForm.project_id" label="Project"><option value="">Any project</option>@foreach ($projects as $project)<option value="{{ $project->id }}">{{ $project->title }}</option>@endforeach</flux:select><label class="flex items-end gap-2 pb-2 text-sm"><input type="checkbox" wire:model="checklistTemplateForm.is_active" class="rounded border-zinc-300"> Active</label><div class="md:col-span-6"><flux:textarea wire:model="checklistTemplateForm.items_text" label="Checklist items, one per line" rows="6" /></div><div class="md:col-span-6 flex gap-2"><flux:button type="submit" variant="primary">Save checklist</flux:button><flux:button type="button" wire:click="resetChecklistTemplateForm" variant="ghost">Reset</flux:button></div></form></x-slot:form>
|
||||
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-3">@forelse ($checklistTemplates as $template)<div class="rounded-2xl border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900"><h3 class="font-semibold">{{ $template->name }}</h3><p class="mt-1 text-sm text-zinc-500">{{ count($template->items ?? []) }} items</p><div class="mt-4 flex justify-end gap-2"><flux:button size="sm" variant="primary" wire:click="editChecklistTemplate({{ $template->id }})">Edit</flux:button><flux:button size="sm" variant="danger" wire:click="deleteChecklistTemplate({{ $template->id }})" wire:confirm="Delete this checklist?">Delete</flux:button></div></div>@empty<div class="rounded-2xl border border-dashed border-zinc-300 p-8 text-center text-sm text-zinc-500 dark:border-zinc-700 md:col-span-2 xl:col-span-3">No checklist templates yet.</div>@endforelse</div>
|
||||
</x-helpdesk::settings-section>
|
||||
@endif
|
||||
|
||||
@if ($section === 'tags')
|
||||
<x-helpdesk::settings-section title="Ticket tags" description="Maintain visible ticket labels for search, triage and Kanban card context.">
|
||||
<x-slot:form><form wire:submit="saveTag" class="grid gap-4 md:grid-cols-5"><flux:input wire:model="tagForm.name" label="Name" /><flux:input wire:model="tagForm.slug" label="Slug" /><flux:input wire:model="tagForm.color" label="Colour" /><div class="md:col-span-5 flex gap-2"><flux:button type="submit" variant="primary">Save tag</flux:button><flux:button type="button" wire:click="resetTagForm" variant="ghost">Reset</flux:button></div></form></x-slot:form>
|
||||
<div class="flex flex-wrap gap-3">@forelse ($tags as $tag)<div class="rounded-2xl border border-zinc-200 bg-white px-4 py-3 dark:border-zinc-700 dark:bg-zinc-900"><span class="rounded-full px-2 py-1 text-xs font-semibold text-white" style="background-color: {{ \Modules\Helpdesk\Support\Color::css($tag->color) }}">{{ $tag->name }}</span><div class="mt-3 flex gap-2"><flux:button size="sm" variant="primary" wire:click="editTag({{ $tag->id }})">Edit</flux:button><flux:button size="sm" variant="danger" wire:click="deleteTag({{ $tag->id }})" wire:confirm="Delete this tag?">Delete</flux:button></div></div>@empty<div class="rounded-2xl border border-dashed border-zinc-300 p-8 text-center text-sm text-zinc-500 dark:border-zinc-700">No tags yet.</div>@endforelse</div>
|
||||
</x-helpdesk::settings-section>
|
||||
@endif
|
||||
|
||||
@if ($section === 'products')
|
||||
<x-helpdesk::settings-section title="Products and services" description="Link tickets to products, services, releases or builds and report support cost/time by product.">
|
||||
<x-slot:form><form wire:submit="saveProduct" class="grid gap-4 md:grid-cols-6"><flux:input wire:model="productForm.name" label="Name" /><flux:input wire:model="productForm.slug" label="Slug" /><flux:input wire:model="productForm.type" label="Type" /><flux:input wire:model="productForm.version" label="Version" /><flux:input wire:model="productForm.release_channel" label="Release/build" /><label class="flex items-end gap-2 pb-2 text-sm"><input type="checkbox" wire:model="productForm.is_active" class="rounded border-zinc-300"> Active</label><div class="md:col-span-6"><flux:textarea wire:model="productForm.description" label="Description" rows="3" /></div><div class="md:col-span-6 flex gap-2"><flux:button type="submit" variant="primary">Save product/service</flux:button><flux:button type="button" wire:click="resetProductForm" variant="ghost">Reset</flux:button></div></form></x-slot:form>
|
||||
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-3">@forelse ($products as $product)<div class="rounded-2xl border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900"><h3 class="font-semibold">{{ $product->name }}</h3><p class="mt-1 text-sm text-zinc-500">{{ ucfirst($product->type) }} · {{ $product->version ?: 'No version' }} · {{ $product->tickets_count }} tickets</p><div class="mt-4 flex justify-end gap-2"><flux:button size="sm" variant="primary" wire:click="editProduct({{ $product->id }})">Edit</flux:button><flux:button size="sm" variant="danger" wire:click="deleteProduct({{ $product->id }})" wire:confirm="Delete this product/service?">Delete</flux:button></div></div>@empty<div class="rounded-2xl border border-dashed border-zinc-300 p-8 text-center text-sm text-zinc-500 dark:border-zinc-700 md:col-span-2 xl:col-span-3">No products or services yet.</div>@endforelse</div>
|
||||
</x-helpdesk::settings-section>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
|
||||
@@ -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.',
|
||||
])
|
||||
|
||||
<form wire:submit.prevent="save" enctype="multipart/form-data" class="space-y-6">
|
||||
|
||||
@@ -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.',
|
||||
])
|
||||
|
||||
<form wire:submit.prevent="save" class="space-y-6">
|
||||
|
||||
@@ -59,6 +59,20 @@
|
||||
|
||||
<form wire:submit="addComment" class="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<h2 class="mb-4 text-base font-semibold text-zinc-900 dark:text-white">Add reply</h2>
|
||||
@if ($cannedResponses->isNotEmpty())
|
||||
<div class="mb-4 grid gap-3 md:grid-cols-[1fr_auto]">
|
||||
<flux:select wire:model="canned_response_id" label="Canned response">
|
||||
<option value="">Choose a saved response...</option>
|
||||
@foreach ($cannedResponses as $response)
|
||||
<option value="{{ $response->id }}">{{ $response->name }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<div class="flex items-end">
|
||||
<flux:button type="button" wire:click="applyCannedResponse" variant="ghost" icon="document-duplicate">Insert response</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<x-helpdesk::html-editor dusk="ticket-comment-editor" wire:model="commentBody" label="Comment" rows="5" placeholder="Write a professional response..." />
|
||||
|
||||
@can('update', $ticket)
|
||||
@@ -88,12 +102,67 @@
|
||||
<dl class="space-y-3 text-sm">
|
||||
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Category</dt><dd>{{ $ticket->category?->name ?? 'None' }}</dd></div>
|
||||
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Project</dt><dd>{{ $ticket->project?->title ?? 'None' }}</dd></div>
|
||||
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Product/service</dt><dd>{{ $ticket->product?->name ?? 'None' }}</dd></div>
|
||||
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Team</dt><dd>{{ $ticket->team?->name ?? 'None' }}</dd></div>
|
||||
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Created</dt><dd>{{ $ticket->created_at->format('d M Y H:i') }}</dd></div>
|
||||
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Due</dt><dd>{{ $ticket->due_at?->format('d M Y') ?? 'No due date' }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<h2 class="mb-4 text-base font-semibold text-zinc-900 dark:text-white">SLA metrics</h2>
|
||||
<div class="grid gap-3 text-sm">
|
||||
<div class="rounded-xl border {{ $ticket->sla_first_response_breached ? 'border-red-300 bg-red-50 text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100' : 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-100' }} p-3">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide">First response</div>
|
||||
<div class="mt-1">{{ $ticket->first_response_at?->format('d M Y H:i') ?? 'Awaiting response' }}</div>
|
||||
<div class="mt-1 text-xs opacity-75">Due {{ $ticket->sla_first_response_due_at?->format('d M Y H:i') ?? 'Not set' }}</div>
|
||||
</div>
|
||||
<div class="rounded-xl border {{ $ticket->sla_resolution_breached ? 'border-red-300 bg-red-50 text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100' : 'border-blue-200 bg-blue-50 text-blue-900 dark:border-blue-900 dark:bg-blue-950 dark:text-blue-100' }} p-3">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide">Resolution</div>
|
||||
<div class="mt-1">{{ $ticket->completed_at?->format('d M Y H:i') ?? 'Open' }}</div>
|
||||
<div class="mt-1 text-xs opacity-75">Due {{ $ticket->sla_resolution_due_at?->format('d M Y H:i') ?? 'Not set' }}</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 text-center">
|
||||
<div class="rounded-xl border border-zinc-200 p-3 dark:border-zinc-700"><div class="text-xl font-semibold">{{ $ticket->public_replies_count }}</div><div class="text-xs text-zinc-500">Public replies</div></div>
|
||||
<div class="rounded-xl border border-zinc-200 p-3 dark:border-zinc-700"><div class="text-xl font-semibold">{{ $ticket->time_spent_minutes }}</div><div class="text-xs text-zinc-500">Minutes logged</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<h2 class="mb-4 text-base font-semibold text-zinc-900 dark:text-white">Requester profile</h2>
|
||||
<dl class="space-y-3 text-sm">
|
||||
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Name</dt><dd>{{ $ticket->owner?->name ?? 'Unknown' }}</dd></div>
|
||||
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Email</dt><dd class="truncate">{{ $ticket->owner?->email ?? 'None' }}</dd></div>
|
||||
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Company</dt><dd>{{ $ticket->owner?->company?->name ?? 'Current company' }}</dd></div>
|
||||
</dl>
|
||||
@if ($ticket->tags->isNotEmpty())
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
@foreach ($ticket->tags as $tag)
|
||||
<span class="rounded-full px-2 py-1 text-xs font-semibold text-white" style="background-color: {{ \Modules\Helpdesk\Support\Color::css($tag->color) }}">{{ $tag->name }}</span>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($ticket->checklistItems->isNotEmpty())
|
||||
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||
@php($completedChecks = $ticket->checklistItems->where('is_completed', true)->count())
|
||||
<div class="mb-4 flex items-center justify-between gap-3">
|
||||
<h2 class="text-base font-semibold text-zinc-900 dark:text-white">Checklist</h2>
|
||||
<flux:badge color="blue">{{ $completedChecks }}/{{ $ticket->checklistItems->count() }}</flux:badge>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
@foreach ($ticket->checklistItems as $item)
|
||||
<label class="flex items-start gap-3 rounded-xl border border-zinc-200 p-3 text-sm dark:border-zinc-700">
|
||||
<input type="checkbox" wire:click="toggleChecklistItem({{ $item->id }})" @checked($item->is_completed) class="mt-1 rounded border-zinc-300">
|
||||
<span class="{{ $item->is_completed ? 'text-zinc-500 line-through' : 'text-zinc-800 dark:text-zinc-100' }}">{{ $item->title }}</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<h2 class="mb-4 text-base font-semibold text-zinc-900 dark:text-white">Git commits</h2>
|
||||
<div class="space-y-2">
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Services;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\Helpdesk\Models\AutomationRule;
|
||||
use Modules\Helpdesk\Models\Ticket;
|
||||
|
||||
final class AutomationRuleService
|
||||
{
|
||||
/** @param array<string, mixed> $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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use Modules\Helpdesk\Models\Ticket;
|
||||
|
||||
final class CannedResponseRenderer
|
||||
{
|
||||
public function render(string $template, Ticket $ticket, ?User $actor = null): string
|
||||
{
|
||||
$ticket->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()]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use Modules\Helpdesk\Models\ChecklistItem;
|
||||
use Modules\Helpdesk\Models\ChecklistTemplate;
|
||||
use Modules\Helpdesk\Models\Ticket;
|
||||
|
||||
final class ChecklistService
|
||||
{
|
||||
public function applyTemplates(Ticket $ticket): void
|
||||
{
|
||||
ChecklistTemplate::query()
|
||||
->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Services;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Helpdesk\Models\GitCommit;
|
||||
use Modules\Helpdesk\Models\GitRepository;
|
||||
use Modules\Helpdesk\Models\GitWebhookLog;
|
||||
use Modules\Helpdesk\Models\Ticket;
|
||||
use Throwable;
|
||||
|
||||
final class GitWebhookIngestionService
|
||||
{
|
||||
private const TICKET_REFERENCE_PATTERN = '/\bHD-\d{4}-\d{5}\b/i';
|
||||
|
||||
/**
|
||||
* Persist supported Git provider commits and link them to matching Helpdesk ticket references.
|
||||
*
|
||||
* @param array<string, mixed> $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<string, mixed>|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<string, mixed> $payload
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use Modules\Helpdesk\Models\SlaPolicy;
|
||||
use Modules\Helpdesk\Models\Ticket;
|
||||
use Modules\Helpdesk\Models\TicketSlaEvent;
|
||||
|
||||
final class SlaService
|
||||
{
|
||||
public function applyPolicy(Ticket $ticket): Ticket
|
||||
{
|
||||
$policy = $this->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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 '<div style="font-family:Inter,Arial,sans-serif;background:#f6f8fb;padding:24px">'
|
||||
.'<div style="max-width:720px;margin:0 auto;background:#ffffff;border:1px solid #d8dee8;border-radius:18px;overflow:hidden">'
|
||||
@@ -98,10 +102,24 @@ final class WorkflowNotificationService
|
||||
.'</div><div style="padding:28px;color:#1f2937;line-height:1.65">'
|
||||
.$body
|
||||
.'<p style="margin-top:28px"><a href="'.e(route('helpdesk.tickets.show', ['ticket' => $ticket->getKey()])).'" style="display:inline-block;background:#2563eb;color:#ffffff;text-decoration:none;padding:11px 16px;border-radius:10px">Open ticket</a></p>'
|
||||
.$this->trackingPixel($trackingToken)
|
||||
.'</div></div></div>';
|
||||
}
|
||||
|
||||
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 '<img src="'.e($url).'" width="1" height="1" alt="" style="display:none" />';
|
||||
}
|
||||
|
||||
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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Tests\Feature;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Modules\Helpdesk\Models\GitCommit;
|
||||
use Modules\Helpdesk\Models\GitWebhookLog;
|
||||
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class GitWebhookIngestionTest extends TestCase
|
||||
{
|
||||
use BuildsHelpdeskFixtures;
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_signed_github_push_webhook_tracks_and_links_commit_to_ticket_reference(): void
|
||||
{
|
||||
$fixtures = $this->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Tests\Feature;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Modules\Helpdesk\Livewire\TicketShow;
|
||||
use Modules\Helpdesk\Models\AutomationRule;
|
||||
use Modules\Helpdesk\Models\CannedResponse;
|
||||
use Modules\Helpdesk\Models\ChecklistItem;
|
||||
use Modules\Helpdesk\Models\ChecklistTemplate;
|
||||
use Modules\Helpdesk\Models\Product;
|
||||
use Modules\Helpdesk\Models\SlaPolicy;
|
||||
use Modules\Helpdesk\Models\Ticket;
|
||||
use Modules\Helpdesk\Models\TicketSlaEvent;
|
||||
use Modules\Helpdesk\Services\AutomationRuleService;
|
||||
use Modules\Helpdesk\Services\ChecklistService;
|
||||
use Modules\Helpdesk\Services\SlaService;
|
||||
use Modules\Helpdesk\Services\TicketService;
|
||||
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class RedmineInspiredHelpdeskFeatureTest extends TestCase
|
||||
{
|
||||
use BuildsHelpdeskFixtures;
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_ticket_creation_applies_automation_sla_checklist_and_product_linkage(): void
|
||||
{
|
||||
$fixtures = $this->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' => '<p>Printer labels fail for every order.</p>',
|
||||
'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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user