Initial Commit
This commit is contained in:
@@ -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()->with('agents:id')->find($categoryId);
|
||||
$agentIds = $category?->agents->pluck('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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Services;
|
||||
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||
use Modules\Helpdesk\Models\Attachment;
|
||||
use Modules\Helpdesk\Models\Comment;
|
||||
use Modules\Helpdesk\Models\Ticket;
|
||||
|
||||
final class AttachmentService
|
||||
{
|
||||
/**
|
||||
* Store multiple Helpdesk attachments against a ticket or comment.
|
||||
*
|
||||
* Livewire create/comment forms pass TemporaryUploadedFile instances, while
|
||||
* controller endpoints pass Symfony/Laravel UploadedFile instances. Both
|
||||
* are supported here so uploads are never silently skipped on ticket create.
|
||||
*
|
||||
* @param array<int|string, mixed> $files
|
||||
*/
|
||||
public function storeMany(Ticket $ticket, array $files, int $userId, ?Comment $comment = null): void
|
||||
{
|
||||
foreach ($files as $file) {
|
||||
if (! $this->isSupportedUpload($file)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$disk = (string) config('helpdesk.disk', 'public');
|
||||
$originalName = $this->originalName($file);
|
||||
$mimeType = $this->mimeType($file);
|
||||
$size = $this->size($file);
|
||||
$path = $file->store('tickets/'.$ticket->id, $disk);
|
||||
|
||||
if (! is_string($path) || $path === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($size === 0) {
|
||||
$size = (int) Storage::disk($disk)->size($path);
|
||||
}
|
||||
|
||||
Attachment::query()->create($this->attachmentAttributes(
|
||||
ticket: $ticket,
|
||||
comment: $comment,
|
||||
userId: $userId,
|
||||
disk: $disk,
|
||||
path: $path,
|
||||
originalName: $originalName,
|
||||
mimeType: $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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build attachment attributes while remaining compatible with older databases.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function attachmentAttributes(Ticket $ticket, ?Comment $comment, int $userId, string $disk, string $path, string $originalName, string $mimeType, int $size): array
|
||||
{
|
||||
$attributes = [
|
||||
'ticket_id' => $ticket->id,
|
||||
'comment_id' => $comment?->id,
|
||||
'user_id' => $userId,
|
||||
'disk' => $disk,
|
||||
'path' => $path,
|
||||
'original_name' => $originalName,
|
||||
'mime_type' => $mimeType,
|
||||
'size' => $size,
|
||||
];
|
||||
|
||||
if (Schema::hasColumn('helpdesk_attachments', 'extension')) {
|
||||
$attributes['extension'] = strtolower((string) pathinfo($originalName, PATHINFO_EXTENSION));
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('helpdesk_attachments', 'category')) {
|
||||
$attributes['category'] = $this->categoryFor($originalName, $mimeType);
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('helpdesk_attachments', 'checksum_sha256') && Storage::disk($disk)->exists($path)) {
|
||||
$stream = Storage::disk($disk)->readStream($path);
|
||||
|
||||
if (is_resource($stream)) {
|
||||
$context = hash_init('sha256');
|
||||
hash_update_stream($context, $stream);
|
||||
$attributes['checksum_sha256'] = hash_final($context);
|
||||
fclose($stream);
|
||||
}
|
||||
}
|
||||
|
||||
return $attributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorise uploaded files for icons, previews and safe code viewing.
|
||||
*/
|
||||
private function categoryFor(string $originalName, string $mimeType): string
|
||||
{
|
||||
$extension = strtolower((string) pathinfo($originalName, PATHINFO_EXTENSION));
|
||||
|
||||
if (str_starts_with($mimeType, 'image/') || in_array($extension, ['png', 'jpg', 'jpeg', 'webp', 'gif'], true)) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
if ($extension === 'pdf' || $mimeType === 'application/pdf') {
|
||||
return 'pdf';
|
||||
}
|
||||
|
||||
if (in_array($extension, ['zip', 'tar', 'gz', '7z', 'rar'], true)) {
|
||||
return 'archive';
|
||||
}
|
||||
|
||||
if (in_array($extension, config('helpdesk.allowed_source_extensions', []), true)) {
|
||||
return 'source';
|
||||
}
|
||||
|
||||
return 'file';
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete a stored attachment while retaining audit metadata.
|
||||
*/
|
||||
public function softDelete(Attachment $attachment, int $userId): void
|
||||
{
|
||||
DB::table('helpdesk_attachments')
|
||||
->where('id', $attachment->id)
|
||||
->update(array_filter([
|
||||
'deleted_at' => now(),
|
||||
'deleted_by' => Schema::hasColumn('helpdesk_attachments', 'deleted_by') ? $userId : null,
|
||||
], static fn (mixed $value): bool => $value !== null));
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the value is a storeable uploaded file.
|
||||
*/
|
||||
private function isSupportedUpload(mixed $file): bool
|
||||
{
|
||||
if ($file instanceof TemporaryUploadedFile) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $file instanceof UploadedFile && $file->isValid();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the uploaded file's original client filename.
|
||||
*/
|
||||
private function originalName(mixed $file): string
|
||||
{
|
||||
return (string) $file->getClientOriginalName();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the uploaded file's reported mime type.
|
||||
*/
|
||||
private function mimeType(mixed $file): string
|
||||
{
|
||||
return (string) ($file->getClientMimeType() ?: $file->getMimeType() ?: 'application/octet-stream');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the uploaded file's byte size.
|
||||
*/
|
||||
private function size(mixed $file): int
|
||||
{
|
||||
return (int) ($file->getSize() ?: 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?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)
|
||||
->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,67 @@
|
||||
<?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
|
||||
{
|
||||
$this->hydrateTicketUsers($ticket);
|
||||
$ticket->loadMissing(['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()]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load requester/agent with one query if the caller has not already attached them.
|
||||
*/
|
||||
private function hydrateTicketUsers(Ticket $ticket): void
|
||||
{
|
||||
if ($ticket->relationLoaded('owner') && $ticket->relationLoaded('agent')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$userIds = collect([$ticket->user_id, $ticket->agent_id])
|
||||
->filter()
|
||||
->map(static fn (mixed $id): int => (int) $id)
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
if ($userIds->isEmpty()) {
|
||||
$ticket->setRelation('user', null);
|
||||
$ticket->setRelation('owner', null);
|
||||
$ticket->setRelation('agent', null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$users = User::query()
|
||||
->whereIn('id', $userIds)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$owner = $users->get((int) $ticket->user_id);
|
||||
$agent = $ticket->agent_id === null ? null : $users->get((int) $ticket->agent_id);
|
||||
|
||||
$ticket->setRelation('user', $owner);
|
||||
$ticket->setRelation('owner', $owner);
|
||||
$ticket->setRelation('agent', $agent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?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('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,299 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* GitWebhookIngestionService.php
|
||||
*
|
||||
* Normalises Git provider webhooks and links commits to Helpdesk tickets.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Services;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
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
|
||||
{
|
||||
/**
|
||||
* Matches the generated Helpdesk reference format, for example HD-2026-00042.
|
||||
*/
|
||||
private const TICKET_REFERENCE_PATTERN = '/\bHD[-\s_]?(\d{4})[-\s_]?(\d{1,8})\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->branchFromPayload($payload);
|
||||
$commits = $this->commitsFromPayload($payload);
|
||||
$commitCount = 0;
|
||||
$linkedCount = 0;
|
||||
$ignoredCount = 0;
|
||||
|
||||
foreach ($commits as $commit) {
|
||||
$hash = $this->commitHash($commit);
|
||||
|
||||
if ($hash === null) {
|
||||
$ignoredCount++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$message = $this->stringOrNull($commit['message'] ?? $commit['title'] ?? $commit['summary'] ?? null) ?? '';
|
||||
$url = $this->commitUrl($commit);
|
||||
$author = is_array($commit['author'] ?? null) ? $commit['author'] : [];
|
||||
$committer = is_array($commit['committer'] ?? null) ? $commit['committer'] : [];
|
||||
$authorName = $this->stringOrNull($author['name'] ?? $author['username'] ?? $committer['name'] ?? null);
|
||||
$authorEmail = $this->stringOrNull($author['email'] ?? $author['raw'] ?? $committer['email'] ?? null);
|
||||
$committedAt = $this->parseTimestamp(
|
||||
$commit['timestamp']
|
||||
?? $commit['committed_at']
|
||||
?? $commit['date']
|
||||
?? Arr::get($commit, 'author.date')
|
||||
?? 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 and normalise 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 sprintf('HD-%s-%05d', $matches[1], (int) $matches[2]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the provider event should be processed as a push payload.
|
||||
*/
|
||||
private function isPushEvent(string $provider, ?string $event): bool
|
||||
{
|
||||
$normalisedProvider = Str::lower(trim($provider));
|
||||
$normalisedEvent = Str::of((string) $event)->lower()->replace(['_', '-'], ' ')->squish()->value();
|
||||
|
||||
return match ($normalisedProvider) {
|
||||
'github' => in_array($normalisedEvent, ['push', ''], true),
|
||||
'gitlab' => in_array($normalisedEvent, ['push hook', 'push', ''], true),
|
||||
'gitea' => in_array($normalisedEvent, ['push', 'push hook', ''], true),
|
||||
'bitbucket' => in_array($normalisedEvent, ['repo push', 'push', ''], true),
|
||||
default => in_array($normalisedEvent, ['push', 'repo push', 'push hook', ''], true),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the branch name from common Git provider payload shapes.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function branchFromPayload(array $payload): ?string
|
||||
{
|
||||
$ref = $this->stringOrNull(
|
||||
$payload['ref']
|
||||
?? Arr::get($payload, 'push.changes.0.new.name')
|
||||
?? Arr::get($payload, 'object_attributes.ref')
|
||||
?? null,
|
||||
);
|
||||
|
||||
if ($ref === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Str::of($ref)
|
||||
->replaceStart('refs/heads/', '')
|
||||
->replaceStart('refs/tags/', '')
|
||||
->trim()
|
||||
->value();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function commitsFromPayload(array $payload): array
|
||||
{
|
||||
$commits = $payload['commits'] ?? null;
|
||||
|
||||
if (is_array($commits) && $commits !== []) {
|
||||
return array_values(array_filter($commits, static fn (mixed $commit): bool => is_array($commit)));
|
||||
}
|
||||
|
||||
$headCommit = $payload['head_commit'] ?? null;
|
||||
|
||||
if (is_array($headCommit)) {
|
||||
return [$headCommit];
|
||||
}
|
||||
|
||||
$bitbucketCommits = Arr::get($payload, 'push.changes.0.commits', []);
|
||||
|
||||
if (is_array($bitbucketCommits)) {
|
||||
return array_values(array_filter($bitbucketCommits, static fn (mixed $commit): bool => is_array($commit)));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $commit
|
||||
*/
|
||||
private function commitHash(array $commit): ?string
|
||||
{
|
||||
return $this->stringOrNull($commit['id'] ?? $commit['sha'] ?? $commit['hash'] ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $commit
|
||||
*/
|
||||
private function commitUrl(array $commit): ?string
|
||||
{
|
||||
return $this->stringOrNull(
|
||||
$commit['url']
|
||||
?? $commit['web_url']
|
||||
?? Arr::get($commit, 'links.html.href')
|
||||
?? null,
|
||||
);
|
||||
}
|
||||
|
||||
private function ticketForCommit(GitRepository $repository, string $message, ?string $branch, ?string $url): ?Ticket
|
||||
{
|
||||
$reference = $this->extractTicketReference(trim($message.' '.($branch ?? '').' '.($url ?? '')));
|
||||
|
||||
if ($reference === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$baseQuery = Ticket::query()->where('reference', $reference);
|
||||
|
||||
$strictProjectTicket = (clone $baseQuery)
|
||||
->when($repository->project_id !== null, static function (Builder $query) use ($repository): void {
|
||||
$query->where('project_id', $repository->project_id);
|
||||
})
|
||||
->first();
|
||||
|
||||
return $strictProjectTicket ?? $baseQuery->first();
|
||||
}
|
||||
|
||||
private function parseTimestamp(mixed $value): ?CarbonImmutable
|
||||
{
|
||||
$timestamp = $this->stringOrNull($value);
|
||||
|
||||
if ($timestamp === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return CarbonImmutable::parse($timestamp);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function stringOrNull(mixed $value): ?string
|
||||
{
|
||||
if (! is_scalar($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$string = trim((string) $value);
|
||||
|
||||
return $string === '' ? null : $string;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\Helpdesk\Models\Comment;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a public ticket response and satisfy the first-response SLA.
|
||||
*
|
||||
* A public Helpdesk response is deliberate customer-visible communication,
|
||||
* so it must satisfy the first-response SLA even in local/single-user
|
||||
* support workflows where the same authenticated user may be testing both
|
||||
* sides of the ticket conversation.
|
||||
*/
|
||||
public function recordPublicResponse(Ticket $ticket, User $actor): void
|
||||
{
|
||||
$respondedAt = now();
|
||||
|
||||
if ($ticket->first_response_at === null) {
|
||||
$this->markFirstResponse($ticket, $respondedAt, $actor);
|
||||
}
|
||||
|
||||
$ticket->increment('public_replies_count');
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild first-response SLA state from the earliest public comment.
|
||||
*
|
||||
* This keeps older tickets correct when public comments existed before the
|
||||
* explicit Public/Internal selector was added or when counters were manually
|
||||
* changed outside the SLA service.
|
||||
*/
|
||||
public function syncFirstResponseFromComments(Ticket $ticket): void
|
||||
{
|
||||
if ($ticket->first_response_at !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var Builder<Comment> $query */
|
||||
$query = Comment::query()
|
||||
->where('ticket_id', $ticket->id)
|
||||
->where('is_internal', false)
|
||||
->with('user')
|
||||
->oldest('created_at');
|
||||
|
||||
if (Schema::hasColumn('helpdesk_comments', 'tombstoned_at')) {
|
||||
$query->whereNull('tombstoned_at');
|
||||
}
|
||||
|
||||
/** @var Comment|null $firstPublicComment */
|
||||
$firstPublicComment = $query->first();
|
||||
|
||||
if ($firstPublicComment === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->markFirstResponse($ticket, $firstPublicComment->created_at ?? now(), $firstPublicComment->user);
|
||||
}
|
||||
|
||||
private function markFirstResponse(Ticket $ticket, mixed $respondedAt, ?User $actor = null): void
|
||||
{
|
||||
$respondedAt = $respondedAt instanceof Carbon
|
||||
? $respondedAt
|
||||
: now();
|
||||
|
||||
$breached = $ticket->sla_first_response_due_at !== null
|
||||
&& $respondedAt->greaterThan($ticket->sla_first_response_due_at);
|
||||
|
||||
$ticket->forceFill([
|
||||
'first_response_at' => $respondedAt,
|
||||
'sla_first_response_breached_at' => $breached ? $respondedAt : null,
|
||||
])->save();
|
||||
|
||||
TicketSlaEvent::query()->firstOrCreate(
|
||||
[
|
||||
'ticket_id' => $ticket->id,
|
||||
'event' => 'sla.first_response',
|
||||
],
|
||||
[
|
||||
'sla_policy_id' => $ticket->sla_policy_id,
|
||||
'due_at' => $ticket->sla_first_response_due_at,
|
||||
'occurred_at' => $respondedAt,
|
||||
'breached' => $breached,
|
||||
'meta' => [
|
||||
'actor_id' => $actor?->id,
|
||||
'actor_name' => $actor?->name,
|
||||
'response_seconds' => max(0, ($ticket->created_at ?? $respondedAt)->diffInSeconds($respondedAt)),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
'meta' => [
|
||||
'resolved_seconds' => max(0, ($ticket->created_at ?? now())->diffInSeconds(now())),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
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)
|
||||
->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,127 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* TicketReferenceService.php
|
||||
*
|
||||
* Generates collision-resistant Helpdesk ticket references.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Services;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\Helpdesk\Models\Ticket;
|
||||
|
||||
final class TicketReferenceService
|
||||
{
|
||||
/**
|
||||
* Build the next human-readable ticket reference for the current year.
|
||||
*
|
||||
* The preferred strategy uses a dedicated sequence row locked inside the
|
||||
* ticket creation transaction. The initial sequence value is backfilled from
|
||||
* all existing ticket references, including soft-deleted tickets, so a
|
||||
* previously used reference can never be reused while the unique index still
|
||||
* protects it.
|
||||
*/
|
||||
public function next(): string
|
||||
{
|
||||
$year = (int) now()->format('Y');
|
||||
$prefix = 'HD';
|
||||
$seriesPrefix = $prefix.'-'.$year.'-';
|
||||
|
||||
$this->lockReferenceSeries($seriesPrefix);
|
||||
|
||||
if (Schema::hasTable('helpdesk_ticket_sequences')) {
|
||||
return $this->nextFromSequenceTable($prefix, $year, $seriesPrefix);
|
||||
}
|
||||
|
||||
return $this->nextFromExistingReferences($seriesPrefix);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the next reference from the durable sequence table.
|
||||
*/
|
||||
private function nextFromSequenceTable(string $prefix, int $year, string $seriesPrefix): string
|
||||
{
|
||||
$sequence = DB::table('helpdesk_ticket_sequences')
|
||||
->where('prefix', $prefix)
|
||||
->where('year', $year)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (! $sequence) {
|
||||
DB::table('helpdesk_ticket_sequences')->insert([
|
||||
'prefix' => $prefix,
|
||||
'year' => $year,
|
||||
'next_number' => $this->highestExistingSuffix($seriesPrefix) + 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$sequence = DB::table('helpdesk_ticket_sequences')
|
||||
->where('prefix', $prefix)
|
||||
->where('year', $year)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
}
|
||||
|
||||
$nextNumber = max((int) $sequence->next_number, $this->highestExistingSuffix($seriesPrefix) + 1);
|
||||
|
||||
DB::table('helpdesk_ticket_sequences')
|
||||
->where('id', $sequence->id)
|
||||
->update([
|
||||
'next_number' => $nextNumber + 1,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return $seriesPrefix.str_pad((string) $nextNumber, 5, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback for installations that have not run the sequence migration yet.
|
||||
*/
|
||||
private function nextFromExistingReferences(string $seriesPrefix): string
|
||||
{
|
||||
return $seriesPrefix.str_pad((string) ($this->highestExistingSuffix($seriesPrefix) + 1), 5, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the highest used suffix across active and soft-deleted tickets.
|
||||
*/
|
||||
private function highestExistingSuffix(string $seriesPrefix): int
|
||||
{
|
||||
/** @var list<string> $existingReferences */
|
||||
$existingReferences = Ticket::query()
|
||||
->withoutGlobalScopes()
|
||||
->withTrashed()
|
||||
->where('reference', 'like', $seriesPrefix.'%')
|
||||
->pluck('reference')
|
||||
->filter(static fn (mixed $reference): bool => is_string($reference) && preg_match('/^'.preg_quote($seriesPrefix, '/').'\d+$/', $reference) === 1)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$highestSuffix = 0;
|
||||
|
||||
foreach ($existingReferences as $existingReference) {
|
||||
$highestSuffix = max($highestSuffix, (int) substr($existingReference, strlen($seriesPrefix)));
|
||||
}
|
||||
|
||||
return $highestSuffix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock the current ticket reference series for the active transaction.
|
||||
*/
|
||||
private function lockReferenceSeries(string $seriesPrefix): void
|
||||
{
|
||||
if (DB::connection()->getDriverName() !== 'pgsql') {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::select('select pg_advisory_xact_lock(hashtext(?))', [
|
||||
'helpdesk-ticket-reference:'.$seriesPrefix,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\Helpdesk\Models\Comment;
|
||||
use Modules\Helpdesk\Models\KanbanBoard;
|
||||
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
|
||||
{
|
||||
for ($attempt = 1; $attempt <= 5; $attempt++) {
|
||||
try {
|
||||
return DB::transaction(fn (): Ticket => $this->createInsideTransaction($user, $data, $files));
|
||||
} catch (QueryException $exception) {
|
||||
if (! $this->isReferenceCollision($exception) || $attempt === 5) {
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new \RuntimeException('Unable to create a unique Helpdesk ticket reference.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the ticket while the caller owns the database transaction.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
* @param array<int, UploadedFile> $files
|
||||
*/
|
||||
private function createInsideTransaction(User $user, array $data, array $files = []): Ticket
|
||||
{
|
||||
$defaultStatusSlug = (string) config('helpdesk.default_status_slug', 'new');
|
||||
$status = Schema::hasTable('helpdesk_statuses') && Schema::hasColumn('helpdesk_statuses', 'is_default')
|
||||
? Status::query()->where('is_default', true)->orderBy('sort_order')->first()
|
||||
: null;
|
||||
|
||||
if (! $status) {
|
||||
$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,
|
||||
]);
|
||||
}
|
||||
|
||||
$boardId = $data['board_id'] ?? null;
|
||||
if (blank($boardId) && Schema::hasTable('helpdesk_kanban_boards')) {
|
||||
$boardId = KanbanBoard::query()
|
||||
->where('is_default', true)
|
||||
->orderBy('sort_order')
|
||||
->value('id') ?: KanbanBoard::query()->orderBy('sort_order')->value('id');
|
||||
}
|
||||
|
||||
/** @var Ticket $ticket */
|
||||
$ticket = Ticket::query()->create([
|
||||
'reference' => $this->references->next(),
|
||||
'user_id' => $user->id,
|
||||
'agent_id' => $this->assignment->selectAgentId($data['category_id'] ?? null),
|
||||
'status_id' => $status->id,
|
||||
'board_id' => $boardId,
|
||||
'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(),
|
||||
]);
|
||||
|
||||
if (! empty($data['tag_ids']) && is_array($data['tag_ids'])) {
|
||||
$ticket->tags()->sync(array_values(array_unique(array_map('intval', $data['tag_ids']))));
|
||||
}
|
||||
|
||||
$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(['status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'attachments', 'checklistItems']) ?? $ticket;
|
||||
$this->workflow->dispatch($ticket, 'ticket.created', $user);
|
||||
|
||||
return $ticket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether a database error was caused by the ticket reference unique index.
|
||||
*/
|
||||
private function isReferenceCollision(QueryException $exception): bool
|
||||
{
|
||||
$message = $exception->getMessage();
|
||||
|
||||
return str_contains($message, 'helpdesk_tickets_reference_unique')
|
||||
|| str_contains($message, 'Duplicate entry') && str_contains($message, 'reference')
|
||||
|| str_contains($message, 'UNIQUE constraint failed') && str_contains($message, 'helpdesk_tickets.reference');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, ?string $subject = null): Comment
|
||||
{
|
||||
return DB::transaction(function () use ($ticket, $user, $body, $internal, $files, $parentId, $subject): Comment {
|
||||
$parent = $parentId === null
|
||||
? null
|
||||
: Comment::query()
|
||||
->where('ticket_id', $ticket->id)
|
||||
->findOrFail($parentId);
|
||||
|
||||
$guid = (string) Str::uuid();
|
||||
$unid = strtoupper(hash('sha256', $guid.'|'.$ticket->id.'|'.microtime(true)));
|
||||
$parentUnid = $parent?->unid;
|
||||
$rootUnid = $parent?->root_unid ?: $parentUnid ?: $unid;
|
||||
$plainTextBody = trim(preg_replace('/\s+/', ' ', strip_tags($body)) ?: '');
|
||||
|
||||
$payload = [
|
||||
'parent_id' => $parentId,
|
||||
'user_id' => $user->id,
|
||||
'body' => $body,
|
||||
'body_text' => $plainTextBody,
|
||||
'is_internal' => $internal,
|
||||
'guid' => $guid,
|
||||
'unid' => $unid,
|
||||
'parent_unid' => $parentUnid,
|
||||
'root_unid' => $rootUnid,
|
||||
'originator_id' => $unid.':1',
|
||||
'sync_sequence' => 1,
|
||||
'sync_hash' => hash('sha256', $body),
|
||||
'source_system' => 'thredary-helpdesk',
|
||||
];
|
||||
|
||||
if (Schema::hasColumn('helpdesk_comments', 'subject')) {
|
||||
$payload['subject'] = trim((string) ($subject ?: ($parentId === null ? 'Response' : 'RE: Response')));
|
||||
}
|
||||
|
||||
/** @var Comment $comment */
|
||||
$comment = $ticket->comments()->create($payload);
|
||||
|
||||
$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(['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(['status', 'priority', 'category', 'project', 'team', 'product']) ?? $ticket;
|
||||
$this->sla->recordClosure($ticket);
|
||||
$this->workflow->dispatch($ticket, 'ticket.closed', $user);
|
||||
|
||||
return $ticket;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
<?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 Illuminate\Support\Str;
|
||||
use Modules\Helpdesk\Models\Ticket;
|
||||
use Modules\Helpdesk\Models\WorkflowLog;
|
||||
use Modules\Helpdesk\Models\WorkflowTemplate;
|
||||
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
|
||||
{
|
||||
$this->hydrateTicketUsers($ticket);
|
||||
$ticket->loadMissing(['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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load requester/agent with one query if the caller has not already attached them.
|
||||
*/
|
||||
private function hydrateTicketUsers(Ticket $ticket): void
|
||||
{
|
||||
if ($ticket->relationLoaded('owner') && $ticket->relationLoaded('agent')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$userIds = collect([$ticket->user_id, $ticket->agent_id])
|
||||
->filter()
|
||||
->map(static fn (mixed $id): int => (int) $id)
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
if ($userIds->isEmpty()) {
|
||||
$ticket->setRelation('user', null);
|
||||
$ticket->setRelation('owner', null);
|
||||
$ticket->setRelation('agent', null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$users = User::query()
|
||||
->whereIn('id', $userIds)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$owner = $users->get((int) $ticket->user_id);
|
||||
$agent = $ticket->agent_id === null ? null : $users->get((int) $ticket->agent_id);
|
||||
|
||||
$ticket->setRelation('user', $owner);
|
||||
$ticket->setRelation('owner', $owner);
|
||||
$ticket->setRelation('agent', $agent);
|
||||
}
|
||||
|
||||
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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user