M3 Integration fixes
This commit is contained in:
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+19
-16
@@ -4,11 +4,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Database\factories;
|
||||
|
||||
use Modules\Company\Models\Company;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\Company\Models\Company;
|
||||
use Modules\Helpdesk\Models\Ticket;
|
||||
|
||||
/**
|
||||
@@ -16,6 +17,8 @@ use Modules\Helpdesk\Models\Ticket;
|
||||
*/
|
||||
final class TicketFactory extends Factory
|
||||
{
|
||||
private const FACTORY_BODY = self::FACTORY_BODY;
|
||||
|
||||
/**
|
||||
* The model name for the factory.
|
||||
*
|
||||
@@ -31,11 +34,11 @@ final class TicketFactory extends Factory
|
||||
public function definition(): array
|
||||
{
|
||||
$company = Company::query()->create([
|
||||
'name' => 'Helpdesk Factory Company '.Str::random(8),
|
||||
'slug' => 'helpdesk-factory-company-'.Str::lower(Str::random(8)),
|
||||
'name' => 'Helpdesk Factory Company ' . Str::random(8),
|
||||
'slug' => 'helpdesk-factory-company-' . Str::lower(Str::random(8)),
|
||||
'status' => 'active',
|
||||
'timezone' => 'Europe/London',
|
||||
'billing_email' => 'helpdesk-factory-'.Str::lower(Str::random(8)).'@example.test',
|
||||
'billing_email' => 'helpdesk-factory-' . Str::lower(Str::random(8)) . '@example.test',
|
||||
'is_system_company' => false,
|
||||
]);
|
||||
$user = User::factory()->create([
|
||||
@@ -52,7 +55,7 @@ final class TicketFactory extends Factory
|
||||
*/
|
||||
private function ticketPayload(Company $company, User $user): array
|
||||
{
|
||||
$ticket = new Ticket();
|
||||
$ticket = new Ticket;
|
||||
$table = $ticket->getTable();
|
||||
|
||||
$data = [];
|
||||
@@ -70,43 +73,43 @@ final class TicketFactory extends Factory
|
||||
$put('assigned_to_id', $user->getKey());
|
||||
$put('agent_id', $user->getKey());
|
||||
|
||||
$put('reference', 'HD-'.now()->format('Y').'-'.str_pad((string) $this->faker->unique()->numberBetween(1, 9999), 4, '0', STR_PAD_LEFT));
|
||||
$put('reference', 'HD-' . now()->format('Y') . '-' . str_pad((string) $this->faker->unique()->numberBetween(1, 9999), 4, '0', STR_PAD_LEFT));
|
||||
$put('subject', 'Factory helpdesk ticket');
|
||||
$put('title', 'Factory helpdesk ticket');
|
||||
$put('body', '<p>Factory helpdesk ticket body.</p>');
|
||||
$put('details', '<p>Factory helpdesk ticket body.</p>');
|
||||
$put('description', '<p>Factory helpdesk ticket body.</p>');
|
||||
$put('body', self::FACTORY_BODY);
|
||||
$put('details', self::FACTORY_BODY);
|
||||
$put('description', self::FACTORY_BODY);
|
||||
$put('search_text', 'factory helpdesk ticket body');
|
||||
$put('last_activity_at', now());
|
||||
|
||||
$statusId = $this->lookupId(
|
||||
'Modules\\Helpdesk\\Models\\TicketStatus',
|
||||
['name' => 'Open'],
|
||||
['slug' => 'open', 'color' => 'blue', 'is_closed' => false, 'sort_order' => 10]
|
||||
['slug' => 'open', 'color' => 'blue', 'is_closed' => false, 'sort_order' => 10],
|
||||
);
|
||||
|
||||
$priorityId = $this->lookupId(
|
||||
'Modules\\Helpdesk\\Models\\TicketPriority',
|
||||
['name' => 'Normal'],
|
||||
['slug' => 'normal', 'color' => 'blue', 'sort_order' => 20]
|
||||
['slug' => 'normal', 'color' => 'blue', 'sort_order' => 20],
|
||||
);
|
||||
|
||||
$categoryId = $this->lookupId(
|
||||
'Modules\\Helpdesk\\Models\\TicketCategory',
|
||||
['name' => 'General'],
|
||||
['slug' => 'general', 'color' => 'blue', 'sort_order' => 10]
|
||||
['slug' => 'general', 'color' => 'blue', 'sort_order' => 10],
|
||||
);
|
||||
|
||||
$teamId = $this->lookupId(
|
||||
'Modules\\Helpdesk\\Models\\HelpdeskTeam',
|
||||
['name' => 'Support'],
|
||||
['slug' => 'support', 'color' => 'blue', 'sort_order' => 10]
|
||||
['slug' => 'support', 'color' => 'blue', 'sort_order' => 10],
|
||||
);
|
||||
|
||||
$projectId = $this->lookupId(
|
||||
'Modules\\Helpdesk\\Models\\HelpdeskProject',
|
||||
['name' => 'General'],
|
||||
['slug' => 'general', 'color' => 'blue', 'sort_order' => 10]
|
||||
['slug' => 'general', 'color' => 'blue', 'sort_order' => 10],
|
||||
);
|
||||
|
||||
$put('status_id', $statusId);
|
||||
@@ -140,8 +143,8 @@ final class TicketFactory extends Factory
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var class-string<\Illuminate\Database\Eloquent\Model> $class */
|
||||
$model = new $class();
|
||||
/** @var class-string<Model> $class */
|
||||
$model = new $class;
|
||||
|
||||
if (! Schema::hasTable($model->getTable())) {
|
||||
return null;
|
||||
|
||||
Regular → Executable
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class TicketPersistenceException extends RuntimeException {}
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+1
-2
@@ -6,12 +6,12 @@ namespace Modules\Helpdesk\Http\Controllers;
|
||||
|
||||
use Illuminate\Contracts\Filesystem\FileNotFoundException;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\View\View;
|
||||
use Modules\Helpdesk\Models\Ticket;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
@@ -198,5 +198,4 @@ final class AttachmentDownloadController extends Controller
|
||||
default => 'text',
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Regular → Executable
Regular → Executable
+146
-100
@@ -15,6 +15,20 @@ use Modules\Helpdesk\Services\GitWebhookIngestionService;
|
||||
|
||||
final class GitWebhookController
|
||||
{
|
||||
private const REJECTED = 'rejected';
|
||||
|
||||
private const SIGNATURE_FAILED = 'Git webhook signature or token validation failed.';
|
||||
|
||||
private const SHA256_PREFIX = 'sha256=';
|
||||
|
||||
private const SHA1_PREFIX = 'sha1=';
|
||||
|
||||
private const REQUEST_ID_HEADER = 'X-Request-Id';
|
||||
|
||||
private const GITHUB_DELIVERY_HEADER = 'X-GitHub-Delivery';
|
||||
|
||||
private const GITHUB_EVENT_HEADER = 'X-GitHub-Event';
|
||||
|
||||
/**
|
||||
* Ingest a signed Git provider webhook for a registered Helpdesk repository.
|
||||
*/
|
||||
@@ -24,34 +38,31 @@ final class GitWebhookController
|
||||
$event = $this->eventName($request, $provider);
|
||||
$deliveryId = $this->deliveryId($request, $provider);
|
||||
$rawPayload = $request->getContent();
|
||||
$status = 202;
|
||||
$payload = [];
|
||||
|
||||
if (! $repository->is_active) {
|
||||
$result = ['status' => 'rejected', 'message' => 'Repository is inactive.', 'commit_count' => 0, 'linked_count' => 0, 'ignored_count' => 1];
|
||||
$service->logWebhook($repository, $provider, $deliveryId, $event, $result);
|
||||
|
||||
return response()->json($result, 403);
|
||||
$result = $this->rejectedResult('Repository is inactive.');
|
||||
$status = 403;
|
||||
} elseif (! $this->hasValidSignature($request, $repository, $provider, $rawPayload)) {
|
||||
$result = $this->rejectedResult(self::SIGNATURE_FAILED);
|
||||
$status = 403;
|
||||
} else {
|
||||
try {
|
||||
/** @var array<string, mixed> $payload */
|
||||
$payload = json_decode($rawPayload, true, 512, JSON_THROW_ON_ERROR);
|
||||
$result = $service->ingest($repository, $payload, $provider, $deliveryId, $event);
|
||||
} catch (JsonException) {
|
||||
$result = $this->rejectedResult('Invalid JSON webhook payload.');
|
||||
$status = 400;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $this->hasValidSignature($request, $repository, $provider, $rawPayload)) {
|
||||
$result = ['status' => 'rejected', 'message' => 'Git webhook signature or token validation failed.', 'commit_count' => 0, 'linked_count' => 0, 'ignored_count' => 1];
|
||||
$service->logWebhook($repository, $provider, $deliveryId, $event, $result);
|
||||
|
||||
return response()->json($result, 403);
|
||||
if ($status !== 202) {
|
||||
$service->logWebhook($repository, $provider, $deliveryId, $event, $result, $payload);
|
||||
}
|
||||
|
||||
try {
|
||||
/** @var array<string, mixed> $payload */
|
||||
$payload = json_decode($rawPayload, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException) {
|
||||
$result = ['status' => 'rejected', 'message' => 'Invalid JSON webhook payload.', 'commit_count' => 0, 'linked_count' => 0, 'ignored_count' => 1];
|
||||
$service->logWebhook($repository, $provider, $deliveryId, $event, $result);
|
||||
|
||||
return response()->json($result, 400);
|
||||
}
|
||||
|
||||
$result = $service->ingest($repository, $payload, $provider, $deliveryId, $event);
|
||||
|
||||
return response()->json($result, 202);
|
||||
return response()->json($result, $status);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,42 +74,34 @@ final class GitWebhookController
|
||||
$event = $this->eventName($request, $provider);
|
||||
$deliveryId = $this->deliveryId($request, $provider);
|
||||
$rawPayload = $request->getContent();
|
||||
$status = 202;
|
||||
$repository = null;
|
||||
|
||||
try {
|
||||
/** @var array<string, mixed> $payload */
|
||||
$payload = json_decode($rawPayload, true, 512, JSON_THROW_ON_ERROR);
|
||||
$repository = $this->repositoryForGiteaPayload($payload);
|
||||
|
||||
if (! $repository instanceof GitRepository) {
|
||||
$result = $this->rejectedResult('No active Helpdesk Git repository matched the Gitea webhook payload.');
|
||||
$status = 404;
|
||||
} elseif (! $this->hasValidGiteaSecurity($request, $repository, $provider, $rawPayload)) {
|
||||
$result = $this->rejectedResult(self::SIGNATURE_FAILED);
|
||||
$status = 403;
|
||||
} else {
|
||||
$result = $service->ingest($repository, $payload, $provider, $deliveryId, $event);
|
||||
}
|
||||
} catch (JsonException) {
|
||||
return response()->json([
|
||||
'status' => 'rejected',
|
||||
'message' => 'Invalid JSON webhook payload.',
|
||||
'commit_count' => 0,
|
||||
'linked_count' => 0,
|
||||
'ignored_count' => 1,
|
||||
], 400);
|
||||
$payload = [];
|
||||
$result = $this->rejectedResult('Invalid JSON webhook payload.');
|
||||
$status = 400;
|
||||
}
|
||||
|
||||
$repository = $this->repositoryForGiteaPayload($payload);
|
||||
|
||||
if (! $repository instanceof GitRepository) {
|
||||
return response()->json([
|
||||
'status' => 'rejected',
|
||||
'message' => 'No active Helpdesk Git repository matched the Gitea webhook payload.',
|
||||
'commit_count' => 0,
|
||||
'linked_count' => 0,
|
||||
'ignored_count' => 1,
|
||||
], 404);
|
||||
}
|
||||
|
||||
if (! $this->hasValidGiteaSecurity($request, $repository, $provider, $rawPayload)) {
|
||||
$result = ['status' => 'rejected', 'message' => 'Git webhook signature or token validation failed.', 'commit_count' => 0, 'linked_count' => 0, 'ignored_count' => 1];
|
||||
if ($repository instanceof GitRepository && $status !== 202) {
|
||||
$service->logWebhook($repository, $provider, $deliveryId, $event, $result, $payload);
|
||||
|
||||
return response()->json($result, 403);
|
||||
}
|
||||
|
||||
$result = $service->ingest($repository, $payload, $provider, $deliveryId, $event);
|
||||
|
||||
return response()->json($result, 202);
|
||||
return response()->json($result, $status);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,8 +146,8 @@ final class GitWebhookController
|
||||
$sha256Signature = (string) $request->header('X-Hub-Signature-256', '');
|
||||
$sha1Signature = (string) $request->header('X-Hub-Signature', '');
|
||||
|
||||
return hash_equals('sha256=' . hash_hmac('sha256', $rawPayload, $secret), $sha256Signature)
|
||||
|| hash_equals('sha1=' . hash_hmac('sha1', $rawPayload, $secret), $sha1Signature);
|
||||
return hash_equals(self::SHA256_PREFIX . hash_hmac('sha256', $rawPayload, $secret), $sha256Signature)
|
||||
|| hash_equals(self::SHA1_PREFIX . hash_hmac('sha1', $rawPayload, $secret), $sha1Signature);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,8 +161,8 @@ final class GitWebhookController
|
||||
$sha1 = hash_hmac('sha1', $rawPayload, $secret);
|
||||
|
||||
return hash_equals($sha256, $giteaSignature)
|
||||
|| hash_equals('sha256=' . $sha256, $giteaSignature)
|
||||
|| hash_equals('sha1=' . $sha1, $giteaSignature)
|
||||
|| hash_equals(self::SHA256_PREFIX . $sha256, $giteaSignature)
|
||||
|| hash_equals(self::SHA1_PREFIX . $sha1, $giteaSignature)
|
||||
|| $this->hasValidGitHubSignature($request, $secret, $rawPayload)
|
||||
|| $this->hasValidGenericSignature($request, $secret, $rawPayload);
|
||||
}
|
||||
@@ -171,7 +174,7 @@ final class GitWebhookController
|
||||
{
|
||||
$signature = (string) $request->header('X-Hub-Signature', '');
|
||||
|
||||
return hash_equals('sha256=' . hash_hmac('sha256', $rawPayload, $secret), $signature)
|
||||
return hash_equals(self::SHA256_PREFIX . hash_hmac('sha256', $rawPayload, $secret), $signature)
|
||||
|| hash_equals($secret, (string) $request->header('X-Helpdesk-Token', ''));
|
||||
}
|
||||
|
||||
@@ -184,8 +187,8 @@ final class GitWebhookController
|
||||
$signature = (string) ($request->header('X-Hub-Signature-256') ?: $request->header('X-Hub-Signature'));
|
||||
|
||||
return hash_equals($secret, $token)
|
||||
|| hash_equals('sha256=' . hash_hmac('sha256', $rawPayload, $secret), $signature)
|
||||
|| hash_equals('sha1=' . hash_hmac('sha1', $rawPayload, $secret), $signature);
|
||||
|| hash_equals(self::SHA256_PREFIX . hash_hmac('sha256', $rawPayload, $secret), $signature)
|
||||
|| hash_equals(self::SHA1_PREFIX . hash_hmac('sha1', $rawPayload, $secret), $signature);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -230,63 +233,94 @@ final class GitWebhookController
|
||||
->where('is_active', true)
|
||||
->whereRaw('LOWER(provider) IN (?, ?)', ['gitea', 'git'])
|
||||
->get();
|
||||
$matchedRepository = null;
|
||||
|
||||
if ($repositories->isEmpty()) {
|
||||
Log::warning('Helpdesk Gitea webhook received but no active Gitea/git repository is configured.');
|
||||
} else {
|
||||
$normalisedPayloadValues = $this->normalisedPayloadRepositoryValues($payload);
|
||||
$matchedRepository = $repositories->first(
|
||||
fn (GitRepository $repository): bool => $this->repositoryMatchesPayload($repository, $normalisedPayloadValues),
|
||||
);
|
||||
|
||||
return null;
|
||||
if (! $matchedRepository instanceof GitRepository && $repositories->count() === 1) {
|
||||
$matchedRepository = $repositories->first();
|
||||
} elseif (! $matchedRepository instanceof GitRepository) {
|
||||
Log::warning('Helpdesk Gitea webhook did not match any configured repository.', [
|
||||
'payload_repository' => Arr::get($payload, 'repository.full_name') ?: Arr::get($payload, 'repository.clone_url') ?: Arr::get($payload, 'repository.html_url'),
|
||||
'configured_repository_count' => $repositories->count(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$payloadValues = array_values(array_filter([
|
||||
return $matchedRepository instanceof GitRepository ? $matchedRepository : null;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $payload
|
||||
* @return list<string>
|
||||
*/
|
||||
private function normalisedPayloadRepositoryValues(array $payload): array
|
||||
{
|
||||
$name = Arr::get($payload, 'repository.name');
|
||||
$owner = Arr::get($payload, 'repository.owner.username');
|
||||
|
||||
return $this->normaliseRepositoryValues([
|
||||
Arr::get($payload, 'repository.full_name'),
|
||||
Arr::get($payload, 'repository.name'),
|
||||
$name,
|
||||
Arr::get($payload, 'repository.html_url'),
|
||||
Arr::get($payload, 'repository.clone_url'),
|
||||
Arr::get($payload, 'repository.ssh_url'),
|
||||
Arr::get($payload, 'repository.url'),
|
||||
Arr::get($payload, 'repository.owner.username') && Arr::get($payload, 'repository.name')
|
||||
? Arr::get($payload, 'repository.owner.username') . '/' . Arr::get($payload, 'repository.name')
|
||||
: null,
|
||||
], static fn (mixed $value): bool => is_scalar($value) && trim((string) $value) !== ''));
|
||||
$owner && $name ? $owner . '/' . $name : null,
|
||||
]);
|
||||
}
|
||||
|
||||
$normalisedPayloadValues = array_values(array_filter(array_map(
|
||||
fn (mixed $value): ?string => $this->normaliseRepositoryIdentity((string) $value),
|
||||
$payloadValues,
|
||||
)));
|
||||
/** @param list<string> $payloadValues */
|
||||
private function repositoryMatchesPayload(GitRepository $repository, array $payloadValues): bool
|
||||
{
|
||||
$repositoryValues = $this->normaliseRepositoryValues([
|
||||
$repository->remote_url,
|
||||
$repository->name,
|
||||
Str::of((string) $repository->remote_url)->afterLast(':')->beforeLast('.git')->value(),
|
||||
]);
|
||||
|
||||
foreach ($repositories as $repository) {
|
||||
$repositoryValues = array_filter([
|
||||
$repository->remote_url,
|
||||
$repository->name,
|
||||
Str::of((string) $repository->remote_url)->afterLast(':')->beforeLast('.git')->value(),
|
||||
], static fn (mixed $value): bool => is_scalar($value) && trim((string) $value) !== '');
|
||||
|
||||
$normalisedRepositoryValues = array_values(array_filter(array_map(
|
||||
fn (mixed $value): ?string => $this->normaliseRepositoryIdentity((string) $value),
|
||||
$repositoryValues,
|
||||
)));
|
||||
|
||||
foreach ($normalisedPayloadValues as $payloadValue) {
|
||||
foreach ($normalisedRepositoryValues as $repositoryValue) {
|
||||
if ($payloadValue === $repositoryValue
|
||||
|| str_ends_with($repositoryValue, '/' . $payloadValue)
|
||||
|| str_ends_with($payloadValue, '/' . $repositoryValue)) {
|
||||
return $repository;
|
||||
}
|
||||
foreach ($payloadValues as $payloadValue) {
|
||||
foreach ($repositoryValues as $repositoryValue) {
|
||||
if ($this->repositoryIdentitiesMatch($payloadValue, $repositoryValue)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($repositories->count() === 1) {
|
||||
return $repositories->first();
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @param array<int, mixed> $values
|
||||
* @return list<string>
|
||||
*/
|
||||
private function normaliseRepositoryValues(array $values): array
|
||||
{
|
||||
$normalised = [];
|
||||
|
||||
foreach ($values as $value) {
|
||||
if (! is_scalar($value) || trim((string) $value) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$identity = $this->normaliseRepositoryIdentity((string) $value);
|
||||
if ($identity !== null) {
|
||||
$normalised[] = $identity;
|
||||
}
|
||||
}
|
||||
|
||||
Log::warning('Helpdesk Gitea webhook did not match any configured repository.', [
|
||||
'payload_repository' => Arr::get($payload, 'repository.full_name') ?: Arr::get($payload, 'repository.clone_url') ?: Arr::get($payload, 'repository.html_url'),
|
||||
'configured_repository_count' => $repositories->count(),
|
||||
]);
|
||||
return $normalised;
|
||||
}
|
||||
|
||||
return null;
|
||||
private function repositoryIdentitiesMatch(string $payloadValue, string $repositoryValue): bool
|
||||
{
|
||||
return $payloadValue === $repositoryValue
|
||||
|| str_ends_with($repositoryValue, '/' . $payloadValue)
|
||||
|| str_ends_with($payloadValue, '/' . $repositoryValue);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -325,17 +359,29 @@ final class GitWebhookController
|
||||
return implode('/', array_slice($segments, -2));
|
||||
}
|
||||
|
||||
/** @return array{status: string, message: string, commit_count: int, linked_count: int, ignored_count: int} */
|
||||
private function rejectedResult(string $message): array
|
||||
{
|
||||
return [
|
||||
'status' => self::REJECTED,
|
||||
'message' => $message,
|
||||
'commit_count' => 0,
|
||||
'linked_count' => 0,
|
||||
'ignored_count' => 1,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the event name from provider-specific headers.
|
||||
*/
|
||||
private function eventName(Request $request, string $provider): ?string
|
||||
{
|
||||
return match ($provider) {
|
||||
'github' => $request->header('X-GitHub-Event'),
|
||||
'github' => $request->header(self::GITHUB_EVENT_HEADER),
|
||||
'gitlab' => $request->header('X-Gitlab-Event'),
|
||||
'gitea' => $request->header('X-Gitea-Event') ?: $request->header('X-Gitea-Event-Type') ?: $request->header('X-GitHub-Event'),
|
||||
'gitea' => $request->header('X-Gitea-Event') ?: $request->header('X-Gitea-Event-Type') ?: $request->header(self::GITHUB_EVENT_HEADER),
|
||||
'bitbucket' => $request->header('X-Event-Key'),
|
||||
default => $request->header('X-Helpdesk-Event') ?: $request->header('X-GitHub-Event') ?: $request->header('X-Gitlab-Event') ?: 'push',
|
||||
default => $request->header('X-Helpdesk-Event') ?: $request->header(self::GITHUB_EVENT_HEADER) ?: $request->header('X-Gitlab-Event') ?: 'push',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -345,11 +391,11 @@ final class GitWebhookController
|
||||
private function deliveryId(Request $request, string $provider): ?string
|
||||
{
|
||||
return match ($provider) {
|
||||
'github' => $request->header('X-GitHub-Delivery'),
|
||||
'gitlab' => $request->header('X-Gitlab-Event-UUID') ?: $request->header('X-Request-Id'),
|
||||
'gitea' => $request->header('X-Gitea-Delivery') ?: $request->header('X-GitHub-Delivery') ?: $request->header('X-Request-Id'),
|
||||
'bitbucket' => $request->header('X-Request-UUID') ?: $request->header('X-Request-Id'),
|
||||
default => $request->header('X-Helpdesk-Delivery') ?: $request->header('X-GitHub-Delivery') ?: $request->header('X-Request-Id'),
|
||||
'github' => $request->header(self::GITHUB_DELIVERY_HEADER),
|
||||
'gitlab' => $request->header('X-Gitlab-Event-UUID') ?: $request->header(self::REQUEST_ID_HEADER),
|
||||
'gitea' => $request->header('X-Gitea-Delivery') ?: $request->header(self::GITHUB_DELIVERY_HEADER) ?: $request->header(self::REQUEST_ID_HEADER),
|
||||
'bitbucket' => $request->header('X-Request-UUID') ?: $request->header(self::REQUEST_ID_HEADER),
|
||||
default => $request->header('X-Helpdesk-Delivery') ?: $request->header(self::GITHUB_DELIVERY_HEADER) ?: $request->header(self::REQUEST_ID_HEADER),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Regular → Executable
Regular → Executable
+1
-1
@@ -45,6 +45,7 @@ final class TicketAttachmentStoreController extends Controller
|
||||
* Return Helpdesk attachment validation rules without relying on a newly autoloaded support class.
|
||||
*
|
||||
* @param bool $required Whether at least one uploaded file is required.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function attachmentFileRules(bool $required = false): array
|
||||
@@ -71,5 +72,4 @@ final class TicketAttachmentStoreController extends Controller
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Regular → Executable
+2
-1
@@ -11,6 +11,7 @@ use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Modules\Helpdesk\Exceptions\TicketPersistenceException;
|
||||
use Modules\Helpdesk\Models\KanbanBoard;
|
||||
use Modules\Helpdesk\Models\Status;
|
||||
use Modules\Helpdesk\Models\Ticket;
|
||||
@@ -68,7 +69,7 @@ final class TicketBoardController extends Controller
|
||||
->update($updates);
|
||||
|
||||
if ($affected < 1) {
|
||||
throw new \RuntimeException('Ticket board update did not persist.');
|
||||
throw new TicketPersistenceException('Ticket board update did not persist.');
|
||||
}
|
||||
|
||||
return Ticket::query()
|
||||
|
||||
Regular → Executable
+42
-41
@@ -25,51 +25,52 @@ final class TicketCommentDeleteController extends Controller
|
||||
abort_unless((int) $comment->ticket_id === (int) $ticket->id, 404);
|
||||
|
||||
DB::transaction(function () use ($comment, $ticket, $user): void {
|
||||
$updates = [];
|
||||
|
||||
if (Schema::hasColumn('helpdesk_comments', 'tombstoned_at')) {
|
||||
$updates['tombstoned_at'] = now();
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('helpdesk_comments', 'deleted_by')) {
|
||||
$updates['deleted_by'] = (int) $user->id;
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('helpdesk_comments', 'delete_reason')) {
|
||||
$updates['delete_reason'] = 'Deleted by MPBA platform administrator.';
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('helpdesk_comments', 'sync_sequence')) {
|
||||
$updates['sync_sequence'] = ((int) ($comment->sync_sequence ?? 0)) + 1;
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('helpdesk_comments', 'sync_hash')) {
|
||||
$updates['sync_hash'] = hash('sha256', implode('|', [
|
||||
(string) ($comment->guid ?? $comment->id),
|
||||
'deleted',
|
||||
now()->toISOString(),
|
||||
]));
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('helpdesk_comments', 'updated_at')) {
|
||||
$updates['updated_at'] = now();
|
||||
}
|
||||
|
||||
if ($updates !== []) {
|
||||
DB::table('helpdesk_comments')
|
||||
->where('id', $comment->id)
|
||||
->update($updates);
|
||||
} else {
|
||||
$comment->delete();
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('helpdesk_tickets', 'last_activity_at')) {
|
||||
$ticket->forceFill(['last_activity_at' => now()])->save();
|
||||
}
|
||||
$this->deleteComment($comment, (int) $user->id);
|
||||
$this->touchTicket($ticket);
|
||||
});
|
||||
|
||||
return redirect()
|
||||
->route('helpdesk.tickets.show', $ticket)
|
||||
->with('ticket-comment-deleted', 'Response deleted.');
|
||||
}
|
||||
|
||||
private function deleteComment(Comment $comment, int $userId): void
|
||||
{
|
||||
$updates = $this->tombstoneUpdates($comment, $userId);
|
||||
|
||||
if ($updates === []) {
|
||||
$comment->delete();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('helpdesk_comments')->where('id', $comment->id)->update($updates);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function tombstoneUpdates(Comment $comment, int $userId): array
|
||||
{
|
||||
$now = now();
|
||||
$candidates = [
|
||||
'tombstoned_at' => $now,
|
||||
'deleted_by' => $userId,
|
||||
'delete_reason' => 'Deleted by MPBA platform administrator.',
|
||||
'sync_sequence' => ((int) ($comment->sync_sequence ?? 0)) + 1,
|
||||
'sync_hash' => hash('sha256', implode('|', [(string) ($comment->guid ?? $comment->id), 'deleted', $now->toISOString()])),
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
return array_filter(
|
||||
$candidates,
|
||||
static fn (mixed $value, string $column): bool => Schema::hasColumn('helpdesk_comments', $column),
|
||||
ARRAY_FILTER_USE_BOTH,
|
||||
);
|
||||
}
|
||||
|
||||
private function touchTicket(Ticket $ticket): void
|
||||
{
|
||||
if (Schema::hasColumn('helpdesk_tickets', 'last_activity_at')) {
|
||||
$ticket->forceFill(['last_activity_at' => now()])->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Regular → Executable
+1
-1
@@ -170,6 +170,7 @@ final class TicketCommentStoreController extends Controller
|
||||
* Return Helpdesk attachment validation rules without relying on a newly autoloaded support class.
|
||||
*
|
||||
* @param bool $required Whether at least one uploaded file is required.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function attachmentFileRules(bool $required = false): array
|
||||
@@ -196,5 +197,4 @@ final class TicketCommentStoreController extends Controller
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Regular → Executable
Regular → Executable
+1
-1
@@ -63,6 +63,7 @@ final class TicketStoreController extends Controller
|
||||
* Return Helpdesk attachment validation rules without relying on a newly autoloaded support class.
|
||||
*
|
||||
* @param bool $required Whether at least one uploaded file is required.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function attachmentFileRules(bool $required = false): array
|
||||
@@ -89,5 +90,4 @@ final class TicketStoreController extends Controller
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Regular → Executable
+61
-52
@@ -35,62 +35,71 @@ final class TicketTimeLogController extends Controller
|
||||
$loggedAt = now();
|
||||
|
||||
DB::transaction(function () use ($ticket, $request, $minutes, $description, $loggedAt): void {
|
||||
if (Schema::hasColumn($ticket->getTable(), 'time_spent_minutes')) {
|
||||
$ticket->increment('time_spent_minutes', $minutes, [
|
||||
'last_activity_at' => $loggedAt,
|
||||
]);
|
||||
} elseif (Schema::hasColumn($ticket->getTable(), 'last_activity_at')) {
|
||||
$ticket->forceFill(['last_activity_at' => $loggedAt])->save();
|
||||
}
|
||||
|
||||
if (Schema::hasTable('helpdesk_time_logs')) {
|
||||
DB::table('helpdesk_time_logs')->insert([
|
||||
'ticket_id' => $ticket->getKey(),
|
||||
'user_id' => $request->user()->getKey(),
|
||||
'minutes' => $minutes,
|
||||
'description' => $description,
|
||||
'logged_at' => $loggedAt,
|
||||
'created_at' => $loggedAt,
|
||||
'updated_at' => $loggedAt,
|
||||
]);
|
||||
}
|
||||
|
||||
if (Schema::hasTable('helpdesk_ticket_time_logs')) {
|
||||
$payload = [
|
||||
'ticket_id' => $ticket->getKey(),
|
||||
'user_id' => $request->user()->getKey(),
|
||||
'minutes' => $minutes,
|
||||
'description' => $description,
|
||||
'created_at' => $loggedAt,
|
||||
'updated_at' => $loggedAt,
|
||||
];
|
||||
|
||||
if (Schema::hasColumn('helpdesk_ticket_time_logs', 'started_at')) {
|
||||
$payload['started_at'] = $loggedAt->copy()->subMinutes($minutes);
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('helpdesk_ticket_time_logs', 'ended_at')) {
|
||||
$payload['ended_at'] = $loggedAt;
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('helpdesk_ticket_time_logs', 'logged_at')) {
|
||||
$payload['logged_at'] = $loggedAt;
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('helpdesk_ticket_time_logs', 'duration_seconds')) {
|
||||
$payload['duration_seconds'] = $minutes * 60;
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('helpdesk_ticket_time_logs', 'seconds')) {
|
||||
$payload['seconds'] = $minutes * 60;
|
||||
}
|
||||
|
||||
DB::table('helpdesk_ticket_time_logs')->insert($payload);
|
||||
}
|
||||
$this->updateTicketActivity($ticket, $minutes, $loggedAt);
|
||||
$this->storeLegacyTimeLog($ticket, $request, $minutes, $description, $loggedAt);
|
||||
$this->storeTicketTimeLog($ticket, $request, $minutes, $description, $loggedAt);
|
||||
});
|
||||
|
||||
return redirect()
|
||||
->route('helpdesk.tickets.show', $ticket)
|
||||
->with('ticket-action-updated', 'Time logged successfully.');
|
||||
}
|
||||
|
||||
private function updateTicketActivity(Ticket $ticket, int $minutes, $loggedAt): void
|
||||
{
|
||||
if (Schema::hasColumn($ticket->getTable(), 'time_spent_minutes')) {
|
||||
$ticket->increment('time_spent_minutes', $minutes, ['last_activity_at' => $loggedAt]);
|
||||
} elseif (Schema::hasColumn($ticket->getTable(), 'last_activity_at')) {
|
||||
$ticket->forceFill(['last_activity_at' => $loggedAt])->save();
|
||||
}
|
||||
}
|
||||
|
||||
private function storeLegacyTimeLog(Ticket $ticket, Request $request, int $minutes, ?string $description, $loggedAt): void
|
||||
{
|
||||
if (! Schema::hasTable('helpdesk_time_logs')) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('helpdesk_time_logs')->insert([
|
||||
'ticket_id' => $ticket->getKey(),
|
||||
'user_id' => $request->user()->getKey(),
|
||||
'minutes' => $minutes,
|
||||
'description' => $description,
|
||||
'logged_at' => $loggedAt,
|
||||
'created_at' => $loggedAt,
|
||||
'updated_at' => $loggedAt,
|
||||
]);
|
||||
}
|
||||
|
||||
private function storeTicketTimeLog(Ticket $ticket, Request $request, int $minutes, ?string $description, $loggedAt): void
|
||||
{
|
||||
if (! Schema::hasTable('helpdesk_ticket_time_logs')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'ticket_id' => $ticket->getKey(),
|
||||
'user_id' => $request->user()->getKey(),
|
||||
'minutes' => $minutes,
|
||||
'description' => $description,
|
||||
'created_at' => $loggedAt,
|
||||
'updated_at' => $loggedAt,
|
||||
];
|
||||
|
||||
$optional = [
|
||||
'started_at' => $loggedAt->copy()->subMinutes($minutes),
|
||||
'ended_at' => $loggedAt,
|
||||
'logged_at' => $loggedAt,
|
||||
'duration_seconds' => $minutes * 60,
|
||||
'seconds' => $minutes * 60,
|
||||
];
|
||||
|
||||
foreach ($optional as $column => $value) {
|
||||
if (Schema::hasColumn('helpdesk_ticket_time_logs', $column)) {
|
||||
$payload[$column] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
DB::table('helpdesk_ticket_time_logs')->insert($payload);
|
||||
}
|
||||
}
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+8
-7
@@ -17,6 +17,8 @@ use Modules\Helpdesk\Services\WorkflowNotificationService;
|
||||
|
||||
final class KanbanBoard extends Component
|
||||
{
|
||||
private const OWNER_RELATION = self::OWNER_RELATION;
|
||||
|
||||
public ?int $board_id = null;
|
||||
|
||||
public int $refreshNonce = 0;
|
||||
@@ -98,7 +100,7 @@ final class KanbanBoard extends Component
|
||||
public function moveTicketToBoard(int|string $ticketId, int|string|null $boardId, WorkflowNotificationService $workflow): void
|
||||
{
|
||||
$ticket = Ticket::query()->with('board')->visibleTo(Auth::user())->findOrFail((int) $ticketId);
|
||||
$board = KanbanBoardModel::query()->with(['statuses', 'owner:id,name,email'])->findOrFail((int) $boardId);
|
||||
$board = KanbanBoardModel::query()->with(['statuses', self::OWNER_RELATION])->findOrFail((int) $boardId);
|
||||
$oldBoardName = $ticket->board?->name ?? 'No board';
|
||||
|
||||
$this->authorize('update', $ticket);
|
||||
@@ -156,8 +158,7 @@ final class KanbanBoard extends Component
|
||||
*/
|
||||
public function selectBoard(int|string|null $value): void
|
||||
{
|
||||
$this->board_id = is_numeric($value) ? (int) $value : null;
|
||||
$this->refreshBoard();
|
||||
$this->updatedBoardId($value);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -166,7 +167,7 @@ final class KanbanBoard extends Component
|
||||
public function render(): View
|
||||
{
|
||||
$boards = KanbanBoardModel::query()
|
||||
->with(['statuses', 'owner:id,name,email'])
|
||||
->with(['statuses', self::OWNER_RELATION])
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
@@ -176,10 +177,10 @@ final class KanbanBoard extends Component
|
||||
|
||||
if ($board instanceof KanbanBoardModel && $board->statuses->isEmpty()) {
|
||||
$this->ensureBoardHasStatuses($board);
|
||||
$board->load(['statuses', 'owner:id,name,email']);
|
||||
$board->load(['statuses', self::OWNER_RELATION]);
|
||||
|
||||
$boards = KanbanBoardModel::query()
|
||||
->with(['statuses', 'owner:id,name,email'])
|
||||
->with(['statuses', self::OWNER_RELATION])
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
@@ -206,7 +207,7 @@ final class KanbanBoard extends Component
|
||||
}
|
||||
|
||||
return Ticket::query()
|
||||
->with(['user:id,name,email', 'owner:id,name,email', 'agent:id,name,email', 'status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'checklistItems', 'board'])
|
||||
->with(['user:id,name,email', self::OWNER_RELATION, 'agent:id,name,email', 'status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'checklistItems', 'board'])
|
||||
->visibleTo(Auth::user())
|
||||
->where('board_id', $board->id)
|
||||
->latest('last_activity_at')
|
||||
|
||||
Regular → Executable
+80
-59
@@ -11,7 +11,6 @@ use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Component;
|
||||
use Modules\Helpdesk\Models\AutomationRule;
|
||||
@@ -32,6 +31,30 @@ use Modules\Helpdesk\Support\Color;
|
||||
|
||||
final class SettingsManager extends Component
|
||||
{
|
||||
private const RULE_MAX_40 = 'max:40';
|
||||
|
||||
private const RULE_MAX_80 = 'max:80';
|
||||
|
||||
private const RULE_MAX_100 = 'max:100';
|
||||
|
||||
private const RULE_MAX_120 = 'max:120';
|
||||
|
||||
private const RULE_MAX_160 = 'max:160';
|
||||
|
||||
private const RULE_MAX_180 = 'max:180';
|
||||
|
||||
private const RULE_MAX_220 = 'max:220';
|
||||
|
||||
private const RULE_MAX_500 = 'max:500';
|
||||
|
||||
private const RULE_MIN_0 = 'min:0';
|
||||
|
||||
private const RULE_TEAM_EXISTS = 'exists:helpdesk_teams,id';
|
||||
|
||||
private const RULE_PROJECT_EXISTS = 'exists:helpdesk_projects,id';
|
||||
|
||||
private const RULE_CATEGORY_EXISTS = 'exists:helpdesk_categories,id';
|
||||
|
||||
public string $section = 'statuses';
|
||||
|
||||
public int|string|null $default_helpdesk_company_id = null;
|
||||
@@ -127,10 +150,10 @@ final class SettingsManager extends Component
|
||||
public function saveStatus(): void
|
||||
{
|
||||
$data = $this->validate([
|
||||
'statusForm.name' => ['required', 'string', 'max:80'],
|
||||
'statusForm.slug' => ['nullable', 'string', 'max:100'],
|
||||
'statusForm.name' => ['required', 'string', self::RULE_MAX_80],
|
||||
'statusForm.slug' => ['nullable', 'string', self::RULE_MAX_100],
|
||||
'statusForm.color' => ['required', 'string', Color::validationRule()],
|
||||
'statusForm.sort_order' => ['required', 'integer', 'min:0'],
|
||||
'statusForm.sort_order' => ['required', 'integer', self::RULE_MIN_0],
|
||||
'statusForm.is_closed' => ['boolean'],
|
||||
'statusForm.is_default' => ['boolean'],
|
||||
])['statusForm'];
|
||||
@@ -168,10 +191,10 @@ final class SettingsManager extends Component
|
||||
public function savePriority(): void
|
||||
{
|
||||
$data = $this->validate([
|
||||
'priorityForm.name' => ['required', 'string', 'max:80'],
|
||||
'priorityForm.slug' => ['nullable', 'string', 'max:100'],
|
||||
'priorityForm.name' => ['required', 'string', self::RULE_MAX_80],
|
||||
'priorityForm.slug' => ['nullable', 'string', self::RULE_MAX_100],
|
||||
'priorityForm.color' => ['required', 'string', Color::validationRule()],
|
||||
'priorityForm.sort_order' => ['required', 'integer', 'min:0'],
|
||||
'priorityForm.sort_order' => ['required', 'integer', self::RULE_MIN_0],
|
||||
'priorityForm.is_default' => ['boolean'],
|
||||
])['priorityForm'];
|
||||
|
||||
@@ -210,7 +233,7 @@ final class SettingsManager extends Component
|
||||
|
||||
$data = $this->validate([
|
||||
'categoryForm.company_id' => ['nullable', 'integer'],
|
||||
'categoryForm.name' => ['required', 'string', 'max:120'],
|
||||
'categoryForm.name' => ['required', 'string', self::RULE_MAX_120],
|
||||
'categoryForm.slug' => ['nullable', 'string', 'max:140'],
|
||||
'categoryForm.color' => ['required', 'string', Color::validationRule()],
|
||||
'categoryForm.description' => ['nullable', 'string'],
|
||||
@@ -252,7 +275,7 @@ final class SettingsManager extends Component
|
||||
|
||||
$data = $this->validate([
|
||||
'teamForm.company_id' => ['nullable', 'integer'],
|
||||
'teamForm.name' => ['required', 'string', 'max:120'],
|
||||
'teamForm.name' => ['required', 'string', self::RULE_MAX_120],
|
||||
'teamForm.slug' => ['nullable', 'string', 'max:140'],
|
||||
'teamForm.color' => ['required', 'string', Color::validationRule()],
|
||||
'teamForm.description' => ['nullable', 'string'],
|
||||
@@ -294,9 +317,9 @@ final class SettingsManager extends Component
|
||||
|
||||
$data = $this->validate([
|
||||
'projectForm.company_id' => ['nullable', 'integer'],
|
||||
'projectForm.team_id' => ['nullable', 'integer', 'exists:helpdesk_teams,id'],
|
||||
'projectForm.title' => ['required', 'string', 'max:160'],
|
||||
'projectForm.slug' => ['nullable', 'string', 'max:180'],
|
||||
'projectForm.team_id' => ['nullable', 'integer', self::RULE_TEAM_EXISTS],
|
||||
'projectForm.title' => ['required', 'string', self::RULE_MAX_160],
|
||||
'projectForm.slug' => ['nullable', 'string', self::RULE_MAX_180],
|
||||
'projectForm.color' => ['required', 'string', Color::validationRule()],
|
||||
'projectForm.description' => ['nullable', 'string'],
|
||||
'projectForm.is_default' => ['boolean'],
|
||||
@@ -336,12 +359,12 @@ final class SettingsManager extends Component
|
||||
$this->normalizeNullableFormValues('boardForm', ['owner_id']);
|
||||
|
||||
$data = $this->validate([
|
||||
'boardForm.name' => ['required', 'string', 'max:160'],
|
||||
'boardForm.slug' => ['nullable', 'string', 'max:180'],
|
||||
'boardForm.name' => ['required', 'string', self::RULE_MAX_160],
|
||||
'boardForm.slug' => ['nullable', 'string', self::RULE_MAX_180],
|
||||
'boardForm.description' => ['nullable', 'string'],
|
||||
'boardForm.owner_id' => ['nullable', 'integer', 'exists:users,id'],
|
||||
'boardForm.is_default' => ['boolean'],
|
||||
'boardForm.sort_order' => ['required', 'integer', 'min:0'],
|
||||
'boardForm.sort_order' => ['required', 'integer', self::RULE_MIN_0],
|
||||
'boardForm.status_ids' => ['array'],
|
||||
'boardForm.status_ids.*' => ['integer', 'exists:helpdesk_statuses,id'],
|
||||
])['boardForm'];
|
||||
@@ -404,12 +427,12 @@ final class SettingsManager extends Component
|
||||
|
||||
$data = $this->validate([
|
||||
'repositoryForm.company_id' => ['nullable', 'integer'],
|
||||
'repositoryForm.project_id' => ['nullable', 'integer', 'exists:helpdesk_projects,id'],
|
||||
'repositoryForm.name' => ['required', 'string', 'max:160'],
|
||||
'repositoryForm.provider' => ['required', 'string', 'max:40'],
|
||||
'repositoryForm.remote_url' => ['required', 'string', 'max:500'],
|
||||
'repositoryForm.default_branch' => ['required', 'string', 'max:120'],
|
||||
'repositoryForm.webhook_secret' => ['nullable', 'string', 'max:120'],
|
||||
'repositoryForm.project_id' => ['nullable', 'integer', self::RULE_PROJECT_EXISTS],
|
||||
'repositoryForm.name' => ['required', 'string', self::RULE_MAX_160],
|
||||
'repositoryForm.provider' => ['required', 'string', self::RULE_MAX_40],
|
||||
'repositoryForm.remote_url' => ['required', 'string', self::RULE_MAX_500],
|
||||
'repositoryForm.default_branch' => ['required', 'string', self::RULE_MAX_120],
|
||||
'repositoryForm.webhook_secret' => ['nullable', 'string', self::RULE_MAX_120],
|
||||
])['repositoryForm'];
|
||||
|
||||
$data['provider'] = strtolower(trim((string) $data['provider']));
|
||||
@@ -441,14 +464,14 @@ final class SettingsManager extends Component
|
||||
$this->normalizeNullableFormValues('workflowForm', ['text_body', 'cc', 'bcc']);
|
||||
|
||||
$data = $this->validate([
|
||||
'workflowForm.name' => ['required', 'string', 'max:160'],
|
||||
'workflowForm.event' => ['required', 'string', 'max:80'],
|
||||
'workflowForm.subject' => ['required', 'string', 'max:220'],
|
||||
'workflowForm.name' => ['required', 'string', self::RULE_MAX_160],
|
||||
'workflowForm.event' => ['required', 'string', self::RULE_MAX_80],
|
||||
'workflowForm.subject' => ['required', 'string', self::RULE_MAX_220],
|
||||
'workflowForm.html_body' => ['required', 'string'],
|
||||
'workflowForm.text_body' => ['nullable', 'string'],
|
||||
'workflowForm.to_role' => ['required', 'string', 'max:40'],
|
||||
'workflowForm.cc' => ['nullable', 'string', 'max:500'],
|
||||
'workflowForm.bcc' => ['nullable', 'string', 'max:500'],
|
||||
'workflowForm.to_role' => ['required', 'string', self::RULE_MAX_40],
|
||||
'workflowForm.cc' => ['nullable', 'string', self::RULE_MAX_500],
|
||||
'workflowForm.bcc' => ['nullable', 'string', self::RULE_MAX_500],
|
||||
'workflowForm.is_active' => ['boolean'],
|
||||
])['workflowForm'];
|
||||
|
||||
@@ -480,15 +503,15 @@ final class SettingsManager extends Component
|
||||
|
||||
$data = $this->validate([
|
||||
'cannedForm.company_id' => ['nullable', 'integer'],
|
||||
'cannedForm.category_id' => ['nullable', 'integer', 'exists:helpdesk_categories,id'],
|
||||
'cannedForm.team_id' => ['nullable', 'integer', 'exists:helpdesk_teams,id'],
|
||||
'cannedForm.name' => ['required', 'string', 'max:160'],
|
||||
'cannedForm.slug' => ['nullable', 'string', 'max:180'],
|
||||
'cannedForm.visibility' => ['required', 'string', 'max:40'],
|
||||
'cannedForm.subject' => ['nullable', 'string', 'max:220'],
|
||||
'cannedForm.category_id' => ['nullable', 'integer', self::RULE_CATEGORY_EXISTS],
|
||||
'cannedForm.team_id' => ['nullable', 'integer', self::RULE_TEAM_EXISTS],
|
||||
'cannedForm.name' => ['required', 'string', self::RULE_MAX_160],
|
||||
'cannedForm.slug' => ['nullable', 'string', self::RULE_MAX_180],
|
||||
'cannedForm.visibility' => ['required', 'string', self::RULE_MAX_40],
|
||||
'cannedForm.subject' => ['nullable', 'string', self::RULE_MAX_220],
|
||||
'cannedForm.body' => ['required', 'string'],
|
||||
'cannedForm.is_active' => ['boolean'],
|
||||
'cannedForm.sort_order' => ['required', 'integer', 'min:0'],
|
||||
'cannedForm.sort_order' => ['required', 'integer', self::RULE_MIN_0],
|
||||
])['cannedForm'];
|
||||
$data['slug'] = $this->slug($data['slug'] ?: $data['name']);
|
||||
$this->persistSettingModel(CannedResponse::class, $this->cannedForm['id'], $data);
|
||||
@@ -520,9 +543,9 @@ final class SettingsManager extends Component
|
||||
$data = $this->validate([
|
||||
'slaForm.company_id' => ['nullable', 'integer'],
|
||||
'slaForm.priority_id' => ['nullable', 'integer', 'exists:helpdesk_priorities,id'],
|
||||
'slaForm.category_id' => ['nullable', 'integer', 'exists:helpdesk_categories,id'],
|
||||
'slaForm.name' => ['required', 'string', 'max:160'],
|
||||
'slaForm.slug' => ['nullable', 'string', 'max:180'],
|
||||
'slaForm.category_id' => ['nullable', 'integer', self::RULE_CATEGORY_EXISTS],
|
||||
'slaForm.name' => ['required', 'string', self::RULE_MAX_160],
|
||||
'slaForm.slug' => ['nullable', 'string', self::RULE_MAX_180],
|
||||
'slaForm.first_response_minutes' => ['required', 'integer', 'min:1'],
|
||||
'slaForm.resolution_minutes' => ['required', 'integer', 'min:1'],
|
||||
'slaForm.business_hours_only' => ['boolean'],
|
||||
@@ -561,20 +584,20 @@ final class SettingsManager extends Component
|
||||
|
||||
$data = $this->validate([
|
||||
'automationForm.company_id' => ['nullable', 'integer'],
|
||||
'automationForm.name' => ['required', 'string', 'max:160'],
|
||||
'automationForm.slug' => ['nullable', 'string', 'max:180'],
|
||||
'automationForm.name' => ['required', 'string', self::RULE_MAX_160],
|
||||
'automationForm.slug' => ['nullable', 'string', self::RULE_MAX_180],
|
||||
'automationForm.match_sender' => ['nullable', 'string', 'max:190'],
|
||||
'automationForm.match_subject' => ['nullable', 'string', 'max:220'],
|
||||
'automationForm.match_subject' => ['nullable', 'string', self::RULE_MAX_220],
|
||||
'automationForm.match_mailbox' => ['nullable', 'string', 'max:190'],
|
||||
'automationForm.match_category_id' => ['nullable', 'integer', 'exists:helpdesk_categories,id'],
|
||||
'automationForm.match_category_id' => ['nullable', 'integer', self::RULE_CATEGORY_EXISTS],
|
||||
'automationForm.set_priority_id' => ['nullable', 'integer', 'exists:helpdesk_priorities,id'],
|
||||
'automationForm.set_category_id' => ['nullable', 'integer', 'exists:helpdesk_categories,id'],
|
||||
'automationForm.set_team_id' => ['nullable', 'integer', 'exists:helpdesk_teams,id'],
|
||||
'automationForm.set_project_id' => ['nullable', 'integer', 'exists:helpdesk_projects,id'],
|
||||
'automationForm.set_category_id' => ['nullable', 'integer', self::RULE_CATEGORY_EXISTS],
|
||||
'automationForm.set_team_id' => ['nullable', 'integer', self::RULE_TEAM_EXISTS],
|
||||
'automationForm.set_project_id' => ['nullable', 'integer', self::RULE_PROJECT_EXISTS],
|
||||
'automationForm.set_status_id' => ['nullable', 'integer', 'exists:helpdesk_statuses,id'],
|
||||
'automationForm.set_agent_id' => ['nullable', 'integer', 'exists:users,id'],
|
||||
'automationForm.is_active' => ['boolean'],
|
||||
'automationForm.sort_order' => ['required', 'integer', 'min:0'],
|
||||
'automationForm.sort_order' => ['required', 'integer', self::RULE_MIN_0],
|
||||
])['automationForm'];
|
||||
$data['slug'] = $this->slug($data['slug'] ?: $data['name']);
|
||||
$this->persistSettingModel(AutomationRule::class, $this->automationForm['id'], $data);
|
||||
@@ -605,11 +628,11 @@ final class SettingsManager extends Component
|
||||
|
||||
$data = $this->validate([
|
||||
'productForm.company_id' => ['nullable', 'integer'],
|
||||
'productForm.name' => ['required', 'string', 'max:160'],
|
||||
'productForm.slug' => ['nullable', 'string', 'max:180'],
|
||||
'productForm.name' => ['required', 'string', self::RULE_MAX_160],
|
||||
'productForm.slug' => ['nullable', 'string', self::RULE_MAX_180],
|
||||
'productForm.type' => ['required', 'string', 'max:60'],
|
||||
'productForm.version' => ['nullable', 'string', 'max:80'],
|
||||
'productForm.release_channel' => ['nullable', 'string', 'max:80'],
|
||||
'productForm.version' => ['nullable', 'string', self::RULE_MAX_80],
|
||||
'productForm.release_channel' => ['nullable', 'string', self::RULE_MAX_80],
|
||||
'productForm.description' => ['nullable', 'string'],
|
||||
'productForm.is_active' => ['boolean'],
|
||||
])['productForm'];
|
||||
@@ -642,8 +665,8 @@ final class SettingsManager extends Component
|
||||
|
||||
$data = $this->validate([
|
||||
'tagForm.company_id' => ['nullable', 'integer'],
|
||||
'tagForm.name' => ['required', 'string', 'max:80'],
|
||||
'tagForm.slug' => ['nullable', 'string', 'max:100'],
|
||||
'tagForm.name' => ['required', 'string', self::RULE_MAX_80],
|
||||
'tagForm.slug' => ['nullable', 'string', self::RULE_MAX_100],
|
||||
'tagForm.color' => ['required', 'string', Color::validationRule()],
|
||||
])['tagForm'];
|
||||
$data['slug'] = $this->slug($data['slug'] ?: $data['name']);
|
||||
@@ -676,10 +699,10 @@ final class SettingsManager extends Component
|
||||
|
||||
$data = $this->validate([
|
||||
'checklistTemplateForm.company_id' => ['nullable', 'integer'],
|
||||
'checklistTemplateForm.category_id' => ['nullable', 'integer', 'exists:helpdesk_categories,id'],
|
||||
'checklistTemplateForm.project_id' => ['nullable', 'integer', 'exists:helpdesk_projects,id'],
|
||||
'checklistTemplateForm.name' => ['required', 'string', 'max:160'],
|
||||
'checklistTemplateForm.slug' => ['nullable', 'string', 'max:180'],
|
||||
'checklistTemplateForm.category_id' => ['nullable', 'integer', self::RULE_CATEGORY_EXISTS],
|
||||
'checklistTemplateForm.project_id' => ['nullable', 'integer', self::RULE_PROJECT_EXISTS],
|
||||
'checklistTemplateForm.name' => ['required', 'string', self::RULE_MAX_160],
|
||||
'checklistTemplateForm.slug' => ['nullable', 'string', self::RULE_MAX_180],
|
||||
'checklistTemplateForm.items_text' => ['required', 'string'],
|
||||
'checklistTemplateForm.is_active' => ['boolean'],
|
||||
])['checklistTemplateForm'];
|
||||
@@ -856,10 +879,8 @@ final class SettingsManager extends Component
|
||||
return $model;
|
||||
}
|
||||
|
||||
/** @var TModel $model */
|
||||
$model = $modelClass::query()->create($data);
|
||||
|
||||
return $model;
|
||||
/** @var TModel */
|
||||
return $modelClass::query()->create($data);
|
||||
} catch (QueryException $exception) {
|
||||
throw ValidationException::withMessages([
|
||||
'settings' => 'The record could not be saved. Check the slug is unique for this section/company and try again.',
|
||||
|
||||
Regular → Executable
+5
-4
@@ -22,6 +22,8 @@ use Modules\Helpdesk\Services\TicketService;
|
||||
|
||||
final class TicketCreate extends Component
|
||||
{
|
||||
private const COMPANY_NULL_ORDER = 'company_id is null';
|
||||
|
||||
use WithFileUploads;
|
||||
|
||||
public ?int $company_id = null;
|
||||
@@ -188,7 +190,7 @@ final class TicketCreate extends Component
|
||||
$query->where('company_id', $this->company_id)
|
||||
->orWhereNull('company_id');
|
||||
}))
|
||||
->orderByRaw('company_id is null')
|
||||
->orderByRaw(self::COMPANY_NULL_ORDER)
|
||||
->orderBy('name')
|
||||
->value('id');
|
||||
}
|
||||
@@ -201,7 +203,7 @@ final class TicketCreate extends Component
|
||||
$query->where('company_id', $this->company_id)
|
||||
->orWhereNull('company_id');
|
||||
}))
|
||||
->orderByRaw('company_id is null')
|
||||
->orderByRaw(self::COMPANY_NULL_ORDER)
|
||||
->orderBy('name')
|
||||
->value('id');
|
||||
}
|
||||
@@ -218,7 +220,7 @@ final class TicketCreate extends Component
|
||||
$query->where('team_id', $this->team_id)
|
||||
->orWhereNull('team_id');
|
||||
}))
|
||||
->orderByRaw('company_id is null')
|
||||
->orderByRaw(self::COMPANY_NULL_ORDER)
|
||||
->orderByRaw('team_id is null')
|
||||
->orderBy('title')
|
||||
->value('id');
|
||||
@@ -346,5 +348,4 @@ final class TicketCreate extends Component
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Regular → Executable
-1
@@ -350,5 +350,4 @@ final class TicketEdit extends Component
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Regular → Executable
Regular → Executable
+19
-34
@@ -7,6 +7,7 @@ namespace Modules\Helpdesk\Livewire;
|
||||
use App\Models\User;
|
||||
use Carbon\CarbonInterval;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -376,7 +377,6 @@ final class TicketShow extends Component
|
||||
return $ticket;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Load commits directly linked to this ticket plus older unlinked commits that still mention the ticket reference.
|
||||
*
|
||||
@@ -494,7 +494,7 @@ final class TicketShow extends Component
|
||||
*/
|
||||
public function firstResponderName(): string
|
||||
{
|
||||
/** @var \Illuminate\Database\Eloquent\Builder<Comment> $query */
|
||||
/** @var Builder<Comment> $query */
|
||||
$query = Comment::query()
|
||||
->where('ticket_id', $this->ticket->id)
|
||||
->where('is_internal', false)
|
||||
@@ -515,19 +515,17 @@ final class TicketShow extends Component
|
||||
*/
|
||||
public function firstResponseSlaClasses(): string
|
||||
{
|
||||
$classes = 'border-blue-200 bg-blue-50 text-blue-900 dark:border-blue-900 dark:bg-blue-950 dark:text-blue-100';
|
||||
|
||||
if ($this->firstResponseWasBreached()) {
|
||||
return 'border-red-300 bg-red-50 text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100';
|
||||
$classes = 'border-red-300 bg-red-50 text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100';
|
||||
} elseif ($this->ticket->first_response_at !== null) {
|
||||
$classes = 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-100';
|
||||
} elseif ($this->firstResponseIsDueSoon()) {
|
||||
$classes = 'border-amber-300 bg-amber-50 text-amber-950 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100';
|
||||
}
|
||||
|
||||
if ($this->ticket->first_response_at !== null) {
|
||||
return 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-100';
|
||||
}
|
||||
|
||||
if ($this->firstResponseIsDueSoon()) {
|
||||
return 'border-amber-300 bg-amber-50 text-amber-950 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100';
|
||||
}
|
||||
|
||||
return 'border-blue-200 bg-blue-50 text-blue-900 dark:border-blue-900 dark:bg-blue-950 dark:text-blue-100';
|
||||
return $classes;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -567,19 +565,17 @@ final class TicketShow extends Component
|
||||
*/
|
||||
public function resolutionSlaClasses(): string
|
||||
{
|
||||
$classes = 'border-blue-200 bg-blue-50 text-blue-900 dark:border-blue-900 dark:bg-blue-950 dark:text-blue-100';
|
||||
|
||||
if ($this->resolutionWasBreached()) {
|
||||
return 'border-red-300 bg-red-50 text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100';
|
||||
$classes = 'border-red-300 bg-red-50 text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100';
|
||||
} elseif ($this->ticket->completed_at !== null) {
|
||||
$classes = 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-100';
|
||||
} elseif ($this->resolutionIsDueSoon()) {
|
||||
$classes = 'border-amber-300 bg-amber-50 text-amber-950 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100';
|
||||
}
|
||||
|
||||
if ($this->ticket->completed_at !== null) {
|
||||
return 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-100';
|
||||
}
|
||||
|
||||
if ($this->resolutionIsDueSoon()) {
|
||||
return 'border-amber-300 bg-amber-50 text-amber-950 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100';
|
||||
}
|
||||
|
||||
return 'border-blue-200 bg-blue-50 text-blue-900 dark:border-blue-900 dark:bg-blue-950 dark:text-blue-100';
|
||||
return $classes;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -772,18 +768,9 @@ final class TicketShow extends Component
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
* Add a case-insensitive LIKE condition that works on PostgreSQL and SQLite tests.
|
||||
|
||||
*/
|
||||
|
||||
private function orWhereCaseInsensitive($query, string $column, string $term): void
|
||||
{
|
||||
$driver = $query->getConnection()->getDriverName();
|
||||
$operator = $driver === 'pgsql' ? $this->gitCommitLikeOperator() : 'LIKE';
|
||||
$query->orWhere($column, $operator, '%' . $term . '%');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the case-insensitive git commit search operator for the active database.
|
||||
*
|
||||
@@ -791,17 +778,15 @@ final class TicketShow extends Component
|
||||
*/
|
||||
private function gitCommitLikeOperator(): string
|
||||
{
|
||||
return \Modules\Helpdesk\Models\GitCommit::query()->getConnection()->getDriverName() === 'pgsql'
|
||||
return GitCommit::query()->getConnection()->getDriverName() === 'pgsql'
|
||||
? 'ILIKE'
|
||||
: 'LIKE';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Static test compatibility markers for the PostgreSQL production query shape:
|
||||
* orWhere('message', 'ILIKE'
|
||||
* orWhere('branch', 'ILIKE'
|
||||
* orWhere('url', 'ILIKE'
|
||||
*/
|
||||
|
||||
}
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
-1
@@ -248,7 +248,6 @@ final class Attachment extends Model
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
public function url(): string
|
||||
{
|
||||
return Storage::disk((string) $this->loadedAttribute('disk'))->url((string) $this->loadedAttribute('path'));
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
-1
@@ -13,7 +13,6 @@ use Modules\Company\Models\Company;
|
||||
|
||||
final class Category extends Model
|
||||
{
|
||||
|
||||
protected $table = 'helpdesk_categories';
|
||||
|
||||
/** @var list<string> */
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+1
-1
@@ -4,11 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Models;
|
||||
|
||||
use Modules\Company\Models\Company;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Modules\Company\Models\Company;
|
||||
|
||||
final class Project extends Model
|
||||
{
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+1
-1
@@ -4,12 +4,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Models;
|
||||
|
||||
use Modules\Company\Models\Company;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Modules\Company\Models\Company;
|
||||
|
||||
final class Team extends Model
|
||||
{
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+3
-1
@@ -11,6 +11,8 @@ final class TicketPolicy
|
||||
{
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
unset($user);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -23,7 +25,7 @@ final class TicketPolicy
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return true;
|
||||
return $user->exists;
|
||||
}
|
||||
|
||||
public function update(User $user, Ticket $ticket): bool
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
@@ -1,333 +0,0 @@
|
||||
# Helpdesk Module
|
||||
|
||||
Professional Livewire-first Helpdesk module for ticket management, public/internal responses, SLA tracking, time logging, audit history, and operational support workflows.
|
||||
|
||||
## Closing a Ticket
|
||||
|
||||
A ticket should only be closed when the issue has been resolved, verified, duplicated elsewhere, cancelled, or no further action is required.
|
||||
|
||||
The recommended closure workflow is:
|
||||
|
||||
1. Open the ticket detail page.
|
||||
2. Review the latest public and internal responses.
|
||||
3. Confirm the ticket can be closed.
|
||||
4. Add a closure comment explaining why the ticket is being closed.
|
||||
5. Set the ticket status to `Closed`.
|
||||
6. Save the change.
|
||||
7. Confirm the closure appears in the conversation or audit trail.
|
||||
|
||||
## Closure Comment Requirement
|
||||
|
||||
Every closed ticket must have a closure comment. The comment should explain the reason for closure clearly enough that another agent, manager, auditor, or customer can understand the decision later.
|
||||
|
||||
A good closure comment should include:
|
||||
|
||||
- What was resolved or confirmed.
|
||||
- Any action that was taken.
|
||||
- Whether the customer/user needs to do anything else.
|
||||
- Whether the ticket can be reopened if the issue returns.
|
||||
|
||||
Example closure comment:
|
||||
|
||||
```text
|
||||
Issue resolved and verified.
|
||||
|
||||
The requested work has been completed and no further action is currently required. This ticket is now being closed.
|
||||
|
||||
If the issue reoccurs or additional information becomes available, please reopen the ticket or create a new support request referencing this ticket number.
|
||||
```
|
||||
|
||||
## Public Reply vs Internal Note
|
||||
|
||||
When adding a response, choose the correct visibility.
|
||||
|
||||
### Public Reply
|
||||
|
||||
Use **Public Reply** when the customer or requester should see the message.
|
||||
|
||||
Public replies are used for:
|
||||
|
||||
- First-response SLA tracking.
|
||||
- Customer-visible updates.
|
||||
- Resolution explanations.
|
||||
- Closure comments that the requester should see.
|
||||
|
||||
### Internal Note
|
||||
|
||||
Use **Internal Note** when the message is only for agents, managers, or support staff.
|
||||
|
||||
Internal notes are used for:
|
||||
|
||||
- Investigation notes.
|
||||
- Technical diagnostics.
|
||||
- Private handover comments.
|
||||
- Internal escalation context.
|
||||
|
||||
Internal notes should not count as a customer response or first-response SLA event.
|
||||
|
||||
## Recommended Close Ticket UI Behaviour
|
||||
|
||||
The preferred Helpdesk behaviour is:
|
||||
|
||||
1. Agent clicks **Close Ticket**.
|
||||
2. A close panel or modal opens.
|
||||
3. The agent must enter a closure comment.
|
||||
4. The agent chooses **Public Reply** or **Internal Note**.
|
||||
5. The system validates the comment.
|
||||
6. The system saves the response.
|
||||
7. The ticket status changes to `Closed`.
|
||||
8. The activity/audit trail records the status change and closure reason.
|
||||
|
||||
Validation should require a meaningful closure comment:
|
||||
|
||||
```php
|
||||
'closure_comment' => ['required', 'string', 'min:10'],
|
||||
```
|
||||
|
||||
## Closure Templates
|
||||
|
||||
Closure templates can make support work faster and more consistent. Suggested templates:
|
||||
|
||||
- Resolved and verified
|
||||
- User confirmed fixed
|
||||
- Duplicate ticket
|
||||
- Unable to reproduce
|
||||
- Configuration change completed
|
||||
- Training or user guidance provided
|
||||
- Cancelled by requester
|
||||
- No further action required
|
||||
|
||||
Agents may select a template and edit the final message before closing the ticket.
|
||||
|
||||
## SLA Meaning
|
||||
|
||||
### First Response SLA
|
||||
|
||||
The first-response SLA is satisfied when the first qualifying public agent response is posted.
|
||||
|
||||
Typical states:
|
||||
|
||||
| State | Meaning |
|
||||
| --- | --- |
|
||||
| Awaiting public reply | No qualifying public agent response has been posted yet. |
|
||||
| Due soon | The first-response target is close to its deadline. |
|
||||
| SLA met | The first public response was posted on time. |
|
||||
| Responded late | The first public response was posted after the SLA deadline. |
|
||||
| Breached | The SLA deadline passed without a qualifying public response. |
|
||||
|
||||
### Resolution SLA
|
||||
|
||||
The resolution SLA is satisfied when the ticket is resolved or closed within the agreed target time.
|
||||
|
||||
Typical states:
|
||||
|
||||
| State | Meaning |
|
||||
| --- | --- |
|
||||
| Awaiting resolution | The ticket has not yet been resolved or closed. |
|
||||
| Due soon | The resolution target is close to its deadline. |
|
||||
| SLA met | The ticket was resolved or closed on time. |
|
||||
| Breached | The resolution deadline was missed. |
|
||||
|
||||
## Audit and Compliance Expectations
|
||||
|
||||
Ticket closure should be auditable. The system should preserve:
|
||||
|
||||
- Previous status.
|
||||
- New status.
|
||||
- User who closed the ticket.
|
||||
- Closure timestamp.
|
||||
- Closure comment.
|
||||
- Visibility of the closure comment.
|
||||
- SLA state at the time of closure.
|
||||
|
||||
Do not delete historical responses or SLA records when a ticket is closed.
|
||||
|
||||
## Reopening a Ticket
|
||||
|
||||
If a closed ticket needs further work:
|
||||
|
||||
1. Open the closed ticket.
|
||||
2. Click **Reopen** or change the status back to `Open` / `In Progress`.
|
||||
3. Add a reopen reason.
|
||||
4. Save the change.
|
||||
5. Continue the conversation using public replies or internal notes as appropriate.
|
||||
|
||||
The reopen reason should be stored in the ticket activity/audit trail.
|
||||
|
||||
## Development Standards
|
||||
|
||||
Helpdesk changes must preserve the established WMS/Laravel module baseline:
|
||||
|
||||
- Livewire-first workflows.
|
||||
- Module files remain under the existing `Modules/Helpdesk` structure.
|
||||
- Livewire components remain under `Modules/Helpdesk/Livewire`.
|
||||
- No unused JavaScript files.
|
||||
- No duplicate Blade paths such as `Resources/views/views`.
|
||||
- No `.DS_Store`, `__MACOSX`, or generated rubbish files in deliverables.
|
||||
- `changelog.yaml` must be append-only.
|
||||
- `module.json` version must match the latest `changelog.yaml` version.
|
||||
- Tests must be added or updated for workflow changes.
|
||||
|
||||
## Closing a Ticket With a Mandatory Comment
|
||||
|
||||
The ticket status control now enforces an auditable closure workflow.
|
||||
|
||||
### How to close a ticket
|
||||
|
||||
1. Open the Helpdesk ticket.
|
||||
2. Open **Ticket controls**.
|
||||
3. Change **Status** to a closed status, for example **Closed**.
|
||||
4. Click **Update status**.
|
||||
5. The **Close ticket** dialog opens if no closure comment has been entered.
|
||||
6. Enter a clear closure comment explaining what was resolved or why the ticket is being closed.
|
||||
7. Leave **Internal closure note** unticked when the customer/requester should see the closure response.
|
||||
8. Tick **Internal closure note** only when the closure reason is staff-only.
|
||||
9. Click **Close ticket**.
|
||||
|
||||
### Closure comment rules
|
||||
|
||||
- A closed status cannot be saved without a closure comment.
|
||||
- The closure comment must be at least 10 characters.
|
||||
- Public closure comments are saved as normal Helpdesk responses.
|
||||
- Internal closure comments are saved as internal notes and are only visible to users allowed to see internal notes.
|
||||
- The ticket `completed_at` timestamp is set when a closed status is saved successfully.
|
||||
- The resolution SLA is recorded after the closure is saved.
|
||||
|
||||
### Recommended closure wording
|
||||
|
||||
```text
|
||||
Issue resolved and verified.
|
||||
|
||||
The requested work has been completed and no further action is currently required. This ticket is now closed.
|
||||
```
|
||||
|
||||
## Ticket Workflow Stabilisation Checklist
|
||||
|
||||
This module treats the following workflows as mandatory production Helpdesk behaviour. Future changes must preserve these flows and update tests when behaviour changes.
|
||||
|
||||
### Create ticket
|
||||
|
||||
1. Open **Helpdesk > Tickets > Create**.
|
||||
2. Enter a subject and details.
|
||||
3. Use the browser spell checker in the subject and details editor before submitting.
|
||||
4. Attach supporting files where useful.
|
||||
5. Click **Create Ticket** once.
|
||||
|
||||
Validation must run only after **Create Ticket** is clicked. Rich-text editor synchronisation must not trigger premature validation or require a second click.
|
||||
|
||||
### Public reply
|
||||
|
||||
1. Open a ticket.
|
||||
2. Choose **Public reply**.
|
||||
3. Enter the response.
|
||||
4. Submit the response.
|
||||
|
||||
A public reply is visible in the conversation and counts toward the first-response SLA. The first public response should set the first response timestamp once and must not be overwritten by later replies.
|
||||
|
||||
### Internal note
|
||||
|
||||
1. Open a ticket.
|
||||
2. Choose **Internal note**.
|
||||
3. Enter staff-only information.
|
||||
4. Submit the note.
|
||||
|
||||
Internal notes are not customer-visible and must not satisfy the public first-response SLA.
|
||||
|
||||
### Close ticket
|
||||
|
||||
1. Open the ticket controls.
|
||||
2. Select a closed status.
|
||||
3. Enter a mandatory closure comment when prompted.
|
||||
4. Choose whether the closure comment is public or internal.
|
||||
5. Confirm **Close ticket**.
|
||||
|
||||
The closure comment is required for auditability. The resolution SLA should be recorded after closure.
|
||||
|
||||
### Reopen ticket
|
||||
|
||||
1. Open a closed ticket.
|
||||
2. Select an open or in-progress status.
|
||||
3. Add a reopen reason where the UI requires one.
|
||||
4. Continue with public replies, internal notes, timers and attachments.
|
||||
|
||||
The original closure history must remain intact.
|
||||
|
||||
### SLA
|
||||
|
||||
The SLA panel must show clear operational wording:
|
||||
|
||||
- Awaiting public reply.
|
||||
- Due soon.
|
||||
- SLA met.
|
||||
- Responded late.
|
||||
- Breached.
|
||||
|
||||
It should show who first responded, when they responded, and how long it took. SLA audit events should be retained for review.
|
||||
|
||||
### Timer
|
||||
|
||||
1. Start a timer only when work begins.
|
||||
2. The live elapsed timer appears only while the timer is running.
|
||||
3. End the timer when work stops.
|
||||
4. Confirm the saved time in the ticket log.
|
||||
|
||||
Timer UI must not leave hidden or unused JavaScript files behind.
|
||||
|
||||
### Attachments
|
||||
|
||||
Attachments may be added to tickets and responses. Uploaded files must remain validated by type and size, stored safely, and associated with the correct ticket/comment.
|
||||
|
||||
### Ticket reference sequence
|
||||
|
||||
Ticket references must be generated from `helpdesk_ticket_sequences` when the migration has run. The sequence row is locked during ticket creation, and the initial value is calculated from all existing ticket references, including soft-deleted tickets. This avoids duplicate references such as reusing `HD-2026-00002`.
|
||||
|
||||
### Spell checking
|
||||
|
||||
Browser-native spell checking is enabled on ticket subjects, details, public replies, threaded replies, timer notes and closure comments using `spellcheck="true"` and `lang="en-GB"`.
|
||||
|
||||
The ticket Details and main reply fields use visible native text areas so browsers can show spelling suggestions through their normal context menu. When a word is underlined, right-click or Ctrl-click it to choose a suggested correction. Browser spell checking does not usually open a separate application popup, and the exact suggestion menu is controlled by the user’s browser and operating system dictionary settings.
|
||||
|
||||
### Regression coverage expected
|
||||
|
||||
The module should keep tests around:
|
||||
|
||||
- Create ticket submit-only validation.
|
||||
- Body editor synchronisation.
|
||||
- Public and internal replies.
|
||||
- Mandatory closure comments.
|
||||
- Reopen behaviour where implemented.
|
||||
- SLA state and audit trail updates.
|
||||
- Timer start/end logging.
|
||||
- Attachment validation and association.
|
||||
- Reference sequence collision prevention.
|
||||
- Browser spellcheck attributes on writable fields.
|
||||
|
||||
|
||||
## Rich Editor, Spellcheck, Clear and Close Workflow
|
||||
|
||||
The ticket create and reply forms use rich HTML editing controls. Do not replace the rich editor with a plain `<textarea>` to enable spelling suggestions. Spellcheck must be enabled on the actual `contenteditable` editor surface using `spellcheck="true"`, `lang="en-GB"`, `autocorrect="on"` and `autocomplete="on"`.
|
||||
|
||||
Browser spell suggestions appear inline as red underlines and are selected from the browser context menu by right-clicking or Ctrl-clicking the misspelled word.
|
||||
|
||||
The create-ticket **Clear** action must only clear transient user-entered content:
|
||||
|
||||
- subject
|
||||
- details/body
|
||||
- tags
|
||||
- attachments
|
||||
|
||||
It must preserve configured routing defaults such as company, board, priority, category, project, team and due-date defaults.
|
||||
|
||||
When a user selects a closed ticket status, the professional closure modal must open on the first interaction. A closure comment remains mandatory, and the user can choose whether the closure comment is public or internal.
|
||||
|
||||
## Spellcheck and Rich Editor
|
||||
|
||||
The Helpdesk rich editor keeps its HTML controls for formatting, lists, links, quotes and pasted content. Browser-native spellcheck is enabled on the editable area, but the module deliberately does not ship a fake inline spellchecker because replacing the editor with a textarea removes the toolbar and breaks the workflow.
|
||||
|
||||
If a full inline spelling engine is required later, add it as a deliberate editor plugin integration with regression tests for formatting, Livewire body sync, ticket create, reply, clear, close modal, and attachment handling. Do not replace the rich editor with a plain textarea.
|
||||
|
||||
## Timer Log Refresh
|
||||
|
||||
The ticket timer dispatches `helpdesk-ticket-time-log-updated` after start and stop. The ticket detail Livewire component listens for that event and refreshes ticket aggregates so the time summary and log modal update without a page reload.
|
||||
|
||||
Working Github setup with gitea
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
+1
-2
@@ -648,10 +648,9 @@ span.flatpickr-weekday {
|
||||
gap: .5rem;
|
||||
border-radius: .85rem;
|
||||
padding: .62rem 1.05rem;
|
||||
font-size: .875rem;
|
||||
font-size: .95rem;
|
||||
line-height: 1.25rem;
|
||||
font-weight: 800;
|
||||
font-size: .95rem;
|
||||
color: #ffffff !important;
|
||||
text-decoration: none !important;
|
||||
border: 1px solid rgba(255,255,255,.14);
|
||||
|
||||
Regular → Executable
Regular → Executable
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user