M3 Integration fixes

This commit is contained in:
Mark Gregory
2026-09-21 10:14:57 +01:00
parent 4af997c465
commit 6d60cbff84
5853 changed files with 79032 additions and 1825 deletions
View File
+24 -41
View File
@@ -45,16 +45,13 @@ final class AttachmentService
$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,
));
Attachment::query()->create($this->attachmentAttributes($ticket, $comment, $userId, [
'disk' => $disk,
'path' => $path,
'original_name' => $originalName,
'mime_type' => $mimeType,
'size' => $size,
]));
}
}
@@ -67,35 +64,34 @@ final class AttachmentService
$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
private function attachmentAttributes(Ticket $ticket, ?Comment $comment, int $userId, array $file): 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,
'disk' => $file['disk'],
'path' => $file['path'],
'original_name' => $file['original_name'],
'mime_type' => $file['mime_type'],
'size' => $file['size'],
];
if (Schema::hasColumn('helpdesk_attachments', 'extension')) {
$attributes['extension'] = strtolower((string) pathinfo($originalName, PATHINFO_EXTENSION));
$attributes['extension'] = strtolower((string) pathinfo($file['original_name'], PATHINFO_EXTENSION));
}
if (Schema::hasColumn('helpdesk_attachments', 'category')) {
$attributes['category'] = $this->categoryFor($originalName, $mimeType);
$attributes['category'] = $this->categoryFor($file['original_name'], $file['mime_type']);
}
if (Schema::hasColumn('helpdesk_attachments', 'checksum_sha256') && Storage::disk($disk)->exists($path)) {
$stream = Storage::disk($disk)->readStream($path);
if (Schema::hasColumn('helpdesk_attachments', 'checksum_sha256') && Storage::disk($file['disk'])->exists($file['path'])) {
$stream = Storage::disk($file['disk'])->readStream($file['path']);
if (is_resource($stream)) {
$context = hash_init('sha256');
@@ -115,27 +111,15 @@ final class AttachmentService
{
$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';
return match (true) {
str_starts_with($mimeType, 'image/'), in_array($extension, ['png', 'jpg', 'jpeg', 'webp', 'gif'], true) => 'image',
$extension === 'pdf', $mimeType === 'application/pdf' => 'pdf',
in_array($extension, ['zip', 'tar', 'gz', '7z', 'rar'], true) => 'archive',
in_array($extension, config('helpdesk.allowed_source_extensions', []), true) => 'source',
default => 'file',
};
}
/**
* Soft-delete a stored attachment while retaining audit metadata.
*/
@@ -149,7 +133,6 @@ final class AttachmentService
], static fn (mixed $value): bool => $value !== null));
}
/**
* Determine whether the value is a storeable uploaded file.
*/
+25 -25
View File
@@ -29,18 +29,7 @@ final class AutomationRuleService
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();
}
$this->applyRuleUpdates($rule, $ticket);
});
return $ticket->fresh() ?? $ticket;
@@ -48,19 +37,30 @@ final class AutomationRuleService
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 $this->containsWhenConfigured($sender, $rule->match_sender)
&& $this->containsWhenConfigured($ticket->subject, $rule->match_subject)
&& $this->containsWhenConfigured($mailbox, $rule->match_mailbox)
&& (! $rule->match_category_id || (int) $ticket->category_id === (int) $rule->match_category_id);
}
return true;
private function containsWhenConfigured(string $value, ?string $match): bool
{
return ! $match || Str::contains(Str::lower($value), Str::lower($match));
}
private function applyRuleUpdates(AutomationRule $rule, Ticket $ticket): void
{
$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();
}
}
}
View File
View File
+12 -19
View File
@@ -13,8 +13,8 @@ namespace Modules\Helpdesk\Services;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Modules\Helpdesk\Models\GitCommit;
use Modules\Helpdesk\Models\GitRepository;
use Modules\Helpdesk\Models\GitWebhookLog;
@@ -23,6 +23,8 @@ use Throwable;
final class GitWebhookIngestionService
{
private const PUSH_HOOK = 'push hook';
/**
* Matches the generated Helpdesk reference format, for example HD-2026-00042.
*/
@@ -173,10 +175,10 @@ final class GitWebhookIngestionService
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),
'gitlab' => in_array($normalisedEvent, [self::PUSH_HOOK, 'push', ''], true),
'gitea' => in_array($normalisedEvent, ['push', self::PUSH_HOOK, ''], true),
'bitbucket' => in_array($normalisedEvent, ['repo push', 'push', ''], true),
default => in_array($normalisedEvent, ['push', 'repo push', 'push hook', ''], true),
default => in_array($normalisedEvent, ['push', 'repo push', self::PUSH_HOOK, ''], true),
};
}
@@ -214,23 +216,14 @@ final class GitWebhookIngestionService
{
$commits = $payload['commits'] ?? null;
if (is_array($commits) && $commits !== []) {
return array_values(array_filter($commits, static fn (mixed $commit): bool => is_array($commit)));
if (! is_array($commits) || $commits === []) {
$headCommit = $payload['head_commit'] ?? null;
$commits = is_array($headCommit) ? [$headCommit] : Arr::get($payload, 'push.changes.0.commits', []);
}
$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 [];
return is_array($commits)
? array_values(array_filter($commits, static fn (mixed $commit): bool => is_array($commit)))
: [];
}
/**
+2 -1
View File
@@ -5,6 +5,7 @@ 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;
@@ -73,7 +74,7 @@ final class SlaService
return;
}
/** @var \Illuminate\Database\Eloquent\Builder<Comment> $query */
/** @var Builder<Comment> $query */
$query = Comment::query()
->where('ticket_id', $ticket->id)
->where('is_internal', false)
View File
+97 -68
View File
@@ -10,6 +10,7 @@ use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Modules\Helpdesk\Exceptions\TicketPersistenceException;
use Modules\Helpdesk\Models\Comment;
use Modules\Helpdesk\Models\KanbanBoard;
use Modules\Helpdesk\Models\Status;
@@ -45,7 +46,7 @@ final class TicketService
}
}
throw new \RuntimeException('Unable to create a unique Helpdesk ticket reference.');
throw new TicketPersistenceException('Unable to create a unique Helpdesk ticket reference.');
}
/**
@@ -56,32 +57,8 @@ final class TicketService
*/
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');
}
$status = $this->resolveInitialStatus();
$boardId = $this->resolveBoardId($data['board_id'] ?? null);
/** @var Ticket $ticket */
$ticket = Ticket::query()->create([
@@ -102,9 +79,7 @@ final class TicketService
'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']))));
}
$this->syncTags($ticket, $data['tag_ids'] ?? null);
$ticket = $this->automation->apply($ticket, $data);
$ticket = $this->sla->applyPolicy($ticket);
@@ -117,6 +92,43 @@ final class TicketService
return $ticket;
}
private function resolveInitialStatus(): Status
{
$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;
$status ??= Status::query()->where('slug', $defaultStatusSlug)->first();
return $status ?? Status::query()->create([
'name' => 'New',
'slug' => $defaultStatusSlug,
'color' => 'sky',
'sort_order' => 1,
'is_closed' => false,
]);
}
private function resolveBoardId(mixed $boardId): mixed
{
if (! blank($boardId) || ! Schema::hasTable('helpdesk_kanban_boards')) {
return $boardId;
}
return KanbanBoard::query()->where('is_default', true)->orderBy('sort_order')->value('id')
?: KanbanBoard::query()->orderBy('sort_order')->value('id');
}
private function syncTags(Ticket $ticket, mixed $tagIds): void
{
if (! is_array($tagIds) || $tagIds === []) {
return;
}
$ticket->tags()->sync(array_values(array_unique(array_map('intval', $tagIds))));
}
/**
* Determine whether a database error was caused by the ticket reference unique index.
*/
@@ -137,52 +149,69 @@ final class TicketService
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' => 'wms-helpdesk',
];
if (Schema::hasColumn('helpdesk_comments', 'subject')) {
$payload['subject'] = trim((string) ($subject ?: ($parentId === null ? 'Response' : 'RE: Response')));
}
$parent = $this->resolveParentComment($ticket, $parentId);
$payload = $this->commentPayload($ticket, $user, $body, $internal, $parent, $parentId, $subject);
/** @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);
$this->afterCommentCreated($ticket, $user, $comment, $files, $internal, $parentId);
return $comment->fresh(['user', 'attachments']) ?? $comment;
});
}
private function resolveParentComment(Ticket $ticket, ?int $parentId): ?Comment
{
return $parentId === null
? null
: Comment::query()->where('ticket_id', $ticket->id)->findOrFail($parentId);
}
/** @return array<string, mixed> */
private function commentPayload(Ticket $ticket, User $user, string $body, bool $internal, ?Comment $parent, ?int $parentId, ?string $subject): array
{
$guid = (string) Str::uuid();
$unid = strtoupper(hash('sha256', $guid . '|' . $ticket->id . '|' . microtime(true)));
$parentUnid = $parent?->unid;
$payload = [
'parent_id' => $parentId,
'user_id' => $user->id,
'body' => $body,
'body_text' => trim(preg_replace('/\s+/', ' ', strip_tags($body)) ?: ''),
'is_internal' => $internal,
'guid' => $guid,
'unid' => $unid,
'parent_unid' => $parentUnid,
'root_unid' => $parent?->root_unid ?: $parentUnid ?: $unid,
'originator_id' => $unid . ':1',
'sync_sequence' => 1,
'sync_hash' => hash('sha256', $body),
'source_system' => 'wms-helpdesk',
];
if (Schema::hasColumn('helpdesk_comments', 'subject')) {
$defaultSubject = $parentId === null ? 'Response' : 'RE: Response';
$payload['subject'] = trim((string) ($subject ?: $defaultSubject));
}
return $payload;
}
/** @param array<int, UploadedFile> $files */
private function afterCommentCreated(Ticket $ticket, User $user, Comment $comment, array $files, bool $internal, ?int $parentId): void
{
$ticket->forceFill(['last_activity_at' => now()])->save();
if (! $internal) {
$this->sla->recordPublicResponse($ticket, $user);
}
$this->attachments->storeMany($ticket, $files, (int) $user->id, $comment);
$event = $parentId === null ? 'ticket.commented' : 'ticket.reply_created';
$freshTicket = $ticket->fresh(['status', 'priority', 'category', 'project', 'team']) ?? $ticket;
$this->workflow->dispatch($freshTicket, $event, $user);
}
/**
* Close a ticket.
*/
+10 -10
View File
@@ -34,7 +34,7 @@ final class WorkflowNotificationService
$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.');
$this->log($template, $ticket, $event, ['recipient' => null, 'status' => 'skipped', 'error' => 'No recipient email was available.']);
return;
}
@@ -42,7 +42,6 @@ final class WorkflowNotificationService
$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 {
@@ -57,10 +56,10 @@ final class WorkflowNotificationService
}
});
$this->log($template, $ticket, $event, (string) $recipient->email, 'sent', null, $subject, $trackingToken);
$this->log($template, $ticket, $event, ['recipient' => (string) $recipient->email, 'status' => 'sent', 'subject' => $subject, 'tracking_token' => $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);
$this->log($template, $ticket, $event, ['recipient' => (string) $recipient->email, 'status' => 'failed', 'error' => $exception->getMessage(), 'subject' => $subject]);
}
});
}
@@ -158,17 +157,18 @@ final class WorkflowNotificationService
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
/** @param array{recipient:?string,status:string,error?:?string,subject?:?string,tracking_token?:?string} $details */
private function log(WorkflowTemplate $template, Ticket $ticket, string $event, array $details): 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,
'recipient' => $details['recipient'],
'subject' => $details['subject'] ?? null,
'status' => $details['status'],
'tracking_token' => $details['tracking_token'] ?? null,
'error' => $details['error'] ?? null,
]);
}
}