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
Regular → Executable
View File
View File
View File
View File
View File
View File
View File
+19 -16
View File
@@ -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;
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Exceptions;
use RuntimeException;
final class TicketPersistenceException extends RuntimeException {}
View File
View File
View File
View File
+1 -2
View File
@@ -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',
};
}
}
View File
+146 -100
View File
@@ -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),
};
}
}
View File
+1 -1
View File
@@ -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();
}
}
+2 -1
View File
@@ -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()
+42 -41
View File
@@ -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();
}
}
}
+1 -1
View File
@@ -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();
}
}
View File
+1 -1
View File
@@ -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();
}
}
+61 -52
View File
@@ -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);
}
}
View File
View File
View File
View File
+8 -7
View File
@@ -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')
+80 -59
View File
@@ -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.',
+5 -4
View File
@@ -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();
}
}
-1
View File
@@ -350,5 +350,4 @@ final class TicketEdit extends Component
->values()
->all();
}
}
View File
+19 -34
View File
@@ -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'
*/
}
View File
Regular → Executable
View File
-1
View File
@@ -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'));
View File
View File
-1
View File
@@ -13,7 +13,6 @@ use Modules\Company\Models\Company;
final class Category extends Model
{
protected $table = 'helpdesk_categories';
/** @var list<string> */
View File
View File
View File
View File
View File
View File
View File
View File
View File
+1 -1
View File
@@ -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
{
View File
View File
Regular → Executable
View File
Regular → Executable
+1 -1
View File
@@ -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
{
View File
View File
View File
View File
View File
View File
View File
+3 -1
View File
@@ -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
View File
View File
View File
-333
View File
@@ -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
View File
View File
+1 -2
View File
@@ -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);
View File
View File
View File

Some files were not shown because too many files have changed in this diff Show More