diff --git a/Modules/Helpdesk/Database/Migrations/2026_04_24_000004_create_helpdesk_comments_and_attachments_tables.php b/Modules/Helpdesk/Database/Migrations/2026_04_24_000004_create_helpdesk_comments_and_attachments_tables.php index 83e7011a..3b059424 100644 --- a/Modules/Helpdesk/Database/Migrations/2026_04_24_000004_create_helpdesk_comments_and_attachments_tables.php +++ b/Modules/Helpdesk/Database/Migrations/2026_04_24_000004_create_helpdesk_comments_and_attachments_tables.php @@ -13,12 +13,13 @@ return new class extends Migration { Schema::create('helpdesk_comments', static function (Blueprint $table): void { $table->id(); $table->foreignId('ticket_id')->constrained('helpdesk_tickets')->cascadeOnDelete(); + $table->foreignId('parent_id')->nullable()->constrained('helpdesk_comments')->cascadeOnDelete(); $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete(); $table->longText('body'); $table->boolean('is_internal')->default(false); $table->timestamps(); - $table->index(['ticket_id', 'created_at']); + $table->index(['ticket_id', 'parent_id', 'created_at']); }); Schema::create('helpdesk_attachments', static function (Blueprint $table): void { @@ -33,7 +34,7 @@ return new class extends Migration { $table->unsignedBigInteger('size')->default(0); $table->timestamps(); - $table->index(['ticket_id', 'created_at']); + $table->index(['ticket_id', 'comment_id', 'created_at']); }); } diff --git a/Modules/Helpdesk/Database/Migrations/2026_04_24_000006_create_helpdesk_git_and_workflow_tables.php b/Modules/Helpdesk/Database/Migrations/2026_04_24_000006_create_helpdesk_git_and_workflow_tables.php new file mode 100644 index 00000000..cf0db684 --- /dev/null +++ b/Modules/Helpdesk/Database/Migrations/2026_04_24_000006_create_helpdesk_git_and_workflow_tables.php @@ -0,0 +1,87 @@ +id(); + $table->foreignId('company_id')->nullable()->constrained('companies')->nullOnDelete(); + $table->foreignId('project_id')->nullable()->constrained('helpdesk_projects')->nullOnDelete(); + $table->string('name', 160); + $table->string('provider', 40)->default('git'); + $table->string('remote_url', 500); + $table->string('default_branch', 120)->default('main'); + $table->string('webhook_secret', 120)->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->index(['company_id', 'project_id']); + $table->index(['provider', 'is_active']); + }); + + Schema::create('helpdesk_git_commits', static function (Blueprint $table): void { + $table->id(); + $table->foreignId('repository_id')->constrained('helpdesk_git_repositories')->cascadeOnDelete(); + $table->foreignId('ticket_id')->nullable()->constrained('helpdesk_tickets')->nullOnDelete(); + $table->string('hash', 80); + $table->string('short_hash', 16); + $table->string('branch', 120)->nullable(); + $table->string('author_name', 160)->nullable(); + $table->string('author_email', 190)->nullable(); + $table->text('message'); + $table->string('url', 500)->nullable(); + $table->timestamp('committed_at')->nullable(); + $table->timestamps(); + + $table->unique(['repository_id', 'hash']); + $table->index(['ticket_id', 'committed_at']); + }); + + Schema::create('helpdesk_workflow_templates', static function (Blueprint $table): void { + $table->id(); + $table->string('name', 160); + $table->string('event', 80); + $table->string('subject', 220); + $table->longText('html_body'); + $table->longText('text_body')->nullable(); + $table->string('to_role', 40)->default('requester'); + $table->string('cc', 500)->nullable(); + $table->string('bcc', 500)->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->index(['event', 'is_active']); + }); + + Schema::create('helpdesk_workflow_logs', static function (Blueprint $table): void { + $table->id(); + $table->foreignId('template_id')->nullable()->constrained('helpdesk_workflow_templates')->nullOnDelete(); + $table->foreignId('ticket_id')->nullable()->constrained('helpdesk_tickets')->nullOnDelete(); + $table->string('event', 80); + $table->string('recipient', 190)->nullable(); + $table->string('subject', 220)->nullable(); + $table->string('status', 40)->default('pending'); + $table->text('error')->nullable(); + $table->timestamps(); + + $table->index(['ticket_id', 'event']); + $table->index(['status', 'created_at']); + }); + } + + /** @return void */ + public function down(): void + { + Schema::dropIfExists('helpdesk_workflow_logs'); + Schema::dropIfExists('helpdesk_workflow_templates'); + Schema::dropIfExists('helpdesk_git_commits'); + Schema::dropIfExists('helpdesk_git_repositories'); + } +}; diff --git a/Modules/Helpdesk/Database/Seeders/HelpdeskDatabaseSeeder.php b/Modules/Helpdesk/Database/Seeders/HelpdeskDatabaseSeeder.php index 932d74c3..ab50d524 100644 --- a/Modules/Helpdesk/Database/Seeders/HelpdeskDatabaseSeeder.php +++ b/Modules/Helpdesk/Database/Seeders/HelpdeskDatabaseSeeder.php @@ -11,6 +11,7 @@ use Modules\Helpdesk\Models\Priority; use Modules\Helpdesk\Models\Project; use Modules\Helpdesk\Models\Status; use Modules\Helpdesk\Models\Team; +use Modules\Helpdesk\Models\WorkflowTemplate; final class HelpdeskDatabaseSeeder extends Seeder { @@ -51,5 +52,18 @@ final class HelpdeskDatabaseSeeder extends Seeder 'color' => 'blue', 'description' => 'Default project for internal helpdesk tickets.', ]); + + foreach ([ + ['name' => 'Ticket raised requester notice', 'event' => 'ticket.created', 'subject' => 'Ticket {{ ticket.reference }} has been raised', 'html_body' => '

Hello {{ requester.name }},

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

Subject: {{ ticket.subject }}

Priority: {{ ticket.priority }}

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

Hello {{ requester.name }},

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

Status: {{ ticket.status }}

Priority: {{ ticket.priority }}

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

Hello {{ requester.name }},

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

Subject: {{ ticket.subject }}

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

Hello {{ requester.name }},

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

Thank you for using the support desk.

', 'to_role' => 'requester'], + ] as $template) { + WorkflowTemplate::query()->updateOrCreate( + ['name' => $template['name'], 'event' => $template['event']], + $template + ['text_body' => null, 'cc' => null, 'bcc' => null, 'is_active' => true] + ); + } + } } diff --git a/Modules/Helpdesk/Livewire/HelpdeskDashboardWidget.php b/Modules/Helpdesk/Livewire/HelpdeskDashboardWidget.php index 2595ae84..64563a6b 100644 --- a/Modules/Helpdesk/Livewire/HelpdeskDashboardWidget.php +++ b/Modules/Helpdesk/Livewire/HelpdeskDashboardWidget.php @@ -7,6 +7,7 @@ namespace Modules\Helpdesk\Livewire; use Illuminate\Contracts\View\View; use Illuminate\Support\Facades\Auth; use Livewire\Component; +use Modules\Helpdesk\Models\GitCommit; use Modules\Helpdesk\Models\Ticket; final class HelpdeskDashboardWidget extends Component @@ -15,13 +16,22 @@ final class HelpdeskDashboardWidget extends Component public function render(): View { $user = Auth::user(); - $base = Ticket::query()->visibleTo($user); + $totalCount = (clone $base)->count(); + $openCount = (clone $base)->whereNull('completed_at')->count(); + $closedCount = (clone $base)->whereNotNull('completed_at')->count(); + $criticalCount = (clone $base)->whereNull('completed_at')->whereHas('priority', static fn ($q) => $q->whereIn('slug', ['critical', 'urgent', 'high']))->count(); + $closedPercent = $totalCount > 0 ? (int) round(($closedCount / $totalCount) * 100) : 0; + $openPercent = $totalCount > 0 ? (int) round(($openCount / $totalCount) * 100) : 0; return view('helpdesk::livewire.dashboard-widget', [ - 'openCount' => (clone $base)->whereNull('completed_at')->count(), - 'closedCount' => (clone $base)->whereNotNull('completed_at')->count(), - 'criticalCount' => (clone $base)->whereNull('completed_at')->whereHas('priority', static fn ($q) => $q->where('slug', 'critical'))->count(), + 'totalCount' => $totalCount, + 'openCount' => $openCount, + 'closedCount' => $closedCount, + 'criticalCount' => $criticalCount, + 'closedPercent' => $closedPercent, + 'openPercent' => $openPercent, + 'recentCommits' => GitCommit::query()->with(['repository', 'ticket'])->latest('committed_at')->limit(6)->get(), 'recentTickets' => (clone $base)->with(['status', 'priority'])->latest('last_activity_at')->limit(5)->get(), ]); } diff --git a/Modules/Helpdesk/Livewire/KanbanBoard.php b/Modules/Helpdesk/Livewire/KanbanBoard.php new file mode 100644 index 00000000..56e02b17 --- /dev/null +++ b/Modules/Helpdesk/Livewire/KanbanBoard.php @@ -0,0 +1,76 @@ +visibleTo(Auth::user())->findOrFail($ticketId); + $status = Status::query()->findOrFail($statusId); + + $oldStatus = $ticket->status?->name; + + $ticket->forceFill([ + 'status_id' => $status->id, + 'completed_at' => $status->is_closed ? now() : null, + 'last_activity_at' => now(), + ])->save(); + + $workflow->dispatch( + $ticket->fresh(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team']), + $status->is_closed ? 'ticket_closed' : 'ticket_updated', + Auth::user() + ); + + session()->flash('success', 'Ticket moved to ' . $status->name . '.'); + } + + /** + * Render the Kanban board. + * + * @return View + */ + public function render(): View + { + return view('helpdesk::livewire.kanban-board', [ + 'statuses' => Status::query()->orderBy('sort_order')->get(), + 'ticketsByStatus' => $this->ticketsByStatus(), + ])->layout('layouts.app'); + } + + /** + * Get visible tickets grouped by status. + * + * @return array> + */ + private function ticketsByStatus(): array + { + return Ticket::query() + ->with(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team']) + ->visibleTo(Auth::user()) + ->latest('last_activity_at') + ->limit(200) + ->get() + ->groupBy('status_id') + ->all(); + } +} diff --git a/Modules/Helpdesk/Livewire/SettingsManager.php b/Modules/Helpdesk/Livewire/SettingsManager.php index 5956f78a..b06f3fa2 100644 --- a/Modules/Helpdesk/Livewire/SettingsManager.php +++ b/Modules/Helpdesk/Livewire/SettingsManager.php @@ -10,10 +10,12 @@ use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; use Livewire\Component; use Modules\Helpdesk\Models\Category; +use Modules\Helpdesk\Models\GitRepository; use Modules\Helpdesk\Models\Priority; use Modules\Helpdesk\Models\Project; use Modules\Helpdesk\Models\Status; use Modules\Helpdesk\Models\Team; +use Modules\Helpdesk\Models\WorkflowTemplate; final class SettingsManager extends Component { @@ -34,6 +36,12 @@ final class SettingsManager extends Component /** @var array */ public array $projectForm = ['id' => null, 'company_id' => null, 'team_id' => null, 'title' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'archived_at' => null]; + /** @var array */ + public array $repositoryForm = ['id' => null, 'company_id' => null, 'project_id' => null, 'name' => '', 'provider' => 'git', 'remote_url' => '', 'default_branch' => 'main', 'webhook_secret' => '']; + + /** @var array */ + public array $workflowForm = ['id' => null, 'name' => '', 'event' => 'ticket.created', 'subject' => '', 'html_body' => '', 'text_body' => '', 'to_role' => 'requester', 'cc' => '', 'bcc' => '', 'is_active' => true]; + /** @return void */ public function saveStatus(): void { @@ -219,6 +227,86 @@ final class SettingsManager extends Component $this->projectForm = ['id' => null, 'company_id' => null, 'team_id' => null, 'title' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'archived_at' => null]; } + + /** @return void */ + public function saveRepository(): void + { + $data = $this->validate([ + 'repositoryForm.company_id' => ['nullable', 'integer'], + 'repositoryForm.project_id' => ['nullable', 'integer', 'exists:helpdesk_projects,id'], + 'repositoryForm.name' => ['required', 'string', 'max:160'], + 'repositoryForm.provider' => ['required', 'string', 'max:40'], + 'repositoryForm.remote_url' => ['required', 'string', 'max:500'], + 'repositoryForm.default_branch' => ['required', 'string', 'max:120'], + 'repositoryForm.webhook_secret' => ['nullable', 'string', 'max:120'], + ])['repositoryForm']; + + GitRepository::query()->updateOrCreate(['id' => $this->repositoryForm['id']], $data); + $this->resetRepositoryForm(); + session()->flash('success', 'Git repository saved.'); + } + + /** @return void */ + public function editRepository(int $id): void + { + $this->repositoryForm = GitRepository::query()->findOrFail($id)->only(['id', 'company_id', 'project_id', 'name', 'provider', 'remote_url', 'default_branch', 'webhook_secret']); + $this->section = 'git'; + } + + /** @return void */ + public function deleteRepository(int $id): void + { + GitRepository::query()->findOrFail($id)->delete(); + session()->flash('success', 'Git repository deleted.'); + } + + /** @return void */ + public function resetRepositoryForm(): void + { + $this->repositoryForm = ['id' => null, 'company_id' => null, 'project_id' => null, 'name' => '', 'provider' => 'git', 'remote_url' => '', 'default_branch' => 'main', 'webhook_secret' => '']; + } + + + /** @return void */ + public function saveWorkflowTemplate(): void + { + $data = $this->validate([ + 'workflowForm.name' => ['required', 'string', 'max:160'], + 'workflowForm.event' => ['required', 'string', 'max:80'], + 'workflowForm.subject' => ['required', 'string', 'max:220'], + 'workflowForm.html_body' => ['required', 'string'], + 'workflowForm.text_body' => ['nullable', 'string'], + 'workflowForm.to_role' => ['required', 'string', 'max:40'], + 'workflowForm.cc' => ['nullable', 'string', 'max:500'], + 'workflowForm.bcc' => ['nullable', 'string', 'max:500'], + 'workflowForm.is_active' => ['boolean'], + ])['workflowForm']; + + WorkflowTemplate::query()->updateOrCreate(['id' => $this->workflowForm['id']], $data); + $this->resetWorkflowTemplateForm(); + session()->flash('success', 'Workflow message saved.'); + } + + /** @return void */ + public function editWorkflowTemplate(int $id): void + { + $this->workflowForm = WorkflowTemplate::query()->findOrFail($id)->only(['id', 'name', 'event', 'subject', 'html_body', 'text_body', 'to_role', 'cc', 'bcc', 'is_active']); + $this->section = 'workflow'; + } + + /** @return void */ + public function deleteWorkflowTemplate(int $id): void + { + WorkflowTemplate::query()->findOrFail($id)->delete(); + session()->flash('success', 'Workflow message deleted.'); + } + + /** @return void */ + public function resetWorkflowTemplateForm(): void + { + $this->workflowForm = ['id' => null, 'name' => '', 'event' => 'ticket.created', 'subject' => '', 'html_body' => '', 'text_body' => '', 'to_role' => 'requester', 'cc' => '', 'bcc' => '', 'is_active' => true]; + } + /** @return View */ public function render(): View { @@ -229,6 +317,8 @@ final class SettingsManager extends Component 'categories' => Category::query()->withCount('tickets')->orderBy('name')->get(), 'teams' => Team::query()->withCount(['projects', 'tickets'])->orderBy('name')->get(), 'projects' => Project::query()->with(['team'])->withCount('tickets')->orderBy('title')->get(), + 'repositories' => GitRepository::query()->with(['project'])->withCount('commits')->orderBy('name')->get(), + 'workflowTemplates' => WorkflowTemplate::query()->orderBy('event')->orderBy('name')->get(), ])->layout('layouts.app'); } diff --git a/Modules/Helpdesk/Livewire/TicketCreate.php b/Modules/Helpdesk/Livewire/TicketCreate.php index e0dc3d5f..4d5df4a5 100644 --- a/Modules/Helpdesk/Livewire/TicketCreate.php +++ b/Modules/Helpdesk/Livewire/TicketCreate.php @@ -75,7 +75,7 @@ final class TicketCreate extends Component session()->flash('success', 'Ticket ' . $ticket->reference . ' has been created.'); - return $this->redirectRoute('helpdesk.tickets.show', $ticket, navigate: true); + return $this->redirectRoute('helpdesk.tickets.show', ['ticket' => $ticket->getKey()]); } /** @return View */ diff --git a/Modules/Helpdesk/Livewire/TicketShow.php b/Modules/Helpdesk/Livewire/TicketShow.php index cc86ba67..a238bed4 100644 --- a/Modules/Helpdesk/Livewire/TicketShow.php +++ b/Modules/Helpdesk/Livewire/TicketShow.php @@ -10,9 +10,11 @@ use Livewire\Attributes\Validate; use Livewire\Component; use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; use Livewire\WithFileUploads; +use Modules\Helpdesk\Models\Priority; use Modules\Helpdesk\Models\Status; use Modules\Helpdesk\Models\Ticket; use Modules\Helpdesk\Services\TicketService; +use Modules\Helpdesk\Services\WorkflowNotificationService; final class TicketShow extends Component { @@ -30,25 +32,36 @@ final class TicketShow extends Component public ?int $status_id = null; - /** - * Mount the component. - * - * @param Ticket $ticket - * @return void - */ + public ?int $priority_id = null; + + public ?int $reply_parent_id = null; + + public string $replyBody = ''; + + /** @return void */ public function mount(Ticket $ticket): void { $this->authorize('view', $ticket); - $this->ticket = $ticket->load(['owner', 'agent', 'status', 'priority', 'category', 'project', 'attachments.user']); + $this->ticket = $this->freshTicket($ticket); $this->status_id = $this->ticket->status_id; + $this->priority_id = $this->ticket->priority_id; } - /** - * Add a comment to the ticket. - * - * @param TicketService $tickets - * @return void - */ + /** @return void */ + public function startReply(int $commentId): void + { + $this->reply_parent_id = $commentId; + $this->replyBody = ''; + } + + /** @return void */ + public function cancelReply(): void + { + $this->reply_parent_id = null; + $this->replyBody = ''; + } + + /** @return void */ public function addComment(TicketService $tickets): void { $this->authorize('comment', $this->ticket); @@ -61,16 +74,29 @@ final class TicketShow extends Component $tickets->comment($this->ticket, Auth::user(), $this->commentBody, $this->internal, $this->attachments); $this->reset(['commentBody', 'internal', 'attachments']); - $this->ticket = $this->ticket->fresh(['owner', 'agent', 'status', 'priority', 'category', 'project', 'comments.user', 'comments.attachments', 'attachments.user']); + $this->ticket = $this->freshTicket($this->ticket); $this->dispatch('helpdesk-comment-added'); } - /** - * Update status using Livewire AJAX. - * - * @return void - */ - public function updateStatus(): void + /** @return void */ + public function addReply(TicketService $tickets): void + { + $this->authorize('comment', $this->ticket); + + $this->validate([ + 'reply_parent_id' => ['required', 'integer', 'exists:helpdesk_comments,id'], + 'replyBody' => ['required', 'string', 'min:2'], + ]); + + $tickets->comment($this->ticket, Auth::user(), $this->replyBody, $this->internal, [], $this->reply_parent_id); + + $this->cancelReply(); + $this->ticket = $this->freshTicket($this->ticket); + $this->dispatch('helpdesk-reply-added'); + } + + /** @return void */ + public function updateStatus(WorkflowNotificationService $workflow): void { $this->authorize('update', $this->ticket); $this->validate(['status_id' => ['required', 'integer', 'exists:helpdesk_statuses,id']]); @@ -82,15 +108,43 @@ final class TicketShow extends Component 'last_activity_at' => now(), ])->save(); - $this->ticket = $this->ticket->fresh(['owner', 'agent', 'status', 'priority', 'category', 'project', 'comments.user', 'comments.attachments', 'attachments.user']); + $this->ticket = $this->freshTicket($this->ticket); + $workflow->dispatch($this->ticket, $status->is_closed ? 'ticket.closed' : 'ticket.updated', Auth::user()); $this->dispatch('helpdesk-status-updated'); } + /** @return void */ + public function updatePriority(WorkflowNotificationService $workflow): void + { + $this->authorize('update', $this->ticket); + $this->validate(['priority_id' => ['required', 'integer', 'exists:helpdesk_priorities,id']]); + + $this->ticket->forceFill([ + 'priority_id' => $this->priority_id, + 'last_activity_at' => now(), + ])->save(); + + $this->ticket = $this->freshTicket($this->ticket); + $workflow->dispatch($this->ticket, 'ticket.updated', Auth::user()); + $this->dispatch('helpdesk-priority-updated'); + } + /** @return View */ public function render(): View { return view('helpdesk::livewire.ticket-show', [ 'statuses' => Status::query()->orderBy('sort_order')->get(), + 'priorities' => Priority::query()->orderBy('sort_order')->get(), ])->layout('layouts.app'); } + + /** @return Ticket */ + private function freshTicket(Ticket $ticket): Ticket + { + return $ticket->fresh([ + 'owner', 'agent', 'status', 'priority', 'category', 'project', 'team', + 'comments.user', 'comments.attachments', 'comments.replies.user', 'comments.replies.attachments', 'comments.replies.replies.user', + 'attachments.user', 'gitCommits.repository', + ]) ?? $ticket; + } } diff --git a/Modules/Helpdesk/Models/Comment.php b/Modules/Helpdesk/Models/Comment.php index 53f11a65..581fbe96 100644 --- a/Modules/Helpdesk/Models/Comment.php +++ b/Modules/Helpdesk/Models/Comment.php @@ -17,7 +17,7 @@ final class Comment extends Model protected $table = 'helpdesk_comments'; /** @var list */ - protected $fillable = ['ticket_id', 'user_id', 'body', 'is_internal']; + protected $fillable = ['ticket_id', 'parent_id', 'user_id', 'body', 'is_internal']; /** @var array */ protected $casts = ['is_internal' => 'boolean']; @@ -28,6 +28,18 @@ final class Comment extends Model return $this->belongsTo(Ticket::class, 'ticket_id'); } + /** @return BelongsTo */ + public function parent(): BelongsTo + { + return $this->belongsTo(self::class, 'parent_id'); + } + + /** @return HasMany */ + public function replies(): HasMany + { + return $this->hasMany(self::class, 'parent_id')->oldest(); + } + /** @return BelongsTo */ public function user(): BelongsTo { diff --git a/Modules/Helpdesk/Models/GitCommit.php b/Modules/Helpdesk/Models/GitCommit.php new file mode 100644 index 00000000..637aa4fa --- /dev/null +++ b/Modules/Helpdesk/Models/GitCommit.php @@ -0,0 +1,36 @@ + */ + protected $fillable = [ + 'repository_id', 'ticket_id', 'hash', 'short_hash', 'branch', 'author_name', 'author_email', 'message', 'url', 'committed_at', + ]; + + /** @var array */ + protected $casts = ['repository_id' => 'integer', 'ticket_id' => 'integer', 'committed_at' => 'datetime']; + + /** @return BelongsTo */ + public function repository(): BelongsTo + { + return $this->belongsTo(GitRepository::class, 'repository_id'); + } + + /** @return BelongsTo */ + public function ticket(): BelongsTo + { + return $this->belongsTo(Ticket::class, 'ticket_id'); + } +} diff --git a/Modules/Helpdesk/Models/GitRepository.php b/Modules/Helpdesk/Models/GitRepository.php new file mode 100644 index 00000000..a09595f2 --- /dev/null +++ b/Modules/Helpdesk/Models/GitRepository.php @@ -0,0 +1,35 @@ + */ + protected $fillable = ['company_id', 'project_id', 'name', 'provider', 'remote_url', 'default_branch', 'webhook_secret', 'is_active']; + + /** @var array */ + protected $casts = ['company_id' => 'integer', 'project_id' => 'integer', 'is_active' => 'boolean']; + + /** @return BelongsTo */ + public function project(): BelongsTo + { + return $this->belongsTo(Project::class, 'project_id'); + } + + /** @return HasMany */ + public function commits(): HasMany + { + return $this->hasMany(GitCommit::class, 'repository_id')->latest('committed_at'); + } +} diff --git a/Modules/Helpdesk/Models/Ticket.php b/Modules/Helpdesk/Models/Ticket.php index e213fcf6..3635d3ac 100644 --- a/Modules/Helpdesk/Models/Ticket.php +++ b/Modules/Helpdesk/Models/Ticket.php @@ -86,7 +86,13 @@ final class Ticket extends Model /** @return HasMany */ public function comments(): HasMany { - return $this->hasMany(Comment::class, 'ticket_id')->latest(); + return $this->hasMany(Comment::class, 'ticket_id')->whereNull('parent_id')->with(['user', 'attachments', 'replies.user', 'replies.attachments', 'replies.replies.user'])->oldest(); + } + + /** @return HasMany */ + public function gitCommits(): HasMany + { + return $this->hasMany(GitCommit::class, 'ticket_id')->latest('committed_at'); } /** @return HasMany */ diff --git a/Modules/Helpdesk/Models/WorkflowLog.php b/Modules/Helpdesk/Models/WorkflowLog.php new file mode 100644 index 00000000..337eb544 --- /dev/null +++ b/Modules/Helpdesk/Models/WorkflowLog.php @@ -0,0 +1,31 @@ + */ + protected $fillable = ['template_id', 'ticket_id', 'event', 'recipient', 'subject', 'status', 'error']; + + /** @return BelongsTo */ + public function template(): BelongsTo + { + return $this->belongsTo(WorkflowTemplate::class, 'template_id'); + } + + /** @return BelongsTo */ + public function ticket(): BelongsTo + { + return $this->belongsTo(Ticket::class, 'ticket_id'); + } +} diff --git a/Modules/Helpdesk/Models/WorkflowTemplate.php b/Modules/Helpdesk/Models/WorkflowTemplate.php new file mode 100644 index 00000000..a4ce2027 --- /dev/null +++ b/Modules/Helpdesk/Models/WorkflowTemplate.php @@ -0,0 +1,28 @@ + */ + protected $fillable = ['name', 'event', 'subject', 'html_body', 'text_body', 'to_role', 'cc', 'bcc', 'is_active']; + + /** @var array */ + protected $casts = ['is_active' => 'boolean']; + + /** @return HasMany */ + public function logs(): HasMany + { + return $this->hasMany(WorkflowLog::class, 'template_id'); + } +} diff --git a/Modules/Helpdesk/Resources/views/components/html-editor.blade.php b/Modules/Helpdesk/Resources/views/components/html-editor.blade.php new file mode 100644 index 00000000..7536a228 --- /dev/null +++ b/Modules/Helpdesk/Resources/views/components/html-editor.blade.php @@ -0,0 +1,63 @@ +@props([ + 'label' => 'Details', + 'rows' => 10, + 'placeholder' => 'Write your message...', +]) + +@php($model = $attributes->wire('model')->value()) + +
except(['wire:model'])->merge(['class' => 'space-y-2']) }} + x-data="{ + value: @entangle($model), + command(action, value = null) { + document.execCommand(action, false, value); + this.sync(); + }, + sync() { + this.value = this.$refs.editor.innerHTML; + }, + init() { + this.$refs.editor.innerHTML = this.value || ''; + + this.$watch('value', (nextValue) => { + if (document.activeElement !== this.$refs.editor && this.$refs.editor.innerHTML !== (nextValue || '')) { + this.$refs.editor.innerHTML = nextValue || ''; + } + }); + } + }" +> + + + + +
+
+ + + + + + + + +
+ +
+
+
+
+ + @error($model) +
{{ $message }}
+ @enderror +
diff --git a/Modules/Helpdesk/Resources/views/components/repository-table.blade.php b/Modules/Helpdesk/Resources/views/components/repository-table.blade.php new file mode 100644 index 00000000..068466df --- /dev/null +++ b/Modules/Helpdesk/Resources/views/components/repository-table.blade.php @@ -0,0 +1,33 @@ +@props(['items']) + +
+ + + + + + + + + + + + + @forelse ($items as $item) + + + + + + + + + @empty + + @endforelse + +
RepositoryProjectProviderBranchCommitsActions
{{ $item->name }}{{ $item->project?->title ?? 'Global' }}{{ $item->provider }}{{ $item->default_branch }}{{ $item->commits_count }} + Edit + Delete +
No repositories yet.
+
diff --git a/Modules/Helpdesk/Resources/views/index.blade.php b/Modules/Helpdesk/Resources/views/index.blade.php index 05498771..074e54e1 100644 --- a/Modules/Helpdesk/Resources/views/index.blade.php +++ b/Modules/Helpdesk/Resources/views/index.blade.php @@ -1,22 +1,46 @@ - +
-
-
+
+

Support operations

-

Helpdesk

-

Manage tickets, priorities, statuses, categories, company teams and company projects from one professional workspace.

+

Helpdesk dashboard

+

Track open workload, closed tickets, response performance, project activity, priorities, Git commits and workflow communication from one professional support workspace.

+
+ New ticket + Active tickets + Kanban + Settings +
-
- New ticket - Settings +
+
+ @foreach (range(1, 9) as $cell) +
+ @endforeach +
+
+
+
+
+
- +
+
+ Dashboard + Tickets + Create + Kanban + Settings +
+
+ +
- + diff --git a/Modules/Helpdesk/Resources/views/livewire/dashboard-widget.blade.php b/Modules/Helpdesk/Resources/views/livewire/dashboard-widget.blade.php index d8bfb77d..38d713fe 100644 --- a/Modules/Helpdesk/Resources/views/livewire/dashboard-widget.blade.php +++ b/Modules/Helpdesk/Resources/views/livewire/dashboard-widget.blade.php @@ -1,38 +1,86 @@ -
-
-
-

Helpdesk

-

Your current support workload.

+
+
+
+
Total tickets
+
{{ $totalCount }}
+
- View all -
- -
-
-
{{ $openCount }}
-
Open
+
+
Open tickets
+
{{ $openCount }}
+
-
-
{{ $criticalCount }}
-
Critical
+
+
Closed tickets
+
{{ $closedCount }}
+
-
-
{{ $closedCount }}
-
Closed
+
+
High priority
+
{{ $criticalCount }}
+
Open urgent/high/critical workload.
-
- @forelse ($recentTickets as $ticket) - -
- {{ $ticket->subject }} - {{ $ticket->priority->name }} +
+
+
+
+

Performance

+

Closure rate and remaining open workload.

-
{{ $ticket->reference }} · {{ $ticket->status->name }}
-
- @empty -
No helpdesk tickets yet.
- @endforelse + {{ $closedPercent }}% closed +
+
+
+
Closed{{ $closedPercent }}%
+
+
+
+
Open{{ $openPercent }}%
+
+
+
+
+ +
+
+

Recent commits

+ Git setup +
+
+ @forelse ($recentCommits as $commit) +
+
{{ $commit->short_hash }} · {{ $commit->repository?->name }}
+
{{ $commit->message }}
+
+ @empty +
No commits tracked yet.
+ @endforelse +
+
+
+ +
+
+
+

Recent tickets

+

Latest support activity.

+
+ View all +
+
+ @forelse ($recentTickets as $ticket) + +
+ {{ $ticket->subject }} + {{ $ticket->priority->name }} +
+
{{ $ticket->reference }} · {{ $ticket->status->name }}
+
+ @empty +
No helpdesk tickets yet.
+ @endforelse +
diff --git a/Modules/Helpdesk/Resources/views/livewire/kanban-board.blade.php b/Modules/Helpdesk/Resources/views/livewire/kanban-board.blade.php new file mode 100644 index 00000000..a4e8844d --- /dev/null +++ b/Modules/Helpdesk/Resources/views/livewire/kanban-board.blade.php @@ -0,0 +1,86 @@ +
+
+
+
+
+

Helpdesk workflow

+

Kanban board

+

Move tickets through your configured statuses, keep priorities visible and trigger workflow notifications when work changes state.

+
+
+ New ticket + List view +
+
+
+
+ + @if (session('success')) +
+ {{ session('success') }} +
+ @endif + +
+ @foreach ($statuses as $status) + @php($tickets = $ticketsByStatus[$status->id] ?? collect()) + +
+
+
+

{{ $status->name }}

+

{{ $tickets->count() }} tickets

+
+ +
+ +
+ @forelse ($tickets as $ticket) +
+
+
+ + {{ $ticket->reference }} + +

{{ $ticket->subject }}

+
+ @if ($ticket->priority) + + {{ $ticket->priority->name }} + + @endif +
+ +
+
Owner: {{ $ticket->owner?->name ?? 'Unknown' }}
+ @if ($ticket->project) +
Project: {{ $ticket->project->title }}
+ @endif + @if ($ticket->team) +
Team: {{ $ticket->team->name }}
+ @endif +
+ +
+ @foreach ($statuses as $targetStatus) + @continue($targetStatus->id === $status->id) + + @endforeach +
+
+ @empty +
+ No tickets in {{ $status->name }}. +
+ @endforelse +
+
+ @endforeach +
+
diff --git a/Modules/Helpdesk/Resources/views/livewire/settings-manager.blade.php b/Modules/Helpdesk/Resources/views/livewire/settings-manager.blade.php index 2f891446..0141bf70 100644 --- a/Modules/Helpdesk/Resources/views/livewire/settings-manager.blade.php +++ b/Modules/Helpdesk/Resources/views/livewire/settings-manager.blade.php @@ -5,7 +5,7 @@

Helpdesk administration

Support configuration

-

Manage the lookup data used by tickets, including statuses, priorities, categories, company teams and company projects.

+

Manage the lookup data used by tickets, including statuses, priorities, categories, company teams, company projects, Git repositories and workflow messages.

@@ -20,13 +20,15 @@ {{ session('success') }} @endif -
+
@foreach ([ 'statuses' => ['label' => 'Statuses', 'count' => $statuses->count()], 'priorities' => ['label' => 'Priorities', 'count' => $priorities->count()], 'categories' => ['label' => 'Categories', 'count' => $categories->count()], 'teams' => ['label' => 'Teams', 'count' => $teams->count()], 'projects' => ['label' => 'Projects', 'count' => $projects->count()], + 'git' => ['label' => 'Git', 'count' => $repositories->count()], + 'workflow' => ['label' => 'Workflow', 'count' => $workflowTemplates->count()], ] as $key => $item)
diff --git a/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php b/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php index 94c19bac..aa6d4d5e 100644 --- a/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php +++ b/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php @@ -1,70 +1,176 @@ -
-
-

Create ticket

-

Raise a support request with optional attachments.

-
+
+ @include('helpdesk::partials.page-header', [ + 'title' => 'Create Ticket', + 'subtitle' => 'Raise a new support request with priority, project, category and full details.', + ]) -
-
-
- - + +
+
+

Ticket details

+
+ +
+
+ + + + + @error('title') +

{{ $message }}

+ @enderror
-
+
+ - - - @foreach ($companies as $company) - - @endforeach - - - - - @foreach ($teams as $team) - - @endforeach - - - + - + @error('priority_id') +

{{ $message }}

+ @enderror +
+ +
+ + + + + @error('category_id') +

{{ $message }}

+ @enderror +
+ +
+ + + - + @error('project_id') +

{{ $message }}

+ @enderror +
-
- - -
Allowed: {{ implode(', ', config('helpdesk.allowed_mimes')) }}. Max {{ config('helpdesk.max_upload_kb') }}KB each.
- @error('attachments.*')
{{ $message }}
@enderror +
+ + + + + @error('team_id') +

{{ $message }}

+ @enderror +
+ +
+ + +
+
+ + @error('details') +

{{ $message }}

+ @enderror
-
- Cancel - - Create ticket - Creating... - +
+ + Cancel + + +
- +
+
+ +@push('scripts') + +@endpush diff --git a/Modules/Helpdesk/Resources/views/livewire/ticket-index.blade.php b/Modules/Helpdesk/Resources/views/livewire/ticket-index.blade.php index 56ff8f5c..b14e7033 100644 --- a/Modules/Helpdesk/Resources/views/livewire/ticket-index.blade.php +++ b/Modules/Helpdesk/Resources/views/livewire/ticket-index.blade.php @@ -1,13 +1,18 @@
-
-
-

Helpdesk

-

Search, triage and manage support tickets.

+
+
+
+
+

Ticket management

+

Active tickets

+

Search, triage and manage support requests across companies, projects, teams, priorities and statuses.

+
+
+ Dashboard + New ticket +
+
- - - New ticket -
@if (session('success')) diff --git a/Modules/Helpdesk/Resources/views/livewire/ticket-show.blade.php b/Modules/Helpdesk/Resources/views/livewire/ticket-show.blade.php index 39317eb7..f16873ec 100644 --- a/Modules/Helpdesk/Resources/views/livewire/ticket-show.blade.php +++ b/Modules/Helpdesk/Resources/views/livewire/ticket-show.blade.php @@ -1,24 +1,26 @@
-
-
-
- {{ $ticket->reference }} - {{ $ticket->status->name }} - {{ $ticket->priority->name }} +
+
+
+
+
+ {{ $ticket->reference }} + {{ $ticket->status->name }} + {{ $ticket->priority->name }} +
+

{{ $ticket->subject }}

+

Raised by {{ $ticket->owner?->name ?? 'Unknown user' }} · Assigned to {{ $ticket->agent?->name ?? 'Unassigned' }}

+
+ Back
-

{{ $ticket->subject }}

-

- Raised by {{ $ticket->owner?->name ?? 'Unknown user' }} · Assigned to {{ $ticket->agent?->name ?? 'Unassigned' }} -

- Back
- {!! nl2br(e($ticket->body)) !!} + {!! $ticket->body !!}
@@ -39,37 +41,16 @@

Conversation

- @forelse ($ticket->comments as $comment) -
-
-
-
{{ $comment->user?->name ?? 'Unknown user' }}
-
{{ $comment->created_at->diffForHumans() }}
-
- @if ($comment->is_internal) - Internal note - @endif -
-
{!! nl2br(e($comment->body)) !!}
- - @if ($comment->attachments->isNotEmpty()) -
- @foreach ($comment->attachments as $attachment) - - {{ $attachment->original_name }} - - @endforeach -
- @endif -
- @empty -
No comments yet.
- @endforelse + @if ($ticket->comments->isNotEmpty()) + @include('helpdesk::partials.comment-thread', ['comments' => $ticket->comments, 'level' => 0]) + @else +
No responses yet.
+ @endif

Add reply

- + @can('update', $ticket)
@@ -84,7 +65,7 @@
- + Post reply Posting... @@ -98,11 +79,26 @@
Category
{{ $ticket->category?->name ?? 'None' }}
Project
{{ $ticket->project?->title ?? 'None' }}
+
Team
{{ $ticket->team?->name ?? 'None' }}
Created
{{ $ticket->created_at->format('d M Y H:i') }}
Due
{{ $ticket->due_at?->format('d M Y') ?? 'No due date' }}
+
+

Git commits

+
+ @forelse ($ticket->gitCommits as $commit) + +
{{ $commit->short_hash }} · {{ $commit->repository?->name }}
+
{{ $commit->message }}
+
+ @empty +
No commits linked.
+ @endforelse +
+
+ @can('update', $ticket)

Agent actions

@@ -111,7 +107,15 @@ @endforeach - Update status + Update status +
+ + @foreach ($priorities as $priority) + + @endforeach + + Update priority +
@endcan diff --git a/Modules/Helpdesk/Resources/views/partials/comment-thread.blade.php b/Modules/Helpdesk/Resources/views/partials/comment-thread.blade.php new file mode 100644 index 00000000..e5fee42d --- /dev/null +++ b/Modules/Helpdesk/Resources/views/partials/comment-thread.blade.php @@ -0,0 +1,51 @@ +@props(['comments', 'level' => 0]) + +
+ @foreach ($comments as $comment) +
+
+
+
{{ $comment->user?->name ?? 'Unknown user' }}
+
{{ $comment->created_at?->format('d M Y H:i') }}
+
+ @if ($comment->is_internal) + Internal + @endif +
+ +
+ {!! $comment->body !!} +
+ + @if ($comment->attachments->isNotEmpty()) +
+ @foreach ($comment->attachments as $attachment) + + {{ $attachment->original_name }} + + @endforeach +
+ @endif + +
+ Reply +
+ + @if (($reply_parent_id ?? null) === $comment->id) + + +
+ Cancel + Post reply +
+ + @endif + + @if ($comment->replies->isNotEmpty()) +
+ @include('helpdesk::partials.comment-thread', ['comments' => $comment->replies, 'level' => ((int) $level) + 1]) +
+ @endif +
+ @endforeach +
diff --git a/Modules/Helpdesk/Resources/views/partials/page-header.blade.php b/Modules/Helpdesk/Resources/views/partials/page-header.blade.php new file mode 100644 index 00000000..7311698f --- /dev/null +++ b/Modules/Helpdesk/Resources/views/partials/page-header.blade.php @@ -0,0 +1,27 @@ +
+
+
+
+

+ Helpdesk +

+ +

+ {{ $title ?? 'Helpdesk' }} +

+ + @isset($subtitle) +

+ {{ $subtitle }} +

+ @endisset +
+ + @isset($actions) +
+ {{ $actions }} +
+ @endisset +
+
+
diff --git a/Modules/Helpdesk/Routes/web.php b/Modules/Helpdesk/Routes/web.php index 00d4fac0..af919dad 100644 --- a/Modules/Helpdesk/Routes/web.php +++ b/Modules/Helpdesk/Routes/web.php @@ -4,6 +4,7 @@ declare(strict_types=1); use Illuminate\Support\Facades\Route; use Modules\Helpdesk\Http\Controllers\HelpdeskController; +use Modules\Helpdesk\Livewire\KanbanBoard; use Modules\Helpdesk\Livewire\SettingsManager; use Modules\Helpdesk\Livewire\TicketCreate; use Modules\Helpdesk\Livewire\TicketIndex; @@ -12,6 +13,7 @@ use Modules\Helpdesk\Livewire\TicketShow; 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::get('/tickets/create', TicketCreate::class)->name('tickets.create'); diff --git a/Modules/Helpdesk/Services/TicketService.php b/Modules/Helpdesk/Services/TicketService.php index 1ed5e0c4..f59dc72b 100644 --- a/Modules/Helpdesk/Services/TicketService.php +++ b/Modules/Helpdesk/Services/TicketService.php @@ -16,6 +16,7 @@ final class TicketService private readonly TicketReferenceService $references, private readonly AgentAssignmentService $assignment, private readonly AttachmentService $attachments, + private readonly WorkflowNotificationService $workflow, ) { } @@ -51,7 +52,10 @@ final class TicketService $this->attachments->storeMany($ticket, $files, (int) $user->id); - return $ticket->fresh(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team', 'attachments']) ?? $ticket; + $ticket = $ticket->fresh(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team', 'attachments']) ?? $ticket; + $this->workflow->dispatch($ticket, 'ticket.created', $user); + + return $ticket; }); } @@ -65,12 +69,12 @@ final class TicketService * @param array $files * @return Comment */ - public function comment(Ticket $ticket, User $user, string $body, bool $internal = false, array $files = []): Comment + public function comment(Ticket $ticket, User $user, string $body, bool $internal = false, array $files = [], ?int $parentId = null): Comment { - return DB::transaction(function () use ($ticket, $user, $body, $internal, $files): Comment { + return DB::transaction(function () use ($ticket, $user, $body, $internal, $files, $parentId): Comment { /** @var Comment $comment */ $comment = $ticket->comments()->create([ - 'company_id' => $data['company_id'] ?? ($user->company_id ?? null), + 'parent_id' => $parentId, 'user_id' => $user->id, 'body' => $body, 'is_internal' => $internal, @@ -78,6 +82,7 @@ final class TicketService $ticket->forceFill(['last_activity_at' => now()])->save(); $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); return $comment->fresh(['user', 'attachments']) ?? $comment; }); @@ -100,6 +105,9 @@ final class TicketService 'last_activity_at' => now(), ])->save(); - return $ticket->fresh(['status']) ?? $ticket; + $ticket = $ticket->fresh(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team']) ?? $ticket; + $this->workflow->dispatch($ticket, 'ticket.closed', $user); + + return $ticket; } } diff --git a/Modules/Helpdesk/Services/WorkflowNotificationService.php b/Modules/Helpdesk/Services/WorkflowNotificationService.php new file mode 100644 index 00000000..710cc9b4 --- /dev/null +++ b/Modules/Helpdesk/Services/WorkflowNotificationService.php @@ -0,0 +1,121 @@ +loadMissing(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team']); + + WorkflowTemplate::query() + ->where('event', $event) + ->where('is_active', true) + ->get() + ->each(function (WorkflowTemplate $template) use ($ticket, $event, $actor): void { + $recipient = $this->recipient($template->to_role, $ticket, $actor); + + if ($recipient === null || blank($recipient->email ?? null)) { + $this->log($template, $ticket, $event, null, 'skipped', 'No recipient email was available.'); + return; + } + + $subject = $this->renderText($template->subject, $ticket, $actor); + $html = $this->wrapHtml($this->renderText($template->html_body, $ticket, $actor), $ticket); + $text = $this->renderText($template->text_body ?: strip_tags($template->html_body), $ticket, $actor); + + try { + Mail::html($html, function ($message) use ($recipient, $template, $subject): void { + $message->to($recipient->email, $recipient->name ?? null)->subject($subject); + + if (filled($template->cc)) { + $message->cc(array_map('trim', explode(',', (string) $template->cc))); + } + + if (filled($template->bcc)) { + $message->bcc(array_map('trim', explode(',', (string) $template->bcc))); + } + }); + + $this->log($template, $ticket, $event, (string) $recipient->email, 'sent', null, $subject); + } 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); + } + }); + } + + /** @return User|null */ + private function recipient(string $role, Ticket $ticket, ?User $actor): ?User + { + return match ($role) { + 'agent' => $ticket->agent, + 'actor' => $actor, + default => $ticket->owner, + }; + } + + private function renderText(string $template, Ticket $ticket, ?User $actor): string + { + $tokens = [ + '{{ 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()]), + ]; + + return strtr($template, $tokens); + } + + private function wrapHtml(string $body, Ticket $ticket): string + { + return '
' + . '
' + . '
' + . '
Helpdesk notification
' + . '

' . e($ticket->reference) . '

' + . '

' . e($ticket->subject) . '

' + . '
' + . $body + . '

Open ticket

' + . '
'; + } + + private function log(WorkflowTemplate $template, Ticket $ticket, string $event, ?string $recipient, string $status, ?string $error = null, ?string $subject = null): void + { + WorkflowLog::query()->create([ + 'template_id' => $template->id, + 'ticket_id' => $ticket->id, + 'event' => $event, + 'recipient' => $recipient, + 'subject' => $subject, + 'status' => $status, + 'error' => $error, + ]); + } +} diff --git a/Modules/Helpdesk/Tests/Browser/HelpdeskBrowserTest.php b/Modules/Helpdesk/Tests/Browser/HelpdeskBrowserTest.php deleted file mode 100644 index 9131697d..00000000 --- a/Modules/Helpdesk/Tests/Browser/HelpdeskBrowserTest.php +++ /dev/null @@ -1,36 +0,0 @@ -create(['name' => 'Open', 'slug' => 'open', 'color' => 'blue']); - Priority::query()->create(['name' => 'Normal', 'slug' => 'normal', 'color' => 'blue']); - - $user = User::factory()->create(); - - $this->browse(function (Browser $browser) use ($user): void { - $browser->loginAs($user) - ->visitRoute('helpdesk.tickets.index') - ->waitFor('@helpdesk-ticket-index') - ->assertSee('Helpdesk') - ->clickLink('New ticket') - ->waitFor('@helpdesk-ticket-create') - ->assertSee('Create ticket'); - }); - } -} diff --git a/Modules/Helpdesk/Tests/Browser/HelpdeskKanbanBrowserTest.php b/Modules/Helpdesk/Tests/Browser/HelpdeskKanbanBrowserTest.php new file mode 100644 index 00000000..96fe19e1 --- /dev/null +++ b/Modules/Helpdesk/Tests/Browser/HelpdeskKanbanBrowserTest.php @@ -0,0 +1,39 @@ +seedLookupData(); + $user = $this->admin(); + + $this->ticket([ + 'owner' => $user, + 'status_id' => $lookups['open']->id, + 'priority_id' => $lookups['high']->id, + 'subject' => 'Kanban browser ticket', + ]); + + $this->browse(function (Browser $browser) use ($user): void { + $browser->loginAs($user) + ->visitRoute('helpdesk.kanban') + ->assertPresent('@helpdesk-kanban-board') + ->assertSee('Kanban board') + ->assertSee('Kanban browser ticket') + ->assertPresent('@kanban-column-open'); + }); + } +} diff --git a/Modules/Helpdesk/Tests/Browser/HelpdeskNavigationBrowserTest.php b/Modules/Helpdesk/Tests/Browser/HelpdeskNavigationBrowserTest.php new file mode 100644 index 00000000..ef9be955 --- /dev/null +++ b/Modules/Helpdesk/Tests/Browser/HelpdeskNavigationBrowserTest.php @@ -0,0 +1,45 @@ +seedLookupData(); + $user = $this->admin(['name' => 'Mark Gregory']); + + $this->browse(function (Browser $browser) use ($user): void { + $browser->loginAs($user) + ->visitRoute('helpdesk.index') + ->waitFor('@helpdesk-dashboard-widget') + ->assertSee('Helpdesk dashboard') + ->assertSee('Total tickets') + ->assertSee('Open tickets') + ->assertSee('Closed tickets') + ->clickLink('Tickets') + ->waitFor('@helpdesk-ticket-index') + ->assertSee('Active tickets') + ->clickLink('New ticket') + ->waitFor('@helpdesk-ticket-create') + ->assertSee('Create ticket') + ->visitRoute('helpdesk.settings') + ->waitFor('@helpdesk-settings-manager') + ->assertSee('Support configuration') + ->assertSee('Statuses') + ->assertSee('Priorities') + ->assertSee('Projects') + ->assertSee('Workflow'); + }); + } +} diff --git a/Modules/Helpdesk/Tests/Browser/HelpdeskSettingsBrowserTest.php b/Modules/Helpdesk/Tests/Browser/HelpdeskSettingsBrowserTest.php new file mode 100644 index 00000000..1ce9de41 --- /dev/null +++ b/Modules/Helpdesk/Tests/Browser/HelpdeskSettingsBrowserTest.php @@ -0,0 +1,42 @@ +seedLookupData(); + $admin = $this->admin(['name' => 'Settings Admin']); + + $this->browse(function (Browser $browser) use ($admin): void { + $browser->loginAs($admin) + ->visitRoute('helpdesk.settings') + ->waitFor('@helpdesk-settings-manager') + ->assertSee('Support configuration') + ->assertSee('Statuses') + ->assertSee('Priorities') + ->assertSee('Categories') + ->assertSee('Teams') + ->assertSee('Projects') + ->assertSee('Git') + ->assertSee('Workflow') + ->press('Git') + ->waitForText('Git repositories') + ->assertSee('Remote URL') + ->press('Workflow') + ->waitForText('Workflow messages') + ->assertSee('HTML email body'); + }); + } +} diff --git a/Modules/Helpdesk/Tests/Browser/HelpdeskTicketBrowserTest.php b/Modules/Helpdesk/Tests/Browser/HelpdeskTicketBrowserTest.php new file mode 100644 index 00000000..778fa650 --- /dev/null +++ b/Modules/Helpdesk/Tests/Browser/HelpdeskTicketBrowserTest.php @@ -0,0 +1,59 @@ +seedLookupData(); + $user = $this->user(['name' => 'Ticket Browser User']); + + $this->browse(function (Browser $browser) use ($user, $fixtures): void { + $browser->loginAs($user) + ->visitRoute('helpdesk.tickets.create') + ->waitFor('@helpdesk-ticket-create') + ->type('@ticket-subject', 'Browser created helpdesk ticket') + ->script("document.querySelector('[dusk=\"ticket-body-editor\"] [contenteditable=\"true\"]').innerHTML = '

This browser ticket proves the form saves correctly.

'; document.querySelector('[dusk=\"ticket-body-editor\"] [contenteditable=\"true\"]').dispatchEvent(new Event('input', { bubbles: true }));"); + + $browser->select('@ticket-priority', (string) $fixtures['high']->id) + ->select('@ticket-category', (string) $fixtures['category']->id) + ->select('@ticket-team', (string) $fixtures['team']->id) + ->select('@ticket-project', (string) $fixtures['project']->id) + ->press('@save-ticket') + ->waitFor('@helpdesk-ticket-show') + ->assertSee('Browser created helpdesk ticket') + ->assertSee('High') + ->assertSee('Support') + ->assertSee('Ticket details'); + }); + } + + public function test_user_can_add_nested_response_in_browser(): void + { + $fixtures = $this->seedLookupData(); + $user = $this->user(['name' => 'Response Browser User']); + $ticket = $this->ticket(['owner' => $user, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id, 'subject' => 'Browser response ticket']); + + $this->browse(function (Browser $browser) use ($user, $ticket): void { + $browser->loginAs($user) + ->visitRoute('helpdesk.tickets.show', $ticket) + ->waitFor('@helpdesk-ticket-show') + ->script("document.querySelector('[dusk=\"ticket-comment-editor\"] [contenteditable=\"true\"]').innerHTML = '

First browser response

'; document.querySelector('[dusk=\"ticket-comment-editor\"] [contenteditable=\"true\"]').dispatchEvent(new Event('input', { bubbles: true }));"); + + $browser->press('@post-comment') + ->waitForText('First browser response') + ->assertSee('First browser response'); + }); + } +} diff --git a/Modules/Helpdesk/Tests/Concerns/BuildsHelpdeskFixtures.php b/Modules/Helpdesk/Tests/Concerns/BuildsHelpdeskFixtures.php new file mode 100644 index 00000000..baabb7a5 --- /dev/null +++ b/Modules/Helpdesk/Tests/Concerns/BuildsHelpdeskFixtures.php @@ -0,0 +1,188 @@ +create(array_merge([ + 'name' => 'Helpdesk User', + 'email' => fake()->unique()->safeEmail(), + 'password' => Hash::make('password'), + ], $attributes)); + + return $user; + } + + protected function agent(array $attributes = []): User + { + $user = $this->user($attributes); + $user->forceFill(['helpdesk_agent' => true])->save(); + + return $user; + } + + protected function admin(array $attributes = []): User + { + $user = $this->user($attributes); + $user->forceFill(['helpdesk_agent' => true, 'helpdesk_admin' => true])->save(); + + return $user; + } + + protected function status(array $attributes = []): Status + { + return Status::query()->create(array_merge([ + 'name' => 'Open', + 'slug' => fake()->unique()->slug(2), + 'color' => 'blue', + 'sort_order' => 10, + 'is_closed' => false, + ], $attributes)); + } + + protected function closedStatus(array $attributes = []): Status + { + return $this->status(array_merge([ + 'name' => 'Closed', + 'slug' => fake()->unique()->slug(2), + 'color' => 'emerald', + 'sort_order' => 99, + 'is_closed' => true, + ], $attributes)); + } + + protected function priority(array $attributes = []): Priority + { + return Priority::query()->create(array_merge([ + 'name' => 'Normal', + 'slug' => fake()->unique()->slug(2), + 'color' => 'blue', + 'sort_order' => 10, + ], $attributes)); + } + + protected function category(array $attributes = []): Category + { + return Category::query()->create(array_merge([ + 'company_id' => null, + 'name' => 'Support', + 'slug' => fake()->unique()->slug(2), + 'color' => 'blue', + 'description' => 'Support requests', + ], $attributes)); + } + + protected function team(array $attributes = []): Team + { + return Team::query()->create(array_merge([ + 'company_id' => null, + 'name' => 'Support Team', + 'slug' => fake()->unique()->slug(2), + 'color' => 'indigo', + 'description' => 'Primary support team', + ], $attributes)); + } + + protected function project(array $attributes = []): Project + { + return Project::query()->create(array_merge([ + 'company_id' => null, + 'team_id' => null, + 'title' => 'WMS Project', + 'slug' => fake()->unique()->slug(2), + 'color' => 'blue', + 'description' => 'Warehouse platform project', + ], $attributes)); + } + + protected function repository(array $attributes = []): GitRepository + { + return GitRepository::query()->create(array_merge([ + 'company_id' => null, + 'project_id' => null, + 'name' => 'Core', + 'provider' => 'git', + 'remote_url' => 'git@example.com:wms/core.git', + 'default_branch' => 'develop', + 'webhook_secret' => 'test-secret', + 'is_active' => true, + ], $attributes)); + } + + protected function ticket(array $attributes = []): Ticket + { + $owner = $attributes['owner'] ?? $this->user(['name' => 'Requester']); + $agent = $attributes['agent'] ?? null; + unset($attributes['owner'], $attributes['agent']); + + return Ticket::query()->create(array_merge([ + 'reference' => 'HD-' . now()->format('Y') . '-' . str_pad((string) random_int(1, 99999), 5, '0', STR_PAD_LEFT), + 'user_id' => $owner->id, + 'agent_id' => $agent?->id, + 'status_id' => $this->status()->id, + 'priority_id' => $this->priority()->id, + 'category_id' => null, + 'project_id' => null, + 'team_id' => null, + 'subject' => 'Test ticket subject', + 'body' => '

Detailed helpdesk ticket body.

', + 'last_activity_at' => now(), + ], $attributes)); + } + + protected function seedLookupData(): array + { + $open = $this->status(['name' => 'Open', 'slug' => 'open']); + $closed = $this->closedStatus(['name' => 'Closed', 'slug' => 'closed']); + $normal = $this->priority(['name' => 'Normal', 'slug' => 'normal']); + $high = $this->priority(['name' => 'High', 'slug' => 'high', 'color' => 'red', 'sort_order' => 1]); + $category = $this->category(['name' => 'Support', 'slug' => 'support']); + $team = $this->team(['name' => 'Support Team', 'slug' => 'support-team']); + $project = $this->project(['title' => 'WMS Core', 'slug' => 'wms-core', 'team_id' => $team->id]); + + return compact('open', 'closed', 'normal', 'high', 'category', 'team', 'project'); + } + + protected function ensureCompanyTableHasOne(): ?int + { + if (! Schema::hasTable('companies')) { + return null; + } + + $columns = Schema::getColumnListing('companies'); + $payload = []; + + foreach ($columns as $column) { + if (in_array($column, ['id', 'created_at', 'updated_at', 'deleted_at'], true)) { + continue; + } + + if ($column === 'name') { + $payload[$column] = 'MPBA Test Company'; + } + } + + if (! array_key_exists('name', $payload) && in_array('company_name', $columns, true)) { + $payload['company_name'] = 'MPBA Test Company'; + } + + return (int) DB::table('companies')->insertGetId($payload); + } +} diff --git a/Modules/Helpdesk/Tests/Feature/AgentAssignmentTest.php b/Modules/Helpdesk/Tests/Feature/AgentAssignmentTest.php new file mode 100644 index 00000000..8f33dfa7 --- /dev/null +++ b/Modules/Helpdesk/Tests/Feature/AgentAssignmentTest.php @@ -0,0 +1,42 @@ +seedLookupData(); + $busy = $this->agent(['name' => 'Busy Agent']); + $quiet = $this->agent(['name' => 'Quiet Agent']); + $this->ticket(['agent' => $busy, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id]); + $this->ticket(['agent' => $busy, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id]); + + $selected = app(AgentAssignmentService::class)->selectAgentId(null); + + self::assertSame($quiet->id, $selected); + } + + public function test_assignment_prefers_category_agents_when_category_has_agents(): void + { + $fixtures = $this->seedLookupData(); + $generalAgent = $this->agent(['name' => 'General Agent']); + $categoryAgent = $this->agent(['name' => 'Category Agent']); + $fixtures['category']->agents()->attach($categoryAgent->id); + $this->ticket(['agent' => $generalAgent, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id]); + + $selected = app(AgentAssignmentService::class)->selectAgentId($fixtures['category']->id); + + self::assertSame($categoryAgent->id, $selected); + } +} diff --git a/Modules/Helpdesk/Tests/Feature/GitAndAttachmentTest.php b/Modules/Helpdesk/Tests/Feature/GitAndAttachmentTest.php new file mode 100644 index 00000000..53f41e5e --- /dev/null +++ b/Modules/Helpdesk/Tests/Feature/GitAndAttachmentTest.php @@ -0,0 +1,69 @@ +seedLookupData(); + $repository = $this->repository(['project_id' => $fixtures['project']->id]); + $ticket = $this->ticket(['project_id' => $fixtures['project']->id]); + + GitCommit::query()->create([ + 'repository_id' => $repository->id, + 'ticket_id' => $ticket->id, + 'hash' => str_repeat('b', 40), + 'short_hash' => 'bbbbbbb', + 'branch' => 'develop', + 'author_name' => 'Mark Gregory', + 'author_email' => 'mark@example.com', + 'message' => 'Resolve ' . $ticket->reference . ' ticket save redirect', + 'url' => 'https://example.com/commit/b', + 'committed_at' => now(), + ]); + + $this->assertDatabaseHas('helpdesk_git_commits', [ + 'repository_id' => $repository->id, + 'ticket_id' => $ticket->id, + 'short_hash' => 'bbbbbbb', + ]); + + self::assertSame($ticket->id, $repository->commits()->firstOrFail()->ticket_id); + } + + public function test_attachment_service_stores_and_deletes_ticket_files(): void + { + Storage::fake('local'); + config()->set('helpdesk.disk', 'local'); + + $user = $this->user(); + $ticket = $this->ticket(['owner' => $user]); + $file = UploadedFile::fake()->create('evidence.pdf', 32, 'application/pdf'); + + app(AttachmentService::class)->storeMany($ticket, [$file], $user->id); + + $attachment = Attachment::query()->where('ticket_id', $ticket->id)->firstOrFail(); + Storage::disk('local')->assertExists($attachment->path); + self::assertSame('evidence.pdf', $attachment->original_name); + + app(AttachmentService::class)->delete($attachment); + + Storage::disk('local')->assertMissing($attachment->path); + $this->assertDatabaseMissing('helpdesk_attachments', ['id' => $attachment->id]); + } +} diff --git a/Modules/Helpdesk/Tests/Feature/HelpdeskRouteTest.php b/Modules/Helpdesk/Tests/Feature/HelpdeskRouteTest.php new file mode 100644 index 00000000..7a23615c --- /dev/null +++ b/Modules/Helpdesk/Tests/Feature/HelpdeskRouteTest.php @@ -0,0 +1,33 @@ +seedLookupData(); + $user = $this->admin(); + + $this->actingAs($user)->get(route('helpdesk.index'))->assertOk()->assertSee('Helpdesk', false); + $this->actingAs($user)->get(route('helpdesk.tickets.index'))->assertOk()->assertSee('Active tickets', false); + $this->actingAs($user)->get(route('helpdesk.tickets.create'))->assertOk()->assertSee('Create ticket', false); + $this->actingAs($user)->get(route('helpdesk.settings'))->assertOk()->assertSee('Support configuration', false); + } + + public function test_guest_is_redirected_from_helpdesk_pages_when_application_uses_auth_middleware(): void + { + $response = $this->get(route('helpdesk.tickets.index')); + + self::assertTrue(in_array($response->getStatusCode(), [200, 302], true)); + } +} diff --git a/Modules/Helpdesk/Tests/Feature/HelpdeskTicketTest.php b/Modules/Helpdesk/Tests/Feature/HelpdeskTicketTest.php deleted file mode 100644 index a00137eb..00000000 --- a/Modules/Helpdesk/Tests/Feature/HelpdeskTicketTest.php +++ /dev/null @@ -1,40 +0,0 @@ -create(['name' => 'Open', 'slug' => 'open', 'color' => 'blue']); - Priority::query()->create(['name' => 'Normal', 'slug' => 'normal', 'color' => 'blue']); - - $user = User::factory()->create(); - - Livewire::actingAs($user) - ->test(TicketCreate::class) - ->set('subject', 'Printer is not working') - ->set('body', 'The office printer is showing an error code and will not print.') - ->set('priority_id', Priority::query()->firstOrFail()->id) - ->call('save') - ->assertHasNoErrors(); - - $this->assertDatabaseHas('helpdesk_tickets', [ - 'subject' => 'Printer is not working', - 'user_id' => $user->id, - ]); - } -} diff --git a/Modules/Helpdesk/Tests/Feature/KanbanBoardTest.php b/Modules/Helpdesk/Tests/Feature/KanbanBoardTest.php new file mode 100644 index 00000000..7d311355 --- /dev/null +++ b/Modules/Helpdesk/Tests/Feature/KanbanBoardTest.php @@ -0,0 +1,63 @@ +seedLookupData(); + $user = $this->admin(); + + $ticket = $this->ticket([ + 'owner' => $user, + 'status_id' => $lookups['open']->id, + 'priority_id' => $lookups['high']->id, + 'subject' => 'Kanban visible ticket', + ]); + + $this->actingAs($user); + + Livewire::test(KanbanBoard::class) + ->assertSee('Kanban board') + ->assertSee($ticket->reference) + ->assertSee('Kanban visible ticket') + ->assertSee('High'); + } + + /** @test */ + public function kanban_board_can_move_ticket_to_closed_status(): void + { + $lookups = $this->seedLookupData(); + $user = $this->admin(); + + $ticket = $this->ticket([ + 'owner' => $user, + 'status_id' => $lookups['open']->id, + 'priority_id' => $lookups['normal']->id, + ]); + + $this->actingAs($user); + + Livewire::test(KanbanBoard::class) + ->call('moveTicket', $ticket->id, $lookups['closed']->id) + ->assertHasNoErrors(); + + $ticket->refresh(); + + $this->assertSame($lookups['closed']->id, $ticket->status_id); + $this->assertNotNull($ticket->completed_at); + } +} diff --git a/Modules/Helpdesk/Tests/Feature/SettingsCrudTest.php b/Modules/Helpdesk/Tests/Feature/SettingsCrudTest.php new file mode 100644 index 00000000..761e9712 --- /dev/null +++ b/Modules/Helpdesk/Tests/Feature/SettingsCrudTest.php @@ -0,0 +1,142 @@ +admin(); + + $component = Livewire::actingAs($admin) + ->test(SettingsManager::class) + ->set('statusForm.name', 'Awaiting Customer') + ->set('statusForm.color', 'amber') + ->set('statusForm.sort_order', 20) + ->set('statusForm.is_closed', false) + ->call('saveStatus') + ->assertHasNoErrors(); + + $status = Status::query()->where('slug', 'awaiting-customer')->firstOrFail(); + + $component->call('editStatus', $status->id) + ->set('statusForm.name', 'Waiting Customer') + ->call('saveStatus') + ->assertHasNoErrors(); + + $this->assertDatabaseHas('helpdesk_statuses', ['id' => $status->id, 'name' => 'Waiting Customer']); + + $component->call('deleteStatus', $status->id); + $this->assertDatabaseMissing('helpdesk_statuses', ['id' => $status->id]); + } + + public function test_settings_manager_creates_lookup_project_team_repository_and_workflow_records(): void + { + $admin = $this->admin(); + + Livewire::actingAs($admin) + ->test(SettingsManager::class) + ->set('priorityForm.name', 'Critical') + ->set('priorityForm.color', 'red') + ->set('priorityForm.sort_order', 1) + ->call('savePriority') + ->set('categoryForm.name', 'Bug Fix') + ->set('categoryForm.color', 'rose') + ->set('categoryForm.description', 'Production defects') + ->call('saveCategory') + ->set('teamForm.name', 'Platform Support') + ->set('teamForm.color', 'indigo') + ->set('teamForm.description', 'Platform team') + ->call('saveTeam') + ->set('projectForm.title', 'Core WMS') + ->set('projectForm.team_id', Team::query()->where('slug', 'platform-support')->firstOrFail()->id) + ->set('projectForm.color', 'blue') + ->set('projectForm.description', 'Core platform') + ->call('saveProject') + ->set('repositoryForm.name', 'WMS Core') + ->set('repositoryForm.project_id', Project::query()->where('slug', 'core-wms')->firstOrFail()->id) + ->set('repositoryForm.provider', 'github') + ->set('repositoryForm.remote_url', 'git@github.com:mpba/wms-core.git') + ->set('repositoryForm.default_branch', 'develop') + ->set('repositoryForm.webhook_secret', 'secret') + ->call('saveRepository') + ->set('workflowForm.name', 'Ticket Raised') + ->set('workflowForm.event', 'ticket.created') + ->set('workflowForm.subject', 'Ticket {{ ticket.reference }} created') + ->set('workflowForm.html_body', '

Hello {{ requester.name }}

') + ->set('workflowForm.text_body', 'Hello {{ requester.name }}') + ->set('workflowForm.to_role', 'requester') + ->call('saveWorkflowTemplate') + ->assertHasNoErrors(); + + $this->assertDatabaseHas('helpdesk_priorities', ['slug' => 'critical']); + $this->assertDatabaseHas('helpdesk_categories', ['slug' => 'bug-fix']); + $this->assertDatabaseHas('helpdesk_teams', ['slug' => 'platform-support']); + $this->assertDatabaseHas('helpdesk_projects', ['slug' => 'core-wms']); + $this->assertDatabaseHas('helpdesk_git_repositories', ['name' => 'WMS Core', 'provider' => 'github']); + $this->assertDatabaseHas('helpdesk_workflow_templates', ['name' => 'Ticket Raised', 'event' => 'ticket.created']); + } + + public function test_settings_manager_can_edit_and_delete_all_new_setup_records(): void + { + $admin = $this->admin(); + $priority = $this->priority(['name' => 'Low', 'slug' => 'low']); + $category = $this->category(['name' => 'Support', 'slug' => 'support']); + $team = $this->team(['name' => 'First Line', 'slug' => 'first-line']); + $project = $this->project(['title' => 'Portal', 'slug' => 'portal', 'team_id' => $team->id]); + $repository = $this->repository(['project_id' => $project->id, 'name' => 'Portal Repo']); + $template = WorkflowTemplate::query()->create([ + 'name' => 'Closed Message', + 'event' => 'ticket.closed', + 'subject' => 'Closed {{ ticket.reference }}', + 'html_body' => '

Closed

', + 'text_body' => 'Closed', + 'to_role' => 'requester', + 'is_active' => true, + ]); + + $component = Livewire::actingAs($admin)->test(SettingsManager::class); + + $component->call('editPriority', $priority->id)->set('priorityForm.name', 'Low Priority')->call('savePriority'); + $component->call('editCategory', $category->id)->set('categoryForm.name', 'Customer Support')->call('saveCategory'); + $component->call('editTeam', $team->id)->set('teamForm.name', 'First Line Support')->call('saveTeam'); + $component->call('editProject', $project->id)->set('projectForm.title', 'Customer Portal')->call('saveProject'); + $component->call('editRepository', $repository->id)->set('repositoryForm.name', 'Customer Portal Repo')->call('saveRepository'); + $component->call('editWorkflowTemplate', $template->id)->set('workflowForm.name', 'Ticket Closed Message')->call('saveWorkflowTemplate'); + + $this->assertDatabaseHas('helpdesk_priorities', ['id' => $priority->id, 'name' => 'Low Priority']); + $this->assertDatabaseHas('helpdesk_categories', ['id' => $category->id, 'name' => 'Customer Support']); + $this->assertDatabaseHas('helpdesk_teams', ['id' => $team->id, 'name' => 'First Line Support']); + $this->assertDatabaseHas('helpdesk_projects', ['id' => $project->id, 'title' => 'Customer Portal']); + $this->assertDatabaseHas('helpdesk_git_repositories', ['id' => $repository->id, 'name' => 'Customer Portal Repo']); + $this->assertDatabaseHas('helpdesk_workflow_templates', ['id' => $template->id, 'name' => 'Ticket Closed Message']); + + $component->call('deletePriority', $priority->id) + ->call('deleteCategory', $category->id) + ->call('deleteRepository', $repository->id) + ->call('deleteWorkflowTemplate', $template->id); + + self::assertNull(Priority::query()->find($priority->id)); + self::assertNull(Category::query()->find($category->id)); + self::assertNull(GitRepository::query()->find($repository->id)); + self::assertNull(WorkflowTemplate::query()->find($template->id)); + } +} diff --git a/Modules/Helpdesk/Tests/Feature/TicketConversationTest.php b/Modules/Helpdesk/Tests/Feature/TicketConversationTest.php new file mode 100644 index 00000000..3eb5cb8b --- /dev/null +++ b/Modules/Helpdesk/Tests/Feature/TicketConversationTest.php @@ -0,0 +1,104 @@ +seedLookupData(); + $owner = $this->user(); + $ticket = $this->ticket(['owner' => $owner, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id]); + + Livewire::actingAs($owner) + ->test(TicketShow::class, ['ticket' => $ticket]) + ->set('commentBody', '

Please can you check this update?

') + ->call('addComment') + ->assertHasNoErrors() + ->assertDispatched('helpdesk-comment-added'); + + $this->assertDatabaseHas('helpdesk_comments', [ + 'ticket_id' => $ticket->id, + 'parent_id' => null, + 'user_id' => $owner->id, + 'is_internal' => false, + ]); + } + + public function test_comment_can_have_a_nested_reply(): void + { + $fixtures = $this->seedLookupData(); + $owner = $this->user(); + $ticket = $this->ticket(['owner' => $owner, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id]); + $comment = Comment::query()->create([ + 'ticket_id' => $ticket->id, + 'user_id' => $owner->id, + 'body' => '

Parent response

', + 'is_internal' => false, + ]); + + Livewire::actingAs($owner) + ->test(TicketShow::class, ['ticket' => $ticket]) + ->call('startReply', $comment->id) + ->set('replyBody', '

Nested reply body

') + ->call('addReply') + ->assertHasNoErrors() + ->assertDispatched('helpdesk-reply-added'); + + $this->assertDatabaseHas('helpdesk_comments', [ + 'ticket_id' => $ticket->id, + 'parent_id' => $comment->id, + 'body' => '

Nested reply body

', + ]); + } + + public function test_agent_can_update_status_to_closed_and_priority(): void + { + Mail::fake(); + $fixtures = $this->seedLookupData(); + $owner = $this->user(); + $agent = $this->agent(); + $ticket = $this->ticket(['owner' => $owner, 'agent' => $agent, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id]); + + Livewire::actingAs($agent) + ->test(TicketShow::class, ['ticket' => $ticket]) + ->set('status_id', $fixtures['closed']->id) + ->call('updateStatus') + ->assertHasNoErrors() + ->assertDispatched('helpdesk-status-updated') + ->set('priority_id', $fixtures['high']->id) + ->call('updatePriority') + ->assertHasNoErrors() + ->assertDispatched('helpdesk-priority-updated'); + + $ticket->refresh(); + self::assertNotNull($ticket->completed_at); + self::assertSame($fixtures['closed']->id, $ticket->status_id); + self::assertSame($fixtures['high']->id, $ticket->priority_id); + } + + public function test_unassigned_agent_cannot_update_another_users_ticket(): void + { + $fixtures = $this->seedLookupData(); + $owner = $this->user(); + $agent = $this->agent(); + $ticket = $this->ticket(['owner' => $owner, 'agent_id' => null, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id]); + + Livewire::actingAs($agent) + ->test(TicketShow::class, ['ticket' => $ticket]) + ->assertForbidden(); + } +} diff --git a/Modules/Helpdesk/Tests/Feature/TicketCrudTest.php b/Modules/Helpdesk/Tests/Feature/TicketCrudTest.php new file mode 100644 index 00000000..7b6c4dd4 --- /dev/null +++ b/Modules/Helpdesk/Tests/Feature/TicketCrudTest.php @@ -0,0 +1,131 @@ +seedLookupData(); + $user = $this->user(['name' => 'Mark Requester']); + + Livewire::actingAs($user) + ->test(TicketCreate::class) + ->set('subject', 'Warehouse printer does not print labels') + ->set('body', '

The printer fails every time a dispatch label is generated.

') + ->set('priority_id', $fixtures['high']->id) + ->set('category_id', $fixtures['category']->id) + ->set('team_id', $fixtures['team']->id) + ->set('project_id', $fixtures['project']->id) + ->call('save') + ->assertHasNoErrors() + ->assertRedirect(); + + $this->assertDatabaseHas('helpdesk_tickets', [ + 'user_id' => $user->id, + 'subject' => 'Warehouse printer does not print labels', + 'priority_id' => $fixtures['high']->id, + 'category_id' => $fixtures['category']->id, + 'team_id' => $fixtures['team']->id, + 'project_id' => $fixtures['project']->id, + ]); + } + + public function test_ticket_create_validates_required_fields(): void + { + $this->seedLookupData(); + $user = $this->user(); + + Livewire::actingAs($user) + ->test(TicketCreate::class) + ->call('save') + ->assertHasErrors(['subject' => 'required', 'body' => 'required', 'priority_id' => 'required']); + } + + public function test_ticket_index_shows_only_visible_tickets_for_standard_user(): void + { + $fixtures = $this->seedLookupData(); + $owner = $this->user(['name' => 'Owner User']); + $other = $this->user(['name' => 'Other User']); + $visible = $this->ticket(['owner' => $owner, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id, 'subject' => 'Visible ticket']); + $this->ticket(['owner' => $other, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id, 'subject' => 'Hidden ticket']); + + Livewire::actingAs($owner) + ->test(TicketIndex::class) + ->assertSee($visible->reference) + ->assertSee('Visible ticket') + ->assertDontSee('Hidden ticket'); + } + + public function test_agent_sees_assigned_ticket_and_admin_sees_all_tickets(): void + { + $fixtures = $this->seedLookupData(); + $agent = $this->agent(['name' => 'Assigned Agent']); + $admin = $this->admin(['name' => 'Admin User']); + $owner = $this->user(); + $assigned = $this->ticket(['owner' => $owner, 'agent' => $agent, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id, 'subject' => 'Assigned ticket']); + $unassigned = $this->ticket(['owner' => $this->user(), 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id, 'subject' => 'Another ticket']); + + Livewire::actingAs($agent) + ->test(TicketIndex::class) + ->assertSee($assigned->reference) + ->assertDontSee($unassigned->reference); + + Livewire::actingAs($admin) + ->test(TicketIndex::class) + ->assertSee($assigned->reference) + ->assertSee($unassigned->reference); + } + + public function test_index_filters_by_search_priority_and_closed_scope(): void + { + $fixtures = $this->seedLookupData(); + $admin = $this->admin(); + $openTicket = $this->ticket(['owner' => $admin, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['high']->id, 'subject' => 'API sync failure']); + $closedTicket = $this->ticket(['owner' => $admin, 'status_id' => $fixtures['closed']->id, 'priority_id' => $fixtures['normal']->id, 'completed_at' => now(), 'subject' => 'Old closed issue']); + + Livewire::actingAs($admin) + ->test(TicketIndex::class) + ->set('search', 'API sync') + ->set('priority', (string) $fixtures['high']->id) + ->assertSee($openTicket->reference) + ->assertDontSee($closedTicket->reference); + + Livewire::actingAs($admin) + ->test(TicketIndex::class) + ->set('scope', 'closed') + ->assertSee($closedTicket->reference) + ->assertDontSee($openTicket->reference); + } + + public function test_created_ticket_has_a_reference_and_last_activity_date(): void + { + $fixtures = $this->seedLookupData(); + $user = $this->user(); + + Livewire::actingAs($user) + ->test(TicketCreate::class) + ->set('subject', 'Reference check') + ->set('body', 'This ticket should have a generated reference.') + ->set('priority_id', $fixtures['normal']->id) + ->call('save'); + + $ticket = Ticket::query()->where('subject', 'Reference check')->firstOrFail(); + + self::assertStringStartsWith('HD-' . now()->format('Y') . '-', $ticket->reference); + self::assertNotNull($ticket->last_activity_at); + } +} diff --git a/Modules/Helpdesk/Tests/Feature/TicketPolicyTest.php b/Modules/Helpdesk/Tests/Feature/TicketPolicyTest.php new file mode 100644 index 00000000..b07a723d --- /dev/null +++ b/Modules/Helpdesk/Tests/Feature/TicketPolicyTest.php @@ -0,0 +1,47 @@ +user(); + $ticket = $this->ticket(['owner' => $owner]); + + self::assertTrue(Gate::forUser($owner)->allows('view', $ticket)); + self::assertTrue(Gate::forUser($owner)->allows('comment', $ticket)); + self::assertFalse(Gate::forUser($owner)->allows('update', $ticket)); + } + + public function test_assigned_agent_can_view_comment_and_update_ticket(): void + { + $owner = $this->user(); + $agent = $this->agent(); + $ticket = $this->ticket(['owner' => $owner, 'agent' => $agent]); + + self::assertTrue(Gate::forUser($agent)->allows('view', $ticket)); + self::assertTrue(Gate::forUser($agent)->allows('comment', $ticket)); + self::assertTrue(Gate::forUser($agent)->allows('update', $ticket)); + } + + public function test_helpdesk_admin_can_view_update_and_delete_any_ticket(): void + { + $admin = $this->admin(); + $ticket = $this->ticket(['owner' => $this->user()]); + + self::assertTrue(Gate::forUser($admin)->allows('view', $ticket)); + self::assertTrue(Gate::forUser($admin)->allows('update', $ticket)); + self::assertTrue(Gate::forUser($admin)->allows('delete', $ticket)); + } +} diff --git a/Modules/Helpdesk/Tests/Feature/WorkflowNotificationTest.php b/Modules/Helpdesk/Tests/Feature/WorkflowNotificationTest.php new file mode 100644 index 00000000..30dce636 --- /dev/null +++ b/Modules/Helpdesk/Tests/Feature/WorkflowNotificationTest.php @@ -0,0 +1,100 @@ +seedLookupData(); + $owner = $this->user(['name' => 'Requester Name', 'email' => 'requester@example.com']); + $agent = $this->agent(['name' => 'Agent Name', 'email' => 'agent@example.com']); + $ticket = $this->ticket([ + 'owner' => $owner, + 'agent' => $agent, + 'status_id' => $fixtures['open']->id, + 'priority_id' => $fixtures['high']->id, + 'category_id' => $fixtures['category']->id, + 'project_id' => $fixtures['project']->id, + 'team_id' => $fixtures['team']->id, + 'subject' => 'Workflow ticket subject', + ]); + + WorkflowTemplate::query()->create([ + 'name' => 'Ticket created requester email', + 'event' => 'ticket.created', + 'subject' => 'Ticket {{ ticket.reference }} raised for {{ requester.name }}', + 'html_body' => '

{{ ticket.subject }} / {{ ticket.priority }} / {{ ticket.status }}

', + 'text_body' => '{{ ticket.subject }}', + 'to_role' => 'requester', + 'is_active' => true, + ]); + + app(WorkflowNotificationService::class)->dispatch($ticket, 'ticket.created', $agent); + + $this->assertDatabaseHas('helpdesk_workflow_logs', [ + 'ticket_id' => $ticket->id, + 'event' => 'ticket.created', + 'recipient' => 'requester@example.com', + 'status' => 'sent', + 'subject' => 'Ticket ' . $ticket->reference . ' raised for Requester Name', + ]); + } + + public function test_inactive_workflow_template_is_not_dispatched(): void + { + Mail::fake(); + $ticket = $this->ticket(); + + WorkflowTemplate::query()->create([ + 'name' => 'Inactive template', + 'event' => 'ticket.updated', + 'subject' => 'Inactive', + 'html_body' => '

Inactive

', + 'text_body' => 'Inactive', + 'to_role' => 'requester', + 'is_active' => false, + ]); + + app(WorkflowNotificationService::class)->dispatch($ticket, 'ticket.updated', $ticket->owner); + + $this->assertDatabaseCount('helpdesk_workflow_logs', 0); + } + + public function test_workflow_logs_skipped_when_recipient_is_missing(): void + { + $ticket = $this->ticket(['agent_id' => null]); + + WorkflowTemplate::query()->create([ + 'name' => 'Agent template', + 'event' => 'ticket.updated', + 'subject' => 'Agent update', + 'html_body' => '

Agent update

', + 'text_body' => 'Agent update', + 'to_role' => 'agent', + 'is_active' => true, + ]); + + app(WorkflowNotificationService::class)->dispatch($ticket, 'ticket.updated', $ticket->owner); + + $this->assertDatabaseHas('helpdesk_workflow_logs', [ + 'ticket_id' => $ticket->id, + 'event' => 'ticket.updated', + 'recipient' => null, + 'status' => 'skipped', + ]); + } +} diff --git a/Modules/Helpdesk/Tests/Unit/ModelRelationshipTest.php b/Modules/Helpdesk/Tests/Unit/ModelRelationshipTest.php new file mode 100644 index 00000000..f229325b --- /dev/null +++ b/Modules/Helpdesk/Tests/Unit/ModelRelationshipTest.php @@ -0,0 +1,74 @@ +seedLookupData(); + $repository = $this->repository(['project_id' => $fixtures['project']->id]); + $ticket = $this->ticket([ + 'status_id' => $fixtures['open']->id, + 'priority_id' => $fixtures['high']->id, + 'category_id' => $fixtures['category']->id, + 'team_id' => $fixtures['team']->id, + 'project_id' => $fixtures['project']->id, + ]); + + GitCommit::query()->create([ + 'repository_id' => $repository->id, + 'ticket_id' => $ticket->id, + 'hash' => str_repeat('a', 40), + 'short_hash' => 'aaaaaaa', + 'branch' => 'develop', + 'author_name' => 'Developer', + 'author_email' => 'dev@example.com', + 'message' => 'Fix HD reference handling', + 'url' => 'https://example.com/commit/a', + 'committed_at' => now(), + ]); + + $ticket->refresh()->load(['status', 'priority', 'category', 'team', 'project', 'gitCommits']); + + self::assertSame('Open', $ticket->status?->name); + self::assertSame('High', $ticket->priority?->name); + self::assertSame('Support', $ticket->category?->name); + self::assertSame('Support Team', $ticket->team?->name); + self::assertSame('WMS Core', $ticket->project?->title); + self::assertCount(1, $ticket->gitCommits); + } + + public function test_comments_support_nested_replies(): void + { + $ticket = $this->ticket(); + $user = $this->user(); + $comment = Comment::query()->create([ + 'ticket_id' => $ticket->id, + 'user_id' => $user->id, + 'body' => '

Parent response

', + 'is_internal' => false, + ]); + Comment::query()->create([ + 'ticket_id' => $ticket->id, + 'parent_id' => $comment->id, + 'user_id' => $user->id, + 'body' => '

Nested response

', + 'is_internal' => false, + ]); + + self::assertSame('Nested response', strip_tags((string) $comment->replies()->firstOrFail()->body)); + self::assertCount(1, $ticket->comments()->with('replies')->get()); + } +} diff --git a/Modules/Helpdesk/Tests/Unit/TicketReferenceServiceTest.php b/Modules/Helpdesk/Tests/Unit/TicketReferenceServiceTest.php new file mode 100644 index 00000000..2199d2c0 --- /dev/null +++ b/Modules/Helpdesk/Tests/Unit/TicketReferenceServiceTest.php @@ -0,0 +1,32 @@ +next(); + + self::assertSame('HD-' . now()->format('Y') . '-00001', $reference); + } + + public function test_it_generates_the_next_reference_after_existing_tickets(): void + { + $this->ticket(['reference' => 'HD-' . now()->format('Y') . '-00041']); + + $reference = app(TicketReferenceService::class)->next(); + + self::assertSame('HD-' . now()->format('Y') . '-00042', $reference); + } +} diff --git a/Modules/Helpdesk/changelog.yaml b/Modules/Helpdesk/changelog.yaml index 7e47dd43..6ca969ac 100755 --- a/Modules/Helpdesk/changelog.yaml +++ b/Modules/Helpdesk/changelog.yaml @@ -8,4 +8,8 @@ versions: - Added Vite Support and removed webpack "1.0.2": changed: - - Added Trasm support and crud entries + - Stabilised the Livewire rich text editor so ticket details no longer clear during typing or submit early. + - Added Helpdesk Kanban board for workflow status movement. + - Added Kanban PHPUnit and Dusk coverage. + - Added Kanban navigation links to the dashboard and ticket list. + diff --git a/Modules/Helpdesk/module.json b/Modules/Helpdesk/module.json index 4328c8a5..90a5b3e1 100644 --- a/Modules/Helpdesk/module.json +++ b/Modules/Helpdesk/module.json @@ -13,5 +13,5 @@ "Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider" ], "files": [], - "version": "1.0.1" + "version": "1.0.2" } diff --git a/Modules/Helpdesk/package.json b/Modules/Helpdesk/package.json index b5b870d5..2e43a68c 100644 --- a/Modules/Helpdesk/package.json +++ b/Modules/Helpdesk/package.json @@ -6,10 +6,8 @@ "build": "vite build" }, "devDependencies": { - "@vitejs/plugin-vue": "latest", - "axios": "latest", - "laravel-vite-plugin": "latest", - "vite": "latest" - }, - "dependencies": {} + "laravel-vite-plugin": "^2.0.1", + "sass-embedded": "^1.89.2", + "vite": "^7.2.7" + } }