added jquery and also fixed fontawsome

This commit is contained in:
Mark Gregory
2026-06-15 15:33:54 +01:00
parent 58e5f2e203
commit 0b2d7225cc
150 changed files with 8507 additions and 168 deletions
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Services;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Modules\Helpdesk\Models\Category;
use Modules\Helpdesk\Models\Ticket;
final class AgentAssignmentService
{
/**
* Select the least-loaded available agent for a category.
*/
public function selectAgentId(?int $categoryId): ?int
{
$query = User::query()
->select('users.*')
->where(static function (Builder $query): void {
$query->where('helpdesk_agent', true)->orWhere('helpdesk_admin', true);
})
->selectSub(
Ticket::query()
->selectRaw('count(*)')
->whereColumn('helpdesk_tickets.agent_id', 'users.id')
->whereNull('helpdesk_tickets.completed_at'),
'open_helpdesk_tickets_count'
);
if ($categoryId !== null) {
$category = Category::query()->find($categoryId);
$agentIds = $category?->agents()->pluck('users.id')->all() ?? [];
if ($agentIds !== []) {
$query->whereIn('id', $agentIds);
}
}
/** @var User|null $agent */
$agent = $query->orderBy('open_helpdesk_tickets_count')->orderBy('id')->first();
return $agent?->id;
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Services;
use Illuminate\Support\Facades\Storage;
use Modules\Helpdesk\Models\Attachment;
use Modules\Helpdesk\Models\Comment;
use Modules\Helpdesk\Models\Ticket;
final class AttachmentService
{
public function storeMany(Ticket $ticket, array $files, int $userId, ?Comment $comment = null): void
{
foreach ($files as $file) {
$disk = (string) config('helpdesk.disk', 'public');
$originalName = $file->getClientOriginalName();
$mimeType = $file->getClientMimeType();
$size = (int) ($file->getSize() ?: 0);
$path = $file->store('tickets/'.$ticket->id, $disk);
if ($size === 0 && $path !== false) {
$size = (int) Storage::disk($disk)->size($path);
}
Attachment::query()->create([
'ticket_id' => $ticket->id,
'comment_id' => $comment?->id,
'user_id' => $userId,
'disk' => $disk,
'path' => $path,
'original_name' => $originalName,
'mime_type' => $mimeType,
'size' => $size,
]);
}
}
/**
* Delete a stored attachment and its backing file.
*/
public function delete(Attachment $attachment): void
{
Storage::disk($attachment->disk)->delete($attachment->path);
$attachment->delete();
}
}
@@ -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()]),
]);
}
}
+53
View File
@@ -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;
}
}
+103
View File
@@ -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();
}
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Services;
use Modules\Helpdesk\Models\Ticket;
final class TicketReferenceService
{
/**
* Build the next human-readable ticket reference.
*/
public function next(): string
{
$year = now()->format('Y');
$prefix = 'HD-'.$year.'-';
$last = Ticket::query()
->where('reference', 'like', $prefix.'%')
->latest('id')
->value('reference');
$next = $last ? ((int) substr((string) $last, -5)) + 1 : 1;
return $prefix.str_pad((string) $next, 5, '0', STR_PAD_LEFT);
}
}
+124
View File
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Services;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Modules\Helpdesk\Models\Comment;
use Modules\Helpdesk\Models\Status;
use Modules\Helpdesk\Models\Ticket;
final class TicketService
{
public function __construct(
private readonly TicketReferenceService $references,
private readonly AgentAssignmentService $assignment,
private readonly AttachmentService $attachments,
private readonly WorkflowNotificationService $workflow,
private readonly AutomationRuleService $automation,
private readonly SlaService $sla,
private readonly ChecklistService $checklists,
) {}
/**
* Create a ticket and store initial attachments.
*
* @param array<string, mixed> $data
* @param array<int, UploadedFile> $files
*/
public function create(User $user, array $data, array $files = []): Ticket
{
return DB::transaction(function () use ($user, $data, $files): Ticket {
$defaultStatusSlug = (string) config('helpdesk.default_status_slug', 'new');
$status = Status::query()->where('slug', $defaultStatusSlug)->first();
if (! $status) {
$status = Status::query()->create([
'name' => 'New',
'slug' => $defaultStatusSlug,
'color' => 'sky',
'sort_order' => 1,
'is_closed' => false,
]);
}
/** @var Ticket $ticket */
$ticket = Ticket::query()->create([
'reference' => $this->references->next(),
'company_id' => $data['company_id'] ?? ($user->company_id ?? null),
'user_id' => $user->id,
'agent_id' => $this->assignment->selectAgentId($data['category_id'] ?? null),
'status_id' => $status->id,
'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'],
'due_at' => $data['due_at'] ?? null,
'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', 'product', 'attachments', 'checklistItems']) ?? $ticket;
$this->workflow->dispatch($ticket, 'ticket.created', $user);
return $ticket;
});
}
/**
* Add a public or internal comment and optional attachments.
*
* @param array<int, UploadedFile> $files
*/
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, $parentId): Comment {
/** @var Comment $comment */
$comment = $ticket->comments()->create([
'parent_id' => $parentId,
'user_id' => $user->id,
'body' => $body,
'is_internal' => $internal,
]);
$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);
return $comment->fresh(['user', 'attachments']) ?? $comment;
});
}
/**
* Close a ticket.
*/
public function close(Ticket $ticket, User $user): Ticket
{
$status = Status::query()->where('slug', config('helpdesk.closed_status_slug'))->firstOrFail();
$ticket->forceFill([
'status_id' => $status->id,
'completed_at' => now(),
'last_activity_at' => now(),
])->save();
$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;
}
}
@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
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
{
/**
* Send all active workflow messages for the supplied ticket event.
*/
public function dispatch(Ticket $ticket, string $event, ?User $actor = null): void
{
$ticket->loadMissing(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team', 'product']);
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;
}
$trackingToken = Str::random(48);
$subject = $this->renderText($template->subject, $ticket, $actor);
$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 {
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, $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);
}
});
}
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 $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">'
.'<div style="background:#0f172a;color:#ffffff;padding:24px 28px">'
.'<div style="font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:#bfdbfe">Helpdesk notification</div>'
.'<h1 style="margin:8px 0 0;font-size:22px">'.e($ticket->reference).'</h1>'
.'<p style="margin:8px 0 0;color:#dbeafe">'.e($ticket->subject).'</p>'
.'</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 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,
'ticket_id' => $ticket->id,
'event' => $event,
'recipient' => $recipient,
'subject' => $subject,
'status' => $status,
'tracking_token' => $trackingToken,
'error' => $error,
]);
}
}