Repo rebuild

This commit is contained in:
Mark Gregory
2026-09-15 12:33:28 +01:00
commit be28c4feec
5629 changed files with 317074 additions and 0 deletions
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Schema;
use Modules\Helpdesk\Models\Attachment;
use Modules\Helpdesk\Services\AttachmentService;
final class AttachmentDeleteController extends Controller
{
/**
* Soft-delete a Helpdesk attachment after enforcing ticket update access.
*/
public function __invoke(Attachment $attachment, AttachmentService $attachments): RedirectResponse
{
$ticket = $attachment->ticket;
abort_if($ticket === null, 404);
Gate::authorize('update', $ticket);
if (Schema::hasColumn('helpdesk_attachments', 'deleted_at')) {
$attachments->softDelete($attachment, (int) auth()->id());
} else {
$attachments->delete($attachment);
}
return redirect()
->route('helpdesk.tickets.show', $ticket)
->with('ticket-attachments-deleted', 'Attachment removed.');
}
}
@@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Http\Controllers;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Http\RedirectResponse;
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;
final class AttachmentDownloadController extends Controller
{
/**
* Download a ticket attachment through Laravel storage instead of a public URL.
*
* This avoids broken public storage links and keeps ticket authorization enforced.
*
* @throws FileNotFoundException
*/
public function __invoke(int $attachment): StreamedResponse|RedirectResponse
{
abort_unless(Schema::hasTable('helpdesk_attachments'), 404);
$row = DB::table('helpdesk_attachments')->where('id', $attachment)->first();
abort_if($row === null, 404);
$ticketId = $row->ticket_id ?? null;
if ($ticketId !== null) {
$ticket = Ticket::query()->findOrFail((int) $ticketId);
Gate::authorize('view', $ticket);
}
$disk = (string) ($row->disk ?? 'public');
$path = (string) ($row->path ?? $row->file_path ?? $row->stored_path ?? '');
$name = (string) ($row->original_name
?? $row->original_filename
?? $row->file_name
?? $row->filename
?? basename($path));
abort_if($path === '', 404);
abort_unless(Storage::disk($disk)->exists($path), 404);
if (Schema::hasColumn('helpdesk_attachments', 'download_count') && Schema::hasColumn('helpdesk_attachments', 'last_downloaded_at')) {
DB::table('helpdesk_attachments')
->where('id', $attachment)
->update([
'download_count' => DB::raw('download_count + 1'),
'last_downloaded_at' => now(),
]);
}
return Storage::disk($disk)->download($path, $name);
}
/**
* Preview a safe Helpdesk attachment using this existing controller so optimized
* Composer autoload does not need a newly introduced route action class.
*
* @throws FileNotFoundException
*/
public function preview(int $attachment): View|StreamedResponse|RedirectResponse
{
abort_unless(Schema::hasTable('helpdesk_attachments'), 404);
$row = DB::table('helpdesk_attachments')->where('id', $attachment)->first();
abort_if($row === null, 404);
$ticketId = $row->ticket_id ?? null;
if ($ticketId !== null) {
$ticket = Ticket::query()->findOrFail((int) $ticketId);
Gate::authorize('view', $ticket);
}
$disk = (string) ($row->disk ?? 'public');
$path = (string) ($row->path ?? $row->file_path ?? $row->stored_path ?? '');
$name = (string) ($row->original_name
?? $row->original_filename
?? $row->file_name
?? $row->filename
?? basename($path));
$mimeType = (string) ($row->mime_type ?? 'application/octet-stream');
$extension = strtolower((string) (($row->extension ?? null) ?: pathinfo($name, PATHINFO_EXTENSION)));
abort_if($path === '', 404);
abort_unless(Storage::disk($disk)->exists($path), 404);
if ($this->isSourceExtension($extension)) {
$contents = Storage::disk($disk)->get($path);
return view('helpdesk::attachments.source-preview', [
'attachment' => $row,
'contents' => is_string($contents) ? $contents : '',
'language' => $this->languageForExtension($extension),
]);
}
abort_unless($this->isInlinePreviewable($extension, $mimeType), 404);
return Storage::disk($disk)->response($path, $name, [
'Content-Disposition' => 'inline; filename="' . addslashes($name) . '"',
'X-Content-Type-Options' => 'nosniff',
]);
}
/**
* Soft-delete or remove an attachment using this existing controller class.
*/
public function destroy(int $attachment): RedirectResponse
{
abort_unless(Schema::hasTable('helpdesk_attachments'), 404);
$row = DB::table('helpdesk_attachments')->where('id', $attachment)->first();
abort_if($row === null, 404);
$ticketId = $row->ticket_id ?? null;
abort_if($ticketId === null, 404);
$ticket = Ticket::query()->findOrFail((int) $ticketId);
Gate::authorize('update', $ticket);
if (Schema::hasColumn('helpdesk_attachments', 'deleted_at')) {
DB::table('helpdesk_attachments')
->where('id', $attachment)
->update(array_filter([
'deleted_at' => now(),
'deleted_by' => Schema::hasColumn('helpdesk_attachments', 'deleted_by') ? (int) auth()->id() : null,
], static fn (mixed $value): bool => $value !== null));
} else {
Storage::disk((string) ($row->disk ?? 'public'))->delete((string) ($row->path ?? ''));
DB::table('helpdesk_attachments')->where('id', $attachment)->delete();
}
return redirect()
->route('helpdesk.tickets.show', $ticket)
->with('ticket-attachments-deleted', 'Attachment removed.');
}
/**
* Determine whether a file can be safely streamed inline by extension or MIME type.
*/
private function isInlinePreviewable(string $extension, string $mimeType): bool
{
return in_array($extension, ['png', 'jpg', 'jpeg', 'webp', 'gif', 'pdf'], true)
|| str_starts_with($mimeType, 'image/')
|| $mimeType === 'application/pdf';
}
/**
* Determine whether a source/configuration file can use the read-only viewer.
*/
private function isSourceExtension(string $extension): bool
{
return in_array($extension, config('helpdesk.allowed_source_extensions', [
'php', 'phtml', 'phps', 'js', 'jsx', 'ts', 'tsx', 'vue', 'css', 'scss', 'sass', 'less',
'html', 'htm', 'xml', 'json', 'yaml', 'yml', 'sql', 'sh', 'bash', 'zsh', 'ps1', 'bat', 'cmd',
'py', 'rb', 'go', 'rs', 'java', 'kt', 'kts', 'cs', 'cpp', 'c', 'h', 'hpp', 'swift', 'dart',
'rpg', 'rpgle', 'cl', 'clle', 'cbl', 'cob', 'copybook', 'inc', 'env', 'ini', 'conf', 'config', 'txt', 'log', 'md',
]), true);
}
/**
* Return the source preview language key for display/highlighting.
*/
private function languageForExtension(string $extension): string
{
return match ($extension) {
'php', 'phtml', 'phps' => 'php',
'js', 'jsx' => 'javascript',
'ts', 'tsx' => 'typescript',
'vue' => 'vue',
'css', 'scss', 'sass', 'less' => 'css',
'html', 'htm' => 'html',
'xml' => 'xml',
'json' => 'json',
'yaml', 'yml' => 'yaml',
'sql' => 'sql',
'sh', 'bash', 'zsh' => 'shell',
'ps1' => 'powershell',
'py' => 'python',
'java', 'kt', 'kts' => 'java',
'c', 'h', 'cpp', 'hpp', 'cs' => 'c-like',
'rpg', 'rpgle' => 'rpgle',
'cl', 'clle' => 'clle',
'cbl', 'cob', 'copybook' => 'cobol',
default => 'text',
};
}
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Http\Controllers;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
use Modules\Helpdesk\Models\Attachment;
use Symfony\Component\HttpFoundation\StreamedResponse;
final class AttachmentPreviewController extends Controller
{
/**
* Preview a safe Helpdesk attachment after enforcing ticket access.
*
* @throws FileNotFoundException
*/
public function __invoke(Attachment $attachment): View|StreamedResponse|RedirectResponse
{
$ticket = $attachment->ticket;
if ($ticket !== null) {
Gate::authorize('view', $ticket);
}
abort_unless(Storage::disk($attachment->disk)->exists($attachment->path), 404);
if ($attachment->isSourceCode()) {
$contents = Storage::disk($attachment->disk)->get($attachment->path);
return view('helpdesk::attachments.source-preview', [
'attachment' => $attachment,
'contents' => is_string($contents) ? $contents : '',
'language' => $attachment->language(),
]);
}
abort_unless($attachment->isPreviewableBinary(), 404);
return Storage::disk($attachment->disk)->response($attachment->path, $attachment->original_name, [
'Content-Disposition' => 'inline; filename="' . addslashes($attachment->original_name) . '"',
'X-Content-Type-Options' => 'nosniff',
]);
}
}
@@ -0,0 +1,355 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use JsonException;
use Modules\Helpdesk\Models\GitRepository;
use Modules\Helpdesk\Services\GitWebhookIngestionService;
final class GitWebhookController
{
/**
* Ingest a signed Git provider webhook for a registered Helpdesk repository.
*/
public function __invoke(Request $request, GitRepository $repository, GitWebhookIngestionService $service): JsonResponse
{
$provider = strtolower((string) ($repository->provider ?: 'git'));
$event = $this->eventName($request, $provider);
$deliveryId = $this->deliveryId($request, $provider);
$rawPayload = $request->getContent();
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);
}
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);
}
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);
}
/**
* Ingest the local Gitea webhook URL that does not include a repository id.
*/
public function gitea(Request $request, GitWebhookIngestionService $service): JsonResponse
{
$provider = 'gitea';
$event = $this->eventName($request, $provider);
$deliveryId = $this->deliveryId($request, $provider);
$rawPayload = $request->getContent();
try {
/** @var array<string, mixed> $payload */
$payload = json_decode($rawPayload, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return response()->json([
'status' => 'rejected',
'message' => 'Invalid JSON webhook payload.',
'commit_count' => 0,
'linked_count' => 0,
'ignored_count' => 1,
], 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];
$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);
}
/**
* Validate provider-specific webhook signatures or tokens.
*/
private function hasValidSignature(Request $request, GitRepository $repository, string $provider, string $rawPayload): bool
{
$secret = $this->secretForProvider($repository, $provider);
if ($secret === '') {
return false;
}
return match ($provider) {
'github' => $this->hasValidGitHubSignature($request, $secret, $rawPayload),
'gitlab' => hash_equals($secret, (string) $request->header('X-Gitlab-Token', '')),
'gitea' => $this->hasValidGiteaSignature($request, $secret, $rawPayload),
'bitbucket' => $this->hasValidBitbucketSignature($request, $secret, $rawPayload),
default => $this->hasValidGenericSignature($request, $secret, $rawPayload),
};
}
/**
* Resolve the repository webhook secret, with local Gitea env fallback support.
*/
private function secretForProvider(GitRepository $repository, string $provider): string
{
$secret = trim((string) $repository->webhook_secret);
if ($secret !== '') {
return $secret;
}
return $provider === 'gitea' ? trim((string) config('services.gitea.webhook_secret', '')) : '';
}
/**
* Validate GitHub's current SHA-256 signature and legacy SHA-1 signature.
*/
private function hasValidGitHubSignature(Request $request, string $secret, string $rawPayload): bool
{
$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);
}
/**
* Validate Gitea's raw SHA-256 signature and GitHub-compatible HMAC headers.
*/
private function hasValidGiteaSignature(Request $request, string $secret, string $rawPayload): bool
{
$giteaSignature = (string) $request->header('X-Gitea-Signature', '');
$sha256 = hash_hmac('sha256', $rawPayload, $secret);
$sha1 = hash_hmac('sha1', $rawPayload, $secret);
return hash_equals($sha256, $giteaSignature)
|| hash_equals('sha256=' . $sha256, $giteaSignature)
|| hash_equals('sha1=' . $sha1, $giteaSignature)
|| $this->hasValidGitHubSignature($request, $secret, $rawPayload)
|| $this->hasValidGenericSignature($request, $secret, $rawPayload);
}
/**
* Validate Bitbucket's webhook HMAC signature.
*/
private function hasValidBitbucketSignature(Request $request, string $secret, string $rawPayload): bool
{
$signature = (string) $request->header('X-Hub-Signature', '');
return hash_equals('sha256=' . hash_hmac('sha256', $rawPayload, $secret), $signature)
|| hash_equals($secret, (string) $request->header('X-Helpdesk-Token', ''));
}
/**
* Validate a generic Git webhook using either a token header or HMAC signature.
*/
private function hasValidGenericSignature(Request $request, string $secret, string $rawPayload): bool
{
$token = (string) ($request->header('X-Helpdesk-Token') ?: $request->header('X-Gitlab-Token') ?: $request->header('X-Gitea-Token'));
$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);
}
/**
* Validate Gitea webhook security without allowing an optional Authorization
* header mismatch to hide otherwise valid signed push deliveries.
*/
private function hasValidGiteaSecurity(Request $request, GitRepository $repository, string $provider, string $rawPayload): bool
{
if ($this->hasValidSignature($request, $repository, $provider, $rawPayload)) {
return true;
}
return $this->hasValidGiteaAuthorization($request);
}
/**
* Validate the optional Authorization header configured for the local Gitea webhook.
*/
private function hasValidGiteaAuthorization(Request $request): bool
{
$expected = trim((string) config('services.gitea.webhook_authorization', ''));
if ($expected === '') {
return true;
}
$actual = trim((string) $request->header('Authorization', ''));
return hash_equals($expected, $actual)
|| hash_equals('Basic ' . $expected, $actual)
|| (Str::startsWith($expected, 'Basic ') && hash_equals($expected, $actual));
}
/**
* Match a Gitea webhook payload to the configured Helpdesk repository.
*
* @param array<string, mixed> $payload
*/
private function repositoryForGiteaPayload(array $payload): ?GitRepository
{
$repositories = GitRepository::query()
->where('is_active', true)
->whereRaw('LOWER(provider) IN (?, ?)', ['gitea', 'git'])
->get();
if ($repositories->isEmpty()) {
Log::warning('Helpdesk Gitea webhook received but no active Gitea/git repository is configured.');
return null;
}
$payloadValues = array_values(array_filter([
Arr::get($payload, 'repository.full_name'),
Arr::get($payload, 'repository.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) !== ''));
$normalisedPayloadValues = array_values(array_filter(array_map(
fn (mixed $value): ?string => $this->normaliseRepositoryIdentity((string) $value),
$payloadValues,
)));
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;
}
}
}
}
if ($repositories->count() === 1) {
return $repositories->first();
}
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 null;
}
/**
* Normalise a repository name or URL so Gitea payloads match local settings reliably.
*/
private function normaliseRepositoryIdentity(string $value): ?string
{
$value = trim($value);
if ($value === '') {
return null;
}
$value = Str::of($value)
->replace('\\', '/')
->replace('ssh://git@', 'ssh://')
->replace('git@', '')
->replace('.git', '')
->lower()
->value();
$path = parse_url($value, PHP_URL_PATH);
if (is_string($path) && trim($path, '/') !== '') {
$value = trim($path, '/');
} elseif (str_contains($value, ':')) {
$value = Str::of($value)->afterLast(':')->trim('/')->value();
}
$segments = array_values(array_filter(explode('/', trim($value, '/'))));
if ($segments === []) {
return null;
}
return implode('/', array_slice($segments, -2));
}
/**
* 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'),
'gitlab' => $request->header('X-Gitlab-Event'),
'gitea' => $request->header('X-Gitea-Event') ?: $request->header('X-Gitea-Event-Type') ?: $request->header('X-GitHub-Event'),
'bitbucket' => $request->header('X-Event-Key'),
default => $request->header('X-Helpdesk-Event') ?: $request->header('X-GitHub-Event') ?: $request->header('X-Gitlab-Event') ?: 'push',
};
}
/**
* Read the provider delivery/request id used for idempotent audit tracing.
*/
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'),
};
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Contracts\View\View;
use Illuminate\Support\HtmlString;
final class HelpdeskController extends Controller
{
/**
* Display the Helpdesk module landing page inside the application shell.
*/
public function __invoke(): View
{
return view('layouts.app', [
'title' => __('Helpdesk'),
'slot' => new HtmlString(view('helpdesk::index')->render()),
]);
}
}
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
use Modules\Helpdesk\Models\Ticket;
use Modules\Helpdesk\Services\AttachmentService;
final class TicketAttachmentStoreController extends Controller
{
/**
* Store one or more supporting files directly against an existing ticket.
*/
public function __invoke(Request $request, Ticket $ticket, AttachmentService $attachments): RedirectResponse
{
Gate::authorize('update', $ticket);
$request->validate([
'attachments' => ['required', 'array', 'min:1'],
'attachments.*' => $this->attachmentFileRules(required: true),
]);
$user = Auth::user();
abort_if($user === null, 403);
$uploadedFiles = $request->file('attachments', []);
$uploadedFiles = is_array($uploadedFiles) ? $uploadedFiles : [$uploadedFiles];
$uploadedFiles = array_values(array_filter($uploadedFiles, static fn ($file): bool => $file instanceof UploadedFile && $file->isValid()));
$attachments->storeMany($ticket, $uploadedFiles, (int) $user->id);
return redirect()
->route('helpdesk.tickets.show', $ticket)
->with('ticket-attachments-added', count($uploadedFiles) . ' supporting file' . (count($uploadedFiles) === 1 ? '' : 's') . ' uploaded.');
}
/**
* 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
{
return array_values(array_filter([
$required ? 'required' : null,
'file',
'max:' . (int) config('helpdesk.max_upload_kb', 10240),
'extensions:' . implode(',', $this->allowedAttachmentExtensions()),
]));
}
/**
* Return lower-case Helpdesk attachment extensions.
*
* @return array<int, string>
*/
private function allowedAttachmentExtensions(): array
{
return collect(config('helpdesk.allowed_extensions', config('helpdesk.allowed_mimes', [])))
->filter(static fn (mixed $extension): bool => is_string($extension) && trim($extension) !== '')
->map(static fn (string $extension): string => strtolower(ltrim(trim($extension), '.')))
->unique()
->values()
->all();
}
}
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\Rule;
use Modules\Helpdesk\Models\KanbanBoard;
use Modules\Helpdesk\Models\Status;
use Modules\Helpdesk\Models\Ticket;
use Modules\Helpdesk\Services\WorkflowNotificationService;
final class TicketBoardController extends Controller
{
public function __invoke(Request $request, Ticket $ticket, WorkflowNotificationService $workflow): RedirectResponse
{
Gate::authorize('update', $ticket);
$validated = $request->validate([
'board_id' => ['required', 'integer', Rule::exists('helpdesk_kanban_boards', 'id')],
'return_to_board_id' => ['nullable', 'integer', Rule::exists('helpdesk_kanban_boards', 'id')],
]);
$targetBoardId = (int) $validated['board_id'];
$targetBoard = KanbanBoard::query()
->with(['statuses' => fn ($query) => $query->orderBy('helpdesk_kanban_board_statuses.sort_order')->orderBy('helpdesk_statuses.sort_order')->orderBy('helpdesk_statuses.name')])
->findOrFail($targetBoardId);
if ($targetBoard->statuses->isEmpty()) {
$this->ensureBoardHasStatuses($targetBoardId);
$targetBoard->load(['statuses' => fn ($query) => $query->orderBy('helpdesk_kanban_board_statuses.sort_order')->orderBy('helpdesk_statuses.sort_order')->orderBy('helpdesk_statuses.name')]);
}
$fresh = DB::transaction(function () use ($ticket, $targetBoard): Ticket {
$lockedTicket = Ticket::query()
->visibleTo(Auth::user())
->lockForUpdate()
->findOrFail($ticket->id);
$targetStatus = $targetBoard->statuses->firstWhere('id', (int) $lockedTicket->status_id)
?: $targetBoard->statuses->first();
if ($targetStatus === null) {
$targetStatus = Status::query()->orderBy('sort_order')->orderBy('name')->firstOrFail();
$this->addStatusToBoard($targetBoard->id, $targetStatus->id);
}
$updates = [
'board_id' => $targetBoard->id,
'status_id' => $targetStatus->id,
'completed_at' => $targetStatus->is_closed ? now() : null,
'last_activity_at' => now(),
];
if (DB::getSchemaBuilder()->hasColumn('helpdesk_tickets', 'updated_at')) {
$updates['updated_at'] = now();
}
$affected = DB::table('helpdesk_tickets')
->where('id', $lockedTicket->id)
->update($updates);
if ($affected < 1) {
throw new \RuntimeException('Ticket board update did not persist.');
}
return Ticket::query()
->with(['status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'checklistItems', 'board'])
->findOrFail($lockedTicket->id);
});
$workflow->dispatch($fresh, 'ticket.updated', Auth::user());
return redirect()
->route('helpdesk.kanban', ['board_id' => $targetBoardId])
->with('success', 'Ticket ' . $fresh->reference . ' moved to ' . $targetBoard->name . '.');
}
private function ensureBoardHasStatuses(int $boardId): void
{
Status::query()
->orderBy('sort_order')
->orderBy('name')
->get(['id'])
->values()
->each(function (Status $status, int $index) use ($boardId): void {
$this->addStatusToBoard($boardId, (int) $status->id, ($index + 1) * 10);
});
}
private function addStatusToBoard(int $boardId, int $statusId, int $sortOrder = 999): void
{
DB::table('helpdesk_kanban_board_statuses')->updateOrInsert(
['board_id' => $boardId, 'status_id' => $statusId],
['sort_order' => $sortOrder, 'updated_at' => now(), 'created_at' => now()],
);
}
}
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\Helpdesk\Models\Comment;
use Modules\Helpdesk\Models\Ticket;
final class TicketCommentDeleteController extends Controller
{
/**
* Tombstone a ticket response. Only MPBA/platform super administrators may delete responses.
*/
public function __invoke(Request $request, Ticket $ticket, Comment $comment): RedirectResponse
{
$user = $request->user();
abort_unless($user !== null && method_exists($user, 'isMpbaPlatformAdmin') && $user->isMpbaPlatformAdmin(), 403);
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();
}
});
return redirect()
->route('helpdesk.tickets.show', $ticket)
->with('ticket-comment-deleted', 'Response deleted.');
}
}
@@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Modules\Helpdesk\Models\CannedResponse;
use Modules\Helpdesk\Models\Ticket;
use Modules\Helpdesk\Services\CannedResponseRenderer;
use Modules\Helpdesk\Services\TicketService;
final class TicketCommentStoreController extends Controller
{
/**
* Store a ticket conversation comment or nested reply using a normal HTTP POST.
*
* This keeps the conversation workflow reliable when rich-editor, Flux, or
* Livewire JavaScript event handling does not invoke the Livewire method.
*/
public function __invoke(Request $request, Ticket $ticket, TicketService $tickets, CannedResponseRenderer $renderer): RedirectResponse
{
Gate::authorize('comment', $ticket);
$validated = $request->validate([
'subject' => ['nullable', 'string', 'max:180'],
'commentBody' => ['nullable', 'string'],
'internal' => ['nullable', 'boolean'],
'parent_id' => [
'nullable',
'integer',
Rule::exists('helpdesk_comments', 'id')->where('ticket_id', $ticket->id),
],
'canned_response_id' => ['nullable', 'integer', Rule::exists('helpdesk_canned_responses', 'id')],
'attachments' => ['nullable', 'array'],
'attachments.*' => $this->attachmentFileRules(),
]);
$user = Auth::user();
abort_if($user === null, 403);
$body = trim((string) ($validated['commentBody'] ?? ''));
$subject = trim((string) ($validated['subject'] ?? ''));
if (filled($validated['canned_response_id'] ?? null)) {
$response = CannedResponse::query()
->where('is_active', true)
->findOrFail((int) $validated['canned_response_id']);
if ($body === '') {
$body = trim($renderer->render($response->body, $ticket, $user));
}
if ($subject === '') {
$subject = (string) $response->name;
}
}
$body = $this->normaliseHtmlBody($body);
$subject = $this->normaliseSubject($subject, $body, filled($validated['parent_id'] ?? null));
validator(['commentBody' => $body], [
'commentBody' => ['required', 'string', 'min:2'],
])->validate();
$uploadedFiles = $request->file('attachments', []);
$uploadedFiles = is_array($uploadedFiles) ? $uploadedFiles : [$uploadedFiles];
$uploadedFiles = array_values(array_filter($uploadedFiles, static fn ($file): bool => $file instanceof UploadedFile && $file->isValid()));
$tickets->comment(
ticket: $ticket,
user: $user,
body: $body,
internal: (bool) ($validated['internal'] ?? false) && $this->userCanCreateInternalNote($user),
files: $uploadedFiles,
parentId: filled($validated['parent_id'] ?? null) ? (int) $validated['parent_id'] : null,
subject: $subject,
);
return redirect()
->route('helpdesk.tickets.show', $ticket)
->with('ticket-comment-added', filled($validated['parent_id'] ?? null) ? 'Reply posted.' : 'Comment posted.');
}
/**
* Derive a user-friendly Lotus Notes style response subject.
*/
private function normaliseSubject(string $subject, string $body, bool $isReply): string
{
$subject = trim(strip_tags(html_entity_decode($subject, ENT_QUOTES | ENT_HTML5, 'UTF-8')));
if ($subject !== '') {
return Str::limit($subject, 180, '');
}
$plain = trim(preg_replace('/\s+/', ' ', strip_tags(html_entity_decode($body, ENT_QUOTES | ENT_HTML5, 'UTF-8'))) ?: '');
if ($plain === '') {
return $isReply ? 'RE: Response' : 'Response';
}
return ($isReply ? 'RE: ' : '') . Str::limit($plain, 120, '');
}
/**
* Keep supported formatting tags but strip scripts and unsafe inline handlers.
*/
private function normaliseHtmlBody(string $body): string
{
$body = trim($body);
for ($i = 0; $i < 5; $i++) {
if (! str_contains($body, '&lt;') && ! str_contains($body, '&gt;') && ! str_contains($body, '&amp;lt;')) {
break;
}
$decoded = html_entity_decode($body, ENT_QUOTES | ENT_HTML5, 'UTF-8');
if ($decoded === $body) {
break;
}
$body = $decoded;
}
$body = preg_replace('/<\s*(script|style)[^>]*>.*?<\s*\/\s*\1\s*>/is', '', $body) ?: '';
$body = preg_replace('/\s+on[a-z]+\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)/i', '', $body) ?: '';
$body = preg_replace('/javascript\s*:/i', '', $body) ?: '';
$body = trim(strip_tags($body, '<p><br><strong><b><em><i><u><ul><ol><li><blockquote><pre><code><a>'));
if ($body === '') {
return '';
}
if ($body === strip_tags($body)) {
return collect(preg_split('/\R{2,}/', $body) ?: [])
->map(static fn (string $paragraph): string => trim($paragraph))
->filter(static fn (string $paragraph): bool => $paragraph !== '')
->map(static fn (string $paragraph): string => '<p>' . nl2br(e($paragraph), false) . '</p>')
->implode('');
}
return $body;
}
/**
* Internal notes are restricted to MPBA/core-company users.
*/
private function userCanCreateInternalNote(mixed $user): bool
{
$company = $user?->company;
if ($company === null) {
return false;
}
return (bool) ($company->is_system_company ?? false)
|| Str::lower((string) ($company->slug ?? '')) === 'mpba-limited'
|| Str::contains(Str::lower((string) ($company->name ?? '')), 'mpba');
}
/**
* 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
{
return array_values(array_filter([
$required ? 'required' : null,
'file',
'max:' . (int) config('helpdesk.max_upload_kb', 10240),
'extensions:' . implode(',', $this->allowedAttachmentExtensions()),
]));
}
/**
* Return lower-case Helpdesk attachment extensions.
*
* @return array<int, string>
*/
private function allowedAttachmentExtensions(): array
{
return collect(config('helpdesk.allowed_extensions', config('helpdesk.allowed_mimes', [])))
->filter(static fn (mixed $extension): bool => is_string($extension) && trim($extension) !== '')
->map(static fn (string $extension): string => strtolower(ltrim(trim($extension), '.')))
->unique()
->values()
->all();
}
}
@@ -0,0 +1,256 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\Rule;
use Modules\Helpdesk\Models\Status;
use Modules\Helpdesk\Models\Ticket;
use Modules\Helpdesk\Services\SlaService;
use Modules\Helpdesk\Services\TicketService;
use Modules\Helpdesk\Services\WorkflowNotificationService;
final class TicketControlsController extends Controller
{
/**
* Persist the assigned agent from the ticket detail controls.
*/
public function updateAgent(Request $request, Ticket $ticket, WorkflowNotificationService $workflow): RedirectResponse
{
$this->hydrateUserRelations($ticket);
Gate::authorize('update', $ticket);
$validated = $request->validate([
'agent_id' => [
'nullable',
'integer',
Rule::exists('users', 'id')->where(function ($query) use ($ticket): void {
if ($ticket->company_id !== null && Schema::hasColumn('users', 'company_id')) {
$query->where(function ($query) use ($ticket): void {
$query->where('company_id', $ticket->company_id)
->orWhereNull('company_id');
});
}
}),
],
]);
$agentId = $this->nullableInt($validated['agent_id'] ?? null);
$this->updateTicket($ticket, [
'agent_id' => $agentId,
'last_activity_at' => now(),
]);
$fresh = $this->freshTicketForAction($ticket);
$workflow->dispatch($fresh, 'ticket.updated', $this->actor());
return redirect()
->route('helpdesk.tickets.show', $fresh)
->with('ticket-action-updated', $agentId === null
? 'Ticket is now unassigned.'
: 'Ticket assigned to ' . ($fresh->agent?->name ?? 'selected agent') . '.');
}
/**
* Persist the selected ticket status from the ticket detail controls.
*/
public function updateStatus(Request $request, Ticket $ticket, WorkflowNotificationService $workflow, SlaService $sla, TicketService $tickets): RedirectResponse
{
$this->hydrateUserRelations($ticket);
Gate::authorize('update', $ticket);
$validated = $request->validate([
'status_id' => ['required', 'integer', Rule::exists('helpdesk_statuses', 'id')],
'closure_comment' => ['nullable', 'string', 'min:10', 'max:5000'],
'closure_internal' => ['nullable', 'boolean'],
]);
$status = Status::query()->findOrFail((int) $validated['status_id']);
if ($status->is_closed) {
validator($validated, [
'closure_comment' => ['required', 'string', 'min:10', 'max:5000'],
], [
'closure_comment.required' => 'A closure comment is required before closing the ticket.',
'closure_comment.min' => 'The closure comment must explain why the ticket is being closed.',
])->validate();
}
$this->updateTicket($ticket, [
'status_id' => $status->id,
'completed_at' => $status->is_closed ? now() : null,
'last_activity_at' => now(),
]);
$this->ensureBoardContainsStatus($ticket, (int) $status->id);
$fresh = $this->freshTicketForAction($ticket);
$actor = $this->actor();
if ($status->is_closed) {
$tickets->comment(
$fresh,
$actor,
trim((string) $validated['closure_comment']),
(bool) ($validated['closure_internal'] ?? false),
[],
null,
'Closure comment',
);
$fresh = $this->freshTicketForAction($ticket);
$sla->recordClosure($fresh);
}
$workflow->dispatch($fresh, $status->is_closed ? 'ticket.closed' : 'ticket.updated', $actor);
return redirect()
->route('helpdesk.tickets.show', $fresh)
->with('ticket-action-updated', 'Status updated to ' . ($fresh->status?->name ?? 'selected status') . '.');
}
/**
* Persist the selected ticket priority from the ticket detail controls.
*/
public function updatePriority(Request $request, Ticket $ticket, WorkflowNotificationService $workflow): RedirectResponse
{
$this->hydrateUserRelations($ticket);
Gate::authorize('update', $ticket);
$validated = $request->validate([
'priority_id' => ['required', 'integer', Rule::exists('helpdesk_priorities', 'id')],
]);
$this->updateTicket($ticket, [
'priority_id' => (int) $validated['priority_id'],
'last_activity_at' => now(),
]);
$fresh = $this->freshTicketForAction($ticket);
$workflow->dispatch($fresh, 'ticket.updated', $this->actor());
return redirect()
->route('helpdesk.tickets.show', $fresh)
->with('ticket-action-updated', 'Priority updated to ' . ($fresh->priority?->name ?? 'selected priority') . '.');
}
/**
* Apply a direct guarded ticket update without relying on Livewire hydration.
*
* @param array<string, mixed> $updates
*/
private function updateTicket(Ticket $ticket, array $updates): void
{
if (Schema::hasColumn('helpdesk_tickets', 'updated_at')) {
$updates['updated_at'] = now();
}
DB::table('helpdesk_tickets')
->where('id', $ticket->id)
->update($updates);
}
/**
* Reload the ticket after a control update without issuing separate owner/agent relation queries.
*/
private function freshTicketForAction(Ticket $ticket): Ticket
{
$fresh = $ticket->fresh([
'status',
'priority',
'category',
'project',
'team',
'product',
]) ?? $ticket;
$this->hydrateUserRelations($fresh);
return $fresh;
}
/**
* Load requester and assigned agent in one user query and attach all aliases used downstream.
*/
private function hydrateUserRelations(Ticket $ticket): void
{
$userIds = collect([$ticket->user_id, $ticket->agent_id])
->filter()
->map(static fn (mixed $id): int => (int) $id)
->unique()
->values();
if ($userIds->isEmpty()) {
$ticket->setRelation('user', null);
$ticket->setRelation('owner', null);
$ticket->setRelation('agent', null);
return;
}
$users = User::query()
->with('company')
->whereIn('id', $userIds)
->get()
->keyBy('id');
$owner = $users->get((int) $ticket->user_id);
$agent = $ticket->agent_id === null ? null : $users->get((int) $ticket->agent_id);
$ticket->setRelation('user', $owner);
$ticket->setRelation('owner', $owner);
$ticket->setRelation('agent', $agent);
}
/**
* Keep the active Kanban board usable when a ticket is moved to a status not yet assigned to the board.
*/
private function ensureBoardContainsStatus(Ticket $ticket, int $statusId): void
{
if ($ticket->board_id === null || ! Schema::hasTable('helpdesk_kanban_board_statuses')) {
return;
}
DB::table('helpdesk_kanban_board_statuses')->updateOrInsert(
['board_id' => $ticket->board_id, 'status_id' => $statusId],
['updated_at' => now(), 'created_at' => now()],
);
}
/**
* Return the authenticated user with company loaded for workflow rendering.
*/
private function actor(): User
{
/** @var User $user */
$user = Auth::user();
$user->loadMissing('company');
return $user;
}
/**
* Convert form select values to nullable integers.
*/
private function nullableInt(mixed $value): ?int
{
if ($value === null || $value === '' || $value === '0' || $value === 0) {
return null;
}
return (int) $value;
}
}
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
use Modules\Helpdesk\Services\TicketService;
final class TicketStoreController extends Controller
{
/**
* Store a new helpdesk ticket using a normal HTTP form post.
*
* This avoids the Livewire update endpoint for ticket creation, which prevents
* proxy/hash update-route 404s from blocking ticket creation.
*/
public function __invoke(Request $request, TicketService $tickets): RedirectResponse
{
$validated = $request->validate([
'company_id' => ['nullable', 'integer'],
'subject' => ['required', 'string', 'max:200'],
'body' => ['required', 'string', 'min:10'],
'priority_id' => ['required', 'integer', Rule::exists('helpdesk_priorities', 'id')],
'category_id' => ['nullable', 'integer', Rule::exists('helpdesk_categories', 'id')],
'project_id' => ['nullable', 'integer', Rule::exists('helpdesk_projects', 'id')],
'product_id' => ['nullable', 'integer', Rule::exists('helpdesk_products', 'id')],
'team_id' => ['nullable', 'integer', Rule::exists('helpdesk_teams', 'id')],
'due_at' => ['nullable', 'date'],
'attachments' => ['nullable', 'array'],
'attachments.*' => $this->attachmentFileRules(),
]);
$user = Auth::user();
abort_if($user === null, 403);
$uploadedFiles = $request->file('attachments', []);
$uploadedFiles = is_array($uploadedFiles) ? $uploadedFiles : [$uploadedFiles];
$ticket = $tickets->create($user, [
'company_id' => $validated['company_id'] ?? null,
'subject' => $validated['subject'],
'body' => $validated['body'],
'priority_id' => $validated['priority_id'],
'category_id' => $validated['category_id'] ?? null,
'project_id' => $validated['project_id'] ?? null,
'product_id' => $validated['product_id'] ?? null,
'team_id' => $validated['team_id'] ?? null,
'due_at' => $validated['due_at'] ?? null,
], $uploadedFiles);
return redirect()
->route('helpdesk.tickets.index')
->with('success', 'Ticket ' . $ticket->reference . ' has been created.');
}
/**
* 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
{
return array_values(array_filter([
$required ? 'required' : null,
'file',
'max:' . (int) config('helpdesk.max_upload_kb', 10240),
'extensions:' . implode(',', $this->allowedAttachmentExtensions()),
]));
}
/**
* Return lower-case Helpdesk attachment extensions.
*
* @return array<int, string>
*/
private function allowedAttachmentExtensions(): array
{
return collect(config('helpdesk.allowed_extensions', config('helpdesk.allowed_mimes', [])))
->filter(static fn (mixed $extension): bool => is_string($extension) && trim($extension) !== '')
->map(static fn (string $extension): string => strtolower(ltrim(trim($extension), '.')))
->unique()
->values()
->all();
}
}
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Http\Controllers;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\Helpdesk\Models\Ticket;
final class TicketTimeLogController extends Controller
{
/**
* Store logged time against the selected helpdesk ticket.
*
* @throws AuthorizationException
*/
public function store(Request $request, Ticket $ticket): RedirectResponse
{
if ($request->user()?->cannot('update', $ticket)) {
throw new AuthorizationException;
}
$validated = $request->validate([
'minutes' => ['required', 'integer', 'min:1', 'max:1440'],
'description' => ['nullable', 'string', 'max:1000'],
]);
$minutes = (int) $validated['minutes'];
$description = $validated['description'] ?? null;
$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);
}
});
return redirect()
->route('helpdesk.tickets.show', $ticket)
->with('ticket-action-updated', 'Time logged successfully.');
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Http\Controllers;
use Illuminate\Http\Response;
use Modules\Helpdesk\Models\WorkflowLog;
final class WorkflowOpenController
{
public function __invoke(string $token): Response
{
$log = WorkflowLog::query()->where('tracking_token', $token)->first();
if ($log) {
$log->forceFill([
'first_opened_at' => $log->first_opened_at ?? now(),
'last_opened_at' => now(),
'opened_count' => ((int) $log->opened_count) + 1,
])->save();
}
return response(base64_decode('R0lGODlhAQABAPAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw=='), 200, [
'Content-Type' => 'image/gif',
'Cache-Control' => 'no-store, no-cache, must-revalidate, max-age=0',
]);
}
}