diff --git a/Modules/ChatGPT/Console/DeleteOpenAiFilesCommand.php b/Modules/ChatGPT/Console/DeleteOpenAiFilesCommand.php index 50e85b31..06a6e6a0 100644 --- a/Modules/ChatGPT/Console/DeleteOpenAiFilesCommand.php +++ b/Modules/ChatGPT/Console/DeleteOpenAiFilesCommand.php @@ -26,40 +26,37 @@ class DeleteOpenAiFilesCommand extends Command return self::FAILURE; } - $service = new OpenAiFileService((string) $setting->api_key); - try { - if ((bool) $this->option('dry-run')) { - $files = $service->listFiles(); - - $this->info('Found ' . count($files) . ' OpenAI API file(s).'); - - foreach ($files as $file) { - $this->line(sprintf( - '- %s %s', - (string) ($file['id'] ?? 'unknown'), - (string) ($file['filename'] ?? 'unnamed'), - )); - } - - return self::SUCCESS; - } - - $result = $service->deleteAllFiles(); - - $this->info("Listed: {$result['listed']}"); - $this->info("Deleted: {$result['deleted']}"); - $this->info("Failed: {$result['failed']}"); - - foreach ($result['failures'] as $failure) { - $this->error($failure['file_id'] . ': ' . $failure['error']); - } - - return $result['failed'] === 0 ? self::SUCCESS : self::FAILURE; + return $this->executeFor(new OpenAiFileService((string) $setting->api_key)); } catch (Throwable $exception) { $this->error($exception->getMessage()); return self::FAILURE; } } + + private function executeFor(OpenAiFileService $service): int + { + if ((bool) $this->option('dry-run')) { + $files = $service->listFiles(); + $this->info('Found ' . count($files) . ' OpenAI API file(s).'); + + foreach ($files as $file) { + $this->line(sprintf('- %s %s', (string) ($file['id'] ?? 'unknown'), (string) ($file['filename'] ?? 'unnamed'))); + } + + return self::SUCCESS; + } + + $result = $service->deleteAllFiles(); + $this->info("Listed: {$result['listed']}"); + $this->info("Deleted: {$result['deleted']}"); + $this->info("Failed: {$result['failed']}"); + + foreach ($result['failures'] as $failure) { + $this->error($failure['file_id'] . ': ' . $failure['error']); + } + + return $result['failed'] === 0 ? self::SUCCESS : self::FAILURE; + } } diff --git a/Modules/ChatGPT/Exceptions/OpenAiFileException.php b/Modules/ChatGPT/Exceptions/OpenAiFileException.php new file mode 100644 index 00000000..4b79a6f6 --- /dev/null +++ b/Modules/ChatGPT/Exceptions/OpenAiFileException.php @@ -0,0 +1,9 @@ +get(self::BASE_URL . '/files', $query); if ($response->failed()) { - throw new RuntimeException('OpenAI file list request failed: ' . $response->body()); + throw new OpenAiFileException('OpenAI file list request failed: ' . $response->body()); } $payload = $response->json(); @@ -63,7 +63,7 @@ class OpenAiFileService ->delete(self::BASE_URL . '/files/' . rawurlencode($fileId)); if ($response->failed()) { - throw new RuntimeException("OpenAI file delete request failed for {$fileId}: " . $response->body()); + throw new OpenAiFileException("OpenAI file delete request failed for {$fileId}: " . $response->body()); } return $response->json() ?? []; @@ -90,7 +90,7 @@ class OpenAiFileService try { $this->deleteFile($fileId); $deleted++; - } catch (RuntimeException $exception) { + } catch (OpenAiFileException $exception) { $failures[] = [ 'file_id' => $fileId, 'error' => $exception->getMessage(), diff --git a/Modules/Countries/Http/Requests/StoreCountryRequest.php b/Modules/Countries/Http/Requests/StoreCountryRequest.php index 7eddbfa0..97201ecd 100644 --- a/Modules/Countries/Http/Requests/StoreCountryRequest.php +++ b/Modules/Countries/Http/Requests/StoreCountryRequest.php @@ -8,6 +8,11 @@ use Modules\Countries\Support\Iso3166; class StoreCountryRequest extends FormRequest { + private const ISO3 = 'size:3'; + + private const MAX_STRING = 'max:255'; + + private const MAX_SHORT = 'max:16'; /** * Authorize country reference-data administration. */ @@ -38,29 +43,29 @@ class StoreCountryRequest extends FormRequest { return [ 'alpha2' => ['required', 'string', 'size:2', 'uppercase', 'unique:countries,alpha2'], - 'alpha3' => ['required', 'string', 'size:3', 'uppercase', 'unique:countries,alpha3'], - 'numeric' => ['required', 'string', 'size:3', 'unique:countries,numeric'], - 'name' => ['required', 'string', 'max:255'], - 'official_name' => ['nullable', 'string', 'max:255'], - 'common_name' => ['nullable', 'string', 'max:255'], - 'display_name' => ['nullable', 'string', 'max:255'], - 'native_name' => ['nullable', 'string', 'max:255'], - 'capital' => ['nullable', 'string', 'max:255'], - 'flag_emoji' => ['nullable', 'string', 'max:16'], - 'calling_code' => ['nullable', 'string', 'max:16'], - 'tld' => ['nullable', 'string', 'max:16'], - 'currency_code' => ['nullable', 'string', 'size:3', 'uppercase'], - 'currency_name' => ['nullable', 'string', 'max:255'], - 'currency_symbol' => ['nullable', 'string', 'max:16'], + 'alpha3' => ['required', 'string', self::ISO3, 'uppercase', 'unique:countries,alpha3'], + 'numeric' => ['required', 'string', self::ISO3, 'unique:countries,numeric'], + 'name' => ['required', 'string', self::MAX_STRING], + 'official_name' => ['nullable', 'string', self::MAX_STRING], + 'common_name' => ['nullable', 'string', self::MAX_STRING], + 'display_name' => ['nullable', 'string', self::MAX_STRING], + 'native_name' => ['nullable', 'string', self::MAX_STRING], + 'capital' => ['nullable', 'string', self::MAX_STRING], + 'flag_emoji' => ['nullable', 'string', self::MAX_SHORT], + 'calling_code' => ['nullable', 'string', self::MAX_SHORT], + 'tld' => ['nullable', 'string', self::MAX_SHORT], + 'currency_code' => ['nullable', 'string', self::ISO3, 'uppercase'], + 'currency_name' => ['nullable', 'string', self::MAX_STRING], + 'currency_symbol' => ['nullable', 'string', self::MAX_SHORT], 'languages' => ['nullable', 'json'], 'timezones' => ['nullable', 'json'], 'latitude' => ['nullable', 'numeric', 'between:-90,90'], 'longitude' => ['nullable', 'numeric', 'between:-180,180'], - 'flag_svg_url' => ['nullable', 'url', 'max:255'], - 'flag_png_url' => ['nullable', 'url', 'max:255'], + 'flag_svg_url' => ['nullable', 'url', self::MAX_STRING], + 'flag_png_url' => ['nullable', 'url', self::MAX_STRING], 'ioc_code' => ['nullable', 'string', 'max:8'], 'fifa_code' => ['nullable', 'string', 'max:8'], - 'postal_code_format' => ['nullable', 'string', 'max:255'], + 'postal_code_format' => ['nullable', 'string', self::MAX_STRING], 'address_format' => ['nullable', 'string', 'max:5000'], 'is_active' => ['required', 'boolean'], 'iso_status' => ['required', 'string', Rule::in(Iso3166::statuses())], @@ -70,10 +75,10 @@ class StoreCountryRequest extends FormRequest 'continent' => ['nullable', 'string', 'max:64'], 'region' => ['nullable', 'string', 'max:100'], 'subregion' => ['nullable', 'string', 'max:100'], - 'intermediate_region' => ['nullable', 'string', 'max:255'], + 'intermediate_region' => ['nullable', 'string', self::MAX_STRING], 'sort_order' => ['nullable', 'integer', 'min:0', 'max:999999'], - 'source' => ['nullable', 'string', 'max:255'], - 'source_version' => ['nullable', 'string', 'max:255'], + 'source' => ['nullable', 'string', self::MAX_STRING], + 'source_version' => ['nullable', 'string', self::MAX_STRING], 'last_synced_at' => ['nullable', 'date'], 'metadata' => ['nullable', 'json'], ]; diff --git a/Modules/Countries/Http/Requests/UpdateCountryRequest.php b/Modules/Countries/Http/Requests/UpdateCountryRequest.php index 83f58acb..99829adb 100644 --- a/Modules/Countries/Http/Requests/UpdateCountryRequest.php +++ b/Modules/Countries/Http/Requests/UpdateCountryRequest.php @@ -8,6 +8,11 @@ use Modules\Countries\Support\Iso3166; class UpdateCountryRequest extends FormRequest { + private const ISO3 = 'size:3'; + + private const MAX_STRING = 'max:255'; + + private const MAX_SHORT = 'max:16'; /** * Authorize country reference-data administration. */ @@ -40,29 +45,29 @@ class UpdateCountryRequest extends FormRequest return [ 'alpha2' => ['required', 'string', 'size:2', 'uppercase', Rule::unique('countries', 'alpha2')->ignore($countryId)], - 'alpha3' => ['required', 'string', 'size:3', 'uppercase', Rule::unique('countries', 'alpha3')->ignore($countryId)], - 'numeric' => ['required', 'string', 'size:3', Rule::unique('countries', 'numeric')->ignore($countryId)], - 'name' => ['required', 'string', 'max:255'], - 'official_name' => ['nullable', 'string', 'max:255'], - 'common_name' => ['nullable', 'string', 'max:255'], - 'display_name' => ['nullable', 'string', 'max:255'], - 'native_name' => ['nullable', 'string', 'max:255'], - 'capital' => ['nullable', 'string', 'max:255'], - 'flag_emoji' => ['nullable', 'string', 'max:16'], - 'calling_code' => ['nullable', 'string', 'max:16'], - 'tld' => ['nullable', 'string', 'max:16'], - 'currency_code' => ['nullable', 'string', 'size:3', 'uppercase'], - 'currency_name' => ['nullable', 'string', 'max:255'], - 'currency_symbol' => ['nullable', 'string', 'max:16'], + 'alpha3' => ['required', 'string', self::ISO3, 'uppercase', Rule::unique('countries', 'alpha3')->ignore($countryId)], + 'numeric' => ['required', 'string', self::ISO3, Rule::unique('countries', 'numeric')->ignore($countryId)], + 'name' => ['required', 'string', self::MAX_STRING], + 'official_name' => ['nullable', 'string', self::MAX_STRING], + 'common_name' => ['nullable', 'string', self::MAX_STRING], + 'display_name' => ['nullable', 'string', self::MAX_STRING], + 'native_name' => ['nullable', 'string', self::MAX_STRING], + 'capital' => ['nullable', 'string', self::MAX_STRING], + 'flag_emoji' => ['nullable', 'string', self::MAX_SHORT], + 'calling_code' => ['nullable', 'string', self::MAX_SHORT], + 'tld' => ['nullable', 'string', self::MAX_SHORT], + 'currency_code' => ['nullable', 'string', self::ISO3, 'uppercase'], + 'currency_name' => ['nullable', 'string', self::MAX_STRING], + 'currency_symbol' => ['nullable', 'string', self::MAX_SHORT], 'languages' => ['nullable', 'json'], 'timezones' => ['nullable', 'json'], 'latitude' => ['nullable', 'numeric', 'between:-90,90'], 'longitude' => ['nullable', 'numeric', 'between:-180,180'], - 'flag_svg_url' => ['nullable', 'url', 'max:255'], - 'flag_png_url' => ['nullable', 'url', 'max:255'], + 'flag_svg_url' => ['nullable', 'url', self::MAX_STRING], + 'flag_png_url' => ['nullable', 'url', self::MAX_STRING], 'ioc_code' => ['nullable', 'string', 'max:8'], 'fifa_code' => ['nullable', 'string', 'max:8'], - 'postal_code_format' => ['nullable', 'string', 'max:255'], + 'postal_code_format' => ['nullable', 'string', self::MAX_STRING], 'address_format' => ['nullable', 'string', 'max:5000'], 'is_active' => ['required', 'boolean'], 'iso_status' => ['required', 'string', Rule::in(Iso3166::statuses())], @@ -72,10 +77,10 @@ class UpdateCountryRequest extends FormRequest 'continent' => ['nullable', 'string', 'max:64'], 'region' => ['nullable', 'string', 'max:100'], 'subregion' => ['nullable', 'string', 'max:100'], - 'intermediate_region' => ['nullable', 'string', 'max:255'], + 'intermediate_region' => ['nullable', 'string', self::MAX_STRING], 'sort_order' => ['nullable', 'integer', 'min:0', 'max:999999'], - 'source' => ['nullable', 'string', 'max:255'], - 'source_version' => ['nullable', 'string', 'max:255'], + 'source' => ['nullable', 'string', self::MAX_STRING], + 'source_version' => ['nullable', 'string', self::MAX_STRING], 'last_synced_at' => ['nullable', 'date'], 'metadata' => ['nullable', 'json'], ]; diff --git a/Modules/Countries/Routes/web.php b/Modules/Countries/Routes/web.php index eaa78263..c1dfe37c 100644 --- a/Modules/Countries/Routes/web.php +++ b/Modules/Countries/Routes/web.php @@ -5,6 +5,8 @@ use Modules\Countries\Http\Controllers\CountriesController; use Modules\Countries\Http\Controllers\CountrySyncController; use Modules\Countries\Http\Controllers\ReferenceDataController; +const COUNTRY_ROUTE = COUNTRY_ROUTE; + Route::prefix('countries')->name('countries.')->middleware(['web', 'auth'])->group(function (): void { Route::get('/', CountriesController::class)->name('index'); Route::get('/create', [CountriesController::class, 'create'])->name('create'); @@ -15,8 +17,8 @@ Route::prefix('countries')->name('countries.')->middleware(['web', 'auth'])->gro Route::get('/reference/languages', [ReferenceDataController::class, 'languages'])->name('languages.index'); Route::get('/reference/scripts', [ReferenceDataController::class, 'scripts'])->name('scripts.index'); Route::get('/reference/locodes', [ReferenceDataController::class, 'locodes'])->name('locodes.index'); - Route::get('/{country:alpha2}', [CountriesController::class, 'show'])->name('show'); - Route::get('/{country:alpha2}/edit', [CountriesController::class, 'edit'])->name('edit'); - Route::put('/{country:alpha2}', [CountriesController::class, 'update'])->name('update'); - Route::delete('/{country:alpha2}', [CountriesController::class, 'destroy'])->name('destroy'); + Route::get(COUNTRY_ROUTE, [CountriesController::class, 'show'])->name('show'); + Route::get(COUNTRY_ROUTE . '/edit', [CountriesController::class, 'edit'])->name('edit'); + Route::put(COUNTRY_ROUTE, [CountriesController::class, 'update'])->name('update'); + Route::delete(COUNTRY_ROUTE, [CountriesController::class, 'destroy'])->name('destroy'); }); diff --git a/Modules/Documentation/Http/Controllers/DocumentationController.php b/Modules/Documentation/Http/Controllers/DocumentationController.php index 86927fbc..8a86492f 100644 --- a/Modules/Documentation/Http/Controllers/DocumentationController.php +++ b/Modules/Documentation/Http/Controllers/DocumentationController.php @@ -15,6 +15,9 @@ use Modules\Documentation\Models\DocumentationDocument; class DocumentationController extends Controller { + private const string STORAGE_DISK_CONFIG = 'documentation.storage_disk'; + + private const string UPLOAD_PATH_CONFIG = 'documentation.upload_path'; /** * Display the documentation dashboard and document list. */ @@ -170,7 +173,7 @@ class DocumentationController extends Controller { abort_if(blank($document->attachment_path), 404); - return Storage::disk($document->attachment_disk ?: config('documentation.storage_disk', 'public')) + return Storage::disk($document->attachment_disk ?: config(self::STORAGE_DISK_CONFIG, 'public')) ->download($document->attachment_path, $document->attachment_name); } @@ -202,27 +205,19 @@ class DocumentationController extends Controller */ private function extractMarkdownBody(Request $request): ?string { - if (! config('documentation.import_markdown_attachments', true)) { - return null; - } - $file = $request->file('attachment'); + $canImport = config('documentation.import_markdown_attachments', true) + && $file !== null + && $this->isMarkdownUpload($request) + && $file->getSize() <= (int) config('documentation.max_markdown_import_bytes', 1048576); - if ($file === null || ! $this->isMarkdownUpload($request)) { - return null; - } - - if ($file->getSize() > (int) config('documentation.max_markdown_import_bytes', 1048576)) { + if (! $canImport) { return null; } $content = file_get_contents($file->getRealPath()); - if ($content === false) { - return null; - } - - return trim($content); + return $content === false ? null : trim($content); } /** @@ -250,8 +245,8 @@ class DocumentationController extends Controller private function storeAttachment(Request $request): array { $file = $request->file('attachment'); - $disk = config('documentation.storage_disk', 'public'); - $path = $file->store(config('documentation.upload_path', 'documentation'), $disk); + $disk = config(self::STORAGE_DISK_CONFIG, 'public'); + $path = $file->store(config(self::UPLOAD_PATH_CONFIG, 'documentation'), $disk); return [ 'attachment_disk' => $disk, @@ -271,14 +266,14 @@ class DocumentationController extends Controller return; } - $disk = config('documentation.storage_disk', 'public'); + $disk = config(self::STORAGE_DISK_CONFIG, 'public'); foreach ((array) $request->file('attachments') as $file) { if ($file === null || ! $file->isValid()) { continue; } - $path = $file->store(config('documentation.upload_path', 'documentation'), $disk); + $path = $file->store(config(self::UPLOAD_PATH_CONFIG, 'documentation'), $disk); $document->attachments()->create([ 'disk' => $disk, @@ -297,7 +292,7 @@ class DocumentationController extends Controller private function deleteAttachment(DocumentationDocument $document): void { if (filled($document->attachment_path)) { - Storage::disk($document->attachment_disk ?: config('documentation.storage_disk', 'public'))->delete($document->attachment_path); + Storage::disk($document->attachment_disk ?: config(self::STORAGE_DISK_CONFIG, 'public'))->delete($document->attachment_path); } } diff --git a/Modules/Documentation/Routes/web.php b/Modules/Documentation/Routes/web.php index 06c60802..5c8ec508 100644 --- a/Modules/Documentation/Routes/web.php +++ b/Modules/Documentation/Routes/web.php @@ -6,6 +6,8 @@ use Illuminate\Support\Facades\Route; use Modules\Documentation\Http\Controllers\DocumentationCategoryController; use Modules\Documentation\Http\Controllers\DocumentationController; +const DOCUMENT_ROUTE = DOCUMENT_ROUTE; + Route::middleware(['web', 'auth']) ->prefix('documentation') ->name('documentation.') @@ -15,13 +17,13 @@ Route::middleware(['web', 'auth']) Route::get('/documents', [DocumentationController::class, 'index'])->name('documents.index'); Route::get('/documents/create', [DocumentationController::class, 'create'])->name('documents.create'); Route::post('/documents', [DocumentationController::class, 'store'])->name('documents.store'); - Route::get('/documents/{document}', [DocumentationController::class, 'show'])->name('documents.show'); - Route::get('/documents/{document}/edit', [DocumentationController::class, 'edit'])->name('documents.edit'); - Route::put('/documents/{document}', [DocumentationController::class, 'update'])->name('documents.update'); - Route::delete('/documents/{document}', [DocumentationController::class, 'destroy'])->name('documents.destroy'); - Route::get('/documents/{document}/download', [DocumentationController::class, 'download'])->name('documents.download'); - Route::get('/documents/{document}/attachments/{attachment}/download', [DocumentationController::class, 'downloadAttachment'])->name('documents.attachments.download'); - Route::delete('/documents/{document}/attachments/{attachment}', [DocumentationController::class, 'destroyAttachment'])->name('documents.attachments.destroy'); + Route::get(DOCUMENT_ROUTE, [DocumentationController::class, 'show'])->name('documents.show'); + Route::get(DOCUMENT_ROUTE . '/edit', [DocumentationController::class, 'edit'])->name('documents.edit'); + Route::put(DOCUMENT_ROUTE, [DocumentationController::class, 'update'])->name('documents.update'); + Route::delete(DOCUMENT_ROUTE, [DocumentationController::class, 'destroy'])->name('documents.destroy'); + Route::get(DOCUMENT_ROUTE . '/download', [DocumentationController::class, 'download'])->name('documents.download'); + Route::get(DOCUMENT_ROUTE . '/attachments/{attachment}/download', [DocumentationController::class, 'downloadAttachment'])->name('documents.attachments.download'); + Route::delete(DOCUMENT_ROUTE . '/attachments/{attachment}', [DocumentationController::class, 'destroyAttachment'])->name('documents.attachments.destroy'); Route::resource('categories', DocumentationCategoryController::class)->except(['show']); }); diff --git a/Modules/Helpdesk/Exceptions/TicketPersistenceException.php b/Modules/Helpdesk/Exceptions/TicketPersistenceException.php new file mode 100644 index 00000000..9c5ea719 --- /dev/null +++ b/Modules/Helpdesk/Exceptions/TicketPersistenceException.php @@ -0,0 +1,11 @@ +header('X-Hub-Signature-256', ''); $sha1Signature = (string) $request->header('X-Hub-Signature', ''); - return hash_equals('sha256=' . hash_hmac('sha256', $rawPayload, $secret), $sha256Signature) - || hash_equals('sha1=' . hash_hmac('sha1', $rawPayload, $secret), $sha1Signature); + return hash_equals(self::SHA256_PREFIX . hash_hmac('sha256', $rawPayload, $secret), $sha256Signature) + || hash_equals(self::SHA1_PREFIX . hash_hmac('sha1', $rawPayload, $secret), $sha1Signature); } /** @@ -152,8 +156,8 @@ final class GitWebhookController $sha1 = hash_hmac('sha1', $rawPayload, $secret); return hash_equals($sha256, $giteaSignature) - || hash_equals('sha256=' . $sha256, $giteaSignature) - || hash_equals('sha1=' . $sha1, $giteaSignature) + || hash_equals(self::SHA256_PREFIX . $sha256, $giteaSignature) + || hash_equals(self::SHA1_PREFIX . $sha1, $giteaSignature) || $this->hasValidGitHubSignature($request, $secret, $rawPayload) || $this->hasValidGenericSignature($request, $secret, $rawPayload); } @@ -165,7 +169,7 @@ final class GitWebhookController { $signature = (string) $request->header('X-Hub-Signature', ''); - return hash_equals('sha256=' . hash_hmac('sha256', $rawPayload, $secret), $signature) + return hash_equals(self::SHA256_PREFIX . hash_hmac('sha256', $rawPayload, $secret), $signature) || hash_equals($secret, (string) $request->header('X-Helpdesk-Token', '')); } @@ -178,8 +182,8 @@ final class GitWebhookController $signature = (string) ($request->header('X-Hub-Signature-256') ?: $request->header('X-Hub-Signature')); return hash_equals($secret, $token) - || hash_equals('sha256=' . hash_hmac('sha256', $rawPayload, $secret), $signature) - || hash_equals('sha1=' . hash_hmac('sha1', $rawPayload, $secret), $signature); + || hash_equals(self::SHA256_PREFIX . hash_hmac('sha256', $rawPayload, $secret), $signature) + || hash_equals(self::SHA1_PREFIX . hash_hmac('sha1', $rawPayload, $secret), $signature); } /** diff --git a/Modules/Helpdesk/Http/Controllers/TicketBoardController.php b/Modules/Helpdesk/Http/Controllers/TicketBoardController.php index 965b966e..afec050c 100644 --- a/Modules/Helpdesk/Http/Controllers/TicketBoardController.php +++ b/Modules/Helpdesk/Http/Controllers/TicketBoardController.php @@ -11,6 +11,7 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Gate; use Illuminate\Validation\Rule; +use Modules\Helpdesk\Exceptions\TicketPersistenceException; use Modules\Helpdesk\Models\KanbanBoard; use Modules\Helpdesk\Models\Status; use Modules\Helpdesk\Models\Ticket; @@ -68,7 +69,7 @@ final class TicketBoardController extends Controller ->update($updates); if ($affected < 1) { - throw new \RuntimeException('Ticket board update did not persist.'); + throw new TicketPersistenceException('Ticket board update did not persist.'); } return Ticket::query() diff --git a/Modules/Helpdesk/Services/AttachmentService.php b/Modules/Helpdesk/Services/AttachmentService.php index 3eb560e8..7702fde0 100644 --- a/Modules/Helpdesk/Services/AttachmentService.php +++ b/Modules/Helpdesk/Services/AttachmentService.php @@ -114,23 +114,13 @@ final class AttachmentService { $extension = strtolower((string) pathinfo($originalName, PATHINFO_EXTENSION)); - if (str_starts_with($mimeType, 'image/') || in_array($extension, ['png', 'jpg', 'jpeg', 'webp', 'gif'], true)) { - return 'image'; - } - - if ($extension === 'pdf' || $mimeType === 'application/pdf') { - return 'pdf'; - } - - if (in_array($extension, ['zip', 'tar', 'gz', '7z', 'rar'], true)) { - return 'archive'; - } - - if (in_array($extension, config('helpdesk.allowed_source_extensions', []), true)) { - return 'source'; - } - - return 'file'; + return match (true) { + str_starts_with($mimeType, 'image/'), in_array($extension, ['png', 'jpg', 'jpeg', 'webp', 'gif'], true) => 'image', + $extension === 'pdf', $mimeType === 'application/pdf' => 'pdf', + in_array($extension, ['zip', 'tar', 'gz', '7z', 'rar'], true) => 'archive', + in_array($extension, config('helpdesk.allowed_source_extensions', []), true) => 'source', + default => 'file', + }; } /** diff --git a/Modules/Helpdesk/Services/TicketService.php b/Modules/Helpdesk/Services/TicketService.php index d598a1fd..8297de57 100644 --- a/Modules/Helpdesk/Services/TicketService.php +++ b/Modules/Helpdesk/Services/TicketService.php @@ -10,6 +10,7 @@ use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; +use Modules\Helpdesk\Exceptions\TicketPersistenceException; use Modules\Helpdesk\Models\Comment; use Modules\Helpdesk\Models\KanbanBoard; use Modules\Helpdesk\Models\Status; @@ -45,7 +46,7 @@ final class TicketService } } - throw new \RuntimeException('Unable to create a unique Helpdesk ticket reference.'); + throw new TicketPersistenceException('Unable to create a unique Helpdesk ticket reference.'); } /** @@ -56,32 +57,8 @@ final class TicketService */ private function createInsideTransaction(User $user, array $data, array $files = []): Ticket { - $defaultStatusSlug = (string) config('helpdesk.default_status_slug', 'new'); - $status = Schema::hasTable('helpdesk_statuses') && Schema::hasColumn('helpdesk_statuses', 'is_default') - ? Status::query()->where('is_default', true)->orderBy('sort_order')->first() - : null; - - if (! $status) { - $status = Status::query()->where('slug', $defaultStatusSlug)->first(); - } - - if (! $status) { - $status = Status::query()->create([ - 'name' => 'New', - 'slug' => $defaultStatusSlug, - 'color' => 'sky', - 'sort_order' => 1, - 'is_closed' => false, - ]); - } - - $boardId = $data['board_id'] ?? null; - if (blank($boardId) && Schema::hasTable('helpdesk_kanban_boards')) { - $boardId = KanbanBoard::query() - ->where('is_default', true) - ->orderBy('sort_order') - ->value('id') ?: KanbanBoard::query()->orderBy('sort_order')->value('id'); - } + $status = $this->resolveInitialStatus(); + $boardId = $this->resolveBoardId($data['board_id'] ?? null); /** @var Ticket $ticket */ $ticket = Ticket::query()->create([ @@ -102,9 +79,7 @@ final class TicketService 'last_activity_at' => now(), ]); - if (! empty($data['tag_ids']) && is_array($data['tag_ids'])) { - $ticket->tags()->sync(array_values(array_unique(array_map('intval', $data['tag_ids'])))); - } + $this->syncTags($ticket, $data['tag_ids'] ?? null); $ticket = $this->automation->apply($ticket, $data); $ticket = $this->sla->applyPolicy($ticket); @@ -117,6 +92,43 @@ final class TicketService return $ticket; } + private function resolveInitialStatus(): Status + { + $defaultStatusSlug = (string) config('helpdesk.default_status_slug', 'new'); + $status = Schema::hasTable('helpdesk_statuses') && Schema::hasColumn('helpdesk_statuses', 'is_default') + ? Status::query()->where('is_default', true)->orderBy('sort_order')->first() + : null; + + $status ??= Status::query()->where('slug', $defaultStatusSlug)->first(); + + return $status ?? Status::query()->create([ + 'name' => 'New', + 'slug' => $defaultStatusSlug, + 'color' => 'sky', + 'sort_order' => 1, + 'is_closed' => false, + ]); + } + + private function resolveBoardId(mixed $boardId): mixed + { + if (! blank($boardId) || ! Schema::hasTable('helpdesk_kanban_boards')) { + return $boardId; + } + + return KanbanBoard::query()->where('is_default', true)->orderBy('sort_order')->value('id') + ?: KanbanBoard::query()->orderBy('sort_order')->value('id'); + } + + private function syncTags(Ticket $ticket, mixed $tagIds): void + { + if (! is_array($tagIds) || $tagIds === []) { + return; + } + + $ticket->tags()->sync(array_values(array_unique(array_map('intval', $tagIds)))); + } + /** * Determine whether a database error was caused by the ticket reference unique index. */ @@ -137,52 +149,68 @@ final class TicketService public function comment(Ticket $ticket, User $user, string $body, bool $internal = false, array $files = [], ?int $parentId = null, ?string $subject = null): Comment { return DB::transaction(function () use ($ticket, $user, $body, $internal, $files, $parentId, $subject): Comment { - $parent = $parentId === null - ? null - : Comment::query() - ->where('ticket_id', $ticket->id) - ->findOrFail($parentId); - - $guid = (string) Str::uuid(); - $unid = strtoupper(hash('sha256', $guid . '|' . $ticket->id . '|' . microtime(true))); - $parentUnid = $parent?->unid; - $rootUnid = $parent?->root_unid ?: $parentUnid ?: $unid; - $plainTextBody = trim(preg_replace('/\s+/', ' ', strip_tags($body)) ?: ''); - - $payload = [ - 'parent_id' => $parentId, - 'user_id' => $user->id, - 'body' => $body, - 'body_text' => $plainTextBody, - 'is_internal' => $internal, - 'guid' => $guid, - 'unid' => $unid, - 'parent_unid' => $parentUnid, - 'root_unid' => $rootUnid, - 'originator_id' => $unid . ':1', - 'sync_sequence' => 1, - 'sync_hash' => hash('sha256', $body), - 'source_system' => 'wms-helpdesk', - ]; - - if (Schema::hasColumn('helpdesk_comments', 'subject')) { - $payload['subject'] = trim((string) ($subject ?: ($parentId === null ? 'Response' : 'RE: Response'))); - } + $parent = $this->resolveParentComment($ticket, $parentId); + $payload = $this->commentPayload($ticket, $user, $body, $internal, $parent, $parentId, $subject); /** @var Comment $comment */ $comment = $ticket->comments()->create($payload); - - $ticket->forceFill(['last_activity_at' => now()])->save(); - if (! $internal) { - $this->sla->recordPublicResponse($ticket, $user); - } - $this->attachments->storeMany($ticket, $files, (int) $user->id, $comment); - $this->workflow->dispatch($ticket->fresh(['status', 'priority', 'category', 'project', 'team']) ?? $ticket, $parentId === null ? 'ticket.commented' : 'ticket.reply_created', $user); + $this->afterCommentCreated($ticket, $user, $comment, $files, $internal, $parentId); return $comment->fresh(['user', 'attachments']) ?? $comment; }); } + private function resolveParentComment(Ticket $ticket, ?int $parentId): ?Comment + { + return $parentId === null + ? null + : Comment::query()->where('ticket_id', $ticket->id)->findOrFail($parentId); + } + + /** @return array */ + private function commentPayload(Ticket $ticket, User $user, string $body, bool $internal, ?Comment $parent, ?int $parentId, ?string $subject): array + { + $guid = (string) Str::uuid(); + $unid = strtoupper(hash('sha256', $guid . '|' . $ticket->id . '|' . microtime(true))); + $parentUnid = $parent?->unid; + $payload = [ + 'parent_id' => $parentId, + 'user_id' => $user->id, + 'body' => $body, + 'body_text' => trim(preg_replace('/\s+/', ' ', strip_tags($body)) ?: ''), + 'is_internal' => $internal, + 'guid' => $guid, + 'unid' => $unid, + 'parent_unid' => $parentUnid, + 'root_unid' => $parent?->root_unid ?: $parentUnid ?: $unid, + 'originator_id' => $unid . ':1', + 'sync_sequence' => 1, + 'sync_hash' => hash('sha256', $body), + 'source_system' => 'wms-helpdesk', + ]; + + if (Schema::hasColumn('helpdesk_comments', 'subject')) { + $payload['subject'] = trim((string) ($subject ?: ($parentId === null ? 'Response' : 'RE: Response'))); + } + + return $payload; + } + + /** @param array $files */ + private function afterCommentCreated(Ticket $ticket, User $user, Comment $comment, array $files, bool $internal, ?int $parentId): void + { + $ticket->forceFill(['last_activity_at' => now()])->save(); + + if (! $internal) { + $this->sla->recordPublicResponse($ticket, $user); + } + + $this->attachments->storeMany($ticket, $files, (int) $user->id, $comment); + $event = $parentId === null ? 'ticket.commented' : 'ticket.reply_created'; + $freshTicket = $ticket->fresh(['status', 'priority', 'category', 'project', 'team']) ?? $ticket; + $this->workflow->dispatch($freshTicket, $event, $user); + } + /** * Close a ticket. */