Repo rebuild
This commit is contained in:
@@ -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, '<') && ! str_contains($body, '>') && ! str_contains($body, '&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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user